@llblab/pi-actors 0.57.1 → 0.58.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.
Files changed (49) hide show
  1. package/AGENTS.md +11 -3
  2. package/BACKLOG.md +14 -1
  3. package/CHANGELOG.md +13 -1
  4. package/README.md +7 -1
  5. package/dist/lib/file-state.d.ts +1 -0
  6. package/dist/lib/file-state.js +8 -2
  7. package/dist/lib/pi.js +1 -1
  8. package/dist/lib/prompts.d.ts +2 -1
  9. package/dist/lib/prompts.js +6 -2
  10. package/dist/lib/recipes-discovery.d.ts +4 -0
  11. package/dist/lib/recipes-discovery.js +22 -0
  12. package/dist/lib/recipes-references.d.ts +1 -0
  13. package/dist/lib/recipes-references.js +1 -1
  14. package/dist/lib/recipes-usage.js +12 -9
  15. package/dist/lib/registry.d.ts +5 -1
  16. package/dist/lib/registry.js +199 -7
  17. package/dist/lib/run-delivery.d.ts +1 -1
  18. package/dist/lib/run-delivery.js +19 -9
  19. package/dist/lib/tools-inspect.js +6 -3
  20. package/dist/lib/tools-register.js +1 -0
  21. package/dist/lib/tools-response.js +20 -16
  22. package/dist/skills/actors/SKILL.md +11 -2
  23. package/dist/skills/actors/references/persistent-tools.md +20 -2
  24. package/dist/skills/actors/references/runs.md +1 -1
  25. package/dist/skills/recipe-memory/SKILL.md +6 -0
  26. package/docs/README.md +1 -0
  27. package/docs/agent-experience.md +5 -6
  28. package/docs/capability-memory.md +121 -0
  29. package/docs/coordinator-delivery.md +2 -2
  30. package/docs/inspection.md +3 -1
  31. package/docs/output-projection-map.md +1 -1
  32. package/docs/releasing.md +5 -1
  33. package/docs/tool-registry.md +37 -1
  34. package/lib/file-state.ts +7 -3
  35. package/lib/pi.ts +1 -1
  36. package/lib/prompts.ts +7 -2
  37. package/lib/recipes-discovery.ts +26 -0
  38. package/lib/recipes-references.ts +1 -1
  39. package/lib/recipes-usage.ts +11 -8
  40. package/lib/registry.ts +195 -10
  41. package/lib/run-delivery.ts +22 -11
  42. package/lib/tools-inspect.ts +6 -3
  43. package/lib/tools-register.ts +1 -0
  44. package/lib/tools-response.ts +20 -13
  45. package/package.json +1 -1
  46. package/skills/actors/SKILL.md +11 -2
  47. package/skills/actors/references/persistent-tools.md +20 -2
  48. package/skills/actors/references/runs.md +1 -1
  49. package/skills/recipe-memory/SKILL.md +6 -0
@@ -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
  }
