@llblab/pi-actors 0.57.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/AGENTS.md +9 -1
  2. package/BACKLOG.md +14 -1
  3. package/CHANGELOG.md +11 -0
  4. package/README.md +9 -1
  5. package/dist/lib/file-state.d.ts +1 -0
  6. package/dist/lib/file-state.js +8 -2
  7. package/dist/lib/prompts.d.ts +2 -1
  8. package/dist/lib/prompts.js +6 -2
  9. package/dist/lib/recipes-discovery.d.ts +4 -0
  10. package/dist/lib/recipes-discovery.js +22 -0
  11. package/dist/lib/recipes-references.d.ts +1 -0
  12. package/dist/lib/recipes-references.js +1 -1
  13. package/dist/lib/recipes-usage.js +12 -9
  14. package/dist/lib/registry.d.ts +5 -1
  15. package/dist/lib/registry.js +199 -7
  16. package/dist/lib/run-delivery.d.ts +1 -1
  17. package/dist/lib/run-delivery.js +146 -32
  18. package/dist/lib/tools-inspect.js +6 -3
  19. package/dist/lib/tools-register.js +1 -0
  20. package/dist/lib/tools-response.js +6 -1
  21. package/dist/skills/actors/SKILL.md +11 -2
  22. package/dist/skills/actors/references/persistent-tools.md +20 -2
  23. package/dist/skills/actors/references/runs.md +1 -1
  24. package/dist/skills/recipe-memory/SKILL.md +6 -0
  25. package/docs/README.md +1 -0
  26. package/docs/agent-experience.md +42 -3
  27. package/docs/capability-memory.md +166 -0
  28. package/docs/coordinator-delivery.md +2 -2
  29. package/docs/inspection.md +3 -1
  30. package/docs/output-projection-map.md +4 -4
  31. package/docs/releasing.md +4 -0
  32. package/docs/tool-registry.md +20 -1
  33. package/lib/file-state.ts +7 -3
  34. package/lib/prompts.ts +7 -2
  35. package/lib/recipes-discovery.ts +26 -0
  36. package/lib/recipes-references.ts +1 -1
  37. package/lib/recipes-usage.ts +11 -8
  38. package/lib/registry.ts +195 -10
  39. package/lib/run-delivery.ts +130 -31
  40. package/lib/tools-inspect.ts +6 -3
  41. package/lib/tools-register.ts +1 -0
  42. package/lib/tools-response.ts +8 -1
  43. package/package.json +1 -1
  44. package/skills/actors/SKILL.md +11 -2
  45. package/skills/actors/references/persistent-tools.md +20 -2
  46. package/skills/actors/references/runs.md +1 -1
  47. package/skills/recipe-memory/SKILL.md +6 -0
package/lib/prompts.ts CHANGED
@@ -7,15 +7,18 @@
7
7
  export const REGISTER_TOOL_DESCRIPTION =
8
8
  "Register a persistent custom tool from one maintained Recipe, command template, or captured draft. " +
9
9
  "Use from for Recipe specialization, template for trusted commands, or draft for promotion. " +
10
+ "Set expose=false to retain a new template/from Recipe without tool activation. " +
10
11
  "Definitions persist under ~/.pi/agent/recipes; activation is reported separately.";
11
12
 
12
13
  export const REGISTER_TOOL_PROMPT_SNIPPET =
13
- "Register persistent Recipes or command templates as agent-callable tools";
14
+ "Persist command templates or Recipes with register_tool; use expose=false to retain without tool activation";
14
15
 
