@lchase/kiln 0.1.0 → 0.4.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/board.js ADDED
@@ -0,0 +1,326 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { scheduledRetry } from "./attention.js";
4
+ const FEATURE_STAGES = ["spec", "build", "review", "approve", "land", "pr-review"];
5
+ const CATALOG = [
6
+ { id: "queued", label: "Queued", who: "waiting · deps", tone: "queued" },
7
+ { id: "spec", label: "Spec", who: "spec-writer · opus" },
8
+ { id: "build", label: "Build", who: "builder · opus" },
9
+ { id: "review", label: "Review", who: "sec · ux · ui · code" },
10
+ { id: "approve", label: "Approve", who: "approver · opus" },
11
+ { id: "explore", label: "Explore", who: "investigator · opus" },
12
+ { id: "land", label: "Land", who: "git commit" },
13
+ { id: "pr-review", label: "PR review", who: "pr-sync" },
14
+ { id: "merged", label: "Merged", who: "main", tone: "merged" },
15
+ { id: "needs-human", label: "Needs human", who: "approve · retry", tone: "human" },
16
+ ];
17
+ const STEP_STAGE = {
18
+ spec: "spec",
19
+ build: "build",
20
+ verify: "build",
21
+ security: "review",
22
+ ux: "review",
23
+ ui: "review",
24
+ code: "review",
25
+ approver: "approve",
26
+ approve: "needs-human",
27
+ land: "land",
28
+ explore: "explore",
29
+ };
30
+ const HUMAN_STATES = new Set(["needs-human", "failed", "dead", "invalid", "rejected"]);
31
+ /**
32
+ * Column for one job. Human gates and broken runs sit in Needs human.
33
+ * A failed job with an automatic retry still scheduled stays on the step
34
+ * its last run reached. `scheduledRetry` is that decision.
35
+ */
36
+ export function stageOf(job) {
37
+ if (scheduledRetry(job))
38
+ return runStage(job);
39
+ if (HUMAN_STATES.has(job.state))
40
+ return "needs-human";
41
+ if (job.state === "in-review")
42
+ return "pr-review";
43
+ if (job.state === "done")
44
+ return "merged";
45
+ const current = job.steps.find((step) => step.phase === "current" || step.phase === "failed");
46
+ if (job.state === "queued" || job.state === "blocked")
47
+ return "queued";
48
+ if (job.state === "approved") {
49
+ if (current && current.id !== "land" && current.id !== "approve" && STEP_STAGE[current.id])
50
+ return STEP_STAGE[current.id];
51
+ return "land";
52
+ }
53
+ if (current && STEP_STAGE[current.id])
54
+ return STEP_STAGE[current.id];
55
+ return runStage(job);
56
+ }
57
+ /**
58
+ * Stage of the last step the run actually reached.
59
+ * The approve gate's column is Needs human; a scheduled retry is not a gate,
60
+ * so that step stays on Approve. Spec or Explore when the run never started a step.
61
+ */
62
+ function runStage(job) {
63
+ const last = [...job.steps].reverse().find((step) => step.phase === "current" || step.phase === "failed");
64
+ const stage = last ? STEP_STAGE[last.id] : undefined;
65
+ if (stage && stage !== "needs-human")
66
+ return stage;
67
+ if (stage === "needs-human") {
68
+ const earlier = [...job.steps].reverse().find((step) => {
69
+ const mapped = STEP_STAGE[step.id];
70
+ return step.phase === "done" && mapped !== undefined && mapped !== "needs-human";
71
+ });
72
+ if (earlier)
73
+ return STEP_STAGE[earlier.id] ?? "approve";
74
+ return "approve";
75
+ }
76
+ return job.workflow === "investigation" ? "explore" : "spec";
77
+ }
78
+ /** Stages to render. An empty kiln still shows the feature pipeline, collapsed. */
79
+ export function stagesFor(jobs) {
80
+ const workflows = new Set(jobs.map((job) => job.workflow));
81
+ const wanted = new Set();
82
+ if (jobs.length === 0 || workflows.has("feature") || [...workflows].some((name) => name !== "investigation")) {
83
+ for (const id of FEATURE_STAGES)
84
+ wanted.add(id);
85
+ }
86
+ if (workflows.has("investigation"))
87
+ wanted.add("explore");
88
+ wanted.add("queued");
89
+ wanted.add("merged");
90
+ wanted.add("needs-human");
91
+ for (const job of jobs)
92
+ wanted.add(stageOf(job));
93
+ return CATALOG.filter((stage) => wanted.has(stage.id));
94
+ }
95
+ const REVIEW_FILE = /^(security|ux|ui|code)(?:\.(\d+))?\.json$/;
96
+ const TEXT_LIMIT = 8_000;
97
+ export function readReviewRounds(runDir) {
98
+ if (!runDir)
99
+ return [];
100
+ const dir = join(runDir, "artifacts");
101
+ if (!existsSync(dir))
102
+ return [];
103
+ let names;
104
+ try {
105
+ names = readdirSync(dir);
106
+ }
107
+ catch {
108
+ return [];
109
+ }
110
+ const rounds = new Map();
111
+ for (const name of names) {
112
+ const match = REVIEW_FILE.exec(name);
113
+ if (!match?.[1])
114
+ continue;
115
+ const n = match[2] ? Number(match[2]) : 1;
116
+ const round = rounds.get(n) ?? { n };
117
+ const verdict = verdictOf(readJson(join(dir, name)));
118
+ if (verdict) {
119
+ if (match[1] === "security")
120
+ round.security = verdict;
121
+ else if (match[1] === "ux")
122
+ round.ux = verdict;
123
+ else if (match[1] === "ui")
124
+ round.ui = verdict;
125
+ else if (match[1] === "code")
126
+ round.code = verdict;
127
+ }
128
+ rounds.set(n, round);
129
+ }
130
+ return [...rounds.values()].sort((left, right) => left.n - right.n);
131
+ }
132
+ export function readBoardFiles(input) {
133
+ const files = [];
134
+ const spec = readText(input.specPath);
135
+ if (spec)
136
+ files.push({ id: "spec", group: "Job", label: "spec.md", text: spec });
137
+ const build = latestArtifact(input.runDir, "build");
138
+ if (build) {
139
+ const doc = artifactDoc(build);
140
+ files.push({ id: "build", group: "Job", label: "build.md", text: doc.text, ...(doc.tone ? { tone: doc.tone } : {}) });
141
+ }
142
+ else if (input.diffSummary?.trim()) {
143
+ files.push({ id: "build", group: "Job", label: "build.md", text: input.diffSummary.trim() });
144
+ }
145
+ const decision = latestArtifact(input.runDir, "decision");
146
+ if (decision) {
147
+ const doc = artifactDoc(decision);
148
+ files.push({ id: "decision", group: "Job", label: "decision.md", text: doc.text, ...(doc.tone ? { tone: doc.tone } : {}) });
149
+ }
150
+ const findingsFile = readText(input.findingsPath);
151
+ const findingsArtifact = latestArtifact(input.runDir, "findings");
152
+ const findingsMarkdown = findingsFile
153
+ || (isRecord(findingsArtifact) && typeof findingsArtifact.markdown === "string" ? findingsArtifact.markdown : "");
154
+ if (input.workflow === "investigation" && findingsMarkdown.trim()) {
155
+ files.push({ id: "findings", group: "Job", label: "findings.md", text: clip(findingsMarkdown) });
156
+ }
157
+ for (const gate of input.gates ?? []) {
158
+ const body = gate.rendered?.trim() || gate.prompt?.trim();
159
+ if (body) {
160
+ const label = input.workflow === "investigation" ? "findings.md" : "gate.md";
161
+ if (!files.some((file) => file.label === label && file.text.includes(body.slice(0, 40)))) {
162
+ files.push({ id: `gate-${gate.stepId}`, group: "Job", label, text: clip(body) });
163
+ }
164
+ }
165
+ for (const artifact of gate.artifacts ?? []) {
166
+ if (!artifact.summary?.trim())
167
+ continue;
168
+ const label = artifact.name.endsWith(".md") ? artifact.name : `${artifact.name}.md`;
169
+ if (files.some((file) => file.label === label || file.label === artifact.name))
170
+ continue;
171
+ const tone = toneFromSummary(artifact.summary);
172
+ files.push({
173
+ id: `artifact-${gate.stepId}-${artifact.name}`,
174
+ group: "Job",
175
+ label,
176
+ text: clip(artifact.summary),
177
+ ...(tone ? { tone } : {}),
178
+ });
179
+ }
180
+ }
181
+ if (input.runDir)
182
+ addReviewFiles(files, input.runDir);
183
+ addGateReviews(files, input.gates ?? []);
184
+ if (input.logTail?.trim()) {
185
+ files.push({ id: "log", group: "Job", label: "log", text: clip(input.logTail) });
186
+ }
187
+ return files;
188
+ }
189
+ function addReviewFiles(files, runDir) {
190
+ const dir = join(runDir, "artifacts");
191
+ if (!existsSync(dir))
192
+ return;
193
+ let names;
194
+ try {
195
+ names = readdirSync(dir);
196
+ }
197
+ catch {
198
+ return;
199
+ }
200
+ const rows = [];
201
+ for (const name of names) {
202
+ const match = REVIEW_FILE.exec(name);
203
+ if (!match?.[1])
204
+ continue;
205
+ rows.push({ n: match[2] ? Number(match[2]) : 1, kind: match[1], file: name });
206
+ }
207
+ const order = ["security", "ux", "ui", "code"];
208
+ rows.sort((left, right) => left.n - right.n || order.indexOf(left.kind) - order.indexOf(right.kind));
209
+ for (const row of rows) {
210
+ const doc = artifactDoc(readJson(join(dir, row.file)));
211
+ files.push({
212
+ id: `r${row.n}-${row.kind}`,
213
+ group: `Round ${row.n}`,
214
+ label: row.kind,
215
+ text: doc.text,
216
+ ...(doc.tone ? { tone: doc.tone } : {}),
217
+ });
218
+ }
219
+ }
220
+ function latestArtifact(runDir, name) {
221
+ if (!runDir)
222
+ return undefined;
223
+ const dir = join(runDir, "artifacts");
224
+ if (!existsSync(dir))
225
+ return undefined;
226
+ let names;
227
+ try {
228
+ names = readdirSync(dir);
229
+ }
230
+ catch {
231
+ return undefined;
232
+ }
233
+ let best;
234
+ const pattern = new RegExp(`^${name}(?:\\.(\\d+))?\\.json$`);
235
+ for (const file of names) {
236
+ const match = pattern.exec(file);
237
+ if (!match)
238
+ continue;
239
+ const iter = match[1] ? Number(match[1]) : 0;
240
+ if (!best || iter >= best.iter)
241
+ best = { iter, file };
242
+ }
243
+ return best ? readJson(join(dir, best.file)) : undefined;
244
+ }
245
+ function artifactDoc(value) {
246
+ if (!isRecord(value))
247
+ return { text: clip(typeof value === "string" ? value : "") };
248
+ const head = typeof value.verdict === "string" ? value.verdict : typeof value.decision === "string" ? value.decision : typeof value.status === "string" ? value.status : "";
249
+ const body = typeof value.findings === "string"
250
+ ? value.findings
251
+ : typeof value.reason === "string"
252
+ ? value.reason
253
+ : typeof value.summary === "string"
254
+ ? value.summary
255
+ : typeof value.markdown === "string"
256
+ ? value.markdown
257
+ : JSON.stringify(value, null, 2);
258
+ const tone = toneOf(head);
259
+ const text = head ? `VERDICT: ${head}\n\n${body}` : body;
260
+ return { text: clip(text), ...(tone ? { tone } : {}) };
261
+ }
262
+ function toneOf(head) {
263
+ if (head === "PASS" || head === "APPROVE" || head === "READY")
264
+ return "good";
265
+ if (head === "CHANGES")
266
+ return "warn";
267
+ if (head === "ESCALATE" || head === "BLOCKED")
268
+ return "stop";
269
+ return undefined;
270
+ }
271
+ function toneFromSummary(summary) {
272
+ const head = summary.trim().split(/[\s—-]/)[0] ?? "";
273
+ return toneOf(head);
274
+ }
275
+ function addGateReviews(files, gates) {
276
+ const known = new Set(files.filter((file) => file.group.startsWith("Round")).map((file) => file.label));
277
+ for (const gate of gates) {
278
+ for (const review of gate.reviews ?? []) {
279
+ if (!review.summary?.trim() || known.has(review.name))
280
+ continue;
281
+ known.add(review.name);
282
+ const tone = toneFromSummary(review.summary);
283
+ files.push({
284
+ id: `review-${review.name}`,
285
+ group: "Round 1",
286
+ label: review.name,
287
+ text: clip(review.summary),
288
+ ...(tone ? { tone } : {}),
289
+ });
290
+ }
291
+ }
292
+ }
293
+ function verdictOf(value) {
294
+ if (!isRecord(value))
295
+ return undefined;
296
+ const verdict = value.verdict;
297
+ return typeof verdict === "string" && verdict.trim() ? verdict.trim() : undefined;
298
+ }
299
+ function readJson(file) {
300
+ try {
301
+ return JSON.parse(readFileSync(file, "utf8"));
302
+ }
303
+ catch {
304
+ return undefined;
305
+ }
306
+ }
307
+ function readText(file) {
308
+ if (!file || !existsSync(file))
309
+ return undefined;
310
+ try {
311
+ const text = readFileSync(file, "utf8").trim();
312
+ return text ? clip(text) : undefined;
313
+ }
314
+ catch {
315
+ return undefined;
316
+ }
317
+ }
318
+ function clip(text) {
319
+ const trimmed = text.trim();
320
+ if (trimmed.length <= TEXT_LIMIT)
321
+ return trimmed;
322
+ return `${trimmed.slice(0, TEXT_LIMIT - 1)}…`;
323
+ }
324
+ function isRecord(value) {
325
+ return typeof value === "object" && value !== null && !Array.isArray(value);
326
+ }
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { addRepo, renderRepoList, resolveKilnConfig } from "./config.js";
3
- import { answerJob, runStatus, runTick } from "./driver.js";
3
+ import { answerJob, retryJob, runStatus, runTick } from "./driver.js";
4
+ import { startServe } from "./serve.js";
4
5
  import { addFromFile } from "./intake/file.js";
