@kuznai/inception-engine 0.22.0 → 0.24.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 (43) hide show
  1. package/README.md +50 -9
  2. package/dist/src/config/agents.js +39 -0
  3. package/dist/src/core/adapters/execution-config.d.ts +14 -0
  4. package/dist/src/core/adapters/execution-config.js +60 -0
  5. package/dist/src/core/adapters/index.d.ts +4 -3
  6. package/dist/src/core/adapters/index.js +7 -5
  7. package/dist/src/core/adapters/rules.js +50 -19
  8. package/dist/src/core/capabilities.d.ts +1 -1
  9. package/dist/src/core/capabilities.js +18 -2
  10. package/dist/src/core/deploy.js +66 -2
  11. package/dist/src/core/init.js +69 -13
  12. package/dist/src/core/ownership.d.ts +1 -0
  13. package/dist/src/core/ownership.js +34 -4
  14. package/dist/src/core/preflight.js +28 -0
  15. package/dist/src/core/revert.js +86 -18
  16. package/dist/src/core/validation.d.ts +1 -1
  17. package/dist/src/core/validation.js +56 -3
  18. package/dist/src/schemas/manifest.d.ts +31 -0
  19. package/dist/src/schemas/manifest.js +39 -3
  20. package/dist/src/types.d.ts +6 -2
  21. package/dist/test/helpers/path.d.ts +2 -2
  22. package/dist/test/helpers/path.js +3 -3
  23. package/dist/test/os/posix/deploy.test.js +395 -0
  24. package/dist/test/os/posix/revert.test.js +188 -0
  25. package/dist/test/os/windows/deploy.test.d.ts +1 -0
  26. package/dist/test/os/windows/deploy.test.js +238 -0
  27. package/dist/test/unit/adapters.test.js +402 -2
  28. package/dist/test/unit/capabilities.test.js +44 -1
  29. package/dist/test/unit/cli.test.js +162 -0
  30. package/dist/test/unit/deploy.test.js +88 -647
  31. package/dist/test/unit/formatters.test.d.ts +1 -0
  32. package/dist/test/unit/formatters.test.js +74 -0
  33. package/dist/test/unit/init-fixture.test.js +85 -1
  34. package/dist/test/unit/manifest.test.js +72 -0
  35. package/dist/test/unit/ownership.test.js +61 -4
  36. package/dist/test/unit/preflight.test.js +145 -0
  37. package/dist/test/unit/resolve.test.js +67 -1
  38. package/dist/test/unit/revert.test.js +81 -182
  39. package/package.json +1 -1
  40. package/dist/test/os/windows/agentRules-integration.test.js +0 -245
  41. package/dist/test/os/windows/revert-integration.test.js +0 -72
  42. /package/dist/test/os/{windows/agentRules-integration.test.d.ts → posix/deploy.test.d.ts} +0 -0
  43. /package/dist/test/os/{windows/revert-integration.test.d.ts → posix/revert.test.d.ts} +0 -0
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ import assert from "node:assert/strict";
2
+ import { stripVTControlCharacters } from "node:util";
3
+ import { describe, it } from "node:test";
4
+ import { formatDryRunPlan } from "../../src/formatters.js";
5
+ function stripAnsi(value) {
6
+ return stripVTControlCharacters(value);
7
+ }
8
+ describe("formatDryRunPlan", () => {
9
+ it("returns an empty string when there are no planned changes", () => {
10
+ assert.equal(formatDryRunPlan([]), "");
11
+ });
12
+ it("groups changes by agent, sorts groups, and renders supported detail fields", () => {
13
+ const plan = [
14
+ {
15
+ agent: "opencode",
16
+ kind: "config-patch",
17
+ skill: "beta",
18
+ target: "/targets/opencode.json",
19
+ verb: "patch-config",
20
+ patch: { enabled: true },
21
+ },
22
+ {
23
+ agent: "claude-code",
24
+ kind: "file-write",
25
+ skill: "alpha",
26
+ source: "/source/alpha.md",
27
+ target: "/targets/alpha.md",
28
+ verb: "write-file",
29
+ },
30
+ {
31
+ agent: "claude-code",
32
+ kind: "config-patch",
33
+ skill: "gamma",
34
+ target: "/targets/gamma.json",
35
+ verb: "unapply-patch",
36
+ patch: { enabled: null },
37
+ },
38
+ {
39
+ agent: "claude-code",
40
+ kind: "toml-patch",
41
+ skill: "delta",
42
+ target: "/targets/delta.toml",
43
+ verb: "patch-toml",
44
+ patch: { approval_policy: "suggest" },
45
+ },
46
+ {
47
+ agent: "claude-code",
48
+ kind: "frontmatter-emit",
49
+ skill: "epsilon",
50
+ target: "/targets/epsilon.md",
51
+ verb: "emit-frontmatter",
52
+ frontmatter: { tools: ["github"] },
53
+ },
54
+ {
55
+ agent: "claude-code",
56
+ kind: "file-write",
57
+ skill: "zeta",
58
+ target: "/targets/zeta.md",
59
+ verb: "remove",
60
+ },
61
+ ];
62
+ const output = stripAnsi(formatDryRunPlan(plan));
63
+ assert.match(output, /^claude-code\n(?:[\s\S]*?)\nopencode\n/m, `expected claude-code group before opencode, got:\n${output}`);
64
+ assert.match(output, /write-file alpha/);
65
+ assert.match(output, /source: \/source\/alpha\.md/);
66
+ assert.match(output, /target: \/targets\/alpha\.md/);
67
+ assert.match(output, /undo:\s+\{"enabled":null\}/);
68
+ assert.match(output, /patch:\s+\{"approval_policy":"suggest"\}/);
69
+ assert.match(output, /frontmatter: \{"tools":\["github"\]\}/);
70
+ assert.match(output, /remove zeta/);
71
+ assert.doesNotMatch(output, /source: \/targets\/zeta\.md/);
72
+ assert.match(output, /patch-config beta/);
73
+ });
74
+ });
@@ -1,10 +1,13 @@
1
1
  import assert from "node:assert/strict";
