@kuznai/inception-engine 0.22.0 → 0.23.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.
@@ -118,12 +118,37 @@ function detectMultipleActiveInstructionScopes(agentId, rulesForAgent) {
118
118
  },
119
119
  ];
120
120
  }
121
+ /**
122
+ * Warns when github-copilot has both a shared-via CLAUDE.md agentRules entry
123
+ * (scope: "repo" or "global") AND a native Copilot instruction entry
124
+ * (scope: "copilot-repo" or "copilot-scoped"). GitHub Copilot merges all
125
+ * active instruction sources at runtime, so duplicate or conflicting rules
126
+ * across these surfaces may cause unexpected agent behavior.
127
+ */
128
+ function detectCopilotInstructionPrecedence(rulesForAgent) {
129
+ const hasSharedVia = (rulesForAgent ?? []).some((e) => e.scope === "repo" || e.scope === "global");
130
+ const hasNative = (rulesForAgent ?? []).some((e) => e.scope === "copilot-repo" || e.scope === "copilot-scoped");
131
+ if (!(hasSharedVia && hasNative))
132
+ return [];
133
+ return [
134
+ {
135
+ kind: "precedence",
136
+ message: `Agent "github-copilot" will load both a CLAUDE.md-shared instruction file` +
137
+ ` and a native Copilot instruction file (.github/copilot-instructions.md or` +
138
+ ` .github/instructions/). GitHub Copilot merges all active instruction` +
139
+ ` sources - ensure content is non-conflicting and does not duplicate rules.`,
140
+ },
141
+ ];
142
+ }
121
143
  function detectInstructionPrecedence(detectedAgents, manifest) {
122
144
  const warnings = [];
123
145
  for (const agentId of detectedAgents) {
124
146
  const rulesForAgent = (manifest.agentRules ?? []).filter((e) => e.agents.includes(agentId));
125
147
  warnings.push(...detectScopeOverlaps(agentId, rulesForAgent));
126
148
  warnings.push(...detectMultipleActiveInstructionScopes(agentId, rulesForAgent));
149
+ if (agentId === "github-copilot") {
150
+ warnings.push(...detectCopilotInstructionPrecedence(rulesForAgent));
151
+ }
127
152
  }
128
153
  return warnings;
129
154
  }
@@ -239,6 +264,9 @@ function collectManifestCapabilityWarnings(manifest, detectedAgents) {
239
264
  for (const entry of manifest.agentDefinitions ?? []) {
240
265
  collectCapabilityWarningsForTargets(acc, entry.agents.filter((agentId) => detectedAgents.includes(agentId)), "agentDefinitions", entry.name, entry.scope);
241
266
  }
267
+ for (const entry of manifest.executionConfigs ?? []) {
268
+ collectCapabilityWarningsForTargets(acc, entry.agents.filter((agentId) => detectedAgents.includes(agentId)), "executionConfigs", entry.name);
269
+ }
242
270
  return acc.warnings;
243
271
  }
244
272
  function detectCapabilityPlanningWarnings(manifest, detectedAgents) {
@@ -2,7 +2,7 @@ import { lstat, readFile, rm, unlink, writeFile } from "node:fs/promises";
2
2
  import { AGENT_REGISTRY_BY_ID } from "../config/agents.js";
3
3
  import { logger } from "../logger.js";
4
4
  import * as frontmatterAdapter from "./adapters/frontmatter.js";
5
- import { compileAgentDefinitionReverts, compileAgentRuleReverts, compileMcpServerReverts, compilePermissionsReverts, } from "./adapters/index.js";
5
+ import { compileAgentDefinitionReverts, compileAgentRuleReverts, compileExecutionConfigReverts, compileHookReverts, compileMcpServerReverts, compilePermissionsReverts, } from "./adapters/index.js";
6
6
  import { revertTomlMcpPatch } from "./adapters/toml.js";
7
7
  import { applyUndoPatch } from "./merge-patch.js";
8
8
  import { lookupDeployment, unregisterDeployment, } from "./ownership.js";
@@ -75,6 +75,8 @@ export function planRevert(manifest, detectedAgents, home, repo) {
75
75
  ...(manifest.mcpServers ?? []).flatMap((e) => compileMcpServerReverts(e, detectedAgents, home, repo)),
76
76
  ...(manifest.agentRules ?? []).flatMap((e) => compileAgentRuleReverts(e, detectedAgents, home, repo)),
77
77
  ...(manifest.permissions ?? []).flatMap((e) => compilePermissionsReverts(e, detectedAgents, home)),
78
+ ...(manifest.hooks ?? []).flatMap((e) => compileHookReverts(e, detectedAgents, home)),
79
+ ...(manifest.executionConfigs ?? []).flatMap((e) => compileExecutionConfigReverts(e, detectedAgents, home)),
78
80
  ...(manifest.agentDefinitions ?? []).flatMap((e) => compileAgentDefinitionReverts(e, detectedAgents, home, repo)),
79
81
  ];
80
82
  }
