@voidlabs.gg/voidshift-agents 0.4.0 → 0.4.1

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,7 +22,8 @@ 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
+ 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<{
25
27
  verdict: 'SHIP' | 'FIX' | 'SKIP';
26
28
  report: string;
27
29
  }>;
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);
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,8 +148,13 @@ 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) {
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
+ }
136
158
  const ci = await waitForChecks(worktree, card.branchName);
137
159
  if (ci.verdict === 'FIX') {
138
160
  end(rec, 'FIX', ci.report);
@@ -144,7 +166,7 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
144
166
  const log = logFile(step);
145
167
  const r = await runAgent({
146
168
  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') }),
169
+ prompt: buildPrompt({ skill: skill(step), card, plan: planText(), base, round, extra: [known, ciNote, UNATTENDED].filter(Boolean).join('\n\n') }),
148
170
  });
149
171
  if (step === 'vs-qa')
150
172
  resetWorktree(worktree);
@@ -158,30 +180,40 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
158
180
  say(`${STEPS[step].label}`);
159
181
  if (isVerdict(step)) {
160
182
  const [start, end] = verdictBlock(steps, i, isVerdict);
183
+ const target = fixTarget(steps, start, isProducer);
184
+ const producer = target >= 0 ? steps[target] : undefined;
185
+ // The round being checked: how many times its producer has run.
186
+ const round = Math.max(producer === 'vs-plan' ? runs.plan : runs.dev, 1);
187
+ setRound(round);
161
188
  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)]));
189
+ for (const s of steps.slice(start, end)) {
190
+ await stage(LABELS.stage[s], round);
191
+ results.push(await check(s, round));
192
+ }
166
193
  if (qaPassed(results)) {
167
194
  i = end;
168
195
  continue;
169
196
  }
170
197
  const notes = findingsFor(results);
171
- await linear.comment(card, `**Changes needed (round ${runs.qa}).**\n\n${notes}`);
172
- const target = fixTarget(steps, start, isProducer);
198
+ lastFindings = `**Latest findings (round ${round}).**\n\n${notes}`;
173
199
  if (target < 0) {
174
- await linear.setLabels(card, withLabels(card, (l) => l === LABELS.working, [LABELS.changes]));
200
+ await postPlan();
201
+ await linear.comment(card, `**Changes needed.**\n\n${notes}`);
202
+ await linear.setLabels(card, stageLabels(card.labels, trigger.label, LABELS.changes, round));
175
203
  say('changes requested');
176
204
  return done('changes');
177
205
  }
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}` : ''}`);
206
+ // Rounds caps how many times Plan or Dev runs, counting the first.
207
+ if (round >= workflow.maxRounds) {
208
+ throw new Park(`Checks still failed after ${round} ${producer === 'vs-plan' ? 'plan' : 'dev'} rounds.${prUrl ? ` PR: ${prUrl}` : ''}`);
209
+ }
180
210
  findings.set(target, notes);
181
211
  i = target;
182
212
  continue;
183
213
  }
184
214
  if (step === 'vs-plan') {
215
+ setRound(runs.plan + 1);
216
+ await stage(LABELS.stage[step], runs.plan + 1);
185
217
  const rec = begin(step);
186
218
  const log = logFile('plan');
187
219
  const r = await runAgent({
@@ -193,18 +225,22 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
193
225
  if (result.status === 'blocked')
194
226
  throw new Park(`The plan agent stopped: ${result.reason}`);
195
227
  plan = planBody(r.output);
196
- await linear.comment(card, `${PLAN_HEADING}\n\n${plan}`);
228
+ planToPost = plan;
197
229
  runs.plan++;
198
- await linear.setLabels(card, withLabels(card, () => false, [LABELS.plan(runs.plan)]));
199
230
  }
200
231
  else if (step === 'vs-publish') {
232
+ await postPlan();
201
233
  const rec = begin(step);
202
234
  push(worktree, card.branchName);
203
235
  end(rec, 'pushed');
204
236
  }
205
237
  else if (step === 'vs-dev') {
238
+ await postPlan();
239
+ setRound(runs.dev + 1);
240
+ await stage(LABELS.stage[step], runs.dev + 1);
206
241
  const rec = begin(step);
207
242
  const log = logFile('dev');
243
+ const before = headSha(worktree);
208
244
  const dev = await runAgent({
209
245
  engine: config.engine, model: config.models.dev, cwd: worktree, vault, write: true, logFile: log, timeoutMs,
210
246
  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 +251,26 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
215
251
  throw new Park(`The dev agent stopped: ${result.reason}`);
216
252
  if (commitsAhead(worktree, base) === 0)
217
253
  throw new Park('The dev agent made no commits.');
254
+ // A round sent back with findings must change something, or QA would
255
+ // just check the same commit again.
256
+ if (runs.dev > 0 && headSha(worktree) === before)
257
+ throw new Park('The dev agent made no new commits this round.');
218
258
  push(worktree, card.branchName);
219
259
  prUrl = ensureDraftPr(worktree, card.branchName, base, `${card.identifier}: ${card.title}`, card.url);
220
260
  record.prUrl = prUrl;
221
261
  save();
222
262
  runs.dev++;
223
- await linear.setLabels(card, withLabels(card, () => false, [LABELS.dev(runs.dev)]));
224
263
  }
225
264
  findings.delete(i);
226
265
  i++;
227
266
  }
267
+ await postPlan();
228
268
  let moved = false;
229
269
  if (hasWork(workflow)) {
230
270
  markReady(worktree, card.branchName);
231
271
  moved = await linear.moveTo(card, config.reviewState);
232
272
  }
233
- await linear.setLabels(card, withLabels(card, (l) => l === LABELS.working, [LABELS.verified]));
273
+ await linear.setLabels(card, stageLabels(card.labels, trigger.label, LABELS.verified, loops || undefined));
234
274
  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
275
  say('verified');
236
276
  return done('verified');
@@ -271,7 +311,7 @@ async function flushPending(rc) {
271
311
  await rc.linear.comment(card, p.comment);
272
312
  p.comment = '';
273
313
  }
274
- await rc.linear.setLabels(card, withLabels(card, (l) => p.drop.includes(l), p.add));
314
+ await rc.linear.setLabels(card, withLabels(card, (l) => p.drop.includes(l) || (!!p.dropStatus && isStatusLabel(l)), p.add));
275
315
  }
276
316
  catch {
277
317
  left.push(p);
@@ -290,7 +330,7 @@ export async function cancelCard(linear, configDir, cardId, label) {
290
330
  const comment = `**Cancelled.** Add \`${label}\` again to restart.`;
291
331
  try {
292
332
  const card = await linear.card(cardId);
293
- await linear.setLabels(card, withLabels(card, (l) => l === LABELS.working, []));
333
+ await linear.setLabels(card, withLabels(card, isStatusLabel, []));
294
334
  await linear.comment(card, comment);
295
335
  return true;
296
336
  }
@@ -304,7 +344,7 @@ function queueCancel(configDir, cardId, comment) {
304
344
  if (pending.some((p) => p.card === cardId && p.comment === comment))
305
345
  return;
306
346
  try {
307
- writePending(configDir, [...pending, { card: cardId, comment, drop: [LABELS.working], add: [] }]);
347
+ writePending(configDir, [...pending, { card: cardId, comment, drop: [], add: [], dropStatus: true }]);
308
348
  }
309
349
  catch {
310
350
  // 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.1",
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": {