@lchase/kiln 0.0.0-stage → 0.3.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/errors.js ADDED
@@ -0,0 +1,6 @@
1
+ export class KilnConfigError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = "KilnConfigError";
5
+ }
6
+ }
@@ -0,0 +1,79 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { jobBasename } from "./paths.js";
4
+ /**
5
+ * Workflow name for a spike, probe, or research job.
6
+ * An approved investigation is `done` with no pull request and no land.
7
+ */
8
+ export const INVESTIGATION_WORKFLOW = "investigation";
9
+ /**
10
+ * Headings the findings markdown must contain. The investigation prompt
11
+ * asks for these spellings. Kiln stores the agent's markdown only when
12
+ * every one of them is present.
13
+ */
14
+ export const FINDINGS_HEADINGS = [
15
+ "## Questions",
16
+ "## Answers",
17
+ "## Unconfirmed",
18
+ "## Implications for dependent tickets",
19
+ ];
20
+ /** `<kilnDir>/findings/<jobId>.md`. The job file does not store this path. */
21
+ export function findingsFilePath(kilnDir, jobId) {
22
+ return join(kilnDir, "findings", `${jobBasename(jobId)}.md`);
23
+ }
24
+ /** Absolute path when the findings file is already on disk. */
25
+ export function existingFindingsPath(kilnDir, jobId) {
26
+ const path = findingsFilePath(kilnDir, jobId);
27
+ return existsSync(path) ? path : undefined;
28
+ }
29
+ export function findingsMarkdownComplete(markdown) {
30
+ return FINDINGS_HEADINGS.every((heading) => markdown.includes(heading));
31
+ }
32
+ /**
33
+ * Store the approved findings document. Writing the same text again does
34
+ * not touch the file. A document missing a required heading is refused.
35
+ */
36
+ export function writeFindings(kilnDir, jobId, markdown) {
37
+ if (!findingsMarkdownComplete(markdown)) {
38
+ throw new Error(`Findings for ${jobId} are missing a required section. Expected ${FINDINGS_HEADINGS.join(", ")}.`);
39
+ }
40
+ const path = findingsFilePath(kilnDir, jobId);
41
+ mkdirSync(dirname(path), { recursive: true });
42
+ const body = markdown.endsWith("\n") ? markdown : `${markdown}\n`;
43
+ if (existsSync(path) && readFileSync(path, "utf8") === body)
44
+ return path;
45
+ writeFileSync(path, body);
46
+ return path;
47
+ }
48
+ /** The explore step's `findings` artifact, when it is a complete markdown document. */
49
+ export function readFindingsMarkdown(checkout, runId) {
50
+ const path = join(checkout, ".runs", runId, "artifacts", "findings.json");
51
+ if (!existsSync(path))
52
+ return undefined;
53
+ try {
54
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
55
+ if (typeof parsed !== "object" || parsed === null || !("markdown" in parsed))
56
+ return undefined;
57
+ const markdown = parsed.markdown;
58
+ if (typeof markdown !== "string")
59
+ return undefined;
60
+ const text = markdown.trim();
61
+ if (!text || !findingsMarkdownComplete(text))
62
+ return undefined;
63
+ return text;
64
+ }
65
+ catch {
66
+ return undefined;
67
+ }
68
+ }
69
+ /**
70
+ * Spec text handed to yak. Investigation dependencies contribute a
71
+ * `## Findings from <title>` section. The stored spec file is not modified.
72
+ */
73
+ export function appendInvestigationFindings(specText, sections) {
74
+ if (sections.length === 0)
75
+ return specText;
76
+ const base = specText.endsWith("\n") ? specText : `${specText}\n`;
77
+ const blocks = sections.map((section) => `## Findings from ${section.title}\n\n${section.markdown.trim()}\n`);
78
+ return `${base}\n${blocks.join("\n")}`;
79
+ }
package/dist/host.js ADDED
@@ -0,0 +1,223 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ /**
6
+ * Git remote and GitHub CLI, behind one seam so tests can fake `gh` and point
7
+ * `origin` at a local bare repo. Nothing here merges a pull request or pushes
8
+ * the base branch.
9
+ */
10
+ export class HostError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = "HostError";
14
+ }
15
+ }
16
+ export function defaultHost() {
17
+ return { github: realGitHub, git: realGitRemote };
18
+ }
19
+ const gitEnv = () => ({ ...process.env, GIT_TERMINAL_PROMPT: "0" });
20
+ function gitResult(checkout, args) {
21
+ const result = spawnSync("git", args, { cwd: checkout, encoding: "utf8", env: gitEnv() });
22
+ return {
23
+ status: result.status ?? 1,
24
+ stdout: result.stdout ?? "",
25
+ stderr: result.stderr || result.error?.message || "",
26
+ };
27
+ }
28
+ export const realGitRemote = {
29
+ fetch(checkout) {
30
+ const result = gitResult(checkout, ["fetch", "origin"]);
31
+ if (result.status !== 0) {
32
+ throw new HostError(`Could not fetch origin in ${checkout}: ${result.stderr.trim() || "git fetch failed"}`);
33
+ }
34
+ },
35
+ revParse(checkout, ref) {
36
+ const result = gitResult(checkout, ["rev-parse", "--verify", ref]);
37
+ const sha = result.stdout.trim();
38
+ if (result.status !== 0 || !/^[0-9a-f]{40}$/.test(sha)) {
39
+ throw new HostError(`Could not resolve ${ref} in ${checkout}: ${(result.stderr || result.stdout || "no such ref").trim()}`);
40
+ }
41
+ return sha;
42
+ },
43
+ remoteBranchSha(checkout, branch) {
44
+ const result = gitResult(checkout, ["ls-remote", "--heads", "origin", `refs/heads/${branch}`]);
45
+ if (result.status !== 0) {
46
+ throw new HostError(`Could not look up origin/${branch} in ${checkout}: ${result.stderr.trim() || "git ls-remote failed"}`);
47
+ }
48
+ const line = result.stdout
49
+ .split("\n")
50
+ .map((item) => item.trim())
51
+ .find((item) => item.endsWith(`refs/heads/${branch}`));
52
+ if (!line)
53
+ return null;
54
+ const sha = line.split(/\s+/)[0] ?? "";
55
+ return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
56
+ },
57
+ pushBranch(checkout, branch, baseBranch) {
58
+ if (branch === baseBranch || branch === `refs/heads/${baseBranch}`) {
59
+ throw new HostError(`Refusing to push ${branch}: that is the base branch.`);
60
+ }
61
+ if (!/^yak\/[A-Za-z0-9._-]+$/.test(branch)) {
62
+ throw new HostError(`Refusing to push ${branch}: kiln only pushes yak/<run-id>.`);
63
+ }
64
+ const result = gitResult(checkout, [
65
+ "push",
66
+ "origin",
67
+ `refs/heads/${branch}:refs/heads/${branch}`,
68
+ ]);
69
+ if (result.status !== 0) {
70
+ throw new HostError(`Could not push ${branch}: ${result.stderr.trim() || "git push failed"}`);
71
+ }
72
+ },
73
+ defaultBranch(checkout) {
74
+ const symbolic = gitResult(checkout, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
75
+ if (symbolic.status === 0) {
76
+ const name = symbolic.stdout.trim().replace(/^origin\//, "");
77
+ if (name && !name.includes(" "))
78
+ return name;
79
+ }
80
+ const listed = gitResult(checkout, ["ls-remote", "--symref", "origin", "HEAD"]);
81
+ if (listed.status === 0) {
82
+ const match = listed.stdout.match(/^ref:\s+refs\/heads\/(\S+)\s+HEAD/m);
83
+ const name = match?.[1];
84
+ if (name)
85
+ return name;
86
+ }
87
+ throw new HostError(`Could not discover origin's default branch in ${checkout}. Pass --base when you register the repo.`);
88
+ },
89
+ };
90
+ function ghBin() {
91
+ return process.env.KILN_GH && process.env.KILN_GH.length > 0 ? process.env.KILN_GH : "gh";
92
+ }
93
+ function runGh(checkout, args) {
94
+ const bin = ghBin();
95
+ const result = spawnSync(bin, args, {
96
+ cwd: checkout,
97
+ encoding: "utf8",
98
+ env: { ...process.env, GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0" },
99
+ });
100
+ if (result.error) {
101
+ const code = result.error.code;
102
+ if (code === "ENOENT") {
103
+ throw new HostError(`gh is not installed (looked for ${bin}). Install the GitHub CLI from https://cli.github.com/ and run gh auth login, then run kiln tick again.`);
104
+ }
105
+ throw new HostError(`Could not run gh: ${result.error.message}`);
106
+ }
107
+ return { status: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
108
+ }
109
+ function ghFailure(stderr, stdout) {
110
+ const detail = (stderr || stdout).trim() || "gh failed";
111
+ const auth = /auth|login|token|credential/i.test(detail);
112
+ const hint = auth
113
+ ? "Run gh auth login, then run kiln tick again."
114
+ : "Fix gh, then run kiln tick again.";
115
+ return new HostError(`Could not open a pull request: ${detail} ${hint}`);
116
+ }
117
+ function asState(value) {
118
+ if (value === "MERGED" || value === "CLOSED" || value === "OPEN")
119
+ return value;
120
+ return "OPEN";
121
+ }
122
+ function asMergedAt(value) {
123
+ return typeof value === "string" && value.length > 0 ? value : null;
124
+ }
125
+ function toRef(raw, fallback) {
126
+ const number = typeof raw.number === "number" ? raw.number : Number.NaN;
127
+ const url = typeof raw.url === "string" ? raw.url : "";
128
+ if (!Number.isInteger(number) || number <= 0 || !url)
129
+ return null;
130
+ return {
131
+ number,
132
+ url,
133
+ state: asState(raw.state),
134
+ mergedAt: asMergedAt(raw.mergedAt),
135
+ head: typeof raw.headRefName === "string" && raw.headRefName.length > 0 ? raw.headRefName : fallback.head,
136
+ base: typeof raw.baseRefName === "string" && raw.baseRefName.length > 0 ? raw.baseRefName : fallback.base,
137
+ };
138
+ }
139
+ export const realGitHub = {
140
+ findByHead(checkout, branch) {
141
+ const result = runGh(checkout, [
142
+ "pr",
143
+ "list",
144
+ "--head",
145
+ branch,
146
+ "--state",
147
+ "all",
148
+ "--limit",
149
+ "30",
150
+ "--json",
151
+ "number,url,state,mergedAt,headRefName,baseRefName",
152
+ ]);
153
+ if (result.status !== 0)
154
+ throw ghFailure(result.stderr, result.stdout);
155
+ let parsed;
156
+ try {
157
+ parsed = JSON.parse(result.stdout || "[]");
158
+ }
159
+ catch {
160
+ throw new HostError(`gh pr list returned data that is not JSON.`);
161
+ }
162
+ if (!Array.isArray(parsed))
163
+ throw new HostError(`gh pr list returned data that is not a list.`);
164
+ const matches = parsed
165
+ .map((item) => toRef(item, { head: branch, base: "" }))
166
+ .filter((item) => item !== null && item.head === branch);
167
+ const open = matches.find((item) => item.state === "OPEN");
168
+ if (open)
169
+ return open;
170
+ const merged = matches.find((item) => item.state === "MERGED" || item.mergedAt);
171
+ if (merged)
172
+ return merged;
173
+ return matches.sort((left, right) => right.number - left.number)[0] ?? null;
174
+ },
175
+ createDraft(input) {
176
+ const dir = mkdtempSync(join(tmpdir(), "kiln-pr-"));
177
+ const bodyFile = join(dir, "body.md");
178
+ writeFileSync(bodyFile, input.body);
179
+ try {
180
+ const result = runGh(input.checkout, [
181
+ "pr",
182
+ "create",
183
+ "--draft",
184
+ "--head",
185
+ input.head,
186
+ "--base",
187
+ input.base,
188
+ "--title",
189
+ input.title.replace(/[\r\n]+/g, " ").trim(),
190
+ "--body-file",
191
+ bodyFile,
192
+ ]);
193
+ if (result.status !== 0)
194
+ throw ghFailure(result.stderr, result.stdout);
195
+ const url = result.stdout
196
+ .split("\n")
197
+ .map((line) => line.trim())
198
+ .filter((line) => line.startsWith("http"))
199
+ .at(-1);
200
+ const number = Number(url?.match(/\/pull\/(\d+)\s*$/)?.[1]);
201
+ if (!url || !Number.isInteger(number)) {
202
+ throw new HostError(`gh pr create did not return a pull request URL: ${result.stdout.trim()}`);
203
+ }
204
+ return { number, url, state: "OPEN", mergedAt: null, head: input.head, base: input.base };
205
+ }
206
+ finally {
207
+ rmSync(dir, { recursive: true, force: true });
208
+ }
209
+ },
210
+ view(checkout, number) {
211
+ const result = runGh(checkout, ["pr", "view", String(number), "--json", "state,mergedAt"]);
212
+ if (result.status !== 0)
213
+ throw ghFailure(result.stderr, result.stdout);
214
+ let parsed;
215
+ try {
216
+ parsed = JSON.parse(result.stdout);
217
+ }
218
+ catch {
219
+ throw new HostError(`gh pr view returned data that is not JSON.`);
220
+ }
221
+ return { state: asState(parsed.state), mergedAt: asMergedAt(parsed.mergedAt) };
222
+ },
223
+ };
@@ -0,0 +1,29 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { titleFromSpec, writeJob } from "../jobs.js";
4
+ /** `kiln add <workflow> --repo R --spec file.md` */
5
+ export function addFromFile(config, input) {
6
+ const source = resolve(input.cwd, input.specPath);
7
+ let text;
8
+ try {
9
+ text = readFileSync(source, "utf8");
10
+ }
11
+ catch (error) {
12
+ const message = error instanceof Error ? error.message : String(error);
13
+ throw new Error(`Could not read spec ${source}: ${message}`);
14
+ }
15
+ if (!text.trim()) {
16
+ throw new Error(`Spec ${source} is empty.`);
17
+ }
18
+ const title = input.title?.trim() || titleFromSpec(text, source);
19
+ return writeJob(config, {
20
+ workflow: input.workflow,
21
+ repo: input.repo,
22
+ specText: text,
23
+ title,
24
+ dependsOn: input.dependsOn,
25
+ ...(input.stack ? { stack: true } : {}),
26
+ source: { kind: "file", ref: source },
27
+ ...(input.now ? { now: input.now } : {}),
28
+ });
29
+ }