@@ -86,6 +88,8 @@ export function planRevertAll(manifest, home, repo) {
86
88
  ...(manifest.mcpServers ?? []).flatMap((e) => compileMcpServerReverts(e, null, home, repo)),
87
89
  ...(manifest.agentRules ?? []).flatMap((e) => compileAgentRuleReverts(e, null, home, repo)),
88
90
  ...(manifest.permissions ?? []).flatMap((e) => compilePermissionsReverts(e, null, home)),
91
+ ...(manifest.hooks ?? []).flatMap((e) => compileHookReverts(e, null, home)),
92
+ ...(manifest.executionConfigs ?? []).flatMap((e) => compileExecutionConfigReverts(e, null, home)),
89
93
  ...(manifest.agentDefinitions ?? []).flatMap((e) => compileAgentDefinitionReverts(e, null, home, repo)),
90
94
  ];
91
95
  }
@@ -4,7 +4,7 @@ export declare function validateSourcePath(source: string, skillPath: string, re
4
4
  export declare function validateSourceFile(sourcePath: string, manifestPath: string): Promise<void>;
5
5
  export declare function validateMcpServerConfigShape(config: Record<string, unknown>, entryName: string, agentId: string): void;
6
6
  export declare function validatePermissionsConfigShape(config: Record<string, unknown>, entryName: string, agentId: string): void;
7
- export declare function validateHookConfigShape(_config: Record<string, unknown>, _entryName: string, _agentId: string): void;
7
+ export declare function validateHookConfigShape(config: Record<string, unknown>, entryName: string, agentId: string): void;
8
8
  export declare function validateAgentRuleMarkdownPath(manifestPath: string, agentId: string): void;
9
9
  export declare function validateSkillDefinitionFile(sourcePath: string, manifestPath: string): Promise<{
10
10
  attributes: Record<string, unknown>;
@@ -163,9 +163,62 @@ export function validatePermissionsConfigShape(config, entryName, agentId) {
163
163
  validateOpenCodePermissions(config, entryName);
164
164
  }
165
165
  }
166
- export function validateHookConfigShape(_config, _entryName, _agentId) {
167
- // Placeholder for agent-specific hook validation logic.
168
- // Currently allows any record as a hook payload.
166
+ function validateClaudeHookCommand(cmd, path) {
167
+ if (typeof cmd !== "object" || cmd === null || Array.isArray(cmd)) {
168
+ throw new UserError("DEPLOY_FAILED", `hooks entry ${path} must be an object`);
169
+ }
170
+ const cmdObj = cmd;
171
+ if (cmdObj.type !== "command") {
172
+ throw new UserError("DEPLOY_FAILED", `hooks entry ${path}.type must be "command"`);
173
+ }
174
+ if (typeof cmdObj.command !== "string") {
175
+ throw new UserError("DEPLOY_FAILED", `hooks entry ${path}.command must be a string`);
176
+ }
177
+ }
178
+ function validateClaudeHookMatcher(matcher, path) {
179
+ if (typeof matcher !== "object" ||
180
+ matcher === null ||
181
+ Array.isArray(matcher)) {
182
+ throw new UserError("DEPLOY_FAILED", `hooks entry ${path} must be an object`);
183
+ }
184
+ const matcherObj = matcher;
185
+ if (matcherObj.matcher !== undefined &&
186
+ typeof matcherObj.matcher !== "string") {
187
+ throw new UserError("DEPLOY_FAILED", `hooks entry ${path}.matcher must be a string when present`);
188
+ }
189
+ const matcherHooks = matcherObj.hooks;
190
+ if (!Array.isArray(matcherHooks)) {
191
+ throw new UserError("DEPLOY_FAILED", `hooks entry ${path}.hooks must be an array`);
192
+ }
193
+ for (const [cmdIdx, cmd] of matcherHooks.entries()) {
194
+ validateClaudeHookCommand(cmd, `${path}.hooks[${cmdIdx}]`);
195
+ }
196
+ }
197
+ function validateClaudeCodeHooks(config, entryName) {
198
+ const unknownKeys = Object.keys(config).filter((k) => k !== "hooks");
199
+ if (unknownKeys.length > 0) {
200
+ throw new UserError("DEPLOY_FAILED", `hooks entry "${entryName}" for agent "claude-code" contains unrecognized keys: ${unknownKeys.join(", ")}. Only "hooks" is allowed.`);
201
+ }
202
+ const hooks = config.hooks;
203
+ if (hooks === undefined)
204
+ return;
205
+ if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks)) {
206
+ throw new UserError("DEPLOY_FAILED", `hooks entry "${entryName}" for agent "claude-code" must define "hooks" as an object`);
207
+ }
208
+ const hooksObj = hooks;
209
+ for (const [eventName, matchers] of Object.entries(hooksObj)) {
210
+ if (!Array.isArray(matchers)) {
211
+ throw new UserError("DEPLOY_FAILED", `hooks entry "${entryName}" for agent "claude-code": "hooks.${eventName}" must be an array`);
212
+ }
213
+ for (const [idx, matcher] of matchers.entries()) {
214
+ validateClaudeHookMatcher(matcher, `"${entryName}" for agent "claude-code": "hooks.${eventName}[${idx}]"`);
215
+ }
216
+ }
217
+ }
218
+ export function validateHookConfigShape(config, entryName, agentId) {
219
+ if (agentId === "claude-code") {
220
+ validateClaudeCodeHooks(config, entryName);
221
+ }
169
222
  }
