@kuznai/inception-engine 0.24.0 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,20 +12,22 @@ export async function loadManifest(directory) {
12
12
  catch (err) {
13
13
  const code = err.code;
14
14
  if (code === "ENOENT") {
15
- throw new UserError("MANIFEST_INVALID", `No inception.json found in ${directory}. Are you pointing to the right repo?`);
15
+ throw new UserError("MANIFEST_INVALID", `No inception.json found in ${directory}. Are you pointing to the right repo?`, { cause: err });
16
16
  }
17
17
  if (code === "EACCES" || code === "EPERM") {
18
- throw new UserError("MANIFEST_INVALID", `Permission denied reading ${manifestPath}. Check file permissions.`);
18
+ throw new UserError("MANIFEST_INVALID", `Permission denied reading ${manifestPath}. Check file permissions.`, { cause: err });
19
19
  }
20
20
  const detail = err instanceof Error ? err.message : String(err);
21
- throw new UserError("MANIFEST_INVALID", `Failed to read ${manifestPath}: ${detail}`);
21
+ throw new UserError("MANIFEST_INVALID", `Failed to read ${manifestPath}: ${detail}`, { cause: err });
22
22
  }
23
23
  let parsed;
24
24
  try {
25
25
  parsed = JSON.parse(raw);
26
26
  }
27
- catch {
28
- throw new UserError("MANIFEST_INVALID", `Invalid JSON in ${manifestPath}`);
27
+ catch (err) {
28
+ throw new UserError("MANIFEST_INVALID", `Invalid JSON in ${manifestPath}`, {
29
+ cause: err,
30
+ });
29
31
  }
30
32
  return validateManifest(parsed, manifestPath);
31
33
  }
@@ -1,6 +1,6 @@
1
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
- import path from "node:path";
1
+ import { readFile } from "node:fs/promises";
3
2
  import YAML from "yaml";
3
+ import { writeFileAtomic } from "../atomic-write.js";
4
4
  /**
5
5
  * Splits a Markdown string into YAML frontmatter and the remaining body.
6
6
  * Expects the file to start with --- delimiter.
@@ -54,11 +54,6 @@ export function buildMarkdownDocument(frontmatter, body = "", options = {}) {
54
54
  const separator = cleanBody ? "\n\n" : "\n";
55
55
  return `---\n${serialized}\n---\n${separator}${cleanBody}`;
56
56
  }
57
- function createAtomicTempPath(targetPath) {
58
- return `${targetPath}.inception-tmp-${process.pid}-${Date.now()}-${Math.random()
59
- .toString(36)
60
- .slice(2)}`;
61
- }
62
57
  /**
63
58
  * Reads an existing `.md` file and parses its frontmatter.
64
59
  * Returns `{ attributes: {}, body: "" }` if the file does not exist.
@@ -98,19 +93,5 @@ export async function writeFrontmatterFile(filePath, frontmatter, options = {})
98
93
  body = existing.body;
99
94
  }
100
95
  const content = buildFrontmatterDocument(frontmatter, body);
101
- const tempPath = createAtomicTempPath(filePath);
102
- try {
103
- await mkdir(path.dirname(filePath), { recursive: true });
104
- await writeFile(tempPath, content, "utf-8");
105
- await rename(tempPath, filePath);
106
- }
107
- catch (err) {
108
- try {
109
- await rm(tempPath, { force: true });
110
- }
111
- catch {
112
- /* best-effort cleanup */
113
- }
114
- throw err;
115
- }
96
+ await writeFileAtomic(filePath, content);
116
97
  }
@@ -1,6 +1,6 @@
1
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
- import path from "node:path";
1
+ import { readFile } from "node:fs/promises";
3
2
  import { parse, stringify } from "smol-toml";
3
+ import { writeFileAtomic } from "../atomic-write.js";
4
4
  /**
5
5
  * Reads and parses a TOML file. Returns an empty object if the file does not
6
6
  * exist (treat a missing config.toml as an empty config).
@@ -19,27 +19,8 @@ export async function readTomlConfig(filePath) {
19
19
  const parsed = parse(raw);
20
20
  return parsed;
21
21
  }
22
- function createAtomicTempPath(targetPath) {
23
- return `${targetPath}.inception-tmp-${process.pid}-${Date.now()}-${Math.random()
24
- .toString(36)
25
- .slice(2)}`;
26
- }
27
22
  async function writeTomlConfigAtomic(filePath, obj) {
28
- const tempPath = createAtomicTempPath(filePath);
29
- try {
30
- await mkdir(path.dirname(filePath), { recursive: true });
31
- await writeFile(tempPath, stringify(obj), "utf-8");
32
- await rename(tempPath, filePath);
33
- }
34
- catch (err) {
35
- try {
36
- await rm(tempPath, { force: true });
37
- }
38
- catch {
39
- /* best-effort cleanup */
40
- }
41
- throw err;
42
- }
23
+ await writeFileAtomic(filePath, stringify(obj));
43
24
  }
