@shrkcrft/cli 0.1.0-alpha.11 → 0.1.0-alpha.12
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/commands/ask.command.d.ts.map +1 -1
- package/dist/commands/ask.command.js +10 -9
- package/dist/commands/command-catalog.d.ts.map +1 -1
- package/dist/commands/command-catalog.js +100 -1
- package/dist/commands/deps-audit.command.d.ts +23 -0
- package/dist/commands/deps-audit.command.d.ts.map +1 -0
- package/dist/commands/deps-audit.command.js +266 -0
- package/dist/commands/doctor.command.d.ts.map +1 -1
- package/dist/commands/doctor.command.js +60 -1
- package/dist/commands/graph-code-subverbs.d.ts.map +1 -1
- package/dist/commands/graph-code-subverbs.js +144 -26
- package/dist/commands/graph.command.d.ts.map +1 -1
- package/dist/commands/graph.command.js +3 -2
- package/dist/commands/help.command.d.ts.map +1 -1
- package/dist/commands/help.command.js +22 -1
- package/dist/commands/impact.command.d.ts.map +1 -1
- package/dist/commands/impact.command.js +3 -2
- package/dist/commands/move-plan.command.d.ts +23 -0
- package/dist/commands/move-plan.command.d.ts.map +1 -0
- package/dist/commands/move-plan.command.js +360 -0
- package/dist/commands/scaffold-validate.command.d.ts +22 -0
- package/dist/commands/scaffold-validate.command.d.ts.map +1 -0
- package/dist/commands/scaffold-validate.command.js +215 -0
- package/dist/commands/smart-context.command.d.ts +30 -0
- package/dist/commands/smart-context.command.d.ts.map +1 -0
- package/dist/commands/smart-context.command.js +3763 -0
- package/dist/commands/spike.command.d.ts +22 -0
- package/dist/commands/spike.command.d.ts.map +1 -0
- package/dist/commands/spike.command.js +235 -0
- package/dist/commands/watch.command.d.ts +26 -0
- package/dist/commands/watch.command.d.ts.map +1 -0
- package/dist/commands/watch.command.js +456 -0
- package/dist/env/load-dotenv.d.ts +15 -0
- package/dist/env/load-dotenv.d.ts.map +1 -0
- package/dist/env/load-dotenv.js +70 -0
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +83 -2
- package/dist/schemas/json-schemas.d.ts +384 -36
- package/dist/schemas/json-schemas.d.ts.map +1 -1
- package/dist/schemas/json-schemas.js +247 -36
- package/package.json +33 -31
|
@@ -0,0 +1,3763 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import * as nodePath from 'node:path';
|
|
4
|
+
import { AiMessageRole, buildPromptMessages, EnhancementPipeline, EnhancementStageKind, OllamaProvider, buildDefaultEnhancementStages, selectAiProvider, } from '@shrkcrft/ai';
|
|
5
|
+
import { buildContext } from '@shrkcrft/context';
|
|
6
|
+
import { EdgeKind, GraphQueryApi, GraphStore, NodeKind } from '@shrkcrft/graph';
|
|
7
|
+
import { buildProjectOverview, buildTaskPacket, inspectSharkcraft, renderOverviewText, } from '@shrkcrft/inspector';
|
|
8
|
+
import { flagBool, flagList, flagNumber, flagString, resolveCwd, } from "../command-registry.js";
|
|
9
|
+
import { DeclarationKind, PLAN_CACHE_SCHEMA, PlanCache, SemanticIndex, TaskType, buildFocusedContext, classifyTask, encodeEmbedding, getDefaultSourceRoots, listIndexableFiles, parseTaskTypeOverride, renderFocusedContextForPrompt, } from '@shrkcrft/embeddings';
|
|
10
|
+
import { SmartContextDetailedPlanSchema, SmartContextExpansionRequestSchema, } from "../schemas/json-schemas.js";
|
|
11
|
+
import { asJson, header, kv } from "../output/format-output.js";
|
|
12
|
+
import { printError } from "../output/print-error.js";
|
|
13
|
+
const SMART_CONTEXT_DIR = nodePath.join('.sharkcraft', 'smart-context');
|
|
14
|
+
/**
|
|
15
|
+
* Gemini-backed context enrichment.
|
|
16
|
+
*
|
|
17
|
+
* Sits next to `shrk ask` as an explicit, opt-in AI surface — the
|
|
18
|
+
* deterministic engine (`shrk context`, `shrk brief`, MCP tools) stays
|
|
19
|
+
* AI-free. See docs/smart-context.md and the
|
|
20
|
+
* `.claude/skills/shrk-smart-context/` skill for the agent workflow.
|
|
21
|
+
*
|
|
22
|
+
* Verbs:
|
|
23
|
+
* - `smart-context "<task>"` — single brief (default).
|
|
24
|
+
* - `smart-context "<task>" --plan` — single structured plan.
|
|
25
|
+
* - `smart-context "<task>" --ai-plan` — two-stage AI-assisted plan.
|
|
26
|
+
* - `smart-context "<task>" --save` — persist under .sharkcraft/smart-context/.
|
|
27
|
+
* - `smart-context plan-ahead "t1" "t2"` — batch-save plans for an upcoming queue.
|
|
28
|
+
* - `smart-context list` — list saved entries.
|
|
29
|
+
* - `smart-context show <slug>` — print a saved entry.
|
|
30
|
+
*/
|
|
31
|
+
export const smartContextCommand = {
|
|
32
|
+
name: 'smart-context',
|
|
33
|
+
description: 'Build deterministic context and ask an AI provider to synthesise an enriched brief (default), structured plan (--plan), or two-stage development plan (--ai-plan).',
|
|
34
|
+
usage: 'shrk smart-context "<task>" [--plan] [--ai-plan] [--save] [--provider auto|ollama|llamacpp] [--enhance|--no-enhance] [--enhance-passes N] [--instructions <path>] [--no-instructions] [--model <id>] [--max-tokens N] [--stage1-max-tokens N] [--seed-tokens N] [--expansion-tokens N] [--expansion-limit N] [--log-prompt] [--save-conversation[=<path>]] [--dry-run] [--debug] [--json]',
|
|
35
|
+
async run(args) {
|
|
36
|
+
const task = args.positional.join(' ').trim();
|
|
37
|
+
if (!task) {
|
|
38
|
+
process.stderr.write('Usage: shrk smart-context "<task>" [--plan] [--ai-plan] [--save]\n');
|
|
39
|
+
return 2;
|
|
40
|
+
}
|
|
41
|
+
const cwd = resolveCwd(args);
|
|
42
|
+
const opts = readCommonOptions(args);
|
|
43
|
+
const inspection = await inspectSharkcraft({ cwd });
|
|
44
|
+
const seed = await buildSmartContextSeed({ cwd, task, inspection, options: opts });
|
|
45
|
+
// --focused / --tiny-only: route through the BGE-built bundle. Skips
|
|
46
|
+
// the verbose seed-dump path entirely (no CLAUDE.md body, no knowledge
|
|
47
|
+
// dump). The bundle is dense, task-specific, and ~2 KB instead of ~10 KB.
|
|
48
|
+
if (opts.focused) {
|
|
49
|
+
const focusedExit = await runFocusedMode({ cwd, task, seed, options: opts });
|
|
50
|
+
if (focusedExit !== null)
|
|
51
|
+
return focusedExit;
|
|
52
|
+
}
|
|
53
|
+
if (opts.aiPlan) {
|
|
54
|
+
if (opts.dryRun) {
|
|
55
|
+
writeAiPlanDryRun(seed, seed.graphGrounding, opts);
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
const aiPlan = await buildAiPlanEnvelope({ cwd, inspection, seed, options: opts });
|
|
59
|
+
if (!aiPlan.ok) {
|
|
60
|
+
printError(aiPlan.error);
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
if (opts.save) {
|
|
64
|
+
const saved = saveEnvelope(cwd, aiPlan.value);
|
|
65
|
+
writeSavedNotice(saved, opts.json, aiPlan.value);
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
writeEnvelope(aiPlan.value, opts.json, opts.debug);
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
const messages = buildMessages(seed, opts.mode);
|
|
72
|
+
logPromptToStderr(opts.mode, messages, opts);
|
|
73
|
+
if (opts.dryRun) {
|
|
74
|
+
writeDryRun(messages, opts.mode, displayProviderName(opts.provider));
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
const selection = selectAiProvider(opts.provider);
|
|
78
|
+
if (!selection.provider) {
|
|
79
|
+
process.stderr.write(providerMissingMessage(selection.requested) + '\n');
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
if (opts.model)
|
|
83
|
+
selection.provider.configure({ model: opts.model });
|
|
84
|
+
if (!opts.json) {
|
|
85
|
+
process.stdout.write(`(provider: ${selection.provider.id})\n`);
|
|
86
|
+
}
|
|
87
|
+
// Brief mode with the multi-pass enhancement pipeline. When an
|
|
88
|
+
// LLM is ready and enhancement is on, run `draft → critique →
|
|
89
|
+
// refine → polish` over the deterministic seed instead of a
|
|
90
|
+
// single LLM shot. Falls back to single-shot when --no-enhance
|
|
91
|
+
// is passed or when SHRK_ENHANCE=off.
|
|
92
|
+
if (opts.mode === 'brief' && opts.enhance) {
|
|
93
|
+
const enhanced = await runEnhancementPipeline({
|
|
94
|
+
provider: selection.provider,
|
|
95
|
+
messages,
|
|
96
|
+
seed,
|
|
97
|
+
options: opts,
|
|
98
|
+
});
|
|
99
|
+
if (!enhanced.ok) {
|
|
100
|
+
printError(enhanced.error);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
if (opts.saveConversation) {
|
|
104
|
+
const path = writeConversationFile({
|
|
105
|
+
cwd,
|
|
106
|
+
task,
|
|
107
|
+
mode: opts.mode,
|
|
108
|
+
options: opts,
|
|
109
|
+
providerId: selection.provider.id,
|
|
110
|
+
model: enhanced.value.ai.model,
|
|
111
|
+
turns: enhanced.value.turns,
|
|
112
|
+
});
|
|
113
|
+
if (!opts.json) {
|
|
114
|
+
process.stderr.write(`[smart-context] conversation saved → ${path}\n`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const envelope = buildEnvelope({
|
|
118
|
+
task,
|
|
119
|
+
seed,
|
|
120
|
+
ai: enhanced.value.ai,
|
|
121
|
+
mode: opts.mode,
|
|
122
|
+
content: enhanced.value.content,
|
|
123
|
+
enhancement: enhanced.value.enhancement,
|
|
124
|
+
});
|
|
125
|
+
if (opts.save) {
|
|
126
|
+
const saved = saveEnvelope(cwd, envelope);
|
|
127
|
+
writeSavedNotice(saved, opts.json, envelope);
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
writeEnvelope(envelope, opts.json, opts.debug);
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
const aiResult = await callProvider({
|
|
134
|
+
provider: selection.provider,
|
|
135
|
+
messages,
|
|
136
|
+
maxTokens: opts.maxTokens,
|
|
137
|
+
model: opts.model,
|
|
138
|
+
});
|
|
139
|
+
if (!aiResult.ok) {
|
|
140
|
+
printError(aiResult.error);
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
143
|
+
if (opts.saveConversation) {
|
|
144
|
+
const path = writeConversationFile({
|
|
145
|
+
cwd,
|
|
146
|
+
task,
|
|
147
|
+
mode: opts.mode,
|
|
148
|
+
options: opts,
|
|
149
|
+
providerId: aiResult.value.providerId,
|
|
150
|
+
model: aiResult.value.model,
|
|
151
|
+
turns: [
|
|
152
|
+
{
|
|
153
|
+
stage: 'single',
|
|
154
|
+
request: { messages: messages.map((m) => ({ role: m.role, content: m.content })) },
|
|
155
|
+
response: {
|
|
156
|
+
content: aiResult.value.content,
|
|
157
|
+
model: aiResult.value.model,
|
|
158
|
+
finishReason: aiResult.value.finishReason,
|
|
159
|
+
usage: aiResult.value.usage,
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
});
|
|
164
|
+
if (!opts.json) {
|
|
165
|
+
process.stderr.write(`[smart-context] conversation saved → ${path}\n`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const envelope = buildEnvelope({
|
|
169
|
+
task,
|
|
170
|
+
seed,
|
|
171
|
+
ai: aiResult.value,
|
|
172
|
+
mode: opts.mode,
|
|
173
|
+
});
|
|
174
|
+
if (opts.save) {
|
|
175
|
+
const saved = saveEnvelope(cwd, envelope);
|
|
176
|
+
writeSavedNotice(saved, opts.json, envelope);
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
writeEnvelope(envelope, opts.json, opts.debug);
|
|
180
|
+
return 0;
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
/** `shrk smart-context plan-ahead "task1" "task2" ...` — batch-saves plans. */
|
|
184
|
+
export const smartContextPlanAheadCommand = {
|
|
185
|
+
name: 'plan-ahead',
|
|
186
|
+
description: 'Generate and save AI-backed plans for a queue of upcoming tasks. Each task is saved under .sharkcraft/smart-context/.',
|
|
187
|
+
usage: 'shrk smart-context plan-ahead "<task1>" "<task2>" ... [--brief] [--provider auto|ollama|llamacpp] [--instructions <path>] [--model <id>] [--max-tokens N] [--dry-run] [--json]',
|
|
188
|
+
async run(args) {
|
|
189
|
+
const tasks = args.positional.map((t) => t.trim()).filter((t) => t.length > 0);
|
|
190
|
+
if (tasks.length === 0) {
|
|
191
|
+
process.stderr.write('Usage: shrk smart-context plan-ahead "<task1>" "<task2>" ...\n');
|
|
192
|
+
return 2;
|
|
193
|
+
}
|
|
194
|
+
const cwd = resolveCwd(args);
|
|
195
|
+
const opts = readCommonOptions(args);
|
|
196
|
+
if (opts.aiPlan) {
|
|
197
|
+
process.stderr.write('`shrk smart-context plan-ahead` does not support `--ai-plan` yet.\n');
|
|
198
|
+
return 2;
|
|
199
|
+
}
|
|
200
|
+
const wantBrief = flagBool(args, 'brief');
|
|
201
|
+
opts.mode = wantBrief ? 'brief' : 'plan';
|
|
202
|
+
opts.save = true;
|
|
203
|
+
const inspection = await inspectSharkcraft({ cwd });
|
|
204
|
+
const results = [];
|
|
205
|
+
const selection = opts.dryRun ? null : selectAiProvider(opts.provider);
|
|
206
|
+
if (!opts.dryRun && !selection?.provider) {
|
|
207
|
+
process.stderr.write(providerMissingMessage(selection?.requested ?? 'gemini') + '\n');
|
|
208
|
+
return 1;
|
|
209
|
+
}
|
|
210
|
+
if (selection?.provider && opts.model)
|
|
211
|
+
selection.provider.configure({ model: opts.model });
|
|
212
|
+
for (const task of tasks) {
|
|
213
|
+
const seed = await buildSmartContextSeed({ cwd, task, inspection, options: opts });
|
|
214
|
+
const messages = buildMessages(seed, opts.mode);
|
|
215
|
+
if (opts.dryRun) {
|
|
216
|
+
results.push({ task, status: 'dry-run', slug: slug(task) });
|
|
217
|
+
if (!opts.json) {
|
|
218
|
+
process.stdout.write(header(`Dry-run prompt for: ${task}`));
|
|
219
|
+
for (const m of messages)
|
|
220
|
+
process.stdout.write(`\n[${m.role}]\n${m.content}\n`);
|
|
221
|
+
}
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const aiResult = await callProvider({
|
|
225
|
+
provider: selection.provider,
|
|
226
|
+
messages,
|
|
227
|
+
maxTokens: opts.maxTokens,
|
|
228
|
+
model: opts.model,
|
|
229
|
+
});
|
|
230
|
+
if (!aiResult.ok) {
|
|
231
|
+
results.push({ task, status: 'error', error: aiResult.error.message, slug: slug(task) });
|
|
232
|
+
if (!opts.json) {
|
|
233
|
+
process.stderr.write(` ✗ ${task}\n ${aiResult.error.message}\n`);
|
|
234
|
+
}
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const envelope = buildEnvelope({ task, seed, ai: aiResult.value, mode: opts.mode });
|
|
238
|
+
const saved = saveEnvelope(cwd, envelope);
|
|
239
|
+
results.push({
|
|
240
|
+
task,
|
|
241
|
+
status: 'saved',
|
|
242
|
+
slug: saved.slug,
|
|
243
|
+
files: { markdown: saved.mdPath, json: saved.jsonPath },
|
|
244
|
+
usage: aiResult.value.usage ?? null,
|
|
245
|
+
});
|
|
246
|
+
if (!opts.json) {
|
|
247
|
+
process.stdout.write(` ✓ ${task}\n → ${saved.mdPath}\n`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (opts.json) {
|
|
251
|
+
process.stdout.write(asJson({ tasks: results.length, results }) + '\n');
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
process.stdout.write(`\nplan-ahead: ${results.filter((r) => r.status === 'saved').length}/${results.length} saved\n`);
|
|
255
|
+
}
|
|
256
|
+
return results.some((r) => r.status === 'error') ? 1 : 0;
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
/** `shrk smart-context list` — list saved entries. */
|
|
260
|
+
export const smartContextListCommand = {
|
|
261
|
+
name: 'list',
|
|
262
|
+
description: 'List saved smart-context entries under .sharkcraft/smart-context/.',
|
|
263
|
+
usage: 'shrk smart-context list [--json]',
|
|
264
|
+
async run(args) {
|
|
265
|
+
const cwd = resolveCwd(args);
|
|
266
|
+
const entries = readSavedIndex(cwd);
|
|
267
|
+
if (flagBool(args, 'json')) {
|
|
268
|
+
process.stdout.write(asJson({ entries }) + '\n');
|
|
269
|
+
return 0;
|
|
270
|
+
}
|
|
271
|
+
if (entries.length === 0) {
|
|
272
|
+
process.stdout.write('No saved smart-context entries yet.\n');
|
|
273
|
+
process.stdout.write('Try: shrk smart-context "<task>" --save\n');
|
|
274
|
+
return 0;
|
|
275
|
+
}
|
|
276
|
+
process.stdout.write(header(`Saved smart-context (${entries.length})`));
|
|
277
|
+
for (const e of entries) {
|
|
278
|
+
process.stdout.write(` ${e.slug.padEnd(40)} [${e.mode}] ${e.savedAt}\n ${e.task}\n`);
|
|
279
|
+
}
|
|
280
|
+
return 0;
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
/** `shrk smart-context show <slug>` — print a saved entry. */
|
|
284
|
+
export const smartContextShowCommand = {
|
|
285
|
+
name: 'show',
|
|
286
|
+
description: 'Print a saved smart-context entry by slug. Use `list` to see slugs.',
|
|
287
|
+
usage: 'shrk smart-context show <slug> [--json]',
|
|
288
|
+
async run(args) {
|
|
289
|
+
const target = args.positional[0]?.trim();
|
|
290
|
+
if (!target) {
|
|
291
|
+
process.stderr.write('Usage: shrk smart-context show <slug>\n');
|
|
292
|
+
return 2;
|
|
293
|
+
}
|
|
294
|
+
const cwd = resolveCwd(args);
|
|
295
|
+
const entries = readSavedIndex(cwd);
|
|
296
|
+
const hit = entries.find((e) => e.slug === target);
|
|
297
|
+
if (!hit) {
|
|
298
|
+
process.stderr.write(`No saved entry "${target}". Try: shrk smart-context list\n`);
|
|
299
|
+
return 1;
|
|
300
|
+
}
|
|
301
|
+
if (flagBool(args, 'json')) {
|
|
302
|
+
try {
|
|
303
|
+
process.stdout.write(readFileSync(hit.jsonPath, 'utf8'));
|
|
304
|
+
}
|
|
305
|
+
catch (e) {
|
|
306
|
+
process.stderr.write(`Failed to read ${hit.jsonPath}: ${e.message}\n`);
|
|
307
|
+
return 1;
|
|
308
|
+
}
|
|
309
|
+
return 0;
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
process.stdout.write(readFileSync(hit.mdPath, 'utf8'));
|
|
313
|
+
}
|
|
314
|
+
catch (e) {
|
|
315
|
+
process.stderr.write(`Failed to read ${hit.mdPath}: ${e.message}\n`);
|
|
316
|
+
return 1;
|
|
317
|
+
}
|
|
318
|
+
return 0;
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
/** `shrk smart-context embeddings build` — (re)build the semantic index. */
|
|
322
|
+
export const smartContextEmbeddingsBuildCommand = {
|
|
323
|
+
name: 'embeddings-build',
|
|
324
|
+
description: 'Build or incrementally refresh the semantic index. Defaults to incremental updates when an index already exists; pass --rebuild for a full rebuild.',
|
|
325
|
+
usage: 'shrk smart-context embeddings-build [--model <hf-id>] [--root <dir>]... [--max-files N] [--rebuild] [--json]',
|
|
326
|
+
async run(args) {
|
|
327
|
+
const cwd = resolveCwd(args);
|
|
328
|
+
const model = flagString(args, 'model');
|
|
329
|
+
const maxFiles = flagNumber(args, 'max-files') ?? 5000;
|
|
330
|
+
const rebuild = flagBool(args, 'rebuild');
|
|
331
|
+
const json = flagBool(args, 'json');
|
|
332
|
+
const explicitRoots = flagList(args, 'root');
|
|
333
|
+
const files = listIndexableFilesForCli(cwd, maxFiles, explicitRoots);
|
|
334
|
+
if (files.length === 0) {
|
|
335
|
+
const triedRoots = explicitRoots.length > 0 ? explicitRoots : getDefaultSourceRoots();
|
|
336
|
+
const existing = triedRoots
|
|
337
|
+
.filter((rel) => existsSync(nodePath.join(cwd, rel)))
|
|
338
|
+
.map((rel) => `${rel}/`);
|
|
339
|
+
const missing = triedRoots
|
|
340
|
+
.filter((rel) => !existsSync(nodePath.join(cwd, rel)))
|
|
341
|
+
.map((rel) => `${rel}/`);
|
|
342
|
+
const lines = [];
|
|
343
|
+
lines.push(`No indexable .ts/.tsx/.js/.jsx/.md files found under ${cwd}.`);
|
|
344
|
+
if (existing.length > 0) {
|
|
345
|
+
lines.push(` • Roots present but empty: ${existing.join(', ')}`);
|
|
346
|
+
}
|
|
347
|
+
if (missing.length > 0) {
|
|
348
|
+
lines.push(` • Roots not found: ${missing.join(', ')}`);
|
|
349
|
+
}
|
|
350
|
+
lines.push(` • Pass --root <dir> (repeatable) to point at your source folder, e.g. --root src --root app.`);
|
|
351
|
+
process.stderr.write(lines.join('\n') + '\n');
|
|
352
|
+
return 1;
|
|
353
|
+
}
|
|
354
|
+
const entries = files.map((path) => ({
|
|
355
|
+
path,
|
|
356
|
+
summary: readLeadingDocComment(cwd, path),
|
|
357
|
+
exports: extractExportedNames(cwd, path),
|
|
358
|
+
}));
|
|
359
|
+
const start = Date.now();
|
|
360
|
+
const existing = rebuild ? null : await SemanticIndex.tryLoad(cwd, model ? { model } : {});
|
|
361
|
+
try {
|
|
362
|
+
if (existing) {
|
|
363
|
+
if (!json) {
|
|
364
|
+
process.stderr.write(`[smart-context] refreshing semantic index (${existing.fileCount} indexed, ${entries.length} on disk)…\n`);
|
|
365
|
+
}
|
|
366
|
+
const report = await existing.refresh(entries, {
|
|
367
|
+
onProgress: json
|
|
368
|
+
? undefined
|
|
369
|
+
: (done, total, action) => {
|
|
370
|
+
if (done === total || done % 25 === 0) {
|
|
371
|
+
process.stderr.write(`[smart-context] re-embedded ${done}/${total} (${action})\n`);
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
const elapsedMs = Date.now() - start;
|
|
376
|
+
if (json) {
|
|
377
|
+
process.stdout.write(asJson({
|
|
378
|
+
mode: 'refresh',
|
|
379
|
+
files: existing.fileCount,
|
|
380
|
+
model: existing.modelName,
|
|
381
|
+
elapsedMs,
|
|
382
|
+
...report,
|
|
383
|
+
}) + '\n');
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
process.stdout.write(`\nRefreshed in ${(elapsedMs / 1000).toFixed(1)}s — added ${report.added}, changed ${report.changed}, removed ${report.removed}, unchanged ${report.unchanged} (total ${report.totalAfter}, model ${existing.modelName}).\n`);
|
|
387
|
+
}
|
|
388
|
+
return 0;
|
|
389
|
+
}
|
|
390
|
+
if (!json) {
|
|
391
|
+
process.stderr.write(`[smart-context] ${rebuild ? 'rebuilding' : 'building'} embedding index for ${entries.length} files (model: ${model ?? 'Xenova/bge-base-en-v1.5'})…\n`);
|
|
392
|
+
}
|
|
393
|
+
const index = await SemanticIndex.build(cwd, entries, {
|
|
394
|
+
...(model ? { model } : {}),
|
|
395
|
+
onProgress: json
|
|
396
|
+
? undefined
|
|
397
|
+
: (done, total) => {
|
|
398
|
+
if (done === total || done % 50 === 0) {
|
|
399
|
+
process.stderr.write(`[smart-context] embedded ${done}/${total}\n`);
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
const elapsedMs = Date.now() - start;
|
|
404
|
+
if (json) {
|
|
405
|
+
process.stdout.write(asJson({ mode: 'build', files: index.fileCount, model: index.modelName, elapsedMs }) + '\n');
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
process.stdout.write(`\nIndexed ${index.fileCount} files in ${(elapsedMs / 1000).toFixed(1)}s (model ${index.modelName}).\n`);
|
|
409
|
+
}
|
|
410
|
+
return 0;
|
|
411
|
+
}
|
|
412
|
+
catch (e) {
|
|
413
|
+
process.stderr.write(`Failed to build semantic index: ${e.message}\n`);
|
|
414
|
+
return 1;
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
function listIndexableFilesForCli(cwd, max, roots) {
|
|
419
|
+
return listIndexableFiles(cwd, max, roots && roots.length > 0 ? { roots } : {});
|
|
420
|
+
}
|
|
421
|
+
/** `shrk smart-context embeddings-status` — freshness report (no model load). */
|
|
422
|
+
export const smartContextEmbeddingsStatusCommand = {
|
|
423
|
+
name: 'embeddings-status',
|
|
424
|
+
description: 'Report semantic index freshness — how many indexed files are stale, missing, or untracked. Does not load the embedding model.',
|
|
425
|
+
usage: 'shrk smart-context embeddings-status [--json]',
|
|
426
|
+
async run(args) {
|
|
427
|
+
const cwd = resolveCwd(args);
|
|
428
|
+
const json = flagBool(args, 'json');
|
|
429
|
+
const current = listIndexableFilesForCli(cwd, 5000);
|
|
430
|
+
const report = SemanticIndex.freshnessReport(cwd, current);
|
|
431
|
+
if (json) {
|
|
432
|
+
process.stdout.write(asJson(report) + '\n');
|
|
433
|
+
return 0;
|
|
434
|
+
}
|
|
435
|
+
if (!report.hasIndex) {
|
|
436
|
+
process.stdout.write(`No semantic index yet (workspace has ${report.untracked} indexable files).\n`);
|
|
437
|
+
process.stdout.write('Run: shrk smart-context embeddings-build\n');
|
|
438
|
+
return 0;
|
|
439
|
+
}
|
|
440
|
+
if (report.corrupt) {
|
|
441
|
+
process.stderr.write('Semantic index meta is corrupt; run `shrk smart-context embeddings-build --rebuild`.\n');
|
|
442
|
+
return 1;
|
|
443
|
+
}
|
|
444
|
+
const stalePct = report.indexed > 0 ? Math.round((report.stale * 100) / report.indexed) : 0;
|
|
445
|
+
process.stdout.write(`Indexed: ${report.indexed} (model ${report.model})\n` +
|
|
446
|
+
` fresh: ${report.fresh}\n` +
|
|
447
|
+
` stale: ${report.stale} (${stalePct}%)\n` +
|
|
448
|
+
` missing: ${report.missing} (in store but deleted on disk)\n` +
|
|
449
|
+
` untracked: ${report.untracked} (on disk but not indexed)\n`);
|
|
450
|
+
if (report.stale + report.missing + report.untracked > 0) {
|
|
451
|
+
process.stdout.write('Refresh: shrk smart-context embeddings-build\n');
|
|
452
|
+
}
|
|
453
|
+
return 0;
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
function extractExportedNames(cwd, path) {
|
|
457
|
+
const abs = nodePath.isAbsolute(path) ? path : nodePath.join(cwd, path);
|
|
458
|
+
let body;
|
|
459
|
+
try {
|
|
460
|
+
body = readFileSync(abs, 'utf8');
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
return [];
|
|
464
|
+
}
|
|
465
|
+
const out = [];
|
|
466
|
+
const pattern = /^\s*export\s+(?:default\s+)?(?:async\s+)?(?:abstract\s+)?(?:function|const|let|var|class|interface|enum|type)\s+([A-Za-z_$][A-Za-z0-9_$]*)/gm;
|
|
467
|
+
let m;
|
|
468
|
+
while ((m = pattern.exec(body)) !== null) {
|
|
469
|
+
if (out.length >= 16)
|
|
470
|
+
break;
|
|
471
|
+
if (m[1])
|
|
472
|
+
out.push(m[1]);
|
|
473
|
+
}
|
|
474
|
+
return out;
|
|
475
|
+
}
|
|
476
|
+
function readCommonOptions(args) {
|
|
477
|
+
const aiPlan = flagBool(args, 'ai-plan');
|
|
478
|
+
const wantPlan = flagBool(args, 'plan') || aiPlan;
|
|
479
|
+
const mode = wantPlan ? 'plan' : 'brief';
|
|
480
|
+
const model = flagString(args, 'model');
|
|
481
|
+
const provider = flagString(args, 'provider');
|
|
482
|
+
const maxTokens = flagNumber(args, 'max-tokens') ?? (mode === 'plan' ? 6144 : 3072);
|
|
483
|
+
return {
|
|
484
|
+
mode,
|
|
485
|
+
...(provider ? { provider } : {}),
|
|
486
|
+
...(model ? { model } : {}),
|
|
487
|
+
maxTokens,
|
|
488
|
+
stage1MaxTokens: flagNumber(args, 'stage1-max-tokens') ?? Math.min(2048, maxTokens),
|
|
489
|
+
seedTokens: flagNumber(args, 'seed-tokens') ?? 3500,
|
|
490
|
+
expansionTokens: flagNumber(args, 'expansion-tokens') ?? 2200,
|
|
491
|
+
expansionLimit: flagNumber(args, 'expansion-limit') ?? 12,
|
|
492
|
+
dryRun: flagBool(args, 'dry-run'),
|
|
493
|
+
json: flagBool(args, 'json'),
|
|
494
|
+
save: flagBool(args, 'save'),
|
|
495
|
+
debug: flagBool(args, 'debug'),
|
|
496
|
+
aiPlan,
|
|
497
|
+
...(flagString(args, 'instructions') ? { instructionsPath: flagString(args, 'instructions') } : {}),
|
|
498
|
+
noInstructions: flagBool(args, 'no-instructions'),
|
|
499
|
+
noRefreshIndex: flagBool(args, 'no-refresh-index'),
|
|
500
|
+
noCache: flagBool(args, 'no-cache'),
|
|
501
|
+
cacheReplayThreshold: flagNumber(args, 'cache-replay-threshold') ?? 0.95,
|
|
502
|
+
cacheReferenceThreshold: flagNumber(args, 'cache-reference-threshold') ?? 0.75,
|
|
503
|
+
focused: flagBool(args, 'focused') || flagBool(args, 'tiny-only'),
|
|
504
|
+
tinyOnly: flagBool(args, 'tiny-only'),
|
|
505
|
+
taskTypeOverride: parseTaskTypeOverride(flagString(args, 'task-type')),
|
|
506
|
+
noPolish: flagBool(args, 'no-polish'),
|
|
507
|
+
...(flagString(args, 'since') ? { sinceRef: flagString(args, 'since') } : {}),
|
|
508
|
+
stream: flagBool(args, 'stream'),
|
|
509
|
+
enhance: resolveEnhanceFlag(args),
|
|
510
|
+
enhancePasses: flagNumber(args, 'enhance-passes') ?? readEnhancePassesEnv(),
|
|
511
|
+
logPrompt: flagBool(args, 'log-prompt'),
|
|
512
|
+
saveConversation: flagBool(args, 'save-conversation') || flagString(args, 'save-conversation') !== undefined,
|
|
513
|
+
...(flagString(args, 'save-conversation')
|
|
514
|
+
? { saveConversationPath: flagString(args, 'save-conversation') }
|
|
515
|
+
: {}),
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
async function buildSmartContextSeed(input) {
|
|
519
|
+
const { cwd, task, inspection, options } = input;
|
|
520
|
+
const overview = buildProjectOverview(inspection.workspace, inspection.config?.projectName);
|
|
521
|
+
const overviewText = renderOverviewText(overview);
|
|
522
|
+
const packet = buildTaskPacket(inspection, task, { maxTokens: options.seedTokens });
|
|
523
|
+
const ctx = buildContext(inspection.knowledgeEntries, {
|
|
524
|
+
task,
|
|
525
|
+
maxTokens: options.seedTokens,
|
|
526
|
+
projectOverview: overviewText,
|
|
527
|
+
});
|
|
528
|
+
const graphGrounding = buildInitialGraphGrounding(cwd, task);
|
|
529
|
+
const semantic = await tryLoadSemanticHits(cwd, task, 10, options);
|
|
530
|
+
return {
|
|
531
|
+
task,
|
|
532
|
+
overviewText,
|
|
533
|
+
contextBody: ctx.body,
|
|
534
|
+
packet,
|
|
535
|
+
repoInstructions: resolveRepoInstructions(cwd, options),
|
|
536
|
+
graphGrounding,
|
|
537
|
+
stage1FileBriefs: buildStage1FileBriefs(cwd, graphGrounding.taskFileCandidates, 6),
|
|
538
|
+
documentationHits: collectDocumentationHits(cwd, tokenizeTask(task), 10),
|
|
539
|
+
semanticCandidates: semantic.hits,
|
|
540
|
+
semanticModel: semantic.model,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
const AUTO_REFRESH_FILE_CAP = 30;
|
|
544
|
+
const FOCUSED_BRIEF_PREAMBLE = [
|
|
545
|
+
"You are a development planner for a SharkCraft-instrumented repository.",
|
|
546
|
+
'The supplied context contains ONLY the most task-relevant code blocks, rules, docs, and validation commands — picked by an embedding model that ranked them against the user task.',
|
|
547
|
+
'STRICT GROUNDING: every file path, rule id, and command in your output MUST appear verbatim in the supplied context. Do not invent.',
|
|
548
|
+
'Output a concise Markdown BRIEF (≤ 400 words):',
|
|
549
|
+
' 1. Restate the task in one sentence.',
|
|
550
|
+
' 2. Cite the most relevant rule ids verbatim, each with one line of how-it-applies.',
|
|
551
|
+
' 3. List the most likely files to read, then the most likely files to edit (use the supplied paths verbatim).',
|
|
552
|
+
' 4. List the commands to run.',
|
|
553
|
+
' 5. Flag gotchas, risks, or forbidden actions if present.',
|
|
554
|
+
'No preamble. No closing pleasantries. Just the brief.',
|
|
555
|
+
].join(' ');
|
|
556
|
+
const FOCUSED_PLAN_PREAMBLE = [
|
|
557
|
+
"You are a development planner for a SharkCraft-instrumented repository.",
|
|
558
|
+
'The supplied context contains ONLY the most task-relevant code blocks (interfaces, signatures), rules, docs, and validation commands — selected by an embedding model that ranked them against the user task.',
|
|
559
|
+
'STRICT GROUNDING: every path / rule id / command in your output MUST appear verbatim in the supplied context. Do not invent any new files.',
|
|
560
|
+
'Output a detailed PLAN as one fenced ```json block then a short Markdown summary.',
|
|
561
|
+
'The JSON must conform to this schema (omit empty arrays):',
|
|
562
|
+
'{',
|
|
563
|
+
' "summary": string,',
|
|
564
|
+
' "filesToRead": [{ "path": string, "why": string }],',
|
|
565
|
+
' "likelyFilesToEdit":[{ "path": string, "why": string }],',
|
|
566
|
+
' "relatedRules": [{ "id": string, "applyWhen": string }],',
|
|
567
|
+
' "firstCommands": [{ "command": string, "why": string }],',
|
|
568
|
+
' "implementationSteps": [{ "step": string, "details": string }],',
|
|
569
|
+
' "risks": [string],',
|
|
570
|
+
' "openQuestions": [string]',
|
|
571
|
+
'}',
|
|
572
|
+
].join(' ');
|
|
573
|
+
const FOCUSED_ARCHITECTURE_PREAMBLE = [
|
|
574
|
+
"You are a senior architect for a SharkCraft-instrumented repository. The user task is abstract: design first, code never.",
|
|
575
|
+
'',
|
|
576
|
+
'This repository has a SPECIFIC shape — use it:',
|
|
577
|
+
'- The CLI (`shrk`) is the only write path. Inputs: argv. Outputs: stdout / fs writes under `.sharkcraft/`.',
|
|
578
|
+
'- The MCP server is READ-ONLY. The agent CALLS it; it never pushes.',
|
|
579
|
+
'- The dashboard is a localhost HTTP read-only server (GET/HEAD only).',
|
|
580
|
+
'- Persistent state lives under `.sharkcraft/` (gitignored).',
|
|
581
|
+
'- A BGE embedding index already exists at `.sharkcraft/embeddings/`.',
|
|
582
|
+
'- A plan cache already exists at `.sharkcraft/smart-context/cache/plans.jsonl`.',
|
|
583
|
+
'',
|
|
584
|
+
'INTEGRATION VOCABULARY — use exactly these surface values. DO NOT use HTTP verbs like GET/POST for CLI, MCP, or file-system surfaces (those do not speak HTTP):',
|
|
585
|
+
'- `cli-command` — a new `shrk <subcommand>` the user runs once.',
|
|
586
|
+
'- `cli-watcher` — a long-running `shrk watch <X>` that emits stdout JSONL when triggers fire.',
|
|
587
|
+
'- `mcp-tool-call` — a new read-only MCP tool the agent invokes (pull, not push).',
|
|
588
|
+
'- `mcp-resource-read` — a new MCP resource the agent reads on demand (pull).',
|
|
589
|
+
'- `file-read` — agent reads a file the producer wrote.',
|
|
590
|
+
'- `file-write` — producer writes a file (under `.sharkcraft/`).',
|
|
591
|
+
'- `stdout-stream` — producer prints JSONL lines on stdout.',
|
|
592
|
+
'- `background-watcher`— an `fs.watch` listener inside a CLI process.',
|
|
593
|
+
'',
|
|
594
|
+
'STRICT GROUNDING: every file path / rule id / command in your output MUST appear in the supplied context. Code blocks below are *patterns to study*, not files to edit.',
|
|
595
|
+
'',
|
|
596
|
+
'Output one ```json block conforming to the schema below, then a short Markdown summary.',
|
|
597
|
+
'',
|
|
598
|
+
'{',
|
|
599
|
+
' "summary": string, // 1 sentence, design framing',
|
|
600
|
+
' "taskUnderstanding": string,',
|
|
601
|
+
' "designQuestions": [string], // ≤ 7 SHRK-SPECIFIC questions; see required topics below',
|
|
602
|
+
' "candidateArchitectures": [{ // 2–4 *genuinely different* options',
|
|
603
|
+
' "name": string,',
|
|
604
|
+
' "shape": string, // concrete 1-liner from the vocabulary above',
|
|
605
|
+
' "howItWorks": string, // 1 paragraph',
|
|
606
|
+
' "differentiator": string, // ONE sentence stating WHAT MAKES THIS DIFFERENT from the others',
|
|
607
|
+
' "uniquePros": [string], // ≥ 1 pro that no other candidate could claim',
|
|
608
|
+
' "uniqueCons": [string], // ≥ 1 con that no other candidate has',
|
|
609
|
+
' "recommendation": "recommended" | "possible-later" | "not-for-mvp"',
|
|
610
|
+
' }],',
|
|
611
|
+
' "recommendedMvp": { // EXACTLY ONE candidate.recommendation must be "recommended"',
|
|
612
|
+
' "architectureName": string, // must match a candidateArchitectures.name',
|
|
613
|
+
' "why": string, // why it is the safest first spike',
|
|
614
|
+
' "explicitlyNotInScope": [string] // what we are NOT building yet',
|
|
615
|
+
' },',
|
|
616
|
+
' "firstSpike": { // small, concrete, runnable',
|
|
617
|
+
' "proposedCommand": string | null, // e.g. "shrk context-feed start --interval 5s"',
|
|
618
|
+
' "proposedFiles": [{ "path": string, "purpose": string }], // e.g. ".sharkcraft/context-stream/<timestamp>.json"',
|
|
619
|
+
' "schemaOutline": string, // minimal JSON sketch of any context packet shape',
|
|
620
|
+
' "successCriteria": [string] // observable pass/fail bullets',
|
|
621
|
+
' },',
|
|
622
|
+
' "integrationPoints": [{ // where this WOULD touch existing code',
|
|
623
|
+
' "surface": "cli-command"|"cli-watcher"|"mcp-tool-call"|"mcp-resource-read"|"file-read"|"file-write"|"stdout-stream"|"background-watcher",',
|
|
624
|
+
' "name": string, // e.g. "shrk context-feed start", "context-packet/next"',
|
|
625
|
+
' "why": string',
|
|
626
|
+
' }],',
|
|
627
|
+
' "concerns": { // pick the ones that apply to the design',
|
|
628
|
+
' "contextPacketSchema": string, // shape of one packet',
|
|
629
|
+
' "updateTrigger": string, // ONE of: file-change, time-tick, user-event, graph-drift, stdin-prompt',
|
|
630
|
+
' "deduplication": string, // how repeated packets are coalesced or skipped',
|
|
631
|
+
' "contextBudget": string, // token / byte cap per packet',
|
|
632
|
+
' "claudeHandoffMechanism": string, // exactly how the consuming agent receives a packet',
|
|
633
|
+
' "mcpVsFsVsCliResponsibility": string, // which surface OWNS which job; explicit split',
|
|
634
|
+
' "sessionPersistence": string // what survives a CLI restart',
|
|
635
|
+
' },',
|
|
636
|
+
' "filesToInspect": [{ "path": string, "why": string }], // EXISTING patterns to read; NOT files to edit',
|
|
637
|
+
' "relatedRules": [{ "id": string, "applyWhen": string }],',
|
|
638
|
+
' "nonGoals": [string],',
|
|
639
|
+
' "risks": [string],',
|
|
640
|
+
' "openQuestions": [string] // ≤ 5; ONLY task-specific',
|
|
641
|
+
'}',
|
|
642
|
+
'',
|
|
643
|
+
'DIFFERENTIATION RULE — failure to obey will make the output rejected:',
|
|
644
|
+
'- Each candidate\'s `uniquePros` and `uniqueCons` MUST list at least one item that no other candidate has.',
|
|
645
|
+
'- If two candidates would have the same pros/cons, DROP one and keep only options that materially differ.',
|
|
646
|
+
'- Exactly ONE candidate has `recommendation: "recommended"` and is named in `recommendedMvp.architectureName`.',
|
|
647
|
+
'',
|
|
648
|
+
'REQUIRED designQuestions topics (skip those that genuinely do not apply, but the surviving ones MUST be specific not generic):',
|
|
649
|
+
'- Context-packet schema (what is in one packet?)',
|
|
650
|
+
'- Update trigger (when do we emit?)',
|
|
651
|
+
'- Deduplication (when is a packet *not* worth emitting?)',
|
|
652
|
+
'- Context budget per packet',
|
|
653
|
+
'- Claude handoff mechanism (how does the agent ingest a packet?)',
|
|
654
|
+
'- MCP vs file-system vs CLI responsibility (who owns what?)',
|
|
655
|
+
'- Session persistence (what survives a CLI restart?)',
|
|
656
|
+
'',
|
|
657
|
+
'ANTI-PATTERNS — emit ANY of these and the output is considered defective:',
|
|
658
|
+
'- "May require additional resources and infrastructure" (generic boilerplate)',
|
|
659
|
+
'- "May introduce additional complexity" (generic boilerplate)',
|
|
660
|
+
'- "May require additional security and privacy considerations" (generic boilerplate)',
|
|
661
|
+
'- "Can be implemented as a separate tool or as a plugin" (every option can — useless differentiation)',
|
|
662
|
+
'- HTTP verbs (`GET`, `POST`, `PUT`, `DELETE`) on `cli-*`, `mcp-*`, `file-*`, or `stdout-*` surfaces',
|
|
663
|
+
'- Questions about "documentation and support level", "user interaction", "monitoring and logging" (enterprise boilerplate, not SHRK-specific)',
|
|
664
|
+
'- Questions about "scalability" or "throughput" unless the task explicitly names a load target',
|
|
665
|
+
'',
|
|
666
|
+
'GOOD differentiator EXAMPLE: "Sidecar is a child process forked from `shrk watch`; lives with that process. MCP-tool-call alternative is a pull-only RPC the agent invokes when it wants a packet — no continuous process at all."',
|
|
667
|
+
'BAD differentiator EXAMPLE: "Can be implemented as a separate tool" (every candidate can).',
|
|
668
|
+
].join('\n');
|
|
669
|
+
const FOCUSED_ARCHITECTURE_POLISH_PREAMBLE = [
|
|
670
|
+
'You are a critic improving an architectural design brief for a SharkCraft repository.',
|
|
671
|
+
'You are given (a) the original deterministic context, (b) the first-pass JSON brief.',
|
|
672
|
+
'Return ONE improved JSON object using the same schema, then a one-paragraph Markdown summary. No preface.',
|
|
673
|
+
'',
|
|
674
|
+
'YOUR JOB — fix EACH of these defects if you see them:',
|
|
675
|
+
'1. Generic / repeated content in candidateArchitectures pros/cons.',
|
|
676
|
+
' - Every `uniquePros` and `uniqueCons` must contain at least one item no other option has.',
|
|
677
|
+
' - Drop any candidate that, after deduplication, has no unique pro or no unique con.',
|
|
678
|
+
'2. Wrong vocabulary in integrationPoints.surface.',
|
|
679
|
+
' - Replace any HTTP verb / generic surface ("CLI"/"MCP server") with the canonical kebab-case vocabulary: `cli-command`, `cli-watcher`, `mcp-tool-call`, `mcp-resource-read`, `file-read`, `file-write`, `stdout-stream`, `background-watcher`.',
|
|
680
|
+
' - Each integrationPoint must name an actual surface (e.g. `shrk context-feed`, `context-packet/next`), not just "GET".',
|
|
681
|
+
'3. No recommendedMvp picked, or `recommendation` field missing.',
|
|
682
|
+
' - Exactly ONE candidate gets `recommendation: "recommended"`. Others split between `possible-later` and `not-for-mvp`.',
|
|
683
|
+
' - Populate `recommendedMvp.architectureName` to match. Fill `recommendedMvp.explicitlyNotInScope` with at least 2 items.',
|
|
684
|
+
'4. firstSpike too vague.',
|
|
685
|
+
' - `proposedCommand`: an actual command line if any.',
|
|
686
|
+
' - `proposedFiles`: actual paths under .sharkcraft/.',
|
|
687
|
+
' - `schemaOutline`: a minimal JSON sketch (a few fields with types).',
|
|
688
|
+
' - `successCriteria`: observable bullets ("packet appears on stdout within 200ms of file save").',
|
|
689
|
+
'5. designQuestions / openQuestions polluted with generic enterprise boilerplate.',
|
|
690
|
+
' - Remove any question about "documentation and support level", "user interaction", "monitoring and logging", broad "scalability".',
|
|
691
|
+
' - Keep only SHRK-specific questions tied to the user task.',
|
|
692
|
+
'6. nonGoals empty.',
|
|
693
|
+
' - At least 2 explicit non-goals, e.g. "Editing provider send methods.", "Adding write paths to MCP.".',
|
|
694
|
+
'',
|
|
695
|
+
'PRESERVE: filesToInspect, relatedRules — only fix what is broken.',
|
|
696
|
+
'STRICT GROUNDING still applies: every file path, rule id, and command name must already appear in the original context.',
|
|
697
|
+
].join('\n');
|
|
698
|
+
const FOCUSED_INVESTIGATION_PREAMBLE = [
|
|
699
|
+
"You are an investigator. The user wants to *understand* something in a SharkCraft-instrumented repository, not change it yet.",
|
|
700
|
+
'Read the supplied code blocks as evidence. Hypotheses are welcome; certainty must be earned.',
|
|
701
|
+
'Output a Markdown report (no JSON required) with: (1) Restated question; (2) What the supplied context tells us; (3) Best current hypothesis with confidence; (4) Files to read next to confirm/refute; (5) Open questions.',
|
|
702
|
+
'STRICT GROUNDING: every path you cite must appear in the context.',
|
|
703
|
+
'DO NOT propose code changes — this is investigation only.',
|
|
704
|
+
].join(' ');
|
|
705
|
+
async function runFocusedMode(input) {
|
|
706
|
+
const index = await SemanticIndex.tryLoad(input.cwd);
|
|
707
|
+
if (!index) {
|
|
708
|
+
process.stderr.write('[smart-context] --focused / --tiny-only requires a semantic index. Run `shrk smart-context embeddings-build` first.\n');
|
|
709
|
+
return 1;
|
|
710
|
+
}
|
|
711
|
+
const auto = classifyTask(input.task);
|
|
712
|
+
const taskType = input.options.taskTypeOverride ?? auto.type;
|
|
713
|
+
const classification = input.options.taskTypeOverride
|
|
714
|
+
? { type: input.options.taskTypeOverride, confidence: 1, signals: ['override'], scores: {} }
|
|
715
|
+
: auto;
|
|
716
|
+
if (!input.options.json) {
|
|
717
|
+
const topSignals = classification.signals.slice(0, 4).join(', ') || 'none';
|
|
718
|
+
process.stderr.write(`[smart-context] task type: ${taskType} (confidence ${classification.confidence.toFixed(2)}, signals: ${topSignals})\n`);
|
|
719
|
+
if (taskType === TaskType.Architecture) {
|
|
720
|
+
process.stderr.write('[smart-context] routing through architecture/design prompt — files listed will be "to inspect", not "to edit".\n');
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
// --since: build a path allowlist of changed files + one-hop graph
|
|
724
|
+
// neighbors so the focused bundle stays anchored to the diff.
|
|
725
|
+
let pathAllowlist;
|
|
726
|
+
if (input.options.sinceRef) {
|
|
727
|
+
pathAllowlist = collectChangedPathsWithNeighbors(input.cwd, input.options.sinceRef);
|
|
728
|
+
if (!input.options.json) {
|
|
729
|
+
if (pathAllowlist.length === 0) {
|
|
730
|
+
process.stderr.write(`[smart-context] --since ${input.options.sinceRef}: no changed files found (or git unavailable). Ignoring the allowlist.\n`);
|
|
731
|
+
pathAllowlist = undefined;
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
process.stderr.write(`[smart-context] --since ${input.options.sinceRef}: restricting to ${pathAllowlist.length} changed-or-neighbor file(s).\n`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
else if (pathAllowlist.length === 0) {
|
|
738
|
+
pathAllowlist = undefined;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
if (!input.options.json) {
|
|
742
|
+
process.stderr.write('[smart-context] building focused context (BGE multi-cycle re-ranking)…\n');
|
|
743
|
+
}
|
|
744
|
+
const focused = await buildFocusedContext({
|
|
745
|
+
cwd: input.cwd,
|
|
746
|
+
task: input.task,
|
|
747
|
+
index,
|
|
748
|
+
rules: input.seed.packet.relevantRules,
|
|
749
|
+
verificationCommands: input.seed.packet.verificationCommands,
|
|
750
|
+
docCandidatePool: input.seed.documentationHits.map((h) => ({
|
|
751
|
+
path: h.path,
|
|
752
|
+
line: h.line,
|
|
753
|
+
snippet: h.snippet,
|
|
754
|
+
})),
|
|
755
|
+
...(pathAllowlist ? { pathAllowlist } : {}),
|
|
756
|
+
});
|
|
757
|
+
if (!input.options.json) {
|
|
758
|
+
process.stderr.write(`[smart-context] focused bundle: ${focused.files.length} files, ${focused.docHits.length} doc hits, ${focused.rules.length} rules (~${focused.approxTokens} tokens).\n`);
|
|
759
|
+
}
|
|
760
|
+
if (input.options.tinyOnly) {
|
|
761
|
+
const plan = renderTinyPlan(focused, taskType);
|
|
762
|
+
const envelope = buildEnvelope({
|
|
763
|
+
task: input.task,
|
|
764
|
+
seed: input.seed,
|
|
765
|
+
mode: input.options.mode,
|
|
766
|
+
ai: {
|
|
767
|
+
content: plan,
|
|
768
|
+
model: index.modelName,
|
|
769
|
+
finishReason: null,
|
|
770
|
+
usage: null,
|
|
771
|
+
providerId: 'tiny-bge',
|
|
772
|
+
},
|
|
773
|
+
});
|
|
774
|
+
if (input.options.save) {
|
|
775
|
+
const saved = saveEnvelope(input.cwd, envelope);
|
|
776
|
+
writeSavedNotice(saved, input.options.json, envelope);
|
|
777
|
+
return 0;
|
|
778
|
+
}
|
|
779
|
+
writeEnvelope(envelope, input.options.json, input.options.debug);
|
|
780
|
+
return 0;
|
|
781
|
+
}
|
|
782
|
+
// --focused (without --tiny-only): single LLM call with the tight bundle.
|
|
783
|
+
const messages = buildFocusedMessages(focused, input.options.mode, taskType);
|
|
784
|
+
logPromptToStderr(`focused-${input.options.mode}-${taskType}`, messages, input.options);
|
|
785
|
+
if (input.options.dryRun) {
|
|
786
|
+
writeDryRun(messages, input.options.mode, displayProviderName(input.options.provider));
|
|
787
|
+
return 0;
|
|
788
|
+
}
|
|
789
|
+
const selection = selectAiProvider(input.options.provider);
|
|
790
|
+
if (!selection.provider) {
|
|
791
|
+
process.stderr.write(providerMissingMessage(selection.requested) + '\n');
|
|
792
|
+
return 1;
|
|
793
|
+
}
|
|
794
|
+
if (input.options.model)
|
|
795
|
+
selection.provider.configure({ model: input.options.model });
|
|
796
|
+
if (!input.options.json) {
|
|
797
|
+
process.stdout.write(`(provider: ${selection.provider.id}, strategy: focused)\n`);
|
|
798
|
+
}
|
|
799
|
+
const aiResult = await callProvider({
|
|
800
|
+
provider: selection.provider,
|
|
801
|
+
messages,
|
|
802
|
+
maxTokens: input.options.maxTokens,
|
|
803
|
+
model: input.options.model,
|
|
804
|
+
...(input.options.stream && !input.options.json
|
|
805
|
+
? {
|
|
806
|
+
onTokenStream: (chunk) => {
|
|
807
|
+
process.stderr.write(chunk);
|
|
808
|
+
},
|
|
809
|
+
}
|
|
810
|
+
: {}),
|
|
811
|
+
});
|
|
812
|
+
if (input.options.stream && !input.options.json)
|
|
813
|
+
process.stderr.write('\n');
|
|
814
|
+
if (!aiResult.ok) {
|
|
815
|
+
printError(aiResult.error);
|
|
816
|
+
return 1;
|
|
817
|
+
}
|
|
818
|
+
// Polish pass — only for architecture tasks in plan mode, default-on,
|
|
819
|
+
// user can opt out with --no-polish. This is a critic call: it takes
|
|
820
|
+
// the first response and the original context and improves on it.
|
|
821
|
+
let finalAi = aiResult.value;
|
|
822
|
+
let polishMessages = null;
|
|
823
|
+
const shouldPolish = taskType === TaskType.Architecture &&
|
|
824
|
+
input.options.mode === 'plan' &&
|
|
825
|
+
!input.options.noPolish;
|
|
826
|
+
if (shouldPolish) {
|
|
827
|
+
polishMessages = buildPolishMessages(focused, finalAi.content);
|
|
828
|
+
logPromptToStderr(`focused-architecture-polish`, polishMessages, input.options);
|
|
829
|
+
if (!input.options.json) {
|
|
830
|
+
process.stderr.write('[smart-context] polish pass — critic refining the design brief…\n');
|
|
831
|
+
}
|
|
832
|
+
const polish = await callProvider({
|
|
833
|
+
provider: selection.provider,
|
|
834
|
+
messages: polishMessages,
|
|
835
|
+
maxTokens: input.options.maxTokens,
|
|
836
|
+
model: input.options.model,
|
|
837
|
+
});
|
|
838
|
+
if (polish.ok) {
|
|
839
|
+
finalAi = polish.value;
|
|
840
|
+
}
|
|
841
|
+
else {
|
|
842
|
+
// Polish failure is non-fatal — we still have the first-pass output.
|
|
843
|
+
if (!input.options.json) {
|
|
844
|
+
process.stderr.write(`[smart-context] polish pass failed (${polish.error.message.slice(0, 100)}); keeping first-pass plan.\n`);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
if (input.options.saveConversation) {
|
|
849
|
+
const turns = [
|
|
850
|
+
{
|
|
851
|
+
stage: 'single',
|
|
852
|
+
request: { messages: messages.map((m) => ({ role: m.role, content: m.content })) },
|
|
853
|
+
response: {
|
|
854
|
+
content: aiResult.value.content,
|
|
855
|
+
model: aiResult.value.model,
|
|
856
|
+
finishReason: aiResult.value.finishReason,
|
|
857
|
+
usage: aiResult.value.usage,
|
|
858
|
+
},
|
|
859
|
+
},
|
|
860
|
+
];
|
|
861
|
+
if (polishMessages && finalAi !== aiResult.value) {
|
|
862
|
+
turns.push({
|
|
863
|
+
stage: 'stage2',
|
|
864
|
+
request: { messages: polishMessages.map((m) => ({ role: m.role, content: m.content })) },
|
|
865
|
+
response: {
|
|
866
|
+
content: finalAi.content,
|
|
867
|
+
model: finalAi.model,
|
|
868
|
+
finishReason: finalAi.finishReason,
|
|
869
|
+
usage: finalAi.usage,
|
|
870
|
+
},
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
const path = writeConversationFile({
|
|
874
|
+
cwd: input.cwd,
|
|
875
|
+
task: input.task,
|
|
876
|
+
mode: input.options.mode,
|
|
877
|
+
options: input.options,
|
|
878
|
+
providerId: finalAi.providerId,
|
|
879
|
+
model: finalAi.model,
|
|
880
|
+
turns,
|
|
881
|
+
});
|
|
882
|
+
if (!input.options.json) {
|
|
883
|
+
process.stderr.write(`[smart-context] conversation saved → ${path}\n`);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
// Parse the LLM JSON output and walk it for unverified paths. Non-fatal
|
|
887
|
+
// if parsing fails — focused mode is permissive about shape. When we do
|
|
888
|
+
// get a parsed plan, attach it to the envelope so `shrk spike` and
|
|
889
|
+
// downstream tooling can act on it.
|
|
890
|
+
let parsedPlan = tryParseFocusedJson(finalAi.content);
|
|
891
|
+
let unverifiedPaths = parsedPlan ? collectUnverifiedPathsFromJson(input.cwd, parsedPlan) : [];
|
|
892
|
+
let pathRetried = false;
|
|
893
|
+
// Path-aware retry: if the parsed plan cites paths that don't exist,
|
|
894
|
+
// one extra LLM call with the offending paths called out usually
|
|
895
|
+
// fixes it. Capped at one retry to avoid spinning. Skipped under
|
|
896
|
+
// --no-polish (the user opted out of extra LLM work).
|
|
897
|
+
if (parsedPlan !== null &&
|
|
898
|
+
unverifiedPaths.length > 0 &&
|
|
899
|
+
!input.options.noPolish &&
|
|
900
|
+
input.options.mode === 'plan') {
|
|
901
|
+
if (!input.options.json) {
|
|
902
|
+
process.stderr.write(`[smart-context] ⚠ ${unverifiedPaths.length} unverified path(s) — retrying once with explicit corrections…\n`);
|
|
903
|
+
}
|
|
904
|
+
const fixupMessages = buildPathFixupMessages(focused, finalAi.content, unverifiedPaths);
|
|
905
|
+
logPromptToStderr(`focused-${input.options.mode}-path-fixup`, fixupMessages, input.options);
|
|
906
|
+
const fixup = await callProvider({
|
|
907
|
+
provider: selection.provider,
|
|
908
|
+
messages: fixupMessages,
|
|
909
|
+
maxTokens: input.options.maxTokens,
|
|
910
|
+
model: input.options.model,
|
|
911
|
+
});
|
|
912
|
+
if (fixup.ok) {
|
|
913
|
+
const reparsed = tryParseFocusedJson(fixup.value.content);
|
|
914
|
+
if (reparsed) {
|
|
915
|
+
const reUnverified = collectUnverifiedPathsFromJson(input.cwd, reparsed);
|
|
916
|
+
// Accept the retry only if it reduced unverified-path count.
|
|
917
|
+
if (reUnverified.length < unverifiedPaths.length) {
|
|
918
|
+
finalAi = fixup.value;
|
|
919
|
+
parsedPlan = reparsed;
|
|
920
|
+
unverifiedPaths = reUnverified;
|
|
921
|
+
pathRetried = true;
|
|
922
|
+
if (!input.options.json) {
|
|
923
|
+
process.stderr.write(`[smart-context] path-aware retry succeeded — ${unverifiedPaths.length} unverified path(s) remaining.\n`);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
else if (!input.options.json) {
|
|
927
|
+
process.stderr.write(`[smart-context] path-aware retry did not improve (${reUnverified.length} unverified); keeping previous response.\n`);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
else if (!input.options.json) {
|
|
932
|
+
process.stderr.write(`[smart-context] path-aware retry failed (${fixup.error.message.slice(0, 100)}); keeping previous response.\n`);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
if (!input.options.json) {
|
|
936
|
+
if (parsedPlan === null) {
|
|
937
|
+
process.stderr.write('[smart-context] focused response did not contain a parseable JSON block; saving as raw text.\n');
|
|
938
|
+
}
|
|
939
|
+
else if (unverifiedPaths.length > 0) {
|
|
940
|
+
process.stderr.write(`[smart-context] ⚠ ${unverifiedPaths.length} unverified path(s) remain (possible hallucination): ${unverifiedPaths
|
|
941
|
+
.slice(0, 4)
|
|
942
|
+
.map((u) => u.path)
|
|
943
|
+
.join(', ')}${unverifiedPaths.length > 4 ? ', …' : ''}\n`);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
const envelope = buildEnvelope({
|
|
947
|
+
task: input.task,
|
|
948
|
+
seed: input.seed,
|
|
949
|
+
ai: finalAi,
|
|
950
|
+
mode: input.options.mode,
|
|
951
|
+
aiPlan: {
|
|
952
|
+
strategy: shouldPolish && finalAi !== aiResult.value ? 'focused-polished' : 'focused',
|
|
953
|
+
requestedProvider: input.options.provider ?? 'auto',
|
|
954
|
+
taskType,
|
|
955
|
+
...(parsedPlan ? { focusedParsedPlan: parsedPlan } : {}),
|
|
956
|
+
...(unverifiedPaths.length > 0 ? { unverifiedPaths } : {}),
|
|
957
|
+
...(pathRetried ? { warnings: ['Path-aware retry was applied.'] } : {}),
|
|
958
|
+
},
|
|
959
|
+
});
|
|
960
|
+
if (input.options.save) {
|
|
961
|
+
const saved = saveEnvelope(input.cwd, envelope);
|
|
962
|
+
writeSavedNotice(saved, input.options.json, envelope);
|
|
963
|
+
return 0;
|
|
964
|
+
}
|
|
965
|
+
writeEnvelope(envelope, input.options.json, input.options.debug);
|
|
966
|
+
return 0;
|
|
967
|
+
}
|
|
968
|
+
function buildPathFixupMessages(focused, firstPassContent, unverified) {
|
|
969
|
+
const context = renderFocusedContextForPrompt(focused);
|
|
970
|
+
const list = unverified.map((u) => ` - "${u.path}" (at ${u.where})`).join('\n');
|
|
971
|
+
const preamble = [
|
|
972
|
+
'You are a critic fixing path hallucinations in a focused plan you previously produced.',
|
|
973
|
+
'Your previous response cited file paths that DO NOT EXIST in the supplied context.',
|
|
974
|
+
'Return ONE corrected JSON object using the same schema as the previous response — no preface, no markdown around the JSON.',
|
|
975
|
+
'',
|
|
976
|
+
'PATHS TO REPLACE (these were invented; remove them or substitute paths that appear verbatim in the context):',
|
|
977
|
+
list,
|
|
978
|
+
'',
|
|
979
|
+
'RULES:',
|
|
980
|
+
'- Every `path` field MUST appear verbatim in the supplied context (`# Most relevant code` headings, `imports:`, `imported by:`, or `Related docs`).',
|
|
981
|
+
'- If you cannot find a real replacement, OMIT the offending entry rather than inventing another one.',
|
|
982
|
+
'- Do not change other fields beyond what is necessary to remove the hallucinated paths.',
|
|
983
|
+
].join('\n');
|
|
984
|
+
const systemContext = [
|
|
985
|
+
context,
|
|
986
|
+
'',
|
|
987
|
+
'# Previous response (fix the paths in here)',
|
|
988
|
+
'```',
|
|
989
|
+
firstPassContent.trim(),
|
|
990
|
+
'```',
|
|
991
|
+
].join('\n');
|
|
992
|
+
return buildPromptMessages({
|
|
993
|
+
systemPreamble: `${preamble}\n\nThe user's task is: ${focused.task}`,
|
|
994
|
+
context: systemContext,
|
|
995
|
+
task: focused.task,
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
function buildPolishMessages(focused, firstPassContent) {
|
|
999
|
+
const context = renderFocusedContextForPrompt(focused);
|
|
1000
|
+
// Bundle the first-pass output INTO the system context so the critic
|
|
1001
|
+
// sees both the deterministic context and the candidate brief in one
|
|
1002
|
+
// turn. The user message restates the task to keep small-model focus.
|
|
1003
|
+
const systemContext = [
|
|
1004
|
+
context,
|
|
1005
|
+
'',
|
|
1006
|
+
'# First-pass design brief (improve this)',
|
|
1007
|
+
'```json-or-markdown',
|
|
1008
|
+
firstPassContent.trim(),
|
|
1009
|
+
'```',
|
|
1010
|
+
].join('\n');
|
|
1011
|
+
return buildPromptMessages({
|
|
1012
|
+
systemPreamble: `${FOCUSED_ARCHITECTURE_POLISH_PREAMBLE}\n\nThe user's task is: ${focused.task}`,
|
|
1013
|
+
context: systemContext,
|
|
1014
|
+
task: focused.task,
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
function buildFocusedMessages(focused, mode, taskType) {
|
|
1018
|
+
const preamble = pickPreamble(mode, taskType);
|
|
1019
|
+
// The task lives in THREE places for small-model anchoring:
|
|
1020
|
+
// 1. Inside the preamble's opening clause.
|
|
1021
|
+
// 2. At the top of the system context (`# TASK` block).
|
|
1022
|
+
// 3. As the literal user message.
|
|
1023
|
+
const context = renderFocusedContextForPrompt(focused);
|
|
1024
|
+
return buildPromptMessages({
|
|
1025
|
+
systemPreamble: `${preamble}\n\nThe user's task is: ${focused.task}`,
|
|
1026
|
+
context,
|
|
1027
|
+
task: focused.task,
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
function pickPreamble(mode, taskType) {
|
|
1031
|
+
if (taskType === TaskType.Architecture)
|
|
1032
|
+
return FOCUSED_ARCHITECTURE_PREAMBLE;
|
|
1033
|
+
if (taskType === TaskType.Investigation)
|
|
1034
|
+
return FOCUSED_INVESTIGATION_PREAMBLE;
|
|
1035
|
+
return mode === 'plan' ? FOCUSED_PLAN_PREAMBLE : FOCUSED_BRIEF_PREAMBLE;
|
|
1036
|
+
}
|
|
1037
|
+
function renderTinyPlan(focused, taskType) {
|
|
1038
|
+
if (taskType === TaskType.Architecture)
|
|
1039
|
+
return renderTinyArchitecturePlan(focused);
|
|
1040
|
+
if (taskType === TaskType.Investigation)
|
|
1041
|
+
return renderTinyInvestigationPlan(focused);
|
|
1042
|
+
return renderTinyImplementationPlan(focused);
|
|
1043
|
+
}
|
|
1044
|
+
function renderTinyImplementationPlan(focused) {
|
|
1045
|
+
const lines = [];
|
|
1046
|
+
lines.push(`# Tiny-AI Plan — ${focused.task}`);
|
|
1047
|
+
lines.push('');
|
|
1048
|
+
lines.push(`_Generated entirely from the BGE-ranked focused context (${focused.model})._`);
|
|
1049
|
+
lines.push(`_Approx ${focused.approxTokens} input tokens, 0 LLM tokens, 0 network calls._`);
|
|
1050
|
+
lines.push('');
|
|
1051
|
+
lines.push('## Task');
|
|
1052
|
+
lines.push(focused.task);
|
|
1053
|
+
lines.push('');
|
|
1054
|
+
if (focused.files.length > 0) {
|
|
1055
|
+
lines.push('## Files to read (semantic match → review in order)');
|
|
1056
|
+
for (const file of focused.files) {
|
|
1057
|
+
lines.push(`- \`${file.path}\` (file-sim ${file.fileSimilarity.toFixed(3)}) — ${file.blocks.length > 0
|
|
1058
|
+
? `top: \`${file.blocks[0].name}\` ${describeKind(file.blocks[0].kind)}`
|
|
1059
|
+
: 'overview'}`);
|
|
1060
|
+
}
|
|
1061
|
+
lines.push('');
|
|
1062
|
+
const editable = focused.files
|
|
1063
|
+
.filter((f) => isLikelyEditable(f.path))
|
|
1064
|
+
.slice(0, 5);
|
|
1065
|
+
if (editable.length > 0) {
|
|
1066
|
+
lines.push('## Likely files to modify (filtered: source files only)');
|
|
1067
|
+
for (const file of editable) {
|
|
1068
|
+
const why = file.blocks[0]?.name
|
|
1069
|
+
? `exposes \`${file.blocks[0].name}\` which matches the task semantically (sim ${file.blocks[0].similarity.toFixed(3)})`
|
|
1070
|
+
: `semantic match`;
|
|
1071
|
+
lines.push(`- \`${file.path}\` — ${why}`);
|
|
1072
|
+
}
|
|
1073
|
+
lines.push('');
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
if (focused.rules.length > 0) {
|
|
1077
|
+
lines.push('## Rules to respect (cite by id)');
|
|
1078
|
+
for (const r of focused.rules) {
|
|
1079
|
+
lines.push(`- \`${r.id}\` — ${r.title}`);
|
|
1080
|
+
if (r.summary)
|
|
1081
|
+
lines.push(` ${r.summary}`);
|
|
1082
|
+
}
|
|
1083
|
+
lines.push('');
|
|
1084
|
+
}
|
|
1085
|
+
if (focused.docHits.length > 0) {
|
|
1086
|
+
lines.push('## Relevant prior writing');
|
|
1087
|
+
for (const h of focused.docHits) {
|
|
1088
|
+
lines.push(`- \`${h.path}\`:${h.line} — ${h.snippet}`);
|
|
1089
|
+
}
|
|
1090
|
+
lines.push('');
|
|
1091
|
+
}
|
|
1092
|
+
if (focused.verificationCommands.length > 0) {
|
|
1093
|
+
lines.push('## Validation commands (run after your change)');
|
|
1094
|
+
for (const c of focused.verificationCommands)
|
|
1095
|
+
lines.push(`- \`${c}\``);
|
|
1096
|
+
lines.push('');
|
|
1097
|
+
}
|
|
1098
|
+
lines.push('## Suggested approach');
|
|
1099
|
+
lines.push(`1. Read the candidate files in the order above; pay extra attention to the highlighted declarations.`);
|
|
1100
|
+
lines.push(`2. Make the change in the "likely files to modify" list. Stay within the rules cited above.`);
|
|
1101
|
+
lines.push(`3. Run the validation commands; iterate until clean.`);
|
|
1102
|
+
lines.push('');
|
|
1103
|
+
lines.push('## Handoff');
|
|
1104
|
+
lines.push(`This plan was assembled deterministically by SharkCraft's local embedding model — it tells you *where to look* and *what to respect*, but it does not invent implementation details. For a richer plan, re-run without \`--tiny-only\` to polish it with the configured generative provider.`);
|
|
1105
|
+
lines.push('');
|
|
1106
|
+
return lines.join('\n');
|
|
1107
|
+
}
|
|
1108
|
+
function renderTinyArchitecturePlan(focused) {
|
|
1109
|
+
const lines = [];
|
|
1110
|
+
lines.push(`# Tiny-AI Design Brief — ${focused.task}`);
|
|
1111
|
+
lines.push('');
|
|
1112
|
+
lines.push(`_Generated entirely from the BGE-ranked focused context (${focused.model})._`);
|
|
1113
|
+
lines.push(`_Approx ${focused.approxTokens} input tokens, 0 LLM tokens, 0 network calls._`);
|
|
1114
|
+
lines.push('_Task classified as **architecture / workflow design** — this brief intentionally avoids prescribing file edits._');
|
|
1115
|
+
lines.push('');
|
|
1116
|
+
lines.push('## Task (as stated)');
|
|
1117
|
+
lines.push(focused.task);
|
|
1118
|
+
lines.push('');
|
|
1119
|
+
lines.push('## ⚠ This task is abstract');
|
|
1120
|
+
lines.push(`It asks _what_ to build at the system level. Resolve the design questions below before opening files for edit. The local model is not equipped to invent a concrete plan deterministically; the items here are *patterns to study* and *questions to answer*, not files to modify.`);
|
|
1121
|
+
lines.push('');
|
|
1122
|
+
lines.push('## Design questions to answer first');
|
|
1123
|
+
lines.push('- **Context budget** — how much information per update? token / size estimate?');
|
|
1124
|
+
lines.push('- **Update trigger** — what causes a new packet to be emitted? (file change, time, user event, graph drift)');
|
|
1125
|
+
lines.push('- **Transport** — MCP read-only tool, file-system packet, CLI stdout JSONL, stdin protocol, in-process subscriber?');
|
|
1126
|
+
lines.push('- **Persistence** — where does the agent-facing state live across restarts? `.sharkcraft/`? in-memory only?');
|
|
1127
|
+
lines.push('- **Handoff** — how does the consuming agent discover updates? polling vs push?');
|
|
1128
|
+
lines.push('- **Lifecycle** — who starts/stops the parallel process; what happens on crash?');
|
|
1129
|
+
lines.push('');
|
|
1130
|
+
lines.push('## Patterns worth studying in this repo (do not assume they should be edited)');
|
|
1131
|
+
if (focused.files.length === 0) {
|
|
1132
|
+
lines.push('- _No strong semantic matches found in the indexed code. Consider exploring `packages/mcp-server/`, `packages/cli/src/dashboard/`, and `packages/inspector/src/` manually._');
|
|
1133
|
+
}
|
|
1134
|
+
else {
|
|
1135
|
+
for (const file of focused.files.slice(0, 6)) {
|
|
1136
|
+
const top = file.blocks[0];
|
|
1137
|
+
lines.push(`- \`${file.path}\` (file-sim ${file.fileSimilarity.toFixed(3)})${top ? ` — see \`${top.name}\` ${describeKind(top.kind)}` : ''}`);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
lines.push('');
|
|
1141
|
+
lines.push('## Candidate integration shapes (informational; not a recommendation)');
|
|
1142
|
+
lines.push('- **Sidecar process** — long-running child that writes context packets to `.sharkcraft/context-stream/` on triggers; consumer tails the directory.');
|
|
1143
|
+
lines.push('- **Watch-mode CLI** — `shrk watch --emit-context` subcommand that emits JSONL on stdout each time files change.');
|
|
1144
|
+
lines.push('- **MCP context-packet tool** — a new read-only MCP tool the agent polls; the tool computes the next packet on demand.');
|
|
1145
|
+
lines.push('- **File-system packet** — periodic dump of a summary to `.sharkcraft/agent-feed.json`; agent diff-reads it.');
|
|
1146
|
+
lines.push('Each shape implies different answers to the questions above.');
|
|
1147
|
+
lines.push('');
|
|
1148
|
+
if (focused.rules.length > 0) {
|
|
1149
|
+
lines.push('## Rules to respect when you do start');
|
|
1150
|
+
for (const r of focused.rules) {
|
|
1151
|
+
lines.push(`- \`${r.id}\` — ${r.title}`);
|
|
1152
|
+
if (r.summary)
|
|
1153
|
+
lines.push(` ${r.summary}`);
|
|
1154
|
+
}
|
|
1155
|
+
lines.push('');
|
|
1156
|
+
}
|
|
1157
|
+
lines.push('## Non-goals (until proven otherwise)');
|
|
1158
|
+
lines.push('- Editing provider `send` methods or model adapters.');
|
|
1159
|
+
lines.push('- Adding write paths to MCP (read-only by contract).');
|
|
1160
|
+
lines.push('- Cross-package boundary changes; pick one host package first.');
|
|
1161
|
+
lines.push('');
|
|
1162
|
+
lines.push('## Recommended first spike (smallest experiment)');
|
|
1163
|
+
lines.push(`1. Pick ONE integration shape from above based on which design question scares you most.`);
|
|
1164
|
+
lines.push(`2. Write a hello-world version that emits one fake packet per second to its chosen transport.`);
|
|
1165
|
+
lines.push(`3. Wire one consumer (the agent, or a script standing in for it) to receive it. Measure latency + size.`);
|
|
1166
|
+
lines.push(`4. Only AFTER that measurement, commit to a final design and write a real plan.`);
|
|
1167
|
+
lines.push('');
|
|
1168
|
+
lines.push('## Handoff');
|
|
1169
|
+
lines.push(`This brief is intentionally light on file-edit suggestions. Re-run without \`--tiny-only\` once you've chosen a candidate shape — the LLM can then write a concrete plan grounded on your decision.`);
|
|
1170
|
+
lines.push('');
|
|
1171
|
+
return lines.join('\n');
|
|
1172
|
+
}
|
|
1173
|
+
function renderTinyInvestigationPlan(focused) {
|
|
1174
|
+
const lines = [];
|
|
1175
|
+
lines.push(`# Tiny-AI Investigation Notes — ${focused.task}`);
|
|
1176
|
+
lines.push('');
|
|
1177
|
+
lines.push(`_Generated entirely from the BGE-ranked focused context (${focused.model}). 0 LLM tokens._`);
|
|
1178
|
+
lines.push('_Task classified as **investigation** — this is a reading list, not a plan to modify code._');
|
|
1179
|
+
lines.push('');
|
|
1180
|
+
lines.push('## Question');
|
|
1181
|
+
lines.push(focused.task);
|
|
1182
|
+
lines.push('');
|
|
1183
|
+
if (focused.files.length > 0) {
|
|
1184
|
+
lines.push('## Files to read (ranked by semantic match)');
|
|
1185
|
+
for (const file of focused.files) {
|
|
1186
|
+
lines.push(`- \`${file.path}\` (sim ${file.fileSimilarity.toFixed(3)})${file.blocks[0] ? ` — start with \`${file.blocks[0].name}\`` : ''}`);
|
|
1187
|
+
}
|
|
1188
|
+
lines.push('');
|
|
1189
|
+
}
|
|
1190
|
+
if (focused.docHits.length > 0) {
|
|
1191
|
+
lines.push('## Documentation pointers');
|
|
1192
|
+
for (const h of focused.docHits) {
|
|
1193
|
+
lines.push(`- \`${h.path}\`:${h.line} — ${h.snippet}`);
|
|
1194
|
+
}
|
|
1195
|
+
lines.push('');
|
|
1196
|
+
}
|
|
1197
|
+
lines.push('## Suggested approach');
|
|
1198
|
+
lines.push('1. Read the candidate files in order; form a hypothesis.');
|
|
1199
|
+
lines.push('2. Use `shrk graph why <a> <b>` to confirm structural relationships.');
|
|
1200
|
+
lines.push('3. When confident, re-run smart-context with a concrete *change* task — that will route through the implementation prompt.');
|
|
1201
|
+
lines.push('');
|
|
1202
|
+
return lines.join('\n');
|
|
1203
|
+
}
|
|
1204
|
+
function describeKind(kind) {
|
|
1205
|
+
switch (kind) {
|
|
1206
|
+
case DeclarationKind.Interface:
|
|
1207
|
+
return 'interface';
|
|
1208
|
+
case DeclarationKind.Type:
|
|
1209
|
+
return 'type alias';
|
|
1210
|
+
case DeclarationKind.Enum:
|
|
1211
|
+
return 'enum';
|
|
1212
|
+
case DeclarationKind.Class:
|
|
1213
|
+
return 'class';
|
|
1214
|
+
case DeclarationKind.Function:
|
|
1215
|
+
return 'function';
|
|
1216
|
+
case DeclarationKind.Const:
|
|
1217
|
+
return 'export';
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
function isLikelyEditable(path) {
|
|
1221
|
+
if (!/\.(ts|tsx|js|jsx)$/.test(path))
|
|
1222
|
+
return false;
|
|
1223
|
+
if (path.includes('__tests__/'))
|
|
1224
|
+
return false;
|
|
1225
|
+
if (/\.(test|spec)\.[jt]sx?$/.test(path))
|
|
1226
|
+
return false;
|
|
1227
|
+
if (path.endsWith('.d.ts'))
|
|
1228
|
+
return false;
|
|
1229
|
+
return true;
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Toggle to disable auto-refresh and plan-cache during automated tests
|
|
1233
|
+
* (which often run against the real repo root and would trigger a model
|
|
1234
|
+
* download). Set `SHRK_DISABLE_AUTO_AI=1` to opt out from auto-AI side
|
|
1235
|
+
* effects without touching the rest of the flow.
|
|
1236
|
+
*/
|
|
1237
|
+
function isSemanticAutomationDisabled() {
|
|
1238
|
+
return (process.env.SHRK_DISABLE_AUTO_AI ?? '').trim().length > 0;
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Returns the list of repo-relative paths touched since `gitRef`,
|
|
1242
|
+
* expanded with one-hop graph neighbors (importers + importees) so
|
|
1243
|
+
* the focused bundle covers the diff *and* the places it likely
|
|
1244
|
+
* ripples through. Empty array on git failure or no-graph.
|
|
1245
|
+
*/
|
|
1246
|
+
function collectChangedPathsWithNeighbors(cwd, gitRef) {
|
|
1247
|
+
// Use `node:child_process` spawnSync (works under both Bun and Node)
|
|
1248
|
+
// instead of `Bun.spawnSync` so the CLI runs cleanly on a pure-Node
|
|
1249
|
+
// runtime after `npm i -g @shrkcrft/cli`. The compat-node preflight
|
|
1250
|
+
// gate flags `Bun.*` direct usages as publish blockers.
|
|
1251
|
+
let changed;
|
|
1252
|
+
try {
|
|
1253
|
+
const out = spawnSync('git', ['-C', cwd, 'diff', '--name-only', `${gitRef}...HEAD`], {
|
|
1254
|
+
encoding: 'utf8',
|
|
1255
|
+
});
|
|
1256
|
+
if (out.status !== 0)
|
|
1257
|
+
return [];
|
|
1258
|
+
changed = (out.stdout ?? '')
|
|
1259
|
+
.split('\n')
|
|
1260
|
+
.map((s) => s.trim())
|
|
1261
|
+
.filter((s) => s.length > 0);
|
|
1262
|
+
}
|
|
1263
|
+
catch {
|
|
1264
|
+
return [];
|
|
1265
|
+
}
|
|
1266
|
+
if (changed.length === 0)
|
|
1267
|
+
return [];
|
|
1268
|
+
// Also include uncommitted changes — agent feedback should reflect
|
|
1269
|
+
// the *current* working tree, not just committed deltas.
|
|
1270
|
+
try {
|
|
1271
|
+
const out = spawnSync('git', ['-C', cwd, 'diff', '--name-only', 'HEAD'], {
|
|
1272
|
+
encoding: 'utf8',
|
|
1273
|
+
});
|
|
1274
|
+
if (out.status === 0) {
|
|
1275
|
+
const uncommitted = (out.stdout ?? '')
|
|
1276
|
+
.split('\n')
|
|
1277
|
+
.map((s) => s.trim())
|
|
1278
|
+
.filter((s) => s.length > 0);
|
|
1279
|
+
for (const p of uncommitted)
|
|
1280
|
+
if (!changed.includes(p))
|
|
1281
|
+
changed.push(p);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
catch {
|
|
1285
|
+
// ignore
|
|
1286
|
+
}
|
|
1287
|
+
const set = new Set(changed);
|
|
1288
|
+
// One-hop graph expansion if the graph is fresh.
|
|
1289
|
+
try {
|
|
1290
|
+
const store = new GraphStore(cwd);
|
|
1291
|
+
if (store.exists()) {
|
|
1292
|
+
const api = GraphQueryApi.fromStore(cwd);
|
|
1293
|
+
for (const path of changed) {
|
|
1294
|
+
const file = api.findFile(path);
|
|
1295
|
+
if (!file)
|
|
1296
|
+
continue;
|
|
1297
|
+
for (const dep of api.importsFrom(file.id)) {
|
|
1298
|
+
if (dep.path)
|
|
1299
|
+
set.add(dep.path);
|
|
1300
|
+
}
|
|
1301
|
+
for (const importer of api.importersOf(file.id)) {
|
|
1302
|
+
if (importer.path)
|
|
1303
|
+
set.add(importer.path);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
catch {
|
|
1309
|
+
// graph optional — ok to skip
|
|
1310
|
+
}
|
|
1311
|
+
return [...set];
|
|
1312
|
+
}
|
|
1313
|
+
async function tryLoadSemanticHits(cwd, task, k, options) {
|
|
1314
|
+
if (isSemanticAutomationDisabled()) {
|
|
1315
|
+
return { hits: [], model: null, index: null };
|
|
1316
|
+
}
|
|
1317
|
+
try {
|
|
1318
|
+
const index = await SemanticIndex.tryLoad(cwd);
|
|
1319
|
+
if (!index) {
|
|
1320
|
+
maybePrintMissingIndexHint(options);
|
|
1321
|
+
return { hits: [], model: null, index: null };
|
|
1322
|
+
}
|
|
1323
|
+
if (!options.noRefreshIndex && !options.dryRun) {
|
|
1324
|
+
await maybeAutoRefresh(cwd, index, options);
|
|
1325
|
+
}
|
|
1326
|
+
const hits = await index.searchFiles(task, k);
|
|
1327
|
+
return { hits, model: index.modelName, index };
|
|
1328
|
+
}
|
|
1329
|
+
catch {
|
|
1330
|
+
return { hits: [], model: null, index: null };
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
async function lookupPlanCache(cwd, task, options) {
|
|
1334
|
+
try {
|
|
1335
|
+
const index = await SemanticIndex.tryLoad(cwd);
|
|
1336
|
+
if (!index)
|
|
1337
|
+
return { replay: null, reference: null, embedding: null, index: null };
|
|
1338
|
+
const embedding = await index.embed(task);
|
|
1339
|
+
const hits = PlanCache.findSimilar(cwd, embedding, {
|
|
1340
|
+
model: index.modelName,
|
|
1341
|
+
k: 1,
|
|
1342
|
+
minSimilarity: options.cacheReferenceThreshold,
|
|
1343
|
+
});
|
|
1344
|
+
if (hits.length === 0)
|
|
1345
|
+
return { replay: null, reference: null, embedding, index };
|
|
1346
|
+
const best = hits[0];
|
|
1347
|
+
if (best.similarity >= options.cacheReplayThreshold) {
|
|
1348
|
+
return { replay: best, reference: null, embedding, index };
|
|
1349
|
+
}
|
|
1350
|
+
return { replay: null, reference: best, embedding, index };
|
|
1351
|
+
}
|
|
1352
|
+
catch {
|
|
1353
|
+
return { replay: null, reference: null, embedding: null, index: null };
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
async function maybeAutoRefresh(cwd, index, options) {
|
|
1357
|
+
const current = listIndexableFiles(cwd, 5000);
|
|
1358
|
+
const report = SemanticIndex.freshnessReport(cwd, current);
|
|
1359
|
+
const driftCount = report.stale + report.missing + report.untracked;
|
|
1360
|
+
if (driftCount === 0)
|
|
1361
|
+
return;
|
|
1362
|
+
if (driftCount > AUTO_REFRESH_FILE_CAP) {
|
|
1363
|
+
if (!options.json) {
|
|
1364
|
+
process.stderr.write(`[smart-context] semantic index drifted by ${driftCount} files — too many for auto-refresh. Run \`shrk smart-context embeddings-build\`.\n`);
|
|
1365
|
+
}
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
const entries = current.map((path) => ({
|
|
1369
|
+
path,
|
|
1370
|
+
summary: readLeadingDocComment(cwd, path),
|
|
1371
|
+
exports: extractExportedNames(cwd, path),
|
|
1372
|
+
}));
|
|
1373
|
+
const refreshReport = await index.refresh(entries);
|
|
1374
|
+
if (!options.json && (refreshReport.added + refreshReport.changed + refreshReport.removed) > 0) {
|
|
1375
|
+
process.stderr.write(`[smart-context] auto-refreshed semantic index: +${refreshReport.added} ~${refreshReport.changed} -${refreshReport.removed} (unchanged ${refreshReport.unchanged}).\n`);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
let missingIndexHintShown = false;
|
|
1379
|
+
function maybePrintMissingIndexHint(options) {
|
|
1380
|
+
if (options.json || options.dryRun)
|
|
1381
|
+
return;
|
|
1382
|
+
if (missingIndexHintShown)
|
|
1383
|
+
return;
|
|
1384
|
+
missingIndexHintShown = true;
|
|
1385
|
+
process.stderr.write('[smart-context] no semantic index found — run `shrk smart-context embeddings-build` for richer grounding.\n');
|
|
1386
|
+
}
|
|
1387
|
+
function collectDocumentationHits(cwd, tokens, limit) {
|
|
1388
|
+
if (tokens.length === 0)
|
|
1389
|
+
return [];
|
|
1390
|
+
const roots = [
|
|
1391
|
+
nodePath.join(cwd, 'CLAUDE.md'),
|
|
1392
|
+
nodePath.join(cwd, 'AGENTS.md'),
|
|
1393
|
+
nodePath.join(cwd, 'README.md'),
|
|
1394
|
+
];
|
|
1395
|
+
const docDir = nodePath.join(cwd, 'docs');
|
|
1396
|
+
if (existsSync(docDir) && statSync(docDir).isDirectory()) {
|
|
1397
|
+
walkMarkdown(docDir, roots, 200);
|
|
1398
|
+
}
|
|
1399
|
+
const out = [];
|
|
1400
|
+
const seen = new Set();
|
|
1401
|
+
for (const file of roots) {
|
|
1402
|
+
if (out.length >= limit)
|
|
1403
|
+
break;
|
|
1404
|
+
if (!existsSync(file))
|
|
1405
|
+
continue;
|
|
1406
|
+
let body;
|
|
1407
|
+
try {
|
|
1408
|
+
body = readFileSync(file, 'utf8');
|
|
1409
|
+
}
|
|
1410
|
+
catch {
|
|
1411
|
+
continue;
|
|
1412
|
+
}
|
|
1413
|
+
const lines = body.split(/\r?\n/);
|
|
1414
|
+
for (let i = 0; i < lines.length && out.length < limit; i += 1) {
|
|
1415
|
+
const line = lines[i];
|
|
1416
|
+
if (line.length === 0)
|
|
1417
|
+
continue;
|
|
1418
|
+
const lower = line.toLowerCase();
|
|
1419
|
+
for (const token of tokens) {
|
|
1420
|
+
if (token.length < 4)
|
|
1421
|
+
continue;
|
|
1422
|
+
if (!lower.includes(token))
|
|
1423
|
+
continue;
|
|
1424
|
+
const key = `${file}:${i}`;
|
|
1425
|
+
if (seen.has(key))
|
|
1426
|
+
continue;
|
|
1427
|
+
seen.add(key);
|
|
1428
|
+
out.push({
|
|
1429
|
+
path: nodePath.relative(cwd, file) || file,
|
|
1430
|
+
line: i + 1,
|
|
1431
|
+
snippet: truncateLine(line, 200),
|
|
1432
|
+
token,
|
|
1433
|
+
});
|
|
1434
|
+
break;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
return out;
|
|
1439
|
+
}
|
|
1440
|
+
function walkMarkdown(dir, out, cap) {
|
|
1441
|
+
if (out.length >= cap)
|
|
1442
|
+
return;
|
|
1443
|
+
let entries = [];
|
|
1444
|
+
try {
|
|
1445
|
+
entries = readdirSync(dir);
|
|
1446
|
+
}
|
|
1447
|
+
catch {
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
for (const entry of entries) {
|
|
1451
|
+
if (out.length >= cap)
|
|
1452
|
+
return;
|
|
1453
|
+
if (entry.startsWith('.'))
|
|
1454
|
+
continue;
|
|
1455
|
+
const abs = nodePath.join(dir, entry);
|
|
1456
|
+
let stat;
|
|
1457
|
+
try {
|
|
1458
|
+
stat = statSync(abs);
|
|
1459
|
+
}
|
|
1460
|
+
catch {
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1463
|
+
if (stat.isDirectory()) {
|
|
1464
|
+
walkMarkdown(abs, out, cap);
|
|
1465
|
+
}
|
|
1466
|
+
else if (stat.isFile() && entry.toLowerCase().endsWith('.md')) {
|
|
1467
|
+
out.push(abs);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
function buildStage1FileBriefs(cwd, candidates, limit) {
|
|
1472
|
+
if (candidates.length === 0)
|
|
1473
|
+
return [];
|
|
1474
|
+
const store = new GraphStore(cwd);
|
|
1475
|
+
if (!store.exists())
|
|
1476
|
+
return [];
|
|
1477
|
+
const api = GraphQueryApi.fromStore(cwd);
|
|
1478
|
+
const out = [];
|
|
1479
|
+
for (const candidate of candidates.slice(0, limit)) {
|
|
1480
|
+
const node = api.findFile(candidate.path);
|
|
1481
|
+
if (!node)
|
|
1482
|
+
continue;
|
|
1483
|
+
const exports = api.symbolsIn(node.id).slice(0, 6).map((s) => s.label);
|
|
1484
|
+
const imports = api
|
|
1485
|
+
.importsFrom(node.id)
|
|
1486
|
+
.slice(0, 5)
|
|
1487
|
+
.map((n) => n.path ?? '')
|
|
1488
|
+
.filter((p) => p.length > 0);
|
|
1489
|
+
const importedBy = api
|
|
1490
|
+
.importersOf(node.id)
|
|
1491
|
+
.slice(0, 5)
|
|
1492
|
+
.map((n) => n.path ?? '')
|
|
1493
|
+
.filter((p) => p.length > 0);
|
|
1494
|
+
out.push({
|
|
1495
|
+
path: candidate.path,
|
|
1496
|
+
summary: readLeadingDocComment(cwd, candidate.path),
|
|
1497
|
+
exports,
|
|
1498
|
+
exportSignatures: extractExportSignatures(cwd, candidate.path, exports, 4),
|
|
1499
|
+
imports,
|
|
1500
|
+
importedBy,
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
return out;
|
|
1504
|
+
}
|
|
1505
|
+
function extractExportSignatures(cwd, path, names, limit) {
|
|
1506
|
+
if (names.length === 0)
|
|
1507
|
+
return [];
|
|
1508
|
+
const abs = nodePath.isAbsolute(path) ? path : nodePath.join(cwd, path);
|
|
1509
|
+
let body;
|
|
1510
|
+
try {
|
|
1511
|
+
body = readFileSync(abs, 'utf8');
|
|
1512
|
+
}
|
|
1513
|
+
catch {
|
|
1514
|
+
return [];
|
|
1515
|
+
}
|
|
1516
|
+
const lines = body.split(/\r?\n/);
|
|
1517
|
+
const out = [];
|
|
1518
|
+
const seen = new Set();
|
|
1519
|
+
for (const name of names) {
|
|
1520
|
+
if (out.length >= limit)
|
|
1521
|
+
break;
|
|
1522
|
+
if (seen.has(name))
|
|
1523
|
+
continue;
|
|
1524
|
+
// Match the declaration line that introduces `name` after an export.
|
|
1525
|
+
// Tolerates: export function foo, export const foo, export class foo,
|
|
1526
|
+
// export interface foo, export enum foo, export type foo, export abstract class foo,
|
|
1527
|
+
// export default function foo (rare), export async function foo.
|
|
1528
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1529
|
+
const pattern = new RegExp(String.raw `^\s*export\s+(?:default\s+)?(?:async\s+)?(?:abstract\s+)?(?:function|const|let|var|class|interface|enum|type)\s+` +
|
|
1530
|
+
escaped +
|
|
1531
|
+
String.raw `\b`);
|
|
1532
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
1533
|
+
const line = lines[i];
|
|
1534
|
+
if (!pattern.test(line))
|
|
1535
|
+
continue;
|
|
1536
|
+
const sig = truncateLine(line, 200);
|
|
1537
|
+
out.push(sig);
|
|
1538
|
+
seen.add(name);
|
|
1539
|
+
break;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
return out;
|
|
1543
|
+
}
|
|
1544
|
+
function readLeadingDocComment(cwd, path) {
|
|
1545
|
+
const abs = nodePath.isAbsolute(path) ? path : nodePath.join(cwd, path);
|
|
1546
|
+
let body;
|
|
1547
|
+
try {
|
|
1548
|
+
body = readFileSync(abs, 'utf8');
|
|
1549
|
+
}
|
|
1550
|
+
catch {
|
|
1551
|
+
return null;
|
|
1552
|
+
}
|
|
1553
|
+
const withoutShebang = body.replace(/^#!.*\r?\n/, '');
|
|
1554
|
+
const trimmed = withoutShebang.replace(/^\s+/, '');
|
|
1555
|
+
const jsdoc = trimmed.match(/^\/\*\*([\s\S]*?)\*\//);
|
|
1556
|
+
if (jsdoc) {
|
|
1557
|
+
const cleaned = jsdoc[1]
|
|
1558
|
+
.split(/\r?\n/)
|
|
1559
|
+
.map((line) => line.replace(/^\s*\*\s?/, '').trim())
|
|
1560
|
+
.filter((line) => line.length > 0 && !line.startsWith('@'))
|
|
1561
|
+
.join(' ')
|
|
1562
|
+
.trim();
|
|
1563
|
+
if (cleaned.length > 0)
|
|
1564
|
+
return truncateLine(cleaned, 240);
|
|
1565
|
+
}
|
|
1566
|
+
const lines = trimmed.split(/\r?\n/);
|
|
1567
|
+
const commentLines = [];
|
|
1568
|
+
for (const line of lines) {
|
|
1569
|
+
const t = line.trim();
|
|
1570
|
+
if (t.startsWith('//')) {
|
|
1571
|
+
commentLines.push(t.replace(/^\/\/\s?/, ''));
|
|
1572
|
+
}
|
|
1573
|
+
else if (t.length === 0) {
|
|
1574
|
+
if (commentLines.length > 0)
|
|
1575
|
+
break;
|
|
1576
|
+
}
|
|
1577
|
+
else {
|
|
1578
|
+
break;
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
if (commentLines.length > 0)
|
|
1582
|
+
return truncateLine(commentLines.join(' '), 240);
|
|
1583
|
+
return null;
|
|
1584
|
+
}
|
|
1585
|
+
function resolveRepoInstructions(cwd, options) {
|
|
1586
|
+
if (options.noInstructions)
|
|
1587
|
+
return null;
|
|
1588
|
+
const candidates = [];
|
|
1589
|
+
if (options.instructionsPath) {
|
|
1590
|
+
candidates.push(nodePath.isAbsolute(options.instructionsPath)
|
|
1591
|
+
? options.instructionsPath
|
|
1592
|
+
: nodePath.resolve(cwd, options.instructionsPath));
|
|
1593
|
+
}
|
|
1594
|
+
else {
|
|
1595
|
+
candidates.push(nodePath.join(cwd, 'CLAUDE.md'), nodePath.join(cwd, 'AGENTS.md'));
|
|
1596
|
+
}
|
|
1597
|
+
for (const p of candidates) {
|
|
1598
|
+
if (!existsSync(p))
|
|
1599
|
+
continue;
|
|
1600
|
+
try {
|
|
1601
|
+
const body = readFileSync(p, 'utf8').trim();
|
|
1602
|
+
if (body.length === 0)
|
|
1603
|
+
continue;
|
|
1604
|
+
return { path: nodePath.relative(cwd, p) || p, body };
|
|
1605
|
+
}
|
|
1606
|
+
catch {
|
|
1607
|
+
/* skip */
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
return null;
|
|
1611
|
+
}
|
|
1612
|
+
function buildMessages(seed, mode) {
|
|
1613
|
+
const systemPreamble = mode === 'plan' ? PLAN_SYSTEM_PREAMBLE : BRIEF_SYSTEM_PREAMBLE;
|
|
1614
|
+
return buildPromptMessages({
|
|
1615
|
+
systemPreamble,
|
|
1616
|
+
context: renderSeed(seed),
|
|
1617
|
+
task: seed.task,
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
function renderSeed(seed) {
|
|
1621
|
+
const lines = [];
|
|
1622
|
+
if (seed.repoInstructions) {
|
|
1623
|
+
lines.push(`# Repository instructions (${seed.repoInstructions.path})`);
|
|
1624
|
+
lines.push(seed.repoInstructions.body);
|
|
1625
|
+
lines.push('');
|
|
1626
|
+
}
|
|
1627
|
+
lines.push('# Task', seed.task, '');
|
|
1628
|
+
lines.push('# Project overview', seed.overviewText.trim(), '');
|
|
1629
|
+
if (seed.packet.relevantRules.length > 0) {
|
|
1630
|
+
lines.push('# Relevant rules (cite by id verbatim)');
|
|
1631
|
+
for (const r of seed.packet.relevantRules.slice(0, 8)) {
|
|
1632
|
+
lines.push(`- \`${r.id}\` — ${r.title}`);
|
|
1633
|
+
const summary = ruleSummaryText(r);
|
|
1634
|
+
if (summary)
|
|
1635
|
+
lines.push(` summary: ${truncateLine(summary, 240)}`);
|
|
1636
|
+
const applies = ruleAppliesWhen(r);
|
|
1637
|
+
if (applies.length > 0) {
|
|
1638
|
+
lines.push(` applies when: ${applies.slice(0, 4).join('; ')}`);
|
|
1639
|
+
}
|
|
1640
|
+
const tags = ruleTags(r);
|
|
1641
|
+
if (tags.length > 0)
|
|
1642
|
+
lines.push(` tags: ${tags.slice(0, 5).join(', ')}`);
|
|
1643
|
+
}
|
|
1644
|
+
lines.push('');
|
|
1645
|
+
}
|
|
1646
|
+
if (seed.packet.relevantPaths.length > 0) {
|
|
1647
|
+
lines.push('# Path conventions');
|
|
1648
|
+
for (const p of seed.packet.relevantPaths.slice(0, 8)) {
|
|
1649
|
+
lines.push(`- \`${p.id}\` — ${p.title}`);
|
|
1650
|
+
const summary = ruleSummaryText(p);
|
|
1651
|
+
if (summary)
|
|
1652
|
+
lines.push(` ${truncateLine(summary, 240)}`);
|
|
1653
|
+
const applies = ruleAppliesWhen(p);
|
|
1654
|
+
if (applies.length > 0) {
|
|
1655
|
+
lines.push(` applies when: ${applies.slice(0, 3).join('; ')}`);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
lines.push('');
|
|
1659
|
+
}
|
|
1660
|
+
if (seed.packet.relevantTemplates.length > 0) {
|
|
1661
|
+
lines.push('# Relevant templates');
|
|
1662
|
+
for (const t of seed.packet.relevantTemplates.slice(0, 6)) {
|
|
1663
|
+
const name = t.name ?? t.id;
|
|
1664
|
+
const description = t.description;
|
|
1665
|
+
lines.push(`- \`${t.id}\` — ${name}`);
|
|
1666
|
+
if (description)
|
|
1667
|
+
lines.push(` ${truncateLine(description, 200)}`);
|
|
1668
|
+
}
|
|
1669
|
+
lines.push('');
|
|
1670
|
+
}
|
|
1671
|
+
if (seed.packet.recommendedCliCommands.length > 0) {
|
|
1672
|
+
lines.push('# Recommended commands');
|
|
1673
|
+
for (const c of seed.packet.recommendedCliCommands.slice(0, 10))
|
|
1674
|
+
lines.push(`- \`${c}\``);
|
|
1675
|
+
lines.push('');
|
|
1676
|
+
}
|
|
1677
|
+
if (seed.packet.verificationCommands.length > 0) {
|
|
1678
|
+
lines.push('# Verification commands (run after change)');
|
|
1679
|
+
for (const c of seed.packet.verificationCommands.slice(0, 8))
|
|
1680
|
+
lines.push(`- \`${c}\``);
|
|
1681
|
+
lines.push('');
|
|
1682
|
+
}
|
|
1683
|
+
if (seed.packet.forbiddenActions.length > 0) {
|
|
1684
|
+
lines.push('# Forbidden actions (must NOT do)');
|
|
1685
|
+
for (const a of seed.packet.forbiddenActions.slice(0, 10))
|
|
1686
|
+
lines.push(`- ${a}`);
|
|
1687
|
+
lines.push('');
|
|
1688
|
+
}
|
|
1689
|
+
if (seed.packet.recommendedPipelines.length > 0) {
|
|
1690
|
+
lines.push('# Recommended pipelines');
|
|
1691
|
+
for (const p of seed.packet.recommendedPipelines) {
|
|
1692
|
+
lines.push(`- ${p.pipelineId} — ${p.reason}`);
|
|
1693
|
+
}
|
|
1694
|
+
lines.push('');
|
|
1695
|
+
}
|
|
1696
|
+
if (seed.graphGrounding.available) {
|
|
1697
|
+
const files = seed.graphGrounding.taskFileCandidates;
|
|
1698
|
+
const symbols = seed.graphGrounding.taskSymbolCandidates;
|
|
1699
|
+
if (files.length > 0 || symbols.length > 0) {
|
|
1700
|
+
lines.push('# Candidate code (graph-ranked from task tokens)');
|
|
1701
|
+
if (files.length > 0) {
|
|
1702
|
+
lines.push('files:');
|
|
1703
|
+
for (const f of files.slice(0, 10))
|
|
1704
|
+
lines.push(`- \`${f.path}\` (score ${f.score})`);
|
|
1705
|
+
}
|
|
1706
|
+
if (symbols.length > 0) {
|
|
1707
|
+
lines.push('symbols:');
|
|
1708
|
+
for (const s of symbols.slice(0, 8)) {
|
|
1709
|
+
lines.push(`- \`${s.symbol}\`${s.path ? ` in \`${s.path}\`` : ''}`);
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
lines.push('');
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
if (seed.semanticCandidates.length > 0) {
|
|
1716
|
+
lines.push(`# Semantically-related files (${seed.semanticModel ?? 'embedding model'}, cosine similarity)`);
|
|
1717
|
+
for (const hit of seed.semanticCandidates.slice(0, 10)) {
|
|
1718
|
+
lines.push(`- \`${hit.path}\` (sim ${hit.score.toFixed(3)})`);
|
|
1719
|
+
}
|
|
1720
|
+
lines.push('');
|
|
1721
|
+
}
|
|
1722
|
+
lines.push('# Knowledge context (engine-ranked, token-budgeted)');
|
|
1723
|
+
lines.push(seed.contextBody.trim());
|
|
1724
|
+
return lines.join('\n');
|
|
1725
|
+
}
|
|
1726
|
+
function ruleSummaryText(entry) {
|
|
1727
|
+
if (entry.summary && entry.summary.trim().length > 0)
|
|
1728
|
+
return entry.summary.trim();
|
|
1729
|
+
if (entry.content && entry.content.trim().length > 0) {
|
|
1730
|
+
return entry.content.trim().split(/\n\n/, 1)[0].replace(/\s+/g, ' ').trim();
|
|
1731
|
+
}
|
|
1732
|
+
return '';
|
|
1733
|
+
}
|
|
1734
|
+
function ruleAppliesWhen(entry) {
|
|
1735
|
+
return (entry.appliesWhen ?? []).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
1736
|
+
}
|
|
1737
|
+
function ruleTags(entry) {
|
|
1738
|
+
return (entry.tags ?? []).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
1739
|
+
}
|
|
1740
|
+
function truncateLine(text, max) {
|
|
1741
|
+
const compact = text.replace(/\s+/g, ' ').trim();
|
|
1742
|
+
if (compact.length <= max)
|
|
1743
|
+
return compact;
|
|
1744
|
+
return compact.slice(0, max - 1).trimEnd() + '…';
|
|
1745
|
+
}
|
|
1746
|
+
/**
|
|
1747
|
+
* Run the multi-pass enhancement pipeline against the deterministic
|
|
1748
|
+
* brief seed. Each stage's transcript is captured so `--save-conversation`
|
|
1749
|
+
* dumps the full draft → critique → refine → polish chain.
|
|
1750
|
+
*
|
|
1751
|
+
* The deterministic seed comes from the existing `messages` array
|
|
1752
|
+
* (system = repo context, user = task). The pipeline reuses that
|
|
1753
|
+
* system body verbatim across stages so the model never loses
|
|
1754
|
+
* grounding; only the user turn changes per stage.
|
|
1755
|
+
*/
|
|
1756
|
+
async function runEnhancementPipeline(input) {
|
|
1757
|
+
const provider = input.provider;
|
|
1758
|
+
const systemMsg = input.messages.find((m) => m.role === AiMessageRole.System);
|
|
1759
|
+
const userMsg = input.messages.find((m) => m.role === AiMessageRole.User);
|
|
1760
|
+
const originalContext = systemMsg?.content ?? '';
|
|
1761
|
+
const taskBody = userMsg?.content ?? input.seed.task;
|
|
1762
|
+
const pipeline = new EnhancementPipeline(buildDefaultEnhancementStages());
|
|
1763
|
+
const stageInputs = [];
|
|
1764
|
+
const stageResponses = [];
|
|
1765
|
+
// Tee per-stage prompts/responses so we can rebuild the conversation
|
|
1766
|
+
// file. The pipeline doesn't expose stage inputs publicly, so we
|
|
1767
|
+
// wrap the provider and record what the caller sees.
|
|
1768
|
+
const recordingProvider = {
|
|
1769
|
+
id: provider.id,
|
|
1770
|
+
configure: (cfg) => provider.configure(cfg),
|
|
1771
|
+
send: async (req) => {
|
|
1772
|
+
stageInputs.push({
|
|
1773
|
+
kind: `pass-${stageInputs.length + 1}`,
|
|
1774
|
+
messages: [...req.messages],
|
|
1775
|
+
});
|
|
1776
|
+
return provider.send(req);
|
|
1777
|
+
},
|
|
1778
|
+
};
|
|
1779
|
+
const piRun = await pipeline.run({ task: taskBody, originalContext }, recordingProvider, {
|
|
1780
|
+
...(input.options.enhancePasses ? { maxPasses: input.options.enhancePasses } : {}),
|
|
1781
|
+
maxTokensPerStage: input.options.maxTokens,
|
|
1782
|
+
...(input.options.model ? { model: input.options.model } : {}),
|
|
1783
|
+
onStage: (e) => {
|
|
1784
|
+
if (!input.options.json) {
|
|
1785
|
+
const tag = e.ok ? 'ok' : 'degraded';
|
|
1786
|
+
process.stderr.write(`[smart-context] enhance ${e.pass}/${e.total} ${e.kind} → ${tag}\n`);
|
|
1787
|
+
}
|
|
1788
|
+
// Mirror the pipeline-internal stage result into our local
|
|
1789
|
+
// capture so `--save-conversation` can dump the full record.
|
|
1790
|
+
// This is a no-op on the call itself; the pipeline owns its
|
|
1791
|
+
// own bookkeeping.
|
|
1792
|
+
stageResponses.push({
|
|
1793
|
+
kind: e.kind,
|
|
1794
|
+
content: '',
|
|
1795
|
+
model: input.options.model ?? provider.id,
|
|
1796
|
+
...(e.ok ? {} : { degraded: true }),
|
|
1797
|
+
});
|
|
1798
|
+
},
|
|
1799
|
+
});
|
|
1800
|
+
if (!piRun.ok) {
|
|
1801
|
+
return { ok: false, error: piRun.error };
|
|
1802
|
+
}
|
|
1803
|
+
const final = piRun.value.finalOutput;
|
|
1804
|
+
// Use the last non-degraded, non-critique stage as the "primary" AI
|
|
1805
|
+
// response surfaced in the envelope — that's the actual brief.
|
|
1806
|
+
const primary = [...piRun.value.stages]
|
|
1807
|
+
.reverse()
|
|
1808
|
+
.find((s) => s.kind !== EnhancementStageKind.Critique && !s.degraded);
|
|
1809
|
+
const usage = primary?.usage ?? {};
|
|
1810
|
+
const ai = {
|
|
1811
|
+
content: final,
|
|
1812
|
+
model: primary?.model ?? input.options.model ?? '',
|
|
1813
|
+
finishReason: piRun.value.deterministicFallback ? 'deterministic-fallback' : 'stop',
|
|
1814
|
+
usage: usage.inputTokens || usage.outputTokens ? usage : null,
|
|
1815
|
+
providerId: provider.id,
|
|
1816
|
+
};
|
|
1817
|
+
// Stitch the captured per-stage prompts + responses into a transcript.
|
|
1818
|
+
const turns = piRun.value.stages.map((stageResult, idx) => {
|
|
1819
|
+
const captured = stageInputs[idx] ?? { kind: stageResult.kind, messages: [] };
|
|
1820
|
+
return {
|
|
1821
|
+
stage: stageResult.kind,
|
|
1822
|
+
request: {
|
|
1823
|
+
messages: captured.messages.map((m) => ({ role: m.role, content: m.content })),
|
|
1824
|
+
},
|
|
1825
|
+
response: {
|
|
1826
|
+
content: stageResult.content,
|
|
1827
|
+
model: stageResult.model,
|
|
1828
|
+
finishReason: stageResult.degraded ? 'degraded' : 'stop',
|
|
1829
|
+
usage: stageResult.usage ?? null,
|
|
1830
|
+
},
|
|
1831
|
+
};
|
|
1832
|
+
});
|
|
1833
|
+
return {
|
|
1834
|
+
ok: true,
|
|
1835
|
+
value: {
|
|
1836
|
+
ai,
|
|
1837
|
+
content: final,
|
|
1838
|
+
enhancement: {
|
|
1839
|
+
enabled: true,
|
|
1840
|
+
stages: piRun.value.stages.map((s) => ({
|
|
1841
|
+
kind: String(s.kind),
|
|
1842
|
+
model: s.model,
|
|
1843
|
+
degraded: Boolean(s.degraded),
|
|
1844
|
+
...(s.errorMessage ? { errorMessage: s.errorMessage } : {}),
|
|
1845
|
+
...(s.usage ? { usage: s.usage } : {}),
|
|
1846
|
+
})),
|
|
1847
|
+
totalUsage: piRun.value.totalUsage,
|
|
1848
|
+
deterministicFallback: piRun.value.deterministicFallback,
|
|
1849
|
+
},
|
|
1850
|
+
turns,
|
|
1851
|
+
},
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1854
|
+
function resolveEnhanceFlag(args) {
|
|
1855
|
+
if (flagBool(args, 'no-enhance'))
|
|
1856
|
+
return false;
|
|
1857
|
+
if (flagBool(args, 'enhance'))
|
|
1858
|
+
return true;
|
|
1859
|
+
const env = (process.env.SHRK_ENHANCE ?? '').trim().toLowerCase();
|
|
1860
|
+
if (env === 'off' || env === '0' || env === 'false' || env === 'no')
|
|
1861
|
+
return false;
|
|
1862
|
+
return true;
|
|
1863
|
+
}
|
|
1864
|
+
function readEnhancePassesEnv() {
|
|
1865
|
+
const raw = (process.env.SHRK_ENHANCE_PASSES ?? '').trim();
|
|
1866
|
+
if (raw.length === 0)
|
|
1867
|
+
return null;
|
|
1868
|
+
const n = Number(raw);
|
|
1869
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
1870
|
+
return null;
|
|
1871
|
+
return Math.floor(n);
|
|
1872
|
+
}
|
|
1873
|
+
async function callProvider(input) {
|
|
1874
|
+
if (input.model)
|
|
1875
|
+
input.provider.configure({ model: input.model });
|
|
1876
|
+
const res = await input.provider.send({
|
|
1877
|
+
messages: input.messages,
|
|
1878
|
+
maxTokens: input.maxTokens,
|
|
1879
|
+
...(input.model ? { model: input.model } : {}),
|
|
1880
|
+
...(input.responseFormat ? { responseFormat: input.responseFormat } : {}),
|
|
1881
|
+
...(input.onTokenStream ? { onTokenStream: input.onTokenStream } : {}),
|
|
1882
|
+
});
|
|
1883
|
+
if (!res.ok || !res.value)
|
|
1884
|
+
return { ok: false, error: res.error };
|
|
1885
|
+
return {
|
|
1886
|
+
ok: true,
|
|
1887
|
+
value: {
|
|
1888
|
+
content: res.value.content,
|
|
1889
|
+
model: res.value.model,
|
|
1890
|
+
finishReason: res.value.finishReason ?? null,
|
|
1891
|
+
usage: res.value.usage ?? null,
|
|
1892
|
+
providerId: input.provider.id,
|
|
1893
|
+
},
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
function logPromptToStderr(label, messages, options) {
|
|
1897
|
+
if (!options.logPrompt)
|
|
1898
|
+
return;
|
|
1899
|
+
const dump = messages.map((m) => ({ role: m.role, content: m.content }));
|
|
1900
|
+
process.stderr.write(`[smart-context] prompt log (${label}):\n`);
|
|
1901
|
+
process.stderr.write(`${asJson(dump)}\n`);
|
|
1902
|
+
}
|
|
1903
|
+
function writeConversationFile(input) {
|
|
1904
|
+
const dir = nodePath.join(input.cwd, SMART_CONTEXT_DIR);
|
|
1905
|
+
const explicit = input.options.saveConversationPath;
|
|
1906
|
+
const target = explicit
|
|
1907
|
+
? nodePath.isAbsolute(explicit)
|
|
1908
|
+
? explicit
|
|
1909
|
+
: nodePath.resolve(input.cwd, explicit)
|
|
1910
|
+
: nodePath.join(dir, `${slug(input.task)}-${input.mode}.conversation.json`);
|
|
1911
|
+
mkdirSync(nodePath.dirname(target), { recursive: true });
|
|
1912
|
+
const body = {
|
|
1913
|
+
task: input.task,
|
|
1914
|
+
mode: input.mode,
|
|
1915
|
+
savedAt: new Date().toISOString(),
|
|
1916
|
+
provider: input.providerId,
|
|
1917
|
+
model: input.model,
|
|
1918
|
+
turns: input.turns,
|
|
1919
|
+
};
|
|
1920
|
+
writeFileSync(target, asJson(body) + '\n', 'utf8');
|
|
1921
|
+
return target;
|
|
1922
|
+
}
|
|
1923
|
+
function buildEnvelope(input) {
|
|
1924
|
+
return {
|
|
1925
|
+
task: input.task,
|
|
1926
|
+
mode: input.mode,
|
|
1927
|
+
savedAt: new Date().toISOString(),
|
|
1928
|
+
ai: {
|
|
1929
|
+
provider: input.ai.providerId,
|
|
1930
|
+
model: input.ai.model,
|
|
1931
|
+
finishReason: input.ai.finishReason,
|
|
1932
|
+
usage: input.ai.usage,
|
|
1933
|
+
},
|
|
1934
|
+
deterministic: {
|
|
1935
|
+
repoInstructionsPath: input.seed.repoInstructions?.path ?? null,
|
|
1936
|
+
relevantRules: input.seed.packet.relevantRules.map((r) => ({ id: r.id, title: r.title })),
|
|
1937
|
+
relevantPaths: input.seed.packet.relevantPaths.map((p) => ({ id: p.id, title: p.title })),
|
|
1938
|
+
relevantTemplates: input.seed.packet.relevantTemplates.map((t) => ({
|
|
1939
|
+
id: t.id,
|
|
1940
|
+
name: t.name ?? t.id,
|
|
1941
|
+
})),
|
|
1942
|
+
recommendedCommands: input.seed.packet.recommendedCliCommands,
|
|
1943
|
+
},
|
|
1944
|
+
content: input.content ?? input.ai.content,
|
|
1945
|
+
...(input.aiPlan ? { aiPlan: input.aiPlan } : {}),
|
|
1946
|
+
...(input.enhancement ? { enhancement: input.enhancement } : {}),
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
function writeEnvelope(envelope, json, debug) {
|
|
1950
|
+
if (json) {
|
|
1951
|
+
process.stdout.write(asJson(envelope) + '\n');
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
if (debug && envelope.aiPlan) {
|
|
1955
|
+
writeAiPlanDebug(envelope);
|
|
1956
|
+
}
|
|
1957
|
+
if (envelope.aiPlan?.warnings && envelope.aiPlan.warnings.length > 0) {
|
|
1958
|
+
for (const w of envelope.aiPlan.warnings) {
|
|
1959
|
+
process.stderr.write(`[smart-context] warning: ${w}\n`);
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
if (envelope.aiPlan?.unverifiedPaths && envelope.aiPlan.unverifiedPaths.length > 0) {
|
|
1963
|
+
process.stderr.write(`[smart-context] unverified paths (possible hallucination): ${envelope.aiPlan.unverifiedPaths
|
|
1964
|
+
.map((u) => u.path)
|
|
1965
|
+
.join(', ')}\n`);
|
|
1966
|
+
}
|
|
1967
|
+
process.stdout.write(envelope.content);
|
|
1968
|
+
if (!envelope.content.endsWith('\n'))
|
|
1969
|
+
process.stdout.write('\n');
|
|
1970
|
+
}
|
|
1971
|
+
function writeDryRun(messages, mode, provider) {
|
|
1972
|
+
process.stdout.write(header(`AI prompt (dry-run, provider: ${provider}, mode: ${mode})`));
|
|
1973
|
+
for (const m of messages) {
|
|
1974
|
+
process.stdout.write(`\n[${m.role}]\n${m.content}\n`);
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
function displayProviderName(explicit) {
|
|
1978
|
+
if (explicit)
|
|
1979
|
+
return explicit;
|
|
1980
|
+
const envProvider = (process.env.AI_PROVIDER ?? '').trim().toLowerCase();
|
|
1981
|
+
if (envProvider === 'ollama' || envProvider === 'llamacpp') {
|
|
1982
|
+
return envProvider;
|
|
1983
|
+
}
|
|
1984
|
+
return 'auto';
|
|
1985
|
+
}
|
|
1986
|
+
function saveEnvelope(cwd, envelope) {
|
|
1987
|
+
const dir = nodePath.join(cwd, SMART_CONTEXT_DIR);
|
|
1988
|
+
mkdirSync(dir, { recursive: true });
|
|
1989
|
+
const base = `${slug(envelope.task)}-${envelope.mode}`;
|
|
1990
|
+
const mdPath = nodePath.join(dir, `${base}.md`);
|
|
1991
|
+
const jsonPath = nodePath.join(dir, `${base}.json`);
|
|
1992
|
+
writeFileSync(mdPath, renderSavedMarkdown(envelope), 'utf8');
|
|
1993
|
+
writeFileSync(jsonPath, asJson(envelope) + '\n', 'utf8');
|
|
1994
|
+
if (envelope.aiPlan?.rawResponses) {
|
|
1995
|
+
const rawPath = nodePath.join(dir, `${base}.raw.json`);
|
|
1996
|
+
writeFileSync(rawPath, asJson(envelope.aiPlan.rawResponses) + '\n', 'utf8');
|
|
1997
|
+
}
|
|
1998
|
+
if (envelope.aiPlan?.promptLog) {
|
|
1999
|
+
const promptPath = nodePath.join(dir, `${base}.prompt.json`);
|
|
2000
|
+
writeFileSync(promptPath, asJson(envelope.aiPlan.promptLog) + '\n', 'utf8');
|
|
2001
|
+
}
|
|
2002
|
+
if (envelope.aiPlan?.focusedParsedPlan) {
|
|
2003
|
+
// Structured plan in a stable shape — `shrk spike <slug>` reads this.
|
|
2004
|
+
const planPath = nodePath.join(dir, `${base}.plan.json`);
|
|
2005
|
+
writeFileSync(planPath, asJson(envelope.aiPlan.focusedParsedPlan) + '\n', 'utf8');
|
|
2006
|
+
}
|
|
2007
|
+
if (envelope.aiPlan?.finalPlan && !envelope.aiPlan.focusedParsedPlan) {
|
|
2008
|
+
// ai-plan (2-stage) also gets a .plan.json so spike works against it.
|
|
2009
|
+
const planPath = nodePath.join(dir, `${base}.plan.json`);
|
|
2010
|
+
writeFileSync(planPath, asJson(envelope.aiPlan.finalPlan) + '\n', 'utf8');
|
|
2011
|
+
}
|
|
2012
|
+
return { slug: base, dir, mdPath, jsonPath };
|
|
2013
|
+
}
|
|
2014
|
+
function writeSavedNotice(saved, json, envelope) {
|
|
2015
|
+
if (json) {
|
|
2016
|
+
process.stdout.write(asJson({
|
|
2017
|
+
...envelope,
|
|
2018
|
+
savedAs: { slug: saved.slug, markdown: saved.mdPath, json: saved.jsonPath },
|
|
2019
|
+
}) + '\n');
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
process.stdout.write(header(`Saved: ${saved.slug}`));
|
|
2023
|
+
process.stdout.write(kv('markdown', saved.mdPath) + '\n');
|
|
2024
|
+
process.stdout.write(kv('json', saved.jsonPath) + '\n');
|
|
2025
|
+
process.stdout.write(`\nPreview with: shrk smart-context show ${saved.slug}\n`);
|
|
2026
|
+
}
|
|
2027
|
+
function renderSavedMarkdown(envelope) {
|
|
2028
|
+
const lines = [];
|
|
2029
|
+
lines.push(`# ${envelope.mode === 'plan' ? 'Plan' : 'Brief'} — ${envelope.task}`);
|
|
2030
|
+
lines.push('');
|
|
2031
|
+
lines.push(`_Saved ${envelope.savedAt} · model ${envelope.ai.model} (${envelope.ai.provider})._`);
|
|
2032
|
+
if (envelope.deterministic.repoInstructionsPath) {
|
|
2033
|
+
lines.push(`_Repo instructions: \`${envelope.deterministic.repoInstructionsPath}\`._`);
|
|
2034
|
+
}
|
|
2035
|
+
if (envelope.aiPlan) {
|
|
2036
|
+
lines.push(`_AI planning strategy: \`${envelope.aiPlan.strategy}\`._`);
|
|
2037
|
+
if (envelope.aiPlan.stage1Retried || envelope.aiPlan.stage2Retried) {
|
|
2038
|
+
const retried = [
|
|
2039
|
+
envelope.aiPlan.stage1Retried ? 'stage 1' : null,
|
|
2040
|
+
envelope.aiPlan.stage2Retried ? 'stage 2' : null,
|
|
2041
|
+
]
|
|
2042
|
+
.filter((s) => s !== null)
|
|
2043
|
+
.join(', ');
|
|
2044
|
+
lines.push(`_Retried after bad JSON: ${retried}._`);
|
|
2045
|
+
}
|
|
2046
|
+
if (envelope.aiPlan.stage1Degraded) {
|
|
2047
|
+
lines.push(`_Stage 1 degraded to empty expansion after retry._`);
|
|
2048
|
+
}
|
|
2049
|
+
if (envelope.aiPlan.warnings && envelope.aiPlan.warnings.length > 0) {
|
|
2050
|
+
lines.push('');
|
|
2051
|
+
lines.push('> **Warnings:**');
|
|
2052
|
+
for (const w of envelope.aiPlan.warnings)
|
|
2053
|
+
lines.push(`> - ${w}`);
|
|
2054
|
+
}
|
|
2055
|
+
if (envelope.aiPlan.unverifiedPaths && envelope.aiPlan.unverifiedPaths.length > 0) {
|
|
2056
|
+
lines.push('');
|
|
2057
|
+
lines.push('> **Unverified paths (possible hallucination):**');
|
|
2058
|
+
for (const u of envelope.aiPlan.unverifiedPaths) {
|
|
2059
|
+
lines.push(`> - \`${u.path}\` (referenced in \`${u.where}\`)`);
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
lines.push('');
|
|
2064
|
+
lines.push(envelope.content.trim());
|
|
2065
|
+
lines.push('');
|
|
2066
|
+
return lines.join('\n');
|
|
2067
|
+
}
|
|
2068
|
+
function readSavedIndex(cwd) {
|
|
2069
|
+
const dir = nodePath.join(cwd, SMART_CONTEXT_DIR);
|
|
2070
|
+
if (!existsSync(dir))
|
|
2071
|
+
return [];
|
|
2072
|
+
const out = [];
|
|
2073
|
+
for (const name of readdirSync(dir)) {
|
|
2074
|
+
if (!name.endsWith('.json'))
|
|
2075
|
+
continue;
|
|
2076
|
+
const jsonPath = nodePath.join(dir, name);
|
|
2077
|
+
try {
|
|
2078
|
+
if (!statSync(jsonPath).isFile())
|
|
2079
|
+
continue;
|
|
2080
|
+
const env = JSON.parse(readFileSync(jsonPath, 'utf8'));
|
|
2081
|
+
const slugBase = name.replace(/\.json$/, '');
|
|
2082
|
+
const mdPath = nodePath.join(dir, `${slugBase}.md`);
|
|
2083
|
+
out.push({
|
|
2084
|
+
slug: slugBase,
|
|
2085
|
+
task: env.task,
|
|
2086
|
+
mode: env.mode,
|
|
2087
|
+
savedAt: env.savedAt,
|
|
2088
|
+
mdPath,
|
|
2089
|
+
jsonPath,
|
|
2090
|
+
});
|
|
2091
|
+
}
|
|
2092
|
+
catch {
|
|
2093
|
+
/* skip malformed */
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
out.sort((a, b) => (a.savedAt < b.savedAt ? 1 : -1));
|
|
2097
|
+
return out;
|
|
2098
|
+
}
|
|
2099
|
+
function slug(s) {
|
|
2100
|
+
return (s
|
|
2101
|
+
.toLowerCase()
|
|
2102
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
2103
|
+
.replace(/(^-+|-+$)/g, '')
|
|
2104
|
+
.slice(0, 60) || 'task');
|
|
2105
|
+
}
|
|
2106
|
+
function buildInitialGraphGrounding(cwd, task) {
|
|
2107
|
+
const store = new GraphStore(cwd);
|
|
2108
|
+
if (!store.exists()) {
|
|
2109
|
+
return {
|
|
2110
|
+
available: false,
|
|
2111
|
+
state: 'missing',
|
|
2112
|
+
taskFileCandidates: [],
|
|
2113
|
+
taskSymbolCandidates: [],
|
|
2114
|
+
};
|
|
2115
|
+
}
|
|
2116
|
+
const verify = store.verifyDigest();
|
|
2117
|
+
const snap = store.loadSnapshot();
|
|
2118
|
+
const api = GraphQueryApi.fromStore(cwd);
|
|
2119
|
+
const tokens = tokenizeTask(task);
|
|
2120
|
+
return {
|
|
2121
|
+
available: true,
|
|
2122
|
+
state: verify.ok ? 'fresh' : 'corrupt',
|
|
2123
|
+
fileCount: snap.manifest.filesIndexed,
|
|
2124
|
+
nodeCount: sumValues(snap.manifest.nodesByKind),
|
|
2125
|
+
edgeCount: sumValues(snap.manifest.edgesByKind),
|
|
2126
|
+
cycleCount: snap.manifest.cycleCount ?? null,
|
|
2127
|
+
unresolvedImportCount: snap.manifest.unresolvedImportCount ?? null,
|
|
2128
|
+
taskFileCandidates: rankTaskFileCandidates(api, tokens, 10),
|
|
2129
|
+
taskSymbolCandidates: rankTaskSymbolCandidates(api, tokens, 8),
|
|
2130
|
+
};
|
|
2131
|
+
}
|
|
2132
|
+
function renderInitialGraphGrounding(grounding) {
|
|
2133
|
+
const lines = [];
|
|
2134
|
+
lines.push('# Graph grounding');
|
|
2135
|
+
if (!grounding.available) {
|
|
2136
|
+
lines.push('- graph unavailable');
|
|
2137
|
+
return lines.join('\n');
|
|
2138
|
+
}
|
|
2139
|
+
lines.push(`- graph state: ${grounding.state}`);
|
|
2140
|
+
lines.push(`- files: ${grounding.fileCount ?? 0}`);
|
|
2141
|
+
lines.push(`- nodes: ${grounding.nodeCount ?? 0}`);
|
|
2142
|
+
lines.push(`- edges: ${grounding.edgeCount ?? 0}`);
|
|
2143
|
+
if (grounding.cycleCount !== null && grounding.cycleCount !== undefined) {
|
|
2144
|
+
lines.push(`- cycles: ${grounding.cycleCount}`);
|
|
2145
|
+
}
|
|
2146
|
+
if (grounding.unresolvedImportCount !== null && grounding.unresolvedImportCount !== undefined) {
|
|
2147
|
+
lines.push(`- unresolved imports: ${grounding.unresolvedImportCount}`);
|
|
2148
|
+
}
|
|
2149
|
+
if (grounding.taskFileCandidates.length > 0) {
|
|
2150
|
+
lines.push('', '## Candidate files from task tokens');
|
|
2151
|
+
for (const c of grounding.taskFileCandidates)
|
|
2152
|
+
lines.push(`- \`${c.path}\` (score ${c.score})`);
|
|
2153
|
+
}
|
|
2154
|
+
if (grounding.taskSymbolCandidates.length > 0) {
|
|
2155
|
+
lines.push('', '## Candidate symbols from task tokens');
|
|
2156
|
+
for (const c of grounding.taskSymbolCandidates) {
|
|
2157
|
+
lines.push(`- \`${c.symbol}\`${c.path ? ` in \`${c.path}\`` : ''}`);
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
return lines.join('\n');
|
|
2161
|
+
}
|
|
2162
|
+
async function buildAiPlanEnvelope(input) {
|
|
2163
|
+
const grounding = input.seed.graphGrounding;
|
|
2164
|
+
// Cache lookup (read-only, no LLM call). Done before provider selection
|
|
2165
|
+
// so a cache hit short-circuits even when no provider is available.
|
|
2166
|
+
const cacheLookup = (input.options.noCache || isSemanticAutomationDisabled())
|
|
2167
|
+
? { replay: null, reference: null, embedding: null, index: null }
|
|
2168
|
+
: await lookupPlanCache(input.cwd, input.seed.task, input.options);
|
|
2169
|
+
if (cacheLookup.replay) {
|
|
2170
|
+
const cached = cacheLookup.replay;
|
|
2171
|
+
const md = cached.entry.planMarkdown ?? '';
|
|
2172
|
+
if (!input.options.json) {
|
|
2173
|
+
process.stderr.write(`[smart-context] cache replay — similar past task "${truncateLine(cached.entry.task, 80)}" (sim ${cached.similarity.toFixed(3)})\n`);
|
|
2174
|
+
}
|
|
2175
|
+
return {
|
|
2176
|
+
ok: true,
|
|
2177
|
+
value: buildEnvelope({
|
|
2178
|
+
task: input.seed.task,
|
|
2179
|
+
seed: input.seed,
|
|
2180
|
+
mode: 'plan',
|
|
2181
|
+
ai: {
|
|
2182
|
+
content: md,
|
|
2183
|
+
model: cached.entry.model,
|
|
2184
|
+
finishReason: null,
|
|
2185
|
+
usage: null,
|
|
2186
|
+
providerId: 'cache',
|
|
2187
|
+
},
|
|
2188
|
+
content: md.length > 0 ? md : `(replayed from cache, no markdown stored)`,
|
|
2189
|
+
aiPlan: {
|
|
2190
|
+
strategy: 'cache-replay',
|
|
2191
|
+
requestedProvider: input.options.provider ?? 'auto',
|
|
2192
|
+
initialGraphGrounding: grounding,
|
|
2193
|
+
finalPlan: cached.entry.plan,
|
|
2194
|
+
cacheReplay: {
|
|
2195
|
+
sourceTask: cached.entry.task,
|
|
2196
|
+
sourceSavedAt: cached.entry.savedAt,
|
|
2197
|
+
similarity: cached.similarity,
|
|
2198
|
+
},
|
|
2199
|
+
},
|
|
2200
|
+
}),
|
|
2201
|
+
};
|
|
2202
|
+
}
|
|
2203
|
+
const selection = selectAiProvider(input.options.provider);
|
|
2204
|
+
if (!selection.provider) {
|
|
2205
|
+
const fallbackContent = renderDeterministicFallback(input.seed);
|
|
2206
|
+
return {
|
|
2207
|
+
ok: true,
|
|
2208
|
+
value: buildEnvelope({
|
|
2209
|
+
task: input.seed.task,
|
|
2210
|
+
seed: input.seed,
|
|
2211
|
+
mode: 'plan',
|
|
2212
|
+
ai: {
|
|
2213
|
+
content: fallbackContent,
|
|
2214
|
+
model: 'deterministic-fallback',
|
|
2215
|
+
finishReason: null,
|
|
2216
|
+
usage: null,
|
|
2217
|
+
providerId: 'deterministic',
|
|
2218
|
+
},
|
|
2219
|
+
content: fallbackContent,
|
|
2220
|
+
aiPlan: {
|
|
2221
|
+
strategy: 'deterministic-fallback',
|
|
2222
|
+
requestedProvider: selection.requested,
|
|
2223
|
+
fallbackReason: providerMissingMessage(selection.requested),
|
|
2224
|
+
initialGraphGrounding: grounding,
|
|
2225
|
+
},
|
|
2226
|
+
}),
|
|
2227
|
+
};
|
|
2228
|
+
}
|
|
2229
|
+
if (input.options.model)
|
|
2230
|
+
selection.provider.configure({ model: input.options.model });
|
|
2231
|
+
const warnings = [];
|
|
2232
|
+
if (selection.provider.id === 'ollama' && selection.provider instanceof OllamaProvider) {
|
|
2233
|
+
const preflight = await selection.provider.healthCheck(input.options.model);
|
|
2234
|
+
if (!preflight.ok) {
|
|
2235
|
+
return {
|
|
2236
|
+
ok: false,
|
|
2237
|
+
error: new Error(preflight.error.message +
|
|
2238
|
+
(preflight.error.suggestion ? `\n hint: ${preflight.error.suggestion}` : '')),
|
|
2239
|
+
};
|
|
2240
|
+
}
|
|
2241
|
+
if (input.options.model && preflight.value.modelPresent === false) {
|
|
2242
|
+
return {
|
|
2243
|
+
ok: false,
|
|
2244
|
+
error: new Error(`Ollama at ${preflight.value.host} does not have model "${input.options.model}" pulled. ` +
|
|
2245
|
+
`Run \`ollama pull ${input.options.model}\` (available: ${preflight.value.models.join(', ') || 'none'}).`),
|
|
2246
|
+
};
|
|
2247
|
+
}
|
|
2248
|
+
progressMarker(`preflight ok — host=${preflight.value.host} models=${preflight.value.models.length}`, input.options);
|
|
2249
|
+
}
|
|
2250
|
+
progressMarker(`stage 1 calling ${selection.provider.id}${input.options.model ? `:${input.options.model}` : ''}…`, input.options);
|
|
2251
|
+
const stage1Messages = buildStage1Messages(input.seed, grounding, cacheLookup.reference);
|
|
2252
|
+
logPromptToStderr('stage1', stage1Messages, input.options);
|
|
2253
|
+
const stage1Outcome = await callProviderWithRetry({
|
|
2254
|
+
provider: selection.provider,
|
|
2255
|
+
messages: stage1Messages,
|
|
2256
|
+
maxTokens: input.options.stage1MaxTokens,
|
|
2257
|
+
model: input.options.model,
|
|
2258
|
+
responseFormat: {
|
|
2259
|
+
type: 'json_schema',
|
|
2260
|
+
schemaName: 'smart_context_expansion_request',
|
|
2261
|
+
schema: SmartContextExpansionRequestSchema,
|
|
2262
|
+
},
|
|
2263
|
+
parse: parseExpansionRequest,
|
|
2264
|
+
repromptInstruction: STAGE1_REPROMPT,
|
|
2265
|
+
stageLabel: 'stage 1',
|
|
2266
|
+
options: input.options,
|
|
2267
|
+
});
|
|
2268
|
+
let stage1Request;
|
|
2269
|
+
let stage1Retried = false;
|
|
2270
|
+
let stage1Degraded = false;
|
|
2271
|
+
let stage1RawResponse;
|
|
2272
|
+
let stage1Call = null;
|
|
2273
|
+
if (stage1Outcome.kind === 'ok') {
|
|
2274
|
+
stage1Request = stage1Outcome.parsed;
|
|
2275
|
+
stage1Retried = stage1Outcome.retried;
|
|
2276
|
+
stage1RawResponse = stage1Outcome.lastRawResponse;
|
|
2277
|
+
stage1Call = stage1Outcome.call;
|
|
2278
|
+
}
|
|
2279
|
+
else if (stage1Outcome.kind === 'call-failed') {
|
|
2280
|
+
return { ok: false, error: stage1Outcome.error };
|
|
2281
|
+
}
|
|
2282
|
+
else {
|
|
2283
|
+
stage1Request = emptyExpansionRequest();
|
|
2284
|
+
stage1Retried = true;
|
|
2285
|
+
stage1Degraded = true;
|
|
2286
|
+
stage1RawResponse = stage1Outcome.lastRawResponse;
|
|
2287
|
+
stage1Call = stage1Outcome.call;
|
|
2288
|
+
warnings.push(`Stage 1 returned invalid JSON after retry; continuing with empty expansion (${stage1Outcome.parseError.message}).`);
|
|
2289
|
+
}
|
|
2290
|
+
const collected = collectExpansionContext({
|
|
2291
|
+
cwd: input.cwd,
|
|
2292
|
+
inspection: input.inspection,
|
|
2293
|
+
request: stage1Request,
|
|
2294
|
+
options: input.options,
|
|
2295
|
+
});
|
|
2296
|
+
progressMarker(`stage 2 calling ${selection.provider.id}${input.options.model ? `:${input.options.model}` : ''}…`, input.options);
|
|
2297
|
+
const stage2Messages = buildStage2Messages(input.seed, grounding, collected, cacheLookup.reference);
|
|
2298
|
+
logPromptToStderr('stage2', stage2Messages, input.options);
|
|
2299
|
+
const stage2Outcome = await callProviderWithRetry({
|
|
2300
|
+
provider: selection.provider,
|
|
2301
|
+
messages: stage2Messages,
|
|
2302
|
+
maxTokens: input.options.maxTokens,
|
|
2303
|
+
model: input.options.model,
|
|
2304
|
+
responseFormat: {
|
|
2305
|
+
type: 'json_schema',
|
|
2306
|
+
schemaName: 'smart_context_detailed_plan',
|
|
2307
|
+
schema: SmartContextDetailedPlanSchema,
|
|
2308
|
+
},
|
|
2309
|
+
parse: parseDetailedPlan,
|
|
2310
|
+
repromptInstruction: STAGE2_REPROMPT,
|
|
2311
|
+
stageLabel: 'stage 2',
|
|
2312
|
+
options: input.options,
|
|
2313
|
+
});
|
|
2314
|
+
const conversationTurns = [];
|
|
2315
|
+
if (stage1Call) {
|
|
2316
|
+
conversationTurns.push({
|
|
2317
|
+
stage: 'stage1',
|
|
2318
|
+
request: { messages: stage1Messages.map((m) => ({ role: m.role, content: m.content })) },
|
|
2319
|
+
response: {
|
|
2320
|
+
content: stage1RawResponse ?? stage1Call.content,
|
|
2321
|
+
model: stage1Call.model,
|
|
2322
|
+
finishReason: stage1Call.finishReason,
|
|
2323
|
+
usage: stage1Call.usage,
|
|
2324
|
+
retried: stage1Retried,
|
|
2325
|
+
...(stage1Degraded ? { parseFailed: true } : {}),
|
|
2326
|
+
},
|
|
2327
|
+
});
|
|
2328
|
+
}
|
|
2329
|
+
const stage2CallForLog = stage2Outcome.kind === 'ok' || stage2Outcome.kind === 'parse-failed' ? stage2Outcome.call : null;
|
|
2330
|
+
const stage2RawForLog = stage2Outcome.kind === 'ok' || stage2Outcome.kind === 'parse-failed'
|
|
2331
|
+
? stage2Outcome.lastRawResponse
|
|
2332
|
+
: undefined;
|
|
2333
|
+
if (stage2CallForLog) {
|
|
2334
|
+
conversationTurns.push({
|
|
2335
|
+
stage: 'stage2',
|
|
2336
|
+
request: { messages: stage2Messages.map((m) => ({ role: m.role, content: m.content })) },
|
|
2337
|
+
response: {
|
|
2338
|
+
content: stage2RawForLog ?? stage2CallForLog.content,
|
|
2339
|
+
model: stage2CallForLog.model,
|
|
2340
|
+
finishReason: stage2CallForLog.finishReason,
|
|
2341
|
+
usage: stage2CallForLog.usage,
|
|
2342
|
+
...(stage2Outcome.kind === 'ok' ? { retried: stage2Outcome.retried } : { parseFailed: true }),
|
|
2343
|
+
},
|
|
2344
|
+
});
|
|
2345
|
+
}
|
|
2346
|
+
const persistConversation = () => {
|
|
2347
|
+
if (!input.options.saveConversation || conversationTurns.length === 0)
|
|
2348
|
+
return;
|
|
2349
|
+
const lastTurn = conversationTurns[conversationTurns.length - 1];
|
|
2350
|
+
const path = writeConversationFile({
|
|
2351
|
+
cwd: input.cwd,
|
|
2352
|
+
task: input.seed.task,
|
|
2353
|
+
mode: 'plan',
|
|
2354
|
+
options: input.options,
|
|
2355
|
+
providerId: stage2CallForLog?.providerId ?? stage1Call?.providerId ?? selection.provider.id,
|
|
2356
|
+
model: lastTurn.response.model,
|
|
2357
|
+
turns: conversationTurns,
|
|
2358
|
+
});
|
|
2359
|
+
if (!input.options.json) {
|
|
2360
|
+
process.stderr.write(`[smart-context] conversation saved → ${path}\n`);
|
|
2361
|
+
}
|
|
2362
|
+
};
|
|
2363
|
+
if (stage2Outcome.kind === 'call-failed') {
|
|
2364
|
+
persistConversation();
|
|
2365
|
+
return { ok: false, error: stage2Outcome.error };
|
|
2366
|
+
}
|
|
2367
|
+
if (stage2Outcome.kind === 'parse-failed') {
|
|
2368
|
+
persistConversation();
|
|
2369
|
+
return { ok: false, error: stage2Outcome.parseError };
|
|
2370
|
+
}
|
|
2371
|
+
const stage2Plan = stage2Outcome.parsed;
|
|
2372
|
+
const stage2Retried = stage2Outcome.retried;
|
|
2373
|
+
const stage2Call = stage2Outcome.call;
|
|
2374
|
+
const stage2RawResponse = stage2Outcome.lastRawResponse;
|
|
2375
|
+
const unverifiedPaths = verifyPlanPaths(input.cwd, stage2Plan);
|
|
2376
|
+
persistConversation();
|
|
2377
|
+
// Persist this run to the plan cache so future similar tasks can replay
|
|
2378
|
+
// it. Only when the semantic index is available (we need an embedding
|
|
2379
|
+
// and a stable model id to key by).
|
|
2380
|
+
if (!input.options.noCache && cacheLookup.embedding && cacheLookup.index) {
|
|
2381
|
+
try {
|
|
2382
|
+
PlanCache.append(input.cwd, {
|
|
2383
|
+
schema: PLAN_CACHE_SCHEMA,
|
|
2384
|
+
task: input.seed.task,
|
|
2385
|
+
taskSlug: slug(input.seed.task),
|
|
2386
|
+
model: cacheLookup.index.modelName,
|
|
2387
|
+
embeddingDimensions: cacheLookup.index.dimensions,
|
|
2388
|
+
embeddingB64: encodeEmbedding(cacheLookup.embedding),
|
|
2389
|
+
plan: stage2Plan,
|
|
2390
|
+
planMarkdown: renderDetailedPlan(stage2Plan),
|
|
2391
|
+
savedAt: new Date().toISOString(),
|
|
2392
|
+
});
|
|
2393
|
+
}
|
|
2394
|
+
catch {
|
|
2395
|
+
// Cache write failures are non-fatal — the plan is still returned.
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
return {
|
|
2399
|
+
ok: true,
|
|
2400
|
+
value: buildEnvelope({
|
|
2401
|
+
task: input.seed.task,
|
|
2402
|
+
seed: input.seed,
|
|
2403
|
+
mode: 'plan',
|
|
2404
|
+
ai: stage2Call,
|
|
2405
|
+
content: renderDetailedPlan(stage2Plan),
|
|
2406
|
+
aiPlan: {
|
|
2407
|
+
strategy: 'two-stage',
|
|
2408
|
+
requestedProvider: selection.requested,
|
|
2409
|
+
initialGraphGrounding: grounding,
|
|
2410
|
+
stage1Request,
|
|
2411
|
+
stage1Retried,
|
|
2412
|
+
stage1Degraded,
|
|
2413
|
+
collectedContext: collected,
|
|
2414
|
+
finalPlan: stage2Plan,
|
|
2415
|
+
stage2Retried,
|
|
2416
|
+
...(unverifiedPaths.length > 0 ? { unverifiedPaths } : {}),
|
|
2417
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
2418
|
+
...(cacheLookup.reference
|
|
2419
|
+
? {
|
|
2420
|
+
cacheReference: {
|
|
2421
|
+
sourceTask: cacheLookup.reference.entry.task,
|
|
2422
|
+
sourceSavedAt: cacheLookup.reference.entry.savedAt,
|
|
2423
|
+
similarity: cacheLookup.reference.similarity,
|
|
2424
|
+
},
|
|
2425
|
+
}
|
|
2426
|
+
: {}),
|
|
2427
|
+
...(stage1RawResponse !== undefined || stage2RawResponse !== undefined
|
|
2428
|
+
? {
|
|
2429
|
+
rawResponses: {
|
|
2430
|
+
...(stage1RawResponse !== undefined ? { stage1: stage1RawResponse } : {}),
|
|
2431
|
+
...(stage2RawResponse !== undefined ? { stage2: stage2RawResponse } : {}),
|
|
2432
|
+
},
|
|
2433
|
+
}
|
|
2434
|
+
: {}),
|
|
2435
|
+
...(input.options.logPrompt
|
|
2436
|
+
? { promptLog: { stage1: stage1Messages, stage2: stage2Messages } }
|
|
2437
|
+
: {}),
|
|
2438
|
+
},
|
|
2439
|
+
}),
|
|
2440
|
+
};
|
|
2441
|
+
}
|
|
2442
|
+
function emptyExpansionRequest() {
|
|
2443
|
+
return {
|
|
2444
|
+
filesToRead: [],
|
|
2445
|
+
similarPatterns: [],
|
|
2446
|
+
publicApiFiles: [],
|
|
2447
|
+
testsToInspect: [],
|
|
2448
|
+
architectureRules: [],
|
|
2449
|
+
riskyAreas: [],
|
|
2450
|
+
missingInformation: [],
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2453
|
+
const STAGE1_REPROMPT = 'Your previous response was not parseable JSON. Reply with ONLY a single JSON object that conforms to the expansion-request schema. No prose, no markdown fence, no commentary.';
|
|
2454
|
+
const STAGE2_REPROMPT = 'Your previous response was not parseable JSON. Reply with ONLY a single JSON object that conforms to the detailed-plan schema. No prose, no markdown fence, no commentary.';
|
|
2455
|
+
async function callProviderWithRetry(input) {
|
|
2456
|
+
const first = await callProvider({
|
|
2457
|
+
provider: input.provider,
|
|
2458
|
+
messages: input.messages,
|
|
2459
|
+
maxTokens: input.maxTokens,
|
|
2460
|
+
...(input.model ? { model: input.model } : {}),
|
|
2461
|
+
...(input.responseFormat ? { responseFormat: input.responseFormat } : {}),
|
|
2462
|
+
});
|
|
2463
|
+
if (!first.ok)
|
|
2464
|
+
return { kind: 'call-failed', error: first.error };
|
|
2465
|
+
const firstParsed = input.parse(first.value.content);
|
|
2466
|
+
if (firstParsed.ok) {
|
|
2467
|
+
return {
|
|
2468
|
+
kind: 'ok',
|
|
2469
|
+
parsed: firstParsed.value,
|
|
2470
|
+
call: first.value,
|
|
2471
|
+
retried: false,
|
|
2472
|
+
lastRawResponse: first.value.content,
|
|
2473
|
+
};
|
|
2474
|
+
}
|
|
2475
|
+
progressMarker(`${input.stageLabel} parse failed (${firstParsed.error.message.slice(0, 80)}); retrying once…`, input.options);
|
|
2476
|
+
const retryMessages = [
|
|
2477
|
+
...input.messages,
|
|
2478
|
+
{ role: AiMessageRole.Assistant, content: first.value.content },
|
|
2479
|
+
{ role: AiMessageRole.User, content: input.repromptInstruction },
|
|
2480
|
+
];
|
|
2481
|
+
const second = await callProvider({
|
|
2482
|
+
provider: input.provider,
|
|
2483
|
+
messages: retryMessages,
|
|
2484
|
+
maxTokens: input.maxTokens,
|
|
2485
|
+
...(input.model ? { model: input.model } : {}),
|
|
2486
|
+
...(input.responseFormat ? { responseFormat: input.responseFormat } : {}),
|
|
2487
|
+
});
|
|
2488
|
+
if (!second.ok) {
|
|
2489
|
+
return {
|
|
2490
|
+
kind: 'parse-failed',
|
|
2491
|
+
parseError: firstParsed.error,
|
|
2492
|
+
call: first.value,
|
|
2493
|
+
lastRawResponse: first.value.content,
|
|
2494
|
+
};
|
|
2495
|
+
}
|
|
2496
|
+
const secondParsed = input.parse(second.value.content);
|
|
2497
|
+
if (secondParsed.ok) {
|
|
2498
|
+
return {
|
|
2499
|
+
kind: 'ok',
|
|
2500
|
+
parsed: secondParsed.value,
|
|
2501
|
+
call: second.value,
|
|
2502
|
+
retried: true,
|
|
2503
|
+
lastRawResponse: second.value.content,
|
|
2504
|
+
};
|
|
2505
|
+
}
|
|
2506
|
+
return {
|
|
2507
|
+
kind: 'parse-failed',
|
|
2508
|
+
parseError: secondParsed.error,
|
|
2509
|
+
call: second.value,
|
|
2510
|
+
lastRawResponse: second.value.content,
|
|
2511
|
+
};
|
|
2512
|
+
}
|
|
2513
|
+
function progressMarker(message, options) {
|
|
2514
|
+
if (options.json)
|
|
2515
|
+
return;
|
|
2516
|
+
process.stderr.write(`[smart-context] ${message}\n`);
|
|
2517
|
+
}
|
|
2518
|
+
function verifyPlanPaths(cwd, plan) {
|
|
2519
|
+
const checks = [
|
|
2520
|
+
['existingPatternsToFollow', plan.existingPatternsToFollow],
|
|
2521
|
+
['filesToRead', plan.filesToRead],
|
|
2522
|
+
['likelyFilesToModify', plan.likelyFilesToModify],
|
|
2523
|
+
['filesToAvoid', plan.filesToAvoid],
|
|
2524
|
+
['publicApiFiles', plan.publicApiFiles],
|
|
2525
|
+
['testsToInspect', plan.testsToInspect],
|
|
2526
|
+
];
|
|
2527
|
+
const seen = new Set();
|
|
2528
|
+
const out = [];
|
|
2529
|
+
for (const [where, items] of checks) {
|
|
2530
|
+
for (const item of items) {
|
|
2531
|
+
const key = `${where}:${item.path}`;
|
|
2532
|
+
if (seen.has(key))
|
|
2533
|
+
continue;
|
|
2534
|
+
seen.add(key);
|
|
2535
|
+
if (!pathExistsInWorkspace(cwd, item.path)) {
|
|
2536
|
+
out.push({ path: item.path, where });
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
return out;
|
|
2541
|
+
}
|
|
2542
|
+
function pathExistsInWorkspace(cwd, candidate) {
|
|
2543
|
+
if (candidate.length === 0)
|
|
2544
|
+
return false;
|
|
2545
|
+
const normalised = candidate.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
2546
|
+
const abs = nodePath.isAbsolute(normalised) ? normalised : nodePath.join(cwd, normalised);
|
|
2547
|
+
try {
|
|
2548
|
+
return existsSync(abs);
|
|
2549
|
+
}
|
|
2550
|
+
catch {
|
|
2551
|
+
return false;
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
/**
|
|
2555
|
+
* Walk an arbitrary parsed-JSON tree looking for `path: string` leaves.
|
|
2556
|
+
* Used by focused-mode (and now ai-plan) to flag hallucinated paths
|
|
2557
|
+
* the LLM invented. The walker is intentionally lenient:
|
|
2558
|
+
*
|
|
2559
|
+
* - any object key called `path` with a string value is treated as a
|
|
2560
|
+
* filesystem reference if it looks like one (contains `/` or ends
|
|
2561
|
+
* in a known extension).
|
|
2562
|
+
* - `firstSpike.proposedFiles[].path` is captured via the same rule
|
|
2563
|
+
* because each item is `{ path, purpose }`.
|
|
2564
|
+
*
|
|
2565
|
+
* Returns the locations of every path that DOES NOT exist on disk, so
|
|
2566
|
+
* the caller can surface them as `unverifiedPaths` on the envelope.
|
|
2567
|
+
*/
|
|
2568
|
+
function collectUnverifiedPathsFromJson(cwd, root) {
|
|
2569
|
+
const misses = [];
|
|
2570
|
+
const seen = new Set();
|
|
2571
|
+
walk(root, '$');
|
|
2572
|
+
return misses;
|
|
2573
|
+
function walk(value, where) {
|
|
2574
|
+
if (Array.isArray(value)) {
|
|
2575
|
+
for (let i = 0; i < value.length; i += 1)
|
|
2576
|
+
walk(value[i], `${where}[${i}]`);
|
|
2577
|
+
return;
|
|
2578
|
+
}
|
|
2579
|
+
if (value === null || typeof value !== 'object')
|
|
2580
|
+
return;
|
|
2581
|
+
const rec = value;
|
|
2582
|
+
for (const key of Object.keys(rec)) {
|
|
2583
|
+
const child = rec[key];
|
|
2584
|
+
if (key === 'path' && typeof child === 'string') {
|
|
2585
|
+
const candidate = child.trim();
|
|
2586
|
+
if (looksLikeFilesystemRef(candidate)) {
|
|
2587
|
+
const id = `${where}.${key}:${candidate}`;
|
|
2588
|
+
if (!seen.has(id)) {
|
|
2589
|
+
seen.add(id);
|
|
2590
|
+
if (!pathExistsInWorkspace(cwd, candidate)) {
|
|
2591
|
+
misses.push({ path: candidate, where });
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
continue;
|
|
2596
|
+
}
|
|
2597
|
+
walk(child, `${where}.${key}`);
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
function looksLikeFilesystemRef(candidate) {
|
|
2602
|
+
if (candidate.length === 0)
|
|
2603
|
+
return false;
|
|
2604
|
+
// Skip obvious schema placeholders like ".sharkcraft/context-stream/<timestamp>.json".
|
|
2605
|
+
if (/[<>{}]/.test(candidate))
|
|
2606
|
+
return false;
|
|
2607
|
+
if (candidate.includes('/'))
|
|
2608
|
+
return true;
|
|
2609
|
+
return /\.(ts|tsx|js|jsx|mjs|cjs|json|md|yml|yaml|css|html)$/.test(candidate);
|
|
2610
|
+
}
|
|
2611
|
+
/**
|
|
2612
|
+
* Try to extract + parse a JSON object from a focused-mode LLM
|
|
2613
|
+
* response. The model is asked to emit one ```json fenced block; if
|
|
2614
|
+
* it complies we parse it. Otherwise we fall back to the existing
|
|
2615
|
+
* balanced-brace heuristics that ai-plan already uses.
|
|
2616
|
+
*
|
|
2617
|
+
* Returns the parsed object on success, `null` on any failure. Never
|
|
2618
|
+
* throws — focused mode tolerates missing structure.
|
|
2619
|
+
*/
|
|
2620
|
+
function tryParseFocusedJson(content) {
|
|
2621
|
+
const parsed = extractJsonObject(content);
|
|
2622
|
+
if (!parsed.ok)
|
|
2623
|
+
return null;
|
|
2624
|
+
if (parsed.value === null || typeof parsed.value !== 'object')
|
|
2625
|
+
return null;
|
|
2626
|
+
if (Array.isArray(parsed.value))
|
|
2627
|
+
return null;
|
|
2628
|
+
return parsed.value;
|
|
2629
|
+
}
|
|
2630
|
+
function buildStage1Messages(seed, grounding, cacheReference = null) {
|
|
2631
|
+
const briefs = renderStage1FileBriefs(seed.stage1FileBriefs);
|
|
2632
|
+
const docHits = renderDocumentationHits(seed.documentationHits);
|
|
2633
|
+
const reference = renderCacheReference(cacheReference);
|
|
2634
|
+
return buildPromptMessages({
|
|
2635
|
+
systemPreamble: STAGE1_SYSTEM_PREAMBLE,
|
|
2636
|
+
context: [
|
|
2637
|
+
renderSeed(seed),
|
|
2638
|
+
'',
|
|
2639
|
+
renderInitialGraphGrounding(grounding),
|
|
2640
|
+
...(briefs ? ['', briefs] : []),
|
|
2641
|
+
...(docHits ? ['', docHits] : []),
|
|
2642
|
+
...(reference ? ['', reference] : []),
|
|
2643
|
+
'',
|
|
2644
|
+
'Use only paths, rule ids, commands, and symbols that appear in the supplied context.',
|
|
2645
|
+
`Expansion schema: ${JSON.stringify(SmartContextExpansionRequestSchema)}`,
|
|
2646
|
+
].join('\n'),
|
|
2647
|
+
task: seed.task,
|
|
2648
|
+
});
|
|
2649
|
+
}
|
|
2650
|
+
function renderCacheReference(hit) {
|
|
2651
|
+
if (!hit)
|
|
2652
|
+
return '';
|
|
2653
|
+
const lines = [];
|
|
2654
|
+
const plan = hit.entry.plan;
|
|
2655
|
+
const summary = typeof plan.summary === 'string' ? plan.summary : '';
|
|
2656
|
+
const approach = typeof plan.likelyTechnicalApproach === 'string' ? plan.likelyTechnicalApproach : '';
|
|
2657
|
+
const handoff = typeof plan.handoffSummary === 'string' ? plan.handoffSummary : '';
|
|
2658
|
+
lines.push(`# Prior similar plan (cosine ${hit.similarity.toFixed(3)} — for reference only, do not copy verbatim)`);
|
|
2659
|
+
lines.push(`- prior task: ${truncateLine(hit.entry.task, 200)}`);
|
|
2660
|
+
lines.push(`- saved: ${hit.entry.savedAt}`);
|
|
2661
|
+
if (summary)
|
|
2662
|
+
lines.push(`- summary: ${truncateLine(summary, 240)}`);
|
|
2663
|
+
if (approach)
|
|
2664
|
+
lines.push(`- approach: ${truncateLine(approach, 240)}`);
|
|
2665
|
+
if (handoff)
|
|
2666
|
+
lines.push(`- handoff: ${truncateLine(handoff, 240)}`);
|
|
2667
|
+
const editable = plan.likelyFilesToModify ?? [];
|
|
2668
|
+
if (editable.length > 0) {
|
|
2669
|
+
lines.push(`- prior files to modify: ${editable.slice(0, 6).map((e) => '`' + e.path + '`').join(', ')}`);
|
|
2670
|
+
}
|
|
2671
|
+
return lines.join('\n');
|
|
2672
|
+
}
|
|
2673
|
+
function renderDocumentationHits(hits) {
|
|
2674
|
+
if (hits.length === 0)
|
|
2675
|
+
return '';
|
|
2676
|
+
const lines = [];
|
|
2677
|
+
lines.push('# Documentation hits (keyword-grep on docs/, CLAUDE.md, AGENTS.md, READMEs)');
|
|
2678
|
+
for (const h of hits) {
|
|
2679
|
+
lines.push(`- \`${h.path}\`:${h.line} (matched \`${h.token}\`) — ${h.snippet}`);
|
|
2680
|
+
}
|
|
2681
|
+
return lines.join('\n');
|
|
2682
|
+
}
|
|
2683
|
+
function renderStage1FileBriefs(briefs) {
|
|
2684
|
+
if (briefs.length === 0)
|
|
2685
|
+
return '';
|
|
2686
|
+
const lines = [];
|
|
2687
|
+
lines.push('# Candidate file briefs (task-ranked — primary source for stage-1 targets)');
|
|
2688
|
+
for (const b of briefs) {
|
|
2689
|
+
lines.push(`## \`${b.path}\``);
|
|
2690
|
+
if (b.summary)
|
|
2691
|
+
lines.push(` summary: ${b.summary}`);
|
|
2692
|
+
if (b.exports.length > 0)
|
|
2693
|
+
lines.push(` exports: ${b.exports.join(', ')}`);
|
|
2694
|
+
if (b.exportSignatures.length > 0) {
|
|
2695
|
+
lines.push(' signatures:');
|
|
2696
|
+
for (const sig of b.exportSignatures)
|
|
2697
|
+
lines.push(` ${sig}`);
|
|
2698
|
+
}
|
|
2699
|
+
if (b.imports.length > 0)
|
|
2700
|
+
lines.push(` imports: ${b.imports.join(', ')}`);
|
|
2701
|
+
if (b.importedBy.length > 0)
|
|
2702
|
+
lines.push(` imported by: ${b.importedBy.join(', ')}`);
|
|
2703
|
+
}
|
|
2704
|
+
return lines.join('\n');
|
|
2705
|
+
}
|
|
2706
|
+
function buildStage2Messages(seed, grounding, collected, cacheReference = null) {
|
|
2707
|
+
const reference = renderCacheReference(cacheReference);
|
|
2708
|
+
return buildPromptMessages({
|
|
2709
|
+
systemPreamble: STAGE2_SYSTEM_PREAMBLE,
|
|
2710
|
+
context: [
|
|
2711
|
+
renderSeed(seed),
|
|
2712
|
+
'',
|
|
2713
|
+
renderInitialGraphGrounding(grounding),
|
|
2714
|
+
'',
|
|
2715
|
+
'# Additional collected context',
|
|
2716
|
+
renderCollectedContext(collected),
|
|
2717
|
+
...(reference ? ['', reference] : []),
|
|
2718
|
+
'',
|
|
2719
|
+
`Detailed plan schema: ${JSON.stringify(SmartContextDetailedPlanSchema)}`,
|
|
2720
|
+
].join('\n'),
|
|
2721
|
+
task: seed.task,
|
|
2722
|
+
});
|
|
2723
|
+
}
|
|
2724
|
+
function renderDeterministicFallback(seed) {
|
|
2725
|
+
const lines = [];
|
|
2726
|
+
lines.push('AI provider unavailable; returning deterministic smart-context only.');
|
|
2727
|
+
lines.push('');
|
|
2728
|
+
lines.push(renderSeed(seed));
|
|
2729
|
+
if (seed.packet.verificationCommands.length > 0) {
|
|
2730
|
+
lines.push('', '# Verification commands');
|
|
2731
|
+
for (const command of seed.packet.verificationCommands)
|
|
2732
|
+
lines.push(`- \`${command}\``);
|
|
2733
|
+
}
|
|
2734
|
+
return lines.join('\n');
|
|
2735
|
+
}
|
|
2736
|
+
function parseExpansionRequest(raw) {
|
|
2737
|
+
const parsed = extractJsonObject(raw);
|
|
2738
|
+
if (!parsed.ok)
|
|
2739
|
+
return parsed;
|
|
2740
|
+
const validated = validateExpansionRequest(parsed.value);
|
|
2741
|
+
if (!validated.ok)
|
|
2742
|
+
return validated;
|
|
2743
|
+
return { ok: true, value: validated.value };
|
|
2744
|
+
}
|
|
2745
|
+
function parseDetailedPlan(raw) {
|
|
2746
|
+
const parsed = extractJsonObject(raw);
|
|
2747
|
+
if (!parsed.ok)
|
|
2748
|
+
return parsed;
|
|
2749
|
+
const validated = validateDetailedPlan(parsed.value);
|
|
2750
|
+
if (!validated.ok)
|
|
2751
|
+
return validated;
|
|
2752
|
+
return { ok: true, value: validated.value };
|
|
2753
|
+
}
|
|
2754
|
+
function extractJsonObject(raw) {
|
|
2755
|
+
const trimmed = raw.trim();
|
|
2756
|
+
const fenced = trimmed.match(/```json\s*([\s\S]*?)```/i);
|
|
2757
|
+
const candidate = fenced?.[1]?.trim() ?? trimmed;
|
|
2758
|
+
const direct = tryParseJson(candidate);
|
|
2759
|
+
if (direct.ok)
|
|
2760
|
+
return direct;
|
|
2761
|
+
const balanced = extractBalancedJsonObject(candidate);
|
|
2762
|
+
if (balanced) {
|
|
2763
|
+
const parsedBalanced = tryParseJson(balanced);
|
|
2764
|
+
if (parsedBalanced.ok)
|
|
2765
|
+
return parsedBalanced;
|
|
2766
|
+
const repaired = repairIncompleteJson(balanced);
|
|
2767
|
+
if (repaired) {
|
|
2768
|
+
const parsedRepaired = tryParseJson(repaired);
|
|
2769
|
+
if (parsedRepaired.ok)
|
|
2770
|
+
return parsedRepaired;
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
const firstBrace = candidate.indexOf('{');
|
|
2774
|
+
const lastBrace = candidate.lastIndexOf('}');
|
|
2775
|
+
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
|
2776
|
+
const sliced = candidate.slice(firstBrace, lastBrace + 1);
|
|
2777
|
+
const parsedSlice = tryParseJson(sliced);
|
|
2778
|
+
if (parsedSlice.ok)
|
|
2779
|
+
return parsedSlice;
|
|
2780
|
+
const repaired = repairIncompleteJson(sliced);
|
|
2781
|
+
if (repaired)
|
|
2782
|
+
return tryParseJson(repaired);
|
|
2783
|
+
}
|
|
2784
|
+
const repairedCandidate = repairIncompleteJson(candidate);
|
|
2785
|
+
if (repairedCandidate) {
|
|
2786
|
+
const parsedRepairedCandidate = tryParseJson(repairedCandidate);
|
|
2787
|
+
if (parsedRepairedCandidate.ok)
|
|
2788
|
+
return parsedRepairedCandidate;
|
|
2789
|
+
}
|
|
2790
|
+
return {
|
|
2791
|
+
ok: false,
|
|
2792
|
+
error: new Error('AI response did not contain a parseable JSON object.'),
|
|
2793
|
+
};
|
|
2794
|
+
}
|
|
2795
|
+
function extractBalancedJsonObject(raw) {
|
|
2796
|
+
const start = raw.indexOf('{');
|
|
2797
|
+
if (start < 0)
|
|
2798
|
+
return null;
|
|
2799
|
+
const stack = ['}'];
|
|
2800
|
+
let inString = false;
|
|
2801
|
+
let escaping = false;
|
|
2802
|
+
for (let i = start + 1; i < raw.length; i += 1) {
|
|
2803
|
+
const ch = raw[i];
|
|
2804
|
+
if (escaping) {
|
|
2805
|
+
escaping = false;
|
|
2806
|
+
continue;
|
|
2807
|
+
}
|
|
2808
|
+
if (ch === '\\') {
|
|
2809
|
+
escaping = true;
|
|
2810
|
+
continue;
|
|
2811
|
+
}
|
|
2812
|
+
if (ch === '"') {
|
|
2813
|
+
inString = !inString;
|
|
2814
|
+
continue;
|
|
2815
|
+
}
|
|
2816
|
+
if (inString)
|
|
2817
|
+
continue;
|
|
2818
|
+
if (ch === '{')
|
|
2819
|
+
stack.push('}');
|
|
2820
|
+
else if (ch === '[')
|
|
2821
|
+
stack.push(']');
|
|
2822
|
+
else if (ch === '}' || ch === ']') {
|
|
2823
|
+
const expected = stack.pop();
|
|
2824
|
+
if (expected !== ch)
|
|
2825
|
+
return null;
|
|
2826
|
+
if (stack.length === 0)
|
|
2827
|
+
return raw.slice(start, i + 1);
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
return raw.slice(start);
|
|
2831
|
+
}
|
|
2832
|
+
function repairIncompleteJson(raw) {
|
|
2833
|
+
const start = raw.indexOf('{');
|
|
2834
|
+
if (start < 0)
|
|
2835
|
+
return null;
|
|
2836
|
+
const candidate = raw.slice(start).trim();
|
|
2837
|
+
const stack = [];
|
|
2838
|
+
let inString = false;
|
|
2839
|
+
let escaping = false;
|
|
2840
|
+
for (let i = 0; i < candidate.length; i += 1) {
|
|
2841
|
+
const ch = candidate[i];
|
|
2842
|
+
if (escaping) {
|
|
2843
|
+
escaping = false;
|
|
2844
|
+
continue;
|
|
2845
|
+
}
|
|
2846
|
+
if (ch === '\\') {
|
|
2847
|
+
escaping = true;
|
|
2848
|
+
continue;
|
|
2849
|
+
}
|
|
2850
|
+
if (ch === '"') {
|
|
2851
|
+
inString = !inString;
|
|
2852
|
+
continue;
|
|
2853
|
+
}
|
|
2854
|
+
if (inString)
|
|
2855
|
+
continue;
|
|
2856
|
+
if (ch === '{')
|
|
2857
|
+
stack.push('}');
|
|
2858
|
+
else if (ch === '[')
|
|
2859
|
+
stack.push(']');
|
|
2860
|
+
else if (ch === '}' || ch === ']') {
|
|
2861
|
+
if (stack.length === 0)
|
|
2862
|
+
return null;
|
|
2863
|
+
const expected = stack.pop();
|
|
2864
|
+
if (expected !== ch)
|
|
2865
|
+
return null;
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
if (inString)
|
|
2869
|
+
return null;
|
|
2870
|
+
if (stack.length === 0)
|
|
2871
|
+
return candidate;
|
|
2872
|
+
return candidate + stack.reverse().join('');
|
|
2873
|
+
}
|
|
2874
|
+
function tryParseJson(raw) {
|
|
2875
|
+
try {
|
|
2876
|
+
return { ok: true, value: JSON.parse(raw) };
|
|
2877
|
+
}
|
|
2878
|
+
catch (e) {
|
|
2879
|
+
return { ok: false, error: new Error(`AI JSON parse failed: ${e.message}`) };
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
function validateExpansionRequest(value) {
|
|
2883
|
+
if (!isRecord(value))
|
|
2884
|
+
return { ok: false, error: new Error('Expansion request must be a JSON object.') };
|
|
2885
|
+
const filesToRead = validateTargetArray(value.filesToRead, 'filesToRead');
|
|
2886
|
+
const similarPatterns = validateTargetArray(value.similarPatterns, 'similarPatterns');
|
|
2887
|
+
const publicApiFiles = validateTargetArray(value.publicApiFiles, 'publicApiFiles');
|
|
2888
|
+
const testsToInspect = validateTargetArray(value.testsToInspect, 'testsToInspect');
|
|
2889
|
+
if (!filesToRead.ok)
|
|
2890
|
+
return filesToRead;
|
|
2891
|
+
if (!similarPatterns.ok)
|
|
2892
|
+
return similarPatterns;
|
|
2893
|
+
if (!publicApiFiles.ok)
|
|
2894
|
+
return publicApiFiles;
|
|
2895
|
+
if (!testsToInspect.ok)
|
|
2896
|
+
return testsToInspect;
|
|
2897
|
+
const architectureRules = validateRuleHintArray(value.architectureRules);
|
|
2898
|
+
if (!architectureRules.ok)
|
|
2899
|
+
return architectureRules;
|
|
2900
|
+
const riskyAreas = validateStringArray(value.riskyAreas, 'riskyAreas');
|
|
2901
|
+
const missingInformation = validateStringArray(value.missingInformation, 'missingInformation');
|
|
2902
|
+
if (!riskyAreas.ok)
|
|
2903
|
+
return riskyAreas;
|
|
2904
|
+
if (!missingInformation.ok)
|
|
2905
|
+
return missingInformation;
|
|
2906
|
+
return {
|
|
2907
|
+
ok: true,
|
|
2908
|
+
value: {
|
|
2909
|
+
...(typeof value.summary === 'string' ? { summary: value.summary } : {}),
|
|
2910
|
+
filesToRead: filesToRead.value,
|
|
2911
|
+
similarPatterns: similarPatterns.value,
|
|
2912
|
+
publicApiFiles: publicApiFiles.value,
|
|
2913
|
+
testsToInspect: testsToInspect.value,
|
|
2914
|
+
architectureRules: architectureRules.value,
|
|
2915
|
+
riskyAreas: riskyAreas.value,
|
|
2916
|
+
missingInformation: missingInformation.value,
|
|
2917
|
+
},
|
|
2918
|
+
};
|
|
2919
|
+
}
|
|
2920
|
+
function validateDetailedPlan(value) {
|
|
2921
|
+
if (!isRecord(value))
|
|
2922
|
+
return { ok: false, error: new Error('Detailed plan must be a JSON object.') };
|
|
2923
|
+
const requiredStrings = ['summary', 'taskUnderstanding', 'likelyTechnicalApproach', 'handoffSummary'];
|
|
2924
|
+
for (const key of requiredStrings) {
|
|
2925
|
+
if (typeof value[key] !== 'string' || value[key].trim().length === 0) {
|
|
2926
|
+
return { ok: false, error: new Error(`Detailed plan field "${key}" must be a non-empty string.`) };
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
const existingPatternsToFollow = validatePathWhyArray(value.existingPatternsToFollow, 'existingPatternsToFollow');
|
|
2930
|
+
const filesToRead = validatePathWhyArray(value.filesToRead, 'filesToRead');
|
|
2931
|
+
const likelyFilesToModify = validatePathWhyArray(value.likelyFilesToModify, 'likelyFilesToModify');
|
|
2932
|
+
const filesToAvoid = validatePathWhyArray(value.filesToAvoid, 'filesToAvoid');
|
|
2933
|
+
const publicApiFiles = validatePathWhyArray(value.publicApiFiles, 'publicApiFiles');
|
|
2934
|
+
const testsToInspect = validatePathWhyArray(value.testsToInspect, 'testsToInspect');
|
|
2935
|
+
const relatedRules = validateRelatedRules(value.relatedRules);
|
|
2936
|
+
const relatedTemplates = validateRelatedTemplates(value.relatedTemplates);
|
|
2937
|
+
const firstCommands = validateCommandWhyArray(value.firstCommands, 'firstCommands');
|
|
2938
|
+
const implementationSteps = validateStepArray(value.implementationSteps);
|
|
2939
|
+
const architectureConstraints = validateStringArray(value.architectureConstraints, 'architectureConstraints');
|
|
2940
|
+
const risks = validateStringArray(value.risks, 'risks');
|
|
2941
|
+
const unknowns = validateStringArray(value.unknowns, 'unknowns');
|
|
2942
|
+
const validationCommands = validateStringArray(value.validationCommands, 'validationCommands');
|
|
2943
|
+
if (!existingPatternsToFollow.ok)
|
|
2944
|
+
return existingPatternsToFollow;
|
|
2945
|
+
if (!filesToRead.ok)
|
|
2946
|
+
return filesToRead;
|
|
2947
|
+
if (!likelyFilesToModify.ok)
|
|
2948
|
+
return likelyFilesToModify;
|
|
2949
|
+
if (!filesToAvoid.ok)
|
|
2950
|
+
return filesToAvoid;
|
|
2951
|
+
if (!publicApiFiles.ok)
|
|
2952
|
+
return publicApiFiles;
|
|
2953
|
+
if (!testsToInspect.ok)
|
|
2954
|
+
return testsToInspect;
|
|
2955
|
+
if (!relatedRules.ok)
|
|
2956
|
+
return relatedRules;
|
|
2957
|
+
if (!relatedTemplates.ok)
|
|
2958
|
+
return relatedTemplates;
|
|
2959
|
+
if (!firstCommands.ok)
|
|
2960
|
+
return firstCommands;
|
|
2961
|
+
if (!implementationSteps.ok)
|
|
2962
|
+
return implementationSteps;
|
|
2963
|
+
if (!architectureConstraints.ok)
|
|
2964
|
+
return architectureConstraints;
|
|
2965
|
+
if (!risks.ok)
|
|
2966
|
+
return risks;
|
|
2967
|
+
if (!unknowns.ok)
|
|
2968
|
+
return unknowns;
|
|
2969
|
+
if (!validationCommands.ok)
|
|
2970
|
+
return validationCommands;
|
|
2971
|
+
return {
|
|
2972
|
+
ok: true,
|
|
2973
|
+
value: {
|
|
2974
|
+
summary: value.summary,
|
|
2975
|
+
taskUnderstanding: value.taskUnderstanding,
|
|
2976
|
+
likelyTechnicalApproach: value.likelyTechnicalApproach,
|
|
2977
|
+
existingPatternsToFollow: existingPatternsToFollow.value,
|
|
2978
|
+
filesToRead: filesToRead.value,
|
|
2979
|
+
likelyFilesToModify: likelyFilesToModify.value,
|
|
2980
|
+
filesToAvoid: filesToAvoid.value,
|
|
2981
|
+
publicApiFiles: publicApiFiles.value,
|
|
2982
|
+
testsToInspect: testsToInspect.value,
|
|
2983
|
+
architectureConstraints: architectureConstraints.value,
|
|
2984
|
+
relatedRules: relatedRules.value,
|
|
2985
|
+
relatedTemplates: relatedTemplates.value,
|
|
2986
|
+
firstCommands: firstCommands.value,
|
|
2987
|
+
implementationSteps: implementationSteps.value,
|
|
2988
|
+
risks: risks.value,
|
|
2989
|
+
unknowns: unknowns.value,
|
|
2990
|
+
validationCommands: validationCommands.value,
|
|
2991
|
+
handoffSummary: value.handoffSummary,
|
|
2992
|
+
},
|
|
2993
|
+
};
|
|
2994
|
+
}
|
|
2995
|
+
function validateTargetArray(value, field) {
|
|
2996
|
+
if (value === undefined)
|
|
2997
|
+
return { ok: true, value: [] };
|
|
2998
|
+
if (!Array.isArray(value))
|
|
2999
|
+
return { ok: false, error: new Error(`Expansion field "${field}" must be an array.`) };
|
|
3000
|
+
const out = [];
|
|
3001
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
3002
|
+
const item = value[i];
|
|
3003
|
+
if (!isRecord(item) || typeof item.target !== 'string' || typeof item.why !== 'string') {
|
|
3004
|
+
return { ok: false, error: new Error(`Expansion field "${field}[${i}]" must contain { target, why }.`) };
|
|
3005
|
+
}
|
|
3006
|
+
out.push({ target: item.target.trim(), why: item.why.trim() });
|
|
3007
|
+
}
|
|
3008
|
+
return { ok: true, value: out.filter((item) => item.target.length > 0 && item.why.length > 0) };
|
|
3009
|
+
}
|
|
3010
|
+
function validateRuleHintArray(value) {
|
|
3011
|
+
if (value === undefined)
|
|
3012
|
+
return { ok: true, value: [] };
|
|
3013
|
+
if (!Array.isArray(value))
|
|
3014
|
+
return { ok: false, error: new Error('Expansion field "architectureRules" must be an array.') };
|
|
3015
|
+
const out = [];
|
|
3016
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
3017
|
+
const item = value[i];
|
|
3018
|
+
if (!isRecord(item) || typeof item.id !== 'string' || typeof item.why !== 'string') {
|
|
3019
|
+
return { ok: false, error: new Error(`Expansion field "architectureRules[${i}]" must contain { id, why }.`) };
|
|
3020
|
+
}
|
|
3021
|
+
out.push({ id: item.id.trim(), why: item.why.trim() });
|
|
3022
|
+
}
|
|
3023
|
+
return { ok: true, value: out.filter((item) => item.id.length > 0 && item.why.length > 0) };
|
|
3024
|
+
}
|
|
3025
|
+
function validateStringArray(value, field) {
|
|
3026
|
+
if (value === undefined)
|
|
3027
|
+
return { ok: true, value: [] };
|
|
3028
|
+
if (!Array.isArray(value))
|
|
3029
|
+
return { ok: false, error: new Error(`Field "${field}" must be an array of strings.`) };
|
|
3030
|
+
const out = [];
|
|
3031
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
3032
|
+
if (typeof value[i] !== 'string')
|
|
3033
|
+
return { ok: false, error: new Error(`Field "${field}[${i}]" must be a string.`) };
|
|
3034
|
+
const trimmed = value[i].trim();
|
|
3035
|
+
if (trimmed.length > 0)
|
|
3036
|
+
out.push(trimmed);
|
|
3037
|
+
}
|
|
3038
|
+
return { ok: true, value: out };
|
|
3039
|
+
}
|
|
3040
|
+
function validatePathWhyArray(value, field) {
|
|
3041
|
+
if (value === undefined)
|
|
3042
|
+
return { ok: true, value: [] };
|
|
3043
|
+
if (!Array.isArray(value))
|
|
3044
|
+
return { ok: false, error: new Error(`Field "${field}" must be an array.`) };
|
|
3045
|
+
const out = [];
|
|
3046
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
3047
|
+
const item = value[i];
|
|
3048
|
+
if (!isRecord(item) || typeof item.path !== 'string' || typeof item.why !== 'string') {
|
|
3049
|
+
return { ok: false, error: new Error(`Field "${field}[${i}]" must contain { path, why }.`) };
|
|
3050
|
+
}
|
|
3051
|
+
out.push({ path: item.path.trim(), why: item.why.trim() });
|
|
3052
|
+
}
|
|
3053
|
+
return { ok: true, value: out.filter((item) => item.path.length > 0 && item.why.length > 0) };
|
|
3054
|
+
}
|
|
3055
|
+
function validateCommandWhyArray(value, field) {
|
|
3056
|
+
if (value === undefined)
|
|
3057
|
+
return { ok: true, value: [] };
|
|
3058
|
+
if (!Array.isArray(value))
|
|
3059
|
+
return { ok: false, error: new Error(`Field "${field}" must be an array.`) };
|
|
3060
|
+
const out = [];
|
|
3061
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
3062
|
+
const item = value[i];
|
|
3063
|
+
if (!isRecord(item) || typeof item.command !== 'string' || typeof item.why !== 'string') {
|
|
3064
|
+
return { ok: false, error: new Error(`Field "${field}[${i}]" must contain { command, why }.`) };
|
|
3065
|
+
}
|
|
3066
|
+
out.push({ command: item.command.trim(), why: item.why.trim() });
|
|
3067
|
+
}
|
|
3068
|
+
return { ok: true, value: out.filter((item) => item.command.length > 0 && item.why.length > 0) };
|
|
3069
|
+
}
|
|
3070
|
+
function validateStepArray(value) {
|
|
3071
|
+
if (value === undefined)
|
|
3072
|
+
return { ok: true, value: [] };
|
|
3073
|
+
if (!Array.isArray(value))
|
|
3074
|
+
return { ok: false, error: new Error('Field "implementationSteps" must be an array.') };
|
|
3075
|
+
const out = [];
|
|
3076
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
3077
|
+
const item = value[i];
|
|
3078
|
+
if (!isRecord(item) || typeof item.step !== 'string' || typeof item.details !== 'string') {
|
|
3079
|
+
return { ok: false, error: new Error(`Field "implementationSteps[${i}]" must contain { step, details }.`) };
|
|
3080
|
+
}
|
|
3081
|
+
out.push({ step: item.step.trim(), details: item.details.trim() });
|
|
3082
|
+
}
|
|
3083
|
+
return { ok: true, value: out.filter((item) => item.step.length > 0 && item.details.length > 0) };
|
|
3084
|
+
}
|
|
3085
|
+
function validateRelatedRules(value) {
|
|
3086
|
+
if (value === undefined)
|
|
3087
|
+
return { ok: true, value: [] };
|
|
3088
|
+
if (!Array.isArray(value))
|
|
3089
|
+
return { ok: false, error: new Error('Field "relatedRules" must be an array.') };
|
|
3090
|
+
const out = [];
|
|
3091
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
3092
|
+
const item = value[i];
|
|
3093
|
+
if (!isRecord(item) ||
|
|
3094
|
+
typeof item.id !== 'string' ||
|
|
3095
|
+
typeof item.title !== 'string' ||
|
|
3096
|
+
typeof item.applyWhen !== 'string') {
|
|
3097
|
+
return { ok: false, error: new Error(`Field "relatedRules[${i}]" must contain { id, title, applyWhen }.`) };
|
|
3098
|
+
}
|
|
3099
|
+
out.push({ id: item.id.trim(), title: item.title.trim(), applyWhen: item.applyWhen.trim() });
|
|
3100
|
+
}
|
|
3101
|
+
return { ok: true, value: out.filter((item) => item.id.length > 0 && item.title.length > 0 && item.applyWhen.length > 0) };
|
|
3102
|
+
}
|
|
3103
|
+
function validateRelatedTemplates(value) {
|
|
3104
|
+
if (value === undefined)
|
|
3105
|
+
return { ok: true, value: [] };
|
|
3106
|
+
if (!Array.isArray(value))
|
|
3107
|
+
return { ok: false, error: new Error('Field "relatedTemplates" must be an array.') };
|
|
3108
|
+
const out = [];
|
|
3109
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
3110
|
+
const item = value[i];
|
|
3111
|
+
if (!isRecord(item) || typeof item.id !== 'string' || typeof item.useFor !== 'string') {
|
|
3112
|
+
return { ok: false, error: new Error(`Field "relatedTemplates[${i}]" must contain { id, useFor }.`) };
|
|
3113
|
+
}
|
|
3114
|
+
out.push({ id: item.id.trim(), useFor: item.useFor.trim() });
|
|
3115
|
+
}
|
|
3116
|
+
return { ok: true, value: out.filter((item) => item.id.length > 0 && item.useFor.length > 0) };
|
|
3117
|
+
}
|
|
3118
|
+
function collectExpansionContext(input) {
|
|
3119
|
+
const graphApi = new GraphStore(input.cwd).exists() ? GraphQueryApi.fromStore(input.cwd) : null;
|
|
3120
|
+
const limitPerCategory = Math.max(2, Math.floor(input.options.expansionLimit / 4));
|
|
3121
|
+
const selectedFiles = resolveFileContexts(input.cwd, graphApi, input.request.filesToRead, limitPerCategory);
|
|
3122
|
+
const similarPatternFiles = resolveFileContexts(input.cwd, graphApi, input.request.similarPatterns, limitPerCategory);
|
|
3123
|
+
const publicApiFiles = uniqueFileContexts([
|
|
3124
|
+
...resolveFileContexts(input.cwd, graphApi, input.request.publicApiFiles, limitPerCategory),
|
|
3125
|
+
...resolveDerivedContexts(input.cwd, graphApi, selectedFiles, limitPerCategory, 'public'),
|
|
3126
|
+
...resolveDerivedContexts(input.cwd, graphApi, similarPatternFiles, limitPerCategory, 'public'),
|
|
3127
|
+
]).slice(0, limitPerCategory);
|
|
3128
|
+
const testFiles = uniqueFileContexts([
|
|
3129
|
+
...resolveFileContexts(input.cwd, graphApi, input.request.testsToInspect, limitPerCategory),
|
|
3130
|
+
...resolveDerivedContexts(input.cwd, graphApi, selectedFiles, limitPerCategory, 'test'),
|
|
3131
|
+
...resolveDerivedContexts(input.cwd, graphApi, similarPatternFiles, limitPerCategory, 'test'),
|
|
3132
|
+
]).slice(0, limitPerCategory);
|
|
3133
|
+
const architectureRules = input.request.architectureRules
|
|
3134
|
+
.map((hint) => {
|
|
3135
|
+
const rule = input.inspection.ruleService.get(hint.id);
|
|
3136
|
+
if (!rule)
|
|
3137
|
+
return null;
|
|
3138
|
+
return { id: rule.id, title: rule.title, why: hint.why };
|
|
3139
|
+
})
|
|
3140
|
+
.filter((rule) => rule !== null);
|
|
3141
|
+
return {
|
|
3142
|
+
schema: 'sharkcraft.smart-context-collection/v1',
|
|
3143
|
+
selectedFiles,
|
|
3144
|
+
similarPatternFiles,
|
|
3145
|
+
publicApiFiles,
|
|
3146
|
+
testFiles,
|
|
3147
|
+
architectureRules,
|
|
3148
|
+
riskyAreas: input.request.riskyAreas.slice(0, input.options.expansionLimit),
|
|
3149
|
+
missingInformation: input.request.missingInformation.slice(0, input.options.expansionLimit),
|
|
3150
|
+
};
|
|
3151
|
+
}
|
|
3152
|
+
function resolveFileContexts(cwd, api, hints, limit) {
|
|
3153
|
+
const out = [];
|
|
3154
|
+
for (const hint of hints) {
|
|
3155
|
+
const resolved = resolvePathsForTarget(cwd, api, hint.target, limit);
|
|
3156
|
+
for (const path of resolved) {
|
|
3157
|
+
out.push(describeFileContext(cwd, api, path, hint.target, hint.why));
|
|
3158
|
+
if (out.length >= limit)
|
|
3159
|
+
return uniqueFileContexts(out).slice(0, limit);
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
return uniqueFileContexts(out).slice(0, limit);
|
|
3163
|
+
}
|
|
3164
|
+
function resolveDerivedContexts(cwd, api, bases, limit, mode) {
|
|
3165
|
+
const out = [];
|
|
3166
|
+
for (const base of bases) {
|
|
3167
|
+
const targets = mode === 'public' ? base.publicApiCandidates : base.testCandidates;
|
|
3168
|
+
for (const target of targets.slice(0, 3)) {
|
|
3169
|
+
out.push(describeFileContext(cwd, api, target, base.path, `${mode === 'public' ? 'public API' : 'test'} candidate for ${base.path}`));
|
|
3170
|
+
if (out.length >= limit)
|
|
3171
|
+
return uniqueFileContexts(out).slice(0, limit);
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
return uniqueFileContexts(out).slice(0, limit);
|
|
3175
|
+
}
|
|
3176
|
+
function describeFileContext(cwd, api, path, requestedTarget, why) {
|
|
3177
|
+
const rel = normalizePath(path);
|
|
3178
|
+
const packageName = packageNameForPath(rel);
|
|
3179
|
+
const imports = [];
|
|
3180
|
+
const importedBy = [];
|
|
3181
|
+
const symbols = [];
|
|
3182
|
+
if (api) {
|
|
3183
|
+
const file = api.findFile(rel);
|
|
3184
|
+
if (file) {
|
|
3185
|
+
for (const dep of api.importsFrom(file.id).slice(0, 6)) {
|
|
3186
|
+
if (dep.path)
|
|
3187
|
+
imports.push(dep.path);
|
|
3188
|
+
}
|
|
3189
|
+
for (const dep of api.importersOf(file.id).slice(0, 6)) {
|
|
3190
|
+
if (dep.path)
|
|
3191
|
+
importedBy.push(dep.path);
|
|
3192
|
+
}
|
|
3193
|
+
for (const symbol of api.symbolsIn(file.id).slice(0, 8)) {
|
|
3194
|
+
symbols.push(symbol.label);
|
|
3195
|
+
}
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
3198
|
+
return {
|
|
3199
|
+
path: rel,
|
|
3200
|
+
why,
|
|
3201
|
+
requestedTarget,
|
|
3202
|
+
packageName,
|
|
3203
|
+
imports,
|
|
3204
|
+
importedBy,
|
|
3205
|
+
symbols,
|
|
3206
|
+
publicApiCandidates: derivePublicApiCandidates(cwd, rel),
|
|
3207
|
+
testCandidates: deriveTestCandidates(cwd, rel, api),
|
|
3208
|
+
};
|
|
3209
|
+
}
|
|
3210
|
+
function resolvePathsForTarget(cwd, api, target, limit) {
|
|
3211
|
+
const normalized = normalizePath(target.trim());
|
|
3212
|
+
const abs = nodePath.isAbsolute(target) ? target : nodePath.join(cwd, normalized);
|
|
3213
|
+
if (existsSync(abs) && statSync(abs).isFile())
|
|
3214
|
+
return [normalizePath(nodePath.relative(cwd, abs))];
|
|
3215
|
+
const out = new Set();
|
|
3216
|
+
if (api) {
|
|
3217
|
+
const exact = api.findFile(normalized);
|
|
3218
|
+
if (exact?.path)
|
|
3219
|
+
out.add(exact.path);
|
|
3220
|
+
for (const hit of fuzzyGraphFileSearch(api, normalized, limit))
|
|
3221
|
+
out.add(hit);
|
|
3222
|
+
if (out.size < limit) {
|
|
3223
|
+
for (const sym of api.findSymbol(target, { exact: false, limit })) {
|
|
3224
|
+
const owner = declaringFileOf(api, sym.id);
|
|
3225
|
+
if (owner?.path)
|
|
3226
|
+
out.add(owner.path);
|
|
3227
|
+
if (out.size >= limit)
|
|
3228
|
+
break;
|
|
3229
|
+
}
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
else {
|
|
3233
|
+
for (const hit of fuzzyFsSearch(cwd, normalized, limit))
|
|
3234
|
+
out.add(hit);
|
|
3235
|
+
}
|
|
3236
|
+
return [...out].slice(0, limit);
|
|
3237
|
+
}
|
|
3238
|
+
function fuzzyGraphFileSearch(api, query, limit) {
|
|
3239
|
+
const q = query.toLowerCase();
|
|
3240
|
+
const hits = [];
|
|
3241
|
+
for (const node of api.allFiles()) {
|
|
3242
|
+
const path = node.path ?? '';
|
|
3243
|
+
const base = path.slice(path.lastIndexOf('/') + 1);
|
|
3244
|
+
let score = 0;
|
|
3245
|
+
if (path === query)
|
|
3246
|
+
score += 12;
|
|
3247
|
+
if (base === query)
|
|
3248
|
+
score += 10;
|
|
3249
|
+
if (base.toLowerCase() === q)
|
|
3250
|
+
score += 9;
|
|
3251
|
+
if (base.toLowerCase().includes(q))
|
|
3252
|
+
score += 6;
|
|
3253
|
+
if (path.toLowerCase().includes(q))
|
|
3254
|
+
score += 4;
|
|
3255
|
+
if (score > 0)
|
|
3256
|
+
hits.push({ path, score });
|
|
3257
|
+
}
|
|
3258
|
+
hits.sort((a, b) => (b.score === a.score ? a.path.localeCompare(b.path) : b.score - a.score));
|
|
3259
|
+
return hits.slice(0, limit).map((hit) => hit.path);
|
|
3260
|
+
}
|
|
3261
|
+
function fuzzyFsSearch(cwd, query, limit) {
|
|
3262
|
+
const q = query.toLowerCase();
|
|
3263
|
+
const hits = [];
|
|
3264
|
+
for (const path of walkFiles(cwd)) {
|
|
3265
|
+
const rel = normalizePath(nodePath.relative(cwd, path));
|
|
3266
|
+
const base = rel.slice(rel.lastIndexOf('/') + 1);
|
|
3267
|
+
let score = 0;
|
|
3268
|
+
if (rel === query)
|
|
3269
|
+
score += 12;
|
|
3270
|
+
if (base.toLowerCase() === q)
|
|
3271
|
+
score += 9;
|
|
3272
|
+
if (base.toLowerCase().includes(q))
|
|
3273
|
+
score += 6;
|
|
3274
|
+
if (rel.toLowerCase().includes(q))
|
|
3275
|
+
score += 4;
|
|
3276
|
+
if (score > 0)
|
|
3277
|
+
hits.push({ path: rel, score });
|
|
3278
|
+
}
|
|
3279
|
+
hits.sort((a, b) => (b.score === a.score ? a.path.localeCompare(b.path) : b.score - a.score));
|
|
3280
|
+
return hits.slice(0, limit).map((hit) => hit.path);
|
|
3281
|
+
}
|
|
3282
|
+
function walkFiles(cwd) {
|
|
3283
|
+
const roots = ['packages', 'docs', 'sharkcraft', 'examples', 'libs']
|
|
3284
|
+
.map((part) => nodePath.join(cwd, part))
|
|
3285
|
+
.filter((abs) => existsSync(abs));
|
|
3286
|
+
const out = [];
|
|
3287
|
+
for (const root of roots) {
|
|
3288
|
+
const stack = [root];
|
|
3289
|
+
while (stack.length > 0) {
|
|
3290
|
+
const cur = stack.pop();
|
|
3291
|
+
let entries = [];
|
|
3292
|
+
try {
|
|
3293
|
+
entries = readdirSync(cur);
|
|
3294
|
+
}
|
|
3295
|
+
catch {
|
|
3296
|
+
continue;
|
|
3297
|
+
}
|
|
3298
|
+
for (const entry of entries) {
|
|
3299
|
+
const abs = nodePath.join(cur, entry);
|
|
3300
|
+
let isFile = false;
|
|
3301
|
+
let isDir = false;
|
|
3302
|
+
try {
|
|
3303
|
+
const stat = statSync(abs);
|
|
3304
|
+
isFile = stat.isFile();
|
|
3305
|
+
isDir = stat.isDirectory();
|
|
3306
|
+
}
|
|
3307
|
+
catch {
|
|
3308
|
+
continue;
|
|
3309
|
+
}
|
|
3310
|
+
if (isDir) {
|
|
3311
|
+
if (entry === 'dist' || entry === 'node_modules' || entry.startsWith('.'))
|
|
3312
|
+
continue;
|
|
3313
|
+
stack.push(abs);
|
|
3314
|
+
}
|
|
3315
|
+
else if (isFile) {
|
|
3316
|
+
out.push(abs);
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
out.sort((a, b) => a.localeCompare(b));
|
|
3322
|
+
return out;
|
|
3323
|
+
}
|
|
3324
|
+
function derivePublicApiCandidates(cwd, path) {
|
|
3325
|
+
const match = path.match(/^packages\/([^/]+)\//);
|
|
3326
|
+
if (!match)
|
|
3327
|
+
return [];
|
|
3328
|
+
const pkg = match[1];
|
|
3329
|
+
const candidates = [
|
|
3330
|
+
`packages/${pkg}/src/index.ts`,
|
|
3331
|
+
`packages/${pkg}/public-api.ts`,
|
|
3332
|
+
`packages/${pkg}/index.ts`,
|
|
3333
|
+
];
|
|
3334
|
+
return candidates.filter((candidate) => candidate !== path && existsSync(nodePath.join(cwd, candidate)));
|
|
3335
|
+
}
|
|
3336
|
+
function deriveTestCandidates(cwd, path, api) {
|
|
3337
|
+
const ext = nodePath.extname(path);
|
|
3338
|
+
const base = path.slice(0, path.length - ext.length);
|
|
3339
|
+
const file = path.slice(path.lastIndexOf('/') + 1, path.length - ext.length);
|
|
3340
|
+
const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
|
|
3341
|
+
const candidates = [
|
|
3342
|
+
`${base}.test${ext}`,
|
|
3343
|
+
`${base}.spec${ext}`,
|
|
3344
|
+
`${dir}/__tests__/${file}.test${ext}`,
|
|
3345
|
+
`${dir}/__tests__/${file}.spec${ext}`,
|
|
3346
|
+
];
|
|
3347
|
+
const out = new Set();
|
|
3348
|
+
for (const candidate of candidates) {
|
|
3349
|
+
if (candidate !== path && existsSync(nodePath.join(cwd, candidate)))
|
|
3350
|
+
out.add(normalizePath(candidate));
|
|
3351
|
+
}
|
|
3352
|
+
if (api && out.size === 0) {
|
|
3353
|
+
for (const hit of fuzzyGraphFileSearch(api, `${file}.test`, 2))
|
|
3354
|
+
out.add(hit);
|
|
3355
|
+
for (const hit of fuzzyGraphFileSearch(api, `${file}.spec`, 2))
|
|
3356
|
+
out.add(hit);
|
|
3357
|
+
}
|
|
3358
|
+
return [...out].slice(0, 4);
|
|
3359
|
+
}
|
|
3360
|
+
function uniqueFileContexts(items) {
|
|
3361
|
+
const seen = new Set();
|
|
3362
|
+
const out = [];
|
|
3363
|
+
for (const item of items) {
|
|
3364
|
+
if (seen.has(item.path))
|
|
3365
|
+
continue;
|
|
3366
|
+
seen.add(item.path);
|
|
3367
|
+
out.push(item);
|
|
3368
|
+
}
|
|
3369
|
+
return out;
|
|
3370
|
+
}
|
|
3371
|
+
function renderCollectedContext(collected) {
|
|
3372
|
+
const lines = [];
|
|
3373
|
+
renderResolvedSection(lines, 'Files to inspect', collected.selectedFiles);
|
|
3374
|
+
renderResolvedSection(lines, 'Similar patterns', collected.similarPatternFiles);
|
|
3375
|
+
renderResolvedSection(lines, 'Public API files', collected.publicApiFiles);
|
|
3376
|
+
renderResolvedSection(lines, 'Tests to inspect', collected.testFiles);
|
|
3377
|
+
if (collected.architectureRules.length > 0) {
|
|
3378
|
+
lines.push('', '## Architecture rules');
|
|
3379
|
+
for (const rule of collected.architectureRules) {
|
|
3380
|
+
lines.push(`- \`${rule.id}\` — ${rule.title} (${rule.why})`);
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
if (collected.riskyAreas.length > 0) {
|
|
3384
|
+
lines.push('', '## Risky areas');
|
|
3385
|
+
for (const item of collected.riskyAreas)
|
|
3386
|
+
lines.push(`- ${item}`);
|
|
3387
|
+
}
|
|
3388
|
+
if (collected.missingInformation.length > 0) {
|
|
3389
|
+
lines.push('', '## Missing information');
|
|
3390
|
+
for (const item of collected.missingInformation)
|
|
3391
|
+
lines.push(`- ${item}`);
|
|
3392
|
+
}
|
|
3393
|
+
return lines.join('\n');
|
|
3394
|
+
}
|
|
3395
|
+
function renderResolvedSection(lines, title, items) {
|
|
3396
|
+
if (items.length === 0)
|
|
3397
|
+
return;
|
|
3398
|
+
lines.push('', `## ${title}`);
|
|
3399
|
+
for (const item of items) {
|
|
3400
|
+
lines.push(`- \`${item.path}\` — ${item.why}`);
|
|
3401
|
+
if (item.symbols.length > 0)
|
|
3402
|
+
lines.push(` symbols: ${item.symbols.join(', ')}`);
|
|
3403
|
+
if (item.imports.length > 0)
|
|
3404
|
+
lines.push(` imports: ${item.imports.join(', ')}`);
|
|
3405
|
+
if (item.importedBy.length > 0)
|
|
3406
|
+
lines.push(` imported by: ${item.importedBy.join(', ')}`);
|
|
3407
|
+
if (item.publicApiCandidates.length > 0)
|
|
3408
|
+
lines.push(` public API candidates: ${item.publicApiCandidates.join(', ')}`);
|
|
3409
|
+
if (item.testCandidates.length > 0)
|
|
3410
|
+
lines.push(` test candidates: ${item.testCandidates.join(', ')}`);
|
|
3411
|
+
}
|
|
3412
|
+
}
|
|
3413
|
+
function renderDetailedPlan(plan) {
|
|
3414
|
+
const summaryLines = [];
|
|
3415
|
+
summaryLines.push(plan.summary);
|
|
3416
|
+
summaryLines.push('');
|
|
3417
|
+
summaryLines.push(`Task understanding: ${plan.taskUnderstanding}`);
|
|
3418
|
+
summaryLines.push(`Likely approach: ${plan.likelyTechnicalApproach}`);
|
|
3419
|
+
if (plan.likelyFilesToModify.length > 0) {
|
|
3420
|
+
summaryLines.push('Likely files to modify:');
|
|
3421
|
+
for (const item of plan.likelyFilesToModify.slice(0, 6))
|
|
3422
|
+
summaryLines.push(`- \`${item.path}\` — ${item.why}`);
|
|
3423
|
+
}
|
|
3424
|
+
if (plan.filesToAvoid.length > 0) {
|
|
3425
|
+
summaryLines.push('Files to avoid:');
|
|
3426
|
+
for (const item of plan.filesToAvoid.slice(0, 4))
|
|
3427
|
+
summaryLines.push(`- \`${item.path}\` — ${item.why}`);
|
|
3428
|
+
}
|
|
3429
|
+
if (plan.architectureConstraints.length > 0) {
|
|
3430
|
+
summaryLines.push('Architecture constraints:');
|
|
3431
|
+
for (const item of plan.architectureConstraints.slice(0, 6))
|
|
3432
|
+
summaryLines.push(`- ${item}`);
|
|
3433
|
+
}
|
|
3434
|
+
if (plan.risks.length > 0) {
|
|
3435
|
+
summaryLines.push('Risks:');
|
|
3436
|
+
for (const item of plan.risks.slice(0, 6))
|
|
3437
|
+
summaryLines.push(`- ${item}`);
|
|
3438
|
+
}
|
|
3439
|
+
if (plan.unknowns.length > 0) {
|
|
3440
|
+
summaryLines.push('Unknowns:');
|
|
3441
|
+
for (const item of plan.unknowns.slice(0, 6))
|
|
3442
|
+
summaryLines.push(`- ${item}`);
|
|
3443
|
+
}
|
|
3444
|
+
if (plan.validationCommands.length > 0) {
|
|
3445
|
+
summaryLines.push('Validation commands:');
|
|
3446
|
+
for (const item of plan.validationCommands.slice(0, 6))
|
|
3447
|
+
summaryLines.push(`- \`${item}\``);
|
|
3448
|
+
}
|
|
3449
|
+
summaryLines.push(`Handoff: ${plan.handoffSummary}`);
|
|
3450
|
+
return `\`\`\`json\n${JSON.stringify(plan, null, 2)}\n\`\`\`\n\n${summaryLines.join('\n')}\n`;
|
|
3451
|
+
}
|
|
3452
|
+
function writeAiPlanDebug(envelope) {
|
|
3453
|
+
if (!envelope.aiPlan)
|
|
3454
|
+
return;
|
|
3455
|
+
process.stdout.write(header('AI Plan Debug'));
|
|
3456
|
+
process.stdout.write('\n');
|
|
3457
|
+
process.stdout.write('Initial smart-context result:\n');
|
|
3458
|
+
process.stdout.write(renderDeterministicEnvelope(envelope.deterministic));
|
|
3459
|
+
process.stdout.write('\n');
|
|
3460
|
+
if (envelope.aiPlan.stage1Request) {
|
|
3461
|
+
process.stdout.write('Stage 1 context expansion request:\n');
|
|
3462
|
+
process.stdout.write(asJson(envelope.aiPlan.stage1Request) + '\n\n');
|
|
3463
|
+
}
|
|
3464
|
+
if (envelope.aiPlan.collectedContext) {
|
|
3465
|
+
process.stdout.write('Additional files selected:\n');
|
|
3466
|
+
const selected = [
|
|
3467
|
+
...envelope.aiPlan.collectedContext.selectedFiles.map((f) => f.path),
|
|
3468
|
+
...envelope.aiPlan.collectedContext.similarPatternFiles.map((f) => f.path),
|
|
3469
|
+
...envelope.aiPlan.collectedContext.publicApiFiles.map((f) => f.path),
|
|
3470
|
+
...envelope.aiPlan.collectedContext.testFiles.map((f) => f.path),
|
|
3471
|
+
];
|
|
3472
|
+
for (const path of dedupeStrings(selected))
|
|
3473
|
+
process.stdout.write(`- ${path}\n`);
|
|
3474
|
+
process.stdout.write('\n');
|
|
3475
|
+
}
|
|
3476
|
+
if (envelope.aiPlan.finalPlan) {
|
|
3477
|
+
process.stdout.write('Final detailed plan:\n');
|
|
3478
|
+
process.stdout.write(asJson(envelope.aiPlan.finalPlan) + '\n\n');
|
|
3479
|
+
}
|
|
3480
|
+
}
|
|
3481
|
+
function renderDeterministicEnvelope(deterministic) {
|
|
3482
|
+
const lines = [];
|
|
3483
|
+
if (deterministic.repoInstructionsPath) {
|
|
3484
|
+
lines.push(`- repo instructions: ${deterministic.repoInstructionsPath}`);
|
|
3485
|
+
}
|
|
3486
|
+
if (deterministic.relevantRules.length > 0) {
|
|
3487
|
+
lines.push(`- relevant rules: ${deterministic.relevantRules.map((r) => r.id).join(', ')}`);
|
|
3488
|
+
}
|
|
3489
|
+
if (deterministic.relevantPaths.length > 0) {
|
|
3490
|
+
lines.push(`- relevant paths: ${deterministic.relevantPaths.map((p) => p.id).join(', ')}`);
|
|
3491
|
+
}
|
|
3492
|
+
if (deterministic.recommendedCommands.length > 0) {
|
|
3493
|
+
lines.push(`- commands: ${deterministic.recommendedCommands.join(', ')}`);
|
|
3494
|
+
}
|
|
3495
|
+
return lines.join('\n') + '\n';
|
|
3496
|
+
}
|
|
3497
|
+
function writeAiPlanDryRun(seed, grounding, options) {
|
|
3498
|
+
process.stdout.write(header('AI Plan Dry Run'));
|
|
3499
|
+
process.stdout.write('\n');
|
|
3500
|
+
process.stdout.write('Initial smart-context result:\n\n');
|
|
3501
|
+
process.stdout.write(renderSeed(seed) + '\n\n');
|
|
3502
|
+
process.stdout.write(renderInitialGraphGrounding(grounding) + '\n\n');
|
|
3503
|
+
const stage1Messages = buildStage1Messages(seed, grounding);
|
|
3504
|
+
process.stdout.write(header(`Stage 1 prompt (${displayProviderName(options.provider)})`));
|
|
3505
|
+
for (const m of stage1Messages)
|
|
3506
|
+
process.stdout.write(`\n[${m.role}]\n${m.content}\n`);
|
|
3507
|
+
const stage2Messages = buildPromptMessages({
|
|
3508
|
+
systemPreamble: STAGE2_SYSTEM_PREAMBLE,
|
|
3509
|
+
context: [
|
|
3510
|
+
renderSeed(seed),
|
|
3511
|
+
'',
|
|
3512
|
+
renderInitialGraphGrounding(grounding),
|
|
3513
|
+
'',
|
|
3514
|
+
'# Additional collected context',
|
|
3515
|
+
'(resolved after Stage 1 at runtime)',
|
|
3516
|
+
'',
|
|
3517
|
+
`Detailed plan schema: ${JSON.stringify(SmartContextDetailedPlanSchema)}`,
|
|
3518
|
+
].join('\n'),
|
|
3519
|
+
task: seed.task,
|
|
3520
|
+
});
|
|
3521
|
+
process.stdout.write('\n');
|
|
3522
|
+
process.stdout.write(header(`Stage 2 prompt template (${displayProviderName(options.provider)})`));
|
|
3523
|
+
for (const m of stage2Messages)
|
|
3524
|
+
process.stdout.write(`\n[${m.role}]\n${m.content}\n`);
|
|
3525
|
+
}
|
|
3526
|
+
function providerMissingMessage(requested) {
|
|
3527
|
+
if (requested === 'ollama') {
|
|
3528
|
+
return 'Ollama is not reachable. Start the daemon with `ollama serve`, set OLLAMA_HOST=http://<host>:<port> (or OLLAMA_HOST=<host> + OLLAMA_PORT=<port>) to point at a remote box, or use --dry-run to print the prompt instead.';
|
|
3529
|
+
}
|
|
3530
|
+
if (requested === 'llamacpp') {
|
|
3531
|
+
return 'llama.cpp is not configured. Set LLAMACPP_MODEL_PATH=/path/to/model.gguf in .env (recommended: qwen2.5-coder-3b Q4_K_M, ~2 GB), or use --dry-run to print the prompt instead.';
|
|
3532
|
+
}
|
|
3533
|
+
if (requested === 'auto') {
|
|
3534
|
+
return 'No local LLM is ready. SharkCraft is local-only — start Ollama (`ollama serve`) or set LLAMACPP_MODEL_PATH=/path/to/model.gguf in .env. Set AI_PROVIDER=ollama or AI_PROVIDER=llamacpp to pin a provider. Run with --dry-run to print the prompt instead.';
|
|
3535
|
+
}
|
|
3536
|
+
// Deprecated branches: hosted providers are no longer in the auto
|
|
3537
|
+
// chain and are not user-documented, but some legacy tests pin them
|
|
3538
|
+
// explicitly via `--provider <name>`. Keep the messages around so
|
|
3539
|
+
// those paths surface a clear error rather than a generic one.
|
|
3540
|
+
if (requested === 'claude') {
|
|
3541
|
+
return 'ANTHROPIC_API_KEY is not set. (Hosted providers are deprecated; SharkCraft uses only Ollama / llama.cpp.)';
|
|
3542
|
+
}
|
|
3543
|
+
return 'GEMINI_API_KEY is not set. (Hosted providers are deprecated; SharkCraft uses only Ollama / llama.cpp.)';
|
|
3544
|
+
}
|
|
3545
|
+
function tokenizeTask(task) {
|
|
3546
|
+
// Generic English stop words only. Do NOT add SharkCraft vocabulary here
|
|
3547
|
+
// (smart, context, plan, task, mode, etc.) — those are exactly the tokens
|
|
3548
|
+
// a user types when asking about the smart-context surface itself, and
|
|
3549
|
+
// stripping them defeats the graph-candidate ranking for those tasks.
|
|
3550
|
+
const stop = new Set([
|
|
3551
|
+
'a', 'an', 'and', 'or', 'but', 'the', 'this', 'that', 'these', 'those',
|
|
3552
|
+
'with', 'from', 'into', 'onto', 'over', 'under', 'about', 'across',
|
|
3553
|
+
'is', 'are', 'was', 'were', 'be', 'been', 'being',
|
|
3554
|
+
'do', 'does', 'did', 'doing', 'done',
|
|
3555
|
+
'have', 'has', 'had', 'having',
|
|
3556
|
+
'i', 'we', 'you', 'they', 'he', 'she', 'it',
|
|
3557
|
+
'my', 'our', 'your', 'their', 'his', 'her', 'its',
|
|
3558
|
+
'me', 'us', 'them', 'him',
|
|
3559
|
+
'on', 'in', 'at', 'by', 'for', 'to', 'of', 'as', 'so',
|
|
3560
|
+
'if', 'then', 'else', 'when', 'while', 'until', 'because',
|
|
3561
|
+
'will', 'would', 'should', 'could', 'might', 'must', 'can', 'cant', 'cannot',
|
|
3562
|
+
'not', 'no', 'yes', 'maybe', 'just', 'only', 'also', 'too', 'very',
|
|
3563
|
+
'what', 'who', 'whom', 'whose', 'where', 'which', 'why', 'how',
|
|
3564
|
+
'there', 'here', 'than',
|
|
3565
|
+
'need', 'want', 'make', 'made', 'use', 'used', 'using', 'try', 'tried',
|
|
3566
|
+
'more', 'less', 'much', 'many', 'some', 'any', 'all', 'each', 'every',
|
|
3567
|
+
'between', 'against',
|
|
3568
|
+
]);
|
|
3569
|
+
const out = [];
|
|
3570
|
+
const seen = new Set();
|
|
3571
|
+
const add = (raw) => {
|
|
3572
|
+
if (raw.length < 3)
|
|
3573
|
+
return false;
|
|
3574
|
+
if (stop.has(raw))
|
|
3575
|
+
return false;
|
|
3576
|
+
if (seen.has(raw))
|
|
3577
|
+
return false;
|
|
3578
|
+
seen.add(raw);
|
|
3579
|
+
out.push(raw);
|
|
3580
|
+
return out.length >= 24;
|
|
3581
|
+
};
|
|
3582
|
+
// 1. Split on non-alphanumerics. Keep each whole chunk (so "smart-context"
|
|
3583
|
+
// after splitting becomes "smart" and "context", and the bigram pass
|
|
3584
|
+
// below also re-joins them as "smartcontext").
|
|
3585
|
+
const chunks = [];
|
|
3586
|
+
for (const raw of task.toLowerCase().split(/[^a-z0-9]+/)) {
|
|
3587
|
+
if (raw.length === 0)
|
|
3588
|
+
continue;
|
|
3589
|
+
chunks.push(raw);
|
|
3590
|
+
}
|
|
3591
|
+
// 2. For each chunk, also split camelCase (e.g. "smartContext" → ["smart","context"]).
|
|
3592
|
+
const expanded = [];
|
|
3593
|
+
for (const chunk of chunks) {
|
|
3594
|
+
expanded.push(chunk);
|
|
3595
|
+
const camelParts = chunk.split(/(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/);
|
|
3596
|
+
for (const part of camelParts) {
|
|
3597
|
+
if (part.toLowerCase() !== chunk)
|
|
3598
|
+
expanded.push(part.toLowerCase());
|
|
3599
|
+
}
|
|
3600
|
+
}
|
|
3601
|
+
// 3. Add each token. Also try a singular form by stripping trailing 's' / 'es'.
|
|
3602
|
+
for (const raw of expanded) {
|
|
3603
|
+
if (add(raw))
|
|
3604
|
+
return out;
|
|
3605
|
+
if (raw.endsWith('ies') && raw.length > 4) {
|
|
3606
|
+
if (add(raw.slice(0, -3) + 'y'))
|
|
3607
|
+
return out;
|
|
3608
|
+
}
|
|
3609
|
+
else if (raw.endsWith('es') && raw.length > 4) {
|
|
3610
|
+
if (add(raw.slice(0, -2)))
|
|
3611
|
+
return out;
|
|
3612
|
+
}
|
|
3613
|
+
else if (raw.endsWith('s') && raw.length > 4 && !raw.endsWith('ss')) {
|
|
3614
|
+
if (add(raw.slice(0, -1)))
|
|
3615
|
+
return out;
|
|
3616
|
+
}
|
|
3617
|
+
}
|
|
3618
|
+
// 4. Compound-token detection: for adjacent meaningful chunks emit the
|
|
3619
|
+
// joined form ("smart" + "context" → "smartcontext"). This helps when
|
|
3620
|
+
// the keyword appears in a symbol or filename in concatenated form.
|
|
3621
|
+
for (let i = 0; i < chunks.length - 1; i += 1) {
|
|
3622
|
+
const a = chunks[i];
|
|
3623
|
+
const b = chunks[i + 1];
|
|
3624
|
+
if (a.length < 3 || b.length < 3)
|
|
3625
|
+
continue;
|
|
3626
|
+
if (stop.has(a) || stop.has(b))
|
|
3627
|
+
continue;
|
|
3628
|
+
if (add(a + b))
|
|
3629
|
+
return out;
|
|
3630
|
+
}
|
|
3631
|
+
return out;
|
|
3632
|
+
}
|
|
3633
|
+
function rankTaskFileCandidates(api, tokens, limit) {
|
|
3634
|
+
const scores = new Map();
|
|
3635
|
+
for (const node of api.allFiles()) {
|
|
3636
|
+
const path = node.path ?? '';
|
|
3637
|
+
const lower = path.toLowerCase();
|
|
3638
|
+
const base = lower.slice(lower.lastIndexOf('/') + 1);
|
|
3639
|
+
let score = 0;
|
|
3640
|
+
for (const token of tokens) {
|
|
3641
|
+
if (base === token)
|
|
3642
|
+
score += 6;
|
|
3643
|
+
else if (base.includes(token))
|
|
3644
|
+
score += 4;
|
|
3645
|
+
else if (lower.includes(token))
|
|
3646
|
+
score += 2;
|
|
3647
|
+
}
|
|
3648
|
+
if (score > 0)
|
|
3649
|
+
scores.set(path, score);
|
|
3650
|
+
}
|
|
3651
|
+
return [...scores.entries()]
|
|
3652
|
+
.sort((a, b) => (b[1] === a[1] ? a[0].localeCompare(b[0]) : b[1] - a[1]))
|
|
3653
|
+
.slice(0, limit)
|
|
3654
|
+
.map(([path, score]) => ({ path, score }));
|
|
3655
|
+
}
|
|
3656
|
+
function rankTaskSymbolCandidates(api, tokens, limit) {
|
|
3657
|
+
const out = [];
|
|
3658
|
+
const seen = new Set();
|
|
3659
|
+
for (const token of tokens) {
|
|
3660
|
+
for (const symbol of api.findSymbol(token, { exact: false, limit: 4 })) {
|
|
3661
|
+
if (seen.has(symbol.id))
|
|
3662
|
+
continue;
|
|
3663
|
+
seen.add(symbol.id);
|
|
3664
|
+
const owner = declaringFileOf(api, symbol.id);
|
|
3665
|
+
out.push({ symbol: symbol.label, path: owner?.path ?? null });
|
|
3666
|
+
if (out.length >= limit)
|
|
3667
|
+
return out;
|
|
3668
|
+
}
|
|
3669
|
+
}
|
|
3670
|
+
return out;
|
|
3671
|
+
}
|
|
3672
|
+
function declaringFileOf(api, symbolId) {
|
|
3673
|
+
const neighbours = api.neighbours(symbolId);
|
|
3674
|
+
if (!neighbours)
|
|
3675
|
+
return undefined;
|
|
3676
|
+
for (const incoming of neighbours.in) {
|
|
3677
|
+
if (incoming.edge.kind !== EdgeKind.DeclaresSymbol)
|
|
3678
|
+
continue;
|
|
3679
|
+
if ('resolved' in incoming.source)
|
|
3680
|
+
continue;
|
|
3681
|
+
if (incoming.source.kind === NodeKind.File)
|
|
3682
|
+
return incoming.source;
|
|
3683
|
+
}
|
|
3684
|
+
return undefined;
|
|
3685
|
+
}
|
|
3686
|
+
function packageNameForPath(path) {
|
|
3687
|
+
const match = path.match(/^packages\/([^/]+)\//);
|
|
3688
|
+
return match?.[1] ?? null;
|
|
3689
|
+
}
|
|
3690
|
+
function normalizePath(path) {
|
|
3691
|
+
return path.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
3692
|
+
}
|
|
3693
|
+
function dedupeStrings(items) {
|
|
3694
|
+
return [...new Set(items)];
|
|
3695
|
+
}
|
|
3696
|
+
function sumValues(input) {
|
|
3697
|
+
if (!input)
|
|
3698
|
+
return 0;
|
|
3699
|
+
return Object.values(input).reduce((sum, value) => sum + value, 0);
|
|
3700
|
+
}
|
|
3701
|
+
function isRecord(value) {
|
|
3702
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3703
|
+
}
|
|
3704
|
+
const BRIEF_SYSTEM_PREAMBLE = [
|
|
3705
|
+
'You are an AI engineer\'s research assistant for a SharkCraft-instrumented repository.',
|
|
3706
|
+
'You are given the deterministic context the SharkCraft engine produced for a task, plus the repository\'s own agent instructions (CLAUDE.md/AGENTS.md) when present.',
|
|
3707
|
+
'Treat the supplied context as authoritative ground truth.',
|
|
3708
|
+
'STRICT GROUNDING: every rule id, template id, file path, and command in your output MUST appear verbatim in the supplied context. If you cannot find evidence in context, omit the item rather than guessing.',
|
|
3709
|
+
'PREFER `Candidate code (graph-ranked from task tokens)` for files-to-read/edit suggestions, then `Relevant rules` / `Path conventions` / `Relevant templates`.',
|
|
3710
|
+
'RESPECT `Forbidden actions` — never suggest a step that violates one; mention the conflict if the user\'s request would.',
|
|
3711
|
+
'If the repository instructions and the engine context conflict, prefer the repository instructions and call out the conflict in a single line.',
|
|
3712
|
+
'Produce a concise Markdown BRIEF (≤ 400 words) that:',
|
|
3713
|
+
' 1. Restates the task in one sentence.',
|
|
3714
|
+
' 2. Highlights the most relevant rules (cite their IDs verbatim, with one line on `applies when`).',
|
|
3715
|
+
' 3. Lists the most likely files to read, then the most likely files to edit (use the candidate-code paths).',
|
|
3716
|
+
' 4. Calls out templates to use, recommended commands to run, and the verification commands to run after.',
|
|
3717
|
+
' 5. Flags gotchas, generated files, forbidden actions, or stability/memory warnings if present.',
|
|
3718
|
+
'No preamble, no closing pleasantries — just the brief.',
|
|
3719
|
+
].join(' ');
|
|
3720
|
+
const PLAN_SYSTEM_PREAMBLE = [
|
|
3721
|
+
'You are an AI engineer\'s research assistant for a SharkCraft-instrumented repository.',
|
|
3722
|
+
'You are given the deterministic context the SharkCraft engine produced for a task, plus the repository\'s own agent instructions (CLAUDE.md/AGENTS.md) when present.',
|
|
3723
|
+
'Treat the supplied context as authoritative ground truth — do not invent rule IDs, file paths, or commands that are not present.',
|
|
3724
|
+
'If the repository instructions and the engine context conflict, prefer the repository instructions and note the conflict in `openQuestions`.',
|
|
3725
|
+
'Produce a detailed implementation PLAN as a single fenced ```json block, then a short Markdown summary below it.',
|
|
3726
|
+
'The JSON must conform to this schema (omit fields with no content):',
|
|
3727
|
+
'{',
|
|
3728
|
+
' "summary": string,',
|
|
3729
|
+
' "filesToRead": [{ "path": string, "why": string }],',
|
|
3730
|
+
' "filesToEdit": [{ "path": string, "why": string }],',
|
|
3731
|
+
' "relatedRules": [{ "id": string, "title": string, "applyWhen": string }],',
|
|
3732
|
+
' "relatedTemplates": [{ "id": string, "useFor": string }],',
|
|
3733
|
+
' "firstCommands": [{ "command": string, "why": string }],',
|
|
3734
|
+
' "implementationSteps": [{ "step": string, "details": string }],',
|
|
3735
|
+
' "gotchas": [string],',
|
|
3736
|
+
' "openQuestions": [string]',
|
|
3737
|
+
'}',
|
|
3738
|
+
'Use only rule IDs, template IDs, paths, and commands that appear in the supplied context.',
|
|
3739
|
+
].join(' ');
|
|
3740
|
+
const STAGE1_SYSTEM_PREAMBLE = [
|
|
3741
|
+
'You are stage 1 of a two-stage planning flow for a SharkCraft-instrumented repository.',
|
|
3742
|
+
'Your job is NOT to implement the task. Your job is to decide what additional deterministic context SharkCraft should collect before stage 2 writes a richer plan.',
|
|
3743
|
+
'Output: exactly one JSON object. No markdown fence. No prose before or after.',
|
|
3744
|
+
'PRIMARY SIGNALS, in order:',
|
|
3745
|
+
' (a) `Candidate file briefs (task-ranked)` — top files with summary, exports + signatures, imports, importers. Use these for `filesToRead` / `similarPatterns` / `publicApiFiles` / `testsToInspect`. Reference signature lines or export names in `why` to prove you read them.',
|
|
3746
|
+
' (b) `Documentation hits` — keyword-grepped lines from CLAUDE.md / AGENTS.md / docs. Use these to discover background and to anchor `architectureRules` / `riskyAreas` / `missingInformation` in real prose. When `Candidate file briefs` is sparse, the hits are your fallback evidence.',
|
|
3747
|
+
' (c) `Path conventions` — use these when the briefs do not cover a needed area; cite the path-rule id in `why`.',
|
|
3748
|
+
'STRICT GROUNDING: every `target` MUST appear verbatim somewhere in the supplied context — in a brief, in a documentation hit, in `Candidate code` paths/symbols, in `Path conventions`, in `Relevant templates`, or in repo instructions. If you cannot find a path, do not list it.',
|
|
3749
|
+
'Every `architectureRules[].id` MUST be one of the `Relevant rules` ids verbatim — do not invent ids.',
|
|
3750
|
+
'Prefer breadth: surface 4–8 file targets, similar patterns, public API/export files, and tests, but stay bounded. Do not request reading the whole repository.',
|
|
3751
|
+
'Each entry must include a one-sentence `why` that references concrete evidence (a brief summary, an export signature line, a doc-hit line number, an import path).',
|
|
3752
|
+
'Empty arrays are allowed; prefer omitting noise over inventing entries.',
|
|
3753
|
+
].join(' ');
|
|
3754
|
+
const STAGE2_SYSTEM_PREAMBLE = [
|
|
3755
|
+
'You are stage 2 of a two-stage planning flow for a SharkCraft-instrumented repository.',
|
|
3756
|
+
'You are given the original task, the initial deterministic smart-context seed, and the additional context SharkCraft collected after stage 1.',
|
|
3757
|
+
'Output: exactly one JSON object. No markdown fence. No prose before or after.',
|
|
3758
|
+
'This is a development-oriented plan for Claude, not a final implementation. Do not pretend certainty or exact implementation details that the context does not justify; surface those as `unknowns`.',
|
|
3759
|
+
'STRICT GROUNDING: every `path` you list MUST appear in the supplied context (candidate code, additional collected context, path-conventions, or knowledge body). Every `relatedRules[].id` MUST match a real rule id from the context. Every command in `firstCommands` / `validationCommands` MUST come from the `Recommended commands` or `Verification commands` sections.',
|
|
3760
|
+
'RESPECT `Forbidden actions` — never recommend a step that violates one.',
|
|
3761
|
+
'Required JSON fields (omit array fields cleanly when empty): summary, taskUnderstanding, likelyTechnicalApproach, existingPatternsToFollow, filesToRead, likelyFilesToModify, filesToAvoid, publicApiFiles, testsToInspect, architectureConstraints, relatedRules, relatedTemplates, firstCommands, implementationSteps, risks, unknowns, validationCommands, handoffSummary.',
|
|
3762
|
+
'`handoffSummary` is a single paragraph (≤ 6 sentences) Claude can read to start work.',
|
|
3763
|
+
].join(' ');
|