oris-skills 2.2.2 → 3.0.1

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 (86) hide show
  1. package/.cursor-plugin/plugin.json +2 -3
  2. package/CHANGELOG.md +24 -6
  3. package/README.md +23 -59
  4. package/docs/architecture.md +15 -11
  5. package/docs/distribution.md +3 -3
  6. package/docs/maintainer-guide.md +4 -4
  7. package/docs/user-guide.md +12 -20
  8. package/package.json +3 -6
  9. package/references/conventions.md +23 -13
  10. package/references/devops.md +58 -0
  11. package/references/devops.schema.json +54 -0
  12. package/references/doc-policy.md +5 -5
  13. package/references/research.md +45 -0
  14. package/references/settings.md +11 -5
  15. package/references/settings.schema.json +10 -0
  16. package/scripts/flow/oris-flow-devops.mjs +77 -0
  17. package/scripts/flow/oris-flow-layout.mjs +0 -16
  18. package/scripts/flow/oris-flow-scan.mjs +32 -317
  19. package/scripts/flow/oris-gitignore.mjs +1 -5
  20. package/scripts/install/install-user-skills.mjs +17 -3
  21. package/scripts/install/oris-mcp.mjs +68 -0
  22. package/scripts/install/uninstall-user-skills.mjs +2 -29
  23. package/scripts/oris-skills.mjs +0 -47
  24. package/scripts/tests/run-all-tests.mjs +3 -9
  25. package/scripts/tests/test-cleanliness.mjs +54 -0
  26. package/scripts/tests/test-install-mcp.mjs +66 -0
  27. package/scripts/tests/test-oris-flow-devops.mjs +64 -0
  28. package/scripts/tests/test-oris-flow-scan.mjs +21 -91
  29. package/scripts/tests/test-routing-lifecycle.mjs +22 -53
  30. package/scripts/tests/test-schemas.mjs +17 -31
  31. package/scripts/tests/test-skill-style.mjs +5 -2
  32. package/skills/oris-flow/SKILL.md +10 -6
  33. package/skills/oris-flow/references/architecture.md +1 -1
  34. package/skills/oris-flow/references/commit.md +46 -0
  35. package/skills/oris-flow/references/criteria.md +2 -2
  36. package/skills/oris-flow/references/discover.md +2 -2
  37. package/skills/oris-flow/references/fix.md +7 -3
  38. package/skills/oris-flow/references/help.md +1 -6
  39. package/skills/oris-flow/references/implement.md +5 -3
  40. package/skills/oris-flow/references/new.md +6 -6
  41. package/skills/oris-flow/references/plan.md +7 -2
  42. package/skills/oris-flow/references/pr.md +44 -0
  43. package/skills/oris-flow/references/setup.md +28 -35
  44. package/skills/oris-flow/references/verify.md +4 -5
  45. package/agents/oris-loop-debriefer.md +0 -30
  46. package/agents/oris-loop-doctor.md +0 -32
  47. package/agents/oris-loop-executor.md +0 -35
  48. package/agents/oris-loop-verifier.md +0 -35
  49. package/references/loop-adapter.schema.json +0 -93
  50. package/references/loop-contract.md +0 -126
  51. package/references/loop.schema.json +0 -156
  52. package/references/repo-map.md +0 -51
  53. package/references/repo-map.schema.json +0 -213
  54. package/scripts/flow/oris-flow-clean-runtime.mjs +0 -182
  55. package/scripts/install/generate-agent-adapters.mjs +0 -81
  56. package/scripts/loop/oris-loop-bootstrap.mjs +0 -383
  57. package/scripts/loop/oris-loop-bundle.mjs +0 -22
  58. package/scripts/loop/oris-loop-chat.mjs +0 -396
  59. package/scripts/loop/oris-loop-demo.mjs +0 -171
  60. package/scripts/loop/oris-loop-document.mjs +0 -196
  61. package/scripts/loop/oris-loop-dry-run.mjs +0 -114
  62. package/scripts/loop/oris-loop-fixtures.mjs +0 -149
  63. package/scripts/loop/oris-loop-list.mjs +0 -81
  64. package/scripts/loop/oris-loop-paths.mjs +0 -232
  65. package/scripts/loop/oris-loop-run.mjs +0 -301
  66. package/scripts/loop/oris-loop-stop.mjs +0 -358
  67. package/scripts/loop/oris-loop-templates.mjs +0 -80
  68. package/scripts/loop/oris-loop-verify.mjs +0 -205
  69. package/scripts/tests/test-agent-adapters.mjs +0 -70
  70. package/scripts/tests/test-oris-1-0-cleanliness.mjs +0 -58
  71. package/scripts/tests/test-oris-flow-clean-runtime.mjs +0 -75
  72. package/scripts/tests/test-oris-loop-bootstrap.mjs +0 -94
  73. package/scripts/tests/test-oris-loop-document.mjs +0 -148
  74. package/scripts/tests/test-oris-loop-list.mjs +0 -74
  75. package/scripts/tests/test-oris-loop-run.mjs +0 -71
  76. package/scripts/tests/test-oris-loop-smoke.mjs +0 -120
  77. package/scripts/tests/test-oris-loop-stop.mjs +0 -432
  78. package/skills/oris-flow/references/loop-craft.md +0 -59
  79. package/skills/oris-flow/references/loop-improve.md +0 -32
  80. package/skills/oris-flow/references/loop-run.md +0 -47
  81. package/skills/oris-flow/references/loop.md +0 -56
  82. package/skills/oris-flow/templates/debriefer.md +0 -19
  83. package/skills/oris-flow/templates/doctor.md +0 -22
  84. package/skills/oris-flow/templates/executor.md +0 -25
  85. package/skills/oris-flow/templates/orchestrator.md +0 -36
  86. package/skills/oris-flow/templates/verifier.md +0 -24
