@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 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 (`vs-dev-v1`,
63
- `vs-qa-v1`, then `vs-qa-verified` or `vs-parked` with a comment saying why).
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 { LABELS, isRoundLabel } from "./workflow.js";
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 drop = new Set([LABELS.parked, LABELS.changes, LABELS.verified, LABELS.working]);
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
- const vaultTools = o.vault ? [`Edit(/${o.vault}/**)`, `Write(/${o.vault}/**)`] : [];
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 waitForChecks(worktree: string, branch: string, timeoutMs?: number): Promise<{
25
- verdict: 'SHIP' | 'FIX' | 'SKIP';
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
- if (view.ok && view.stdout)
49
- return view.stdout;
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
- export function waitForChecks(worktree, branch, timeoutMs = 30 * 60_000) {
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: 'FIX', report: `CI did not finish in time. Still running: ${summary.pending.join(', ')}.` });
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, isRoundLabel, latestPlan, parseDevResult, parseVerdict, planBody, qaPassed, verdictBlock, } from "./workflow.js";
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, withLabels(card, (l) => l === trigger.label || l === LABELS.working, [LABELS.parked]));
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, LABELS.working], add: [LABELS.parked] }]);
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 linear.setLabels(card, withLabels(card, (l) => l === trigger.label || statusLabel(l), [LABELS.working]));
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, qa: 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(runs.plan + runs.dev + runs.qa).padStart(2, '0')}-${name}.log`);
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 ci = await waitForChecks(worktree, card.branchName);
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: runs.qa + 1, extra: [known, ciNote, UNATTENDED].filter(Boolean).join('\n\n') }),
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
- results.push(await check(s));
164
- runs.qa++;
165
- await linear.setLabels(card, withLabels(card, () => false, [LABELS.qa(runs.qa)]));
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
- await linear.comment(card, `**Changes needed (round ${runs.qa}).**\n\n${notes}`);
172
- const target = fixTarget(steps, start, isProducer);
206
+ lastFindings = `**Latest findings (round ${round}).**\n\n${notes}`;
173
207
  if (target < 0) {
174
- await linear.setLabels(card, withLabels(card, (l) => l === LABELS.working, [LABELS.changes]));
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
- 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}` : ''}`);
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
- await linear.comment(card, `${PLAN_HEADING}\n\n${plan}`);
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, withLabels(card, (l) => l === LABELS.working, [LABELS.verified]));
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, (l) => l === LABELS.working, []));
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: [LABELS.working], add: [] }]);
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;
@@ -1,14 +1,16 @@
1
1
  export declare const PLAN_HEADING = "## Plan";
2
2
  export declare const LABELS: {
3
- working: string;
3
+ stage: Record<string, string>;
4
4
  changes: string;
5
5
  verified: string;
6
6
  parked: string;
7
- plan: (round: number) => string;
8
- dev: (round: number) => string;
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
- working: 'vs-working',
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
- plan: (round) => `vs-plan-v${round}`,
12
- dev: (round) => `vs-dev-v${round}`,
13
- qa: (round) => `vs-qa-v${round}`,
19
+ version: (round) => `vs-v${round}`,
20
+ // Labels from earlier runners, still cleaned off cards.
21
+ working: 'vs-working',
14
22
  };
15
- export const isRoundLabel = (name) => /^vs-(plan|dev|qa)-v\d+$/.test(name);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voidlabs.gg/voidshift-agents",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Agents for Claude Code and Codex that plan, build and QA Linear cards, chained and run in the background.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {