@shrkcrft/cli 0.1.0-alpha.26 → 0.1.0-alpha.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/command-registry.d.ts +12 -0
  2. package/dist/command-registry.d.ts.map +1 -1
  3. package/dist/command-registry.js +25 -0
  4. package/dist/commands/baseline.command.d.ts +8 -0
  5. package/dist/commands/baseline.command.d.ts.map +1 -0
  6. package/dist/commands/baseline.command.js +511 -0
  7. package/dist/commands/changelog-data.d.ts.map +1 -1
  8. package/dist/commands/changelog-data.js +43 -0
  9. package/dist/commands/check.command.d.ts.map +1 -1
  10. package/dist/commands/check.command.js +28 -1
  11. package/dist/commands/command-catalog.d.ts.map +1 -1
  12. package/dist/commands/command-catalog.js +112 -0
  13. package/dist/commands/delegate.command.d.ts +76 -1
  14. package/dist/commands/delegate.command.d.ts.map +1 -1
  15. package/dist/commands/delegate.command.js +585 -25
  16. package/dist/commands/finish.command.js +4 -4
  17. package/dist/commands/gates.command.d.ts +6 -0
  18. package/dist/commands/gates.command.d.ts.map +1 -0
  19. package/dist/commands/gates.command.js +334 -0
  20. package/dist/commands/generated.command.d.ts +6 -0
  21. package/dist/commands/generated.command.d.ts.map +1 -0
  22. package/dist/commands/generated.command.js +514 -0
  23. package/dist/commands/help.command.d.ts.map +1 -1
  24. package/dist/commands/help.command.js +73 -0
  25. package/dist/commands/ingest.command.d.ts +11 -0
  26. package/dist/commands/ingest.command.d.ts.map +1 -1
  27. package/dist/commands/ingest.command.js +49 -23
  28. package/dist/commands/policy-lint.command.d.ts +37 -0
  29. package/dist/commands/policy-lint.command.d.ts.map +1 -1
  30. package/dist/commands/policy-lint.command.js +119 -2
  31. package/dist/commands/registry-resolve.d.ts +11 -4
  32. package/dist/commands/registry-resolve.d.ts.map +1 -1
  33. package/dist/commands/registry-resolve.js +50 -24
  34. package/dist/commands/registry.command.d.ts.map +1 -1
  35. package/dist/commands/registry.command.js +36 -4
  36. package/dist/commands/trace.command.d.ts.map +1 -1
  37. package/dist/commands/trace.command.js +7 -1
  38. package/dist/commands/wiring.command.d.ts.map +1 -1
  39. package/dist/commands/wiring.command.js +113 -14
  40. package/dist/exit-codes.d.ts +41 -0
  41. package/dist/exit-codes.d.ts.map +1 -1
  42. package/dist/exit-codes.js +85 -0
  43. package/dist/finish/run-finish.d.ts +22 -3
  44. package/dist/finish/run-finish.d.ts.map +1 -1
  45. package/dist/finish/run-finish.js +194 -18
  46. package/dist/gates/gate-rule-view.d.ts +35 -0
  47. package/dist/gates/gate-rule-view.d.ts.map +1 -0
  48. package/dist/gates/gate-rule-view.js +80 -0
  49. package/dist/gates/rule-coverage.d.ts +53 -0
  50. package/dist/gates/rule-coverage.d.ts.map +1 -0
  51. package/dist/gates/rule-coverage.js +165 -0
  52. package/dist/main.d.ts.map +1 -1
  53. package/dist/main.js +46 -6
  54. package/dist/output/output-compression.d.ts.map +1 -1
  55. package/dist/output/output-compression.js +4 -1
  56. package/package.json +33 -33
@@ -11,14 +11,15 @@
11
11
  * through the same apply primitives `shrk apply` uses. A failed verification
12
12
  * auto-reverts the edit, so a bad generation costs a retry, never a wrong write.
13
13
  */
14
- import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
15
15
  import * as nodePath from 'node:path';
16
16
  import { containsTraversal, safeResolveTargetPath } from '@shrkcrft/core';
17
- import { AiMessageRole, callDelegateWithRetry, delegateRepromptMessage, selectAiProvider, } from '@shrkcrft/ai';
17
+ import { AiMessageRole, DELEGATE_ANALYSIS_JSON_SCHEMA, callDelegateWithRetry, callDelegateAnalysisWithRetry, delegateAnalysisRepromptMessage, delegateRepromptMessage, parseDelegateAnalysis, runBoundedQueryLoop, selectAiProvider, } from '@shrkcrft/ai';
18
18
  import { loadProjectConfig } from '@shrkcrft/config';
19
19
  import { compressCode, compressDiff } from '@shrkcrft/compress';
20
20
  import { listIndexableFiles } from '@shrkcrft/embeddings';
21
- import { checkGuardrailGlobs, resolveDelegateCatalogForProject, unifiedDiff, } from '@shrkcrft/inspector';
21
+ import { loadGraphApiCached } from '@shrkcrft/graph';
22
+ import { analyzeTestImpact, buildCoverageReport, buildDelegateAnalysisReport, buildDelegateFailureFacts, buildTaskRiskReport, checkGuardrailGlobs, crossCheckFindings, inspectSharkcraft, resolveDelegateCatalogForProject, runGroundingReport, unifiedDiff, } from '@shrkcrft/inspector';
22
23
  import { evaluateSavedPlanInPlace, packageDelegatePlan, savePlanToFile, signPlan, verifyPlan, writeSyntheticPlan, } from '@shrkcrft/generator';
23
24
  import { flagBool, flagString, resolveCwd, } from "../command-registry.js";
24
25
  import { asJson, header, kv } from "../output/format-output.js";
