@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 +52 -4
- package/dist/config.d.ts +65 -6
- package/dist/config.js +114 -16
- package/dist/control.d.ts +3 -1
- package/dist/control.js +69 -39
- package/dist/cron.d.ts +9 -0
- package/dist/cron.js +66 -0
- package/dist/engine.d.ts +1 -0
- package/dist/engine.js +2 -0
- package/dist/git.d.ts +6 -0
- package/dist/git.js +60 -0
- package/dist/index.js +151 -35
- package/dist/linear.d.ts +1 -1
- package/dist/linear.js +3 -3
- package/dist/manage.d.ts +5 -2
- package/dist/manage.js +138 -21
- package/dist/names.d.ts +1 -0
- package/dist/names.js +4 -1
- package/dist/profile.d.ts +10 -0
- package/dist/profile.js +70 -0
- package/dist/runner.d.ts +15 -8
- package/dist/runner.js +219 -98
- package/dist/state.d.ts +7 -2
- package/dist/state.js +15 -0
- package/dist/tracker.d.ts +24 -0
- package/dist/tracker.js +36 -0
- package/dist/voidtrack.d.ts +18 -0
- package/dist/voidtrack.js +89 -0
- package/dist/workflow.d.ts +2 -2
- package/package.json +1 -1
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
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(
|
|
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(
|
|
97
|
+
info(`${name} connected as ${me.name}. Key stored in ${keyStore()}.`);
|
|
92
98
|
return me.name;
|
|
93
99
|
}
|
|
94
100
|
catch (err) {
|
|
95
|
-
warn(
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
281
|
-
|
|
282
|
-
:
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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 (
|
|
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
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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[];
|