@voidlabs.gg/voidshift-agents 0.0.0-stage → 0.4.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/LICENSE +202 -0
- package/README.md +134 -2
- package/dist/config.d.ts +60 -0
- package/dist/config.js +98 -0
- package/dist/control.d.ts +25 -0
- package/dist/control.js +712 -0
- package/dist/engine.d.ts +22 -0
- package/dist/engine.js +94 -0
- package/dist/git.d.ts +28 -0
- package/dist/git.js +105 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1171 -0
- package/dist/keychain.d.ts +3 -0
- package/dist/keychain.js +19 -0
- package/dist/launchd.d.ts +7 -0
- package/dist/launchd.js +68 -0
- package/dist/linear.d.ts +33 -0
- package/dist/linear.js +122 -0
- package/dist/manage.d.ts +18 -0
- package/dist/manage.js +120 -0
- package/dist/memory.d.ts +2 -0
- package/dist/memory.js +37 -0
- package/dist/names.d.ts +8 -0
- package/dist/names.js +26 -0
- package/dist/repos.d.ts +1 -0
- package/dist/repos.js +45 -0
- package/dist/runner.d.ts +23 -0
- package/dist/runner.js +392 -0
- package/dist/state.d.ts +62 -0
- package/dist/state.js +107 -0
- package/dist/types.d.ts +51 -0
- package/dist/types.js +3 -0
- package/dist/workflow.d.ts +50 -0
- package/dist/workflow.js +93 -0
- package/pack/vs-dev/SKILL.md +51 -0
- package/pack/vs-plan/SKILL.md +140 -0
- package/pack/vs-plan-review/SKILL.md +63 -0
- package/pack/vs-publish/SKILL.md +45 -0
- package/pack/vs-qa/SKILL.md +72 -0
- package/package.json +50 -4
package/dist/runner.js
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { LABELS, PLAN_HEADING, buildPrompt, findingsFor, fixTarget, isRoundLabel, latestPlan, parseDevResult, parseVerdict, planBody, qaPassed, verdictBlock, } from "./workflow.js";
|
|
5
|
+
import { STEPS, agentsDir, hasWork, isProducer } from "./config.js";
|
|
6
|
+
import { runAgent, stopAgents } from "./engine.js";
|
|
7
|
+
import { commitsAhead, defaultBranch, ensureDraftPr, ensureWorktree, markReady, push, resetWorktree, waitForChecks } from "./git.js";
|
|
8
|
+
import { recall, vaultPath } from "./memory.js";
|
|
9
|
+
import { bootTime, clearCurrent, isPaused, ours, readCurrent, readPending, writeCurrent, writePending, writeRun } from "./state.js";
|
|
10
|
+
const packDir = fileURLToPath(new URL('../pack/', import.meta.url));
|
|
11
|
+
const skill = (name) => readFileSync(join(packDir, name, 'SKILL.md'), 'utf8');
|
|
12
|
+
const stamp = () => new Date().toISOString().replace(/[:.]/g, '-');
|
|
13
|
+
class Park extends Error {
|
|
14
|
+
}
|
|
15
|
+
function withLabels(card, drop, add) {
|
|
16
|
+
return [...new Set([...card.labels.filter((l) => !drop(l)), ...add])];
|
|
17
|
+
}
|
|
18
|
+
const statusLabel = (l) => l === LABELS.working || l === LABELS.verified || l === LABELS.parked || l === LABELS.changes || isRoundLabel(l);
|
|
19
|
+
const UNATTENDED = `# Running unattended
|
|
20
|
+
|
|
21
|
+
You are one step in a workflow. Nobody can answer questions and the
|
|
22
|
+
Linear connection is not available: the card and any plan are included above.
|
|
23
|
+
Do not write plan files; return your output as your final message. Make the
|
|
24
|
+
call the code supports and state it.`;
|
|
25
|
+
// Agents keep the memory vault the way you do: durable learnings become notes.
|
|
26
|
+
const memoryNote = (vault) => `# Memory
|
|
27
|
+
|
|
28
|
+
The memory vault is \`${vault}\`. If you learn something durable that a later
|
|
29
|
+
run should know (how to run the tests, something broken on the base branch, a
|
|
30
|
+
trap in this code), save it there as a note: follow \`${vault}/CONVENTIONS.md\`
|
|
31
|
+
and add the note's line to \`${vault}/INDEX.md\`. Update a note in place
|
|
32
|
+
rather than adding a second one. Skip it when nothing durable came up.`;
|
|
33
|
+
// First three non-empty lines of what an agent reported, for run.json.
|
|
34
|
+
export function summarize(output) {
|
|
35
|
+
return output.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 3).join(' ').slice(0, 300);
|
|
36
|
+
}
|
|
37
|
+
// The run in progress, so a cancel signal can mark it.
|
|
38
|
+
let active = null;
|
|
39
|
+
const isVerdict = (s) => STEPS[s].output === 'verdict';
|
|
40
|
+
// One card through a trigger's workflow. Steps run in order. A failed check (or a
|
|
41
|
+
// run of consecutive checks) sends its findings back to the nearest earlier
|
|
42
|
+
// step that produces something, Plan or Dev, and the workflow continues from
|
|
43
|
+
// there, until everything passes or the loop cap parks the card.
|
|
44
|
+
export async function runCard(card, trigger, workflowName, workflow, project, rc, opts = {}) {
|
|
45
|
+
const { linear, config, log } = rc;
|
|
46
|
+
const runDir = join(agentsDir(rc.configDir), 'runs', card.identifier, stamp());
|
|
47
|
+
mkdirSync(runDir, { recursive: true });
|
|
48
|
+
const say = (line) => log(`${card.identifier} ${line}`);
|
|
49
|
+
const steps = workflow.steps;
|
|
50
|
+
const startedAt = new Date().toISOString();
|
|
51
|
+
const record = { card: card.identifier, title: card.title, url: card.url, workflow: workflowName, trigger: trigger.label, steps: [], outcome: 'running', startedAt };
|
|
52
|
+
active = { record, runDir };
|
|
53
|
+
const save = () => writeRun(runDir, record);
|
|
54
|
+
save();
|
|
55
|
+
let loops = 0;
|
|
56
|
+
// The lock tells status and cancel what is running.
|
|
57
|
+
const mark = (step) => writeCurrent(rc.configDir, { pid: process.pid, card: card.identifier, title: card.title, workflow: workflowName, step, round: loops, startedAt, runDir });
|
|
58
|
+
// Written before the first Linear call, so a cancel always knows the card.
|
|
59
|
+
mark('Starting');
|
|
60
|
+
const begin = (step) => {
|
|
61
|
+
const rec = { step, label: STEPS[step].label, startedAt: new Date().toISOString() };
|
|
62
|
+
record.steps.push(rec);
|
|
63
|
+
mark(rec.label);
|
|
64
|
+
save();
|
|
65
|
+
return rec;
|
|
66
|
+
};
|
|
67
|
+
const end = (rec, result, output, logFile) => {
|
|
68
|
+
rec.endedAt = new Date().toISOString();
|
|
69
|
+
rec.result = result;
|
|
70
|
+
if (output !== undefined)
|
|
71
|
+
rec.summary = summarize(output);
|
|
72
|
+
if (logFile)
|
|
73
|
+
rec.log = basename(logFile);
|
|
74
|
+
save();
|
|
75
|
+
};
|
|
76
|
+
const done = (outcome, reason) => {
|
|
77
|
+
record.outcome = outcome;
|
|
78
|
+
if (reason)
|
|
79
|
+
record.reason = reason;
|
|
80
|
+
record.endedAt = new Date().toISOString();
|
|
81
|
+
save();
|
|
82
|
+
return outcome;
|
|
83
|
+
};
|
|
84
|
+
async function park(reason) {
|
|
85
|
+
say(`parked: ${reason}`);
|
|
86
|
+
const comment = `**Parked:** ${reason}\n\nFix the cause, then add \`${trigger.label}\` again to retry.`;
|
|
87
|
+
let commented = false;
|
|
88
|
+
try {
|
|
89
|
+
await linear.comment(card, comment);
|
|
90
|
+
commented = true;
|
|
91
|
+
await linear.setLabels(card, withLabels(card, (l) => l === trigger.label || l === LABELS.working, [LABELS.parked]));
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
say(`could not reach Linear to park it (${err.message}). Will retry next minute.`);
|
|
95
|
+
// An empty comment is skipped on retry, so it is not posted twice.
|
|
96
|
+
writePending(rc.configDir, [...readPending(rc.configDir), { card: card.identifier, comment: commented ? '' : comment, drop: [trigger.label, LABELS.working], add: [LABELS.parked] }]);
|
|
97
|
+
}
|
|
98
|
+
return done('parked', reason);
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
if (!existsSync(join(project.path, '.git')))
|
|
102
|
+
throw new Park(`The project folder ${project.path} is not a git repository on this Mac.`);
|
|
103
|
+
const from = opts.from ? steps.indexOf(opts.from) : 0;
|
|
104
|
+
if (from < 0)
|
|
105
|
+
throw new Park(`Workflow ${workflowName} has no ${STEPS[opts.from].label} step.`);
|
|
106
|
+
let plan = latestPlan(await linear.comments(card.identifier));
|
|
107
|
+
const firstPlan = steps.indexOf('vs-plan', from);
|
|
108
|
+
const needsPlan = steps.findIndex((s, n) => n >= from && (s === 'vs-dev' || s === 'vs-plan-review'));
|
|
109
|
+
if (!plan && needsPlan >= 0 && (firstPlan < 0 || firstPlan > needsPlan)) {
|
|
110
|
+
throw new Park('No plan comment (starting with "## Plan") was found. Publish a plan first, or start the workflow with Plan.');
|
|
111
|
+
}
|
|
112
|
+
await linear.setLabels(card, withLabels(card, (l) => l === trigger.label || statusLabel(l), [LABELS.working]));
|
|
113
|
+
const base = project.base || defaultBranch(project.path);
|
|
114
|
+
const vault = vaultPath(rc.configDir);
|
|
115
|
+
const known = [recall(rc.selfCommand, rc.configDir, `${workflow.project} ${card.title}\n${card.description ?? ''}`), vault ? memoryNote(vault) : ''].filter(Boolean).join('\n\n');
|
|
116
|
+
const worktree = join(agentsDir(rc.configDir), 'worktrees', card.identifier);
|
|
117
|
+
ensureWorktree(project.path, worktree, card.branchName, base);
|
|
118
|
+
say(`working in ${worktree} on ${card.branchName}`);
|
|
119
|
+
const findings = new Map();
|
|
120
|
+
const runs = { plan: 0, dev: 0, qa: 0 };
|
|
121
|
+
let prUrl = '';
|
|
122
|
+
const planText = () => plan ?? '(No plan was posted on this card. Check the work against the card itself.)';
|
|
123
|
+
const timeoutMs = config.timeoutMinutes * 60_000;
|
|
124
|
+
const logFile = (name) => join(runDir, `${String(runs.plan + runs.dev + runs.qa).padStart(2, '0')}-${name}.log`);
|
|
125
|
+
async function check(step) {
|
|
126
|
+
const label = STEPS[step].label;
|
|
127
|
+
const rec = begin(step);
|
|
128
|
+
let ciNote = '';
|
|
129
|
+
if (step === 'vs-qa') {
|
|
130
|
+
if (commitsAhead(worktree, base) === 0) {
|
|
131
|
+
end(rec, 'SKIP', 'No changes on the branch yet.');
|
|
132
|
+
return { step: label, verdict: 'SKIP', report: 'No changes on the branch yet.' };
|
|
133
|
+
}
|
|
134
|
+
// CI first, without an agent: a red build goes straight back.
|
|
135
|
+
if (prUrl) {
|
|
136
|
+
const ci = await waitForChecks(worktree, card.branchName);
|
|
137
|
+
if (ci.verdict === 'FIX') {
|
|
138
|
+
end(rec, 'FIX', ci.report);
|
|
139
|
+
return { step: label, verdict: 'FIX', report: ci.report };
|
|
140
|
+
}
|
|
141
|
+
ciNote = ci.verdict === 'SHIP' ? '# CI\n\nCI passed on this branch.' : '# CI\n\nThis repo has no CI checks.';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const log = logFile(step);
|
|
145
|
+
const r = await runAgent({
|
|
146
|
+
engine: config.engine, model: config.models.review, cwd: worktree, vault, write: step === 'vs-qa', logFile: log, timeoutMs,
|
|
147
|
+
prompt: buildPrompt({ skill: skill(step), card, plan: planText(), base, round: runs.qa + 1, extra: [known, ciNote, UNATTENDED].filter(Boolean).join('\n\n') }),
|
|
148
|
+
});
|
|
149
|
+
if (step === 'vs-qa')
|
|
150
|
+
resetWorktree(worktree);
|
|
151
|
+
const verdict = parseVerdict(r.output);
|
|
152
|
+
end(rec, verdict, r.output, log);
|
|
153
|
+
return { step: label, verdict, report: r.output };
|
|
154
|
+
}
|
|
155
|
+
let i = from;
|
|
156
|
+
while (i < steps.length) {
|
|
157
|
+
const step = steps[i];
|
|
158
|
+
say(`${STEPS[step].label}`);
|
|
159
|
+
if (isVerdict(step)) {
|
|
160
|
+
const [start, end] = verdictBlock(steps, i, isVerdict);
|
|
161
|
+
const results = [];
|
|
162
|
+
for (const s of steps.slice(start, end))
|
|
163
|
+
results.push(await check(s));
|
|
164
|
+
runs.qa++;
|
|
165
|
+
await linear.setLabels(card, withLabels(card, () => false, [LABELS.qa(runs.qa)]));
|
|
166
|
+
if (qaPassed(results)) {
|
|
167
|
+
i = end;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const notes = findingsFor(results);
|
|
171
|
+
await linear.comment(card, `**Changes needed (round ${runs.qa}).**\n\n${notes}`);
|
|
172
|
+
const target = fixTarget(steps, start, isProducer);
|
|
173
|
+
if (target < 0) {
|
|
174
|
+
await linear.setLabels(card, withLabels(card, (l) => l === LABELS.working, [LABELS.changes]));
|
|
175
|
+
say('changes requested');
|
|
176
|
+
return done('changes');
|
|
177
|
+
}
|
|
178
|
+
if (++loops > workflow.maxRounds)
|
|
179
|
+
throw new Park(`Checks still failed after sending the work back ${workflow.maxRounds} times. Latest findings are in the comment above.${prUrl ? ` PR: ${prUrl}` : ''}`);
|
|
180
|
+
findings.set(target, notes);
|
|
181
|
+
i = target;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (step === 'vs-plan') {
|
|
185
|
+
const rec = begin(step);
|
|
186
|
+
const log = logFile('plan');
|
|
187
|
+
const r = await runAgent({
|
|
188
|
+
engine: config.engine, model: config.models.dev, cwd: worktree, vault, write: false, logFile: log, timeoutMs,
|
|
189
|
+
prompt: buildPrompt({ skill: skill('vs-plan'), card, plan: plan ?? '(none yet, you are writing it)', base, round: runs.plan + 1, findings: findings.get(i), extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }),
|
|
190
|
+
});
|
|
191
|
+
const result = parseDevResult(r.output);
|
|
192
|
+
end(rec, result.status === 'blocked' ? 'blocked' : 'done', r.output, log);
|
|
193
|
+
if (result.status === 'blocked')
|
|
194
|
+
throw new Park(`The plan agent stopped: ${result.reason}`);
|
|
195
|
+
plan = planBody(r.output);
|
|
196
|
+
await linear.comment(card, `${PLAN_HEADING}\n\n${plan}`);
|
|
197
|
+
runs.plan++;
|
|
198
|
+
await linear.setLabels(card, withLabels(card, () => false, [LABELS.plan(runs.plan)]));
|
|
199
|
+
}
|
|
200
|
+
else if (step === 'vs-publish') {
|
|
201
|
+
const rec = begin(step);
|
|
202
|
+
push(worktree, card.branchName);
|
|
203
|
+
end(rec, 'pushed');
|
|
204
|
+
}
|
|
205
|
+
else if (step === 'vs-dev') {
|
|
206
|
+
const rec = begin(step);
|
|
207
|
+
const log = logFile('dev');
|
|
208
|
+
const dev = await runAgent({
|
|
209
|
+
engine: config.engine, model: config.models.dev, cwd: worktree, vault, write: true, logFile: log, timeoutMs,
|
|
210
|
+
prompt: buildPrompt({ skill: skill('vs-dev'), card, plan: planText(), base, round: runs.dev + 1, findings: findings.get(i), extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }),
|
|
211
|
+
});
|
|
212
|
+
const result = parseDevResult(dev.output);
|
|
213
|
+
end(rec, result.status === 'blocked' ? 'blocked' : 'done', dev.output, log);
|
|
214
|
+
if (result.status === 'blocked')
|
|
215
|
+
throw new Park(`The dev agent stopped: ${result.reason}`);
|
|
216
|
+
if (commitsAhead(worktree, base) === 0)
|
|
217
|
+
throw new Park('The dev agent made no commits.');
|
|
218
|
+
push(worktree, card.branchName);
|
|
219
|
+
prUrl = ensureDraftPr(worktree, card.branchName, base, `${card.identifier}: ${card.title}`, card.url);
|
|
220
|
+
record.prUrl = prUrl;
|
|
221
|
+
save();
|
|
222
|
+
runs.dev++;
|
|
223
|
+
await linear.setLabels(card, withLabels(card, () => false, [LABELS.dev(runs.dev)]));
|
|
224
|
+
}
|
|
225
|
+
findings.delete(i);
|
|
226
|
+
i++;
|
|
227
|
+
}
|
|
228
|
+
let moved = false;
|
|
229
|
+
if (hasWork(workflow)) {
|
|
230
|
+
markReady(worktree, card.branchName);
|
|
231
|
+
moved = await linear.moveTo(card, config.reviewState);
|
|
232
|
+
}
|
|
233
|
+
await linear.setLabels(card, withLabels(card, (l) => l === LABELS.working, [LABELS.verified]));
|
|
234
|
+
await linear.comment(card, `**All checks passed.**${prUrl ? ` PR ready: ${prUrl}` : ''}${hasWork(workflow) && !moved ? `\n\n(No "${config.reviewState}" state on this team, so the card was not moved.)` : ''}`);
|
|
235
|
+
say('verified');
|
|
236
|
+
return done('verified');
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
if (err instanceof Park)
|
|
240
|
+
return park(err.message);
|
|
241
|
+
return park(`Something went wrong: ${err.message}`);
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
active = null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// The first card waiting on any workflow's triggers, in workflow order.
|
|
248
|
+
// With a workflow name, only that workflow is considered.
|
|
249
|
+
export async function nextJob(rc, only, workflowName) {
|
|
250
|
+
const pinned = only ? await rc.linear.card(only) : null;
|
|
251
|
+
for (const [name, workflow] of Object.entries(rc.config.workflows)) {
|
|
252
|
+
if (workflowName && name !== workflowName)
|
|
253
|
+
continue;
|
|
254
|
+
for (const trigger of workflow.triggers) {
|
|
255
|
+
const cards = pinned ? [pinned] : await rc.linear.myCardsWithLabel(trigger.label);
|
|
256
|
+
// A pinned card may have lost its label (a parked card re-run by hand), so match by team.
|
|
257
|
+
const card = cards.find((c) => c.teamKey === trigger.team && (pinned || c.labels.includes(trigger.label)));
|
|
258
|
+
if (card)
|
|
259
|
+
return { card, trigger, workflow: name };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
// Retries Linear writes that failed while parking. Never throws.
|
|
265
|
+
async function flushPending(rc) {
|
|
266
|
+
const left = [];
|
|
267
|
+
for (const p of readPending(rc.configDir)) {
|
|
268
|
+
try {
|
|
269
|
+
const card = await rc.linear.card(p.card);
|
|
270
|
+
if (p.comment) {
|
|
271
|
+
await rc.linear.comment(card, p.comment);
|
|
272
|
+
p.comment = '';
|
|
273
|
+
}
|
|
274
|
+
await rc.linear.setLabels(card, withLabels(card, (l) => p.drop.includes(l), p.add));
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
left.push(p);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
writePending(rc.configDir, left);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
// nothing more to do
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// Takes the working label off a cancelled card and says so. When Linear
|
|
288
|
+
// cannot be reached the same change is queued for the next tick.
|
|
289
|
+
export async function cancelCard(linear, configDir, cardId, label) {
|
|
290
|
+
const comment = `**Cancelled.** Add \`${label}\` again to restart.`;
|
|
291
|
+
try {
|
|
292
|
+
const card = await linear.card(cardId);
|
|
293
|
+
await linear.setLabels(card, withLabels(card, (l) => l === LABELS.working, []));
|
|
294
|
+
await linear.comment(card, comment);
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
queueCancel(configDir, cardId, comment);
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function queueCancel(configDir, cardId, comment) {
|
|
303
|
+
const pending = readPending(configDir);
|
|
304
|
+
if (pending.some((p) => p.card === cardId && p.comment === comment))
|
|
305
|
+
return;
|
|
306
|
+
try {
|
|
307
|
+
writePending(configDir, [...pending, { card: cardId, comment, drop: [LABELS.working], add: [] }]);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// nothing more to do
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// One pass for the background runner. A lock keeps two passes from working at once.
|
|
314
|
+
export async function tick(rc, only, opts = {}) {
|
|
315
|
+
const held = readCurrent(rc.configDir);
|
|
316
|
+
const booted = bootTime();
|
|
317
|
+
// A lock from before the last boot cannot be a live run, whatever its pid is now.
|
|
318
|
+
const fromBeforeBoot = !!(held?.startedAt && booted && new Date(held.startedAt) < booted);
|
|
319
|
+
if (held && held.pid !== process.pid && !fromBeforeBoot && ours(held.pid)) {
|
|
320
|
+
if (only)
|
|
321
|
+
rc.log(`The runner is busy with ${held.card ?? 'a check'}. Try again when it finishes.`);
|
|
322
|
+
return only ? 1 : 0;
|
|
323
|
+
}
|
|
324
|
+
writeCurrent(rc.configDir, { pid: process.pid, startedAt: new Date().toISOString() });
|
|
325
|
+
// A stop (Ctrl+C, launchd, shutdown) ends the agents, marks the run, and
|
|
326
|
+
// takes the working label off the card so it is never left stuck.
|
|
327
|
+
const onSignal = async () => {
|
|
328
|
+
await stopAgents();
|
|
329
|
+
const running = active && active.record.outcome === 'running' ? active : null;
|
|
330
|
+
if (active) {
|
|
331
|
+
active.record.outcome = 'cancelled';
|
|
332
|
+
active.record.endedAt = new Date().toISOString();
|
|
333
|
+
try {
|
|
334
|
+
writeRun(active.runDir, active.record);
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
// the run folder is gone
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (running) {
|
|
341
|
+
const { card, trigger } = running.record;
|
|
342
|
+
const timeout = new Promise((resolve) => setTimeout(() => resolve(false), 5000).unref());
|
|
343
|
+
const finished = await Promise.race([cancelCard(rc.linear, rc.configDir, card, trigger), timeout]);
|
|
344
|
+
// Did not finish in time: leave the work for the next tick.
|
|
345
|
+
if (!finished)
|
|
346
|
+
queueCancel(rc.configDir, card, `**Cancelled.** Add \`${trigger}\` again to restart.`);
|
|
347
|
+
}
|
|
348
|
+
clearCurrent(rc.configDir);
|
|
349
|
+
process.exit(143);
|
|
350
|
+
};
|
|
351
|
+
process.once('SIGTERM', onSignal);
|
|
352
|
+
process.once('SIGINT', onSignal);
|
|
353
|
+
try {
|
|
354
|
+
await flushPending(rc);
|
|
355
|
+
if (!only && isPaused(rc.configDir))
|
|
356
|
+
return 0;
|
|
357
|
+
let job;
|
|
358
|
+
try {
|
|
359
|
+
job = await nextJob(rc, only, opts.workflow);
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
rc.log(`Could not check Linear (${err.message}). Trying again next minute.`);
|
|
363
|
+
return 1;
|
|
364
|
+
}
|
|
365
|
+
if (!job) {
|
|
366
|
+
if (only)
|
|
367
|
+
rc.log(`${only} starts no workflow: it needs a workflow's team and label.`);
|
|
368
|
+
return only ? 1 : 0;
|
|
369
|
+
}
|
|
370
|
+
const workflow = rc.config.workflows[job.workflow];
|
|
371
|
+
const project = rc.config.projects[workflow.project];
|
|
372
|
+
if (!project) {
|
|
373
|
+
rc.log(`Workflow ${job.workflow} points at a missing project ${workflow.project}. Run vsf agents workflow edit to fix it.`);
|
|
374
|
+
return 1;
|
|
375
|
+
}
|
|
376
|
+
if (opts.from && !workflow.steps.includes(opts.from)) {
|
|
377
|
+
rc.log(`Workflow ${job.workflow} has no ${STEPS[opts.from].label} step.`);
|
|
378
|
+
return 2;
|
|
379
|
+
}
|
|
380
|
+
const outcome = await runCard(job.card, job.trigger, job.workflow, workflow, project, rc, { from: opts.from });
|
|
381
|
+
return outcome === 'verified' ? 0 : 1;
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
rc.log(`Error: ${err.message}`);
|
|
385
|
+
return 1;
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
process.off('SIGTERM', onSignal);
|
|
389
|
+
process.off('SIGINT', onSignal);
|
|
390
|
+
clearCurrent(rc.configDir);
|
|
391
|
+
}
|
|
392
|
+
}
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type StepId } from './config.ts';
|
|
2
|
+
export interface Current {
|
|
3
|
+
pid: number;
|
|
4
|
+
card?: string;
|
|
5
|
+
title?: string;
|
|
6
|
+
workflow?: string;
|
|
7
|
+
step?: string;
|
|
8
|
+
round?: number;
|
|
9
|
+
startedAt?: string;
|
|
10
|
+
runDir?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare const lockPath: (configDir: string) => string;
|
|
13
|
+
export declare function readCurrent(configDir: string): Current | null;
|
|
14
|
+
export declare function writeCurrent(configDir: string, current: Current): void;
|
|
15
|
+
export declare const clearCurrent: (configDir: string) => void;
|
|
16
|
+
export declare function alive(pid: number): boolean;
|
|
17
|
+
export declare function ours(pid: number): boolean;
|
|
18
|
+
export declare function bootTime(): Date | null;
|
|
19
|
+
export declare function liveCurrent(configDir: string): Current | null;
|
|
20
|
+
export declare const isPaused: (configDir: string) => boolean;
|
|
21
|
+
export declare function setPaused(configDir: string, paused: boolean): void;
|
|
22
|
+
export interface Pending {
|
|
23
|
+
card: string;
|
|
24
|
+
comment: string;
|
|
25
|
+
drop: string[];
|
|
26
|
+
add: string[];
|
|
27
|
+
}
|
|
28
|
+
export declare function readPending(configDir: string): Pending[];
|
|
29
|
+
export declare function writePending(configDir: string, pending: Pending[]): void;
|
|
30
|
+
export type Outcome = 'running' | 'verified' | 'changes' | 'parked' | 'cancelled';
|
|
31
|
+
export interface StepRecord {
|
|
32
|
+
step: StepId;
|
|
33
|
+
label: string;
|
|
34
|
+
startedAt: string;
|
|
35
|
+
endedAt?: string;
|
|
36
|
+
result?: string;
|
|
37
|
+
summary?: string;
|
|
38
|
+
log?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface RunRecord {
|
|
41
|
+
card: string;
|
|
42
|
+
title: string;
|
|
43
|
+
url?: string;
|
|
44
|
+
workflow: string;
|
|
45
|
+
trigger: string;
|
|
46
|
+
steps: StepRecord[];
|
|
47
|
+
outcome: Outcome;
|
|
48
|
+
reason?: string;
|
|
49
|
+
prUrl?: string;
|
|
50
|
+
startedAt: string;
|
|
51
|
+
endedAt?: string;
|
|
52
|
+
}
|
|
53
|
+
export declare const runsDir: (configDir: string) => string;
|
|
54
|
+
export declare function writeRun(runDir: string, run: RunRecord): void;
|
|
55
|
+
export interface RunEntry {
|
|
56
|
+
card: string;
|
|
57
|
+
stamp: string;
|
|
58
|
+
dir: string;
|
|
59
|
+
record?: RunRecord;
|
|
60
|
+
logs: string[];
|
|
61
|
+
}
|
|
62
|
+
export declare function listRuns(configDir: string, card?: string): RunEntry[];
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { agentsDir } from "./config.js";
|
|
5
|
+
export const lockPath = (configDir) => join(agentsDir(configDir), 'runner.lock');
|
|
6
|
+
export function readCurrent(configDir) {
|
|
7
|
+
const path = lockPath(configDir);
|
|
8
|
+
if (!existsSync(path))
|
|
9
|
+
return null;
|
|
10
|
+
const text = readFileSync(path, 'utf8').trim();
|
|
11
|
+
try {
|
|
12
|
+
const parsed = JSON.parse(text);
|
|
13
|
+
return typeof parsed === 'number' ? { pid: parsed } : parsed;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function writeCurrent(configDir, current) {
|
|
20
|
+
mkdirSync(agentsDir(configDir), { recursive: true });
|
|
21
|
+
writeFileSync(lockPath(configDir), JSON.stringify(current) + '\n');
|
|
22
|
+
}
|
|
23
|
+
export const clearCurrent = (configDir) => rmSync(lockPath(configDir), { force: true });
|
|
24
|
+
export function alive(pid) {
|
|
25
|
+
try {
|
|
26
|
+
process.kill(pid, 0);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// True only when the pid is alive and is our runner ("agents tick" or
|
|
34
|
+
// "agents run"). After a reboot the old pid can belong to any program.
|
|
35
|
+
export function ours(pid) {
|
|
36
|
+
if (!alive(pid))
|
|
37
|
+
return false;
|
|
38
|
+
const r = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' });
|
|
39
|
+
return / agents (tick|run)\b/.test(r.stdout ?? '');
|
|
40
|
+
}
|
|
41
|
+
// When the machine last booted, or null when it cannot be read.
|
|
42
|
+
export function bootTime() {
|
|
43
|
+
const r = spawnSync('sysctl', ['-n', 'kern.boottime'], { encoding: 'utf8' });
|
|
44
|
+
const sec = /sec = (\d+)/.exec(r.stdout ?? '')?.[1];
|
|
45
|
+
return sec ? new Date(Number(sec) * 1000) : null;
|
|
46
|
+
}
|
|
47
|
+
// The live run, or null when nothing runs or the lock is stale.
|
|
48
|
+
export function liveCurrent(configDir) {
|
|
49
|
+
const c = readCurrent(configDir);
|
|
50
|
+
return c && ours(c.pid) ? c : null;
|
|
51
|
+
}
|
|
52
|
+
// Paused: the runner stays loaded but picks up no new cards.
|
|
53
|
+
const pausePath = (configDir) => join(agentsDir(configDir), 'paused');
|
|
54
|
+
export const isPaused = (configDir) => existsSync(pausePath(configDir));
|
|
55
|
+
export function setPaused(configDir, paused) {
|
|
56
|
+
mkdirSync(agentsDir(configDir), { recursive: true });
|
|
57
|
+
if (paused)
|
|
58
|
+
writeFileSync(pausePath(configDir), new Date().toISOString() + '\n');
|
|
59
|
+
else
|
|
60
|
+
rmSync(pausePath(configDir), { force: true });
|
|
61
|
+
}
|
|
62
|
+
const pendingPath = (configDir) => join(agentsDir(configDir), 'pending.json');
|
|
63
|
+
export function readPending(configDir) {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(readFileSync(pendingPath(configDir), 'utf8'));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function writePending(configDir, pending) {
|
|
72
|
+
mkdirSync(agentsDir(configDir), { recursive: true });
|
|
73
|
+
if (pending.length)
|
|
74
|
+
writeFileSync(pendingPath(configDir), JSON.stringify(pending, null, 2) + '\n');
|
|
75
|
+
else
|
|
76
|
+
rmSync(pendingPath(configDir), { force: true });
|
|
77
|
+
}
|
|
78
|
+
export const runsDir = (configDir) => join(agentsDir(configDir), 'runs');
|
|
79
|
+
export function writeRun(runDir, run) {
|
|
80
|
+
writeFileSync(join(runDir, 'run.json'), JSON.stringify(run, null, 2) + '\n');
|
|
81
|
+
}
|
|
82
|
+
// Every saved run, newest first. Pass a card to limit it to that card.
|
|
83
|
+
export function listRuns(configDir, card) {
|
|
84
|
+
const root = runsDir(configDir);
|
|
85
|
+
// Skip stray files such as .DS_Store at both levels.
|
|
86
|
+
const dirs = (path) => readdirSync(path, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
87
|
+
const cards = card ? [card] : existsSync(root) ? dirs(root) : [];
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const c of cards) {
|
|
90
|
+
const dir = join(root, c);
|
|
91
|
+
if (!existsSync(dir))
|
|
92
|
+
continue;
|
|
93
|
+
for (const stamp of dirs(dir)) {
|
|
94
|
+
const runDir = join(dir, stamp);
|
|
95
|
+
let record;
|
|
96
|
+
try {
|
|
97
|
+
record = JSON.parse(readFileSync(join(runDir, 'run.json'), 'utf8'));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// an older run, logs only
|
|
101
|
+
}
|
|
102
|
+
const logs = readdirSync(runDir).filter((f) => f.endsWith('.log')).sort();
|
|
103
|
+
out.push({ card: c, stamp, dir: runDir, record, logs });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out.sort((a, b) => (a.stamp < b.stamp ? 1 : -1));
|
|
107
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type CheckStatus = 'ok' | 'warn' | 'fail';
|
|
2
|
+
export interface Check {
|
|
3
|
+
name: string;
|
|
4
|
+
status: CheckStatus;
|
|
5
|
+
detail: string;
|
|
6
|
+
fix?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface Choice<T extends string> {
|
|
9
|
+
value: T;
|
|
10
|
+
label: string;
|
|
11
|
+
hint?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface Ui {
|
|
14
|
+
info(line: string): void;
|
|
15
|
+
warn(line: string): void;
|
|
16
|
+
text(message: string, o?: {
|
|
17
|
+
initial?: string;
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
validate?: (v: string) => string | undefined;
|
|
20
|
+
}): Promise<string>;
|
|
21
|
+
secret(message: string, validate?: (v: string) => string | undefined): Promise<string>;
|
|
22
|
+
confirm(message: string, initial?: boolean): Promise<boolean>;
|
|
23
|
+
select<T extends string>(message: string, choices: Choice<T>[], initial?: T): Promise<T>;
|
|
24
|
+
multiselect<T extends string>(message: string, choices: Choice<T>[], initial?: T[]): Promise<T[]>;
|
|
25
|
+
search<T extends string>(message: string, choices: Choice<T>[], placeholder?: string, initial?: T): Promise<T>;
|
|
26
|
+
path(message: string, o?: {
|
|
27
|
+
initial?: string;
|
|
28
|
+
validate?: (v: string) => string | undefined;
|
|
29
|
+
}): Promise<string>;
|
|
30
|
+
}
|
|
31
|
+
export interface ToolContext {
|
|
32
|
+
defaults: boolean;
|
|
33
|
+
home: string;
|
|
34
|
+
configDir: string;
|
|
35
|
+
selfCommand: string[];
|
|
36
|
+
log: (line: string) => void;
|
|
37
|
+
ui: Ui;
|
|
38
|
+
}
|
|
39
|
+
export type ToolCommand = (args: string[], ctx: ToolContext) => Promise<number>;
|
|
40
|
+
export interface Tool {
|
|
41
|
+
name: string;
|
|
42
|
+
summary: string;
|
|
43
|
+
install(ctx: ToolContext): Promise<void>;
|
|
44
|
+
uninstall(ctx: ToolContext): Promise<void>;
|
|
45
|
+
checks(ctx: ToolContext): Promise<Check[]>;
|
|
46
|
+
commands?: Record<string, ToolCommand>;
|
|
47
|
+
config?: {
|
|
48
|
+
read(configDir: string): Record<string, unknown>;
|
|
49
|
+
write(configDir: string, value: Record<string, unknown>): string[];
|
|
50
|
+
};
|
|
51
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export declare const PLAN_HEADING = "## Plan";
|
|
2
|
+
export declare const LABELS: {
|
|
3
|
+
working: string;
|
|
4
|
+
changes: string;
|
|
5
|
+
verified: string;
|
|
6
|
+
parked: string;
|
|
7
|
+
plan: (round: number) => string;
|
|
8
|
+
dev: (round: number) => string;
|
|
9
|
+
qa: (round: number) => string;
|
|
10
|
+
};
|
|
11
|
+
export declare const isRoundLabel: (name: string) => boolean;
|
|
12
|
+
export type Verdict = 'SHIP' | 'FIX' | 'SKIP';
|
|
13
|
+
export interface StepResult {
|
|
14
|
+
step: string;
|
|
15
|
+
verdict: Verdict;
|
|
16
|
+
report: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function parseVerdict(output: string): Verdict;
|
|
19
|
+
export type DevResult = {
|
|
20
|
+
status: 'done';
|
|
21
|
+
} | {
|
|
22
|
+
status: 'blocked';
|
|
23
|
+
reason: string;
|
|
24
|
+
};
|
|
25
|
+
export declare function parseDevResult(output: string): DevResult;
|
|
26
|
+
export declare function qaPassed(results: StepResult[]): boolean;
|
|
27
|
+
export declare function findingsFor(results: StepResult[]): string;
|
|
28
|
+
export declare function latestPlan(comments: {
|
|
29
|
+
body: string;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
}[]): string | null;
|
|
32
|
+
export declare function skillBody(markdown: string): string;
|
|
33
|
+
export interface PromptInput {
|
|
34
|
+
skill: string;
|
|
35
|
+
card: {
|
|
36
|
+
identifier: string;
|
|
37
|
+
title: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
url?: string;
|
|
40
|
+
};
|
|
41
|
+
plan: string;
|
|
42
|
+
base: string;
|
|
43
|
+
round: number;
|
|
44
|
+
findings?: string;
|
|
45
|
+
extra?: string;
|
|
46
|
+
}
|
|
47
|
+
export declare function buildPrompt(p: PromptInput): string;
|
|
48
|
+
export declare function verdictBlock<T extends string>(steps: T[], i: number, isVerdict: (s: T) => boolean): [number, number];
|
|
49
|
+
export declare function fixTarget<T extends string>(steps: T[], blockStart: number, isProducer: (s: T) => boolean): number;
|
|
50
|
+
export declare function planBody(output: string): string;
|