oris-skills 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/.cursor-plugin/plugin.json +2 -11
  2. package/CHANGELOG.md +48 -0
  3. package/README.md +79 -31
  4. package/agents/oris-loop-debriefer.md +1 -0
  5. package/agents/oris-loop-doctor.md +1 -1
  6. package/agents/oris-loop-executor.md +5 -2
  7. package/agents/oris-loop-verifier.md +6 -2
  8. package/docs/architecture.md +9 -7
  9. package/docs/distribution.md +1 -1
  10. package/docs/maintainer-guide.md +5 -5
  11. package/docs/user-guide.md +12 -9
  12. package/package.json +2 -3
  13. package/references/clean-code-checklist.md +20 -107
  14. package/references/conventions.md +43 -3
  15. package/references/doc-policy.md +19 -3
  16. package/references/loop-contract.md +24 -17
  17. package/references/loop.schema.json +13 -0
  18. package/references/repo-map.md +3 -3
  19. package/references/repo-map.schema.json +41 -0
  20. package/references/settings.md +2 -1
  21. package/references/settings.schema.json +5 -0
  22. package/scripts/flow/oris-flow-layout.mjs +1 -0
  23. package/scripts/flow/oris-flow-scan.mjs +265 -27
  24. package/scripts/install/generate-agent-adapters.mjs +19 -2
  25. package/scripts/install/install-user-skills.mjs +61 -3
  26. package/scripts/install/uninstall-user-skills.mjs +17 -1
  27. package/scripts/loop/oris-loop-bootstrap.mjs +7 -2
  28. package/scripts/loop/oris-loop-chat.mjs +34 -16
  29. package/scripts/loop/oris-loop-demo.mjs +11 -20
  30. package/scripts/loop/oris-loop-document.mjs +24 -7
  31. package/scripts/loop/oris-loop-dry-run.mjs +8 -4
  32. package/scripts/loop/oris-loop-fixtures.mjs +13 -12
  33. package/scripts/loop/oris-loop-run.mjs +80 -20
  34. package/scripts/loop/oris-loop-stop.mjs +48 -9
  35. package/scripts/loop/oris-loop-templates.mjs +13 -10
  36. package/scripts/loop/oris-loop-verify.mjs +1 -2
  37. package/scripts/oris-skills.mjs +3 -2
  38. package/scripts/tests/run-all-tests.mjs +1 -2
  39. package/scripts/tests/test-oris-flow-scan.mjs +109 -1
  40. package/scripts/tests/test-oris-loop-document.mjs +39 -3
  41. package/scripts/tests/test-oris-loop-run.mjs +28 -2
  42. package/scripts/tests/test-oris-loop-smoke.mjs +36 -3
  43. package/scripts/tests/test-oris-loop-stop.mjs +65 -4
  44. package/scripts/tests/test-routing-lifecycle.mjs +38 -23
  45. package/scripts/tests/test-skill-style.mjs +64 -0
  46. package/skills/oris-flow/SKILL.md +53 -25
  47. package/skills/oris-flow/references/architecture.md +77 -0
  48. package/skills/oris-flow/references/change.md +60 -0
  49. package/skills/oris-flow/references/criteria.md +60 -0
  50. package/skills/oris-flow/references/discover.md +66 -0
  51. package/skills/oris-flow/references/docs.md +33 -0
  52. package/skills/oris-flow/references/fix.md +49 -0
  53. package/skills/oris-flow/references/help.md +34 -0
  54. package/skills/oris-flow/references/implement.md +48 -0
  55. package/skills/oris-flow/references/loop-craft.md +59 -0
  56. package/skills/oris-flow/references/loop-improve.md +32 -0
  57. package/skills/oris-flow/references/loop-run.md +47 -0
  58. package/skills/oris-flow/references/loop.md +56 -0
  59. package/skills/oris-flow/references/new.md +64 -0
  60. package/skills/oris-flow/references/plan.md +58 -0
  61. package/skills/oris-flow/references/setup.md +100 -0
  62. package/skills/oris-flow/references/verify.md +57 -0
  63. package/skills/{oris-loop → oris-flow}/templates/debriefer.md +2 -1
  64. package/skills/oris-flow/templates/doctor.md +22 -0
  65. package/skills/{oris-loop → oris-flow}/templates/executor.md +8 -2
  66. package/skills/{oris-loop → oris-flow}/templates/orchestrator.md +10 -7
  67. package/skills/{oris-loop → oris-flow}/templates/verifier.md +6 -3
  68. package/references/questions.md +0 -38
  69. package/skills/oris-flow-criteria/SKILL.md +0 -49
  70. package/skills/oris-flow-discover/SKILL.md +0 -58
  71. package/skills/oris-flow-docs/SKILL.md +0 -31
  72. package/skills/oris-flow-fix/SKILL.md +0 -42
  73. package/skills/oris-flow-implement/SKILL.md +0 -43
  74. package/skills/oris-flow-plan/SKILL.md +0 -51
  75. package/skills/oris-flow-setup/SKILL.md +0 -49
  76. package/skills/oris-help/SKILL.md +0 -30
  77. package/skills/oris-help/agents/openai.yaml +0 -4
  78. package/skills/oris-loop/SKILL.md +0 -66
  79. package/skills/oris-loop/agents/openai.yaml +0 -4
  80. package/skills/oris-loop/references/craft.md +0 -48
  81. package/skills/oris-loop/references/improve.md +0 -23
  82. package/skills/oris-loop/references/run.md +0 -30
  83. package/skills/oris-loop/templates/doctor.md +0 -20