15
16
  export const REGISTER_TOOL_GUIDELINES = [
16
17
  "Use register_tool from=<skill>/<recipe> with defaults={...} to specialize a maintained Recipe without copying its contract.",
17
18
  "Use register_tool template only for trusted command templates, and use register_tool draft only for captured draft promotion.",
18
- "After register_tool succeeds, trust its callable_now and activation result; persistence alone is not callability.",
19
+ "Use register_tool expose=false to retain a new named template/from Recipe without activation; update, delete and draft promotion are unsupported in this mode.",
20
+ "A register_tool retained_recipe receipt with activation=not_requested is deliberate retention, not failed activation; reuse its exact Recipe path through spawn.",
21
+ "When register_tool requests tool exposure, trust callable_now and activation; persistence alone is not callability.",
19
22
  'Set template=null or template="" in register_tool to delete a persisted tool.',
20
23
  "Set update=true in register_tool to overwrite an existing tool registration.",
21
24
  ];
@@ -42,6 +45,8 @@ export const REGISTER_TOOL_PARAM_DESCRIPTIONS = {
42
45
  "Optional caller-owned defaults. Keys and values must satisfy the effective source or command-template argument contract.",
43
46
  draft:
44
47
  "Promote a captured draft Recipe path from ~/.pi/agent/recipes/drafts. This is a source mode; do not combine it with from or template.",
48
+ expose:
49
+ "Set false to retain a new named template/from Recipe in recipes/library without registering or activating a tool. Default true preserves ordinary tool registration. Retained update, delete and draft promotion are unsupported.",
45
50
  async:
46
51
  "Set true for a co-located async template recipe. Omit for ordinary command templates or file-backed recipe references.",
47
52
  template:
@@ -756,6 +756,32 @@ function templatePreview(value: unknown): string | undefined {
756
756
  : rendered;
757
757
  }
758
758
 
759
+ export function inventoryRetainedRecipes(
760
+ recipeRoot: string,
761
+ resolutionContext?: RecipeResolutionContext,
762
+ ): { recipes: Array<Record<string, unknown>>; diagnostics: string[] } {
763
+ const discovery = discoverRecipeSources([
764
+ { root: join(recipeRoot, "library"), resolutionContext },
765
+ ]);
766
+ return {
767
+ recipes: [...discovery.active.values()]
768
+ .map((entry) => ({
769
+ id: entry.id,
770
+ path: entry.path,
771
+ source_kind: "user_retained_recipe",
772
+ tool: false,
773
+ invalid: entry.invalid,
774
+ disabled: entry.disabled,
775
+ ...(entry.config ? { async: entry.config.async === true } : {}),
776
+ ...(entry.config?.description ? { description: entry.config.description } : {}),
777
+ ...(entry.diagnostics.length ? { diagnostics: entry.diagnostics } : {}),
778
+ ...(entry.riskLabels.length ? { risk_labels: entry.riskLabels } : {}),
779
+ }))
780
+ .sort((left, right) => left.id.localeCompare(right.id)),
781
+ diagnostics: discovery.diagnostics,
782
+ };
783
+ }
784
+
759
785
  export function listDraftRecipes(root: string): Array<Record<string, unknown>> {
760
786
  return listRecipeFiles(root).map((path) => {
761
787
  for (let attempt = 0; attempt < 3; attempt += 1) {
@@ -16,7 +16,7 @@ import * as Paths from "./paths.ts";
16
16
  import * as RecipeControl from "./recipe-control.ts";
17
17
  import * as Schema from "./schema.ts";
18
18
 
19
- const MAX_RECIPE_FILE_BYTES = 1024 * 1024;
19
+ export const MAX_RECIPE_FILE_BYTES = 1024 * 1024;
20
20
  const MAX_RECIPE_IMPORT_DEPTH = 32;
21
21
 
22
22
  export interface TemplateRecipeImportBinding {
@@ -101,7 +101,7 @@ function readRecipeSource(path: string): { fingerprint: string } | undefined {
101
101
 
102
102
  function inferRecipeRoot(path: string): string {
103
103
  const parent = dirname(resolve(path));
104
- return basename(parent) === "drafts" ? dirname(parent) : parent;
104
+ return ["drafts", "library"].includes(basename(parent)) ? dirname(parent) : parent;
105
105
  }
106
106
 
107
107
  function pathKey(path: string, recipeRoot: string): string {
@@ -112,8 +112,9 @@ function pathKey(path: string, recipeRoot: string): string {
112
112
  return relation.replaceAll("\\", "/");
113
113
  }
114
114
 
115
- function recipeName(path: string): string {
116
- return basename(path).replace(/\.(?:json|md)$/u, "");
115
+ function recipeName(path: string, recipeRoot: string): string {
116
+ const name = basename(path).replace(/\.(?:json|md)$/u, "");
117
+ return dirname(resolve(path)) === resolve(recipeRoot, "library") ? `library/${name}` : name;
117
118
  }
118
119
 
119
120
  function lineageFilename(lineageName: string): string {
@@ -232,8 +233,10 @@ function findRelocatedRecipe(
232
233
  index: RecipeUsageIndex,
233
234
  fingerprint: string,
234
235
  recipeRoot: string,
236
+ newPath: string,
235
237
  ): { indexedName: string; path: string; record: RecipeUsageRecord } | undefined {
236
238
  const candidates = Object.entries(index.paths).flatMap(([oldPath, indexedName]) => {
239
+ if (oldPath.startsWith("library/") !== newPath.startsWith("library/")) return [];
237
240
  if (existsSync(join(recipeRoot, oldPath))) return [];
238
241
  const found = readIndexedLedger(indexedName, recipeRoot);
239
242
  return found?.record.fingerprint === fingerprint
@@ -288,7 +291,7 @@ export function ensureRecipeLineage(
288
291
  const source = readRecipeSource(path);
289
292
  if (!source) return false;
290
293
  const key = pathKey(path, recipeRoot);
291
- const name = recipeName(path);
294
+ const name = recipeName(path, recipeRoot);
292
295
  const index = readIndex(recipeRoot);
293
296
  const indexedName = index.paths[key];
294
297
  if (indexedName) {
@@ -392,11 +395,11 @@ export function recordRecipeLaunch(
392
395
  if (!source) return false;
393
396
  const { fingerprint } = source;
394
397
  const key = pathKey(path, recipeRoot);
395
- const desiredName = recipeName(path);
398
+ const desiredName = recipeName(path, recipeRoot);
396
399
  const index = readIndex(recipeRoot);
397
400
  const relocated = index.paths[key]
398
401
  ? undefined
399
- : findRelocatedRecipe(index, fingerprint, recipeRoot);
402
+ : findRelocatedRecipe(index, fingerprint, recipeRoot, key);
400
403
  const indexedName = index.paths[key] ?? relocated?.indexedName;
401
404
  const found = indexedName
402
405
  ? readIndexedLedger(indexedName, recipeRoot)
@@ -436,7 +439,7 @@ export function recordRecipeLaunch(
436
439
  former_names: renamed
437
440
  ? appendBounded(stored?.former_names ?? [], stored!.lineage_name)
438
441
  : relocated
439
- ? appendBounded(stored?.former_names ?? [], recipeName(relocated.path))
442
+ ? appendBounded(stored?.former_names ?? [], recipeName(join(recipeRoot, relocated.path), recipeRoot))
440
443
  : stored?.former_names ?? [],
441
444
  former_paths: relocated
442
445
  ? appendBounded(stored?.former_paths ?? [], relocated.path)
@@ -591,7 +594,7 @@ export function moveRecipeUsage(
591
594
  const toKey = pathKey(toPath, recipeRoot);
592
595
  const index = readIndex(recipeRoot);
593
596
  const oldName = index.paths[fromKey];
594
- const newName = recipeName(toPath);
597
+ const newName = recipeName(toPath, recipeRoot);
595
598
  if (!oldName) {
596
599
  return index.paths[toKey] === newName && Boolean(readLedger(newName, recipeRoot));
597
600
  }
package/lib/registry.ts CHANGED
@@ -4,8 +4,8 @@
4
4
  * Owns register/update/delete validation, persistence, runtime side effects, and result payloads
5
5
  */
6
6
 
7
- import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
8
- import { dirname, join, relative, resolve } from "node:path";
7
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, unlinkSync } from "node:fs";
8
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
9
9
 
10
10
  import * as CommandTemplates from "./command-templates.ts";
11
11
  import * as Config from "./config.ts";
@@ -28,6 +28,7 @@ export interface RegisterToolInput {
28
28
  name?: string;
29
29
  description?: string;
30
30
  async?: boolean;
31
+ expose?: boolean;
31
32
  template?: CommandTemplates.CommandTemplateValue | null;
32
33
  from?: string;
33
34
  defaults?: Record<string, unknown>;
@@ -38,13 +39,16 @@ export interface RegisterToolInput {
38
39
 
39
40
  export interface RegisterToolResultDetails {
40
41
  active_tool?: boolean;
41
- activation?: "current_session" | "unverified";
42
+ activation?: "current_session" | "unverified" | "not_requested";
42
43
  activation_boundary?: string;
43
44
  args?: string[];
44
45
  async?: boolean;
45
46
  callable_now?: boolean;
46
47
  defaults?: Record<string, string>;
47
48
  host_registered?: boolean;
49
+ kind?: "retained_recipe";
50
+ recipe?: string;
51
+ result?: "retained";
48
52
  next_actions?: string[];
49
53
  optional_args?: string[];
50
54
  persisted?: boolean;
@@ -811,12 +815,18 @@ function executeRegisterToolUnlocked<TContext>(
811
815
  params: unknown,
812
816
  ctx: TContext,
813
817
  deps: RegisterToolRuntimeDeps<TContext>,
814
- ): RegisterToolResult {
818
+ ): RegisterToolResult | (() => RegisterToolResult) {
815
819
  const rawInput =
816
820
  params && typeof params === "object"
817
821
  ? (params as RegisterToolInput)
818
822
  : ({} as RegisterToolInput);
819
823
  assertRegisterToolInputModes(rawInput);
824
+ if (Object.hasOwn(rawInput, "expose")) {
825
+ const exposure = rawInput.expose;
826
+ if (typeof exposure !== "boolean") throw new RetainedRecipeRefusal("expose", "expose must be Boolean.");
827
+ // Select under the existing name reservation; acquire retention locks only after it releases.
828
+ if (exposure === false) return () => retainRecipe(rawInput, ctx, deps);
829
+ }
820
830
  let input = rawInput;
821
831
  if (!input.name) return listTools(deps);
822
832
  const name = Identity.normalizeToolName(input.name);
@@ -995,16 +1005,191 @@ function executeRegisterToolUnlocked<TContext>(
995
1005
  };
996
1006
  }
997
1007
 
1008
+ class RetainedRecipeRefusal extends Error {
1009
+ readonly reason: string;
1010
+ constructor(field: string, reason: string, cause?: unknown) {
1011
+ super(ExecutionOutput.formatToolText(registryMetadataText({ field, result: reason })), { cause });
1012
+ this.reason = reason;
1013
+ }
1014
+ }
1015
+
1016
+ function retainedDirectory(path: string) {
1017
+ const stat = lstatSync(path, { bigint: true });
1018
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
1019
+ throw new RetainedRecipeRefusal("storage", "Retained storage must be a regular non-symlink directory.");
1020
+ }
1021
+ return { path, realpath: realpathSync.native(path), dev: stat.dev, ino: stat.ino };
1022
+ }
1023
+
1024
+ function recheckRetainedDirectory(frame: ReturnType<typeof retainedDirectory>): void {
1025
+ const current = retainedDirectory(frame.path);
1026
+ if (current.realpath !== frame.realpath || current.dev !== frame.dev || current.ino !== frame.ino) {
1027
+ throw new RetainedRecipeRefusal("storage", "Retained storage identity changed.");
1028
+ }
1029
+ }
1030
+
1031
+ function retainedSlotAbsent(path: string, ownTargetPublished = false): void {
1032
+ for (const candidate of ownTargetPublished ? [path.replace(/\.json$/u, ".md")] : [path, path.replace(/\.json$/u, ".md")]) {
1033
+ try { lstatSync(candidate); }
1034
+ catch (error) {
1035
+ let code: unknown;
1036
+ try { code = (error as NodeJS.ErrnoException | undefined)?.code; }
1037
+ catch (observationError) { throw new AggregateError([error, observationError], "Retained slot observation failed.", { cause: error }); }
1038
+ if (code === "ENOENT") continue;
1039
+ throw error;
1040
+ }
1041
+ throw new RetainedRecipeRefusal("name", "Retained Recipe already exists; this path is create-only.");
1042
+ }
1043
+ }
1044
+
1045
+ function retainedFile(path: string) {
1046
+ const stat = lstatSync(path, { bigint: true });
1047
+ if (!stat.isFile() || stat.isSymbolicLink()) {
1048
+ throw new RetainedRecipeRefusal("storage", "Retained target must be a regular non-symlink file.");
1049
+ }
1050
+ if (stat.size > BigInt(RecipesReferences.MAX_RECIPE_FILE_BYTES)) throw new RetainedRecipeRefusal("storage", "Retained target exceeds the source size limit.");
1051
+ return { dev: stat.dev, ino: stat.ino, size: stat.size, mtime: stat.mtimeNs, ctime: stat.ctimeNs };
1052
+ }
1053
+
1054
+ function withinRetainedSource(root: string, path: string): boolean {
1055
+ const relation = relative(resolve(root), resolve(path));
1056
+ return relation === "" || (!isAbsolute(relation) && !/^\.\.(?:[\\/]|$)/u.test(relation));
1057
+ }
1058
+
1059
+ function assertRetainedSourceLifetime(source: string, root: string, field: string): void {
1060
+ const drafts = join(root, "drafts");
1061
+ if (withinRetainedSource(drafts, source) ||
1062
+ (existsSync(drafts) && existsSync(source) && withinRetainedSource(realpathSync.native(drafts), realpathSync.native(source)))) {
1063
+ throw new RetainedRecipeRefusal(field, "Retained creation cannot reference a managed disposable draft.");
1064
+ }
1065
+ }
1066
+
1067
+ function assertRetainedTemplateLifetimes(value: unknown, path: string, root: string, context: RecipesContext.RecipeResolutionContext): void {
1068
+ if (typeof value === "string") {
1069
+ const source = RecipesReferences.resolveRecipeReferencePath(value, dirname(path), context.activeSkills);
1070
+ if (source) assertRetainedSourceLifetime(source, root, "template");
1071
+ } else if (Array.isArray(value)) {
1072
+ for (const child of value) assertRetainedTemplateLifetimes(child, path, root, context);
1073
+ } else if (value && typeof value === "object") {
1074
+ const node = value as Record<string, unknown>;
1075
+ assertRetainedTemplateLifetimes(node.template, path, root, context);
1076
+ assertRetainedTemplateLifetimes(node.recover, path, root, context);
1077
+ }
1078
+ }
1079
+
1080
+ function retainRecipe<TContext>(
1081
+ input: RegisterToolInput,
1082
+ ctx: TContext,
1083
+ deps: RegisterToolRuntimeDeps<TContext>,
1084
+ ): RegisterToolResult {
1085
+ assertRegisterToolInputModes(input);
1086
+ if (typeof input.name !== "string" || !input.name.trim()) {
1087
+ throw new RetainedRecipeRefusal("name", "Retained creation requires a name.");
1088
+ }
1089
+ const name = Identity.normalizeToolName(input.name);
1090
+ if (!name || deps.reservedToolNames.has(name)) {
1091
+ throw new RetainedRecipeRefusal("name", "Invalid or reserved retained Recipe name.");
1092
+ }
1093
+ const blocker = deps.getToolNameBlocker(name);
1094
+ if (blocker) throw new Error(ExecutionOutput.formatToolText(blocker));
1095
+ if (input.update || Object.hasOwn(input, "draft")) {
1096
+ throw new RetainedRecipeRefusal("expose", "Retained creation does not support update or draft promotion.");
1097
+ }
1098
+ const template = getInputTemplate(input.template);
1099
+ if (template === null || template === "") {
1100
+ throw new RetainedRecipeRefusal("template", "Retained deletion is not supported.");
1101
+ }
1102
+ if (template === undefined && !input.from) {
1103
+ throw new RetainedRecipeRefusal("source", "Retained creation requires template or from.");
1104
+ }
1105
+ const root = resolve(getRecipeRoot(deps)), library = join(root, "library"), path = join(library, `${name}.json`);
1106
+ const context = getRegistrationResolutionContext(ctx, deps);
1107
+ const from = input.from === undefined ? undefined : normalizeFromReference(input.from, context);
1108
+ if (from) {
1109
+ const source = from.endsWith(".json") || from.endsWith(".md") ? from
1110
+ : RecipesReferences.resolveRecipeReferencePath(from, context.cwd, context.activeSkills);
1111
+ if (source) assertRetainedSourceLifetime(source, root, "from");
1112
+ }
1113
+ const authored = buildAuthoredRecipe({ ...input, ...(from ? { from } : {}) }, undefined, undefined);
1114
+ if (!from) assertRetainedTemplateLifetimes(authored.template, path, root, context);
1115
+ const bytes = `${JSON.stringify(authored, null, 2)}\n`;
1116
+ if (Buffer.byteLength(bytes) > RecipesReferences.MAX_RECIPE_FILE_BYTES) {
1117
+ throw new RetainedRecipeRefusal("source", "Retained Recipe exceeds the 1 MiB source limit.");
1118
+ }
1119
+ const candidate = RecipesDiscovery.admitUserRecipe(path, context, authored);
1120
+ if (!candidate.validated || !candidate.tool) {
1121
+ throw new RetainedRecipeRefusal("source", "Retained Recipe admission failed; check the source and caller argument contract.", candidate.diagnostics);
1122
+ }
1123
+ let stage = "storage";
1124
+ try {
1125
+ return withFileMutationLock(root, () => {
1126
+ mkdirSync(root, { recursive: true });
1127
+ const rootFrame = retainedDirectory(root);
1128
+ return withFileMutationLock(library, () => {
1129
+ recheckRetainedDirectory(rootFrame);
1130
+ mkdirSync(library, { recursive: true });
1131
+ const libraryFrame = retainedDirectory(library);
1132
+ const relation = relative(rootFrame.realpath, libraryFrame.realpath);
1133
+ if ((process.platform === "win32" ? relation.toLowerCase() : relation) !== "library") {
1134
+ throw new RetainedRecipeRefusal("storage", "Retained library escapes its Recipe root.");
1135
+ }
1136
+ const recheck = () => { recheckRetainedDirectory(rootFrame); recheckRetainedDirectory(libraryFrame); };
1137
+ return withFileMutationLock(path, () => {
1138
+ recheck(); retainedSlotAbsent(path);
1139
+ stage = "persistence";
1140
+ writeTextAtomic(path, bytes, { exclusive: true, onBeforeReplace: () => { recheck(); retainedSlotAbsent(path); } });
1141
+ stage = "persisted_admission";
1142
+ recheck(); retainedSlotAbsent(path, true);
1143
+ const before = retainedFile(path);
1144
+ if (readFileSync(path, "utf8") !== bytes) throw new RetainedRecipeRefusal("storage", "Persisted retained content changed.");
1145
+ const admitted = RecipesDiscovery.admitUserRecipe(path, context);
1146
+ if (!admitted.validated || !admitted.tool) throw new RetainedRecipeRefusal("source", "Persisted retained Recipe admission failed.", admitted.diagnostics);
1147
+ const after = retainedFile(path);
1148
+ if (Object.keys(before).some(key => before[key as keyof typeof before] !== after[key as keyof typeof after]) ||
1149
+ readFileSync(path, "utf8") !== bytes) throw new RetainedRecipeRefusal("storage", "Persisted retained content changed.");
1150
+ recheck(); retainedSlotAbsent(path, true);
1151
+ stage = "receipt";
1152
+ const args = RecipesDiscovery.summarizeRegisteredToolArgs(admitted.tool);
1153
+ const source = from ? from.endsWith(".json") || from.endsWith(".md") ? `<explicit-file:${basename(from)}>` : from : "template";
1154
+ const recipe = registrySelected("recipe", path), selectedName = registrySelected("tool", name);
1155
+ const selectedSource = registrySelected("source", source);
1156
+ const required = ToolsResponse.selectYamlField("required_args", args.required, REGISTRY_METADATA_BUDGET);
1157
+ const optional = ToolsResponse.selectYamlField("optional_args", args.optional, REGISTRY_METADATA_BUDGET);
1158
+ const facts = { kind: "retained_recipe" as const, result: "retained" as const, persisted: true, resolved: true,
1159
+ validated: true, activation: "not_requested" as const, async: admitted.async };
1160
+ const text = growRegistryDocument(() => ({ ...selectedName.selected, ...facts, ...recipe.selected, ...selectedSource.selected,
1161
+ ...required.selected, ...optional.selected, next: { tool: "inspect", target: "recipes", view: "summary", verbose: true } }),
1162
+ [recipe, selectedName, selectedSource, required, optional]);
1163
+ const result = {
1164
+ content: [textContent(ExecutionOutput.formatToolText(text))],
1165
+ details: { ...facts, tool: name, recipe: path, source, required_args: args.required, optional_args: args.optional,
1166
+ next_actions: ["inspect target=recipes view=summary verbose=true"] },
1167
+ };
1168
+ stage = "release";
1169
+ return result;
1170
+ });
1171
+ });
1172
+ });
1173
+ } catch (error) {
1174
+ const reason = error instanceof RetainedRecipeRefusal ? { reason: error.reason } : {};
1175
+ throw new Error(ExecutionOutput.formatToolText(registryMetadataText({ operation: "retain", stage, result: "unconfirmed",
1176
+ effects: "unknown; inspect retained inventory before retry", ...reason,
1177
+ next: { tool: "inspect", target: "recipes", view: "summary", verbose: true } })), { cause: error });
1178
+ }
1179
+ }
1180
+
998
1181
  export async function executeRegisterTool<TContext>(
999
1182
  params: unknown,
1000
1183
  ctx: TContext,
1001
1184
  deps: RegisterToolRuntimeDeps<TContext>,
1002
1185
  ): Promise<RegisterToolResult> {
1003
1186
  const input = params as RegisterToolInput;
1004
- if (!input.name) return executeRegisterToolUnlocked(params, ctx, deps);
1005
- const name = Identity.normalizeToolName(input.name);
1006
- if (!name) return executeRegisterToolUnlocked(params, ctx, deps);
1007
- return withFileMutationLock(getToolRecipePath(deps, name), () =>
1008
- executeRegisterToolUnlocked(params, ctx, deps),
1009
- );
1187
+ let result: RegisterToolResult | (() => RegisterToolResult);
1188
+ if (!input.name) result = executeRegisterToolUnlocked(params, ctx, deps);
1189
+ else {
1190
+ const name = Identity.normalizeToolName(input.name);
1191
+ result = !name ? executeRegisterToolUnlocked(params, ctx, deps)
1192
+ : withFileMutationLock(getToolRecipePath(deps, name), () => executeRegisterToolUnlocked(params, ctx, deps));
1193
+ }
1194
+ return typeof result === "function" ? result() : result;
1010
1195
  }