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.
- package/CHANGELOG.md +8 -0
- package/README.md +4 -3
- package/docs/adr/README.md +5 -0
- package/docs/distribution.md +1 -2
- package/docs/user-guide.md +1 -1
- package/package.json +1 -1
- package/references/conventions.md +27 -6
- package/references/devops.md +58 -0
- package/references/devops.schema.json +54 -0
- package/references/doc-policy.md +1 -1
- package/references/loop-adapter.schema.json +1 -34
- package/references/repo-map.md +2 -4
- package/references/repo-map.schema.json +0 -26
- package/references/settings.md +8 -20
- package/references/settings.schema.json +30 -80
- package/scripts/flow/oris-flow-devops.mjs +77 -0
- package/scripts/flow/oris-flow-layout.mjs +1 -1
- package/scripts/flow/oris-flow-scan.mjs +3 -9
- package/scripts/install/install-user-skills.mjs +39 -28
- package/scripts/install/oris-mcp.mjs +68 -0
- package/scripts/loop/oris-loop-bootstrap.mjs +0 -5
- package/scripts/loop/oris-loop-chat.mjs +0 -30
- package/scripts/tests/run-all-tests.mjs +3 -0
- package/scripts/tests/test-install-mcp.mjs +66 -0
- package/scripts/tests/test-oris-flow-devops.mjs +64 -0
- package/scripts/tests/test-oris-loop-smoke.mjs +1 -44
- package/scripts/tests/test-schemas.mjs +9 -8
- package/scripts/tests/test-skill-self-contained.mjs +66 -0
- package/skills/oris-flow/SKILL.md +10 -2
- package/skills/oris-flow/references/commit.md +45 -0
- package/skills/oris-flow/references/help.md +1 -1
- package/skills/oris-flow/references/pr.md +44 -0
- package/skills/oris-flow/references/setup.md +12 -10
|
@@ -433,9 +433,9 @@ function resolveTasksRoot(root, existingManifest) {
|
|
|
433
433
|
|
|
434
434
|
/**
|
|
435
435
|
* Refresh keeps user truth: confirmed decisions, tasksRoot, version control,
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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 {
|
|
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
|
|
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"
|
|
19
|
-
assert.ok(schema
|
|
20
|
-
assert.ok(schema
|
|
21
|
-
assert.ok(schema
|
|
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,
|
|
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", "
|
|
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.
|
|
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
|
+
});
|