170
223
  export function validateAgentRuleMarkdownPath(manifestPath, agentId) {
171
224
  const extension = path.extname(manifestPath).toLowerCase();
@@ -81,7 +81,10 @@ export declare const AgentRuleEntrySchema: z.ZodObject<{
81
81
  global: "global";
82
82
  repo: "repo";
83
83
  workspace: "workspace";
84
+ "copilot-repo": "copilot-repo";
85
+ "copilot-scoped": "copilot-scoped";
84
86
  }>>;
87
+ targetDir: z.ZodOptional<z.ZodString>;
85
88
  }, z.core.$strip>;
86
89
  export declare const PermissionsEntrySchema: z.ZodObject<{
87
90
  name: z.ZodString;
@@ -95,6 +98,18 @@ export declare const PermissionsEntrySchema: z.ZodObject<{
95
98
  }>>>, z.ZodTransform<("claude-code" | "codex" | "gemini-cli" | "antigravity" | "opencode" | "github-copilot")[], ("claude-code" | "codex" | "gemini-cli" | "antigravity" | "opencode" | "github-copilot")[]>>;
96
99
  config: z.ZodRecord<z.ZodString, z.ZodUnknown>;
97
100
  }, z.core.$strip>;
101
+ export declare const ExecutionConfigEntrySchema: z.ZodObject<{
102
+ name: z.ZodString;
103
+ agents: z.ZodPipe<z.ZodArray<z.ZodPipe<z.ZodString, z.ZodEnum<{
104
+ "claude-code": "claude-code";
105
+ codex: "codex";
106
+ "gemini-cli": "gemini-cli";
107
+ antigravity: "antigravity";
108
+ opencode: "opencode";
109
+ "github-copilot": "github-copilot";
110
+ }>>>, z.ZodTransform<("claude-code" | "codex" | "gemini-cli" | "antigravity" | "opencode" | "github-copilot")[], ("claude-code" | "codex" | "gemini-cli" | "antigravity" | "opencode" | "github-copilot")[]>>;
111
+ config: z.ZodRecord<z.ZodString, z.ZodUnknown>;
112
+ }, z.core.$strip>;
98
113
  export declare const AgentDefinitionEntrySchema: z.ZodObject<{
99
114
  name: z.ZodString;
100
115
  agents: z.ZodPipe<z.ZodArray<z.ZodPipe<z.ZodString, z.ZodEnum<{
@@ -196,7 +211,10 @@ export declare const ManifestSchema: z.ZodObject<{
196
211
  global: "global";
197
212
  repo: "repo";
198
213
  workspace: "workspace";
214
+ "copilot-repo": "copilot-repo";
215
+ "copilot-scoped": "copilot-scoped";
199
216
  }>>;
217
+ targetDir: z.ZodOptional<z.ZodString>;
200
218
  }, z.core.$strip>>>;
201
219
  permissions: z.ZodDefault<z.ZodArray<z.ZodObject<{
202
220
  name: z.ZodString;
@@ -239,6 +257,18 @@ export declare const ManifestSchema: z.ZodObject<{
239
257
  }>>>, z.ZodTransform<("claude-code" | "codex" | "gemini-cli" | "antigravity" | "opencode" | "github-copilot")[], ("claude-code" | "codex" | "gemini-cli" | "antigravity" | "opencode" | "github-copilot")[]>>;
240
258
  config: z.ZodRecord<z.ZodString, z.ZodUnknown>;
241
259
  }, z.core.$strip>>>;
260
+ executionConfigs: z.ZodOptional<z.ZodArray<z.ZodObject<{
261
+ name: z.ZodString;
262
+ agents: z.ZodPipe<z.ZodArray<z.ZodPipe<z.ZodString, z.ZodEnum<{
263
+ "claude-code": "claude-code";
264
+ codex: "codex";
265
+ "gemini-cli": "gemini-cli";
266
+ antigravity: "antigravity";
267
+ opencode: "opencode";
268
+ "github-copilot": "github-copilot";
269
+ }>>>, z.ZodTransform<("claude-code" | "codex" | "gemini-cli" | "antigravity" | "opencode" | "github-copilot")[], ("claude-code" | "codex" | "gemini-cli" | "antigravity" | "opencode" | "github-copilot")[]>>;
270
+ config: z.ZodRecord<z.ZodString, z.ZodUnknown>;
271
+ }, z.core.$strip>>>;
242
272
  }, z.core.$strip>;
