@lchase/kiln 0.8.0 → 0.10.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 CHANGED
@@ -108,7 +108,7 @@ Then set the kiln config to `"sandbox": { "mode": "docker" }`.
108
108
 
109
109
  `kiln tick` and `kiln serve` name what is missing when sandbox mode is on: the docker binary, the docker daemon, each image, and the credential. They also refuse an installed `@lchase/yak` that does not pass `allowedDomains` from the agent step into the sandbox proxy. `@lchase/yak` 0.8.0 does. An older package drops `sandbox`, `image`, and `allowedDomains` while loading YAML and opens the proxy with only `api.anthropic.com`, so the job would run on the host with the open internet. Kiln leaves that job failed until `kiln retry`, after the installed package is 0.8.0 or newer.
110
110
 
111
- A step that fails with yak reason `sandbox-error` goes to Needs human. The card shows the failure detail. Kiln does not schedule an automatic retry for it. The card of a sandboxed job shows `sandbox · docker` in the same monospace metadata as the rest of the board.
111
+ A sandbox or container start flake (the container never started, the daemon was unreachable) is transient and retries with the usual backoff. Any other yak `sandbox-error` is not retried. The card shows the failure detail, and a sandboxed job shows `sandbox · docker`.
112
112
 
113
113
  ## Register repos
114
114
 
@@ -271,12 +271,14 @@ while true; do npm run kiln -- tick; sleep 30; done
271
271
 
272
272
  A job is ready when its dependencies are met and the number of jobs in flight is under `maxConcurrent`. The default is after merged: every job in `dependsOn` is `done`. `--stack` is met when that one upstream has landed. A rejected gate does not unblock the next job. An ok run with no readable approval does not either. An authentication failure does not either: reason `auth-failed` stays in Needs human until `kiln retry`.
273
273
 
274
- `stallTimeoutMs` (default `900000`, 15 minutes), `maxAttempts` (default `3`), and `maxFixRounds` (default `2`) live in the kiln config. `stallTimeoutMs` of `0` or below turns stall detection off. A running job with no yak journal event and no log output for that long is killed, process tree included, and marked failed with reason `stalled`. A process that is already gone when `kiln tick` or `kiln serve` starts is `orphaned`. An agent or launch crash is `crashed`. A non-zero exit that is not a gate rejection is `failed`. Those four retry on their own after `10s × 2^(n-1)`, capped at 5 minutes. A rejected gate, a cancelled run, and an ok run with no readable approval do not. After `maxAttempts` the job is `dead`. Each attempt is a file under `<kiln-dir>/attempts/<job-id>.json`, so state stays derived. Status prints `attempts`, `reason`, and `next retry` from the same view `readStatus` returns. On the board, a dead card in Needs human shows `attempt N/M` and the reason. A failure that still has a scheduled retry stays in its stage column instead, with `retrying` and the local time.
274
+ `stallTimeoutMs` (default `900000`, 15 minutes), `maxAttempts` (default `3`), and `maxFixRounds` (default `2`) live in the kiln config. `stallTimeoutMs` of `0` or below turns stall detection off. A running job with no yak journal event and no log output for that long is killed, process tree included, and marked failed with reason `stalled`. A process that is already gone when `kiln tick` or `kiln serve` starts is `orphaned`. An agent or launch crash is `crashed`. A non-zero exit that is not a gate rejection is `failed`. Those four retry on their own after `10s × 2^(n-1)`, capped at 5 minutes. A rejected gate, a cancelled run, and an ok run with no readable approval do not. After `maxAttempts` the failure is class `budget` and the job is `needs-human`. The same fingerprint does that after `maxSameFingerprint` (default 3) even sooner. A test, build, or type error is class `deterministic` and is not retried. `repos.<name>.maxAttempts` and `repos.<name>.maxFixRounds` override the global caps. `dailyDollarCap`, when set, blocks new runs once today's recorded dollars reach it. `kiln pause` does the same until `kiln resume`. In-flight runs finish. `kiln revise <id> --note <text>` sends a gated job around again with that note in the next prompt. Each attempt is a file under `<kiln-dir>/attempts/<job-id>.json`, so state stays derived. Status prints `attempts`, `reason`, and `next retry` from the same view `readStatus` returns. On the board, a dead card in Needs human shows `attempt N/M` and the reason. A failure that still has a scheduled retry stays in its stage column instead, with `retrying` and the local time.
275
275
 
276
276
  While a `deliver=pr` job is `in-review`, tick reads that pull request with `gh`: check runs for the head, reviews, and unresolved review comments. The operator's inline comments and unresolved threads count; a top-level note counts only when it starts with `/kiln`, and that prefix is left out of the fix prompt. Comments from `kiln` or a `[bot]` login are ignored, and a resolved thread does not start a round. Pending checks are ignored. A completed failing check on a head that no round has recorded, or review feedback newer than the last round, starts `workflows/fix.yaml` with `yak run --from` that head. The prompt is the original spec plus the failing check names (with a short log excerpt) and the review comment bodies, including file and line. The agent commits on `yak/<run-id>` and does not push. Kiln fast-forwards the open pull request branch. It does not open a second pull request, force-push, merge, approve, or comment. The same head and the same feedback do not start two runs. Each round is a file under `<kiln-dir>/feedback/<job-id>.json`. A stall or crash during a fix uses the attempt ledger above. After `maxFixRounds` the job is `needs-human` with reason `feedback-exhausted`. Feedback Kiln cannot classify is `needs-human` with reason `feedback-ambiguous`. The person merges the pull request.
