@llblab/pi-actors 0.57.1 → 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 (39) hide show
  1. package/AGENTS.md +8 -0
  2. package/BACKLOG.md +13 -0
  3. package/CHANGELOG.md +7 -0
  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/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/tools-inspect.js +6 -3
  17. package/dist/lib/tools-register.js +1 -0
  18. package/dist/lib/tools-response.js +6 -1
  19. package/dist/skills/actors/SKILL.md +10 -1
  20. package/dist/skills/actors/references/persistent-tools.md +20 -2
  21. package/dist/skills/recipe-memory/SKILL.md +6 -0
  22. package/docs/README.md +1 -0
  23. package/docs/capability-memory.md +166 -0
  24. package/docs/inspection.md +3 -1
  25. package/docs/releasing.md +4 -0
  26. package/docs/tool-registry.md +20 -1
  27. package/lib/file-state.ts +7 -3
  28. package/lib/prompts.ts +7 -2
  29. package/lib/recipes-discovery.ts +26 -0
  30. package/lib/recipes-references.ts +1 -1
  31. package/lib/recipes-usage.ts +11 -8
  32. package/lib/registry.ts +195 -10
  33. package/lib/tools-inspect.ts +6 -3
  34. package/lib/tools-register.ts +1 -0
  35. package/lib/tools-response.ts +8 -1
  36. package/package.json +1 -1
  37. package/skills/actors/SKILL.md +10 -1
  38. package/skills/actors/references/persistent-tools.md +20 -2
  39. package/skills/recipe-memory/SKILL.md +6 -0
package/AGENTS.md CHANGED
@@ -52,6 +52,8 @@ Pi host
52
52
 
53
53
  `index.ts` wires Pi ports and must not own domain behavior. Keep the local TypeScript import graph acyclic. Prefer cohesive existing owners over per-phase or per-iteration modules; file size alone does not justify extraction. Earn new files through a real ownership, dependency, authority or reuse boundary. Colocation must preserve distinct typed admission/privacy contracts, not collapse them into generic helpers or broad facades. For architecture-affecting work, load and follow `.agents/skills/domain-dag/SKILL.md`; its validator is local agent tooling, not an npm, CI, or release gate.
54
54
 
55
+ For product validation launched from an Actor, clear only `PI_ACTORS_DELIVERY_OWNER_ID`, `PI_ACTORS_DELIVERY_PARENT_RUN`, `PI_ACTORS_DELIVERY_PARENT_INSTANCE` and `PI_ACTORS_DELIVERY_PARENT_STATE_DIR` in the test command's environment. SDK fixtures are independent coordinators; inheriting the validation Actor's lineage can suppress their completion. Keep the launcher's own Run lineage, standard test preload, cases and watchdogs unchanged. Use Node24 for CI-equivalent release validation.
56
+
55
57
  The committed `dist/` tree is required for npm and git package installs. `npm run build` compiles into a temporary candidate and atomically replaces the distributive; `npm run build:check` must reject source/artifact drift without rewriting the working tree.
56
58
 
57
59
  Unit-owner files may construct real SDK sessions. Under a zero-SDK-session bound, do not use a broad negative `--test-name-pattern` as an execution guard: Node24 matches test ancestors, including `<root>`, and can admit forbidden leaves. Before execution, prove the declaration/import/ancestor closure with explicit positive leaf names and/or supported `--test-skip-pattern` exclusions. If exclusion is unproved, stop. Budget session-only lifecycle constructors separately from public fixture Runs and driver/provider/reopen sessions; passing logs never retroactively authorize excess.
@@ -166,6 +168,12 @@ The user-requested JSONL bundle/CodeMode direction is recorded in docs/compositi
166
168
 
167
169
  `~/.pi/agent/recipes/*.json` is executable capability memory. Preserve filename identity, atomic writes, canonical per-path locks with atomic owner publication and non-blocking abandoned staging, explicit operator-gated changes, and transportability.
168
170
 
171
+ The adopted [capability-memory direction](docs/capability-memory.md) separates retaining a reusable Recipe from exposing a named tool. Sync/async is an execution contract, not a maturity/usefulness score or automatic registration rule; subagent-backed tools are capability launchers, not persisted live Runs. Explicit `register_tool expose=false` creates a new template/from Recipe under `recipes/library` without host activation or tool-map mutation. Its retained_recipe/not_requested receipt omits global callability flags because an unrelated same-named tool may already be active. Keep it create-only, source/size-admitted and exclusively published with root/library/target fences; preserve uncertain committed bytes and original causes. Retained update/delete/draft promotion and direct disposable-draft references are unsupported. Automatic retained lifecycle is not implemented: current root-based exposure and review actions remain unchanged. Read-only Recipe inspection now inventories `recipes/library` separately, in the same active Skill context, without granting review/callability/Run status. Keep library files outside automatic tool discovery, including older-host behavior. Do not invent automatic-review actions or Recipe exposure flags, or migrate the live portfolio from this implementation alone.
172
+
173
+ Structural review and launch counts do not establish semantic purpose, good tool names, successful reuse, or accepted outcomes. Missing evidence is unknown, not a reason to fabricate usefulness or irreversibly discard a potentially useful capability. Preserve value-free review and unchanged-source executor boundaries; semantic evidence and new retention/exposure transitions require their own privacy, provenance, transaction and acceptance contract.
174
+
175
+ Retained usage belongs to the parent Recipe root's existing `.usage` owner. Keep private `library/<stem>` lineage distinct from a same-stem tool entrypoint; that ledger label is not a public Recipe reference. Missing files and matching content do not authorize implicit lineage transfer between retained and tool/draft namespaces. Preserve explicit transaction/move ownership and unknown legacy nested evidence rather than guessing a migration.
176
+
169
177
  Automatic review receives value-free structural projections, not executable content, paths, prose, canonical names, or secrets. Deterministic executors derive unchanged Recipes from trusted captures. Approved mutation must journal intent before mutation and roll forward safely after crashes.