2
- import { cp, readFile, rm } from "node:fs/promises";
2
+ import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { spawn } from "node:child_process";
4
4
  import path from "node:path";
5
5
  import { describe, it } from "node:test";
6
+ import { runInit } from "../../src/core/init.js";
7
+ import { logger } from "../../src/logger.js";
6
8
  import { makeTmpDir } from "../helpers/fs.js";
7
9
  import { assertPathEndsWith, normalizeSlashes } from "../helpers/path.js";
10
+ logger.silence();
8
11
  const PROJECT_ROOT = path.resolve(import.meta.dirname, "..", "..");
9
12
  const FIXTURE_DIR = path.join(PROJECT_ROOT, "test", "fixtures", "readme-sample");
10
13
  function run(args, env) {
@@ -151,3 +154,84 @@ describe("init against readme-sample fixture", () => {
151
154
  }
152
155
  });
153
156
  });
157
+ // ---------------------------------------------------------------------------
158
+ // Block 3: init discovery of GitHub Copilot native instruction surfaces
159
+ // ---------------------------------------------------------------------------
160
+ describe("init Copilot native instruction discovery", () => {
161
+ it("runInit dryRun succeeds when .github/copilot-instructions.md is present", async () => {
162
+ const dir = await makeTmpDir();
163
+ try {
164
+ await mkdir(path.join(dir, ".github"), { recursive: true });
165
+ await writeFile(path.join(dir, ".github", "copilot-instructions.md"), "# Copilot instructions");
166
+ // Use dryRun to avoid writing inception.json
167
+ const result = await runInit({
168
+ directory: dir,
169
+ agents: null,
170
+ dryRun: true,
171
+ force: false,
172
+ verbose: false,
173
+ });
174
+ assert.equal(result, 0);
175
+ }
176
+ finally {
177
+ await rm(dir, { recursive: true });
178
+ }
179
+ });
180
+ it("init --plan output includes copilot-repo scope for .github/copilot-instructions.md", async () => {
181
+ const dir = await makeTmpDir();
182
+ try {
183
+ await mkdir(path.join(dir, ".github"), { recursive: true });
184
+ await writeFile(path.join(dir, ".github", "copilot-instructions.md"), "# Copilot instructions");
185
+ const { stdout, code } = await run(["init", dir, "--plan"]);
186
+ assert.equal(code, 0, `init --plan failed:\n${stdout}`);
187
+ const manifest = extractPlanJson(stdout);
188
+ const copilotEntry = manifest.agentRules.find((r) => r.scope === "copilot-repo");
189
+ assert.ok(copilotEntry, `expected a copilot-repo entry in agentRules, got: ${JSON.stringify(manifest.agentRules)}`);
190
+ assert.deepEqual(copilotEntry.agents, ["github-copilot"]);
191
+ assertPathEndsWith(copilotEntry.path, ".github/copilot-instructions.md", `copilot-repo entry path should end with .github/copilot-instructions.md`);
192
+ }
193
+ finally {
194
+ await rm(dir, { recursive: true });
195
+ }
196
+ });
197
+ it("init --plan output includes copilot-scoped scope for .github/instructions/*.instructions.md", async () => {
198
+ const dir = await makeTmpDir();
199
+ try {
200
+ await mkdir(path.join(dir, ".github", "instructions"), {
201
+ recursive: true,
202
+ });
203
+ await writeFile(path.join(dir, ".github", "instructions", "typescript.instructions.md"), "# TypeScript scoped instructions");
204
+ await writeFile(path.join(dir, ".github", "instructions", "python.instructions.md"), "# Python scoped instructions");
205
+ const { stdout, code } = await run(["init", dir, "--plan"]);
206
+ assert.equal(code, 0, `init --plan failed:\n${stdout}`);
207
+ const manifest = extractPlanJson(stdout);
208
+ const scopedEntries = manifest.agentRules.filter((r) => r.scope === "copilot-scoped");
209
+ assert.equal(scopedEntries.length, 2, `expected 2 copilot-scoped entries, got ${scopedEntries.length}: ${JSON.stringify(manifest.agentRules)}`);
210
+ for (const entry of scopedEntries) {
211
+ assert.deepEqual(entry.agents, ["github-copilot"]);
212
+ }
213
+ const names = scopedEntries.map((e) => e.name).sort();
214
+ assert.deepEqual(names, ["python", "typescript"]);
215
+ }
216
+ finally {
217
+ await rm(dir, { recursive: true });
218
+ }
219
+ });
220
+ it("non-.github/ copilot-instructions.md still maps to claude-code (backward compat)", async () => {
221
+ const dir = await makeTmpDir();
222
+ try {
223
+ await mkdir(path.join(dir, "rules"), { recursive: true });
224
+ await writeFile(path.join(dir, "rules", "copilot-instructions.md"), "# Copilot instructions");
225
+ const { stdout, code } = await run(["init", dir, "--plan"]);
226
+ assert.equal(code, 0, `init --plan failed:\n${stdout}`);
227
+ const manifest = extractPlanJson(stdout);
228
+ const entry = manifest.agentRules.find((r) => r.path.endsWith("copilot-instructions.md"));
229
+ assert.ok(entry, `expected a copilot-instructions.md entry, got: ${JSON.stringify(manifest.agentRules)}`);
230
+ assert.ok(entry.agents.includes("claude-code"), `expected agents to include claude-code, got: ${JSON.stringify(entry.agents)}`);
231
+ assert.notEqual(entry.scope, "copilot-repo", "rules/ copilot-instructions.md should not use copilot-repo scope");
232
+ }
233
+ finally {
234
+ await rm(dir, { recursive: true });
235
+ }
236
+ });
237
+ });
@@ -554,6 +554,78 @@ describe("loadManifest", () => {
554
554
  await rm(dir, { recursive: true });
555
555
  }
556
556
  });
