@lchase/kiln 0.8.0 → 0.10.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,73 @@
1
+ import { spawnSync } from "node:child_process";
2
+ export function fixTrailerLines(input) {
3
+ return [`Kiln-Job: ${input.jobId}`, `Kiln-Round: ${input.round}`, `Kiln-Prompts-Hash: ${input.promptsHash}`];
4
+ }
5
+ /**
6
+ * The fix workflow `land` step. `workflows/fix.yaml` keeps the same command.
7
+ * Launch sets `KILN_JOB`, `KILN_ROUND`, and `KILN_PROMPTS_HASH`.
8
+ */
9
+ export const FIX_LAND_COMMAND = 'git add -A && git diff --cached --quiet || git commit -m "kiln: $KILN_TASK_TITLE" --trailer "Kiln-Job: $KILN_JOB" --trailer "Kiln-Round: $KILN_ROUND" --trailer "Kiln-Prompts-Hash: $KILN_PROMPTS_HASH"';
10
+ /**
11
+ * Make the tip of `dir` carry the fix-round trailers.
12
+ * A dirty tree is committed. A tip that already moved and lacks the trailers
13
+ * is amended. A clean tree still on `baseSha` is left alone.
14
+ */
15
+ export function ensureFixTrailers(dir, input, baseSha) {
16
+ if (!hasTrailers(dir, input)) {
17
+ if (isDirty(dir)) {
18
+ commit(dir, input, false);
19
+ return "committed";
20
+ }
21
+ const head = revParse(dir, "HEAD");
22
+ if (!head || (baseSha && head === baseSha))
23
+ return "clean";
24
+ commit(dir, input, true);
25
+ return "amended";
26
+ }
27
+ return "present";
28
+ }
29
+ export function commitMessageHasTrailers(message, input) {
30
+ return fixTrailerLines(input).every((line) => message.split("\n").some((entry) => entry.trim() === line));
31
+ }
32
+ function hasTrailers(dir, input) {
33
+ const message = git(dir, ["log", "-1", "--format=%B"]);
34
+ if (message.status !== 0)
35
+ return false;
36
+ return commitMessageHasTrailers(message.stdout, input);
37
+ }
38
+ function isDirty(dir) {
39
+ const status = git(dir, ["status", "--porcelain"]);
40
+ if (status.status !== 0)
41
+ return false;
42
+ return status.stdout.trim().length > 0;
43
+ }
44
+ function revParse(dir, ref) {
45
+ const result = git(dir, ["rev-parse", ref]);
46
+ if (result.status !== 0)
47
+ return undefined;
48
+ const sha = result.stdout.trim();
49
+ return sha || undefined;
50
+ }
51
+ function commit(dir, input, amend) {
52
+ if (!amend) {
53
+ const added = git(dir, ["add", "-A"]);
54
+ if (added.status !== 0)
55
+ return;
56
+ }
57
+ const args = ["commit"];
58
+ if (amend)
59
+ args.push("--amend", "--no-edit");
60
+ else
61
+ args.push("-m", `kiln: ${input.title}`);
62
+ for (const line of fixTrailerLines(input))
63
+ args.push("--trailer", line);
64
+ git(dir, args);
65
+ }
66
+ function git(cwd, args) {
67
+ const result = spawnSync("git", args, {
68
+ cwd,
69
+ encoding: "utf8",
70
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
71
+ });
72
+ return { status: result.status ?? 1, stdout: result.stdout ?? "" };
73
+ }
package/dist/jobs.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { basename, isAbsolute, join } from "node:path";
4
+ import { appendEvent } from "./events.js";
4
5
  import { INVESTIGATION_WORKFLOW } from "./findings.js";
5
6
  import { jobBasename } from "./paths.js";
6
7
  import { JobSchema, parseIssueKey } from "./schemas.js";