170
178
 
171
179
  `PI_ACTORS_AUTOMATIC_REVIEW=off` disables scheduling and safe-boundary activation while remaining visible in runtime status.
package/BACKLOG.md CHANGED
@@ -6,6 +6,7 @@ Task IDs name bounded work, not execution order. The operator authorizes autonom
6
6
 
7
7
  ## Next bounded work
8
8
 
9
+ - **Capability memory:** P1.3 must admit the evidence contract before P1.2c can change automatic retention/exposure. These future slices are separate from the explicit manual workflow; source/package acceptance does not authorize live migration or reload an already running host.
9
10
  - **Separate remaining owners:** F1.7 (one rollout candidate) remains the last open F1 owner; the migrated surfaces do not by themselves prove the loaded rollout or registry notice layout. Registry activation/promotion/deletion notice text stays outside the completed refusal migration. Inspector popup color recognition is verified against current producer results; a later feedback syntax change must update that anchored recognition with it.
10
11
  - **Explicit gates:** U1 requires authorized loaded-host observation/reload; N1.2p needs actual Windows profile evidence, N1.3 an asset-distribution decision, and N1.6 separate activation admission. Original-clock/native-scope, macOS research and composition decisions retain their own B/C/T gates. Existing release permission does not adopt them.
11
12
  - **Evidence reuse:** Reuse the accepted three-OS SOURCE/PACKED quit witnesses for B2.4; extend only the missing descendants/triggers/same-ID/crash cases. N1.9 stays a gated unresolved risk, not an instruction to repeat the completed ownership inquiry.
@@ -185,6 +186,18 @@ Exit: Any later cleanup is independently justified and validated against its own
185
186
 
186
187
  - [ ] **R1.2 — Remove temporary diagnostics after native acceptance.** Gate on N1.7; remove only proven temporary/redundant instrumentation, preserve owned regressions, then refresh invalidated focused checks and final candidate-bound packaging/native evidence. A dirty status note or earlier executable result is not final-tarball proof.
187
188
 
189
+ ## 8. Capability memory separate from tool exposure
190
+
191
+ Outcome: Implement the [adopted capability-memory direction](docs/capability-memory.md): a reviewed Recipe can be retained without becoming a tool; sync/async remains execution policy. Success means easier, coherent management: one existing Recipe inspection surface distinguishes retained definitions from exposed tool candidates, with exact reuse references and no confusion with running instances. Existing explicit registration remains valid for both modes; no automatic-policy or live portfolio migration is authorized by local implementation alone.
192
+
193
+ ### P1 — Retention, evidence and deliberate exposure
194
+
195
+ Exit: Retain/defer/expose/withdraw have explicit evidence and replay-safe transitions in existing owners; useful async capabilities remain eligible and unsuitable sync captures are not exposed solely by mode. Current activation, privacy, source identity and lineage contracts survive source/packed acceptance. No second runtime, resolver or tool registry.
196
+
197
+ - [ ] **P1.2c — Integrate automatic lifecycle transitions.** After P1.3, extend the explicit manual path with CAS/recovery-safe retain/defer/expose/withdraw transitions through existing review transactions, without duplicate capability copies or demotion churn. Specify retention bounds and distinguish evidence deferral from safety rejection and admitted expiry/disposal before enabling automatic retention. Verify: draft/tool transaction, projection and recovery properties.
198
+ - [ ] **P1.3 — Ground automatic exposure in admitted evidence.** Define the privacy/provenance/budget contract for any semantic or accepted-outcome evidence before using it; keep structural-only review honest and non-destructive on uncertainty. Do not infer task success from launches, invent names from opaque shape, weaken confidentiality or permit automatic executable rewrites. Verify: review/projection safety properties and controlled same-shape/different-purpose, failed-launch and insufficient-evidence cases.
199
+ - [ ] **P1.4 — Accept and evaluate the automatic policy.** After P1.2c/P1.3, update owning Skills and current user docs and prove the automatic transitions in isolated source/packed portfolios. Reuse the explicit manual workflow witnesses when their source/activation semantics are unchanged; refresh only affected boundaries. Compare accepted useful reuse and selection cost against baseline, not tool counts. Any live migration requires an exact admitted portfolio plan and applicable operator authority; loaded-host claims require separate observation. Verify: registration-truth, agent journeys, installed-package and controlled policy evaluation.
200
+
188
201
  ## Evidence and scope notes
189
202
 
190
203
  The committed baseline of the former backlog is retained in Git at `16a3417`; later operational evidence is linked from the owning design and CI runs. Old candidate logs, review hashes and investigation iterations are evidence, not additional open work. Do not infer completed functionality from a closed author/reviewer process or infer publication/activation from a prototype PASS.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.58.0: Explicit Capability Retention
8
+
9
+ - `Capability Retention`: `register_tool expose=false` saves new template/from Recipes under `recipes/library` without activating tools or counting launches. Receipts return exact reusable paths and `activation: not_requested`, not failed activation. Both sync and async capabilities can be retained, spawned or deliberately exposed later; deleting an exposed wrapper preserves its retained source.
10
+ - `Retained Discovery and Usage`: Recipe inspection distinguishes retained definitions from tool candidates and drafts, with exact verbose paths, execution modes and invalid/disabled diagnostics. Inspection is read-only. Retained launches share the existing usage owner while keeping same-named tool history separate; matching content cannot silently transfer lineage across that boundary.
11
+ - `Safe Create-only Storage`: Retention validates sources and size, rejects direct disposable-draft dependencies and preserves name conflicts through atomic no-overwrite publication. Filesystems without hard-link support refuse rather than replace. Partial-effect failures preserve committed bytes and original causes; inspect before retrying. Retained update, delete and draft promotion remain unsupported.
12
+ - `Compatibility and Guidance`: Existing registration, tool discovery, automatic review and live portfolios remain unchanged; no migration or default policy is enabled. Updated Skills distinguish execution mode, retention and tool exposure. Delegated sources remain external dependencies, not self-contained backups; structural review and launch counts do not prove usefulness or accepted success.
13
+
7
14
  ## 0.57.1: Rooted Completion Headers
