@lchase/kiln 0.9.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,13 +271,13 @@ 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` 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.
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
281
 
282
282
  State is derived from the yak journal, the gate files, and (for `deliver=pr`) `gh pr view`:
283
283
 
@@ -286,11 +286,11 @@ State is derived from the yak journal, the gate files, and (for `deliver=pr`) `g
286
286
  | `queued` | Dependencies are met, and the job has not started |
287
287
  | `blocked` | An after-merged dependency is not `done`, or a stacked dependency has not landed. A missing job id is named |
288
288
  | `running` | Yak is alive, or the launch process is alive and has not written a journal |
289
- | `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 |
290
290
  | `approved` | The gate says approve and the run has not finished. This still holds a slot |
291
291
  | `rejected` | The run finished ok and the gate says reject |
292
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 |
293
- | `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 |
294
294
  | `in-review` | `deliver=pr` and the run has landed. The pull request is open, or the next tick still has to open it |
295
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` |
296
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,8 @@
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";
5
6
  import { parseSince, renderLedgerFile } from "./ledger.js";
6
7
  import { startServe } from "./serve.js";
7
8
  import { addFromFile } from "./intake/file.js";
@@ -19,7 +20,10 @@ function usage() {
19
20
  " kiln status [jobId] [--config <file>]",
20
21
  " kiln approve <jobId> [--config <file>]",
21
22
  " kiln reject <jobId> [--config <file>]",
23
+ " kiln revise <jobId> --note <text> [--config <file>]",
22
24
  " kiln retry <jobId> [--config <file>]",
25
+ " kiln pause [--config <file>]",
26
+ " kiln resume [--config <file>]",
23
27
  " kiln ledger [--since 7d] [--repo <name>] [--config <file>]",
24
28
  " kiln sandbox [build] [--config <file>]",
25
29
  " kiln serve [--port 4100] [--tick <seconds>|--no-tick] [--config <file>]",
@@ -66,7 +70,13 @@ function usage() {
66
70
  "or a process that is already gone when tick or serve starts (orphaned) retries",
67
71
  "after 10s, 20s, 40s, and so on, capped at 5 minutes, up to maxAttempts",
68
72
  "A rejected Claude login (401 or an invalid bearer token) is auth-failed and is not retried.",
69
- "(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.",
70
80
  "Attempts are files under the kiln home, not fields on the job.",
71
81
  "Events are one JSON line each in <kiln-home>/events.jsonl. A write",
72
82
  "failure is reported and does not stop the job. kiln ledger counts",
@@ -227,6 +237,13 @@ function parseFlags(argv) {
227
237
  bag.noTick = true;
228
238
  continue;
229
239
  }
240
+ if (flag === "--note") {
241
+ if (!next || next.startsWith("--"))
242
+ usage();
243
+ bag.note = next;
244
+ i += 1;
245
+ continue;
246
+ }
230
247
  if (flag === "--since") {
231
248
  if (!next || next.startsWith("--"))
232
249
  usage();
@@ -380,7 +397,7 @@ function sandboxCommand(argv) {
380
397
  function tickCommand(argv) {
381
398
  const flags = parseFlags(argv);
382
399
  rejectServeFlags(flags);
383
- 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) {
384
401
  usage();
385
402
  }
386
403
  const config = resolveKilnConfig({ configPath: flags.configPath });
@@ -389,7 +406,7 @@ function tickCommand(argv) {
389
406
  function statusCommand(argv) {
390
407
  const flags = parseFlags(argv);
391
408
  rejectServeFlags(flags);
392
- 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) {
393
410
  usage();
394
411
  }
395
412
  const config = resolveKilnConfig({ configPath: flags.configPath });
@@ -399,7 +416,7 @@ function retryCommand(argv) {
399
416
  const flags = parseFlags(argv);
400
417
  rejectServeFlags(flags);
401
418
  const jobId = flags.positionals[0];
402
- 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) {
403
420
  usage();
404
421
  }
405
422
  const config = resolveKilnConfig({ configPath: flags.configPath });
@@ -429,13 +446,41 @@ function ledgerCommand(argv) {
429
446
  process.stdout.write(renderLedgerFile(config.kilnDir, {
430
447
  ...(since ? { since } : {}),
431
448
  ...(flags.repo ? { repo: flags.repo } : {}),
449
+ ...(config.dailyDollarCap !== undefined ? { dailyDollarCap: config.dailyDollarCap } : {}),
432
450
  }));
433
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
+ }
434
479
  function gateCommand(choice, argv) {
435
480
  const flags = parseFlags(argv);
436
481
  rejectServeFlags(flags);
437
482
  const jobId = flags.positionals[0];
438
- 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) {
439
484
  usage();
440
485
  }
441
486
  const config = resolveKilnConfig({ configPath: flags.configPath });
@@ -511,6 +556,18 @@ async function main() {
511
556
  retryCommand(argv.slice(1));
512
557
  return;
513
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
+ }
514
571
  if (command === "ledger") {
515
572
  ledgerCommand(argv.slice(1));
516
573
  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")