@@ -69,7 +69,7 @@ export interface RunCompletionBatch {
69
69
  /** Retained singleton capture; absence preserves older indexed bytes. Never a new-capture selector. */
70
70
  readonly single_result?: true;
71
71
  /** Captured record-list shape; no public format/version choice or source authority. */
72
- readonly result_layout?: "records" | "run_headers" | "headings" | "headings_flat_omissions" | "headings_outcomes" | "headings_shared_results" | "headings_shared_kinds" | "frontmatter_v1" | "frontmatter_v2" | "completion_yaml_core_v1" | "completion_yaml_files_v1";
72
+ readonly result_layout?: "records" | "run_headers" | "headings" | "headings_flat_omissions" | "headings_outcomes" | "headings_shared_results" | "headings_shared_kinds" | "frontmatter_v1" | "frontmatter_v2" | "completion_yaml_core_v1" | "completion_yaml_files_v1" | "completion_yaml_files_v2";
73
73
  /** Immutable scope/order/range data, not a caller-selected format or allocation authority. */
74
74
  readonly numbering?: RunCompletionNumbering;
75
75
  /** Formatting version/data only; no recipient ownership, read or publication capability. */
@@ -467,8 +467,12 @@ function isFrontmatterCompletionLayout(layout: unknown): boolean {
467
467
  return layout === "frontmatter_v1" || layout === "frontmatter_v2";
468
468
  }
469
469
 
470
+ function isRootedCompletionLayout(layout: unknown): boolean {
471
+ return layout === "completion_yaml_files_v1" || layout === "completion_yaml_files_v2";
472
+ }
473
+
470
474
  function isYamlCompletionLayout(layout: unknown): boolean {
471
- return layout === "completion_yaml_core_v1" || layout === "completion_yaml_files_v1";
475
+ return layout === "completion_yaml_core_v1" || isRootedCompletionLayout(layout);
472
476
  }
473
477
 
474
478
  function isSharedCompletionLayout(layout: unknown): boolean {
@@ -895,7 +899,7 @@ export function requestRunCompletionStream(input: { tempDir: string; ownerId: st
895
899
  }
896
900
 
897
901
  export function admitRunCompletionBatch(input: AdmitRunCompletionBatchInput): RunCompletionBatch {
898
- return captureCompletionBatch(input, false, "completion_yaml_files_v1");
902
+ return captureCompletionBatch(input, false, "completion_yaml_files_v2");
899
903
  }
900
904
 
901
905
  /** Kernel-only opt-in; ordinary Pi capture stays fenced until session/lifecycle integration is admitted. */
@@ -905,10 +909,10 @@ export function captureRunCompletionStreamBatch(input: AdmitRunCompletionBatchIn
905
909
 
906
910
  /** Private ordinary candidate composition; stream capture retains its separately gated codec. */
907
911
  export function captureRunCompletionYamlCandidateBatch(input: AdmitRunCompletionBatchInput): RunCompletionBatch {
908
- return captureCompletionBatch(input, false, "completion_yaml_files_v1");
912
+ return captureCompletionBatch(input, false, "completion_yaml_files_v2");
909
913
  }
910
914
 
911
- function captureCompletionBatch(input: AdmitRunCompletionBatchInput, numbered: boolean, layout: "frontmatter_v2" | "completion_yaml_core_v1" | "completion_yaml_files_v1" = "frontmatter_v2"): RunCompletionBatch {
915
+ function captureCompletionBatch(input: AdmitRunCompletionBatchInput, numbered: boolean, layout: "frontmatter_v2" | "completion_yaml_core_v1" | "completion_yaml_files_v1" | "completion_yaml_files_v2" = "frontmatter_v2"): RunCompletionBatch {
912
916
  const now = (input.now ?? new Date()).toISOString();
913
917
  if (Object.hasOwn(input, "presentationFormat")) throw new Error("Completion presentation selection is not supported; new batches use AX.");
914
918
  if (Object.hasOwn(input, "numbering")) throw new Error("Completion numbering is assigned only by journal capture");
@@ -1869,12 +1873,13 @@ type PreparedCompletionYamlProjection =
1869
1873
  function prepareCompletionYamlProjection(safe: RunCompletionBatch, candidates: readonly { row: CompletionRow; location?: CompletionPiece }[],
1870
1874
  references: ReadonlySet<number>, ordered: readonly RunCompletionBatchMember[]): PreparedCompletionYamlProjection {
1871
1875
  const packet: CompletionYamlPacket = { batchId: safe.batch_id, records: [] };
1872
- const rooted = safe.result_layout === "completion_yaml_files_v1";
1876
+ const rooted = isRootedCompletionLayout(safe.result_layout);
1877
+ const localSingleton = safe.result_layout === "completion_yaml_files_v2" && !safe.numbering && candidates.length === 1;
1873
1878
  if (!safe.batch_id.isWellFormed()) return { state: "unsupported" };
1874
1879
  for (const [index, { row, location }] of candidates.entries()) {
1875
1880
  const member = ordered[index]!;
1876
1881
  const displayed = safe.numbering ? safe.numbering.kind === "stream" ? safe.numbering.first_index + index : "?" : index;
1877
- const metadata: Record<string, CompletionYamlData> = { index: displayed };
1882
+ const metadata: Record<string, CompletionYamlData> = localSingleton ? {} : { index: displayed };
1878
1883
  for (const piece of row.pieces) {
1879
1884
  if (piece.block) continue;
1880
1885
  if (!piece.field) return { state: "unsupported" };
@@ -1917,7 +1922,9 @@ function prepareCompletionYamlProjection(safe: RunCompletionBatch, candidates: r
1917
1922
  }
1918
1923
  packet.records.push({ metadata, parts });
1919
1924
  }
1920
- const service: Record<string, CompletionYamlData> = rooted ? { header_layout: "rooted_files_v1" } : {};
1925
+ const service: Record<string, CompletionYamlData> = rooted ? {
1926
+ header_layout: safe.result_layout === "completion_yaml_files_v2" ? "rooted_files_v2" : "rooted_files_v1",
1927
+ } : {};
1921
1928
  const omitted = safe.members.length - candidates.length;
1922
1929
  if (omitted) {
1923
1930
  service.omitted_results = omitted;
@@ -2349,7 +2356,7 @@ export function readRunCompletionPresentation(content: string, value: unknown):
2349
2356
  !completionYamlKeys(hint, ["format", "content_sha256"]) || hint.format !== completionYamlCodec ||
2350
2357
  hint.content_sha256 !== createHash("sha256").update(content).digest("hex")) return full;
2351
2358
  const segments: NonNullable<RunCompletionPresentationView["segments"]>[number][] = [];
2352
- const rooted = core.packet.service?.header_layout === "rooted_files_v1";
2359
+ const rooted = core.packet.service?.header_layout === "rooted_files_v1" || core.packet.service?.header_layout === "rooted_files_v2";
2353
2360
  const facts = Object.fromEntries(Object.entries(core.packet.service ?? {}).filter(([key]) => key !== "header_layout"));
2354
2361
  for (const [index, record] of core.packet.records.entries()) {
2355
2362
  if (index) segments.push({ kind: "separator", text: "\n" });
@@ -2581,6 +2588,8 @@ export function encodeCompletionYamlCarrier(input: unknown): { state: "available
2581
2588
  typeof packet.batchId !== "string" || !packet.batchId.length || !Array.isArray(packet.records) || packet.records.length > 64 ||
2582
2589
  (Object.hasOwn(packet, "service") && (!completionYamlMapping(packet.service) || !Object.keys(packet.service).length)) ||
2583
2590
  (!packet.records.length && !Object.hasOwn(packet, "service"))) return unavailable;
2591
+ const localSingleton = packet.records.length === 1 && completionYamlMapping(packet.service) &&
2592
+ packet.service.header_layout === "rooted_files_v2" && !Object.hasOwn(packet.service, "numbering");
2584
2593
  const rows: string[] = [];
2585
2594
  for (let index = 0; index < Math.max(1, packet.records.length); index++) {
2586
2595
  const row: CompletionYamlData | undefined = packet.records[index];
@@ -2596,13 +2605,15 @@ export function encodeCompletionYamlCarrier(input: unknown): { state: "available
2596
2605
  if (!completionYamlMapping(row) || !completionYamlKeys(row, ["metadata", "parts"]) ||
2597
2606
  !completionYamlMapping(row.metadata) || !Array.isArray(row.parts)) return unavailable;
2598
2607
  const meta = row.metadata;
2599
- if (!(meta.index === "?" || (typeof meta.index === "number" && Number.isSafeInteger(meta.index) && meta.index >= 0)) ||
2608
+ const validIndex = localSingleton ? !Object.hasOwn(meta, "index")
2609
+ : meta.index === "?" || (typeof meta.index === "number" && Number.isSafeInteger(meta.index) && meta.index >= 0);
2610
+ if (!validIndex ||
2600
2611
  typeof meta.run !== "string" || !meta.run.length || typeof meta.state !== "string" || !meta.state.length ||
2601
2612
  (Object.hasOwn(meta, "kind") && meta.kind !== "primary" && meta.kind !== "diagnostic") ||
2602
2613
  Object.hasOwn(meta, completionYamlServiceKey) ||
2603
2614
  // JS enumerates array-index keys before semantic core fields; do not silently reorder the header.
2604
2615
  Object.keys(meta).some(key => /^(0|[1-9]\d*)$/u.test(key) && Number(key) < 4294967295)) return unavailable;
2605
- for (const key of ["index", "run", "state", ...(Object.hasOwn(meta, "kind") ? ["kind"] : []),
2616
+ for (const key of [...(localSingleton ? [] : ["index"]), "run", "state", ...(Object.hasOwn(meta, "kind") ? ["kind"] : []),
2606
2617
  ...Object.keys(meta).filter(key => !["index", "run", "state", "kind"].includes(key))]) {
2607
2618
  Object.defineProperty(header, key, { value: meta[key]!, enumerable: true });
2608
2619
  }
@@ -448,9 +448,6 @@ function inspectRecipes(
448
448
  if (view === "reviews") {
449
449
  return ReviewDiagnostics.readAutomaticReviewDiagnostics({ recipeRoot });
450
450
  }
451
- const discovered = RecipesDiscovery.discoverRecipeSources([
452
- { root: recipeRoot, defaultTool: true, mutableUsage: true },
453
- ]);
454
451
  const resolutionContext =
455
452
  context && typeof context === "object" && "recipeResolutionContext" in context
456
453
  ? (context as {
@@ -506,7 +503,11 @@ function inspectRecipes(
506
503
  ...(diagnostic.stem ? { stem: diagnostic.stem } : {}),
507
504
  }),
508
505
  );
506
+ const discovered = RecipesDiscovery.discoverRecipeSources([
507
+ { root: recipeRoot, defaultTool: true, mutableUsage: true, resolutionContext },
508
+ ]);
509
509
  const discoverySummary = RecipesDiscovery.summarizeDiscovery(discovered);
510
+ const retained = RecipesDiscovery.inventoryRetainedRecipes(recipeRoot, resolutionContext);
510
511
  const summary = {
511
512
  ...discoverySummary,
512
513
  ...(registryStatus
@@ -532,6 +533,8 @@ function inspectRecipes(
532
533
  error: `Ambiguous active Skill Recipe namespace ${name}`,
533
534
  })),
534
535
  drafts: RecipesDiscovery.listDraftRecipes(join(recipeRoot, "drafts")),
536
+ retained: retained.recipes,
537
+ retained_diagnostics: retained.diagnostics,
535
538
  };
536
539
  return {
537
540
  ...summary,
@@ -40,6 +40,7 @@ export function createRegisterToolDefinition<TContext>(
40
40
  Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.defaults,
41
41
  ),
42
42
  draft: stringSchema(Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.draft),
43
+ expose: booleanSchema(Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.expose),
43
44
  from: stringSchema(Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.from),
44
45
  name: stringSchema(Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.name),
45
46
  template: unionSchema([
@@ -517,6 +517,12 @@ export function recipeRegistryNextActions(
517
517
  if (summary.skill_recipe_catalog_partial === true) {
518
518
  actions.push("inspect target=recipes view=imports verbose=true");
519
519
  }
520
+ if (
521
+ (Array.isArray(summary.retained) && summary.retained.length > 0) ||
522
+ (Array.isArray(summary.retained_diagnostics) && summary.retained_diagnostics.length > 0)
523
+ ) {
524
+ actions.push("inspect target=recipes view=summary verbose=true");
525
+ }
520
526
  if (drafts.length > 0) {
521
527
  actions.push("inspect target=recipes view=summary verbose=true");
522
528
  const firstPath =
@@ -532,16 +538,18 @@ export function compactYamlDocument(value: Record<string, unknown>): string {
532
538
  return `\n${text.replace(/\n$/u, "")}`;
533
539
  }
534
540
 
541
+ function recipeInspectionHint(action: string): { view: "doctor" | "imports" | "summary"; verbose?: true } | undefined {
542
+ if (action === "inspect target=recipes view=doctor") return { view: "doctor" };
543
+ if (action === "inspect target=recipes view=imports verbose=true") return { view: "imports", verbose: true };
544
+ if (action === "inspect target=recipes view=summary verbose=true") return { view: "summary", verbose: true };
545
+ return undefined;
546
+ }
547
+
535
548
  /** Only the closed read-only Recipe inspect literals become typed next; other advice stays opaque content. */
536
549
  export function nextActionMetadata(action: string, byteBudget: number): YamlPart {
537
- const recognized = [
538
- ["inspect target=recipes view=doctor", "doctor", false],
539
- ["inspect target=recipes view=imports verbose=true", "imports", true],
540
- ["inspect target=recipes view=summary verbose=true", "summary", true],
541
- ] as const;
542
- const match = recognized.find(([literal]) => literal === action);
543
- if (match) {
544
- const typed = { next: { tool: "inspect", target: "recipes", view: match[1], ...(match[2] ? { verbose: true } : {}) } };
550
+ const hint = recipeInspectionHint(action);
551
+ if (hint) {
552
+ const typed = { next: { tool: "inspect", target: "recipes", ...hint } };
545
553
  return { full: typed, selected: typed };
546
554
  }
547
555
  const instruction = selectYamlField("instruction", action, byteBudget);
@@ -550,10 +558,8 @@ export function nextActionMetadata(action: string, byteBudget: number): YamlPart
550
558
 
551
559
  /** Only these existing read-only inspection hints become action nodes; other advice stays content. */
552
560
  export function formatInspectionHint(action: string): string {
553
- if (action === "inspect target=recipes view=doctor") return formatInspectNextAction("recipes", "doctor");
554
- if (action === "inspect target=recipes view=imports verbose=true") return formatInspectNextAction("recipes", "imports", true);
555
- if (action === "inspect target=recipes view=summary verbose=true") return formatInspectNextAction("recipes", "summary", true);
556
- return formatMarkdownField("instruction", action);
561
+ const hint = recipeInspectionHint(action);
562
+ return hint ? formatInspectNextAction("recipes", hint.view, hint.verbose) : formatMarkdownField("instruction", action);
557
563
  }
558
564
 
559
565
  export function compactRecipeRegistry(
@@ -563,6 +569,7 @@ export function compactRecipeRegistry(
563
569
  const active = count("active", summary.active), shadowed = count("shadowed", summary.shadowed);
564
570
  const invalid = count("invalid", summary.invalid), disabled = count("disabled", summary.disabled);
565
571
  const diagnostics = count("diagnostics", summary.diagnostics), drafts = count("drafts", summary.drafts);
572
+ const retained = count("retained", summary.retained);
566
573
  const recommendations = count("recommendations", summary.recommendations);
567
574
  const skillCatalogPartial = summary.skill_recipe_catalog_partial === true;
568
575
  const currentPolicy = Array.isArray(summary.active)
@@ -574,7 +581,7 @@ export function compactRecipeRegistry(
574
581
  ? (summary.next_actions as string[])
575
582
  : [];
576
583
  const hint = nextActions.length ? nextActionMetadata(nextActions[0]!, Limits.RUN_DELIVERY_MODEL_MAX_BYTES) : undefined;
577
- const render = (): Record<string, unknown> => ({ recipes: "registry", active, drafts, shadowed, invalid, disabled,
584
+ const render = (): Record<string, unknown> => ({ recipes: "registry", active, drafts, retained, shadowed, invalid, disabled,
578
585
  current_policy: currentPolicy, skill_catalog_partial: skillCatalogPartial, recommendations, diagnostics,
579
586
  ...(hint ? hint.selected : {}) });
580
587
  return hint
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-actors",
3
- "version": "0.57.1",
3
+ "version": "0.58.1",
4
4
  "private": false,
5
5
  "description": "Local Actor Kernel for Pi",
6
6
  "keywords": [
@@ -19,6 +19,9 @@ Make a maintained capability a persistent agent-callable tool
19
19
  Keep the same capability but narrow caller defaults
20
20
  → use register_tool from=<skill>/<recipe> defaults={...}
21
21
 
22
+ Retain a new named capability without exposing a tool
23
+ → use register_tool expose=false with from or template
24
+
22
25
  Register a trusted command directly
23
26
  → use register_tool template="..."
24
27
 
@@ -54,6 +57,12 @@ Run Control ≠ actor chat
54
57
 
55
58
  A Skill Recipe is a maintained component addressed by `<skill>/<recipe>`. `spawn` creates a Run from a Recipe. `register_tool from=<skill>/<recipe>` persists compact logical delegation and activates from the resolved effective contract without copying, symlinking, or ambient re-resolution. A tool is callable in the current session only when activation evidence says `callable_now: true`.
56
59
 
60
+ Sync/async is an execution contract, not a usefulness or tool-exposure rule. A sync tool awaits its result; an async tool returns a Run launch receipt, with the eventual result delivered separately. A subagent-backed tool is a capability launcher, not a persisted live subagent. Register a stable, meaningfully parameterized capability when its name and contract save repeated planning; prefer one-off `spawn` over a redundant generic launcher or a new tool for every mission.
61
+
62
+ Use `register_tool expose=false` with a new name and one `template` or `from` source to retain a capability without requesting tool activation. Its `kind: retained_recipe`, `activation: not_requested` receipt is deliberate retention, not failed registration; it omits global callability flags because a same-named tool may already exist. Reuse the exact returned Recipe path through `spawn`, or deliberately expose it later with ordinary `register_tool from=<exact path>`. Retained update, delete and draft promotion are unsupported; do not hand-edit the live registry to bypass those guards.
63
+
64
+ Automatic retained lifecycle is not implemented. Current user-root tool discovery and automatic review actions are unchanged. `inspect target=recipes view=summary` distinguishes retained definitions under `recipes/library` from root tool candidates; verbose detail gives exact paths, resolved mode and invalid/disabled diagnostics. Storage is not proof of review, callability or a running Actor. `expose` is registration input, not a Recipe field or automatic-review action; do not invent retain/defer review commands. See [persistent tools](./references/persistent-tools.md) for current selection and activation proof.
65
+
57
66
  `actors` owns generic Recipe/tool/Run mechanics. The owning capability Skill owns capability-specific selection and constraints. `swarm` owns multi-actor decomposition and integration methodology.
58
67
 
59
68
  ## Presentation Direction and Current Output
@@ -64,7 +73,7 @@ Rollout is partial: compact launch/attachment receipts use block YAML; other ope
64
73
 
65
74
  Compact launches use bare block-YAML mappings for the Run/state/launch facts and nested observed locations, explicitly identify actual existing-Run reuse, and preserve multiline scalar contents. One leading response-separator LF supplies invocation spacing; it is outside the pure metadata emitter and is not a `---` wrapper. Model/thinking provenance groups are labeled model and thinking, without a redundant _policy suffix; structured data and saved text stay unchanged. A complete direct file-location row needs no repeated session-map hint; absent/unavailable/omitted rows retain one optional next node naming inspect, target=runtime, view=runs and verbose=true. It never executes automatically. Exact locations, verbose details and saved completion formats stay unchanged.
66
75
 
67
- Fresh automatic completions have one AX capture path with no format selector: YAML frontmatter begins index, run, state, then kind:primary for admitted primary/final Actor response or kind:diagnostic for explicit diagnostics. Markdown follows unindented outside the closed metadata block, without #N heading, result:/preview: wrapper or whole-body literal fence. One record uses the same shape as many. Display index is not member_index or generation; default indices are packet-local. Whole-result/reference omissions and non-done omitted states stay explicit. Known display loss appears at the actual cut as [N characters omitted], counting Unicode scalar characters of admitted retained text/field, never raw/private loss; no extra clipping ellipsis or body clipping flags/counts in YAML. Unknown upstream completeness/privacy remains separately visible at the body boundary, not a fabricated count or a known positive loss. Untracked fallback has no kind and cannot become primary. Full text fitting its share has no display-cut marker; upstream uncertainty still prevents a completeness claim. New packets share48KiB UTF-8 including references/framing across at most64candidate rows, with frozen equal shares and no unused-share redistribution or fixed16KiB result/2KiB preview cap. Captured members remain bounded at256. Text is admitted work output/final response or explicit error/failure diagnostics, never launch commands. Successful composition/retry/Pi text-print can retain text_basis:actor_response; scope and exact references remain technical, while original coverage facts remain structured retained data. The model receives one complete content with whole source/generation/location references or truthful omission/inventory, not a standing full-map instruction. The TUI renders compiler headers/markers as Text and each body as independent native Markdown; only whole validated header reference/location rows and framing disappear in compact view. Body-authored YAML, next/tool/batch and omission lookalikes remain content. Content-bound hashes/ranges/blocks are display consistency, not authentication, source/read/action or acceptance authority. Invalid/missing hints and expansion reveal full original text, without Run-source IO. Old saved codecs, budgets, labels, excerpts, counts and hints replay exactly with no refresh, repair, ACK/resend or restyling—including earlier #N packets and the first frontmatter encoding. Fresh unavailable indices use quoted YAML "?"; an old saved unquoted value is not silently corrected. Routine successful delivery adds no separate completions/delivery-accepted toast; actual failures and handled-generation fences remain. These source/package rules do not prove that an already loaded session was reloaded. Fresh explicit agent Trace previews append boundary … and [omitted: N chars] from normalized retained turn, with a separate upstream [omitted: unknown]. N counts only display loss after existing session privacy, not discarded original/source text. Short well-formed summaries stay unchanged, including authored older-looking markers; malformed retained text yields Agent turn [omitted: unknown] (malformed retained text), never a guessed count or repaired scalar. This is read-time display only, not a rewrite/version of saved packets or sources. Other legacy clipping is not yet uniformly covered. Follow retained references selectively without inventing completeness or permission. Agent Trace ids reference recorded entry provenance, not aggregate traversal order or chronological event numbers. Native entry keys survive sibling growth; bounded legacy/ambiguous/private-metadata fallbacks are not permanent identities, source immutability or new read/work permission.
76
+ Fresh automatic completions have one AX capture path with no format selector: YAML frontmatter begins run, state, then kind:primary for admitted primary/final Actor response or kind:diagnostic for explicit diagnostics. Markdown follows unindented outside the closed metadata block, without #N heading, result:/preview: wrapper or whole-body literal fence. Prepend index only when two or more results are shown; exactly one shown result omits it even if other results were omitted. Omission/inventory facts remain explicit. Display index is not member_index or generation; default indices are packet-local. This new-capture rule neither restyles saved messages nor changes numbered-stream presentation. Whole-result/reference omissions and non-done omitted states stay explicit. Known display loss appears at the actual cut as [N characters omitted], counting Unicode scalar characters of admitted retained text/field, never raw/private loss; no extra clipping ellipsis or body clipping flags/counts in YAML. Unknown upstream completeness/privacy remains separately visible at the body boundary, not a fabricated count or a known positive loss. Untracked fallback has no kind and cannot become primary. Full text fitting its share has no display-cut marker; upstream uncertainty still prevents a completeness claim. New packets share48KiB UTF-8 including references/framing across at most64candidate rows, with frozen equal shares and no unused-share redistribution or fixed16KiB result/2KiB preview cap. Captured members remain bounded at256. Text is admitted work output/final response or explicit error/failure diagnostics, never launch commands. Successful composition/retry/Pi text-print can retain text_basis:actor_response; scope and exact references remain technical, while original coverage facts remain structured retained data. The model receives one complete content with whole source/generation/location references or truthful omission/inventory, not a standing full-map instruction. The TUI renders compiler headers/markers as Text and each body as independent native Markdown; only whole validated header reference/location rows and framing disappear in compact view. Body-authored YAML, next/tool/batch and omission lookalikes remain content. Content-bound hashes/ranges/blocks are display consistency, not authentication, source/read/action or acceptance authority. Invalid/missing hints and expansion reveal full original text, without Run-source IO. Old saved codecs, budgets, labels, excerpts, counts and hints replay exactly with no refresh, repair, ACK/resend or restyling—including earlier #N packets and the first frontmatter encoding. Fresh unavailable indices use quoted YAML "?"; an old saved unquoted value is not silently corrected. Routine successful delivery adds no separate completions/delivery-accepted toast; actual failures and handled-generation fences remain. These source/package rules do not prove that an already loaded session was reloaded. Fresh explicit agent Trace previews append boundary … and [omitted: N chars] from normalized retained turn, with a separate upstream [omitted: unknown]. N counts only display loss after existing session privacy, not discarded original/source text. Short well-formed summaries stay unchanged, including authored older-looking markers; malformed retained text yields Agent turn [omitted: unknown] (malformed retained text), never a guessed count or repaired scalar. This is read-time display only, not a rewrite/version of saved packets or sources. Other legacy clipping is not yet uniformly covered. Follow retained references selectively without inventing completeness or permission. Agent Trace ids reference recorded entry provenance, not aggregate traversal order or chronological event numbers. Native entry keys survive sibling growth; bounded legacy/ambiguous/private-metadata fallbacks are not permanent identities, source immutability or new read/work permission.
68
77
 
69
78
  Current completion index fields are packet-local, not persistent historical indices; earlier saved #N headings keep their original meaning. Session completion streams are adopted work in progress, not an available action; do not invent a start-stream command, initialize a missing counter or reinterpret old headings. A kernel-only path commits numbered snapshots/counters/activation atomically. Its internal runtime bridge requires trusted fresh-session opt-in plus current manager/generation binding; the default entrypoint does not opt in and no public tool/command exposes it. Unknown state does not fall back to local numbering on that path. Startup/reload/resume, including ambiguous imports, cannot resume a stream automatically; stored numbers remain data, not a new session grant. A pending target or projected scope is not an active stream. Missing state never authorizes bootstrap, and unknown/conflicting delivery never authorizes bypass or reallocation. Saved numbers stay exact; an admitted unavailable counter or unfitting whole range freezes an unknown index (quoted "?" in fresh YAML, #? in older layouts) rather than resetting or partially numbering a packet.
70
79
 
@@ -168,7 +177,7 @@ When a pi-actors operation fails:
168
177
  3. Report resolver, registry, activation, Run, Trace, or Control truth exactly.
169
178
  4. Retry only after the owning state is healthy.
170
179
 
171
- Stop if spawn and registry resolve the same Recipe differently. Stop if registration persists but is not callable. Stop if an operation cannot be proven through pi-actors surfaces.
180
+ Stop if spawn and registry resolve the same Recipe differently. When tool exposure was requested, stop if registration persists but is not callable. A `retained_recipe` receipt with `activation: not_requested` instead permits ordinary Recipe reuse, not a claim of tool invocation. Stop if an operation cannot be proven through pi-actors surfaces.
172
181
 
173
182
  Never recover by copying maintained Recipe args, defaults, Control, artifacts, or helper commands. Never hard-code a `{skill_dir}` replacement path. Never introduce `bash -lc`, `eval`, direct bundled-helper execution, or shell backgrounding to bypass resolution. Never call `spawn` and claim a tool call. Use [diagnostics](./references/diagnostics.md) for the safe next action.
174
183