@kuznai/inception-engine 0.25.0 → 1.0.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.
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { spawn } from "node:child_process";
3
- import { mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { describe, it } from "node:test";
6
6
  import { makeTmpDir } from "../helpers/fs.js";
@@ -993,4 +993,60 @@ describe("init command", () => {
993
993
  await rm(dir, { recursive: true });
994
994
  }
995
995
  });
996
+ it("keeps conventional markdown discovery focused outside discovered skill roots", async () => {
997
+ const dir = await makeTmpDir();
998
+ try {
999
+ await mkdir(path.join(dir, "skills", "alpha"), { recursive: true });
1000
+ await writeFile(path.join(dir, "skills", "alpha", "SKILL.md"), "---\nname: alpha\ndescription: test\n---\n");
1001
+ await writeFile(path.join(dir, "skills", "alpha", "notes.md"), "# direct child in skill\n");
1002
+ await mkdir(path.join(dir, "rules"), { recursive: true });
1003
+ await writeFile(path.join(dir, "rules", "shared.md"), "# shared markdown rule\n");
1004
+ await mkdir(path.join(dir, ".claude", "agents"), { recursive: true });
1005
+ await writeFile(path.join(dir, ".claude", "agents", "reviewer.md"), "---\nname: reviewer\ndescription: test\n---\n# Agent\n");
1006
+ const { code } = await run(["init", dir]);
1007
+ assert.equal(code, 0);
1008
+ const manifest = JSON.parse(await readFile(path.join(dir, "inception.json"), "utf-8"));
1009
+ const discoveredRulePaths = manifest.agentRules.map((entry) => normalizeSlashes(entry.path));
1010
+ assert.ok(!discoveredRulePaths.includes("skills/alpha/notes.md"), "direct markdown child of a skill should be excluded from agentRules");
1011
+ assert.ok(discoveredRulePaths.includes("rules/shared.md"), "supported markdown outside skills should still be scanned");
1012
+ const discoveredDefinitionPaths = manifest.agentDefinitions.map((entry) => normalizeSlashes(entry.path));
1013
+ assert.ok(discoveredDefinitionPaths.includes(".claude/agents/reviewer.md"), "agent definitions outside skills should still be discovered");
1014
+ }
1015
+ finally {
1016
+ await rm(dir, { recursive: true });
1017
+ }
1018
+ });
1019
+ it("keeps skill discovery deterministic and does not descend below discovered skill roots", async () => {
1020
+ const dir = await makeTmpDir();
1021
+ try {
1022
+ for (const skillName of ["charlie", "alpha", "bravo"]) {
1023
+ const skillDir = path.join(dir, "skills", skillName);
1024
+ await mkdir(path.join(skillDir, "deep", "nested"), { recursive: true });
1025
+ await writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${skillName}\ndescription: test\n---\n`);
1026
+ await writeFile(path.join(skillDir, "deep", "nested", "ignored.md"), "# ignored because it is inside a skill\n");
1027
+ await writeFile(path.join(skillDir, "deep", "nested", "SKILL.md"), "---\nname: ignored\ndescription: nested\n---\n");
1028
+ }
1029
+ await mkdir(path.join(dir, "rules"), { recursive: true });
1030
+ await writeFile(path.join(dir, "rules", "shared.md"), "# shared rule\n");
1031
+ for (let i = 0; i < 24; i++) {
1032
+ const branchDir = path.join(dir, "packages", `pkg-${i}`);
1033
+ await mkdir(path.join(branchDir, "docs"), { recursive: true });
1034
+ await writeFile(path.join(branchDir, "docs", `ignored-${i}.md`), `# package doc ${i}\n`);
1035
+ }
1036
+ await mkdir(path.join(dir, ".claude", "agents"), { recursive: true });
1037
+ await writeFile(path.join(dir, ".claude", "agents", "maintainer.md"), "---\nname: maintainer\ndescription: test\n---\n# Agent\n");
1038
+ const { code } = await run(["init", dir]);
1039
+ assert.equal(code, 0);
1040
+ const manifest = JSON.parse(await readFile(path.join(dir, "inception.json"), "utf-8"));
1041
+ assert.deepEqual(manifest.skills.map((entry) => entry.name), ["alpha", "bravo", "charlie"], "skill ordering should be path-sorted and deterministic");
1042
+ assert.deepEqual(manifest.skills.map((entry) => normalizeSlashes(entry.path)), ["skills/alpha", "skills/bravo", "skills/charlie"]);
1043
+ assert.ok(!manifest.skills.some((entry) => entry.name === "nested"), "nested SKILL.md below a discovered skill should not be treated as a new skill");
1044
+ assert.ok(!manifest.agentRules.some((entry) => normalizeSlashes(entry.path).includes("ignored.md")), "markdown below a discovered skill should be excluded from agentRules");
1045
+ assert.ok(manifest.agentRules.some((entry) => normalizeSlashes(entry.path) === "rules/shared.md"), "documented rule directories should still be scanned for markdown rules");
1046
+ assert.ok(manifest.agentDefinitions.some((entry) => normalizeSlashes(entry.path) === ".claude/agents/maintainer.md"), "agent definitions outside skills should still be discovered");
1047
+ }
1048
+ finally {
1049
+ await rm(dir, { recursive: true });
1050
+ }
1051
+ });
996
1052
  });
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { copyFile, mkdir, readdir, readFile, rename, rm, symlink, writeFile, } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { describe, it } from "node:test";
5
+ import { setTimeout as delay } from "node:timers/promises";
5
6
  import { executeDeploy, planDeploy } from "../../src/core/deploy.js";
