@lchase/kiln 0.0.0-stage → 0.1.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.
@@ -0,0 +1,123 @@
1
+ export function inFlightCount(observation) {
2
+ return observation.jobs.filter((job) => inFlight(job, observation)).length;
3
+ }
4
+ function inFlight(job, observation) {
5
+ if (job.error)
6
+ return false;
7
+ const state = jobState(job, observation);
8
+ return state === "running" || state === "needs-human" || state === "approved";
9
+ }
10
+ /**
11
+ * `deliver=local`: `done` is a finished run whose gate says approve.
12
+ * `deliver=pr`: that landed run is `in-review` while the pull request is open
13
+ * (and in the gap before tick opens it) and `done` only once it has merged.
14
+ * `approved` is the approve decision while the run is still alive. It holds
15
+ * a slot and does not unblock dependents. An ok run with no readable
16
+ * decision is `failed`, so a missing artifact cannot unblock the next job.
17
+ */
18
+ export function jobState(job, observation) {
19
+ if (job.launchFailed && !job.run)
20
+ return "failed";
21
+ if (job.run) {
22
+ if (isLanded(job))
23
+ return landedState(job);
24
+ if (job.run.status === "ok" && job.run.gateDecision === "reject")
25
+ return "rejected";
26
+ if (job.run.status === "ok")
27
+ return "failed";
28
+ if (job.run.status === "failed")
29
+ return "failed";
30
+ if (job.run.status === "dead")
31
+ return "dead";
32
+ if (job.run.status === "suspended")
33
+ return "needs-human";
34
+ if (job.run.gateDecision === "approve")
35
+ return "approved";
36
+ return "running";
37
+ }
38
+ if (job.launching)
39
+ return "running";
40
+ if (!dependenciesMet(job, observation))
41
+ return "blocked";
42
+ if (job.launchError)
43
+ return "failed";
44
+ return "queued";
45
+ }
46
+ /**
47
+ * After-merged (the default) needs every upstream `done`.
48
+ * Stacked needs its one upstream landed (approved and the run finished ok),
49
+ * which is true for `in-review` as well as `done`.
50
+ */
51
+ export function dependenciesMet(job, observation) {
52
+ return job.dependsOn.every((dep) => {
53
+ const upstream = observation.jobs.find((candidate) => candidate.jobId === dep);
54
+ if (!upstream)
55
+ return false;
56
+ return job.stack ? isLanded(upstream) : isDone(upstream);
57
+ });
58
+ }
59
+ /** Run finished ok and the gate says approve. The branch `yak/<run-id>` has landed. */
60
+ export function isLanded(job) {
61
+ return job.run?.status === "ok" && job.run.gateDecision === "approve";
62
+ }
63
+ /** `deliver=local`: landed. `deliver=pr`: landed and the pull request has merged. */
64
+ export function isDone(job) {
65
+ if (!isLanded(job))
66
+ return false;
67
+ if ((job.deliver ?? "local") !== "pr")
68
+ return true;
69
+ return job.delivery?.merged === true || job.delivery?.prState === "MERGED";
70
+ }
71
+ function landedState(job) {
72
+ if ((job.deliver ?? "local") !== "pr")
73
+ return "done";
74
+ const delivery = job.delivery;
75
+ if (delivery?.merged || delivery?.prState === "MERGED")
76
+ return "done";
77
+ if (delivery?.prState === "CLOSED")
78
+ return "failed";
79
+ if (delivery?.prNumber)
80
+ return "in-review";
81
+ if (delivery?.error)
82
+ return "failed";
83
+ return "in-review";
84
+ }
85
+ function needsDelivery(job) {
86
+ if (job.error)
87
+ return false;
88
+ if ((job.deliver ?? "local") !== "pr")
89
+ return false;
90
+ if (!isLanded(job))
91
+ return false;
92
+ if (job.delivery?.merged || job.delivery?.prState === "MERGED")
93
+ return false;
94
+ if (job.delivery?.prState === "CLOSED")
95
+ return false;
96
+ if (job.delivery?.prNumber)
97
+ return false;
98
+ return true;
99
+ }
100
+ function canLaunch(job, observation) {
101
+ if (job.error || job.run || job.launching || job.launchFailed)
102
+ return false;
103
+ return dependenciesMet(job, observation);
104
+ }
105
+ export function decide(observation) {
106
+ const actions = observation.breadcrumbsToClear.map((crumb) => ({
107
+ type: "clear-breadcrumb",
108
+ path: crumb.path,
109
+ }));
110
+ for (const job of observation.jobs) {
111
+ if (!needsDelivery(job))
112
+ continue;
113
+ actions.push({ type: "deliver", jobId: job.jobId, repo: job.repo });
114
+ }
115
+ if (inFlightCount(observation) >= observation.maxConcurrent)
116
+ return actions;
117
+ const ready = observation.jobs.filter((job) => canLaunch(job, observation));
118
+ const healthy = ready.filter((job) => !job.launchError);
119
+ const picked = healthy[0] ?? ready[0];
120
+ if (picked)
121
+ actions.push({ type: "launch", jobId: picked.jobId, repo: picked.repo });
122
+ return actions;
123
+ }
package/dist/report.js ADDED
@@ -0,0 +1,157 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, join } from "node:path";
4
+ import { inFlightCount, isDone, isLanded, jobState, } from "./reconcile.js";
5
+ export function shellQuote(value) {
6
+ return `'${value.replaceAll("'", `'\\''`)}'`;
7
+ }
8
+ /** Absolute path of the yak CLI, from the installed package's `bin.yak`. */
9
+ export function yakCliPath() {
10
+ const require = createRequire(import.meta.url);
11
+ const pkgPath = require.resolve("@lchase/yak/package.json");
12
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
13
+ const relative = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.yak;
14
+ if (!relative) {
15
+ throw new Error(`${pkgPath} has no "yak" bin.`);
16
+ }
17
+ return join(dirname(pkgPath), relative);
18
+ }
19
+ export function yakInvocation(subcommand, runId) {
20
+ return `${shellQuote(process.execPath)} ${shellQuote(yakCliPath())} ${subcommand} ${shellQuote(runId)}`;
21
+ }
22
+ /** One variant of the command that answers an open gate and continues the run. */
23
+ export function gateAnswerCommand(checkout, runId, gate, choice, title) {
24
+ const pendingDir = shellQuote(`.runs/${runId}/pending`);
25
+ const answerFile = shellQuote(`.runs/${runId}/pending/${gate.stepId}.answer.json`);
26
+ const field = gate.answerField ?? "decision";
27
+ const answer = JSON.stringify({ [field]: choice });
28
+ const titlePrefix = title ? `KILN_TASK_TITLE=${shellQuote(title)} ` : "";
29
+ return `cd ${shellQuote(checkout)} && mkdir -p ${pendingDir} && printf '%s\\n' ${shellQuote(answer)} > ${answerFile} && ${titlePrefix}${yakInvocation("resume", runId)}`;
30
+ }
31
+ export function renderStatus(observation, jobId) {
32
+ const jobs = jobId ? observation.jobs.filter((job) => job.jobId === jobId) : observation.jobs;
33
+ if (jobId && jobs.length === 0) {
34
+ throw new Error(`No job ${jobId}.`);
35
+ }
36
+ if (jobs.length === 0)
37
+ return "no jobs\n";
38
+ const lines = [];
39
+ for (const job of jobs) {
40
+ if (job.error) {
41
+ lines.push(`${job.jobId} invalid: ${job.error}`);
42
+ continue;
43
+ }
44
+ const state = jobState(job, observation);
45
+ const run = job.run ? ` run ${job.run.runId}` : "";
46
+ const branch = job.run && (state === "done" || state === "in-review") ? ` branch yak/${job.run.runId}` : "";
47
+ const pr = prSuffix(job, state);
48
+ lines.push(`${job.jobId} ${job.repo} ${job.workflow} ${state}${run}${branch}${pr}`);
49
+ if (job.source)
50
+ lines.push(` source ${job.source.kind} ${job.source.ref}`);
51
+ if (state === "in-review" && !job.delivery?.prNumber) {
52
+ lines.push(" pull request not opened yet");
53
+ }
54
+ if (state === "failed" && job.delivery?.error) {
55
+ lines.push(` ${job.delivery.error}`);
56
+ }
57
+ if (state === "failed" && job.delivery?.prState === "CLOSED" && job.delivery.prNumber) {
58
+ lines.push(` pull request #${job.delivery.prNumber} was closed without merging`);
59
+ }
60
+ if (job.delivery?.viewError && (state === "in-review" || state === "done")) {
61
+ lines.push(` could not read pull request: ${job.delivery.viewError}`);
62
+ }
63
+ if (state === "failed" && job.launchFailed && job.logPath) {
64
+ lines.push(` log ${job.logPath}`);
65
+ }
66
+ if (state === "failed" && job.launchError) {
67
+ lines.push(` ${job.launchError}`);
68
+ }
69
+ if (state === "failed" && job.run?.status === "ok" && !job.run.gateDecision) {
70
+ lines.push(" no readable approval decision");
71
+ }
72
+ if (state === "dead" && job.run) {
73
+ lines.push(` fix: cd ${shellQuote(job.run.checkout)} && ${yakInvocation("cancel", job.run.runId)}`);
74
+ }
75
+ if (state === "blocked") {
76
+ for (const line of blockedLines(job, observation))
77
+ lines.push(line);
78
+ }
79
+ if (state === "needs-human" && job.run) {
80
+ for (const gate of job.run.gates) {
81
+ lines.push(` gate ${gate.stepId}: ${gate.prompt}`);
82
+ appendGateCommands(lines, job, gate);
83
+ }
84
+ }
85
+ }
86
+ return `${lines.join("\n")}\n`;
87
+ }
88
+ function prSuffix(job, state) {
89
+ const url = job.delivery?.prUrl;
90
+ if (!url)
91
+ return "";
92
+ if (state !== "in-review" && state !== "done" && state !== "failed")
93
+ return "";
94
+ return ` pr ${url}`;
95
+ }
96
+ function blockedLines(job, observation) {
97
+ const lines = [];
98
+ for (const dep of job.dependsOn) {
99
+ const upstream = observation.jobs.find((candidate) => candidate.jobId === dep);
100
+ if (!upstream) {
101
+ lines.push(` blocked on missing job ${dep}`);
102
+ continue;
103
+ }
104
+ if (job.stack) {
105
+ if (!isLanded(upstream))
106
+ lines.push(` waiting for ${dep} to land`);
107
+ continue;
108
+ }
109
+ if (isDone(upstream))
110
+ continue;
111
+ const number = upstream.delivery?.prNumber;
112
+ if (number && !upstream.delivery?.merged && upstream.delivery?.prState !== "CLOSED") {
113
+ lines.push(` waiting for PR #${number} to merge`);
114
+ continue;
115
+ }
116
+ if ((upstream.deliver ?? "local") === "pr" && isLanded(upstream)) {
117
+ lines.push(` waiting for ${dep} to open a pull request`);
118
+ }
119
+ }
120
+ return lines;
121
+ }
122
+ function appendGateCommands(lines, job, gate) {
123
+ const choices = gate.choices?.length ? gate.choices : ["approve", "reject"];
124
+ if (choices.includes("approve"))
125
+ lines.push(` approve: kiln approve ${job.jobId}`);
126
+ if (choices.includes("reject"))
127
+ lines.push(` reject: kiln reject ${job.jobId}`);
128
+ }
129
+ export function renderTick(observation, actions, launches, delivers = []) {
130
+ const lines = [`tick inFlight=${inFlightCount(observation)} maxConcurrent=${observation.maxConcurrent}`];
131
+ for (const job of observation.jobs) {
132
+ if (job.error)
133
+ lines.push(`skip ${job.jobId} (invalid: ${job.error})`);
134
+ }
135
+ for (const action of actions) {
136
+ if (action.type === "clear-breadcrumb")
137
+ lines.push(`clear breadcrumb ${action.path}`);
138
+ }
139
+ for (const deliver of delivers) {
140
+ lines.push(deliver.ok
141
+ ? `deliver ${deliver.jobId} ${deliver.repo} ${deliver.detail}`
142
+ : `deliver ${deliver.jobId} ${deliver.repo} failed: ${deliver.detail}`);
143
+ }
144
+ for (const launch of launches) {
145
+ lines.push(`launch ${launch.jobId} ${launch.repo}`);
146
+ for (const line of launch.output.split("\n")) {
147
+ if (line.trim())
148
+ lines.push(` ${line}`);
149
+ }
150
+ }
151
+ if (!actions.some((action) => action.type === "launch") &&
152
+ launches.length === 0 &&
153
+ delivers.length === 0) {
154
+ lines.push("nothing to launch");
155
+ }
156
+ return `${lines.join("\n")}\n`;
157
+ }
@@ -0,0 +1,90 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Kiln config file. `repos` is an open map: any name, not a fixed set.
4
+ * `path` is absolute, relative to the config file's directory, or `~`.
5
+ * `test` is the shell command a workflow runs as its verify step (`KILN_TEST`).
6
+ */
7
+ /**
8
+ * `deliver` defaults to `local` when omitted: land on `yak/<run-id>` and do not push.
9
+ * `pr` pushes that branch and opens a draft pull request against `base`.
10
+ * `base` defaults to origin's default branch, discovered with git, when omitted.
11
+ */
12
+ export const RepoEntrySchema = z.object({
13
+ path: z.string().min(1),
14
+ test: z.string().min(1).optional(),
15
+ deliver: z.enum(["local", "pr"]).optional(),
16
+ base: z.string().min(1).optional(),
17
+ });
18
+ /**
19
+ * Optional Jira Cloud credentials in the gitignored kiln config.
20
+ * `JIRA_BASE_URL`, `JIRA_EMAIL`, and `JIRA_API_TOKEN` override these.
21
+ * Empty strings are treated as unset. The token is never copied into a job.
22
+ */
23
+ export const JiraConfigSchema = z.object({
24
+ baseUrl: z.string().optional(),
25
+ email: z.string().optional(),
26
+ apiToken: z.string().optional(),
27
+ });
28
+ export const KilnConfigFileSchema = z.object({
29
+ note: z.string().min(1).optional(),
30
+ maxConcurrent: z.number().int().min(1).optional(),
31
+ adapter: z.enum(["mock", "claude-code"]).optional(),
32
+ repos: z.record(z.string(), RepoEntrySchema),
33
+ jira: JiraConfigSchema.optional(),
34
+ });
35
+ /**
36
+ * Where a job came from. Adapters set this; tick and reconcile do not branch on it.
37
+ * `ref` is an absolute spec path (`file`), `inline` or `stdin` (`prompt`), the
38
+ * absolute ticket path (`tickets`), or the issue key (`jira`, such as `PROJ-123`).
39
+ */
40
+ export const JobSourceSchema = z.object({
41
+ kind: z.enum(["file", "prompt", "tickets", "jira"]),
42
+ ref: z.string().min(1),
43
+ });
44
+ /**
45
+ * One unit of factory work. Stored as `<kilnDir>/jobs/<id>.json`.
46
+ * `spec` is a markdown file under the kiln directory.
47
+ * `dependsOn` names other job ids. There is no plan and no stored state.
48
+ * Every intake writes this same shape. `source` is omitted on jobs written
49
+ * before intake; those files still parse.
50
+ */
51
+ export const JobSchema = z.object({
52
+ id: z.string().min(1),
53
+ repo: z.string().min(1),
54
+ workflow: z.string().min(1),
55
+ spec: z.string().min(1),
56
+ title: z.string().min(1),
57
+ dependsOn: z.array(z.string().min(1)).optional(),
58
+ /**
59
+ * When true, this job starts once its single upstream has landed, on that
60
+ * upstream's branch. Omitted means after-merged: wait until every upstream
61
+ * is `done`. More than one `dependsOn` with `stack` is rejected at add time.
62
+ */
63
+ stack: z.boolean().optional(),
64
+ source: JobSourceSchema.optional(),
65
+ createdAt: z.string().min(1),
66
+ });
67
+ /**
68
+ * Derived from the yak journal, gate files, and (for `deliver=pr`) the
69
+ * delivery record plus `gh pr view`. Never written into the job file.
70
+ *
71
+ * For `deliver=local`, `done` is a run that finished ok with an approve
72
+ * decision. For `deliver=pr`, `done` is that landed run whose pull request
73
+ * has merged (`state` or `mergedAt`, so a squash merge counts). `in-review`
74
+ * is the landed run while the pull request is still open. A reject, a
75
+ * failure, a dead process, and an ok run with no readable approval do not
76
+ * unblock an after-merged dependent. A stacked job unblocks when its one
77
+ * upstream has landed, which includes `in-review`.
78
+ */
79
+ export const JOB_STATES = [
80
+ "queued",
81
+ "blocked",
82
+ "running",
83
+ "needs-human",
84
+ "approved",
85
+ "rejected",
86
+ "failed",
87
+ "dead",
88
+ "in-review",
89
+ "done",
90
+ ];
@@ -0,0 +1,122 @@
1
+ import { spawn } from "node:child_process";
2
+ import { writeSync } from "node:fs";
3
+ import { pathToFileURL } from "node:url";
4
+ /** Last lines of verify output kept for later prompts. */
5
+ export const VERIFY_MAX_LINES = 200;
6
+ /** Last bytes of verify output kept for later prompts, after the line cut. */
7
+ export const VERIFY_MAX_BYTES = 20 * 1024;
8
+ const RAW_CAP = 1024 * 1024;
9
+ /**
10
+ * Keep the tail of a verify stream so a long test log cannot land whole in
11
+ * the next prompt. A cut is marked on the first line.
12
+ */
13
+ export function truncateCaptured(text, stream) {
14
+ const lined = lastLines(text, VERIFY_MAX_LINES);
15
+ const tailed = lastBytes(lined.text, VERIFY_MAX_BYTES);
16
+ if (!lined.cut && !tailed.cut)
17
+ return text;
18
+ const note = `[truncated: showing the last ${VERIFY_MAX_LINES} lines or ${VERIFY_MAX_BYTES} bytes of ${stream}]\n`;
19
+ return note + tailed.text;
20
+ }
21
+ function lastLines(text, maxLines) {
22
+ if (text.length === 0)
23
+ return { text, cut: false };
24
+ const trailingNewline = text.endsWith("\n");
25
+ const parts = text.split("\n");
26
+ if (trailingNewline)
27
+ parts.pop();
28
+ if (parts.length <= maxLines)
29
+ return { text, cut: false };
30
+ const kept = parts.slice(-maxLines);
31
+ return { text: kept.join("\n") + (trailingNewline ? "\n" : ""), cut: true };
32
+ }
33
+ function lastBytes(text, maxBytes) {
34
+ const buf = Buffer.from(text, "utf8");
35
+ if (buf.length <= maxBytes)
36
+ return { text, cut: false };
37
+ let start = buf.length - maxBytes;
38
+ while (start < buf.length && (buf[start] & 0xc0) === 0x80)
39
+ start += 1;
40
+ return { text: buf.subarray(start).toString("utf8"), cut: true };
41
+ }
42
+ function capTail(current, chunk, cap) {
43
+ let piece = chunk;
44
+ let cut = false;
45
+ if (Buffer.byteLength(piece, "utf8") > cap) {
46
+ piece = lastBytes(piece, cap).text;
47
+ cut = true;
48
+ current = "";
49
+ }
50
+ const next = current + piece;
51
+ if (Buffer.byteLength(next, "utf8") <= cap)
52
+ return { text: next, cut };
53
+ return { text: lastBytes(next, cap).text, cut: true };
54
+ }
55
+ /**
56
+ * `process.stdout.write` queues behind an in-flight write. `process.exit`
57
+ * leaves that queue in the process and the parent's `close` handler never
58
+ * sees it. A blocking write puts the bytes in the pipe first.
59
+ */
60
+ function writeAll(fd, text) {
61
+ if (text.length === 0)
62
+ return;
63
+ const buf = Buffer.from(text);
64
+ let offset = 0;
65
+ while (offset < buf.length) {
66
+ let wrote = 0;
67
+ try {
68
+ wrote = writeSync(fd, buf, offset);
69
+ }
70
+ catch (error) {
71
+ const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : "";
72
+ if (code === "EAGAIN" || code === "EWOULDBLOCK")
73
+ continue;
74
+ throw error;
75
+ }
76
+ if (wrote <= 0)
77
+ return;
78
+ offset += wrote;
79
+ }
80
+ }
81
+ function isDirectRun() {
82
+ const entry = process.argv[1];
83
+ if (!entry)
84
+ return false;
85
+ return import.meta.url === pathToFileURL(entry).href;
86
+ }
87
+ function main() {
88
+ const command = process.env.KILN_TEST;
89
+ if (!command?.trim()) {
90
+ writeAll(2, "KILN_TEST is empty\n");
91
+ process.exit(1);
92
+ }
93
+ const child = spawn(command, { shell: true, stdio: ["ignore", "pipe", "pipe"] });
94
+ let stdout = "";
95
+ let stderr = "";
96
+ let settled = false;
97
+ const finish = (code) => {
98
+ if (settled)
99
+ return;
100
+ settled = true;
101
+ writeAll(1, truncateCaptured(stdout, "stdout"));
102
+ writeAll(2, truncateCaptured(stderr, "stderr"));
103
+ process.exit(code);
104
+ };
105
+ child.stdout?.setEncoding("utf8");
106
+ child.stderr?.setEncoding("utf8");
107
+ child.stdout?.on("data", (chunk) => {
108
+ stdout = capTail(stdout, chunk, RAW_CAP).text;
109
+ });
110
+ child.stderr?.on("data", (chunk) => {
111
+ stderr = capTail(stderr, chunk, RAW_CAP).text;
112
+ });
113
+ child.on("error", (error) => {
114
+ stderr += `${error.message}\n`;
115
+ finish(1);
116
+ });
117
+ child.on("close", (code) => {
118
+ finish(code ?? 1);
119
+ });
120
+ }
121
+ if (isDirectRun())
122
+ main();
@@ -0,0 +1,113 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { basename, join, sep } from "node:path";
4
+ import { jobBasename, packageRoot } from "./paths.js";
5
+ const FACTORY_DIRECTIVE = /^<!--\s*factory:\s*(prompts\/factory\/[a-z0-9-]+\.md)\s*-->/;
6
+ const WORKFLOW_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
7
+ const FEATURE_PROMPTS = 7;
8
+ /**
9
+ * Values Kiln can pass into a workflow. A workflow's inputSchema lists the
10
+ * ones it uses. `description` and `acceptance` stay so
11
+ * `workflows/feature.yaml` can keep its schema: both are the spec text.
12
+ */
13
+ const INPUT_VALUES = {
14
+ title: (input) => input.title,
15
+ spec: (input) => input.specText,
16
+ baseRef: (input) => input.baseRef,
17
+ runNonce: (input) => input.runNonce,
18
+ description: (input) => input.specText,
19
+ acceptance: (input) => input.specText,
20
+ };
21
+ export function assertWorkflowName(name) {
22
+ if (!WORKFLOW_NAME.test(name)) {
23
+ throw new Error(`Workflow name ${JSON.stringify(name)} must start with a letter or digit and contain only letters, digits, dots, underscores, and hyphens.`);
24
+ }
25
+ const path = join(packageRoot(), "workflows", `${name}.yaml`);
26
+ if (!existsSync(path)) {
27
+ throw new Error(`Unknown workflow ${name}. Expected ${path}.`);
28
+ }
29
+ return path;
30
+ }
31
+ /**
32
+ * Yak reads `prompt.file` with `path.resolve(cwd, file)`, and Kiln launches
33
+ * yak with cwd set to the product checkout. Rewrite `prompts/kiln/<name>.md`
34
+ * to an absolute file under the kiln directory: the Kiln preamble plus the
35
+ * verbatim factory prompt. The content hash is part of the filename, so a
36
+ * prompt edit changes the path yak caches on.
37
+ */
38
+ export function materializeWorkflow(kilnDir, jobId, workflowName) {
39
+ const sourcePath = assertWorkflowName(workflowName);
40
+ const source = readFileSync(sourcePath, "utf8");
41
+ const destDir = join(kilnDir, "rendered", jobBasename(jobId));
42
+ mkdirSync(destDir, { recursive: true });
43
+ let count = 0;
44
+ const rendered = source.replace(/file: prompts\/kiln\/([a-z0-9-]+)\.md/g, (_full, name) => {
45
+ count += 1;
46
+ const rel = `prompts/kiln/${name}.md`;
47
+ const absolute = writeCombinedPrompt(rel, destDir);
48
+ return `file: ${yamlDoubleQuote(absolute)}`;
49
+ });
50
+ if (workflowName === "feature" && count !== FEATURE_PROMPTS) {
51
+ throw new Error(`Feature workflow has ${count} prompts/kiln refs; expected ${FEATURE_PROMPTS}. Refusing to launch a partial prompt set.`);
52
+ }
53
+ if (rendered.includes("file: prompts/")) {
54
+ throw new Error(`Rendered ${workflowName} workflow still has a checkout-relative prompt path.`);
55
+ }
56
+ const workflowPath = join(destDir, `${workflowName}.yaml`);
57
+ writeFileSync(workflowPath, rendered);
58
+ return workflowPath;
59
+ }
60
+ /** The subset of {@link INPUT_VALUES} that this workflow's inputSchema declares. */
61
+ export function workflowInputs(workflowYaml, input) {
62
+ const names = declaredInputProperties(workflowYaml);
63
+ const out = {};
64
+ for (const name of names) {
65
+ const fill = INPUT_VALUES[name];
66
+ if (!fill) {
67
+ throw new Error(`Workflow input "${name}" is not a Kiln launch input. Kiln passes title, spec, baseRef, and runNonce (and KILN_TEST in the environment).`);
68
+ }
69
+ out[name] = fill(input);
70
+ }
71
+ return out;
72
+ }
73
+ function declaredInputProperties(yaml) {
74
+ const at = yaml.indexOf("\ninputSchema:");
75
+ const start = at >= 0 ? at : yaml.startsWith("inputSchema:") ? 0 : -1;
76
+ if (start < 0) {
77
+ throw new Error("Workflow has no inputSchema. Kiln passes title, spec, baseRef, and runNonce.");
78
+ }
79
+ const slice = yaml.slice(start);
80
+ const props = slice.match(/\n[^\n]*properties:\n([\s\S]*?)\n[^\n]*required:/);
81
+ if (!props?.[1]) {
82
+ throw new Error("Workflow inputSchema has no properties block.");
83
+ }
84
+ const names = [...props[1].matchAll(/(?:^|\n)\s+([A-Za-z_][A-Za-z0-9_-]*):/g)].map((match) => match[1] ?? "");
85
+ if (names.length === 0 || names.some((name) => name.length === 0)) {
86
+ throw new Error("Workflow inputSchema lists no input properties.");
87
+ }
88
+ return names;
89
+ }
90
+ function writeCombinedPrompt(preambleRel, destDir) {
91
+ const root = packageRoot();
92
+ const preamblePath = join(root, preambleRel);
93
+ const preamble = readFileSync(preamblePath, "utf8");
94
+ const directive = preamble.match(FACTORY_DIRECTIVE);
95
+ if (!directive?.[1]) {
96
+ throw new Error(`${preambleRel} must start with <!-- factory: prompts/factory/<file>.md -->`);
97
+ }
98
+ const factoryRel = directive[1];
99
+ const factoryPath = join(root, factoryRel);
100
+ const factoryRoot = join(root, "prompts", "factory") + sep;
101
+ if (!factoryPath.startsWith(factoryRoot)) {
102
+ throw new Error(`${preambleRel} factory path ${factoryRel} escapes prompts/factory.`);
103
+ }
104
+ const factory = readFileSync(factoryPath, "utf8");
105
+ const combined = `${preamble.trimEnd()}\n\n${factory.trimStart()}`;
106
+ const digest = createHash("sha256").update(combined).digest("hex").slice(0, 16);
107
+ const outPath = join(destDir, `${basename(preambleRel, ".md")}-${digest}.md`);
108
+ writeFileSync(outPath, combined);
109
+ return outPath;
110
+ }
111
+ function yamlDoubleQuote(value) {
112
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
113
+ }
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@lchase/kiln",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Run a workflow on any registered repo. A markdown spec is the input.",
5
+ "type": "module",
6
+ "bin": {
7
+ "kiln": "./dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "workflows",
12
+ "prompts",
13
+ "README.md"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/lchase/kiln.git"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.json && chmod +x dist/cli.js",
24
+ "kiln": "node dist/cli.js",
25
+ "prekiln": "npm run build",
26
+ "prepack": "npm run build",
27
+ "prepublishOnly": "npm run build",
28
+ "test": "tsc -p tsconfig.json && tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js",
29
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit"
30
+ },
31
+ "engines": {
32
+ "node": ">=22"
33
+ },
34
+ "dependencies": {
35
+ "@lchase/yak": "0.7.1",
36
+ "zod": "^4.6.7"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^22.19.0",
40
+ "typescript": "^5.9.3"
41
+ }
42
+ }