@lchase/kiln 0.6.0 → 0.7.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/attention.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { INVESTIGATION_WORKFLOW } from "./findings.js";
3
+ import { AUTH_FAILED_LINE } from "./auth.js";
3
4
  import { SANDBOX_PREFLIGHT_PREFIX } from "./sandbox.js";
4
5
  /**
5
6
  * Attention order for `kiln serve`. Built from the same derived jobs as
@@ -104,8 +105,12 @@ export function attentionReason(job, tail = errorTail(job)) {
104
105
  return question ? `Gate: ${oneLine(question)}` : "Waiting on a person";
105
106
  }
106
107
  case "dead":
108
+ if (job.lastFailureReason === "auth-failed")
109
+ return AUTH_FAILED_LINE;
107
110
  return job.sandboxError ? `Sandbox: ${oneLine(job.sandboxError)}` : "Run died before it finished";
108
111
  case "failed": {
112
+ if (job.lastFailureReason === "auth-failed")
113
+ return AUTH_FAILED_LINE;
109
114
  if (job.sandboxError)
110
115
  return `Sandbox: ${oneLine(job.sandboxError)}`;
111
116
  const why = failureLine(job.launchError) || firstLine(job.deliveryError) || firstLine(job.failureDetail) || firstLine(tail) || "Run failed";
package/dist/auth.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Claude credential shape, checked before tick or serve launches yak.
3
+ * A missing token is allowed: Claude Code can use a macOS keychain login.
4
+ * A set-but-unusable value is not, because Claude prefers it and then 401s.
5
+ * The text names the variable and never includes the value.
6
+ */
7
+ export const AUTH_PREFLIGHT_PREFIX = "Claude credentials are not usable:";
8
+ /** Printed on an authentication failure. The attempt is not retried. */
9
+ export const AUTH_FAILED_LINE = "Claude login rejected (401) — check CLAUDE_CODE_OAUTH_TOKEN / claude login, then Retry";
10
+ const OAUTH_PREFIX = "sk-ant-oat";
11
+ export function claudeCredentialPreflight(env, adapter) {
12
+ if (adapter !== "claude-code")
13
+ return undefined;
14
+ const lines = [];
15
+ if (Object.prototype.hasOwnProperty.call(env, "CLAUDE_CODE_OAUTH_TOKEN")) {
16
+ const value = (env.CLAUDE_CODE_OAUTH_TOKEN ?? "").trim();
17
+ if (!value) {
18
+ lines.push("CLAUDE_CODE_OAUTH_TOKEN is set but empty. Unset it, or export a token from `claude setup-token` (it starts with sk-ant-oat). Kiln will not launch.");
19
+ }
20
+ else if (!value.startsWith(OAUTH_PREFIX)) {
21
+ lines.push("CLAUDE_CODE_OAUTH_TOKEN is set but does not look like a Claude OAuth token (expected a value starting with sk-ant-oat). Kiln will not launch.");
22
+ }
23
+ }
24
+ if (Object.prototype.hasOwnProperty.call(env, "ANTHROPIC_API_KEY")) {
25
+ const value = (env.ANTHROPIC_API_KEY ?? "").trim();
26
+ if (!value) {
27
+ lines.push("ANTHROPIC_API_KEY is set but empty. Unset it, or export a key. Kiln will not launch.");
28
+ }
29
+ }
30
+ if (lines.length === 0)
31
+ return undefined;
32
+ return `${AUTH_PREFLIGHT_PREFIX}\n${lines.join("\n")}`;
33
+ }
34
+ /**
35
+ * True when yak's failure detail or the run log shows a rejected Claude login.
36
+ * `401`, `Invalid bearer token`, and `Failed to authenticate` are the strings
37
+ * the Claude CLI and the API put on stderr. Yak copies that stderr tail into
38
+ * the `step.failed` detail and prints the same line on its own stderr, which
39
+ * Kiln stores under `<kiln-dir>/logs`.
40
+ */
41
+ export function authenticationFailureLine(texts) {
42
+ const haystack = texts.join("\n");
43
+ if (!haystack)
44
+ return undefined;
45
+ if (/Invalid bearer token|Failed to authenticate|\b401\b/.test(haystack))
46
+ return AUTH_FAILED_LINE;
47
+ return undefined;
48
+ }
package/dist/board.js CHANGED
@@ -11,7 +11,8 @@ const CATALOG = [
11
11
  { id: "explore", label: "Explore", who: "investigator · opus" },
12
12
  { id: "land", label: "Land", who: "git commit" },
13
13
  { id: "pr-review", label: "PR review", who: "pr-sync" },
14
- { id: "merged", label: "Merged", who: "main", tone: "merged" },
14
+ { id: "done", label: "Done", who: "local", tone: "merged" },
15
+ { id: "merged", label: "Merged", who: "pr", tone: "merged" },
15
16
  { id: "needs-human", label: "Needs human", who: "approve · retry", tone: "human" },
16
17
  ];
