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,408 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import childProcess from "node:child_process";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import process from "node:process";
|
|
7
|
+
import {
|
|
8
|
+
isTaskPathUnderAdapterRoots,
|
|
9
|
+
readAdapter,
|
|
10
|
+
resolveLoopDocumentPaths,
|
|
11
|
+
resolveLoopDocumentPathsBySlug,
|
|
12
|
+
runtimeRelativePath,
|
|
13
|
+
} from "./oris-loop-paths.mjs";
|
|
14
|
+
import {
|
|
15
|
+
ORIS_FLOW_ADAPTER,
|
|
16
|
+
ORIS_LOOP_SCHEMA,
|
|
17
|
+
ORIS_LOOP_SCHEMA_VERSION,
|
|
18
|
+
ORIS_SKILLS_SETTINGS,
|
|
19
|
+
} from "../flow/oris-flow-layout.mjs";
|
|
20
|
+
import {
|
|
21
|
+
assessPromptFiles,
|
|
22
|
+
improveMode,
|
|
23
|
+
normalizeModels,
|
|
24
|
+
validateLoopDocumentText,
|
|
25
|
+
} from "./oris-loop-document.mjs";
|
|
26
|
+
|
|
27
|
+
const terminalStates = new Set(["complete", "blocked", "cancelled"]);
|
|
28
|
+
|
|
29
|
+
function fail(message) {
|
|
30
|
+
throw new Error(message);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseArgs(argv) {
|
|
34
|
+
const options = {
|
|
35
|
+
action: "status",
|
|
36
|
+
loopSlug: "",
|
|
37
|
+
taskPath: "",
|
|
38
|
+
state: "continue",
|
|
39
|
+
repositoryRoot: "",
|
|
40
|
+
runner: "chat",
|
|
41
|
+
maxIterations: 25,
|
|
42
|
+
maxRepairCycles: 10,
|
|
43
|
+
maxMinutes: 240,
|
|
44
|
+
profile: "",
|
|
45
|
+
settingsPath: ORIS_SKILLS_SETTINGS,
|
|
46
|
+
};
|
|
47
|
+
const readValue = (index, raw) => {
|
|
48
|
+
if (raw.includes("=")) return [index, raw.slice(raw.indexOf("=") + 1)];
|
|
49
|
+
if (index + 1 >= argv.length) fail(`Missing value for ${raw}.`);
|
|
50
|
+
return [index + 1, argv[index + 1]];
|
|
51
|
+
};
|
|
52
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
53
|
+
const raw = argv[i];
|
|
54
|
+
const key = raw.toLowerCase();
|
|
55
|
+
if (key === "--action" || key.startsWith("--action=")) [i, options.action] = readValue(i, raw);
|
|
56
|
+
else if (key === "--loop" || key === "--loop-slug" || key.startsWith("--loop=") || key.startsWith("--loop-slug=")) [i, options.loopSlug] = readValue(i, raw);
|
|
57
|
+
else if (key === "--task" || key === "--task-path" || key.startsWith("--task=") || key.startsWith("--task-path=")) [i, options.taskPath] = readValue(i, raw);
|
|
58
|
+
else if (key === "--state" || key.startsWith("--state=")) [i, options.state] = readValue(i, raw);
|
|
59
|
+
else if (key === "--repository-root" || key.startsWith("--repository-root=")) [i, options.repositoryRoot] = readValue(i, raw);
|
|
60
|
+
else if (key === "--runner" || key.startsWith("--runner=")) [i, options.runner] = readValue(i, raw);
|
|
61
|
+
else if (key === "--profile" || key.startsWith("--profile=")) [i, options.profile] = readValue(i, raw);
|
|
62
|
+
else if (key === "--settings-path" || key.startsWith("--settings-path=")) [i, options.settingsPath] = readValue(i, raw);
|
|
63
|
+
else if (key === "--max-iterations" || key.startsWith("--max-iterations=")) {
|
|
64
|
+
const parsed = readValue(i, raw);
|
|
65
|
+
i = parsed[0];
|
|
66
|
+
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
|
+
} else if (key === "--max-minutes" || key.startsWith("--max-minutes=")) {
|
|
72
|
+
const parsed = readValue(i, raw);
|
|
73
|
+
i = parsed[0];
|
|
74
|
+
options.maxMinutes = Number.parseInt(parsed[1], 10);
|
|
75
|
+
} else fail(`Unknown argument: ${raw}`);
|
|
76
|
+
}
|
|
77
|
+
options.action = options.action.toLowerCase();
|
|
78
|
+
options.state = options.state.toLowerCase();
|
|
79
|
+
options.runner = options.runner.toLowerCase();
|
|
80
|
+
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.");
|
|
82
|
+
if (!["chat", "headless"].includes(options.runner)) fail("Unsupported --runner (chat|headless).");
|
|
83
|
+
return options;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function run(command, args, cwd) {
|
|
87
|
+
return childProcess.spawnSync(command, args, {
|
|
88
|
+
cwd,
|
|
89
|
+
shell: process.platform === "win32",
|
|
90
|
+
encoding: "utf8",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getRoot(options) {
|
|
95
|
+
if (options.repositoryRoot) return path.resolve(options.repositoryRoot);
|
|
96
|
+
const result = run("git", ["rev-parse", "--show-toplevel"], process.cwd());
|
|
97
|
+
if (result.status !== 0 || !result.stdout.trim()) fail("Run inside a Git worktree or pass --repository-root.");
|
|
98
|
+
return path.resolve(result.stdout.trim());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function writeJson(filePath, value) {
|
|
102
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
103
|
+
const temporary = `${filePath}.${process.pid}.tmp`;
|
|
104
|
+
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, {
|
|
105
|
+
encoding: "utf8",
|
|
106
|
+
mode: 0o600,
|
|
107
|
+
});
|
|
108
|
+
fs.renameSync(temporary, filePath);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function setContextField(content, name, value) {
|
|
112
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
113
|
+
const pattern = new RegExp(`^${escaped}:\\s*.*$`, "m");
|
|
114
|
+
const replacement = `${name}: ${value}`;
|
|
115
|
+
return pattern.test(content) ? content.replace(pattern, replacement) : `${content.trimEnd()}\n${replacement}\n`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function reconcileContext(root, state) {
|
|
119
|
+
if (!state.taskPath) return;
|
|
120
|
+
const contextPath = state.contextPath ? path.resolve(root, state.contextPath) : "";
|
|
121
|
+
if (!contextPath) return;
|
|
122
|
+
const contextRelative = path.relative(root, contextPath);
|
|
123
|
+
if (contextRelative.startsWith("..") || path.isAbsolute(contextRelative)) return;
|
|
124
|
+
if (!fs.existsSync(contextPath)) return;
|
|
125
|
+
let content = fs.readFileSync(contextPath, "utf8");
|
|
126
|
+
const phases = Array.isArray(state.phases) ? state.phases : ["execute"];
|
|
127
|
+
const phase = currentPhase({ ...state, phases });
|
|
128
|
+
for (const [name, value] of Object.entries({
|
|
129
|
+
Run: state.active === true ? "active" : "inactive",
|
|
130
|
+
State: state.state,
|
|
131
|
+
"Current program": state.program,
|
|
132
|
+
"Current phase": phase,
|
|
133
|
+
"Current phase index": Number(state.phaseIndex ?? 0),
|
|
134
|
+
"Current iteration": Number(state.iteration ?? 0),
|
|
135
|
+
"Phase iteration": Number(state.phaseIteration ?? 0),
|
|
136
|
+
"Repair cycles": Number(state.repairCycles ?? 0),
|
|
137
|
+
Updated: new Date().toISOString(),
|
|
138
|
+
})) {
|
|
139
|
+
content = setContextField(content, name, value);
|
|
140
|
+
}
|
|
141
|
+
const temporary = `${contextPath}.${process.pid}.tmp`;
|
|
142
|
+
fs.writeFileSync(temporary, content, { encoding: "utf8", mode: 0o600 });
|
|
143
|
+
fs.renameSync(temporary, contextPath);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function getPathValue(value, dottedPath) {
|
|
147
|
+
return dottedPath.split(".").filter(Boolean).reduce((current, key) => (
|
|
148
|
+
current != null && typeof current === "object" ? current[key] : undefined
|
|
149
|
+
), value);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function validateRequiredProfile(options, adapter) {
|
|
153
|
+
if (adapter.profile?.required !== true) return;
|
|
154
|
+
const notArmed = "Oris chat loop was not armed; the stop hook will not resume this chat. Fix the profile, rerun start, then end the turn.";
|
|
155
|
+
if (!fs.existsSync(options.settingsPath)) fail(`Required Oris settings are missing: ${options.settingsPath}. ${notArmed}`);
|
|
156
|
+
const settings = JSON.parse(fs.readFileSync(options.settingsPath, "utf8"));
|
|
157
|
+
if (settings.version !== 1) fail(`Oris settings must use schema v1. ${notArmed}`);
|
|
158
|
+
const role = adapter.profile.defaultRole || "testing";
|
|
159
|
+
const profileName = options.profile || settings.defaultProfiles?.[role];
|
|
160
|
+
const profile = settings.profiles?.[profileName];
|
|
161
|
+
if (!profile) fail(`Required Oris profile '${profileName || "(none)"}' was not found. ${notArmed}`);
|
|
162
|
+
for (const mapping of adapter.profile.mappings ?? []) {
|
|
163
|
+
const value = getPathValue(profile, mapping.from);
|
|
164
|
+
if (mapping.required === true && (value == null || String(value).trim() === "")) {
|
|
165
|
+
fail(`Profile '${profileName}' is missing required field '${mapping.from}'. ${notArmed}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A same-chat loop needs at least one armed stop hook: Cursor or Claude Code. */
|
|
171
|
+
function assessStopHooks(root) {
|
|
172
|
+
const registered = [];
|
|
173
|
+
const cursorHooks = path.join(root, ".cursor", "hooks.json");
|
|
174
|
+
if (fs.existsSync(cursorHooks)) {
|
|
175
|
+
try {
|
|
176
|
+
const hooks = JSON.parse(fs.readFileSync(cursorHooks, "utf8"));
|
|
177
|
+
const stops = hooks?.hooks?.stop ?? [];
|
|
178
|
+
if (stops.some((entry) => String(entry?.command ?? "").includes("oris-loop-stop"))) registered.push("cursor");
|
|
179
|
+
} catch {
|
|
180
|
+
// Unreadable hooks.json counts as not registered.
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const claudeSettings = path.join(root, ".claude", "settings.json");
|
|
184
|
+
if (fs.existsSync(claudeSettings)) {
|
|
185
|
+
try {
|
|
186
|
+
const settings = JSON.parse(fs.readFileSync(claudeSettings, "utf8"));
|
|
187
|
+
const stops = settings?.hooks?.Stop ?? [];
|
|
188
|
+
const flat = JSON.stringify(stops);
|
|
189
|
+
if (flat.includes("oris-loop-stop")) registered.push("claude");
|
|
190
|
+
} catch {
|
|
191
|
+
// Unreadable settings.json counts as not registered.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return registered;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function currentPhase(state) {
|
|
198
|
+
const phases = Array.isArray(state.phases) && state.phases.length > 0 ? state.phases : ["execute"];
|
|
199
|
+
const index = Number.isInteger(Number(state.phaseIndex)) ? Number(state.phaseIndex) : 0;
|
|
200
|
+
return phases[Math.max(0, Math.min(index, phases.length - 1))] ?? phases[0];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function normalizeChatState(state) {
|
|
204
|
+
if (!state || typeof state !== "object") fail("Invalid Oris chat loop state.");
|
|
205
|
+
const normalized = { ...state };
|
|
206
|
+
const version = Number(normalized.schemaVersion ?? 0);
|
|
207
|
+
if (version !== 1) {
|
|
208
|
+
fail(`Unsupported chat loop schemaVersion: ${normalized.schemaVersion}`);
|
|
209
|
+
}
|
|
210
|
+
normalized.phases = Array.isArray(normalized.phases) && normalized.phases.length > 0
|
|
211
|
+
? normalized.phases
|
|
212
|
+
: ["execute"];
|
|
213
|
+
normalized.phaseName = currentPhase(normalized);
|
|
214
|
+
normalized.iteration = Number(normalized.iteration ?? 0);
|
|
215
|
+
normalized.phaseIteration = Number(normalized.phaseIteration ?? 0);
|
|
216
|
+
normalized.repairCycles = Number(normalized.repairCycles ?? 0);
|
|
217
|
+
normalized.phaseIndex = Number.isInteger(Number(normalized.phaseIndex)) ? Number(normalized.phaseIndex) : 0;
|
|
218
|
+
if (normalized.state === "running" && normalized.active === true) {
|
|
219
|
+
normalized.state = "continue";
|
|
220
|
+
}
|
|
221
|
+
return normalized;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function readChatState() {
|
|
225
|
+
return normalizeChatState(JSON.parse(fs.readFileSync(statePath, "utf8")));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const options = parseArgs(process.argv.slice(2));
|
|
229
|
+
const root = getRoot(options);
|
|
230
|
+
const adapterForRuntime = readAdapter(root, ORIS_FLOW_ADAPTER);
|
|
231
|
+
const runtimeRoot = path.join(root, runtimeRelativePath(adapterForRuntime));
|
|
232
|
+
const statePath = path.join(runtimeRoot, "chat-active.json");
|
|
233
|
+
|
|
234
|
+
if (options.action === "start") {
|
|
235
|
+
if (!options.taskPath && !options.loopSlug) fail("--task or --loop is required for start.");
|
|
236
|
+
if (options.taskPath && options.loopSlug) fail("Use either --task or --loop for start, not both.");
|
|
237
|
+
const adapterPath = path.join(root, ORIS_FLOW_ADAPTER);
|
|
238
|
+
if (!fs.existsSync(adapterPath)) fail(`Repository adapter is missing: ${adapterPath}. Run: oris-skills loop bootstrap`);
|
|
239
|
+
const adapter = JSON.parse(fs.readFileSync(adapterPath, "utf8"));
|
|
240
|
+
if (
|
|
241
|
+
adapter.$schema !== ORIS_LOOP_SCHEMA
|
|
242
|
+
|| adapter.schemaVersion !== ORIS_LOOP_SCHEMA_VERSION
|
|
243
|
+
|| !adapter.paths
|
|
244
|
+
|| !Array.isArray(adapter.paths.allowed)
|
|
245
|
+
) {
|
|
246
|
+
fail(`Repository adapter must use schemaVersion ${ORIS_LOOP_SCHEMA_VERSION}: ${adapterPath}`);
|
|
247
|
+
}
|
|
248
|
+
if (options.taskPath) {
|
|
249
|
+
const taskFull = path.resolve(root, options.taskPath);
|
|
250
|
+
const relative = path.relative(root, taskFull);
|
|
251
|
+
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) fail("--task must be below the repository root.");
|
|
252
|
+
if (!isTaskPathUnderAdapterRoots(root, options.taskPath, adapter)) {
|
|
253
|
+
fail("--task is outside adapter paths.taskRoots.");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const loopDocs = options.loopSlug
|
|
257
|
+
? resolveLoopDocumentPathsBySlug(root, options.loopSlug, adapter)
|
|
258
|
+
: resolveLoopDocumentPaths(root, options, adapter);
|
|
259
|
+
const loopPath = loopDocs.loopFull;
|
|
260
|
+
const contextPath = loopDocs.contextFull;
|
|
261
|
+
for (const required of [adapterPath, loopPath, contextPath]) {
|
|
262
|
+
if (!fs.existsSync(required)) fail(`Required loop file is missing: ${required}`);
|
|
263
|
+
}
|
|
264
|
+
if (options.runner === "chat") {
|
|
265
|
+
const hooks = assessStopHooks(root);
|
|
266
|
+
if (hooks.length === 0) {
|
|
267
|
+
fail("No Oris stop hook is registered (.cursor/hooks.json or .claude/settings.json). Run: oris-skills loop bootstrap");
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
validateRequiredProfile(options, adapter);
|
|
271
|
+
const minimumFreeDiskGiB = Number(adapter.preflight?.minFreeDiskGiB ?? 0);
|
|
272
|
+
if (typeof fs.statfsSync === "function") {
|
|
273
|
+
const disk = fs.statfsSync(root);
|
|
274
|
+
const freeDiskGiB = Number(disk.bavail) * Number(disk.bsize) / 1024 ** 3;
|
|
275
|
+
if (freeDiskGiB < minimumFreeDiskGiB) {
|
|
276
|
+
fail(`Disk preflight failed: ${freeDiskGiB.toFixed(2)} GiB free; ${minimumFreeDiskGiB} GiB required.`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const loopText = fs.readFileSync(loopPath, "utf8");
|
|
280
|
+
let loopDocument;
|
|
281
|
+
try {
|
|
282
|
+
loopDocument = validateLoopDocumentText(loopText, loopPath);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
fail(error.message);
|
|
285
|
+
}
|
|
286
|
+
const prompts = assessPromptFiles(path.dirname(loopPath));
|
|
287
|
+
if (prompts.missing.length > 0) {
|
|
288
|
+
fail(`Loop prompt files are missing or empty: ${prompts.missing.join(", ")}. Craft or copy them from skills/oris-loop/templates/.`);
|
|
289
|
+
}
|
|
290
|
+
if (fs.existsSync(statePath)) {
|
|
291
|
+
const existing = normalizeChatState(JSON.parse(fs.readFileSync(statePath, "utf8")));
|
|
292
|
+
if (existing.active === true && !terminalStates.has(existing.state)) {
|
|
293
|
+
fail(`An Oris chat loop is already active for this workspace: ${existing.taskPath}. Run --action stop before starting again.`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const loopMetadata = loopDocument.data;
|
|
297
|
+
const phases = [...loopMetadata.phases];
|
|
298
|
+
const nextState = {
|
|
299
|
+
schemaVersion: 1,
|
|
300
|
+
active: true,
|
|
301
|
+
state: "continue",
|
|
302
|
+
runner: options.runner,
|
|
303
|
+
program: loopMetadata.name,
|
|
304
|
+
phases,
|
|
305
|
+
phaseIndex: 0,
|
|
306
|
+
phaseName: phases[0],
|
|
307
|
+
phaseIteration: 0,
|
|
308
|
+
taskPath: (options.taskPath || loopDocs.taskPath).replaceAll("\\", "/"),
|
|
309
|
+
repositoryRoot: root.replaceAll("\\", "/"),
|
|
310
|
+
loopPath: loopDocs.loopRelative,
|
|
311
|
+
contextPath: loopDocs.contextRelative,
|
|
312
|
+
promptsPath: loopDocs.promptsRelative,
|
|
313
|
+
models: normalizeModels(loopMetadata.models, adapter.models),
|
|
314
|
+
improveMode: improveMode(loopMetadata),
|
|
315
|
+
conversationId: null,
|
|
316
|
+
iteration: 0,
|
|
317
|
+
maxIterations: loopMetadata.limits.maxIterations,
|
|
318
|
+
repairCycles: 0,
|
|
319
|
+
maxRepairCycles: options.maxRepairCycles,
|
|
320
|
+
maxMinutes: loopMetadata.limits.maxMinutes ?? options.maxMinutes,
|
|
321
|
+
startedAt: new Date().toISOString(),
|
|
322
|
+
updatedAt: new Date().toISOString(),
|
|
323
|
+
};
|
|
324
|
+
writeJson(statePath, nextState);
|
|
325
|
+
reconcileContext(root, nextState);
|
|
326
|
+
console.log(`Oris loop armed (${options.runner}): ${loopMetadata.name} / ${(options.taskPath || loopDocs.taskPath).replaceAll("\\", "/")}`);
|
|
327
|
+
process.exit(0);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!fs.existsSync(statePath)) {
|
|
331
|
+
if (options.action === "status") {
|
|
332
|
+
console.log("No Oris chat loop state.");
|
|
333
|
+
process.exit(0);
|
|
334
|
+
}
|
|
335
|
+
fail(`No Oris chat loop state exists at ${statePath}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const current = readChatState();
|
|
339
|
+
|
|
340
|
+
if (options.action === "repair") {
|
|
341
|
+
if (current.active === true) {
|
|
342
|
+
current.startedAt = new Date().toISOString();
|
|
343
|
+
delete current.stopReason;
|
|
344
|
+
} else if (
|
|
345
|
+
current.state === "blocked"
|
|
346
|
+
&& /limit exhausted/.test(String(current.stopReason ?? ""))
|
|
347
|
+
&& Number(current.iteration ?? 0) < Number(current.maxIterations ?? 25)
|
|
348
|
+
) {
|
|
349
|
+
current.active = true;
|
|
350
|
+
current.state = "continue";
|
|
351
|
+
current.startedAt = new Date().toISOString();
|
|
352
|
+
delete current.stopReason;
|
|
353
|
+
}
|
|
354
|
+
current.conversationId = null;
|
|
355
|
+
current.updatedAt = new Date().toISOString();
|
|
356
|
+
writeJson(statePath, current);
|
|
357
|
+
reconcileContext(root, current);
|
|
358
|
+
console.log(`Oris chat loop repaired: schemaVersion ${current.schemaVersion}, state ${current.state}.`);
|
|
359
|
+
process.exit(0);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (options.action === "set-state") {
|
|
363
|
+
const phases = Array.isArray(current.phases) && current.phases.length > 0 ? current.phases : ["execute"];
|
|
364
|
+
if (options.state === "advance") {
|
|
365
|
+
current.phaseIndex = phases.length === 1 ? 0 : (Number(current.phaseIndex ?? 0) + 1) % phases.length;
|
|
366
|
+
current.phaseIteration = 0;
|
|
367
|
+
current.repairCycles = Number(current.repairCycles ?? 0);
|
|
368
|
+
current.state = "advance";
|
|
369
|
+
current.active = true;
|
|
370
|
+
} else {
|
|
371
|
+
current.state = options.state;
|
|
372
|
+
current.active = !terminalStates.has(options.state);
|
|
373
|
+
}
|
|
374
|
+
current.phases = phases;
|
|
375
|
+
current.phaseName = currentPhase(current);
|
|
376
|
+
current.schemaVersion = 1;
|
|
377
|
+
current.updatedAt = new Date().toISOString();
|
|
378
|
+
writeJson(statePath, current);
|
|
379
|
+
reconcileContext(root, current);
|
|
380
|
+
console.log(`Oris chat loop state: ${current.state}`);
|
|
381
|
+
process.exit(0);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (options.action === "stop") {
|
|
385
|
+
current.state = "cancelled";
|
|
386
|
+
current.active = false;
|
|
387
|
+
current.stopReason = "manual stop";
|
|
388
|
+
current.updatedAt = new Date().toISOString();
|
|
389
|
+
writeJson(statePath, current);
|
|
390
|
+
reconcileContext(root, current);
|
|
391
|
+
console.log("Oris chat loop cancelled.");
|
|
392
|
+
process.exit(0);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
console.log(JSON.stringify({
|
|
396
|
+
active: Boolean(current.active),
|
|
397
|
+
state: current.state,
|
|
398
|
+
runner: current.runner ?? "chat",
|
|
399
|
+
program: current.program,
|
|
400
|
+
phase: currentPhase(current),
|
|
401
|
+
taskPath: current.taskPath,
|
|
402
|
+
iteration: Number(current.iteration ?? 0),
|
|
403
|
+
maxIterations: Number(current.maxIterations ?? 0),
|
|
404
|
+
improveMode: current.improveMode ?? "propose",
|
|
405
|
+
models: current.models ?? {},
|
|
406
|
+
conversationBound: Boolean(current.conversationId),
|
|
407
|
+
updatedAt: current.updatedAt,
|
|
408
|
+
}, null, 2));
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `oris-skills loop demo` — create a safe tutorial loop the user can run to
|
|
5
|
+
* learn Oris loops end to end. The demo only writes inside its own loop
|
|
6
|
+
* folder, so it cannot touch product files.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import childProcess from "node:child_process";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import process from "node:process";
|
|
13
|
+
import { bootstrapLoop } from "./oris-loop-bootstrap.mjs";
|
|
14
|
+
import { defaultLoopDocumentPaths, readAdapter } from "./oris-loop-paths.mjs";
|
|
15
|
+
|
|
16
|
+
const DEMO_SLUG = "oris-demo";
|
|
17
|
+
|
|
18
|
+
function fail(message) {
|
|
19
|
+
console.error(`[oris-skills] ERROR ${message}`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseArgs(argv) {
|
|
24
|
+
const options = { repositoryRoot: "", force: false };
|
|
25
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
26
|
+
const raw = argv[i];
|
|
27
|
+
if (raw === "--force") options.force = true;
|
|
28
|
+
else if (raw.startsWith("--repository-root=")) options.repositoryRoot = raw.slice("--repository-root=".length);
|
|
29
|
+
else if (raw === "--repository-root" && argv[i + 1]) {
|
|
30
|
+
options.repositoryRoot = argv[i + 1];
|
|
31
|
+
i += 1;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return options;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getRoot(options) {
|
|
38
|
+
if (options.repositoryRoot) return path.resolve(options.repositoryRoot);
|
|
39
|
+
const result = childProcess.spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
40
|
+
cwd: process.cwd(),
|
|
41
|
+
shell: process.platform === "win32",
|
|
42
|
+
encoding: "utf8",
|
|
43
|
+
});
|
|
44
|
+
if (result.status !== 0 || !result.stdout.trim()) fail("Run inside a Git worktree or pass --repository-root.");
|
|
45
|
+
return path.resolve(result.stdout.trim());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const options = parseArgs(process.argv.slice(2));
|
|
49
|
+
const root = getRoot(options);
|
|
50
|
+
|
|
51
|
+
const bootstrap = bootstrapLoop(root, {});
|
|
52
|
+
if (!bootstrap.ready) fail(`Loop bootstrap incomplete: ${bootstrap.after.missing.join(", ")}`);
|
|
53
|
+
|
|
54
|
+
const adapter = readAdapter(root);
|
|
55
|
+
const docs = defaultLoopDocumentPaths(root, DEMO_SLUG, adapter);
|
|
56
|
+
const loopDir = path.dirname(docs.loopFull);
|
|
57
|
+
if (fs.existsSync(docs.loopFull) && !options.force) {
|
|
58
|
+
fail(`Demo loop already exists: ${docs.loopRelative}. Re-run with --force to recreate it.`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const workspaceRelative = `${docs.loopDir}/workspace`.replace(/\\/g, "/");
|
|
62
|
+
fs.mkdirSync(path.join(loopDir, "workspace"), { recursive: true });
|
|
63
|
+
fs.mkdirSync(path.join(loopDir, "prompts"), { recursive: true });
|
|
64
|
+
fs.mkdirSync(path.join(loopDir, "receipts"), { recursive: true });
|
|
65
|
+
fs.mkdirSync(path.join(loopDir, "proposals"), { recursive: true });
|
|
66
|
+
fs.mkdirSync(path.join(loopDir, "history"), { recursive: true });
|
|
67
|
+
|
|
68
|
+
fs.writeFileSync(path.join(loopDir, "workspace", "counter.txt"), "0\n", "utf8");
|
|
69
|
+
|
|
70
|
+
fs.writeFileSync(docs.loopFull, `---
|
|
71
|
+
schemaVersion: 2
|
|
72
|
+
kind: oris-loop
|
|
73
|
+
name: ${DEMO_SLUG}
|
|
74
|
+
approved: true
|
|
75
|
+
phases: [work]
|
|
76
|
+
limits:
|
|
77
|
+
maxIterations: 3
|
|
78
|
+
maxNoProgress: 2
|
|
79
|
+
maxMinutes: 30
|
|
80
|
+
models:
|
|
81
|
+
default: inherit
|
|
82
|
+
improve:
|
|
83
|
+
mode: auto
|
|
84
|
+
permissions:
|
|
85
|
+
allowedPaths: [${workspaceRelative}, ${docs.loopDir.replace(/\\/g, "/")}]
|
|
86
|
+
forbiddenActions: [commit, push, deploy, external-message]
|
|
87
|
+
verification:
|
|
88
|
+
commands: []
|
|
89
|
+
evidenceRequired: true
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
# Oris demo loop
|
|
93
|
+
|
|
94
|
+
Goal: increment the number in \`${workspaceRelative}/counter.txt\` by 1 per pass until it reaches 3.
|
|
95
|
+
Stop: complete at 3; blocked if the file is missing or not a number.
|
|
96
|
+
|
|
97
|
+
This loop exists to LEARN the loop system safely:
|
|
98
|
+
- it only touches files inside its own folder;
|
|
99
|
+
- it runs 3 short passes, then completes;
|
|
100
|
+
- improve.mode is \`auto\`, so you can watch the debriefer tune \`prompts/\` between passes.
|
|
101
|
+
|
|
102
|
+
Try: edit \`prompts/executor.md\` and see the next pass obey your change.
|
|
103
|
+
`, "utf8");
|
|
104
|
+
|
|
105
|
+
fs.writeFileSync(path.join(loopDir, "prompts", "executor.md"), `# Executor prompt — demo loop (edit me and watch the next pass change)
|
|
106
|
+
|
|
107
|
+
You are the executor of the Oris demo loop.
|
|
108
|
+
|
|
109
|
+
THIS PASS: iteration {{iteration}}.
|
|
110
|
+
DO: read ${workspaceRelative}/counter.txt, add 1, write the new value back.
|
|
111
|
+
ONLY touch files under ${workspaceRelative}.
|
|
112
|
+
|
|
113
|
+
RETURN compact JSON only:
|
|
114
|
+
{ "role": "executor", "state": "claimed|blocked", "summary": "", "changedFiles": [], "claim": "counter is now N" }
|
|
115
|
+
`, "utf8");
|
|
116
|
+
|
|
117
|
+
fs.writeFileSync(path.join(loopDir, "prompts", "verifier.md"), `# Verifier prompt — demo loop
|
|
118
|
+
|
|
119
|
+
You are the independent verifier of the Oris demo loop.
|
|
120
|
+
NEVER trust the executor claim without reading the file yourself.
|
|
121
|
+
|
|
122
|
+
VERIFY: ${workspaceRelative}/counter.txt contains exactly the claimed number.
|
|
123
|
+
|
|
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": "" }
|
|
139
|
+
`, "utf8");
|
|
140
|
+
|
|
141
|
+
fs.writeFileSync(path.join(loopDir, "prompts", "debriefer.md"), `# Debriefer prompt — demo loop (improve mode: auto)
|
|
142
|
+
|
|
143
|
+
You are the debriefer. After each pass, read the latest receipt.
|
|
144
|
+
IF a prompt caused friction: rewrite that file under prompts/ (copy the old one to history/ first) and note the change.
|
|
145
|
+
ONE small improvement max per pass. NEVER touch loop.md.
|
|
146
|
+
|
|
147
|
+
RETURN compact JSON only:
|
|
148
|
+
{ "role": "debriefer", "outcome": "keep|tuned", "insight": "", "changedFiles": [] }
|
|
149
|
+
`, "utf8");
|
|
150
|
+
|
|
151
|
+
fs.writeFileSync(docs.contextFull, `# Oris Loop Context
|
|
152
|
+
|
|
153
|
+
Run: inactive
|
|
154
|
+
State: ready
|
|
155
|
+
Current program: ${DEMO_SLUG}
|
|
156
|
+
Current phase: work
|
|
157
|
+
Current phase index: 0
|
|
158
|
+
Current iteration: 0
|
|
159
|
+
Phase iteration: 0
|
|
160
|
+
Repair cycles: 0
|
|
161
|
+
Updated: ${new Date().toISOString()}
|
|
162
|
+
|
|
163
|
+
## Objective
|
|
164
|
+
|
|
165
|
+
Learn the Oris loop system: counter 0 → 3, one increment per pass.
|
|
166
|
+
|
|
167
|
+
## Next action
|
|
168
|
+
|
|
169
|
+
Increment the counter from its current value.
|
|
170
|
+
`, "utf8");
|
|
171
|
+
|
|
172
|
+
console.log(`Demo loop created: ${docs.loopRelative}`);
|
|
173
|
+
console.log("");
|
|
174
|
+
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}`);
|
|
177
|
+
console.log(" then end the turn — the stop hook schedules pass 1 automatically.");
|
|
178
|
+
console.log(` 3. Watch and tune: edit ${docs.promptsRelative}/executor.md between passes.`);
|
|
179
|
+
console.log("");
|
|
180
|
+
console.log(`Stop anytime: oris-skills loop chat --action stop`);
|