277
277
 
278
278
  `npm run kiln -- status` lists jobs across repos. When a job has `source`, status prints `source <kind> <ref>`. For `needs-human` it prints `approve: kiln approve <id>` and `reject: kiln reject <id>`. Run those as `npm run kiln -- approve <id>` and `npm run kiln -- reject <id>`. For `done` and `in-review` it prints the branch. `deliver=local` prints `branch yak/<run-id>`. `deliver=pr` prints the remote branch name recorded on the delivery, and the pull request url once one exists. `auth-failed` prints `Claude login rejected (401) — check CLAUDE_CODE_OAUTH_TOKEN / claude login, then Retry`. For an investigation whose findings file exists, it prints `findings <path>`. That path is `findingsPath` on the same status view `kiln serve` renders, and the console links it. For `blocked` on a dependency id that has no job file, it prints `blocked on missing job <id>`. While an upstream pull request is open, it prints `waiting for PR #N to merge`. `npm run kiln -- retry <id>` relaunches a failed or dead job, or delivers again when the only failure is opening the pull request, and clears the attempt ledger.
279
279
 
280
+ `npm run kiln -- ledger` prints today's recorded dollars against `dailyDollarCap` (or `no cap`) and counts human interventions from `<kiln-home>/events.jsonl`: a rejected gate, a job retried by hand, an edit noted on a gate answer, and a needs-human stop. `--since 7d` (also `12h`, `30m`, `45s`, or an ISO time) keeps events at or after that instant. `--repo <name>` limits the count to one repo. A failed append is reported and does not fail the job. `GET /api/events?job_id=` on `kiln serve` returns that job's lines. A pull request body gains a short Evidence section from the same lines, with tokens and secret env values masked.
281
+
280
282
  State is derived from the yak journal, the gate files, and (for `deliver=pr`) `gh pr view`:
281
283
 
282
284
  | State | Meaning |
