@voidlabs.gg/voidshift-agents 0.4.3 → 0.4.5

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
@@ -77,8 +86,33 @@ vsf agents workflow add|edit|show|rename|move|rm
77
86
  vsf agents projects | project add|edit|show|rename|rm
78
87
  vsf agents homes | link DIR # where the agents are installed
79
88
  vsf agents clean # old worktrees and run logs
89
+ vsf agents validate [DIR] # check the config and agent folders
90
+ ```
91
+
92
+ A workflow can also be a folder you write by hand,
93
+ `~/.config/voidshift/agents/defs/NAME/agent.json`, with its prompt files next to
94
+ it. Steps are the built-in ids above or your own:
95
+
96
+ ```json
97
+ {
98
+ "triggers": [{ "source": "cron", "schedule": "0 7 * * 1-5", "task": "Morning brief" }],
99
+ "steps": [{ "id": "brief", "prompt": "brief.md", "output": "none", "model": "haiku", "timeoutMinutes": 10 }],
100
+ "maxRounds": 1,
101
+ "project": "tether-relay",
102
+ "writeBack": { "labels": true, "state": null, "comment": true }
103
+ }
80
104
  ```
81
105
 
106
+ A step's `output` is `plan`, `verdict`, `commits`, `publish` or `none`; a failed
107
+ `verdict` sends the work back to the nearest `plan` or `commits` step. `engine`,
108
+ `model` and `timeoutMinutes` fall back to the config, and `write` to true only
109
+ for `commits`. A `publish` step takes no prompt; the others take one `prompt`
110
+ file or `skill`, which must ask for a `RESULT:` line (`plan`, `commits`) or a
111
+ `VERDICT:` line (`verdict`). `writeBack` applies to card runs: `state` moves a
112
+ verified card there (`null` never moves it), `labels: false` only takes the
113
+ trigger label off, and `comment: false` skips the closing comment. Edit the file,
114
+ then `vsf agents validate`.
115
+
82
116
  ### memory
83
117
 
84
118
  A folder of markdown notes, one topic per note, with an `INDEX.md` holding one
@@ -99,6 +133,20 @@ vsf memory on | off # turn recall on or off
99
133
  Setup creates a vault at `~/memory` or uses one you already have. Codex asks you
100
134
  to trust the recall hook once: run `/hooks` in Codex.
101
135
 
136
+ ### Your profile
137
+
138
+ Every agent run carries your profile, on any machine, without touching your
139
+ Claude or Codex folder. `vsf agents profile init` creates
140
+ `~/.config/voidshift/agents/profile/`:
141
+
142
+ - `RULES.md` is added to every step's prompt (style, commit rules, tone).
143
+ - `claude-settings.json` is passed to Claude runs with `--settings`, so hooks
144
+ come along. `{{profile}}` stands for the folder, so hook scripts can live in it.
145
+
146
+ Setup also asks what the runner does to agent commits before it pushes them:
147
+ remove AI co-author lines, and add `Signed-off-by` for repos that ask for a
148
+ DCO. Only the new commits of that step are rewritten, never pushed ones.
149
+
102
150
  ## What to know
103
151
 
104
152
  - **Your own logins.** Agents run the official `claude` and `codex` CLIs you
package/dist/config.d.ts CHANGED
@@ -1,32 +1,69 @@
1
1
  import type { Engine } from './engine.ts';
2
2
  export type StepId = 'vs-plan' | 'vs-plan-review' | 'vs-publish' | 'vs-dev' | 'vs-qa';
3
- export type Output = 'plan' | 'verdict' | 'commits' | 'publish';
3
+ export type Output = 'plan' | 'verdict' | 'commits' | 'publish' | 'none';
4
+ export declare const OUTPUTS: Output[];
4
5
  export declare const STEPS: Record<StepId, {
5
6
  label: string;
6
7
  output: Output;
7
8
  hint: string;
9
+ write: boolean;
10
+ role: 'dev' | 'review';
8
11
  }>;
9
- export declare const isProducer: (s: StepId) => boolean;
12
+ export interface StepDef {
13
+ id: string;
14
+ label?: string;
15
+ skill?: string;
16
+ prompt?: string;
17
+ output: Output;
18
+ engine?: Engine;
19
+ model?: string;
20
+ timeoutMinutes?: number;
21
+ write?: boolean;
22
+ }
23
+ export type Step = StepId | StepDef;
24
+ export declare const STEP_ID: RegExp;
25
+ export declare const isBuiltin: (id: string) => id is StepId;
26
+ export declare const outputOf: (s: Step) => Output;
27
+ export declare const stepId: (s: Step) => string;
28
+ export declare const stepLabel: (s: Step) => string;
29
+ export declare const stepIdLabel: (id: string) => string;
30
+ export declare const isProducer: (s: Step) => boolean;
10
31
  export declare const AGENTS: Record<string, {
11
32
  label: string;
12
33
  hint: string;
13
34
  }>;
14
35
  export declare const visibleAgents: () => string[];
15
36
  export declare const stepEnabled: (step: StepId, enabled: string[]) => boolean;
16
- export interface Trigger {
17
- source: 'linear';
37
+ export type Tracker = 'linear' | 'voidtrack';
38
+ export interface CardTrigger {
39
+ source: Tracker;
18
40
  team: string;
19
41
  label: string;
20
42
  }
43
+ export interface CronTrigger {
44
+ source: 'cron';
45
+ schedule: string;
46
+ task: string;
47
+ }
48
+ export type Trigger = CardTrigger | CronTrigger;
49
+ export declare const isCardTrigger: (t: Trigger) => t is CardTrigger;
50
+ export declare function triggerSpec(t: Trigger): string;
51
+ export declare function describeTrigger(t: Trigger): string;
21
52
  export interface Project {
22
53
  path: string;
23
54
  base?: string;
24
55
  }
56
+ export interface WriteBack {
57
+ labels?: boolean;
58
+ state?: string | null;
59
+ comment?: boolean;
60
+ }
25
61
  export interface Workflow {
26
62
  triggers: Trigger[];
27
- steps: StepId[];
63
+ steps: Step[];
28
64
  maxRounds: number;
29
65
  project: string;
66
+ writeBack?: WriteBack;
30
67
  }
31
68
  export interface AgentsConfig {
32
69
  engine: Engine;
@@ -43,6 +80,11 @@ export interface AgentsConfig {
43
80
  dir: string;
44
81
  }[];
45
82
  enabled: string[];
83
+ commits?: {
84
+ signOff?: boolean;
85
+ dropAiTrailers?: boolean;
86
+ };
87
+ folders?: Record<string, string>;
46
88
  }
47
89
  export declare const TEMPLATES: Record<string, {
48
90
  steps: StepId[];
@@ -52,9 +94,26 @@ export declare const DEFAULTS: AgentsConfig;
52
94
  export declare const agentsDir: (configDir: string) => string;
53
95
  export declare const hasWork: (workflow: Pick<Workflow, "steps">) => boolean;
54
96
  export declare const hasProducer: (workflow: Pick<Workflow, "steps">) => boolean;
55
- export declare const describeSteps: (steps: StepId[]) => string;
97
+ export declare const describeSteps: (steps: Step[]) => string;
56
98
  export declare const describeTriggers: (triggers: Trigger[]) => string;
57
99
  export declare const describeWorkflow: (c: Workflow) => string;
58
100
  export declare function migrate(saved: Record<string, any>): Partial<AgentsConfig>;
101
+ export declare const defsDir: (configDir: string) => string;
102
+ export declare const AGENT_FILE = "agent.json";
103
+ export declare function agentFolders(configDir: string): string[];
104
+ export declare function readAgentFolder(dir: string): Workflow;
59
105
  export declare function readAgentsConfig(configDir: string): AgentsConfig;
60
106
  export declare function writeAgentsConfig(configDir: string, config: AgentsConfig): void;
107
+ export interface ResolvedStep {
108
+ id: string;
109
+ label: string;
110
+ output: Output;
111
+ engine: Engine;
112
+ model?: string;
113
+ timeoutMs: number;
114
+ write: boolean;
115
+ prompt: () => string;
116
+ builtin?: StepId;
117
+ }
118
+ export declare function stepFile(step: StepDef, dir?: string): string | null;
119
+ export declare function resolveStep(step: Step, config: Pick<AgentsConfig, 'engine' | 'models' | 'timeoutMinutes'>, dir?: string): ResolvedStep;
package/dist/config.js CHANGED
@@ -1,14 +1,25 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { basename, join } from 'node:path';
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { basename, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ export const OUTPUTS = ['plan', 'verdict', 'commits', 'publish', 'none'];
5
+ // The built-in steps, the vs-* template. `write` is whether the agent may
6
+ // edit files; `role` picks the model from config.models.
3
7
  export const STEPS = {
4
- 'vs-plan': { label: 'Plan', output: 'plan', hint: 'writes a plan and posts it to the card' },
5
- 'vs-plan-review': { label: 'Plan review', output: 'verdict', hint: 'checks the plan; on FIX the workflow goes back to Plan' },
6
- 'vs-publish': { label: 'Publish', output: 'publish', hint: 'creates and pushes the branch for the card' },
7
- 'vs-dev': { label: 'Dev', output: 'commits', hint: 'implements the plan, commits, opens a draft PR' },
8
- 'vs-qa': { label: 'QA', output: 'verdict', hint: 'CI, then code review, then a usability check; on FIX back to Dev' },
8
+ 'vs-plan': { label: 'Plan', output: 'plan', hint: 'writes a plan and posts it to the card', write: false, role: 'dev' },
9
+ 'vs-plan-review': { label: 'Plan review', output: 'verdict', hint: 'checks the plan; on FIX the workflow goes back to Plan', write: false, role: 'review' },
10
+ 'vs-publish': { label: 'Publish', output: 'publish', hint: 'creates and pushes the branch for the card', write: false, role: 'dev' },
11
+ 'vs-dev': { label: 'Dev', output: 'commits', hint: 'implements the plan, commits, opens a draft PR', write: true, role: 'dev' },
12
+ 'vs-qa': { label: 'QA', output: 'verdict', hint: 'CI, then code review, then a usability check; on FIX back to Dev', write: true, role: 'review' },
9
13
  };
14
+ // What a custom step id may look like.
15
+ export const STEP_ID = /^[a-z0-9][a-z0-9-]*$/;
16
+ export const isBuiltin = (id) => Object.hasOwn(STEPS, id);
17
+ export const outputOf = (s) => (typeof s === 'string' ? STEPS[s].output : s.output);
18
+ export const stepId = (s) => (typeof s === 'string' ? s : s.id);
19
+ export const stepLabel = (s) => (typeof s === 'string' ? STEPS[s]?.label ?? s : s.label ?? s.id);
20
+ export const stepIdLabel = (id) => (isBuiltin(id) ? STEPS[id].label : id);
10
21
  // Steps a failed verdict can send work back to.
11
- export const isProducer = (s) => STEPS[s].output === 'plan' || STEPS[s].output === 'commits';
22
+ export const isProducer = (s) => outputOf(s) === 'plan' || outputOf(s) === 'commits';
12
23
  // The agents in the pack, as setup describes them.
13
24
  export const AGENTS = {
14
25
  'vs-plan': { label: 'Plan', hint: 'plans a card against the code' },
@@ -19,6 +30,19 @@ export const AGENTS = {
19
30
  };
20
31
  export const visibleAgents = () => Object.keys(AGENTS);
21
32
  export const stepEnabled = (step, enabled) => enabled.includes(step);
33
+ export const isCardTrigger = (t) => t.source !== 'cron';
34
+ // The form `--trigger` takes: "ENG:vs-plan" (Linear), "voidtrack:ORG:vs-start",
35
+ // or "cron:0 5 * * 1:Update the dependencies".
36
+ export function triggerSpec(t) {
37
+ if (t.source === 'cron')
38
+ return `cron:${t.schedule}:${t.task}`;
39
+ return t.source === 'voidtrack' ? `voidtrack:${t.team}:${t.label}` : `${t.team}:${t.label}`;
40
+ }
41
+ export function describeTrigger(t) {
42
+ if (t.source === 'cron')
43
+ return `cron ${t.schedule}`;
44
+ return `${t.source === 'voidtrack' ? 'VoidTrack ' : ''}${t.team} + ${t.label}`;
45
+ }
22
46
  // Step lists to start a workflow from.
23
47
  export const TEMPLATES = {
24
48
  build: { steps: ['vs-dev', 'vs-qa'], maxRounds: 3 },
@@ -37,10 +61,10 @@ export const DEFAULTS = {
37
61
  };
38
62
  export const agentsDir = (configDir) => join(configDir, 'agents');
39
63
  const configPath = (configDir) => join(agentsDir(configDir), 'config.json');
40
- export const hasWork = (workflow) => workflow.steps.some((s) => STEPS[s].output === 'commits');
64
+ export const hasWork = (workflow) => workflow.steps.some((s) => outputOf(s) === 'commits');
41
65
  export const hasProducer = (workflow) => workflow.steps.some(isProducer);
42
- export const describeSteps = (steps) => steps.map((s) => STEPS[s].label).join(' → ');
43
- export const describeTriggers = (triggers) => triggers.map((t) => `${t.team} + ${t.label}`).join(', ');
66
+ export const describeSteps = (steps) => steps.map(stepLabel).join(' → ');
67
+ export const describeTriggers = (triggers) => triggers.map(describeTrigger).join(', ');
44
68
  export const describeWorkflow = (c) => `when ${describeTriggers(c.triggers)}: ${describeSteps(c.steps)} in ${c.project}`;
45
69
  // Step ids that were folded into QA.
46
70
  const RENAMED = { 'vs-qa-review': 'vs-qa', ci: null, 'vs-qa-ui': null };
@@ -85,14 +109,88 @@ export function migrate(saved) {
85
109
  const enabled = [...new Set((saved.enabled ?? []).map((a) => (a in RENAMED ? RENAMED[a] : a)).filter(Boolean))];
86
110
  return { ...rest, ...(models ? { models } : {}), projects, workflows, enabled };
87
111
  }
112
+ // Agent folders: defs/<name>/agent.json, with its prompt files next to it.
113
+ export const defsDir = (configDir) => join(agentsDir(configDir), 'defs');
114
+ export const AGENT_FILE = 'agent.json';
115
+ export function agentFolders(configDir) {
116
+ try {
117
+ return readdirSync(defsDir(configDir), { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => join(defsDir(configDir), e.name));
118
+ }
119
+ catch {
120
+ return [];
121
+ }
122
+ }
123
+ // The workflow in an agent folder. Throws when agent.json is missing or not JSON.
124
+ export function readAgentFolder(dir) {
125
+ const value = JSON.parse(readFileSync(join(dir, AGENT_FILE), 'utf8'));
126
+ if (!value || typeof value !== 'object' || Array.isArray(value))
127
+ throw new Error(`${AGENT_FILE} must be an object`);
128
+ const { triggers, steps, maxRounds, project, writeBack } = value;
129
+ return { triggers, steps, maxRounds, project, ...(writeBack !== undefined ? { writeBack } : {}) };
130
+ }
88
131
  export function readAgentsConfig(configDir) {
89
132
  const path = configPath(configDir);
90
- if (!existsSync(path))
91
- return structuredClone(DEFAULTS);
92
- const saved = migrate(JSON.parse(readFileSync(path, 'utf8')));
93
- return { ...structuredClone(DEFAULTS), ...saved, models: { ...DEFAULTS.models, ...saved.models } };
133
+ let config = structuredClone(DEFAULTS);
134
+ if (existsSync(path)) {
135
+ const saved = migrate(JSON.parse(readFileSync(path, 'utf8')));
136
+ config = { ...config, ...saved, models: { ...DEFAULTS.models, ...saved.models } };
137
+ }
138
+ // A name config.json already uses wins, and a broken folder is skipped:
139
+ // `vsf agents validate` reports both.
140
+ for (const dir of agentFolders(configDir)) {
141
+ const name = basename(dir);
142
+ if (config.workflows[name])
143
+ continue;
144
+ try {
145
+ config.workflows[name] = readAgentFolder(dir);
146
+ config.folders = { ...config.folders, [name]: dir };
147
+ }
148
+ catch {
149
+ // skipped
150
+ }
151
+ }
152
+ return config;
94
153
  }
95
154
  export function writeAgentsConfig(configDir, config) {
96
155
  mkdirSync(agentsDir(configDir), { recursive: true });
97
- writeFileSync(configPath(configDir), JSON.stringify(config, null, 2) + '\n');
156
+ const { folders, ...rest } = config;
157
+ const workflows = Object.fromEntries(Object.entries(config.workflows).filter(([name]) => !folders?.[name]));
158
+ writeFileSync(configPath(configDir), JSON.stringify({ ...rest, workflows }, null, 2) + '\n');
159
+ }
160
+ const packDir = fileURLToPath(new URL('../pack/', import.meta.url));
161
+ const packSkill = (name) => join(packDir, name, 'SKILL.md');
162
+ // The file a custom step's prompt comes from: its prompt file, a pack skill,
163
+ // or a skill folder next to agent.json. Null for a step with neither.
164
+ export function stepFile(step, dir = '.') {
165
+ if (step.prompt)
166
+ return resolve(dir, step.prompt);
167
+ if (!step.skill)
168
+ return null;
169
+ if (STEP_ID.test(step.skill) && existsSync(packSkill(step.skill)))
170
+ return packSkill(step.skill);
171
+ return resolve(dir, step.skill, 'SKILL.md');
172
+ }
173
+ export function resolveStep(step, config, dir) {
174
+ if (typeof step === 'string') {
175
+ const s = STEPS[step];
176
+ return {
177
+ id: step, label: s.label, output: s.output, engine: config.engine, model: config.models[s.role],
178
+ timeoutMs: config.timeoutMinutes * 60_000, write: s.write, prompt: () => readFileSync(packSkill(step), 'utf8'), builtin: step,
179
+ };
180
+ }
181
+ return {
182
+ id: step.id,
183
+ label: step.label ?? step.id,
184
+ output: step.output,
185
+ engine: step.engine ?? config.engine,
186
+ model: step.model ?? config.models[step.output === 'verdict' ? 'review' : 'dev'],
187
+ timeoutMs: (step.timeoutMinutes ?? config.timeoutMinutes) * 60_000,
188
+ write: step.write ?? step.output === 'commits',
189
+ prompt: () => {
190
+ const file = stepFile(step, dir);
191
+ if (!file)
192
+ throw new Error(`Step ${step.id} has no prompt or skill.`);
193
+ return readFileSync(file, 'utf8');
194
+ },
195
+ };
98
196
  }
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,12 +2,11 @@ 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";
10
- import { parseStep, resolveName, stepNames } from "./names.js";
8
+ import { TRACKERS, trackersFor } from "./tracker.js";
9
+ import { parseFrom, resolveName, stepNames } from "./names.js";
11
10
  import { isStatusLabel } from "./workflow.js";
12
11
  import { cancelCard, tick } from "./runner.js";
13
12
  const HOUR = 3_600_000;
@@ -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) => {
@@ -347,13 +373,13 @@ const run = guard(async (args, ctx) => {
347
373
  }
348
374
  let from;
349
375
  if (p.values.from) {
350
- from = parseStep(p.values.from);
376
+ from = parseFrom(p.values.from);
351
377
  if (!from) {
352
378
  ctx.log(`Unknown step ${p.values.from}. Steps: ${stepNames()}.`);
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;