8
15
 
9
16
  - `Completion Headers`: New captures keep all runtime metadata above the Actor Markdown, with no service/file footer. File references share literal roots with relative entries, English purposes and a read-first pointer. External roots and unresolved bases stay explicit; declarations do not prove production. Known cut counts remain at the cut. Original Markdown, source/generation/privacy fences, shared budgets and exact historical replay are preserved.
package/README.md CHANGED
@@ -25,6 +25,12 @@ This topology does not require every task to become a subagent. Short work with
25
25
 
26
26
  Design notes for [portable Recipe composition bundles and CodeMode interoperability](docs/composition-transport.md) are recorded separately. JSONL bundle loading is not implemented or included in the current UX/AX release; host CodeMode, when available, calls existing tools rather than replacing the Run kernel.
27
27
 
28
+ ## Capability Memory Direction
29
+
30
+ A reusable Recipe does not have to become a named tool. The [adopted capability-memory direction](docs/capability-memory.md) separates retention from tool exposure: sync/async determines execution, not usefulness, and a subagent-backed tool is a capability launcher rather than a saved live Actor. The aim is useful procedural memory with a selective tool interface, not more registered names.
31
+
32
+ Use `register_tool expose=false` with a new name and a template/from source to save a capability in `recipes/library` without activating a tool. Its retained_recipe/not_requested receipt returns the exact Recipe path for `spawn` or later deliberate `register_tool from` exposure. Creation is exclusive; retained update, delete and draft promotion are not supported. `inspect target=recipes view=summary` distinguishes retained definitions, with exact paths, modes and diagnostics in verbose detail. Automatic retained lifecycle is not implemented. Current tool-root discovery, automatic review and ordinary registration remain unchanged; structural review and launch counts alone do not prove useful successful reuse. [BACKLOG.md](BACKLOG.md) owns implementation and evidence gates; adopting the direction neither migrates existing tools nor reloads a running host.
33
+
28
34
  ## Install
29
35
 
30
36
  Requires Node.js 22.19.0 or newer and Pi 1.0.0 or newer.
@@ -103,7 +109,7 @@ A Run exposes exactly `recipe`, `trace`, and `control` views. Run-specific `insp
103
109
 
104
110
  ### `register_tool`
105
111
 
106
- Persist a maintained Recipe with `register_tool name=<tool> from=<skill>/<recipe> defaults={...}`, or register a trusted command through the separate `template` mode. Definitions live under `~/.pi/agent/recipes`. Treat a tool as callable in the current session only when the result reports `callable_now: true`; persistence, Recipe spawning, and registered-tool invocation are distinct states.
112
+ Persist a maintained Recipe with `register_tool name=<tool> from=<skill>/<recipe> defaults={...}`, or register a trusted command through the separate `template` mode. Definitions live under `~/.pi/agent/recipes`. With `expose=false`, a new template/from definition is retained under `recipes/library` instead; its not_requested receipt makes no tool-callability claim. Treat an exposed tool as callable in the current session only when the result reports `callable_now: true`; persistence, Recipe spawning, and registered-tool invocation are distinct states.
107
113
 
108
114
  ## Recipe
109
115
 
@@ -13,6 +13,7 @@ export interface FileMutationLockOptions {
13
13
  export declare function acquireFileMutationLock(path: string, options?: FileMutationLockOptions): () => void;
14
14
  export declare function withFileMutationLock<T>(path: string, mutate: () => T, options?: FileMutationLockOptions): T;
15
15
  export declare function writeTextAtomic(path: string, content: string, options?: {
16
+ exclusive?: boolean;
16
17
  onBeforeReplace?(): void;
17
18
  }): void;
18
19
  export declare function writeJsonAtomic(path: string, value: unknown): void;
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { createHash, randomUUID } from "node:crypto";
8
- import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
8
+ import { existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { basename, dirname, join, parse, resolve } from "node:path";
11
11
  const FILE_MUTATION_LOCK_TIMEOUT_MS = process.platform === "win32" ? 30000 : 15000;
@@ -312,7 +312,13 @@ export function writeTextAtomic(path, content, options = {}) {
312
312
  try {
313
313
  writeFileSync(tempPath, content, "utf8");
314
314
  options.onBeforeReplace?.();
315
- replaceFileWithRetry(tempPath, path);
315
+ if (options.exclusive === true) {
316
+ // Publish a complete new file atomically without clobbering an uncooperative creator.
317
+ linkSync(tempPath, path);
318
+ unlinkSync(tempPath);
319
+ }
320
+ else
321
+ replaceFileWithRetry(tempPath, path);
316
322
  }
317
323
  catch (error) {
318
324
  try {
@@ -4,7 +4,7 @@
4
4
  * Owns LLM-facing descriptions, prompt snippets, guidelines, and parameter descriptions
5
5
  */
6
6
  export declare const REGISTER_TOOL_DESCRIPTION: string;
7
- export declare const REGISTER_TOOL_PROMPT_SNIPPET = "Register persistent Recipes or command templates as agent-callable tools";
7
+ export declare const REGISTER_TOOL_PROMPT_SNIPPET = "Persist command templates or Recipes with register_tool; use expose=false to retain without tool activation";
8
8
  export declare const REGISTER_TOOL_GUIDELINES: string[];
9
9
  export declare const ONBOARDING_SYSTEM_PROMPT = "pi-actors Skill routing:\n- Treat active bundled Skills as the operating authority.\n- For non-trivial pi-actors operation, diagnosis, or development, load and read the actors Skill before acting.\n- For work requiring multiple actors or subagents, additionally load and read the swarm Skill.\n- For capability-specific selection or constraints, load the owning capability Skill; actors owns generic mechanics and swarm owns multi-actor methodology.\n- Keep a Skill Recipe distinct from a registered tool and a Recipe spawn distinct from registered-tool invocation; actors owns the proof rules.\n- Treat persistence or registration as distinct from current callability; actors owns activation proof.\n- On failure or disagreement, preserve the logical Recipe identity, stop, and follow actors diagnosis; never bypass the owning Skills with copied contracts, helper paths, shell evaluation, or background-process workarounds.\n- If a capability Skill conflicts with actors about generic mechanics, follow actors and report the stale capability guidance.\n- README and docs are human-facing references, not the normal agent operating path.\n- AGENTS, source, and tests are implementation protocol and evidence; use them when changing or debugging the extension, not as substitutes for operating Skills.";
10
10
  export declare const REGISTER_TOOL_PARAM_DESCRIPTIONS: {
@@ -13,6 +13,7 @@ export declare const REGISTER_TOOL_PARAM_DESCRIPTIONS: {
13
13
  readonly from: "Recipe to specialize by canonical <skill>/<recipe> identity or explicit .json/.md path. Inherits async, args/types, source defaults, artifacts, Control, and runtime origins.";
14
14
  readonly defaults: "Optional caller-owned defaults. Keys and values must satisfy the effective source or command-template argument contract.";
15
15
  readonly draft: "Promote a captured draft Recipe path from ~/.pi/agent/recipes/drafts. This is a source mode; do not combine it with from or template.";
16
+ readonly expose: "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.";
16
17
  readonly async: "Set true for a co-located async template recipe. Omit for ordinary command templates or file-backed recipe references.";
17
18
  readonly template: "Trusted command template with {arg} or {arg=default} placeholders. To specialize a Recipe, use from instead. Omitted updates keep the old template; empty string deletes the tool.";
18
19
  readonly templateArray: "Sequential command-template composition array. Leaves may be strings or objects with template/defaults/timeout/retry/failure/recover.";
@@ -5,12 +5,15 @@
5
5
  */
6
6
  export const REGISTER_TOOL_DESCRIPTION = "Register a persistent custom tool from one maintained Recipe, command template, or captured draft. " +
7
7
  "Use from for Recipe specialization, template for trusted commands, or draft for promotion. " +
8
+ "Set expose=false to retain a new template/from Recipe without tool activation. " +
8
9
  "Definitions persist under ~/.pi/agent/recipes; activation is reported separately.";
9
- export const REGISTER_TOOL_PROMPT_SNIPPET = "Register persistent Recipes or command templates as agent-callable tools";
10
+ export const REGISTER_TOOL_PROMPT_SNIPPET = "Persist command templates or Recipes with register_tool; use expose=false to retain without tool activation";
10
11
  export const REGISTER_TOOL_GUIDELINES = [
11
12
  "Use register_tool from=<skill>/<recipe> with defaults={...} to specialize a maintained Recipe without copying its contract.",
12
13
  "Use register_tool template only for trusted command templates, and use register_tool draft only for captured draft promotion.",
13
- "After register_tool succeeds, trust its callable_now and activation result; persistence alone is not callability.",
14
+ "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.",
15
+ "A register_tool retained_recipe receipt with activation=not_requested is deliberate retention, not failed activation; reuse its exact Recipe path through spawn.",
16
+ "When register_tool requests tool exposure, trust callable_now and activation; persistence alone is not callability.",
14
17
  'Set template=null or template="" in register_tool to delete a persisted tool.',
15
18
  "Set update=true in register_tool to overwrite an existing tool registration.",
16
19
  ];
@@ -31,6 +34,7 @@ export const REGISTER_TOOL_PARAM_DESCRIPTIONS = {
31
34
  from: "Recipe to specialize by canonical <skill>/<recipe> identity or explicit .json/.md path. Inherits async, args/types, source defaults, artifacts, Control, and runtime origins.",
32
35
  defaults: "Optional caller-owned defaults. Keys and values must satisfy the effective source or command-template argument contract.",
33
36
  draft: "Promote a captured draft Recipe path from ~/.pi/agent/recipes/drafts. This is a source mode; do not combine it with from or template.",
37
+ expose: "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.",
34
38
  async: "Set true for a co-located async template recipe. Omit for ordinary command templates or file-backed recipe references.",
35
39
  template: "Trusted command template with {arg} or {arg=default} placeholders. To specialize a Recipe, use from instead. Omitted updates keep the old template; empty string deletes the tool.",
36
40
  templateArray: "Sequential command-template composition array. Leaves may be strings or objects with template/defaults/timeout/retry/failure/recover.",
@@ -67,6 +67,10 @@ export declare function discoverRecipeSources(sources: RecipesDiscoverySource[])
67
67
  export declare function discoverRecipes(roots: string[]): RecipesDiscoveryResult;
68
68
  export declare function createRecipeIntegrityManifest(result: RecipesDiscoveryResult): RecipeIntegrityManifestEntry[];
69
69
  export declare function getShadowedLaunchDiagnostic(result: RecipesDiscoveryResult, id: string): Record<string, unknown> | undefined;
70
+ export declare function inventoryRetainedRecipes(recipeRoot: string, resolutionContext?: RecipeResolutionContext): {
71
+ recipes: Array<Record<string, unknown>>;
72
+ diagnostics: string[];
73
+ };
70
74
  export declare function listDraftRecipes(root: string): Array<Record<string, unknown>>;
71
75
  export declare function summarizeDiscovery(result: RecipesDiscoveryResult): Record<string, unknown>;
72
76
  export declare function summarizeRegisteredToolArgs(tool: RegisteredTool): {
@@ -550,6 +550,28 @@ function templatePreview(value) {
550
550
  ? `${rendered.slice(0, 117)}...`
551
551
  : rendered;
552
552
  }
553
+ export function inventoryRetainedRecipes(recipeRoot, resolutionContext) {
554
+ const discovery = discoverRecipeSources([
555
+ { root: join(recipeRoot, "library"), resolutionContext },
556
+ ]);
557
+ return {
558
+ recipes: [...discovery.active.values()]
559
+ .map((entry) => ({
560
+ id: entry.id,
561
+ path: entry.path,
562
+ source_kind: "user_retained_recipe",
563
+ tool: false,
564
+ invalid: entry.invalid,
565
+ disabled: entry.disabled,
566
+ ...(entry.config ? { async: entry.config.async === true } : {}),
567
+ ...(entry.config?.description ? { description: entry.config.description } : {}),
568
+ ...(entry.diagnostics.length ? { diagnostics: entry.diagnostics } : {}),
569
+ ...(entry.riskLabels.length ? { risk_labels: entry.riskLabels } : {}),
570
+ }))
571
+ .sort((left, right) => left.id.localeCompare(right.id)),
572
+ diagnostics: discovery.diagnostics,
573
+ };
574
+ }
553
575
  export function listDraftRecipes(root) {
554
576
  return listRecipeFiles(root).map((path) => {
555
577
  for (let attempt = 0; attempt < 3; attempt += 1) {
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import type { CommandTemplateValue } from "./command-templates.ts";
7
7
  import * as CommandTemplates from "./command-templates.ts";
8
+ export declare const MAX_RECIPE_FILE_BYTES: number;
8
9
  export interface TemplateRecipeImportBinding {
9
10
  from?: string;
10
11
  defaults?: Record<string, unknown>;
@@ -9,7 +9,7 @@ import * as CommandTemplates from "./command-templates.js";
9
9
  import * as Paths from "./paths.js";
10
10
  import * as RecipeControl from "./recipe-control.js";
11
11
  import * as Schema from "./schema.js";
12
- const MAX_RECIPE_FILE_BYTES = 1024 * 1024;
12
+ export const MAX_RECIPE_FILE_BYTES = 1024 * 1024;
13
13
  const MAX_RECIPE_IMPORT_DEPTH = 32;
14
14
  const RESERVED_AUTHORED_RECIPE_VALUE_NAMES = new Set([
15
15
  "recipe_dir",
@@ -44,7 +44,7 @@ function readRecipeSource(path) {
44
44
  }
45
45
  function inferRecipeRoot(path) {
46
46
  const parent = dirname(resolve(path));
47
- return basename(parent) === "drafts" ? dirname(parent) : parent;
47
+ return ["drafts", "library"].includes(basename(parent)) ? dirname(parent) : parent;
48
48
  }
49
49
  function pathKey(path, recipeRoot) {
50
50
  const relation = relative(resolve(recipeRoot), resolve(path));
@@ -53,8 +53,9 @@ function pathKey(path, recipeRoot) {
53
53
  }
54
54
  return relation.replaceAll("\\", "/");
55
55
  }
56
- function recipeName(path) {
57
- return basename(path).replace(/\.(?:json|md)$/u, "");
56
+ function recipeName(path, recipeRoot) {
57
+ const name = basename(path).replace(/\.(?:json|md)$/u, "");
58
+ return dirname(resolve(path)) === resolve(recipeRoot, "library") ? `library/${name}` : name;
58
59
  }
59
60
  function lineageFilename(lineageName) {
60
61
  if (!lineageName || lineageName === "." || lineageName === "..") {
@@ -117,8 +118,10 @@ function readIndexedLedger(indexedName, recipeRoot) {
117
118
  const current = readLedger(indexedName, recipeRoot);
118
119
  return current ? { record: current } : undefined;
119
120
  }
120
- function findRelocatedRecipe(index, fingerprint, recipeRoot) {
121
+ function findRelocatedRecipe(index, fingerprint, recipeRoot, newPath) {
121
122
  const candidates = Object.entries(index.paths).flatMap(([oldPath, indexedName]) => {
123
+ if (oldPath.startsWith("library/") !== newPath.startsWith("library/"))
124
+ return [];
122
125
  if (existsSync(join(recipeRoot, oldPath)))
123
126
  return [];
124
127
  const found = readIndexedLedger(indexedName, recipeRoot);
@@ -157,7 +160,7 @@ export function ensureRecipeLineage(path, now = new Date(), recipeRoot = inferRe
157
160
  if (!source)
158
161
  return false;
159
162
  const key = pathKey(path, recipeRoot);
160
- const name = recipeName(path);
163
+ const name = recipeName(path, recipeRoot);
161
164
  const index = readIndex(recipeRoot);
162
165
  const indexedName = index.paths[key];
163
166
  if (indexedName) {
@@ -253,11 +256,11 @@ export function recordRecipeLaunch(path, now = new Date(), kind = "direct", reci
253
256
  return false;
254
257
  const { fingerprint } = source;
255
258
  const key = pathKey(path, recipeRoot);
256
- const desiredName = recipeName(path);
259
+ const desiredName = recipeName(path, recipeRoot);
257
260
  const index = readIndex(recipeRoot);
258
261
  const relocated = index.paths[key]
259
262
  ? undefined
260
- : findRelocatedRecipe(index, fingerprint, recipeRoot);
263
+ : findRelocatedRecipe(index, fingerprint, recipeRoot, key);
261
264
  const indexedName = index.paths[key] ?? relocated?.indexedName;
262
265
  const found = indexedName
263
266
  ? readIndexedLedger(indexedName, recipeRoot)
@@ -298,7 +301,7 @@ export function recordRecipeLaunch(path, now = new Date(), kind = "direct", reci
298
301
  former_names: renamed
299
302
  ? appendBounded(stored?.former_names ?? [], stored.lineage_name)
300
303
  : relocated
301
- ? appendBounded(stored?.former_names ?? [], recipeName(relocated.path))
304
+ ? appendBounded(stored?.former_names ?? [], recipeName(join(recipeRoot, relocated.path), recipeRoot))
302
305
  : stored?.former_names ?? [],
303
306
  former_paths: relocated
304
307
  ? appendBounded(stored?.former_paths ?? [], relocated.path)
@@ -444,7 +447,7 @@ export function moveRecipeUsage(fromPath, toPath, recipeRoot = inferRecipeRoot(f
444
447
  const toKey = pathKey(toPath, recipeRoot);
445
448
  const index = readIndex(recipeRoot);
446
449
  const oldName = index.paths[fromKey];
447
- const newName = recipeName(toPath);
450
+ const newName = recipeName(toPath, recipeRoot);
448
451
  if (!oldName) {
449
452
  return index.paths[toKey] === newName && Boolean(readLedger(newName, recipeRoot));
450
453
  }
@@ -10,6 +10,7 @@ export interface RegisterToolInput {
10
10
  name?: string;
11
11
  description?: string;
12
12
  async?: boolean;
13
+ expose?: boolean;
13
14
  template?: CommandTemplates.CommandTemplateValue | null;
14
15
  from?: string;
15
16
  defaults?: Record<string, unknown>;
@@ -19,13 +20,16 @@ export interface RegisterToolInput {
19
20
  }
20
21
  export interface RegisterToolResultDetails {
21
22
  active_tool?: boolean;
22
- activation?: "current_session" | "unverified";
23
+ activation?: "current_session" | "unverified" | "not_requested";
23
24
  activation_boundary?: string;
24
25
  args?: string[];
25
26
  async?: boolean;
26
27
  callable_now?: boolean;
27
28
  defaults?: Record<string, string>;
28
29
  host_registered?: boolean;
30
+ kind?: "retained_recipe";
31
+ recipe?: string;
32
+ result?: "retained";
29
33
  next_actions?: string[];
30
34
  optional_args?: string[];
31
35
  persisted?: boolean;
@@ -3,8 +3,8 @@
3
3
  * Zones: registry mutations, persistence, runtime activation
4
4
  * Owns register/update/delete validation, persistence, runtime side effects, and result payloads
5
5
  */
6
- import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
7
- import { dirname, join, relative, resolve } from "node:path";
6
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, unlinkSync } from "node:fs";
7
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
8
8
  import * as CommandTemplates from "./command-templates.js";
9
9
  import * as Config from "./config.js";
10
10
  import * as ExecutionOutput from "./execution-output.js";
@@ -560,6 +560,14 @@ function executeRegisterToolUnlocked(params, ctx, deps) {
560
560
  ? params
561
561
  : {};
562
562
  assertRegisterToolInputModes(rawInput);
563
+ if (Object.hasOwn(rawInput, "expose")) {
564
+ const exposure = rawInput.expose;
565
+ if (typeof exposure !== "boolean")
566
+ throw new RetainedRecipeRefusal("expose", "expose must be Boolean.");
567
+ // Select under the existing name reservation; acquire retention locks only after it releases.
568
+ if (exposure === false)
569
+ return () => retainRecipe(rawInput, ctx, deps);
570
+ }
563
571
  let input = rawInput;
564
572
  if (!input.name)
565
573
  return listTools(deps);
@@ -702,12 +710,196 @@ function executeRegisterToolUnlocked(params, ctx, deps) {
702
710
  },
703
711
  };
704
712
  }
713
+ class RetainedRecipeRefusal extends Error {
714
+ reason;
715
+ constructor(field, reason, cause) {
716
+ super(ExecutionOutput.formatToolText(registryMetadataText({ field, result: reason })), { cause });
717
+ this.reason = reason;
718
+ }
719
+ }
720
+ function retainedDirectory(path) {
721
+ const stat = lstatSync(path, { bigint: true });
722
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
723
+ throw new RetainedRecipeRefusal("storage", "Retained storage must be a regular non-symlink directory.");
724
+ }
725
+ return { path, realpath: realpathSync.native(path), dev: stat.dev, ino: stat.ino };
726
+ }
727
+ function recheckRetainedDirectory(frame) {
728
+ const current = retainedDirectory(frame.path);
729
+ if (current.realpath !== frame.realpath || current.dev !== frame.dev || current.ino !== frame.ino) {
730
+ throw new RetainedRecipeRefusal("storage", "Retained storage identity changed.");
731
+ }
732
+ }
733
+ function retainedSlotAbsent(path, ownTargetPublished = false) {
734
+ for (const candidate of ownTargetPublished ? [path.replace(/\.json$/u, ".md")] : [path, path.replace(/\.json$/u, ".md")]) {
735
+ try {
736
+ lstatSync(candidate);
737
+ }
738
+ catch (error) {
739
+ let code;
740
+ try {
741
+ code = error?.code;
742
+ }
743
+ catch (observationError) {
744
+ throw new AggregateError([error, observationError], "Retained slot observation failed.", { cause: error });
745
+ }
746
+ if (code === "ENOENT")
747
+ continue;
748
+ throw error;
749
+ }
750
+ throw new RetainedRecipeRefusal("name", "Retained Recipe already exists; this path is create-only.");
751
+ }
752
+ }
753
+ function retainedFile(path) {
754
+ const stat = lstatSync(path, { bigint: true });
755
+ if (!stat.isFile() || stat.isSymbolicLink()) {
756
+ throw new RetainedRecipeRefusal("storage", "Retained target must be a regular non-symlink file.");
757
+ }
758
+ if (stat.size > BigInt(RecipesReferences.MAX_RECIPE_FILE_BYTES))
759
+ throw new RetainedRecipeRefusal("storage", "Retained target exceeds the source size limit.");
760
+ return { dev: stat.dev, ino: stat.ino, size: stat.size, mtime: stat.mtimeNs, ctime: stat.ctimeNs };
761
+ }
762
+ function withinRetainedSource(root, path) {
763
+ const relation = relative(resolve(root), resolve(path));
764
+ return relation === "" || (!isAbsolute(relation) && !/^\.\.(?:[\\/]|$)/u.test(relation));
765
+ }
766
+ function assertRetainedSourceLifetime(source, root, field) {
767
+ const drafts = join(root, "drafts");
768
+ if (withinRetainedSource(drafts, source) ||
769
+ (existsSync(drafts) && existsSync(source) && withinRetainedSource(realpathSync.native(drafts), realpathSync.native(source)))) {
770
+ throw new RetainedRecipeRefusal(field, "Retained creation cannot reference a managed disposable draft.");
771
+ }
772
+ }
773
+ function assertRetainedTemplateLifetimes(value, path, root, context) {
774
+ if (typeof value === "string") {
775
+ const source = RecipesReferences.resolveRecipeReferencePath(value, dirname(path), context.activeSkills);
776
+ if (source)
777
+ assertRetainedSourceLifetime(source, root, "template");
778
+ }
779
+ else if (Array.isArray(value)) {
780
+ for (const child of value)
781
+ assertRetainedTemplateLifetimes(child, path, root, context);
782
+ }
783
+ else if (value && typeof value === "object") {
784
+ const node = value;
785
+ assertRetainedTemplateLifetimes(node.template, path, root, context);
786
+ assertRetainedTemplateLifetimes(node.recover, path, root, context);
787
+ }
788
+ }
789
+ function retainRecipe(input, ctx, deps) {
790
+ assertRegisterToolInputModes(input);
791
+ if (typeof input.name !== "string" || !input.name.trim()) {
792
+ throw new RetainedRecipeRefusal("name", "Retained creation requires a name.");
793
+ }
794
+ const name = Identity.normalizeToolName(input.name);
795
+ if (!name || deps.reservedToolNames.has(name)) {
796
+ throw new RetainedRecipeRefusal("name", "Invalid or reserved retained Recipe name.");
797
+ }
798
+ const blocker = deps.getToolNameBlocker(name);
799
+ if (blocker)
800
+ throw new Error(ExecutionOutput.formatToolText(blocker));
801
+ if (input.update || Object.hasOwn(input, "draft")) {
802
+ throw new RetainedRecipeRefusal("expose", "Retained creation does not support update or draft promotion.");
803
+ }
804
+ const template = getInputTemplate(input.template);
805
+ if (template === null || template === "") {
806
+ throw new RetainedRecipeRefusal("template", "Retained deletion is not supported.");
807
+ }
808
+ if (template === undefined && !input.from) {
809
+ throw new RetainedRecipeRefusal("source", "Retained creation requires template or from.");
810
+ }
811
+ const root = resolve(getRecipeRoot(deps)), library = join(root, "library"), path = join(library, `${name}.json`);
812
+ const context = getRegistrationResolutionContext(ctx, deps);
813
+ const from = input.from === undefined ? undefined : normalizeFromReference(input.from, context);
814
+ if (from) {
815
+ const source = from.endsWith(".json") || from.endsWith(".md") ? from
816
+ : RecipesReferences.resolveRecipeReferencePath(from, context.cwd, context.activeSkills);
817
+ if (source)
818
+ assertRetainedSourceLifetime(source, root, "from");
819
+ }
820
+ const authored = buildAuthoredRecipe({ ...input, ...(from ? { from } : {}) }, undefined, undefined);
821
+ if (!from)
822
+ assertRetainedTemplateLifetimes(authored.template, path, root, context);
823
+ const bytes = `${JSON.stringify(authored, null, 2)}\n`;
824
+ if (Buffer.byteLength(bytes) > RecipesReferences.MAX_RECIPE_FILE_BYTES) {
825
+ throw new RetainedRecipeRefusal("source", "Retained Recipe exceeds the 1 MiB source limit.");
826
+ }
827
+ const candidate = RecipesDiscovery.admitUserRecipe(path, context, authored);
828
+ if (!candidate.validated || !candidate.tool) {
829
+ throw new RetainedRecipeRefusal("source", "Retained Recipe admission failed; check the source and caller argument contract.", candidate.diagnostics);
830
+ }
831
+ let stage = "storage";
832
+ try {
833
+ return withFileMutationLock(root, () => {
834
+ mkdirSync(root, { recursive: true });
835
+ const rootFrame = retainedDirectory(root);
836
+ return withFileMutationLock(library, () => {
837
+ recheckRetainedDirectory(rootFrame);
838
+ mkdirSync(library, { recursive: true });
839
+ const libraryFrame = retainedDirectory(library);
840
+ const relation = relative(rootFrame.realpath, libraryFrame.realpath);
841
+ if ((process.platform === "win32" ? relation.toLowerCase() : relation) !== "library") {
842
+ throw new RetainedRecipeRefusal("storage", "Retained library escapes its Recipe root.");
843
+ }
844
+ const recheck = () => { recheckRetainedDirectory(rootFrame); recheckRetainedDirectory(libraryFrame); };
845
+ return withFileMutationLock(path, () => {
846
+ recheck();
847
+ retainedSlotAbsent(path);
848
+ stage = "persistence";
849
+ writeTextAtomic(path, bytes, { exclusive: true, onBeforeReplace: () => { recheck(); retainedSlotAbsent(path); } });
850
+ stage = "persisted_admission";
851
+ recheck();
852
+ retainedSlotAbsent(path, true);
853
+ const before = retainedFile(path);
854
+ if (readFileSync(path, "utf8") !== bytes)
855
+ throw new RetainedRecipeRefusal("storage", "Persisted retained content changed.");
856
+ const admitted = RecipesDiscovery.admitUserRecipe(path, context);
857
+ if (!admitted.validated || !admitted.tool)
858
+ throw new RetainedRecipeRefusal("source", "Persisted retained Recipe admission failed.", admitted.diagnostics);
859
+ const after = retainedFile(path);
860
+ if (Object.keys(before).some(key => before[key] !== after[key]) ||
861
+ readFileSync(path, "utf8") !== bytes)
862
+ throw new RetainedRecipeRefusal("storage", "Persisted retained content changed.");
863
+ recheck();
864
+ retainedSlotAbsent(path, true);
865
+ stage = "receipt";
866
+ const args = RecipesDiscovery.summarizeRegisteredToolArgs(admitted.tool);
867
+ const source = from ? from.endsWith(".json") || from.endsWith(".md") ? `<explicit-file:${basename(from)}>` : from : "template";
868
+ const recipe = registrySelected("recipe", path), selectedName = registrySelected("tool", name);
869
+ const selectedSource = registrySelected("source", source);
870
+ const required = ToolsResponse.selectYamlField("required_args", args.required, REGISTRY_METADATA_BUDGET);
871
+ const optional = ToolsResponse.selectYamlField("optional_args", args.optional, REGISTRY_METADATA_BUDGET);
872
+ const facts = { kind: "retained_recipe", result: "retained", persisted: true, resolved: true,
873
+ validated: true, activation: "not_requested", async: admitted.async };
874
+ const text = growRegistryDocument(() => ({ ...selectedName.selected, ...facts, ...recipe.selected, ...selectedSource.selected,
875
+ ...required.selected, ...optional.selected, next: { tool: "inspect", target: "recipes", view: "summary", verbose: true } }), [recipe, selectedName, selectedSource, required, optional]);
876
+ const result = {
877
+ content: [textContent(ExecutionOutput.formatToolText(text))],
878
+ details: { ...facts, tool: name, recipe: path, source, required_args: args.required, optional_args: args.optional,
879
+ next_actions: ["inspect target=recipes view=summary verbose=true"] },
880
+ };
881
+ stage = "release";
882
+ return result;
883
+ });
884
+ });
885
+ });
886
+ }
887
+ catch (error) {
888
+ const reason = error instanceof RetainedRecipeRefusal ? { reason: error.reason } : {};
889
+ throw new Error(ExecutionOutput.formatToolText(registryMetadataText({ operation: "retain", stage, result: "unconfirmed",
890
+ effects: "unknown; inspect retained inventory before retry", ...reason,
891
+ next: { tool: "inspect", target: "recipes", view: "summary", verbose: true } })), { cause: error });
892
+ }
893
+ }
705
894
  export async function executeRegisterTool(params, ctx, deps) {
706
895
  const input = params;
896
+ let result;
707
897
  if (!input.name)
708
- return executeRegisterToolUnlocked(params, ctx, deps);
709
- const name = Identity.normalizeToolName(input.name);
710
- if (!name)
711
- return executeRegisterToolUnlocked(params, ctx, deps);
712
- return withFileMutationLock(getToolRecipePath(deps, name), () => executeRegisterToolUnlocked(params, ctx, deps));
898
+ result = executeRegisterToolUnlocked(params, ctx, deps);
899
+ else {
900
+ const name = Identity.normalizeToolName(input.name);
901
+ result = !name ? executeRegisterToolUnlocked(params, ctx, deps)
902
+ : withFileMutationLock(getToolRecipePath(deps, name), () => executeRegisterToolUnlocked(params, ctx, deps));
903
+ }
904
+ return typeof result === "function" ? result() : result;
713
905
  }
@@ -364,9 +364,6 @@ function inspectRecipes(requestedView, input, deps, context) {
364
364
  if (view === "reviews") {
365
365
  return ReviewDiagnostics.readAutomaticReviewDiagnostics({ recipeRoot });
366
366
  }
367
- const discovered = RecipesDiscovery.discoverRecipeSources([
368
- { root: recipeRoot, defaultTool: true, mutableUsage: true },
369
- ]);
370
367
  const resolutionContext = context && typeof context === "object" && "recipeResolutionContext" in context
371
368
  ? context.recipeResolutionContext
372
369
  : undefined;
@@ -399,7 +396,11 @@ function inspectRecipes(requestedView, input, deps, context) {
399
396
  skill: diagnostic.skill,
400
397
  ...(diagnostic.stem ? { stem: diagnostic.stem } : {}),
401
398
  }));
399
+ const discovered = RecipesDiscovery.discoverRecipeSources([
400
+ { root: recipeRoot, defaultTool: true, mutableUsage: true, resolutionContext },
401
+ ]);
402
402
  const discoverySummary = RecipesDiscovery.summarizeDiscovery(discovered);
403
+ const retained = RecipesDiscovery.inventoryRetainedRecipes(recipeRoot, resolutionContext);
403
404
  const summary = {
404
405
  ...discoverySummary,
405
406
  ...(registryStatus
@@ -425,6 +426,8 @@ function inspectRecipes(requestedView, input, deps, context) {
425
426
  error: `Ambiguous active Skill Recipe namespace ${name}`,
426
427
  })),
427
428
  drafts: RecipesDiscovery.listDraftRecipes(join(recipeRoot, "drafts")),
429
+ retained: retained.recipes,
430
+ retained_diagnostics: retained.diagnostics,
428
431
  };
429
432
  return {
430
433
  ...summary,
@@ -26,6 +26,7 @@ export function createRegisterToolDefinition(deps) {
26
26
  description: stringSchema(Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.description),
27
27
  defaults: looseObjectSchema(Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.defaults),
28
28
  draft: stringSchema(Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.draft),
29
+ expose: booleanSchema(Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.expose),
29
30
  from: stringSchema(Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.from),
30
31
  name: stringSchema(Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.name),
31
32
  template: unionSchema([