@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.
@@ -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
- commandTemplateConfig as CommandTemplates.CommandTemplateConfig,
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],
@@ -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) position = 0;
164
- const chunk = buffer.subarray(position).toString("utf8");
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
- for (const line of chunk.split("\n")) {
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) {
@@ -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("inspect tool:pi-actors supports view=status.");
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 = getPiActorsRuntimeStatus();
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
- compactPiActorsRuntimeStatus(details),
608
+ view === "triage"
609
+ ? compactPiActorsTriage(details)
610
+ : compactPiActorsRuntimeStatus(details),
429
611
  ),
430
612
  },
431
613
  ],
@@ -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.35.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
- "validate": "npx tsc --noEmit && npm run build && npm run check && npm test && npm run pack:dry",
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 validateFile(file) {
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: true,
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 report = validateRecipes(process.argv.slice(2));
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));