@llblab/pi-actors 0.35.0 → 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 +3 -1
- package/BACKLOG.md +4 -72
- package/CHANGELOG.md +11 -3
- package/README.md +2 -1
- package/dist/lib/command-templates.d.ts +2 -0
- package/dist/lib/command-templates.js +76 -0
- package/dist/lib/recipes-discovery.d.ts +2 -0
- package/dist/lib/recipes-discovery.js +52 -5
- package/dist/lib/runtime-notifier.js +8 -3
- package/dist/lib/tools-inspect.js +172 -4
- package/dist/lib/tools-response.js +13 -2
- package/dist/scripts/validate-recipe.mjs +164 -7
- package/dist/skills/actors/SKILL.md +28 -11
- package/dist/skills/swarm/SKILL.md +1 -1
- package/docs/recipe-library.md +2 -0
- package/docs/template-recipes.md +2 -0
- package/docs/tool-registry.md +15 -10
- package/lib/command-templates.ts +124 -0
- package/lib/recipes-discovery.ts +69 -6
- package/lib/runtime-notifier.ts +9 -3
- package/lib/tools-inspect.ts +186 -4
- package/lib/tools-response.ts +16 -2
- package/package.json +3 -2
- package/scripts/validate-recipe.mjs +164 -7
- package/skills/actors/SKILL.md +28 -11
- package/skills/swarm/SKILL.md +1 -1
package/lib/recipes-discovery.ts
CHANGED
|
@@ -27,6 +27,7 @@ export interface DiscoveredRecipe {
|
|
|
27
27
|
tool: boolean;
|
|
28
28
|
mutableUsage: boolean;
|
|
29
29
|
diagnostics: string[];
|
|
30
|
+
riskLabels: CommandTemplates.CommandTemplateRiskLabel[];
|
|
30
31
|
shadows: string[];
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -110,6 +111,14 @@ function listRecipeFiles(root: string): string[] {
|
|
|
110
111
|
);
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
function getRecipeCommandTemplateConfig(
|
|
115
|
+
config: TemplateRecipeConfig,
|
|
116
|
+
): CommandTemplates.CommandTemplateConfig {
|
|
117
|
+
return (typeof config.template === "object" && config.template !== null
|
|
118
|
+
? config.template
|
|
119
|
+
: config) as CommandTemplates.CommandTemplateConfig;
|
|
120
|
+
}
|
|
121
|
+
|
|
113
122
|
function getRecipeConfigDiagnostics(
|
|
114
123
|
file: string,
|
|
115
124
|
config: TemplateRecipeConfig | undefined,
|
|
@@ -118,15 +127,36 @@ function getRecipeConfigDiagnostics(
|
|
|
118
127
|
const reason = RecipesReferences.diagnoseRawRecipeConfigFailure(file);
|
|
119
128
|
return [`Invalid recipe: ${file}${reason ? `: ${reason}` : ""}`];
|
|
120
129
|
}
|
|
121
|
-
const commandTemplateConfig =
|
|
122
|
-
typeof config.template === "object" && config.template !== null
|
|
123
|
-
? config.template
|
|
124
|
-
: config;
|
|
125
130
|
return CommandTemplates.getCommandTemplateWarnings(
|
|
126
|
-
|
|
131
|
+
getRecipeCommandTemplateConfig(config),
|
|
127
132
|
).map((warning) => `Recipe ${file}: ${warning}`);
|
|
128
133
|
}
|
|
129
134
|
|
|
135
|
+
function getRecipeRiskLabels(
|
|
136
|
+
config: TemplateRecipeConfig | undefined,
|
|
137
|
+
): CommandTemplates.CommandTemplateRiskLabel[] {
|
|
138
|
+
if (!config) return [];
|
|
139
|
+
const labels = new Set(
|
|
140
|
+
CommandTemplates.getCommandTemplateRiskLabels(
|
|
141
|
+
getRecipeCommandTemplateConfig(config),
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
if (config.async === true) labels.add("risk.long_running");
|
|
145
|
+
return [
|
|
146
|
+
"risk.shell",
|
|
147
|
+
"risk.eval",
|
|
148
|
+
"risk.destructive_fs",
|
|
149
|
+
"risk.broad_fs_write",
|
|
150
|
+
"risk.external_side_effect",
|
|
151
|
+
"risk.secret_touching",
|
|
152
|
+
"risk.network",
|
|
153
|
+
"risk.long_running",
|
|
154
|
+
"risk.platform_specific",
|
|
155
|
+
].filter((label): label is CommandTemplates.CommandTemplateRiskLabel =>
|
|
156
|
+
labels.has(label as CommandTemplates.CommandTemplateRiskLabel),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
130
160
|
function readDiscoveredRecipe(
|
|
131
161
|
root: string,
|
|
132
162
|
file: string,
|
|
@@ -152,6 +182,7 @@ function readDiscoveredRecipe(
|
|
|
152
182
|
tool: defaultTool && !disabled && !invalid,
|
|
153
183
|
mutableUsage,
|
|
154
184
|
diagnostics: getRecipeConfigDiagnostics(file, config),
|
|
185
|
+
riskLabels: getRecipeRiskLabels(config),
|
|
155
186
|
shadows: [],
|
|
156
187
|
};
|
|
157
188
|
} catch (error) {
|
|
@@ -169,6 +200,7 @@ function readDiscoveredRecipe(
|
|
|
169
200
|
diagnostics: [
|
|
170
201
|
`Failed to load recipe ${file}: ${error instanceof Error ? error.message : String(error)}`,
|
|
171
202
|
],
|
|
203
|
+
riskLabels: [],
|
|
172
204
|
shadows: [],
|
|
173
205
|
};
|
|
174
206
|
}
|
|
@@ -392,7 +424,7 @@ function diagnosticSeverity(message: string): "info" | "warning" | "error" {
|
|
|
392
424
|
return "error";
|
|
393
425
|
}
|
|
394
426
|
if (
|
|
395
|
-
/world-writable|group-writable|invokes bash|eval|destructive|unsafe/i.test(
|
|
427
|
+
/world-writable|group-writable|invokes bash|eval|destructive|broad filesystem|unsafe/i.test(
|
|
396
428
|
message,
|
|
397
429
|
)
|
|
398
430
|
) {
|
|
@@ -435,6 +467,7 @@ function diagnosticDetails(
|
|
|
435
467
|
seen.add(key);
|
|
436
468
|
details.push({
|
|
437
469
|
...(entry ? { id: entry.id, path: entry.path } : {}),
|
|
470
|
+
...(entry?.riskLabels.length ? { risk_labels: entry.riskLabels } : {}),
|
|
438
471
|
action: diagnosticSuggestedAction(message),
|
|
439
472
|
message,
|
|
440
473
|
severity: diagnosticSeverity(message),
|
|
@@ -496,6 +529,7 @@ function remediationForEntry(
|
|
|
496
529
|
kind: "risky_shell_boundary",
|
|
497
530
|
severity: "warning",
|
|
498
531
|
path: entry.path,
|
|
532
|
+
...(entry.riskLabels.length ? { risk_labels: entry.riskLabels } : {}),
|
|
499
533
|
reason: riskyDiagnostics[0],
|
|
500
534
|
action:
|
|
501
535
|
"audit trusted command boundary; keep only if the recipe is local and intentional",
|
|
@@ -544,6 +578,31 @@ function discoveryRemediations(
|
|
|
544
578
|
);
|
|
545
579
|
}
|
|
546
580
|
|
|
581
|
+
function summarizeRiskLabels(
|
|
582
|
+
entries: DiscoveredRecipe[],
|
|
583
|
+
): Array<Record<string, unknown>> {
|
|
584
|
+
const counts = new Map<string, { count: number; ids: Set<string> }>();
|
|
585
|
+
for (const entry of entries) {
|
|
586
|
+
for (const label of entry.riskLabels) {
|
|
587
|
+
const current = counts.get(label) ?? { count: 0, ids: new Set<string>() };
|
|
588
|
+
current.count += 1;
|
|
589
|
+
current.ids.add(entry.id);
|
|
590
|
+
counts.set(label, current);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return [...counts.entries()]
|
|
594
|
+
.map(([label, value]) => ({
|
|
595
|
+
label,
|
|
596
|
+
count: value.count,
|
|
597
|
+
recipes: [...value.ids].sort(),
|
|
598
|
+
}))
|
|
599
|
+
.sort(
|
|
600
|
+
(a, b) =>
|
|
601
|
+
Number(b.count) - Number(a.count) ||
|
|
602
|
+
String(a.label).localeCompare(String(b.label)),
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
547
606
|
function recommendationForEntry(
|
|
548
607
|
entry: DiscoveredRecipe,
|
|
549
608
|
activePath: string | undefined,
|
|
@@ -598,6 +657,7 @@ export function listDraftRecipes(root: string): Array<Record<string, unknown>> {
|
|
|
598
657
|
/spawn run ([^\s]+)/,
|
|
599
658
|
)?.[1];
|
|
600
659
|
const preview = templatePreview(config?.template);
|
|
660
|
+
const riskLabels = getRecipeRiskLabels(resolved);
|
|
601
661
|
return {
|
|
602
662
|
id,
|
|
603
663
|
path,
|
|
@@ -607,6 +667,7 @@ export function listDraftRecipes(root: string): Array<Record<string, unknown>> {
|
|
|
607
667
|
modified_at: stat.mtime.toISOString(),
|
|
608
668
|
valid: Boolean(resolved),
|
|
609
669
|
diagnostics,
|
|
670
|
+
...(riskLabels.length ? { risk_labels: riskLabels } : {}),
|
|
610
671
|
...(config?.description ? { description: config.description } : {}),
|
|
611
672
|
...(sourceRun ? { source_run: sourceRun } : {}),
|
|
612
673
|
...(config?.async !== undefined ? { async: config.async } : {}),
|
|
@@ -639,6 +700,7 @@ export function summarizeDiscovery(
|
|
|
639
700
|
disabled: entry.disabled,
|
|
640
701
|
invalid: entry.invalid,
|
|
641
702
|
shadows: entry.shadows,
|
|
703
|
+
...(entry.riskLabels.length ? { risk_labels: entry.riskLabels } : {}),
|
|
642
704
|
...(entry.config?.imports ? { imports: entry.config.imports } : {}),
|
|
643
705
|
...(recipeUsage(entry.config)
|
|
644
706
|
? { usage: recipeUsage(entry.config) }
|
|
@@ -665,6 +727,7 @@ export function summarizeDiscovery(
|
|
|
665
727
|
.filter((entry) => entry.disabled)
|
|
666
728
|
.map((entry) => ({ id: entry.id, path: entry.path }))
|
|
667
729
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
730
|
+
risk_summary: summarizeRiskLabels(result.entries),
|
|
668
731
|
recommendations,
|
|
669
732
|
remediations,
|
|
670
733
|
top_action: remediations[0],
|
package/lib/runtime-notifier.ts
CHANGED
|
@@ -147,6 +147,7 @@ export function createFileRuntimeNotifier(
|
|
|
147
147
|
mkdirSync(dirname(file), { recursive: true });
|
|
148
148
|
let position =
|
|
149
149
|
options.replay || !existsSync(file) ? 0 : statSync(file).size;
|
|
150
|
+
let pending = "";
|
|
150
151
|
let closed = false;
|
|
151
152
|
const reconcile = (reason: RuntimeReconcileReason): void => {
|
|
152
153
|
if (closed) return;
|
|
@@ -160,10 +161,15 @@ export function createFileRuntimeNotifier(
|
|
|
160
161
|
const drain = (): void => {
|
|
161
162
|
if (closed || !existsSync(file)) return;
|
|
162
163
|
const buffer = readFileSync(file);
|
|
163
|
-
if (position > buffer.length)
|
|
164
|
-
|
|
164
|
+
if (position > buffer.length) {
|
|
165
|
+
position = 0;
|
|
166
|
+
pending = "";
|
|
167
|
+
}
|
|
168
|
+
const chunk = pending + buffer.subarray(position).toString("utf8");
|
|
165
169
|
position = buffer.length;
|
|
166
|
-
|
|
170
|
+
const lines = chunk.split("\n");
|
|
171
|
+
pending = chunk.endsWith("\n") ? "" : (lines.pop() ?? "");
|
|
172
|
+
for (const line of lines) {
|
|
167
173
|
if (!line.trim()) continue;
|
|
168
174
|
const event = parseRuntimeWakeEventLine(line);
|
|
169
175
|
if (event && event.actor === actor) {
|
package/lib/tools-inspect.ts
CHANGED
|
@@ -248,6 +248,181 @@ function compactPiActorsRuntimeStatus(status: Record<string, unknown>): string {
|
|
|
248
248
|
return `\npi-actors version=${String(status.version)} mode=${String(status.mode)} path=${String(status.package_root)} entrypoint=${String(status.entrypoint)}${status.git_commit ? ` git=${String(status.git_commit)}` : ""}`;
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
function isStaleClaim(message: AsyncRuns.RunInboxMessage, now: number): boolean {
|
|
252
|
+
if (message.status !== "claimed") return false;
|
|
253
|
+
const claimedAt = Date.parse(String(message.claimed_at ?? ""));
|
|
254
|
+
return Number.isFinite(claimedAt) && now - claimedAt > 5 * 60 * 1000;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function getRunTriageSignals(
|
|
258
|
+
runs: Array<Record<string, unknown>>,
|
|
259
|
+
): Record<string, unknown> {
|
|
260
|
+
const now = Date.now();
|
|
261
|
+
const staleClaims: Array<Record<string, unknown>> = [];
|
|
262
|
+
const attentionMessages: Array<Record<string, unknown>> = [];
|
|
263
|
+
for (const run of runs) {
|
|
264
|
+
const stateDir = String(run.state_dir ?? "");
|
|
265
|
+
const runId = String(run.run ?? "");
|
|
266
|
+
if (!stateDir) continue;
|
|
267
|
+
try {
|
|
268
|
+
for (const message of AsyncRuns.readRunInboxMessages(stateDir, 200)) {
|
|
269
|
+
if (!isStaleClaim(message, now)) continue;
|
|
270
|
+
staleClaims.push({
|
|
271
|
+
run: runId,
|
|
272
|
+
id: message.id,
|
|
273
|
+
claimed_at: message.claimed_at,
|
|
274
|
+
claimed_by: message.claimed_by,
|
|
275
|
+
type: message.type,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
} catch {}
|
|
279
|
+
try {
|
|
280
|
+
for (const event of AsyncRuns.readRunEvents(stateDir, 80)) {
|
|
281
|
+
if (event.metadata?.requires_response !== true) continue;
|
|
282
|
+
attentionMessages.push({
|
|
283
|
+
run: runId,
|
|
284
|
+
id: event.id,
|
|
285
|
+
summary: event.summary,
|
|
286
|
+
type: event.type ?? event.event,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
} catch {}
|
|
290
|
+
}
|
|
291
|
+
return { attention_messages: attentionMessages, stale_claims: staleClaims };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function isTriageHighRiskRecipe(recipe: Record<string, unknown>): boolean {
|
|
295
|
+
if (recipe.tool !== true) return false;
|
|
296
|
+
const labels = Array.isArray(recipe.risk_labels)
|
|
297
|
+
? recipe.risk_labels.map((label) => String(label))
|
|
298
|
+
: [];
|
|
299
|
+
return labels.some((label) => label !== "risk.long_running");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function getPiActorsTriage(
|
|
303
|
+
ctx: unknown,
|
|
304
|
+
deps: InspectToolDeps,
|
|
305
|
+
): Record<string, unknown> {
|
|
306
|
+
const runtime = getPiActorsRuntimeStatus();
|
|
307
|
+
const currentSession = ToolsAccess.getContextSessionId(ctx);
|
|
308
|
+
const allRuns = AsyncRuns.listRuns().map((run) =>
|
|
309
|
+
AsyncRuns.getRunStatus(String(run.state_dir)),
|
|
310
|
+
);
|
|
311
|
+
const visibleRuns = currentSession
|
|
312
|
+
? allRuns.filter(
|
|
313
|
+
(run) => !run.ownerId || run.ownerId === currentSession,
|
|
314
|
+
)
|
|
315
|
+
: allRuns;
|
|
316
|
+
const activeRuns = visibleRuns.filter((run) => run.status === "running");
|
|
317
|
+
const failedRuns = visibleRuns.filter((run) => run.status === "failed");
|
|
318
|
+
const otherRuns = currentSession
|
|
319
|
+
? allRuns.filter((run) => run.ownerId && run.ownerId !== currentSession)
|
|
320
|
+
: [];
|
|
321
|
+
const recipeRoot = deps.recipeRoot ?? Paths.getRecipeRoot();
|
|
322
|
+
const discovered = RecipesDiscovery.discoverRecipeSources([
|
|
323
|
+
{ root: recipeRoot, defaultTool: true, mutableUsage: true },
|
|
324
|
+
{ root: deps.packagedRecipeRoot ?? Paths.getPackagedRecipeRoot() },
|
|
325
|
+
]);
|
|
326
|
+
const recipeSummary: Record<string, unknown> = {
|
|
327
|
+
...RecipesDiscovery.summarizeDiscovery(discovered),
|
|
328
|
+
drafts: RecipesDiscovery.listDraftRecipes(join(recipeRoot, "drafts")),
|
|
329
|
+
};
|
|
330
|
+
const activeRecipes = Array.isArray(recipeSummary.active)
|
|
331
|
+
? (recipeSummary.active as Array<Record<string, unknown>>)
|
|
332
|
+
: [];
|
|
333
|
+
const highRiskRecipes = activeRecipes.filter(isTriageHighRiskRecipe);
|
|
334
|
+
const signals = getRunTriageSignals(visibleRuns);
|
|
335
|
+
const attentionMessages = signals.attention_messages as Array<
|
|
336
|
+
Record<string, unknown>
|
|
337
|
+
>;
|
|
338
|
+
const staleClaims = signals.stale_claims as Array<Record<string, unknown>>;
|
|
339
|
+
const invalidRecipes = Array.isArray(recipeSummary.invalid)
|
|
340
|
+
? (recipeSummary.invalid as Array<Record<string, unknown>>)
|
|
341
|
+
: [];
|
|
342
|
+
const remediations = Array.isArray(recipeSummary.remediations)
|
|
343
|
+
? (recipeSummary.remediations as Array<Record<string, unknown>>)
|
|
344
|
+
: [];
|
|
345
|
+
const drafts = Array.isArray(recipeSummary.drafts)
|
|
346
|
+
? (recipeSummary.drafts as Array<Record<string, unknown>>)
|
|
347
|
+
: [];
|
|
348
|
+
const nextActions = [
|
|
349
|
+
invalidRecipes.length || remediations.length
|
|
350
|
+
? "inspect target=recipes view=doctor"
|
|
351
|
+
: "",
|
|
352
|
+
drafts.length ? "inspect target=recipes view=summary verbose=true" : "",
|
|
353
|
+
failedRuns[0]?.run
|
|
354
|
+
? `inspect target=run:${String(failedRuns[0].run)} view=tail lines=80`
|
|
355
|
+
: "",
|
|
356
|
+
attentionMessages[0]?.run
|
|
357
|
+
? `inspect target=run:${String(attentionMessages[0].run)} view=messages`
|
|
358
|
+
: "",
|
|
359
|
+
activeRuns.length
|
|
360
|
+
? "inspect target=session:all view=runs status=active"
|
|
361
|
+
: "",
|
|
362
|
+
].filter(Boolean);
|
|
363
|
+
return {
|
|
364
|
+
runtime,
|
|
365
|
+
current_session: currentSession ?? null,
|
|
366
|
+
active_runs: activeRuns.map((run) => ({
|
|
367
|
+
run: run.run,
|
|
368
|
+
ownerId: run.ownerId,
|
|
369
|
+
recipe: run.recipe,
|
|
370
|
+
status: run.status,
|
|
371
|
+
})),
|
|
372
|
+
other_session_runs: otherRuns.length,
|
|
373
|
+
invalid_recipes: invalidRecipes,
|
|
374
|
+
blocking_recipes: remediations.filter((item) =>
|
|
375
|
+
String(item.kind ?? "").startsWith("blocking_"),
|
|
376
|
+
),
|
|
377
|
+
high_risk_recipes: highRiskRecipes.map((recipe) => ({
|
|
378
|
+
id: recipe.id,
|
|
379
|
+
path: recipe.path,
|
|
380
|
+
risk_labels: recipe.risk_labels,
|
|
381
|
+
})),
|
|
382
|
+
draft_recipes: drafts,
|
|
383
|
+
stale_claims: staleClaims,
|
|
384
|
+
recent_failed_runs: failedRuns.slice(0, 5).map((run) => ({
|
|
385
|
+
run: run.run,
|
|
386
|
+
recipe: run.recipe,
|
|
387
|
+
status: run.status,
|
|
388
|
+
})),
|
|
389
|
+
attention_messages: attentionMessages.slice(-10),
|
|
390
|
+
next_actions: [...new Set(nextActions)].slice(0, 5),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function compactPiActorsTriage(summary: Record<string, unknown>): string {
|
|
395
|
+
const runtime = asRecord(summary.runtime);
|
|
396
|
+
const activeRuns = Array.isArray(summary.active_runs)
|
|
397
|
+
? summary.active_runs.length
|
|
398
|
+
: 0;
|
|
399
|
+
const invalidRecipes = Array.isArray(summary.invalid_recipes)
|
|
400
|
+
? summary.invalid_recipes.length
|
|
401
|
+
: 0;
|
|
402
|
+
const blockingRecipes = Array.isArray(summary.blocking_recipes)
|
|
403
|
+
? summary.blocking_recipes.length
|
|
404
|
+
: 0;
|
|
405
|
+
const highRiskRecipes = Array.isArray(summary.high_risk_recipes)
|
|
406
|
+
? summary.high_risk_recipes.length
|
|
407
|
+
: 0;
|
|
408
|
+
const drafts = Array.isArray(summary.draft_recipes)
|
|
409
|
+
? summary.draft_recipes.length
|
|
410
|
+
: 0;
|
|
411
|
+
const staleClaims = Array.isArray(summary.stale_claims)
|
|
412
|
+
? summary.stale_claims.length
|
|
413
|
+
: 0;
|
|
414
|
+
const failedRuns = Array.isArray(summary.recent_failed_runs)
|
|
415
|
+
? summary.recent_failed_runs.length
|
|
416
|
+
: 0;
|
|
417
|
+
const attention = Array.isArray(summary.attention_messages)
|
|
418
|
+
? summary.attention_messages.length
|
|
419
|
+
: 0;
|
|
420
|
+
const nextActions = Array.isArray(summary.next_actions)
|
|
421
|
+
? (summary.next_actions as string[])
|
|
422
|
+
: [];
|
|
423
|
+
return `\ntriage version=${String(runtime.version ?? "unknown")} mode=${String(runtime.mode ?? "unknown")} active_runs=${activeRuns} other_runs=${String(summary.other_session_runs ?? 0)} invalid_recipes=${invalidRecipes} blocking_recipes=${blockingRecipes} high_risk_recipes=${highRiskRecipes} drafts=${drafts} stale_claims=${staleClaims} failed_runs=${failedRuns} attention=${attention}${ToolsResponse.compactNextActions(nextActions)}`;
|
|
424
|
+
}
|
|
425
|
+
|
|
251
426
|
function compactToolActor(name: string, tool: Record<string, unknown>): string {
|
|
252
427
|
const parameters = asRecord(tool.parameters);
|
|
253
428
|
const required = Array.isArray(parameters.required)
|
|
@@ -414,10 +589,15 @@ export function createInspectToolDefinition<TContext = unknown>(
|
|
|
414
589
|
}
|
|
415
590
|
if (address.kind === "tool" && address.value) {
|
|
416
591
|
if (address.value === "pi-actors") {
|
|
417
|
-
if (view !== "status") {
|
|
418
|
-
throw new Error(
|
|
592
|
+
if (view !== "status" && view !== "triage") {
|
|
593
|
+
throw new Error(
|
|
594
|
+
"inspect tool:pi-actors supports view=status or view=triage.",
|
|
595
|
+
);
|
|
419
596
|
}
|
|
420
|
-
const details =
|
|
597
|
+
const details =
|
|
598
|
+
view === "triage"
|
|
599
|
+
? getPiActorsTriage(ctx, deps)
|
|
600
|
+
: getPiActorsRuntimeStatus();
|
|
421
601
|
return {
|
|
422
602
|
content: [
|
|
423
603
|
{
|
|
@@ -425,7 +605,9 @@ export function createInspectToolDefinition<TContext = unknown>(
|
|
|
425
605
|
text: maybeJsonText(
|
|
426
606
|
details,
|
|
427
607
|
input.verbose === true,
|
|
428
|
-
|
|
608
|
+
view === "triage"
|
|
609
|
+
? compactPiActorsTriage(details)
|
|
610
|
+
: compactPiActorsRuntimeStatus(details),
|
|
429
611
|
),
|
|
430
612
|
},
|
|
431
613
|
],
|
package/lib/tools-response.ts
CHANGED
|
@@ -173,15 +173,26 @@ export function compactRecipeDoctor(summary: Record<string, unknown>): string {
|
|
|
173
173
|
const recommendations = Array.isArray(summary.recommendations)
|
|
174
174
|
? (summary.recommendations as Array<Record<string, unknown>>)
|
|
175
175
|
: [];
|
|
176
|
+
const riskSummary = Array.isArray(summary.risk_summary)
|
|
177
|
+
? (summary.risk_summary as Array<Record<string, unknown>>)
|
|
178
|
+
: [];
|
|
176
179
|
const counts = { error: 0, info: 0, warning: 0 };
|
|
177
180
|
for (const detail of details) {
|
|
178
181
|
const severity = String(detail.severity ?? "info");
|
|
179
182
|
if (severity === "error" || severity === "warning" || severity === "info")
|
|
180
183
|
counts[severity] += 1;
|
|
181
184
|
}
|
|
185
|
+
const riskCount = riskSummary.reduce(
|
|
186
|
+
(total, item) => total + Number(item.count ?? 0),
|
|
187
|
+
0,
|
|
188
|
+
);
|
|
189
|
+
const topRisks = riskSummary
|
|
190
|
+
.slice(0, 4)
|
|
191
|
+
.map((item) => `${String(item.label)}:${String(item.count ?? 0)}`)
|
|
192
|
+
.join(",");
|
|
182
193
|
const topAction = asRecord(summary.top_action);
|
|
183
194
|
const lines = [
|
|
184
|
-
`recipes doctor errors=${counts.error} warnings=${counts.warning} info=${counts.info} actions=${remediations.length} recommendations=${recommendations.length}`,
|
|
195
|
+
`recipes doctor errors=${counts.error} warnings=${counts.warning} info=${counts.info} actions=${remediations.length} recommendations=${recommendations.length} risks=${riskCount}${topRisks ? ` top_risks=${topRisks}` : ""}`,
|
|
185
196
|
];
|
|
186
197
|
if (Object.keys(topAction).length > 0) {
|
|
187
198
|
const action = compactPreview(
|
|
@@ -200,8 +211,11 @@ export function compactRecipeDoctor(summary: Record<string, unknown>): string {
|
|
|
200
211
|
const blocked = item.blocked_fallback
|
|
201
212
|
? ` blocked=${compactPreview(item.blocked_fallback, Limits.DOCTOR_ACTION_PREVIEW_CHARS)}`
|
|
202
213
|
: "";
|
|
214
|
+
const labels = Array.isArray(item.risk_labels)
|
|
215
|
+
? ` labels=${(item.risk_labels as unknown[]).map(String).slice(0, 4).join(",")}`
|
|
216
|
+
: "";
|
|
203
217
|
lines.push(
|
|
204
|
-
`${String(item.severity ?? "info")} kind=${String(item.kind ?? "inspect")} id=${String(item.id ?? "root")}${blocked} action=${action ?? "inspect"}`,
|
|
218
|
+
`${String(item.severity ?? "info")} kind=${String(item.kind ?? "inspect")} id=${String(item.id ?? "root")}${blocked}${labels} action=${action ?? "inspect"}`,
|
|
205
219
|
);
|
|
206
220
|
}
|
|
207
221
|
const nextActions = Array.isArray(summary.next_actions)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llblab/pi-actors",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Local Actor Kernel for Pi",
|
|
6
6
|
"keywords": [
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"conformance": "node scripts/conformance.mjs",
|
|
30
30
|
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
|
31
31
|
"pack:dry": "npm pack --dry-run",
|
|
32
|
-
"
|
|
32
|
+
"recipes:qa": "node scripts/validate-recipe.mjs recipes --all --qa --summary",
|
|
33
|
+
"validate": "npx tsc --noEmit && npm run recipes:qa && npm run build && npm run check && npm test && npm run pack:dry",
|
|
33
34
|
"build": "node scripts/build-dist.mjs",
|
|
34
35
|
"prepack": "npm run build"
|
|
35
36
|
},
|
|
@@ -27,9 +27,9 @@ const { readResolvedRecipeConfig } = await importRuntimeModule("recipes-referenc
|
|
|
27
27
|
|
|
28
28
|
export function validateRecipeUsage() {
|
|
29
29
|
return `Usage:
|
|
30
|
-
validate-recipe.mjs <recipe-file-or-dir> [--all]
|
|
30
|
+
validate-recipe.mjs <recipe-file-or-dir> [--all] [--qa] [--summary]
|
|
31
31
|
|
|
32
|
-
Validates one template recipe file, or all *.json/*.md files in a directory when --all is set.`;
|
|
32
|
+
Validates one template recipe file, or all *.json/*.md files in a directory when --all is set. Add --qa for packaged-recipe quality checks. Add --summary for compact CLI output.`;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function expandPath(value) {
|
|
@@ -63,14 +63,133 @@ function recipeFiles(target, all) {
|
|
|
63
63
|
.map((file) => resolve(target, file));
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
function
|
|
66
|
+
function mailboxType(value) {
|
|
67
|
+
if (typeof value === "string") return value;
|
|
68
|
+
if (value && typeof value === "object" && typeof value.type === "string")
|
|
69
|
+
return value.type;
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function collectTemplateStrings(value, out = []) {
|
|
74
|
+
if (typeof value === "string") out.push(value);
|
|
75
|
+
else if (Array.isArray(value)) value.forEach((item) => collectTemplateStrings(item, out));
|
|
76
|
+
else if (value && typeof value === "object") {
|
|
77
|
+
collectTemplateStrings(value.template, out);
|
|
78
|
+
collectTemplateStrings(value.recover, out);
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function hasPlatformNote(config) {
|
|
84
|
+
const text = [
|
|
85
|
+
config.description,
|
|
86
|
+
config.platforms,
|
|
87
|
+
config.platform_notes,
|
|
88
|
+
config.requirements,
|
|
89
|
+
]
|
|
90
|
+
.filter(Boolean)
|
|
91
|
+
.join(" ");
|
|
92
|
+
return /linux|macos|darwin|windows|win32|unix|wsl|cross-platform|portable/i.test(text);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function validateArtifactDeclarations(config) {
|
|
96
|
+
const diagnostics = [];
|
|
97
|
+
const artifacts = config.artifacts;
|
|
98
|
+
if (artifacts === undefined) return diagnostics;
|
|
99
|
+
if (!artifacts || typeof artifacts !== "object" || Array.isArray(artifacts)) {
|
|
100
|
+
diagnostics.push("artifacts: must be an object of named artifact paths");
|
|
101
|
+
return diagnostics;
|
|
102
|
+
}
|
|
103
|
+
for (const [name, value] of Object.entries(artifacts)) {
|
|
104
|
+
const path = typeof value === "string" ? value : value?.path;
|
|
105
|
+
if (typeof path !== "string" || !path.trim())
|
|
106
|
+
diagnostics.push(`artifacts.${name}: must declare a non-empty path`);
|
|
107
|
+
if (typeof path === "string" && /^\/home\//.test(path))
|
|
108
|
+
diagnostics.push(`artifacts.${name}: must not use a machine-local absolute path`);
|
|
109
|
+
}
|
|
110
|
+
return diagnostics;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function validateHelperPaths(file, config) {
|
|
114
|
+
const diagnostics = [];
|
|
115
|
+
for (const template of collectTemplateStrings(config.template)) {
|
|
116
|
+
if (/(^|\s)(?:node\s+)?scripts\/[\w.-]+\.mjs/.test(template))
|
|
117
|
+
diagnostics.push("template: helper scripts must be referenced through {repo}/scripts for installed packages");
|
|
118
|
+
for (const match of template.matchAll(/\{repo\}\/scripts\/([^\s"']+\.mjs)/g)) {
|
|
119
|
+
const scriptPath = join(packageRoot(), "scripts", match[1]);
|
|
120
|
+
if (!existsSync(scriptPath))
|
|
121
|
+
diagnostics.push(`template: referenced helper script not found: scripts/${match[1]}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return diagnostics;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function validateMailboxContract(file, config) {
|
|
128
|
+
const diagnostics = [];
|
|
129
|
+
const mailbox = config.mailbox;
|
|
130
|
+
const accepts = Array.isArray(mailbox?.accepts) ? mailbox.accepts : [];
|
|
131
|
+
const emits = Array.isArray(mailbox?.emits) ? mailbox.emits : [];
|
|
132
|
+
if (config.async === true && !mailbox)
|
|
133
|
+
diagnostics.push("mailbox: async recipes must declare mailbox metadata");
|
|
134
|
+
if (config.async === true && !accepts.map(mailboxType).includes("control.kill"))
|
|
135
|
+
diagnostics.push("mailbox.accepts: async recipes must include control.kill");
|
|
136
|
+
const allowedLegacyTypes = new Set(["awaiting_assignment"]);
|
|
137
|
+
for (const [key, entries] of Object.entries({ accepts, emits })) {
|
|
138
|
+
entries.forEach((entry, index) => {
|
|
139
|
+
const type = mailboxType(entry);
|
|
140
|
+
if (!type) diagnostics.push(`mailbox.${key}[${index}]: must be a string or object with type`);
|
|
141
|
+
else if (
|
|
142
|
+
!allowedLegacyTypes.has(type) &&
|
|
143
|
+
!/^[a-z][a-z0-9_-]*\.(?:[a-z][a-z0-9_-]*|\*)$/.test(type)
|
|
144
|
+
)
|
|
145
|
+
diagnostics.push(`mailbox.${key}[${index}]: message type must use channel.action form`);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const allowedDomainTermination = new Set([
|
|
149
|
+
"coordinator-locker.json",
|
|
150
|
+
"locker.json",
|
|
151
|
+
"music-player.json",
|
|
152
|
+
]);
|
|
153
|
+
const hasDomainTermination = accepts
|
|
154
|
+
.map(mailboxType)
|
|
155
|
+
.some((type) => type === "control.stop" || type === "control.cancel");
|
|
156
|
+
if (hasDomainTermination && !allowedDomainTermination.has(file.split(/[\\/]/).pop()))
|
|
157
|
+
diagnostics.push("mailbox.accepts: control.stop/control.cancel are reserved for actor-domain handlers");
|
|
158
|
+
return diagnostics;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function qaDiagnostics(file, config) {
|
|
162
|
+
const diagnostics = [];
|
|
163
|
+
const warnings = [];
|
|
164
|
+
if (typeof config.description !== "string" || !config.description.trim())
|
|
165
|
+
warnings.push("description: missing or empty");
|
|
166
|
+
diagnostics.push(...validateMailboxContract(file, config));
|
|
167
|
+
diagnostics.push(...validateArtifactDeclarations(config));
|
|
168
|
+
diagnostics.push(...validateHelperPaths(file, config));
|
|
169
|
+
const platformSpecificTemplate = collectTemplateStrings(config.template).some(
|
|
170
|
+
(template) =>
|
|
171
|
+
/(^|\s)(?:systemctl|launchctl|osascript|powershell|pwsh|cmd\.exe|apt|apt-get|dnf|yum|brew|pacman|apk)(\s|$)/i.test(
|
|
172
|
+
template,
|
|
173
|
+
),
|
|
174
|
+
);
|
|
175
|
+
if (platformSpecificTemplate && !hasPlatformNote(config))
|
|
176
|
+
diagnostics.push("platform: platform-specific templates must document platform scope");
|
|
177
|
+
return { diagnostics, warnings };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function qaOk(qaReport) {
|
|
181
|
+
return qaReport.diagnostics.length === 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function validateFile(file, qa = false) {
|
|
67
185
|
try {
|
|
68
186
|
const config = readResolvedRecipeConfig(file);
|
|
69
187
|
if (!config?.template)
|
|
70
188
|
throw new Error("Recipe must define a non-empty template.");
|
|
189
|
+
const qaReport = qa ? qaDiagnostics(file, config) : { diagnostics: [], warnings: [] };
|
|
71
190
|
return {
|
|
72
191
|
file,
|
|
73
|
-
ok:
|
|
192
|
+
ok: qaOk(qaReport),
|
|
74
193
|
name: config.name ?? "",
|
|
75
194
|
async: Boolean(config.async),
|
|
76
195
|
args: Array.isArray(config.args) ? config.args : [],
|
|
@@ -93,6 +212,15 @@ function validateFile(file) {
|
|
|
93
212
|
: [],
|
|
94
213
|
}
|
|
95
214
|
: undefined,
|
|
215
|
+
...(qa
|
|
216
|
+
? {
|
|
217
|
+
qa: {
|
|
218
|
+
ok: qaOk(qaReport),
|
|
219
|
+
diagnostics: qaReport.diagnostics,
|
|
220
|
+
warnings: qaReport.warnings,
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
: {}),
|
|
96
224
|
template: templateKind(config.template),
|
|
97
225
|
};
|
|
98
226
|
} catch (error) {
|
|
@@ -107,12 +235,13 @@ function validateFile(file) {
|
|
|
107
235
|
export function validateRecipes(argv) {
|
|
108
236
|
const targetArg = argv.find((arg) => !arg.startsWith("-"));
|
|
109
237
|
const all = argv.includes("--all");
|
|
238
|
+
const qa = argv.includes("--qa");
|
|
110
239
|
if (!targetArg || argv.includes("--help") || argv.includes("-h")) {
|
|
111
240
|
return { help: true, ok: Boolean(targetArg), usage: validateRecipeUsage() };
|
|
112
241
|
}
|
|
113
242
|
|
|
114
243
|
const files = recipeFiles(expandPath(targetArg), all);
|
|
115
|
-
const results = files.map(validateFile);
|
|
244
|
+
const results = files.map((file) => validateFile(file, qa));
|
|
116
245
|
const failed = results.filter((result) => !result.ok).length;
|
|
117
246
|
return {
|
|
118
247
|
ok: failed === 0,
|
|
@@ -123,10 +252,38 @@ export function validateRecipes(argv) {
|
|
|
123
252
|
};
|
|
124
253
|
}
|
|
125
254
|
|
|
255
|
+
function summarizeReport(report) {
|
|
256
|
+
const results = Array.isArray(report.results) ? report.results : [];
|
|
257
|
+
return {
|
|
258
|
+
ok: report.ok,
|
|
259
|
+
total: report.total,
|
|
260
|
+
passed: report.passed,
|
|
261
|
+
failed: report.failed,
|
|
262
|
+
diagnostics: results.reduce(
|
|
263
|
+
(total, result) => total + (result.qa?.diagnostics?.length ?? 0),
|
|
264
|
+
0,
|
|
265
|
+
),
|
|
266
|
+
warnings: results.reduce(
|
|
267
|
+
(total, result) => total + (result.qa?.warnings?.length ?? 0),
|
|
268
|
+
0,
|
|
269
|
+
),
|
|
270
|
+
failed_files: results
|
|
271
|
+
.filter((result) => !result.ok)
|
|
272
|
+
.map((result) => ({
|
|
273
|
+
file: result.file,
|
|
274
|
+
...(result.error ? { error: result.error } : {}),
|
|
275
|
+
...(result.qa?.diagnostics?.length
|
|
276
|
+
? { diagnostics: result.qa.diagnostics }
|
|
277
|
+
: {}),
|
|
278
|
+
})),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
126
282
|
try {
|
|
127
|
-
const
|
|
283
|
+
const argv = process.argv.slice(2);
|
|
284
|
+
const report = validateRecipes(argv);
|
|
128
285
|
if (report.help) console.error(report.usage);
|
|
129
|
-
else console.log(JSON.stringify(report, null, 2));
|
|
286
|
+
else console.log(JSON.stringify(argv.includes("--summary") ? summarizeReport(report) : report, null, 2));
|
|
130
287
|
process.exit(report.ok ? 0 : 1);
|
|
131
288
|
} catch (error) {
|
|
132
289
|
console.error(error instanceof Error ? error.message : String(error));
|