@@ -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
+ }
@@ -24,7 +24,6 @@ function parseArgs(argv) {
24
24
  const options = {
25
25
  dryRun: false,
26
26
  bundleRoot: path.join(home, ".oris", "oris-skills"),
27
- settingsPath: path.join(home, ".oris", "settings.json"),
28
27
  cursorRoot: path.join(home, ".cursor"),
29
28
  claudeRoot: path.join(home, ".claude"),
30
29
  codexRoot: path.join(home, ".codex"),
@@ -45,7 +44,6 @@ function parseArgs(argv) {
45
44
  };
46
45
  if (key === "--dry-run") options.dryRun = true;
47
46
  else if (key === "--bundle-root" || key.startsWith("--bundle-root=")) options.bundleRoot = value();
48
- else if (key === "--settings" || key.startsWith("--settings=")) options.settingsPath = value();
49
47
  else if (key === "--cursor-root" || key.startsWith("--cursor-root=")) options.cursorRoot = value();
50
48
  else if (key === "--claude-root" || key.startsWith("--claude-root=")) options.claudeRoot = value();
51
49
  else if (key === "--codex-root" || key.startsWith("--codex-root=")) options.codexRoot = value();
@@ -92,15 +90,6 @@ function managedCodexPrompts(codexRoot) {
92
90
  .map((entry) => path.join(promptsRoot, entry.name));
93
91
  }
94
92
 
95
- /** Loop agent adapters that pre-2.2.1 installers wrote into <agent home>/agents/. */
96
- function managedAgentAdapters(agentRoot, extension) {
97
- const agentsRoot = path.join(agentRoot, "agents");
98
- if (!fs.existsSync(agentsRoot)) return [];
99
- return fs.readdirSync(agentsRoot, { withFileTypes: true })
100
- .filter((entry) => entry.isFile() && entry.name.toLowerCase().startsWith("oris-") && entry.name.endsWith(extension))
101
- .map((entry) => path.join(agentsRoot, entry.name));
102
- }
103
-
104
93
  function managedCopilotPrompts(copilotRoot) {
105
94
  const promptsRoot = path.join(copilotRoot, "prompts");
106
95
  if (!fs.existsSync(promptsRoot)) return [];
@@ -109,18 +98,6 @@ function managedCopilotPrompts(copilotRoot) {
109
98
  .map((entry) => path.join(promptsRoot, entry.name));
110
99
  }
111
100
 
112
- function legacyOrisPaths(options) {
113
- return [
114
- path.join(options.cursorRoot, "oris-skills"),
115
- path.join(options.cursorRoot, "oris-flow"),
116
- path.join(options.cursorRoot, "oris-loop"),
117
- path.join(options.cursorRoot, "oris-skills.settings.json"),
118
- path.join(options.cursorRoot, ".cursor-plugin-install-backups"),
119
- path.join(options.cursorRoot, "plugins", "oris-skills"),
120
- options.settingsPath,
121
- ];
122
- }
123
-
124
101
  function plannedRemovals(options) {
125
102
  const paths = new Set([
126
103
  options.bundleRoot,
@@ -128,11 +105,7 @@ function plannedRemovals(options) {
128
105
  ...managedSkillDirs(path.join(options.claudeRoot, "skills")),
129
106
  ...managedCodexPrompts(options.codexRoot),
130
107
  ...managedCopilotPrompts(options.copilotRoot),
131
- ...managedAgentAdapters(options.cursorRoot, ".md"),
132
- ...managedAgentAdapters(options.claudeRoot, ".md"),
133
- ...managedAgentAdapters(options.codexRoot, ".toml"),
134
108
  path.join(path.dirname(options.bundleRoot), "oris-flow.md"),
135
- ...legacyOrisPaths(options),
136
109
  ]);
137
110
  return [...paths].map((target) => path.resolve(target)).filter(safeOrisPath).sort();
138
111
  }
@@ -153,7 +126,7 @@ function removeOrisMcpServers(userMcpPath, dryRun) {
153
126
 
154
127
  export function uninstallUserSkills(rawArgs = []) {
155
128
  const options = parseArgs(rawArgs);
156
- for (const target of [options.bundleRoot, options.settingsPath, options.cursorRoot, options.claudeRoot, options.codexRoot, options.copilotRoot]) {
129
+ for (const target of [options.bundleRoot, options.cursorRoot, options.claudeRoot, options.codexRoot, options.copilotRoot]) {
157
130
  assertInsideHome(target);
158
131
  }
159
132
 
@@ -161,7 +134,7 @@ export function uninstallUserSkills(rawArgs = []) {
161
134
  log(options.dryRun ? "Dry run uninstall plan:" : "Uninstalling Oris-owned assets:");
162
135
  for (const target of removals) log(`${options.dryRun ? "Would remove" : "Remove"} ${target}`);
163
136
 
164
- // Legacy cleanup: older releases merged an Oris MCP server into Cursor's user config.
137
+ // The installer seeds the Oris MCP server into Cursor's user config; remove it with the rest.
165
138
  const mcpResult = removeOrisMcpServers(path.join(options.cursorRoot, "mcp.json"), options.dryRun);
166
139
  if (mcpResult.changed) log(`${options.dryRun ? "Would update" : "Updated"} ${mcpResult.path}`);
167
140
 
@@ -4,7 +4,6 @@ import childProcess from "node:child_process";
4
4
  import path from "node:path";
5
5
  import process from "node:process";
6
6
  import { fileURLToPath } from "node:url";
7
- import { bootstrapLoop } from "./loop/oris-loop-bootstrap.mjs";
8
7
 
9
8
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
9
 
@@ -19,15 +18,6 @@ function runNode(script, args, options = {}) {
19
18
  process.exit(status);
20
19
  }
21
20
 
22
- function runGitRoot() {
23
- const result = childProcess.spawnSync("git", ["rev-parse", "--show-toplevel"], {
24
- cwd: process.cwd(),
25
- shell: process.platform === "win32",
26
- encoding: "utf8",
27
- });
28
- return result.status === 0 && result.stdout.trim() ? path.resolve(result.stdout.trim()) : process.cwd();
29
- }
30
-
31
21
  function printHelp() {
32
22
  console.log(`Oris Skills
33
23
 
@@ -36,24 +26,10 @@ Install for your agents (Cursor, Claude Code, Codex):
36
26
  oris-skills uninstall [--dry-run]
37
27
  oris-skills reinstall [--dry-run]
38
28
 
39
- Learn and test loops:
40
- oris-skills loop demo create a safe tutorial loop
41
- oris-skills loop dry-run --loop <slug> preview the next pass, run nothing
42
- oris-skills loop verify --temp self-check the loop runtime
43
-
44
- Run and manage loops:
45
- oris-skills loop list [--json]
46
- oris-skills loop bootstrap [--platforms cursor,claude]
47
- oris-skills loop chat --action start --loop <slug>
48
- oris-skills loop chat --action status|set-state|repair|stop [...]
49
- oris-skills loop run --loop <slug> --agent codex|claude headless runner
50
-
51
29
  Repository tools:
52
- oris-skills doctor
53
30
  oris-skills validate
54
31
  oris-skills flow scan [...]
55
32
  oris-skills flow version-control [...]
56
- oris-skills flow clean-runtime [...]
57
33
  `);
58
34
  }
59
35
 
@@ -79,37 +55,14 @@ if (command === "reinstall") {
79
55
  runNode("install/install-user-skills.mjs", ["--force", ...commandArgs]);
80
56
  }
81
57
 
82
- if (command === "doctor") {
83
- const root = runGitRoot();
84
- const result = bootstrapLoop(root, { dryRun: true });
85
- console.log(JSON.stringify({
86
- repositoryRoot: root,
87
- ready: result.ready,
88
- missing: result.before.missing,
89
- plannedActions: result.actions,
90
- }, null, 2));
91
- process.exit(result.ready ? 0 : 1);
92
- }
93
-
94
58
  if (command === "validate") {
95
59
  // Kept as an alias of the full test suite for older docs and muscle memory.
96
60
  runNode("tests/run-all-tests.mjs", []);
97
61
  }
98
62
 
99
- if (command === "loop") {
100
- if (subcommand === "list") runNode("loop/oris-loop-list.mjs", rest);
101
- if (subcommand === "bootstrap") runNode("loop/oris-loop-bootstrap.mjs", rest);
102
- if (subcommand === "chat") runNode("loop/oris-loop-chat.mjs", rest);
103
- if (subcommand === "verify") runNode("loop/oris-loop-verify.mjs", rest);
104
- if (subcommand === "demo") runNode("loop/oris-loop-demo.mjs", rest);
105
- if (subcommand === "dry-run") runNode("loop/oris-loop-dry-run.mjs", rest);
106
- if (subcommand === "run") runNode("loop/oris-loop-run.mjs", rest);
107
- }
108
-
109
63
  if (command === "flow") {
110
64
  if (subcommand === "scan") runNode("flow/oris-flow-scan.mjs", rest);
111
65
  if (subcommand === "version-control") runNode("flow/oris-flow-version-control.mjs", rest);
112
- if (subcommand === "clean-runtime") runNode("flow/oris-flow-clean-runtime.mjs", rest);
113
66
  }
114
67
 
115
68
  console.error(`Unknown command: ${[command, subcommand].filter(Boolean).join(" ")}`);
@@ -9,19 +9,13 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".
9
9
 
10
10
  const nodeTestFiles = [
11
11
  "scripts/tests/test-schemas.mjs",
12
- "scripts/tests/test-oris-loop-document.mjs",
13
- "scripts/tests/test-agent-adapters.mjs",
14
12
  "scripts/tests/test-skill-self-contained.mjs",
15
- "scripts/tests/test-oris-1-0-cleanliness.mjs",
13
+ "scripts/tests/test-cleanliness.mjs",
16
14
  "scripts/tests/test-oris-flow-scan.mjs",
15
+ "scripts/tests/test-oris-flow-devops.mjs",
16
+ "scripts/tests/test-install-mcp.mjs",
17
17
  "scripts/tests/test-routing-lifecycle.mjs",
18
18
  "scripts/tests/test-skill-style.mjs",
19
- "scripts/tests/test-oris-loop-stop.mjs",
20
- "scripts/tests/test-oris-loop-smoke.mjs",
21
- "scripts/tests/test-oris-loop-bootstrap.mjs",
22
- "scripts/tests/test-oris-flow-clean-runtime.mjs",
23
- "scripts/tests/test-oris-loop-list.mjs",
24
- "scripts/tests/test-oris-loop-run.mjs",
25
19
  "scripts/tests/test-oris-gitignore.mjs",
26
20
  ];
27
21
 
@@ -0,0 +1,54 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import assert from "node:assert/strict";
4
+ import test from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
8
+ const ignoredDirs = new Set([".git", "node_modules", ".cursor", ".claude", ".codex", "dist", ".cursor-plugin-install-backups", ".oris-flow"]);
9
+
10
+ function walk(dir, matches = []) {
11
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
12
+ if (ignoredDirs.has(entry.name)) continue;
13
+ const full = path.join(dir, entry.name);
14
+ if (entry.isDirectory()) walk(full, matches);
15
+ else matches.push(full);
16
+ }
17
+ return matches;
18
+ }
19
+
20
+ function repoFiles() {
21
+ return walk(repoRoot).map((filePath) => ({
22
+ filePath,
23
+ relative: path.relative(repoRoot, filePath).replace(/\\/g, "/"),
24
+ }));
25
+ }
26
+
27
+ test("repository contains no PowerShell scripts", () => {
28
+ const psFiles = repoFiles().filter((entry) => entry.relative.endsWith(".ps1"));
29
+ assert.deepEqual(psFiles.map((entry) => entry.relative), []);
30
+ });
31
+
32
+ // Machinery removed in 3.0: the loop runtime and the manifest/maps knowledge store.
33
+ // The agent-facing surface (skills, references, docs, README) must never point at it;
34
+ // CHANGELOG keeps the history, and the uninstaller/gitignore keep legacy-cleanup paths.
35
+ const removedMachinery = /oris-skills loop |scripts\/loop\/|\.oris-flow\/(?:loops|runtime|manifest\.json|maps|adapter\.json)|loop-contract|references\/repo-map|flow scan[^\n]*--(?:check|write|area)|flow clean-runtime/;
36
+
37
+ test("agent-facing text never references machinery removed in 3.0", () => {
38
+ const offenders = repoFiles()
39
+ .filter((entry) => /^(skills|references|docs)\/.*\.md$|^(README|AGENTS|CONTEXT)\.md$/.test(entry.relative))
40
+ .filter((entry) => removedMachinery.test(fs.readFileSync(entry.filePath, "utf8")))
41
+ .map((entry) => entry.relative);
42
+ assert.deepEqual(offenders, []);
43
+ });
44
+
45
+ test("production scripts never invoke machinery removed in 3.0", () => {
46
+ const offenders = repoFiles()
47
+ // scripts/tests assert absences; the uninstaller/gitignore clean legacy paths — both must name them.
48
+ .filter((entry) => /^scripts\/(flow|install)\/.*\.mjs$|^scripts\/[^/]+\.mjs$/.test(entry.relative))
49
+ .filter((entry) => entry.relative !== "scripts/install/uninstall-user-skills.mjs")
50
+ .filter((entry) => entry.relative !== "scripts/flow/oris-gitignore.mjs")
51
+ .filter((entry) => removedMachinery.test(fs.readFileSync(entry.filePath, "utf8")))
52
+ .map((entry) => entry.relative);
53
+ assert.deepEqual(offenders, []);
54
+ });
@@ -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
+ });
@@ -1,18 +1,16 @@
1
- import assert from "node:assert/strict";
1
+ import assert from "node:assert/strict";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import test from "node:test";
6
- import childProcess from "node:child_process";
7
- import { checkFreshness, scanRepository, writeScanResult } from "../flow/oris-flow-scan.mjs";
8
- import { ORIS_FLOW_MANIFEST } from "../flow/oris-flow-layout.mjs";
6
+ import { scanRepository } from "../flow/oris-flow-scan.mjs";
9
7
 
10
8
  function write(filePath, content) {
11
9
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
12
10
  fs.writeFileSync(filePath, content, "utf8");
13
11
  }
14
12
 
15
- test("scanner creates only evidence-backed map sections", () => {
13
+ test("scanner reports only evidence-backed areas and writes nothing", () => {
16
14
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-flow-scan-"));
17
15
  try {
18
16
  write(path.join(root, "README.md"), "# Demo\n");
@@ -20,17 +18,13 @@ test("scanner creates only evidence-backed map sections", () => {
20
18
  write(path.join(root, "src", "index.js"), "export const value = 1;\n");
21
19
 
22
20
  const scan = scanRepository(root);
23
- assert.ok(scan.manifest.sections.docs);
24
- assert.ok(scan.manifest.sections.stack);
25
- assert.ok(scan.manifest.sections.commands);
26
- assert.ok(scan.manifest.sections.code);
27
- assert.equal(scan.manifest.sections.backendData, undefined);
28
- assert.equal(scan.manifest["known" + "Gaps"], undefined);
21
+ assert.ok(scan.areas.docs);
22
+ assert.ok(scan.areas.code);
23
+ assert.ok(scan.stacks.some((stack) => stack.id === "node"));
24
+ assert.ok(scan.commands.some((command) => command.command === "npm run test"));
25
+ assert.equal(scan.areas.tests, undefined, "no test evidence → no tests area");
29
26
 
30
- const written = writeScanResult(scan);
31
- assert.equal(written.manifestPath, ORIS_FLOW_MANIFEST);
32
- assert.ok(fs.existsSync(path.join(root, ORIS_FLOW_MANIFEST)));
33
- assert.ok(fs.existsSync(path.join(root, ".oris-flow", "maps", "stack.md")));
27
+ assert.equal(fs.existsSync(path.join(root, ".oris-flow")), false, "scan never writes");
34
28
  } finally {
35
29
  fs.rmSync(root, { recursive: true, force: true });
36
30
  }
@@ -41,8 +35,9 @@ test("scanner supports documentation-only repositories", () => {
41
35
  try {
42
36
  write(path.join(root, "docs", "guide.md"), "# Guide\n");
43
37
  const scan = scanRepository(root);
44
- assert.deepEqual(Object.keys(scan.manifest.sections), ["docs"]);
38
+ assert.deepEqual(Object.keys(scan.areas), ["docs"]);
45
39
  assert.deepEqual(scan.stacks, []);
40
+ assert.deepEqual(scan.commands, []);
46
41
  } finally {
47
42
  fs.rmSync(root, { recursive: true, force: true });
48
43
  }
@@ -61,96 +56,31 @@ test("scanner detects project type and derives inferred stack commands", () => {
61
56
  const dotnetTest = scan.commands.find((command) => command.command.startsWith("dotnet test"));
62
57
  assert.ok(dotnetTest, "expected an inferred dotnet test command");
63
58
  assert.equal(dotnetTest.confidence, "inferred");
64
- assert.equal(scan.manifest.setup.tasksRoot, ".oris-flow/tasks");
65
59
  } finally {
66
60
  fs.rmSync(root, { recursive: true, force: true });
67
61
  }
68
62
  });
69
63
 
70
- test("refresh preserves confirmed sections, commands, and tasksRoot", () => {
64
+ test("scanner flags truncation instead of silently under-reporting", () => {
71
65
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-flow-scan-"));
72
66
  try {
73
- write(path.join(root, "README.md"), "# Demo\n");
74
- write(path.join(root, "package.json"), `${JSON.stringify({ scripts: { test: "node --test" } })}\n`);
75
-
76
- const first = scanRepository(root);
77
- first.manifest.setup.tasksRoot = "docs/tasks";
78
- first.manifest.setup.confirmedDecisions = ["tests live under scripts/tests"];
79
- first.manifest.sections.docs.confidence = "confirmed";
80
- first.manifest.commands[0].confidence = "confirmed";
81
- writeScanResult(first);
82
- const enriched = "# Docs\n\nUser-confirmed enrichment that must survive refresh.\n";
83
- write(path.join(root, ".oris-flow", "maps", "docs.md"), enriched);
84
-
85
- const second = scanRepository(root);
86
- assert.equal(second.manifest.setup.tasksRoot, "docs/tasks");
87
- assert.deepEqual(second.manifest.setup.confirmedDecisions, ["tests live under scripts/tests"]);
88
- assert.equal(second.manifest.sections.docs.confidence, "confirmed");
89
- assert.equal(second.manifest.sections.docs.stale, false);
90
- assert.equal(second.manifest.commands[0].confidence, "confirmed");
91
- assert.ok(second.preservedSections.has("docs"));
92
-
93
- writeScanResult(second);
94
- assert.equal(fs.readFileSync(path.join(root, ".oris-flow", "maps", "docs.md"), "utf8"), enriched);
67
+ for (let index = 0; index < 12; index += 1) {
68
+ write(path.join(root, "src", `file-${index}.js`), "export {};\n");
69
+ }
70
+ const scan = scanRepository(root, { maxFiles: 5 });
71
+ assert.equal(scan.truncated, true);
72
+ assert.ok(scan.fileCount <= 5);
95
73
  } finally {
96
74
  fs.rmSync(root, { recursive: true, force: true });
97
75
  }
98
76
  });
99
77
 
100
- test("confirmed section without fresh evidence is marked stale, not deleted", () => {
101
- const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-flow-scan-"));
102
- try {
103
- write(path.join(root, "README.md"), "# Demo\n");
104
- const first = scanRepository(root);
105
- first.manifest.sections.docs.confidence = "confirmed";
106
- writeScanResult(first);
107
-
108
- fs.rmSync(path.join(root, "README.md"));
109
- const second = scanRepository(root);
110
- assert.equal(second.manifest.sections.docs.confidence, "confirmed");
111
- assert.equal(second.manifest.sections.docs.stale, true);
112
- } finally {
113
- fs.rmSync(root, { recursive: true, force: true });
114
- }
115
- });
116
-
117
-
118
- test("scan anchors the map to HEAD; --check answers freshness from the SHA + changed files", () => {
78
+ test("empty repository classifies as empty", () => {
119
79
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-flow-scan-"));
120
- const git = (...args) => childProcess.execFileSync("git", args, { cwd: root, encoding: "utf8" });
121
80
  try {
122
- write(path.join(root, "README.md"), "# Demo\n");
123
- write(path.join(root, "package.json"), `${JSON.stringify({ scripts: { test: "node --test" } })}\n`);
124
- git("init", "-q");
125
- git("-c", "user.email=t@t", "-c", "user.name=t", "add", ".");
126
- git("-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", "init");
127
-
128
- const scan = scanRepository(root);
129
- assert.equal(typeof scan.manifest.headSha, "string", "scan records the HEAD anchor");
130
- writeScanResult(scan);
131
-
132
- const fresh = checkFreshness(root);
133
- assert.equal(fresh.fresh, true, "same SHA + clean tree → fresh, zero reads");
134
- assert.deepEqual(fresh.changedFiles, []);
135
-
136
- write(path.join(root, "src", "new.js"), "export const later = 1;\n");
137
- const stale = checkFreshness(root);
138
- assert.equal(stale.fresh, false, "working-tree change → stale");
139
- assert.ok(stale.changedFiles.some((file) => file.includes("new.js")), "changed files name the delta");
140
- } finally {
141
- fs.rmSync(root, { recursive: true, force: true });
142
- }
143
- });
144
-
145
- test("outside git the map still works — no anchor, never an error", () => {
146
- const root = fs.mkdtempSync(path.join(os.tmpdir(), "oris-flow-scan-"));
147
- try {
148
- write(path.join(root, "README.md"), "# Demo\n");
149
81
  const scan = scanRepository(root);
150
- assert.equal(scan.manifest.headSha, null);
151
- writeScanResult(scan);
152
- const check = checkFreshness(root);
153
- assert.equal(check.fresh, false, "no anchor → refresh advised, not an error");
82
+ assert.equal(scan.projectType.id, "empty");
83
+ assert.deepEqual(scan.areas, {});
154
84
  } finally {
155
85
  fs.rmSync(root, { recursive: true, force: true });
156
86
  }