@lchase/kiln 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +263 -2
- package/assets/serve.css +233 -0
- package/dist/cli.js +409 -0
- package/dist/config.js +255 -0
- package/dist/delivery.js +245 -0
- package/dist/driver.js +825 -0
- package/dist/errors.js +6 -0
- package/dist/findings.js +79 -0
- package/dist/host.js +223 -0
- package/dist/intake/file.js +29 -0
- package/dist/intake/jira.js +585 -0
- package/dist/intake/prompt.js +47 -0
- package/dist/intake/tickets.js +448 -0
- package/dist/jobs.js +154 -0
- package/dist/paths.js +34 -0
- package/dist/reconcile.js +141 -0
- package/dist/report.js +254 -0
- package/dist/schemas.js +95 -0
- package/dist/serve-page.js +216 -0
- package/dist/serve.js +351 -0
- package/dist/truncate-output.js +122 -0
- package/dist/workflow.js +131 -0
- package/package.json +41 -4
- package/prompts/factory/approver.md +49 -0
- package/prompts/factory/builder.md +76 -0
- package/prompts/factory/review-code.md +55 -0
- package/prompts/factory/review-security.md +50 -0
- package/prompts/factory/review-ui.md +50 -0
- package/prompts/factory/review-ux.md +48 -0
- package/prompts/factory/spec-writer.md +67 -0
- package/prompts/kiln/approver.md +33 -0
- package/prompts/kiln/build.md +38 -0
- package/prompts/kiln/code.md +26 -0
- package/prompts/kiln/investigation.md +31 -0
- package/prompts/kiln/security.md +26 -0
- package/prompts/kiln/spec.md +18 -0
- package/prompts/kiln/ui.md +26 -0
- package/prompts/kiln/ux.md +26 -0
- package/workflows/feature.yaml +239 -0
- package/workflows/investigation.yaml +67 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { INVESTIGATION_WORKFLOW } from "../findings.js";
|
|
4
|
+
import { listValidJobs, newJobId, writeJob } from "../jobs.js";
|
|
5
|
+
import { assertWorkflowName } from "../workflow.js";
|
|
6
|
+
/** Context files larger than this are named in the spec instead of copied in. */
|
|
7
|
+
export const CONTEXT_INLINE_LIMIT = 40 * 1024;
|
|
8
|
+
const TICKET_FILE = /^(\d+)-.+\.md$/;
|
|
9
|
+
/**
|
|
10
|
+
* `kiln import <dir> --repo R`. One job per `NN-slug.md` file.
|
|
11
|
+
* Re-running keys on the ticket directory plus the filename (`source.ref`).
|
|
12
|
+
*/
|
|
13
|
+
export function importTickets(config, input) {
|
|
14
|
+
const dirAbs = resolveTicketDir(input.cwd, input.dir);
|
|
15
|
+
if (!config.repos[input.repo]) {
|
|
16
|
+
const known = Object.keys(config.repos).sort();
|
|
17
|
+
const suffix = known.length > 0 ? ` Known repos: ${known.join(", ")}.` : " No repos are registered.";
|
|
18
|
+
throw new Error(`Unknown repo ${input.repo}.${suffix} Run kiln repo add ${input.repo} <path>.`);
|
|
19
|
+
}
|
|
20
|
+
const requested = input.workflow ?? "feature";
|
|
21
|
+
assertWorkflowName(requested);
|
|
22
|
+
const context = resolveContext(dirAbs, input.contextPath, input.cwd);
|
|
23
|
+
const tickets = readTickets(dirAbs);
|
|
24
|
+
const warnings = [];
|
|
25
|
+
const workflows = new Map();
|
|
26
|
+
for (const ticket of tickets) {
|
|
27
|
+
const workflow = workflowFor(ticket, requested);
|
|
28
|
+
if (workflow !== requested)
|
|
29
|
+
assertWorkflowName(workflow);
|
|
30
|
+
workflows.set(ticket.path, workflow);
|
|
31
|
+
}
|
|
32
|
+
if (input.stack) {
|
|
33
|
+
const offender = tickets.find((ticket) => ticket.blockedBy.length > 1) ??
|
|
34
|
+
tickets.find((ticket) => ticket.blockedBy.length !== 1);
|
|
35
|
+
if (offender) {
|
|
36
|
+
throw new Error(`Ticket ${offender.file} has ${offender.blockedBy.length} dependencies. stack needs exactly one.`);
|
|
37
|
+
}
|
|
38
|
+
for (const ticket of tickets) {
|
|
39
|
+
const dep = ticket.blockedBy[0];
|
|
40
|
+
if (!dep)
|
|
41
|
+
continue;
|
|
42
|
+
const upstream = tickets.find((candidate) => candidate.number === dep.number);
|
|
43
|
+
if (!upstream || workflows.get(upstream.path) !== INVESTIGATION_WORKFLOW)
|
|
44
|
+
continue;
|
|
45
|
+
throw new Error(`Ticket ${ticket.file} stacks on ${upstream.file}, which is an investigation. An investigation does not land a branch.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const existing = new Map();
|
|
49
|
+
for (const job of listValidJobs(config)) {
|
|
50
|
+
if (job.source?.kind !== "tickets")
|
|
51
|
+
continue;
|
|
52
|
+
if (!existing.has(job.source.ref))
|
|
53
|
+
existing.set(job.source.ref, job);
|
|
54
|
+
}
|
|
55
|
+
const assigned = new Map();
|
|
56
|
+
const start = input.now ?? new Date();
|
|
57
|
+
let offset = 0;
|
|
58
|
+
for (const ticket of tickets) {
|
|
59
|
+
const prior = existing.get(ticket.path);
|
|
60
|
+
if (prior) {
|
|
61
|
+
assigned.set(ticket.path, prior.id);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
assigned.set(ticket.path, newJobId(new Date(start.getTime() + offset)));
|
|
65
|
+
offset += 1;
|
|
66
|
+
}
|
|
67
|
+
const pendingIds = new Set();
|
|
68
|
+
for (const ticket of tickets) {
|
|
69
|
+
if (!existing.has(ticket.path)) {
|
|
70
|
+
const id = assigned.get(ticket.path);
|
|
71
|
+
if (id)
|
|
72
|
+
pendingIds.add(id);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const rows = [];
|
|
76
|
+
const writes = [];
|
|
77
|
+
let newOffset = 0;
|
|
78
|
+
for (const ticket of tickets) {
|
|
79
|
+
const id = assigned.get(ticket.path);
|
|
80
|
+
if (!id)
|
|
81
|
+
continue;
|
|
82
|
+
const workflow = workflows.get(ticket.path) ?? requested;
|
|
83
|
+
const dependsOn = ticket.blockedBy.map((dep) => {
|
|
84
|
+
const upstream = tickets.find((candidate) => candidate.number === dep.number);
|
|
85
|
+
const upstreamId = upstream ? assigned.get(upstream.path) : undefined;
|
|
86
|
+
if (!upstream || !upstreamId) {
|
|
87
|
+
throw new Error(`Ticket ${ticket.file} blocks on unknown ticket ${dep.raw}.`);
|
|
88
|
+
}
|
|
89
|
+
return upstreamId;
|
|
90
|
+
});
|
|
91
|
+
const depLabels = ticket.blockedBy.map((dep) => {
|
|
92
|
+
const upstream = tickets.find((candidate) => candidate.number === dep.number);
|
|
93
|
+
return upstream?.label ?? dep.raw;
|
|
94
|
+
});
|
|
95
|
+
const prior = existing.get(ticket.path);
|
|
96
|
+
if (prior) {
|
|
97
|
+
rows.push({
|
|
98
|
+
status: "unchanged",
|
|
99
|
+
label: ticket.label,
|
|
100
|
+
title: prior.title,
|
|
101
|
+
workflow: prior.workflow,
|
|
102
|
+
depLabels,
|
|
103
|
+
jobId: prior.id,
|
|
104
|
+
});
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const now = new Date(start.getTime() + newOffset);
|
|
108
|
+
newOffset += 1;
|
|
109
|
+
writes.push({ ticket, id, now, dependsOn });
|
|
110
|
+
rows.push({
|
|
111
|
+
status: "new",
|
|
112
|
+
label: ticket.label,
|
|
113
|
+
title: ticket.title,
|
|
114
|
+
workflow,
|
|
115
|
+
depLabels,
|
|
116
|
+
...(input.dryRun ? {} : { jobId: id }),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
if (!input.dryRun) {
|
|
120
|
+
for (const write of writes) {
|
|
121
|
+
const workflow = workflows.get(write.ticket.path) ?? requested;
|
|
122
|
+
writeJob(config, {
|
|
123
|
+
id: write.id,
|
|
124
|
+
workflow,
|
|
125
|
+
repo: input.repo,
|
|
126
|
+
specText: composeSpec(write.ticket.text, context),
|
|
127
|
+
title: write.ticket.title,
|
|
128
|
+
dependsOn: write.dependsOn,
|
|
129
|
+
...(input.stack ? { stack: true } : {}),
|
|
130
|
+
source: { kind: "tickets", ref: write.ticket.path },
|
|
131
|
+
now: write.now,
|
|
132
|
+
pendingIds,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
report: renderImport(dirAbs, input.repo, rows, input.dryRun),
|
|
138
|
+
warnings,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function resolveTicketDir(cwd, dir) {
|
|
142
|
+
const resolved = resolve(cwd, dir);
|
|
143
|
+
if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
|
|
144
|
+
throw new Error(`Ticket directory ${resolved} does not exist or is not a directory.`);
|
|
145
|
+
}
|
|
146
|
+
return realpathSync(resolved);
|
|
147
|
+
}
|
|
148
|
+
function resolveContext(dirAbs, contextPath, cwd) {
|
|
149
|
+
const explicit = contextPath ? resolve(cwd, contextPath) : undefined;
|
|
150
|
+
const fallback = join(dirname(dirAbs), "spec.md");
|
|
151
|
+
const path = explicit ?? (existsSync(fallback) && statSync(fallback).isFile() ? fallback : undefined);
|
|
152
|
+
if (!path)
|
|
153
|
+
return undefined;
|
|
154
|
+
if (!existsSync(path) || !statSync(path).isFile()) {
|
|
155
|
+
throw new Error(`Could not read context ${path}.`);
|
|
156
|
+
}
|
|
157
|
+
const bytes = statSync(path).size;
|
|
158
|
+
if (bytes > CONTEXT_INLINE_LIMIT)
|
|
159
|
+
return { path, bytes };
|
|
160
|
+
let text;
|
|
161
|
+
try {
|
|
162
|
+
text = readFileSync(path, "utf8");
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
166
|
+
throw new Error(`Could not read context ${path}: ${message}`);
|
|
167
|
+
}
|
|
168
|
+
if (!text.trim())
|
|
169
|
+
return undefined;
|
|
170
|
+
return { path, bytes, text };
|
|
171
|
+
}
|
|
172
|
+
function composeSpec(ticketText, context) {
|
|
173
|
+
const base = ticketText.endsWith("\n") ? ticketText : `${ticketText}\n`;
|
|
174
|
+
if (!context)
|
|
175
|
+
return base;
|
|
176
|
+
if (context.text === undefined) {
|
|
177
|
+
return `${base}\n## Background\n\nContext: ${context.path} (${context.bytes} bytes). The file is larger than 40KB and is referenced by path.\n`;
|
|
178
|
+
}
|
|
179
|
+
const body = context.text.endsWith("\n") ? context.text : `${context.text}\n`;
|
|
180
|
+
return `${base}\n## Background\n\n${body}`;
|
|
181
|
+
}
|
|
182
|
+
function workflowFor(ticket, requested) {
|
|
183
|
+
return workflowForSpike(ticket.spike, requested);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Spike and research work uses the investigation workflow.
|
|
187
|
+
* Jira intake calls this for issue types Spike and Research.
|
|
188
|
+
*/
|
|
189
|
+
export function workflowForSpike(spike, requested) {
|
|
190
|
+
if (!spike)
|
|
191
|
+
return requested;
|
|
192
|
+
return INVESTIGATION_WORKFLOW;
|
|
193
|
+
}
|
|
194
|
+
function readTickets(dirAbs) {
|
|
195
|
+
const names = readdirSync(dirAbs)
|
|
196
|
+
.filter((name) => TICKET_FILE.test(name))
|
|
197
|
+
.sort((left, right) => ticketNumber(left) - ticketNumber(right) || left.localeCompare(right));
|
|
198
|
+
if (names.length === 0) {
|
|
199
|
+
throw new Error(`No NN-slug.md tickets in ${dirAbs}.`);
|
|
200
|
+
}
|
|
201
|
+
const tickets = names.map((name) => parseTicket(dirAbs, name));
|
|
202
|
+
const byNumber = new Map();
|
|
203
|
+
for (const ticket of tickets) {
|
|
204
|
+
const prior = byNumber.get(ticket.number);
|
|
205
|
+
if (prior) {
|
|
206
|
+
throw new Error(`Tickets ${prior.file} and ${ticket.file} share number ${ticket.label}.`);
|
|
207
|
+
}
|
|
208
|
+
byNumber.set(ticket.number, ticket);
|
|
209
|
+
}
|
|
210
|
+
const problems = [];
|
|
211
|
+
for (const ticket of tickets) {
|
|
212
|
+
if (ticket.blockedBy.some((dep) => dep.number === ticket.number)) {
|
|
213
|
+
problems.push(`${ticket.file} blocks on itself.`);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const unknown = ticket.blockedBy.filter((dep) => !byNumber.has(dep.number));
|
|
217
|
+
if (unknown.length === 0)
|
|
218
|
+
continue;
|
|
219
|
+
const listed = unknown.map((dep) => dep.raw).join(", ");
|
|
220
|
+
const noun = unknown.length === 1 ? "ticket" : "tickets";
|
|
221
|
+
problems.push(`${ticket.file} blocks on unknown ${noun} ${listed}.`);
|
|
222
|
+
}
|
|
223
|
+
if (problems.length > 0) {
|
|
224
|
+
throw new Error(problems.join("\n"));
|
|
225
|
+
}
|
|
226
|
+
return tickets;
|
|
227
|
+
}
|
|
228
|
+
function parseTicket(dirAbs, file) {
|
|
229
|
+
const path = join(dirAbs, file);
|
|
230
|
+
const label = ticketLabel(file);
|
|
231
|
+
const number = ticketNumber(file);
|
|
232
|
+
let text;
|
|
233
|
+
try {
|
|
234
|
+
text = readFileSync(path, "utf8");
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
238
|
+
throw new Error(`Could not read ticket ${file}: ${message}`);
|
|
239
|
+
}
|
|
240
|
+
if (!text.trim()) {
|
|
241
|
+
throw new Error(`Ticket ${file} is empty.`);
|
|
242
|
+
}
|
|
243
|
+
let meta;
|
|
244
|
+
let body;
|
|
245
|
+
try {
|
|
246
|
+
({ meta, body } = splitFrontMatter(text));
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
250
|
+
throw new Error(`Could not read front matter in ${file}: ${message}`);
|
|
251
|
+
}
|
|
252
|
+
let bodyDeps;
|
|
253
|
+
try {
|
|
254
|
+
bodyDeps = blockedByFromBody(body, file);
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
258
|
+
throw new Error(message);
|
|
259
|
+
}
|
|
260
|
+
const heading = headingTitle(body);
|
|
261
|
+
if (heading?.number !== undefined && heading.number !== number) {
|
|
262
|
+
throw new Error(`Ticket ${file} heading is numbered ${heading.rawNumber ?? heading.number}, not ${label}.`);
|
|
263
|
+
}
|
|
264
|
+
const title = heading?.title ?? meta.title;
|
|
265
|
+
if (!title) {
|
|
266
|
+
throw new Error(`Ticket ${file} has no title. Expected a "# NN: Title" heading or front matter title.`);
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
file,
|
|
270
|
+
path,
|
|
271
|
+
label,
|
|
272
|
+
number,
|
|
273
|
+
title,
|
|
274
|
+
text,
|
|
275
|
+
blockedBy: dedupeRefs([...meta.blockedBy, ...bodyDeps]),
|
|
276
|
+
spike: looksLikeSpike(title, text, meta.type),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function splitFrontMatter(text) {
|
|
280
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
|
|
281
|
+
if (!match?.[1])
|
|
282
|
+
return { meta: { blockedBy: [] }, body: text };
|
|
283
|
+
return { meta: parseFrontMatter(match[1]), body: text.slice(match[0].length) };
|
|
284
|
+
}
|
|
285
|
+
function parseFrontMatter(yaml) {
|
|
286
|
+
const lines = yaml.split(/\r?\n/);
|
|
287
|
+
let title;
|
|
288
|
+
let type;
|
|
289
|
+
const blockedBy = [];
|
|
290
|
+
let listKey;
|
|
291
|
+
for (const line of lines) {
|
|
292
|
+
if (!line.trim() || line.trim().startsWith("#"))
|
|
293
|
+
continue;
|
|
294
|
+
const item = /^\s*-\s+(.+)$/.exec(line);
|
|
295
|
+
if (item?.[1] && listKey === "blocked-by") {
|
|
296
|
+
blockedBy.push(...refsFromScalar(item[1]));
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const field = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(line);
|
|
300
|
+
if (!field?.[1])
|
|
301
|
+
continue;
|
|
302
|
+
listKey = undefined;
|
|
303
|
+
const key = field[1];
|
|
304
|
+
const value = field[2] ?? "";
|
|
305
|
+
if (key === "title")
|
|
306
|
+
title = unquote(value);
|
|
307
|
+
else if (key === "type")
|
|
308
|
+
type = unquote(value);
|
|
309
|
+
else if (key === "blocked-by") {
|
|
310
|
+
if (!value.trim())
|
|
311
|
+
listKey = "blocked-by";
|
|
312
|
+
else
|
|
313
|
+
blockedBy.push(...refsFromScalar(value));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return { title: title?.trim() || undefined, type: type?.trim() || undefined, blockedBy };
|
|
317
|
+
}
|
|
318
|
+
function refsFromScalar(raw) {
|
|
319
|
+
const value = unquote(raw.trim());
|
|
320
|
+
if (!value || /^(none\b.*|null|~)$/i.test(value))
|
|
321
|
+
return [];
|
|
322
|
+
const flow = /^\[([\s\S]*)\]$/.exec(value);
|
|
323
|
+
if (flow?.[1] !== undefined) {
|
|
324
|
+
const inner = flow[1].trim();
|
|
325
|
+
if (!inner)
|
|
326
|
+
return [];
|
|
327
|
+
return inner.split(",").flatMap((part) => refsFromScalar(part));
|
|
328
|
+
}
|
|
329
|
+
const num = /^(\d+)\b/.exec(value);
|
|
330
|
+
if (!num?.[1]) {
|
|
331
|
+
throw new Error(`could not read blocked-by value ${JSON.stringify(raw)}`);
|
|
332
|
+
}
|
|
333
|
+
return [{ raw: num[1], number: Number(num[1]) }];
|
|
334
|
+
}
|
|
335
|
+
function blockedByFromBody(body, filename) {
|
|
336
|
+
const lines = body.split(/\r?\n/);
|
|
337
|
+
const start = lines.findIndex((line) => /^\*\*Blocked by:\*\*/i.test(line.trim()));
|
|
338
|
+
if (start < 0)
|
|
339
|
+
return [];
|
|
340
|
+
const collected = [];
|
|
341
|
+
const inline = /^\*\*Blocked by:\*\*\s*(.*)$/i.exec(lines[start]?.trim() ?? "");
|
|
342
|
+
if (inline?.[1]?.trim())
|
|
343
|
+
collected.push(inline[1].trim());
|
|
344
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
345
|
+
const line = lines[index] ?? "";
|
|
346
|
+
if (!line.trim())
|
|
347
|
+
continue;
|
|
348
|
+
const trimmed = line.trim();
|
|
349
|
+
if (/^#{1,6}\s/.test(trimmed) || /^\*\*[^*]/.test(trimmed))
|
|
350
|
+
break;
|
|
351
|
+
const bullet = /^[-*]\s+/.test(trimmed);
|
|
352
|
+
const none = /^none\b/i.test(trimmed) || /^[-*]\s+none\b/i.test(trimmed);
|
|
353
|
+
if (!bullet && !none) {
|
|
354
|
+
if (collected.length > 0)
|
|
355
|
+
break;
|
|
356
|
+
throw new Error(`Could not read blocked-by line ${JSON.stringify(trimmed)} in ${filename}.`);
|
|
357
|
+
}
|
|
358
|
+
collected.push(trimmed);
|
|
359
|
+
}
|
|
360
|
+
const refs = [];
|
|
361
|
+
for (const line of collected) {
|
|
362
|
+
const bullet = /^[-*]\s+(.*)$/.exec(line);
|
|
363
|
+
const text = (bullet?.[1] ?? line).trim();
|
|
364
|
+
if (/^none\b/i.test(text))
|
|
365
|
+
continue;
|
|
366
|
+
const num = /^(\d+)\b/.exec(text);
|
|
367
|
+
if (!num?.[1]) {
|
|
368
|
+
throw new Error(`Could not read blocked-by line ${JSON.stringify(line)} in ${filename}.`);
|
|
369
|
+
}
|
|
370
|
+
refs.push({ raw: num[1], number: Number(num[1]) });
|
|
371
|
+
}
|
|
372
|
+
return refs;
|
|
373
|
+
}
|
|
374
|
+
function headingTitle(body) {
|
|
375
|
+
for (const line of body.split(/\r?\n/)) {
|
|
376
|
+
const heading = /^#\s+(.+?)\s*$/.exec(line);
|
|
377
|
+
if (!heading?.[1])
|
|
378
|
+
continue;
|
|
379
|
+
const numbered = /^(\d+)\s*:\s+(\S.*?)$/.exec(heading[1]);
|
|
380
|
+
if (numbered?.[1] && numbered[2]) {
|
|
381
|
+
return { title: `${numbered[1]}: ${numbered[2]}`, number: Number(numbered[1]), rawNumber: numbered[1] };
|
|
382
|
+
}
|
|
383
|
+
return { title: heading[1] };
|
|
384
|
+
}
|
|
385
|
+
return undefined;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* A spike is a typed research ticket, a title that says spike or probe, or text
|
|
389
|
+
* that says there is no product code. Mentioning an upstream spike in the body
|
|
390
|
+
* does not count.
|
|
391
|
+
*/
|
|
392
|
+
function looksLikeSpike(title, text, type) {
|
|
393
|
+
if (typeLooksLikeSpike(type))
|
|
394
|
+
return true;
|
|
395
|
+
if (/\b(spike|probe)\b/i.test(title))
|
|
396
|
+
return true;
|
|
397
|
+
return /no product code/i.test(text);
|
|
398
|
+
}
|
|
399
|
+
/** `spike`, `investigation`, `research`, or that word after a colon (`wayfinder:research`). */
|
|
400
|
+
export function typeLooksLikeSpike(type) {
|
|
401
|
+
if (!type)
|
|
402
|
+
return false;
|
|
403
|
+
return type.split(":").some((part) => /^(spike|investigation|research)$/i.test(part.trim()));
|
|
404
|
+
}
|
|
405
|
+
function dedupeRefs(refs) {
|
|
406
|
+
const seen = new Set();
|
|
407
|
+
const out = [];
|
|
408
|
+
for (const ref of refs) {
|
|
409
|
+
if (seen.has(ref.number))
|
|
410
|
+
continue;
|
|
411
|
+
seen.add(ref.number);
|
|
412
|
+
out.push(ref);
|
|
413
|
+
}
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
416
|
+
function ticketLabel(file) {
|
|
417
|
+
const label = TICKET_FILE.exec(basename(file))?.[1];
|
|
418
|
+
if (!label)
|
|
419
|
+
throw new Error(`Ticket ${file} does not match NN-slug.md.`);
|
|
420
|
+
return label;
|
|
421
|
+
}
|
|
422
|
+
function ticketNumber(file) {
|
|
423
|
+
return Number(ticketLabel(file));
|
|
424
|
+
}
|
|
425
|
+
function unquote(value) {
|
|
426
|
+
const trimmed = value.trim();
|
|
427
|
+
const quoted = /^(['"])([\s\S]*)\1$/.exec(trimmed);
|
|
428
|
+
return quoted?.[2] ?? trimmed;
|
|
429
|
+
}
|
|
430
|
+
function renderImport(dir, repo, rows, dryRun) {
|
|
431
|
+
const lines = [`import ${dir}`, `repo ${repo}`];
|
|
432
|
+
for (const row of rows) {
|
|
433
|
+
const deps = row.depLabels.length > 0 ? row.depLabels.join(", ") : "none";
|
|
434
|
+
if (dryRun) {
|
|
435
|
+
lines.push(`${row.label} ${row.title} ${row.workflow} deps ${deps} ${row.status}`);
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
lines.push(`${row.status} ${row.jobId} ${row.label} ${row.title} ${row.workflow} deps ${deps}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const created = rows.filter((row) => row.status === "new").length;
|
|
442
|
+
const unchanged = rows.filter((row) => row.status === "unchanged").length;
|
|
443
|
+
if (dryRun)
|
|
444
|
+
lines.push(`dry-run: ${created} new, ${unchanged} unchanged, created nothing`);
|
|
445
|
+
else
|
|
446
|
+
lines.push(`imported ${created} new, ${unchanged} unchanged`);
|
|
447
|
+
return `${lines.join("\n")}\n`;
|
|
448
|
+
}
|
package/dist/jobs.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
4
|
+
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
5
|
+
import { jobBasename } from "./paths.js";
|
|
6
|
+
import { JobSchema } from "./schemas.js";
|
|
7
|
+
import { assertWorkflowName } from "./workflow.js";
|
|
8
|
+
export function newJobId(now = new Date()) {
|
|
9
|
+
const stamp = now.toISOString().replace(/[-:TZ.]/g, "").slice(0, 14);
|
|
10
|
+
return `j${stamp}${randomBytes(2).toString("hex")}`;
|
|
11
|
+
}
|
|
12
|
+
export function jobFilePath(jobsDir, id) {
|
|
13
|
+
return join(jobsDir, `${jobBasename(id)}.json`);
|
|
14
|
+
}
|
|
15
|
+
export function resolveJobSpec(kilnDir, spec) {
|
|
16
|
+
return isAbsolute(spec) ? spec : join(kilnDir, spec);
|
|
17
|
+
}
|
|
18
|
+
export function readJobSpec(kilnDir, job) {
|
|
19
|
+
const path = resolveJobSpec(kilnDir, job.spec);
|
|
20
|
+
try {
|
|
21
|
+
return readFileSync(path, "utf8");
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
25
|
+
throw new Error(`Could not read spec for job ${job.id} at ${path}: ${message}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function titleFromSpec(text, filePath) {
|
|
29
|
+
for (const line of text.split("\n")) {
|
|
30
|
+
const heading = /^#\s+(.+?)\s*$/.exec(line);
|
|
31
|
+
if (heading?.[1])
|
|
32
|
+
return heading[1];
|
|
33
|
+
}
|
|
34
|
+
const base = basename(filePath).replace(/\.md$/i, "");
|
|
35
|
+
if (!base || base === "." || base === "..") {
|
|
36
|
+
throw new Error(`Could not derive a title from ${filePath}. Pass --title.`);
|
|
37
|
+
}
|
|
38
|
+
return base;
|
|
39
|
+
}
|
|
40
|
+
/** Persist one job. Intake adapters call this; they do not write job files themselves. */
|
|
41
|
+
export function writeJob(config, input) {
|
|
42
|
+
assertWorkflowName(input.workflow);
|
|
43
|
+
if (!config.repos[input.repo]) {
|
|
44
|
+
const known = Object.keys(config.repos).sort();
|
|
45
|
+
const suffix = known.length > 0 ? ` Known repos: ${known.join(", ")}.` : " No repos are registered.";
|
|
46
|
+
throw new Error(`Unknown repo ${input.repo}.${suffix} Run kiln repo add ${input.repo} <path>.`);
|
|
47
|
+
}
|
|
48
|
+
if (!input.specText.trim()) {
|
|
49
|
+
throw new Error("Job spec is empty.");
|
|
50
|
+
}
|
|
51
|
+
const title = input.title.trim();
|
|
52
|
+
if (!title) {
|
|
53
|
+
throw new Error("Job title is empty. Pass --title.");
|
|
54
|
+
}
|
|
55
|
+
const now = input.now ?? new Date();
|
|
56
|
+
const id = input.id ?? newJobId(now);
|
|
57
|
+
const dependsOn = normalizedDepends(input.dependsOn);
|
|
58
|
+
if (dependsOn.includes(id)) {
|
|
59
|
+
throw new Error(`Job ${id} cannot depend on itself.`);
|
|
60
|
+
}
|
|
61
|
+
const known = listValidJobs(config);
|
|
62
|
+
assertCanStack(input.repo, dependsOn, input.stack, known);
|
|
63
|
+
const knownIds = new Set(known.map((job) => job.id));
|
|
64
|
+
for (const pending of input.pendingIds ?? [])
|
|
65
|
+
knownIds.add(pending);
|
|
66
|
+
for (const dep of dependsOn) {
|
|
67
|
+
if (!knownIds.has(dep)) {
|
|
68
|
+
throw new Error(`Job ${dep} does not exist. --after must name a job already under ${config.jobsDir}.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const specRel = `specs/${id}.md`;
|
|
72
|
+
const specDest = join(config.kilnDir, specRel);
|
|
73
|
+
mkdirSync(join(config.kilnDir, "specs"), { recursive: true });
|
|
74
|
+
writeFileSync(specDest, input.specText);
|
|
75
|
+
const job = JobSchema.parse({
|
|
76
|
+
id,
|
|
77
|
+
repo: input.repo,
|
|
78
|
+
workflow: input.workflow,
|
|
79
|
+
spec: specRel,
|
|
80
|
+
title,
|
|
81
|
+
...(dependsOn.length > 0 ? { dependsOn } : {}),
|
|
82
|
+
...(input.stack ? { stack: true } : {}),
|
|
83
|
+
source: input.source,
|
|
84
|
+
createdAt: now.toISOString(),
|
|
85
|
+
});
|
|
86
|
+
mkdirSync(config.jobsDir, { recursive: true });
|
|
87
|
+
const jsonPath = jobFilePath(config.jobsDir, job.id);
|
|
88
|
+
writeFileSync(jsonPath, `${JSON.stringify(job, null, 2)}\n`);
|
|
89
|
+
return { job, jsonPath };
|
|
90
|
+
}
|
|
91
|
+
export function renderAddedJob(job, jsonPath) {
|
|
92
|
+
const lines = [`added ${job.id}`, `repo ${job.repo}`, `workflow ${job.workflow}`, `spec ${job.spec}`];
|
|
93
|
+
if (job.stack)
|
|
94
|
+
lines.push("stack");
|
|
95
|
+
if (job.source)
|
|
96
|
+
lines.push(`source ${job.source.kind} ${job.source.ref}`);
|
|
97
|
+
lines.push(`wrote ${jsonPath}`, "");
|
|
98
|
+
return lines.join("\n");
|
|
99
|
+
}
|
|
100
|
+
/** Valid job files. Unreadable files are skipped; the driver reports those. */
|
|
101
|
+
export function listValidJobs(config) {
|
|
102
|
+
if (!existsSync(config.jobsDir))
|
|
103
|
+
return [];
|
|
104
|
+
const jobs = [];
|
|
105
|
+
for (const name of readdirSync(config.jobsDir)) {
|
|
106
|
+
if (!name.endsWith(".json"))
|
|
107
|
+
continue;
|
|
108
|
+
try {
|
|
109
|
+
const raw = JSON.parse(readFileSync(join(config.jobsDir, name), "utf8"));
|
|
110
|
+
const job = JobSchema.parse(raw);
|
|
111
|
+
if (`${jobBasename(job.id)}.json` === name)
|
|
112
|
+
jobs.push(job);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// An unreadable job is not a dependency target. The driver reports it.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return jobs;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* `stack` needs one upstream in the same repo: the pull request has one base
|
|
122
|
+
* branch, and the worktree is cut from that branch. Zero dependencies and
|
|
123
|
+
* two or more are both rejected. An investigation upstream is rejected: that
|
|
124
|
+
* workflow does not land a branch. An upstream that is not on disk yet is
|
|
125
|
+
* allowed here so an import batch can validate the count before every job
|
|
126
|
+
* exists; the same-repo check runs when the upstream file is already there.
|
|
127
|
+
*/
|
|
128
|
+
export function assertCanStack(repo, dependsOn, stack, known = []) {
|
|
129
|
+
if (!stack)
|
|
130
|
+
return;
|
|
131
|
+
if (dependsOn.length !== 1) {
|
|
132
|
+
throw new Error(`stack needs exactly one dependency so the pull request has one base branch. Got ${dependsOn.length}.`);
|
|
133
|
+
}
|
|
134
|
+
const upstreamId = dependsOn[0];
|
|
135
|
+
const upstream = known.find((job) => job.id === upstreamId);
|
|
136
|
+
if (upstream?.workflow === INVESTIGATION_WORKFLOW) {
|
|
137
|
+
throw new Error(`stack cannot target investigation job ${upstreamId}. An investigation does not land a branch.`);
|
|
138
|
+
}
|
|
139
|
+
if (upstream && upstream.repo !== repo) {
|
|
140
|
+
throw new Error(`stack needs upstream ${upstreamId} to be in repo ${repo}. That job is in repo ${upstream.repo}.`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function normalizedDepends(dependsOn) {
|
|
144
|
+
const seen = new Set();
|
|
145
|
+
const out = [];
|
|
146
|
+
for (const id of dependsOn) {
|
|
147
|
+
if (seen.has(id)) {
|
|
148
|
+
throw new Error(`--after lists ${id} more than once.`);
|
|
149
|
+
}
|
|
150
|
+
seen.add(id);
|
|
151
|
+
out.push(id);
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
/**
|
|
5
|
+
* Directory of the Kiln package that contains this file.
|
|
6
|
+
*
|
|
7
|
+
* Starts at `import.meta.url` and walks up to the nearest `package.json`.
|
|
8
|
+
* That is the repo root in a checkout (`src/` via tsx, or compiled `dist/`)
|
|
9
|
+
* and the installed package root for `node_modules/@lchase/kiln`. Workflows
|
|
10
|
+
* and prompts are read from there, not from `process.cwd()`.
|
|
11
|
+
*/
|
|
12
|
+
export function packageRoot() {
|
|
13
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
for (let i = 0; i < 6; i += 1) {
|
|
15
|
+
if (existsSync(join(dir, "package.json")))
|
|
16
|
+
return dir;
|
|
17
|
+
const parent = dirname(dir);
|
|
18
|
+
if (parent === dir)
|
|
19
|
+
break;
|
|
20
|
+
dir = parent;
|
|
21
|
+
}
|
|
22
|
+
throw new Error("Could not find package.json above the running file.");
|
|
23
|
+
}
|
|
24
|
+
/** One path segment. Job ids we generate already pass; this rejects `..` and slashes. */
|
|
25
|
+
export function jobBasename(id) {
|
|
26
|
+
const safe = id
|
|
27
|
+
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
28
|
+
.replace(/^[-.]+/, "")
|
|
29
|
+
.replace(/[-.]+$/, "");
|
|
30
|
+
if (!safe) {
|
|
31
|
+
throw new Error(`Job id ${JSON.stringify(id)} cannot be used as a file name.`);
|
|
32
|
+
}
|
|
33
|
+
return safe;
|
|
34
|
+
}
|