@voidlabs.gg/voidshift-agents 0.4.3 → 0.4.4

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
@@ -50,15 +50,24 @@ Five agents, installed into Claude Code (and Codex if you use it):
50
50
  | QA | CI, then code review, then a usability check in a headless browser |
51
51
 
52
52
  Use them yourself (`/vs-plan ENG-123` in Claude Code), or let a **workflow**
53
- run them. A workflow starts when one of your Linear cards gets a label, runs
54
- agents in order in a project (a repo on your Mac), and sends work back to the
55
- step that made it when a check fails:
53
+ run them. A workflow starts when one of your Linear or VoidTrack cards gets a
54
+ label, or on a schedule, runs agents in order in a project (a repo on your
55
+ machine), and sends work back to the step that made it when a check fails:
56
56
 
57
57
  ```
58
58
  when ENG + vs-plan: Dev → QA in tether-relay
59
59
  ```
60
60
 
61
- The runner checks Linear every minute, works in its own copy of the repo so
61
+ Triggers, as `--trigger` takes them:
62
+
63
+ ```
64
+ ENG:vs-plan # a Linear card in ENG assigned to you gets vs-plan
65
+ voidtrack:ORG:vs-start # the same for VoidTrack (vsf agents key --voidtrack)
66
+ "cron:0 5 * * 1:Update dependencies" # every Monday 05:00, no card, on its own branch
67
+ ```
68
+
69
+ A schedule fires once when due (a missed one is made up within a day) and
70
+ `vsf agents run --workflow NAME` fires it now. The runner checks every minute, works in its own copy of the repo so
62
71
  your checkout is never touched, and keeps two labels on the card as it goes: the
63
72
  stage (`vs-planning`, `vs-plan-review`, `vs-dev`, `vs-qa`, then `vs-qa-verified`
64
73
  or `vs-parked`) and the round (`vs-v1`, `vs-v2`). On Linear it posts the plan and
package/dist/config.d.ts CHANGED
@@ -13,11 +13,21 @@ export declare const AGENTS: Record<string, {
13
13
  }>;
14
14
  export declare const visibleAgents: () => string[];
15
15
  export declare const stepEnabled: (step: StepId, enabled: string[]) => boolean;
