@llblab/pi-actors 0.34.1 → 0.36.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.
package/AGENTS.md CHANGED
@@ -113,9 +113,11 @@ Pi host
113
113
  - `~/.pi/agent/recipes/*.json` is executable muscle memory: recipes there become persistent tools by location.
114
114
  - Preserve filename identity, atomic writes, explicit operator-gated changes, and local transportability.
115
115
  - Packaged/ad hoc recipes outside the agent root are components, not user tools.
116
+ - Register existing recipes by importing them from the user-root wrapper and using a `{ "name": "alias" }` template node; do not duplicate a ready recipe's script command, defaults, mailbox, or artifact contract in the wrapper.
117
+ - Skill-owned scripts must be exposed through skill-owned recipes first. If a local tool needs that capability, import the skill recipe via `{agent}/skills/<skill>/recipes/<recipe>.json` instead of calling `{agent}/skills/<skill>/scripts/*` directly.
116
118
  - Tool definitions use `template`, not `script`, and built-in/core tool names must not be shadowed.
117
119
  - Packaged recipe growth is demand-driven: prefer reusable components over speculative scenario catalogs.
118
- - Recipe templates may point directly at executable helper scripts; keep script executable bits and avoid unnecessary `node` prefixes.
120
+ - Recipe templates may point directly at executable helper scripts when the recipe owns that script boundary; keep script executable bits and avoid unnecessary `node` prefixes.
119
121
 
120
122
  ## Command And Recipe Layers
121
123
 
package/BACKLOG.md CHANGED
@@ -51,91 +51,9 @@ No open hotfix items.
51
51
 
52
52
  ## Minor Backlog
53
53
 
54
- The backlog is intentionally pruned to the 20% of work most likely to deliver 80% of value for `pi-actors` as a local actor kernel. Bias toward consolidation, smaller public surface area, and reliability over new feature breadth.
54
+ No open minor items.
55
55
 
56
- ### M-18 Draft Recipe Promotion UX
57
-
58
- - Priority: High.
59
- - Status: Planned.
60
- - Goal: Make successful ad hoc actor patterns easy to promote manually from draft memory into active user recipe memory.
61
- - Why now: Draft recipes under `~/.pi/agent/recipes/drafts` are replayable but intentionally not active tools, and the two-stage memory model needs an explicit operator-gated promotion path.
62
- - Direction:
63
- - List draft recipes with source run, timestamp, fingerprint, description/template preview, and validation status.
64
- - Promote a selected draft to `~/.pi/agent/recipes/<name>.json` only through an explicit action or explicit tool argument.
65
- - Run recipe validation/doctor before writing and expose collision/shadowing diagnostics.
66
- - Preserve draft files unless deletion is explicitly requested.
67
- - Prefer extending existing registry/tool surfaces over adding a new public noun.
68
- - Acceptance:
69
- - Draft recipes remain non-tools until promotion.
70
- - Promotion writes atomically and never auto-promotes.
71
- - Tests cover valid promotion, invalid draft, name collision, and packaged-recipe shadowing.
72
- - Docs explain draft memory vs active tool memory in one compact section.
73
-
74
- ### M-19 Recipe Doctor Risk Labels v2
75
-
76
- - Priority: Medium.
77
- - Status: Planned.
78
- - Goal: Evolve recipe doctor into a compact capability-risk membrane without pretending to sandbox trusted local execution.
79
- - Why now: Recipe doctor already has remediation UX; the next useful slice is deterministic advisory risk classification for local capabilities.
80
- - Direction:
81
- - Add advisory labels such as `risk.shell`, `risk.eval`, `risk.broad_fs_write`, `risk.destructive_fs`, `risk.network`, `risk.external_side_effect`, `risk.long_running`, `risk.platform_specific`, and `risk.secret_touching`.
82
- - Keep labels advisory and deterministic; do not block execution unless existing validation already blocks it.
83
- - Expose compact risk summaries in `inspect target=recipes view=doctor` and verbose per-recipe labels.
84
- - Keep launch-time warnings quiet except for already-failing or clearly dangerous cases.
85
- - Preserve honest wording: trusted local execution, not isolation.
86
- - Acceptance:
87
- - Risk labels are deterministic and tested.
88
- - Existing risky shell-boundary diagnostics remain intact.
89
- - Doctor output stays compact by default.
90
- - README/docs do not introduce sandbox or security-boundary claims.
91
-
92
- ### M-20 Runtime Triage Surface
93
-
94
- - Priority: Medium.
95
- - Status: Planned.
96
- - Goal: Add one compact operator triage view that answers what needs attention right now without performing repairs.
97
- - Why now: Runtime status, recipe doctor, drafts, stale claims, session mismatches, failed runs, and other-session counts are currently separate bounded surfaces.
98
- - Direction:
99
- - Add `inspect target=tool:pi-actors view=triage` or an equivalent existing inspect surface.
100
- - Summarize runtime version/mode, active runs, other-session runs, invalid or blocking recipes, high-risk recipes, draft recipes, stale worker claims, recent failed runs, attention messages, and suggested next inspect actions.
101
- - Keep every warning tied to a next inspect/action hint.
102
- - Do not auto-repair, auto-prune, relax ownership, or hide detailed source-of-truth views.
103
- - Acceptance:
104
- - Triage output is compact enough for agent context.
105
- - Healthy and degraded states are covered by tests.
106
- - Detailed inspect/doctor/status views remain source of truth.
107
-
108
- ### M-21 Packaged Recipe QA Matrix
109
-
110
- - Priority: Medium.
111
- - Status: Planned.
112
- - Goal: Prevent packaged recipes from drifting into inconsistent mailbox, artifact, platform, or package-root behavior.
113
- - Why now: Packaged recipes are standard-library components; they should be boringly consistent before operators copy or register them as durable local capabilities.
114
- - Direction:
115
- - Add an internal QA check over `recipes/*.json` for descriptions, async mailbox contracts, termination vocabulary, artifact declarations, platform notes, installed-package-safe helper paths, and compiled shim coverage.
116
- - Keep `control.kill` as generic runtime termination and allow `control.stop` / `control.cancel` only as actor-domain vocabulary.
117
- - Fail with exact recipe/path/key diagnostics.
118
- - Avoid a broad recipe-library rewrite beyond violations discovered by the check.
119
- - Acceptance:
120
- - QA runs under an existing validation command or a clearly named subcheck used by `npm run validate`.
121
- - Tests/fixtures cover at least one positive and one negative case.
122
- - Packaged recipes remain optional components, not policy workflows.
123
-
124
- ### M-22 Wake and Watcher Chaos Fixtures
125
-
126
- - Priority: Medium.
127
- - Status: Planned.
128
- - Goal: Harden the invariant that durable files are canonical and wake notifications are advisory acceleration.
129
- - Why now: Wake, watcher, line-counter, and JSONL resilience are central to operator trust as actor counts grow.
130
- - Direction:
131
- - Add deterministic fixtures for watcher restart, line-counter reset, duplicate terminal events, missing wake with present inbox record, wake before file catch-up, corrupt JSONL with later valid records, and killed run with stale progress phase.
132
- - Preserve event-driven observability without reintroducing polling-first coordination examples.
133
- - Keep tests fast and local.
134
- - Acceptance:
135
- - Duplicate follow-ups do not reappear.
136
- - Missing wake does not lose durable messages.
137
- - Corrupt records degrade inspect but do not kill it.
138
- - Killed/stale progress states remain diagnosable.
56
+ The backlog is intentionally pruned to the 20% of work most likely to deliver 80% of value for `pi-actors` as a local actor kernel. Bias toward consolidation, smaller public surface area, and reliability over new feature breadth.
139
57
 
