oris-skills 2.2.1 → 2.2.3

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.
@@ -433,9 +433,9 @@ function resolveTasksRoot(root, existingManifest) {
433
433
 
434
434
  /**
435
435
  * Refresh keeps user truth: confirmed decisions, tasksRoot, version control,
436
- * refresh policy, confirmed commands, and confirmed sections (whose map files
437
- * are also left untouched). A confirmed section the scan no longer sees is
438
- * marked stale, never deleted.
436
+ * confirmed commands, and confirmed sections (whose map files are also left
437
+ * untouched). A confirmed section the scan no longer sees is marked stale,
438
+ * never deleted.
439
439
  */
440
440
  function mergeWithExisting(scan, existingManifest) {
441
441
  if (!existingManifest || typeof existingManifest !== "object") return scan;
@@ -447,9 +447,6 @@ function mergeWithExisting(scan, existingManifest) {
447
447
  if (existingManifest.setup?.versionControl?.orisFlowMap) {
448
448
  merged.manifest.setup.versionControl.orisFlowMap = existingManifest.setup.versionControl.orisFlowMap;
449
449
  }
450
- if (existingManifest.refreshPolicy && typeof existingManifest.refreshPolicy === "object") {
451
- merged.manifest.refreshPolicy = existingManifest.refreshPolicy;
452
- }
453
450
  if (existingManifest.projectType?.confidence === "confirmed") {
454
451
  merged.manifest.projectType = existingManifest.projectType;
455
452
  }
@@ -507,9 +504,6 @@ export function scanRepository(repositoryRoot, options = {}) {
507
504
  orisFlowMap: options.versionControl ?? "commit",
508
505
  },
509
506
  },
510
- refreshPolicy: {
511
- mode: "manual",
512
- },
513
507
  stacks,
514
508
  commands,
515
509
  sections: sectionPointers,
@@ -22,6 +22,8 @@ import os from "node:os";
22
22
  import path from "node:path";
23
23
  import process from "node:process";
24
24
  import { fileURLToPath } from "node:url";
25
+ import { buildMcpServer, mcpServerName, resolveDevops } from "../flow/oris-flow-devops.mjs";
26
+ import { MCP_AGENTS, seedMcpServer } from "./oris-mcp.mjs";
25
27
 
26
28
  const markerName = ".oris-skills-managed.json";
27
29
  const installManifestName = ".oris-skills-install.json";
@@ -47,7 +49,6 @@ function defaultPaths() {
47
49
  const home = os.homedir();
48
50
  return {
49
51
  bundleRoot: path.join(home, ".oris", "oris-skills"),
50
- settingsPath: path.join(home, ".oris", "settings.json"),
51
52
  agentRoots: {
52
53
  cursor: path.join(home, ".cursor"),
53
54
  claude: path.join(home, ".claude"),
@@ -62,9 +63,9 @@ function parseArgs(argv) {
62
63
  const options = {
63
64
  dryRun: false,
64
65
  force: false,
66
+ noMcp: false,
65
67
  agents: [],
66
68
  bundleRoot: paths.bundleRoot,
67
- settingsPath: paths.settingsPath,
68
69
  agentRoots: paths.agentRoots,
69
70
  };
70
71
  for (let i = 0; i < argv.length; i += 1) {
@@ -78,10 +79,10 @@ function parseArgs(argv) {
78
79
  };
79
80
  if (key === "--dry-run") options.dryRun = true;
80
81
  else if (key === "--force") options.force = true;
82
+ else if (key === "--no-mcp") options.noMcp = true;
81
83
  else if (key === "--agents" || key.startsWith("--agents=")) {
82
84
  options.agents = value().split(",").map((name) => name.trim().toLowerCase()).filter(Boolean);
83
85
  } else if (key === "--bundle-root" || key.startsWith("--bundle-root=")) options.bundleRoot = value();
84
- else if (key === "--settings" || key.startsWith("--settings=")) options.settingsPath = value();
85
86
  else if (key === "--cursor-root" || key.startsWith("--cursor-root=")) options.agentRoots.cursor = value();
86
87
  else if (key === "--claude-root" || key.startsWith("--claude-root=")) options.agentRoots.claude = value();
87
88
  else if (key === "--codex-root" || key.startsWith("--codex-root=")) options.agentRoots.codex = value();
@@ -160,28 +161,24 @@ function manifestSkills(manifest, repoRoot) {
160
161
  });
161
162
  }
162
163
 
163
- function defaultSettings() {
164
- return {
165
- version: 1,
166
- orisFlow: {
167
- uiLanguage: "en",
168
- artifactLanguage: "en",
169
- responseStyle: "standard",
170
- },
171
- defaultProfiles: {
172
- testing: "default",
173
- },
174
- profiles: {
175
- default: {
176
- environment: "local",
177
- values: {},
178
- secrets: {},
179
- flags: {
180
- allowDataMutation: false,
181
- },
182
- },
183
- },
184
- };
164
+ /**
165
+ * Rewrite bundle-root-relative Oris paths to skill-root-relative in a standalone skill
166
+ * copy. The source keeps bundle-relative paths (`skills/<name>/references/…`) so the bundle
167
+ * and the Codex/Copilot pointers resolve; a standalone Claude/Cursor skill has no bundle
168
+ * root above it, so its own `.md` files must point at `references/…` and `templates/…`.
169
+ */
170
+ function rewriteBundlePaths(skillDir, relativePath) {
171
+ // In a standalone skill the skill folder IS the root, so any `skills/<name>/…` pointer
172
+ // (SKILL.md, references/…, templates/…, agents/…) becomes root-relative once the prefix
173
+ // is stripped.
174
+ const prefix = `${relativePath.replace(/\\/g, "/").replace(/\/+$/, "")}/`;
175
+ const entries = fs.readdirSync(skillDir, { recursive: true, withFileTypes: true });
176
+ for (const entry of entries) {
177
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
178
+ const filePath = path.join(entry.parentPath ?? entry.path, entry.name);
179
+ const text = fs.readFileSync(filePath, "utf8");
180
+ if (text.includes(prefix)) fs.writeFileSync(filePath, text.split(prefix).join(""), "utf8");
181
+ }
185
182
  }
186
183
 
187
184
  function installSkillDirs(skills, skillsRoot, manifest, bundleRoot, options) {
@@ -193,6 +190,13 @@ function installSkillDirs(skills, skillsRoot, manifest, bundleRoot, options) {
193
190
  }
194
191
  fs.rmSync(target, { recursive: true, force: true });
195
192
  copy(skill.sourceDir, target);
193
+ // Make the standalone skill self-contained: the shared references it points to
194
+ // (conventions.md, doc-policy.md, …) live in the bundle root, not inside the skill
195
+ // folder, so they were never copied — the whole reason `references/conventions.md`
196
+ // dangled at runtime. Merge them in, then rewrite the paths to skill-relative.
197
+ const bundleReferences = path.join(bundleRoot, "references");
198
+ if (fs.existsSync(bundleReferences)) copy(bundleReferences, path.join(target, "references"));
199
+ rewriteBundlePaths(target, skill.relativePath);
196
200
  writeJson(path.join(target, markerName), {
197
201
  package: manifest.name,
198
202
  version: manifest.version,
@@ -256,13 +260,16 @@ function main() {
256
260
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
257
261
  const manifest = readJson(path.join(repoRoot, ".cursor-plugin", "plugin.json"));
258
262
  const skills = manifestSkills(manifest, repoRoot);
263
+ const devops = resolveDevops(null).devops;
264
+ const mcpServer = buildMcpServer(devops);
265
+ const mcpName = mcpServerName(devops);
266
+ const mcpAgents = options.noMcp ? [] : (options.agents.length > 0 ? options.agents : detectAgents(options.agentRoots)).filter((agent) => MCP_AGENTS.includes(agent));
259
267
  const agents = options.agents.length > 0 ? options.agents : detectAgents(options.agentRoots);
260
268
  if (agents.length === 0) {
261
269
  fail("No agent detected (~/.cursor, ~/.claude, ~/.codex). Pass --agents cursor,claude,codex explicitly.");
262
270
  }
263
271
 
264
272
  assertInsideHome(options.bundleRoot);
265
- assertInsideHome(options.settingsPath);
266
273
  for (const agent of agents) assertInsideHome(options.agentRoots[agent]);
267
274
 
268
275
  if (options.dryRun) {
@@ -274,7 +281,7 @@ function main() {
274
281
  else log(`Would write ${agent} skills: ${path.join(options.agentRoots[agent], "skills")}`);
275
282
  }
276
283
  log(`Would write universal pointer: ${path.join(path.dirname(options.bundleRoot), "oris-flow.md")}`);
277
- log(`Would create settings if missing: ${options.settingsPath}`);
284
+ if (mcpAgents.length > 0) log(`Would seed MCP server '${mcpName}' for: ${mcpAgents.join(", ")}`);
278
285
  return;
279
286
  }
280
287
 
@@ -303,7 +310,11 @@ function main() {
303
310
  }
304
311
  installUniversalPointer(skills, options.bundleRoot);
305
312
 
306
- if (!fs.existsSync(options.settingsPath)) writeJson(options.settingsPath, defaultSettings());
313
+ for (const agent of mcpAgents) {
314
+ const result = seedMcpServer(agent, options.agentRoots, mcpName, mcpServer, { force: options.force });
315
+ log(`MCP ${mcpName} for ${agent}: ${result.status}`);
316
+ }
317
+
307
318
  writeJson(path.join(options.bundleRoot, installManifestName), {
308
319
  package: manifest.name,
309
320
  version: manifest.version,
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ /** Agents that receive an MCP seed. Copilot has no home-scoped MCP target here. */
7
+ export const MCP_AGENTS = ["cursor", "claude", "codex"];
8
+
9
+ /** MCP config file + format per agent, derived from the (overridable) agent roots. */
10
+ export function mcpConfigTarget(agent, agentRoots) {
11
+ if (agent === "cursor") return { kind: "json", filePath: path.join(agentRoots.cursor, "mcp.json") };
12
+ if (agent === "claude") return { kind: "json", filePath: path.join(path.dirname(agentRoots.claude), ".claude.json") };
13
+ if (agent === "codex") return { kind: "toml", filePath: path.join(agentRoots.codex, "config.toml") };
14
+ return null;
15
+ }
16
+
17
+ function readText(filePath) {
18
+ try {
19
+ return fs.readFileSync(filePath, "utf8");
20
+ } catch {
21
+ return "";
22
+ }
23
+ }
24
+
25
+ function tomlBlock(name, server) {
26
+ const args = server.args.map((arg) => JSON.stringify(arg)).join(", ");
27
+ return `\n[mcp_servers.${name}]\ncommand = ${JSON.stringify(server.command)}\nargs = [${args}]\n`;
28
+ }
29
+
30
+ /**
31
+ * Seed one MCP server into an agent's config, idempotently. Already present → skip unless
32
+ * `force`. `dryRun` reports the plan without writing. Returns a status per agent:
33
+ * "seeded" | "present" | "planned" | "skipped".
34
+ */
35
+ export function seedMcpServer(agent, agentRoots, name, server, options = {}) {
36
+ const target = mcpConfigTarget(agent, agentRoots);
37
+ if (!target) return { status: "skipped", agent };
38
+
39
+ if (target.kind === "json") {
40
+ let data = {};
41
+ const existing = readText(target.filePath);
42
+ if (existing.trim()) {
43
+ try {
44
+ data = JSON.parse(existing);
45
+ } catch {
46
+ data = {};
47
+ }
48
+ }
49
+ const servers = data.mcpServers && typeof data.mcpServers === "object" ? data.mcpServers : {};
50
+ const present = Object.prototype.hasOwnProperty.call(servers, name);
51
+ if (present && !options.force) return { status: "present", agent, filePath: target.filePath };
52
+ if (options.dryRun) return { status: "planned", agent, filePath: target.filePath };
53
+ servers[name] = server;
54
+ data.mcpServers = servers;
55
+ fs.mkdirSync(path.dirname(target.filePath), { recursive: true });
56
+ fs.writeFileSync(target.filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
57
+ return { status: "seeded", agent, filePath: target.filePath };
58
+ }
59
+
60
+ // TOML (Codex config.toml)
61
+ const text = readText(target.filePath);
62
+ const present = text.includes(`[mcp_servers.${name}]`);
63
+ if (present) return { status: "present", agent, filePath: target.filePath };
64
+ if (options.dryRun) return { status: "planned", agent, filePath: target.filePath };
65
+ fs.mkdirSync(path.dirname(target.filePath), { recursive: true });
66
+ fs.writeFileSync(target.filePath, `${text}${tomlBlock(name, server)}`, "utf8");
67
+ return { status: "seeded", agent, filePath: target.filePath };
68
+ }
@@ -168,11 +168,6 @@ export function buildAdapterDraft(repositoryRoot, hints = {}) {
168
168
  },
169
169
  commands,
170
170
  models: {},
171
- profile: {
172
- required: false,
173
- defaultRole: "testing",
174
- mappings: [],
175
- },
176
171
  preflight: {
177
172
  minFreeDiskGiB: 5,
178
173
  },
@@ -15,7 +15,6 @@ import {
15
15
  ORIS_FLOW_ADAPTER,
16
16
  ORIS_LOOP_SCHEMA,
17
17
  ORIS_LOOP_SCHEMA_VERSION,
18
- ORIS_SKILLS_SETTINGS,
19
18
  } from "../flow/oris-flow-layout.mjs";
20
19
  import {
21
20
  activeRoles,
@@ -42,8 +41,6 @@ function parseArgs(argv) {
42
41
  runner: "chat",
43
42
  maxIterations: 10,
44
43
  maxMinutes: 240,
45
- profile: "",
46
- settingsPath: ORIS_SKILLS_SETTINGS,
47
44
  };
48
45
  const readValue = (index, raw) => {
49
46
  if (raw.includes("=")) return [index, raw.slice(raw.indexOf("=") + 1)];
@@ -60,8 +57,6 @@ function parseArgs(argv) {
60
57
  else if (key === "--progress" || key.startsWith("--progress=")) [i, options.progress] = readValue(i, raw);
61
58
  else if (key === "--repository-root" || key.startsWith("--repository-root=")) [i, options.repositoryRoot] = readValue(i, raw);
62
59
  else if (key === "--runner" || key.startsWith("--runner=")) [i, options.runner] = readValue(i, raw);
63
- else if (key === "--profile" || key.startsWith("--profile=")) [i, options.profile] = readValue(i, raw);
64
- else if (key === "--settings-path" || key.startsWith("--settings-path=")) [i, options.settingsPath] = readValue(i, raw);
65
60
  else if (key === "--max-iterations" || key.startsWith("--max-iterations=")) {
66
61
  const parsed = readValue(i, raw);
67
62
  i = parsed[0];
@@ -143,30 +138,6 @@ function reconcileContext(root, state) {
143
138
  fs.renameSync(temporary, contextPath);
144
139
  }
145
140
 
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
141
  /** A same-chat loop needs at least one armed stop hook: Cursor or Claude Code. */
171
142
  function assessStopHooks(root) {
172
143
  const registered = [];
@@ -267,7 +238,6 @@ if (options.action === "start") {
267
238
  fail("No Oris stop hook is registered (.cursor/hooks.json or .claude/settings.json). Run: oris-skills loop bootstrap");
268
239
  }
269
240
  }
270
- validateRequiredProfile(options, adapter);
271
241
  const minimumFreeDiskGiB = Number(adapter.preflight?.minFreeDiskGiB ?? 0);
272
242
  if (typeof fs.statfsSync === "function") {
273
243
  const disk = fs.statfsSync(root);
@@ -11,8 +11,11 @@ const nodeTestFiles = [
11
11
  "scripts/tests/test-schemas.mjs",
12
12
  "scripts/tests/test-oris-loop-document.mjs",
13
13
  "scripts/tests/test-agent-adapters.mjs",
14
+ "scripts/tests/test-skill-self-contained.mjs",
14
15
  "scripts/tests/test-oris-1-0-cleanliness.mjs",
15
16
  "scripts/tests/test-oris-flow-scan.mjs",
17
+ "scripts/tests/test-oris-flow-devops.mjs",
18
+ "scripts/tests/test-install-mcp.mjs",
16
19
  "scripts/tests/test-routing-lifecycle.mjs",
17
20
  "scripts/tests/test-skill-style.mjs",
18
21
  "scripts/tests/test-oris-loop-stop.mjs",
@@ -0,0 +1,66 @@
1
+ import assert from "node:assert/strict";
2
+ import childProcess from "node:child_process";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import process from "node:process";
7
+ import test from "node:test";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
11
+
12
+ function runCli(args, homeDir) {
13
+ return childProcess.spawnSync(process.execPath, ["scripts/oris-skills.mjs", ...args], {
14
+ cwd: root,
15
+ encoding: "utf8",
16
+ env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir },
17
+ shell: false,
18
+ });
19
+ }
20
+
21
+ test("install --dry-run plans the oris-devops MCP server", () => {
22
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "oris-mcp-dry-"));
23
+ try {
24
+ const result = runCli(["install", "--dry-run", "--agents", "claude,cursor,codex"], home);
25
+ assert.equal(result.status, 0, result.stderr);
26
+ assert.match(result.stdout, /Would seed MCP server 'oris-devops'/);
27
+ } finally {
28
+ fs.rmSync(home, { recursive: true, force: true });
29
+ }
30
+ });
31
+
32
+ test("--no-mcp skips seeding", () => {
33
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "oris-mcp-off-"));
34
+ try {
35
+ const result = runCli(["install", "--dry-run", "--agents", "claude", "--no-mcp"], home);
36
+ assert.equal(result.status, 0, result.stderr);
37
+ assert.doesNotMatch(result.stdout, /Would seed MCP/);
38
+ } finally {
39
+ fs.rmSync(home, { recursive: true, force: true });
40
+ }
41
+ });
42
+
43
+ test("real install seeds oris-devops into the Claude config, idempotently", () => {
44
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "oris-mcp-real-"));
45
+ try {
46
+ fs.mkdirSync(path.join(home, ".claude"), { recursive: true });
47
+ const first = runCli(["install", "--force", "--agents", "claude"], home);
48
+ assert.equal(first.status, 0, first.stderr);
49
+
50
+ const claudeConfig = path.join(home, ".claude.json");
51
+ assert.ok(fs.existsSync(claudeConfig), "~/.claude.json written");
52
+ const data = JSON.parse(fs.readFileSync(claudeConfig, "utf8"));
53
+ assert.ok(data.mcpServers["oris-devops"], "oris-devops seeded");
54
+ assert.deepEqual(
55
+ data.mcpServers["oris-devops"].args.slice(0, 3),
56
+ ["-y", "@azure-devops/mcp@2.7.0", "orislineweb"],
57
+ );
58
+
59
+ const second = runCli(["install", "--force", "--agents", "claude"], home);
60
+ assert.equal(second.status, 0, second.stderr);
61
+ const after = JSON.parse(fs.readFileSync(claudeConfig, "utf8"));
62
+ assert.equal(Object.keys(after.mcpServers).length, 1, "no duplicate server on re-install");
63
+ } finally {
64
+ fs.rmSync(home, { recursive: true, force: true });
65
+ }
66
+ });
@@ -0,0 +1,64 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import { fileURLToPath } from "node:url";
7
+ import { DEFAULT_DEVOPS, resolveDevops, buildMcpServer, mcpServerName } from "../flow/oris-flow-devops.mjs";
8
+
9
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
10
+
11
+ test("default provider is Azure DevOps with the oris-devops MCP and #{id} work items", () => {
12
+ const d = DEFAULT_DEVOPS.devops;
13
+ assert.equal(d.provider, "azure-devops");
14
+ assert.equal(d.organization, "orislineweb");
15
+ assert.equal(d.workItemSyntax, "#{id}");
16
+ assert.equal(d.commitLanguage, "en");
17
+ assert.equal(d.prLanguage, "en");
18
+ assert.equal(mcpServerName(d), "oris-devops");
19
+ });
20
+
21
+ test("buildMcpServer produces the exact Azure DevOps command", () => {
22
+ const server = buildMcpServer(DEFAULT_DEVOPS.devops);
23
+ assert.deepEqual(server, {
24
+ command: "npx",
25
+ args: ["-y", "@azure-devops/mcp@2.7.0", "orislineweb", "-d", "core", "work", "work-items", "repositories"],
26
+ });
27
+ });
28
+
29
+ test("buildMcpServer honours explicit args for a non-Azure provider", () => {
30
+ const server = buildMcpServer({ organization: "x", mcp: { command: "node", args: ["jira-mcp.js"] } });
31
+ assert.deepEqual(server, { command: "node", args: ["jira-mcp.js"] });
32
+ });
33
+
34
+ test("resolution order is default -> org -> repo, each overriding the previous", () => {
35
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "oris-devops-home-"));
36
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), "oris-devops-repo-"));
37
+ try {
38
+ fs.mkdirSync(path.join(home, ".oris"), { recursive: true });
39
+ fs.writeFileSync(
40
+ path.join(home, ".oris", "devops.json"),
41
+ JSON.stringify({ version: 1, devops: { organization: "acme", workItemSyntax: "AB#{id}" } }),
42
+ );
43
+ fs.mkdirSync(path.join(repo, ".oris-flow"), { recursive: true });
44
+ fs.writeFileSync(
45
+ path.join(repo, ".oris-flow", "devops.json"),
46
+ JSON.stringify({ version: 1, devops: { workItemSyntax: "JIRA-{id}" } }),
47
+ );
48
+ const resolved = resolveDevops(repo, { home }).devops;
49
+ assert.equal(resolved.provider, "azure-devops", "provider inherited from default");
50
+ assert.equal(resolved.organization, "acme", "organization from org layer");
51
+ assert.equal(resolved.workItemSyntax, "JIRA-{id}", "repo layer wins over org layer");
52
+ } finally {
53
+ fs.rmSync(home, { recursive: true, force: true });
54
+ fs.rmSync(repo, { recursive: true, force: true });
55
+ }
56
+ });
57
+
58
+ test("devops schema is a versioned v1 contract", () => {
59
+ const schema = JSON.parse(fs.readFileSync(path.join(root, "references", "devops.schema.json"), "utf8"));
60
+ assert.equal(schema.$id, "https://oris.dev/schemas/oris-flow-devops-v1.json");
61
+ assert.equal(schema.properties.version.const, 1);
62
+ assert.deepEqual(schema.required, ["version", "devops"]);
63
+ assert.ok(schema.properties.devops.properties.provider);
64
+ });
@@ -6,7 +6,7 @@ import path from "node:path";
6
6
  import test from "node:test";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { initSmokeRepository } from "../loop/oris-loop-fixtures.mjs";
9
- import { ORIS_FLOW_ADAPTER, ORIS_FLOW_RUNTIME } from "../flow/oris-flow-layout.mjs";
9
+ import { ORIS_FLOW_RUNTIME } from "../flow/oris-flow-layout.mjs";
10
10
 
11
11
  const scriptsRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "loop");
12
12
 
@@ -118,46 +118,3 @@ test("same-chat start accepts an existing loop slug", () => {
118
118
  }
119
119
  });
120
120
 
121
- test("missing required profile leaves same-chat loop unarmed", () => {
122
- const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-loop-profile-"));
123
- try {
124
- initSmokeRepository(root, {
125
- taskSlug: "profile-smoke",
126
- program: "profile-smoke",
127
- });
128
- const adapterPath = path.join(root, ORIS_FLOW_ADAPTER);
129
- const adapter = JSON.parse(fs.readFileSync(adapterPath, "utf8"));
130
- adapter.profile = {
131
- required: true,
132
- defaultRole: "testing",
133
- mappings: [
134
- { env: "BASE_URL", from: "values.baseUrl", required: true, secret: false },
135
- ],
136
- };
137
- fs.writeFileSync(adapterPath, `${JSON.stringify(adapter, null, 2)}\n`, "utf8");
138
- const settingsPath = path.join(root, "settings.json");
139
- fs.writeFileSync(settingsPath, `${JSON.stringify({
140
- version: 1,
141
- orisFlow: { uiLanguage: "en", artifactLanguage: "en" },
142
- defaultProfiles: { testing: "default" },
143
- profiles: {
144
- default: { environment: "local", values: {}, secrets: {}, flags: {} },
145
- },
146
- }, null, 2)}\n`, "utf8");
147
-
148
- const chat = path.join(scriptsRoot, "oris-loop-chat.mjs");
149
- const start = spawnSync(process.execPath, [
150
- chat,
151
- "--action", "start",
152
- "--loop", "profile-smoke",
153
- "--repository-root", root,
154
- "--settings-path", settingsPath,
155
- ], { encoding: "utf8" });
156
- assert.notEqual(start.status, 0);
157
- assert.match(start.stderr, /missing required field 'values\.baseUrl'/);
158
- assert.match(start.stderr, /Oris chat loop was not armed; the stop hook will not resume this chat/);
159
- assert.equal(fs.existsSync(path.join(root, ORIS_FLOW_RUNTIME, "chat-active.json")), false);
160
- } finally {
161
- fs.rmSync(root, { recursive: true, force: true });
162
- }
163
- });
@@ -12,26 +12,27 @@ function read(relativePath) {
12
12
  return JSON.parse(fs.readFileSync(path.join(root, relativePath), "utf8"));
13
13
  }
14
14
 
15
- test("settings schema is the generic v1 contract", () => {
15
+ test("settings schema is the minimal v1 contract: only responseStyle and artifactLanguage", () => {
16
16
  const schema = read("references/settings.schema.json");
17
17
  assert.equal(schema.properties.version.const, 1);
18
- assert.deepEqual(schema.required, ["version", "orisFlow", "defaultProfiles", "profiles"]);
19
- assert.ok(schema.$defs.profile.properties.values);
20
- assert.ok(schema.$defs.profile.properties.secrets);
21
- assert.ok(schema.$defs.profile.properties.flags);
18
+ assert.deepEqual(schema.required, ["version", "orisFlow"]);
19
+ assert.ok(schema.properties.orisFlow.properties.responseStyle);
20
+ assert.ok(schema.properties.orisFlow.properties.artifactLanguage);
21
+ assert.ok(!schema.properties.defaultProfiles, "test profiles were removed from settings");
22
+ assert.ok(!schema.properties.profiles, "test profiles were removed from settings");
22
23
  });
23
24
 
24
- test("adapter schema v1 declares paths, commands, model aliases, profile, preflight", () => {
25
+ test("adapter schema v1 declares paths, commands, model aliases, preflight (no test profiles)", () => {
25
26
  const schema = read("references/loop-adapter.schema.json");
26
27
  assert.equal(schema.$id, "https://oris.dev/schemas/oris-loop-adapter-v1.json");
27
28
  assert.equal(schema.properties.schemaVersion.const, 1);
28
29
  assert.deepEqual(
29
30
  schema.required,
30
- ["$schema", "schemaVersion", "repository", "paths", "commands", "profile", "preflight"],
31
+ ["$schema", "schemaVersion", "repository", "paths", "commands", "preflight"],
31
32
  );
32
33
  assert.ok(schema.properties.paths.properties.loops);
33
34
  assert.ok(schema.properties.models, "adapter schema must allow a model alias map");
34
- assert.equal(schema.$defs.mapping.properties.secret.type, "boolean");
35
+ assert.ok(!schema.properties.profile, "test profiles were removed from the adapter");
35
36
  });
36
37
 
37
38
  test("loop document schema is v2 with approved gate, per-role models, and improve mode", () => {
@@ -0,0 +1,66 @@
1
+ import childProcess from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import assert from "node:assert/strict";
6
+ import process from "node:process";
7
+ import test from "node:test";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
11
+
12
+ function runCli(args, homeDir) {
13
+ return childProcess.spawnSync(process.execPath, ["scripts/oris-skills.mjs", ...args], {
14
+ cwd: root,
15
+ encoding: "utf8",
16
+ env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir },
17
+ shell: false,
18
+ });
19
+ }
20
+
21
+ function markdownFiles(dir) {
22
+ return fs
23
+ .readdirSync(dir, { recursive: true, withFileTypes: true })
24
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
25
+ .map((entry) => path.join(entry.parentPath ?? entry.path, entry.name));
26
+ }
27
+
28
+ // A standalone skill (Claude/Cursor) has no bundle root above it, so every reference it
29
+ // points to must resolve inside the skill folder — the regression that broke
30
+ // `references/conventions.md` at runtime.
31
+ test("installed Claude skill is self-contained", () => {
32
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "oris-self-contained-"));
33
+ try {
34
+ fs.mkdirSync(path.join(homeDir, ".claude"), { recursive: true });
35
+ const result = runCli(["install", "--force"], homeDir);
36
+ assert.equal(result.status, 0, result.stderr);
37
+
38
+ const skill = path.join(homeDir, ".claude", "skills", "oris-flow");
39
+ assert.ok(fs.existsSync(skill), "oris-flow skill was installed");
40
+
41
+ // The shared references that used to dangle must now be present.
42
+ for (const name of ["conventions.md", "doc-policy.md"]) {
43
+ assert.ok(
44
+ fs.existsSync(path.join(skill, "references", name)),
45
+ `shared reference ${name} is bundled into the skill`,
46
+ );
47
+ }
48
+
49
+ const files = markdownFiles(skill);
50
+ const pathToken = /(?:references|templates|agents)\/[A-Za-z0-9._/-]+\.(?:md|json|toml)/g;
51
+ const missing = [];
52
+ let bundlePrefixLeaks = 0;
53
+ for (const file of files) {
54
+ const text = fs.readFileSync(file, "utf8");
55
+ // No bundle-root-relative pointer may survive in a standalone skill.
56
+ if (text.includes("skills/oris-flow/")) bundlePrefixLeaks += 1;
57
+ for (const cited of text.match(pathToken) ?? []) {
58
+ if (!fs.existsSync(path.join(skill, cited))) missing.push(`${path.basename(file)} → ${cited}`);
59
+ }
60
+ }
61
+ assert.equal(bundlePrefixLeaks, 0, "no `skills/oris-flow/` paths remain in the standalone skill");
62
+ assert.deepEqual(missing, [], "every cited reference resolves inside the skill");
63
+ } finally {
64
+ fs.rmSync(homeDir, { recursive: true, force: true });
65
+ }
66
+ });