6
7
  import { lookupDeployment, registerDeployment, } from "../../src/core/ownership.js";
7
8
  import { executeRevert, planRevert } from "../../src/core/revert.js";
@@ -955,6 +956,102 @@ describe("planDeploy", () => {
955
956
  await rm(sourceDir, { recursive: true });
956
957
  }
957
958
  });
959
+ it("runs per-agent structural validation on shared agentDefinitions source (github-copilot missing tools/instructions)", async () => {
960
+ const sourceDir = await makeTmpDir();
961
+ // Source missing both `tools` and `instructions` — valid for antigravity,
962
+ // invalid for github-copilot. The per-agent check must still fire even
963
+ // though parsing is now shared across agents.
964
+ const manifest = {
965
+ skills: [],
966
+ files: [],
967
+ configs: [],
968
+ mcpServers: [],
969
+ agentRules: [],
970
+ permissions: [],
971
+ agentDefinitions: [
972
+ {
973
+ name: "bad-def",
974
+ path: "agents/bad.md",
975
+ scope: "repo",
976
+ agents: ["antigravity", "github-copilot"],
977
+ },
978
+ ],
979
+ };
980
+ try {
981
+ await mkdir(path.join(sourceDir, "agents"), { recursive: true });
982
+ await writeFile(path.join(sourceDir, "agents/bad.md"), "---\nname: bad-def\ndescription: bad\n---\n# body\n");
983
+ await assert.rejects(() => planDeploy(manifest, sourceDir, ["antigravity", "github-copilot"], "/home/test", "/repo"), (err) => err instanceof UserError &&
984
+ err.message.includes("github-copilot") &&
985
+ err.message.includes("tools"), "expected per-agent github-copilot validation to still fire");
986
+ }
987
+ finally {
988
+ await rm(sourceDir, { recursive: true });
989
+ }
990
+ });
991
+ it("planning a shared agentDefinitions source succeeds when frontmatter satisfies every targeted agent", async () => {
992
+ const sourceDir = await makeTmpDir();
993
+ const manifest = {
994
+ skills: [],
995
+ files: [],
996
+ configs: [],
997
+ mcpServers: [],
998
+ agentRules: [],
999
+ permissions: [],
1000
+ agentDefinitions: [
1001
+ {
1002
+ name: "shared-def",
1003
+ path: "agents/shared.md",
1004
+ scope: "repo",
1005
+ agents: ["github-copilot", "antigravity"],
1006
+ },
1007
+ ],
1008
+ };
1009
+ try {
1010
+ await mkdir(path.join(sourceDir, "agents"), { recursive: true });
1011
+ await writeFile(path.join(sourceDir, "agents/shared.md"), '---\nname: shared-def\ndescription: shared\ntools: ["Read"]\n---\n# body\n');
1012
+ const { actions } = await planDeploy(manifest, sourceDir, ["github-copilot", "antigravity"], "/home/test", "/repo");
1013
+ const defActions = actions.filter((a) => a.kind === "file-write" && a.skill === "shared-def");
1014
+ // Both agents produce distinct targets — exercises the per-agent
1015
+ // validation loop against a single cached parse result.
1016
+ assert.equal(defActions.length, 2, "expected one file-write action per agent");
1017
+ }
1018
+ finally {
1019
+ await rm(sourceDir, { recursive: true });
1020
+ }
1021
+ });
1022
+ });
1023
+ describe("instruction file parsing helpers", () => {
1024
+ it("parseInstructionDocument reads and parses the source only once per call", async () => {
1025
+ const { parseInstructionDocument } = await import("../../src/core/validation.js");
1026
+ const sourceDir = await makeTmpDir();
1027
+ try {
1028
+ const sourcePath = path.join(sourceDir, "shared.md");
1029
+ await writeFile(sourcePath, "---\nname: shared\ndescription: shared\ntools: []\n---\n# body\n");
1030
+ const parsed = await parseInstructionDocument(sourcePath, "shared.md");
1031
+ assert.equal(parsed.attributes.name, "shared");
1032
+ assert.equal(parsed.attributes.description, "shared");
1033
+ assert.deepEqual(parsed.attributes.tools, []);
1034
+ }
1035
+ finally {
1036
+ await rm(sourceDir, { recursive: true });
1037
+ }
1038
+ });
1039
+ it("validateInstructionAgentRequirements enforces per-agent rules from cached attributes without re-reading", async () => {
1040
+ const { validateInstructionAgentRequirements } = await import("../../src/core/validation.js");
1041
+ // Simulate a cached parse result shared across agents.
1042
+ const attributesMissingTools = {
1043
+ name: "shared",
1044
+ description: "shared",
1045
+ };
1046
+ // antigravity passes (mcp-servers is optional).
1047
+ assert.doesNotThrow(() => validateInstructionAgentRequirements(attributesMissingTools, "shared.md", "antigravity"));
1048
+ // github-copilot fails (requires tools or instructions).
1049
+ assert.throws(() => validateInstructionAgentRequirements(attributesMissingTools, "shared.md", "github-copilot"), (err) => err instanceof UserError &&
1050
+ err.message.includes("github-copilot") &&
1051
+ err.message.includes("tools"));
1052
+ // Agents without instructionFrontmatterRequired are a no-op regardless of attrs.
1053
+ assert.doesNotThrow(() => validateInstructionAgentRequirements({}, "shared.md", "claude-code"));
1054
+ });
958
1055
  });
