@llblab/pi-actors 0.24.8 → 0.25.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/BACKLOG.md CHANGED
@@ -165,7 +165,7 @@ The backlog is intentionally pruned to the 20% of work most likely to deliver 80
165
165
  ### M-08 Recipe Doctor Remediation UX
166
166
 
167
167
  - Priority: High.
168
- - Status: Open.
168
+ - Status: Done.
169
169
  - Goal: Turn recipe doctor output into an operator action surface, not just a diagnostic listing.
170
170
  - Why now: Recipe registry warnings are intentionally actionable; the next value is helping operators decide whether to fix, disable, delete, or inspect a recipe without hiding the warning.
171
171
  - Direction:
@@ -240,8 +240,8 @@ These are valid ideas but not current focus. Reintroduce only with concrete evid
240
240
  ## Suggested Milestone Order
241
241
 
242
242
  ```text
243
- 0.25 — Operator remediation and worker maturity:
244
- M-08, M-09
243
+ 0.25 — Worker maturity:
244
+ M-09
245
245
 
246
246
  0.26 — Package contract hardening and lifecycle semantics:
247
247
  M-10, M-11
package/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.25.0: Recipe Doctor Remediation UX
6
+
7
+ - `[Recipe Doctor]` Added prioritized advisory remediations to recipe doctor output, including a compact top action, structured verbose remediation entries, blocked lower-priority candidates for invalid/disabled overrides, and ordered coverage for invalid, disabled, shadowed, and risky shell-boundary recipes.
8
+ - `[Docs]` Documented the recipe doctor remediation surface in the tool registry guide.
9
+
5
10
  ## 0.24.8: Mailbox Loop Kill Contract Hotfix
6
11
 
7
12
  - `[Mailbox Loop]` Aligned generic mailbox-loop termination with the actor-message kill contract: only `control.kill` stops generic loop drains; `control.stop` and `control.cancel` remain actor-domain messages unless a recipe handles them explicitly.
@@ -273,6 +273,10 @@ function diagnosticSeverity(message) {
273
273
  return "info";
274
274
  }
275
275
  function diagnosticSuggestedAction(message) {
276
+ if (/world-writable|group-writable/i.test(message))
277
+ return "tighten recipe root permissions";
278
+ if (/invokes bash/i.test(message))
279
+ return "audit the trusted shell boundary and keep only if intentional";
276
280
  if (/must define template/i.test(message))
277
281
  return "add a template field or remove the recipe";
278
282
  if (/JSON|Expected|parse/i.test(message))
@@ -285,10 +289,6 @@ function diagnosticSuggestedAction(message) {
285
289
  return "split large prompt or data into separate files";
286
290
  if (/repeat must/i.test(message))
287
291
  return "use a positive repeat count or an array-typed repeat source";
288
- if (/world-writable|group-writable/i.test(message))
289
- return "tighten recipe root permissions";
290
- if (/invokes bash/i.test(message))
291
- return "audit the trusted shell boundary or move details to recipe doctor";
292
292
  if (/shadows/i.test(message))
293
293
  return "confirm the active override or rename one recipe";
294
294
  if (/disabled/i.test(message))
@@ -323,6 +323,84 @@ function diagnosticDetails(result) {
323
323
  String(a.message).localeCompare(String(b.message)));
324
324
  });
325
325
  }
326
+ function remediationForEntry(entry, activePath) {
327
+ const riskyDiagnostics = entry.diagnostics.filter((message) => diagnosticSeverity(message) === "warning");
328
+ const blockedCandidate = entry.shadows[0];
329
+ if (entry.invalid) {
330
+ return {
331
+ id: entry.id,
332
+ kind: blockedCandidate ? "blocking_invalid" : "invalid",
333
+ severity: "error",
334
+ path: entry.path,
335
+ ...(blockedCandidate ? { blocked_candidate: blockedCandidate } : {}),
336
+ reason: blockedCandidate
337
+ ? "invalid higher-priority recipe blocks a lower-priority candidate"
338
+ : "recipe is invalid and cannot be exposed as a tool",
339
+ action: "fix recipe syntax/config, or disable/delete/archive it to restore fallback",
340
+ };
341
+ }
342
+ if (entry.disabled && entry.active) {
343
+ return {
344
+ id: entry.id,
345
+ kind: blockedCandidate ? "blocking_disabled" : "disabled",
346
+ severity: "warning",
347
+ path: entry.path,
348
+ ...(blockedCandidate ? { blocked_candidate: blockedCandidate } : {}),
349
+ reason: blockedCandidate
350
+ ? "disabled higher-priority recipe intentionally blocks a lower-priority candidate"
351
+ : "recipe is disabled and not exposed as a tool",
352
+ action: "keep disabled intentionally, re-enable, or delete/archive the file",
353
+ };
354
+ }
355
+ if (riskyDiagnostics.length > 0) {
356
+ return {
357
+ id: entry.id,
358
+ kind: "risky_shell_boundary",
359
+ severity: "warning",
360
+ path: entry.path,
361
+ reason: riskyDiagnostics[0],
362
+ action: "audit trusted command boundary; keep only if the recipe is local and intentional",
363
+ };
364
+ }
365
+ if (entry.shadowed) {
366
+ return {
367
+ id: entry.id,
368
+ kind: "shadowed",
369
+ severity: "info",
370
+ path: entry.path,
371
+ ...(activePath ? { active_path: activePath } : {}),
372
+ reason: activePath
373
+ ? `shadowed by ${activePath}`
374
+ : "shadowed by a higher-priority recipe",
375
+ action: "keep as fallback/component, merge, rename, delete, or archive",
376
+ };
377
+ }
378
+ return undefined;
379
+ }
380
+ function remediationRank(item) {
381
+ const kind = String(item.kind ?? "");
382
+ if (kind === "blocking_invalid")
383
+ return 0;
384
+ if (kind === "invalid")
385
+ return 1;
386
+ if (kind === "blocking_disabled")
387
+ return 2;
388
+ if (kind === "risky_shell_boundary")
389
+ return 3;
390
+ if (kind === "disabled")
391
+ return 4;
392
+ if (kind === "shadowed")
393
+ return 5;
394
+ return 6;
395
+ }
396
+ function discoveryRemediations(result) {
397
+ return result.entries
398
+ .map((entry) => remediationForEntry(entry, result.active.get(entry.id)?.path))
399
+ .filter((entry) => Boolean(entry))
400
+ .sort((a, b) => remediationRank(a) - remediationRank(b) ||
401
+ String(a.id).localeCompare(String(b.id)) ||
402
+ String(a.path).localeCompare(String(b.path)));
403
+ }
326
404
  function recommendationForEntry(entry, activePath) {
327
405
  const recommendation = cleanupRecommendation(entry);
328
406
  if (!recommendation)
@@ -341,6 +419,7 @@ export function summarizeDiscovery(result) {
341
419
  .filter((entry) => Boolean(entry))
342
420
  .sort((a, b) => String(a.id).localeCompare(String(b.id)) ||
343
421
  String(a.path).localeCompare(String(b.path)));
422
+ const remediations = discoveryRemediations(result);
344
423
  return {
345
424
  active: [...result.active.values()]
346
425
  .map((entry) => ({
@@ -378,6 +457,8 @@ export function summarizeDiscovery(result) {
378
457
  .map((entry) => ({ id: entry.id, path: entry.path }))
379
458
  .sort((a, b) => a.id.localeCompare(b.id)),
380
459
  recommendations,
460
+ remediations,
461
+ top_action: remediations[0],
381
462
  diagnostics: result.diagnostics,
382
463
  diagnostic_details: diagnosticDetails(result),
383
464
  integrity_manifest: createRecipeIntegrityManifest(result),
package/dist/lib/tools.js CHANGED
@@ -387,6 +387,9 @@ function compactRecipeDoctor(summary) {
387
387
  const details = Array.isArray(summary.diagnostic_details)
388
388
  ? summary.diagnostic_details
389
389
  : [];
390
+ const remediations = Array.isArray(summary.remediations)
391
+ ? summary.remediations
392
+ : [];
390
393
  const recommendations = Array.isArray(summary.recommendations)
391
394
  ? summary.recommendations
392
395
  : [];
@@ -396,11 +399,20 @@ function compactRecipeDoctor(summary) {
396
399
  if (severity === "error" || severity === "warning" || severity === "info")
397
400
  counts[severity] += 1;
398
401
  }
402
+ const topAction = asRecord(summary.top_action);
399
403
  const lines = [
400
- `recipes doctor errors=${counts.error} warnings=${counts.warning} info=${counts.info} recommendations=${recommendations.length}`,
404
+ `recipes doctor errors=${counts.error} warnings=${counts.warning} info=${counts.info} actions=${remediations.length} recommendations=${recommendations.length}`,
401
405
  ];
402
- for (const detail of details.slice(0, 8)) {
403
- lines.push(`${String(detail.severity ?? "info")} id=${String(detail.id ?? "root")} action=${compactPreview(detail.action, Limits.DOCTOR_ACTION_PREVIEW_CHARS) ?? "inspect"}`);
406
+ if (Object.keys(topAction).length > 0) {
407
+ const action = compactPreview(topAction.action, Limits.DOCTOR_ACTION_PREVIEW_CHARS);
408
+ lines.push(`top severity=${String(topAction.severity ?? "info")} kind=${String(topAction.kind ?? "inspect")} id=${String(topAction.id ?? "root")} action=${action ?? "inspect"}`);
409
+ }
410
+ for (const item of remediations.slice(0, 8)) {
411
+ const action = compactPreview(item.action, Limits.DOCTOR_ACTION_PREVIEW_CHARS);
412
+ const blocked = item.blocked_candidate
413
+ ? ` blocked=${compactPreview(item.blocked_candidate, Limits.DOCTOR_ACTION_PREVIEW_CHARS)}`
414
+ : "";
415
+ lines.push(`${String(item.severity ?? "info")} kind=${String(item.kind ?? "inspect")} id=${String(item.id ?? "root")}${blocked} action=${action ?? "inspect"}`);
404
416
  }
405
417
  return `\n${lines.join("\n")}`;
406
418
  }
@@ -2,7 +2,7 @@
2
2
  name: actors
3
3
  description: Highest-density practical guide for pi-actors. Read this skill whenever prompt and tools are not enough for spawn, message, inspect, actor runs, tools, recipes, command templates, async lifecycle, mailboxes, artifacts, and local orchestration mechanics.
4
4
  metadata:
5
- version: 0.24.8
5
+ version: 0.25.0
6
6
  ---
7
7
 
8
8
  # Actors (pi-actors)
@@ -2,7 +2,7 @@
2
2
  name: swarm
3
3
  description: Subagent orchestration with scoped locks and quorum consensus. Use for multi-model review, parallel scoped work, delegated audit, and coordinated subagent execution.
4
4
  metadata:
5
- version: 0.24.8
5
+ version: 0.25.0
6
6
  ---
7
7
 
8
8
  # Swarm
@@ -23,10 +23,11 @@ Inspect the discovered registry with:
23
23
 
24
24
  ```text
25
25
  inspect target=recipes view=status
26
+ inspect target=recipes view=doctor
26
27
  inspect target=recipes view=summary verbose=true
27
28
  ```
28
29
 
29
- The summary reports active, shadowed, invalid, disabled, and diagnostic entries so operators can answer why a tool is present, hidden, broken, or disabled.
30
+ The summary reports active, shadowed, invalid, disabled, and diagnostic entries so operators can answer why a tool is present, hidden, broken, or disabled. The doctor view keeps the same registry evidence but promotes an advisory action surface: compact output includes the highest-priority `top` remediation plus ordered actions for invalid/blocking, disabled, risky shell-boundary, and shadowed recipes. Verbose inspection keeps the structured `remediations`, `top_action`, diagnostic details, and blocked lower-priority candidate paths when a broken or disabled higher-priority recipe masks a fallback.
30
31
 
31
32
  ## Registering Tools
32
33
 
@@ -403,6 +403,10 @@ function diagnosticSeverity(message: string): "info" | "warning" | "error" {
403
403
  }
404
404
 
405
405
  function diagnosticSuggestedAction(message: string): string {
406
+ if (/world-writable|group-writable/i.test(message))
407
+ return "tighten recipe root permissions";
408
+ if (/invokes bash/i.test(message))
409
+ return "audit the trusted shell boundary and keep only if intentional";
406
410
  if (/must define template/i.test(message))
407
411
  return "add a template field or remove the recipe";
408
412
  if (/JSON|Expected|parse/i.test(message))
@@ -414,10 +418,6 @@ function diagnosticSuggestedAction(message: string): string {
414
418
  return "split large prompt or data into separate files";
415
419
  if (/repeat must/i.test(message))
416
420
  return "use a positive repeat count or an array-typed repeat source";
417
- if (/world-writable|group-writable/i.test(message))
418
- return "tighten recipe root permissions";
419
- if (/invokes bash/i.test(message))
420
- return "audit the trusted shell boundary or move details to recipe doctor";
421
421
  if (/shadows/i.test(message))
422
422
  return "confirm the active override or rename one recipe";
423
423
  if (/disabled/i.test(message))
@@ -455,6 +455,93 @@ function diagnosticDetails(
455
455
  });
456
456
  }
457
457
 
458
+ function remediationForEntry(
459
+ entry: DiscoveredRecipe,
460
+ activePath: string | undefined,
461
+ ): Record<string, unknown> | undefined {
462
+ const riskyDiagnostics = entry.diagnostics.filter(
463
+ (message) => diagnosticSeverity(message) === "warning",
464
+ );
465
+ const blockedCandidate = entry.shadows[0];
466
+ if (entry.invalid) {
467
+ return {
468
+ id: entry.id,
469
+ kind: blockedCandidate ? "blocking_invalid" : "invalid",
470
+ severity: "error",
471
+ path: entry.path,
472
+ ...(blockedCandidate ? { blocked_candidate: blockedCandidate } : {}),
473
+ reason: blockedCandidate
474
+ ? "invalid higher-priority recipe blocks a lower-priority candidate"
475
+ : "recipe is invalid and cannot be exposed as a tool",
476
+ action: "fix recipe syntax/config, or disable/delete/archive it to restore fallback",
477
+ };
478
+ }
479
+ if (entry.disabled && entry.active) {
480
+ return {
481
+ id: entry.id,
482
+ kind: blockedCandidate ? "blocking_disabled" : "disabled",
483
+ severity: "warning",
484
+ path: entry.path,
485
+ ...(blockedCandidate ? { blocked_candidate: blockedCandidate } : {}),
486
+ reason: blockedCandidate
487
+ ? "disabled higher-priority recipe intentionally blocks a lower-priority candidate"
488
+ : "recipe is disabled and not exposed as a tool",
489
+ action: "keep disabled intentionally, re-enable, or delete/archive the file",
490
+ };
491
+ }
492
+ if (riskyDiagnostics.length > 0) {
493
+ return {
494
+ id: entry.id,
495
+ kind: "risky_shell_boundary",
496
+ severity: "warning",
497
+ path: entry.path,
498
+ reason: riskyDiagnostics[0],
499
+ action: "audit trusted command boundary; keep only if the recipe is local and intentional",
500
+ };
501
+ }
502
+ if (entry.shadowed) {
503
+ return {
504
+ id: entry.id,
505
+ kind: "shadowed",
506
+ severity: "info",
507
+ path: entry.path,
508
+ ...(activePath ? { active_path: activePath } : {}),
509
+ reason: activePath
510
+ ? `shadowed by ${activePath}`
511
+ : "shadowed by a higher-priority recipe",
512
+ action: "keep as fallback/component, merge, rename, delete, or archive",
513
+ };
514
+ }
515
+ return undefined;
516
+ }
517
+
518
+ function remediationRank(item: Record<string, unknown>): number {
519
+ const kind = String(item.kind ?? "");
520
+ if (kind === "blocking_invalid") return 0;
521
+ if (kind === "invalid") return 1;
522
+ if (kind === "blocking_disabled") return 2;
523
+ if (kind === "risky_shell_boundary") return 3;
524
+ if (kind === "disabled") return 4;
525
+ if (kind === "shadowed") return 5;
526
+ return 6;
527
+ }
528
+
529
+ function discoveryRemediations(
530
+ result: RecipeDiscoveryResult,
531
+ ): Array<Record<string, unknown>> {
532
+ return result.entries
533
+ .map((entry) =>
534
+ remediationForEntry(entry, result.active.get(entry.id)?.path),
535
+ )
536
+ .filter((entry): entry is Record<string, unknown> => Boolean(entry))
537
+ .sort(
538
+ (a, b) =>
539
+ remediationRank(a) - remediationRank(b) ||
540
+ String(a.id).localeCompare(String(b.id)) ||
541
+ String(a.path).localeCompare(String(b.path)),
542
+ );
543
+ }
544
+
458
545
  function recommendationForEntry(
459
546
  entry: DiscoveredRecipe,
460
547
  activePath: string | undefined,
@@ -483,6 +570,7 @@ export function summarizeDiscovery(
483
570
  String(a.id).localeCompare(String(b.id)) ||
484
571
  String(a.path).localeCompare(String(b.path)),
485
572
  );
573
+ const remediations = discoveryRemediations(result);
486
574
  return {
487
575
  active: [...result.active.values()]
488
576
  .map((entry) => ({
@@ -520,6 +608,8 @@ export function summarizeDiscovery(
520
608
  .map((entry) => ({ id: entry.id, path: entry.path }))
521
609
  .sort((a, b) => a.id.localeCompare(b.id)),
522
610
  recommendations,
611
+ remediations,
612
+ top_action: remediations[0],
523
613
  diagnostics: result.diagnostics,
524
614
  diagnostic_details: diagnosticDetails(result),
525
615
  integrity_manifest: createRecipeIntegrityManifest(result),
package/lib/tools.ts CHANGED
@@ -470,6 +470,9 @@ function compactRecipeDoctor(summary: Record<string, unknown>): string {
470
470
  const details = Array.isArray(summary.diagnostic_details)
471
471
  ? (summary.diagnostic_details as Array<Record<string, unknown>>)
472
472
  : [];
473
+ const remediations = Array.isArray(summary.remediations)
474
+ ? (summary.remediations as Array<Record<string, unknown>>)
475
+ : [];
473
476
  const recommendations = Array.isArray(summary.recommendations)
474
477
  ? (summary.recommendations as Array<Record<string, unknown>>)
475
478
  : [];
@@ -479,12 +482,26 @@ function compactRecipeDoctor(summary: Record<string, unknown>): string {
479
482
  if (severity === "error" || severity === "warning" || severity === "info")
480
483
  counts[severity] += 1;
481
484
  }
485
+ const topAction = asRecord(summary.top_action);
482
486
  const lines = [
483
- `recipes doctor errors=${counts.error} warnings=${counts.warning} info=${counts.info} recommendations=${recommendations.length}`,
487
+ `recipes doctor errors=${counts.error} warnings=${counts.warning} info=${counts.info} actions=${remediations.length} recommendations=${recommendations.length}`,
484
488
  ];
485
- for (const detail of details.slice(0, 8)) {
489
+ if (Object.keys(topAction).length > 0) {
490
+ const action = compactPreview(
491
+ topAction.action,
492
+ Limits.DOCTOR_ACTION_PREVIEW_CHARS,
493
+ );
494
+ lines.push(
495
+ `top severity=${String(topAction.severity ?? "info")} kind=${String(topAction.kind ?? "inspect")} id=${String(topAction.id ?? "root")} action=${action ?? "inspect"}`,
496
+ );
497
+ }
498
+ for (const item of remediations.slice(0, 8)) {
499
+ const action = compactPreview(item.action, Limits.DOCTOR_ACTION_PREVIEW_CHARS);
500
+ const blocked = item.blocked_candidate
501
+ ? ` blocked=${compactPreview(item.blocked_candidate, Limits.DOCTOR_ACTION_PREVIEW_CHARS)}`
502
+ : "";
486
503
  lines.push(
487
- `${String(detail.severity ?? "info")} id=${String(detail.id ?? "root")} action=${compactPreview(detail.action, Limits.DOCTOR_ACTION_PREVIEW_CHARS) ?? "inspect"}`,
504
+ `${String(item.severity ?? "info")} kind=${String(item.kind ?? "inspect")} id=${String(item.id ?? "root")}${blocked} action=${action ?? "inspect"}`,
488
505
  );
489
506
  }
490
507
  return `\n${lines.join("\n")}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-actors",
3
- "version": "0.24.8",
3
+ "version": "0.25.0",
4
4
  "private": false,
5
5
  "description": "Local Actor Kernel for Pi",
6
6
  "keywords": [
@@ -2,7 +2,7 @@
2
2
  name: actors
3
3
  description: Highest-density practical guide for pi-actors. Read this skill whenever prompt and tools are not enough for spawn, message, inspect, actor runs, tools, recipes, command templates, async lifecycle, mailboxes, artifacts, and local orchestration mechanics.
4
4
  metadata:
5
- version: 0.24.8
5
+ version: 0.25.0
6
6
  ---
7
7
 
8
8
  # Actors (pi-actors)
@@ -2,7 +2,7 @@
2
2
  name: swarm
3
3
  description: Subagent orchestration with scoped locks and quorum consensus. Use for multi-model review, parallel scoped work, delegated audit, and coordinated subagent execution.
4
4
  metadata:
5
- version: 0.24.8
5
+ version: 0.25.0
6
6
  ---
7
7
 
8
8
  # Swarm