@lchase/kiln 0.1.0 → 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/README.md +51 -15
- package/assets/serve.css +233 -0
- package/dist/cli.js +79 -4
- package/dist/config.js +45 -27
- package/dist/driver.js +203 -12
- package/dist/findings.js +79 -0
- package/dist/intake/jira.js +4 -1
- package/dist/intake/tickets.js +23 -18
- package/dist/jobs.js +6 -1
- package/dist/reconcile.js +19 -1
- package/dist/report.js +168 -71
- package/dist/schemas.js +10 -5
- package/dist/serve-page.js +216 -0
- package/dist/serve.js +351 -0
- package/dist/workflow.js +28 -10
- package/package.json +2 -1
- package/prompts/kiln/investigation.md +31 -0
- package/workflows/investigation.yaml +67 -0
package/dist/serve.js
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { join, resolve, sep } from "node:path";
|
|
4
|
+
import { answerJob, readStatus, runTick } from "./driver.js";
|
|
5
|
+
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
6
|
+
import { packageRoot } from "./paths.js";
|
|
7
|
+
import { renderBoard, renderInbox, renderMessagePage, renderTextPage } from "./serve-page.js";
|
|
8
|
+
const BODY_LIMIT = 65_536;
|
|
9
|
+
const TEXT_LIMIT = 100_000;
|
|
10
|
+
const JOB_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
11
|
+
/**
|
|
12
|
+
* Local UI over the same derived job view as `kiln status`.
|
|
13
|
+
* Listens on 127.0.0.1 only. Resolves when the process is signalled.
|
|
14
|
+
*/
|
|
15
|
+
export function startServe(options) {
|
|
16
|
+
const css = loadServeCss();
|
|
17
|
+
const secrets = secretValues(options.config);
|
|
18
|
+
let port = options.port;
|
|
19
|
+
return new Promise((resolvePromise, reject) => {
|
|
20
|
+
const server = createServer((req, res) => {
|
|
21
|
+
void handle(req, res, options.config, () => port, css, secrets).catch((error) => {
|
|
22
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
23
|
+
const status = statusCode(error) ?? 500;
|
|
24
|
+
sendJson(res, status, { error: redact(message, secrets) }, secrets);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
server.on("error", reject);
|
|
28
|
+
let stopped = false;
|
|
29
|
+
let timer;
|
|
30
|
+
let firstTick;
|
|
31
|
+
const shutdown = () => {
|
|
32
|
+
if (stopped)
|
|
33
|
+
return;
|
|
34
|
+
stopped = true;
|
|
35
|
+
if (timer)
|
|
36
|
+
clearInterval(timer);
|
|
37
|
+
if (firstTick)
|
|
38
|
+
clearTimeout(firstTick);
|
|
39
|
+
server.close(() => resolvePromise());
|
|
40
|
+
};
|
|
41
|
+
process.once("SIGINT", shutdown);
|
|
42
|
+
process.once("SIGTERM", shutdown);
|
|
43
|
+
server.listen(options.port, "127.0.0.1", () => {
|
|
44
|
+
const address = server.address();
|
|
45
|
+
if (!address || typeof address === "string") {
|
|
46
|
+
reject(new Error("kiln serve did not bind a TCP port."));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
port = address.port;
|
|
50
|
+
process.stdout.write(`listening http://${address.address}:${address.port}\n`);
|
|
51
|
+
if (options.tickSeconds === undefined)
|
|
52
|
+
return;
|
|
53
|
+
const tick = () => {
|
|
54
|
+
if (stopped)
|
|
55
|
+
return;
|
|
56
|
+
safeTick(options.config, secrets);
|
|
57
|
+
};
|
|
58
|
+
firstTick = setTimeout(tick, 0);
|
|
59
|
+
timer = setInterval(tick, options.tickSeconds * 1000);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
export function loadServeCss() {
|
|
64
|
+
const path = join(packageRoot(), "assets", "serve.css");
|
|
65
|
+
if (!existsSync(path)) {
|
|
66
|
+
throw new Error(`Kiln serve stylesheet is missing at ${path}.`);
|
|
67
|
+
}
|
|
68
|
+
return readFileSync(path, "utf8");
|
|
69
|
+
}
|
|
70
|
+
function safeTick(config, secrets) {
|
|
71
|
+
try {
|
|
72
|
+
runTick(config);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
76
|
+
process.stderr.write(`tick: ${redact(message, secrets)}\n`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function handle(req, res, config, boundPort, css, secrets) {
|
|
80
|
+
const method = req.method ?? "GET";
|
|
81
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
82
|
+
const path = url.pathname;
|
|
83
|
+
if (method === "GET" && path === "/favicon.ico") {
|
|
84
|
+
res.writeHead(204, { Connection: "close", "Cache-Control": "no-store" });
|
|
85
|
+
res.end();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (method === "POST") {
|
|
89
|
+
await drain(req);
|
|
90
|
+
if (!originAllowed(req, boundPort())) {
|
|
91
|
+
sendJson(res, 403, { error: "Cross-origin request rejected." }, secrets);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const action = /^\/api\/jobs\/([^/]+)\/(approve|reject)$/.exec(path);
|
|
95
|
+
if (!action?.[1] || (action[2] !== "approve" && action[2] !== "reject")) {
|
|
96
|
+
sendJson(res, 404, { error: "Not found." }, secrets);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const jobId = parseJobId(action[1]);
|
|
100
|
+
if (!jobId) {
|
|
101
|
+
sendJson(res, 404, { error: "Not found." }, secrets);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
await answer(req, res, config, css, secrets, jobId, action[2]);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
108
|
+
sendJson(res, 405, { error: "Method not allowed." }, secrets);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (path === "/" || path === "") {
|
|
112
|
+
sendHtml(res, 200, renderInbox(css, readStatus(config)), secrets);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (path === "/board") {
|
|
116
|
+
sendHtml(res, 200, renderBoard(css, readStatus(config)), secrets);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (path === "/api/jobs") {
|
|
120
|
+
sendJson(res, 200, readStatus(config), secrets);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const apiJob = /^\/api\/jobs\/([^/]+)$/.exec(path);
|
|
124
|
+
if (apiJob?.[1]) {
|
|
125
|
+
const jobId = parseJobId(apiJob[1]);
|
|
126
|
+
const job = jobId ? findJob(config, jobId) : undefined;
|
|
127
|
+
if (!job) {
|
|
128
|
+
sendJson(res, 404, { error: jobId ? `No job ${jobId}.` : "Not found." }, secrets);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
sendJson(res, 200, job, secrets);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const page = /^\/jobs\/([^/]+)\/(spec|log|findings)$/.exec(path);
|
|
135
|
+
if (page?.[1] && (page[2] === "spec" || page[2] === "log" || page[2] === "findings")) {
|
|
136
|
+
const jobId = parseJobId(page[1]);
|
|
137
|
+
const job = jobId ? findJob(config, jobId) : undefined;
|
|
138
|
+
if (!job) {
|
|
139
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", jobId ? `No job ${jobId}.` : "Not found."), secrets);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (page[2] === "findings") {
|
|
143
|
+
sendFindings(res, config, css, secrets, job);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const target = page[2] === "spec" ? job.specPath : job.logPath;
|
|
147
|
+
if (!target) {
|
|
148
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", page[2] === "log" ? "No log yet." : "No spec."), secrets);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const text = readInside(config.kilnDir, target);
|
|
152
|
+
if (text === undefined) {
|
|
153
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", "That file is not in the kiln directory."), secrets);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const title = page[2] === "spec" ? `${job.title} spec` : `${job.title} log`;
|
|
157
|
+
sendHtml(res, 200, renderTextPage(css, title, text), secrets);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (path.startsWith("/api/"))
|
|
161
|
+
sendJson(res, 404, { error: "Not found." }, secrets);
|
|
162
|
+
else
|
|
163
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", "Not found."), secrets);
|
|
164
|
+
}
|
|
165
|
+
async function answer(req, res, config, css, secrets, jobId, choice) {
|
|
166
|
+
try {
|
|
167
|
+
answerJob(config, jobId, choice);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
171
|
+
const status = message.startsWith("No job ") ? 404 : 409;
|
|
172
|
+
if (wantsJson(req))
|
|
173
|
+
sendJson(res, status, { error: message }, secrets);
|
|
174
|
+
else
|
|
175
|
+
sendHtml(res, status, renderMessagePage(css, "Could not answer", message), secrets);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const job = findJob(config, jobId);
|
|
179
|
+
if (wantsJson(req)) {
|
|
180
|
+
sendJson(res, 200, { ok: true, job }, secrets);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
redirect(res, redirectTarget(req));
|
|
184
|
+
}
|
|
185
|
+
function sendFindings(res, config, css, secrets, job) {
|
|
186
|
+
if (job.workflow !== INVESTIGATION_WORKFLOW) {
|
|
187
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", "Not found."), secrets);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (!job.findingsPath) {
|
|
191
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", "No findings yet."), secrets);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const text = readInside(config.kilnDir, job.findingsPath);
|
|
195
|
+
if (text === undefined) {
|
|
196
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", "That file is not in the kiln directory."), secrets);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
sendHtml(res, 200, renderTextPage(css, `${job.title} findings`, text), secrets);
|
|
200
|
+
}
|
|
201
|
+
function findJob(config, jobId) {
|
|
202
|
+
try {
|
|
203
|
+
return readStatus(config, jobId).jobs[0];
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
207
|
+
if (message === `No job ${jobId}.`)
|
|
208
|
+
return undefined;
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Browsers send Origin on POST. A cross-site page cannot use this server:
|
|
214
|
+
* the header has to be exactly `http://127.0.0.1:<bound port>`.
|
|
215
|
+
* Curl and other non-browser clients omit Origin and are allowed.
|
|
216
|
+
* `Sec-Fetch-Site: cross-site` is rejected even when Origin is absent.
|
|
217
|
+
*/
|
|
218
|
+
export function originAllowed(req, port) {
|
|
219
|
+
const fetchSite = req.headers["sec-fetch-site"];
|
|
220
|
+
if (fetchSite === "cross-site")
|
|
221
|
+
return false;
|
|
222
|
+
const origin = req.headers.origin;
|
|
223
|
+
if (Array.isArray(origin))
|
|
224
|
+
return false;
|
|
225
|
+
if (origin === undefined)
|
|
226
|
+
return true;
|
|
227
|
+
return origin === `http://127.0.0.1:${port}`;
|
|
228
|
+
}
|
|
229
|
+
function redirectTarget(req) {
|
|
230
|
+
const referer = req.headers.referer;
|
|
231
|
+
if (typeof referer !== "string")
|
|
232
|
+
return "/";
|
|
233
|
+
try {
|
|
234
|
+
const url = new URL(referer);
|
|
235
|
+
if (url.pathname === "/board")
|
|
236
|
+
return "/board";
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return "/";
|
|
240
|
+
}
|
|
241
|
+
return "/";
|
|
242
|
+
}
|
|
243
|
+
function wantsJson(req) {
|
|
244
|
+
const accept = headerValue(req.headers.accept);
|
|
245
|
+
if (accept.includes("application/json"))
|
|
246
|
+
return true;
|
|
247
|
+
return headerValue(req.headers["content-type"]).includes("application/json");
|
|
248
|
+
}
|
|
249
|
+
function headerValue(value) {
|
|
250
|
+
if (Array.isArray(value))
|
|
251
|
+
return value.join(",");
|
|
252
|
+
return value ?? "";
|
|
253
|
+
}
|
|
254
|
+
function parseJobId(raw) {
|
|
255
|
+
let id;
|
|
256
|
+
try {
|
|
257
|
+
id = decodeURIComponent(raw);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
if (!JOB_ID.test(id))
|
|
263
|
+
return undefined;
|
|
264
|
+
return id;
|
|
265
|
+
}
|
|
266
|
+
function readInside(kilnDir, target) {
|
|
267
|
+
let root;
|
|
268
|
+
try {
|
|
269
|
+
root = realpathSync(resolve(kilnDir));
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
const resolved = resolve(target);
|
|
275
|
+
if (resolved !== root && !resolved.startsWith(root + sep))
|
|
276
|
+
return undefined;
|
|
277
|
+
let real;
|
|
278
|
+
try {
|
|
279
|
+
real = realpathSync(resolved);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
if (real !== root && !real.startsWith(root + sep))
|
|
285
|
+
return undefined;
|
|
286
|
+
const text = readFileSync(real, "utf8");
|
|
287
|
+
if (text.length <= TEXT_LIMIT)
|
|
288
|
+
return text;
|
|
289
|
+
return `showing the last ${TEXT_LIMIT} characters\n\n${text.slice(text.length - TEXT_LIMIT)}`;
|
|
290
|
+
}
|
|
291
|
+
async function drain(req) {
|
|
292
|
+
let size = 0;
|
|
293
|
+
for await (const chunk of req) {
|
|
294
|
+
size += chunk.length;
|
|
295
|
+
if (size > BODY_LIMIT) {
|
|
296
|
+
const error = new Error("Request body is too large.");
|
|
297
|
+
error.statusCode = 413;
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function statusCode(error) {
|
|
303
|
+
if (typeof error === "object" && error !== null && "statusCode" in error) {
|
|
304
|
+
const code = error.statusCode;
|
|
305
|
+
if (typeof code === "number")
|
|
306
|
+
return code;
|
|
307
|
+
}
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
function sendHtml(res, status, html, secrets) {
|
|
311
|
+
const body = redact(html, secrets);
|
|
312
|
+
res.writeHead(status, {
|
|
313
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
314
|
+
Connection: "close",
|
|
315
|
+
"Cache-Control": "no-store",
|
|
316
|
+
"X-Content-Type-Options": "nosniff",
|
|
317
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
318
|
+
"Referrer-Policy": "same-origin",
|
|
319
|
+
});
|
|
320
|
+
res.end(body);
|
|
321
|
+
}
|
|
322
|
+
function sendJson(res, status, value, secrets) {
|
|
323
|
+
const body = redact(`${JSON.stringify(value, null, 2)}\n`, secrets);
|
|
324
|
+
res.writeHead(status, {
|
|
325
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
326
|
+
Connection: "close",
|
|
327
|
+
"Cache-Control": "no-store",
|
|
328
|
+
"X-Content-Type-Options": "nosniff",
|
|
329
|
+
});
|
|
330
|
+
res.end(body);
|
|
331
|
+
}
|
|
332
|
+
function redirect(res, location) {
|
|
333
|
+
res.writeHead(303, { Location: location, Connection: "close", "Cache-Control": "no-store" });
|
|
334
|
+
res.end();
|
|
335
|
+
}
|
|
336
|
+
function secretValues(config) {
|
|
337
|
+
const values = [config.jira?.apiToken, process.env.JIRA_API_TOKEN];
|
|
338
|
+
const found = [];
|
|
339
|
+
for (const value of values) {
|
|
340
|
+
const trimmed = value?.trim();
|
|
341
|
+
if (trimmed && trimmed.length >= 8 && !found.includes(trimmed))
|
|
342
|
+
found.push(trimmed);
|
|
343
|
+
}
|
|
344
|
+
return found;
|
|
345
|
+
}
|
|
346
|
+
function redact(text, secrets) {
|
|
347
|
+
let out = text;
|
|
348
|
+
for (const secret of secrets)
|
|
349
|
+
out = out.split(secret).join("[redacted]");
|
|
350
|
+
return out;
|
|
351
|
+
}
|
package/dist/workflow.js
CHANGED
|
@@ -5,6 +5,12 @@ import { jobBasename, packageRoot } from "./paths.js";
|
|
|
5
5
|
const FACTORY_DIRECTIVE = /^<!--\s*factory:\s*(prompts\/factory\/[a-z0-9-]+\.md)\s*-->/;
|
|
6
6
|
const WORKFLOW_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
7
7
|
const FEATURE_PROMPTS = 7;
|
|
8
|
+
const INVESTIGATION_PROMPTS = 1;
|
|
9
|
+
/** Prompt files Kiln rewrites before launch. A mismatch refuses the launch. */
|
|
10
|
+
const PROMPT_COUNTS = {
|
|
11
|
+
feature: FEATURE_PROMPTS,
|
|
12
|
+
investigation: INVESTIGATION_PROMPTS,
|
|
13
|
+
};
|
|
8
14
|
/**
|
|
9
15
|
* Values Kiln can pass into a workflow. A workflow's inputSchema lists the
|
|
10
16
|
* ones it uses. `description` and `acceptance` stay so
|
|
@@ -34,6 +40,10 @@ export function assertWorkflowName(name) {
|
|
|
34
40
|
* to an absolute file under the kiln directory: the Kiln preamble plus the
|
|
35
41
|
* verbatim factory prompt. The content hash is part of the filename, so a
|
|
36
42
|
* prompt edit changes the path yak caches on.
|
|
43
|
+
*
|
|
44
|
+
* A preamble that starts with `<!-- factory: prompts/factory/<file>.md -->`
|
|
45
|
+
* is combined with that factory body. A prompt without that directive is
|
|
46
|
+
* stored as itself. `prompts/kiln/investigation.md` uses the second form.
|
|
37
47
|
*/
|
|
38
48
|
export function materializeWorkflow(kilnDir, jobId, workflowName) {
|
|
39
49
|
const sourcePath = assertWorkflowName(workflowName);
|
|
@@ -47,8 +57,9 @@ export function materializeWorkflow(kilnDir, jobId, workflowName) {
|
|
|
47
57
|
const absolute = writeCombinedPrompt(rel, destDir);
|
|
48
58
|
return `file: ${yamlDoubleQuote(absolute)}`;
|
|
49
59
|
});
|
|
50
|
-
|
|
51
|
-
|
|
60
|
+
const expected = PROMPT_COUNTS[workflowName];
|
|
61
|
+
if (expected !== undefined && count !== expected) {
|
|
62
|
+
throw new Error(`${workflowName} workflow has ${count} prompts/kiln refs; expected ${expected}. Refusing to launch a partial prompt set.`);
|
|
52
63
|
}
|
|
53
64
|
if (rendered.includes("file: prompts/")) {
|
|
54
65
|
throw new Error(`Rendered ${workflowName} workflow still has a checkout-relative prompt path.`);
|
|
@@ -70,6 +81,10 @@ export function workflowInputs(workflowYaml, input) {
|
|
|
70
81
|
}
|
|
71
82
|
return out;
|
|
72
83
|
}
|
|
84
|
+
/** True when the workflow runs the repo test command. Investigation does not. */
|
|
85
|
+
export function workflowNeedsTestCommand(workflowYaml) {
|
|
86
|
+
return /\bKILN_TEST\b/.test(workflowYaml) || workflowYaml.includes("KILN_VERIFY_WRAPPER");
|
|
87
|
+
}
|
|
73
88
|
function declaredInputProperties(yaml) {
|
|
74
89
|
const at = yaml.indexOf("\ninputSchema:");
|
|
75
90
|
const start = at >= 0 ? at : yaml.startsWith("inputSchema:") ? 0 : -1;
|
|
@@ -92,17 +107,20 @@ function writeCombinedPrompt(preambleRel, destDir) {
|
|
|
92
107
|
const preamblePath = join(root, preambleRel);
|
|
93
108
|
const preamble = readFileSync(preamblePath, "utf8");
|
|
94
109
|
const directive = preamble.match(FACTORY_DIRECTIVE);
|
|
110
|
+
let combined;
|
|
95
111
|
if (!directive?.[1]) {
|
|
96
|
-
|
|
112
|
+
combined = `${preamble.trimEnd()}\n`;
|
|
97
113
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
114
|
+
else {
|
|
115
|
+
const factoryRel = directive[1];
|
|
116
|
+
const factoryPath = join(root, factoryRel);
|
|
117
|
+
const factoryRoot = join(root, "prompts", "factory") + sep;
|
|
118
|
+
if (!factoryPath.startsWith(factoryRoot)) {
|
|
119
|
+
throw new Error(`${preambleRel} factory path ${factoryRel} escapes prompts/factory.`);
|
|
120
|
+
}
|
|
121
|
+
const factory = readFileSync(factoryPath, "utf8");
|
|
122
|
+
combined = `${preamble.trimEnd()}\n\n${factory.trimStart()}`;
|
|
103
123
|
}
|
|
104
|
-
const factory = readFileSync(factoryPath, "utf8");
|
|
105
|
-
const combined = `${preamble.trimEnd()}\n\n${factory.trimStart()}`;
|
|
106
124
|
const digest = createHash("sha256").update(combined).digest("hex").slice(0, 16);
|
|
107
125
|
const outPath = join(destDir, `${basename(preambleRel, ".md")}-${digest}.md`);
|
|
108
126
|
writeFileSync(outPath, combined);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lchase/kiln",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Run a workflow on any registered repo. A markdown spec is the input.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"dist",
|
|
11
|
+
"assets",
|
|
11
12
|
"workflows",
|
|
12
13
|
"prompts",
|
|
13
14
|
"README.md"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Kiln runs this investigation inside yak. The working directory is an isolated worktree of the repo at base SHA `{{input.baseRef}}`. That SHA is the commit to inspect. Do not guess a branch name.
|
|
2
|
+
|
|
3
|
+
Explore the tree and answer the questions in the spec. You may run commands and scripts. You must not change product code. Do not create, modify, or delete files. Do not commit, push, or open a pull request. Do not run formatters, package installs, or anything else that writes. Read-only git and file inspection are allowed. This step has no Write or Edit tool.
|
|
4
|
+
|
|
5
|
+
Do not write a findings file. Return JSON with one string field, `markdown`. Kiln stores that markdown after a person approves it, and later jobs read it. The markdown is the whole document, not a fragment.
|
|
6
|
+
|
|
7
|
+
Use these headings, in this order, spelled exactly:
|
|
8
|
+
|
|
9
|
+
# Findings: {{input.title}}
|
|
10
|
+
|
|
11
|
+
## Questions
|
|
12
|
+
|
|
13
|
+
The questions the spec asks, as a list.
|
|
14
|
+
|
|
15
|
+
## Answers
|
|
16
|
+
|
|
17
|
+
One subsection per question. State the answer, then the evidence. Evidence is file paths and command output you actually observed in this worktree. Do not invent paths or output. If you did not run a command, do not claim you did.
|
|
18
|
+
|
|
19
|
+
## Unconfirmed
|
|
20
|
+
|
|
21
|
+
What you could not confirm, and what you looked at. Write "None." when nothing is left open.
|
|
22
|
+
|
|
23
|
+
## Implications for dependent tickets
|
|
24
|
+
|
|
25
|
+
What a later ticket should rely on, and what it must not assume.
|
|
26
|
+
|
|
27
|
+
Spec title: {{input.title}}
|
|
28
|
+
|
|
29
|
+
{{input.spec}}
|
|
30
|
+
|
|
31
|
+
Launch id: {{input.runNonce}}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# One Kiln investigation, run by yak (@lchase/yak).
|
|
2
|
+
#
|
|
3
|
+
# A spike, probe, or research ticket. The agent explores the worktree and
|
|
4
|
+
# returns a findings document. A person approves or rejects it. There is no
|
|
5
|
+
# build, review, verify, or land step, and Kiln does not open a pull request
|
|
6
|
+
# for this workflow, whatever the repo's deliver mode is.
|
|
7
|
+
#
|
|
8
|
+
# The agent artifact is `findings`: `{ "markdown": "<document>" }`.
|
|
9
|
+
# The document uses these headings, in order:
|
|
10
|
+
# # Findings: <title>
|
|
11
|
+
# ## Questions
|
|
12
|
+
# ## Answers (each answer includes evidence: file paths and command output)
|
|
13
|
+
# ## Unconfirmed
|
|
14
|
+
# ## Implications for dependent tickets
|
|
15
|
+
# On approve, Kiln copies that markdown to `<kilnDir>/findings/<jobId>.md`.
|
|
16
|
+
# A later job that depends on this one receives the file as a
|
|
17
|
+
# `## Findings from <title>` section in the spec yak sees.
|
|
18
|
+
#
|
|
19
|
+
# `adapter: "mock"` reads `test/fixtures/kiln-investigation/explore.json`
|
|
20
|
+
# from the checkout. The gate has no fixture.
|
|
21
|
+
# Prompt paths are rewritten to absolute files under the kiln directory
|
|
22
|
+
# before launch, same as workflows/feature.yaml. This prompt has no factory
|
|
23
|
+
# directive; it is stored as itself.
|
|
24
|
+
name: kiln-investigation
|
|
25
|
+
version: "1"
|
|
26
|
+
inputSchema:
|
|
27
|
+
inline:
|
|
28
|
+
type: object
|
|
29
|
+
additionalProperties: false
|
|
30
|
+
properties:
|
|
31
|
+
title: { type: string }
|
|
32
|
+
spec: { type: string }
|
|
33
|
+
runNonce: { type: string }
|
|
34
|
+
baseRef: { type: string }
|
|
35
|
+
required: [title, spec, runNonce, baseRef]
|
|
36
|
+
steps:
|
|
37
|
+
- id: explore
|
|
38
|
+
needs: [input]
|
|
39
|
+
agent:
|
|
40
|
+
model: opus
|
|
41
|
+
prompt:
|
|
42
|
+
file: prompts/kiln/investigation.md
|
|
43
|
+
schema:
|
|
44
|
+
inline:
|
|
45
|
+
type: object
|
|
46
|
+
additionalProperties: false
|
|
47
|
+
properties:
|
|
48
|
+
markdown: { type: string, minLength: 1 }
|
|
49
|
+
required: [markdown]
|
|
50
|
+
# Bash can run read-only commands. File-editing tools are omitted.
|
|
51
|
+
# The prompt forbids any command that writes.
|
|
52
|
+
tools: [Read, Grep, Glob, Bash]
|
|
53
|
+
produces: findings
|
|
54
|
+
|
|
55
|
+
- id: approve
|
|
56
|
+
needs: [input, findings]
|
|
57
|
+
gate:
|
|
58
|
+
schema:
|
|
59
|
+
inline:
|
|
60
|
+
type: object
|
|
61
|
+
additionalProperties: false
|
|
62
|
+
properties:
|
|
63
|
+
decision: { type: string, enum: [approve, reject] }
|
|
64
|
+
required: [decision]
|
|
65
|
+
render:
|
|
66
|
+
inline: "Approve findings for \"{{input.title}}\"?\n\n{{findings.markdown}}"
|
|
67
|
+
produces: approval
|