@lchase/kiln 0.3.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,6 @@
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
4
  import { startServe } from "./serve.js";
5
5
  import { addFromFile } from "./intake/file.js";
6
6
  import { addFromJira } from "./intake/jira.js";
@@ -17,6 +17,7 @@ function usage() {
17
17
  " kiln status [jobId] [--config <file>]",
18
18
  " kiln approve <jobId> [--config <file>]",
19
19
  " kiln reject <jobId> [--config <file>]",
20
+ " kiln retry <jobId> [--config <file>]",
20
21
  " kiln serve [--port 4100] [--tick <seconds>|--no-tick] [--config <file>]",
21
22
  "",
22
23
  "repo add registers a checkout. A relative path is stored absolute, resolved",
@@ -49,12 +50,21 @@ function usage() {
49
50
  "yak/<run-id> and opens a draft pull request. An investigation is done when",
50
51
  "its gate is approved. It does not open a pull request. status prints the",
51
52
  "derived state, and the findings path once an investigation is approved.",
52
- "approve and reject answer the human gate.",
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.",
53
62
  "",
54
- "serve listens on 127.0.0.1. The inbox lists needs-human jobs. The board",
55
- "groups every job by the same derived state as status. Approve and reject",
56
- "call the same functions as the CLI. POST from another origin is rejected.",
57
- "A tick runs every 30 seconds unless --no-tick is set, using the tick lock.",
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.",
58
68
  ].join("\n"));
59
69
  process.exit(2);
60
70
  }
@@ -319,6 +329,16 @@ function statusCommand(argv) {
319
329
  const config = resolveKilnConfig({ configPath: flags.configPath });
320
330
  process.stdout.write(runStatus(config, flags.positionals[0]));
321
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
+ }
322
342
  function gateCommand(choice, argv) {
323
343
  const flags = parseFlags(argv);
324
344
  rejectServeFlags(flags);
@@ -394,6 +414,10 @@ async function main() {
394
414
  gateCommand("reject", argv.slice(1));
395
415
  return;
396
416
  }
417
+ if (command === "retry") {
418
+ retryCommand(argv.slice(1));
419
+ return;
420
+ }
397
421
  if (command === "serve") {
398
422
  await serveCommand(argv.slice(1));
399
423
  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 { 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 = {}) {
@@ -45,6 +45,8 @@ export function loadKilnConfig(configPath, options = {}) {
45
45
  kilnDir,
46
46
  jobsDir: join(kilnDir, "jobs"),
47
47
  maxConcurrent: parsed.data.maxConcurrent ?? 2,
48
+ stallTimeoutMs: parsed.data.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS,
49
+ maxAttempts: parsed.data.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
48
50
  adapter: parsed.data.adapter ?? "claude-code",
49
51
  ...(jira ? { jira } : {}),
50
52
  };