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