17
18
  const STEP_STAGE = {
@@ -41,7 +42,7 @@ export function stageOf(job) {
41
42
  if (job.state === "in-review")
42
43
  return "pr-review";
43
44
  if (job.state === "done")
44
- return "merged";
45
+ return job.prUrl || job.prNumber ? "merged" : "done";
45
46
  const current = job.steps.find((step) => step.phase === "current" || step.phase === "failed");
46
47
  if (job.state === "queued" || job.state === "blocked")
47
48
  return "queued";
@@ -86,10 +87,14 @@ export function stagesFor(jobs) {
86
87
  if (workflows.has("investigation"))
87
88
  wanted.add("explore");
88
89
  wanted.add("queued");
89
- wanted.add("merged");
90
90
  wanted.add("needs-human");
91
- for (const job of jobs)
92
- wanted.add(stageOf(job));
91
+ const reached = new Set(jobs.map((job) => stageOf(job)));
92
+ if (reached.has("done") || !reached.has("merged"))
93
+ wanted.add("done");
94
+ if (reached.has("merged"))
95
+ wanted.add("merged");
96
+ for (const id of reached)
97
+ wanted.add(id);
93
98
  return CATALOG.filter((stage) => wanted.has(stage.id));
94
99
  }
95
100
  const REVIEW_FILE = /^(security|ux|ui|code)(?:\.(\d+))?\.json$/;
package/dist/cli.js CHANGED
@@ -12,7 +12,7 @@ function usage() {
12
12
  console.error([
13
13
  "Usage: kiln repo add <name> <path> [--test <cmd>] [--deliver local|pr] [--base <branch>] [--config <file>]",
14
14
  " kiln repo list [--config <file>]",
15
- " kiln add <workflow> --repo <name> (--spec <file.md> | --prompt <text> | --jira <KEY>) [--title <text>] [--after <jobId>]... [--stack] [--dry-run] [--config <file>]",
15
+ " kiln add <workflow> --repo <name> (--spec <file.md> | --prompt <text> | --jira <KEY>) [--title <text>] [--issue <KEY>] [--after <jobId>]... [--stack] [--dry-run] [--config <file>]",
16
16
  " kiln import <dir> --repo <name> [--workflow <name>] [--context <file.md>] [--stack] [--dry-run] [--config <file>]",
17
17
  " kiln tick [--config <file>]",
18
18
  " kiln status [jobId] [--config <file>]",
@@ -22,8 +22,10 @@ function usage() {
22
22
  " kiln sandbox [build] [--config <file>]",
23
23
  " kiln serve [--port 4100] [--tick <seconds>|--no-tick] [--config <file>]",
24
24
  "",
25
- "repo add registers a checkout. A relative path is stored absolute, resolved",
26
- "from the working directory. ~ is expanded from the home directory.",
25
+ "repo add registers a checkout and prints the deliver mode (default local)",
26
+ "and how to change it. Adding an existing name updates that repo in place.",
27
+ "A relative path is stored absolute, resolved from the working directory.",
28
+ "~ is expanded from the home directory.",
27
29
  "",
28
30
  "Config, jobs, logs, and deliveries live in one kiln home: --config <file>",
29
31
  "if given, otherwise $KILN_HOME/kiln.config.json when KILN_HOME is set,",
@@ -34,6 +36,8 @@ function usage() {
34
36
  "",
35
37
  "add writes one job from a spec file, from --prompt text (--prompt - reads stdin),",
36
38
  "or from a Jira Cloud issue (--jira PROJ-123). Pass one of those, not more.",
39
+ "--issue KEY stores an issue id (KEY-123). With deliver=pr the draft title",
40
+ "defaults to [KEY] title. A job with no issue keeps its title.",
37
41
  "The repo and the workflow (workflows/<name>.yaml) must already exist.",
38
42
  "--after names jobs that must be done before this one launches. --stack waits",
39
43
  "for one upstream to land and opens the pull request against that branch.",
@@ -59,6 +63,7 @@ function usage() {
59
63
  "15 minutes; 0 disables it) is killed and retried. A crash, a non-zero exit,",
60
64
  "or a process that is already gone when tick or serve starts (orphaned) retries",
61
65
  "after 10s, 20s, 40s, and so on, capped at 5 minutes, up to maxAttempts",
66
+ "A rejected Claude login (401 or an invalid bearer token) is auth-failed and is not retried.",
62
67
  "(default 3). The job is then dead. A rejected gate is not retried.",
63
68
  "Attempts are files under the kiln home, not fields on the job.",
64
69
  "Once a pull request is open, tick watches it. A failed check or a review",
@@ -67,10 +72,17 @@ function usage() {
67
72
  "job is needs-human with reason feedback-exhausted.",
68
73
  "",
69
74
  "serve listens on 127.0.0.1. The page is a board of workflow stages:",
70
- "queued, spec, build, review, approve, land, PR review, merged, and needs human.",
71
- "Empty stages collapse. Approve, reject, and retry call the same",
72
- "functions as the CLI. POST from another origin is rejected. A tick",
73
- "runs every 30 seconds unless --no-tick is set, using the tick lock.",
75
+ "queued, spec, build, review, approve, land, PR review, Done (landed locally),",
76
+ "Merged (pull request), and needs human. An empty board shows Done.",
77
+ "Empty stages collapse. Approve, reject, retry, and Add job call the same",
78
+ "functions as the CLI. Settings edits repos and the global limits in the",
79
+ "config file and writes it without a restart. POST and PUT send the page's",
80
+ "CSRF token. A request whose",
81
+ "Origin or Host is not this localhost port is rejected. Before each",
82
+ "request and each tick, serve re-reads the kiln config when the file's",
83
+ "mtime or size changed. A file that fails to load keeps the last good",
84
+ "config. A tick runs every 30 seconds unless --no-tick is set, using the",
85
+ "tick lock.",
74
86
  "",
75
87
  "sandbox prints the resolved mode, allowlist, and anything missing before a",
76
88
  "sandboxed run can start. sandbox build builds the yak agent and proxy images",
@@ -151,6 +163,13 @@ function parseFlags(argv) {
151
163
  i += 1;
152
164
  continue;
153
165
  }
166
+ if (flag === "--issue") {
167
+ if (!next || next.startsWith("--"))
168
+ usage();
169
+ bag.issue = next;
170
+ i += 1;
171
+ continue;
172
+ }
154
173
  if (flag === "--after") {
155
174
  if (!next || next.startsWith("--"))
156
175
  usage();
@@ -263,6 +282,7 @@ async function addCommand(argv) {
263
282
  repo: flags.repo,
264
283
  key: flags.jira,
265
284
  ...(flags.title !== undefined ? { title: flags.title } : {}),
285
+ ...(flags.issue !== undefined ? { issue: flags.issue } : {}),
266
286
  dependsOn: flags.after,
267
287
  ...(flags.stack ? { stack: true } : {}),
268
288
  dryRun: flags.dryRun,
@@ -278,6 +298,7 @@ async function addCommand(argv) {
278
298
  repo: flags.repo,
279
299
  specPath: flags.spec,
280
300
  ...(flags.title !== undefined ? { title: flags.title } : {}),
301
+ ...(flags.issue !== undefined ? { issue: flags.issue } : {}),
281
302
  dependsOn: flags.after,
282
303
  ...(flags.stack ? { stack: true } : {}),
283
304
  cwd: process.cwd(),
@@ -287,6 +308,7 @@ async function addCommand(argv) {
287
308
  repo: flags.repo,
288
309
  prompt: flags.prompt ?? "",
289
310
  ...(flags.title !== undefined ? { title: flags.title } : {}),
311
+ ...(flags.issue !== undefined ? { issue: flags.issue } : {}),
290
312
  dependsOn: flags.after,
291
313
  ...(flags.stack ? { stack: true } : {}),
292
314
  });
@@ -302,6 +324,7 @@ function importCommand(argv) {
302
324
  flags.spec ||
303
325
  flags.prompt ||
304
326
  flags.title ||
327
+ flags.issue ||
305
328
  flags.after.length > 0 ||
306
329
  flags.test ||
307
330
  flags.deliver ||
@@ -401,6 +424,7 @@ function hasIntakeFlags(flags) {
401
424
  flags.prompt ||
402
425
  flags.jira ||
403
426
  flags.title ||
427
+ flags.issue ||
404
428
  flags.workflow ||
405
429
  flags.context ||
406
430
  flags.dryRun ||
package/dist/config.js CHANGED
@@ -2,9 +2,8 @@ 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, } from "./schemas.js";
5
+ import { DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_FIX_ROUNDS, DEFAULT_STALL_TIMEOUT_MS, KilnConfigFileSchema, REPO_NAME, } from "./schemas.js";
6
6
  const CONFIG_NAME = "kiln.config.json";
7
- const REPO_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
8
7
  export function loadKilnConfig(configPath, options = {}) {
9
8
  const home = options.home ?? homedir();
10
9
  const resolvedPath = resolve(configPath);
@@ -24,15 +23,14 @@ export function loadKilnConfig(configPath, options = {}) {
24
23
  const repos = {};
25
24
  for (const [name, entry] of Object.entries(parsed.data.repos)) {
26
25
  assertRepoName(name);
27
- const checkout = resolveConfiguredPath(configDir, entry.path, home);
28
- if (!existsSync(checkout) || !statSync(checkout).isDirectory()) {
29
- throw new KilnConfigError(`Checkout for ${name} does not exist or is not a directory: ${checkout}. Set repos.${name}.path in the kiln config.`);
30
- }
26
+ const checkout = assertCheckoutDirectory(name, configDir, entry.path, home);
31
27
  repos[name] = {
32
28
  path: checkout,
33
29
  ...(entry.test ? { test: entry.test } : {}),
34
30
  deliver: entry.deliver ?? "local",
35
31
  ...(entry.base ? { base: entry.base } : {}),
32
+ ...(entry.prTitle ? { prTitle: entry.prTitle } : {}),
33
+ ...(entry.branch ? { branchTemplate: entry.branch } : {}),
36
34
  };
37
35
  }
38
36
  const kilnDir = configDir;
@@ -108,6 +106,12 @@ export function addRepo(options) {
108
106
  const base = options.base ?? previousString(previous, "base");
109
107
  if (base)
110
108
  entry.base = base;
109
+ const prTitle = previousString(previous, "prTitle");
110
+ if (prTitle)
111
+ entry.prTitle = prTitle;
112
+ const branchTemplate = previousString(previous, "branch");
113
+ if (branchTemplate)
114
+ entry.branch = branchTemplate;
111
115
  repos[options.name] = entry;
112
116
  raw.repos = repos;
113
117
  const parsed = KilnConfigFileSchema.safeParse(raw);
@@ -116,13 +120,21 @@ export function addRepo(options) {
116
120
  }
117
121
  mkdirSync(configDir, { recursive: true });
118
122
  writeFileSync(configPath, `${JSON.stringify(raw, null, 2)}\n`);
123
+ const effectiveDeliver = options.deliver ?? previousDeliver(previous) ?? "local";
119
124
  const lines = [`added ${options.name}`, `path ${checkout}`];
120
125
  if (entry.test)
121
126
  lines.push(`test ${entry.test}`);
122
- if (entry.deliver)
123
- lines.push(`deliver ${entry.deliver}`);
127
+ lines.push(`deliver ${effectiveDeliver}`);
124
128
  if (entry.base)
125
129
  lines.push(`base ${entry.base}`);
130
+ if (entry.prTitle)
131
+ lines.push(`prTitle ${entry.prTitle}`);
132
+ if (entry.branch)
133
+ lines.push(`branch ${entry.branch}`);
134
+ const change = effectiveDeliver === "pr"
135
+ ? `kiln repo add ${options.name} ${options.repoPath} --deliver local`
136
+ : `kiln repo add ${options.name} ${options.repoPath} --deliver pr --base main`;
137
+ lines.push(`Change delivery with: ${change}`);
126
138
  lines.push(`wrote ${configPath}`);
127
139
  return `${lines.join("\n")}\n`;
128
140
  }
@@ -137,10 +149,13 @@ export function renderRepoList(config) {
137
149
  const bits = [name, repo.path];
138
150
  if (repo.test)
139
151
  bits.push(`test ${repo.test}`);
140
- if (repo.deliver === "pr")
141
- bits.push("deliver pr");
152
+ bits.push(`deliver ${repo.deliver}`);
142
153
  if (repo.base)
143
154
  bits.push(`base ${repo.base}`);
155
+ if (repo.prTitle)
156
+ bits.push(`prTitle ${repo.prTitle}`);
157
+ if (repo.branchTemplate)
158
+ bits.push(`branch ${repo.branchTemplate}`);
144
159
  return bits.join(" ");
145
160
  });
146
161
  return `${lines.join("\n")}\n`;
@@ -230,6 +245,14 @@ export function resolveConfiguredPath(configDir, value, home) {
230
245
  const expanded = expandHome(value, home);
231
246
  return isAbsolute(expanded) ? expanded : resolve(configDir, expanded);
232
247
  }
248
+ /** Resolved checkout. The loader and the settings API both refuse a missing directory. */
249
+ export function assertCheckoutDirectory(name, configDir, value, home) {
250
+ const checkout = resolveConfiguredPath(configDir, value, home);
251
+ if (!existsSync(checkout) || !statSync(checkout).isDirectory()) {
252
+ throw new KilnConfigError(`Checkout for ${name} does not exist or is not a directory: ${checkout}. Set repos.${name}.path in the kiln config.`);
253
+ }
254
+ return checkout;
255
+ }
233
256
  function pathToStore(userPath, cwd, home) {
234
257
  if (userPath === "~" || userPath.startsWith("~/"))
235
258
  return userPath;
package/dist/delivery.js CHANGED
@@ -4,6 +4,7 @@ import { z } from "zod";
4
4
  import { HostError } from "./host.js";
5
5
  import { readJobSpec } from "./jobs.js";
6
6
  import { jobBasename } from "./paths.js";
7
+ import { chooseRemoteBranch, defaultRemoteBranch, formatBranchTemplate, isPublishableBranch, pullRequestTitle } from "./pr-names.js";
7
8
  /**
8
9
  * Derived per run, under `<kilnDir>/deliveries/<jobId>.json`. Not the job file.
9
10
  * `pushed` is true once origin has this branch at `sha`. Re-tick uses it, and
@@ -69,7 +70,8 @@ export function clearLaunchError(kilnDir, jobId) {
69
70
  *
70
71
  * `deliver=local` without `--stack` is checkout HEAD.
71
72
  * `deliver=pr` fetches and uses `origin/<base>` (configured, or origin's default).
72
- * A stacked job uses the upstream `yak/<run-id>` tip in this checkout.
73
+ * A stacked job uses the local upstream `yak/<run-id>` tip. The caller passes
74
+ * that ref, not the remote pull request head.
73
75
  */
74
76
  export function resolveLaunchBase(input) {
75
77
  if (input.stack) {
@@ -86,36 +88,72 @@ export function resolveLaunchBase(input) {
86
88
  return input.git.revParse(input.checkout, "HEAD");
87
89
  }
88
90
  /**
89
- * Push `yak/<run-id>` and open one draft pull request. A later tick looks up
90
- * the branch's pull request and skips both when the remote SHA already matches.
91
+ * Remote branch this job would publish, before a delivery record exists.
92
+ * A branch template that is not a safe ref falls back to the default name.
93
+ */
94
+ export function plannedRemoteBranch(job, template) {
95
+ const input = {
96
+ title: job.title,
97
+ jobId: job.id,
98
+ ...(job.issue ? { issue: job.issue } : {}),
99
+ };
100
+ if (template?.trim()) {
101
+ const name = formatBranchTemplate(template, input);
102
+ if (isPublishableBranch(name))
103
+ return name;
104
+ }
105
+ return defaultRemoteBranch(input);
106
+ }
107
+ /**
108
+ * Push local `yak/<run-id>` to the remote branch and open one draft pull request.
109
+ * The delivery record stores the remote name. A later tick looks that name up
110
+ * and skips both the push and `gh pr create` when origin already has this commit.
111
+ * A recorded name, including an older `yak/<run-id>` record, is reused.
91
112
  * Failures stay on this job (`HostError` text) and do not escape.
92
113
  */
93
114
  export function deliverJob(request) {
94
- const branch = `yak/${request.runId}`;
115
+ const localBranch = `yak/${request.runId}`;
95
116
  const prior = readDelivery(request.config.kilnDir, request.job.id);
96
117
  const sameRun = prior?.runId === request.runId ? prior : undefined;
118
+ const repo = request.config.repos[request.job.repo];
119
+ let remoteName = sameRun?.branch;
97
120
  try {
98
- const localSha = request.host.git.revParse(request.checkout, branch);
121
+ const localSha = request.host.git.revParse(request.checkout, localBranch);
99
122
  if (sameRun?.prNumber && sameRun.prUrl && sameRun.sha === localSha) {
100
123
  return { ok: true, detail: sameRun.prUrl, prUrl: sameRun.prUrl, prNumber: sameRun.prNumber };
101
124
  }
125
+ if (!remoteName) {
126
+ remoteName = chooseRemoteBranch({
127
+ title: request.job.title,
128
+ jobId: request.job.id,
129
+ ...(request.job.issue ? { issue: request.job.issue } : {}),
130
+ ...(repo?.branchTemplate ? { template: repo.branchTemplate } : {}),
131
+ localSha,
132
+ remoteSha: (name) => request.host.git.remoteBranchSha(request.checkout, name),
133
+ });
134
+ }
102
135
  // Ask gh before pushing so a missing or logged-out CLI does not publish the branch.
103
- let existing = request.host.github.findByHead(request.checkout, branch);
104
- const remoteSha = request.host.git.remoteBranchSha(request.checkout, branch);
136
+ let existing = request.host.github.findByHead(request.checkout, remoteName);
137
+ const remoteSha = request.host.git.remoteBranchSha(request.checkout, remoteName);
105
138
  if (remoteSha !== localSha) {
106
- request.host.git.pushBranch(request.checkout, branch, request.baseBranch);
139
+ request.host.git.publishBranch(request.checkout, localBranch, remoteName, request.baseBranch);
107
140
  }
141
+ const title = pullRequestTitle({
142
+ title: request.job.title,
143
+ ...(request.job.issue ? { issue: request.job.issue } : {}),
144
+ ...(repo?.prTitle ? { template: repo.prTitle } : {}),
145
+ });
108
146
  writeDelivery(request.config.kilnDir, {
109
147
  jobId: request.job.id,
110
148
  runId: request.runId,
111
- branch,
149
+ branch: remoteName,
112
150
  base: request.baseBranch,
113
151
  sha: localSha,
114
152
  pushed: true,
115
153
  ...(sameRun?.prNumber ? { prNumber: sameRun.prNumber, prUrl: sameRun.prUrl } : {}),
116
154
  });
117
155
  if (!existing)
118
- existing = request.host.github.findByHead(request.checkout, branch);
156
+ existing = request.host.github.findByHead(request.checkout, remoteName);
119
157
  const pr = existing ??
120
158
  (sameRun?.prNumber && sameRun.prUrl
121
159
  ? {
@@ -123,20 +161,20 @@ export function deliverJob(request) {
123
161
  url: sameRun.prUrl,
124
162
  state: "OPEN",
125
163
  mergedAt: null,
126
- head: branch,
164
+ head: remoteName,
127
165
  base: request.baseBranch,
128
166
  }
129
167
  : request.host.github.createDraft({
130
168
  checkout: request.checkout,
131
- head: branch,
169
+ head: remoteName,
132
170
  base: request.baseBranch,
133
- title: request.job.title,
171
+ title,
134
172
  body: pullRequestBody(request),
135
173
  }));
136
174
  writeDelivery(request.config.kilnDir, {
137
175
  jobId: request.job.id,
138
176
  runId: request.runId,
139
- branch,
177
+ branch: remoteName,
140
178
  base: request.baseBranch,
141
179
  sha: localSha,
142
180
  pushed: true,
@@ -152,7 +190,7 @@ export function deliverJob(request) {
152
190
  writeDelivery(request.config.kilnDir, {
153
191
  jobId: request.job.id,
154
192
  runId: request.runId,
155
- branch,
193
+ branch: remoteName ?? plannedRemoteBranch(request.job, repo?.branchTemplate),
156
194
  base: request.baseBranch,
157
195
  ...(base?.sha ? { sha: base.sha } : {}),
158
196
  pushed: base?.pushed ?? false,
package/dist/driver.js CHANGED
@@ -2,8 +2,9 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { closeSync, existsSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync, } from "node:fs";
4
4
  import { basename, dirname, isAbsolute, join } from "node:path";
5
+ import { claudeCredentialPreflight } from "./auth.js";
5
6
  import { buildAttention } from "./attention.js";
6
- import { clearLaunchError, deliverJob, readDelivery, readLaunchError, resolveLaunchBase, writeDelivery, writeLaunchError, isMerged, } from "./delivery.js";
7
+ import { clearLaunchError, deliverJob, plannedRemoteBranch, readDelivery, readLaunchError, resolveLaunchBase, writeDelivery, writeLaunchError, isMerged, } from "./delivery.js";
7
8
  import { feedbackSpec, openFeedbackRound, planFeedback, readFeedbackLedger, writeFeedbackLedger, } from "./feedback.js";
8
9
  import { appendInvestigationFindings, existingFindingsPath, findingsFilePath, INVESTIGATION_WORKFLOW, readFindingsMarkdown, writeFindings, } from "./findings.js";
9
10
  import { defaultHost } from "./host.js";
@@ -50,8 +51,11 @@ export function runTick(config, host = defaultHost(), options = {}) {
50
51
  const feedbackNotes = watchPullRequests(config, world, host);
51
52
  const actions = decide(world.observation, now);
52
53
  const body = apply(config, world, actions, host, notes, now, [...published, ...feedbackNotes], facts);
53
- const problem = facts ? sandboxPreflight(config, facts) : undefined;
54
- return problem ? `${problem}\n${body}` : body;
54
+ const heads = [
55
+ claudeCredentialPreflight(process.env, config.adapter),
56
+ facts ? sandboxPreflight(config, facts) : undefined,
57
+ ].filter((line) => Boolean(line));
58
+ return heads.length > 0 ? `${heads.join("\n")}\n${body}` : body;
55
59
  });
56
60
  }
57
61
  /** Startup pass shared by `kiln tick` (via `runTick`) and `kiln serve`. Idempotent. */
@@ -366,7 +370,7 @@ function deliverObserved(config, world, jobId, host) {
366
370
  writeDelivery(config.kilnDir, {
367
371
  jobId,
368
372
  runId: observed.run.runId,
369
- branch: `yak/${observed.run.runId}`,
373
+ branch: plannedRemoteBranch(job, repo.branchTemplate),
370
374
  base: repo.base ?? "unknown",
371
375
  pushed: false,
372
376
  error: message,
@@ -415,6 +419,7 @@ function specHandedToYak(config, world, job) {
415
419
  }
416
420
  return appendInvestigationFindings(base, sections);
417
421
  }
422
+ /** Remote name the stacked pull request targets. Falls back to the local yak ref. */
418
423
  function upstreamBranch(world, job) {
419
424
  const upstreamId = job.dependsOn?.[0];
420
425
  if (!upstreamId)
@@ -424,6 +429,16 @@ function upstreamBranch(world, job) {
424
429
  return undefined;
425
430
  return upstream.delivery?.branch ?? `yak/${upstream.run.runId}`;
426
431
  }
432
+ /** Local `yak/<run-id>` the stacked worktree is cut from. Yak owns that ref. */
433
+ function upstreamLocalBranch(world, job) {
434
+ const upstreamId = job.dependsOn?.[0];
435
+ if (!upstreamId)
436
+ return undefined;
437
+ const upstream = world.observation.jobs.find((candidate) => candidate.jobId === upstreamId);
438
+ if (!upstream?.run)
439
+ return undefined;
440
+ return `yak/${upstream.run.runId}`;
441
+ }
427
442
  function observedDelivery(kilnDir, host, checkout, jobId, runId) {
428
443
  const record = readDelivery(kilnDir, jobId);
429
444
  if (!record)
@@ -633,6 +648,9 @@ function markFeedback(kilnDir, jobId, reason) {
633
648
  * retries on the next tick without consuming a round.
634
649
  */
635
650
  function startFix(config, world, job, host, now, sandboxFacts) {
651
+ const refused = claudeCredentialPreflight(process.env, config.adapter);
652
+ if (refused)
653
+ return { jobId: job.id, repo: job.repo, kind: "fix", output: refused };
636
654
  const observed = world.observation.jobs.find((candidate) => candidate.jobId === job.id);
637
655
  const plan = observed?.feedbackPlan;
638
656
  const repo = config.repos[job.repo];
@@ -707,6 +725,9 @@ function resolveCommit(host, checkout, sha) {
707
725
  }
708
726
  /** `workflows/fix.yaml` on `headSha`. Commits on `yak/<run-id>`. Does not push. */
709
727
  function launchFixRun(config, job, host, checkout, headSha, specText, sandboxFacts) {
728
+ const refused = claudeCredentialPreflight(process.env, config.adapter);
729
+ if (refused)
730
+ return { jobId: job.id, repo: job.repo, kind: "fix", output: refused };
710
731
  const sourceWorkflow = readFileSync(join(packageRoot(), "workflows", "fix.yaml"), "utf8");
711
732
  const workflow = materializeWorkflow(config.kilnDir, job.id, "fix");
712
733
  const repo = config.repos[job.repo];
@@ -827,6 +848,9 @@ function spawnAgent(host, spec) {
827
848
  return { pid: child.pid ?? 0 };
828
849
  }
829
850
  function launchJob(config, world, job, host, sandboxFacts) {
851
+ const refused = claudeCredentialPreflight(process.env, config.adapter);
852
+ if (refused)
853
+ return { jobId: job.id, repo: job.repo, output: refused };
830
854
  const repo = config.repos[job.repo];
831
855
  if (!repo) {
832
856
  throw new Error(`Repo ${job.repo} is not registered. Run kiln repo add ${job.repo} <path>.`);
@@ -857,7 +881,7 @@ function launchJob(config, world, job, host, sandboxFacts) {
857
881
  deliver: repo.deliver,
858
882
  stack: job.stack === true,
859
883
  ...(repo.base ? { configuredBase: repo.base } : {}),
860
- ...(job.stack ? { upstreamBranch: upstreamBranch(world, job) } : {}),
884
+ ...(job.stack ? { upstreamBranch: upstreamLocalBranch(world, job) } : {}),
861
885
  git: host.git,
862
886
  });
863
887
  specText = specHandedToYak(config, world, job);
package/dist/errors.js CHANGED
@@ -4,3 +4,12 @@ export class KilnConfigError extends Error {
4
4
  this.name = "KilnConfigError";
5
5
  }
6
6
  }
7
+ /** A request the serve process should answer with `statusCode` and the message. */
8
+ export class HttpError extends Error {
9
+ statusCode;
10
+ constructor(statusCode, message) {
11
+ super(message);
12
+ this.name = "HttpError";
13
+ this.statusCode = statusCode;
14
+ }
15
+ }