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,319 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Shared stop hook for same-chat Oris loops.
5
+ *
6
+ * Platforms:
7
+ * - cursor: reads Cursor stop-hook JSON on stdin, answers { followup_message }.
8
+ * - claude: reads Claude Code Stop-hook JSON on stdin, answers
9
+ * { decision: "block", reason } to continue the same session.
10
+ *
11
+ * The platform comes from --platform, else it is detected from input keys.
12
+ */
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import process from "node:process";
17
+ import { ORIS_FLOW_RUNTIME } from "../flow/oris-flow-layout.mjs";
18
+ import { readTemplate, stripTemplateHeading, substitutePlaceholders } from "./oris-loop-templates.mjs";
19
+
20
+ const terminalStates = new Set(["complete", "blocked", "cancelled"]);
21
+ const resumableStates = new Set(["continue", "advance", "running"]);
22
+
23
+ function emit(value) {
24
+ process.stdout.write(`${JSON.stringify(value)}\n`);
25
+ }
26
+
27
+ /** Windows may pipe hook stdin with a UTF-8 BOM; JSON.parse rejects it. */
28
+ function stripUtf8Bom(text) {
29
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
30
+ }
31
+
32
+ function parseJsonText(text) {
33
+ const trimmed = stripUtf8Bom(String(text ?? "").trim());
34
+ return trimmed ? JSON.parse(trimmed) : {};
35
+ }
36
+
37
+ function readInput() {
38
+ return new Promise((resolve, reject) => {
39
+ let raw = "";
40
+ process.stdin.setEncoding("utf8");
41
+ process.stdin.on("data", (chunk) => {
42
+ raw += chunk;
43
+ });
44
+ process.stdin.on("end", () => {
45
+ try {
46
+ resolve(parseJsonText(raw));
47
+ } catch (error) {
48
+ reject(error);
49
+ }
50
+ });
51
+ process.stdin.on("error", reject);
52
+ });
53
+ }
54
+
55
+ function writeStateAtomic(statePath, state) {
56
+ const directory = path.dirname(statePath);
57
+ fs.mkdirSync(directory, { recursive: true });
58
+ const temporary = `${statePath}.${process.pid}.tmp`;
59
+ fs.writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, {
60
+ encoding: "utf8",
61
+ mode: 0o600,
62
+ });
63
+ fs.renameSync(temporary, statePath);
64
+ }
65
+
66
+ function stopState(statePath, state, reason) {
67
+ writeStateAtomic(statePath, {
68
+ ...state,
69
+ active: false,
70
+ state: "blocked",
71
+ stopReason: reason,
72
+ updatedAt: new Date().toISOString(),
73
+ });
74
+ }
75
+
76
+ function normalizeState(state) {
77
+ if (!state || typeof state !== "object") return null;
78
+ const normalized = { ...state };
79
+ const version = Number(normalized.schemaVersion ?? 0);
80
+ if (version !== 1) return null;
81
+ return normalized;
82
+ }
83
+
84
+ function detectPlatform(argv, raw) {
85
+ const explicit = argv.find((arg) => arg.startsWith("--platform="))?.slice("--platform=".length)
86
+ ?? (argv.includes("--platform") ? argv[argv.indexOf("--platform") + 1] : "");
87
+ if (explicit === "cursor" || explicit === "claude") return explicit;
88
+ if (String(raw.hook_event_name ?? "") === "Stop" || raw.session_id !== undefined || raw.stop_hook_active !== undefined) {
89
+ return "claude";
90
+ }
91
+ return "cursor";
92
+ }
93
+
94
+ /** Normalize Cursor and Claude hook payloads to one shape. */
95
+ function readHookInput(raw, platform) {
96
+ if (platform === "claude") {
97
+ return {
98
+ status: "completed",
99
+ conversationId: raw.session_id ? String(raw.session_id) : "",
100
+ loopCount: 0,
101
+ workspaceRoots: raw.cwd ? [String(raw.cwd)] : [],
102
+ hookEventName: String(raw.hook_event_name ?? ""),
103
+ rawKeys: Object.keys(raw).sort(),
104
+ };
105
+ }
106
+ const status = String(raw.status ?? raw.agent_status ?? "").toLowerCase();
107
+ const conversationId = raw.conversation_id ?? raw.conversationId ?? null;
108
+ const loopCount = Number(raw.loop_count ?? raw.loopCount ?? 0);
109
+ const workspaceRoots = Array.isArray(raw.workspace_roots)
110
+ ? raw.workspace_roots
111
+ : Array.isArray(raw.workspaceRoots)
112
+ ? raw.workspaceRoots
113
+ : [];
114
+ return {
115
+ status,
116
+ conversationId: conversationId ? String(conversationId) : "",
117
+ loopCount: Number.isFinite(loopCount) ? loopCount : 0,
118
+ workspaceRoots,
119
+ hookEventName: String(raw.hook_event_name ?? raw.hookEventName ?? ""),
120
+ rawKeys: Object.keys(raw).sort(),
121
+ };
122
+ }
123
+
124
+ function resolveStatePath(input, workspaceRoots) {
125
+ if (process.env.ORIS_LOOP_CHAT_STATE) {
126
+ return path.resolve(process.env.ORIS_LOOP_CHAT_STATE);
127
+ }
128
+ for (const root of workspaceRoots) {
129
+ if (typeof root !== "string" || root.trim().length === 0) continue;
130
+ const candidate = path.join(root, ORIS_FLOW_RUNTIME, "chat-active.json");
131
+ if (fs.existsSync(candidate)) return path.resolve(candidate);
132
+ }
133
+ return path.resolve(process.cwd(), ORIS_FLOW_RUNTIME, "chat-active.json");
134
+ }
135
+
136
+ function resolveDebugPath(statePath) {
137
+ return path.join(path.dirname(statePath), "hook-debug.jsonl");
138
+ }
139
+
140
+ function appendDebug(debugPath, entry) {
141
+ try {
142
+ fs.mkdirSync(path.dirname(debugPath), { recursive: true });
143
+ fs.appendFileSync(debugPath, `${JSON.stringify(entry)}\n`, "utf8");
144
+ } catch {
145
+ // Debug logging must never break the hook.
146
+ }
147
+ }
148
+
149
+ function packageOutput(platform, followup) {
150
+ if (!followup) return {};
151
+ if (platform === "claude") return { decision: "block", reason: followup };
152
+ return { followup_message: followup };
153
+ }
154
+
155
+ function finish(debugPath, baseEntry, result) {
156
+ appendDebug(debugPath, {
157
+ ...baseEntry,
158
+ at: new Date().toISOString(),
159
+ decision: result.decision,
160
+ reason: result.reason,
161
+ emittedFollowup: Boolean(result.followup),
162
+ });
163
+ emit(packageOutput(baseEntry.platform, result.followup));
164
+ }
165
+
166
+ /** Resolve the orchestrator prompt: per-loop file > packaged template > embedded. */
167
+ function buildFollowup(state, repositoryRoot, values) {
168
+ let template = null;
169
+ if (state.promptsPath && repositoryRoot) {
170
+ const perLoop = path.join(repositoryRoot, state.promptsPath, "orchestrator.md");
171
+ if (fs.existsSync(perLoop)) {
172
+ template = stripTemplateHeading(fs.readFileSync(perLoop, "utf8"));
173
+ }
174
+ }
175
+ if (!template) template = readTemplate("orchestrator", import.meta.url);
176
+ return substitutePlaceholders(template, values);
177
+ }
178
+
179
+ try {
180
+ const rawInput = await readInput();
181
+ const platform = detectPlatform(process.argv.slice(2), rawInput);
182
+ const input = readHookInput(rawInput, platform);
183
+ const statePath = resolveStatePath(rawInput, input.workspaceRoots);
184
+ const debugPath = resolveDebugPath(statePath);
185
+ const baseEntry = {
186
+ pid: process.pid,
187
+ cwd: process.cwd(),
188
+ platform,
189
+ statePath,
190
+ inputStatus: input.status,
191
+ conversationId: input.conversationId || null,
192
+ loopCount: input.loopCount,
193
+ hookEventName: input.hookEventName,
194
+ rawKeys: input.rawKeys,
195
+ };
196
+
197
+ if (input.status !== "completed") {
198
+ finish(debugPath, baseEntry, { decision: "noop", reason: `status-not-completed:${input.status || "missing"}` });
199
+ process.exit(0);
200
+ }
201
+
202
+ if (!input.conversationId) {
203
+ finish(debugPath, baseEntry, { decision: "noop", reason: "missing-conversation-id" });
204
+ process.exit(0);
205
+ }
206
+
207
+ if (!fs.existsSync(statePath)) {
208
+ finish(debugPath, baseEntry, { decision: "noop", reason: "chat-active-missing" });
209
+ process.exit(0);
210
+ }
211
+
212
+ let state;
213
+ try {
214
+ state = normalizeState(parseJsonText(fs.readFileSync(statePath, "utf8")));
215
+ } catch {
216
+ finish(debugPath, baseEntry, { decision: "noop", reason: "chat-active-invalid-json" });
217
+ process.exit(0);
218
+ }
219
+
220
+ baseEntry.runtimeActive = state?.active ?? null;
221
+ baseEntry.runtimeState = state?.state ?? null;
222
+ baseEntry.runtimeIteration = state?.iteration ?? null;
223
+
224
+ if (!state) {
225
+ finish(debugPath, baseEntry, { decision: "noop", reason: "chat-active-unsupported-schema" });
226
+ process.exit(0);
227
+ }
228
+
229
+ if (state.active !== true || terminalStates.has(state.state) || !resumableStates.has(state.state)) {
230
+ finish(debugPath, baseEntry, { decision: "noop", reason: `runtime-not-resumable:${state.state}` });
231
+ process.exit(0);
232
+ }
233
+
234
+ let boundConversation = false;
235
+ if (!state.conversationId) {
236
+ state.conversationId = input.conversationId;
237
+ boundConversation = true;
238
+ }
239
+ if (state.conversationId !== input.conversationId) {
240
+ finish(debugPath, baseEntry, { decision: "noop", reason: `conversation-mismatch:bound=${state.conversationId}` });
241
+ process.exit(0);
242
+ }
243
+ if (boundConversation) {
244
+ writeStateAtomic(statePath, { ...state, updatedAt: new Date().toISOString() });
245
+ baseEntry.boundConversation = true;
246
+ }
247
+
248
+ const iteration = Number(state.iteration ?? 0);
249
+ const maxIterations = Number(state.maxIterations ?? 25);
250
+ const maxMinutes = Number(state.maxMinutes ?? 240);
251
+ const startedAt = Date.parse(state.startedAt ?? "");
252
+ const elapsedMinutes = Number.isFinite(startedAt)
253
+ ? (Date.now() - startedAt) / 60_000
254
+ : Number.POSITIVE_INFINITY;
255
+
256
+ if (!Number.isInteger(iteration) || !Number.isInteger(maxIterations) || maxIterations < 1) {
257
+ finish(debugPath, baseEntry, { decision: "noop", reason: "invalid-runtime-counters" });
258
+ process.exit(0);
259
+ }
260
+
261
+ if (iteration >= maxIterations || input.loopCount >= maxIterations) {
262
+ stopState(statePath, state, "iteration limit exhausted");
263
+ finish(debugPath, baseEntry, { decision: "stop", reason: "iteration-limit-exhausted" });
264
+ process.exit(0);
265
+ }
266
+
267
+ if (elapsedMinutes >= maxMinutes) {
268
+ stopState(statePath, state, "wall-clock limit exhausted");
269
+ finish(debugPath, baseEntry, { decision: "stop", reason: "wall-clock-limit-exhausted" });
270
+ process.exit(0);
271
+ }
272
+
273
+ const nextIteration = iteration + 1;
274
+ const phases = Array.isArray(state.phases) && state.phases.length > 0 ? state.phases : ["execute"];
275
+ const phaseIndex = Number.isInteger(Number(state.phaseIndex)) ? Number(state.phaseIndex) : 0;
276
+ const phaseName = state.phaseName ?? phases[Math.max(0, Math.min(phaseIndex, phases.length - 1))] ?? "execute";
277
+ const models = state.models && typeof state.models === "object" ? state.models : {};
278
+ const defaultModel = String(models.default ?? "inherit");
279
+ const repositoryRoot = state.repositoryRoot && fs.existsSync(state.repositoryRoot)
280
+ ? state.repositoryRoot
281
+ : input.workspaceRoots.find((root) => typeof root === "string" && fs.existsSync(root)) ?? process.cwd();
282
+
283
+ const followup = buildFollowup(state, repositoryRoot, {
284
+ iteration: nextIteration,
285
+ maxIterations,
286
+ loop: state.program ?? "",
287
+ phase: phaseName,
288
+ phaseIndex: phaseIndex + 1,
289
+ phaseCount: phases.length,
290
+ instruction: state.state === "advance" ? "advance to the next declared phase" : "continue the current phase",
291
+ loopPath: state.loopPath ?? "",
292
+ contextPath: state.contextPath ?? "",
293
+ promptsDir: state.promptsPath ?? "",
294
+ executorModel: String(models.executor ?? defaultModel),
295
+ verifierModel: String(models.verifier ?? defaultModel),
296
+ doctorModel: String(models.doctor ?? defaultModel),
297
+ debrieferModel: String(models.debriefer ?? defaultModel),
298
+ improveMode: String(state.improveMode ?? "propose"),
299
+ taskPath: state.taskPath ?? "",
300
+ });
301
+
302
+ writeStateAtomic(statePath, {
303
+ ...state,
304
+ state: "running",
305
+ iteration: nextIteration,
306
+ phaseIteration: Number(state.phaseIteration ?? 0) + 1,
307
+ updatedAt: new Date().toISOString(),
308
+ });
309
+
310
+ finish(debugPath, baseEntry, { decision: "followup", reason: "scheduled-next-iteration", followup });
311
+ } catch (error) {
312
+ const fallbackPath = path.resolve(process.cwd(), ORIS_FLOW_RUNTIME, "hook-debug.jsonl");
313
+ appendDebug(fallbackPath, {
314
+ at: new Date().toISOString(),
315
+ decision: "error",
316
+ reason: String(error?.message ?? error),
317
+ });
318
+ emit({});
319
+ }
@@ -0,0 +1,77 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ /**
6
+ * Prompt templates live in skills/oris-loop/templates/*.md so users and
7
+ * agents can read and copy them. Scripts fall back to an embedded
8
+ * orchestrator prompt so the stop hook can never fail on a missing file.
9
+ */
10
+ export const TEMPLATE_NAMES = ["orchestrator", "executor", "verifier", "doctor", "debriefer"];
11
+
12
+ export const EMBEDDED_ORCHESTRATOR = `ORIS LOOP RUN — pass {{iteration}}/{{maxIterations}}
13
+ Loop: {{loop}} | Phase: {{phase}} ({{phaseIndex}}/{{phaseCount}}) | Do: {{instruction}}
14
+ Definition: {{loopPath}} | Context: {{contextPath}} | Prompts: {{promptsDir}}
15
+ Models: executor={{executorModel}} verifier={{verifierModel}} doctor={{doctorModel}} debriefer={{debrieferModel}} ("inherit" = current session model)
16
+ Improve mode: {{improveMode}}
17
+
18
+ ROLE: you are the orchestrator only. NEVER do executor, verifier, or doctor work in this chat.
19
+
20
+ EACH PASS:
21
+ 1. READ {{contextPath}}; pick exactly ONE bounded next step.
22
+ 2. SPAWN executor subagent with {{promptsDir}}/executor.md (fill runtime values), model {{executorModel}}.
23
+ 3. SPAWN verifier subagent with {{promptsDir}}/verifier.md + the executor claim, model {{verifierModel}}.
24
+ 4. SPAWN doctor subagent with {{promptsDir}}/doctor.md + both compact summaries, model {{doctorModel}}.
25
+ 5. WRITE a compact receipt to the loop receipts/ folder; update context.md with durable facts only.
26
+ 6. RUN: \`oris-skills loop chat --action set-state --state <continue|advance|complete|blocked>\`.
27
+ 7. END the turn with exactly: \`ORIS_LOOP_STATE: continue | advance | complete | blocked | ask_user\`.
28
+
29
+ IMPROVE ({{improveMode}}):
30
+ - auto → after the doctor verdict, the debriefer may rewrite files under {{promptsDir}} (archive the old version to history/ first). It must NEVER change scope, limits, permissions, or verification in loop.md — those go to proposals/.
31
+ - propose → the debriefer writes proposals/ only; nothing changes until the user approves.
32
+
33
+ ALWAYS:
34
+ - Keep chat output compact; raw logs go to .oris-flow/runtime/evidence/.
35
+ - Tell the user in one line what this pass will do before spawning workers.
36
+
37
+ NEVER:
38
+ - Continue when the doctor says blocked, scope is violated, or the same failure repeats.
39
+ - Commit, push, deploy, edit .oris-flow/runtime/**, or touch paths outside the approved scope.
40
+ - Ask "run next pass?" after continue/advance — end the turn; the hook schedules the next pass.
41
+
42
+ IF prompts files are missing or subagents cannot be spawned: \`set-state --state blocked\`, say why, end the turn.`;
43
+
44
+ export function resolveTemplatesDir(fromImportMetaUrl) {
45
+ const scriptsLoop = path.dirname(fileURLToPath(fromImportMetaUrl));
46
+ const candidates = [
47
+ path.resolve(scriptsLoop, "..", "..", "skills", "oris-loop", "templates"),
48
+ path.resolve(scriptsLoop, "..", "skills", "oris-loop", "templates"),
49
+ ];
50
+ for (const candidate of candidates) {
51
+ if (fs.existsSync(path.join(candidate, "orchestrator.md"))) return candidate;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /** Strip the leading "# ..." comment line templates carry for humans. */
57
+ export function stripTemplateHeading(text) {
58
+ const lines = String(text ?? "").split(/\r?\n/);
59
+ while (lines.length > 0 && (lines[0].startsWith("# ") || !lines[0].trim())) lines.shift();
60
+ return lines.join("\n").trim();
61
+ }
62
+
63
+ export function readTemplate(name, fromImportMetaUrl) {
64
+ const dir = resolveTemplatesDir(fromImportMetaUrl);
65
+ if (dir) {
66
+ const full = path.join(dir, `${name}.md`);
67
+ if (fs.existsSync(full)) return stripTemplateHeading(fs.readFileSync(full, "utf8"));
68
+ }
69
+ return name === "orchestrator" ? EMBEDDED_ORCHESTRATOR : null;
70
+ }
71
+
72
+ /** Replace {{key}} for provided keys only; craft placeholders stay intact. */
73
+ export function substitutePlaceholders(text, values) {
74
+ return String(text ?? "").replace(/\{\{([A-Za-z0-9_]+)\}\}/g, (token, key) => (
75
+ Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : token
76
+ ));
77
+ }
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * `oris-skills loop verify [--temp]` — self-check of the same-chat loop
5
+ * runtime. With --temp it builds a throwaway smoke repository, arms it, and
6
+ * simulates both the Cursor and the Claude Code stop hooks.
7
+ */
8
+
9
+ import childProcess from "node:child_process";
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import process from "node:process";
14
+ import { scriptPath } from "./oris-loop-bundle.mjs";
15
+ import { initSmokeRepository } from "./oris-loop-fixtures.mjs";
16
+ import { ORIS_FLOW_ADAPTER, ORIS_FLOW_RUNTIME } from "../flow/oris-flow-layout.mjs";
17
+
18
+ function readJson(filePath) {
19
+ if (!fs.existsSync(filePath)) return null;
20
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
21
+ }
22
+
23
+ function tailJsonl(filePath, count = 5) {
24
+ if (!fs.existsSync(filePath)) return [];
25
+ return fs.readFileSync(filePath, "utf8")
26
+ .trim()
27
+ .split("\n")
28
+ .filter(Boolean)
29
+ .slice(-count)
30
+ .map((line) => JSON.parse(line));
31
+ }
32
+
33
+ function runHook(hookPath, repoRoot, statePath, input, { bom = false, platformArg = "" } = {}) {
34
+ const payload = JSON.stringify(input);
35
+ const stdin = bom ? `\uFEFF${payload}` : payload;
36
+ const args = [hookPath];
37
+ if (platformArg) args.push("--platform", platformArg);
38
+ const result = childProcess.spawnSync(process.execPath, args, {
39
+ input: stdin,
40
+ encoding: "utf8",
41
+ cwd: repoRoot,
42
+ env: { ...process.env, ORIS_LOOP_CHAT_STATE: statePath },
43
+ });
44
+ return {
45
+ status: result.status,
46
+ stderr: result.stderr?.trim() ?? "",
47
+ stdout: result.stdout?.trim() ?? "",
48
+ output: result.stdout?.trim() ? JSON.parse(result.stdout.trim()) : {},
49
+ state: readJson(statePath),
50
+ };
51
+ }
52
+
53
+ function check(name, ok, detail) {
54
+ const mark = ok ? "OK" : "FAIL";
55
+ console.log(`[${mark}] ${name}${detail ? `: ${detail}` : ""}`);
56
+ return ok;
57
+ }
58
+
59
+ function parseArgs(argv) {
60
+ const options = {
61
+ repositoryRoot: "",
62
+ temp: false,
63
+ simulateOnly: false,
64
+ statusOnly: false,
65
+ withBom: false,
66
+ conversation: "",
67
+ };
68
+ for (let i = 0; i < argv.length; i += 1) {
69
+ const raw = argv[i];
70
+ if (raw === "--temp") options.temp = true;
71
+ else if (raw === "--simulate") options.simulateOnly = true;
72
+ else if (raw === "--status-only") options.statusOnly = true;
73
+ else if (raw === "--bom") options.withBom = true;
74
+ else if (raw.startsWith("--conversation=")) options.conversation = raw.slice("--conversation=".length);
75
+ else if (raw.startsWith("--repository-root=")) options.repositoryRoot = raw.slice("--repository-root=".length);
76
+ else if (raw === "--repository-root" && argv[i + 1]) {
77
+ options.repositoryRoot = argv[i + 1];
78
+ i += 1;
79
+ } else if (!raw.startsWith("-") && !options.repositoryRoot) {
80
+ options.repositoryRoot = raw;
81
+ }
82
+ }
83
+ return options;
84
+ }
85
+
86
+ const options = parseArgs(process.argv.slice(2));
87
+ let repoRoot = options.repositoryRoot ? path.resolve(options.repositoryRoot) : process.cwd();
88
+ let tempRoot = null;
89
+
90
+ if (options.temp) {
91
+ tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "oris-loop-verify-"));
92
+ const task = initSmokeRepository(tempRoot);
93
+ repoRoot = tempRoot;
94
+ if (!options.statusOnly && !options.simulateOnly) {
95
+ const chatScript = scriptPath(import.meta.url, "oris-loop-chat.mjs");
96
+ const start = childProcess.spawnSync(process.execPath, [
97
+ chatScript,
98
+ "--action", "start",
99
+ "--task", task.taskPath,
100
+ "--repository-root", repoRoot,
101
+ "--max-repair-cycles", "0",
102
+ ], { cwd: repoRoot, encoding: "utf8" });
103
+ if (start.status !== 0) {
104
+ console.error(start.stderr || start.stdout);
105
+ process.exit(1);
106
+ }
107
+ }
108
+ }
109
+
110
+ const runtimeDir = path.join(repoRoot, ORIS_FLOW_RUNTIME);
111
+ const statePath = path.join(runtimeDir, "chat-active.json");
112
+ const debugPath = path.join(runtimeDir, "hook-debug.jsonl");
113
+ const stopHookPath = scriptPath(import.meta.url, "oris-loop-stop.mjs");
114
+ const chatScript = scriptPath(import.meta.url, "oris-loop-chat.mjs");
115
+ const cursorWrapper = path.join(repoRoot, ".cursor", "hooks", "oris-loop-stop.mjs");
116
+ const claudeWrapper = path.join(repoRoot, ".claude", "hooks", "oris-loop-stop.mjs");
117
+
118
+ console.log("Oris loop verify (same-chat)");
119
+ console.log(`Repository: ${repoRoot}`);
120
+ if (tempRoot) console.log("(temporary smoke repository)");
121
+ console.log("");
122
+
123
+ const checks = [];
124
+ checks.push(check("adapter.json", fs.existsSync(path.join(repoRoot, ORIS_FLOW_ADAPTER))));
125
+ checks.push(check("shared stop hook", fs.existsSync(stopHookPath)));
126
+ checks.push(check("chat helper", fs.existsSync(chatScript)));
127
+ const hasCursor = fs.existsSync(path.join(repoRoot, ".cursor", "hooks.json")) && fs.existsSync(cursorWrapper);
128
+ const hasClaude = fs.existsSync(path.join(repoRoot, ".claude", "settings.json")) && fs.existsSync(claudeWrapper);
129
+ checks.push(check("stop hook armed (cursor or claude)", hasCursor || hasClaude, `cursor=${hasCursor} claude=${hasClaude}`));
130
+
131
+ const chat = readJson(statePath);
132
+ console.log("");
133
+ console.log("chat-active.json:");
134
+ if (!chat) {
135
+ console.log("(missing - run oris-skills loop chat --action start first)");
136
+ } else {
137
+ console.log(JSON.stringify(chat, null, 2));
138
+ }
139
+
140
+ console.log("");
141
+ console.log(`hook-debug.jsonl (last ${Math.min(5, tailJsonl(debugPath).length)} events):`);
142
+ const events = tailJsonl(debugPath, 5);
143
+ if (events.length === 0) {
144
+ console.log("(none yet)");
145
+ } else {
146
+ for (const event of events) {
147
+ console.log(`- ${event.at} | ${event.decision} | ${event.reason}`);
148
+ }
149
+ }
150
+
151
+ if (!options.simulateOnly && !options.statusOnly) {
152
+ console.log("");
153
+ console.log("Simulating Cursor stop hook...");
154
+ if (!chat) {
155
+ check("simulate", false, "chat-active.json missing");
156
+ if (tempRoot) fs.rmSync(tempRoot, { recursive: true, force: true });
157
+ process.exit(1);
158
+ }
159
+
160
+ const conversationId = options.conversation || chat.conversationId || "debug-conversation";
161
+
162
+ const cursorInput = {
163
+ conversation_id: conversationId,
164
+ status: "completed",
165
+ loop_count: Number(chat.iteration ?? 0),
166
+ hook_event_name: "stop",
167
+ workspace_roots: [repoRoot],
168
+ };
169
+
170
+ console.log(`Conversation: ${conversationId}${options.withBom ? " (UTF-8 BOM stdin)" : ""}`);
171
+ const result = runHook(stopHookPath, repoRoot, statePath, cursorInput, { bom: options.withBom });
172
+ checks.push(check("cursor hook exit code 0", result.status === 0, result.stderr || undefined));
173
+ checks.push(check("cursor followup_message emitted", Boolean(result.output.followup_message)));
174
+
175
+ if (result.output.followup_message) {
176
+ const preview = result.output.followup_message.split("\n").slice(0, 3).join(" | ");
177
+ console.log(`Follow-up preview: ${preview}`);
178
+ }
179
+
180
+ if (result.state) {
181
+ checks.push(check("conversation bound", result.state.conversationId === conversationId));
182
+ checks.push(check("iteration incremented", Number(result.state.iteration) > Number(chat.iteration ?? 0)));
183
+ }
184
+
185
+ console.log("");
186
+ console.log("Simulating Claude Code stop hook...");
187
+ const beforeClaude = readJson(statePath);
188
+ const claudeInput = {
189
+ session_id: conversationId,
190
+ hook_event_name: "Stop",
191
+ stop_hook_active: false,
192
+ cwd: repoRoot,
193
+ };
194
+ const claudeResult = runHook(stopHookPath, repoRoot, statePath, claudeInput, { platformArg: "claude" });
195
+ checks.push(check("claude hook exit code 0", claudeResult.status === 0, claudeResult.stderr || undefined));
196
+ checks.push(check("claude decision block emitted", claudeResult.output.decision === "block" && Boolean(claudeResult.output.reason)));
197
+ if (claudeResult.state && beforeClaude) {
198
+ checks.push(check("claude iteration incremented", Number(claudeResult.state.iteration) > Number(beforeClaude.iteration ?? 0)));
199
+ }
200
+ }
201
+
202
+ console.log("");
203
+ const passed = checks.every(Boolean);
204
+ console.log(passed ? "Same-chat verification passed." : "Same-chat verification failed.");
205
+ if (tempRoot) fs.rmSync(tempRoot, { recursive: true, force: true });
206
+ process.exit(passed ? 0 : 1);