@@ -88,6 +89,17 @@ export function writeJob(config, input) {
88
89
  mkdirSync(config.jobsDir, { recursive: true });
89
90
  const jsonPath = jobFilePath(config.jobsDir, job.id);
90
91
  writeFileSync(jsonPath, `${JSON.stringify(job, null, 2)}\n`);
92
+ appendEvent(config.kilnDir, {
93
+ job_id: job.id,
94
+ event: "job.added",
95
+ properties: {
96
+ repo: job.repo,
97
+ workflow: job.workflow,
98
+ title: job.title,
99
+ ...(job.source ? { source_kind: job.source.kind } : {}),
100
+ once: "job.added",
101
+ },
102
+ });
91
103
  return { job, jsonPath };
92
104
  }
93
105
  export function renderAddedJob(job, jsonPath) {
package/dist/ledger.js ADDED
@@ -0,0 +1,152 @@
1
+ import { readEvents } from "./events.js";
2
+ import { redact } from "./redact.js";
3
+ import { formatSpend, spendSnapshot } from "./rails.js";
4
+ /**
5
+ * Interventions are derived from the event log. Nothing is stored beside it.
6
+ * A human had to act when a gate was rejected, a job was retried by hand,
7
+ * a gate carried a manual edit note, or the job stopped as needs-human.
8
+ */
9
+ const SINCE = /^(\d+)([dhms])$/;
10
+ const REASON_RANK = ["gate rejected", "manual edit", "re-queued or retried by hand"];
11
+ /** `7d`, `12h`, `30m`, `45s`, or an ISO-8601 time. */
12
+ export function parseSince(raw, now = new Date()) {
13
+ const match = SINCE.exec(raw.trim());
14
+ if (match?.[1] && match[2]) {
15
+ const count = Number(match[1]);
16
+ const unit = match[2];
17
+ const ms = unit === "d" ? 86_400_000 : unit === "h" ? 3_600_000 : unit === "m" ? 60_000 : 1_000;
18
+ return new Date(now.getTime() - count * ms);
19
+ }
20
+ const parsed = Date.parse(raw);
21
+ if (!Number.isFinite(parsed)) {
22
+ throw new Error(`--since ${JSON.stringify(raw)} is not a duration like 7d or an ISO-8601 time.`);
23
+ }
24
+ return new Date(parsed);
25
+ }
26
+ export function ledgerRows(events, options = {}) {
27
+ const repos = repoByJob(events);
28
+ const counts = new Map();
29
+ for (const event of events) {
30
+ if (!event.job_id)
31
+ continue;
32
+ if (options.since && Date.parse(event.ts) < options.since.getTime())
33
+ continue;
34
+ const repo = repos.get(event.job_id) ?? "(no repo)";
35
+ if (options.repo !== undefined && repo !== options.repo)
36
+ continue;
37
+ for (const reason of reasonsFor(event)) {
38
+ const jobs = counts.get(repo) ?? new Map();
39
+ const key = `${event.job_id}\0${reason}`;
40
+ jobs.set(key, (jobs.get(key) ?? 0) + 1);
41
+ counts.set(repo, jobs);
42
+ }
43
+ }
44
+ const rows = [];
45
+ for (const repo of [...counts.keys()].sort()) {
46
+ const jobs = counts.get(repo);
47
+ if (!jobs)
48
+ continue;
49
+ const byJob = new Map();
50
+ for (const [key, count] of jobs) {
51
+ const split = key.indexOf("\0");
52
+ const jobId = key.slice(0, split);
53
+ const reason = key.slice(split + 1);
54
+ const list = byJob.get(jobId) ?? [];
55
+ list.push({ reason, count });
56
+ byJob.set(jobId, list);
57
+ }
58
+ for (const jobId of [...byJob.keys()].sort()) {
59
+ const reasons = byJob.get(jobId) ?? [];
60
+ reasons.sort((left, right) => rank(left.reason) - rank(right.reason) || left.reason.localeCompare(right.reason));
61
+ const total = reasons.reduce((sum, item) => sum + item.count, 0);
62
+ rows.push({ repo, jobId, reasons, total });
63
+ }
64
+ }
65
+ return rows;
66
+ }
67
+ export function renderLedger(events, options = {}) {
68
+ const spend = options.spend ??
69
+ (options.dailyDollarCap !== undefined || options.now
70
+ ? spendSnapshot(events, options.dailyDollarCap, options.now ?? new Date())
71
+ : undefined);
72
+ const head = spend ? `${formatSpend(spend)}\n` : "";
73
+ const rows = ledgerRows(events, options);
74
+ if (rows.length === 0)
75
+ return redact(`${head}0 interventions\n`);
76
+ const lines = [];
77
+ let grand = 0;
78
+ let index = 0;
79
+ while (index < rows.length) {
80
+ const repo = rows[index]?.repo ?? "";
81
+ const group = [];
82
+ while (index < rows.length && rows[index]?.repo === repo) {
83
+ const row = rows[index];
84
+ if (row)
85
+ group.push(row);
86
+ index += 1;
87
+ }
88
+ const repoTotal = group.reduce((sum, row) => sum + row.total, 0);
89
+ lines.push(`${repo} ${repoTotal}`);
90
+ for (const row of group) {
91
+ lines.push(` ${row.jobId} ${row.total}`);
92
+ for (const reason of row.reasons)
93
+ lines.push(` ${reason.reason} ${reason.count}`);
94
+ }
95
+ grand += repoTotal;
96
+ }
97
+ lines.push("");
98
+ lines.push(`${grand} ${grand === 1 ? "intervention" : "interventions"}`);
99
+ return redact(`${head}${lines.join("\n")}\n`);
100
+ }
101
+ export function renderLedgerFile(kilnDir, options = {}) {
102
+ const events = readEvents(kilnDir);
103
+ const spend = spendSnapshot(events, options.dailyDollarCap, options.now ?? new Date());
104
+ return renderLedger(events, { ...options, spend });
105
+ }
106
+ function reasonsFor(event) {
107
+ const reasons = [];
108
+ if (event.event === "gate.resolved" && event.properties.decision === "reject")
109
+ reasons.push("gate rejected");
110
+ if ((event.event === "gate.resolved" || event.event === "gate.opened") && hasEdit(event.properties)) {
111
+ reasons.push("manual edit");
112
+ }
113
+ if (event.event === "job.retried")
114
+ reasons.push("re-queued or retried by hand");
115
+ if (event.event === "needs-human")
116
+ reasons.push(`needs-human (${event.properties.reason})`);
117
+ return reasons;
118
+ }
119
+ function hasEdit(properties) {
120
+ return Boolean(properties.note?.trim() || properties.edit?.trim());
121
+ }
122
+ function repoByJob(events) {
123
+ const repos = new Map();
124
+ for (const event of events) {
125
+ if (event.event === "job.added" && event.job_id)
126
+ repos.set(event.job_id, event.properties.repo);
127
+ }
128
+ for (const event of events) {
129
+ if (!event.job_id || repos.has(event.job_id))
130
+ continue;
131
+ const repo = repoOf(event);
132
+ if (repo)
133
+ repos.set(event.job_id, repo);
134
+ }
135
+ return repos;
136
+ }
137
+ function repoOf(event) {
138
+ if (event.event === "job.added" || event.event === "job.retried" || event.event === "run.started") {
139
+ return event.properties.repo;
140
+ }
141
+ if (event.event === "gate.resolved")
142
+ return event.properties.repo;
143
+ return undefined;
144
+ }
145
+ function rank(reason) {
146
+ const index = REASON_RANK.indexOf(reason);
147
+ if (index >= 0)
148
+ return index;
149
+ if (reason.startsWith("needs-human"))
150
+ return REASON_RANK.length;
151
+ return REASON_RANK.length + 1;
152
+ }
@@ -0,0 +1,394 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { basename, join, relative, sep } from "node:path";
5
+ import { appendEvent } from "./events.js";
6
+ import { costFromJournal, decideFailure } from "./failure.js";
7
+ import { jobBasename, packageRoot } from "./paths.js";
8
+ /**
9
+ * Project a yak journal into the kiln event log. Called when a run is
10
+ * observed. `properties.once` makes a second observation of the same journal
11
+ * line a no-op.
12
+ *
13
+ * `run.started` carries the kiln version, the yak version, the workflow
14
+ * name, and a hash of the prompts and skills that workflow is using.
15
+ */
16
+ const REVIEW_STEPS = new Set(["security", "ux", "ui", "code"]);
17
+ const CLIP = 240;
18
+ export function projectRunJournal(input) {
19
+ try {
20
+ project(input);
21
+ }
22
+ catch (error) {
23
+ const message = error instanceof Error ? error.message : String(error);
24
+ console.error(`kiln: event log: ${message}`);
25
+ }
26
+ }
27
+ export function kilnVersion() {
28
+ return packageVersion(join(packageRoot(), "package.json"));
29
+ }
30
+ export function yakVersion() {
31
+ const require = createRequire(import.meta.url);
32
+ return packageVersion(require.resolve("@lchase/yak/package.json"));
33
+ }
34
+ /**
35
+ * SHA-256 of the prompt files this workflow launches with, then any files
36
+ * under the package `skills/` directory. The rendered prompt (preamble plus
37
+ * factory body) is what the run uses when it is already on disk. Otherwise
38
+ * the packaged prompt, combined the same way, is hashed. Labels are basenames
39
+ * for prompts and package-relative paths for skills, so the hash does not
40
+ * depend on the machine path.
41
+ */
42
+ export function promptsHash(workflow, kilnDir, jobId) {
43
+ const hash = createHash("sha256");
44
+ hash.update("prompts\0");
45
+ for (const file of promptFiles(workflow, kilnDir, jobId)) {
46
+ hash.update(file.label);
47
+ hash.update("\0");
48
+ hash.update(file.body);
49
+ hash.update("\0");
50
+ }
51
+ hash.update("skills\0");
52
+ for (const file of skillFiles()) {
53
+ hash.update(file.label);
54
+ hash.update("\0");
55
+ hash.update(file.body);
56
+ hash.update("\0");
57
+ }
58
+ return hash.digest("hex");
59
+ }
60
+ function project(input) {
61
+ const workflow = workflowName(input);
62
+ for (let index = 0; index < input.events.length; index += 1) {
63
+ const event = asRecord(input.events[index]);
64
+ if (!event || typeof event.t !== "string")
65
+ continue;
66
+ if (event.t === "run.started")
67
+ recordRunStarted(input, workflow);
68
+ else if (event.t === "step.started")
69
+ recordPhaseStarted(input, event, index);
70
+ else if (event.t === "step.completed" || event.t === "step.failed")
71
+ recordPhaseFinished(input, event, index);
72
+ else if (event.t === "gate.opened")
73
+ recordGateOpened(input, event, index);
74
+ else if (event.t === "gate.answered")
75
+ recordGateResolved(input, event);
76
+ else if (event.t === "run.finished")
77
+ recordRunFinished(input, event);
78
+ }
79
+ }
80
+ function recordRunStarted(input, workflow) {
81
+ appendEvent(input.kilnDir, {
82
+ job_id: input.jobId,
83
+ run_id: input.runId,
84
+ event: "run.started",
85
+ properties: {
86
+ kiln_version: kilnVersion(),
87
+ yak_version: yakVersion(),
88
+ workflow,
89
+ prompts_hash: promptsHash(workflow, input.kilnDir, input.jobId),
90
+ repo: input.repo || "unknown",
91
+ once: "run.started",
92
+ },
93
+ });
94
+ }
95
+ function recordPhaseStarted(input, event, index) {
96
+ const step = typeof event.stepId === "string" ? event.stepId : "";
97
+ if (!step)
98
+ return;
99
+ const iteration = iterationOf(event);
100
+ appendEvent(input.kilnDir, {
101
+ job_id: input.jobId,
102
+ run_id: input.runId,
103
+ event: "phase.started",
104
+ step,
105
+ properties: {
106
+ ...(iteration !== undefined ? { iteration } : {}),
107
+ once: `phase.started:${index}`,
108
+ },
109
+ });
110
+ }
111
+ function recordPhaseFinished(input, event, index) {
112
+ const step = typeof event.stepId === "string" ? event.stepId : "";
113
+ if (!step)
114
+ return;
115
+ const failed = event.t === "step.failed";
116
+ const iteration = iterationOf(event);
117
+ const artifactName = typeof event.artifact === "string" ? event.artifact : step;
118
+ const artifact = failed ? undefined : readArtifact(input.runDir, artifactName, iteration);
119
+ const failure = failed ? asRecord(event.failure) : undefined;
120
+ const reason = typeof failure?.reason === "string" ? clip(failure.reason) : undefined;
121
+ const detail = typeof failure?.detail === "string" ? clip(failure.detail) : undefined;
122
+ const verdict = artifact && typeof artifact.verdict === "string" ? clip(artifact.verdict) : undefined;
123
+ const exitCode = step === "verify" && artifact && typeof artifact.exitCode === "number" && Number.isInteger(artifact.exitCode)
124
+ ? artifact.exitCode
125
+ : undefined;
126
+ const testCommand = step === "verify" && input.testCommand?.trim() ? clip(input.testCommand.trim()) : undefined;
127
+ appendEvent(input.kilnDir, {
128
+ job_id: input.jobId,
129
+ run_id: input.runId,
130
+ event: "phase.finished",
131
+ step,
132
+ properties: {
133
+ outcome: failed ? "failed" : "completed",
134
+ ...(iteration !== undefined ? { iteration } : {}),
135
+ ...(typeof event.cached === "boolean" ? { cached: event.cached } : {}),
136
+ ...(event.skipped === true ? { skipped: true } : {}),
137
+ ...(reason ? { reason } : {}),
138
+ ...(detail ? { detail } : {}),
139
+ ...(verdict && REVIEW_STEPS.has(step) ? { verdict } : {}),
140
+ ...(testCommand ? { test_command: testCommand } : {}),
141
+ ...(exitCode !== undefined ? { exit_code: exitCode } : {}),
142
+ once: `phase.finished:${index}`,
143
+ },
144
+ });
145
+ }
146
+ function recordGateOpened(input, event, index) {
147
+ const step = typeof event.stepId === "string" ? event.stepId : "";
148
+ if (!step)
149
+ return;
150
+ const request = readJson(join(input.runDir, "pending", `${step}.request.json`));
151
+ const rendered = request && typeof request.rendered === "string" ? clip(request.rendered.split("\n")[0] ?? "") : "";
152
+ const noted = noteFrom(request);
153
+ appendEvent(input.kilnDir, {
154
+ job_id: input.jobId,
155
+ run_id: input.runId,
156
+ event: "gate.opened",
157
+ step,
158
+ properties: {
159
+ ...(rendered ? { prompt: rendered } : {}),
160
+ ...(noted ?? {}),
161
+ once: `gate.opened:${index}`,
162
+ },
163
+ });
164
+ }
165
+ function recordGateResolved(input, event) {
166
+ const step = typeof event.stepId === "string" ? event.stepId : "";
167
+ if (!step || event.skipped === true)
168
+ return;
169
+ const answer = readJson(join(input.runDir, "pending", `${step}.answer.json`)) ??
170
+ readJson(join(input.runDir, "artifacts", "approval.json"));
171
+ const decision = answer && typeof answer.decision === "string" ? answer.decision : "";
172
+ if (!decision)
173
+ return;
174
+ const noted = noteFrom(answer);
175
+ appendEvent(input.kilnDir, {
176
+ job_id: input.jobId,
177
+ run_id: input.runId,
178
+ event: "gate.resolved",
179
+ step,
180
+ properties: {
181
+ decision,
182
+ by: "human",
183
+ ...(input.repo ? { repo: input.repo } : {}),
184
+ ...(noted ?? {}),
185
+ once: `gate.resolved:${step}`,
186
+ },
187
+ });
188
+ }
189
+ function recordRunFinished(input, event) {
190
+ const cost = costFromJournal(input.events);
191
+ if (event.status === "ok") {
192
+ appendEvent(input.kilnDir, {
193
+ job_id: input.jobId,
194
+ run_id: input.runId,
195
+ event: "run.finished",
196
+ properties: { status: "ok", tokens: cost.tokens, usd: cost.usd, once: "run.finished" },
197
+ });
198
+ return;
199
+ }
200
+ // A normal failure is classified in recoverRuns, which owns the retry decision.
201
+ // A cancelled run is not recovered, so the class is recorded here.
202
+ if (event.status !== "failed" || event.reason !== "cancelled")
203
+ return;
204
+ const decision = decideFailure({
205
+ text: "cancelled",
206
+ operational: false,
207
+ blocked: true,
208
+ attempt: 1,
209
+ maxAttempts: Number.MAX_SAFE_INTEGER,
210
+ sameFingerprint: 1,
211
+ maxSameFingerprint: Number.MAX_SAFE_INTEGER,
212
+ });
213
+ appendEvent(input.kilnDir, {
214
+ job_id: input.jobId,
215
+ run_id: input.runId,
216
+ event: "run.failed",
217
+ properties: {
218
+ status: "failed",
219
+ reason: "cancelled",
220
+ class: decision.class,
221
+ fingerprint: decision.fingerprint,
222
+ tokens: cost.tokens,
223
+ usd: cost.usd,
224
+ once: `run.failed:${input.runId}`,
225
+ },
226
+ });
227
+ }
228
+ function workflowName(input) {
229
+ const started = input.events.map(asRecord).find((event) => event?.t === "run.started");
230
+ const journal = started && typeof started.workflow === "string" ? started.workflow : "";
231
+ if (journal === "kiln-fix")
232
+ return "fix";
233
+ if (journal === "kiln-feature")
234
+ return "feature";
235
+ if (journal === "kiln-investigation")
236
+ return "investigation";
237
+ if (journal.startsWith("kiln-") && journal.length > "kiln-".length)
238
+ return journal.slice("kiln-".length);
239
+ return input.workflow || journal || "unknown";
240
+ }
241
+ function iterationOf(event) {
242
+ return typeof event.iteration === "number" && Number.isInteger(event.iteration) && event.iteration >= 0
243
+ ? event.iteration
244
+ : undefined;
245
+ }
246
+ function noteFrom(value) {
247
+ if (!value)
248
+ return undefined;
249
+ const note = typeof value.note === "string" ? clip(value.note) : "";
250
+ const edit = typeof value.edit === "string" ? clip(value.edit) : "";
251
+ if (!note && !edit)
252
+ return undefined;
253
+ return {
254
+ ...(note ? { note } : {}),
255
+ ...(edit ? { edit } : {}),
256
+ };
257
+ }
258
+ function readArtifact(runDir, name, iteration) {
259
+ if (!/^[A-Za-z0-9._-]+$/.test(name))
260
+ return undefined;
261
+ const file = iteration === undefined ? `${name}.json` : `${name}.${iteration}.json`;
262
+ return readJson(join(runDir, "artifacts", file));
263
+ }
264
+ function readJson(path) {
265
+ if (!existsSync(path))
266
+ return undefined;
267
+ try {
268
+ return asRecord(JSON.parse(readFileSync(path, "utf8")));
269
+ }
270
+ catch {
271
+ return undefined;
272
+ }
273
+ }
274
+ function asRecord(value) {
275
+ if (typeof value !== "object" || value === null || Array.isArray(value))
276
+ return undefined;
277
+ return value;
278
+ }
279
+ function clip(text) {
280
+ const trimmed = text.trim();
281
+ if (!trimmed)
282
+ return "";
283
+ return trimmed.length <= CLIP ? trimmed : `${trimmed.slice(0, CLIP - 1)}…`;
284
+ }
285
+ function promptFiles(workflow, kilnDir, jobId) {
286
+ const rendered = renderedWorkflow(kilnDir, jobId, workflow);
287
+ const packaged = join(packageRoot(), "workflows", `${workflow}.yaml`);
288
+ const yamlPath = rendered ?? (existsSync(packaged) ? packaged : undefined);
289
+ if (!yamlPath)
290
+ return [];
291
+ let yaml = "";
292
+ try {
293
+ yaml = readFileSync(yamlPath, "utf8");
294
+ }
295
+ catch {
296
+ return [];
297
+ }
298
+ const paths = new Set();
299
+ for (const match of yaml.matchAll(/file:\s*(?:"([^"]+\.md)"|'([^']+\.md)'|(\S+\.md))/g)) {
300
+ const found = match[1] ?? match[2] ?? match[3];
301
+ if (found)
302
+ paths.add(found);
303
+ }
304
+ const files = [];
305
+ for (const file of [...paths].sort()) {
306
+ const body = promptBody(file, Boolean(rendered));
307
+ if (body === undefined)
308
+ continue;
309
+ files.push({ label: basename(file), body });
310
+ }
311
+ files.sort((left, right) => (left.label < right.label ? -1 : left.label > right.label ? 1 : 0));
312
+ return files;
313
+ }
314
+ function renderedWorkflow(kilnDir, jobId, workflow) {
315
+ let safe;
316
+ try {
317
+ safe = jobBasename(jobId);
318
+ }
319
+ catch {
320
+ return undefined;
321
+ }
322
+ const path = join(kilnDir, "rendered", safe, `${workflow}.yaml`);
323
+ return existsSync(path) ? path : undefined;
324
+ }
325
+ /** A rendered prompt is already the combined text. A packaged path is combined here. */
326
+ function promptBody(file, rendered) {
327
+ const path = file.startsWith("/") || /^[A-Za-z]:/.test(file) ? file : join(packageRoot(), file);
328
+ let text;
329
+ try {
330
+ text = readFileSync(path, "utf8");
331
+ }
332
+ catch {
333
+ return undefined;
334
+ }
335
+ if (rendered)
336
+ return text;
337
+ const directive = text.match(/^<!--\s*factory:\s*(prompts\/factory\/[a-z0-9-]+\.md)\s*-->/m);
338
+ if (!directive?.[1])
339
+ return text;
340
+ const factoryPath = join(packageRoot(), directive[1]);
341
+ const factoryRoot = join(packageRoot(), "prompts", "factory") + sep;
342
+ if (!factoryPath.startsWith(factoryRoot))
343
+ return text;
344
+ try {
345
+ const factory = readFileSync(factoryPath, "utf8");
346
+ return `${text.trimEnd()}\n\n${factory.trimStart()}`;
347
+ }
348
+ catch {
349
+ return text;
350
+ }
351
+ }
352
+ function skillFiles() {
353
+ const root = packageRoot();
354
+ const dir = join(root, "skills");
355
+ if (!existsSync(dir))
356
+ return [];
357
+ const found = [];
358
+ const walk = (current) => {
359
+ let entries;
360
+ try {
361
+ entries = readdirSync(current, { withFileTypes: true });
362
+ }
363
+ catch {
364
+ return;
365
+ }
366
+ for (const entry of entries) {
367
+ if (entry.name.startsWith("."))
368
+ continue;
369
+ const path = join(current, entry.name);
370
+ if (entry.isDirectory())
371
+ walk(path);
372
+ else if (entry.isFile()) {
373
+ try {
374
+ found.push({ label: relative(root, path), body: readFileSync(path, "utf8") });
375
+ }
376
+ catch {
377
+ // A skill that cannot be read is left out of this hash.
378
+ }
379
+ }
380
+ }
381
+ };
382
+ walk(dir);
383
+ found.sort((left, right) => (left.label < right.label ? -1 : left.label > right.label ? 1 : 0));
384
+ return found;
385
+ }
386
+ function packageVersion(path) {
387
+ try {
388
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
389
+ return typeof parsed.version === "string" && parsed.version ? parsed.version : "unknown";
390
+ }
391
+ catch {
392
+ return "unknown";
393
+ }
394
+ }