140
58
  ## Explicitly Deferred
141
59
 
@@ -145,7 +63,7 @@ These are valid ideas but not current focus. Reintroduce only with concrete evid
145
63
  - Run restart/reattach policy: risky for isolation; defer until corruption recovery and protocol fixtures are stronger.
146
64
  - Cross-session force kill or attach/adopt/reparent: useful later, but ownership policy should not change until observability makes current boundaries clear.
147
65
  - Actor address helper CLI: keep diagnostics improving opportunistically inside existing parser/tests.
148
- - Golden flow docs and flow conformance runner: useful after M-14, M-15, M-17, and M-18 make the diagnostic and promotion surfaces stable.
66
+ - Golden flow docs and flow conformance runner: useful after the diagnostic and promotion surfaces are stable.
149
67
  - Documentation refactor: defer until the canonical mailbox loop and worker recipe exist; avoid rewriting docs twice.
150
68
  - Host-level tool unregistration: blocked on host API support.
151
69
  - Branch-local checkpoint semantics: wait for real collaborative branch-runner experiments.
@@ -153,8 +71,4 @@ These are valid ideas but not current focus. Reintroduce only with concrete evid
153
71
 
154
72
  ## Suggested Milestone Order
155
73
 
156
- ```text
157
- Next milestone: M-18 Draft Recipe Promotion UX.
158
- Then: M-19 Recipe Doctor Risk Labels v2.
159
- Small cleanup lane: continue opportunistic domain polish only when a real ownership boundary appears.
160
- ```
74
+ No current milestone. Reassess runtime evidence before adding the next reliability slice.
package/CHANGELOG.md CHANGED
@@ -2,11 +2,23 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.36.0: Recipe Diagnostics And Runtime Triage
6
+
7
+ - `[Recipe Doctor]` Added deterministic advisory risk labels for discovered recipes, including shell, eval, filesystem mutation, network, external side effect, long-running, platform-specific, and secret-touching signals in verbose recipe inspection plus compact doctor risk counts.
8
+ - `[Runtime]` Added `inspect target=tool:pi-actors view=triage` as a compact read-only operator attention surface covering runtime mode, active and other-session runs, invalid or blocking recipes, exposed tool recipes with non-lifecycle risk labels, drafts, stale claims, failed runs, attention messages, and next inspect actions.
9
+ - `[Recipes]` Added packaged recipe QA through `validate-recipe.mjs --qa` and `npm run recipes:qa`, with exact diagnostics for async mailbox contracts, termination vocabulary, artifact paths, platform scope, installed-package-safe helper paths, and missing helper scripts.
10
+ - `[Runtime]` Hardened wake/watch chaos behavior with deterministic coverage for watcher restarts, partial wake records before file catch-up, corrupt wake JSONL with later valid records, missing wake records with durable inbox work, and killed runs with stale progress state.
11
+ - `[Docs]` Documented recipe-doctor risk labels, runtime triage, packaged recipe QA, and import-first ready-recipe registration as operator review aids and source-of-truth preservation, not sandbox, repair, or security-boundary claims.
12
+
13
+ ## 0.35.0: Draft Recipe Promotion UX
14
+
15
+ - `[Recipes]` Added draft recipe promotion UX: `inspect target=recipes view=summary verbose=true` now exposes draft timestamps, fingerprints, validation state, source run when known, and template previews, while `register_tool name=<tool> draft=<path>` promotes a validated draft into active recipe memory without deleting the draft and rejects collisions unless `update=true` is explicit.
16
+
5
17
  ## 0.34.1: Message Delivery Outcome Hotfix
