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.
Files changed (81) hide show
  1. package/.cursor-plugin/plugin.json +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/agents/oris-loop-debriefer.md +29 -0
  5. package/agents/oris-loop-doctor.md +32 -0
  6. package/agents/oris-loop-executor.md +32 -0
  7. package/agents/oris-loop-verifier.md +31 -0
  8. package/docs/architecture.md +26 -0
  9. package/docs/distribution.md +38 -0
  10. package/docs/maintainer-guide.md +32 -0
  11. package/docs/user-guide.md +42 -0
  12. package/package.json +49 -0
  13. package/references/clean-code-checklist.md +107 -0
  14. package/references/conventions.md +39 -0
  15. package/references/doc-policy.md +24 -0
  16. package/references/loop-adapter.schema.json +126 -0
  17. package/references/loop-contract.md +119 -0
  18. package/references/loop.schema.json +143 -0
  19. package/references/questions.md +38 -0
  20. package/references/repo-map.md +53 -0
  21. package/references/repo-map.schema.json +198 -0
  22. package/references/settings.md +35 -0
  23. package/references/settings.schema.json +75 -0
  24. package/scripts/flow/oris-flow-clean-runtime.mjs +182 -0
  25. package/scripts/flow/oris-flow-layout.mjs +53 -0
  26. package/scripts/flow/oris-flow-scan.mjs +350 -0
  27. package/scripts/flow/oris-flow-version-control.mjs +42 -0
  28. package/scripts/flow/oris-gitignore.mjs +79 -0
  29. package/scripts/install/generate-agent-adapters.mjs +74 -0
  30. package/scripts/install/install-user-skills.mjs +271 -0
  31. package/scripts/install/uninstall-user-skills.mjs +163 -0
  32. package/scripts/loop/oris-loop-bootstrap.mjs +383 -0
  33. package/scripts/loop/oris-loop-bundle.mjs +22 -0
  34. package/scripts/loop/oris-loop-chat.mjs +408 -0
  35. package/scripts/loop/oris-loop-demo.mjs +180 -0
  36. package/scripts/loop/oris-loop-document.mjs +179 -0
  37. package/scripts/loop/oris-loop-dry-run.mjs +110 -0
  38. package/scripts/loop/oris-loop-fixtures.mjs +148 -0
  39. package/scripts/loop/oris-loop-list.mjs +81 -0
  40. package/scripts/loop/oris-loop-paths.mjs +232 -0
  41. package/scripts/loop/oris-loop-run.mjs +241 -0
  42. package/scripts/loop/oris-loop-stop.mjs +319 -0
  43. package/scripts/loop/oris-loop-templates.mjs +77 -0
  44. package/scripts/loop/oris-loop-verify.mjs +206 -0
  45. package/scripts/oris-skills.mjs +116 -0
  46. package/scripts/package-oris-skills.mjs +53 -0
  47. package/scripts/tests/run-all-tests.mjs +38 -0
  48. package/scripts/tests/test-agent-adapters.mjs +19 -0
  49. package/scripts/tests/test-oris-1-0-cleanliness.mjs +58 -0
  50. package/scripts/tests/test-oris-flow-clean-runtime.mjs +75 -0
  51. package/scripts/tests/test-oris-flow-scan.mjs +49 -0
  52. package/scripts/tests/test-oris-gitignore.mjs +35 -0
  53. package/scripts/tests/test-oris-loop-bootstrap.mjs +94 -0
  54. package/scripts/tests/test-oris-loop-document.mjs +112 -0
  55. package/scripts/tests/test-oris-loop-list.mjs +74 -0
  56. package/scripts/tests/test-oris-loop-run.mjs +45 -0
  57. package/scripts/tests/test-oris-loop-smoke.mjs +130 -0
  58. package/scripts/tests/test-oris-loop-stop.mjs +371 -0
  59. package/scripts/tests/test-routing-lifecycle.mjs +181 -0
  60. package/scripts/tests/test-schemas.mjs +55 -0
  61. package/skills/oris-flow/SKILL.md +55 -0
  62. package/skills/oris-flow/agents/openai.yaml +4 -0
  63. package/skills/oris-flow-criteria/SKILL.md +49 -0
  64. package/skills/oris-flow-discover/SKILL.md +58 -0
  65. package/skills/oris-flow-docs/SKILL.md +31 -0
  66. package/skills/oris-flow-fix/SKILL.md +42 -0
  67. package/skills/oris-flow-implement/SKILL.md +43 -0
  68. package/skills/oris-flow-plan/SKILL.md +51 -0
  69. package/skills/oris-flow-setup/SKILL.md +49 -0
  70. package/skills/oris-help/SKILL.md +30 -0
  71. package/skills/oris-help/agents/openai.yaml +4 -0
  72. package/skills/oris-loop/SKILL.md +66 -0
  73. package/skills/oris-loop/agents/openai.yaml +4 -0
  74. package/skills/oris-loop/references/craft.md +48 -0
  75. package/skills/oris-loop/references/improve.md +23 -0
  76. package/skills/oris-loop/references/run.md +30 -0
  77. package/skills/oris-loop/templates/debriefer.md +18 -0
  78. package/skills/oris-loop/templates/doctor.md +20 -0
  79. package/skills/oris-loop/templates/executor.md +19 -0
  80. package/skills/oris-loop/templates/orchestrator.md +33 -0
  81. 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`);