@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/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 { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
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 = basename(configDir) === ".kiln" ? configDir : join(cwd, ".kiln");
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
- * Local config wins: `./.kiln/kiln.config.json`, then `~/.kiln/kiln.config.json`.
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 cwdConfig = join(cwd, ".kiln", CONFIG_NAME);
63
- if (existsSync(cwdConfig))
64
- return loadKilnConfig(cwdConfig, { cwd, home });
65
- const homeConfig = join(home, ".kiln", CONFIG_NAME);
66
- if (existsSync(homeConfig))
67
- return loadKilnConfig(homeConfig, { cwd, home });
68
- throw new KilnConfigError([
69
- "No kiln config found.",
70
- `Run kiln repo add <name> <path> to create ${join(cwd, ".kiln", CONFIG_NAME)},`,
71
- `or create ${join(home, ".kiln", CONFIG_NAME)}.`,
72
- "Those directories are gitignored. Do not commit checkout paths.",
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, configDir, cwd, home);
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 configPathForWrite(options) {
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 cwdConfig;
162
- const homeConfig = join(home, ".kiln", CONFIG_NAME);
179
+ if (!existsSync(cwdConfig))
180
+ return;
181
+ if (resolve(cwdConfig) === resolve(homeConfig))
182
+ return;
163
183
  if (existsSync(homeConfig))
164
- return homeConfig;
165
- return cwdConfig;
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, configDir, cwd, home) {
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
- const absolute = resolve(cwd, userPath);
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(observe(config, host).observation, jobId);
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
- ...(crumb?.logPath ? { logPath: crumb.logPath } : {}),
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;
@@ -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
+ }
@@ -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(key, typeLooksLikeSpike(issue.issueType), input.workflow, warnings);
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({
@@ -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, warnings);
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, warnings) {
174
- return workflowForSpike(ticket.file, ticket.spike, requested, warnings);
182
+ function workflowFor(ticket, requested) {
183
+ return workflowForSpike(ticket.spike, requested);
175
184
  }
176
185
  /**
177
- * Spike and research work uses `investigation` when that workflow file exists.
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(label, spike, requested, warnings) {
189
+ export function workflowForSpike(spike, requested) {
182
190
  if (!spike)
183
191
  return requested;
184
- if (existsSync(join(packageRoot(), "workflows", "investigation.yaml")))
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 that is not on disk yet is
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
  }