(args: z.infer<typeof Args>, ctx: ToolContext)
| 19 | argsSchema = Args; |
| 20 | |
| 21 | async execute(args: z.infer<typeof Args>, ctx: ToolContext): Promise<ToolResult> { |
| 22 | const cwd = ctx.cwd ?? process.cwd(); |
| 23 | |
| 24 | // Files changed this session (working tree vs HEAD). |
| 25 | const changedFiles = await (async () => { |
| 26 | try { |
| 27 | const { git } = await import('../git/git-runner.js'); |
| 28 | const d = await git(['diff', 'HEAD', '--name-only'], { cwd }); |
| 29 | return d.exitCode === 0 ? d.stdout.split('\n').map(s => s.trim()).filter(Boolean) : []; |
| 30 | } catch { return []; } |
| 31 | })(); |
| 32 | |
| 33 | const { suggestSkillFromSession, draftCandidateSkill, commonArea } = await import('../../skills/learning/skill-suggest.js'); |
| 34 | const area = commonArea(changedFiles); |
| 35 | |
| 36 | // Cohesion = how concentrated the change is in one module (a code-organization signal), with a |
| 37 | // best-effort boost when the code graph shows the touched files are import-connected. |
| 38 | const inArea = changedFiles.filter(f => f.split('/').slice(0, 2).join('/') === area).length; |
| 39 | let cohesion = changedFiles.length ? inArea / changedFiles.length : 0; |
| 40 | let touchedSymbols: string[] | undefined; |
| 41 | try { |
| 42 | const { getSymbolGraph, dependencyContextFor } = await import('../../context/symbol-graph.js'); |
| 43 | const graph = await getSymbolGraph(cwd, { signal: AbortSignal.timeout(3000) }); |
| 44 | if (graph && changedFiles.length) { |
| 45 | const deps = dependencyContextFor(graph, changedFiles); |
| 46 | touchedSymbols = [...new Set(deps.map((d: any) => d.symbol ?? d.name).filter(Boolean))].slice(0, 6); |
| 47 | // connected cluster (the change's files reference each other) → real reusable pattern |
| 48 | if (deps.some((d: any) => (d.neighbors ?? d.deps ?? []).length > 0)) cohesion = Math.min(1, cohesion + 0.15); |
| 49 | } |
| 50 | } catch { /* graph optional */ } |
| 51 | |
| 52 | const input = { prompt: args.task, changedFiles, cohesion, touchedSymbols }; |
| 53 | const s = suggestSkillFromSession(input); |
| 54 | |
| 55 | // Proactive: when the shape clearly says "reusable pattern", draft the candidate now (quarantined) |
| 56 | // so the user only reviews + promotes. Skipped if draft:false, or if an identically-named candidate |
| 57 | // already exists (don't spam duplicates). |
| 58 | let drafted: string | null = null; |
| 59 | if (s.worth && args.draft !== false) { |
| 60 | try { |
| 61 | const { listCandidates, writeCandidate } = await import('../../skills/learning/candidate-store.js'); |
| 62 | const existing = await listCandidates().catch(() => []); |
| 63 | if (!existing.some(c => c.name === s.proposedName)) { |
| 64 | const c = draftCandidateSkill(input, s, new Date().toISOString()); |
| 65 | await writeCandidate(c); |
| 66 | drafted = c.name; |
| 67 | } |
| 68 | } catch { /* drafting is best-effort — the recommendation still stands */ } |
| 69 | } |
| 70 | |
| 71 | const head = s.worth |
| 72 | ? (drafted |
| 73 | ? `💡 Drafted a candidate skill: "${drafted}" (quarantined)\n ${s.reason}\n → review with \`qodex skill candidates\`, promote with \`qodex skill curate\`.` |
| 74 | : `💡 Worth saving as a skill: "${s.proposedName}"\n ${s.reason}\n → review with \`qodex skill candidates\`, then \`qodex skill curate\`.`) |
| 75 | : `No skill suggested. ${s.reason}`; |
| 76 | return { content: head, metadata: { worth: s.worth, score: s.score, proposedName: s.proposedName, area: s.area, changedFiles: changedFiles.length, drafted: !!drafted } }; |
| 77 | } |
| 78 | } |
nothing calls this directly
no test coverage detected