243
273
  export type SkillEntry = z.infer<typeof SkillEntrySchema>;
244
274
  export type FileEntry = z.infer<typeof FileEntrySchema>;
@@ -248,6 +278,7 @@ export type AgentRuleEntry = z.infer<typeof AgentRuleEntrySchema>;
248
278
  export type PermissionsEntry = z.infer<typeof PermissionsEntrySchema>;
249
279
  export type AgentDefinitionEntry = z.infer<typeof AgentDefinitionEntrySchema>;
250
280
  export type HookEntry = z.infer<typeof HookEntrySchema>;
281
+ export type ExecutionConfigEntry = z.infer<typeof ExecutionConfigEntrySchema>;
251
282
  export type Manifest = z.infer<typeof ManifestSchema>;
252
283
  export declare const AgentListSchema: z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<string[], string>>, z.ZodArray<z.ZodPipe<z.ZodString, z.ZodEnum<{
253
284
  "claude-code": "claude-code";
@@ -92,7 +92,8 @@ export const McpServerEntrySchema = z.object({
92
92
  .enum(["global", "repo", "workspace", "devcontainer"])
93
93
  .default("global"),
94
94
  });
95
- export const AgentRuleEntrySchema = z.object({
95
+ export const AgentRuleEntrySchema = z
96
+ .object({
96
97
  name: nameField,
97
98
  agents: agentsField,
98
99
  // Relative path to the rules/instruction file within the source bundle.
@@ -103,7 +104,33 @@ export const AgentRuleEntrySchema = z.object({
103
104
  // file (default), "repo" targets the project-root instruction file within the
104
105
  // deployed repository (e.g. {repo}/CLAUDE.md for claude-code), and "workspace"
105
106
  // targets the agent's workspace-local instruction surface.
106
- scope: z.enum(["global", "repo", "workspace"]).default("global"),
107
+ // "copilot-repo" targets GitHub Copilot's native repo-level instruction file
108
+ // at {repo}/.github/copilot-instructions.md (github-copilot only).
109
+ // "copilot-scoped" targets {repo}/.github/instructions/{name}.instructions.md
110
+ // where {name} is the manifest entry name (github-copilot only).
111
+ scope: z
112
+ .enum(["global", "repo", "workspace", "copilot-repo", "copilot-scoped"])
113
+ .default("global"),
114
+ // Optional relative directory within the repo/workspace where the rule
115
+ // should be deployed. Only supported for scope: "repo" and scope: "workspace".
116
+ targetDir: z
117
+ .string()
118
+ .optional()
119
+ .refine((p) => !(p && nodePath.isAbsolute(p)), {
120
+ message: "targetDir must be a relative path",
121
+ })
122
+ .refine((p) => !(p && nodePath.normalize(p).startsWith("..")), {
123
+ message: "targetDir must not escape the target root",
124
+ }),
125
+ })
126
+ .superRefine((data, ctx) => {
127
+ if (data.targetDir && data.scope !== "repo" && data.scope !== "workspace") {
128
+ ctx.addIssue({
129
+ code: "custom",
130
+ path: ["targetDir"],
131
+ message: 'targetDir is only supported for scope "repo" or "workspace"',
132
+ });
133
+ }
107
134
  });
108
135
  export const PermissionsEntrySchema = z.object({
109
136
  name: nameField,
@@ -114,6 +141,13 @@ export const PermissionsEntrySchema = z.object({
114
141
  // For opencode: { permissions: { allow?: string[], ask?: string[], deny?: string[] } }
115
142
  config: z.record(z.string(), z.unknown()),
116
143
  });
144
+ export const ExecutionConfigEntrySchema = z.object({
145
+ name: nameField,
146
+ agents: agentsField,
147
+ // Raw execution config payload validated per agent by the execution-config adapter.
148
+ // For gemini-cli: { safeMode?: boolean, ... }
149
+ config: z.record(z.string(), z.unknown()),
150
+ });
117
151
  export const AgentDefinitionEntrySchema = z.object({
118
152
  name: nameField,
119
153
  agents: agentsField,
@@ -130,7 +164,8 @@ export const HookEntrySchema = z.object({
130
164
  name: nameField,
131
165
  agents: agentsField,
132
166
  // Raw hook config payload validated per agent by the hooks adapter.
133
- // Supports lifecycle-binding hooks (e.g. pre-exec, post-exec) for various agents.
167
+ // For claude-code: { hooks: { "<EventName>": [{ matcher?: string, hooks: [{ type: "command", command: string }] }] } }
168
+ // Event names follow Claude Code's settings.json hooks surface (e.g. PreToolUse, PostToolUse, Notification, Stop, SubagentStop).
134
169
  config: z.record(z.string(), z.unknown()),
135
170
  });
136
171
  export const ManifestSchema = z.object({
@@ -154,6 +189,7 @@ export const ManifestSchema = z.object({
154
189
  permissions: z.array(PermissionsEntrySchema).default([]),
155
190
  agentDefinitions: z.array(AgentDefinitionEntrySchema).default([]),
156
191
  hooks: z.array(HookEntrySchema).optional(),
192
+ executionConfigs: z.array(ExecutionConfigEntrySchema).optional(),
157
193
  });
158
194
  // Parses the --agents CLI flag: comma-separated agent IDs → AgentId[]
159
195
  export const AgentListSchema = z
@@ -1,11 +1,11 @@
1
1
  import type { AgentId } from "./schemas/manifest.ts";
2
- export type { AgentDefinitionEntry, AgentId, ConfigEntry, FileEntry, HookEntry, Manifest, SkillEntry, } from "./schemas/manifest.ts";
2
+ export type { AgentDefinitionEntry, AgentId, ConfigEntry, ExecutionConfigEntry, FileEntry, HookEntry, Manifest, SkillEntry, } from "./schemas/manifest.ts";
3
3
  export interface AgentPaths {
4
4
  posix: string[];
5
5
  windows: string[];
6
6
  }
7
7
  export type Confidence = "documented" | "implementation-only" | "provisional";
8
- export type CapabilityKind = "skills" | "mcpServers" | "agentRules" | "permissions" | "hooks" | "agentDefinitions";
8
+ export type CapabilityKind = "skills" | "mcpServers" | "agentRules" | "permissions" | "hooks" | "executionConfigs" | "agentDefinitions";
9
9
  export interface SupportedAgentSurface {
10
10
  status: "supported";
11
11
  /**
@@ -68,6 +68,7 @@ export interface AgentProvenance {
68
68
  agentRules?: Confidence;
69
69
  permissions?: Confidence;
70
70
  hooks?: Confidence;
71
+ executionConfig?: Confidence;
71
72
  agentDefinitions?: Confidence;
72
73
  }
73
74
  export interface AgentConfig {
@@ -97,6 +98,8 @@ export interface AgentConfig {
97
98
  agentRulesSupport?: AgentSurfaceSupport;
98
99
  agentRulesRepoSupport?: AgentSurfaceSupport;
99
100
  agentRulesWorkspaceSupport?: AgentSurfaceSupport;
101
+ agentRulesCopilotRepoSupport?: AgentSurfaceSupport;
102
+ agentRulesCopilotScopedSupport?: AgentSurfaceSupport;
100
103
  permissionsSupport?: AgentSurfaceSupport;
101
104
  hooksSupport?: AgentSurfaceSupport;
102
105
  hooksRepoSupport?: AgentSurfaceSupport;
@@ -106,6 +109,7 @@ export interface AgentConfig {
106
109
  agentDefinitionsWorkspaceSupport?: AgentSurfaceSupport;
107
110
  agentDefinitionsTomlSupport?: AgentSurfaceSupport;
108
111
  agentDefinitionsTomlRepoSupport?: AgentSurfaceSupport;
112
+ executionConfigSupport?: AgentSurfaceSupport;
109
113
  mcpDevcontainerSupport?: AgentSurfaceSupport;
110
114
  mcpAgentFrontmatterSupport?: AgentSurfaceSupport;
111
115
  policyNote?: string;
@@ -3,8 +3,9 @@ import { realpath, rm, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { describe, it } from "node:test";
5
5
  import { compileMcpServerActions } from "../../src/core/adapters/mcp.js";
6
+ import { compileHookActions } from "../../src/core/adapters/hooks.js";
6
7
  import { compilePermissionsActions } from "../../src/core/adapters/permissions.js";
7
- import { compileAgentRuleActions } from "../../src/core/adapters/rules.js";
8
+ import { compileAgentRuleActions, compileAgentRuleReverts, } from "../../src/core/adapters/rules.js";
8
9
  import { makeTmpDir } from "../helpers/fs.js";
9
10
  import { assertPathEndsWith, normalizeSlashes } from "../helpers/path.js";
10
11
  describe("compileMcpServerActions", () => {
@@ -354,6 +355,115 @@ describe("compileAgentRuleActions", () => {
354
355
  await rm(dir, { recursive: true });
355
356
  }
356
357
  });
358
+ it("scope copilot-repo: returns file-write action targeting {repo}/.github/copilot-instructions.md", async () => {
359
+ const dir = await makeTmpDir();
360
+ try {
361
+ await writeFile(path.join(dir, "copilot.md"), "# Copilot rules");
362
+ const repo = "/repo/myproject";
363
+ const realRoot = await realpath(dir);
364
+ const { actions, warnings } = await compileAgentRuleActions({
365
+ name: "copilot-main",
366
+ agents: ["github-copilot"],
367
+ path: "copilot.md",
368
+ scope: "copilot-repo",
369
+ }, dir, dir, realRoot, ["github-copilot"], "/home/test", repo);
370
+ assert.equal(actions.length, 1);
371
+ assert.equal(warnings.length, 0);
372
+ const action = actions[0];
373
+ assert.equal(action.kind, "file-write");
374
+ assert.equal(action.agent, "github-copilot");
375
+ assert.equal(normalizeSlashes(action.target), `${repo}/.github/copilot-instructions.md`, `expected target at {repo}/.github/copilot-instructions.md, got: ${action.target}`);
376
+ assert.equal(action.confidence, "documented");
377
+ }
378
+ finally {
379
+ await rm(dir, { recursive: true });
380
+ }
381
+ });
382
+ it("scope copilot-scoped: returns file-write action with {name} substituted in path", async () => {
383
+ const dir = await makeTmpDir();
384
+ try {
385
+ await writeFile(path.join(dir, "typescript.md"), "# TypeScript rules");
386
+ const repo = "/repo/myproject";
387
+ const realRoot = await realpath(dir);
388
+ const { actions, warnings } = await compileAgentRuleActions({
389
+ name: "typescript",
390
+ agents: ["github-copilot"],
391
+ path: "typescript.md",
392
+ scope: "copilot-scoped",
393
+ }, dir, dir, realRoot, ["github-copilot"], "/home/test", repo);
394
+ assert.equal(actions.length, 1);
395
+ assert.equal(warnings.length, 0);
396
+ const action = actions[0];
397
+ assert.equal(action.kind, "file-write");
398
+ assert.equal(action.agent, "github-copilot");
399
+ assert.equal(normalizeSlashes(action.target), `${repo}/.github/instructions/typescript.instructions.md`, `expected target at {repo}/.github/instructions/typescript.instructions.md, got: ${action.target}`);
400
+ assert.equal(action.confidence, "documented");
401
+ }
402
+ finally {
403
+ await rm(dir, { recursive: true });
404
+ }
405
+ });
406
+ it("scope copilot-repo: returns warning when no repo path provided", async () => {
407
+ const dir = await makeTmpDir();
408
+ try {
409
+ await writeFile(path.join(dir, "copilot.md"), "# Copilot rules");
410
+ const realRoot = await realpath(dir);
411
+ const { actions, warnings } = await compileAgentRuleActions({
412
+ name: "copilot-main",
413
+ agents: ["github-copilot"],
414
+ path: "copilot.md",
415
+ scope: "copilot-repo",
416
+ }, dir, dir, realRoot, ["github-copilot"], "/home/test");
417
+ assert.equal(actions.length, 0);
418
+ assert.equal(warnings.length, 1);
419
+ assert.equal(warnings[0]?.kind, "confidence");
420
+ assert.match(warnings[0]?.message ?? "", /copilot-repo/);
421
+ assert.match(warnings[0]?.message ?? "", /repository path/);
422
+ }
423
+ finally {
424
+ await rm(dir, { recursive: true });
425
+ }
426
+ });
427
+ it("scope copilot-repo: non-github-copilot agents get unsupported warning", async () => {
428
+ const dir = await makeTmpDir();
429
+ try {
430
+ await writeFile(path.join(dir, "copilot.md"), "# Copilot rules");
431
+ const realRoot = await realpath(dir);
432
+ const { actions, warnings } = await compileAgentRuleActions({
433
+ name: "copilot-main",
434
+ agents: ["claude-code"],
435
+ path: "copilot.md",
436
+ scope: "copilot-repo",
437
+ }, dir, dir, realRoot, ["claude-code"], "/home/test", "/repo/test");
438
+ assert.equal(actions.length, 0);
439
+ assert.equal(warnings.length, 1);
440
+ assert.equal(warnings[0]?.kind, "confidence");
441
+ }
442
+ finally {
443
+ await rm(dir, { recursive: true });
444
+ }
445
+ });
446
+ it("scope copilot-repo: plain markdown without frontmatter succeeds (no instructionFrontmatterRequired check)", async () => {
447
+ const dir = await makeTmpDir();
448
+ try {
449
+ // Plain markdown with no frontmatter — should NOT throw despite github-copilot
450
+ // having instructionFrontmatterRequired: true (native agentRules scopes skip that check)
451
+ await writeFile(path.join(dir, "plain.md"), "# Plain rules\n\nNo frontmatter.");
452
+ const repo = "/repo/myproject";
453
+ const realRoot = await realpath(dir);
454
+ const { actions, warnings } = await compileAgentRuleActions({
455
+ name: "plain-rules",
456
+ agents: ["github-copilot"],
457
+ path: "plain.md",
458
+ scope: "copilot-repo",
459
+ }, dir, dir, realRoot, ["github-copilot"], "/home/test", repo);
460
+ assert.equal(actions.length, 1);
461
+ assert.equal(warnings.length, 0);
462
+ }
463
+ finally {
464
+ await rm(dir, { recursive: true });
465
+ }
466
+ });
357
467
  it("throws when rules source file does not exist", async () => {
358
468
  const dir = await makeTmpDir();
359
469
  try {
@@ -469,6 +579,29 @@ describe("compileAgentRuleActions", () => {
469
579
  await rm(dir, { recursive: true });
470
580
  }
471
581
  });
582
+ it("scope repo with targetDir: injects targetDir into path for claude-code", async () => {
583
+ const dir = await makeTmpDir();
584
+ try {
585
+ const rulesFile = path.join(dir, "CLAUDE.md");
586
+ await writeFile(rulesFile, "# Rules");
587
+ const repo = "/repo/myproject";
588
+ const realRoot = await realpath(dir);
589
+ const { actions, warnings } = await compileAgentRuleActions({
590
+ name: "my-rule",
591
+ agents: ["claude-code"],
592
+ path: "CLAUDE.md",
593
+ scope: "repo",
594
+ targetDir: "apps/frontend",
595
+ }, dir, dir, realRoot, ["claude-code"], "/home/test", repo);
596
+ assert.equal(actions.length, 1);
597
+ assert.equal(warnings.length, 0);
598
+ const action = actions[0];
599
+ assert.equal(normalizeSlashes(action.target), `${repo}/apps/frontend/CLAUDE.md`, `expected target at {repo}/apps/frontend/CLAUDE.md, got: ${action.target}`);
600
+ }
601
+ finally {
602
+ await rm(dir, { recursive: true });
603
+ }
604
+ });
472
605
  it("scope repo: emits a warning and skips when repo path is not provided", async () => {
473
606
  const dir = await makeTmpDir();
474
607
  try {
@@ -552,6 +685,21 @@ describe("compileAgentRuleActions", () => {
552
685
  }
553
686
  });
554
687
  });
688
+ describe("compileAgentRuleReverts", () => {
689
+ it("injects targetDir into revert path", () => {
690
+ const home = "/home/test";
691
+ const repo = "/repo/test";
692
+ const actions = compileAgentRuleReverts({
693
+ name: "my-rule",
694
+ agents: ["claude-code"],
695
+ path: "CLAUDE.md",
696
+ scope: "repo",
697
+ targetDir: "apps/frontend",
698
+ }, ["claude-code"], home, repo);
699
+ assert.equal(actions.length, 1);
700
+ assert.equal(normalizeSlashes(actions[0]?.target ?? ""), `${repo}/apps/frontend/CLAUDE.md`);
701
+ });
702
+ });
555
703
  describe("compilePermissionsActions", () => {
556
704
  it("returns zero actions and warnings when no detected agents overlap", () => {
557
705
  const { actions, warnings } = compilePermissionsActions({
@@ -683,6 +831,121 @@ describe("compilePermissionsActions", () => {
683
831
  assert.ok(agents.includes("codex"));
684
832
  });
685
833
  });
834
+ describe("compileHookActions", () => {
835
+ const validClaudeHookConfig = {
836
+ hooks: {
837
+ PreToolUse: [
838
+ {
839
+ matcher: "Bash",
840
+ hooks: [{ type: "command", command: "scripts/check.sh" }],
841
+ },
842
+ ],
843
+ },
844
+ };
845
+ it("returns zero actions and warnings when no detected agents overlap", () => {
846
+ const { actions, warnings } = compileHookActions({ name: "check", agents: ["claude-code"], config: validClaudeHookConfig }, ["codex"], "/home/test");
847
+ assert.equal(actions.length, 0);
848
+ assert.equal(warnings.length, 0);
849
+ });
850
+ it("returns a config-patch action for claude-code targeting settings.json", () => {
851
+ const home = "/home/test";
852
+ const { actions, warnings } = compileHookActions({ name: "check", agents: ["claude-code"], config: validClaudeHookConfig }, ["claude-code"], home);
853
+ assert.equal(warnings.length, 0);
854
+ assert.equal(actions.length, 1);
855
+ const action = actions[0];
856
+ assert.equal(action.kind, "config-patch");
857
+ assert.ok(action.target.endsWith(".claude/settings.json".replace("/", path.sep)));
858
+ assert.equal(action.agent, "claude-code");
859
+ assert.equal(action.confidence, "documented");
860
+ });
861
+ it("accepts a hooks entry with no matcher property on the matcher object", () => {
862
+ const { actions, warnings } = compileHookActions({
863
+ name: "check",
864
+ agents: ["claude-code"],
865
+ config: {
866
+ hooks: {
867
+ Stop: [{ hooks: [{ type: "command", command: "notify.sh" }] }],
868
+ },
869
+ },
870
+ }, ["claude-code"], "/home/test");
871
+ assert.equal(warnings.length, 0);
872
+ assert.equal(actions.length, 1);
873
+ });
874
+ it("emits a warning and skips for agents without a hooks surface", () => {
875
+ const { actions, warnings } = compileHookActions({ name: "check", agents: ["gemini-cli"], config: {} }, ["gemini-cli"], "/home/test");
876
+ assert.equal(actions.length, 0);
877
+ assert.equal(warnings.length, 1);
878
+ });
879
+ it("throws on unknown top-level key in config for claude-code", () => {
880
+ assert.throws(() => compileHookActions({ name: "bad", agents: ["claude-code"], config: { bad_key: {} } }, ["claude-code"], "/home/test"), /unrecognized keys/);
881
+ });
882
+ it("throws when hooks value is not an object for claude-code", () => {
883
+ assert.throws(() => compileHookActions({ name: "bad", agents: ["claude-code"], config: { hooks: "string" } }, ["claude-code"], "/home/test"), /must define "hooks" as an object/);
884
+ });
885
+ it("throws when an event value is not an array", () => {
886
+ assert.throws(() => compileHookActions({
887
+ name: "bad",
888
+ agents: ["claude-code"],
889
+ config: { hooks: { PreToolUse: "not-array" } },
890
+ }, ["claude-code"], "/home/test"), /must be an array/);
891
+ });
892
+ it("throws when a matcher element is not an object", () => {
893
+ assert.throws(() => compileHookActions({
894
+ name: "bad",
895
+ agents: ["claude-code"],
896
+ config: { hooks: { PreToolUse: ["string"] } },
897
+ }, ["claude-code"], "/home/test"), /must be an object/);
898
+ });
899
+ it("throws when matcher.hooks is missing", () => {
900
+ assert.throws(() => compileHookActions({
901
+ name: "bad",
902
+ agents: ["claude-code"],
903
+ config: { hooks: { PreToolUse: [{ matcher: "Bash" }] } },
904
+ }, ["claude-code"], "/home/test"), /must be an array/);
905
+ });
906
+ it("throws when a hook command type is not 'command'", () => {
907
+ assert.throws(() => compileHookActions({
908
+ name: "bad",
909
+ agents: ["claude-code"],
910
+ config: {
911
+ hooks: {
912
+ PreToolUse: [
913
+ {
914
+ hooks: [{ type: "script", command: "check.sh" }],
915
+ },
916
+ ],
917
+ },
918
+ },
919
+ }, ["claude-code"], "/home/test"), /must be "command"/);
920
+ });
921
+ it("throws when a hook command is not a string", () => {
922
+ assert.throws(() => compileHookActions({
923
+ name: "bad",
924
+ agents: ["claude-code"],
925
+ config: {
926
+ hooks: {
927
+ PreToolUse: [{ hooks: [{ type: "command", command: 123 }] }],
928
+ },
929
+ },
930
+ }, ["claude-code"], "/home/test"), /must be a string/);
931
+ });
932
+ it("throws when matcher.matcher is not a string", () => {
933
+ assert.throws(() => compileHookActions({
934
+ name: "bad",
935
+ agents: ["claude-code"],
936
+ config: {
937
+ hooks: {
938
+ PreToolUse: [
939
+ {
940
+ matcher: 42,
941
+ hooks: [{ type: "command", command: "check.sh" }],
942
+ },
943
+ ],
944
+ },
945
+ },
946
+ }, ["claude-code"], "/home/test"), /must be a string when present/);
947
+ });
948
+ });
686
949
  describe("compileAgentDefinitionActions", () => {
687
950
  // Import is added inline to avoid modifying the top-level imports block
688
951
  // (the function is async so we do a dynamic import once and reuse).