@@ -284,11 +286,11 @@ State is derived from the yak journal, the gate files, and (for `deliver=pr`) `g
284
286
  | `queued` | Dependencies are met, and the job has not started |
285
287
  | `blocked` | An after-merged dependency is not `done`, or a stacked dependency has not landed. A missing job id is named |
286
288
  | `running` | Yak is alive, or the launch process is alive and has not written a journal |
287
- | `needs-human` | The run is suspended on an open gate, or pull request feedback is exhausted (`feedback-exhausted`) or ambiguous (`feedback-ambiguous`) |
289
+ | `needs-human` | A gate is open, feedback is exhausted or ambiguous, the attempt cap is used, or the same fingerprint has failed `maxSameFingerprint` times. `kiln retry` clears an attempt-cap or fingerprint stop. `kiln revise --note` sends a gate back with a note |
288
290
  | `approved` | The gate says approve and the run has not finished. This still holds a slot |
289
291
  | `rejected` | The run finished ok and the gate says reject |
290
292
  | `failed` | The run failed, the launch died before a journal, the run finished ok with no readable approval, a pull request could not be opened, a Claude login was rejected (`auth-failed`, not retried), or a transient failure is waiting to retry |
291
- | `dead` | The attempt budget is used up. Before the next tick, a journal whose process is already gone is also `dead`. Status prints the cancel command when a process is gone, and `kiln retry <id>` once attempts are exhausted |
293
+ | `dead` | A journal whose process is already gone, before the next tick records it. Status prints the cancel command when a process is gone |
292
294
  | `in-review` | `deliver=pr` and the run has landed. The pull request is open, or the next tick still has to open it |
293
295
  | `done` | `deliver=local`: the run finished ok and the gate says approve. `deliver=pr`: that pull request has merged. An investigation is `done` when the gate says approve, including when the repo is `deliver=pr`. It does not enter `in-review` |
294
296
 
package/assets/serve.css CHANGED
@@ -61,6 +61,19 @@ header {
61
61
  }
62
62
  .ver { font-size: 12px; line-height: 16px; color: var(--faint); align-self: flex-end; margin: 0 0 4px -4px; }
63
63
  h1 { margin: 0; font-size: 18px; line-height: 24px; font-weight: 500; }
64
+ .spend { font-family: var(--mono); font-size: 12px; line-height: 16px; color: var(--muted); }
65
+ .pause { margin: 0; }
66
+ .pause button, .d-actions .revise input {
67
+ font: inherit;
68
+ color: var(--ink);
69
+ background: var(--card);
70
+ border: 1px solid var(--line-strong);
71
+ border-radius: 6px;
72
+ padding: 6px 10px;
73
+ }
74
+ .pause button[aria-pressed="true"] { color: var(--human); }
75
+ .d-actions .revise { display: flex; gap: 8px; align-items: center; }
76
+ .d-actions .revise input { min-width: 16rem; }
64
77
  .status { margin-left: auto; font-family: var(--mono); font-size: 12px; line-height: 16px; color: var(--muted); }
65
78
  .status::before {
66
79
  content: "";
package/assets/serve.js CHANGED
@@ -605,6 +605,8 @@
605
605
  base: repo.base || "",
606
606
  prTitle: repo.prTitle || "",
607
607
  branch: repo.branch || "",
608
+ maxAttempts: repo.maxAttempts != null ? String(repo.maxAttempts) : "",
609
+ maxFixRounds: repo.maxFixRounds != null ? String(repo.maxFixRounds) : "",
608
610
  };
609
611
  }
610
612
  return out;
@@ -625,7 +627,11 @@
625
627
  var attempts = document.getElementById("hint-attempts");
626
628
  var stall = document.getElementById("hint-stall");
627
629
  var rounds = document.getElementById("hint-fix");
628
- if (attempts) attempts.textContent = "Default " + (settingsDefaults.maxAttempts != null ? settingsDefaults.maxAttempts : 3) + ". Transient failures before the job is dead.";
630
+ if (attempts) attempts.textContent = "Default " + (settingsDefaults.maxAttempts != null ? settingsDefaults.maxAttempts : 3) + ". Transient failures before the job needs a person.";
631
+ var fingerprint = document.getElementById("hint-fingerprint");
632
+ var cap = document.getElementById("hint-cap");
633
+ if (fingerprint) fingerprint.textContent = "Default " + (settingsDefaults.maxSameFingerprint != null ? settingsDefaults.maxSameFingerprint : 3) + ". Identical fingerprints before the job needs a person.";
634
+ if (cap) cap.textContent = "Empty means no daily cap. Recorded dollars, local day.";
629
635
  if (stall) stall.textContent = "Default " + (settingsDefaults.stallTimeoutMs != null ? settingsDefaults.stallTimeoutMs : 900000) + " (15 minutes). 0 disables stall detection.";
630
636
  if (rounds) rounds.textContent = "Default " + (settingsDefaults.maxFixRounds != null ? settingsDefaults.maxFixRounds : 2) + ". Fix rounds before the job needs a person.";
631
637
  var attemptInput = document.getElementById("max-attempts");
@@ -634,6 +640,8 @@
634
640
  if (attemptInput) attemptInput.placeholder = String(settingsDefaults.maxAttempts != null ? settingsDefaults.maxAttempts : 3);
635
641
  if (stallInput) stallInput.placeholder = String(settingsDefaults.stallTimeoutMs != null ? settingsDefaults.stallTimeoutMs : 900000);
636
642
  if (roundInput) roundInput.placeholder = String(settingsDefaults.maxFixRounds != null ? settingsDefaults.maxFixRounds : 2);
643
+ var fingerprintInput = document.getElementById("max-same-fingerprint");
644
+ if (fingerprintInput) fingerprintInput.placeholder = String(settingsDefaults.maxSameFingerprint != null ? settingsDefaults.maxSameFingerprint : 3);
637
645
  }
638
646
 
639
647
  function fillGlobal(data) {
@@ -644,6 +652,10 @@
644
652
  if (attempts) attempts.value = data.maxAttempts != null ? String(data.maxAttempts) : "";
645
653
  if (stall) stall.value = data.stallTimeoutMs != null ? String(data.stallTimeoutMs) : "";
646
654
  if (rounds) rounds.value = data.maxFixRounds != null ? String(data.maxFixRounds) : "";
655
+ var fingerprint = document.getElementById("max-same-fingerprint");
656
+ var cap = document.getElementById("daily-dollar-cap");
657
+ if (fingerprint) fingerprint.value = data.maxSameFingerprint != null ? String(data.maxSameFingerprint) : "";
658
+ if (cap) cap.value = data.dailyDollarCap != null ? String(data.dailyDollarCap) : "";
647
659
  setPressed("#sandbox-mode [data-sandbox]", "data-sandbox", data.sandbox && data.sandbox.mode === "docker" ? "docker" : "off");
648
660
  var domains = document.getElementById("sandbox-domains");
649
661
  if (domains) domains.value = data.sandbox && data.sandbox.allowDomains ? data.sandbox.allowDomains.join("\n") : "";
@@ -674,12 +686,16 @@
674
686
  var baseInput = document.getElementById("repo-base");
675
687
  var titleInput = document.getElementById("repo-pr-title");
676
688
  var branchInput = document.getElementById("repo-branch");
689
+ var attemptsInput = document.getElementById("repo-max-attempts");
690
+ var roundsInput = document.getElementById("repo-max-fix-rounds");
677
691
  if (nameInput) nameInput.value = name || "";
678
692
  if (pathInput) pathInput.value = repo.path || "";
679
693
  if (testInput) testInput.value = repo.test || "";
680
694
  if (baseInput) baseInput.value = repo.base || "";
681
695
  if (titleInput) titleInput.value = repo.prTitle || "";
682
696
  if (branchInput) branchInput.value = repo.branch || "";
697
+ if (attemptsInput) attemptsInput.value = repo.maxAttempts != null && repo.maxAttempts !== "" ? String(repo.maxAttempts) : "";
698
+ if (roundsInput) roundsInput.value = repo.maxFixRounds != null && repo.maxFixRounds !== "" ? String(repo.maxFixRounds) : "";
683
699
  setPressed("#repo-deliver [data-deliver]", "data-deliver", repo.deliver === "pr" ? "pr" : "local");
684
700
  var browser = document.getElementById("repo-browser");
685
701
  if (browser) { browser.hidden = true; browser.innerHTML = ""; }
@@ -698,6 +714,8 @@
698
714
  base: value("repo-base"),
699
715
  prTitle: value("repo-pr-title"),
700
716
  branch: value("repo-branch"),
717
+ maxAttempts: value("repo-max-attempts"),
718
+ maxFixRounds: value("repo-max-fix-rounds"),
701
719
  };
702
720
  }
703
721
 
@@ -763,6 +781,13 @@
763
781
  renderRepoList();
764
782
  }
765
783
 
784
+ function optionalNumber(raw) {
785
+ var text = String(raw == null ? "" : raw).trim();
786
+ if (!text) return undefined;
787
+ if (!/^\d+(\.\d+)?$/.test(text)) return text;
788
+ return Number(text);
789
+ }
790
+
766
791
  function optionalInt(raw) {
767
792
  var text = String(raw == null ? "" : raw).trim();
768
793
  if (!text) return undefined;
@@ -791,6 +816,10 @@
791
816
  if (repo.base) entry.base = repo.base;
792
817
  if (repo.prTitle) entry.prTitle = repo.prTitle;
793
818
  if (repo.branch) entry.branch = repo.branch;
819
+ var repoAttempts = optionalInt(repo.maxAttempts);
820
+ var repoRounds = optionalInt(repo.maxFixRounds);
821
+ if (repoAttempts !== undefined) entry.maxAttempts = repoAttempts;
822
+ if (repoRounds !== undefined) entry.maxFixRounds = repoRounds;
794
823
  repos[name] = entry;
795
824
  }
796
825
  var body = { revision: settingsDoc ? settingsDoc.revision : "", repos: repos };
@@ -800,6 +829,10 @@
800
829
  if (attempts !== undefined) body.maxAttempts = attempts;
801
830
  if (stall !== undefined) body.stallTimeoutMs = stall;
802
831
  if (rounds !== undefined) body.maxFixRounds = rounds;
832
+ var fingerprint = optionalInt(inputValue("max-same-fingerprint"));
833
+ var cap = optionalNumber(inputValue("daily-dollar-cap"));
834
+ if (fingerprint !== undefined) body.maxSameFingerprint = fingerprint;
835
+ if (cap !== undefined) body.dailyDollarCap = cap;
803
836
  var mode = selectedAttr("#sandbox-mode [data-sandbox]", "data-sandbox", "off");
804
837
  var domains = domainList(inputValue("sandbox-domains"));
805
838
  var sandbox = {};
@@ -820,7 +853,8 @@
820
853
  if (repoField) {
821
854
  setSettingsTab("repos");
822
855
  if (Object.prototype.hasOwnProperty.call(repoDraft, repoField[1])) startEdit(repoField[1]);
823
- showSlot(repoField[2], message);
856
+ var slotName = repoField[2] === "maxAttempts" ? "repoAttempts" : repoField[2] === "maxFixRounds" ? "repoRounds" : repoField[2];
857
+ showSlot(slotName, message);
824
858
  continue;
825
859
  }
826
860
  if (key.indexOf("sandbox.allowDomains") === 0) {
@@ -828,7 +862,7 @@
828
862
  showSlot("sandbox.allowDomains", message);
829
863
  continue;
830
864
  }
831
- if (key === "sandbox.mode" || key === "maxAttempts" || key === "stallTimeoutMs" || key === "maxFixRounds") {
865
+ if (key === "sandbox.mode" || key === "maxAttempts" || key === "stallTimeoutMs" || key === "maxFixRounds" || key === "maxSameFingerprint" || key === "dailyDollarCap") {
832
866
  setSettingsTab("global");
833
867
  showSlot(key, message);
834
868
  continue;
@@ -1154,6 +1188,18 @@
1154
1188
  location.reload();
1155
1189
  return;
1156
1190
  }
1191
+ var toggle = document.getElementById("pause-toggle");
1192
+ var form = document.getElementById("pause-form");
1193
+ if (toggle && form && data && typeof data.paused === "boolean") {
1194
+ toggle.textContent = data.paused ? "Resume" : "Pause";
1195
+ toggle.setAttribute("aria-pressed", data.paused ? "true" : "false");
1196
+ form.setAttribute("action", data.paused ? "/api/resume" : "/api/pause");
1197
+ }
1198
+ var spend = document.getElementById("spend");
1199
+ if (spend && data && data.spend) {
1200
+ var capText = data.spend.cap == null ? "no cap" : "$" + Number(data.spend.cap).toFixed(2);
1201
+ spend.textContent = "today " + data.spend.day + " $" + Number(data.spend.usd).toFixed(2) + " / " + capText;
1202
+ }
1157
1203
  stamp(true);
1158
1204
  })
1159
1205
  .catch(function () { stamp(false); });
package/dist/attention.js CHANGED
@@ -24,6 +24,8 @@ const RETRYABLE_REASONS = new Set(["stalled", "crashed", "orphaned", "failed"]);
24
24
  * so a waiting retry is not a call for a person.
25
25
  */
26
26
  export function scheduledRetry(job) {
27
+ if (job.waitReason)
28
+ return false;
27
29
  if (job.state !== "failed")
28
30
  return false;
29
31
  if (!job.nextRetryAt || !Number.isFinite(Date.parse(job.nextRetryAt)))
@@ -92,13 +94,18 @@ function toItem(job) {
92
94
  reason: attentionReason(job, tail),
93
95
  ...(tail ? { errorTail: tail } : {}),
94
96
  ...(findings ? { findings } : {}),
95
- retry: job.state === "failed" || job.state === "dead",
97
+ retry: (job.state === "failed" || job.state === "dead" || (job.state === "needs-human" && Boolean(job.stopReason))) &&
98
+ job.gates.length === 0,
96
99
  ...(since ? { since } : {}),
97
100
  };
98
101
  }
99
102
  export function attentionReason(job, tail = errorTail(job)) {
100
103
  switch (job.state) {
101
104
  case "needs-human": {
105
+ if (job.stopReason === "attempt-cap")
106
+ return "Attempt cap reached";
107
+ if (job.stopReason === "fingerprint")
108
+ return "Same failure repeated";
102
109
  if (job.feedbackReason)
103
110
  return job.feedbackReason;
104
111
  const question = gateQuestion(job);
@@ -109,6 +116,8 @@ export function attentionReason(job, tail = errorTail(job)) {
109
116
  return AUTH_FAILED_LINE;
110
117
  return job.sandboxError ? `Sandbox: ${oneLine(job.sandboxError)}` : "Run died before it finished";
111
118
  case "failed": {
119
+ if (job.waitReason)
120
+ return job.waitReason;
112
121
  if (job.lastFailureReason === "auth-failed")
113
122
  return AUTH_FAILED_LINE;
114
123
  if (job.sandboxError)
@@ -124,8 +133,6 @@ export function attentionReason(job, tail = errorTail(job)) {
124
133
  return "In review";
125
134
  case "invalid":
126
135
  return job.invalidReason ? `Invalid: ${oneLine(job.invalidReason)}` : "Job file is invalid";
127
- case "blocked":
128
- return oneLine(waitingOn(job));
129
136
  case "running": {
130
137
  const current = job.steps.find((step) => step.phase === "current" || step.phase === "failed");
131
138
  return current ? `Running ${current.id}` : "Running";
@@ -133,7 +140,9 @@ export function attentionReason(job, tail = errorTail(job)) {
133
140
  case "approved":
134
141
  return "Approved, still finishing";
135
142
  case "queued":
136
- return "Queued, ready to launch";
143
+ return job.waitReason ?? "Queued, ready to launch";
144
+ case "blocked":
145
+ return job.waitReason ?? oneLine(waitingOn(job));
137
146
  case "done":
138
147
  return job.prUrl ? "Merged" : "Done";
139
148
  case "rejected":
package/dist/cli.js CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { addRepo, renderRepoList, resolveKilnConfig } from "./config.js";
3
3
  import { renderSandboxStatus, runSandboxBuild } from "./sandbox.js";
4
- import { answerJob, retryJob, runStatus, runTick } from "./driver.js";
4
+ import { answerJob, retryJob, reviseJob, runStatus, runTick } from "./driver.js";
5
+ import { pauseFactory, resumeFactory } from "./rails.js";
6
+ import { parseSince, renderLedgerFile } from "./ledger.js";
5
7
  import { startServe } from "./serve.js";
6
8
  import { addFromFile } from "./intake/file.js";
7
9
  import { addFromJira } from "./intake/jira.js";
@@ -18,7 +20,11 @@ function usage() {
18
20
  " kiln status [jobId] [--config <file>]",
19
21
  " kiln approve <jobId> [--config <file>]",
20
22
  " kiln reject <jobId> [--config <file>]",
23
+ " kiln revise <jobId> --note <text> [--config <file>]",
21
24
  " kiln retry <jobId> [--config <file>]",
25
+ " kiln pause [--config <file>]",
26
+ " kiln resume [--config <file>]",
27
+ " kiln ledger [--since 7d] [--repo <name>] [--config <file>]",
22
28
  " kiln sandbox [build] [--config <file>]",
23
29
  " kiln serve [--port 4100] [--tick <seconds>|--no-tick] [--config <file>]",
24
30
  "",
@@ -27,7 +33,7 @@ function usage() {
27
33
  "A relative path is stored absolute, resolved from the working directory.",
28
34
  "~ is expanded from the home directory.",
29
35
  "",
30
- "Config, jobs, logs, and deliveries live in one kiln home: --config <file>",
36
+ "Config, jobs, logs, events, and deliveries live in one kiln home: --config <file>",
31
37
  "if given, otherwise $KILN_HOME/kiln.config.json when KILN_HOME is set,",
32
38
  "otherwise ~/.kiln/kiln.config.json. repo add creates that directory.",
33
39
  "Run kiln repo add <name> <path> from any directory. A leftover",
@@ -64,8 +70,20 @@ function usage() {
64
70
  "or a process that is already gone when tick or serve starts (orphaned) retries",
65
71
  "after 10s, 20s, 40s, and so on, capped at 5 minutes, up to maxAttempts",
66
72
  "A rejected Claude login (401 or an invalid bearer token) is auth-failed and is not retried.",
67
- "(default 3). The job is then dead. A rejected gate is not retried.",
73
+ "(default 3, overridable per repo). Hitting the cap is a budget failure and the",
74
+ "job needs a person. The same failure fingerprint stops the job after",
75
+ "maxSameFingerprint (default 3). A test, build, or type error is not retried.",
76
+ "A rejected gate is not retried. revise sends the job around again with the",
77
+ "note in the next prompt. pause stops new runs until resume. In-flight runs",
78
+ "finish. A daily dollar cap, when set, does the same once today's recorded",
79
+ "spend reaches it. ledger prints that spend.",
68
80
  "Attempts are files under the kiln home, not fields on the job.",
81
+ "Events are one JSON line each in <kiln-home>/events.jsonl. A write",
82
+ "failure is reported and does not stop the job. kiln ledger counts",
83
+ "human interventions from that log: a rejected gate, a manual retry,",
84
+ "an edit noted on a gate, and a needs-human stop. --since 7d (or 12h,",
85
+ "30m, 45s, or an ISO time) keeps events at or after that time. --repo",
86
+ "limits the count to one registered repo name.",
69
87
  "Once a pull request is open, tick watches it. A failed check or a review",
70
88
  "that requests changes starts a fix on that same branch, up to maxFixRounds",
71
89
  "(default 2). Kiln does not merge, approve, or comment. Past that bound the",
@@ -219,6 +237,20 @@ function parseFlags(argv) {
219
237
  bag.noTick = true;
220
238
  continue;
221
239
  }
240
+ if (flag === "--note") {
241
+ if (!next || next.startsWith("--"))
242
+ usage();
243
+ bag.note = next;
244
+ i += 1;
245
+ continue;
246
+ }
247
+ if (flag === "--since") {
248
+ if (!next || next.startsWith("--"))
249
+ usage();
250
+ bag.since = next;
251
+ i += 1;
252
+ continue;
253
+ }
222
254
  if (flag?.startsWith("--"))
223
255
  usage();
224
256
  if (flag !== undefined)
@@ -226,9 +258,11 @@ function parseFlags(argv) {
226
258
  }
227
259
  return bag;
228
260
  }
229
- function rejectServeFlags(flags) {
261
+ function rejectServeFlags(flags, options = {}) {
230
262
  if (flags.port !== undefined || flags.tickSeconds !== undefined || flags.noTick)
231
263
  usage();
264
+ if (options.since !== false && flags.since)
265
+ usage();
232
266
  }
233
267
  function repoCommand(argv) {
234
268
  const sub = argv[0];
@@ -363,7 +397,7 @@ function sandboxCommand(argv) {
363
397
  function tickCommand(argv) {
364
398
  const flags = parseFlags(argv);
365
399
  rejectServeFlags(flags);
366
- if (flags.positionals.length !== 0 || flags.test || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
400
+ if (flags.positionals.length !== 0 || flags.test || flags.note || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
367
401
  usage();
368
402
  }
369
403
  const config = resolveKilnConfig({ configPath: flags.configPath });
@@ -372,7 +406,7 @@ function tickCommand(argv) {
372
406
  function statusCommand(argv) {
373
407
  const flags = parseFlags(argv);
374
408
  rejectServeFlags(flags);
375
- if (flags.positionals.length > 1 || flags.test || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
409
+ if (flags.positionals.length > 1 || flags.test || flags.note || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
376
410
  usage();
377
411
  }
378
412
  const config = resolveKilnConfig({ configPath: flags.configPath });
@@ -382,17 +416,71 @@ function retryCommand(argv) {
382
416
  const flags = parseFlags(argv);
383
417
  rejectServeFlags(flags);
384
418
  const jobId = flags.positionals[0];
385
- if (!jobId || flags.positionals.length !== 1 || flags.test || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
419
+ if (!jobId || flags.positionals.length !== 1 || flags.test || flags.note || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
386
420
  usage();
387
421
  }
388
422
  const config = resolveKilnConfig({ configPath: flags.configPath });
389
423
  process.stdout.write(retryJob(config, jobId));
390
424
  }
425
+ function ledgerCommand(argv) {
426
+ const flags = parseFlags(argv);
427
+ rejectServeFlags(flags, { since: false });
428
+ if (flags.positionals.length !== 0 ||
429
+ flags.test ||
430
+ flags.spec ||
431
+ flags.prompt ||
432
+ flags.jira ||
433
+ flags.title ||
434
+ flags.issue ||
435
+ flags.workflow ||
436
+ flags.context ||
437
+ flags.dryRun ||
438
+ flags.stack ||
439
+ flags.after.length > 0 ||
440
+ flags.deliver ||
441
+ flags.base) {
442
+ usage();
443
+ }
444
+ const config = resolveKilnConfig({ configPath: flags.configPath });
445
+ const since = flags.since ? parseSince(flags.since) : undefined;
446
+ process.stdout.write(renderLedgerFile(config.kilnDir, {
447
+ ...(since ? { since } : {}),
448
+ ...(flags.repo ? { repo: flags.repo } : {}),
449
+ ...(config.dailyDollarCap !== undefined ? { dailyDollarCap: config.dailyDollarCap } : {}),
450
+ }));
451
+ }
452
+ function reviseCommand(argv) {
453
+ const flags = parseFlags(argv);
454
+ rejectServeFlags(flags);
455
+ const jobId = flags.positionals[0];
456
+ if (!jobId ||
457
+ flags.positionals.length !== 1 ||
458
+ !flags.note ||
459
+ flags.test ||
460
+ hasIntakeFlags(flags) ||
461
+ flags.deliver ||
462
+ flags.base ||
463
+ flags.stack) {
464
+ usage();
465
+ }
466
+ const config = resolveKilnConfig({ configPath: flags.configPath });
467
+ process.stdout.write(reviseJob(config, jobId, flags.note));
468
+ }
469
+ function pauseCommand(argv, pause) {
470
+ const flags = parseFlags(argv);
471
+ rejectServeFlags(flags);
472
+ if (flags.positionals.length !== 0 || flags.test || hasIntakeFlags(flags) || flags.note || flags.deliver || flags.base || flags.stack) {
473
+ usage();
474
+ }
475
+ const config = resolveKilnConfig({ configPath: flags.configPath });
476
+ const changed = pause ? pauseFactory(config.kilnDir) : resumeFactory(config.kilnDir);
477
+ process.stdout.write(pause ? (changed ? "paused\n" : "already paused\n") : changed ? "resumed\n" : "already running\n");
478
+ }
391
479
  function gateCommand(choice, argv) {
392
480
  const flags = parseFlags(argv);
393
481
  rejectServeFlags(flags);
394
482
  const jobId = flags.positionals[0];
395
- if (!jobId || flags.positionals.length !== 1 || flags.test || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
483
+ if (!jobId || flags.positionals.length !== 1 || flags.test || flags.note || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
396
484
  usage();
397
485
  }
398
486
  const config = resolveKilnConfig({ configPath: flags.configPath });
@@ -468,6 +556,22 @@ async function main() {
468
556
  retryCommand(argv.slice(1));
469
557
  return;
470
558
  }
559
+ if (command === "revise") {
560
+ reviseCommand(argv.slice(1));
561
+ return;
562
+ }
563
+ if (command === "pause") {
564
+ pauseCommand(argv.slice(1), true);
565
+ return;
566
+ }
567
+ if (command === "resume") {
568
+ pauseCommand(argv.slice(1), false);
569
+ return;
570
+ }
571
+ if (command === "ledger") {
572
+ ledgerCommand(argv.slice(1));
573
+ return;
574
+ }
471
575
  if (command === "sandbox") {
472
576
  sandboxCommand(argv.slice(1));
473
577
  return;
package/dist/config.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "no
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { KilnConfigError } from "./errors.js";
5
- import { DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_FIX_ROUNDS, DEFAULT_STALL_TIMEOUT_MS, KilnConfigFileSchema, REPO_NAME, } from "./schemas.js";
5
+ import { DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_FIX_ROUNDS, DEFAULT_MAX_SAME_FINGERPRINT, DEFAULT_STALL_TIMEOUT_MS, KilnConfigFileSchema, REPO_NAME, } from "./schemas.js";
6
6
  const CONFIG_NAME = "kiln.config.json";
7
7
  export function loadKilnConfig(configPath, options = {}) {
8
8
  const home = options.home ?? homedir();
@@ -31,6 +31,8 @@ export function loadKilnConfig(configPath, options = {}) {
31
31
  ...(entry.base ? { base: entry.base } : {}),
32
32
  ...(entry.prTitle ? { prTitle: entry.prTitle } : {}),
33
33
  ...(entry.branch ? { branchTemplate: entry.branch } : {}),
34
+ ...(entry.maxAttempts !== undefined ? { maxAttempts: entry.maxAttempts } : {}),
35
+ ...(entry.maxFixRounds !== undefined ? { maxFixRounds: entry.maxFixRounds } : {}),
34
36
  };
35
37
  }
36
38
  const kilnDir = configDir;
@@ -46,6 +48,8 @@ export function loadKilnConfig(configPath, options = {}) {
46
48
  stallTimeoutMs: parsed.data.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS,
47
49
  maxAttempts: parsed.data.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
48
50
  maxFixRounds: parsed.data.maxFixRounds ?? DEFAULT_MAX_FIX_ROUNDS,
51
+ maxSameFingerprint: parsed.data.maxSameFingerprint ?? DEFAULT_MAX_SAME_FINGERPRINT,
52
+ ...(parsed.data.dailyDollarCap !== undefined ? { dailyDollarCap: parsed.data.dailyDollarCap } : {}),
49
53
  adapter: parsed.data.adapter ?? "claude-code",
50
54
  sandbox: parsed.data.sandbox ?? {},
51
55
  workflowSandbox: workflowSandboxFrom(parsed.data.workflows),
@@ -112,6 +116,12 @@ export function addRepo(options) {
112
116
  const branchTemplate = previousString(previous, "branch");
113
117
  if (branchTemplate)
114
118
  entry.branch = branchTemplate;
119
+ const maxAttempts = previousNumber(previous, "maxAttempts");
120
+ if (maxAttempts !== undefined)
121
+ entry.maxAttempts = maxAttempts;
122
+ const maxFixRounds = previousNumber(previous, "maxFixRounds");
123
+ if (maxFixRounds !== undefined)
124
+ entry.maxFixRounds = maxFixRounds;
115
125
  repos[options.name] = entry;
116
126
  raw.repos = repos;
117
127
  const parsed = KilnConfigFileSchema.safeParse(raw);
@@ -138,6 +148,14 @@ export function addRepo(options) {
138
148
  lines.push(`wrote ${configPath}`);
139
149
  return `${lines.join("\n")}\n`;
140
150
  }
151
+ /** Attempt cap for one repo: the repo override, otherwise the global cap. */
152
+ export function attemptsFor(config, repo) {
153
+ return config.repos[repo]?.maxAttempts ?? config.maxAttempts;
154
+ }
155
+ /** Fix-round cap for one repo: the repo override, otherwise the global cap. */
156
+ export function fixRoundsFor(config, repo) {
157
+ return config.repos[repo]?.maxFixRounds ?? config.maxFixRounds;
158
+ }
141
159
  export function renderRepoList(config) {
142
160
  const names = Object.keys(config.repos).sort();
143
161
  if (names.length === 0)
@@ -226,6 +244,12 @@ function previousString(previous, key) {
226
244
  const value = previous[key];
227
245
  return typeof value === "string" && value.length > 0 ? value : undefined;
228
246
  }
247
+ function previousNumber(previous, key) {
248
+ if (typeof previous !== "object" || previous === null || !(key in previous))
249
+ return undefined;
250
+ const value = previous[key];
251
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
252
+ }
229
253
  function previousDeliver(previous) {
230
254
  const value = previousString(previous, "deliver");
231
255
  if (value === "local" || value === "pr")
package/dist/delivery.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
+ import { evidenceSectionFor } from "./evidence.js";
5
+ import { appendEvent } from "./events.js";
4
6
  import { HostError } from "./host.js";
5
7
  import { readJobSpec } from "./jobs.js";
6
8
  import { jobBasename } from "./paths.js";
@@ -181,6 +183,20 @@ export function deliverJob(request) {
181
183
  prNumber: pr.number,
182
184
  prUrl: pr.url,
183
185
  });
186
+ if (!sameRun?.prNumber && remoteName) {
187
+ appendEvent(request.config.kilnDir, {
188
+ job_id: request.job.id,
189
+ run_id: request.runId,
190
+ event: "pr.opened",
191
+ properties: {
192
+ number: pr.number,
193
+ url: pr.url,
194
+ branch: remoteName,
195
+ base: request.baseBranch,
196
+ once: `pr.opened:${pr.number}`,
197
+ },
198
+ });
199
+ }
184
200
  return { ok: true, detail: pr.url, prUrl: pr.url, prNumber: pr.number };
185
201
  }
186
202
  catch (error) {
@@ -213,6 +229,8 @@ export function pullRequestBody(request) {
213
229
  "## Reviewers",
214
230
  ...(reviews.length > 0 ? reviews : ["- (no reviewer artifacts)"]),
215
231
  "",
232
+ evidenceSectionFor(request.config.kilnDir, request.job.id).trimEnd(),
233
+ "",
216
234
  ].join("\n");
217
235
  }
218
236
  export function specSummary(specText) {