557
+ it("agentRules accepts targetDir with scope: 'repo'", async () => {
558
+ const dir = await makeTmpDir();
559
+ try {
560
+ await writeFile(path.join(dir, "inception.json"), JSON.stringify({
561
+ skills: [],
562
+ agentRules: [
563
+ {
564
+ name: "my-rule",
565
+ agents: ["claude-code"],
566
+ path: "rules/CLAUDE.md",
567
+ scope: "repo",
568
+ targetDir: "apps/frontend",
569
+ },
570
+ ],
571
+ }));
572
+ const manifest = await loadManifest(dir);
573
+ assert.equal(manifest.agentRules[0]?.targetDir, "apps/frontend");
574
+ }
575
+ finally {
576
+ await rm(dir, { recursive: true });
577
+ }
578
+ });
579
+ it("agentRules rejects targetDir with scope: 'global'", async () => {
580
+ const dir = await makeTmpDir();
581
+ try {
582
+ await writeFile(path.join(dir, "inception.json"), JSON.stringify({
583
+ skills: [],
584
+ agentRules: [
585
+ {
586
+ name: "my-rule",
587
+ agents: ["claude-code"],
588
+ path: "rules/CLAUDE.md",
589
+ scope: "global",
590
+ targetDir: "apps/frontend",
591
+ },
592
+ ],
593
+ }));
594
+ await assert.rejects(loadManifest(dir), (err) => {
595
+ assert.ok(err instanceof UserError);
596
+ assert.match(err.message, /targetDir is only supported for scope "repo" or "workspace"/);
597
+ return true;
598
+ });
599
+ }
600
+ finally {
601
+ await rm(dir, { recursive: true });
602
+ }
603
+ });
604
+ it("throws when agentRules targetDir is absolute", async () => {
605
+ const dir = await makeTmpDir();
606
+ try {
607
+ await writeFile(path.join(dir, "inception.json"), JSON.stringify({
608
+ skills: [],
609
+ agentRules: [
610
+ {
611
+ name: "my-rule",
612
+ agents: ["claude-code"],
613
+ path: "rules/CLAUDE.md",
614
+ scope: "repo",
615
+ targetDir: "/absolute/path",
616
+ },
617
+ ],
618
+ }));
619
+ await assert.rejects(loadManifest(dir), (err) => {
620
+ assert.ok(err instanceof UserError);
621
+ assert.match(err.message, /targetDir must be a relative path/);
622
+ return true;
623
+ });
624
+ }
625
+ finally {
626
+ await rm(dir, { recursive: true });
627
+ }
628
+ });
557
629
  it("defaults mcpServers and agentRules to [] when omitted", async () => {
558
630
  const dir = await makeTmpDir();
559
631
  try {
@@ -1,8 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { describe, it } from "node:test";
5
- import { defaultRegistryPersistence, lookupDeployment, registerDeployment, registryPath, unregisterDeployment, verifyDeployment, } from "../../src/core/ownership.js";
5
+ import { defaultRegistryPersistence, lookupDeployment, registerDeployment, registryDirPath, registryPath, unregisterDeployment, verifyDeployment, } from "../../src/core/ownership.js";
6
6
  import { exists, makeTmpDir } from "../helpers/fs.js";
7
7
  describe("registerDeployment", () => {
8
8
  it("creates registry file and adds entry", async () => {
@@ -56,7 +56,7 @@ describe("registerDeployment", () => {
56
56
  await rm(home, { recursive: true });
57
57
  }
58
58
  });
59
- it("sets registry file permissions to 0o644 on POSIX", {
59
+ it("sets registry file permissions to 0o600 on POSIX", {
60
60
  skip: process.platform === "win32",
61
61
  }, async () => {
62
62
  const home = await makeTmpDir();
@@ -70,7 +70,27 @@ describe("registerDeployment", () => {
70
70
  });
71
71
  const { stat } = await import("node:fs/promises");
72
72
  const statResult = await stat(registryPath(home));
73
- assert.equal(statResult.mode & 0o777, 0o644);
73
+ assert.equal(statResult.mode & 0o777, 0o600);
74
+ }
75
+ finally {
76
+ await rm(home, { recursive: true });
77
+ }
78
+ });
79
+ it("sets registry directory permissions to 0o700 on POSIX", {
80
+ skip: process.platform === "win32",
81
+ }, async () => {
82
+ const home = await makeTmpDir();
83
+ try {
84
+ await registerDeployment(home, "/fake/target", {
85
+ kind: "skill-dir",
86
+ source: "/fake/source",
87
+ skill: "my-skill",
88
+ agent: "claude-code",
89
+ method: "symlink",
90
+ });
91
+ const { stat } = await import("node:fs/promises");
92
+ const statResult = await stat(registryDirPath(home));
93
+ assert.equal(statResult.mode & 0o777, 0o700);
74
94
  }
75
95
  finally {
76
96
  await rm(home, { recursive: true });
@@ -121,6 +141,26 @@ describe("registerDeployment", () => {
121
141
  await rm(home, { recursive: true, force: true });
122
142
  }
123
143
  });
144
+ it("refuses to write the registry through a symlinked state directory", {
145
+ skip: process.platform === "win32",
146
+ }, async () => {
147
+ const home = await makeTmpDir();
148
+ const elsewhere = await makeTmpDir();
149
+ try {
150
+ await symlink(elsewhere, registryDirPath(home), "dir");
151
+ await assert.rejects(registerDeployment(home, "/fake/target", {
152
+ kind: "skill-dir",
153
+ source: "/fake/source",
154
+ skill: "my-skill",
155
+ agent: "claude-code",
156
+ method: "copy",
157
+ }), /Refusing to use registry directory symlink/);
158
+ }
159
+ finally {
160
+ await rm(home, { recursive: true, force: true });
161
+ await rm(elsewhere, { recursive: true, force: true });
162
+ }
163
+ });
124
164
  });
125
165
  describe("unregisterDeployment", () => {
126
166
  it("removes the entry for the given target", async () => {
@@ -219,6 +259,23 @@ describe("lookupDeployment", () => {
219
259
  await rm(home, { recursive: true });
220
260
  }
221
261
  });
262
+ it("returns null when the registry file path is a symlink", {
263
+ skip: process.platform === "win32",
264
+ }, async () => {
265
+ const home = await makeTmpDir();
266
+ const elsewhere = await makeTmpDir();
267
+ try {
268
+ await mkdir(registryDirPath(home), { recursive: true });
269
+ await writeFile(path.join(elsewhere, "registry.json"), "{}");
270
+ await symlink(path.join(elsewhere, "registry.json"), registryPath(home), "file");
271
+ const entry = await lookupDeployment(home, "/anything");
272
+ assert.equal(entry, null);
273
+ }
274
+ finally {
275
+ await rm(home, { recursive: true, force: true });
276
+ await rm(elsewhere, { recursive: true, force: true });
277
+ }
278
+ });
222
279
  });
223
280
  describe("verifyDeployment", () => {
224
281
  it("returns entry when all fields match", async () => {
@@ -124,6 +124,65 @@ describe("runPreflight", () => {
124
124
  const implementationOnlyWarning = warnings.find((w) => w.kind === "config-authority" && /implementation-only/.test(w.message));
125
125
  assert.equal(implementationOnlyWarning, undefined, `expected no implementation-only warning, got: ${implementationOnlyWarning?.message}`);
126
126
  });
127
+ it("emits shared-surface config-authority guidance when github-copilot agentRules ride through claude-code", async () => {
128
+ const manifest = {
129
+ ...emptyManifest,
130
+ agentRules: [
131
+ {
132
+ name: "shared-rules",
133
+ path: "CLAUDE.md",
134
+ agents: ["claude-code", "github-copilot"],
135
+ scope: "repo",
136
+ },
137
+ ],
138
+ };
139
+ const warnings = await runPreflight(baseOptions, manifest, "/home/test", [
140
+ "claude-code",
141
+ "github-copilot",
142
+ ]);
143
+ const warning = warnings.find((w) => w.kind === "config-authority" &&
144
+ w.message.includes('shared through "claude-code"'));
145
+ assert.ok(warning, `expected shared-surface warning, got: ${JSON.stringify(warnings)}`);
146
+ assert.match(warning.message, /requires the primary target to deploy/);
147
+ });
148
+ it("emits shared-surface config-authority guidance for antigravity repo rules", async () => {
149
+ const manifest = {
150
+ ...emptyManifest,
151
+ agentRules: [
152
+ {
153
+ name: "gemini-rules",
154
+ path: "GEMINI.md",
155
+ agents: ["antigravity"],
156
+ scope: "repo",
157
+ },
158
+ ],
159
+ };
160
+ const warnings = await runPreflight(baseOptions, manifest, "/home/test", [
161
+ "antigravity",
162
+ ]);
163
+ const warning = warnings.find((w) => w.kind === "config-authority" &&
164
+ w.message.includes('shared through "gemini-cli"'));
165
+ assert.ok(warning, `expected antigravity shared-via warning, got: ${JSON.stringify(warnings)}`);
166
+ assert.doesNotMatch(warning.message, /requires the primary target to deploy/);
167
+ });
168
+ it("emits provisional config-authority warning for supported gemini executionConfigs", async () => {
169
+ const manifest = {
170
+ ...emptyManifest,
171
+ executionConfigs: [
172
+ {
173
+ name: "safe-mode",
174
+ agents: ["gemini-cli"],
175
+ config: { sandbox: "workspace-write" },
176
+ },
177
+ ],
178
+ };
179
+ const warnings = await runPreflight(baseOptions, manifest, "/home/test", [
180
+ "gemini-cli",
181
+ ]);
182
+ const capabilityWarning = warnings.find((w) => w.kind === "config-authority" && /execution-config/.test(w.message));
183
+ assert.ok(capabilityWarning, `expected executionConfig warning, got: ${JSON.stringify(warnings)}`);
184
+ assert.match(capabilityWarning.message, /provisional/);
185
+ });
127
186
  });
128
187
  describe("github-copilot devcontainer support", () => {
129
188
  it("emits no capability warning for devcontainer MCP when Copilot is detected and devcontainer scope is targeted", async () => {
@@ -279,6 +338,92 @@ describe("instruction precedence warnings", () => {
279
338
  await rm(sourceDir, { recursive: true });
280
339
  }
281
340
  });
341
+ it("emits Copilot precedence warning when github-copilot has both shared-via (scope: repo) and native (scope: copilot-repo) entries", async () => {
342
+ const sourceDir = await makeTmpDir();
343
+ try {
344
+ await writeFile(path.join(sourceDir, "shared.md"), "# Shared rules");
345
+ await writeFile(path.join(sourceDir, "native.md"), "# Native rules");
346
+ const manifest = {
347
+ ...emptyManifest,
348
+ agentRules: [
349
+ {
350
+ name: "shared-rules",
351
+ path: "shared.md",
352
+ agents: ["claude-code", "github-copilot"],
353
+ scope: "repo",
354
+ },
355
+ {
356
+ name: "native-rules",
357
+ path: "native.md",
358
+ agents: ["github-copilot"],
359
+ scope: "copilot-repo",
360
+ },
361
+ ],
362
+ };
363
+ const warnings = await runPreflight({ ...baseOptions, directory: sourceDir }, manifest, "/home/test", ["github-copilot"]);
364
+ const precedenceWarnings = warnings.filter((w) => w.kind === "precedence");
365
+ assert.ok(precedenceWarnings.length > 0, "expected a precedence warning");
366
+ assert.ok(precedenceWarnings.some((w) => w.message.includes("github-copilot") &&
367
+ w.message.includes("CLAUDE.md-shared") &&
368
+ w.message.includes("native Copilot")), `expected Copilot precedence warning, got: ${JSON.stringify(precedenceWarnings)}`);
369
+ }
370
+ finally {
371
+ await rm(sourceDir, { recursive: true });
372
+ }
373
+ });
374
+ it("emits Copilot precedence warning when github-copilot has both global (scope: global) and copilot-scoped entries", async () => {
375
+ const sourceDir = await makeTmpDir();
376
+ try {
377
+ await writeFile(path.join(sourceDir, "shared.md"), "# Shared rules");
378
+ await writeFile(path.join(sourceDir, "scoped.md"), "# Scoped rules");
379
+ const manifest = {
380
+ ...emptyManifest,
381
+ agentRules: [
382
+ {
383
+ name: "shared-rules",
384
+ path: "shared.md",
385
+ agents: ["claude-code", "github-copilot"],
386
+ scope: "global",
387
+ },
388
+ {
389
+ name: "typescript",
390
+ path: "scoped.md",
391
+ agents: ["github-copilot"],
392
+ scope: "copilot-scoped",
393
+ },
394
+ ],
395
+ };
396
+ const warnings = await runPreflight({ ...baseOptions, directory: sourceDir }, manifest, "/home/test", ["github-copilot"]);
397
+ const precedenceWarnings = warnings.filter((w) => w.kind === "precedence");
398
+ assert.ok(precedenceWarnings.some((w) => w.message.includes("github-copilot") &&
399
+ w.message.includes("native Copilot")), `expected Copilot precedence warning, got: ${JSON.stringify(precedenceWarnings)}`);
400
+ }
401
+ finally {
402
+ await rm(sourceDir, { recursive: true });
403
+ }
404
+ });
405
+ it("emits no Copilot precedence warning when github-copilot only has native entries", async () => {
406
+ const sourceDir = await makeTmpDir();
407
+ try {
408
+ await writeFile(path.join(sourceDir, "native.md"), "# Native rules");
409
+ const manifest = {
410
+ ...emptyManifest,
411
+ agentRules: [
412
+ {
413
+ name: "native-rules",
414
+ path: "native.md",
415
+ agents: ["github-copilot"],
416
+ scope: "copilot-repo",
417
+ },
418
+ ],
419
+ };
420
+ const warnings = await runPreflight({ ...baseOptions, directory: sourceDir }, manifest, "/home/test", ["github-copilot"]);
421
+ assert.equal(warnings.filter((w) => w.kind === "precedence").length, 0);
422
+ }
423
+ finally {
424
+ await rm(sourceDir, { recursive: true });
425
+ }
426
+ });
282
427
  });
283
428
  describe("instruction budget warnings", () => {
284
429
  it("emits budget warning for agentRules source file exceeding 50 KB", async () => {
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { describe, it } from "node:test";
5
5
  import { AGENT_REGISTRY } from "../../src/config/agents.js";
6
6
  import { resolveAgentDetectPath, resolveAgentSkillPath, resolveHome, } from "../../src/core/resolve.js";
7
- import { resolveTargetTemplate } from "../../src/core/runtime-paths.js";
7
+ import { resolveRuntimePaths, resolveTargetTemplate, } from "../../src/core/runtime-paths.js";
8
8
  import { UserError } from "../../src/errors.js";
9
9
  const posixJoin = path.posix.join;
10
10
  function getAgent(id) {
@@ -113,7 +113,73 @@ describe("resolveTargetTemplate", () => {
113
113
  const result = resolveTargetTemplate("{home}/.claude/settings.json", home);
114
114
  assert.equal(result, `${home}/.claude/settings.json`);
115
115
  });
116
+ it("resolves appdata, local_appdata, xdg_config, repo, and workspace roots", () => {
117
+ const savedAppData = process.env.APPDATA;
118
+ const savedLocalAppData = process.env.LOCALAPPDATA;
119
+ const savedXdg = process.env.XDG_CONFIG_HOME;
120
+ try {
121
+ process.env.APPDATA = "/env/appdata";
122
+ process.env.LOCALAPPDATA = "/env/local";
123
+ process.env.XDG_CONFIG_HOME = "/env/xdg";
124
+ assert.equal(resolveTargetTemplate("{appdata}/opencode/opencode.json", "/home/user"), "/env/appdata/opencode/opencode.json");
125
+ assert.equal(resolveTargetTemplate("{local_appdata}/Temp/config.json", "/home/user"), "/env/local/Temp/config.json");
126
+ assert.equal(resolveTargetTemplate("{xdg_config}/opencode/config.json", "/home/user"), "/env/xdg/opencode/config.json");
127
+ assert.equal(resolveTargetTemplate("{repo}/.claude/mcp.json", "/home/user", "/repo"), "/repo/.claude/mcp.json");
128
+ assert.equal(resolveTargetTemplate("{workspace}/CLAUDE.md", "/home/user", "/repo"), "/repo/CLAUDE.md");
129
+ assert.equal(resolveTargetTemplate("{workspace}/CLAUDE.md", "/home/user", "/repo", "/workspace"), "/workspace/CLAUDE.md");
130
+ assert.equal(resolveTargetTemplate("{home}", "/home/user"), "/home/user");
131
+ }
132
+ finally {
133
+ if (savedAppData === undefined)
134
+ delete process.env.APPDATA;
135
+ else
136
+ process.env.APPDATA = savedAppData;
137
+ if (savedLocalAppData === undefined)
138
+ delete process.env.LOCALAPPDATA;
139
+ else
140
+ process.env.LOCALAPPDATA = savedLocalAppData;
141
+ if (savedXdg === undefined)
142
+ delete process.env.XDG_CONFIG_HOME;
143
+ else
144
+ process.env.XDG_CONFIG_HOME = savedXdg;
145
+ }
146
+ });
147
+ it("throws for invalid templates and missing repo or workspace roots", () => {
148
+ assert.throws(() => resolveTargetTemplate("relative/path.txt", "/home/user"), /Invalid target template/);
149
+ assert.throws(() => resolveTargetTemplate("{repo}/x", "/home/user"), /no repo directory was provided/);
150
+ assert.throws(() => resolveTargetTemplate("{workspace}/x", "/home/user"), /no workspace directory was provided/);
151
+ });
116
152
  it("throws when the template escapes the placeholder root", () => {
117
153
  assert.throws(() => resolveTargetTemplate("{home}/../.ssh/config", "/home/user"), /outside its placeholder root/);
118
154
  });
119
155
  });
156
+ describe("resolveRuntimePaths", () => {
157
+ it("uses absolute env vars and ignores relative overrides", () => {
158
+ const savedAppData = process.env.APPDATA;
159
+ const savedLocalAppData = process.env.LOCALAPPDATA;
160
+ const savedXdg = process.env.XDG_CONFIG_HOME;
161
+ try {
162
+ process.env.APPDATA = "/custom/appdata";
163
+ process.env.LOCALAPPDATA = "relative/local";
164
+ process.env.XDG_CONFIG_HOME = "relative/xdg";
165
+ const paths = resolveRuntimePaths("/home/user");
166
+ assert.equal(paths.appdata, "/custom/appdata");
167
+ assert.equal(paths.localAppdata, "/home/user/AppData/Local");
168
+ assert.equal(paths.xdgConfig, "/home/user/.config");
169
+ }
170
+ finally {
171
+ if (savedAppData === undefined)
172
+ delete process.env.APPDATA;
173
+ else
174
+ process.env.APPDATA = savedAppData;
175
+ if (savedLocalAppData === undefined)
176
+ delete process.env.LOCALAPPDATA;
177
+ else
178
+ process.env.LOCALAPPDATA = savedLocalAppData;
179
+ if (savedXdg === undefined)
180
+ delete process.env.XDG_CONFIG_HOME;
181
+ else
182
+ process.env.XDG_CONFIG_HOME = savedXdg;
183
+ }
184
+ });
185
+ });