@shrkcrft/cli 0.1.0-alpha.17 → 0.1.0-alpha.18
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/dist/command-registry.d.ts +10 -0
- package/dist/command-registry.d.ts.map +1 -1
- package/dist/command-registry.js +7 -1
- package/dist/commands/command-catalog.d.ts.map +1 -1
- package/dist/commands/command-catalog.js +12 -0
- package/dist/commands/compress.command.d.ts +0 -7
- package/dist/commands/compress.command.d.ts.map +1 -1
- package/dist/commands/compress.command.js +7 -0
- package/dist/commands/delegate.command.d.ts +65 -0
- package/dist/commands/delegate.command.d.ts.map +1 -0
- package/dist/commands/delegate.command.js +657 -0
- package/dist/commands/deps-audit.command.js +1 -1
- package/dist/commands/doctor.command.d.ts.map +1 -1
- package/dist/commands/doctor.command.js +24 -3
- package/dist/commands/graph-code-subverbs.d.ts +22 -0
- package/dist/commands/graph-code-subverbs.d.ts.map +1 -1
- package/dist/commands/graph-code-subverbs.js +450 -54
- package/dist/commands/graph.command.d.ts.map +1 -1
- package/dist/commands/graph.command.js +9 -3
- package/dist/commands/move-plan.command.js +1 -1
- package/dist/commands/smart-context.command.d.ts +26 -17
- package/dist/commands/smart-context.command.d.ts.map +1 -1
- package/dist/commands/smart-context.command.js +113 -16
- package/dist/commands/tests.command.d.ts.map +1 -1
- package/dist/commands/tests.command.js +13 -2
- package/dist/dashboard/code-intelligence-data.d.ts.map +1 -1
- package/dist/dashboard/code-intelligence-data.js +25 -3
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +3 -1
- package/dist/output/ccr-store-config.d.ts +1 -1
- package/dist/output/ccr-store-config.d.ts.map +1 -1
- package/dist/output/ccr-store-config.js +21 -2
- package/package.json +33 -33
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shrk delegate` — hand a mechanical, deterministically-verifiable edit to a
|
|
3
|
+
* LOCAL-LLM worker, gated end-to-end by the deterministic engine.
|
|
4
|
+
*
|
|
5
|
+
* Flow (the worker is the ONLY stochastic step):
|
|
6
|
+
* provider.send → parseDelegateEdit → checkGuardrailGlobs → packageDelegatePlan
|
|
7
|
+
* → signPlan → savePlanToFile → (apply) verify → evaluateSavedPlanInPlace
|
|
8
|
+
* → writeSyntheticPlan → runValidationLoop → auto-revert on verify failure.
|
|
9
|
+
*
|
|
10
|
+
* The model never writes: its output becomes a SIGNED synthetic plan that flows
|
|
11
|
+
* through the same apply primitives `shrk apply` uses. A failed verification
|
|
12
|
+
* auto-reverts the edit, so a bad generation costs a retry, never a wrong write.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import * as nodePath from 'node:path';
|
|
16
|
+
import { containsTraversal, safeResolveTargetPath } from '@shrkcrft/core';
|
|
17
|
+
import { AiMessageRole, callDelegateWithRetry, delegateRepromptMessage, selectAiProvider, } from '@shrkcrft/ai';
|
|
18
|
+
import { loadProjectConfig } from '@shrkcrft/config';
|
|
19
|
+
import { compressCode, compressDiff } from '@shrkcrft/compress';
|
|
20
|
+
import { listIndexableFiles } from '@shrkcrft/embeddings';
|
|
21
|
+
import { checkGuardrailGlobs, resolveDelegateCatalogForProject, unifiedDiff, } from '@shrkcrft/inspector';
|
|
22
|
+
import { evaluateSavedPlanInPlace, packageDelegatePlan, savePlanToFile, signPlan, verifyPlan, writeSyntheticPlan, } from '@shrkcrft/generator';
|
|
23
|
+
import { flagBool, flagString, resolveCwd, } from "../command-registry.js";
|
|
24
|
+
import { asJson, header, kv } from "../output/format-output.js";
|
|
25
|
+
import { runValidationLoop } from "../validation/run-validation-loop.js";
|
|
26
|
+
const DEFAULT_MAX_BUDGET_MS = 60_000;
|
|
27
|
+
/** A concrete sample op for a kind — a few-shot anchor for weak local models. */
|
|
28
|
+
function opExample(kind) {
|
|
29
|
+
switch (kind) {
|
|
30
|
+
case 'export':
|
|
31
|
+
return { targetPath: 'src/index.ts', operation: { kind: 'export', from: './health' } };
|
|
32
|
+
case 'ensure-import':
|
|
33
|
+
return { targetPath: 'src/service.ts', operation: { kind: 'ensure-import', from: './logger', symbols: ['log'] } };
|
|
34
|
+
case 'replace':
|
|
35
|
+
return { targetPath: 'src/config.ts', operation: { kind: 'replace', find: 'timeoutMs = 30000', replaceWith: 'timeoutMs = 60000', expectMatches: 1 } };
|
|
36
|
+
case 'create':
|
|
37
|
+
return { targetPath: 'src/new-file.ts', operation: { kind: 'create', content: 'export const x = 1;\n' } };
|
|
38
|
+
case 'insert-array-entry':
|
|
39
|
+
return { targetPath: 'src/registry.ts', operation: { kind: 'insert-array-entry', arrayName: 'ALL', entryValue: 'newEntry' } };
|
|
40
|
+
case 'insert-enum-entry':
|
|
41
|
+
return { targetPath: 'src/kinds.ts', operation: { kind: 'insert-enum-entry', enumName: 'Kind', entryName: 'NEW', entryValue: 'new' } };
|
|
42
|
+
default:
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const RECIPE_CONTEXT_FILE_CAP = 8;
|
|
47
|
+
/**
|
|
48
|
+
* The in-scope files (current contents, compressed to signatures via the
|
|
49
|
+
* code-outline pass) handed to the LOCAL worker so it can pick the right
|
|
50
|
+
* targetPath, check idempotency, and find exact text to replace — instead of
|
|
51
|
+
* guessing. This goes in the WORKER's prompt, read locally, so it costs the
|
|
52
|
+
* orchestrator (Claude) NOTHING. Bounded to keep a small local model's context
|
|
53
|
+
* focused. Returns '' when there's nothing in scope / on any error.
|
|
54
|
+
*/
|
|
55
|
+
export function gatherRecipeContext(projectRoot, recipe) {
|
|
56
|
+
let candidates;
|
|
57
|
+
try {
|
|
58
|
+
const all = listIndexableFiles(projectRoot, 3000);
|
|
59
|
+
candidates = checkGuardrailGlobs(all, recipe.guardrailGlobs).allowed;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return '';
|
|
63
|
+
}
|
|
64
|
+
if (candidates.length === 0)
|
|
65
|
+
return '';
|
|
66
|
+
const blocks = [];
|
|
67
|
+
for (const rel of candidates.slice(0, RECIPE_CONTEXT_FILE_CAP)) {
|
|
68
|
+
try {
|
|
69
|
+
const outline = compressCode(readFileSync(nodePath.join(projectRoot, rel), 'utf8')).compressed;
|
|
70
|
+
blocks.push(`## ${rel}\n${outline}`);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
/* skip unreadable */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (blocks.length === 0)
|
|
77
|
+
return '';
|
|
78
|
+
const more = candidates.length > blocks.length ? `\n\n(showing ${blocks.length} of ${candidates.length} in-scope files)` : '';
|
|
79
|
+
return `Files in scope you may edit (current contents):\n\n${blocks.join('\n\n')}${more}`;
|
|
80
|
+
}
|
|
81
|
+
function systemPrompt(recipe) {
|
|
82
|
+
const example = recipe.allowedOps.map(opExample).find((e) => e !== null);
|
|
83
|
+
const lines = [
|
|
84
|
+
'You are a deterministic mechanical code-edit worker.',
|
|
85
|
+
'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(', ')}.`,
|
|
88
|
+
'Make the SMALLEST mechanical edit that satisfies the task. Never invent files, never change unrelated code, never reformat.',
|
|
89
|
+
'Each op has a "targetPath" (relative to project root) and an "operation" with a "kind" and the fields that kind needs.',
|
|
90
|
+
];
|
|
91
|
+
if (example) {
|
|
92
|
+
// A concrete few-shot anchor — weak local models reliably copy the SHAPE
|
|
93
|
+
// from an example even when they ignore a bare schema.
|
|
94
|
+
lines.push(`Example of a valid reply (copy the shape, not the values): ${JSON.stringify({ ops: [example] })}`);
|
|
95
|
+
}
|
|
96
|
+
return lines.join('\n');
|
|
97
|
+
}
|
|
98
|
+
/** Statuses worth re-prompting the worker for (the model can plausibly fix). */
|
|
99
|
+
const RETRYABLE_STATUSES = new Set([
|
|
100
|
+
'generate-failed',
|
|
101
|
+
'guardrail-refused',
|
|
102
|
+
'package-error',
|
|
103
|
+
'conflicts',
|
|
104
|
+
'verify-failed',
|
|
105
|
+
]);
|
|
106
|
+
/**
|
|
107
|
+
* The testable orchestration core: a bounded GENERATE→VERIFY retry loop. On a
|
|
108
|
+
* retryable failure (parse / guardrail / bad-op / conflict / verification) the
|
|
109
|
+
* worker is re-prompted with the failure injected, up to `recipe.maxAttempts`,
|
|
110
|
+
* then the run escalates. A provider / signing / environment failure is NOT
|
|
111
|
+
* retried. Takes an already-resolved recipe + provider so tests inject a fake.
|
|
112
|
+
*/
|
|
113
|
+
export async function executeDelegateRun(input) {
|
|
114
|
+
// No local LLM → deterministic no-op (NOT an error).
|
|
115
|
+
if (input.provider === null) {
|
|
116
|
+
return {
|
|
117
|
+
status: 'no-provider',
|
|
118
|
+
recipeId: input.recipe.id,
|
|
119
|
+
message: 'No local LLM reachable — delegate is a no-op. Start Ollama / set LLAMACPP_MODEL_PATH to enable.',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const maxAttempts = Math.max(1, Math.min(5, input.recipe.maxAttempts ?? 2));
|
|
123
|
+
// In-scope file context for the LOCAL worker (free to the orchestrator).
|
|
124
|
+
const fileContext = gatherRecipeContext(input.projectRoot, input.recipe);
|
|
125
|
+
const baseMessages = [
|
|
126
|
+
{ role: AiMessageRole.System, content: systemPrompt(input.recipe) },
|
|
127
|
+
{ role: AiMessageRole.User, content: fileContext ? `Task: ${input.task}\n\n${fileContext}` : `Task: ${input.task}` },
|
|
128
|
+
];
|
|
129
|
+
const feedback = [];
|
|
130
|
+
let last = {
|
|
131
|
+
status: 'generate-failed',
|
|
132
|
+
recipeId: input.recipe.id,
|
|
133
|
+
message: 'no attempt ran',
|
|
134
|
+
};
|
|
135
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
136
|
+
last = { ...(await runOneDelegateAttempt(input, [...baseMessages, ...feedback])), attempts: attempt };
|
|
137
|
+
if (!RETRYABLE_STATUSES.has(last.status) || attempt === maxAttempts)
|
|
138
|
+
return last;
|
|
139
|
+
feedback.push(buildRetryFeedback(last, input.recipe));
|
|
140
|
+
}
|
|
141
|
+
return last;
|
|
142
|
+
}
|
|
143
|
+
/** Build the User message that tells the worker why the previous attempt failed. */
|
|
144
|
+
function buildRetryFeedback(r, recipe) {
|
|
145
|
+
let detail;
|
|
146
|
+
if (r.conflicts && r.conflicts.length > 0) {
|
|
147
|
+
detail = `Your previous edit was REFUSED with conflicts: ${r.conflicts.join('; ')}. Fix the target paths / anchors and try again.`;
|
|
148
|
+
}
|
|
149
|
+
else if (r.status === 'verify-failed') {
|
|
150
|
+
detail = `Your previous edit FAILED verification (${r.verification?.commandsFailed.join(', ') || 'see logs'}) and was reverted. Produce a CORRECT edit.`;
|
|
151
|
+
}
|
|
152
|
+
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(', ')}.`;
|
|
154
|
+
}
|
|
155
|
+
else if (r.status === 'package-error') {
|
|
156
|
+
detail = `${r.message}. You may ONLY use op kinds: ${recipe.allowedOps.join(', ')}.`;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
detail = `Your previous reply was unusable: ${r.message}.`;
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
role: AiMessageRole.User,
|
|
163
|
+
content: `${detail}\nReturn a corrected single JSON object matching the schema — no prose.`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/** One generate→guardrail→package→sign→apply→verify pass. */
|
|
167
|
+
async function runOneDelegateAttempt(input, messages) {
|
|
168
|
+
const { recipe, projectRoot } = input;
|
|
169
|
+
const provider = input.provider; // non-null: the wrapper handled no-provider
|
|
170
|
+
// Generate (the only stochastic step). The plan secret is NEVER put in the
|
|
171
|
+
// messages — only the task + recipe constraints are.
|
|
172
|
+
const call = await callDelegateWithRetry({
|
|
173
|
+
provider,
|
|
174
|
+
messages,
|
|
175
|
+
...(recipe.model ? { model: recipe.model } : {}),
|
|
176
|
+
timeoutMs: recipe.maxBudgetMs ?? DEFAULT_MAX_BUDGET_MS,
|
|
177
|
+
reprompt: (bad, error) => [...messages, delegateRepromptMessage(bad, error)],
|
|
178
|
+
});
|
|
179
|
+
if (!call.ok) {
|
|
180
|
+
return {
|
|
181
|
+
status: 'generate-failed',
|
|
182
|
+
recipeId: recipe.id,
|
|
183
|
+
message: `worker failed to produce a valid edit: ${call.error.message}`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const edit = call.value.edit;
|
|
187
|
+
// 3. Guardrail globs — refuse any target outside the recipe's blast radius.
|
|
188
|
+
// The check MUST run on the NORMALIZED path that will actually be written
|
|
189
|
+
// (a `..` traversal whose `**` the glob swallows would otherwise pass the
|
|
190
|
+
// fence yet normalize to a file OUTSIDE the fenced dir but still in-root).
|
|
191
|
+
// So: reject any `..` segment, resolve through the engine's path floor,
|
|
192
|
+
// and glob-check the resulting relative path — the same string the write
|
|
193
|
+
// uses via `safeResolveTargetPath` in `evaluateSavedPlanInPlace`.
|
|
194
|
+
const normalizedTargets = [];
|
|
195
|
+
for (const op of edit.ops) {
|
|
196
|
+
if (containsTraversal(op.targetPath)) {
|
|
197
|
+
return {
|
|
198
|
+
status: 'guardrail-refused',
|
|
199
|
+
recipeId: recipe.id,
|
|
200
|
+
message: `worker target "${op.targetPath}" contains a \`..\` traversal segment`,
|
|
201
|
+
refused: [op.targetPath],
|
|
202
|
+
retried: call.value.retried,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
normalizedTargets.push(safeResolveTargetPath(op.targetPath, projectRoot).relativePath);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return {
|
|
210
|
+
status: 'guardrail-refused',
|
|
211
|
+
recipeId: recipe.id,
|
|
212
|
+
message: `worker target "${op.targetPath}" escapes the project root`,
|
|
213
|
+
refused: [op.targetPath],
|
|
214
|
+
retried: call.value.retried,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const guard = checkGuardrailGlobs(normalizedTargets, recipe.guardrailGlobs);
|
|
219
|
+
if (!guard.ok) {
|
|
220
|
+
return {
|
|
221
|
+
status: 'guardrail-refused',
|
|
222
|
+
recipeId: recipe.id,
|
|
223
|
+
message: `worker targeted ${guard.refused.length} file(s) outside the recipe's guardrail globs`,
|
|
224
|
+
refused: guard.refused,
|
|
225
|
+
retried: call.value.retried,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
// 4. Package into a synthetic plan (drops disallowed ops, evaluates conflicts).
|
|
229
|
+
const packaged = packageDelegatePlan({
|
|
230
|
+
ops: edit.ops,
|
|
231
|
+
allowedOps: recipe.allowedOps,
|
|
232
|
+
recipeId: recipe.id,
|
|
233
|
+
projectRoot,
|
|
234
|
+
});
|
|
235
|
+
if (!packaged.ok) {
|
|
236
|
+
return {
|
|
237
|
+
status: 'package-error',
|
|
238
|
+
recipeId: recipe.id,
|
|
239
|
+
message: packaged.error.message,
|
|
240
|
+
retried: call.value.retried,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
if (!packaged.value.ready || !packaged.value.plan) {
|
|
244
|
+
const conflicts = packaged.value.generation.changes
|
|
245
|
+
.filter((c) => String(c.type) === 'conflict')
|
|
246
|
+
.map((c) => `${c.relativePath}: ${c.reason}`);
|
|
247
|
+
return {
|
|
248
|
+
status: 'conflicts',
|
|
249
|
+
recipeId: recipe.id,
|
|
250
|
+
message: `edit evaluated to ${conflicts.length} conflict(s) — refused before any write`,
|
|
251
|
+
conflicts,
|
|
252
|
+
droppedOps: packaged.value.droppedOps,
|
|
253
|
+
retried: call.value.retried,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
// 5. Sign + save the plan.
|
|
257
|
+
const signed = signPlan(packaged.value.plan, input.planSecret ? { secret: input.planSecret } : {});
|
|
258
|
+
if (!signed.ok) {
|
|
259
|
+
return {
|
|
260
|
+
status: 'sign-failed',
|
|
261
|
+
recipeId: recipe.id,
|
|
262
|
+
message: signed.error.message,
|
|
263
|
+
retried: call.value.retried,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
const planPath = input.planPath ?? nodePath.join(projectRoot, '.sharkcraft', 'delegate', `${recipe.id}.plan.json`);
|
|
267
|
+
const saved = savePlanToFile(signed.value, planPath);
|
|
268
|
+
if (!saved.ok) {
|
|
269
|
+
return { status: 'apply-failed', recipeId: recipe.id, message: saved.error.message, planPath };
|
|
270
|
+
}
|
|
271
|
+
const baseResult = {
|
|
272
|
+
status: 'generated',
|
|
273
|
+
recipeId: recipe.id,
|
|
274
|
+
message: input.apply ? '' : `signed plan written to ${planPath} (not applied; review the diff, then \`shrk apply ${planPath} --verify-signature\` or re-run with --apply)`,
|
|
275
|
+
planPath,
|
|
276
|
+
ops: edit.ops.length,
|
|
277
|
+
droppedOps: packaged.value.droppedOps,
|
|
278
|
+
...(call.value.usage ? { usage: call.value.usage } : {}),
|
|
279
|
+
retried: call.value.retried,
|
|
280
|
+
};
|
|
281
|
+
if (!input.apply) {
|
|
282
|
+
// Preview: show exactly what the worker WOULD write, so the agent can review
|
|
283
|
+
// before landing it (the plan is signed + saved but unapplied).
|
|
284
|
+
return { ...baseResult, ...(buildPreviewDiff(packaged.value.generation.changes, input.task) ?? {}) };
|
|
285
|
+
}
|
|
286
|
+
// A recipe with no verification has no deterministic gate — refuse to apply an
|
|
287
|
+
// unverified edit (runValidationLoop reports passed:true when no command runs,
|
|
288
|
+
// so this must be caught here). The plan is already signed + saved on disk.
|
|
289
|
+
if (recipe.verificationIds.length === 0) {
|
|
290
|
+
return {
|
|
291
|
+
...baseResult,
|
|
292
|
+
status: 'no-verification',
|
|
293
|
+
message: `recipe "${recipe.id}" declares no verificationIds — refusing to apply an unverified edit (signed plan at ${planPath})`,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
// 6. Apply through the same primitives `shrk apply` uses.
|
|
297
|
+
const verify = verifyPlan(signed.value, input.planSecret ? { secret: input.planSecret } : {});
|
|
298
|
+
if (!verify.ok) {
|
|
299
|
+
return { ...baseResult, status: 'apply-failed', message: `signature verification failed: ${verify.message}` };
|
|
300
|
+
}
|
|
301
|
+
const livePlan = evaluateSavedPlanInPlace(signed.value, projectRoot);
|
|
302
|
+
if (livePlan.hasConflicts) {
|
|
303
|
+
const conflicts = livePlan.changes.filter((c) => String(c.type) === 'conflict').map((c) => `${c.relativePath}: ${c.reason}`);
|
|
304
|
+
return { ...baseResult, status: 'conflicts', message: 'plan diverged at apply time', conflicts };
|
|
305
|
+
}
|
|
306
|
+
// Snapshot originals so a verify failure (or a partial-write failure) can be
|
|
307
|
+
// auto-reverted.
|
|
308
|
+
const snapshots = snapshotChanges(livePlan.changes);
|
|
309
|
+
const write = writeSyntheticPlan(livePlan);
|
|
310
|
+
if (!write.ok) {
|
|
311
|
+
// A mid-write failure can leave earlier files written — revert them.
|
|
312
|
+
revertSnapshots(snapshots);
|
|
313
|
+
return { ...baseResult, status: 'apply-failed', message: write.error.message, reverted: true };
|
|
314
|
+
}
|
|
315
|
+
const written = write.value.written.map((c) => c.relativePath);
|
|
316
|
+
// Compact result hand-back: a compressed unified diff of exactly what changed,
|
|
317
|
+
// so the orchestrator confirms the edit without re-reading the file.
|
|
318
|
+
const diffField = buildCompressedDiff(snapshots, write.value.written, input.task);
|
|
319
|
+
// 7. Deterministic verification gate.
|
|
320
|
+
const validation = await runValidationLoop({
|
|
321
|
+
cwd: projectRoot,
|
|
322
|
+
verificationIds: recipe.verificationIds,
|
|
323
|
+
allVerifications: false,
|
|
324
|
+
allowPackCommands: false,
|
|
325
|
+
reportDir: input.reportDir ?? nodePath.join(projectRoot, '.sharkcraft', 'delegate', 'reports'),
|
|
326
|
+
});
|
|
327
|
+
if (!validation.passed) {
|
|
328
|
+
revertSnapshots(snapshots);
|
|
329
|
+
return {
|
|
330
|
+
...baseResult,
|
|
331
|
+
status: 'verify-failed',
|
|
332
|
+
message: `edit verification FAILED (${validation.commandsFailed.join(', ') || 'boundary violations'}) — auto-reverted`,
|
|
333
|
+
written,
|
|
334
|
+
reverted: true,
|
|
335
|
+
verification: { passed: false, commandsFailed: validation.commandsFailed },
|
|
336
|
+
...(diffField ?? {}),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
...baseResult,
|
|
341
|
+
status: 'applied',
|
|
342
|
+
message: `applied + verified (${written.length} file(s))`,
|
|
343
|
+
written,
|
|
344
|
+
verification: { passed: true, commandsFailed: [] },
|
|
345
|
+
...(diffField ?? {}),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
/** A compressed unified diff of the written changes (before = snapshot, after = contents). */
|
|
349
|
+
function compressedDiffOf(pairs, task) {
|
|
350
|
+
if (pairs.length === 0)
|
|
351
|
+
return null;
|
|
352
|
+
const bodies = pairs.map((p) => unifiedDiff(p.before, p.after, { relativePath: p.relativePath, maxLines: 60 }).body);
|
|
353
|
+
const compressed = compressDiff(bodies.join('\n'), { query: task });
|
|
354
|
+
return { diff: compressed.compressed, ...(compressed.ccrKey ? { diffCcrKey: compressed.ccrKey } : {}) };
|
|
355
|
+
}
|
|
356
|
+
/** Diff of the APPLIED edit: before = snapshot, after = written contents. */
|
|
357
|
+
function buildCompressedDiff(snapshots, written, task) {
|
|
358
|
+
const before = new Map(snapshots.map((s) => [s.absolutePath, s.original ?? '']));
|
|
359
|
+
return compressedDiffOf(written.map((c) => ({ relativePath: c.relativePath, before: before.get(c.absolutePath) ?? '', after: c.contents })), task);
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* PREVIEW diff for a `delegate run` without `--apply`: before = the file on disk
|
|
363
|
+
* now, after = the proposed contents from the evaluated plan. Lets the agent
|
|
364
|
+
* review exactly what the worker would write before deciding to land it.
|
|
365
|
+
*/
|
|
366
|
+
function buildPreviewDiff(changes, task) {
|
|
367
|
+
const pairs = changes
|
|
368
|
+
.filter((c) => String(c.type) !== 'skip' && String(c.type) !== 'conflict')
|
|
369
|
+
.map((c) => ({
|
|
370
|
+
relativePath: c.relativePath,
|
|
371
|
+
before: existsSync(c.absolutePath) ? readFileSync(c.absolutePath, 'utf8') : '',
|
|
372
|
+
after: c.contents,
|
|
373
|
+
}));
|
|
374
|
+
return compressedDiffOf(pairs, task);
|
|
375
|
+
}
|
|
376
|
+
function snapshotChanges(changes) {
|
|
377
|
+
const out = [];
|
|
378
|
+
const seen = new Set();
|
|
379
|
+
for (const c of changes) {
|
|
380
|
+
if (c.type === 'skip' || c.type === 'conflict')
|
|
381
|
+
continue;
|
|
382
|
+
if (seen.has(c.absolutePath))
|
|
383
|
+
continue;
|
|
384
|
+
seen.add(c.absolutePath);
|
|
385
|
+
out.push({
|
|
386
|
+
absolutePath: c.absolutePath,
|
|
387
|
+
original: existsSync(c.absolutePath) ? readFileSync(c.absolutePath, 'utf8') : null,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
return out;
|
|
391
|
+
}
|
|
392
|
+
function revertSnapshots(snapshots) {
|
|
393
|
+
for (const s of snapshots) {
|
|
394
|
+
try {
|
|
395
|
+
if (s.original === null) {
|
|
396
|
+
if (existsSync(s.absolutePath))
|
|
397
|
+
rmSync(s.absolutePath);
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
writeFileSync(s.absolutePath, s.original, 'utf8');
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
/* best-effort revert; report still surfaces verify-failed */
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// ─── recipe resolution ───────────────────────────────────────────────────────
|
|
409
|
+
/**
|
|
410
|
+
* Load the resolved delegate catalog: config recipes + pack-contributed recipes
|
|
411
|
+
* (best-effort) + `recipeOverrides`. Pack discovery failures degrade to
|
|
412
|
+
* config-only — a missing/odd node_modules never blocks a configured recipe.
|
|
413
|
+
*/
|
|
414
|
+
async function loadResolvedCatalog(cwd) {
|
|
415
|
+
const loaded = await loadProjectConfig(cwd);
|
|
416
|
+
if (!loaded.ok)
|
|
417
|
+
return { ok: false, message: `could not load config: ${loaded.error.message}` };
|
|
418
|
+
const catalog = await resolveDelegateCatalogForProject(loaded.value.config, loaded.value.projectRoot);
|
|
419
|
+
return { ok: true, config: loaded.value.config, projectRoot: loaded.value.projectRoot, catalog };
|
|
420
|
+
}
|
|
421
|
+
async function resolveRecipe(cwd, recipeId) {
|
|
422
|
+
const c = await loadResolvedCatalog(cwd);
|
|
423
|
+
if (!c.ok)
|
|
424
|
+
return { ok: false, message: c.message };
|
|
425
|
+
const delegation = c.config.delegation;
|
|
426
|
+
if (!delegation || delegation.enabled === false) {
|
|
427
|
+
return { ok: false, message: 'delegation is not enabled in sharkcraft.config.ts' };
|
|
428
|
+
}
|
|
429
|
+
if (c.catalog.length === 0)
|
|
430
|
+
return { ok: false, message: 'no delegate recipes are configured' };
|
|
431
|
+
if (!recipeId) {
|
|
432
|
+
return { ok: false, message: `--recipe <id> is required. Available: ${c.catalog.map((r) => r.id).join(', ')}` };
|
|
433
|
+
}
|
|
434
|
+
const found = c.catalog.find((r) => r.id === recipeId);
|
|
435
|
+
if (!found) {
|
|
436
|
+
return { ok: false, message: `unknown recipe "${recipeId}". Available: ${c.catalog.map((r) => r.id).join(', ')}` };
|
|
437
|
+
}
|
|
438
|
+
// Fold the resolved provider/model onto the recipe for executeDelegateRun.
|
|
439
|
+
const recipe = {
|
|
440
|
+
...found,
|
|
441
|
+
provider: found.resolvedProvider,
|
|
442
|
+
...(found.resolvedModel ? { model: found.resolvedModel } : {}),
|
|
443
|
+
};
|
|
444
|
+
return { ok: true, recipe, projectRoot: c.projectRoot };
|
|
445
|
+
}
|
|
446
|
+
// ─── CLI surface ─────────────────────────────────────────────────────────────
|
|
447
|
+
async function runDelegateRun(args) {
|
|
448
|
+
const cwd = resolveCwd(args);
|
|
449
|
+
const wantJson = flagBool(args, 'json');
|
|
450
|
+
const task = args.positional.slice(1).join(' ').trim();
|
|
451
|
+
if (!task) {
|
|
452
|
+
process.stderr.write('Usage: shrk delegate run "<task>" --recipe <id> [--apply] [--provider auto] [--json]\n');
|
|
453
|
+
return 2;
|
|
454
|
+
}
|
|
455
|
+
const resolved = await resolveRecipe(cwd, flagString(args, 'recipe'));
|
|
456
|
+
if (!resolved.ok) {
|
|
457
|
+
if (wantJson)
|
|
458
|
+
process.stdout.write(asJson({ ok: false, error: resolved.message }) + '\n');
|
|
459
|
+
else
|
|
460
|
+
process.stderr.write(resolved.message + '\n');
|
|
461
|
+
return 1;
|
|
462
|
+
}
|
|
463
|
+
const providerKind = flagString(args, 'provider') ?? resolved.recipe.provider ?? 'auto';
|
|
464
|
+
const { provider } = selectAiProvider(providerKind);
|
|
465
|
+
const result = await executeDelegateRun({
|
|
466
|
+
task,
|
|
467
|
+
recipe: resolved.recipe,
|
|
468
|
+
projectRoot: resolved.projectRoot,
|
|
469
|
+
provider,
|
|
470
|
+
apply: flagBool(args, 'apply'),
|
|
471
|
+
});
|
|
472
|
+
if (wantJson) {
|
|
473
|
+
process.stdout.write(asJson({ ok: isOkStatus(result.status), ...result }) + '\n');
|
|
474
|
+
return exitFor(result.status);
|
|
475
|
+
}
|
|
476
|
+
process.stdout.write(header(`Delegate: ${result.recipeId}`));
|
|
477
|
+
process.stdout.write(kv('status', result.status) + '\n');
|
|
478
|
+
if (result.attempts && result.attempts > 1)
|
|
479
|
+
process.stdout.write(kv('attempts', String(result.attempts)) + '\n');
|
|
480
|
+
process.stdout.write(kv('message', result.message) + '\n');
|
|
481
|
+
if (result.refused && result.refused.length > 0) {
|
|
482
|
+
process.stdout.write('\nRefused (outside guardrail globs):\n');
|
|
483
|
+
for (const f of result.refused)
|
|
484
|
+
process.stdout.write(` ✗ ${f}\n`);
|
|
485
|
+
}
|
|
486
|
+
if (result.conflicts && result.conflicts.length > 0) {
|
|
487
|
+
process.stdout.write('\nConflicts:\n');
|
|
488
|
+
for (const c of result.conflicts)
|
|
489
|
+
process.stdout.write(` ! ${c}\n`);
|
|
490
|
+
}
|
|
491
|
+
if (result.droppedOps && result.droppedOps.length > 0) {
|
|
492
|
+
process.stdout.write('\nDropped ops (kind not allowed):\n');
|
|
493
|
+
for (const d of result.droppedOps)
|
|
494
|
+
process.stdout.write(` - ${d.kind} → ${d.targetPath}\n`);
|
|
495
|
+
}
|
|
496
|
+
if (result.written && result.written.length > 0) {
|
|
497
|
+
process.stdout.write(`\n${result.reverted ? 'Reverted' : 'Wrote'} ${result.written.length} file(s):\n`);
|
|
498
|
+
for (const w of result.written)
|
|
499
|
+
process.stdout.write(` ${result.reverted ? '↺' : '✓'} ${w}\n`);
|
|
500
|
+
}
|
|
501
|
+
if (result.diff) {
|
|
502
|
+
process.stdout.write(`\nDiff:\n${result.diff}\n`);
|
|
503
|
+
if (result.diffCcrKey)
|
|
504
|
+
process.stdout.write(`(compressed — recover with \`shrk expand ${result.diffCcrKey}\`)\n`);
|
|
505
|
+
}
|
|
506
|
+
return exitFor(result.status);
|
|
507
|
+
}
|
|
508
|
+
async function runDelegateBrief(args) {
|
|
509
|
+
const cwd = resolveCwd(args);
|
|
510
|
+
const wantJson = flagBool(args, 'json');
|
|
511
|
+
const task = args.positional.slice(1).join(' ').trim();
|
|
512
|
+
const resolved = await resolveRecipe(cwd, flagString(args, 'recipe'));
|
|
513
|
+
if (!resolved.ok) {
|
|
514
|
+
if (wantJson)
|
|
515
|
+
process.stdout.write(asJson({ ok: false, error: resolved.message }) + '\n');
|
|
516
|
+
else
|
|
517
|
+
process.stderr.write(resolved.message + '\n');
|
|
518
|
+
return 1;
|
|
519
|
+
}
|
|
520
|
+
const r = resolved.recipe;
|
|
521
|
+
const brief = {
|
|
522
|
+
schema: 'sharkcraft.delegate-brief/v1',
|
|
523
|
+
recipeId: r.id,
|
|
524
|
+
title: r.title ?? r.id,
|
|
525
|
+
task: task || null,
|
|
526
|
+
allowedOps: r.allowedOps,
|
|
527
|
+
guardrailGlobs: r.guardrailGlobs,
|
|
528
|
+
verificationIds: r.verificationIds,
|
|
529
|
+
provider: r.provider ?? 'auto',
|
|
530
|
+
model: r.model ?? null,
|
|
531
|
+
next: `shrk delegate run "${task || '<task>'}" --recipe ${r.id} --apply`,
|
|
532
|
+
note: 'Read-only. The worker may only emit the allowed ops, only touch the guardrail globs, and the edit is verified deterministically before it is kept.',
|
|
533
|
+
};
|
|
534
|
+
if (wantJson) {
|
|
535
|
+
process.stdout.write(asJson(brief) + '\n');
|
|
536
|
+
return 0;
|
|
537
|
+
}
|
|
538
|
+
process.stdout.write(header(`Delegate brief: ${brief.title}`));
|
|
539
|
+
process.stdout.write(kv('recipe', r.id) + '\n');
|
|
540
|
+
if (task)
|
|
541
|
+
process.stdout.write(kv('task', task) + '\n');
|
|
542
|
+
process.stdout.write(kv('allowed ops', r.allowedOps.join(', ')) + '\n');
|
|
543
|
+
process.stdout.write(kv('guardrail globs', r.guardrailGlobs.join(', ')) + '\n');
|
|
544
|
+
process.stdout.write(kv('verification', r.verificationIds.join(', ') || '(none)') + '\n');
|
|
545
|
+
process.stdout.write(kv('provider', `${brief.provider}${r.model ? ` (${r.model})` : ''}`) + '\n');
|
|
546
|
+
process.stdout.write(`\nNext:\n ${brief.next}\n`);
|
|
547
|
+
return 0;
|
|
548
|
+
}
|
|
549
|
+
async function runDelegateList(args) {
|
|
550
|
+
const cwd = resolveCwd(args);
|
|
551
|
+
const wantJson = flagBool(args, 'json');
|
|
552
|
+
const c = await loadResolvedCatalog(cwd);
|
|
553
|
+
if (!c.ok) {
|
|
554
|
+
if (wantJson)
|
|
555
|
+
process.stdout.write(asJson({ ok: false, error: c.message }) + '\n');
|
|
556
|
+
else
|
|
557
|
+
process.stderr.write(c.message + '\n');
|
|
558
|
+
return 1;
|
|
559
|
+
}
|
|
560
|
+
const catalog = c.catalog;
|
|
561
|
+
if (wantJson) {
|
|
562
|
+
process.stdout.write(asJson({ ok: true, total: catalog.length, recipes: catalog }) + '\n');
|
|
563
|
+
return 0;
|
|
564
|
+
}
|
|
565
|
+
process.stdout.write(header('Delegate recipes'));
|
|
566
|
+
if (catalog.length === 0) {
|
|
567
|
+
process.stdout.write(' (none configured — add a delegation { recipes: [...] } block to sharkcraft.config.ts)\n');
|
|
568
|
+
return 0;
|
|
569
|
+
}
|
|
570
|
+
for (const r of catalog) {
|
|
571
|
+
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`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
process.stdout.write(`\nRun \`shrk delegate explain <id>\` for the full fence.\n`);
|
|
579
|
+
return 0;
|
|
580
|
+
}
|
|
581
|
+
async function runDelegateExplain(args) {
|
|
582
|
+
const cwd = resolveCwd(args);
|
|
583
|
+
const wantJson = flagBool(args, 'json');
|
|
584
|
+
const recipeId = args.positional[1];
|
|
585
|
+
if (!recipeId) {
|
|
586
|
+
process.stderr.write('Usage: shrk delegate explain <recipeId>\n');
|
|
587
|
+
return 2;
|
|
588
|
+
}
|
|
589
|
+
const c = await loadResolvedCatalog(cwd);
|
|
590
|
+
if (!c.ok) {
|
|
591
|
+
if (wantJson)
|
|
592
|
+
process.stdout.write(asJson({ ok: false, error: c.message }) + '\n');
|
|
593
|
+
else
|
|
594
|
+
process.stderr.write(c.message + '\n');
|
|
595
|
+
return 1;
|
|
596
|
+
}
|
|
597
|
+
const r = c.catalog.find((x) => x.id === recipeId);
|
|
598
|
+
if (!r) {
|
|
599
|
+
if (wantJson)
|
|
600
|
+
process.stdout.write(asJson({ ok: false, error: `unknown recipe "${recipeId}"` }) + '\n');
|
|
601
|
+
else
|
|
602
|
+
process.stderr.write(`unknown recipe "${recipeId}"\n`);
|
|
603
|
+
return 1;
|
|
604
|
+
}
|
|
605
|
+
const known = new Set((c.config.verificationCommands ?? []).map((v) => v.id));
|
|
606
|
+
const verifications = r.verificationIds.map((id) => ({ id, bound: known.has(id) }));
|
|
607
|
+
if (wantJson) {
|
|
608
|
+
process.stdout.write(asJson({ ok: true, recipe: r, verifications }) + '\n');
|
|
609
|
+
return r.delegatable ? 0 : 1;
|
|
610
|
+
}
|
|
611
|
+
process.stdout.write(header(`Delegate recipe: ${r.id}`));
|
|
612
|
+
process.stdout.write(kv('title', r.title ?? r.id) + '\n');
|
|
613
|
+
process.stdout.write(kv('source', r.source === 'pack' ? `pack: ${r.packageName}` : 'config') + '\n');
|
|
614
|
+
process.stdout.write(kv('delegatable', r.delegatable ? 'yes' : 'no — fix the verification binding first') + '\n');
|
|
615
|
+
process.stdout.write(kv('allowed ops', r.allowedOps.join(', ')) + '\n');
|
|
616
|
+
process.stdout.write(kv('guardrail globs', r.guardrailGlobs.join(', ')) + '\n');
|
|
617
|
+
process.stdout.write(kv('provider', `${r.resolvedProvider}${r.resolvedModel ? ` (${r.resolvedModel})` : ''}`) + '\n');
|
|
618
|
+
process.stdout.write(kv('risk ceiling', r.riskCeiling ?? '(none)') + '\n');
|
|
619
|
+
process.stdout.write(kv('max attempts', String(r.maxAttempts ?? 2)) + '\n');
|
|
620
|
+
process.stdout.write('\nVerification (must pass or the edit is reverted):\n');
|
|
621
|
+
if (verifications.length === 0) {
|
|
622
|
+
process.stdout.write(' ⚠ none declared — the edit would apply UNVERIFIED (refused at apply-time)\n');
|
|
623
|
+
}
|
|
624
|
+
for (const v of verifications) {
|
|
625
|
+
process.stdout.write(` ${v.bound ? '✓' : '✗'} ${v.id}${v.bound ? '' : ' (NOT in verificationCommands[] — would un-gate the edit)'}\n`);
|
|
626
|
+
}
|
|
627
|
+
process.stdout.write('\nThe worker may emit ONLY the allowed ops and touch ONLY the guardrail globs;\nthe edit is verified deterministically and auto-reverted on failure.\n');
|
|
628
|
+
return 0;
|
|
629
|
+
}
|
|
630
|
+
function isOkStatus(s) {
|
|
631
|
+
return s === 'applied' || s === 'generated' || s === 'no-provider';
|
|
632
|
+
}
|
|
633
|
+
function exitFor(s) {
|
|
634
|
+
return isOkStatus(s) ? 0 : 1;
|
|
635
|
+
}
|
|
636
|
+
export const delegateCommand = {
|
|
637
|
+
name: 'delegate',
|
|
638
|
+
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' +
|
|
640
|
+
'shrk delegate brief "<task>" --recipe <id> [--json]\n' +
|
|
641
|
+
'shrk delegate list [--json] — recipes + whether each is safely delegatable\n' +
|
|
642
|
+
'shrk delegate explain <id> [--json] — the full fence for one recipe',
|
|
643
|
+
booleanFlags: new Set(['apply', 'json']),
|
|
644
|
+
async run(args) {
|
|
645
|
+
const sub = args.positional[0];
|
|
646
|
+
if (sub === 'run')
|
|
647
|
+
return runDelegateRun(args);
|
|
648
|
+
if (sub === 'brief')
|
|
649
|
+
return runDelegateBrief(args);
|
|
650
|
+
if (sub === 'list')
|
|
651
|
+
return runDelegateList(args);
|
|
652
|
+
if (sub === 'explain')
|
|
653
|
+
return runDelegateExplain(args);
|
|
654
|
+
process.stderr.write('Usage: shrk delegate run|brief|list|explain ...\n');
|
|
655
|
+
return 2;
|
|
656
|
+
},
|
|
657
|
+
};
|
|
@@ -35,7 +35,7 @@ export const depsAuditCommand = {
|
|
|
35
35
|
: null;
|
|
36
36
|
const store = new GraphStore(cwd);
|
|
37
37
|
if (!store.exists()) {
|
|
38
|
-
process.stderr.write('No SharkCraft graph found. Run `shrk graph
|
|
38
|
+
process.stderr.write('No SharkCraft graph found. Run `shrk graph index` first so deps-audit has import data.\n');
|
|
39
39
|
return 1;
|
|
40
40
|
}
|
|
41
41
|
const api = GraphQueryApi.fromStore(cwd);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"doctor.command.d.ts","sourceRoot":"","sources":["../../src/commands/doctor.command.ts"],"names":[],"mappings":"AAqBA,OAAO,EAML,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"doctor.command.d.ts","sourceRoot":"","sources":["../../src/commands/doctor.command.ts"],"names":[],"mappings":"AAqBA,OAAO,EAML,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AA6PhC,eAAO,MAAM,aAAa,EAAE,eAW3B,CAAC;AAwfF,eAAO,MAAM,qBAAqB,EAAE,eAmCnC,CAAC;AAuDF,eAAO,MAAM,yBAAyB,EAAE,eAavC,CAAC;AAIF,eAAO,MAAM,wBAAwB,EAAE,eA2CtC,CAAC;AAgCF,eAAO,MAAM,6BAA6B,EAAE,eAa3C,CAAC"}
|