@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/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/engine.d.ts CHANGED
@@ -9,6 +9,7 @@ export interface RunOptions {
9
9
  logFile: string;
10
10
  timeoutMs?: number;
11
11
  vault?: string;
12
+ settingsFile?: string;
12
13
  }
13
14
  export declare function engineArgs(o: RunOptions, outFile: string): {
14
15
  cmd: string;
package/dist/engine.js CHANGED
@@ -17,6 +17,8 @@ export function engineArgs(o, outFile) {
17
17
  args.push('--permission-mode', 'acceptEdits');
18
18
  if (o.model)
19
19
  args.push('--model', o.model);
20
+ if (o.settingsFile)
21
+ args.push('--settings', o.settingsFile);
20
22
  return { cmd: 'claude', args };
21
23
  }
22
24
  const args = ['exec', '-C', o.cwd, '-s', o.write ? 'workspace-write' : 'read-only', '-o', outFile];
package/dist/git.d.ts CHANGED
@@ -28,3 +28,9 @@ export declare function waitForChecks(worktree: string, branch: string, timeoutM
28
28
  report: string;
29
29
  }>;
30
30
  export declare function resetWorktree(worktree: string): void;
31
+ export interface CommitPolicy {
32
+ signOff?: boolean;
33
+ dropAiTrailers?: boolean;
34
+ }
35
+ export declare function applyPolicy(message: string, policy: CommitPolicy, signer: string | null, cwd: string): string;
36
+ export declare function enforceCommitPolicy(worktree: string, from: string, policy: CommitPolicy): number;
package/dist/git.js CHANGED
@@ -134,3 +134,63 @@ export function resetWorktree(worktree) {
134
134
  sh('git', ['reset', '--hard', 'HEAD'], worktree);
135
135
  sh('git', ['clean', '-fd'], worktree);
136
136
  }
137
+ const AI_LINE = /^(co-authored-by:.*(claude|anthropic|codex|openai|copilot|gemini|cursor)|.*generated with \[?(claude|codex)).*$/i;
138
+ // The message with AI lines removed and a sign-off added, as the policy asks.
139
+ export function applyPolicy(message, policy, signer, cwd) {
140
+ let msg = message.replace(/\r\n/g, '\n');
141
+ if (policy.dropAiTrailers)
142
+ msg = msg.split('\n').filter((l) => !AI_LINE.test(l.trim())).join('\n');
143
+ msg = msg.replace(/\s+$/, '') + '\n';
144
+ if (policy.signOff && signer) {
145
+ // git's own trailer rules decide the blank line and skip a duplicate.
146
+ const r = spawnSync('git', ['interpret-trailers', '--if-exists', 'addIfDifferent', '--trailer', `Signed-off-by: ${signer}`], { cwd, input: msg, encoding: 'utf8' });
147
+ if (r.status === 0)
148
+ msg = r.stdout;
149
+ }
150
+ return msg;
151
+ }
152
+ // Rewrites the commits after `from` up to HEAD so their messages follow the
153
+ // policy. Trees, authors and dates stay as they were; only commits not yet
154
+ // pushed may be passed. Returns how many changed.
155
+ export function enforceCommitPolicy(worktree, from, policy) {
156
+ if (!policy.signOff && !policy.dropAiTrailers)
157
+ return 0;
158
+ const name = sh('git', ['config', 'user.name'], worktree).stdout;
159
+ const email = sh('git', ['config', 'user.email'], worktree).stdout;
160
+ const signer = name && email ? `${name} <${email}>` : null;
161
+ const list = sh('git', ['rev-list', '--reverse', '--parents', `${from}..HEAD`], worktree);
162
+ if (!list.ok || !list.stdout)
163
+ return 0;
164
+ const map = new Map();
165
+ let changed = 0;
166
+ let head = '';
167
+ for (const line of list.stdout.split('\n')) {
168
+ const [sha, ...parents] = line.split(' ');
169
+ const info = must(sh('git', ['log', '-1', '--format=%T%x00%an%x00%ae%x00%aD%x00%cn%x00%ce%x00%cD', sha], worktree), 'git log');
170
+ const [tree, an, ae, ad, cn, ce, cd] = info.split('\0');
171
+ const raw = spawnSync('git', ['log', '-1', '--format=%B', sha], { cwd: worktree, encoding: 'utf8' }).stdout ?? '';
172
+ const message = applyPolicy(raw, policy, signer, worktree);
173
+ const newParents = parents.map((p) => map.get(p) ?? p);
174
+ if (message === raw.replace(/\s+$/, '') + '\n' && newParents.every((p, i) => p === parents[i])) {
175
+ map.set(sha, sha);
176
+ head = sha;
177
+ continue;
178
+ }
179
+ const r = spawnSync('git', ['commit-tree', tree, ...newParents.flatMap((p) => ['-p', p]), '-F', '-'], {
180
+ cwd: worktree,
181
+ input: message,
182
+ encoding: 'utf8',
183
+ env: { ...process.env, GIT_AUTHOR_NAME: an, GIT_AUTHOR_EMAIL: ae, GIT_AUTHOR_DATE: ad, GIT_COMMITTER_NAME: cn, GIT_COMMITTER_EMAIL: ce, GIT_COMMITTER_DATE: cd },
184
+ });
185
+ if (r.status !== 0)
186
+ throw new Error(`git commit-tree failed: ${r.stderr}`);
187
+ const next = r.stdout.trim();
188
+ map.set(sha, next);
189
+ head = next;
190
+ changed++;
191
+ }
192
+ // Same tree at the tip, so moving the branch leaves the files untouched.
193
+ if (changed)
194
+ must(sh('git', ['reset', '-q', '--soft', head], worktree), 'git reset');
195
+ return changed;
196
+ }
package/dist/index.js CHANGED
@@ -4,12 +4,17 @@ 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, AGENT_FILE, STEPS, TEMPLATES, isProducer, agentFolders, agentsDir, describeSteps, describeTrigger, describeTriggers, describeWorkflow, stepEnabled, stepId, 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";
15
+ import { initProfile, profileChecks, profileDir, readRules, renderClaudeSettings, CLAUDE_SETTINGS, RULES_FILE } from "./profile.js";
11
16
  import { createLinear } from "./linear.js";
12
- import { duplicateTriggerWarnings, moveKey, parseTrigger, renameKey, sameTrigger, validateConfig, validateWorkflow, workflowsUsing } from "./manage.js";
17
+ import { TRIGGER_FORMS, duplicateTriggerWarnings, moveKey, parseTrigger, renameKey, sameTrigger, validateAgentFolder, validateConfig, validateWorkflow, workflowsUsing } from "./manage.js";
13
18
  import { parseStep, resolveName, stepNames } from "./names.js";
14
19
  import { findRepos } from "./repos.js";
15
20
  import { defaultBranch, sh } from "./git.js";
@@ -81,26 +86,28 @@ async function chooseHomes(ui, configDir, saved) {
81
86
  return homes;
82
87
  }
83
88
  // Checks the key against Linear, and only then stores it.
84
- async function verifyAndStore(configDir, key, info, warn) {
89
+ async function verifyAndStore(configDir, key, info, warn, kind = 'linear') {
90
+ const name = TRACKERS[kind].name;
85
91
  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()}.`);
92
+ const me = await (kind === 'voidtrack' ? createVoidTrack(key) : createLinear(key)).viewer();
93
+ if (!(kind === 'voidtrack' ? storeSecret(configDir, 'voidtrack', key) : storeLinearKey(configDir, key))) {
94
+ warn(`${name} accepted the key but it could not be stored in ${keyStore()}.`);
89
95
  return null;
90
96
  }
91
- info(`Linear connected as ${me.name}. Key stored in ${keyStore()}.`);
97
+ info(`${name} connected as ${me.name}. Key stored in ${keyStore()}.`);
92
98
  return me.name;
93
99
  }
94
100
  catch (err) {
95
- warn(`Linear did not accept that key: ${err.message}. Nothing was stored.`);
101
+ warn(`${name} did not accept that key: ${err.message}. Nothing was stored.`);
96
102
  return null;
97
103
  }
98
104
  }
99
105
  // 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);
106
+ async function setupKey(ui, configDir, kind = 'linear') {
107
+ const { name, keyHint } = TRACKERS[kind];
108
+ ui.info(`Agents need a ${name} API key (${keyHint}).`);
109
+ 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');
110
+ return verifyAndStore(configDir, key.trim(), ui.info, ui.warn, kind);
104
111
  }
105
112
  async function currentLinearUser(configDir) {
106
113
  try {
@@ -277,11 +284,22 @@ async function promptWorkflow(ui, config, teams, current = {}, edit = false) {
277
284
  if (!triggers) {
278
285
  triggers = [];
279
286
  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();
287
+ const source = await ui.select(triggers.length ? 'Also start it when...' : 'Starts when...', [
288
+ { value: 'linear', label: 'A Linear card gets a label' },
289
+ { value: 'voidtrack', label: 'A VoidTrack card gets a label' },
290
+ { value: 'cron', label: 'On a schedule', hint: 'runs on a standing task, no card' },
291
+ ], 'linear');
292
+ if (source === 'cron') {
293
+ 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();
294
+ 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();
295
+ triggers.push({ source: 'cron', schedule, task });
296
+ continue;
297
+ }
298
+ const team = source === 'linear' && teams.length
299
+ ? 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)
300
+ : (await ui.text(`A card in which ${TRACKERS[source].name} team key...`, { validate: (v) => (v.trim() ? undefined : 'Required') })).trim().toUpperCase();
283
301
  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 });
302
+ triggers.push({ source, team, label });
285
303
  } while (await ui.confirm('Add another way to start this workflow?', false));
286
304
  }
287
305
  let steps = w.steps?.length && (await keep('steps', describeSteps(w.steps))) ? w.steps : undefined;
@@ -341,7 +359,7 @@ async function setupWorkflows(ui, config, teams, log) {
341
359
  // One entry per command, in the order help lists them.
342
360
  const USAGE = [
343
361
  ['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'],
362
+ ['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
363
  ['workflow show NAME [--json]', 'Show one workflow'],
346
364
  ['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
365
  ['workflow rename OLD NEW', 'Rename a workflow, keeping its place'],
@@ -356,7 +374,9 @@ const USAGE = [
356
374
  ['homes [--json]', 'Show where the agents are linked'],
357
375
  ['link DIR [--claude | --codex]', 'Link the agents into a config folder'],
358
376
  ['unlink DIR', 'Remove the agents from a config folder'],
359
- ['key', 'Store or replace the Linear API key (reads stdin without a terminal)'],
377
+ ['key [--voidtrack]', 'Store or replace the Linear or VoidTrack API key (reads stdin without a terminal)'],
378
+ ['profile [init]', `Your profile: rules (${RULES_FILE}) and Claude settings (${CLAUDE_SETTINGS}) every agent run carries; init creates it`],
379
+ ['validate [DIR]', `Check an agent folder (DIR/${AGENT_FILE} and its prompts), or the config and every folder under agents/defs`],
360
380
  ['help [COMMAND]', 'Show this list, or one command'],
361
381
  ];
362
382
  const usageOf = (name) => USAGE.filter(([c]) => c === name || c.startsWith(`${name} `))
@@ -441,7 +461,7 @@ function readWorkflowFlags(v, config) {
441
461
  for (const t of v.trigger) {
442
462
  const parsed = parseTrigger(t);
443
463
  if (!parsed)
444
- return { error: `Bad trigger ${t}. Use TEAM:LABEL, like ENG:vs-plan.`, code: 2 };
464
+ return { error: `Bad trigger ${t}. Use ${TRIGGER_FORMS}.`, code: 2 };
445
465
  flags.triggers.push(parsed);
446
466
  }
447
467
  }
@@ -487,6 +507,14 @@ function pickWorkflow(config, input, ctx) {
487
507
  }
488
508
  return r.name;
489
509
  }
510
+ // A workflow from an agent folder is changed by editing its file. True (and
511
+ // says so) when `name` is one.
512
+ function inFolder(config, name, ctx) {
513
+ const dir = config.folders?.[name];
514
+ if (dir)
515
+ ctx.log(`${name} is defined in ${tilde(join(dir, AGENT_FILE))}; edit that file.`);
516
+ return !!dir;
517
+ }
490
518
  async function teamsOrNull(ctx) {
491
519
  try {
492
520
  return await linear(ctx.configDir).teams();
@@ -590,7 +618,7 @@ const workflowSubs = {
590
618
  else {
591
619
  ctx.log(`Workflow ${name}: ${describeWorkflow(w)}`);
592
620
  for (const t of w.triggers)
593
- ctx.log(`Trigger: ${t.team} + ${t.label}`);
621
+ ctx.log(`Trigger: ${describeTrigger(t)}${t.source === 'cron' ? `, task: ${t.task}` : ''}`);
594
622
  ctx.log(`Steps: ${describeSteps(w.steps)}`);
595
623
  ctx.log(`Project: ${w.project}`);
596
624
  ctx.log(`Rounds: ${w.maxRounds}`);
@@ -611,6 +639,8 @@ const workflowSubs = {
611
639
  const name = pickWorkflow(config, p.positionals[0], ctx);
612
640
  if (!name)
613
641
  return 1;
642
+ if (inFolder(config, name, ctx))
643
+ return 1;
614
644
  const flags = readWorkflowFlags(p.values, config);
615
645
  if ('error' in flags) {
616
646
  ctx.log(flags.error);
@@ -637,7 +667,7 @@ const workflowSubs = {
637
667
  for (const t of (v['add-trigger'] ?? [])) {
638
668
  const parsed = parseTrigger(t);
639
669
  if (!parsed) {
640
- ctx.log(`Bad trigger ${t}. Use TEAM:LABEL, like ENG:vs-plan.`);
670
+ ctx.log(`Bad trigger ${t}. Use ${TRIGGER_FORMS}.`);
641
671
  return 2;
642
672
  }
643
673
  if (!next.triggers.some((x) => sameTrigger(x, parsed)))
@@ -671,6 +701,8 @@ const workflowSubs = {
671
701
  const old = pickWorkflow(config, p.positionals[0], ctx);
672
702
  if (!old)
673
703
  return 1;
704
+ if (inFolder(config, old, ctx))
705
+ return 1;
674
706
  const next = p.positionals[1].trim();
675
707
  if (!next) {
676
708
  ctx.log('The new name cannot be empty.');
@@ -695,6 +727,8 @@ const workflowSubs = {
695
727
  const name = pickWorkflow(config, p.positionals[0], ctx);
696
728
  if (!name)
697
729
  return 1;
730
+ if (inFolder(config, name, ctx))
731
+ return 1;
698
732
  const ok = await confirmed(`Delete workflow ${name}?`, !!p.values.yes, ctx);
699
733
  if (ok === 'refused')
700
734
  return 2;
@@ -723,6 +757,8 @@ const workflowSubs = {
723
757
  const name = pickWorkflow(config, p.positionals[0], ctx);
724
758
  if (!name)
725
759
  return 1;
760
+ if (inFolder(config, name, ctx))
761
+ return 1;
726
762
  let place;
727
763
  if (v.first)
728
764
  place = { first: true };
@@ -1045,6 +1081,11 @@ const agents = {
1045
1081
  config.models = { ...DEFAULTS.models };
1046
1082
  }
1047
1083
  config.engine = engine;
1084
+ // What the runner does to agent commits before pushing them.
1085
+ const commits = config.commits ?? {};
1086
+ commits.dropAiTrailers = await ui.confirm('Remove AI co-author lines from agent commits?', commits.dropAiTrailers ?? true);
1087
+ commits.signOff = await ui.confirm('Sign off agent commits (Signed-off-by, for repos that ask for a DCO)?', commits.signOff ?? false);
1088
+ config.commits = commits;
1048
1089
  const name = await currentLinearUser(ctx.configDir);
1049
1090
  if (!name || !(await ui.confirm(`Keep using Linear as ${name}?`))) {
1050
1091
  if (!(await setupKey(ui, ctx.configDir)))
@@ -1053,7 +1094,11 @@ const agents = {
1053
1094
  await setupProjects(ui, config, ctx.log);
1054
1095
  await setupWorkflows(ui, config, await linear(ctx.configDir).teams(), ctx.log);
1055
1096
  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.'))) {
1097
+ if (trackersInUse(config).includes('voidtrack') && !readSecret(ctx.configDir, 'voidtrack')) {
1098
+ if (!(await setupKey(ui, ctx.configDir, 'voidtrack')))
1099
+ ctx.log('VoidTrack is not connected yet. Run vsf agents key --voidtrack.');
1100
+ }
1101
+ 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
1102
  const ok = runnerService().start(ctx.home, [...ctx.selfCommand, 'agents', 'tick'], join(agentsDir(ctx.configDir), 'runner.log'));
1058
1103
  ctx.log(ok ? 'Runner started.' : 'Could not start the runner. Try vsf agents start.');
1059
1104
  }
@@ -1077,12 +1122,19 @@ const agents = {
1077
1122
  ? { name: `agents in ${homeLabel(h)}`, status: 'ok', detail: `${wanted.length} linked` }
1078
1123
  : { name: `agents in ${homeLabel(h)}`, status: 'warn', detail: `missing ${missing.join(', ')}`, fix: `vsf agents link ${tilde(h.dir)}` });
1079
1124
  }
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' });
1125
+ const inUse = trackersInUse(saved);
1126
+ for (const kind of inUse.length ? inUse : ['linear']) {
1127
+ const { name } = TRACKERS[kind];
1128
+ try {
1129
+ const key = readSecret(ctx.configDir, kind);
1130
+ if (!key)
1131
+ throw new Error(`No ${name} API key`);
1132
+ const me = await (kind === 'voidtrack' ? createVoidTrack(key) : createLinear(key)).viewer();
1133
+ checks.push({ name, status: 'ok', detail: `connected as ${me.name}` });
1134
+ }
1135
+ catch (err) {
1136
+ checks.push({ name, status: 'fail', detail: err.message, fix: `vsf agents key${kind === 'linear' ? '' : ` --${kind}`}` });
1137
+ }
1086
1138
  }
1087
1139
  const workflows = Object.entries(saved.workflows);
1088
1140
  const broken = workflows.filter(([, c]) => !saved.projects[c.project] || !isRepo(saved.projects[c.project].path)).map(([n]) => n);
@@ -1091,7 +1143,14 @@ const agents = {
1091
1143
  : broken.length
1092
1144
  ? { name: 'agent workflows', status: 'fail', detail: `${broken.join(', ')} point at a missing project`, fix: 'vsf agents project edit' }
1093
1145
  : { name: 'agent workflows', status: 'ok', detail: `${workflows.length} ready` });
1094
- return [...checks, ...(await controlChecks(ctx))];
1146
+ const folders = agentFolders(ctx.configDir);
1147
+ if (folders.length) {
1148
+ const bad = folders.filter((d) => validateAgentFolder(d, saved).length).map((d) => basename(d));
1149
+ checks.push(bad.length
1150
+ ? { name: 'agent folders', status: 'fail', detail: `${bad.join(', ')} ${bad.length === 1 ? 'has' : 'have'} problems`, fix: 'vsf agents validate' }
1151
+ : { name: 'agent folders', status: 'ok', detail: `${folders.length} valid` });
1152
+ }
1153
+ return [...checks, ...profileChecks(ctx.configDir), ...(await controlChecks(ctx))];
1095
1154
  },
1096
1155
  commands: {
1097
1156
  ...controlCommands,
@@ -1101,7 +1160,7 @@ const agents = {
1101
1160
  return p;
1102
1161
  const config = readAgentsConfig(ctx.configDir);
1103
1162
  if (p.values.json)
1104
- out(ctx, Object.entries(config.workflows).map(([name, w]) => ({ name, ...w })));
1163
+ out(ctx, Object.entries(config.workflows).map(([name, w]) => ({ name, ...w, steps: w.steps.map(stepId) })));
1105
1164
  else if (!Object.keys(config.workflows).length)
1106
1165
  ctx.log('No workflows yet. vsf agents workflow add creates one.');
1107
1166
  else
@@ -1115,6 +1174,58 @@ const agents = {
1115
1174
  return 0;
1116
1175
  },
1117
1176
  workflow: (args, ctx) => group('workflow', workflowSubs, args, ctx),
1177
+ async profile(args, ctx) {
1178
+ const p = begin('profile', args, {}, ctx);
1179
+ if (typeof p === 'number')
1180
+ return p;
1181
+ const dir = profileDir(ctx.configDir);
1182
+ if (p.positionals[0] === 'init') {
1183
+ const made = initProfile(ctx.configDir);
1184
+ ctx.log(made.length ? `Created ${made.join(', ')} in ${tilde(dir)}.` : `${tilde(dir)} already exists; nothing changed.`);
1185
+ return 0;
1186
+ }
1187
+ if (p.positionals.length) {
1188
+ ctx.log(usageOf('profile'));
1189
+ return 2;
1190
+ }
1191
+ ctx.log(`Profile: ${tilde(dir)}${existsSync(dir) ? '' : ' (not created yet: vsf agents profile init)'}`);
1192
+ const rules = readRules(ctx.configDir).replace(/<!--[\s\S]*?-->/g, '').trim();
1193
+ ctx.log(`Rules: ${rules ? `${rules.split('\n').length} lines in ${RULES_FILE}, added to every step's prompt` : 'none'}`);
1194
+ let settings = 'none';
1195
+ try {
1196
+ settings = renderClaudeSettings(ctx.configDir) === null ? 'none' : `${CLAUDE_SETTINGS}, passed to every Claude run`;
1197
+ }
1198
+ catch (err) {
1199
+ settings = `broken: ${err.message}`;
1200
+ }
1201
+ ctx.log(`Claude settings: ${settings}`);
1202
+ const c = readAgentsConfig(ctx.configDir).commits ?? {};
1203
+ ctx.log(`Commits: ${[c.dropAiTrailers ? 'AI co-author lines removed' : 'AI lines kept', c.signOff ? 'signed off' : 'not signed off'].join(', ')} (commits in the agents config)`);
1204
+ return 0;
1205
+ },
1206
+ async validate(args, ctx) {
1207
+ const p = begin('validate', args, {}, ctx);
1208
+ if (typeof p === 'number')
1209
+ return p;
1210
+ if (p.positionals.length > 1) {
1211
+ ctx.log('Give one folder at most.');
1212
+ ctx.log(usageOf('validate'));
1213
+ return 2;
1214
+ }
1215
+ const config = readAgentsConfig(ctx.configDir);
1216
+ const problems = [];
1217
+ const dirs = p.positionals.length ? [resolve(expandHome(p.positionals[0]))] : agentFolders(ctx.configDir);
1218
+ if (!p.positionals.length)
1219
+ problems.push(...validateConfig(config));
1220
+ for (const dir of dirs)
1221
+ for (const line of validateAgentFolder(dir, config))
1222
+ problems.push(`${basename(dir)}: ${line}`);
1223
+ for (const line of problems)
1224
+ ctx.log(line);
1225
+ if (!problems.length)
1226
+ ctx.log(p.positionals.length ? `${basename(dirs[0])} is valid.` : `Config and ${plural(dirs.length, 'agent folder')} are valid.`);
1227
+ return problems.length ? 1 : 0;
1228
+ },
1118
1229
  async projects(args, ctx) {
1119
1230
  const p = begin('projects', args, { json: { type: 'boolean' } }, ctx);
1120
1231
  if (typeof p === 'number')
@@ -1134,21 +1245,26 @@ const agents = {
1134
1245
  link: homeCommands.link,
1135
1246
  unlink: homeCommands.unlink,
1136
1247
  async key(args, ctx) {
1137
- const p = begin('key', args, {}, ctx);
1248
+ const p = begin('key', args, { voidtrack: { type: 'boolean' } }, ctx);
1138
1249
  if (typeof p === 'number')
1139
1250
  return p;
1140
1251
  if (p.positionals.length) {
1141
1252
  ctx.log('Keys are not accepted on the command line. Pipe the key in instead.');
1142
1253
  return 2;
1143
1254
  }
1255
+ const kind = p.values.voidtrack ? 'voidtrack' : 'linear';
1144
1256
  if (interactive())
1145
- return (await setupKey(ctx.ui, ctx.configDir)) ? 0 : 1;
1257
+ return (await setupKey(ctx.ui, ctx.configDir, kind)) ? 0 : 1;
1146
1258
  const key = readFileSync(0, 'utf8').trim();
1147
- if (!looksLikeLinearKey(key)) {
1259
+ if (kind === 'linear' && !looksLikeLinearKey(key)) {
1148
1260
  ctx.log('Linear keys start with lin_api_');
1149
1261
  return 2;
1150
1262
  }
1151
- return (await verifyAndStore(ctx.configDir, key, ctx.log, ctx.log)) ? 0 : 1;
1263
+ if (!key) {
1264
+ ctx.log('No key on stdin.');
1265
+ return 2;
1266
+ }
1267
+ return (await verifyAndStore(ctx.configDir, key, ctx.log, ctx.log, kind)) ? 0 : 1;
1152
1268
  },
1153
1269
  async help(args, ctx) {
1154
1270
  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,8 @@
1
- import { type AgentsConfig, type Trigger, type Workflow } from './config.ts';
2
- export declare function validateWorkflow(config: Pick<AgentsConfig, 'enabled' | 'projects'>, workflow: Workflow): string[];
1
+ import { type AgentsConfig, type Step, type Trigger, type Workflow } from './config.ts';
2
+ export declare function validateStep(step: Step, config: Pick<AgentsConfig, 'enabled'>, dir?: string): string[];
3
+ export declare function validateWorkflow(config: Pick<AgentsConfig, 'enabled' | 'projects'>, workflow: Workflow, dir?: string): string[];
4
+ export declare function validateAgentFolder(dir: string, config: Pick<AgentsConfig, 'enabled' | 'projects' | 'workflows' | 'folders'>): string[];
5
+ export declare const TRIGGER_FORMS = "TEAM:LABEL (Linear), voidtrack:TEAM:LABEL, or cron:SCHEDULE:TASK";
3
6
  export declare function parseTrigger(input: string): Trigger | null;
4
7
  export declare const sameTrigger: (a: Trigger, b: Trigger) => boolean;
5
8
  export declare function duplicateTriggerWarnings(workflows: Record<string, Workflow>, name: string, workflow: Workflow): string[];