@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/config.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import {
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { KilnConfigError } from "./errors.js";
|
|
5
5
|
import { KilnConfigFileSchema } from "./schemas.js";
|
|
6
6
|
const CONFIG_NAME = "kiln.config.json";
|
|
7
7
|
const REPO_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
8
8
|
export function loadKilnConfig(configPath, options = {}) {
|
|
9
|
-
const cwd = options.cwd ?? process.cwd();
|
|
10
9
|
const home = options.home ?? homedir();
|
|
11
10
|
const resolvedPath = resolve(configPath);
|
|
12
11
|
let raw;
|
|
@@ -36,7 +35,7 @@ export function loadKilnConfig(configPath, options = {}) {
|
|
|
36
35
|
...(entry.base ? { base: entry.base } : {}),
|
|
37
36
|
};
|
|
38
37
|
}
|
|
39
|
-
const kilnDir =
|
|
38
|
+
const kilnDir = configDir;
|
|
40
39
|
const jira = jiraFromFile(parsed.data.jira);
|
|
41
40
|
return {
|
|
42
41
|
configPath: resolvedPath,
|
|
@@ -51,7 +50,8 @@ export function loadKilnConfig(configPath, options = {}) {
|
|
|
51
50
|
};
|
|
52
51
|
}
|
|
53
52
|
/**
|
|
54
|
-
*
|
|
53
|
+
* One kiln home: `--config <file>` if given, else `$KILN_HOME/kiln.config.json`
|
|
54
|
+
* when `KILN_HOME` is set, else `~/.kiln/kiln.config.json`.
|
|
55
55
|
*/
|
|
56
56
|
export function resolveKilnConfig(options = {}) {
|
|
57
57
|
const cwd = options.cwd ?? process.cwd();
|
|
@@ -59,18 +59,17 @@ export function resolveKilnConfig(options = {}) {
|
|
|
59
59
|
if (options.configPath) {
|
|
60
60
|
return loadKilnConfig(resolve(cwd, options.configPath), { cwd, home });
|
|
61
61
|
}
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
].join("\n"));
|
|
62
|
+
const configPath = defaultConfigPath(options);
|
|
63
|
+
assertNoStaleCwdConfig(cwd, configPath);
|
|
64
|
+
if (!existsSync(configPath)) {
|
|
65
|
+
throw new KilnConfigError([
|
|
66
|
+
"No kiln config found.",
|
|
67
|
+
`Run kiln repo add <name> <path> from any directory to create ${configPath}.`,
|
|
68
|
+
"Set KILN_HOME or pass --config <file> to use a different kiln home.",
|
|
69
|
+
"Do not commit checkout paths.",
|
|
70
|
+
].join("\n"));
|
|
71
|
+
}
|
|
72
|
+
return loadKilnConfig(configPath, { cwd, home });
|
|
74
73
|
}
|
|
75
74
|
const BRANCH_NAME = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
76
75
|
/** Create or update one repo entry. Returns the text to print. */
|
|
@@ -85,7 +84,7 @@ export function addRepo(options) {
|
|
|
85
84
|
assertBranchName(options.base);
|
|
86
85
|
const configPath = configPathForWrite(options);
|
|
87
86
|
const configDir = dirname(configPath);
|
|
88
|
-
const storedPath = pathToStore(options.repoPath,
|
|
87
|
+
const storedPath = pathToStore(options.repoPath, cwd, home);
|
|
89
88
|
const checkout = resolveConfiguredPath(configDir, storedPath, home);
|
|
90
89
|
if (!existsSync(checkout) || !statSync(checkout).isDirectory()) {
|
|
91
90
|
throw new KilnConfigError(`Checkout for ${options.name} does not exist or is not a directory: ${checkout}.`);
|
|
@@ -151,18 +150,39 @@ export function assertRepoName(name) {
|
|
|
151
150
|
throw new KilnConfigError(`Repo name ${JSON.stringify(name)} must start with a letter or digit and contain only letters, digits, dots, underscores, and hyphens.`);
|
|
152
151
|
}
|
|
153
152
|
}
|
|
154
|
-
function
|
|
153
|
+
function defaultConfigPath(options) {
|
|
154
|
+
return join(kilnHomeDirectory(options), CONFIG_NAME);
|
|
155
|
+
}
|
|
156
|
+
/** `$KILN_HOME` when set, otherwise `~/.kiln`. A relative `KILN_HOME` resolves from `cwd`. */
|
|
157
|
+
function kilnHomeDirectory(options) {
|
|
155
158
|
const cwd = options.cwd ?? process.cwd();
|
|
159
|
+
const fromEnv = process.env.KILN_HOME;
|
|
160
|
+
if (typeof fromEnv === "string" && fromEnv.trim().length > 0)
|
|
161
|
+
return resolve(cwd, fromEnv);
|
|
156
162
|
const home = options.home ?? homedir();
|
|
163
|
+
return join(home, ".kiln");
|
|
164
|
+
}
|
|
165
|
+
function configPathForWrite(options) {
|
|
166
|
+
const cwd = options.cwd ?? process.cwd();
|
|
157
167
|
if (options.configPath)
|
|
158
168
|
return resolve(cwd, options.configPath);
|
|
169
|
+
const configPath = defaultConfigPath(options);
|
|
170
|
+
assertNoStaleCwdConfig(cwd, configPath);
|
|
171
|
+
return configPath;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* A leftover `./.kiln/kiln.config.json` used to be a second kiln home.
|
|
175
|
+
* Fail instead of ignoring it when the machine home has no config yet.
|
|
176
|
+
*/
|
|
177
|
+
function assertNoStaleCwdConfig(cwd, homeConfig) {
|
|
159
178
|
const cwdConfig = join(cwd, ".kiln", CONFIG_NAME);
|
|
160
|
-
if (existsSync(cwdConfig))
|
|
161
|
-
return
|
|
162
|
-
|
|
179
|
+
if (!existsSync(cwdConfig))
|
|
180
|
+
return;
|
|
181
|
+
if (resolve(cwdConfig) === resolve(homeConfig))
|
|
182
|
+
return;
|
|
163
183
|
if (existsSync(homeConfig))
|
|
164
|
-
return
|
|
165
|
-
|
|
184
|
+
return;
|
|
185
|
+
throw new KilnConfigError(`Found ${cwdConfig} but no home config at ${homeConfig}. mv ./.kiln ~/.kiln or pass --config / set KILN_HOME.`);
|
|
166
186
|
}
|
|
167
187
|
function readConfigObject(configPath) {
|
|
168
188
|
if (!existsSync(configPath))
|
|
@@ -205,14 +225,12 @@ export function resolveConfiguredPath(configDir, value, home) {
|
|
|
205
225
|
const expanded = expandHome(value, home);
|
|
206
226
|
return isAbsolute(expanded) ? expanded : resolve(configDir, expanded);
|
|
207
227
|
}
|
|
208
|
-
function pathToStore(userPath,
|
|
228
|
+
function pathToStore(userPath, cwd, home) {
|
|
209
229
|
if (userPath === "~" || userPath.startsWith("~/"))
|
|
210
230
|
return userPath;
|
|
211
231
|
if (isAbsolute(userPath))
|
|
212
232
|
return userPath;
|
|
213
|
-
|
|
214
|
-
const stored = relative(configDir, absolute);
|
|
215
|
-
return stored === "" ? "." : stored;
|
|
233
|
+
return resolve(cwd, userPath);
|
|
216
234
|
}
|
|
217
235
|
function jiraFromFile(jira) {
|
|
218
236
|
if (!jira)
|
package/dist/driver.js
CHANGED
|
@@ -3,13 +3,14 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { closeSync, existsSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
4
4
|
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
5
5
|
import { clearLaunchError, deliverJob, readDelivery, readLaunchError, resolveLaunchBase, writeDelivery, writeLaunchError, isMerged, } from "./delivery.js";
|
|
6
|
+
import { appendInvestigationFindings, existingFindingsPath, findingsFilePath, INVESTIGATION_WORKFLOW, readFindingsMarkdown, writeFindings, } from "./findings.js";
|
|
6
7
|
import { defaultHost } from "./host.js";
|
|
7
|
-
import { readJobSpec } from "./jobs.js";
|
|
8
|
+
import { readJobSpec, resolveJobSpec } from "./jobs.js";
|
|
8
9
|
import { jobBasename, packageRoot } from "./paths.js";
|
|
9
|
-
import { decide, jobState, } from "./reconcile.js";
|
|
10
|
-
import { gateAnswerCommand, renderStatus, renderTick, yakCliPath, } from "./report.js";
|
|
10
|
+
import { decide, isLanded, jobState, } from "./reconcile.js";
|
|
11
|
+
import { gateAnswerCommand, renderStatus, renderTick, statusView, yakCliPath, } from "./report.js";
|
|
11
12
|
import { JobSchema } from "./schemas.js";
|
|
12
|
-
import { materializeWorkflow, workflowInputs } from "./workflow.js";
|
|
13
|
+
import { materializeWorkflow, workflowInputs, workflowNeedsTestCommand } from "./workflow.js";
|
|
13
14
|
const TAG_PREFIX = "kiln:";
|
|
14
15
|
export function jobTag(jobId) {
|
|
15
16
|
return `${TAG_PREFIX}${jobId}`;
|
|
@@ -22,8 +23,12 @@ export function parseJobTag(tag) {
|
|
|
22
23
|
return undefined;
|
|
23
24
|
return id;
|
|
24
25
|
}
|
|
26
|
+
/** The derived view `kiln status` prints and `kiln serve` renders. */
|
|
27
|
+
export function readStatus(config, jobId, host = defaultHost()) {
|
|
28
|
+
return statusView(observe(config, host).observation, jobId);
|
|
29
|
+
}
|
|
25
30
|
export function runStatus(config, jobId, host = defaultHost()) {
|
|
26
|
-
return renderStatus(
|
|
31
|
+
return renderStatus(readStatus(config, jobId, host));
|
|
27
32
|
}
|
|
28
33
|
export function runTick(config, host = defaultHost()) {
|
|
29
34
|
return withTickLock(config.kilnDir, () => {
|
|
@@ -55,6 +60,9 @@ export function answerJob(config, jobId, choice, host = defaultHost()) {
|
|
|
55
60
|
if (result.status !== 0) {
|
|
56
61
|
throw new Error(output.trim() || `Could not ${choice} job ${jobId}.`);
|
|
57
62
|
}
|
|
63
|
+
if (choice === "approve") {
|
|
64
|
+
recordInvestigationFindings(config, observe(config, host), jobId);
|
|
65
|
+
}
|
|
58
66
|
return output.endsWith("\n") || output.length === 0 ? output : `${output}\n`;
|
|
59
67
|
}
|
|
60
68
|
function gateForChoice(gates, choice) {
|
|
@@ -94,6 +102,11 @@ function observe(config, host) {
|
|
|
94
102
|
const stackError = entry.job?.stack && (entry.job.dependsOn?.length ?? 0) !== 1
|
|
95
103
|
? "stack needs exactly one dependency."
|
|
96
104
|
: undefined;
|
|
105
|
+
const logPath = crumb?.logPath || existingLog(config.kilnDir, entry.meta.jobId);
|
|
106
|
+
const specPath = entry.job ? resolveJobSpec(config.kilnDir, entry.job.spec) : undefined;
|
|
107
|
+
const findingsPath = entry.job?.workflow === INVESTIGATION_WORKFLOW
|
|
108
|
+
? existingFindingsPath(config.kilnDir, entry.job.id)
|
|
109
|
+
: undefined;
|
|
97
110
|
jobs.push({
|
|
98
111
|
jobId: entry.meta.jobId,
|
|
99
112
|
repo: entry.job?.repo ?? "",
|
|
@@ -101,13 +114,15 @@ function observe(config, host) {
|
|
|
101
114
|
title: entry.job?.title ?? "",
|
|
102
115
|
dependsOn: entry.job?.dependsOn ?? [],
|
|
103
116
|
...(entry.job?.source ? { source: entry.job.source } : {}),
|
|
117
|
+
...(specPath ? { specPath } : {}),
|
|
104
118
|
deliver,
|
|
105
119
|
...(entry.job?.stack ? { stack: true } : {}),
|
|
106
120
|
...(delivery ? { delivery } : {}),
|
|
121
|
+
...(findingsPath ? { findingsPath } : {}),
|
|
107
122
|
launching: Boolean(crumb) && !run && childAlive,
|
|
108
123
|
launchFailed: Boolean(crumb) && !run && !childAlive,
|
|
109
124
|
...(launchError ? { launchError } : {}),
|
|
110
|
-
...(
|
|
125
|
+
...(logPath ? { logPath } : {}),
|
|
111
126
|
...(run ? { run } : {}),
|
|
112
127
|
...(entry.meta.error || repoError || stackError
|
|
113
128
|
? { error: entry.meta.error ?? repoError ?? stackError }
|
|
@@ -126,6 +141,7 @@ function observe(config, host) {
|
|
|
126
141
|
function apply(config, world, actions, host) {
|
|
127
142
|
const launches = [];
|
|
128
143
|
const delivers = [];
|
|
144
|
+
const findings = [];
|
|
129
145
|
for (const action of actions) {
|
|
130
146
|
if (action.type === "clear-breadcrumb") {
|
|
131
147
|
rmSync(action.path, { force: true });
|
|
@@ -135,13 +151,38 @@ function apply(config, world, actions, host) {
|
|
|
135
151
|
delivers.push(deliverObserved(config, world, action.jobId, host));
|
|
136
152
|
continue;
|
|
137
153
|
}
|
|
154
|
+
if (action.type === "record-findings") {
|
|
155
|
+
const recorded = recordInvestigationFindings(config, world, action.jobId);
|
|
156
|
+
if (recorded.status !== "skip")
|
|
157
|
+
findings.push(recorded);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
138
160
|
const job = world.jobs.get(action.jobId);
|
|
139
161
|
if (!job) {
|
|
140
162
|
throw new Error(`Tick planned a launch for ${action.jobId}, which is not in the observed jobs.`);
|
|
141
163
|
}
|
|
142
164
|
launches.push(launchJob(config, world, job, host));
|
|
143
165
|
}
|
|
144
|
-
return renderTick(world.observation, actions, launches, delivers);
|
|
166
|
+
return renderTick(world.observation, actions, launches, delivers, findings);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Copy an approved investigation's markdown into `<kilnDir>/findings`.
|
|
170
|
+
* A file that is already present is left as it is. A missing or incomplete
|
|
171
|
+
* artifact is reported and does not fail the tick.
|
|
172
|
+
*/
|
|
173
|
+
function recordInvestigationFindings(config, world, jobId) {
|
|
174
|
+
const job = world.jobs.get(jobId);
|
|
175
|
+
const observed = world.observation.jobs.find((candidate) => candidate.jobId === jobId);
|
|
176
|
+
if (!job || job.workflow !== INVESTIGATION_WORKFLOW || !observed?.run || !isLanded(observed)) {
|
|
177
|
+
return { status: "skip", jobId };
|
|
178
|
+
}
|
|
179
|
+
if (observed.findingsPath)
|
|
180
|
+
return { status: "skip", jobId };
|
|
181
|
+
const markdown = readFindingsMarkdown(observed.run.checkout, observed.run.runId);
|
|
182
|
+
if (!markdown)
|
|
183
|
+
return { status: "missing", jobId };
|
|
184
|
+
const path = writeFindings(config.kilnDir, job.id, markdown);
|
|
185
|
+
return { status: "wrote", jobId, path };
|
|
145
186
|
}
|
|
146
187
|
function deliverObserved(config, world, jobId, host) {
|
|
147
188
|
const job = world.jobs.get(jobId);
|
|
@@ -190,6 +231,33 @@ function pullRequestBase(world, job, repo, host) {
|
|
|
190
231
|
host.git.fetch(repo.path);
|
|
191
232
|
return host.git.defaultBranch(repo.path);
|
|
192
233
|
}
|
|
234
|
+
function assertStackTarget(world, job) {
|
|
235
|
+
if (!job.stack)
|
|
236
|
+
return;
|
|
237
|
+
const upstreamId = job.dependsOn?.[0];
|
|
238
|
+
if (!upstreamId)
|
|
239
|
+
return;
|
|
240
|
+
const upstream = world.jobs.get(upstreamId);
|
|
241
|
+
if (upstream?.workflow !== INVESTIGATION_WORKFLOW)
|
|
242
|
+
return;
|
|
243
|
+
throw new Error(`stack cannot target investigation job ${upstream.id}. An investigation does not land a branch.`);
|
|
244
|
+
}
|
|
245
|
+
/** Spec text for this launch. Investigation dependencies append their findings. */
|
|
246
|
+
function specHandedToYak(config, world, job) {
|
|
247
|
+
const base = readJobSpec(config.kilnDir, job);
|
|
248
|
+
const sections = [];
|
|
249
|
+
for (const depId of job.dependsOn ?? []) {
|
|
250
|
+
const dep = world.jobs.get(depId);
|
|
251
|
+
if (!dep || dep.workflow !== INVESTIGATION_WORKFLOW)
|
|
252
|
+
continue;
|
|
253
|
+
const path = findingsFilePath(config.kilnDir, dep.id);
|
|
254
|
+
if (!existsSync(path)) {
|
|
255
|
+
throw new Error(`Job ${job.id} depends on investigation ${dep.id} (${dep.title}), which has no findings file at ${path}.`);
|
|
256
|
+
}
|
|
257
|
+
sections.push({ title: dep.title, markdown: readFileSync(path, "utf8") });
|
|
258
|
+
}
|
|
259
|
+
return appendInvestigationFindings(base, sections);
|
|
260
|
+
}
|
|
193
261
|
function upstreamBranch(world, job) {
|
|
194
262
|
const upstreamId = job.dependsOn?.[0];
|
|
195
263
|
if (!upstreamId)
|
|
@@ -233,8 +301,9 @@ function launchJob(config, world, job, host) {
|
|
|
233
301
|
if (!repo) {
|
|
234
302
|
throw new Error(`Repo ${job.repo} is not registered. Run kiln repo add ${job.repo} <path>.`);
|
|
235
303
|
}
|
|
304
|
+
const sourceWorkflow = readFileSync(join(packageRoot(), "workflows", `${job.workflow}.yaml`), "utf8");
|
|
236
305
|
const testCommand = repo.test;
|
|
237
|
-
if (!testCommand?.trim()) {
|
|
306
|
+
if (workflowNeedsTestCommand(sourceWorkflow) && !testCommand?.trim()) {
|
|
238
307
|
throw new Error(`Repo ${job.repo} has no test command. Set repos.${job.repo}.test. The feature workflow refuses to run an empty KILN_TEST.`);
|
|
239
308
|
}
|
|
240
309
|
const workflow = materializeWorkflow(config.kilnDir, job.id, job.workflow);
|
|
@@ -243,8 +312,10 @@ function launchJob(config, world, job, host) {
|
|
|
243
312
|
const crumbPath = breadcrumbPath(config.kilnDir, job.id);
|
|
244
313
|
ensureCheckoutExcludes(repo.path);
|
|
245
314
|
let baseSha;
|
|
315
|
+
let specText;
|
|
246
316
|
try {
|
|
247
317
|
clearLaunchError(config.kilnDir, job.id);
|
|
318
|
+
assertStackTarget(world, job);
|
|
248
319
|
baseSha = resolveLaunchBase({
|
|
249
320
|
checkout: repo.path,
|
|
250
321
|
deliver: repo.deliver,
|
|
@@ -253,6 +324,7 @@ function launchJob(config, world, job, host) {
|
|
|
253
324
|
...(job.stack ? { upstreamBranch: upstreamBranch(world, job) } : {}),
|
|
254
325
|
git: host.git,
|
|
255
326
|
});
|
|
327
|
+
specText = specHandedToYak(config, world, job);
|
|
256
328
|
}
|
|
257
329
|
catch (error) {
|
|
258
330
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -260,8 +332,6 @@ function launchJob(config, world, job, host) {
|
|
|
260
332
|
writeFileSync(logPath, `${message}\n`);
|
|
261
333
|
return { jobId: job.id, repo: job.repo, output: message };
|
|
262
334
|
}
|
|
263
|
-
const specText = readJobSpec(config.kilnDir, job);
|
|
264
|
-
const sourceWorkflow = readFileSync(join(packageRoot(), "workflows", `${job.workflow}.yaml`), "utf8");
|
|
265
335
|
const input = workflowInputs(sourceWorkflow, {
|
|
266
336
|
title: job.title,
|
|
267
337
|
specText,
|
|
@@ -467,20 +537,141 @@ function openGates(runDir, events) {
|
|
|
467
537
|
return gates;
|
|
468
538
|
}
|
|
469
539
|
function readGate(runDir, stepId) {
|
|
540
|
+
const reviews = readReviewArtifacts(runDir);
|
|
470
541
|
const file = join(runDir, "pending", `${stepId}.request.json`);
|
|
471
|
-
if (!existsSync(file))
|
|
472
|
-
return { stepId, prompt: "(request file missing)" };
|
|
542
|
+
if (!existsSync(file)) {
|
|
543
|
+
return { stepId, prompt: "(request file missing)", ...(reviews.length > 0 ? { reviews } : {}) };
|
|
544
|
+
}
|
|
473
545
|
const request = JSON.parse(readFileSync(file, "utf8"));
|
|
474
546
|
const prompt = request.kind === "loop-exhausted"
|
|
475
547
|
? `loop exhausted (${request.tripped ?? "budget"} at iteration ${request.iteration ?? "?"})`
|
|
476
548
|
: (request.rendered ?? stepId).split("\n")[0] ?? stepId;
|
|
477
549
|
const shape = answerShape(request.answerSchema);
|
|
550
|
+
const rendered = typeof request.rendered === "string" ? clipText(request.rendered, 2000) : undefined;
|
|
551
|
+
const artifacts = readNamedArtifacts(runDir, artifactNames(request.context?.artifacts));
|
|
478
552
|
return {
|
|
479
553
|
stepId,
|
|
480
554
|
prompt,
|
|
555
|
+
...(rendered ? { rendered } : {}),
|
|
481
556
|
...(shape ? { answerField: shape.field, choices: shape.choices } : {}),
|
|
557
|
+
...(artifacts.length > 0 ? { artifacts } : {}),
|
|
558
|
+
...(reviews.length > 0 ? { reviews } : {}),
|
|
482
559
|
};
|
|
483
560
|
}
|
|
561
|
+
const REVIEW_STEPS = ["security", "ux", "ui", "code"];
|
|
562
|
+
const ARTIFACT_NAME = /^[A-Za-z0-9._-]+$/;
|
|
563
|
+
const SECRET_KEY = /api[_-]?token|password|secret|authorization|credential/i;
|
|
564
|
+
/** The launch log stays after the breadcrumb is cleared. Status prints it only for a failed launch. */
|
|
565
|
+
function existingLog(kilnDir, jobId) {
|
|
566
|
+
let name;
|
|
567
|
+
try {
|
|
568
|
+
name = jobBasename(jobId);
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
return undefined;
|
|
572
|
+
}
|
|
573
|
+
const path = join(kilnDir, "logs", `${name}.log`);
|
|
574
|
+
return existsSync(path) ? path : undefined;
|
|
575
|
+
}
|
|
576
|
+
function artifactNames(value) {
|
|
577
|
+
if (!Array.isArray(value))
|
|
578
|
+
return [];
|
|
579
|
+
const names = [];
|
|
580
|
+
for (const entry of value) {
|
|
581
|
+
if (typeof entry !== "string" || !ARTIFACT_NAME.test(entry) || names.includes(entry))
|
|
582
|
+
continue;
|
|
583
|
+
names.push(entry);
|
|
584
|
+
}
|
|
585
|
+
return names;
|
|
586
|
+
}
|
|
587
|
+
function readNamedArtifacts(runDir, names) {
|
|
588
|
+
const found = [];
|
|
589
|
+
for (const name of names) {
|
|
590
|
+
const file = join(runDir, "artifacts", `${name}.json`);
|
|
591
|
+
if (!existsSync(file))
|
|
592
|
+
continue;
|
|
593
|
+
found.push(readArtifactSummary(file, name));
|
|
594
|
+
}
|
|
595
|
+
return found;
|
|
596
|
+
}
|
|
597
|
+
function readReviewArtifacts(runDir) {
|
|
598
|
+
const dir = join(runDir, "artifacts");
|
|
599
|
+
if (!existsSync(dir))
|
|
600
|
+
return [];
|
|
601
|
+
let files;
|
|
602
|
+
try {
|
|
603
|
+
files = readdirSync(dir);
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
return [];
|
|
607
|
+
}
|
|
608
|
+
const best = new Map();
|
|
609
|
+
for (const file of files) {
|
|
610
|
+
const match = /^(security|ux|ui|code)(?:\.(\d+))?\.json$/.exec(file);
|
|
611
|
+
if (!match?.[1])
|
|
612
|
+
continue;
|
|
613
|
+
const iter = match[2] ? Number(match[2]) : 0;
|
|
614
|
+
const previous = best.get(match[1]);
|
|
615
|
+
if (!previous || iter >= previous.iter)
|
|
616
|
+
best.set(match[1], { iter, file });
|
|
617
|
+
}
|
|
618
|
+
const found = [];
|
|
619
|
+
for (const step of REVIEW_STEPS) {
|
|
620
|
+
const picked = best.get(step);
|
|
621
|
+
if (!picked)
|
|
622
|
+
continue;
|
|
623
|
+
found.push(readArtifactSummary(join(dir, picked.file), step));
|
|
624
|
+
}
|
|
625
|
+
return found;
|
|
626
|
+
}
|
|
627
|
+
function readArtifactSummary(file, name) {
|
|
628
|
+
if (!existsSync(file))
|
|
629
|
+
return { name, summary: "(missing artifact)" };
|
|
630
|
+
try {
|
|
631
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
632
|
+
return { name, summary: summarizeArtifact(name, parsed) };
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
return { name, summary: "(unreadable artifact)" };
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
function summarizeArtifact(name, value) {
|
|
639
|
+
const clean = redactSecrets(value);
|
|
640
|
+
if (name === "input" && isRecord(clean)) {
|
|
641
|
+
const title = clean.title;
|
|
642
|
+
return typeof title === "string" && title.trim() ? `title: ${clipText(title, 120)}` : "(no title)";
|
|
643
|
+
}
|
|
644
|
+
if (isRecord(clean)) {
|
|
645
|
+
const head = typeof clean.decision === "string" ? clean.decision : typeof clean.verdict === "string" ? clean.verdict : undefined;
|
|
646
|
+
const detail = typeof clean.reason === "string" ? clean.reason : typeof clean.findings === "string" ? clean.findings : undefined;
|
|
647
|
+
if (head)
|
|
648
|
+
return clipText(detail ? `${head} — ${detail}` : head, 280);
|
|
649
|
+
}
|
|
650
|
+
try {
|
|
651
|
+
return clipText(JSON.stringify(clean), 280);
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
return "(unreadable artifact)";
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
function redactSecrets(value) {
|
|
658
|
+
if (Array.isArray(value))
|
|
659
|
+
return value.map(redactSecrets);
|
|
660
|
+
if (!isRecord(value))
|
|
661
|
+
return value;
|
|
662
|
+
const out = {};
|
|
663
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
664
|
+
out[key] = SECRET_KEY.test(key) ? "[redacted]" : redactSecrets(inner);
|
|
665
|
+
}
|
|
666
|
+
return out;
|
|
667
|
+
}
|
|
668
|
+
function isRecord(value) {
|
|
669
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
670
|
+
}
|
|
671
|
+
function clipText(text, max) {
|
|
672
|
+
const trimmed = text.trim();
|
|
673
|
+
return trimmed.length <= max ? trimmed : `${trimmed.slice(0, max - 1)}...`;
|
|
674
|
+
}
|
|
484
675
|
function answerShape(schema) {
|
|
485
676
|
if (typeof schema !== "object" || schema === null || !("properties" in schema))
|
|
486
677
|
return undefined;
|
package/dist/findings.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { jobBasename } from "./paths.js";
|
|
4
|
+
/**
|
|
5
|
+
* Workflow name for a spike, probe, or research job.
|
|
6
|
+
* An approved investigation is `done` with no pull request and no land.
|
|
7
|
+
*/
|
|
8
|
+
export const INVESTIGATION_WORKFLOW = "investigation";
|
|
9
|
+
/**
|
|
10
|
+
* Headings the findings markdown must contain. The investigation prompt
|
|
11
|
+
* asks for these spellings. Kiln stores the agent's markdown only when
|
|
12
|
+
* every one of them is present.
|
|
13
|
+
*/
|
|
14
|
+
export const FINDINGS_HEADINGS = [
|
|
15
|
+
"## Questions",
|
|
16
|
+
"## Answers",
|
|
17
|
+
"## Unconfirmed",
|
|
18
|
+
"## Implications for dependent tickets",
|
|
19
|
+
];
|
|
20
|
+
/** `<kilnDir>/findings/<jobId>.md`. The job file does not store this path. */
|
|
21
|
+
export function findingsFilePath(kilnDir, jobId) {
|
|
22
|
+
return join(kilnDir, "findings", `${jobBasename(jobId)}.md`);
|
|
23
|
+
}
|
|
24
|
+
/** Absolute path when the findings file is already on disk. */
|
|
25
|
+
export function existingFindingsPath(kilnDir, jobId) {
|
|
26
|
+
const path = findingsFilePath(kilnDir, jobId);
|
|
27
|
+
return existsSync(path) ? path : undefined;
|
|
28
|
+
}
|
|
29
|
+
export function findingsMarkdownComplete(markdown) {
|
|
30
|
+
return FINDINGS_HEADINGS.every((heading) => markdown.includes(heading));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Store the approved findings document. Writing the same text again does
|
|
34
|
+
* not touch the file. A document missing a required heading is refused.
|
|
35
|
+
*/
|
|
36
|
+
export function writeFindings(kilnDir, jobId, markdown) {
|
|
37
|
+
if (!findingsMarkdownComplete(markdown)) {
|
|
38
|
+
throw new Error(`Findings for ${jobId} are missing a required section. Expected ${FINDINGS_HEADINGS.join(", ")}.`);
|
|
39
|
+
}
|
|
40
|
+
const path = findingsFilePath(kilnDir, jobId);
|
|
41
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
42
|
+
const body = markdown.endsWith("\n") ? markdown : `${markdown}\n`;
|
|
43
|
+
if (existsSync(path) && readFileSync(path, "utf8") === body)
|
|
44
|
+
return path;
|
|
45
|
+
writeFileSync(path, body);
|
|
46
|
+
return path;
|
|
47
|
+
}
|
|
48
|
+
/** The explore step's `findings` artifact, when it is a complete markdown document. */
|
|
49
|
+
export function readFindingsMarkdown(checkout, runId) {
|
|
50
|
+
const path = join(checkout, ".runs", runId, "artifacts", "findings.json");
|
|
51
|
+
if (!existsSync(path))
|
|
52
|
+
return undefined;
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
55
|
+
if (typeof parsed !== "object" || parsed === null || !("markdown" in parsed))
|
|
56
|
+
return undefined;
|
|
57
|
+
const markdown = parsed.markdown;
|
|
58
|
+
if (typeof markdown !== "string")
|
|
59
|
+
return undefined;
|
|
60
|
+
const text = markdown.trim();
|
|
61
|
+
if (!text || !findingsMarkdownComplete(text))
|
|
62
|
+
return undefined;
|
|
63
|
+
return text;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Spec text handed to yak. Investigation dependencies contribute a
|
|
71
|
+
* `## Findings from <title>` section. The stored spec file is not modified.
|
|
72
|
+
*/
|
|
73
|
+
export function appendInvestigationFindings(specText, sections) {
|
|
74
|
+
if (sections.length === 0)
|
|
75
|
+
return specText;
|
|
76
|
+
const base = specText.endsWith("\n") ? specText : `${specText}\n`;
|
|
77
|
+
const blocks = sections.map((section) => `## Findings from ${section.title}\n\n${section.markdown.trim()}\n`);
|
|
78
|
+
return `${base}\n${blocks.join("\n")}`;
|
|
79
|
+
}
|
package/dist/intake/jira.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { assertCanStack, listValidJobs, renderAddedJob, writeJob } from "../jobs.js";
|
|
2
2
|
import { assertWorkflowName } from "../workflow.js";
|
|
3
|
+
import { INVESTIGATION_WORKFLOW } from "../findings.js";
|
|
3
4
|
import { typeLooksLikeSpike, workflowForSpike } from "./tickets.js";
|
|
4
5
|
/** Jira issue keys, stored and matched in uppercase (`PROJ-123`). */
|
|
5
6
|
const ISSUE_KEY = /^[A-Z][A-Z0-9]+-\d+$/;
|
|
@@ -29,7 +30,9 @@ export async function addFromJira(config, input) {
|
|
|
29
30
|
}
|
|
30
31
|
const issue = parseIssue(key, parseJson(issueResponse.body, key), acceptanceField);
|
|
31
32
|
const warnings = [];
|
|
32
|
-
const workflow = workflowForSpike(
|
|
33
|
+
const workflow = workflowForSpike(typeLooksLikeSpike(issue.issueType), input.workflow);
|
|
34
|
+
if (workflow === INVESTIGATION_WORKFLOW)
|
|
35
|
+
assertWorkflowName(workflow);
|
|
33
36
|
const dependsOn = mergeDepends(dependencyIds(config, issue.blockedBy, warnings), input.dependsOn);
|
|
34
37
|
assertDependsExist(config, dependsOn);
|
|
35
38
|
const specText = jiraIssueSpec({
|
package/dist/intake/tickets.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { INVESTIGATION_WORKFLOW } from "../findings.js";
|
|
3
4
|
import { listValidJobs, newJobId, writeJob } from "../jobs.js";
|
|
4
5
|
import { assertWorkflowName } from "../workflow.js";
|
|
5
|
-
import { packageRoot } from "../paths.js";
|
|
6
6
|
/** Context files larger than this are named in the spec instead of copied in. */
|
|
7
7
|
export const CONTEXT_INLINE_LIMIT = 40 * 1024;
|
|
8
8
|
const TICKET_FILE = /^(\d+)-.+\.md$/;
|
|
@@ -21,21 +21,30 @@ export function importTickets(config, input) {
|
|
|
21
21
|
assertWorkflowName(requested);
|
|
22
22
|
const context = resolveContext(dirAbs, input.contextPath, input.cwd);
|
|
23
23
|
const tickets = readTickets(dirAbs);
|
|
24
|
-
if (input.stack) {
|
|
25
|
-
const offender = tickets.find((ticket) => ticket.blockedBy.length > 1) ??
|
|
26
|
-
tickets.find((ticket) => ticket.blockedBy.length !== 1);
|
|
27
|
-
if (offender) {
|
|
28
|
-
throw new Error(`Ticket ${offender.file} has ${offender.blockedBy.length} dependencies. stack needs exactly one.`);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
24
|
const warnings = [];
|
|
32
25
|
const workflows = new Map();
|
|
33
26
|
for (const ticket of tickets) {
|
|
34
|
-
const workflow = workflowFor(ticket, requested
|
|
27
|
+
const workflow = workflowFor(ticket, requested);
|
|
35
28
|
if (workflow !== requested)
|
|
36
29
|
assertWorkflowName(workflow);
|
|
37
30
|
workflows.set(ticket.path, workflow);
|
|
38
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
|
+
}
|
|
39
48
|
const existing = new Map();
|
|
40
49
|
for (const job of listValidJobs(config)) {
|
|
41
50
|
if (job.source?.kind !== "tickets")
|
|
@@ -170,21 +179,17 @@ function composeSpec(ticketText, context) {
|
|
|
170
179
|
const body = context.text.endsWith("\n") ? context.text : `${context.text}\n`;
|
|
171
180
|
return `${base}\n## Background\n\n${body}`;
|
|
172
181
|
}
|
|
173
|
-
function workflowFor(ticket, requested
|
|
174
|
-
return workflowForSpike(ticket.
|
|
182
|
+
function workflowFor(ticket, requested) {
|
|
183
|
+
return workflowForSpike(ticket.spike, requested);
|
|
175
184
|
}
|
|
176
185
|
/**
|
|
177
|
-
* Spike and research work uses
|
|
178
|
-
* Otherwise the requested workflow is kept and the same warning is recorded.
|
|
186
|
+
* Spike and research work uses the investigation workflow.
|
|
179
187
|
* Jira intake calls this for issue types Spike and Research.
|
|
180
188
|
*/
|
|
181
|
-
export function workflowForSpike(
|
|
189
|
+
export function workflowForSpike(spike, requested) {
|
|
182
190
|
if (!spike)
|
|
183
191
|
return requested;
|
|
184
|
-
|
|
185
|
-
return "investigation";
|
|
186
|
-
warnings.push(`warning: ${label} looks like a spike or says no product code; workflows/investigation.yaml is missing, using ${requested}.`);
|
|
187
|
-
return requested;
|
|
192
|
+
return INVESTIGATION_WORKFLOW;
|
|
188
193
|
}
|
|
189
194
|
function readTickets(dirAbs) {
|
|
190
195
|
const names = readdirSync(dirAbs)
|
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 { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
4
5
|
import { jobBasename } from "./paths.js";
|
|
5
6
|
import { JobSchema } from "./schemas.js";
|
|
6
7
|
import { assertWorkflowName } from "./workflow.js";
|
|
@@ -119,7 +120,8 @@ export function listValidJobs(config) {
|
|
|
119
120
|
/**
|
|
120
121
|
* `stack` needs one upstream in the same repo: the pull request has one base
|
|
121
122
|
* branch, and the worktree is cut from that branch. Zero dependencies and
|
|
122
|
-
* two or more are both rejected. An upstream
|
|
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
|
|
123
125
|
* allowed here so an import batch can validate the count before every job
|
|
124
126
|
* exists; the same-repo check runs when the upstream file is already there.
|
|
125
127
|
*/
|
|
@@ -131,6 +133,9 @@ export function assertCanStack(repo, dependsOn, stack, known = []) {
|
|
|
131
133
|
}
|
|
132
134
|
const upstreamId = dependsOn[0];
|
|
133
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
|
+
}
|
|
134
139
|
if (upstream && upstream.repo !== repo) {
|
|
135
140
|
throw new Error(`stack needs upstream ${upstreamId} to be in repo ${repo}. That job is in repo ${upstream.repo}.`);
|
|
136
141
|
}
|