6
18
 
7
19
  - `[Dogfood]` Added a deterministic actor-worker stale-claim smoke covering an intentionally claimed branch inbox record; the worker now reports stale claim counts in both `worker-status.json` and its awaiting-assignment room event without adding auto-recovery or scheduler policy.
8
- - `[Messages]` Completed the M-17 message delivery outcome contract by normalizing public message results with `delivered`, `persisted`, `queued`, `forwarded`, `consumer`, and `reason` fields across run, branch, room, coordinator, session, and tool destinations; room multicast now also exposes per-recipient branch delivery outcomes.
9
- - `[Backlog]` Closed M-15 Worker Stale-Claim Dogfood and M-17 Message Delivery Outcome Contract after adding delivery-result coverage for run, branch, room, coordinator, session, tool, and ownership-denied paths.
20
+ - `[Messages]` Normalized public message results with `delivered`, `persisted`, `queued`, `forwarded`, `consumer`, and `reason` fields across run, branch, room, coordinator, session, and tool destinations; room multicast now also exposes per-recipient branch delivery outcomes.
21
+ - `[Backlog]` Closed the worker stale-claim dogfood and message delivery outcome work after adding delivery-result coverage for run, branch, room, coordinator, session, tool, and ownership-denied paths.
10
22
 
11
23
  ## 0.34.0: Actor Kernel Domain Compression
12
24
 
package/README.md CHANGED
@@ -132,6 +132,7 @@ docs_review scope="README.md" model="current-review-model" run_id=docs_review
132
132
  Inspect only when there is a reason:
133
133
 
134
134
  ```text
135
+ inspect target=tool:pi-actors view=triage
135
136
  inspect target=run:docs_review view=status
136
137
  inspect target=run:docs_review view=tail lines=80
137
138
  inspect target=run:docs_review view=messages
@@ -207,7 +208,8 @@ Rules:
207
208
  - User recipes override same-name lower-priority recipes;
208
209
  - Same-id JSON recipes shadow Markdown recipes in the same priority layer;
209
210
  - Packaged recipes are standard-library components, not automatically installed operator policy;
210
- - `register_tool` creates, updates, lists, or deletes user recipe files through the normal agent interface.
211
+ - Draft recipes in `~/.pi/agent/recipes/drafts/` are replayable memory, not active tools;
212
+ - `register_tool` creates, updates, lists, deletes, or explicitly promotes draft recipe files through the normal agent interface.
211
213
 
212
214
  Example foreground tool:
213
215
 
@@ -226,6 +228,14 @@ register_tool name=docs_review \
226
228
  args="scope:path,model:string"
227
229
  ```
228
230
 
231
+ Promote a successful captured draft only after an explicit operator decision:
232
+
233
+ ```text
234
+ register_tool name=docs_review draft=~/.pi/agent/recipes/drafts/spawned-run.json
235
+ ```
236
+
237
+ Promotion validates the draft, writes `~/.pi/agent/recipes/<name>.json`, preserves the draft, and rejects collisions unless `update=true` is supplied.
238
+
229
239
  Inspect the discovered registry:
230
240
 