@@ -56,7 +57,7 @@ export function gatherRecipeContext(projectRoot, recipe) {
56
57
  let candidates;
57
58
  try {
58
59
  const all = listIndexableFiles(projectRoot, 3000);
59
- candidates = checkGuardrailGlobs(all, recipe.guardrailGlobs).allowed;
60
+ candidates = checkGuardrailGlobs(all, recipe.guardrailGlobs ?? []).allowed;
60
61
  }
61
62
  catch {
62
63
  return '';
@@ -79,12 +80,14 @@ export function gatherRecipeContext(projectRoot, recipe) {
79
80
  return `Files in scope you may edit (current contents):\n\n${blocks.join('\n\n')}${more}`;
80
81
  }
81
82
  function systemPrompt(recipe) {
82
- const example = recipe.allowedOps.map(opExample).find((e) => e !== null);
83
+ const allowedOps = recipe.allowedOps ?? [];
84
+ const guardrailGlobs = recipe.guardrailGlobs ?? [];
85
+ const example = allowedOps.map(opExample).find((e) => e !== null);
83
86
  const lines = [
84
87
  'You are a deterministic mechanical code-edit worker.',
85
88
  'Output ONLY a single JSON object matching the provided schema — no prose, no markdown fences.',
86
- `You may emit ONLY operations of these kinds: ${recipe.allowedOps.join(', ')}.`,
87
- `You may target ONLY files matching one of these globs: ${recipe.guardrailGlobs.join(', ')}.`,
89
+ `You may emit ONLY operations of these kinds: ${allowedOps.join(', ')}.`,
90
+ `You may target ONLY files matching one of these globs: ${guardrailGlobs.join(', ')}.`,
88
91
  'Make the SMALLEST mechanical edit that satisfies the task. Never invent files, never change unrelated code, never reformat.',
89
92
  'Each op has a "targetPath" (relative to project root) and an "operation" with a "kind" and the fields that kind needs.',
90
93
  ];
@@ -136,12 +139,43 @@ export async function executeDelegateRun(input) {
136
139
  last = { ...(await runOneDelegateAttempt(input, [...baseMessages, ...feedback])), attempts: attempt };
137
140
  if (!RETRYABLE_STATUSES.has(last.status) || attempt === maxAttempts)
138
141
  return last;
139
- feedback.push(buildRetryFeedback(last, input.recipe));
142
+ // Best-effort LLM diagnosis of the failure (Phase 2). Never blocks the loop:
143
+ // a null return or a throw simply means no extra hint this round.
144
+ let advisory;
145
+ if (input.retryAdvisor) {
146
+ try {
147
+ const adv = await input.retryAdvisor(failureContextOf(last, input.recipe, attempt), attempt);
148
+ advisory = adv ?? undefined;
149
+ }
150
+ catch {
151
+ advisory = undefined;
152
+ }
153
+ }
154
+ feedback.push(buildRetryFeedback(last, input.recipe, advisory));
140
155
  }
141
156
  return last;
142
157
  }
143
- /** Build the User message that tells the worker why the previous attempt failed. */
144
- function buildRetryFeedback(r, recipe) {
158
+ /** Map a delegate run result onto the layer-neutral failure context for grounding. */
159
+ function failureContextOf(r, recipe, attempt) {
160
+ return {
161
+ status: r.status,
162
+ message: r.message,
163
+ ...(r.conflicts ? { conflicts: r.conflicts } : {}),
164
+ ...(r.verification?.commandsFailed ? { commandsFailed: r.verification.commandsFailed } : {}),
165
+ ...(r.refused ? { refused: r.refused } : {}),
166
+ ...(r.droppedOps ? { droppedOps: r.droppedOps.map((d) => ({ kind: d.kind, targetPath: d.targetPath })) } : {}),
167
+ ...(recipe.guardrailGlobs ? { guardrailGlobs: recipe.guardrailGlobs } : {}),
168
+ ...(recipe.allowedOps ? { allowedOps: recipe.allowedOps } : {}),
169
+ attempt,
170
+ };
171
+ }
172
+ /**
173
+ * Build the User message that tells the worker why the previous attempt failed.
174
+ * `advisory` (optional) is a corrected-instruction hint from a `retry-analysis`
175
+ * pass (Phase 2, `--assisted-retry`); it augments — never replaces — the
176
+ * deterministic failure detail.
177
+ */
178
+ function buildRetryFeedback(r, recipe, advisory) {
145
179
  let detail;
146
180
  if (r.conflicts && r.conflicts.length > 0) {
147
181
  detail = `Your previous edit was REFUSED with conflicts: ${r.conflicts.join('; ')}. Fix the target paths / anchors and try again.`;
@@ -150,17 +184,18 @@ function buildRetryFeedback(r, recipe) {
150
184
  detail = `Your previous edit FAILED verification (${r.verification?.commandsFailed.join(', ') || 'see logs'}) and was reverted. Produce a CORRECT edit.`;
151
185
  }
152
186
  else if (r.status === 'guardrail-refused') {
153
- detail = `You targeted files outside the allowed scope (${(r.refused ?? []).join(', ')}). You may ONLY touch files matching: ${recipe.guardrailGlobs.join(', ')}.`;
187
+ detail = `You targeted files outside the allowed scope (${(r.refused ?? []).join(', ')}). You may ONLY touch files matching: ${(recipe.guardrailGlobs ?? []).join(', ')}.`;
154
188
  }
155
189
  else if (r.status === 'package-error') {
156
- detail = `${r.message}. You may ONLY use op kinds: ${recipe.allowedOps.join(', ')}.`;
190
+ detail = `${r.message}. You may ONLY use op kinds: ${(recipe.allowedOps ?? []).join(', ')}.`;
157
191
  }
158
192
  else {
159
193
  detail = `Your previous reply was unusable: ${r.message}.`;
160
194
  }
195
+ const advisoryLine = advisory && advisory.trim().length > 0 ? `\nDiagnostic hint: ${advisory.trim()}` : '';
161
196
  return {
162
197
  role: AiMessageRole.User,
163
- content: `${detail}\nReturn a corrected single JSON object matching the schema — no prose.`,
198
+ content: `${detail}${advisoryLine}\nReturn a corrected single JSON object matching the schema — no prose.`,
164
199
  };
165
200
  }
166
201
  /** One generate→guardrail→package→sign→apply→verify pass. */
@@ -215,7 +250,7 @@ async function runOneDelegateAttempt(input, messages) {
215
250
  };
216
251
  }
217
252
  }
218
- const guard = checkGuardrailGlobs(normalizedTargets, recipe.guardrailGlobs);
253
+ const guard = checkGuardrailGlobs(normalizedTargets, recipe.guardrailGlobs ?? []);
219
254
  if (!guard.ok) {
220
255
  return {
221
256
  status: 'guardrail-refused',
@@ -228,7 +263,7 @@ async function runOneDelegateAttempt(input, messages) {
228
263
  // 4. Package into a synthetic plan (drops disallowed ops, evaluates conflicts).
229
264
  const packaged = packageDelegatePlan({
230
265
  ops: edit.ops,
231
- allowedOps: recipe.allowedOps,
266
+ allowedOps: recipe.allowedOps ?? [],
232
267
  recipeId: recipe.id,
233
268
  projectRoot,
234
269
  });
@@ -286,7 +321,7 @@ async function runOneDelegateAttempt(input, messages) {
286
321
  // A recipe with no verification has no deterministic gate — refuse to apply an
287
322
  // unverified edit (runValidationLoop reports passed:true when no command runs,
288
323
  // so this must be caught here). The plan is already signed + saved on disk.
289
- if (recipe.verificationIds.length === 0) {
324
+ if ((recipe.verificationIds ?? []).length === 0) {
290
325
  return {
291
326
  ...baseResult,
292
327
  status: 'no-verification',
@@ -319,7 +354,7 @@ async function runOneDelegateAttempt(input, messages) {
319
354
  // 7. Deterministic verification gate.
320
355
  const validation = await runValidationLoop({
321
356
  cwd: projectRoot,
322
- verificationIds: recipe.verificationIds,
357
+ verificationIds: recipe.verificationIds ?? [],
323
358
  allVerifications: false,
324
359
  allowPackCommands: false,
325
360
  reportDir: input.reportDir ?? nodePath.join(projectRoot, '.sharkcraft', 'delegate', 'reports'),
@@ -435,7 +470,8 @@ async function resolveRecipe(cwd, recipeId) {
435
470
  if (!found) {
436
471
  return { ok: false, message: `unknown recipe "${recipeId}". Available: ${c.catalog.map((r) => r.id).join(', ')}` };
437
472
  }
438
- // Fold the resolved provider/model onto the recipe for executeDelegateRun.
473
+ // Fold the resolved provider/model onto the recipe (keeping the resolved type
474
+ // so patch consumers see present write-fence arrays) for the run/analyze cores.
439
475
  const recipe = {
440
476
  ...found,
441
477
  provider: found.resolvedProvider,
@@ -443,7 +479,479 @@ async function resolveRecipe(cwd, recipeId) {
443
479
  };
444
480
  return { ok: true, recipe, projectRoot: c.projectRoot };
445
481
  }
482
+ /** Human descriptions of the read-only queries shown to the model in the loop. */
483
+ export const DELEGATE_QUERY_CATALOG = [
484
+ { name: 'task-risk', description: 'deterministic per-task risk (level, reasons, affected files). args: {task?}' },
485
+ { name: 'coverage', description: 'project-intelligence coverage gaps (weakest categories). args: {}' },
486
+ { name: 'test-impact', description: 'likely + missing tests and risk areas. args: {files?: string[]}' },
487
+ { name: 'graph-callers', description: 'who calls/references a symbol, as path:line. args: {symbol: string}' },
488
+ { name: 'graph-context', description: 'a symbol\'s declaring file + importers/imports (is it wired?). args: {symbol: string}' },
489
+ ];
490
+ /**
491
+ * Build the read-only query executor: maps each `DELEGATE_QUERY_IDS` value to a
492
+ * deterministic inspector function. Every query is read-only — there is no
493
+ * write/apply query. Returns compact text + the entities it surfaced (merged
494
+ * into the analysis ground truth so a finding citing a pulled fact is grounded).
495
+ */
496
+ export function buildAnalysisQueryExecutor(inspection, task) {
497
+ // The graph index is loaded lazily (only when a graph query is actually asked)
498
+ // and cached across queries in one run. `undefined` = not yet attempted.
499
+ let graphApi;
500
+ const graph = () => {
501
+ if (graphApi === undefined)
502
+ graphApi = loadGraphApiCached(inspection.projectRoot);
503
+ return graphApi;
504
+ };
505
+ return async (name, args) => {
506
+ switch (name) {
507
+ case 'task-risk': {
508
+ const r = await buildTaskRiskReport(typeof args.task === 'string' ? args.task : task, inspection, {});
509
+ const entities = [...r.affectedFiles, ...r.highFanInFiles, ...r.reasons.map((x) => x.code)];
510
+ const content = `risk=${r.riskLevel} score=${r.score}; reasons: ${r.reasons.slice(0, 6).map((x) => x.code).join(', ') || '(none)'}; files: ${r.affectedFiles.slice(0, 8).join(', ') || '(none)'}`;
511
+ return { content, entities };
512
+ }
513
+ case 'coverage': {
514
+ const c = buildCoverageReport(inspection);
515
+ const weak = c.categories.filter((cat) => cat.score < 100).slice(0, 6);
516
+ return {
517
+ content: `overall=${c.overall}%; weakest: ${weak.map((cat) => `${cat.id} ${cat.score}%`).join(', ') || '(all covered)'}`,
518
+ entities: weak.map((cat) => cat.id),
519
+ };
520
+ }
521
+ case 'test-impact': {
522
+ const files = Array.isArray(args.files)
523
+ ? args.files.filter((x) => typeof x === 'string')
524
+ : [];
525
+ const t = analyzeTestImpact(inspection, { task, ...(files.length > 0 ? { files } : {}) });
526
+ return {
527
+ content: `existing: ${t.likelyTestFiles.slice(0, 8).join(', ') || '(none)'}; missing: ${t.missingTestFiles.slice(0, 8).join(', ') || '(none)'}; risk: ${t.riskAreas.slice(0, 4).join('; ') || '(none)'}`,
528
+ entities: [...t.missingTestFiles, ...t.likelyTestFiles, ...t.riskAreas],
529
+ };
530
+ }
531
+ case 'graph-callers': {
532
+ const api = graph();
533
+ if (!api)
534
+ return { content: 'graph index missing — run `shrk graph index`', entities: [] };
535
+ const symbol = typeof args.symbol === 'string' ? args.symbol.trim() : '';
536
+ if (!symbol)
537
+ return { content: 'graph-callers needs {symbol}', entities: [] };
538
+ const sym = api.findSymbol(symbol, { limit: 1 })[0];
539
+ if (!sym)
540
+ return { content: `no symbol matched "${symbol}"`, entities: [] };
541
+ const sites = api.callerSitesOf(sym.id).slice(0, 15);
542
+ const lines = sites.map((s) => `${s.node.path ?? s.node.label}${s.line ? `:${s.line}` : ''}`);
543
+ return {
544
+ content: `${symbol}: ${sites.length} caller site(s)\n${lines.join('\n') || '(none)'}`,
545
+ entities: sites.map((s) => s.node.path ?? '').filter((p) => p.length > 0),
546
+ };
547
+ }
548
+ case 'graph-context': {
549
+ const api = graph();
550
+ if (!api)
551
+ return { content: 'graph index missing — run `shrk graph index`', entities: [] };
552
+ const symbol = typeof args.symbol === 'string' ? args.symbol.trim() : '';
553
+ if (!symbol)
554
+ return { content: 'graph-context needs {symbol}', entities: [] };
555
+ const sym = api.findSymbol(symbol, { limit: 1 })[0];
556
+ if (!sym)
557
+ return { content: `no symbol matched "${symbol}"`, entities: [] };
558
+ const file = api.declaringFileOf(sym.id);
559
+ const importers = file ? api.importersOf(file.id).slice(0, 10) : [];
560
+ const imports = file ? api.importsFrom(file.id).slice(0, 10) : [];
561
+ const ent = [sym.path, file?.path, ...importers.map((n) => n.path), ...imports.map((n) => n.path)].filter((x) => typeof x === 'string' && x.length > 0);
562
+ return {
563
+ content: `${symbol} declared in ${sym.path ?? file?.path ?? '?'}${sym.line ? `:${sym.line}` : ''}; importers=${importers.length}, imports=${imports.length}`,
564
+ entities: ent,
565
+ };
566
+ }
567
+ default:
568
+ return { content: `unknown query "${name}"`, entities: [] };
569
+ }
570
+ };
571
+ }
572
+ /** Split `xs` into `g` contiguous, deterministic chunks (last may be smaller). */
573
+ function contiguousChunks(xs, g) {
574
+ const groups = Math.max(1, Math.min(g, xs.length));
575
+ const size = Math.ceil(xs.length / groups);
576
+ const out = [];
577
+ for (let i = 0; i < xs.length; i += size)
578
+ out.push(xs.slice(i, i + size));
579
+ return out;
580
+ }
581
+ /** Dedup raw findings by normalised message, preserving first-seen order. */
582
+ function dedupeFindingsByMessage(findings) {
583
+ const seen = new Set();
584
+ const out = [];
585
+ for (const f of findings) {
586
+ const key = f.message.trim().toLowerCase();
587
+ if (key.length === 0 || seen.has(key))
588
+ continue;
589
+ seen.add(key);
590
+ out.push(f);
591
+ }
592
+ return out;
593
+ }
594
+ function analysisSystemPrompt(recipe) {
595
+ return [
596
+ 'You are a READ-ONLY code-analysis assistant. You add judgment on top of a deterministic report; you never propose edits.',
597
+ 'Output ONLY a single JSON object matching the provided schema — no prose, no markdown fences.',
598
+ 'Return a "findings" array. Each finding has a "message" (your judgment) and "refs" (the files / constructs / reason-codes it is about).',
599
+ 'CRITICAL: every ref MUST be copied verbatim from the ground truth provided below. Do NOT invent files, symbols, or codes. If you cannot ground a claim in the report, omit it.',
600
+ `Recipe: ${recipe.title ?? recipe.id} — prioritise and explain the risks; flag which ones need human judgment.`,
601
+ ].join('\n');
602
+ }
603
+ /**
604
+ * The testable analysis core (no I/O beyond the injected provider). Runs the
605
+ * model on the deterministic ground truth, cross-checks its findings, and
606
+ * assembles the advisory report. NEVER writes — analysis mode is read-only.
607
+ * `provider === null` degrades to a deterministic grounding-only report (ok).
608
+ */
609
+ export async function executeDelegateAnalyze(input) {
610
+ const { recipe, task, facts, provider } = input;
611
+ // No local LLM → deterministic grounding only (NOT an error).
612
+ if (provider === null) {
613
+ const crossCheck = crossCheckFindings([], facts, {});
614
+ return {
615
+ status: 'no-provider',
616
+ recipeId: recipe.id,
617
+ report: buildDelegateAnalysisReport({
618
+ recipeId: recipe.id,
619
+ groundedOn: facts.groundedOn,
620
+ task,
621
+ provider: 'none',
622
+ facts,
623
+ crossCheck,
624
+ modelUnavailable: true,
625
+ generatedAt: input.generatedAt,
626
+ }),
627
+ };
628
+ }
629
+ const messages = [
630
+ { role: AiMessageRole.System, content: analysisSystemPrompt(recipe) },
631
+ {
632
+ role: AiMessageRole.User,
633
+ content: `Task: ${task}\n\nGround truth (do NOT contradict; cite only entities from it in "refs"):\n${facts.summary}`,
634
+ },
635
+ ];
636
+ const model = recipe.model ?? recipe.resolvedModel;
637
+ // Phase 4 — fan-out. Split the grounding entities into deterministic slices,
638
+ // run one focused pass per slice, then merge + dedup. Engine-owned control flow
639
+ // (the model never schedules anything). Takes precedence over the query loop.
640
+ if (recipe.fanOut === true && facts.entities.length >= 2) {
641
+ const g = Math.max(2, Math.min(6, recipe.maxFanOut ?? 3));
642
+ const slices = contiguousChunks(facts.entities, g);
643
+ const merged = [];
644
+ let anySuccess = false;
645
+ for (const slice of slices) {
646
+ const sliceMessages = [
647
+ { role: AiMessageRole.System, content: analysisSystemPrompt(recipe) },
648
+ {
649
+ role: AiMessageRole.User,
650
+ content: `Task: ${task}\n\nFocus your analysis ONLY on these items from the ground truth: ${slice.join(', ')}.\n\nGround truth (cite refs only from it):\n${facts.summary}`,
651
+ },
652
+ ];
653
+ const call = await callDelegateAnalysisWithRetry({
654
+ provider,
655
+ messages: sliceMessages,
656
+ ...(model ? { model } : {}),
657
+ timeoutMs: recipe.maxBudgetMs ?? DEFAULT_MAX_BUDGET_MS,
658
+ });
659
+ if (call.ok) {
660
+ anySuccess = true;
661
+ merged.push(...call.value.analysis.findings);
662
+ }
663
+ }
664
+ if (!anySuccess) {
665
+ return {
666
+ status: 'analyze-failed',
667
+ recipeId: recipe.id,
668
+ report: buildDelegateAnalysisReport({
669
+ recipeId: recipe.id,
670
+ groundedOn: facts.groundedOn,
671
+ task,
672
+ provider: input.providerLabel,
673
+ facts,
674
+ crossCheck: crossCheckFindings([], facts, {}),
675
+ modelUnavailable: true,
676
+ modelNote: 'every fan-out slice failed to produce a valid analysis',
677
+ fanOutSlices: slices.length,
678
+ generatedAt: input.generatedAt,
679
+ }),
680
+ };
681
+ }
682
+ const crossCheck = crossCheckFindings(dedupeFindingsByMessage(merged), facts, { strict: input.strict ?? false });
683
+ return {
684
+ status: 'analyzed',
685
+ recipeId: recipe.id,
686
+ report: buildDelegateAnalysisReport({
687
+ recipeId: recipe.id,
688
+ groundedOn: facts.groundedOn,
689
+ task,
690
+ provider: input.providerLabel,
691
+ facts,
692
+ crossCheck,
693
+ fanOutSlices: slices.length,
694
+ ...(recipe.escalateTo ? { escalateTo: recipe.escalateTo } : {}),
695
+ generatedAt: input.generatedAt,
696
+ }),
697
+ };
698
+ }
699
+ // Phase 3 — bounded read-only query loop. When the recipe opts in
700
+ // (allowedQueries + maxQueryRounds > 0) and a query executor is injected, let
701
+ // the model pull a few read-only facts before answering; entities it surfaces
702
+ // are merged into the ground truth so a finding citing them is grounded.
703
+ if ((recipe.allowedQueries?.length ?? 0) > 0 && (recipe.maxQueryRounds ?? 0) > 0 && input.queryExecutor) {
704
+ const loop = await runBoundedQueryLoop({
705
+ provider,
706
+ messages,
707
+ ...(model ? { model } : {}),
708
+ timeoutMs: recipe.maxBudgetMs ?? DEFAULT_MAX_BUDGET_MS,
709
+ allowedQueries: recipe.allowedQueries ?? [],
710
+ maxQueryRounds: recipe.maxQueryRounds ?? 0,
711
+ ...(recipe.maxBudgetMs ? { budgetMs: recipe.maxBudgetMs } : {}),
712
+ catalog: DELEGATE_QUERY_CATALOG,
713
+ executeQuery: input.queryExecutor,
714
+ finalInstruction: 'Now output ONLY your findings as a single JSON object matching the schema — no prose, no markdown fences. Cite refs only from the ground truth and the query results.',
715
+ finalResponseFormat: { type: 'json_schema', schema: DELEGATE_ANALYSIS_JSON_SCHEMA, schemaName: 'DelegateAnalysis' },
716
+ });
717
+ if (!loop.ok) {
718
+ return {
719
+ status: 'analyze-failed',
720
+ recipeId: recipe.id,
721
+ report: buildDelegateAnalysisReport({
722
+ recipeId: recipe.id,
723
+ groundedOn: facts.groundedOn,
724
+ task,
725
+ provider: input.providerLabel,
726
+ facts,
727
+ crossCheck: crossCheckFindings([], facts, {}),
728
+ modelUnavailable: true,
729
+ modelNote: `query loop failed: ${loop.error.message}`,
730
+ generatedAt: input.generatedAt,
731
+ }),
732
+ };
733
+ }
734
+ const parsed = parseDelegateAnalysis(loop.value.content);
735
+ if (!parsed.ok) {
736
+ return {
737
+ status: 'analyze-failed',
738
+ recipeId: recipe.id,
739
+ report: buildDelegateAnalysisReport({
740
+ recipeId: recipe.id,
741
+ groundedOn: facts.groundedOn,
742
+ task,
743
+ provider: loop.value.model || input.providerLabel,
744
+ facts,
745
+ crossCheck: crossCheckFindings([], facts, {}),
746
+ modelUnavailable: true,
747
+ modelNote: `model produced an unparseable analysis: ${parsed.error.message}`,
748
+ queriesRun: loop.value.roundsRun,
749
+ generatedAt: input.generatedAt,
750
+ }),
751
+ };
752
+ }
753
+ // Merge query-surfaced entities into the ground truth for the cross-check —
754
+ // a finding citing a fact the model legitimately pulled is grounded.
755
+ const enrichedFacts = { ...facts, entities: [...facts.entities, ...loop.value.gatheredEntities] };
756
+ const crossCheck = crossCheckFindings(parsed.value.findings, enrichedFacts, { strict: input.strict ?? false });
757
+ return {
758
+ status: 'analyzed',
759
+ recipeId: recipe.id,
760
+ report: buildDelegateAnalysisReport({
761
+ recipeId: recipe.id,
762
+ groundedOn: facts.groundedOn,
763
+ task,
764
+ provider: loop.value.model || input.providerLabel,
765
+ facts: enrichedFacts,
766
+ crossCheck,
767
+ ...(parsed.value.note ? { modelNote: parsed.value.note } : {}),
768
+ queriesRun: loop.value.roundsRun,
769
+ ...(recipe.escalateTo ? { escalateTo: recipe.escalateTo } : {}),
770
+ generatedAt: input.generatedAt,
771
+ }),
772
+ ...(loop.value.usage ? { usage: loop.value.usage } : {}),
773
+ };
774
+ }
775
+ const call = await callDelegateAnalysisWithRetry({
776
+ provider,
777
+ messages,
778
+ ...(model ? { model } : {}),
779
+ timeoutMs: recipe.maxBudgetMs ?? DEFAULT_MAX_BUDGET_MS,
780
+ reprompt: (bad, error) => [...messages, delegateAnalysisRepromptMessage(bad, error)],
781
+ });
782
+ if (!call.ok) {
783
+ // The model ran but failed — degrade to grounding-only, note the failure.
784
+ const crossCheck = crossCheckFindings([], facts, {});
785
+ return {
786
+ status: 'analyze-failed',
787
+ recipeId: recipe.id,
788
+ report: buildDelegateAnalysisReport({
789
+ recipeId: recipe.id,
790
+ groundedOn: facts.groundedOn,
791
+ task,
792
+ provider: input.providerLabel,
793
+ facts,
794
+ crossCheck,
795
+ modelUnavailable: true,
796
+ modelNote: `model failed to produce a valid analysis: ${call.error.message}`,
797
+ generatedAt: input.generatedAt,
798
+ }),
799
+ };
800
+ }
801
+ const analysis = call.value.analysis;
802
+ const crossCheck = crossCheckFindings(analysis.findings, facts, { strict: input.strict ?? false });
803
+ return {
804
+ status: 'analyzed',
805
+ recipeId: recipe.id,
806
+ report: buildDelegateAnalysisReport({
807
+ recipeId: recipe.id,
808
+ groundedOn: facts.groundedOn,
809
+ task,
810
+ provider: call.value.model || input.providerLabel,
811
+ facts,
812
+ crossCheck,
813
+ ...(analysis.note ? { modelNote: analysis.note } : {}),
814
+ ...(recipe.escalateTo ? { escalateTo: recipe.escalateTo } : {}),
815
+ generatedAt: input.generatedAt,
816
+ }),
817
+ ...(call.value.usage ? { usage: call.value.usage } : {}),
818
+ retried: call.value.retried,
819
+ };
820
+ }
821
+ /** The single corrected instruction to feed back: a grounded finding wins. */
822
+ export function correctedInstruction(report) {
823
+ const grounded = report.findings.find((f) => f.grounded);
824
+ if (grounded)
825
+ return grounded.message;
826
+ if (report.findings.length > 0)
827
+ return report.findings[0].message;
828
+ return report.modelNote ?? null;
829
+ }
830
+ /**
831
+ * Build a retry advisor from a `retry-analysis` recipe: it grounds on the failed
832
+ * attempt (`buildDelegateFailureFacts`), runs the analysis model, and returns the
833
+ * single corrected instruction. Read-only — it never writes; a null provider or
834
+ * an empty analysis yields `null` (no enrichment). The corrected instruction is
835
+ * cross-checked against the failure ground truth just like any analysis finding.
836
+ */
837
+ export function buildRetryAnalysisAdvisor(deps) {
838
+ return async (failure, attempt) => {
839
+ if (deps.provider === null)
840
+ return null;
841
+ const facts = buildDelegateFailureFacts(failure);
842
+ const result = await executeDelegateAnalyze({
843
+ task: `The delegate patch recipe "${deps.patchRecipeId}" failed on attempt ${attempt} (${failure.status}). Diagnose why and give ONE corrected mechanical instruction, citing the affected files/ops from the ground truth.`,
844
+ recipe: deps.retryRecipe,
845
+ facts,
846
+ provider: deps.provider,
847
+ providerLabel: deps.providerLabel,
848
+ generatedAt: new Date().toISOString(),
849
+ });
850
+ return correctedInstruction(result.report);
851
+ };
852
+ }
446
853
  // ─── CLI surface ─────────────────────────────────────────────────────────────
854
+ async function runDelegateAnalyze(args) {
855
+ const cwd = resolveCwd(args);
856
+ const wantJson = flagBool(args, 'json');
857
+ const strict = flagBool(args, 'strict-grounding');
858
+ const task = args.positional.slice(1).join(' ').trim();
859
+ if (!task) {
860
+ process.stderr.write('Usage: shrk delegate analyze "<task>" --recipe <id> [--provider auto] [--strict-grounding] [--json]\n');
861
+ return 2;
862
+ }
863
+ const resolved = await resolveRecipe(cwd, flagString(args, 'recipe'));
864
+ if (!resolved.ok) {
865
+ if (wantJson)
866
+ process.stdout.write(asJson({ ok: false, error: resolved.message }) + '\n');
867
+ else
868
+ process.stderr.write(resolved.message + '\n');
869
+ return 1;
870
+ }
871
+ const recipe = resolved.recipe;
872
+ if (recipe.mode !== 'analysis') {
873
+ const msg = `recipe "${recipe.id}" is a patch recipe — use \`shrk delegate run\`. \`analyze\` requires mode: "analysis".`;
874
+ if (wantJson)
875
+ process.stdout.write(asJson({ ok: false, error: msg }) + '\n');
876
+ else
877
+ process.stderr.write(msg + '\n');
878
+ return 1;
879
+ }
880
+ if (!recipe.groundedOn) {
881
+ const msg = `analysis recipe "${recipe.id}" has no groundedOn — nothing to ground the model against.`;
882
+ if (wantJson)
883
+ process.stdout.write(asJson({ ok: false, error: msg }) + '\n');
884
+ else
885
+ process.stderr.write(msg + '\n');
886
+ return 1;
887
+ }
888
+ const planPath = flagString(args, 'plan');
889
+ const inspection = await inspectSharkcraft({ cwd: resolved.projectRoot });
890
+ const facts = await runGroundingReport(recipe.groundedOn, task, inspection, { ...(planPath ? { planPath } : {}) });
891
+ const providerKind = flagString(args, 'provider') ?? recipe.provider ?? 'auto';
892
+ const { provider } = selectAiProvider(providerKind);
893
+ const result = await executeDelegateAnalyze({
894
+ task,
895
+ recipe,
896
+ facts,
897
+ provider,
898
+ strict,
899
+ queryExecutor: buildAnalysisQueryExecutor(inspection, task),
900
+ providerLabel: provider ? providerKind : 'none',
901
+ generatedAt: new Date().toISOString(),
902
+ });
903
+ // Phase 4 escalation: `--escalate` GENERATES (never applies) the target patch
904
+ // recipe's signed plan through the four fences, from the advisory task. The
905
+ // human reviews + applies — analysis never writes.
906
+ let escalationPlan;
907
+ if (flagBool(args, 'escalate') && result.report.escalation && provider) {
908
+ const patch = await resolveRecipe(cwd, result.report.escalation.recipe);
909
+ if (patch.ok && patch.recipe.mode !== 'analysis') {
910
+ escalationPlan = await executeDelegateRun({
911
+ task: result.report.escalation.task,
912
+ recipe: patch.recipe,
913
+ projectRoot: resolved.projectRoot,
914
+ provider,
915
+ apply: false, // generate-only — the human runs the write
916
+ });
917
+ }
918
+ }
919
+ // --save: persist the advisory report under `.sharkcraft/reports/` (writes-drafts,
920
+ // never source). Markdown for humans + JSON for tooling.
921
+ let savedPath;
922
+ if (flagBool(args, 'save')) {
923
+ const slug = task.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'analysis';
924
+ const dir = nodePath.join(resolved.projectRoot, '.sharkcraft', 'reports');
925
+ mkdirSync(dir, { recursive: true });
926
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
927
+ savedPath = nodePath.join(dir, `delegate-analysis-${slug}-${stamp}.md`);
928
+ writeFileSync(savedPath, result.report.markdown, 'utf8');
929
+ writeFileSync(savedPath.replace(/\.md$/, '.json'), asJson(result.report) + '\n', 'utf8');
930
+ }
931
+ const exit = result.status === 'analyze-failed' ? 1 : 0;
932
+ if (wantJson) {
933
+ process.stdout.write(asJson({ ok: exit === 0, ...result, ...(escalationPlan ? { escalationPlan } : {}), ...(savedPath ? { savedPath } : {}) }) + '\n');
934
+ return exit;
935
+ }
936
+ const rep = result.report;
937
+ process.stdout.write(header(`Delegate analysis: ${result.recipeId}`));
938
+ process.stdout.write(kv('status', result.status) + '\n');
939
+ process.stdout.write(kv('grounded on', rep.groundedOn) + '\n');
940
+ process.stdout.write(kv('provider', rep.provider) + '\n');
941
+ process.stdout.write(kv('findings', `${rep.findings.length} (grounded ${rep.groundedCount}, unverified ${rep.unverifiedCount})`) + '\n');
942
+ if (savedPath)
943
+ process.stdout.write(kv('saved', savedPath) + '\n');
944
+ process.stdout.write('\n' + rep.markdown + '\n');
945
+ if (escalationPlan) {
946
+ process.stdout.write(`\n── Escalation (generate-only, review before applying) ──\n`);
947
+ process.stdout.write(kv('patch recipe', escalationPlan.recipeId) + '\n');
948
+ process.stdout.write(kv('status', escalationPlan.status) + '\n');
949
+ if (escalationPlan.planPath)
950
+ process.stdout.write(kv('signed plan', escalationPlan.planPath) + '\n');
951
+ process.stdout.write(kv('message', escalationPlan.message) + '\n');
952
+ }
953
+ return exit;
954
+ }
447
955
  async function runDelegateRun(args) {
448
956
  const cwd = resolveCwd(args);
449
957
  const wantJson = flagBool(args, 'json');
@@ -462,12 +970,32 @@ async function runDelegateRun(args) {
462
970
  }
463
971
  const providerKind = flagString(args, 'provider') ?? resolved.recipe.provider ?? 'auto';
464
972
  const { provider } = selectAiProvider(providerKind);
973
+ // Opt-in assisted retry: enrich the deterministic retry feedback with an LLM
974
+ // diagnosis from a `retry-analysis` recipe (mode: analysis, groundedOn:
975
+ // delegate-failure). Best-effort — absent a recipe or provider, the loop runs
976
+ // exactly as before.
977
+ let retryAdvisor;
978
+ if (flagBool(args, 'assisted-retry') && provider) {
979
+ const cat = await loadResolvedCatalog(cwd);
980
+ const retryRecipe = cat.ok
981
+ ? cat.catalog.find((r) => r.mode === 'analysis' && r.groundedOn === 'delegate-failure' && r.delegatable)
982
+ : undefined;
983
+ if (retryRecipe) {
984
+ retryAdvisor = buildRetryAnalysisAdvisor({
985
+ retryRecipe,
986
+ provider,
987
+ providerLabel: providerKind,
988
+ patchRecipeId: resolved.recipe.id,
989
+ });
990
+ }
991
+ }
465
992
  const result = await executeDelegateRun({
466
993
  task,
467
994
  recipe: resolved.recipe,
468
995
  projectRoot: resolved.projectRoot,
469
996
  provider,
470
997
  apply: flagBool(args, 'apply'),
998
+ ...(retryAdvisor ? { retryAdvisor } : {}),
471
999
  });
472
1000
  if (wantJson) {
473
1001
  process.stdout.write(asJson({ ok: isOkStatus(result.status), ...result }) + '\n');
@@ -569,10 +1097,23 @@ async function runDelegateList(args) {
569
1097
  }
570
1098
  for (const r of catalog) {
571
1099
  const src = r.source === 'pack' ? ` [pack: ${r.packageName}]` : '';
572
- process.stdout.write(` ${r.delegatable ? '✓' : '✗'} ${r.id} — ${r.title ?? r.id}${src}\n`);
573
- process.stdout.write(` ops: ${r.allowedOps.join(', ')} | globs: ${r.guardrailGlobs.join(', ')} | verify: ${r.verificationIds.join(', ') || '(none)'}\n`);
574
- if (!r.delegatable) {
575
- process.stdout.write(` ⚠ NOT delegatable — ${r.unboundVerificationIds.length > 0 ? `unbound verificationIds: ${r.unboundVerificationIds.join(', ')}` : 'no verificationIds declared'}\n`);
1100
+ process.stdout.write(` ${r.delegatable ? '✓' : '✗'} ${r.id} — ${r.title ?? r.id} [${r.mode}]${src}\n`);
1101
+ if (r.mode === 'analysis') {
1102
+ const extras = [
1103
+ (r.allowedQueries?.length ?? 0) > 0 ? `queries: ${r.allowedQueries.join('/')}` : '',
1104
+ r.fanOut ? 'fan-out' : '',
1105
+ r.escalateTo ? `→ ${r.escalateTo}` : '',
1106
+ ].filter((x) => x.length > 0);
1107
+ process.stdout.write(` grounded on: ${r.groundedOn ?? '(unset)'}${extras.length ? ` | ${extras.join(' | ')}` : ''} (read-only)\n`);
1108
+ if (!r.delegatable) {
1109
+ process.stdout.write(` ⚠ NOT usable — groundedOn "${r.groundedOn ?? '(unset)'}" is not a known grounding report\n`);
1110
+ }
1111
+ }
1112
+ else {
1113
+ process.stdout.write(` ops: ${r.allowedOps.join(', ')} | globs: ${r.guardrailGlobs.join(', ')} | verify: ${r.verificationIds.join(', ') || '(none)'}\n`);
1114
+ if (!r.delegatable) {
1115
+ process.stdout.write(` ⚠ NOT delegatable — ${r.unboundVerificationIds.length > 0 ? `unbound verificationIds: ${r.unboundVerificationIds.join(', ')}` : 'no verificationIds declared'}\n`);
1116
+ }
576
1117
  }
577
1118
  }
578
1119
  process.stdout.write(`\nRun \`shrk delegate explain <id>\` for the full fence.\n`);
@@ -611,6 +1152,22 @@ async function runDelegateExplain(args) {
611
1152
  process.stdout.write(header(`Delegate recipe: ${r.id}`));
612
1153
  process.stdout.write(kv('title', r.title ?? r.id) + '\n');
613
1154
  process.stdout.write(kv('source', r.source === 'pack' ? `pack: ${r.packageName}` : 'config') + '\n');
1155
+ process.stdout.write(kv('mode', r.mode) + '\n');
1156
+ if (r.mode === 'analysis') {
1157
+ process.stdout.write(kv('grounded on', `${r.groundedOn ?? '(unset)'}${r.groundingBound ? '' : ' — NOT a known grounding report'}`) + '\n');
1158
+ process.stdout.write(kv('usable', r.delegatable ? 'yes (read-only, grounded)' : 'no — set a known groundedOn first') + '\n');
1159
+ process.stdout.write(kv('provider', `${r.resolvedProvider}${r.resolvedModel ? ` (${r.resolvedModel})` : ''}`) + '\n');
1160
+ if ((r.allowedQueries?.length ?? 0) > 0) {
1161
+ process.stdout.write(kv('query loop', `${r.allowedQueries.join(', ')} (max ${r.maxQueryRounds ?? 0} round(s))`) + '\n');
1162
+ }
1163
+ if (r.fanOut)
1164
+ process.stdout.write(kv('fan-out', `yes (max ${r.maxFanOut ?? 3} slices)`) + '\n');
1165
+ if (r.escalateTo)
1166
+ process.stdout.write(kv('escalates to', r.escalateTo) + '\n');
1167
+ process.stdout.write('\nAnalysis recipes are READ-ONLY: a local model adds judgment on top of the grounded\n' +
1168
+ 'deterministic report; findings citing entities absent from it are flagged unverified.\nNo write is ever performed.\n');
1169
+ return r.delegatable ? 0 : 1;
1170
+ }
614
1171
  process.stdout.write(kv('delegatable', r.delegatable ? 'yes' : 'no — fix the verification binding first') + '\n');
615
1172
  process.stdout.write(kv('allowed ops', r.allowedOps.join(', ')) + '\n');
616
1173
  process.stdout.write(kv('guardrail globs', r.guardrailGlobs.join(', ')) + '\n');
@@ -636,22 +1193,25 @@ function exitFor(s) {
636
1193
  export const delegateCommand = {
637
1194
  name: 'delegate',
638
1195
  description: 'Hand a mechanical, deterministically-verifiable edit to a local-LLM worker. The engine verifies the result (config verificationCommands) and auto-reverts on failure — a bad generation costs a retry, never a wrong write. Local-only.',
639
- usage: 'shrk delegate run "<task>" --recipe <id> [--apply] [--provider auto|ollama|llamacpp] [--json]\n' +
1196
+ usage: 'shrk delegate run "<task>" --recipe <id> [--apply] [--assisted-retry] [--provider auto|ollama|llamacpp] [--json]\n' +
1197
+ 'shrk delegate analyze "<task>" --recipe <id> [--strict-grounding] [--plan <p>] [--escalate] [--save] [--provider ...] [--json] — read-only grounded analysis (no write)\n' +
640
1198
  'shrk delegate brief "<task>" --recipe <id> [--json]\n' +
641
1199
  'shrk delegate list [--json] — recipes + whether each is safely delegatable\n' +
642
1200
  'shrk delegate explain <id> [--json] — the full fence for one recipe',
643
- booleanFlags: new Set(['apply', 'json']),
1201
+ booleanFlags: new Set(['apply', 'json', 'strict-grounding', 'assisted-retry', 'escalate', 'save']),
644
1202
  async run(args) {
645
1203
  const sub = args.positional[0];
646
1204
  if (sub === 'run')
647
1205
  return runDelegateRun(args);
1206
+ if (sub === 'analyze')
1207
+ return runDelegateAnalyze(args);
648
1208
  if (sub === 'brief')
649
1209
  return runDelegateBrief(args);
650
1210
  if (sub === 'list')
651
1211
  return runDelegateList(args);
652
1212
  if (sub === 'explain')
653
1213
  return runDelegateExplain(args);
654
- process.stderr.write('Usage: shrk delegate run|brief|list|explain ...\n');
1214
+ process.stderr.write('Usage: shrk delegate run|analyze|brief|list|explain ...\n');
655
1215
  return 2;
656
1216
  },
657
1217
  };