5
6
  import { addFromJira } from "./intake/jira.js";
6
7
  import { addFromPrompt } from "./intake/prompt.js";
@@ -16,15 +17,25 @@ function usage() {
16
17
  " kiln status [jobId] [--config <file>]",
17
18
  " kiln approve <jobId> [--config <file>]",
18
19
  " kiln reject <jobId> [--config <file>]",
20
+ " kiln retry <jobId> [--config <file>]",
21
+ " kiln serve [--port 4100] [--tick <seconds>|--no-tick] [--config <file>]",
19
22
  "",
20
- "repo add registers a checkout. Relative paths are stored relative to the",
21
- "config directory. ~ is expanded from the home directory.",
23
+ "repo add registers a checkout. A relative path is stored absolute, resolved",
24
+ "from the working directory. ~ is expanded from the home directory.",
25
+ "",
26
+ "Config, jobs, logs, and deliveries live in one kiln home: --config <file>",
27
+ "if given, otherwise $KILN_HOME/kiln.config.json when KILN_HOME is set,",
28
+ "otherwise ~/.kiln/kiln.config.json. repo add creates that directory.",
29
+ "Run kiln repo add <name> <path> from any directory. A leftover",
30
+ "./.kiln/kiln.config.json is an error until you mv ./.kiln ~/.kiln,",
31
+ "pass --config <file>, or set KILN_HOME.",
22
32
  "",
23
33
  "add writes one job from a spec file, from --prompt text (--prompt - reads stdin),",
24
34
  "or from a Jira Cloud issue (--jira PROJ-123). Pass one of those, not more.",
25
35
  "The repo and the workflow (workflows/<name>.yaml) must already exist.",
26
36
  "--after names jobs that must be done before this one launches. --stack waits",
27
37
  "for one upstream to land and opens the pull request against that branch.",
38
+ "--stack cannot target an investigation job: that workflow does not land a branch.",
28
39
  "A Jira Blocks link where this issue is blocked by another key is included",
29
40
  "when a kiln job for that key already exists.",
30
41
  "--dry-run with --jira prints the job and writes nothing. Running add again",
@@ -36,13 +47,29 @@ function usage() {
36
47
  "",
37
48
  "tick launches at most one ready job. After merged, dependencies must be done.",
38
49
  "Stacked jobs launch once the upstream has landed. deliver=pr then pushes",
39
- "yak/<run-id> and opens a draft pull request. status prints the derived state.",
40
- "approve and reject answer the human gate.",
50
+ "yak/<run-id> and opens a draft pull request. An investigation is done when",
51
+ "its gate is approved. It does not open a pull request. status prints the",
52
+ "derived state, and the findings path once an investigation is approved.",
53
+ "approve and reject answer the human gate. retry launches a failed or dead",
54
+ "job again, the same way tick launches, and clears the attempt ledger.",
55
+ "A landed job that only failed to open its pull request is delivered again.",
56
+ "A running job with no yak event or log output for stallTimeoutMs (default",
57
+ "15 minutes; 0 disables it) is killed and retried. A crash, a non-zero exit,",
58
+ "or a process that is already gone when tick or serve starts (orphaned) retries",
59
+ "after 10s, 20s, 40s, and so on, capped at 5 minutes, up to maxAttempts",
60
+ "(default 3). The job is then dead. A rejected gate is not retried.",
61
+ "Attempts are files under the kiln home, not fields on the job.",
62
+ "",
63
+ "serve listens on 127.0.0.1. The page is a board of workflow stages:",
64
+ "queued, spec, build, review, approve, land, PR review, merged, and needs human.",
65
+ "Empty stages collapse. Approve, reject, and retry call the same",
66
+ "functions as the CLI. POST from another origin is rejected. A tick",
67
+ "runs every 30 seconds unless --no-tick is set, using the tick lock.",
41
68
  ].join("\n"));
