@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/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.launching && (!job.run || job.runRetired))
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 gates = state === "needs-human" && job.run ? job.run.gates.map(toGateView) : [];
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") && runId !== undefined;
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
- lines.push(`${launch.retry ? "retry" : "launch"} ${launch.jobId} ${launch.repo}`);
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
- if (!actions.some((action) => action.type === "launch" || action.type === "retry") &&
352
- launches.length === 0 &&
353
- delivers.length === 0 &&
354
- findings.length === 0) {
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(),
@@ -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
- if (job.state === "in-review" || (job.state === "done" && safeUrl(job.prUrl))) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lchase/kiln",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Run a workflow on any registered repo. A markdown spec is the input.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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"'