@agentplate/cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/agents/architect.md +108 -0
- package/agents/builder.md +97 -0
- package/agents/coordinator.md +113 -0
- package/agents/deployer.md +117 -0
- package/agents/devops.md +114 -0
- package/agents/lead.md +107 -0
- package/agents/merger.md +103 -0
- package/agents/reviewer.md +90 -0
- package/agents/scout.md +95 -0
- package/agents/verifier.md +106 -0
- package/package.json +64 -0
- package/src/agents/guard-rules.ts +55 -0
- package/src/agents/identity.test.ts +161 -0
- package/src/agents/identity.ts +229 -0
- package/src/agents/manifest.test.ts +260 -0
- package/src/agents/manifest.ts +286 -0
- package/src/agents/overlay.test.ts +190 -0
- package/src/agents/overlay.ts +212 -0
- package/src/agents/system-prompt.test.ts +53 -0
- package/src/agents/system-prompt.ts +95 -0
- package/src/agents/turn-runner.ts +79 -0
- package/src/commands/coordinator.test.ts +75 -0
- package/src/commands/coordinator.ts +259 -0
- package/src/commands/deploy.test.ts +504 -0
- package/src/commands/deploy.ts +874 -0
- package/src/commands/doctor.test.ts +106 -0
- package/src/commands/doctor.ts +208 -0
- package/src/commands/init.ts +71 -0
- package/src/commands/log.ts +51 -0
- package/src/commands/mail.ts +197 -0
- package/src/commands/merge.ts +127 -0
- package/src/commands/model.ts +58 -0
- package/src/commands/prime.ts +61 -0
- package/src/commands/reap.ts +87 -0
- package/src/commands/serve.ts +61 -0
- package/src/commands/setup.ts +48 -0
- package/src/commands/ship.test.ts +106 -0
- package/src/commands/ship.ts +202 -0
- package/src/commands/skill.test.ts +458 -0
- package/src/commands/skill.ts +730 -0
- package/src/commands/sling.ts +365 -0
- package/src/commands/status.ts +60 -0
- package/src/commands/stop.ts +56 -0
- package/src/commands/tui.ts +199 -0
- package/src/commands/worktree.ts +77 -0
- package/src/config.test.ts +92 -0
- package/src/config.ts +202 -0
- package/src/db/sqlite.test.ts +77 -0
- package/src/db/sqlite.ts +102 -0
- package/src/deploy/audit.test.ts +233 -0
- package/src/deploy/audit.ts +245 -0
- package/src/deploy/context.test.ts +243 -0
- package/src/deploy/context.ts +72 -0
- package/src/deploy/registry.test.ts +101 -0
- package/src/deploy/registry.ts +86 -0
- package/src/deploy/secrets.test.ts +129 -0
- package/src/deploy/secrets.ts +69 -0
- package/src/deploy/targets/docker-gha.test.ts +323 -0
- package/src/deploy/targets/docker-gha.ts +841 -0
- package/src/deploy/types.ts +153 -0
- package/src/errors.test.ts +42 -0
- package/src/errors.ts +69 -0
- package/src/events/store.test.ts +183 -0
- package/src/events/store.ts +201 -0
- package/src/index.ts +137 -0
- package/src/insights/quality-gates.ts +73 -0
- package/src/json.test.ts +28 -0
- package/src/json.ts +50 -0
- package/src/logging/color.ts +62 -0
- package/src/logging/logger.ts +60 -0
- package/src/logging/sanitizer.test.ts +36 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/client.test.ts +192 -0
- package/src/mail/client.ts +188 -0
- package/src/mail/store.test.ts +279 -0
- package/src/mail/store.ts +311 -0
- package/src/merge/lock.test.ts +88 -0
- package/src/merge/lock.ts +84 -0
- package/src/merge/queue.test.ts +136 -0
- package/src/merge/queue.ts +177 -0
- package/src/merge/resolver.test.ts +219 -0
- package/src/merge/resolver.ts +274 -0
- package/src/paths.ts +36 -0
- package/src/providers/apply.test.ts +90 -0
- package/src/providers/apply.ts +66 -0
- package/src/providers/registry.test.ts +74 -0
- package/src/providers/registry.ts +254 -0
- package/src/runtimes/claude.ts +313 -0
- package/src/runtimes/codex.ts +280 -0
- package/src/runtimes/cursor.ts +247 -0
- package/src/runtimes/gemini.ts +173 -0
- package/src/runtimes/mock.ts +71 -0
- package/src/runtimes/opencode.ts +259 -0
- package/src/runtimes/registry.test.ts +924 -0
- package/src/runtimes/registry.ts +63 -0
- package/src/runtimes/resolve.ts +45 -0
- package/src/runtimes/types.ts +97 -0
- package/src/scaffold.ts +68 -0
- package/src/secrets.test.ts +51 -0
- package/src/secrets.ts +78 -0
- package/src/serve/api.ts +667 -0
- package/src/serve/server.test.ts +433 -0
- package/src/serve/server.ts +271 -0
- package/src/serve/system.ts +90 -0
- package/src/serve/weather.ts +140 -0
- package/src/sessions/reaper.test.ts +162 -0
- package/src/sessions/reaper.ts +149 -0
- package/src/sessions/store.test.ts +351 -0
- package/src/sessions/store.ts +350 -0
- package/src/skills/distiller.test.ts +498 -0
- package/src/skills/distiller.ts +426 -0
- package/src/skills/feedback.test.ts +300 -0
- package/src/skills/feedback.ts +168 -0
- package/src/skills/lifecycle.ts +169 -0
- package/src/skills/retrieval.test.ts +421 -0
- package/src/skills/retrieval.ts +365 -0
- package/src/skills/safety.test.ts +335 -0
- package/src/skills/safety.ts +216 -0
- package/src/skills/store.test.ts +425 -0
- package/src/skills/store.ts +684 -0
- package/src/skills/types.ts +107 -0
- package/src/types.ts +442 -0
- package/src/utils/detect.test.ts +35 -0
- package/src/utils/detect.ts +82 -0
- package/src/version.test.ts +19 -0
- package/src/version.ts +7 -0
- package/src/wizard/setup.ts +254 -0
- package/src/worktree/manager.test.ts +181 -0
- package/src/worktree/manager.ts +229 -0
- package/templates/overlay.md.tmpl +102 -0
- package/ui/dist/assets/index-C7rXIMER.css +1 -0
- package/ui/dist/assets/index-W4kbr4by.js +4526 -0
- package/ui/dist/favicon.svg +21 -0
- package/ui/dist/index.html +16 -0
- package/ui/dist/logo-clay.svg +21 -0
- package/ui/dist/logo.svg +18 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI skill distiller.
|
|
3
|
+
*
|
|
4
|
+
* The distiller closes Agentplate's learning loop at session-end: it shows a model
|
|
5
|
+
* the change a worker just made (the git diff), a cheap digest of which files
|
|
6
|
+
* moved, the task spec, and the skills that were applied during the session, then
|
|
7
|
+
* asks it to decide whether the work is worth crystallizing into a reusable
|
|
8
|
+
* {@link Skill}. The model answers with one of three actions:
|
|
9
|
+
*
|
|
10
|
+
* - **create** — this session discovered a repeatable playbook; mint a new skill.
|
|
11
|
+
* - **update** — an applied skill was extended/corrected; bump that skill.
|
|
12
|
+
* - **skip** — trivial / one-off work that should NOT mint a skill (the common,
|
|
13
|
+
* correct answer — skill spam is the failure mode we guard against).
|
|
14
|
+
*
|
|
15
|
+
* This module is the orchestration glue plus the two pure halves that bracket the
|
|
16
|
+
* model call:
|
|
17
|
+
*
|
|
18
|
+
* - {@link buildDistillerPrompt} renders the instruction text (pure).
|
|
19
|
+
* - {@link parseDistillerOutput} tolerantly extracts a {@link SkillDraft} from
|
|
20
|
+
* the model's stdout — code fences, surrounding prose, or a bare object all
|
|
21
|
+
* parse; anything malformed or actionless returns `null` (pure).
|
|
22
|
+
* - {@link distillSkill} wires them to the real world: gather the diff via git,
|
|
23
|
+
* run a one-shot model call through the runtime's `buildPrintCommand`, scrub
|
|
24
|
+
* the draft with {@link sanitizeSkillDraft}, and persist via the store.
|
|
25
|
+
*
|
|
26
|
+
* Every untrusted output path is fail-safe: a missing/garbage/`skip` draft, an
|
|
27
|
+
* empty diff, or a fatal safety violation all resolve to `{ action: "skipped" }`
|
|
28
|
+
* rather than minting a hazardous or noise skill.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { AgentRuntime } from "../runtimes/types.ts";
|
|
32
|
+
import { resolveArgv } from "../utils/detect.ts";
|
|
33
|
+
import { sanitizeSkillDraft } from "./safety.ts";
|
|
34
|
+
import type { createSkillStore } from "./store.ts";
|
|
35
|
+
import type { Skill, SkillDraft, SkillProvenance } from "./types.ts";
|
|
36
|
+
|
|
37
|
+
/** The skill store handle produced by {@link createSkillStore}. */
|
|
38
|
+
type SkillStoreHandle = ReturnType<typeof createSkillStore>;
|
|
39
|
+
|
|
40
|
+
/** Context assembled for {@link buildDistillerPrompt}. */
|
|
41
|
+
export interface DistillerPromptContext {
|
|
42
|
+
/** The full `git diff baseRef..HEAD` for the session's work. */
|
|
43
|
+
diff: string;
|
|
44
|
+
/** A short summary of which files changed and by how much (diff --stat style). */
|
|
45
|
+
insightDigest: string;
|
|
46
|
+
/** The task spec text, or "" when no spec was attached. */
|
|
47
|
+
specText: string;
|
|
48
|
+
/** The skills that were injected into the session (so the model can UPDATE one). */
|
|
49
|
+
appliedSkills: Skill[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Arguments for {@link distillSkill}. */
|
|
53
|
+
export interface DistillSkillArgs {
|
|
54
|
+
/** Skill store rooted at the main project (NOT the throwaway worktree). */
|
|
55
|
+
store: SkillStoreHandle;
|
|
56
|
+
/** Runtime adapter providing the one-shot `buildPrintCommand`. */
|
|
57
|
+
runtime: AgentRuntime;
|
|
58
|
+
/** Main project root (for provenance / future use). */
|
|
59
|
+
root: string;
|
|
60
|
+
/** The agent's worktree (cwd for git + the model call). */
|
|
61
|
+
worktreePath: string;
|
|
62
|
+
/** Git ref the worktree branched from; the diff base. */
|
|
63
|
+
baseRef: string;
|
|
64
|
+
/** Task id this work belongs to, or null. */
|
|
65
|
+
taskId: string | null;
|
|
66
|
+
/** Name of the agent whose work is being distilled. */
|
|
67
|
+
agentName: string;
|
|
68
|
+
/** The agent's capability (builder / lead / …); carried for context. */
|
|
69
|
+
capability: string;
|
|
70
|
+
/** Slugs of skills that were applied during the session. */
|
|
71
|
+
appliedSlugs: string[];
|
|
72
|
+
/** Optional model override for the one-shot call. */
|
|
73
|
+
model?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Result of a distillation attempt. */
|
|
77
|
+
export interface DistillResult {
|
|
78
|
+
action: "created" | "updated" | "skipped";
|
|
79
|
+
/** The slug created/updated; absent when skipped. */
|
|
80
|
+
slug?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Max characters of diff embedded in the prompt — a huge diff is truncated. */
|
|
84
|
+
const MAX_DIFF_CHARS = 24_000;
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Prompt construction (pure)
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Render one applied skill as a compact reference block the model can target for
|
|
92
|
+
* an UPDATE. Only the identifying/decision-relevant fields are included (slug,
|
|
93
|
+
* title, goal, when-to-use) — the full body is omitted to keep the prompt lean.
|
|
94
|
+
*/
|
|
95
|
+
function renderAppliedSkill(skill: Skill): string {
|
|
96
|
+
const when = skill.whenToUse.length > 0 ? skill.whenToUse.join("; ") : "(none)";
|
|
97
|
+
return [
|
|
98
|
+
`- slug: ${skill.slug}`,
|
|
99
|
+
` title: ${skill.title}`,
|
|
100
|
+
` goal: ${skill.goal}`,
|
|
101
|
+
` whenToUse: ${when}`,
|
|
102
|
+
].join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build the distiller instruction prompt.
|
|
107
|
+
*
|
|
108
|
+
* The prompt frames the decision (create / update / skip), hammers on "skip is
|
|
109
|
+
* usually correct" to prevent skill spam, pins the STRICT JSON output contract
|
|
110
|
+
* (a single object, no prose, no code fence), and specifies the markdown body
|
|
111
|
+
* structure (`## Steps` / `## Gotchas` / `## Verification`). The diff is embedded
|
|
112
|
+
* last (after the instructions) and truncated to {@link MAX_DIFF_CHARS} so an
|
|
113
|
+
* enormous changeset cannot blow the context.
|
|
114
|
+
*/
|
|
115
|
+
export function buildDistillerPrompt(ctx: DistillerPromptContext): string {
|
|
116
|
+
const diff =
|
|
117
|
+
ctx.diff.length > MAX_DIFF_CHARS
|
|
118
|
+
? `${ctx.diff.slice(0, MAX_DIFF_CHARS)}\n…[diff truncated]…`
|
|
119
|
+
: ctx.diff;
|
|
120
|
+
|
|
121
|
+
const appliedBlock =
|
|
122
|
+
ctx.appliedSkills.length > 0
|
|
123
|
+
? ctx.appliedSkills.map(renderAppliedSkill).join("\n")
|
|
124
|
+
: "(no skills were applied this session)";
|
|
125
|
+
|
|
126
|
+
const specBlock = ctx.specText.trim() !== "" ? ctx.specText.trim() : "(no spec provided)";
|
|
127
|
+
|
|
128
|
+
return `You are Agentplate's skill distiller. A worker agent just finished a task. Your job
|
|
129
|
+
is to decide whether this work is worth crystallizing into a REUSABLE skill — a
|
|
130
|
+
playbook a future agent could follow to do similar work faster.
|
|
131
|
+
|
|
132
|
+
You have three possible actions:
|
|
133
|
+
|
|
134
|
+
- "create": this session discovered a repeatable, generalizable technique that
|
|
135
|
+
is NOT already captured by an applied skill. Mint a new skill.
|
|
136
|
+
- "update": an applied skill was extended, corrected, or sharpened by this work.
|
|
137
|
+
Improve that skill in place (set targetSlug to its slug).
|
|
138
|
+
- "skip": the work was trivial, one-off, project-specific boilerplate, or a
|
|
139
|
+
minor edit with no transferable lesson. Do NOT mint a skill.
|
|
140
|
+
|
|
141
|
+
IMPORTANT: "skip" is the RIGHT ANSWER for the large majority of sessions. A skill
|
|
142
|
+
must earn its place — only create or update when there is a genuinely reusable,
|
|
143
|
+
non-obvious lesson that would help a different agent on a different task. When in
|
|
144
|
+
doubt, skip. Minting low-value skills (skill spam) is worse than missing one.
|
|
145
|
+
|
|
146
|
+
A good skill body is concrete and actionable markdown with exactly these sections:
|
|
147
|
+
|
|
148
|
+
## Steps
|
|
149
|
+
- Ordered, concrete steps. Include the actual commands to run (in \`bash\` code
|
|
150
|
+
fences) — generalized, with no secrets and no machine-specific absolute paths.
|
|
151
|
+
|
|
152
|
+
## Gotchas
|
|
153
|
+
- The non-obvious pitfalls, edge cases, and mistakes to avoid that this session
|
|
154
|
+
revealed.
|
|
155
|
+
|
|
156
|
+
## Verification
|
|
157
|
+
- How to confirm the work is correct (the concrete test/lint/build commands).
|
|
158
|
+
|
|
159
|
+
OUTPUT CONTRACT — read carefully:
|
|
160
|
+
Respond with STRICT JSON: a SINGLE JSON object and nothing else. No prose before
|
|
161
|
+
or after, no markdown code fence around the JSON. The object shape is:
|
|
162
|
+
|
|
163
|
+
{
|
|
164
|
+
"action": "create" | "update" | "skip",
|
|
165
|
+
"targetSlug": "<existing-slug>", // REQUIRED only when action is "update"
|
|
166
|
+
"title": "<short imperative skill title>", // required for create
|
|
167
|
+
"goal": "<one sentence: what applying this skill accomplishes>",
|
|
168
|
+
"whenToUse": ["<retrieval signal>", "..."],
|
|
169
|
+
"filePatterns": ["src/**/*.ts", "..."], // globs this skill is relevant to
|
|
170
|
+
"tags": ["<tag>", "..."],
|
|
171
|
+
"body": "## Steps\\n...\\n## Gotchas\\n...\\n## Verification\\n..."
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
For action "skip", a bare {"action":"skip"} is sufficient — omit the other fields.
|
|
175
|
+
|
|
176
|
+
--- TASK SPEC ---
|
|
177
|
+
${specBlock}
|
|
178
|
+
|
|
179
|
+
--- SKILLS APPLIED THIS SESSION (candidates for "update") ---
|
|
180
|
+
${appliedBlock}
|
|
181
|
+
|
|
182
|
+
--- CHANGE DIGEST ---
|
|
183
|
+
${ctx.insightDigest.trim() === "" ? "(no digest)" : ctx.insightDigest.trim()}
|
|
184
|
+
|
|
185
|
+
--- DIFF (baseRef..HEAD) ---
|
|
186
|
+
${diff.trim() === "" ? "(empty diff)" : diff}
|
|
187
|
+
`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Output parsing (pure, tolerant)
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
/** The three valid actions, as a runtime-checkable set. */
|
|
195
|
+
const VALID_ACTIONS = new Set(["create", "update", "skip"]);
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Scan `text` for the first *balanced* top-level JSON object and return its raw
|
|
199
|
+
* substring (braces included), or null if none is found.
|
|
200
|
+
*
|
|
201
|
+
* Tolerant of code fences and surrounding prose: it simply walks characters,
|
|
202
|
+
* tracking brace depth while respecting string literals (so a `}` inside a JSON
|
|
203
|
+
* string does not prematurely close the object) and backslash escapes. The first
|
|
204
|
+
* `{` at depth 0 starts the candidate; the matching `}` that returns depth to 0
|
|
205
|
+
* ends it.
|
|
206
|
+
*/
|
|
207
|
+
export function extractFirstJsonObject(text: string): string | null {
|
|
208
|
+
let start = -1;
|
|
209
|
+
let depth = 0;
|
|
210
|
+
let inString = false;
|
|
211
|
+
let escaped = false;
|
|
212
|
+
|
|
213
|
+
for (let i = 0; i < text.length; i++) {
|
|
214
|
+
const ch = text[i];
|
|
215
|
+
if (ch === undefined) continue;
|
|
216
|
+
|
|
217
|
+
if (inString) {
|
|
218
|
+
if (escaped) {
|
|
219
|
+
escaped = false;
|
|
220
|
+
} else if (ch === "\\") {
|
|
221
|
+
escaped = true;
|
|
222
|
+
} else if (ch === '"') {
|
|
223
|
+
inString = false;
|
|
224
|
+
}
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (ch === '"') {
|
|
229
|
+
inString = true;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (ch === "{") {
|
|
233
|
+
if (depth === 0) start = i;
|
|
234
|
+
depth++;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (ch === "}") {
|
|
238
|
+
if (depth > 0) {
|
|
239
|
+
depth--;
|
|
240
|
+
if (depth === 0 && start !== -1) {
|
|
241
|
+
return text.slice(start, i + 1);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Coerce an unknown parsed value into a `string[]`, or undefined if not an array. */
|
|
250
|
+
function optStringArray(value: unknown): string[] | undefined {
|
|
251
|
+
if (!Array.isArray(value)) return undefined;
|
|
252
|
+
const out: string[] = [];
|
|
253
|
+
for (const item of value) {
|
|
254
|
+
if (typeof item === "string") out.push(item);
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Coerce an unknown parsed value into a non-empty string, or undefined. */
|
|
260
|
+
function optString(value: unknown): string | undefined {
|
|
261
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Parse a distiller model's stdout into a {@link SkillDraft}, or null.
|
|
266
|
+
*
|
|
267
|
+
* Tolerant by design — the model may wrap its JSON in a ```json fence or sandwich
|
|
268
|
+
* it in prose. We locate the first balanced JSON object ({@link
|
|
269
|
+
* extractFirstJsonObject}), `JSON.parse` it, and validate:
|
|
270
|
+
* - the value is a plain object,
|
|
271
|
+
* - `action` is present and ∈ {create, update, skip}.
|
|
272
|
+
* Returns null on any failure (no object found, parse error, missing/invalid
|
|
273
|
+
* action). Optional fields are copied through only when well-typed, so a
|
|
274
|
+
* malformed `whenToUse` etc. is dropped rather than corrupting the draft.
|
|
275
|
+
*/
|
|
276
|
+
export function parseDistillerOutput(stdout: string): SkillDraft | null {
|
|
277
|
+
const candidate = extractFirstJsonObject(stdout);
|
|
278
|
+
if (candidate === null) return null;
|
|
279
|
+
|
|
280
|
+
let parsed: unknown;
|
|
281
|
+
try {
|
|
282
|
+
parsed = JSON.parse(candidate);
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
287
|
+
|
|
288
|
+
const obj = parsed as Record<string, unknown>;
|
|
289
|
+
const action = obj.action;
|
|
290
|
+
if (typeof action !== "string" || !VALID_ACTIONS.has(action)) return null;
|
|
291
|
+
|
|
292
|
+
const draft: SkillDraft = { action: action as SkillDraft["action"] };
|
|
293
|
+
|
|
294
|
+
const targetSlug = optString(obj.targetSlug);
|
|
295
|
+
if (targetSlug !== undefined) draft.targetSlug = targetSlug;
|
|
296
|
+
const title = optString(obj.title);
|
|
297
|
+
if (title !== undefined) draft.title = title;
|
|
298
|
+
const goal = optString(obj.goal);
|
|
299
|
+
if (goal !== undefined) draft.goal = goal;
|
|
300
|
+
const whenToUse = optStringArray(obj.whenToUse);
|
|
301
|
+
if (whenToUse !== undefined) draft.whenToUse = whenToUse;
|
|
302
|
+
const filePatterns = optStringArray(obj.filePatterns);
|
|
303
|
+
if (filePatterns !== undefined) draft.filePatterns = filePatterns;
|
|
304
|
+
const tags = optStringArray(obj.tags);
|
|
305
|
+
if (tags !== undefined) draft.tags = tags;
|
|
306
|
+
const body = optString(obj.body);
|
|
307
|
+
if (body !== undefined) draft.body = body;
|
|
308
|
+
|
|
309
|
+
return draft;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
// Orchestration (impure)
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
/** Result of running a git subprocess: trimmed stdout + success flag. */
|
|
317
|
+
interface GitResult {
|
|
318
|
+
stdout: string;
|
|
319
|
+
ok: boolean;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Run a git command in `cwd` and capture stdout. Never throws on a non-zero
|
|
324
|
+
* exit (the distiller is best-effort — a git hiccup must degrade to a skip, not
|
|
325
|
+
* crash session-end); the caller checks `ok` and treats failure as "no diff".
|
|
326
|
+
*/
|
|
327
|
+
async function runGit(cwd: string, args: string[]): Promise<GitResult> {
|
|
328
|
+
const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
329
|
+
const stdout = await new Response(proc.stdout).text();
|
|
330
|
+
const exitCode = await proc.exited;
|
|
331
|
+
return { stdout: stdout.trim(), ok: exitCode === 0 };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Read the spec markdown for a task id under `<root>/.agentplate/specs/`, or "". */
|
|
335
|
+
async function readSpecText(root: string, taskId: string | null): Promise<string> {
|
|
336
|
+
if (taskId === null || taskId === "") return "";
|
|
337
|
+
const specFile = `${root}/.agentplate/specs/${taskId}.md`;
|
|
338
|
+
const file = Bun.file(specFile);
|
|
339
|
+
if (!(await file.exists())) return "";
|
|
340
|
+
try {
|
|
341
|
+
return await file.text();
|
|
342
|
+
} catch {
|
|
343
|
+
return "";
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Distill a worker's session into a skill (or skip).
|
|
349
|
+
*
|
|
350
|
+
* Flow (any failure short-circuits to a safe skip):
|
|
351
|
+
* 1. Gather the session diff (`git diff baseRef..HEAD` in the worktree). An empty
|
|
352
|
+
* diff means there is nothing to learn from — skip.
|
|
353
|
+
* 2. Build a cheap insight digest from `git diff --stat`, and read the task spec.
|
|
354
|
+
* 3. Resolve the applied skills the store still knows about (dropping any that
|
|
355
|
+
* have since been removed) so the model can choose to UPDATE one.
|
|
356
|
+
* 4. Render the prompt and make a single one-shot model call via the runtime's
|
|
357
|
+
* `buildPrintCommand`, capturing stdout.
|
|
358
|
+
* 5. Parse the output. A null parse or an explicit `skip` action → skipped.
|
|
359
|
+
* 6. Scrub the draft ({@link sanitizeSkillDraft}). A FATAL violation (dangerous
|
|
360
|
+
* command / deploy verb) downgrades the whole draft to a skip — we never
|
|
361
|
+
* persist a hazardous skill.
|
|
362
|
+
* 7. Persist the redacted draft via `store.upsert` with provenance stamped from
|
|
363
|
+
* the task / agent / HEAD sha, and report the action + slug.
|
|
364
|
+
*/
|
|
365
|
+
export async function distillSkill(args: DistillSkillArgs): Promise<DistillResult> {
|
|
366
|
+
const { store, runtime, root, worktreePath, baseRef, taskId, agentName, model } = args;
|
|
367
|
+
|
|
368
|
+
// 1. Gather the diff. No diff → nothing to distill.
|
|
369
|
+
const diffResult = await runGit(worktreePath, ["diff", `${baseRef}..HEAD`]);
|
|
370
|
+
if (!diffResult.ok || diffResult.stdout === "") {
|
|
371
|
+
return { action: "skipped" };
|
|
372
|
+
}
|
|
373
|
+
const diff = diffResult.stdout;
|
|
374
|
+
|
|
375
|
+
// 2. Cheap digest from --stat (best-effort; empty string if it fails), + spec.
|
|
376
|
+
const statResult = await runGit(worktreePath, ["diff", "--stat", `${baseRef}..HEAD`]);
|
|
377
|
+
const insightDigest = statResult.ok ? statResult.stdout : "";
|
|
378
|
+
const specText = await readSpecText(root, taskId);
|
|
379
|
+
|
|
380
|
+
// 3. Resolve applied skills the store still has (skip any since removed).
|
|
381
|
+
const appliedSkills: Skill[] = [];
|
|
382
|
+
for (const slug of args.appliedSlugs) {
|
|
383
|
+
const skill = store.get(slug);
|
|
384
|
+
if (skill !== null) appliedSkills.push(skill);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// 4. Build the prompt and run a single one-shot model call.
|
|
388
|
+
const prompt = buildDistillerPrompt({ diff, insightDigest, specText, appliedSkills });
|
|
389
|
+
|
|
390
|
+
let stdout: string;
|
|
391
|
+
try {
|
|
392
|
+
const proc = Bun.spawn(resolveArgv(runtime.buildPrintCommand(prompt, model)), {
|
|
393
|
+
cwd: worktreePath,
|
|
394
|
+
stdout: "pipe",
|
|
395
|
+
stderr: "pipe",
|
|
396
|
+
});
|
|
397
|
+
stdout = await new Response(proc.stdout).text();
|
|
398
|
+
await proc.exited;
|
|
399
|
+
} catch {
|
|
400
|
+
// The model CLI failed to spawn/run — degrade to a skip rather than crash.
|
|
401
|
+
return { action: "skipped" };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// 5. Parse. No usable object, or an explicit skip → skipped.
|
|
405
|
+
const draft = parseDistillerOutput(stdout);
|
|
406
|
+
if (draft === null || draft.action === "skip") {
|
|
407
|
+
return { action: "skipped" };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// 6. Safety scrub. A fatal violation downgrades the draft to a skip.
|
|
411
|
+
const report = sanitizeSkillDraft(draft);
|
|
412
|
+
if (!report.ok) {
|
|
413
|
+
return { action: "skipped" };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// 7. Persist via the store with provenance from this session.
|
|
417
|
+
const head = await runGit(worktreePath, ["rev-parse", "HEAD"]);
|
|
418
|
+
const provenance: SkillProvenance = {
|
|
419
|
+
taskId,
|
|
420
|
+
agent: agentName,
|
|
421
|
+
commit: head.ok && head.stdout !== "" ? head.stdout : null,
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const result = store.upsert(report.redactedDraft, provenance);
|
|
425
|
+
return { action: result.action, slug: result.skill.slug };
|
|
426
|
+
}
|