16
- export interface Trigger {
17
- source: 'linear';
16
+ export type Tracker = 'linear' | 'voidtrack';
17
+ export interface CardTrigger {
18
+ source: Tracker;
18
19
  team: string;
19
20
  label: string;
20
21
  }
22
+ export interface CronTrigger {
23
+ source: 'cron';
24
+ schedule: string;
25
+ task: string;
26
+ }
27
+ export type Trigger = CardTrigger | CronTrigger;
28
+ export declare const isCardTrigger: (t: Trigger) => t is CardTrigger;
29
+ export declare function triggerSpec(t: Trigger): string;
30
+ export declare function describeTrigger(t: Trigger): string;
21
31
  export interface Project {
22
32
  path: string;
23
33
  base?: string;
package/dist/config.js CHANGED
@@ -19,6 +19,19 @@ export const AGENTS = {
19
19
  };
20
20
  export const visibleAgents = () => Object.keys(AGENTS);
21
21
  export const stepEnabled = (step, enabled) => enabled.includes(step);
22
+ export const isCardTrigger = (t) => t.source !== 'cron';
23
+ // The form `--trigger` takes: "ENG:vs-plan" (Linear), "voidtrack:ORG:vs-start",
24
+ // or "cron:0 5 * * 1:Update the dependencies".
25
+ export function triggerSpec(t) {
26
+ if (t.source === 'cron')
27
+ return `cron:${t.schedule}:${t.task}`;
28
+ return t.source === 'voidtrack' ? `voidtrack:${t.team}:${t.label}` : `${t.team}:${t.label}`;
29
+ }
30
+ export function describeTrigger(t) {
31
+ if (t.source === 'cron')
32
+ return `cron ${t.schedule}`;
33
+ return `${t.source === 'voidtrack' ? 'VoidTrack ' : ''}${t.team} + ${t.label}`;
34
+ }
22
35
  // Step lists to start a workflow from.
23
36
  export const TEMPLATES = {
24
37
  build: { steps: ['vs-dev', 'vs-qa'], maxRounds: 3 },
@@ -40,7 +53,7 @@ const configPath = (configDir) => join(agentsDir(configDir), 'config.json');
40
53
  export const hasWork = (workflow) => workflow.steps.some((s) => STEPS[s].output === 'commits');
41
54
  export const hasProducer = (workflow) => workflow.steps.some(isProducer);
42
55
  export const describeSteps = (steps) => steps.map((s) => STEPS[s].label).join(' → ');
43
- export const describeTriggers = (triggers) => triggers.map((t) => `${t.team} + ${t.label}`).join(', ');
56
+ export const describeTriggers = (triggers) => triggers.map(describeTrigger).join(', ');
44
57
  export const describeWorkflow = (c) => `when ${describeTriggers(c.triggers)}: ${describeSteps(c.steps)} in ${c.project}`;
45
58
  // Step ids that were folded into QA.
46
59
  const RENAMED = { 'vs-qa-review': 'vs-qa', ci: null, 'vs-qa-ui': null };
package/dist/control.d.ts CHANGED
@@ -1,6 +1,8 @@
1
+ import { type AgentsConfig, type Tracker as Kind } from './config.ts';
1
2
  import { type Current, type RunEntry } from './state.ts';
2
3
  import type { Check, ToolCommand, ToolContext } from './types.ts';
3
4
  export { parsePlistArgs } from './launchd.ts';
5
+ export declare const trackersInUse: (config: Pick<AgentsConfig, "workflows">) => Kind[];
4
6
  export declare function parseAge(input: string): number | null;
5
7
  export declare function formatDuration(ms: number): string;
6
8
  export declare function stampDate(stamp: string): Date;
@@ -18,7 +20,7 @@ export interface CancelDeps {
18
20
  ours: (pid: number) => boolean;
19
21
  kill: (pid: number, signal: 'SIGTERM' | 'SIGKILL') => void;
20
22
  sleep: (ms: number) => Promise<void>;
21
- cancelCard: (card: string, label: string) => Promise<boolean>;
23
+ cancelCard: (card: string, label: string, source: Kind) => Promise<boolean>;
22
24
  }
23
25
  export declare function cancelCurrent(ctx: ToolContext, c: Current, deps: CancelDeps): Promise<void>;
24
26
  export declare const controlCommands: Record<string, ToolCommand>;
package/dist/control.js CHANGED
@@ -2,11 +2,10 @@ import { spawnSync } from 'node:child_process';
2
2
  import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
- import { agentsDir, readAgentsConfig } from "./config.js";
6
- import { readLinearKey } from "./secrets.js";
5
+ import { agentsDir, isCardTrigger, readAgentsConfig } from "./config.js";
7
6
  import { isPaused, listRuns, ours, clearCurrent, liveCurrent, readCurrent, setPaused, writeRun } from "./state.js";
8
7
  import { runnerService } from "./service.js";
9
- import { createLinear } from "./linear.js";
8
+ import { TRACKERS, trackersFor } from "./tracker.js";
10
9
  import { parseStep, resolveName, stepNames } from "./names.js";
11
10
  import { isStatusLabel } from "./workflow.js";
12
11
  import { cancelCard, tick } from "./runner.js";
@@ -15,11 +14,27 @@ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', '
15
14
  const CARD_ID = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
16
15
  const ISO_LINE = /^(\d{4}-\d\d-\d\dT[\d:.]+Z)\s*(.*)$/;
17
16
  export { parsePlistArgs } from "./launchd.js";
18
- function linear(configDir) {
19
- const key = readLinearKey(configDir);
20
- if (!key)
21
- throw new Error('No Linear API key. Run vsf agents key');
22
- return createLinear(key);
17
+ // Trackers the workflows' card triggers use.
18
+ export const trackersInUse = (config) => [
19
+ ...new Set(Object.values(config.workflows).flatMap((w) => w.triggers.filter(isCardTrigger).map((t) => t.source))),
20
+ ];
21
+ // A card by id, from the tracker of a workflow that starts on its team.
22
+ async function findCard(config, trackers, id) {
23
+ const team = id.split('-')[0].toUpperCase();
24
+ const sources = [...new Set(Object.values(config.workflows).flatMap((w) => w.triggers.filter(isCardTrigger).filter((t) => t.team === team).map((t) => t.source)))];
25
+ if (!sources.length)
26
+ throw new Error(`No workflow starts on team ${team}.`);
27
+ let last = null;
28
+ for (const source of sources) {
29
+ try {
30
+ const tracker = trackers(source);
31
+ return { card: await tracker.card(id), tracker, source };
32
+ }
33
+ catch (err) {
34
+ last = err;
35
+ }
36
+ }
37
+ throw last;
23
38
  }
24
39
  const logPath = (configDir) => join(agentsDir(configDir), 'runner.log');
25
40
  const program = (ctx) => [...ctx.selfCommand, 'agents', 'tick'];
@@ -179,7 +194,7 @@ export async function cancelCurrent(ctx, c, deps) {
179
194
  // gone just now
180
195
  }
181
196
  }
182
- ctx.log(c.card ? `Cancelled ${c.card}.` : 'Stopped a check of Linear.');
197
+ ctx.log(c.card ? `Cancelled ${c.card}.` : 'Stopped a check.');
183
198
  return;
184
199
  }
185
200
  // Dead or a reused pid: no signal, just tidy up what it left behind.
@@ -193,33 +208,44 @@ export async function cancelCurrent(ctx, c, deps) {
193
208
  // the record is only a nicety
194
209
  }
195
210
  }
196
- if (c.card) {
197
- const label = readAgentsConfig(ctx.configDir).workflows[c.workflow ?? '']?.triggers[0]?.label ?? "the workflow's label";
198
- await deps.cancelCard(c.card, label);
211
+ if (c.card && c.source !== 'cron') {
212
+ const first = readAgentsConfig(ctx.configDir).workflows[c.workflow ?? '']?.triggers.find(isCardTrigger);
213
+ await deps.cancelCard(c.card, first?.label ?? "the workflow's label", c.source ?? 'linear');
199
214
  ctx.log(`Cleared a stale lock and cancelled ${c.card}.`);
200
215
  }
201
216
  else
202
217
  ctx.log('Cleared a stale lock.');
203
218
  clearCurrent(ctx.configDir);
204
219
  }
205
- const realDeps = (ctx, l) => ({
220
+ const realDeps = (ctx) => ({
206
221
  ours,
207
222
  kill: (pid, signal) => process.kill(pid, signal),
208
223
  sleep,
209
- cancelCard: (card, label) => cancelCard(l(), ctx.configDir, card, label),
224
+ cancelCard: async (card, label, source) => {
225
+ try {
226
+ return await cancelCard(trackersFor(ctx.configDir)(source), ctx.configDir, card, label, source);
227
+ }
228
+ catch {
229
+ return false;
230
+ }
231
+ },
210
232
  });
211
233
  async function startCmd(ctx) {
212
234
  if (!Object.keys(readAgentsConfig(ctx.configDir).workflows).length) {
213
235
  ctx.log('No workflows yet. Create one first: vsf agents workflow add.');
214
236
  return 1;
215
237
  }
216
- try {
217
- await linear(ctx.configDir).viewer();
218
- }
219
- catch (err) {
220
- const msg = err.message;
221
- ctx.log(/^No Linear API key/.test(msg) ? msg : `Linear did not accept the key: ${msg}. Run vsf agents key.`);
222
- return 1;
238
+ const trackers = trackersFor(ctx.configDir);
239
+ for (const kind of trackersInUse(readAgentsConfig(ctx.configDir))) {
240
+ try {
241
+ await trackers(kind).viewer();
242
+ }
243
+ catch (err) {
244
+ const msg = err.message;
245
+ const flag = kind === 'linear' ? '' : ` --${kind}`;
246
+ ctx.log(/^No .* API key/.test(msg) ? msg : `${TRACKERS[kind].name} did not accept the key: ${msg}. Run vsf agents key${flag}.`);
247
+ return 1;
248
+ }
223
249
  }
224
250
  setPaused(ctx.configDir, false);
225
251
  if (!runnerService().start(ctx.home, program(ctx), logPath(ctx.configDir))) {
@@ -233,7 +259,7 @@ async function stopCmd(ctx) {
233
259
  // Stopping the runner may have stopped the pass; finish or tidy up whatever the lock still names.
234
260
  const c = readCurrent(ctx.configDir);
235
261
  if (c)
236
- await cancelCurrent(ctx, c, realDeps(ctx, () => linear(ctx.configDir)));
262
+ await cancelCurrent(ctx, c, realDeps(ctx));
237
263
  ctx.log('Runner stopped.');
238
264
  return 0;
239
265
  }
@@ -266,7 +292,7 @@ const status = guard(async (args, ctx) => {
266
292
  return 0;
267
293
  }
268
294
  const working = current && !current.card
269
- ? ' Checking Linear now.'
295
+ ? ' Checking now.'
270
296
  : current
271
297
  ? ` Working on ${current.card ?? 'a card'}${current.title ? ` "${current.title}"` : ''} (${[current.workflow, current.step, current.round !== undefined ? `round ${current.round + 1}` : '', `${current.elapsedMinutes}m`].filter(Boolean).join(', ')}).`
272
298
  : '';
@@ -291,7 +317,7 @@ const start = guard(async (args, ctx) => {
291
317
  return p;
292
318
  const code = await startCmd(ctx);
293
319
  if (!code)
294
- ctx.log('Runner started. It checks Linear every minute for cards that start your workflows.');
320
+ ctx.log('Runner started. Every minute it checks for cards and schedules that start your workflows.');
295
321
  return code;
296
322
  });
297
323
  const stop = guard(async (args, ctx) => {
@@ -353,7 +379,7 @@ const run = guard(async (args, ctx) => {
353
379
  return 2;
354
380
  }
355
381
  }
356
- const code = await tick({ configDir: ctx.configDir, config, linear: linear(ctx.configDir), log: ctx.log, selfCommand: ctx.selfCommand }, card, { workflow, from });
382
+ const code = await tick({ configDir: ctx.configDir, config, trackers: trackersFor(ctx.configDir), log: ctx.log, selfCommand: ctx.selfCommand }, card, { workflow, from });
357
383
  if (!card && code === 0) {
358
384
  if (isPaused(ctx.configDir))
359
385
  ctx.log('The runner is paused, so nothing was picked up. vsf agents resume, or name a card.');
@@ -378,7 +404,7 @@ const cancel = guard(async (args, ctx) => {
378
404
  ctx.log(`${c.card ?? 'Another card'} is running, not ${card}.`);
379
405
  return 1;
380
406
  }
381
- await cancelCurrent(ctx, c, realDeps(ctx, () => linear(ctx.configDir)));
407
+ await cancelCurrent(ctx, c, realDeps(ctx));
382
408
  return 0;
383
409
  });
384
410
  const retry = guard(async (args, ctx) => {
@@ -394,9 +420,17 @@ const retry = guard(async (args, ctx) => {
394
420
  }
395
421
  const config = readAgentsConfig(ctx.configDir);
396
422
  const names = Object.keys(config.workflows);
397
- const l = linear(ctx.configDir);
398
- const card = await l.card(id);
399
- const startsOn = (n) => config.workflows[n].triggers.some((t) => t.team === card.teamKey);
423
+ let found;
424
+ try {
425
+ found = await findCard(config, trackersFor(ctx.configDir), id);
426
+ }
427
+ catch (err) {
428
+ ctx.log(err.message);
429
+ return 1;
430
+ }
431
+ const { card, source } = found;
432
+ const l = found.tracker;
433
+ const startsOn = (n) => config.workflows[n].triggers.some((t) => isCardTrigger(t) && t.source === source && t.team === card.teamKey);
400
434
  let chosen;
401
435
  if (p.values.workflow) {
402
436
  const r = resolveName(names, p.values.workflow, 'workflow');
@@ -423,7 +457,7 @@ const retry = guard(async (args, ctx) => {
423
457
  }
424
458
  chosen = candidates[0];
425
459
  }
426
- const label = config.workflows[chosen].triggers.find((t) => t.team === card.teamKey).label;
460
+ const label = config.workflows[chosen].triggers.filter(isCardTrigger).find((t) => t.source === source && t.team === card.teamKey).label;
427
461
  const next = card.labels.filter((n) => !isStatusLabel(n));
428
462
  if (!next.includes(label))
429
463
  next.push(label);
@@ -474,7 +508,7 @@ const show = guard(async (args, ctx) => {
474
508
  }
475
509
  let labels = null;
476
510
  try {
477
- labels = (await linear(ctx.configDir).card(id)).labels;
511
+ labels = (await findCard(readAgentsConfig(ctx.configDir), trackersFor(ctx.configDir), id)).card.labels;
478
512
  }
479
513
  catch {
480
514
  // live labels are optional
@@ -646,11 +680,7 @@ const tickCmd = async (_args, ctx) => {
646
680
  const stamped = (l) => ctx.log(`${new Date().toISOString()} ${l}`);
647
681
  try {
648
682
  const config = readAgentsConfig(ctx.configDir);
649
- if (!readLinearKey(ctx.configDir)) {
650
- stamped('No Linear API key. Run vsf agents key.');
651
- return 1;
652
- }
653
- return await tick({ configDir: ctx.configDir, config, linear: linear(ctx.configDir), log: stamped, selfCommand: ctx.selfCommand });
683
+ return await tick({ configDir: ctx.configDir, config, trackers: trackersFor(ctx.configDir), log: stamped, selfCommand: ctx.selfCommand });
654
684
  }
655
685
  catch (err) {
656
686
  stamped(`Could not run the check: ${err.message}`);
@@ -681,7 +711,7 @@ export async function controlChecks(ctx) {
681
711
  const checks = [];
682
712
  const state = runnerState(ctx);
683
713
  checks.push(state === 'running'
684
- ? { name: 'agent runner', status: 'ok', detail: 'polling Linear every minute' }
714
+ ? { name: 'agent runner', status: 'ok', detail: 'checking every minute' }
685
715
  : state === 'paused'
686
716
  ? { name: 'agent runner', status: 'warn', detail: 'paused', fix: 'vsf agents resume' }
687
717
  : { name: 'agent runner', status: 'warn', detail: 'not running', fix: 'vsf agents start' });
package/dist/cron.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ export interface Schedule {
2
+ sets: Set<number>[];
3
+ anyDom: boolean;
4
+ anyDow: boolean;
5
+ }
6
+ export declare function parseCron(schedule: string): Schedule | null;
7
+ export declare function matches(s: Schedule, d: Date): boolean;
8
+ export declare const CATCH_UP_MS: number;
9
+ export declare function lastDue(s: Schedule, since: Date, now: Date): Date | null;
package/dist/cron.js ADDED
@@ -0,0 +1,66 @@
1
+ // Five field cron schedules: minute hour day-of-month month day-of-week, each
2
+ // a list of *, numbers, ranges and steps (*/15, 1-5, 0,30). Sunday is 0 or 7.
3
+ // When both day fields are restricted, either may match, as in classic cron.
4
+ const FIELDS = [
5
+ [0, 59],
6
+ [0, 23],
7
+ [1, 31],
8
+ [1, 12],
9
+ [0, 7],
10
+ ];
11
+ function field(text, [min, max]) {
12
+ const out = new Set();
13
+ for (const part of text.split(',')) {
14
+ const m = /^(\*|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/.exec(part);
15
+ if (!m)
16
+ return null;
17
+ const step = m[4] ? Number(m[4]) : 1;
18
+ const lo = m[1] === '*' ? min : Number(m[2]);
19
+ const hi = m[1] === '*' ? max : m[3] ? Number(m[3]) : m[4] ? max : lo;
20
+ if (step < 1 || lo < min || hi > max || lo > hi)
21
+ return null;
22
+ for (let v = lo; v <= hi; v += step)
23
+ out.add(v);
24
+ }
25
+ return out;
26
+ }
27
+ export function parseCron(schedule) {
28
+ const parts = schedule.trim().split(/\s+/);
29
+ if (parts.length !== 5)
30
+ return null;
31
+ const sets = [];
32
+ for (let i = 0; i < 5; i++) {
33
+ const s = field(parts[i], FIELDS[i]);
34
+ if (!s)
35
+ return null;
36
+ sets.push(s);
37
+ }
38
+ if (sets[4].has(7))
39
+ sets[4].add(0);
40
+ return { sets, anyDom: parts[2] === '*', anyDow: parts[4] === '*' };
41
+ }
42
+ export function matches(s, d) {
43
+ const [min, hour, dom, month, dow] = s.sets;
44
+ if (!min.has(d.getMinutes()) || !hour.has(d.getHours()) || !month.has(d.getMonth() + 1))
45
+ return false;
46
+ const domOk = dom.has(d.getDate());
47
+ const dowOk = dow.has(d.getDay());
48
+ if (s.anyDom || s.anyDow)
49
+ return domOk && dowOk;
50
+ return domOk || dowOk;
51
+ }
52
+ const MINUTE = 60_000;
53
+ // How far back a missed schedule is still made up for, e.g. after the runner
54
+ // was busy or the machine was off. Older misses are skipped, not queued.
55
+ export const CATCH_UP_MS = 24 * 60 * MINUTE;
56
+ // The latest minute after `since` and up to `now` that the schedule matches,
57
+ // or null. Only the latest counts: several missed runs fire once.
58
+ export function lastDue(s, since, now) {
59
+ const floor = Math.max(since.getTime(), now.getTime() - CATCH_UP_MS);
60
+ const t = new Date(now);
61
+ t.setSeconds(0, 0);
62
+ for (; t.getTime() > floor; t.setTime(t.getTime() - MINUTE))
63
+ if (matches(s, t))
64
+ return new Date(t);
65
+ return null;
66
+ }
package/dist/index.js CHANGED
@@ -4,12 +4,16 @@ import { homedir } from 'node:os';
4
4
  import { basename, dirname, join, resolve, sep } from 'node:path';
5
5
  import { parseArgs } from 'node:util';
6
6
  import { fileURLToPath } from 'node:url';
7
- import { AGENTS, STEPS, TEMPLATES, isProducer, agentsDir, describeSteps, describeTriggers, describeWorkflow, stepEnabled, visibleAgents, readAgentsConfig, writeAgentsConfig, DEFAULTS } from "./config.js";
7
+ import { AGENTS, STEPS, TEMPLATES, isProducer, agentsDir, describeSteps, describeTrigger, describeTriggers, describeWorkflow, stepEnabled, visibleAgents, readAgentsConfig, writeAgentsConfig, DEFAULTS } from "./config.js";
8
8
  import { controlChecks, controlCommands, controlUsage } from "./control.js";
9
- import { keyStore, looksLikeLinearKey, readLinearKey, storeLinearKey } from "./secrets.js";
9
+ import { keyStore, looksLikeLinearKey, readLinearKey, readSecret, storeLinearKey, storeSecret } from "./secrets.js";
10
+ import { TRACKERS } from "./tracker.js";
11
+ import { createVoidTrack } from "./voidtrack.js";
12
+ import { parseCron } from "./cron.js";
13
+ import { trackersInUse } from "./control.js";
10
14
  import { runnerService } from "./service.js";
11
15
  import { createLinear } from "./linear.js";
12
- import { duplicateTriggerWarnings, moveKey, parseTrigger, renameKey, sameTrigger, validateConfig, validateWorkflow, workflowsUsing } from "./manage.js";
16
+ import { TRIGGER_FORMS, duplicateTriggerWarnings, moveKey, parseTrigger, renameKey, sameTrigger, validateConfig, validateWorkflow, workflowsUsing } from "./manage.js";
13
17
  import { parseStep, resolveName, stepNames } from "./names.js";
14
18
  import { findRepos } from "./repos.js";
15
19
  import { defaultBranch, sh } from "./git.js";
@@ -81,26 +85,28 @@ async function chooseHomes(ui, configDir, saved) {
81
85
  return homes;
82
86
  }
83
87
  // Checks the key against Linear, and only then stores it.
84
- async function verifyAndStore(configDir, key, info, warn) {
88
+ async function verifyAndStore(configDir, key, info, warn, kind = 'linear') {
89
+ const name = TRACKERS[kind].name;
85
90
  try {
86
- const me = await createLinear(key).viewer();
87
- if (!storeLinearKey(configDir, key)) {
88
- warn(`Linear accepted the key but it could not be stored in ${keyStore()}.`);
91
+ const me = await (kind === 'voidtrack' ? createVoidTrack(key) : createLinear(key)).viewer();
92
+ if (!(kind === 'voidtrack' ? storeSecret(configDir, 'voidtrack', key) : storeLinearKey(configDir, key))) {
93
+ warn(`${name} accepted the key but it could not be stored in ${keyStore()}.`);
89
94
  return null;
90
95
  }
91
- info(`Linear connected as ${me.name}. Key stored in ${keyStore()}.`);
96
+ info(`${name} connected as ${me.name}. Key stored in ${keyStore()}.`);
92
97
  return me.name;
93
98
  }
94
99
  catch (err) {
95
- warn(`Linear did not accept that key: ${err.message}. Nothing was stored.`);
100
+ warn(`${name} did not accept that key: ${err.message}. Nothing was stored.`);
96
101
  return null;
97
102
  }
98
103
  }
99
104
  // Asks for the key, checks it against Linear, and only then stores it.
100
- async function setupKey(ui, configDir) {
101
- ui.info('Agents need a Linear personal API key (Linear > Settings > Security & access > API keys).');
102
- const key = await ui.secret('Paste your Linear API key', (v) => (looksLikeLinearKey(v.trim()) ? undefined : 'Linear keys start with lin_api_'));
103
- return verifyAndStore(configDir, key.trim(), ui.info, ui.warn);
105
+ async function setupKey(ui, configDir, kind = 'linear') {
106
+ const { name, keyHint } = TRACKERS[kind];
107
+ ui.info(`Agents need a ${name} API key (${keyHint}).`);
108
+ const key = await ui.secret(`Paste your ${name} API key`, (v) => kind === 'linear' ? (looksLikeLinearKey(v.trim()) ? undefined : 'Linear keys start with lin_api_') : v.trim() ? undefined : 'Required');
109
+ return verifyAndStore(configDir, key.trim(), ui.info, ui.warn, kind);
104
110
  }
105
111
  async function currentLinearUser(configDir) {
106
112
  try {
@@ -277,11 +283,22 @@ async function promptWorkflow(ui, config, teams, current = {}, edit = false) {
277
283
  if (!triggers) {
278
284
  triggers = [];
279
285
  do {
280
- const team = teams.length
281
- ? await ui.select(triggers.length ? 'Also start for cards in which Linear team?' : 'Starts when a card in which Linear team...', teams.map((t) => ({ value: t.key, label: `${t.key} ${t.name}`, hint: t.mine ? 'your team' : undefined })), myTeams.length === 1 ? myTeams[0].key : undefined)
282
- : (await ui.text('Starts when a card in which Linear team key...', { validate: (v) => (v.trim() ? undefined : 'Required') })).trim().toUpperCase();
286
+ const source = await ui.select(triggers.length ? 'Also start it when...' : 'Starts when...', [
287
+ { value: 'linear', label: 'A Linear card gets a label' },
288
+ { value: 'voidtrack', label: 'A VoidTrack card gets a label' },
289
+ { value: 'cron', label: 'On a schedule', hint: 'runs on a standing task, no card' },
290
+ ], 'linear');
291
+ if (source === 'cron') {
292
+ const schedule = (await ui.text('Cron schedule (minute hour day month weekday)', { initial: '0 5 * * 1', validate: (v) => (parseCron(v) ? undefined : 'Five fields, like 0 5 * * 1') })).trim();
293
+ const task = (await ui.text('What should each run work on?', { placeholder: 'Update the dependencies and fix what breaks', validate: (v) => (v.trim() ? undefined : 'Required') })).trim();
294
+ triggers.push({ source: 'cron', schedule, task });
295
+ continue;
296
+ }
297
+ const team = source === 'linear' && teams.length
298
+ ? await ui.select('A card in which Linear team...', teams.map((t) => ({ value: t.key, label: `${t.key} ${t.name}`, hint: t.mine ? 'your team' : undefined })), myTeams.length === 1 ? myTeams[0].key : undefined)
299
+ : (await ui.text(`A card in which ${TRACKERS[source].name} team key...`, { validate: (v) => (v.trim() ? undefined : 'Required') })).trim().toUpperCase();
283
300
  const label = (await ui.text('...gets which label?', { initial: 'vs-plan', validate: (v) => (v.trim() ? undefined : 'Required') })).trim();
284
- triggers.push({ source: 'linear', team, label });
301
+ triggers.push({ source, team, label });
285
302
  } while (await ui.confirm('Add another way to start this workflow?', false));
286
303
  }
287
304
  let steps = w.steps?.length && (await keep('steps', describeSteps(w.steps))) ? w.steps : undefined;
@@ -341,7 +358,7 @@ async function setupWorkflows(ui, config, teams, log) {
341
358
  // One entry per command, in the order help lists them.
342
359
  const USAGE = [
343
360
  ['workflows [--json]', 'List workflows in the order they match'],
344
- ['workflow add [NAME] [--trigger TEAM:LABEL]... [--steps dev,qa | --template build] [--project P] [--rounds N]', 'Create a workflow; prompts for what is missing'],
361
+ ['workflow add [NAME] [--trigger TEAM:LABEL]... [--steps dev,qa | --template build] [--project P] [--rounds N]', 'Create a workflow; prompts for what is missing. A trigger is TEAM:LABEL (Linear), voidtrack:TEAM:LABEL or "cron:SCHEDULE:TASK"'],
345
362
  ['workflow show NAME [--json]', 'Show one workflow'],
346
363
  ['workflow edit NAME [--trigger T:L]... [--add-trigger T:L] [--remove-trigger T:L] [--steps S | --template T] [--project P] [--rounds N]', 'Change a workflow; no flags asks'],
347
364
  ['workflow rename OLD NEW', 'Rename a workflow, keeping its place'],
@@ -356,7 +373,7 @@ const USAGE = [
356
373
  ['homes [--json]', 'Show where the agents are linked'],
357
374
  ['link DIR [--claude | --codex]', 'Link the agents into a config folder'],
358
375
  ['unlink DIR', 'Remove the agents from a config folder'],
359
- ['key', 'Store or replace the Linear API key (reads stdin without a terminal)'],
376
+ ['key [--voidtrack]', 'Store or replace the Linear or VoidTrack API key (reads stdin without a terminal)'],
360
377
  ['help [COMMAND]', 'Show this list, or one command'],
361
378
  ];
362
379
  const usageOf = (name) => USAGE.filter(([c]) => c === name || c.startsWith(`${name} `))
@@ -441,7 +458,7 @@ function readWorkflowFlags(v, config) {
441
458
  for (const t of v.trigger) {
442
459
  const parsed = parseTrigger(t);
443
460
  if (!parsed)
444
- return { error: `Bad trigger ${t}. Use TEAM:LABEL, like ENG:vs-plan.`, code: 2 };
461
+ return { error: `Bad trigger ${t}. Use ${TRIGGER_FORMS}.`, code: 2 };
445
462
  flags.triggers.push(parsed);
446
463
  }
447
464
  }
@@ -590,7 +607,7 @@ const workflowSubs = {
590
607
  else {
591
608
  ctx.log(`Workflow ${name}: ${describeWorkflow(w)}`);
592
609
  for (const t of w.triggers)
593
- ctx.log(`Trigger: ${t.team} + ${t.label}`);
610
+ ctx.log(`Trigger: ${describeTrigger(t)}${t.source === 'cron' ? `, task: ${t.task}` : ''}`);
594
611
  ctx.log(`Steps: ${describeSteps(w.steps)}`);
595
612
  ctx.log(`Project: ${w.project}`);
596
613
  ctx.log(`Rounds: ${w.maxRounds}`);
@@ -637,7 +654,7 @@ const workflowSubs = {
637
654
  for (const t of (v['add-trigger'] ?? [])) {
638
655
  const parsed = parseTrigger(t);
639
656
  if (!parsed) {
640
- ctx.log(`Bad trigger ${t}. Use TEAM:LABEL, like ENG:vs-plan.`);
657
+ ctx.log(`Bad trigger ${t}. Use ${TRIGGER_FORMS}.`);
641
658
  return 2;
642
659
  }
643
660
  if (!next.triggers.some((x) => sameTrigger(x, parsed)))
@@ -1053,7 +1070,11 @@ const agents = {
1053
1070
  await setupProjects(ui, config, ctx.log);
1054
1071
  await setupWorkflows(ui, config, await linear(ctx.configDir).teams(), ctx.log);
1055
1072
  writeAgentsConfig(ctx.configDir, config);
1056
- if (Object.keys(config.workflows).length && (await ui.confirm('Start the background runner now? It checks Linear every minute for cards that start your workflows.'))) {
1073
+ if (trackersInUse(config).includes('voidtrack') && !readSecret(ctx.configDir, 'voidtrack')) {
1074
+ if (!(await setupKey(ui, ctx.configDir, 'voidtrack')))
1075
+ ctx.log('VoidTrack is not connected yet. Run vsf agents key --voidtrack.');
1076
+ }
1077
+ if (Object.keys(config.workflows).length && (await ui.confirm('Start the background runner now? Every minute it checks for cards and schedules that start your workflows.'))) {
1057
1078
  const ok = runnerService().start(ctx.home, [...ctx.selfCommand, 'agents', 'tick'], join(agentsDir(ctx.configDir), 'runner.log'));
1058
1079
  ctx.log(ok ? 'Runner started.' : 'Could not start the runner. Try vsf agents start.');
1059
1080
  }
@@ -1077,12 +1098,19 @@ const agents = {
1077
1098
  ? { name: `agents in ${homeLabel(h)}`, status: 'ok', detail: `${wanted.length} linked` }
1078
1099
  : { name: `agents in ${homeLabel(h)}`, status: 'warn', detail: `missing ${missing.join(', ')}`, fix: `vsf agents link ${tilde(h.dir)}` });
1079
1100
  }
1080
- try {
1081
- const me = await linear(ctx.configDir).viewer();
1082
- checks.push({ name: 'Linear', status: 'ok', detail: `connected as ${me.name}` });
1083
- }
1084
- catch (err) {
1085
- checks.push({ name: 'Linear', status: 'fail', detail: err.message, fix: 'vsf agents key' });
1101
+ const inUse = trackersInUse(saved);
1102
+ for (const kind of inUse.length ? inUse : ['linear']) {
1103
+ const { name } = TRACKERS[kind];
1104
+ try {
1105
+ const key = readSecret(ctx.configDir, kind);
1106
+ if (!key)
1107
+ throw new Error(`No ${name} API key`);
1108
+ const me = await (kind === 'voidtrack' ? createVoidTrack(key) : createLinear(key)).viewer();
1109
+ checks.push({ name, status: 'ok', detail: `connected as ${me.name}` });
1110
+ }
1111
+ catch (err) {
1112
+ checks.push({ name, status: 'fail', detail: err.message, fix: `vsf agents key${kind === 'linear' ? '' : ` --${kind}`}` });
1113
+ }
1086
1114
  }
1087
1115
  const workflows = Object.entries(saved.workflows);
1088
1116
  const broken = workflows.filter(([, c]) => !saved.projects[c.project] || !isRepo(saved.projects[c.project].path)).map(([n]) => n);
@@ -1134,21 +1162,26 @@ const agents = {
1134
1162
  link: homeCommands.link,
1135
1163
  unlink: homeCommands.unlink,
1136
1164
  async key(args, ctx) {
1137
- const p = begin('key', args, {}, ctx);
1165
+ const p = begin('key', args, { voidtrack: { type: 'boolean' } }, ctx);
1138
1166
  if (typeof p === 'number')
1139
1167
  return p;
1140
1168
  if (p.positionals.length) {
1141
1169
  ctx.log('Keys are not accepted on the command line. Pipe the key in instead.');
1142
1170
  return 2;
1143
1171
  }
1172
+ const kind = p.values.voidtrack ? 'voidtrack' : 'linear';
1144
1173
  if (interactive())
1145
- return (await setupKey(ctx.ui, ctx.configDir)) ? 0 : 1;
1174
+ return (await setupKey(ctx.ui, ctx.configDir, kind)) ? 0 : 1;
1146
1175
  const key = readFileSync(0, 'utf8').trim();
1147
- if (!looksLikeLinearKey(key)) {
1176
+ if (kind === 'linear' && !looksLikeLinearKey(key)) {
1148
1177
  ctx.log('Linear keys start with lin_api_');
1149
1178
  return 2;
1150
1179
  }
1151
- return (await verifyAndStore(ctx.configDir, key, ctx.log, ctx.log)) ? 0 : 1;
1180
+ if (!key) {
1181
+ ctx.log('No key on stdin.');
1182
+ return 2;
1183
+ }
1184
+ return (await verifyAndStore(ctx.configDir, key, ctx.log, ctx.log, kind)) ? 0 : 1;
1152
1185
  },
1153
1186
  async help(args, ctx) {
1154
1187
  const want = args.join(' ').trim();
package/dist/linear.d.ts CHANGED
@@ -20,7 +20,7 @@ export declare function createLinear(apiKey: string): {
20
20
  name: string;
21
21
  mine: boolean;
22
22
  }[]>;
23
- myCardsWithLabel(label: string): Promise<Card[]>;
23
+ myCardsWithLabel(label: string, team: string): Promise<Card[]>;
24
24
  card(identifier: string): Promise<Card>;
25
25
  comments(identifier: string): Promise<{
26
26
  body: string;
package/dist/linear.js CHANGED
@@ -79,14 +79,14 @@ export function createLinear(apiKey) {
79
79
  const all = d?.teams?.nodes ?? [];
80
80
  return all.map((t) => ({ ...t, mine: mine.has(t.key) })).sort((a, b) => Number(b.mine) - Number(a.mine));
81
81
  },
82
- // My cards carrying the trigger label.
83
- async myCardsWithLabel(label) {
82
+ // My cards in the team carrying the trigger label.
83
+ async myCardsWithLabel(label, team) {
84
84
  const d = await gql('myCards', `query MyCards($label: String!) {
85
85
  issues(first: 20, orderBy: createdAt, filter: { assignee: { isMe: { eq: true } }, labels: { name: { eq: $label } } }) {
86
86
  nodes { ${CARD_FIELDS} }
87
87
  }
88
88
  }`, { label });
89
- return (d?.issues?.nodes ?? []).map(mapCard);
89
+ return (d?.issues?.nodes ?? []).map(mapCard).filter((c) => c.teamKey === team);
90
90
  },
91
91
  async card(identifier) {
92
92
  const d = await gql('card', `query Card($id: String!) { issue(id: $id) { ${CARD_FIELDS} } }`, { id: identifier });
package/dist/manage.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type AgentsConfig, type Trigger, type Workflow } from './config.ts';
2
2
  export declare function validateWorkflow(config: Pick<AgentsConfig, 'enabled' | 'projects'>, workflow: Workflow): string[];
3
+ export declare const TRIGGER_FORMS = "TEAM:LABEL (Linear), voidtrack:TEAM:LABEL, or cron:SCHEDULE:TASK";
3
4
  export declare function parseTrigger(input: string): Trigger | null;
4
5
  export declare const sameTrigger: (a: Trigger, b: Trigger) => boolean;
5
6
  export declare function duplicateTriggerWarnings(workflows: Record<string, Workflow>, name: string, workflow: Workflow): string[];