42
69
  process.exit(2);
43
70
  }
44
71
  function parseFlags(argv) {
45
- const bag = { positionals: [], after: [], dryRun: false, stack: false };
72
+ const bag = { positionals: [], after: [], dryRun: false, stack: false, noTick: false };
46
73
  for (let i = 0; i < argv.length; i += 1) {
47
74
  const flag = argv[i];
48
75
  const next = argv[i + 1];
@@ -138,6 +165,30 @@ function parseFlags(argv) {
138
165
  i += 1;
139
166
  continue;
140
167
  }
168
+ if (flag === "--port") {
169
+ if (!next || !/^\d+$/.test(next))
170
+ usage();
171
+ const port = Number(next);
172
+ if (port > 65535)
173
+ usage();
174
+ bag.port = port;
175
+ i += 1;
176
+ continue;
177
+ }
178
+ if (flag === "--tick") {
179
+ if (!next || !/^[1-9]\d*$/.test(next))
180
+ usage();
181
+ const seconds = Number(next);
182
+ if (seconds > 86_400)
183
+ usage();
184
+ bag.tickSeconds = seconds;
185
+ i += 1;
186
+ continue;
187
+ }
188
+ if (flag === "--no-tick") {
189
+ bag.noTick = true;
190
+ continue;
191
+ }
141
192
  if (flag?.startsWith("--"))
142
193
  usage();
143
194
  if (flag !== undefined)
@@ -145,9 +196,14 @@ function parseFlags(argv) {
145
196
  }
146
197
  return bag;
147
198
  }
199
+ function rejectServeFlags(flags) {
200
+ if (flags.port !== undefined || flags.tickSeconds !== undefined || flags.noTick)
201
+ usage();
202
+ }
148
203
  function repoCommand(argv) {
149
204
  const sub = argv[0];
150
205
  const flags = parseFlags(argv.slice(1));
206
+ rejectServeFlags(flags);
151
207
  if (sub === "list") {
152
208
  if (flags.positionals.length !== 0 || hasIntakeFlags(flags) || flags.test || flags.deliver || flags.base)
153
209
  usage();
@@ -174,6 +230,7 @@ function repoCommand(argv) {
174
230
  }
175
231
  async function addCommand(argv) {
176
232
  const flags = parseFlags(argv);
233
+ rejectServeFlags(flags);
177
234
  const workflow = flags.positionals[0];
178
235
  const selected = [flags.spec !== undefined, flags.prompt !== undefined, flags.jira !== undefined].filter(Boolean).length;
179
236
  if (!workflow ||
@@ -226,6 +283,7 @@ async function addCommand(argv) {
226
283
  }
227
284
  function importCommand(argv) {
228
285
  const flags = parseFlags(argv);
286
+ rejectServeFlags(flags);
229
287
  const dir = flags.positionals[0];
230
288
  if (!dir ||
231
289
  flags.positionals.length !== 1 ||
@@ -255,6 +313,7 @@ function importCommand(argv) {
255
313
  }
256
314
  function tickCommand(argv) {
257
315
  const flags = parseFlags(argv);
316
+ rejectServeFlags(flags);
258
317
  if (flags.positionals.length !== 0 || flags.test || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
259
318
  usage();
260
319
  }
@@ -263,14 +322,26 @@ function tickCommand(argv) {
263
322
  }
264
323
  function statusCommand(argv) {
265
324
  const flags = parseFlags(argv);
325
+ rejectServeFlags(flags);
266
326
  if (flags.positionals.length > 1 || flags.test || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
267
327
  usage();
268
328
  }
269
329
  const config = resolveKilnConfig({ configPath: flags.configPath });
270
330
  process.stdout.write(runStatus(config, flags.positionals[0]));
271
331
  }
332
+ function retryCommand(argv) {
333
+ const flags = parseFlags(argv);
334
+ rejectServeFlags(flags);
335
+ const jobId = flags.positionals[0];
336
+ if (!jobId || flags.positionals.length !== 1 || flags.test || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
337
+ usage();
338
+ }
339
+ const config = resolveKilnConfig({ configPath: flags.configPath });
340
+ process.stdout.write(retryJob(config, jobId));
341
+ }
272
342
  function gateCommand(choice, argv) {
273
343
  const flags = parseFlags(argv);
344
+ rejectServeFlags(flags);
274
345
  const jobId = flags.positionals[0];
275
346
  if (!jobId || flags.positionals.length !== 1 || flags.test || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
276
347
  usage();
@@ -278,6 +349,26 @@ function gateCommand(choice, argv) {
278
349
  const config = resolveKilnConfig({ configPath: flags.configPath });
279
350
  process.stdout.write(answerJob(config, jobId, choice));
280
351
  }
352
+ async function serveCommand(argv) {
353
+ const flags = parseFlags(argv);
354
+ if (flags.positionals.length !== 0 ||
355
+ flags.test ||
356
+ hasIntakeFlags(flags) ||
357
+ flags.deliver ||
358
+ flags.base ||
359
+ flags.stack ||
360
+ (flags.noTick && flags.tickSeconds !== undefined)) {
361
+ usage();
362
+ }
363
+ const config = resolveKilnConfig({ configPath: flags.configPath });
364
+ const port = flags.port ?? 4100;
365
+ const tickSeconds = flags.noTick ? undefined : (flags.tickSeconds ?? 30);
366
+ await startServe({
367
+ config,
368
+ port,
369
+ ...(tickSeconds !== undefined ? { tickSeconds } : {}),
370
+ });
371
+ }
281
372
  function hasIntakeFlags(flags) {
282
373
  return Boolean(flags.repo ||
283
374
  flags.spec ||
@@ -323,6 +414,14 @@ async function main() {
323
414
  gateCommand("reject", argv.slice(1));
324
415
  return;
325
416
  }
417
+ if (command === "retry") {
418
+ retryCommand(argv.slice(1));
419
+ return;
420
+ }
421
+ if (command === "serve") {
422
+ await serveCommand(argv.slice(1));
423
+ return;
424
+ }
326
425
  usage();
327
426
  }
328
427
  try {
package/dist/config.js CHANGED
@@ -1,12 +1,11 @@
1
1
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { KilnConfigError } from "./errors.js";
5
- import { KilnConfigFileSchema } from "./schemas.js";
5
+ import { DEFAULT_MAX_ATTEMPTS, DEFAULT_STALL_TIMEOUT_MS, KilnConfigFileSchema, } from "./schemas.js";
6
6
  const CONFIG_NAME = "kiln.config.json";
7
7
  const REPO_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
8
8
  export function loadKilnConfig(configPath, options = {}) {
9
- const cwd = options.cwd ?? process.cwd();
10
9
  const home = options.home ?? homedir();
11
10
  const resolvedPath = resolve(configPath);
12
11
  let raw;
@@ -36,7 +35,7 @@ export function loadKilnConfig(configPath, options = {}) {
36
35
  ...(entry.base ? { base: entry.base } : {}),
37
36
  };
38
37
  }
39
- const kilnDir = basename(configDir) === ".kiln" ? configDir : join(cwd, ".kiln");
38
+ const kilnDir = configDir;
40
39
  const jira = jiraFromFile(parsed.data.jira);
41
40
  return {
42
41
  configPath: resolvedPath,
@@ -46,12 +45,15 @@ export function loadKilnConfig(configPath, options = {}) {
46
45
  kilnDir,
47
46
  jobsDir: join(kilnDir, "jobs"),
48
47
  maxConcurrent: parsed.data.maxConcurrent ?? 2,
48
+ stallTimeoutMs: parsed.data.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS,
49
+ maxAttempts: parsed.data.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
49
50
  adapter: parsed.data.adapter ?? "claude-code",
50
51
  ...(jira ? { jira } : {}),
51
52
  };
52
53
  }
53
54
  /**
54
- * Local config wins: `./.kiln/kiln.config.json`, then `~/.kiln/kiln.config.json`.
55
+ * One kiln home: `--config <file>` if given, else `$KILN_HOME/kiln.config.json`
56
+ * when `KILN_HOME` is set, else `~/.kiln/kiln.config.json`.
55
57
  */
56
58
  export function resolveKilnConfig(options = {}) {
57
59
  const cwd = options.cwd ?? process.cwd();
@@ -59,18 +61,17 @@ export function resolveKilnConfig(options = {}) {
59
61
  if (options.configPath) {
60
62
  return loadKilnConfig(resolve(cwd, options.configPath), { cwd, home });
61
63
  }
62
- const cwdConfig = join(cwd, ".kiln", CONFIG_NAME);
63
- if (existsSync(cwdConfig))
64
- return loadKilnConfig(cwdConfig, { cwd, home });
65
- const homeConfig = join(home, ".kiln", CONFIG_NAME);
66
- if (existsSync(homeConfig))
67
- return loadKilnConfig(homeConfig, { cwd, home });
68
- throw new KilnConfigError([
69
- "No kiln config found.",
70
- `Run kiln repo add <name> <path> to create ${join(cwd, ".kiln", CONFIG_NAME)},`,
71
- `or create ${join(home, ".kiln", CONFIG_NAME)}.`,
72
- "Those directories are gitignored. Do not commit checkout paths.",
73
- ].join("\n"));
64
+ const configPath = defaultConfigPath(options);
65
+ assertNoStaleCwdConfig(cwd, configPath);
66
+ if (!existsSync(configPath)) {
67
+ throw new KilnConfigError([
68
+ "No kiln config found.",
69
+ `Run kiln repo add <name> <path> from any directory to create ${configPath}.`,
70
+ "Set KILN_HOME or pass --config <file> to use a different kiln home.",
71
+ "Do not commit checkout paths.",
72
+ ].join("\n"));
73
+ }
74
+ return loadKilnConfig(configPath, { cwd, home });
74
75
  }
75
76
  const BRANCH_NAME = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
76
77
  /** Create or update one repo entry. Returns the text to print. */
@@ -85,7 +86,7 @@ export function addRepo(options) {
85
86
  assertBranchName(options.base);
86
87
  const configPath = configPathForWrite(options);
87
88
  const configDir = dirname(configPath);
88
- const storedPath = pathToStore(options.repoPath, configDir, cwd, home);
89
+ const storedPath = pathToStore(options.repoPath, cwd, home);
89
90
  const checkout = resolveConfiguredPath(configDir, storedPath, home);
90
91
  if (!existsSync(checkout) || !statSync(checkout).isDirectory()) {
91
92
  throw new KilnConfigError(`Checkout for ${options.name} does not exist or is not a directory: ${checkout}.`);
@@ -151,18 +152,39 @@ export function assertRepoName(name) {
151
152
  throw new KilnConfigError(`Repo name ${JSON.stringify(name)} must start with a letter or digit and contain only letters, digits, dots, underscores, and hyphens.`);
152
153
  }
153
154
  }
154
- function configPathForWrite(options) {
155
+ function defaultConfigPath(options) {
156
+ return join(kilnHomeDirectory(options), CONFIG_NAME);
157
+ }
158
+ /** `$KILN_HOME` when set, otherwise `~/.kiln`. A relative `KILN_HOME` resolves from `cwd`. */
159
+ function kilnHomeDirectory(options) {
155
160
  const cwd = options.cwd ?? process.cwd();
161
+ const fromEnv = process.env.KILN_HOME;
162
+ if (typeof fromEnv === "string" && fromEnv.trim().length > 0)
163
+ return resolve(cwd, fromEnv);
156
164
  const home = options.home ?? homedir();
165
+ return join(home, ".kiln");
166
+ }
167
+ function configPathForWrite(options) {
168
+ const cwd = options.cwd ?? process.cwd();
157
169
  if (options.configPath)
158
170
  return resolve(cwd, options.configPath);
171
+ const configPath = defaultConfigPath(options);
172
+ assertNoStaleCwdConfig(cwd, configPath);
173
+ return configPath;
174
+ }
175
+ /**
176
+ * A leftover `./.kiln/kiln.config.json` used to be a second kiln home.
177
+ * Fail instead of ignoring it when the machine home has no config yet.
178
+ */
179
+ function assertNoStaleCwdConfig(cwd, homeConfig) {
159
180
  const cwdConfig = join(cwd, ".kiln", CONFIG_NAME);
160
- if (existsSync(cwdConfig))
161
- return cwdConfig;
162
- const homeConfig = join(home, ".kiln", CONFIG_NAME);
181
+ if (!existsSync(cwdConfig))
182
+ return;
183
+ if (resolve(cwdConfig) === resolve(homeConfig))
184
+ return;
163
185
  if (existsSync(homeConfig))
164
- return homeConfig;
165
- return cwdConfig;
186
+ return;
187
+ throw new KilnConfigError(`Found ${cwdConfig} but no home config at ${homeConfig}. mv ./.kiln ~/.kiln or pass --config / set KILN_HOME.`);
166
188
  }
167
189
  function readConfigObject(configPath) {
168
190
  if (!existsSync(configPath))
@@ -205,14 +227,12 @@ export function resolveConfiguredPath(configDir, value, home) {
205
227
  const expanded = expandHome(value, home);
206
228
  return isAbsolute(expanded) ? expanded : resolve(configDir, expanded);
207
229
  }
208
- function pathToStore(userPath, configDir, cwd, home) {
230
+ function pathToStore(userPath, cwd, home) {
209
231
  if (userPath === "~" || userPath.startsWith("~/"))
210
232
  return userPath;
211
233
  if (isAbsolute(userPath))
212
234
  return userPath;
213
- const absolute = resolve(cwd, userPath);
214
- const stored = relative(configDir, absolute);
215
- return stored === "" ? "." : stored;
235
+ return resolve(cwd, userPath);
216
236
  }
217
237
  function jiraFromFile(jira) {
218
238
  if (!jira)