@lchase/kiln 0.4.0 → 0.5.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/README.md +7 -5
- package/dist/attention.js +10 -0
- package/dist/cli.js +4 -0
- package/dist/config.js +2 -1
- package/dist/driver.js +424 -48
- package/dist/feedback.js +610 -0
- package/dist/host.js +26 -1
- package/dist/reconcile.js +31 -3
- package/dist/report.js +48 -9
- package/dist/schemas.js +7 -0
- package/dist/serve-page.js +7 -1
- package/dist/workflow.js +2 -0
- package/package.json +1 -1
- package/prompts/kiln/fix.md +17 -0
- package/workflows/fix.yaml +44 -0
package/dist/reconcile.js
CHANGED
|
@@ -5,7 +5,7 @@ export function inFlightCount(observation) {
|
|
|
5
5
|
return observation.jobs.filter((job) => inFlight(job, observation)).length;
|
|
6
6
|
}
|
|
7
7
|
function inFlight(job, observation) {
|
|
8
|
-
if (job.error)
|
|
8
|
+
if (job.error || job.feedbackExhausted || job.feedbackAmbiguous)
|
|
9
9
|
return false;
|
|
10
10
|
const state = jobState(job, observation);
|
|
11
11
|
return state === "running" || state === "needs-human" || state === "approved";
|
|
@@ -20,8 +20,17 @@ function inFlight(job, observation) {
|
|
|
20
20
|
* An investigation is `done` on approve and never `in-review`.
|
|
21
21
|
*/
|
|
22
22
|
export function jobState(job, observation) {
|
|
23
|
-
if (job.
|
|
23
|
+
if (job.fixAwaitingPublish)
|
|
24
|
+
return "in-review";
|
|
25
|
+
if (job.launching && (!job.run || job.runRetired || job.fixInProgress))
|
|
24
26
|
return "running";
|
|
27
|
+
if ((job.feedbackExhausted || job.feedbackAmbiguous) && isLanded(job) && !job.fixInProgress) {
|
|
28
|
+
if (job.delivery?.merged || job.delivery?.prState === "MERGED")
|
|
29
|
+
return "done";
|
|
30
|
+
if (job.delivery?.prState === "CLOSED")
|
|
31
|
+
return "failed";
|
|
32
|
+
return "needs-human";
|
|
33
|
+
}
|
|
25
34
|
if (job.run && !job.runRetired)
|
|
26
35
|
return activeRunState(job);
|
|
27
36
|
if (job.run && isLanded(job))
|
|
@@ -126,10 +135,16 @@ function needsDelivery(job) {
|
|
|
126
135
|
return true;
|
|
127
136
|
}
|
|
128
137
|
function canLaunch(job, observation) {
|
|
129
|
-
if (job.error || job.run || job.launching || job.launchFailed)
|
|
138
|
+
if (job.error || job.run || job.launching || job.launchFailed || job.fixInProgress)
|
|
130
139
|
return false;
|
|
131
140
|
return dependenciesMet(job, observation);
|
|
132
141
|
}
|
|
142
|
+
/** An in-review job with new pull request feedback and room under `maxFixRounds`. */
|
|
143
|
+
function canStartFix(job) {
|
|
144
|
+
if (job.error || job.launching || job.fixInProgress || job.fixAwaitingPublish)
|
|
145
|
+
return false;
|
|
146
|
+
return job.feedbackPlan?.action === "fix";
|
|
147
|
+
}
|
|
133
148
|
/** A recorded transient failure whose backoff has elapsed. One retry per tick, ahead of a new job. */
|
|
134
149
|
function canRetry(job, observation, now) {
|
|
135
150
|
if (job.error || job.launching || job.retriesExhausted || job.ledgerError)
|
|
@@ -164,6 +179,14 @@ export function decide(observation, now = new Date()) {
|
|
|
164
179
|
continue;
|
|
165
180
|
actions.push({ type: "deliver", jobId: job.jobId, repo: job.repo });
|
|
166
181
|
}
|
|
182
|
+
for (const job of observation.jobs) {
|
|
183
|
+
if (job.feedbackPlan?.action === "exhaust") {
|
|
184
|
+
actions.push({ type: "mark-feedback", jobId: job.jobId, reason: "feedback-exhausted" });
|
|
185
|
+
}
|
|
186
|
+
else if (job.feedbackPlan?.action === "ambiguous") {
|
|
187
|
+
actions.push({ type: "mark-feedback", jobId: job.jobId, reason: "feedback-ambiguous" });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
167
190
|
if (inFlightCount(observation) >= observation.maxConcurrent)
|
|
168
191
|
return actions;
|
|
169
192
|
const retry = observation.jobs.find((job) => canRetry(job, observation, now));
|
|
@@ -171,6 +194,11 @@ export function decide(observation, now = new Date()) {
|
|
|
171
194
|
actions.push({ type: "retry", jobId: retry.jobId, repo: retry.repo });
|
|
172
195
|
return actions;
|
|
173
196
|
}
|
|
197
|
+
const fix = observation.jobs.find((job) => canStartFix(job));
|
|
198
|
+
if (fix) {
|
|
199
|
+
actions.push({ type: "start-fix", jobId: fix.jobId, repo: fix.repo });
|
|
200
|
+
return actions;
|
|
201
|
+
}
|
|
174
202
|
const ready = observation.jobs.filter((job) => canLaunch(job, observation));
|
|
175
203
|
const healthy = ready.filter((job) => !job.launchError);
|
|
176
204
|
const picked = healthy[0] ?? ready[0];
|
package/dist/report.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { readBoardFiles, readReviewRounds } from "./board.js";
|
|
5
|
+
import { feedbackRoundText } from "./feedback.js";
|
|
5
6
|
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
6
7
|
import { inFlightCount, isDone, isLanded, jobState, } from "./reconcile.js";
|
|
7
8
|
export function shellQuote(value) {
|
|
@@ -125,6 +126,12 @@ export function statusDetails(job) {
|
|
|
125
126
|
lines.push(job.launchError);
|
|
126
127
|
if (job.ledgerError)
|
|
127
128
|
lines.push(job.ledgerError);
|
|
129
|
+
if (job.feedbackLedgerError)
|
|
130
|
+
lines.push(job.feedbackLedgerError);
|
|
131
|
+
if ((job.feedbackRounds?.length ?? 0) > 0)
|
|
132
|
+
lines.push(`feedback ${job.feedbackRounds?.length}`);
|
|
133
|
+
if (job.state === "needs-human" && job.feedbackReason)
|
|
134
|
+
lines.push(`reason ${job.feedbackReason}`);
|
|
128
135
|
if (job.attemptCount > 0)
|
|
129
136
|
lines.push(`attempts ${job.attemptCount}`);
|
|
130
137
|
if (job.lastFailureReason)
|
|
@@ -197,10 +204,15 @@ function toStatusJob(job, observation) {
|
|
|
197
204
|
};
|
|
198
205
|
}
|
|
199
206
|
const state = jobState(job, observation);
|
|
200
|
-
const
|
|
207
|
+
const feedbackReason = job.feedbackExhausted
|
|
208
|
+
? "feedback-exhausted"
|
|
209
|
+
: job.feedbackAmbiguous
|
|
210
|
+
? "feedback-ambiguous"
|
|
211
|
+
: undefined;
|
|
212
|
+
const gates = state === "needs-human" && job.run && !feedbackReason ? job.run.gates.map(toGateView) : [];
|
|
201
213
|
const prNumber = job.delivery?.prNumber;
|
|
202
214
|
const runId = job.run?.runId;
|
|
203
|
-
const showBranch = (state === "done" || state === "in-review"
|
|
215
|
+
const showBranch = runId !== undefined && (state === "done" || state === "in-review" || feedbackReason !== undefined);
|
|
204
216
|
const failureDetail = job.run?.stepEvents ? latestStepFailure(job.run.stepEvents) : undefined;
|
|
205
217
|
return {
|
|
206
218
|
jobId: job.jobId,
|
|
@@ -224,6 +236,8 @@ function toStatusJob(job, observation) {
|
|
|
224
236
|
...(job.lastFailureReason ? { lastFailureReason: job.lastFailureReason } : {}),
|
|
225
237
|
...(job.nextRetryAt && !job.retriesExhausted ? { nextRetryAt: job.nextRetryAt } : {}),
|
|
226
238
|
...(job.ledgerError ? { ledgerError: job.ledgerError } : {}),
|
|
239
|
+
...(job.feedbackLedgerError ? { feedbackLedgerError: job.feedbackLedgerError } : {}),
|
|
240
|
+
...(feedbackReason ? { feedbackReason } : {}),
|
|
227
241
|
...(job.launchError ? { launchError: job.launchError } : {}),
|
|
228
242
|
...(job.delivery?.error ? { deliveryError: job.delivery.error } : {}),
|
|
229
243
|
...(job.delivery?.viewError ? { viewError: job.delivery.viewError } : {}),
|
|
@@ -258,12 +272,29 @@ function boardFields(job) {
|
|
|
258
272
|
...(job.workflow ? { workflow: job.workflow } : {}),
|
|
259
273
|
gates: job.run?.gates ?? [],
|
|
260
274
|
});
|
|
275
|
+
for (const round of job.feedbackRounds ?? []) {
|
|
276
|
+
const label = round.trigger === "ci" ? "CI" : "review";
|
|
277
|
+
files.push({
|
|
278
|
+
id: `feedback-r${round.round}`,
|
|
279
|
+
group: "Feedback",
|
|
280
|
+
label: `R${round.round} ${label}.md`,
|
|
281
|
+
text: feedbackRoundText(round),
|
|
282
|
+
tone: round.outcome ? "good" : "warn",
|
|
283
|
+
});
|
|
284
|
+
}
|
|
261
285
|
return {
|
|
262
286
|
...(rounds.length > 0 ? { rounds } : {}),
|
|
287
|
+
...(job.feedbackRounds?.length ? { feedbackRounds: job.feedbackRounds } : {}),
|
|
263
288
|
...(files.length > 0 ? { files } : {}),
|
|
264
289
|
};
|
|
265
290
|
}
|
|
266
291
|
function progressFor(job) {
|
|
292
|
+
if (job.fixInProgress && !job.run) {
|
|
293
|
+
return [
|
|
294
|
+
{ id: "build", phase: "current" },
|
|
295
|
+
{ id: "land", phase: "pending" },
|
|
296
|
+
];
|
|
297
|
+
}
|
|
267
298
|
const plan = job.run?.stepPlan?.length ? job.run.stepPlan : (job.stepPlan ?? []);
|
|
268
299
|
return stepProgress(plan, job.run?.stepEvents ?? []);
|
|
269
300
|
}
|
|
@@ -317,7 +348,7 @@ function blockReasons(job, observation) {
|
|
|
317
348
|
}
|
|
318
349
|
return reasons;
|
|
319
350
|
}
|
|
320
|
-
export function renderTick(observation, actions, launches, delivers = [], findings = [], notes = []) {
|
|
351
|
+
export function renderTick(observation, actions, launches, delivers = [], findings = [], notes = [], feedbackNotes = []) {
|
|
321
352
|
const lines = [`tick inFlight=${inFlightCount(observation)} maxConcurrent=${observation.maxConcurrent}`];
|
|
322
353
|
for (const job of observation.jobs) {
|
|
323
354
|
if (job.error)
|
|
@@ -341,18 +372,26 @@ export function renderTick(observation, actions, launches, delivers = [], findin
|
|
|
341
372
|
const next = note.nextRetryAt ? ` next ${note.nextRetryAt}` : " dead";
|
|
342
373
|
lines.push(`${note.kind} ${note.jobId} ${note.reason} attempt ${note.attempt}${next}`);
|
|
343
374
|
}
|
|
375
|
+
for (const note of feedbackNotes) {
|
|
376
|
+
if (note.trim())
|
|
377
|
+
lines.push(note);
|
|
378
|
+
}
|
|
344
379
|
for (const launch of launches) {
|
|
345
|
-
|
|
380
|
+
const verb = launch.kind === "fix" ? "fix" : launch.retry ? "retry" : "launch";
|
|
381
|
+
lines.push(`${verb} ${launch.jobId} ${launch.repo}`);
|
|
346
382
|
for (const line of launch.output.split("\n")) {
|
|
347
383
|
if (line.trim())
|
|
348
384
|
lines.push(` ${line}`);
|
|
349
385
|
}
|
|
350
386
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
387
|
+
const acted = actions.some((action) => action.type === "launch" ||
|
|
388
|
+
action.type === "retry" ||
|
|
389
|
+
action.type === "start-fix" ||
|
|
390
|
+
action.type === "mark-feedback") ||
|
|
391
|
+
launches.length > 0 ||
|
|
392
|
+
delivers.length > 0 ||
|
|
393
|
+
findings.length > 0;
|
|
394
|
+
if (!acted)
|
|
355
395
|
lines.push("nothing to launch");
|
|
356
|
-
}
|
|
357
396
|
return `${lines.join("\n")}\n`;
|
|
358
397
|
}
|
package/dist/schemas.js
CHANGED
|
@@ -30,6 +30,8 @@ export const JiraConfigSchema = z.object({
|
|
|
30
30
|
export const DEFAULT_STALL_TIMEOUT_MS = 15 * 60 * 1000;
|
|
31
31
|
/** Transient failures retry until this many attempts, then the job is dead. */
|
|
32
32
|
export const DEFAULT_MAX_ATTEMPTS = 3;
|
|
33
|
+
/** Pull request fix rounds before the job is needs-human (`feedback-exhausted`). */
|
|
34
|
+
export const DEFAULT_MAX_FIX_ROUNDS = 2;
|
|
33
35
|
export const KilnConfigFileSchema = z.object({
|
|
34
36
|
note: z.string().min(1).optional(),
|
|
35
37
|
maxConcurrent: z.number().int().min(1).optional(),
|
|
@@ -40,6 +42,11 @@ export const KilnConfigFileSchema = z.object({
|
|
|
40
42
|
stallTimeoutMs: z.number().int().optional(),
|
|
41
43
|
/** Transient failures before the job is dead. Default 3. */
|
|
42
44
|
maxAttempts: z.number().int().min(1).optional(),
|
|
45
|
+
/**
|
|
46
|
+
* Fix rounds for an open pull request before the job is needs-human with
|
|
47
|
+
* reason `feedback-exhausted`. Default 2. Kiln does not merge.
|
|
48
|
+
*/
|
|
49
|
+
maxFixRounds: z.number().int().min(1).optional(),
|
|
43
50
|
adapter: z.enum(["mock", "claude-code"]).optional(),
|
|
44
51
|
repos: z.record(z.string(), RepoEntrySchema),
|
|
45
52
|
jira: JiraConfigSchema.optional(),
|
package/dist/serve-page.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { scheduledRetry } from "./attention.js";
|
|
4
4
|
import { readBoardFiles, stageOf, stagesFor } from "./board.js";
|
|
5
|
+
import { feedbackRoundLabel } from "./feedback.js";
|
|
5
6
|
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
6
7
|
import { packageRoot } from "./paths.js";
|
|
7
8
|
const REVIEWERS = [
|
|
@@ -201,7 +202,12 @@ function roundsHtml(job, stage) {
|
|
|
201
202
|
const rounds = job.rounds ?? [];
|
|
202
203
|
const last = rounds[rounds.length - 1]?.n;
|
|
203
204
|
const rows = rounds.map((round) => roundRow(round, stage === "review" && job.state === "running" && round.n === last));
|
|
204
|
-
|
|
205
|
+
for (const round of job.feedbackRounds ?? []) {
|
|
206
|
+
const tone = round.outcome ? " PASS" : " CHANGES";
|
|
207
|
+
rows.push(`<div class="round"><span class="r">R${round.round}</span><span class="chip${tone}" style="grid-column: 2 / -1">${escapeHtml(feedbackRoundLabel(round))}</span></div>`);
|
|
208
|
+
}
|
|
209
|
+
const inReview = job.state === "in-review" || (job.state === "done" && safeUrl(job.prUrl));
|
|
210
|
+
if (inReview && (job.feedbackRounds?.length ?? 0) === 0) {
|
|
205
211
|
const n = (last ?? 0) + 1;
|
|
206
212
|
const tone = job.state === "done" ? " PASS" : "";
|
|
207
213
|
rows.push(`<div class="round"><span class="r">R${n}</span><span class="chip${tone}" style="grid-column: 2 / -1">PR review</span></div>`);
|
package/dist/workflow.js
CHANGED
|
@@ -6,10 +6,12 @@ const FACTORY_DIRECTIVE = /^<!--\s*factory:\s*(prompts\/factory\/[a-z0-9-]+\.md)
|
|
|
6
6
|
const WORKFLOW_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
7
7
|
const FEATURE_PROMPTS = 7;
|
|
8
8
|
const INVESTIGATION_PROMPTS = 1;
|
|
9
|
+
const FIX_PROMPTS = 1;
|
|
9
10
|
/** Prompt files Kiln rewrites before launch. A mismatch refuses the launch. */
|
|
10
11
|
const PROMPT_COUNTS = {
|
|
11
12
|
feature: FEATURE_PROMPTS,
|
|
12
13
|
investigation: INVESTIGATION_PROMPTS,
|
|
14
|
+
fix: FIX_PROMPTS,
|
|
13
15
|
};
|
|
14
16
|
/**
|
|
15
17
|
* Values Kiln can pass into a workflow. A workflow's inputSchema lists the
|
package/package.json
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!-- factory: prompts/factory/builder.md -->
|
|
2
|
+
|
|
3
|
+
Kiln runs this factory agent inside yak to answer pull request feedback. The working directory is the worktree (there is no separate worktree path). There is no `.factory/` job folder and no `.factory/repo.md`; read the repo for conventions. `.factory/lessons.md` applies only when that file is really present.
|
|
4
|
+
|
|
5
|
+
`<main branch>` is the base SHA `{{input.baseRef}}` (the pull request head this worktree was branched from, via yak `--from`). Use that SHA wherever the factory instructions say `<main branch>`. Do not guess `main`.
|
|
6
|
+
|
|
7
|
+
Your branch is whatever `git branch --show-current` prints. It is `yak/<run-id>`. That is the branch name in this prompt. Do not BLOCK because it is not `factory/...` or `main`.
|
|
8
|
+
|
|
9
|
+
Commit on that branch. Do not push. Do not force-push. Do not open a pull request. Do not approve one. Do not comment on one. Kiln fast-forwards the existing pull request branch after this run.
|
|
10
|
+
|
|
11
|
+
Do not write `build.md`. Return JSON: `status` is `READY` or `BLOCKED`, `summary` is what you changed. The original spec and the feedback items are the task text below, not files.
|
|
12
|
+
|
|
13
|
+
Task: {{input.title}}
|
|
14
|
+
|
|
15
|
+
{{input.description}}
|
|
16
|
+
|
|
17
|
+
The factory agent instructions follow. Their frontmatter is the original definition; follow the prose, and return the JSON above instead of writing the file they name.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# One pull-request fix, run by yak (@lchase/yak).
|
|
2
|
+
#
|
|
3
|
+
# The same builder as workflows/feature.yaml (factory builder, opus). Kiln
|
|
4
|
+
# launches it with `yak run --isolation worktree --from <pull request head>`
|
|
5
|
+
# on a job whose pull request is already open. Yak cuts a worktree branch
|
|
6
|
+
# `yak/<run-id>` from that commit. The agent commits there. Kiln fast-forwards
|
|
7
|
+
# the open pull request branch. This workflow does not open, approve, merge,
|
|
8
|
+
# or comment on a pull request, and it does not force-push.
|
|
9
|
+
name: kiln-fix
|
|
10
|
+
version: "1"
|
|
11
|
+
inputSchema:
|
|
12
|
+
inline:
|
|
13
|
+
type: object
|
|
14
|
+
additionalProperties: false
|
|
15
|
+
properties:
|
|
16
|
+
title: { type: string }
|
|
17
|
+
description: { type: string }
|
|
18
|
+
acceptance: { type: string }
|
|
19
|
+
runNonce: { type: string }
|
|
20
|
+
baseRef: { type: string }
|
|
21
|
+
required: [title, description, acceptance, runNonce, baseRef]
|
|
22
|
+
steps:
|
|
23
|
+
- id: build
|
|
24
|
+
needs: [input]
|
|
25
|
+
agent:
|
|
26
|
+
model: opus
|
|
27
|
+
prompt:
|
|
28
|
+
file: prompts/kiln/fix.md
|
|
29
|
+
schema:
|
|
30
|
+
inline:
|
|
31
|
+
type: object
|
|
32
|
+
additionalProperties: false
|
|
33
|
+
properties:
|
|
34
|
+
summary: { type: string }
|
|
35
|
+
status: { type: string, enum: [READY, BLOCKED] }
|
|
36
|
+
required: [summary, status]
|
|
37
|
+
tools: [Read, Grep, Glob, Bash, Write, Edit, Skill]
|
|
38
|
+
produces: build
|
|
39
|
+
|
|
40
|
+
- id: land
|
|
41
|
+
needs: [input, build]
|
|
42
|
+
skipIf: 'build.status != "READY"'
|
|
43
|
+
command:
|
|
44
|
+
run: 'git add -A && git diff --cached --quiet || git commit -m "kiln: $KILN_TASK_TITLE"'
|