@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,141 @@
|
|
|
1
|
+
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
2
|
+
export function inFlightCount(observation) {
|
|
3
|
+
return observation.jobs.filter((job) => inFlight(job, observation)).length;
|
|
4
|
+
}
|
|
5
|
+
function inFlight(job, observation) {
|
|
6
|
+
if (job.error)
|
|
7
|
+
return false;
|
|
8
|
+
const state = jobState(job, observation);
|
|
9
|
+
return state === "running" || state === "needs-human" || state === "approved";
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* `deliver=local`: `done` is a finished run whose gate says approve.
|
|
13
|
+
* `deliver=pr`: that landed run is `in-review` while the pull request is open
|
|
14
|
+
* (and in the gap before tick opens it) and `done` only once it has merged.
|
|
15
|
+
* `approved` is the approve decision while the run is still alive. It holds
|
|
16
|
+
* a slot and does not unblock dependents. An ok run with no readable
|
|
17
|
+
* decision is `failed`, so a missing artifact cannot unblock the next job.
|
|
18
|
+
* An investigation is `done` on approve and never `in-review`.
|
|
19
|
+
*/
|
|
20
|
+
export function jobState(job, observation) {
|
|
21
|
+
if (job.launchFailed && !job.run)
|
|
22
|
+
return "failed";
|
|
23
|
+
if (job.run) {
|
|
24
|
+
if (isLanded(job))
|
|
25
|
+
return landedState(job);
|
|
26
|
+
if (job.run.status === "ok" && job.run.gateDecision === "reject")
|
|
27
|
+
return "rejected";
|
|
28
|
+
if (job.run.status === "ok")
|
|
29
|
+
return "failed";
|
|
30
|
+
if (job.run.status === "failed")
|
|
31
|
+
return "failed";
|
|
32
|
+
if (job.run.status === "dead")
|
|
33
|
+
return "dead";
|
|
34
|
+
if (job.run.status === "suspended")
|
|
35
|
+
return "needs-human";
|
|
36
|
+
if (job.run.gateDecision === "approve")
|
|
37
|
+
return "approved";
|
|
38
|
+
return "running";
|
|
39
|
+
}
|
|
40
|
+
if (job.launching)
|
|
41
|
+
return "running";
|
|
42
|
+
if (!dependenciesMet(job, observation))
|
|
43
|
+
return "blocked";
|
|
44
|
+
if (job.launchError)
|
|
45
|
+
return "failed";
|
|
46
|
+
return "queued";
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* After-merged (the default) needs every upstream `done`.
|
|
50
|
+
* Stacked needs its one upstream landed (approved and the run finished ok),
|
|
51
|
+
* which is true for `in-review` as well as `done`.
|
|
52
|
+
*/
|
|
53
|
+
export function dependenciesMet(job, observation) {
|
|
54
|
+
return job.dependsOn.every((dep) => {
|
|
55
|
+
const upstream = observation.jobs.find((candidate) => candidate.jobId === dep);
|
|
56
|
+
if (!upstream)
|
|
57
|
+
return false;
|
|
58
|
+
return job.stack ? isLanded(upstream) : isDone(upstream);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** Run finished ok and the gate says approve. The branch `yak/<run-id>` has landed. */
|
|
62
|
+
export function isLanded(job) {
|
|
63
|
+
return job.run?.status === "ok" && job.run.gateDecision === "approve";
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* `deliver=local`: landed. `deliver=pr`: landed and the pull request has merged.
|
|
67
|
+
* An investigation is done once the gate says approve. There is no pull request.
|
|
68
|
+
*/
|
|
69
|
+
export function isDone(job) {
|
|
70
|
+
if (!isLanded(job))
|
|
71
|
+
return false;
|
|
72
|
+
if (job.workflow === INVESTIGATION_WORKFLOW)
|
|
73
|
+
return true;
|
|
74
|
+
if ((job.deliver ?? "local") !== "pr")
|
|
75
|
+
return true;
|
|
76
|
+
return job.delivery?.merged === true || job.delivery?.prState === "MERGED";
|
|
77
|
+
}
|
|
78
|
+
function landedState(job) {
|
|
79
|
+
if (job.workflow === INVESTIGATION_WORKFLOW)
|
|
80
|
+
return "done";
|
|
81
|
+
if ((job.deliver ?? "local") !== "pr")
|
|
82
|
+
return "done";
|
|
83
|
+
const delivery = job.delivery;
|
|
84
|
+
if (delivery?.merged || delivery?.prState === "MERGED")
|
|
85
|
+
return "done";
|
|
86
|
+
if (delivery?.prState === "CLOSED")
|
|
87
|
+
return "failed";
|
|
88
|
+
if (delivery?.prNumber)
|
|
89
|
+
return "in-review";
|
|
90
|
+
if (delivery?.error)
|
|
91
|
+
return "failed";
|
|
92
|
+
return "in-review";
|
|
93
|
+
}
|
|
94
|
+
function needsDelivery(job) {
|
|
95
|
+
if (job.error)
|
|
96
|
+
return false;
|
|
97
|
+
if (job.workflow === INVESTIGATION_WORKFLOW)
|
|
98
|
+
return false;
|
|
99
|
+
if ((job.deliver ?? "local") !== "pr")
|
|
100
|
+
return false;
|
|
101
|
+
if (!isLanded(job))
|
|
102
|
+
return false;
|
|
103
|
+
if (job.delivery?.merged || job.delivery?.prState === "MERGED")
|
|
104
|
+
return false;
|
|
105
|
+
if (job.delivery?.prState === "CLOSED")
|
|
106
|
+
return false;
|
|
107
|
+
if (job.delivery?.prNumber)
|
|
108
|
+
return false;
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
function canLaunch(job, observation) {
|
|
112
|
+
if (job.error || job.run || job.launching || job.launchFailed)
|
|
113
|
+
return false;
|
|
114
|
+
return dependenciesMet(job, observation);
|
|
115
|
+
}
|
|
116
|
+
export function decide(observation) {
|
|
117
|
+
const actions = observation.breadcrumbsToClear.map((crumb) => ({
|
|
118
|
+
type: "clear-breadcrumb",
|
|
119
|
+
path: crumb.path,
|
|
120
|
+
}));
|
|
121
|
+
for (const job of observation.jobs) {
|
|
122
|
+
if (job.error || job.workflow !== INVESTIGATION_WORKFLOW)
|
|
123
|
+
continue;
|
|
124
|
+
if (!isLanded(job) || job.findingsPath)
|
|
125
|
+
continue;
|
|
126
|
+
actions.push({ type: "record-findings", jobId: job.jobId });
|
|
127
|
+
}
|
|
128
|
+
for (const job of observation.jobs) {
|
|
129
|
+
if (!needsDelivery(job))
|
|
130
|
+
continue;
|
|
131
|
+
actions.push({ type: "deliver", jobId: job.jobId, repo: job.repo });
|
|
132
|
+
}
|
|
133
|
+
if (inFlightCount(observation) >= observation.maxConcurrent)
|
|
134
|
+
return actions;
|
|
135
|
+
const ready = observation.jobs.filter((job) => canLaunch(job, observation));
|
|
136
|
+
const healthy = ready.filter((job) => !job.launchError);
|
|
137
|
+
const picked = healthy[0] ?? ready[0];
|
|
138
|
+
if (picked)
|
|
139
|
+
actions.push({ type: "launch", jobId: picked.jobId, repo: picked.repo });
|
|
140
|
+
return actions;
|
|
141
|
+
}
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
5
|
+
import { inFlightCount, isDone, isLanded, jobState, } from "./reconcile.js";
|
|
6
|
+
export function shellQuote(value) {
|
|
7
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
8
|
+
}
|
|
9
|
+
/** Absolute path of the yak CLI, from the installed package's `bin.yak`. */
|
|
10
|
+
export function yakCliPath() {
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const pkgPath = require.resolve("@lchase/yak/package.json");
|
|
13
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
14
|
+
const relative = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.yak;
|
|
15
|
+
if (!relative) {
|
|
16
|
+
throw new Error(`${pkgPath} has no "yak" bin.`);
|
|
17
|
+
}
|
|
18
|
+
return join(dirname(pkgPath), relative);
|
|
19
|
+
}
|
|
20
|
+
export function yakInvocation(subcommand, runId) {
|
|
21
|
+
return `${shellQuote(process.execPath)} ${shellQuote(yakCliPath())} ${subcommand} ${shellQuote(runId)}`;
|
|
22
|
+
}
|
|
23
|
+
/** One variant of the command that answers an open gate and continues the run. */
|
|
24
|
+
export function gateAnswerCommand(checkout, runId, gate, choice, title) {
|
|
25
|
+
const pendingDir = shellQuote(`.runs/${runId}/pending`);
|
|
26
|
+
const answerFile = shellQuote(`.runs/${runId}/pending/${gate.stepId}.answer.json`);
|
|
27
|
+
const field = gate.answerField ?? "decision";
|
|
28
|
+
const answer = JSON.stringify({ [field]: choice });
|
|
29
|
+
const titlePrefix = title ? `KILN_TASK_TITLE=${shellQuote(title)} ` : "";
|
|
30
|
+
return `cd ${shellQuote(checkout)} && mkdir -p ${pendingDir} && printf '%s\\n' ${shellQuote(answer)} > ${answerFile} && ${titlePrefix}${yakInvocation("resume", runId)}`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Derived state for every job, or for one id.
|
|
34
|
+
* Throws `No job <id>` when that id is not on disk, same as `kiln status`.
|
|
35
|
+
*/
|
|
36
|
+
export function statusView(observation, jobId) {
|
|
37
|
+
const selected = jobId ? observation.jobs.filter((job) => job.jobId === jobId) : observation.jobs;
|
|
38
|
+
if (jobId && selected.length === 0) {
|
|
39
|
+
throw new Error(`No job ${jobId}.`);
|
|
40
|
+
}
|
|
41
|
+
return { jobs: selected.map((job) => toStatusJob(job, observation)) };
|
|
42
|
+
}
|
|
43
|
+
export function renderStatus(view) {
|
|
44
|
+
if (view.jobs.length === 0)
|
|
45
|
+
return "no jobs\n";
|
|
46
|
+
const lines = [];
|
|
47
|
+
for (const job of view.jobs) {
|
|
48
|
+
lines.push(statusHeader(job));
|
|
49
|
+
if (job.state === "invalid")
|
|
50
|
+
continue;
|
|
51
|
+
for (const detail of statusDetails(job))
|
|
52
|
+
lines.push(` ${detail}`);
|
|
53
|
+
}
|
|
54
|
+
return `${lines.join("\n")}\n`;
|
|
55
|
+
}
|
|
56
|
+
/** Extra lines `kiln status` prints under the job header, without the indent. */
|
|
57
|
+
export function statusDetails(job) {
|
|
58
|
+
const lines = [];
|
|
59
|
+
if (job.source)
|
|
60
|
+
lines.push(`source ${job.source.kind} ${job.source.ref}`);
|
|
61
|
+
if (job.findingsPath)
|
|
62
|
+
lines.push(`findings ${job.findingsPath}`);
|
|
63
|
+
else if (job.workflow === INVESTIGATION_WORKFLOW && job.state === "done")
|
|
64
|
+
lines.push("findings missing");
|
|
65
|
+
if (job.prPending)
|
|
66
|
+
lines.push("pull request not opened yet");
|
|
67
|
+
if (job.state === "failed" && job.deliveryError)
|
|
68
|
+
lines.push(job.deliveryError);
|
|
69
|
+
if (job.state === "failed" && job.prClosedUnmerged && job.prNumber) {
|
|
70
|
+
lines.push(`pull request #${job.prNumber} was closed without merging`);
|
|
71
|
+
}
|
|
72
|
+
if (job.viewError && (job.state === "in-review" || job.state === "done")) {
|
|
73
|
+
lines.push(`could not read pull request: ${job.viewError}`);
|
|
74
|
+
}
|
|
75
|
+
if (job.state === "failed" && job.launchFailed && job.logPath)
|
|
76
|
+
lines.push(`log ${job.logPath}`);
|
|
77
|
+
if (job.state === "failed" && job.launchError)
|
|
78
|
+
lines.push(job.launchError);
|
|
79
|
+
if (job.noReadableApproval)
|
|
80
|
+
lines.push("no readable approval decision");
|
|
81
|
+
if (job.cancelCommand)
|
|
82
|
+
lines.push(job.cancelCommand);
|
|
83
|
+
if (job.state === "blocked") {
|
|
84
|
+
for (const dep of job.dependsOn) {
|
|
85
|
+
if (dep.blockedDetail)
|
|
86
|
+
lines.push(dep.blockedDetail);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (job.state === "needs-human") {
|
|
90
|
+
for (const gate of job.gates) {
|
|
91
|
+
lines.push(`gate ${gate.stepId}: ${gate.prompt}`);
|
|
92
|
+
if (gate.choices.includes("approve"))
|
|
93
|
+
lines.push(`approve: kiln approve ${job.jobId}`);
|
|
94
|
+
if (gate.choices.includes("reject"))
|
|
95
|
+
lines.push(`reject: kiln reject ${job.jobId}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return lines;
|
|
99
|
+
}
|
|
100
|
+
function statusHeader(job) {
|
|
101
|
+
if (job.state === "invalid")
|
|
102
|
+
return `${job.jobId} invalid: ${job.invalidReason ?? "invalid"}`;
|
|
103
|
+
const run = job.runId ? ` run ${job.runId}` : "";
|
|
104
|
+
const branch = job.branch ? ` branch ${job.branch}` : "";
|
|
105
|
+
const pr = job.prUrl && (job.state === "in-review" || job.state === "done" || job.state === "failed")
|
|
106
|
+
? ` pr ${job.prUrl}`
|
|
107
|
+
: "";
|
|
108
|
+
return `${job.jobId} ${job.repo} ${job.workflow} ${job.state}${run}${branch}${pr}`;
|
|
109
|
+
}
|
|
110
|
+
function toStatusJob(job, observation) {
|
|
111
|
+
if (job.error) {
|
|
112
|
+
return {
|
|
113
|
+
jobId: job.jobId,
|
|
114
|
+
title: job.title,
|
|
115
|
+
repo: job.repo,
|
|
116
|
+
workflow: job.workflow,
|
|
117
|
+
state: "invalid",
|
|
118
|
+
dependsOn: dependencyViews(job, observation),
|
|
119
|
+
stack: job.stack === true,
|
|
120
|
+
...(job.source ? { source: { kind: job.source.kind, ref: job.source.ref } } : {}),
|
|
121
|
+
...(job.specPath ? { specPath: job.specPath } : {}),
|
|
122
|
+
...(job.logPath ? { logPath: job.logPath } : {}),
|
|
123
|
+
...(job.findingsPath ? { findingsPath: job.findingsPath } : {}),
|
|
124
|
+
launchFailed: job.launchFailed,
|
|
125
|
+
invalidReason: job.error,
|
|
126
|
+
prPending: false,
|
|
127
|
+
prClosedUnmerged: false,
|
|
128
|
+
noReadableApproval: false,
|
|
129
|
+
gates: [],
|
|
130
|
+
canApprove: false,
|
|
131
|
+
canReject: false,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const state = jobState(job, observation);
|
|
135
|
+
const gates = state === "needs-human" && job.run ? job.run.gates.map(toGateView) : [];
|
|
136
|
+
const prNumber = job.delivery?.prNumber;
|
|
137
|
+
const runId = job.run?.runId;
|
|
138
|
+
const showBranch = (state === "done" || state === "in-review") && runId !== undefined;
|
|
139
|
+
return {
|
|
140
|
+
jobId: job.jobId,
|
|
141
|
+
title: job.title,
|
|
142
|
+
repo: job.repo,
|
|
143
|
+
workflow: job.workflow,
|
|
144
|
+
state,
|
|
145
|
+
dependsOn: dependencyViews(job, observation),
|
|
146
|
+
stack: job.stack === true,
|
|
147
|
+
...(job.source ? { source: { kind: job.source.kind, ref: job.source.ref } } : {}),
|
|
148
|
+
...(runId ? { runId } : {}),
|
|
149
|
+
...(showBranch ? { branch: `yak/${runId}` } : {}),
|
|
150
|
+
...(job.delivery?.prUrl ? { prUrl: job.delivery.prUrl } : {}),
|
|
151
|
+
...(prNumber ? { prNumber } : {}),
|
|
152
|
+
...(job.logPath ? { logPath: job.logPath } : {}),
|
|
153
|
+
...(job.specPath ? { specPath: job.specPath } : {}),
|
|
154
|
+
...(job.findingsPath ? { findingsPath: job.findingsPath } : {}),
|
|
155
|
+
launchFailed: job.launchFailed,
|
|
156
|
+
...(job.launchError ? { launchError: job.launchError } : {}),
|
|
157
|
+
...(job.delivery?.error ? { deliveryError: job.delivery.error } : {}),
|
|
158
|
+
...(job.delivery?.viewError ? { viewError: job.delivery.viewError } : {}),
|
|
159
|
+
prPending: state === "in-review" && !prNumber,
|
|
160
|
+
prClosedUnmerged: job.delivery?.prState === "CLOSED",
|
|
161
|
+
noReadableApproval: state === "failed" && job.run?.status === "ok" && !job.run.gateDecision,
|
|
162
|
+
...(state === "dead" && job.run
|
|
163
|
+
? { cancelCommand: `fix: cd ${shellQuote(job.run.checkout)} && ${yakInvocation("cancel", job.run.runId)}` }
|
|
164
|
+
: {}),
|
|
165
|
+
gates,
|
|
166
|
+
canApprove: gates.some((gate) => gate.choices.includes("approve")),
|
|
167
|
+
canReject: gates.some((gate) => gate.choices.includes("reject")),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function toGateView(gate) {
|
|
171
|
+
const choices = gate.choices?.length ? gate.choices : ["approve", "reject"];
|
|
172
|
+
return {
|
|
173
|
+
stepId: gate.stepId,
|
|
174
|
+
prompt: gate.prompt,
|
|
175
|
+
...(gate.rendered ? { rendered: gate.rendered } : {}),
|
|
176
|
+
choices,
|
|
177
|
+
artifacts: gate.artifacts ?? [],
|
|
178
|
+
reviews: gate.reviews ?? [],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function dependencyViews(job, observation) {
|
|
182
|
+
const reasons = blockReasons(job, observation);
|
|
183
|
+
return job.dependsOn.map((dep) => {
|
|
184
|
+
const upstream = observation.jobs.find((candidate) => candidate.jobId === dep);
|
|
185
|
+
const state = !upstream ? "missing" : upstream.error ? "invalid" : jobState(upstream, observation);
|
|
186
|
+
const blockedDetail = reasons.find((reason) => reason.dep === dep)?.detail;
|
|
187
|
+
return {
|
|
188
|
+
jobId: dep,
|
|
189
|
+
state,
|
|
190
|
+
...(blockedDetail ? { blockedDetail } : {}),
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
function blockReasons(job, observation) {
|
|
195
|
+
const reasons = [];
|
|
196
|
+
for (const dep of job.dependsOn) {
|
|
197
|
+
const upstream = observation.jobs.find((candidate) => candidate.jobId === dep);
|
|
198
|
+
if (!upstream) {
|
|
199
|
+
reasons.push({ dep, detail: `blocked on missing job ${dep}` });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (job.stack) {
|
|
203
|
+
if (!isLanded(upstream))
|
|
204
|
+
reasons.push({ dep, detail: `waiting for ${dep} to land` });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (isDone(upstream))
|
|
208
|
+
continue;
|
|
209
|
+
const number = upstream.delivery?.prNumber;
|
|
210
|
+
if (number && !upstream.delivery?.merged && upstream.delivery?.prState !== "CLOSED") {
|
|
211
|
+
reasons.push({ dep, detail: `waiting for PR #${number} to merge` });
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if ((upstream.deliver ?? "local") === "pr" && isLanded(upstream)) {
|
|
215
|
+
reasons.push({ dep, detail: `waiting for ${dep} to open a pull request` });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return reasons;
|
|
219
|
+
}
|
|
220
|
+
export function renderTick(observation, actions, launches, delivers = [], findings = []) {
|
|
221
|
+
const lines = [`tick inFlight=${inFlightCount(observation)} maxConcurrent=${observation.maxConcurrent}`];
|
|
222
|
+
for (const job of observation.jobs) {
|
|
223
|
+
if (job.error)
|
|
224
|
+
lines.push(`skip ${job.jobId} (invalid: ${job.error})`);
|
|
225
|
+
}
|
|
226
|
+
for (const action of actions) {
|
|
227
|
+
if (action.type === "clear-breadcrumb")
|
|
228
|
+
lines.push(`clear breadcrumb ${action.path}`);
|
|
229
|
+
}
|
|
230
|
+
for (const deliver of delivers) {
|
|
231
|
+
lines.push(deliver.ok
|
|
232
|
+
? `deliver ${deliver.jobId} ${deliver.repo} ${deliver.detail}`
|
|
233
|
+
: `deliver ${deliver.jobId} ${deliver.repo} failed: ${deliver.detail}`);
|
|
234
|
+
}
|
|
235
|
+
for (const recorded of findings) {
|
|
236
|
+
lines.push(recorded.status === "wrote"
|
|
237
|
+
? `findings ${recorded.jobId} ${recorded.path}`
|
|
238
|
+
: `findings ${recorded.jobId} missing`);
|
|
239
|
+
}
|
|
240
|
+
for (const launch of launches) {
|
|
241
|
+
lines.push(`launch ${launch.jobId} ${launch.repo}`);
|
|
242
|
+
for (const line of launch.output.split("\n")) {
|
|
243
|
+
if (line.trim())
|
|
244
|
+
lines.push(` ${line}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (!actions.some((action) => action.type === "launch") &&
|
|
248
|
+
launches.length === 0 &&
|
|
249
|
+
delivers.length === 0 &&
|
|
250
|
+
findings.length === 0) {
|
|
251
|
+
lines.push("nothing to launch");
|
|
252
|
+
}
|
|
253
|
+
return `${lines.join("\n")}\n`;
|
|
254
|
+
}
|
package/dist/schemas.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Kiln config file. `repos` is an open map: any name, not a fixed set.
|
|
4
|
+
* `path` is absolute, `~`, or (in older files) relative to the config directory.
|
|
5
|
+
* `repo add` stores a relative path as absolute, resolved from the working directory.
|
|
6
|
+
* `test` is the shell command a workflow runs as its verify step (`KILN_TEST`).
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* `deliver` defaults to `local` when omitted: land on `yak/<run-id>` and do not push.
|
|
10
|
+
* `pr` pushes that branch and opens a draft pull request against `base`.
|
|
11
|
+
* `base` defaults to origin's default branch, discovered with git, when omitted.
|
|
12
|
+
*/
|
|
13
|
+
export const RepoEntrySchema = z.object({
|
|
14
|
+
path: z.string().min(1),
|
|
15
|
+
test: z.string().min(1).optional(),
|
|
16
|
+
deliver: z.enum(["local", "pr"]).optional(),
|
|
17
|
+
base: z.string().min(1).optional(),
|
|
18
|
+
});
|
|
19
|
+
/**
|
|
20
|
+
* Optional Jira Cloud credentials in the gitignored kiln config.
|
|
21
|
+
* `JIRA_BASE_URL`, `JIRA_EMAIL`, and `JIRA_API_TOKEN` override these.
|
|
22
|
+
* Empty strings are treated as unset. The token is never copied into a job.
|
|
23
|
+
*/
|
|
24
|
+
export const JiraConfigSchema = z.object({
|
|
25
|
+
baseUrl: z.string().optional(),
|
|
26
|
+
email: z.string().optional(),
|
|
27
|
+
apiToken: z.string().optional(),
|
|
28
|
+
});
|
|
29
|
+
export const KilnConfigFileSchema = z.object({
|
|
30
|
+
note: z.string().min(1).optional(),
|
|
31
|
+
maxConcurrent: z.number().int().min(1).optional(),
|
|
32
|
+
adapter: z.enum(["mock", "claude-code"]).optional(),
|
|
33
|
+
repos: z.record(z.string(), RepoEntrySchema),
|
|
34
|
+
jira: JiraConfigSchema.optional(),
|
|
35
|
+
});
|
|
36
|
+
/**
|
|
37
|
+
* Where a job came from. Adapters set this; tick and reconcile do not branch on it.
|
|
38
|
+
* `ref` is an absolute spec path (`file`), `inline` or `stdin` (`prompt`), the
|
|
39
|
+
* absolute ticket path (`tickets`), or the issue key (`jira`, such as `PROJ-123`).
|
|
40
|
+
*/
|
|
41
|
+
export const JobSourceSchema = z.object({
|
|
42
|
+
kind: z.enum(["file", "prompt", "tickets", "jira"]),
|
|
43
|
+
ref: z.string().min(1),
|
|
44
|
+
});
|
|
45
|
+
/**
|
|
46
|
+
* One unit of factory work. Stored as `<kilnDir>/jobs/<id>.json`.
|
|
47
|
+
* `spec` is a markdown file under the kiln directory.
|
|
48
|
+
* `dependsOn` names other job ids. There is no plan and no stored state.
|
|
49
|
+
* Every intake writes this same shape. `source` is omitted on jobs written
|
|
50
|
+
* before intake; those files still parse.
|
|
51
|
+
*/
|
|
52
|
+
export const JobSchema = z.object({
|
|
53
|
+
id: z.string().min(1),
|
|
54
|
+
repo: z.string().min(1),
|
|
55
|
+
workflow: z.string().min(1),
|
|
56
|
+
spec: z.string().min(1),
|
|
57
|
+
title: z.string().min(1),
|
|
58
|
+
dependsOn: z.array(z.string().min(1)).optional(),
|
|
59
|
+
/**
|
|
60
|
+
* When true, this job starts once its single upstream has landed, on that
|
|
61
|
+
* upstream's branch. Omitted means after-merged: wait until every upstream
|
|
62
|
+
* is `done`. More than one `dependsOn` with `stack` is rejected at add time.
|
|
63
|
+
* Stacking onto an investigation is rejected: that workflow does not land
|
|
64
|
+
* a branch.
|
|
65
|
+
*/
|
|
66
|
+
stack: z.boolean().optional(),
|
|
67
|
+
source: JobSourceSchema.optional(),
|
|
68
|
+
createdAt: z.string().min(1),
|
|
69
|
+
});
|
|
70
|
+
/**
|
|
71
|
+
* Derived from the yak journal, gate files, and (for `deliver=pr`) the
|
|
72
|
+
* delivery record plus `gh pr view`. Never written into the job file.
|
|
73
|
+
*
|
|
74
|
+
* For `deliver=local`, `done` is a run that finished ok with an approve
|
|
75
|
+
* decision. For `deliver=pr`, `done` is that landed run whose pull request
|
|
76
|
+
* has merged (`state` or `mergedAt`, so a squash merge counts). `in-review`
|
|
77
|
+
* is the landed run while the pull request is still open. An investigation
|
|
78
|
+
* is `done` when the gate says approve, including when the repo is
|
|
79
|
+
* `deliver=pr`. It does not enter `in-review`. A reject, a failure, a dead
|
|
80
|
+
* process, and an ok run with no readable approval do not unblock an
|
|
81
|
+
* after-merged dependent. A stacked job unblocks when its one upstream has
|
|
82
|
+
* landed, which includes `in-review`.
|
|
83
|
+
*/
|
|
84
|
+
export const JOB_STATES = [
|
|
85
|
+
"queued",
|
|
86
|
+
"blocked",
|
|
87
|
+
"running",
|
|
88
|
+
"needs-human",
|
|
89
|
+
"approved",
|
|
90
|
+
"rejected",
|
|
91
|
+
"failed",
|
|
92
|
+
"dead",
|
|
93
|
+
"in-review",
|
|
94
|
+
"done",
|
|
95
|
+
];
|