959
1056
  describe("planDeploy path traversal", () => {
960
1057
  it("throws when skill.path resolves to the repository root itself (.)", async () => {
@@ -1268,6 +1365,110 @@ describe("executeDeploy — file-write", () => {
1268
1365
  await rm(home, { recursive: true, force: true });
1269
1366
  }
1270
1367
  });
1368
+ it("runs independent file-write targets concurrently", async () => {
1369
+ const sourceDir = await makeTmpDir();
1370
+ const home = await makeTmpDir();
1371
+ let inFlight = 0;
1372
+ let maxInFlight = 0;
1373
+ try {
1374
+ const sourceA = path.join(sourceDir, "a.txt");
1375
+ const sourceB = path.join(sourceDir, "b.txt");
1376
+ await writeFile(sourceA, "a");
1377
+ await writeFile(sourceB, "b");
1378
+ const actions = [
1379
+ {
1380
+ kind: "file-write",
1381
+ skill: "skill-a",
1382
+ agent: "claude-code",
1383
+ source: sourceA,
1384
+ target: path.join(home, "a.txt"),
1385
+ },
1386
+ {
1387
+ kind: "file-write",
1388
+ skill: "skill-b",
1389
+ agent: "codex",
1390
+ source: sourceB,
1391
+ target: path.join(home, "b.txt"),
1392
+ },
1393
+ ];
1394
+ const { succeeded, failed } = await executeDeploy(actions, false, false, home, {
1395
+ fileOps: {
1396
+ async copyFile(source, target) {
1397
+ inFlight += 1;
1398
+ maxInFlight = Math.max(maxInFlight, inFlight);
1399
+ await delay(25);
1400
+ await copyFile(source, target);
1401
+ inFlight -= 1;
1402
+ },
1403
+ rename,
1404
+ rm,
1405
+ writeFile,
1406
+ },
1407
+ });
1408
+ assert.equal(succeeded, 2);
1409
+ assert.equal(failed.length, 0);
1410
+ assert.equal(maxInFlight, 2);
1411
+ }
1412
+ finally {
1413
+ await rm(sourceDir, { recursive: true });
1414
+ await rm(home, { recursive: true, force: true });
1415
+ }
1416
+ });
1417
+ it("serializes file-write actions that share the same target", async () => {
1418
+ const sourceDir = await makeTmpDir();
1419
+ const home = await makeTmpDir();
1420
+ let inFlight = 0;
1421
+ let maxInFlight = 0;
1422
+ try {
1423
+ const sourceFile = path.join(sourceDir, "file.txt");
1424
+ const targetFile = path.join(home, "shared.txt");
1425
+ await writeFile(sourceFile, "version 1");
1426
+ const action = {
1427
+ kind: "file-write",
1428
+ skill: "test-skill",
1429
+ agent: "claude-code",
1430
+ source: sourceFile,
1431
+ target: targetFile,
1432
+ };
1433
+ await executeDeploy([action], false, false, home, {
1434
+ fileOps: {
1435
+ async copyFile(source, target) {
1436
+ inFlight += 1;
1437
+ maxInFlight = Math.max(maxInFlight, inFlight);
1438
+ await delay(25);
1439
+ await copyFile(source, target);
1440
+ inFlight -= 1;
1441
+ },
1442
+ rename,
1443
+ rm,
1444
+ writeFile,
1445
+ },
1446
+ });
1447
+ await writeFile(sourceFile, "version 2");
1448
+ const { succeeded, failed } = await executeDeploy([action, action], false, false, home, {
1449
+ fileOps: {
1450
+ async copyFile(source, target) {
1451
+ inFlight += 1;
1452
+ maxInFlight = Math.max(maxInFlight, inFlight);
1453
+ await delay(25);
1454
+ await copyFile(source, target);
1455
+ inFlight -= 1;
1456
+ },
1457
+ rename,
1458
+ rm,
1459
+ writeFile,
1460
+ },
1461
+ });
1462
+ assert.equal(succeeded, 2);
1463
+ assert.equal(failed.length, 0);
1464
+ assert.equal(maxInFlight, 1);
1465
+ assert.equal(await readFile(targetFile, "utf-8"), "version 2");
1466
+ }
1467
+ finally {
1468
+ await rm(sourceDir, { recursive: true });
1469
+ await rm(home, { recursive: true, force: true });
1470
+ }
1471
+ });
1271
1472
  it("fails gracefully when source file does not exist", async () => {
1272
1473
  const home = await makeTmpDir();
1273
1474
  try {
@@ -1450,6 +1651,58 @@ describe("executeDeploy — file-write", () => {
1450
1651
  await rm(home, { recursive: true, force: true });
1451
1652
  }
1452
1653
  });
1654
+ it("returns failed actions in input order under concurrent execution", async () => {
1655
+ const sourceDir = await makeTmpDir();
1656
+ const home = await makeTmpDir();
1657
+ try {
1658
+ const sourceA = path.join(sourceDir, "a.txt");
1659
+ const sourceB = path.join(sourceDir, "b.txt");
1660
+ const targetA = path.join(home, "a.txt");
1661
+ const targetB = path.join(home, "b.txt");
1662
+ await writeFile(sourceA, "a");
1663
+ await writeFile(sourceB, "b");
1664
+ const actions = [
1665
+ {
1666
+ kind: "file-write",
1667
+ skill: "slow-fail",
1668
+ agent: "claude-code",
1669
+ source: sourceA,
1670
+ target: targetA,
1671
+ },
1672
+ {
1673
+ kind: "file-write",
1674
+ skill: "fast-fail",
1675
+ agent: "codex",
1676
+ source: sourceB,
1677
+ target: targetB,
1678
+ },
1679
+ ];
1680
+ const { succeeded, failed } = await executeDeploy(actions, false, false, home, {
1681
+ fileOps: {
1682
+ async copyFile(_source, target) {
1683
+ if (target.includes("a.txt")) {
1684
+ await delay(25);
1685
+ throw new Error("slow failure");
1686
+ }
1687
+ throw new Error("fast failure");
1688
+ },
1689
+ rename,
1690
+ rm,
1691
+ writeFile,
1692
+ },
1693
+ });
1694
+ assert.equal(succeeded, 0);
1695
+ assert.equal(failed.length, 2);
1696
+ assert.equal(failed[0]?.action.skill, "slow-fail");
1697
+ assert.match(failed[0]?.error ?? "", /slow failure/);
1698
+ assert.equal(failed[1]?.action.skill, "fast-fail");
1699
+ assert.match(failed[1]?.error ?? "", /fast failure/);
1700
+ }
1701
+ finally {
1702
+ await rm(sourceDir, { recursive: true });
1703
+ await rm(home, { recursive: true, force: true });
1704
+ }
1705
+ });
1453
1706
  });
1454
1707
  describe("executeDeploy — config-patch", () => {
1455
1708
  it("applies a JSON merge patch to an existing config file", async () => {
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { describe, it } from "node:test";
5
5
  import { compileAgentDefinitionActions } from "../../src/core/adapters/agent-definitions.js";
6
6
  import { runPreflight } from "../../src/core/preflight.js";
7
+ import { createSourcePathValidator } from "../../src/core/validation.js";
7
8
  import { makeTmpDir } from "../helpers/fs.js";
8
9
  import { assertPathEndsWith } from "../helpers/path.js";
9
10
  describe("Gemini CLI North Star", () => {
@@ -11,13 +12,13 @@ describe("Gemini CLI North Star", () => {
11
12
  const dir = await makeTmpDir();
12
13
  try {
13
14
  await writeFile(path.join(dir, "my-agent.md"), "---\nname: test-agent\ndescription: test\ntools: []\n---\n# Agent");
14
- const realRoot = await realpath(dir);
15
+ const validateSource = createSourcePathValidator(await realpath(dir));
15
16
  const { actions, warnings } = await compileAgentDefinitionActions({
16
17
  name: "my-agent",
17
18
  agents: ["gemini-cli"],
18
19
  path: "my-agent.md",
19
20
  scope: "global",
20
- }, dir, dir, realRoot, ["gemini-cli"], "/home/test", "/repo/test");
21
+ }, dir, dir, validateSource, ["gemini-cli"], "/home/test", "/repo/test");
21
22
  assert.equal(warnings.length, 0);
22
23
  assert.equal(actions.length, 1);
23
24
  const action = actions[0];
@@ -33,13 +34,13 @@ describe("Gemini CLI North Star", () => {
33
34
  const dir = await makeTmpDir();
34
35
  try {
35
36
  await writeFile(path.join(dir, "my-agent.toml"), '[agent]\nname = "my-agent"\ndescription = "test"\n');
36
- const realRoot = await realpath(dir);
37
+ const validateSource = createSourcePathValidator(await realpath(dir));
37
38
  const { actions, warnings } = await compileAgentDefinitionActions({
38
39
  name: "my-agent",
39
40
  agents: ["gemini-cli"],
40
41
  path: "my-agent.toml",
41
42
  scope: "global",
42
- }, dir, dir, realRoot, ["gemini-cli"], "/home/test", "/repo/test");
43
+ }, dir, dir, validateSource, ["gemini-cli"], "/home/test", "/repo/test");
43
44
  assert.equal(warnings.length, 0);
44
45
  assert.equal(actions.length, 1);
45
46
  const action = actions[0];
@@ -55,13 +56,13 @@ describe("Gemini CLI North Star", () => {
55
56
  const dir = await makeTmpDir();
56
57
  try {
57
58
  await writeFile(path.join(dir, "my-agent.toml"), '[agent]\nname = "my-agent"\ndescription = "test"\n');
58
- const realRoot = await realpath(dir);
59
+ const validateSource = createSourcePathValidator(await realpath(dir));
59
60
  const { actions, warnings } = await compileAgentDefinitionActions({
60
61
  name: "my-agent",
61
62
  agents: ["gemini-cli"],
62
63
  path: "my-agent.toml",
63
64
  scope: "repo",
64
- }, dir, dir, realRoot, ["gemini-cli"], "/home/test", "/repo/test");
65
+ }, dir, dir, validateSource, ["gemini-cli"], "/home/test", "/repo/test");
65
66
  assert.equal(warnings.length, 0);
66
67
  assert.equal(actions.length, 1);
67
68
  const action = actions[0];
@@ -77,14 +78,14 @@ describe("Gemini CLI North Star", () => {
77
78
  const dir = await makeTmpDir();
78
79
  try {
79
80
  await writeFile(path.join(dir, "my-agent.toml"), '[agent]\nname = "my-agent"\ndescription = "test"\n');
80
- const realRoot = await realpath(dir);
81
+ const validateSource = createSourcePathValidator(await realpath(dir));
81
82
  // claude-code has no TOML surface — should silently produce no action
82
83
  const { actions, warnings } = await compileAgentDefinitionActions({
83
84
  name: "my-agent",
84
85
  agents: ["claude-code"],
85
86
  path: "my-agent.toml",
86
87
  scope: "repo",
87
- }, dir, dir, realRoot, ["claude-code"], "/home/test", "/repo/test");
88
+ }, dir, dir, validateSource, ["claude-code"], "/home/test", "/repo/test");
88
89
  assert.equal(actions.length, 0, "expected no action for agent without TOML surface");
89
90
  assert.equal(warnings.length, 0, "expected no warnings");
90
91
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { isInsideSkillDir } from "../../src/core/init.js";
4
+ describe("isInsideSkillDir", () => {
5
+ it("matches a file directly inside a discovered skill directory", () => {
6
+ assert.equal(isInsideSkillDir("skills/alpha/guide.md", new Set(["skills/alpha"])), true);
7
+ });
8
+ it("matches a file nested below a discovered skill directory", () => {
9
+ assert.equal(isInsideSkillDir("skills/alpha/docs/reference/guide.md", new Set(["skills/alpha"])), true);
10
+ });
11
+ it("does not match similarly prefixed sibling paths", () => {
12
+ assert.equal(isInsideSkillDir("skills/alpha-helper/guide.md", new Set(["skills/alpha"])), false);
13
+ });
14
+ });
@@ -229,6 +229,31 @@ describe("loadManifest", () => {
229
229
  await rm(dir, { recursive: true });
230
230
  }
231
231
  });
232
+ it("mentions {workspace} in target placeholder validation errors", async () => {
233
+ const dir = await makeTmpDir();
234
+ try {
235
+ await writeFile(path.join(dir, "inception.json"), JSON.stringify({
236
+ skills: [],
237
+ files: [
238
+ {
239
+ name: "bad-target",
240
+ path: "files/settings.json",
241
+ target: "{invalid}/settings.json",
242
+ agents: ["claude-code"],
243
+ },
244
+ ],
245
+ }));
246
+ await assert.rejects(loadManifest(dir), (err) => {
247
+ assert.ok(err instanceof UserError);
248
+ assert.equal(err.code, "MANIFEST_INVALID");
249
+ assert.match(err.message, /\{workspace\}/);
250
+ return true;
251
+ });
252
+ }
253
+ finally {
254
+ await rm(dir, { recursive: true });
255
+ }
256
+ });
232
257
  it("throws when agentRules is not an array", async () => {
233
258
  const dir = await makeTmpDir();
234
259
  try {
@@ -51,6 +51,35 @@ describe("runPreflight", () => {
51
51
  assert.equal(warnings[0]?.kind, "config-authority");
52
52
  assert.match(warnings[0]?.message ?? "", /antigravity/);
53
53
  });
54
+ it("keeps agent-specific warning order aligned with detectedAgents", async () => {
55
+ const home = await makeTmpDir();
56
+ try {
57
+ await mkdir(path.join(home, ".gemini"), { recursive: true });
58
+ await writeFile(path.join(home, ".gemini", "settings.json"), JSON.stringify({ instructionFilename: "CUSTOM.md" }));
59
+ const manifest = {
60
+ ...emptyManifest,
61
+ agentRules: [
62
+ {
63
+ name: "gemini-rules",
64
+ path: "GEMINI.md",
65
+ agents: ["gemini-cli"],
66
+ scope: "global",
67
+ },
68
+ ],
69
+ };
70
+ const warnings = await runPreflight(baseOptions, manifest, home, [
71
+ "gemini-cli",
72
+ "antigravity",
73
+ ]);
74
+ assert.equal(warnings.length, 2);
75
+ assert.match(warnings[0]?.message ?? "", /instructionFilename/);
76
+ assert.match(warnings[1]?.message ?? "", /antigravity/);
77
+ assert.match(warnings[1]?.message ?? "", /implementation-only/);
78
+ }
79
+ finally {
80
+ await rm(home, { recursive: true, force: true });
81
+ }
82
+ });
54
83
  it("emits shared-surface guidance when a skill targets github-copilot without claude-code", async () => {
55
84
  const manifest = {
56
85
  ...emptyManifest,
@@ -1,8 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
- import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { describe, it } from "node:test";
5
- import { lookupDeployment, registerDeployment, } from "../../src/core/ownership.js";
5
+ import { defaultRegistryPersistence, lookupDeployment, registerDeployment, } from "../../src/core/ownership.js";
6
6
  import { executeRevert, planRevert, planRevertAll, } from "../../src/core/revert.js";
7
7
  import { logger } from "../../src/logger.js";
8
8
  import { exists, makeTmpDir } from "../helpers/fs.js";
@@ -257,20 +257,21 @@ describe("executeRevert — copy method (cross-platform)", () => {
257
257
  agent: "claude-code",
258
258
  method: "copy",
259
259
  });
260
- const registryFile = path.join(home, ".inception-engine", "registry.json");
261
- await chmod(registryFile, 0o444);
262
- const { succeeded, skipped, failed } = await executeRevert(actions, false, false, home);
260
+ // Simulate an unwritable registry via a failing RegistryPersistence
261
+ // instead of relying on chmod, which is not enforced for admin processes
262
+ // on Windows (e.g. GitHub Actions windows-latest runners).
263
+ const failingRegistry = {
264
+ load: (h) => defaultRegistryPersistence.load(h),
265
+ save: async () => {
266
+ throw Object.assign(new Error("EACCES: permission denied, open 'registry.json'"), { code: "EACCES" });
267
+ },
268
+ };
269
+ const { succeeded, skipped, failed } = await executeRevert(actions, false, false, home, { registry: failingRegistry });
263
270
  assert.equal(succeeded, 0);
264
271
  assert.equal(skipped, 0);
265
272
  assert.equal(failed.length, 1);
266
273
  }
267
274
  finally {
268
- try {
269
- await chmod(path.join(home, ".inception-engine", "registry.json"), 0o666);
270
- }
271
- catch {
272
- /* best effort */
273
- }
274
275
  await rm(home, { recursive: true, force: true });
275
276
  await rm(sourceDir, { recursive: true, force: true });
276
277
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,102 @@
1
+ import assert from "node:assert/strict";
2
+ import { realpath, rm, symlink, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { describe, it } from "node:test";
5
+ import { createSourcePathValidator } from "../../src/core/validation.js";
6
+ import { UserError } from "../../src/errors.js";
7
+ import { makeTmpDir } from "../helpers/fs.js";
8
+ describe("createSourcePathValidator", () => {
9
+ it("reports per-entry manifestPath on repeated symlink-escape errors for the same source", async () => {
10
+ const root = await realpath(await makeTmpDir());
11
+ const outside = await realpath(await makeTmpDir());
12
+ try {
13
+ const src = path.join(root, "escape");
14
+ await symlink(outside, src, process.platform === "win32" ? "junction" : "dir");
15
+ const validate = createSourcePathValidator(root);
16
+ await assert.rejects(() => validate(src, "entry-one", root), (err) => {
17
+ assert.ok(err instanceof UserError);
18
+ assert.match(err.message, /entry-one/);
19
+ assert.match(err.message, /via symlink/);
20
+ return true;
21
+ });
22
+ // Second call hits the cached escape outcome but still surfaces the
23
+ // current manifestPath, not the one captured on the first call.
24
+ await assert.rejects(() => validate(src, "entry-two", root), (err) => {
25
+ assert.ok(err instanceof UserError);
26
+ assert.match(err.message, /entry-two/);
27
+ assert.doesNotMatch(err.message, /entry-one/);
28
+ return true;
29
+ });
30
+ }
31
+ finally {
32
+ await rm(root, { recursive: true, force: true });
33
+ await rm(outside, { recursive: true });
34
+ }
35
+ });
36
+ it("memoizes the ok outcome for a missing source across calls", async () => {
37
+ const root = await realpath(await makeTmpDir());
38
+ const outside = await realpath(await makeTmpDir());
39
+ try {
40
+ const src = path.join(root, "later");
41
+ const validate = createSourcePathValidator(root);
42
+ // Missing source: validator swallows ENOENT and caches "ok".
43
+ await validate(src, "entry-one", root);
44
+ // Replace the missing source with an escaping symlink. If the first
45
+ // call's ok outcome is cached, the validator does not re-run
46
+ // realpath/identity-walk and still passes.
47
+ await symlink(outside, src, process.platform === "win32" ? "junction" : "dir");
48
+ await validate(src, "entry-two", root);
49
+ }
50
+ finally {
51
+ await rm(root, { recursive: true, force: true });
52
+ await rm(outside, { recursive: true });
53
+ }
54
+ });
55
+ it("reuses the cached ok outcome when the same source is validated repeatedly", async () => {
56
+ const root = await realpath(await makeTmpDir());
57
+ try {
58
+ const src = path.join(root, "file.md");
59
+ await writeFile(src, "# ok");
60
+ const validate = createSourcePathValidator(root);
61
+ await validate(src, "entry-one", root);
62
+ // Deleting the file before the second call would cause a fresh realpath
63
+ // to return ENOENT and also resolve to ok, so to really observe the
64
+ // cache we replace it with an escaping symlink.
65
+ await rm(src);
66
+ const outside = await realpath(await makeTmpDir());
67
+ try {
68
+ await symlink(outside, src, process.platform === "win32" ? "junction" : "dir");
69
+ // Cache hit: no escape error despite the symlink now pointing outside.
70
+ await validate(src, "entry-two", root);
71
+ }
72
+ finally {
73
+ await rm(outside, { recursive: true });
74
+ }
75
+ }
76
+ finally {
77
+ await rm(root, { recursive: true, force: true });
78
+ }
79
+ });
80
+ it("runs the out-of-root string gate per call with the caller's manifestPath", async () => {
81
+ const root = await realpath(await makeTmpDir());
82
+ try {
83
+ const validate = createSourcePathValidator(root);
84
+ const outside = path.resolve(root, "..", "elsewhere", "thing.md");
85
+ await assert.rejects(() => validate(outside, "entry-one", root), (err) => {
86
+ assert.ok(err instanceof UserError);
87
+ assert.match(err.message, /entry-one/);
88
+ assert.match(err.message, /resolves outside the repository root/);
89
+ assert.doesNotMatch(err.message, /via symlink/);
90
+ return true;
91
+ });
92
+ await assert.rejects(() => validate(outside, "entry-two", root), (err) => {
93
+ assert.ok(err instanceof UserError);
94
+ assert.match(err.message, /entry-two/);
95
+ return true;
96
+ });
97
+ }
98
+ finally {
99
+ await rm(root, { recursive: true, force: true });
100
+ }
101
+ });
102
+ });