231
241
  ```text
@@ -298,7 +308,7 @@ Packaged recipes should prefer mailbox/wake behavior for portable control. Recip
298
308
 
299
309
  Commands execute directly without shell evaluation where possible, but trusted executables still run with the same system permissions as Pi. Only register commands, scripts, recipes, and paths you trust.
300
310
 
301
- High-risk templates such as shells, interpreter eval modes, and broad filesystem mutation may surface warnings, but the runtime is not a security boundary.
311
+ High-risk templates such as shells, interpreter eval modes, and broad filesystem mutation may surface warnings, but the runtime is not a security boundary. Recipe doctor also exposes advisory labels like `risk.shell`, `risk.eval`, `risk.destructive_fs`, `risk.network`, and `risk.external_side_effect` for operator review.
302
312
 
303
313
  Prefer:
304
314
 
@@ -50,6 +50,7 @@ export interface CommandTemplateExecResult {
50
50
  code: number;
51
51
  killed: boolean;
52
52
  }
53
+ export type CommandTemplateRiskLabel = "risk.shell" | "risk.eval" | "risk.broad_fs_write" | "risk.destructive_fs" | "risk.network" | "risk.external_side_effect" | "risk.long_running" | "risk.platform_specific" | "risk.secret_touching";
53
54
  export type CommandTemplateExecCommand = (command: string, args: string[], options?: CommandTemplateExecOptions) => Promise<CommandTemplateExecResult>;
54
55
  export declare function normalizeCommandTemplateConfig(config: CommandTemplateConfig): CommandTemplateObjectConfig;
55
56
  export declare function resolveInheritedDefaultReferences(ownDefaults: Record<string, unknown> | undefined, inheritedDefaults: Record<string, unknown> | undefined, runtimeValues?: Record<string, unknown>): Record<string, unknown> | undefined;
@@ -58,6 +59,7 @@ export declare function isCommandTemplateRepeatPlaceholder(name: string): boolea
58
59
  export declare function getCommandTemplateRepeatDefaults(index: number, repeat: number): Record<string, string>;
59
60
  export declare function expandCommandTemplateConfigs(config: CommandTemplateConfig, inherited?: Pick<CommandTemplateObjectConfig, "args" | "defaults">): CommandTemplateLeafConfig[];
60
61
  export declare function getCommandTemplateWarnings(config: CommandTemplateConfig): string[];
62
+ export declare function getCommandTemplateRiskLabels(config: CommandTemplateConfig): CommandTemplateRiskLabel[];
61
63
  export declare function getCommandTemplateDefaults(config: CommandTemplateConfig | undefined): Record<string, string>;
62
64
  export declare function splitCommandTemplate(input: string): string[];
63
65
  export declare function expandCommandTemplateExecutable(command: string, cwd: string): string;
@@ -6,6 +6,17 @@
6
6
  import { spawn } from "node:child_process";
7
7
  import { homedir } from "node:os";
8
8
  import { isAbsolute, resolve } from "node:path";
9
+ const COMMAND_TEMPLATE_RISK_LABEL_ORDER = [
10
+ "risk.shell",
11
+ "risk.eval",
12
+ "risk.destructive_fs",
13
+ "risk.broad_fs_write",
14
+ "risk.external_side_effect",
15
+ "risk.secret_touching",
16
+ "risk.network",
17
+ "risk.long_running",
18
+ "risk.platform_specific",
19
+ ];
9
20
  function normalizeCommandTemplateArgs(value) {
10
21
  if (!Array.isArray(value))
11
22
  return [];
@@ -93,6 +104,68 @@ function hasRiskyPathArg(args) {
93
104
  arg.startsWith("~/") ||
94
105
  arg.startsWith("/"));
95
106
  }
107
+ function sortRiskLabels(labels) {
108
+ const unique = new Set(labels);
109
+ return COMMAND_TEMPLATE_RISK_LABEL_ORDER.filter((label) => unique.has(label));
110
+ }
111
+ function hasAnyArg(args, values) {
112
+ return args.some((arg) => values.includes(arg.toLowerCase()));
113
+ }
114
+ function hasSecretTouchingText(parts) {
115
+ return parts.some((part) => /(^|[{}._\-\s/])(?:secret|token|password|passwd|credential|api[_-]?key|private[_-]?key|\.env|ssh[_-]?key)(?:[{}._\-\s/]|$)/i.test(part));
116
+ }
117
+ function getLeafCommandTemplateRiskLabels(config) {
118
+ const parts = splitCommandTemplate(config.template);
119
+ const command = getExecutableName(parts[0]);
120
+ const args = parts.slice(1);
121
+ const labels = new Set();
122
+ if (["bash", "sh", "zsh", "fish"].includes(command)) {
123
+ labels.add("risk.shell");
124
+ if (hasAnyFlag(args, ["-c"]))
125
+ labels.add("risk.eval");
126
+ }
127
+ if (["node", "deno", "bun"].includes(command) &&
128
+ hasAnyFlag(args, ["-e", "--eval"])) {
129
+ labels.add("risk.eval");
130
+ }
131
+ if (["python", "python3", "perl", "ruby"].includes(command) &&
132
+ hasAnyFlag(args, ["-c", "-e"])) {
133
+ labels.add("risk.eval");
134
+ }
135
+ if (command === "rm" &&
136
+ (args.some((arg) => /^-[^-]*r/.test(arg) || /^-[^-]*f/.test(arg)) ||
137
+ hasRiskyPathArg(args))) {
138
+ labels.add("risk.destructive_fs");
139
+ }
140
+ if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
141
+ labels.add("risk.broad_fs_write");
142
+ }
143
+ if (["curl", "wget", "ssh", "scp", "sftp", "rsync", "nc", "ncat", "telnet", "ftp"].includes(command) ||
144
+ (command === "git" &&
145
+ hasAnyArg(args, ["clone", "fetch", "pull", "push", "ls-remote"])) ||
146
+ ["npm", "pnpm", "yarn", "pip", "cargo"].includes(command)) {
147
+ labels.add("risk.network");
148
+ }
149
+ if (["gh", "glab", "hub", "kubectl", "terraform"].includes(command) ||
150
+ (command === "git" && hasAnyArg(args, ["push"])) ||
151
+ (["npm", "pnpm", "yarn"].includes(command) &&
152
+ hasAnyArg(args, ["publish", "login", "logout", "deprecate"]))) {
153
+ labels.add("risk.external_side_effect");
154
+ }
155
+ if (command === "sleep" ||
156
+ command === "watch" ||
157
+ (command === "tail" && hasAnyFlag(args, ["-f"])) ||
158
+ hasAnyArg(args, ["--watch", "--serve", "serve"])) {
159
+ labels.add("risk.long_running");
160
+ }
161
+ if (["systemctl", "launchctl", "osascript", "open", "xdg-open", "powershell", "pwsh", "cmd.exe", "apt", "apt-get", "dnf", "yum", "brew", "pacman", "apk", "xclip", "wl-copy"].includes(command)) {
162
+ labels.add("risk.platform_specific");
163
+ }
164
+ if (["pass", "gpg", "ssh-add"].includes(command) || hasSecretTouchingText(parts)) {
165
+ labels.add("risk.secret_touching");
166
+ }
167
+ return sortRiskLabels(labels);
168
+ }
96
169
  function getLeafCommandTemplateWarnings(config) {
97
170
  const parts = splitCommandTemplate(config.template);
98
171
  const command = getExecutableName(parts[0]);
@@ -206,6 +279,9 @@ export function getCommandTemplateWarnings(config) {
206
279
  ...new Set(expandCommandTemplateConfigs(config).flatMap((leaf) => getLeafCommandTemplateWarnings(leaf))),
207
280
  ];
208
281
  }
282
+ export function getCommandTemplateRiskLabels(config) {
283
+ return sortRiskLabels(expandCommandTemplateConfigs(config).flatMap((leaf) => getLeafCommandTemplateRiskLabels(leaf)));
284
+ }
209
285
  function parseCommandTemplateArgToken(value) {
210
286
  const separatorIndex = value.indexOf("=");
211
287
  const rawName = separatorIndex === -1 ? value : value.slice(0, separatorIndex);
@@ -10,6 +10,7 @@ export declare const ONBOARDING_SYSTEM_PROMPT = "pi-actors quick model:\n- Local
10
10
  export declare const REGISTER_TOOL_PARAM_DESCRIPTIONS: {
11
11
  readonly name: "Tool name in snake_case (e.g., 'transcribe')";
12
12
  readonly description: "Describe what the tool does for the LLM. Required unless deleting; omitted updates keep the old description.";
13
+ readonly draft: "Promote a draft recipe path from ~/.pi/agent/recipes/drafts into an active named recipe under ~/.pi/agent/recipes. Requires name; use update=true to overwrite.";
13
14
  readonly async: "Set true for a co-located async template recipe. Omit for ordinary command templates or file-backed recipe references.";
14
15
  readonly state_dir: "Optional async run state directory for a co-located template recipe.";
15
16
  readonly template: "Command template with {arg} or {arg=default} placeholders, or a template recipe JSON path/name. With async, this is the co-located recipe body. Bare recipe names resolve under ~/.pi/agent/recipes. Omitted updates keep the old template. Empty string deletes the tool.";
@@ -30,6 +30,7 @@ export const ONBOARDING_SYSTEM_PROMPT = `pi-actors quick model:
30
30
  export const REGISTER_TOOL_PARAM_DESCRIPTIONS = {
31
31
  name: "Tool name in snake_case (e.g., 'transcribe')",
32
32
  description: "Describe what the tool does for the LLM. Required unless deleting; omitted updates keep the old description.",
33
+ draft: "Promote a draft recipe path from ~/.pi/agent/recipes/drafts into an active named recipe under ~/.pi/agent/recipes. Requires name; use update=true to overwrite.",
33
34
  async: "Set true for a co-located async template recipe. Omit for ordinary command templates or file-backed recipe references.",
34
35
  state_dir: "Optional async run state directory for a co-located template recipe.",
35
36
  template: "Command template with {arg} or {arg=default} placeholders, or a template recipe JSON path/name. With async, this is the co-located recipe body. Bare recipe names resolve under ~/.pi/agent/recipes. Omitted updates keep the old template. Empty string deletes the tool.",
@@ -3,6 +3,7 @@
3
3
  * Zones: recipe discovery, tool exposure, registry diagnostics
4
4
  * Owns filename identity discovery across prioritized recipe roots
5
5
  */
6
+ import * as CommandTemplates from "./command-templates.ts";
6
7
  import type { RegisteredTool } from "./config.ts";
7
8
  import type { TemplateRecipeConfig } from "./recipes-references.ts";
8
9
  export interface DiscoveredRecipe {
@@ -18,6 +19,7 @@ export interface DiscoveredRecipe {
18
19
  tool: boolean;
19
20
  mutableUsage: boolean;
20
21
  diagnostics: string[];
22
+ riskLabels: CommandTemplates.CommandTemplateRiskLabel[];
21
23
  shadows: string[];
22
24
  }
23
25
  export interface RecipeIntegrityManifestEntry {
@@ -45,15 +45,35 @@ function listRecipeFiles(root) {
45
45
  .localeCompare(b.replace(/\.md$/, ".json")) ||
46
46
  (a.endsWith(".json") ? -1 : 1));
47
47
  }
48
+ function getRecipeCommandTemplateConfig(config) {
49
+ return (typeof config.template === "object" && config.template !== null
50
+ ? config.template
51
+ : config);
52
+ }
48
53
  function getRecipeConfigDiagnostics(file, config) {
49
54
  if (!config) {
50
55
  const reason = RecipesReferences.diagnoseRawRecipeConfigFailure(file);
51
56
  return [`Invalid recipe: ${file}${reason ? `: ${reason}` : ""}`];
52
57
  }
53
- const commandTemplateConfig = typeof config.template === "object" && config.template !== null
54
- ? config.template
55
- : config;
56
- return CommandTemplates.getCommandTemplateWarnings(commandTemplateConfig).map((warning) => `Recipe ${file}: ${warning}`);
58
+ return CommandTemplates.getCommandTemplateWarnings(getRecipeCommandTemplateConfig(config)).map((warning) => `Recipe ${file}: ${warning}`);
59
+ }
60
+ function getRecipeRiskLabels(config) {
61
+ if (!config)
62
+ return [];
63
+ const labels = new Set(CommandTemplates.getCommandTemplateRiskLabels(getRecipeCommandTemplateConfig(config)));
64
+ if (config.async === true)
65
+ labels.add("risk.long_running");
66
+ return [
67
+ "risk.shell",
68
+ "risk.eval",
69
+ "risk.destructive_fs",
70
+ "risk.broad_fs_write",
71
+ "risk.external_side_effect",
72
+ "risk.secret_touching",
73
+ "risk.network",
74
+ "risk.long_running",
75
+ "risk.platform_specific",
76
+ ].filter((label) => labels.has(label));
57
77
  }
58
78
  function readDiscoveredRecipe(root, file, priority, defaultTool = false, mutableUsage = false) {
59
79
  const id = RecipesReferences.getRecipeIdFromPath(file);
@@ -74,6 +94,7 @@ function readDiscoveredRecipe(root, file, priority, defaultTool = false, mutable
74
94
  tool: defaultTool && !disabled && !invalid,
75
95
  mutableUsage,
76
96
  diagnostics: getRecipeConfigDiagnostics(file, config),
97
+ riskLabels: getRecipeRiskLabels(config),
77
98
  shadows: [],
78
99
  };
79
100
  }
@@ -92,6 +113,7 @@ function readDiscoveredRecipe(root, file, priority, defaultTool = false, mutable
92
113
  diagnostics: [
93
114
  `Failed to load recipe ${file}: ${error instanceof Error ? error.message : String(error)}`,
94
115
  ],
116
+ riskLabels: [],
95
117
  shadows: [],
96
118
  };
97
119
  }
@@ -266,7 +288,7 @@ function diagnosticSeverity(message) {
266
288
  if (/invalid|failed to load|not found|cyclic|exceeds|must define|repeat must/i.test(message)) {
267
289
  return "error";
268
290
  }
269
- if (/world-writable|group-writable|invokes bash|eval|destructive|unsafe/i.test(message)) {
291
+ if (/world-writable|group-writable|invokes bash|eval|destructive|broad filesystem|unsafe/i.test(message)) {
270
292
  return "warning";
271
293
  }
272
294
  return "info";
@@ -304,6 +326,7 @@ function diagnosticDetails(result) {
304
326
  seen.add(key);
305
327
  details.push({
306
328
  ...(entry ? { id: entry.id, path: entry.path } : {}),
329
+ ...(entry?.riskLabels.length ? { risk_labels: entry.riskLabels } : {}),
307
330
  action: diagnosticSuggestedAction(message),
308
331
  message,
309
332
  severity: diagnosticSeverity(message),
@@ -357,6 +380,7 @@ function remediationForEntry(entry, activePath) {
357
380
  kind: "risky_shell_boundary",
358
381
  severity: "warning",
359
382
  path: entry.path,
383
+ ...(entry.riskLabels.length ? { risk_labels: entry.riskLabels } : {}),
360
384
  reason: riskyDiagnostics[0],
361
385
  action: "audit trusted command boundary; keep only if the recipe is local and intentional",
362
386
  };
@@ -400,6 +424,25 @@ function discoveryRemediations(result) {
400
424
  String(a.id).localeCompare(String(b.id)) ||
401
425
  String(a.path).localeCompare(String(b.path)));
402
426
  }
427
+ function summarizeRiskLabels(entries) {
428
+ const counts = new Map();
429
+ for (const entry of entries) {
430
+ for (const label of entry.riskLabels) {
431
+ const current = counts.get(label) ?? { count: 0, ids: new Set() };
432
+ current.count += 1;
433
+ current.ids.add(entry.id);
434
+ counts.set(label, current);
435
+ }
436
+ }
437
+ return [...counts.entries()]
438
+ .map(([label, value]) => ({
439
+ label,
440
+ count: value.count,
441
+ recipes: [...value.ids].sort(),
442
+ }))
443
+ .sort((a, b) => Number(b.count) - Number(a.count) ||
444
+ String(a.label).localeCompare(String(b.label)));
445
+ }
403
446
  function recommendationForEntry(entry, activePath) {
404
447
  const recommendation = cleanupRecommendation(entry);
405
448
  if (!recommendation)
@@ -425,14 +468,41 @@ export function getShadowedLaunchDiagnostic(result, id) {
425
468
  reason: active.invalid ? "shadowed_invalid" : "shadowed_disabled",
426
469
  };
427
470
  }
471
+ function templatePreview(value) {
472
+ const rendered = typeof value === "string"
473
+ ? value
474
+ : value === undefined
475
+ ? undefined
476
+ : JSON.stringify(value);
477
+ return rendered && rendered.length > 120
478
+ ? `${rendered.slice(0, 117)}...`
479
+ : rendered;
480
+ }
428
481
  export function listDraftRecipes(root) {
429
482
  return listRecipeFiles(root).map((path) => {
430
483
  const id = RecipesReferences.getRecipeIdFromPath(path);
484
+ const bytes = readFileSync(path);
485
+ const stat = statSync(path);
431
486
  const config = RecipesReferences.readRawRecipeConfig(path);
487
+ const resolved = RecipesReferences.readResolvedRecipeConfig(path);
488
+ const diagnostics = getRecipeConfigDiagnostics(path, resolved);
489
+ const sourceRun = String(config?.description ?? "").match(/spawn run ([^\s]+)/)?.[1];
490
+ const preview = templatePreview(config?.template);
491
+ const riskLabels = getRecipeRiskLabels(resolved);
432
492
  return {
433
493
  id,
434
494
  path,
495
+ sha256: createHash("sha256").update(bytes).digest("hex"),
496
+ size: bytes.byteLength,
497
+ created_at: stat.birthtime.toISOString(),
498
+ modified_at: stat.mtime.toISOString(),
499
+ valid: Boolean(resolved),
500
+ diagnostics,
501
+ ...(riskLabels.length ? { risk_labels: riskLabels } : {}),
435
502
  ...(config?.description ? { description: config.description } : {}),
503
+ ...(sourceRun ? { source_run: sourceRun } : {}),
504
+ ...(config?.async !== undefined ? { async: config.async } : {}),
505
+ ...(preview ? { template_preview: preview } : {}),
436
506
  };
437
507
  });
438
508
  }
@@ -453,6 +523,7 @@ export function summarizeDiscovery(result) {
453
523
  disabled: entry.disabled,
454
524
  invalid: entry.invalid,
455
525
  shadows: entry.shadows,
526
+ ...(entry.riskLabels.length ? { risk_labels: entry.riskLabels } : {}),
456
527
  ...(entry.config?.imports ? { imports: entry.config.imports } : {}),
457
528
  ...(recipeUsage(entry.config)
458
529
  ? { usage: recipeUsage(entry.config) }
@@ -479,6 +550,7 @@ export function summarizeDiscovery(result) {
479
550
  .filter((entry) => entry.disabled)
480
551
  .map((entry) => ({ id: entry.id, path: entry.path }))
481
552
  .sort((a, b) => a.id.localeCompare(b.id)),
553
+ risk_summary: summarizeRiskLabels(result.entries),
482
554
  recommendations,
483
555
  remediations,
484
556
  top_action: remediations[0],
@@ -11,6 +11,7 @@ export interface RegisterToolInput {
11
11
  async?: boolean;
12
12
  state_dir?: string;
13
13
  template?: CommandTemplates.CommandTemplateValue | null;
14
+ draft?: string;
14
15
  args?: string;
15
16
  update?: boolean;
16
17
  values?: Record<string, unknown>;
@@ -20,6 +21,8 @@ export interface RegisterToolResultDetails {
20
21
  async?: boolean;
21
22
  config?: string;
22
23
  defaults?: Record<string, string>;
24
+ draft?: string;
25
+ promoted?: boolean;
23
26
  recipeName?: string;
24
27
  state_dir?: string;
25
28
  template?: CommandTemplates.CommandTemplateValue;
@@ -4,7 +4,7 @@
4
4
  * Owns register/update/delete validation, persistence, runtime side effects, and result payloads
5
5
  */
6
6
  import { existsSync, mkdirSync, unlinkSync } from "node:fs";
7
- import { dirname, join } from "node:path";
7
+ import { dirname, join, relative, resolve } from "node:path";
8
8
  import * as CommandTemplates from "./command-templates.js";
9
9
  import * as ExecutionOutput from "./execution-output.js";
10
10
  import { writeJsonAtomic } from "./file-state.js";
@@ -32,6 +32,62 @@ function getRecipeRoot(deps) {
32
32
  function getToolRecipePath(deps, name) {
33
33
  return join(getRecipeRoot(deps), `${name}.json`);
34
34
  }
35
+ function assertDraftPath(deps, draft) {
36
+ const draftRoot = resolve(getRecipeRoot(deps), "drafts");
37
+ const path = resolve(draft);
38
+ const relation = relative(draftRoot, path);
39
+ if (relation === "" ||
40
+ relation.startsWith("..") ||
41
+ resolve(relation) === relation) {
42
+ throw new Error(ExecutionOutput.formatToolText(`Draft must be under ${draftRoot}. Use inspect target=recipes view=summary to list drafts.`));
43
+ }
44
+ if (!existsSync(path)) {
45
+ throw new Error(ExecutionOutput.formatToolText(`Draft not found: ${path}`));
46
+ }
47
+ return path;
48
+ }
49
+ function promoteDraftRecipe(name, input, ctx, deps) {
50
+ const draftPath = assertDraftPath(deps, String(input.draft ?? ""));
51
+ const targetPath = getToolRecipePath(deps, name);
52
+ const tools = deps.getTools();
53
+ const existing = tools.get(name);
54
+ const conflict = deps.getExternalToolConflict(name);
55
+ if (conflict)
56
+ throw new Error(ExecutionOutput.formatToolText(conflict));
57
+ if ((existing || existsSync(targetPath)) && !input.update) {
58
+ throw new Error(ExecutionOutput.formatToolText(`Tool "${name}" already registered. Use update=true to overwrite.`));
59
+ }
60
+ const config = RecipesReferences.readResolvedRecipeConfig(draftPath);
61
+ if (!config) {
62
+ const reason = RecipesReferences.diagnoseRawRecipeConfigFailure(draftPath);
63
+ throw new Error(ExecutionOutput.formatToolText(`Draft recipe is invalid${reason ? `: ${reason}` : "."}`));
64
+ }
65
+ const promoted = buildConfig(name, {
66
+ ...input,
67
+ description: input.description ?? config.description,
68
+ template: draftPath,
69
+ }, existing);
70
+ const raw = RecipesReferences.readRawRecipeConfig(draftPath);
71
+ mkdirSync(dirname(targetPath), { recursive: true });
72
+ writeJsonAtomic(targetPath, raw);
73
+ promoted.template = targetPath;
74
+ promoted.sourcePath = targetPath;
75
+ tools.set(name, promoted);
76
+ deps.registerRuntimeTool(promoted);
77
+ deps.notify(ctx, `Promoted draft recipe: ${name}`, "info");
78
+ return {
79
+ content: [
80
+ textContent(ExecutionOutput.formatToolText(`${existing ? "Updated" : "Registered"} tool "${name}" from draft recipe.`)),
81
+ ],
82
+ details: {
83
+ args: promoted.args,
84
+ config: targetPath,
85
+ draft: draftPath,
86
+ promoted: true,
87
+ tool: name,
88
+ },
89
+ };
90
+ }
35
91
  function persistToolRecipe(deps, cfg) {
36
92
  const path = getToolRecipePath(deps, cfg.name);
37
93
  mkdirSync(dirname(path), { recursive: true });
@@ -173,6 +229,9 @@ export async function executeRegisterTool(params, ctx, deps) {
173
229
  if (deps.reservedToolNames.has(name)) {
174
230
  throw new Error(ExecutionOutput.formatToolText(`Reserved tool name: ${name}`));
175
231
  }
232
+ if (typeof input.draft === "string" && input.draft.trim()) {
233
+ return promoteDraftRecipe(name, input, ctx, deps);
234
+ }
176
235
  const templateProvided = Object.hasOwn(input, "template");
177
236
  const template = getInputTemplate(input.template);
178
237
  if (templateProvided && (template === null || template === ""))
@@ -74,6 +74,7 @@ export function createFileRuntimeNotifier(stateDir, options = {}) {
74
74
  subscribe: (actor, onWake, subscribeOptions = {}) => {
75
75
  mkdirSync(dirname(file), { recursive: true });
76
76
  let position = options.replay || !existsSync(file) ? 0 : statSync(file).size;
77
+ let pending = "";
77
78
  let closed = false;
78
79
  const reconcile = (reason) => {
79
80
  if (closed)
@@ -89,11 +90,15 @@ export function createFileRuntimeNotifier(stateDir, options = {}) {
89
90
  if (closed || !existsSync(file))
90
91
  return;
91
92
  const buffer = readFileSync(file);
92
- if (position > buffer.length)
93
+ if (position > buffer.length) {
93
94
  position = 0;
94
- const chunk = buffer.subarray(position).toString("utf8");
95
+ pending = "";
96
+ }
97
+ const chunk = pending + buffer.subarray(position).toString("utf8");
95
98
  position = buffer.length;
96
- for (const line of chunk.split("\n")) {
99
+ const lines = chunk.split("\n");
100
+ pending = chunk.endsWith("\n") ? "" : (lines.pop() ?? "");
101
+ for (const line of lines) {
97
102
  if (!line.trim())
98
103
  continue;
99
104
  const event = parseRuntimeWakeEventLine(line);