@voidlabs.gg/voidshift-agents 0.4.0 → 0.4.2
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 +4 -2
- package/dist/control.js +2 -3
- package/dist/engine.js +2 -1
- package/dist/git.d.ts +4 -2
- package/dist/git.js +36 -5
- package/dist/runner.js +77 -29
- package/dist/state.d.ts +1 -0
- package/dist/workflow.d.ts +6 -4
- package/dist/workflow.js +23 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -59,8 +59,10 @@ when ENG + vs-plan: Dev → QA in tether-relay
|
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
The runner checks Linear every minute, works in its own copy of the repo so
|
|
62
|
-
your checkout is never touched, and labels the card as it goes
|
|
63
|
-
`vs-
|
|
62
|
+
your checkout is never touched, and keeps two labels on the card as it goes: the
|
|
63
|
+
stage (`vs-planning`, `vs-plan-review`, `vs-dev`, `vs-qa`, then `vs-qa-verified`
|
|
64
|
+
or `vs-parked`) and the round (`vs-v1`, `vs-v2`). On Linear it posts the plan and
|
|
65
|
+
one final comment, nothing in between.
|
|
64
66
|
|
|
65
67
|
```sh
|
|
66
68
|
vsf agents status # runner, the card in progress, parked cards
|
package/dist/control.js
CHANGED
|
@@ -8,7 +8,7 @@ import { isPaused, listRuns, ours, clearCurrent, liveCurrent, readCurrent, setPa
|
|
|
8
8
|
import { plistPath, runnerLoaded, startRunner, stopRunner } from "./launchd.js";
|
|
9
9
|
import { createLinear } from "./linear.js";
|
|
10
10
|
import { parseStep, resolveName, stepNames } from "./names.js";
|
|
11
|
-
import {
|
|
11
|
+
import { isStatusLabel } from "./workflow.js";
|
|
12
12
|
import { cancelCard, tick } from "./runner.js";
|
|
13
13
|
const HOUR = 3_600_000;
|
|
14
14
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
@@ -429,8 +429,7 @@ const retry = guard(async (args, ctx) => {
|
|
|
429
429
|
chosen = candidates[0];
|
|
430
430
|
}
|
|
431
431
|
const label = config.workflows[chosen].triggers.find((t) => t.team === card.teamKey).label;
|
|
432
|
-
const
|
|
433
|
-
const next = card.labels.filter((n) => !drop.has(n) && !isRoundLabel(n));
|
|
432
|
+
const next = card.labels.filter((n) => !isStatusLabel(n));
|
|
434
433
|
if (!next.includes(label))
|
|
435
434
|
next.push(label);
|
|
436
435
|
await l.setLabels(card, next);
|
package/dist/engine.js
CHANGED
|
@@ -8,7 +8,8 @@ const CLAUDE_WRITE = ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'];
|
|
|
8
8
|
const CLAUDE_READ = ['Read', 'Glob', 'Grep', 'Bash(git diff:*)', 'Bash(git log:*)', 'Bash(git show:*)', 'Bash(npm test:*)', 'Bash(npm run:*)', 'Bash(pnpm:*)', 'Bash(yarn:*)', 'Bash(npx:*)', 'Bash(node:*)', 'Bash(ls:*)', 'Bash(rg:*)'];
|
|
9
9
|
export function engineArgs(o, outFile) {
|
|
10
10
|
if (o.engine === 'claude') {
|
|
11
|
-
|
|
11
|
+
// Edit rules cover every file-writing tool; a Write(path) rule is ignored.
|
|
12
|
+
const vaultTools = o.vault ? [`Edit(/${o.vault}/**)`] : [];
|
|
12
13
|
const args = ['-p', '--output-format', 'text', '--allowedTools', [...(o.write ? CLAUDE_WRITE : CLAUDE_READ), ...vaultTools].join(',')];
|
|
13
14
|
if (o.vault)
|
|
14
15
|
args.push('--add-dir', o.vault);
|
package/dist/git.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export declare function sh(cmd: string, args: string[], cwd: string, timeoutMs?:
|
|
|
8
8
|
export declare function defaultBranch(repo: string): string;
|
|
9
9
|
export declare function ensureWorktree(repo: string, path: string, branch: string, base: string): void;
|
|
10
10
|
export declare function commitsAhead(worktree: string, base: string): number;
|
|
11
|
+
export declare function headSha(worktree: string): string;
|
|
11
12
|
export declare function push(worktree: string, branch: string): void;
|
|
12
13
|
export declare function ensureDraftPr(worktree: string, branch: string, base: string, title: string, body: string): string;
|
|
13
14
|
export declare function markReady(worktree: string, branch: string): boolean;
|
|
@@ -21,8 +22,9 @@ export interface CheckSummary {
|
|
|
21
22
|
}
|
|
22
23
|
export declare function summarizeChecks(text: string): CheckSummary;
|
|
23
24
|
export declare function ciReport(s: CheckSummary): string;
|
|
24
|
-
export declare function
|
|
25
|
-
|
|
25
|
+
export declare function waitForPrHead(worktree: string, branch: string, sha: string, timeoutMs?: number): Promise<boolean>;
|
|
26
|
+
export declare function waitForChecks(worktree: string, branch: string, timeoutMs?: number, settleMs?: number): Promise<{
|
|
27
|
+
verdict: 'SHIP' | 'FIX' | 'SKIP' | 'PENDING';
|
|
26
28
|
report: string;
|
|
27
29
|
}>;
|
|
28
30
|
export declare function resetWorktree(worktree: string): void;
|
package/dist/git.js
CHANGED
|
@@ -40,13 +40,21 @@ export function commitsAhead(worktree, base) {
|
|
|
40
40
|
const out = sh('git', ['rev-list', '--count', `origin/${base}..HEAD`], worktree);
|
|
41
41
|
return out.ok ? Number(out.stdout) : 0;
|
|
42
42
|
}
|
|
43
|
+
export function headSha(worktree) {
|
|
44
|
+
return sh('git', ['rev-parse', 'HEAD'], worktree).stdout;
|
|
45
|
+
}
|
|
43
46
|
export function push(worktree, branch) {
|
|
44
47
|
must(sh('git', ['push', '-u', 'origin', branch], worktree), 'git push');
|
|
45
48
|
}
|
|
49
|
+
// The branch's open pull request. A closed one is reopened, since a closed PR
|
|
50
|
+
// never runs CI on new commits; a merged one gets a fresh PR.
|
|
46
51
|
export function ensureDraftPr(worktree, branch, base, title, body) {
|
|
47
|
-
const view = sh('gh', ['pr', 'view', branch, '--json', 'url', '-q', '.url'], worktree);
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
const view = sh('gh', ['pr', 'view', branch, '--json', 'url,state', '-q', '.state + " " + .url'], worktree);
|
|
53
|
+
const [state, url] = view.ok ? view.stdout.split(' ') : [];
|
|
54
|
+
if (state === 'OPEN' && url)
|
|
55
|
+
return url;
|
|
56
|
+
if (state === 'CLOSED' && url && sh('gh', ['pr', 'reopen', branch], worktree).ok)
|
|
57
|
+
return url;
|
|
50
58
|
return must(sh('gh', ['pr', 'create', '--draft', '--base', base, '--head', branch, '--title', title, '--body', body], worktree), 'gh pr create');
|
|
51
59
|
}
|
|
52
60
|
export function markReady(worktree, branch) {
|
|
@@ -77,7 +85,30 @@ export function ciReport(s) {
|
|
|
77
85
|
}
|
|
78
86
|
// Waits for CI on the PR, then reads the final state once. Repos without CI
|
|
79
87
|
// pass through as SKIP.
|
|
80
|
-
|
|
88
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
89
|
+
// True once GitHub shows `sha` as the head of the branch's pull request, so the
|
|
90
|
+
// checks read next belong to that commit and not to an older push.
|
|
91
|
+
export async function waitForPrHead(worktree, branch, sha, timeoutMs = 3 * 60_000) {
|
|
92
|
+
const until = Date.now() + timeoutMs;
|
|
93
|
+
for (;;) {
|
|
94
|
+
const head = sh('gh', ['pr', 'view', branch, '--json', 'headRefOid', '-q', '.headRefOid'], worktree);
|
|
95
|
+
if (head.ok && head.stdout === sha)
|
|
96
|
+
return true;
|
|
97
|
+
if (Date.now() > until)
|
|
98
|
+
return false;
|
|
99
|
+
await sleep(10_000);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// CI for the pull request's current head. Checks take a moment to register
|
|
103
|
+
// after a push, so "no checks" only counts once they have had time to show up.
|
|
104
|
+
export async function waitForChecks(worktree, branch, timeoutMs = 30 * 60_000, settleMs = 2 * 60_000) {
|
|
105
|
+
const settled = Date.now() + settleMs;
|
|
106
|
+
const noChecks = () => {
|
|
107
|
+
const out = sh('gh', ['pr', 'checks', branch], worktree);
|
|
108
|
+
return /no checks reported/i.test(`${out.stdout}\n${out.stderr}`);
|
|
109
|
+
};
|
|
110
|
+
while (Date.now() < settled && noChecks())
|
|
111
|
+
await sleep(15_000);
|
|
81
112
|
return new Promise((resolve) => {
|
|
82
113
|
const child = spawn('gh', ['pr', 'checks', branch, '--watch', '--interval', '30'], { cwd: worktree, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
83
114
|
track(child);
|
|
@@ -93,7 +124,7 @@ export function waitForChecks(worktree, branch, timeoutMs = 30 * 60_000) {
|
|
|
93
124
|
if (!summary.failed.length && !summary.pending.length)
|
|
94
125
|
return resolve({ verdict: 'SHIP', report: `CI passed (${summary.passed} checks).` });
|
|
95
126
|
if (!summary.failed.length)
|
|
96
|
-
return resolve({ verdict: '
|
|
127
|
+
return resolve({ verdict: 'PENDING', report: `CI did not finish in time. Still running: ${summary.pending.join(', ')}.` });
|
|
97
128
|
resolve({ verdict: 'FIX', report: ciReport(summary) });
|
|
98
129
|
});
|
|
99
130
|
});
|
package/dist/runner.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { basename, join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { LABELS, PLAN_HEADING, buildPrompt, findingsFor, fixTarget,
|
|
4
|
+
import { LABELS, PLAN_HEADING, buildPrompt, findingsFor, fixTarget, isStatusLabel, latestPlan, parseDevResult, parseVerdict, planBody, qaPassed, stageLabels, verdictBlock, } from "./workflow.js";
|
|
5
5
|
import { STEPS, agentsDir, hasWork, isProducer } from "./config.js";
|
|
6
6
|
import { runAgent, stopAgents } from "./engine.js";
|
|
7
|
-
import { commitsAhead, defaultBranch, ensureDraftPr, ensureWorktree, markReady, push, resetWorktree, waitForChecks } from "./git.js";
|
|
7
|
+
import { commitsAhead, defaultBranch, ensureDraftPr, ensureWorktree, headSha, markReady, push, resetWorktree, waitForChecks, waitForPrHead } from "./git.js";
|
|
8
8
|
import { recall, vaultPath } from "./memory.js";
|
|
9
9
|
import { bootTime, clearCurrent, isPaused, ours, readCurrent, readPending, writeCurrent, writePending, writeRun } from "./state.js";
|
|
10
10
|
const packDir = fileURLToPath(new URL('../pack/', import.meta.url));
|
|
@@ -15,7 +15,6 @@ class Park extends Error {
|
|
|
15
15
|
function withLabels(card, drop, add) {
|
|
16
16
|
return [...new Set([...card.labels.filter((l) => !drop(l)), ...add])];
|
|
17
17
|
}
|
|
18
|
-
const statusLabel = (l) => l === LABELS.working || l === LABELS.verified || l === LABELS.parked || l === LABELS.changes || isRoundLabel(l);
|
|
19
18
|
const UNATTENDED = `# Running unattended
|
|
20
19
|
|
|
21
20
|
You are one step in a workflow. Nobody can answer questions and the
|
|
@@ -81,19 +80,36 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
81
80
|
save();
|
|
82
81
|
return outcome;
|
|
83
82
|
};
|
|
83
|
+
// Shown on the card as the version label and in the lock as the round.
|
|
84
|
+
const setRound = (n) => {
|
|
85
|
+
loops = n;
|
|
86
|
+
};
|
|
87
|
+
const stage = (name, round) => linear.setLabels(card, stageLabels(card.labels, trigger.label, name, round));
|
|
88
|
+
// Linear hears from the runner twice: the plan once it has passed review,
|
|
89
|
+
// and one final comment. Findings stay here until the final one.
|
|
90
|
+
let lastFindings = '';
|
|
91
|
+
let planToPost = null;
|
|
92
|
+
const postPlan = async () => {
|
|
93
|
+
if (planToPost === null)
|
|
94
|
+
return;
|
|
95
|
+
await linear.comment(card, `${PLAN_HEADING}\n\n${planToPost}`);
|
|
96
|
+
planToPost = null;
|
|
97
|
+
};
|
|
84
98
|
async function park(reason) {
|
|
85
99
|
say(`parked: ${reason}`);
|
|
86
|
-
const comment = `**Parked:** ${reason}\n\nFix the cause, then add \`${trigger.label}\` again to retry.`;
|
|
100
|
+
const comment = `**Parked:** ${reason}${lastFindings ? `\n\n${lastFindings}` : ''}\n\nFix the cause, then add \`${trigger.label}\` again to retry.`;
|
|
101
|
+
const add = [LABELS.parked, ...(loops ? [LABELS.version(loops)] : [])];
|
|
87
102
|
let commented = false;
|
|
88
103
|
try {
|
|
104
|
+
await postPlan();
|
|
89
105
|
await linear.comment(card, comment);
|
|
90
106
|
commented = true;
|
|
91
|
-
await linear.setLabels(card,
|
|
107
|
+
await linear.setLabels(card, stageLabels(card.labels, trigger.label, LABELS.parked, loops || undefined));
|
|
92
108
|
}
|
|
93
109
|
catch (err) {
|
|
94
110
|
say(`could not reach Linear to park it (${err.message}). Will retry next minute.`);
|
|
95
111
|
// 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,
|
|
112
|
+
writePending(rc.configDir, [...readPending(rc.configDir), { card: card.identifier, comment: commented ? '' : comment, drop: [trigger.label], add, dropStatus: true }]);
|
|
97
113
|
}
|
|
98
114
|
return done('parked', reason);
|
|
99
115
|
}
|
|
@@ -109,7 +125,7 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
109
125
|
if (!plan && needsPlan >= 0 && (firstPlan < 0 || firstPlan > needsPlan)) {
|
|
110
126
|
throw new Park('No plan comment (starting with "## Plan") was found. Publish a plan first, or start the workflow with Plan.');
|
|
111
127
|
}
|
|
112
|
-
await
|
|
128
|
+
await stage(LABELS.stage[steps[from]]);
|
|
113
129
|
const base = project.base || defaultBranch(project.path);
|
|
114
130
|
const vault = vaultPath(rc.configDir);
|
|
115
131
|
const known = [recall(rc.selfCommand, rc.configDir, `${workflow.project} ${card.title}\n${card.description ?? ''}`), vault ? memoryNote(vault) : ''].filter(Boolean).join('\n\n');
|
|
@@ -117,12 +133,13 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
117
133
|
ensureWorktree(project.path, worktree, card.branchName, base);
|
|
118
134
|
say(`working in ${worktree} on ${card.branchName}`);
|
|
119
135
|
const findings = new Map();
|
|
120
|
-
const runs = { plan: 0, dev: 0
|
|
136
|
+
const runs = { plan: 0, dev: 0 };
|
|
121
137
|
let prUrl = '';
|
|
138
|
+
let logs = 0;
|
|
122
139
|
const planText = () => plan ?? '(No plan was posted on this card. Check the work against the card itself.)';
|
|
123
140
|
const timeoutMs = config.timeoutMinutes * 60_000;
|
|
124
|
-
const logFile = (name) => join(runDir, `${String(
|
|
125
|
-
async function check(step) {
|
|
141
|
+
const logFile = (name) => join(runDir, `${String(logs++).padStart(2, '0')}-${name}.log`);
|
|
142
|
+
async function check(step, round) {
|
|
126
143
|
const label = STEPS[step].label;
|
|
127
144
|
const rec = begin(step);
|
|
128
145
|
let ciNote = '';
|
|
@@ -131,9 +148,22 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
131
148
|
end(rec, 'SKIP', 'No changes on the branch yet.');
|
|
132
149
|
return { step: label, verdict: 'SKIP', report: 'No changes on the branch yet.' };
|
|
133
150
|
}
|
|
134
|
-
// CI first, without an agent: a red build goes straight back.
|
|
151
|
+
// CI first, without an agent: a red build goes straight back. Only
|
|
152
|
+
// checks for the commit just pushed count, never an older push's.
|
|
135
153
|
if (prUrl) {
|
|
136
|
-
const
|
|
154
|
+
const sha = headSha(worktree);
|
|
155
|
+
if (!(await waitForPrHead(worktree, card.branchName, sha))) {
|
|
156
|
+
throw new Park(`GitHub never showed commit ${sha.slice(0, 9)} on the pull request, so its CI could not be read. PR: ${prUrl}`);
|
|
157
|
+
}
|
|
158
|
+
// A slow CI is not a failed one: wait a second window, then park
|
|
159
|
+
// rather than send Dev a round it can only answer with no commit.
|
|
160
|
+
let ci = await waitForChecks(worktree, card.branchName);
|
|
161
|
+
if (ci.verdict === 'PENDING') {
|
|
162
|
+
say('CI still running, waiting another window');
|
|
163
|
+
ci = await waitForChecks(worktree, card.branchName);
|
|
164
|
+
}
|
|
165
|
+
if (ci.verdict === 'PENDING')
|
|
166
|
+
throw new Park(`${ci.report} PR: ${prUrl}`);
|
|
137
167
|
if (ci.verdict === 'FIX') {
|
|
138
168
|
end(rec, 'FIX', ci.report);
|
|
139
169
|
return { step: label, verdict: 'FIX', report: ci.report };
|
|
@@ -144,7 +174,7 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
144
174
|
const log = logFile(step);
|
|
145
175
|
const r = await runAgent({
|
|
146
176
|
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
|
|
177
|
+
prompt: buildPrompt({ skill: skill(step), card, plan: planText(), base, round, extra: [known, ciNote, UNATTENDED].filter(Boolean).join('\n\n') }),
|
|
148
178
|
});
|
|
149
179
|
if (step === 'vs-qa')
|
|
150
180
|
resetWorktree(worktree);
|
|
@@ -158,30 +188,40 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
158
188
|
say(`${STEPS[step].label}`);
|
|
159
189
|
if (isVerdict(step)) {
|
|
160
190
|
const [start, end] = verdictBlock(steps, i, isVerdict);
|
|
191
|
+
const target = fixTarget(steps, start, isProducer);
|
|
192
|
+
const producer = target >= 0 ? steps[target] : undefined;
|
|
193
|
+
// The round being checked: how many times its producer has run.
|
|
194
|
+
const round = Math.max(producer === 'vs-plan' ? runs.plan : runs.dev, 1);
|
|
195
|
+
setRound(round);
|
|
161
196
|
const results = [];
|
|
162
|
-
for (const s of steps.slice(start, end))
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
197
|
+
for (const s of steps.slice(start, end)) {
|
|
198
|
+
await stage(LABELS.stage[s], round);
|
|
199
|
+
results.push(await check(s, round));
|
|
200
|
+
}
|
|
166
201
|
if (qaPassed(results)) {
|
|
167
202
|
i = end;
|
|
168
203
|
continue;
|
|
169
204
|
}
|
|
170
205
|
const notes = findingsFor(results);
|
|
171
|
-
|
|
172
|
-
const target = fixTarget(steps, start, isProducer);
|
|
206
|
+
lastFindings = `**Latest findings (round ${round}).**\n\n${notes}`;
|
|
173
207
|
if (target < 0) {
|
|
174
|
-
await
|
|
208
|
+
await postPlan();
|
|
209
|
+
await linear.comment(card, `**Changes needed.**\n\n${notes}`);
|
|
210
|
+
await linear.setLabels(card, stageLabels(card.labels, trigger.label, LABELS.changes, round));
|
|
175
211
|
say('changes requested');
|
|
176
212
|
return done('changes');
|
|
177
213
|
}
|
|
178
|
-
|
|
179
|
-
|
|
214
|
+
// Rounds caps how many times Plan or Dev runs, counting the first.
|
|
215
|
+
if (round >= workflow.maxRounds) {
|
|
216
|
+
throw new Park(`Checks still failed after ${round} ${producer === 'vs-plan' ? 'plan' : 'dev'} rounds.${prUrl ? ` PR: ${prUrl}` : ''}`);
|
|
217
|
+
}
|
|
180
218
|
findings.set(target, notes);
|
|
181
219
|
i = target;
|
|
182
220
|
continue;
|
|
183
221
|
}
|
|
184
222
|
if (step === 'vs-plan') {
|
|
223
|
+
setRound(runs.plan + 1);
|
|
224
|
+
await stage(LABELS.stage[step], runs.plan + 1);
|
|
185
225
|
const rec = begin(step);
|
|
186
226
|
const log = logFile('plan');
|
|
187
227
|
const r = await runAgent({
|
|
@@ -193,18 +233,22 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
193
233
|
if (result.status === 'blocked')
|
|
194
234
|
throw new Park(`The plan agent stopped: ${result.reason}`);
|
|
195
235
|
plan = planBody(r.output);
|
|
196
|
-
|
|
236
|
+
planToPost = plan;
|
|
197
237
|
runs.plan++;
|
|
198
|
-
await linear.setLabels(card, withLabels(card, () => false, [LABELS.plan(runs.plan)]));
|
|
199
238
|
}
|
|
200
239
|
else if (step === 'vs-publish') {
|
|
240
|
+
await postPlan();
|
|
201
241
|
const rec = begin(step);
|
|
202
242
|
push(worktree, card.branchName);
|
|
203
243
|
end(rec, 'pushed');
|
|
204
244
|
}
|
|
205
245
|
else if (step === 'vs-dev') {
|
|
246
|
+
await postPlan();
|
|
247
|
+
setRound(runs.dev + 1);
|
|
248
|
+
await stage(LABELS.stage[step], runs.dev + 1);
|
|
206
249
|
const rec = begin(step);
|
|
207
250
|
const log = logFile('dev');
|
|
251
|
+
const before = headSha(worktree);
|
|
208
252
|
const dev = await runAgent({
|
|
209
253
|
engine: config.engine, model: config.models.dev, cwd: worktree, vault, write: true, logFile: log, timeoutMs,
|
|
210
254
|
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') }),
|
|
@@ -215,22 +259,26 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
215
259
|
throw new Park(`The dev agent stopped: ${result.reason}`);
|
|
216
260
|
if (commitsAhead(worktree, base) === 0)
|
|
217
261
|
throw new Park('The dev agent made no commits.');
|
|
262
|
+
// A round sent back with findings must change something, or QA would
|
|
263
|
+
// just check the same commit again.
|
|
264
|
+
if (runs.dev > 0 && headSha(worktree) === before)
|
|
265
|
+
throw new Park('The dev agent made no new commits this round.');
|
|
218
266
|
push(worktree, card.branchName);
|
|
219
267
|
prUrl = ensureDraftPr(worktree, card.branchName, base, `${card.identifier}: ${card.title}`, card.url);
|
|
220
268
|
record.prUrl = prUrl;
|
|
221
269
|
save();
|
|
222
270
|
runs.dev++;
|
|
223
|
-
await linear.setLabels(card, withLabels(card, () => false, [LABELS.dev(runs.dev)]));
|
|
224
271
|
}
|
|
225
272
|
findings.delete(i);
|
|
226
273
|
i++;
|
|
227
274
|
}
|
|
275
|
+
await postPlan();
|
|
228
276
|
let moved = false;
|
|
229
277
|
if (hasWork(workflow)) {
|
|
230
278
|
markReady(worktree, card.branchName);
|
|
231
279
|
moved = await linear.moveTo(card, config.reviewState);
|
|
232
280
|
}
|
|
233
|
-
await linear.setLabels(card,
|
|
281
|
+
await linear.setLabels(card, stageLabels(card.labels, trigger.label, LABELS.verified, loops || undefined));
|
|
234
282
|
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
283
|
say('verified');
|
|
236
284
|
return done('verified');
|
|
@@ -271,7 +319,7 @@ async function flushPending(rc) {
|
|
|
271
319
|
await rc.linear.comment(card, p.comment);
|
|
272
320
|
p.comment = '';
|
|
273
321
|
}
|
|
274
|
-
await rc.linear.setLabels(card, withLabels(card, (l) => p.drop.includes(l), p.add));
|
|
322
|
+
await rc.linear.setLabels(card, withLabels(card, (l) => p.drop.includes(l) || (!!p.dropStatus && isStatusLabel(l)), p.add));
|
|
275
323
|
}
|
|
276
324
|
catch {
|
|
277
325
|
left.push(p);
|
|
@@ -290,7 +338,7 @@ export async function cancelCard(linear, configDir, cardId, label) {
|
|
|
290
338
|
const comment = `**Cancelled.** Add \`${label}\` again to restart.`;
|
|
291
339
|
try {
|
|
292
340
|
const card = await linear.card(cardId);
|
|
293
|
-
await linear.setLabels(card, withLabels(card,
|
|
341
|
+
await linear.setLabels(card, withLabels(card, isStatusLabel, []));
|
|
294
342
|
await linear.comment(card, comment);
|
|
295
343
|
return true;
|
|
296
344
|
}
|
|
@@ -304,7 +352,7 @@ function queueCancel(configDir, cardId, comment) {
|
|
|
304
352
|
if (pending.some((p) => p.card === cardId && p.comment === comment))
|
|
305
353
|
return;
|
|
306
354
|
try {
|
|
307
|
-
writePending(configDir, [...pending, { card: cardId, comment, drop: [
|
|
355
|
+
writePending(configDir, [...pending, { card: cardId, comment, drop: [], add: [], dropStatus: true }]);
|
|
308
356
|
}
|
|
309
357
|
catch {
|
|
310
358
|
// nothing more to do
|
package/dist/state.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ export interface Pending {
|
|
|
24
24
|
comment: string;
|
|
25
25
|
drop: string[];
|
|
26
26
|
add: string[];
|
|
27
|
+
dropStatus?: boolean;
|
|
27
28
|
}
|
|
28
29
|
export declare function readPending(configDir: string): Pending[];
|
|
29
30
|
export declare function writePending(configDir: string, pending: Pending[]): void;
|
package/dist/workflow.d.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
export declare const PLAN_HEADING = "## Plan";
|
|
2
2
|
export declare const LABELS: {
|
|
3
|
-
|
|
3
|
+
stage: Record<string, string>;
|
|
4
4
|
changes: string;
|
|
5
5
|
verified: string;
|
|
6
6
|
parked: string;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
qa: (round: number) => string;
|
|
7
|
+
version: (round: number) => string;
|
|
8
|
+
working: string;
|
|
10
9
|
};
|
|
10
|
+
export declare const isVersionLabel: (name: string) => boolean;
|
|
11
11
|
export declare const isRoundLabel: (name: string) => boolean;
|
|
12
|
+
export declare const isStatusLabel: (name: string) => boolean;
|
|
13
|
+
export declare function stageLabels(current: string[], trigger: string, stage: string, round?: number): string[];
|
|
12
14
|
export type Verdict = 'SHIP' | 'FIX' | 'SKIP';
|
|
13
15
|
export interface StepResult {
|
|
14
16
|
step: string;
|
package/dist/workflow.js
CHANGED
|
@@ -3,16 +3,34 @@
|
|
|
3
3
|
export const PLAN_HEADING = '## Plan';
|
|
4
4
|
// Plans posted before 0.1.5 used this heading; still read them.
|
|
5
5
|
const OLD_PLAN_HEADING = '## VoidShift plan';
|
|
6
|
+
// A card carries at most two VoidShift labels: one stage label (which agent has
|
|
7
|
+
// it, or how it ended) and one version label (which round it is on).
|
|
6
8
|
export const LABELS = {
|
|
7
|
-
|
|
9
|
+
stage: {
|
|
10
|
+
'vs-plan': 'vs-planning',
|
|
11
|
+
'vs-plan-review': 'vs-plan-review',
|
|
12
|
+
'vs-publish': 'vs-publish',
|
|
13
|
+
'vs-dev': 'vs-dev',
|
|
14
|
+
'vs-qa': 'vs-qa',
|
|
15
|
+
},
|
|
8
16
|
changes: 'vs-changes-requested',
|
|
9
17
|
verified: 'vs-qa-verified',
|
|
10
18
|
parked: 'vs-parked',
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
19
|
+
version: (round) => `vs-v${round}`,
|
|
20
|
+
// Labels from earlier runners, still cleaned off cards.
|
|
21
|
+
working: 'vs-working',
|
|
14
22
|
};
|
|
15
|
-
|
|
23
|
+
const STAGE_NAMES = new Set([...Object.values(LABELS.stage), LABELS.changes, LABELS.verified, LABELS.parked, LABELS.working]);
|
|
24
|
+
export const isVersionLabel = (name) => /^vs-v\d+$/.test(name);
|
|
25
|
+
// Round labels earlier runners stacked up, one per step and round.
|
|
26
|
+
export const isRoundLabel = (name) => /^vs-(plan|review|dev|qa)-v\d+$/.test(name);
|
|
27
|
+
export const isStatusLabel = (name) => STAGE_NAMES.has(name) || isVersionLabel(name) || isRoundLabel(name);
|
|
28
|
+
// The card's labels with every VoidShift label (and the trigger) replaced by
|
|
29
|
+
// one stage label and, when given, one version label.
|
|
30
|
+
export function stageLabels(current, trigger, stage, round) {
|
|
31
|
+
const kept = current.filter((l) => l !== trigger && !isStatusLabel(l));
|
|
32
|
+
return [...new Set([...kept, stage, ...(round ? [LABELS.version(round)] : [])])];
|
|
33
|
+
}
|
|
16
34
|
// The last "VERDICT: X" line wins; anything unreadable counts as FIX so a
|
|
17
35
|
// confused agent never waves a change through.
|
|
18
36
|
export function parseVerdict(output) {
|
package/package.json
CHANGED