@@ -1,5 +1,6 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
+ import childProcess from "node:child_process";
3
4
  import fs from "node:fs";
4
5
  import path from "node:path";
5
6
  import process from "node:process";
@@ -9,12 +10,14 @@ import {
9
10
  ORIS_FLOW_MAP_SCHEMA,
10
11
  ORIS_FLOW_MAP_SCHEMA_VERSION,
11
12
  ORIS_FLOW_MAPS_ROOT,
13
+ ORIS_FLOW_TASKS_ROOT,
12
14
  relativeToRepository,
13
15
  resolveInsideRepository,
14
16
  toPosixPath,
15
17
  } from "./oris-flow-layout.mjs";
16
18
 
17
19
  const ignoredDirectories = new Set([
20
+ ".oris-flow",
18
21
  ".git",
19
22
  ".hg",
20
23
  ".svn",
@@ -40,9 +43,17 @@ const stackDetectors = [
40
43
  { id: "java", label: "Java", match: (file) => ["pom.xml", "build.gradle", "build.gradle.kts"].includes(file.name) },
41
44
  { id: "go", label: "Go", match: (file) => file.name === "go.mod" },
42
45
  { id: "rust", label: "Rust", match: (file) => file.name === "Cargo.toml" },
46
+ { id: "php", label: "PHP", match: (file) => file.name === "composer.json" },
47
+ { id: "ruby", label: "Ruby", match: (file) => file.name === "Gemfile" },
48
+ { id: "make", label: "Make", match: (file) => file.name === "Makefile" },
43
49
  { id: "playwright", label: "Playwright", match: (file) => /^playwright\.config\.(ts|js|mjs|cjs)$/i.test(file.name) },
44
50
  { id: "vitest", label: "Vitest", match: (file) => /^vitest\.config\.(ts|js|mjs|cjs)$/i.test(file.name) },
45
51
  { id: "jest", label: "Jest", match: (file) => /^jest\.config\.(ts|js|mjs|cjs)$/i.test(file.name) || file.name === "jest.config.json" },
52
+ { id: "vite", label: "Vite", match: (file) => /^vite\.config\.(ts|js|mjs|cjs)$/i.test(file.name) },
53
+ { id: "next", label: "Next.js", match: (file) => /^next\.config\.(ts|js|mjs|cjs)$/i.test(file.name) },
54
+ { id: "angular", label: "Angular", match: (file) => file.name === "angular.json" },
55
+ { id: "nx", label: "Nx", match: (file) => file.name === "nx.json" },
56
+ { id: "turborepo", label: "Turborepo", match: (file) => file.name === "turbo.json" },
46
57
  { id: "docker", label: "Docker", match: (file) => file.name === "Dockerfile" || file.name === "docker-compose.yml" || file.name === "compose.yaml" },
47
58
  { id: "azure-pipelines", label: "Azure Pipelines", match: (file) => /^azure-pipelines.*\.ya?ml$/i.test(file.name) },
48
59
  { id: "github-actions", label: "GitHub Actions", match: (file) => file.relative.startsWith(".github/workflows/") && /\.ya?ml$/i.test(file.name) },
@@ -53,6 +64,68 @@ function fail(message) {
53
64
  process.exit(1);
54
65
  }
55
66
 
67
+ function git(root, args) {
68
+ try {
69
+ const output = childProcess.execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
70
+ return output.trim();
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /** Commit SHA the scan anchors to; null outside git (the anchor is then unavailable, never an error). */
77
+ export function gitHeadSha(root) {
78
+ return git(root, ["rev-parse", "HEAD"]);
79
+ }
80
+
81
+ /**
82
+ * Files changed since the anchored SHA: committed delta + working-tree changes,
83
+ * deduped. The map's own files (.oris-flow/**) never count — they describe the
84
+ * code, they are not the code.
85
+ */
86
+ export function gitChangedFiles(root, sinceSha) {
87
+ const diff = git(root, ["diff", "--name-only", `${sinceSha}..HEAD`]);
88
+ const status = git(root, ["status", "--porcelain", "-uall"]);
89
+ if (diff === null && status === null) return null;
90
+ const changed = new Set();
91
+ const add = (file) => {
92
+ const clean = file.replace(/^"|"$/g, "");
93
+ if (clean && !clean.startsWith(".oris-flow/")) changed.add(clean);
94
+ };
95
+ for (const line of (diff ?? "").split(/\r?\n/)) if (line.trim()) add(line.trim());
96
+ for (const line of (status ?? "").split(/\r?\n/)) {
97
+ const file = line.slice(3).trim();
98
+ if (file) add(file.includes(" -> ") ? file.split(" -> ")[1] : file);
99
+ }
100
+ return [...changed].sort();
101
+ }
102
+
103
+ /**
104
+ * Freshness in one cheap call (the delta-scan anchor): compare the manifest's
105
+ * headSha with the current HEAD. Equal and clean → fresh, zero reads. Moved →
106
+ * the changed file list tells the caller which map areas to refresh in place.
107
+ */
108
+ export function checkFreshness(repositoryRoot) {
109
+ const root = path.resolve(repositoryRoot);
110
+ const manifest = readJson(path.join(root, ...ORIS_FLOW_MANIFEST.split("/")));
111
+ if (!manifest) return { manifest: false, fresh: false, reason: "no manifest — run a full scan" };
112
+ const headSha = gitHeadSha(root);
113
+ const scannedSha = manifest.headSha ?? null;
114
+ if (!headSha || !scannedSha) {
115
+ return { manifest: true, fresh: false, reason: "no SHA anchor — refresh writes one", headSha, scannedSha, changedFiles: null };
116
+ }
117
+ const changedFiles = gitChangedFiles(root, scannedSha) ?? [];
118
+ const fresh = headSha === scannedSha && changedFiles.length === 0;
119
+ return {
120
+ manifest: true,
121
+ fresh,
122
+ headSha,
123
+ scannedSha,
124
+ changedFiles: fresh ? [] : changedFiles.slice(0, 200),
125
+ sections: Object.keys(manifest.sections ?? {}),
126
+ };
127
+ }
128
+
56
129
  function parseArgs(argv) {
57
130
  const options = {
58
131
  repositoryRoot: process.cwd(),
@@ -72,6 +145,7 @@ function parseArgs(argv) {
72
145
  else if (key === "--write") options.write = true;
73
146
  else if (key === "--json") options.json = true;
74
147
  else if (key === "--area" || key.startsWith("--area=")) [i, options.area] = readValue(i, raw);
148
+ else if (key === "--check") options.check = true;
75
149
  else fail(`Unknown argument: ${raw}`);
76
150
  }
77
151
  return options;
@@ -80,9 +154,16 @@ function parseArgs(argv) {
80
154
  function walk(root, options = {}) {
81
155
  const maxFiles = Number(options.maxFiles ?? 6000);
82
156
  const files = [];
83
- const stack = [root];
84
- while (stack.length > 0 && files.length < maxFiles) {
85
- const current = stack.pop();
157
+ const queue = [root];
158
+ let truncated = false;
159
+ let index = 0;
160
+ while (index < queue.length) {
161
+ if (files.length >= maxFiles) {
162
+ truncated = true;
163
+ break;
164
+ }
165
+ const current = queue[index];
166
+ index += 1;
86
167
  let entries = [];
87
168
  try {
88
169
  entries = fs.readdirSync(current, { withFileTypes: true });
@@ -95,14 +176,17 @@ function walk(root, options = {}) {
95
176
  const full = path.join(current, entry.name);
96
177
  const relative = relativeToRepository(root, full);
97
178
  if (entry.isDirectory()) {
98
- stack.push(full);
179
+ queue.push(full);
99
180
  } else if (entry.isFile()) {
100
181
  files.push({ full, relative, name: entry.name });
182
+ if (files.length >= maxFiles) {
183
+ truncated = true;
184
+ break;
185
+ }
101
186
  }
102
- if (files.length >= maxFiles) break;
103
187
  }
104
188
  }
105
- return files;
189
+ return { files, truncated };
106
190
  }
107
191
 
108
192
  function readJson(filePath) {
@@ -130,6 +214,49 @@ function detectStacks(files) {
130
214
  }));
131
215
  }
132
216
 
217
+ function detectProjectType(files, stacks) {
218
+ const stackIds = new Set(stacks.map((stack) => stack.id));
219
+ const has = (predicate) => files.some(predicate);
220
+ const packageJsonCount = files.filter((file) => file.name === "package.json").length;
221
+ const projectFileCount = files.filter((file) => /\.(csproj|fsproj|vbproj)$/i.test(file.name)).length;
222
+
223
+ if (files.length === 0) {
224
+ return { id: "empty", label: "Empty repository", confidence: "detected", evidence: [] };
225
+ }
226
+ if (stackIds.has("nx") || stackIds.has("turborepo") || packageJsonCount > 2 || projectFileCount > 3
227
+ || has((file) => /^packages\/[^/]+\/package\.json$/.test(file.relative))) {
228
+ return {
229
+ id: "monorepo",
230
+ label: "Monorepo / multi-project",
231
+ confidence: "detected",
232
+ evidence: files.filter((file) => file.name === "package.json" || /\.(sln|csproj)$/i.test(file.name)).map((file) => file.relative).slice(0, 8),
233
+ };
234
+ }
235
+ const webSignals = files.filter((file) =>
236
+ stackIds.has("next") && /^next\.config\./.test(file.name)
237
+ || stackIds.has("angular") && file.name === "angular.json"
238
+ || stackIds.has("vite") && /^vite\.config\./.test(file.name)
239
+ || /(^|\/)(wwwroot|public)\//.test(file.relative)
240
+ || /(^|\/)(Views|Pages|pages|app)\/.+\.(cshtml|razor|tsx|jsx|vue|svelte)$/i.test(file.relative));
241
+ if (webSignals.length > 0) {
242
+ return { id: "webapp", label: "Web application", confidence: "detected", evidence: webSignals.map((file) => file.relative).slice(0, 8) };
243
+ }
244
+ const apiSignals = files.filter((file) => /(^|\/)(Controllers|controllers|routes|api)\//.test(file.relative) || /openapi\.(ya?ml|json)$/i.test(file.name));
245
+ if (apiSignals.length > 0) {
246
+ return { id: "api", label: "API / backend service", confidence: "detected", evidence: apiSignals.map((file) => file.relative).slice(0, 8) };
247
+ }
248
+ const rootPackage = files.find((file) => file.relative === "package.json");
249
+ if (rootPackage) {
250
+ const pkg = readJson(rootPackage.full);
251
+ if (pkg?.bin) return { id: "cli", label: "Command-line tool", confidence: "detected", evidence: ["package.json (bin)"] };
252
+ if (pkg?.exports || pkg?.main) return { id: "library", label: "Library / package", confidence: "detected", evidence: ["package.json (main/exports)"] };
253
+ }
254
+ if (has((file) => /(^|\/)(src|lib)\//.test(file.relative))) {
255
+ return { id: "library", label: "Library / package", confidence: "inferred", evidence: [] };
256
+ }
257
+ return { id: "unknown", label: "Unclassified", confidence: "inferred", evidence: [] };
258
+ }
259
+
133
260
  function findPackageCommands(files) {
134
261
  const commands = [];
135
262
  for (const file of files.filter((item) => item.name === "package.json")) {
@@ -143,6 +270,7 @@ function findPackageCommands(files) {
143
270
  cwd,
144
271
  command: cwd === "." ? `npm run ${name}` : `npm run ${name} --prefix ${cwd}`,
145
272
  evidence: file.relative,
273
+ confidence: "detected",
146
274
  });
147
275
  }
148
276
  }
@@ -150,6 +278,37 @@ function findPackageCommands(files) {
150
278
  return commands;
151
279
  }
152
280
 
281
+ /** Default commands for stacks whose runners are conventional; marked inferred until the user confirms them. */
282
+ function deriveStackCommands(stacks, files) {
283
+ const commands = [];
284
+ const stackIds = new Set(stacks.map((stack) => stack.id));
285
+ const firstEvidence = (id) => stacks.find((stack) => stack.id === id)?.evidence[0] ?? "";
286
+ if (stackIds.has("dotnet")) {
287
+ const solution = files.find((file) => /\.sln$/i.test(file.name));
288
+ const target = solution ? solution.relative : firstEvidence("dotnet");
289
+ commands.push(
290
+ { name: "build", cwd: ".", command: `dotnet build ${target}`.trim(), evidence: target, confidence: "inferred" },
291
+ { name: "test", cwd: ".", command: `dotnet test ${target}`.trim(), evidence: target, confidence: "inferred" },
292
+ );
293
+ }
294
+ if (stackIds.has("python")) {
295
+ commands.push({ name: "test", cwd: ".", command: "python -m pytest", evidence: firstEvidence("python"), confidence: "inferred" });
296
+ }
297
+ if (stackIds.has("go")) {
298
+ commands.push(
299
+ { name: "build", cwd: ".", command: "go build ./...", evidence: firstEvidence("go"), confidence: "inferred" },
300
+ { name: "test", cwd: ".", command: "go test ./...", evidence: firstEvidence("go"), confidence: "inferred" },
301
+ );
302
+ }
303
+ if (stackIds.has("rust")) {
304
+ commands.push(
305
+ { name: "build", cwd: ".", command: "cargo build", evidence: firstEvidence("rust"), confidence: "inferred" },
306
+ { name: "test", cwd: ".", command: "cargo test", evidence: firstEvidence("rust"), confidence: "inferred" },
307
+ );
308
+ }
309
+ return commands;
310
+ }
311
+
153
312
  function hasAny(files, predicate) {
154
313
  return files.some(predicate);
155
314
  }
@@ -171,8 +330,8 @@ function buildSections(root, files, stacks, commands, scannedAt) {
171
330
  const sections = {};
172
331
  const topFiles = files.map((file) => file.relative);
173
332
 
174
- if (hasAny(files, (file) => /(^|\/)(README|CONTEXT|CHANGELOG|CONTRIBUTING)\.md$/i.test(file.relative) || file.relative.startsWith("docs/"))) {
175
- const docs = topFiles.filter((file) => /(^|\/)(README|CONTEXT|CHANGELOG|CONTRIBUTING)\.md$/i.test(file) || file.startsWith("docs/")).slice(0, 20);
333
+ if (hasAny(files, (file) => /(^|\/)(README|CONTEXT|CHANGELOG|CONTRIBUTING|AGENTS|CLAUDE)\.md$/i.test(file.relative) || file.relative.startsWith("docs/"))) {
334
+ const docs = topFiles.filter((file) => /(^|\/)(README|CONTEXT|CHANGELOG|CONTRIBUTING|AGENTS|CLAUDE)\.md$/i.test(file) || file.startsWith("docs/")).slice(0, 20);
176
335
  sections.docs = makeSection(".oris-flow/maps/docs.md", "Documentation and task context detected.", {
177
336
  scannedAt,
178
337
  markdown: [
@@ -185,15 +344,18 @@ function buildSections(root, files, stacks, commands, scannedAt) {
185
344
  });
186
345
  }
187
346
 
188
- if (hasAny(files, (file) => file.relative.startsWith(".cursor/skills/") || file.relative.startsWith(".cursor/rules/"))) {
189
- const cursorFiles = topFiles.filter((file) => file.startsWith(".cursor/skills/") || file.startsWith(".cursor/rules/")).slice(0, 20);
190
- sections.cursorGuidance = makeSection(".oris-flow/maps/cursor-guidance.md", "Project-local Cursor skills or rules detected.", {
347
+ const guidancePaths = topFiles.filter((file) =>
348
+ file.startsWith(".cursor/skills/") || file.startsWith(".cursor/rules/")
349
+ || file.startsWith(".claude/skills/") || /^AGENTS\.md$/i.test(file) || /^CLAUDE\.md$/i.test(file)
350
+ || /^CONTEXT\.md$/i.test(file) || file.startsWith("docs/adr/")).slice(0, 20);
351
+ if (guidancePaths.length > 0) {
352
+ sections.agentGuidance = makeSection(".oris-flow/maps/agent-guidance.md", "Agent guidance detected (AGENTS/CLAUDE/CONTEXT, skills, rules, ADRs).", {
191
353
  scannedAt,
192
354
  markdown: [
193
- "# Cursor Guidance",
355
+ "# Agent Guidance",
194
356
  "",
195
- "Detected local Cursor guidance:",
196
- ...cursorFiles.map((file) => `- ${file}`),
357
+ "Detected agent-facing guidance:",
358
+ ...guidancePaths.map((file) => `- ${file}`),
197
359
  "",
198
360
  ].join("\n"),
199
361
  });
@@ -216,12 +378,12 @@ function buildSections(root, files, stacks, commands, scannedAt) {
216
378
  }
217
379
 
218
380
  if (commands.length > 0) {
219
- sections.commands = makeSection(".oris-flow/maps/commands.md", `Detected ${commands.length} package command(s).`, {
381
+ sections.commands = makeSection(".oris-flow/maps/commands.md", `Detected ${commands.length} command(s).`, {
220
382
  scannedAt,
221
383
  markdown: [
222
384
  "# Commands",
223
385
  "",
224
- ...commands.map((command) => `- \`${command.command}\` (${command.evidence})`),
386
+ ...commands.map((command) => `- \`${command.command}\` (${command.evidence || command.confidence}${command.confidence === "inferred" ? ", inferred — confirm before relying on it" : ""})`),
225
387
  "",
226
388
  ].join("\n"),
227
389
  });
@@ -261,12 +423,68 @@ function buildSections(root, files, stacks, commands, scannedAt) {
261
423
  return sections;
262
424
  }
263
425
 
426
+ function resolveTasksRoot(root, existingManifest) {
427
+ const configured = existingManifest?.setup?.tasksRoot;
428
+ if (typeof configured === "string" && configured.length > 0) return configured;
429
+ const legacy = path.join(root, "docs", "tasks");
430
+ if (fs.existsSync(legacy) && fs.readdirSync(legacy).length > 0) return "docs/tasks";
431
+ return ORIS_FLOW_TASKS_ROOT;
432
+ }
433
+
434
+ /**
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.
439
+ */
440
+ function mergeWithExisting(scan, existingManifest) {
441
+ if (!existingManifest || typeof existingManifest !== "object") return scan;
442
+ const merged = scan;
443
+ merged.manifest.generatedAt = existingManifest.generatedAt ?? merged.manifest.generatedAt;
444
+ if (Array.isArray(existingManifest.setup?.confirmedDecisions)) {
445
+ merged.manifest.setup.confirmedDecisions = existingManifest.setup.confirmedDecisions;
446
+ }
447
+ if (existingManifest.setup?.versionControl?.orisFlowMap) {
448
+ merged.manifest.setup.versionControl.orisFlowMap = existingManifest.setup.versionControl.orisFlowMap;
449
+ }
450
+ if (existingManifest.refreshPolicy && typeof existingManifest.refreshPolicy === "object") {
451
+ merged.manifest.refreshPolicy = existingManifest.refreshPolicy;
452
+ }
453
+ if (existingManifest.projectType?.confidence === "confirmed") {
454
+ merged.manifest.projectType = existingManifest.projectType;
455
+ }
456
+
457
+ const confirmedCommands = (existingManifest.commands ?? []).filter((command) => command?.confidence === "confirmed");
458
+ if (confirmedCommands.length > 0) {
459
+ const scanned = merged.manifest.commands.filter((command) =>
460
+ !confirmedCommands.some((confirmed) => confirmed.name === command.name && confirmed.cwd === command.cwd));
461
+ merged.manifest.commands = [...confirmedCommands, ...scanned];
462
+ merged.commands = merged.manifest.commands;
463
+ }
464
+
465
+ merged.preservedSections = new Set();
466
+ for (const [key, pointer] of Object.entries(existingManifest.sections ?? {})) {
467
+ if (pointer?.confidence !== "confirmed") continue;
468
+ const rescanned = merged.manifest.sections[key];
469
+ merged.manifest.sections[key] = {
470
+ ...pointer,
471
+ stale: rescanned ? pointer.stale === true : true,
472
+ };
473
+ merged.preservedSections.add(key);
474
+ if (merged.sections[key]) delete merged.sections[key];
475
+ }
476
+ return merged;
477
+ }
478
+
264
479
  export function scanRepository(repositoryRoot, options = {}) {
265
480
  const root = path.resolve(repositoryRoot);
266
481
  const scannedAt = new Date().toISOString();
267
- const files = walk(root, options);
482
+ const headSha = gitHeadSha(root);
483
+ const existingManifest = readJson(path.join(root, ...ORIS_FLOW_MANIFEST.split("/")));
484
+ const { files, truncated } = walk(root, options);
268
485
  const stacks = detectStacks(files);
269
- const commands = findPackageCommands(files);
486
+ const projectType = detectProjectType(files, stacks);
487
+ const commands = [...findPackageCommands(files), ...deriveStackCommands(stacks, files)];
270
488
  const sections = buildSections(root, files, stacks, commands, scannedAt);
271
489
  const sectionPointers = Object.fromEntries(
272
490
  Object.entries(sections).map(([key, value]) => [key, value.pointer]),
@@ -277,9 +495,13 @@ export function scanRepository(repositoryRoot, options = {}) {
277
495
  repository: path.basename(root),
278
496
  generatedAt: scannedAt,
279
497
  updatedAt: scannedAt,
498
+ headSha,
499
+ projectType,
500
+ truncated,
280
501
  setup: {
281
- mode: options.mode ?? "create",
502
+ mode: options.mode ?? (existingManifest ? "refresh" : "create"),
282
503
  scanDepth: options.scanDepth ?? "balanced",
504
+ tasksRoot: options.tasksRoot ?? resolveTasksRoot(root, existingManifest),
283
505
  confirmedDecisions: [],
284
506
  versionControl: {
285
507
  orisFlowMap: options.versionControl ?? "commit",
@@ -292,27 +514,35 @@ export function scanRepository(repositoryRoot, options = {}) {
292
514
  commands,
293
515
  sections: sectionPointers,
294
516
  };
295
- return {
517
+ const scan = {
296
518
  repositoryRoot: root,
297
519
  scannedAt,
298
520
  fileCount: files.length,
521
+ truncated,
522
+ projectType,
299
523
  stacks,
300
524
  commands,
301
525
  sections,
302
526
  manifest,
527
+ preservedSections: new Set(),
303
528
  };
529
+ return mergeWithExisting(scan, existingManifest);
304
530
  }
305
531
 
306
- export function writeScanResult(scan) {
532
+ export function writeScanResult(scan, options = {}) {
307
533
  const root = scan.repositoryRoot;
534
+ const area = options.area ?? "";
308
535
  const manifestPath = resolveInsideRepository(root, ORIS_FLOW_MANIFEST, "Oris Flow manifest");
309
536
  fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
310
537
  fs.writeFileSync(manifestPath, `${JSON.stringify(scan.manifest, null, 2)}\n`, "utf8");
311
538
 
312
- for (const section of Object.values(scan.sections)) {
539
+ const written = [];
540
+ for (const [key, section] of Object.entries(scan.sections)) {
541
+ if (area && key !== area) continue;
313
542
  const sectionPath = resolveInsideRepository(root, section.pointer.path, "Oris Flow section");
314
543
  fs.mkdirSync(path.dirname(sectionPath), { recursive: true });
315
544
  fs.writeFileSync(sectionPath, section.markdown, "utf8");
545
+ written.push(section.pointer.path);
316
546
  }
317
547
 
318
548
  const readmePath = resolveInsideRepository(root, `${ORIS_FLOW_MAPS_ROOT}/README.md`, "Oris Flow map README");
@@ -322,17 +552,25 @@ export function writeScanResult(scan) {
322
552
  }
323
553
  return {
324
554
  manifestPath: relativeToRepository(root, manifestPath),
325
- sectionPaths: Object.values(scan.sections).map((section) => section.pointer.path),
555
+ sectionPaths: written,
556
+ preservedSections: [...(scan.preservedSections ?? [])],
326
557
  };
327
558
  }
328
559
 
329
560
  if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
330
561
  const options = parseArgs(process.argv.slice(2));
562
+ if (options.check) {
563
+ console.log(JSON.stringify(checkFreshness(options.repositoryRoot), null, 2));
564
+ process.exit(0);
565
+ }
331
566
  const scan = scanRepository(options.repositoryRoot, options);
332
- const written = options.write ? writeScanResult(scan) : null;
567
+ const written = options.write ? writeScanResult(scan, { area: options.area }) : null;
333
568
  const payload = {
334
569
  repositoryRoot: toPosixPath(scan.repositoryRoot),
335
570
  fileCount: scan.fileCount,
571
+ truncated: scan.truncated,
572
+ projectType: scan.projectType,
573
+ tasksRoot: scan.manifest.setup.tasksRoot,
336
574
  stacks: scan.stacks,
337
575
  commands: scan.commands,
338
576
  sections: scan.manifest.sections,
@@ -342,9 +580,9 @@ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToP
342
580
  console.log(JSON.stringify(payload, null, 2));
343
581
  } else {
344
582
  console.log(`Oris Flow scan: ${payload.repositoryRoot}`);
345
- console.log(`Files scanned: ${payload.fileCount}`);
583
+ console.log(`Files scanned: ${payload.fileCount}${payload.truncated ? " (truncated)" : ""}`);
584
+ console.log(`Project type: ${payload.projectType.label}`);
346
585
  console.log(`Stacks: ${payload.stacks.map((stack) => stack.label).join(", ") || "none"}`);
347
586
  console.log(`Sections: ${Object.keys(payload.sections).join(", ") || "none"}`);
348
587
  }
349
588
  }
350
-
@@ -52,17 +52,34 @@ developer_instructions = ${instructions}
52
52
  `;
53
53
  }
54
54
 
55
- export function generateAgentAdapters(root = repoRoot) {
56
- const agents = fs.readdirSync(sourceRoot)
55
+ function readAgents() {
56
+ return fs.readdirSync(sourceRoot)
57
57
  .filter((name) => name.endsWith(".md"))
58
58
  .map((name) => ({ name, ...readAgent(path.join(sourceRoot, name)) }));
59
+ }
59
60
 
61
+ /** Write adapters into repo-local .cursor/.claude/.codex dirs under root. */
62
+ export function generateAgentAdapters(root = repoRoot) {
63
+ const agents = readAgents();
60
64
  for (const agent of agents) {
61
65
  writeFile(path.join(root, ".cursor", "agents", agent.name), renderMarkdownAgent(agent));
62
66
  writeFile(path.join(root, ".claude", "agents", agent.name), renderMarkdownAgent(agent));
63
67
  writeFile(path.join(root, ".codex", "agents", agent.name.replace(/\.md$/, ".toml")), renderCodexAgent(agent));
64
68
  }
69
+ return agents.map((agent) => agent.frontMatter.name);
70
+ }
65
71
 
72
+ /**
73
+ * Write adapters into explicit agent homes, e.g. { cursor: "~/.cursor", codex: "~/.codex" }.
74
+ * Used by the installer so the generated adapters actually reach the user's agents.
75
+ */
76
+ export function writeAgentAdapters(targets) {
77
+ const agents = readAgents();
78
+ for (const agent of agents) {
79
+ if (targets.cursor) writeFile(path.join(targets.cursor, "agents", agent.name), renderMarkdownAgent(agent));
80
+ if (targets.claude) writeFile(path.join(targets.claude, "agents", agent.name), renderMarkdownAgent(agent));
81
+ if (targets.codex) writeFile(path.join(targets.codex, "agents", agent.name.replace(/\.md$/, ".toml")), renderCodexAgent(agent));
82
+ }
66
83
  return agents.map((agent) => agent.frontMatter.name);
67
84
  }
68
85
 
@@ -8,9 +8,12 @@
8
8
  * - Cursor skills: ~/.cursor/skills/<name>/
9
9
  * - Claude Code skills: ~/.claude/skills/<name>/
10
10
  * - Codex prompts: ~/.codex/prompts/<name>.md (pointer to the bundled SKILL.md)
11
+ * - Copilot prompts: VS Code user prompts dir /<name>.prompt.md (same pointer)
12
+ * - Any other agent: ~/.oris/oris-flow.md — a universal pointer any file-reading
13
+ * agent can be told to follow.
11
14
  *
12
15
  * Agents are auto-detected from existing home directories; override with
13
- * --agents cursor,claude,codex.
16
+ * --agents cursor,claude,codex,copilot.
14
17
  */
15
18
 
16
19
  import childProcess from "node:child_process";
@@ -19,11 +22,19 @@ import os from "node:os";
19
22
  import path from "node:path";
20
23
  import process from "node:process";
21
24
  import { fileURLToPath } from "node:url";
25
+ import { writeAgentAdapters } from "./generate-agent-adapters.mjs";
22
26
 
23
27
  const markerName = ".oris-skills-managed.json";
24
28
  const installManifestName = ".oris-skills-install.json";
25
29
  const nodeDownloadUrl = "https://nodejs.org/en/download";
26
- const KNOWN_AGENTS = ["cursor", "claude", "codex"];
30
+ const KNOWN_AGENTS = ["cursor", "claude", "codex", "copilot"];
31
+
32
+ /** VS Code user dir — where Copilot Chat reads user prompt files (chat.promptFiles). */
33
+ function vscodeUserDir(home) {
34
+ if (process.platform === "win32") return path.join(home, "AppData", "Roaming", "Code", "User");
35
+ if (process.platform === "darwin") return path.join(home, "Library", "Application Support", "Code", "User");
36
+ return path.join(home, ".config", "Code", "User");
37
+ }
27
38
 
28
39
  function fail(message) {
29
40
  throw new Error(message);
@@ -42,6 +53,7 @@ function defaultPaths() {
42
53
  cursor: path.join(home, ".cursor"),
43
54
  claude: path.join(home, ".claude"),
44
55
  codex: path.join(home, ".codex"),
56
+ copilot: vscodeUserDir(home),
45
57
  },
46
58
  };
47
59
  }
@@ -74,6 +86,7 @@ function parseArgs(argv) {
74
86
  else if (key === "--cursor-root" || key.startsWith("--cursor-root=")) options.agentRoots.cursor = value();
75
87
  else if (key === "--claude-root" || key.startsWith("--claude-root=")) options.agentRoots.claude = value();
76
88
  else if (key === "--codex-root" || key.startsWith("--codex-root=")) options.agentRoots.codex = value();
89
+ else if (key === "--copilot-root" || key.startsWith("--copilot-root=")) options.agentRoots.copilot = value();
77
90
  else fail(`Unknown argument: ${raw}`);
78
91
  }
79
92
  for (const agent of options.agents) {
@@ -154,6 +167,7 @@ function defaultSettings() {
154
167
  orisFlow: {
155
168
  uiLanguage: "en",
156
169
  artifactLanguage: "en",
170
+ responseStyle: "standard",
157
171
  },
158
172
  defaultProfiles: {
159
173
  testing: "default",
@@ -204,6 +218,39 @@ User request: $ARGUMENTS
204
218
  }
205
219
  }
206
220
 
221
+ /** Copilot Chat user prompt files: /<name> in chat once chat.promptFiles is enabled. */
222
+ function installCopilotPrompts(skills, vscodeUserRoot, bundleRoot) {
223
+ const promptsRoot = path.join(vscodeUserRoot, "prompts");
224
+ fs.mkdirSync(promptsRoot, { recursive: true });
225
+ for (const skill of skills) {
226
+ const bundledSkill = path.join(bundleRoot, skill.relativePath, "SKILL.md").replace(/\\/g, "/");
227
+ const description = skillDescription(skill.sourceDir);
228
+ fs.writeFileSync(path.join(promptsRoot, `${skill.name}.prompt.md`), `---
229
+ description: ${description || "Oris entry point"}
230
+ ---
231
+ <!-- ${markerName} -->
232
+ Read the Oris skill file at \`${bundledSkill}\` and follow it exactly.
233
+ Then handle the user's request in this conversation.
234
+ `, "utf8");
235
+ }
236
+ }
237
+
238
+ /** Universal pointer for any file-reading agent that has no install target of its own. */
239
+ function installUniversalPointer(skills, bundleRoot) {
240
+ const primary = skills[0];
241
+ const bundledSkill = path.join(bundleRoot, primary.relativePath, "SKILL.md").replace(/\\/g, "/");
242
+ fs.writeFileSync(path.join(path.dirname(bundleRoot), "oris-flow.md"), `<!-- ${markerName} -->
243
+ # Oris — universal entry point
244
+
245
+ Tell any agent that can read files:
246
+
247
+ > Read \`${bundledSkill}\` and follow it exactly.
248
+
249
+ That file routes every Oris flow (setup, discover, criteria, plan, implement, fix,
250
+ verify, change, loop, architecture, docs, help) in the same chat.
251
+ `, "utf8");
252
+ }
253
+
207
254
  function main() {
208
255
  requireNode();
209
256
  const options = parseArgs(process.argv.slice(2));
@@ -224,8 +271,11 @@ function main() {
224
271
  log(`Would write bundle: ${options.bundleRoot}`);
225
272
  for (const agent of agents) {
226
273
  if (agent === "codex") log(`Would write Codex prompts: ${path.join(options.agentRoots.codex, "prompts")}`);
274
+ else if (agent === "copilot") log(`Would write Copilot prompts: ${path.join(options.agentRoots.copilot, "prompts")}`);
227
275
  else log(`Would write ${agent} skills: ${path.join(options.agentRoots[agent], "skills")}`);
276
+ if (agent !== "copilot") log(`Would write ${agent} loop agents: ${path.join(options.agentRoots[agent], "agents")}`);
228
277
  }
278
+ log(`Would write universal pointer: ${path.join(path.dirname(options.bundleRoot), "oris-flow.md")}`);
229
279
  log(`Would create settings if missing: ${options.settingsPath}`);
230
280
  return;
231
281
  }
@@ -245,11 +295,19 @@ function main() {
245
295
  if (agent === "codex") {
246
296
  installCodexPrompts(skills, options.agentRoots.codex, options.bundleRoot);
247
297
  log(`Installed ${skills.length} Codex prompts`);
298
+ } else if (agent === "copilot") {
299
+ installCopilotPrompts(skills, options.agentRoots.copilot, options.bundleRoot);
300
+ log(`Installed ${skills.length} Copilot prompts (enable VS Code setting chat.promptFiles)`);
248
301
  } else {
249
302
  installSkillDirs(skills, path.join(options.agentRoots[agent], "skills"), manifest, options.bundleRoot, options);
250
303
  log(`Installed ${skills.length} ${agent} skills`);
251
304
  }
252
305
  }
306
+ installUniversalPointer(skills, options.bundleRoot);
307
+
308
+ const adapterTargets = Object.fromEntries(agents.filter((agent) => agent !== "copilot").map((agent) => [agent, options.agentRoots[agent]]));
309
+ const loopAgents = writeAgentAdapters(adapterTargets);
310
+ log(`Installed ${loopAgents.length} loop agent adapters (${agents.join(", ")})`);
253
311
 
254
312
  if (!fs.existsSync(options.settingsPath)) writeJson(options.settingsPath, defaultSettings());
255
313
  writeJson(path.join(options.bundleRoot, installManifestName), {
@@ -260,7 +318,7 @@ function main() {
260
318
  installedAt: new Date().toISOString(),
261
319
  });
262
320
  log(`Done for agents: ${agents.join(", ")}. Reload your agent environment.`);
263
- log("Next: type /oris-flow in your agent chat, or try the loop demo: oris-skills loop demo");
321
+ log("Next: type /oris-flow in your agent chat, or try the loop demo: npx oris-skills loop demo");
264
322
  }
265
323
 
266
324
  try {