44
25
  /**
45
26
  * Merges a named MCP server entry into `config.toml`'s `[mcpServers]` table.
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Writes `content` to `targetPath` atomically by staging through a temp file
3
+ * in the same directory and renaming into place. The temp file is cleaned up
4
+ * on error. Parent directory is created if absent.
5
+ *
6
+ * On Windows, `fs.rename` throws EPERM when the target file already exists.
7
+ * In that case we fall back to `cp` + `unlink` of the temp file, which is not
8
+ * atomic but is the best available option without third-party packages.
9
+ */
10
+ export declare function writeFileAtomic(targetPath: string, content: string, options?: {
11
+ encoding?: BufferEncoding;
12
+ }): Promise<void>;
@@ -0,0 +1,42 @@
1
+ import { cp, mkdir, rename, unlink, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ /**
4
+ * Writes `content` to `targetPath` atomically by staging through a temp file
5
+ * in the same directory and renaming into place. The temp file is cleaned up
6
+ * on error. Parent directory is created if absent.
7
+ *
8
+ * On Windows, `fs.rename` throws EPERM when the target file already exists.
9
+ * In that case we fall back to `cp` + `unlink` of the temp file, which is not
10
+ * atomic but is the best available option without third-party packages.
11
+ */
12
+ export async function writeFileAtomic(targetPath, content, options) {
13
+ const dir = path.dirname(targetPath);
14
+ const tempPath = path.join(dir, `.inception-tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
15
+ await mkdir(dir, { recursive: true });
16
+ try {
17
+ await writeFile(tempPath, content, options?.encoding ?? "utf-8");
18
+ try {
19
+ await rename(tempPath, targetPath);
20
+ }
21
+ catch (err) {
22
+ // On Windows, rename fails with EPERM when the target already exists.
23
+ if (process.platform === "win32" &&
24
+ (err.code === "EPERM" ||
25
+ err.code === "EBUSY")) {
26
+ await cp(tempPath, targetPath);
27
+ await unlink(tempPath);
28
+ return;
29
+ }
30
+ throw err;
31
+ }
32
+ }
33
+ catch (err) {
34
+ try {
35
+ await unlink(tempPath);
36
+ }
37
+ catch {
38
+ /* best-effort cleanup */
39
+ }
40
+ throw err;
41
+ }
42
+ }
@@ -1,10 +1,10 @@
1
1
  import type { AgentId, DeployAction, Manifest, PlannedChange, PlanWarning, SkillDirDeployAction } from "../types.ts";
2
2
  import { type RegistryPersistence } from "./ownership.ts";
3
- export declare function planDeploy(manifest: Manifest, sourceDir: string, detectedAgents: AgentId[], home: string, repo?: string, workspace?: string): Promise<{
3
+ export declare function planDeploy(manifest: Manifest, sourceDir: string, detectedAgents: AgentId[], home: string, repo?: string, workspace?: string, signal?: AbortSignal): Promise<{
4
4
  actions: DeployAction[];
5
5
  warnings: PlanWarning[];
6
6
  }>;
7
- export declare function executeDeploy(actions: DeployAction[], dryRun: boolean, verbose: boolean, home: string, deps?: DeployDependencies): Promise<{
7
+ export declare function executeDeploy(actions: DeployAction[], dryRun: boolean, verbose: boolean, home: string, deps?: DeployDependencies, signal?: AbortSignal): Promise<{
8
8
  succeeded: number;
9
9
  failed: Array<{
10
10
  action: DeployAction;
@@ -9,7 +9,7 @@ import { compileAdapterActions } from "./adapters/index.js";
9
9
  import { applyTomlMcpPatch } from "./adapters/toml.js";
10
10
  import { planCapabilityForDeploy } from "./capabilities.js";
11
11
  import { applyMergePatch, computeUndoPatch, isPlainObject, } from "./merge-patch.js";
12
- import { lookupDeployment, registryDirPath, registerDeployment, verifyDeployment, } from "./ownership.js";
12
+ import { defaultRegistryPersistence, lookupDeployment, RunRegistry, registerDeployment, registryDirPath, verifyDeployment, } from "./ownership.js";
13
13
  import { getDeployMethod, resolveAgentSkillPath } from "./resolve.js";
14
14
  import { resolveTargetTemplate } from "./runtime-paths.js";
15
15
  import { sourceAccessError, validateSkillDefinitionFile, validateSourceFile, validateSourcePath, } from "./validation.js";
@@ -21,18 +21,18 @@ async function readJsonConfigFile(filePath) {
21
21
  catch (err) {
22
22
  const code = err.code;
23
23
  if (code === "ENOENT")
24
- throw new Error(`Config file not found: ${filePath}`);
24
+ throw new UserError("DEPLOY_FAILED", `Config file not found: ${filePath}`, { cause: err });
25
25
  throw err;
26
26
  }
27
27
  let parsed;
28
28
  try {
29
29
  parsed = JSON.parse(rawContent);
30
30
  }
31
- catch {
32
- throw new Error(`Config file is not valid JSON: ${filePath}`);
31
+ catch (err) {
32
+ throw new UserError("DEPLOY_FAILED", `Config file is not valid JSON: ${filePath}`, { cause: err });
33
33
  }
34
34
  if (!isPlainObject(parsed)) {
35
- throw new Error(`Config file is not a JSON object: ${filePath}`);
35
+ throw new UserError("DEPLOY_FAILED", `Config file is not a JSON object: ${filePath}`);
36
36
  }
37
37
  return parsed;
38
38
  }
@@ -324,7 +324,7 @@ function planConfigPatchActions(manifest, detectedAgents, home, repo, workspace)
324
324
  }
325
325
  return actions;
326
326
  }
327
- export async function planDeploy(manifest, sourceDir, detectedAgents, home, repo, workspace) {
327
+ export async function planDeploy(manifest, sourceDir, detectedAgents, home, repo, workspace, signal) {
328
328
  assertNoAntigravityPathCollisions(manifest);
329
329
  const resolvedSourceDir = path.resolve(sourceDir);
330
330
  let realRoot;
@@ -336,11 +336,15 @@ export async function planDeploy(manifest, sourceDir, detectedAgents, home, repo
336
336
  }
337
337
  const repoDir = repo ?? realRoot;
338
338
  const skillPlan = await planSkillDirActions(manifest, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home);
339
+ if (signal?.aborted)
340
+ return { actions: [], warnings: [] };
339
341
  const actions = [
340
342
  ...skillPlan.actions,
341
343
  ...(await planFileWriteActions(manifest, sourceDir, resolvedSourceDir, realRoot, detectedAgents, home, repoDir, workspace)),
342
344
  ...planConfigPatchActions(manifest, detectedAgents, home, repoDir, workspace),
343
345
  ];
346
+ if (signal?.aborted)
347
+ return { actions: [], warnings: [] };
344
348
  const adapterResult = await compileAdapterActions(manifest.mcpServers, manifest.agentRules, manifest.permissions ?? [], sourceDir, resolvedSourceDir, realRoot, detectedAgents, home, repoDir, manifest.agentDefinitions ?? [], workspace, manifest.hooks ?? [], manifest.executionConfigs ?? []);
345
349
  actions.push(...adapterResult.actions);
346
350
  const warnings = [
@@ -355,12 +359,32 @@ export async function planDeploy(manifest, sourceDir, detectedAgents, home, repo
355
359
  }
356
360
  return { actions, warnings };
357
361
  }
358
- export async function executeDeploy(actions, dryRun, verbose, home, deps = {}) {
362
+ export async function executeDeploy(actions, dryRun, verbose, home, deps = {}, signal) {
359
363
  let succeeded = 0;
360
364
  const failed = [];
361
365
  const planned = [];
366
+ const runRegistry = new RunRegistry(deps.registry ?? defaultRegistryPersistence);
367
+ const depsWithRegistry = {
368
+ ...deps,
369
+ registry: runRegistry,
370
+ };
371
+ if (!dryRun) {
372
+ try {
373
+ await runRegistry.preflight(home);
374
+ }
375
+ catch (err) {
376
+ const message = err instanceof Error ? err.message : String(err);
377
+ return {
378
+ succeeded: 0,
379
+ failed: actions.map((action) => ({ action, error: message })),
380
+ planned,
381
+ };
382
+ }
383
+ }
362
384
  for (const action of actions) {
363
- const result = await dispatchDeployAction(action, dryRun, verbose, home, planned, deps);
385
+ if (signal?.aborted)
386
+ break;
387
+ const result = await dispatchDeployAction(action, dryRun, verbose, home, planned, depsWithRegistry);
364
388
  if (result.error === null) {
365
389
  succeeded++;
366
390
  }
@@ -368,6 +392,9 @@ export async function executeDeploy(actions, dryRun, verbose, home, deps = {}) {
368
392
  failed.push({ action, error: result.error });
369
393
  }
370
394
  }
395
+ if (!dryRun) {
396
+ await runRegistry.flush(home);
397
+ }
371
398
  return { succeeded, failed, planned };
372
399
  }
373
400
  async function dispatchDeployAction(action, dryRun, verbose, home, planned, deps) {
@@ -434,7 +461,7 @@ async function backupManagedFileWriteTarget(action, home, deps) {
434
461
  agent: action.agent,
435
462
  }, deps.registry);
436
463
  if (!isOwned) {
437
- throw new Error(`Target "${action.target}" exists but is not managed by inception-engine - refusing to overwrite`);
464
+ throw new UserError("DEPLOY_FAILED", `Target "${action.target}" exists but is not managed by inception-engine - refusing to overwrite`);
438
465
  }
439
466
  const backupPath = `${action.target}.inception-backup`;
440
467
  await (deps.fileOps ?? defaultDeployFileOps).rm(backupPath, {
@@ -592,7 +619,7 @@ async function deployConfigPatch(action, dryRun, verbose, home, planned, deps) {
592
619
  if (existingEntry &&
593
620
  (existingEntry.skill !== action.skill ||
594
621
  existingEntry.agent !== action.agent)) {
595
- throw new Error(`Config "${action.target}" is already patched by skill "${existingEntry.skill}" for agent "${existingEntry.agent}" - refusing to double-patch`);
622
+ throw new UserError("DEPLOY_FAILED", `Config "${action.target}" is already patched by skill "${existingEntry.skill}" for agent "${existingEntry.agent}" - refusing to double-patch`);
596
623
  }
597
624
  const original = await readJsonConfigFile(action.target);
598
625
  const undoPatch = computeUndoPatch(original, patch);
@@ -686,7 +713,7 @@ async function deployFrontmatterEmit(action, dryRun, verbose, home, planned, dep
686
713
  (existingEntry.kind !== "frontmatter-emit" ||
687
714
  existingEntry.skill !== action.skill ||
688
715
  existingEntry.agent !== action.agent)) {
689
- throw new Error(`Frontmatter target "${action.target}" is already patched by skill "${existingEntry.skill}" for agent "${existingEntry.agent}" - refusing to double-patch`);
716
+ throw new UserError("DEPLOY_FAILED", `Frontmatter target "${action.target}" is already patched by skill "${existingEntry.skill}" for agent "${existingEntry.agent}" - refusing to double-patch`);
690
717
  }
691
718
  const existing = await frontmatterAdapter.readFrontmatterDocumentFile(action.target);
692
719
  const undoPatch = computeUndoPatch(existing.attributes, action.frontmatter);
@@ -735,12 +762,12 @@ async function validateSkillContract(source, skillPath) {
735
762
  catch (err) {
736
763
  const code = err.code;
737
764
  if (code === "ENOENT") {
738
- throw new UserError("DEPLOY_FAILED", `Skill "${skillPath}" source not found: ${source}`);
765
+ throw new UserError("DEPLOY_FAILED", `Skill "${skillPath}" source not found: ${source}`, { cause: err });
739
766
  }
740
767
  if (code === "EACCES" || code === "EPERM") {
741
- throw new UserError("DEPLOY_FAILED", `Permission denied accessing skill "${skillPath}" source: ${source}`);
768
+ throw new UserError("DEPLOY_FAILED", `Permission denied accessing skill "${skillPath}" source: ${source}`, { cause: err });
742
769
  }
743
- throw new UserError("DEPLOY_FAILED", `Cannot access skill "${skillPath}" source: ${source}`);
770
+ throw new UserError("DEPLOY_FAILED", `Cannot access skill "${skillPath}" source: ${source}`, { cause: err });
744
771
  }
745
772
  if (!stat.isDirectory()) {
746
773
  throw new UserError("DEPLOY_FAILED", `Skill "${skillPath}" source is not a directory: ${source}`);
@@ -751,9 +778,9 @@ async function validateSkillContract(source, skillPath) {
751
778
  catch (err) {
752
779
  const code = err.code;
753
780
  if (code === "EACCES" || code === "EPERM") {
754
- throw new UserError("DEPLOY_FAILED", `Permission denied reading skill directory "${skillPath}": ${source}`);
781
+ throw new UserError("DEPLOY_FAILED", `Permission denied reading skill directory "${skillPath}": ${source}`, { cause: err });
755
782
  }
756
- throw new UserError("DEPLOY_FAILED", `Cannot read skill directory "${skillPath}": ${source}`);
783
+ throw new UserError("DEPLOY_FAILED", `Cannot read skill directory "${skillPath}": ${source}`, { cause: err });
757
784
  }
758
785
  try {
759
786
  await access(path.join(source, "SKILL.md"), constants.R_OK);
@@ -761,9 +788,9 @@ async function validateSkillContract(source, skillPath) {
761
788
  catch (err) {
762
789
  const code = err.code;
763
790
  if (code === "EACCES" || code === "EPERM") {
764
- throw new UserError("DEPLOY_FAILED", `Permission denied reading SKILL.md in skill "${skillPath}": ${source}`);
791
+ throw new UserError("DEPLOY_FAILED", `Permission denied reading SKILL.md in skill "${skillPath}": ${source}`, { cause: err });
765
792
  }
766
- throw new UserError("DEPLOY_FAILED", `Skill "${skillPath}" source is missing SKILL.md: ${source}`);
793
+ throw new UserError("DEPLOY_FAILED", `Skill "${skillPath}" source is missing SKILL.md: ${source}`, { cause: err });
767
794
  }
768
795
  await validateSkillDefinitionFile(path.join(source, "SKILL.md"), skillPath);
769
796
  }
@@ -838,7 +865,7 @@ async function backupExisting(targetPath, verbose, home, expected, deps) {
838
865
  skill: expected.skill,
839
866
  agent: expected.agent,
840
867
  }, deps.registry))) {
841
- throw new Error(`Target "${targetPath}" exists but is not managed by inception-engine - refusing to overwrite`);
868
+ throw new UserError("DEPLOY_FAILED", `Target "${targetPath}" exists but is not managed by inception-engine - refusing to overwrite`);
842
869
  }
843
870
  const backupPath = `${targetPath}.inception-backup`;
844
871
  if (verbose) {
@@ -5,5 +5,6 @@ export interface InitOptions {
5
5
  dryRun: boolean;
6
6
  force: boolean;
7
7
  verbose: boolean;
8
+ signal?: AbortSignal;
8
9
  }
9
10
  export declare function runInit(options: InitOptions): Promise<number>;
@@ -1,9 +1,10 @@
1
- import { access, readdir, readFile, writeFile } from "node:fs/promises";
1
+ import { access, readdir, readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { AGENT_REGISTRY } from "../config/agents.js";
4
4
  import { dryRunPrefix, logger } from "../logger.js";
5
5
  import { AGENT_IDS, AgentDefinitionEntrySchema, ConfigEntrySchema, FileEntrySchema, McpServerEntrySchema, } from "../schemas/manifest.js";
6
6
  import { parseFrontmatterDocument } from "./adapters/frontmatter.js";
7
+ import { writeFileAtomic } from "./atomic-write.js";
7
8
  import { shouldInitIncludeAgent } from "./capabilities.js";
8
9
  const SAFE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
9
10
  // Ordered list: first match wins. Catch-all is applied at call site.
@@ -78,7 +79,9 @@ async function hasAntigravityMcpFrontmatter(absPath) {
78
79
  return (Object.hasOwn(attributes, "mcp-servers") ||
79
80
  Object.hasOwn(attributes, "mcpServers"));
80
81
  }
81
- async function findSkillDirs(baseDir, dir, found) {
82
+ async function findSkillDirs(baseDir, dir, found, signal) {
83
+ if (signal?.aborted)
84
+ return;
82
85
  let entries;
83
86
  try {
84
87
  entries = await readdir(dir, { withFileTypes: true, encoding: "utf-8" });
@@ -100,8 +103,10 @@ async function findSkillDirs(baseDir, dir, found) {
100
103
  return;
101
104
  }
102
105
  for (const entry of entries) {
106
+ if (signal?.aborted)
107
+ return;
103
108
  if (entry.isDirectory() && !entry.name.startsWith(".")) {
104
- await findSkillDirs(baseDir, path.join(dir, entry.name), found);
109
+ await findSkillDirs(baseDir, path.join(dir, entry.name), found, signal);
105
110
  }
106
111
  }
107
112
  }
@@ -152,7 +157,9 @@ function deriveAgentRulesName(relPath, fileName) {
152
157
  }
153
158
  return rawName;
154
159
  }
155
- async function scanDirForMarkdown(dir, baseDir, skillDirRelPaths, seen, candidates) {
160
+ async function scanDirForMarkdown(dir, baseDir, skillDirRelPaths, seen, candidates, signal) {
161
+ if (signal?.aborted)
162
+ return;
156
163
  let entries;
157
164
  try {
158
165
  entries = await readdir(dir, { withFileTypes: true, encoding: "utf-8" });
@@ -177,12 +184,12 @@ async function scanDirForMarkdown(dir, baseDir, skillDirRelPaths, seen, candidat
177
184
  candidates.push({ relPath, name, defaultAgents: [] });
178
185
  }
179
186
  }
180
- async function findAgentRulesCandidates(baseDir, skillDirRelPaths) {
187
+ async function findAgentRulesCandidates(baseDir, skillDirRelPaths, signal) {
181
188
  const candidates = [];
182
189
  const seen = new Set();
183
- await scanDirForMarkdown(baseDir, baseDir, skillDirRelPaths, seen, candidates);
190
+ await scanDirForMarkdown(baseDir, baseDir, skillDirRelPaths, seen, candidates, signal);
184
191
  for (const subdir of AGENT_RULES_SUBDIRS) {
185
- await scanDirForMarkdown(path.join(baseDir, subdir), baseDir, skillDirRelPaths, seen, candidates);
192
+ await scanDirForMarkdown(path.join(baseDir, subdir), baseDir, skillDirRelPaths, seen, candidates, signal);
186
193
  }
187
194
  // Promote .github/copilot-instructions.md to the native copilot-repo scope.
188
195
  // The general scanDirForMarkdown pass above picks it up via AGENT_RULES_SUBDIRS
@@ -196,6 +203,8 @@ async function findAgentRulesCandidates(baseDir, skillDirRelPaths) {
196
203
  // Discover .github/instructions/*.instructions.md as copilot-scoped entries.
197
204
  // These are not covered by the general scan (it only looks for .md/.markdown
198
205
  // and the stem derivation would lose the .instructions suffix).
206
+ if (signal?.aborted)
207
+ return candidates;
199
208
  const instructionsDir = path.join(baseDir, ".github", "instructions");
200
209
  let instrEntries;
201
210
  try {
@@ -383,10 +392,12 @@ async function scanDefinitionSubdir(subdir, baseDir, seen, skillDirRelPaths, age
383
392
  candidates.push({ relPath, name, suggestedAgents });
384
393
  }
385
394
  }
386
- async function findAgentDefinitionCandidates(baseDir, skillDirRelPaths, agentRulesRelPaths) {
395
+ async function findAgentDefinitionCandidates(baseDir, skillDirRelPaths, agentRulesRelPaths, signal) {
387
396
  const candidates = [];
388
397
  const seen = new Set();
389
398
  for (const subdir of AGENT_DEFINITION_SUBDIRS) {
399
+ if (signal?.aborted)
400
+ break;
390
401
  await scanDefinitionSubdir(subdir, baseDir, seen, skillDirRelPaths, agentRulesRelPaths, candidates);
391
402
  }
392
403
  return candidates;
@@ -615,7 +626,7 @@ function logVerboseManifest(skills, agentRules, mcpServers, files, configs, agen
615
626
  }
616
627
  }
617
628
  export async function runInit(options) {
618
- const { directory, dryRun, force, verbose } = options;
629
+ const { directory, dryRun, force, verbose, signal } = options;
619
630
  const agents = options.agents ?? [...AGENT_IDS];
620
631
  const agentRulesCapableAgents = agents.filter((id) => shouldInitIncludeAgent(id, "agentRules", "global"));
621
632
  const manifestPath = path.join(directory, "inception.json");
@@ -624,21 +635,27 @@ export async function runInit(options) {
624
635
  return 2;
625
636
  }
626
637
  const found = [];
627
- await findSkillDirs(directory, directory, found);
638
+ await findSkillDirs(directory, directory, found, signal);
639
+ if (signal?.aborted)
640
+ return 0;
628
641
  if (found.length === 0) {
629
642
  logger.info("No skill directories found (looking for directories containing SKILL.md).");
630
643
  }
631
644
  const skills = buildSkills(found, agents);
632
645
  const skillNamesSeen = new Set(skills.map((s) => s.name));
633
646
  const skillDirRelPaths = new Set(found.map((f) => f.relPath));
634
- const agentRulesCandidates = await findAgentRulesCandidates(directory, skillDirRelPaths);
647
+ const agentRulesCandidates = await findAgentRulesCandidates(directory, skillDirRelPaths, signal);
648
+ if (signal?.aborted)
649
+ return 0;
635
650
  const agentRules = buildAgentRules(agentRulesCandidates, agentRulesCapableAgents, agents, skillNamesSeen);
636
651
  const allNamesSeen = new Set([
637
652
  ...skillNamesSeen,
638
653
  ...agentRules.map((r) => r.name),
639
654
  ]);
640
655
  const agentRulesRelPaths = new Set(agentRules.map((r) => r.path));
641
- const agentDefinitionCandidates = await findAgentDefinitionCandidates(directory, skillDirRelPaths, agentRulesRelPaths);
656
+ const agentDefinitionCandidates = await findAgentDefinitionCandidates(directory, skillDirRelPaths, agentRulesRelPaths, signal);
657
+ if (signal?.aborted)
658
+ return 0;
642
659
  const discoveredDefinitions = buildAgentDefinitions(agentDefinitionCandidates, agents, allNamesSeen);
643
660
  // Sidecar file overrides take precedence over auto-discovery
644
661
  const sidecarDefinitions = await loadAgentDefinitionsManifest(directory);
@@ -673,7 +690,7 @@ export async function runInit(options) {
673
690
  await emitDirectoryHints(directory, files.length, configs.length);
674
691
  return 0;
675
692
  }
676
- await writeFile(manifestPath, json, "utf-8");
693
+ await writeFileAtomic(manifestPath, json);
677
694
  logger.info(`Generated ${manifestPath} with ${summarize()}.`);
678
695
  if (verbose) {
679
696
  logVerboseManifest(skills, agentRules, mcpServers, files, configs, agentDefinitions);
@@ -23,6 +23,30 @@ export type VerifyExpected = {
23
23
  export declare function registryPath(home: string): string;
24
24
  export declare function registryDirPath(home: string): string;
25
25
  export declare const defaultRegistryPersistence: RegistryPersistence;
26
+ /**
27
+ * Per-run in-memory registry cache. Loads the registry from disk once on
28
+ * first access, buffers all save calls in memory, and writes to disk only
29
+ * when flush() is called explicitly at the end of a deploy or revert run.
30
+ *
31
+ * Implements RegistryPersistence so it can be passed as deps.registry to
32
+ * executeDeploy and executeRevert without touching any action-level callers.
33
+ */
34
+ export declare class RunRegistry implements RegistryPersistence {
35
+ private cache;
36
+ private dirty;
37
+ private readonly backing;
38
+ constructor(backing: RegistryPersistence);
39
+ load(home: string): Promise<Registry>;
40
+ save(_home: string, registry: Registry): Promise<void>;
41
+ /**
42
+ * Validate that the registry is writable before any actions run. Writes the
43
+ * current (possibly empty) registry state so that a backing store failure is
44
+ * detected upfront, before any filesystem changes are made by deploy or
45
+ * revert actions. Skipped in dry-run flows.
46
+ */
47
+ preflight(home: string): Promise<void>;
48
+ flush(home: string): Promise<void>;
49
+ }
26
50
  export type RegisterEntry = Omit<SkillDirRegistryEntry, "deployed"> | Omit<FileWriteRegistryEntry, "deployed"> | Omit<ConfigPatchRegistryEntry, "deployed"> | Omit<FrontmatterEmitRegistryEntry, "deployed">;
27
51
  export declare function registerDeployment(home: string, targetPath: string, entry: RegisterEntry, persistence?: RegistryPersistence): Promise<void>;
28
52
  export declare function unregisterDeployment(home: string, targetPath: string, persistence?: RegistryPersistence): Promise<void>;
@@ -1,6 +1,8 @@
1
- import { chmod, lstat, mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { chmod, lstat, mkdir, readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { UserError } from "../errors.js";
3
4
  import { RegistrySchema, } from "../schemas/registry.js";
5
+ import { writeFileAtomic } from "./atomic-write.js";
4
6
  const REGISTRY_DIR = ".inception-engine";
5
7
  const REGISTRY_FILE = "registry.json";
6
8
  export function registryPath(home) {
@@ -13,7 +15,7 @@ async function assertSafeRegistryStoragePath(targetPath, label) {
13
15
  try {
14
16
  const stat = await lstat(targetPath);
15
17
  if (stat.isSymbolicLink()) {
16
- throw new Error(`Refusing to use ${label} symlink: ${targetPath}`);
18
+ throw new UserError("DEPLOY_FAILED", `Refusing to use ${label} symlink: ${targetPath}`);
17
19
  }
18
20
  }
19
21
  catch (err) {
@@ -42,13 +44,55 @@ async function saveRegistry(home, registry) {
42
44
  await setDirectoryPermissions(dir);
43
45
  const filePath = registryPath(home);
44
46
  await assertSafeRegistryStoragePath(filePath, "registry file");
45
- await writeFile(filePath, `${JSON.stringify(registry, null, 2)}\n`);
47
+ await writeFileAtomic(filePath, `${JSON.stringify(registry, null, 2)}\n`);
46
48
  await setFilePermissions(filePath);
47
49
  }
48
50
  export const defaultRegistryPersistence = {
49
51
  load: loadRegistry,
50
52
  save: saveRegistry,
51
53
  };
54
+ /**
55
+ * Per-run in-memory registry cache. Loads the registry from disk once on
56
+ * first access, buffers all save calls in memory, and writes to disk only
57
+ * when flush() is called explicitly at the end of a deploy or revert run.
58
+ *
59
+ * Implements RegistryPersistence so it can be passed as deps.registry to
60
+ * executeDeploy and executeRevert without touching any action-level callers.
61
+ */
62
+ export class RunRegistry {
63
+ cache = null;
64
+ dirty = false;
65
+ backing;
66
+ constructor(backing) {
67
+ this.backing = backing;
68
+ }
69
+ async load(home) {
70
+ if (this.cache === null) {
71
+ this.cache = await this.backing.load(home);
72
+ }
73
+ return this.cache;
74
+ }
75
+ async save(_home, registry) {
76
+ this.cache = registry;
77
+ this.dirty = true;
78
+ }
79
+ /**
80
+ * Validate that the registry is writable before any actions run. Writes the
81
+ * current (possibly empty) registry state so that a backing store failure is
82
+ * detected upfront, before any filesystem changes are made by deploy or
83
+ * revert actions. Skipped in dry-run flows.
84
+ */
85
+ async preflight(home) {
86
+ const registry = await this.load(home);
87
+ await this.backing.save(home, registry);
88
+ }
89
+ async flush(home) {
90
+ if (this.dirty && this.cache !== null) {
91
+ await this.backing.save(home, this.cache);
92
+ this.dirty = false;
93
+ }
94
+ }
95
+ }
52
96
  /**
53
97
  * Restrict access to the state directory regardless of umask.
54
98
  * On Windows, the OS inherits ACLs from the parent directory — no-op is correct.
@@ -3,4 +3,4 @@ export interface PreflightWarning {
3
3
  kind: "policy" | "config-authority" | "info" | "precedence" | "budget";
4
4
  message: string;
5
5
  }
6
- export declare function runPreflight(options: CliOptions, manifest: Manifest, home: string, detectedAgents: AgentId[]): Promise<PreflightWarning[]>;
6
+ export declare function runPreflight(options: CliOptions, manifest: Manifest, home: string, detectedAgents: AgentId[], signal?: AbortSignal): Promise<PreflightWarning[]>;
@@ -312,13 +312,21 @@ async function collectAgentWarnings(agentId, manifest, home) {
312
312
  }
313
313
  return warnings;
314
314
  }
315
- export async function runPreflight(options, manifest, home, detectedAgents) {
315
+ export async function runPreflight(options, manifest, home, detectedAgents, signal) {
316
316
  const warnings = [];
317
317
  for (const agentId of detectedAgents) {
318
+ if (signal?.aborted)
319
+ return warnings;
318
320
  warnings.push(...(await collectAgentWarnings(agentId, manifest, home)));
319
321
  }
322
+ if (signal?.aborted)
323
+ return warnings;
320
324
  warnings.push(...detectCapabilityPlanningWarnings(manifest, detectedAgents));
325
+ if (signal?.aborted)
326
+ return warnings;
321
327
  warnings.push(...detectInstructionPrecedence(detectedAgents, manifest));
328
+ if (signal?.aborted)
329
+ return warnings;
322
330
  warnings.push(...(await detectInstructionBudgetRisk(detectedAgents, manifest, options.directory)));
323
331
  return warnings;
324
332
  }
@@ -89,7 +89,7 @@ export function resolveAgentSkillPathFor(agent, skillName, home, platform) {
89
89
  const hint = agent.skillsSurfaceKind?.kind === "shared-via"
90
90
  ? ` Deploy skills via the "${agent.skillsSurfaceKind.via}" target, which covers this agent natively.`
91
91
  : "";
92
- throw new Error(`Agent "${agent.id}" does not have a skills deployment path.${hint}`);
92
+ throw new UserError("RESOLVE_FAILED", `Agent "${agent.id}" does not have a skills deployment path.${hint}`);
93
93
  }
94
94
  return resolvePlaceholders(agent.skills[platform], skillName, home);
95
95
  }
@@ -2,7 +2,7 @@ import type { AgentId, Manifest, PlannedChange, RevertAction } from "../types.ts
2
2
  import { type RegistryPersistence } from "./ownership.ts";
3
3
  export declare function planRevert(manifest: Manifest, detectedAgents: AgentId[], home: string, repo?: string): RevertAction[];
4
4
  export declare function planRevertAll(manifest: Manifest, home: string, repo?: string): RevertAction[];
5
- export declare function executeRevert(actions: RevertAction[], dryRun: boolean, verbose: boolean, home: string, deps?: RevertDependencies): Promise<{
5
+ export declare function executeRevert(actions: RevertAction[], dryRun: boolean, verbose: boolean, home: string, deps?: RevertDependencies, signal?: AbortSignal): Promise<{
6
6
  succeeded: number;
7
7
  skipped: number;
8
8
  failed: Array<{