@shrkcrft/cli 0.1.0-alpha.2 → 0.1.0-alpha.20

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