@voidlabs.gg/voidshift-agents 0.5.0 → 0.6.0

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/index.js CHANGED
@@ -4,7 +4,7 @@ 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, AGENT_FILE, STEPS, TEMPLATES, isProducer, agentFolders, agentsDir, describeSteps, describeTrigger, describeTriggers, describeWorkflow, stepEnabled, stepId, 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, workspaceOf, DEFAULTS, DEFAULT_WORKSPACE, WORKSPACE_NAME } from "./config.js";
8
8
  import { controlChecks, controlCommands, controlUsage } from "./control.js";
9
9
  import { keyStore, looksLikeLinearKey, readLinearKey, readSecret, storeLinearKey, storeSecret } from "./secrets.js";
10
10
  import { TRACKERS } from "./tracker.js";
@@ -14,7 +14,7 @@ import { trackersInUse } from "./control.js";
14
14
  import { runnerService } from "./service.js";
15
15
  import { initProfile, profileChecks, profileDir, readRules, renderClaudeSettings, CLAUDE_SETTINGS, RULES_FILE } from "./profile.js";
16
16
  import { createLinear } from "./linear.js";
17
- import { TRIGGER_FORMS, duplicateTriggerWarnings, moveKey, parseTrigger, renameKey, sameTrigger, validateAgentFolder, validateConfig, validateWorkflow, workflowsUsing } from "./manage.js";
17
+ import { TRIGGER_FORMS, duplicateTriggerWarnings, moveKey, parseTrigger, projectProblems, renameKey, sameTrigger, validateAgentFolder, validateConfig, validateWorkflow, workflowsUsing } from "./manage.js";
18
18
  import { parseStep, resolveName, stepNames } from "./names.js";
19
19
  import { findRepos } from "./repos.js";
20
20
  import { defaultBranch, sh } from "./git.js";
@@ -239,7 +239,8 @@ async function promptProject(ui, config, current = {}, edit = false, allowExisti
239
239
  const describeProject = (config, name) => {
240
240
  const p = config.projects[name];
241
241
  const used = workflowsUsing(config, name);
242
- return `${name} ${tilde(p.path)}, PRs into ${p.base ?? 'the default branch'}, ${used.length ? `used by ${used.join(', ')}` : 'not used by any workflow'}`;
242
+ const where = [p.workspace ? `workspace ${p.workspace}` : '', p.match?.length ? `matches ${p.match.join(', ')}` : ''].filter(Boolean);
243
+ return `${name} ${tilde(p.path)}, PRs into ${p.base ?? 'the default branch'}, ${where.map((w) => `${w}, `).join('')}${used.length ? `used by ${used.join(', ')}` : 'not used by any workflow'}`;
243
244
  };
244
245
  // Named local repos the workflows run in. Triggers point at them by name.
245
246
  async function setupProjects(ui, config, log) {
@@ -366,11 +367,14 @@ const USAGE = [
366
367
  ['workflow rm NAME [--yes]', 'Delete a workflow'],
367
368
  ['workflow move NAME (--first | --last | --before OTHER | --after OTHER)', 'Change the match order'],
368
369
  ['projects [--json]', 'List projects'],
369
- ['project add [NAME] [--path P] [--base B]', 'Add a project; prompts for what is missing'],
370
+ ['project add [NAME] [--path P] [--base B] [--workspace W] [--match NAME]...', 'Add a project; prompts for what is missing. --match routes cards of that tracker project here, whatever the workflow says'],
370
371
  ['project show NAME [--json]', 'Show one project'],
371
- ['project edit NAME [--path P] [--base B]', 'Change a project; no flags asks'],
372
+ ['project edit NAME [--path P] [--base B] [--workspace W] [--match NAME]...', 'Change a project; no flags asks. An empty --workspace or --match clears it'],
372
373
  ['project rename OLD NEW', 'Rename a project and the workflows that use it'],
373
374
  ['project rm NAME [--yes]', 'Delete a project nothing uses'],
375
+ ['workspace add NAME --root DIR [--context FILE]', 'Add a workspace: a folder of repos with its own run queue. Steps carry FILE, or DIR/CONTEXT.md'],
376
+ ['workspace list [--json]', 'List workspaces and their projects'],
377
+ ['workspace rm NAME', 'Delete a workspace no project uses'],
374
378
  ['homes [--json]', 'Show where the agents are linked'],
375
379
  ['link DIR [--claude | --codex]', 'Link the agents into a config folder'],
376
380
  ['unlink DIR', 'Remove the agents from a config folder'],
@@ -789,9 +793,31 @@ function pickProject(config, input, ctx) {
789
793
  }
790
794
  return r.name;
791
795
  }
796
+ const PROJECT_FLAGS = { path: { type: 'string' }, base: { type: 'string' }, workspace: { type: 'string' }, match: { type: 'string', multiple: true } };
797
+ // Applies --workspace and --match to a project. An empty value clears either.
798
+ // Returns a complaint when the workspace or a match does not fit.
799
+ function applyWorkspaceFlags(config, name, project, v) {
800
+ if (v.workspace !== undefined) {
801
+ const ws = String(v.workspace).trim();
802
+ if (ws && ws !== DEFAULT_WORKSPACE && !config.workspaces[ws])
803
+ return `No workspace named ${ws}. vsf agents workspace add creates it.`;
804
+ if (ws && ws !== DEFAULT_WORKSPACE)
805
+ project.workspace = ws;
806
+ else
807
+ delete project.workspace;
808
+ }
809
+ if (v.match !== undefined) {
810
+ const match = v.match.map((m) => m.trim()).filter(Boolean);
811
+ if (match.length)
812
+ project.match = match;
813
+ else
814
+ delete project.match;
815
+ }
816
+ return projectProblems({ ...config.projects, [name]: project }, config.workspaces)[0] ?? null;
817
+ }
792
818
  const projectSubs = {
793
819
  async add(args, ctx) {
794
- const p = begin('project add', args, { path: { type: 'string' }, base: { type: 'string' } }, ctx);
820
+ const p = begin('project add', args, PROJECT_FLAGS, ctx);
795
821
  if (typeof p === 'number')
796
822
  return p;
797
823
  if (p.positionals.length > 1) {
@@ -838,6 +864,11 @@ const projectSubs = {
838
864
  name = r.name;
839
865
  project = r.project;
840
866
  }
867
+ const bad = applyWorkspaceFlags(config, name, project, p.values);
868
+ if (bad) {
869
+ ctx.log(bad);
870
+ return 1;
871
+ }
841
872
  config.projects[name] = project;
842
873
  save(ctx, config);
843
874
  ctx.log(`Project ${name}: ${tilde(project.path)}${project.base ? `, PRs into ${project.base}` : ''}`);
@@ -861,12 +892,15 @@ const projectSubs = {
861
892
  ctx.log(`Project ${name}`);
862
893
  ctx.log(`Path: ${tilde(proj.path)}`);
863
894
  ctx.log(`PRs into: ${proj.base ?? 'the default branch'}`);
895
+ ctx.log(`Workspace: ${workspaceOf(proj)}`);
896
+ if (proj.match?.length)
897
+ ctx.log(`Matches: ${proj.match.join(', ')}`);
864
898
  ctx.log(`Used by: ${used.length ? used.join(', ') : 'no workflows'}`);
865
899
  }
866
900
  return 0;
867
901
  },
868
902
  async edit(args, ctx) {
869
- const p = begin('project edit', args, { path: { type: 'string' }, base: { type: 'string' } }, ctx);
903
+ const p = begin('project edit', args, PROJECT_FLAGS, ctx);
870
904
  if (typeof p === 'number')
871
905
  return p;
872
906
  if (!needArgs(p, 1, 'project edit', ctx))
@@ -877,9 +911,9 @@ const projectSubs = {
877
911
  return 1;
878
912
  const current = config.projects[name];
879
913
  const v = p.values;
880
- if (v.path === undefined && v.base === undefined) {
914
+ if (v.path === undefined && v.base === undefined && v.workspace === undefined && v.match === undefined) {
881
915
  if (!interactive()) {
882
- ctx.log('Nothing to change. Give --path or --base, or run this in a terminal to be asked.');
916
+ ctx.log('Nothing to change. Give --path, --base, --workspace or --match, or run this in a terminal to be asked.');
883
917
  ctx.log(usageOf('project edit'));
884
918
  return 2;
885
919
  }
@@ -887,7 +921,8 @@ const projectSubs = {
887
921
  }
888
922
  else {
889
923
  const path = v.path !== undefined ? resolve(expandHome(v.path)) : current.path;
890
- if (!isRepo(path)) {
924
+ // Only a new path needs checking; a workspace or match change keeps the repo.
925
+ if (v.path !== undefined && !isRepo(path)) {
891
926
  ctx.log(`${tilde(path)} is not a git repository.`);
892
927
  return 1;
893
928
  }
@@ -899,6 +934,11 @@ const projectSubs = {
899
934
  else
900
935
  delete next.base;
901
936
  }
937
+ const bad = applyWorkspaceFlags(config, name, next, v);
938
+ if (bad) {
939
+ ctx.log(bad);
940
+ return 1;
941
+ }
902
942
  config.projects[name] = next;
903
943
  }
904
944
  save(ctx, config);
@@ -960,6 +1000,87 @@ const projectSubs = {
960
1000
  return 0;
961
1001
  },
962
1002
  };
1003
+ // ---- workspaces ----
1004
+ const projectsIn = (config, ws) => Object.entries(config.projects).filter(([, p]) => workspaceOf(p) === ws).map(([n]) => n);
1005
+ const workspaceSubs = {
1006
+ async add(args, ctx) {
1007
+ const p = begin('workspace add', args, { root: { type: 'string' }, context: { type: 'string' } }, ctx);
1008
+ if (typeof p === 'number')
1009
+ return p;
1010
+ if (!needArgs(p, 1, 'workspace add', ctx))
1011
+ return 2;
1012
+ const name = p.positionals[0].trim();
1013
+ if (!WORKSPACE_NAME.test(name)) {
1014
+ ctx.log('A workspace name is letters, digits, dashes and underscores.');
1015
+ return 2;
1016
+ }
1017
+ if (!p.values.root) {
1018
+ ctx.log('Missing --root.');
1019
+ ctx.log(usageOf('workspace add'));
1020
+ return 2;
1021
+ }
1022
+ const config = readAgentsConfig(ctx.configDir);
1023
+ if (config.workspaces[name]) {
1024
+ ctx.log(`A workspace named ${name} already exists.`);
1025
+ return 1;
1026
+ }
1027
+ const root = resolve(expandHome(p.values.root));
1028
+ if (!existsSync(root)) {
1029
+ ctx.log(`${tilde(root)} does not exist.`);
1030
+ return 1;
1031
+ }
1032
+ const context = p.values.context ? resolve(root, expandHome(p.values.context)) : undefined;
1033
+ if (context && !existsSync(context)) {
1034
+ ctx.log(`${tilde(context)} does not exist.`);
1035
+ return 1;
1036
+ }
1037
+ config.workspaces[name] = { root, ...(context ? { context } : {}) };
1038
+ save(ctx, config);
1039
+ ctx.log(`Workspace ${name}: ${tilde(root)}. vsf agents project edit P --workspace ${name} puts a project in it.`);
1040
+ return 0;
1041
+ },
1042
+ async list(args, ctx) {
1043
+ const p = begin('workspace list', args, { json: { type: 'boolean' } }, ctx);
1044
+ if (typeof p === 'number')
1045
+ return p;
1046
+ const config = readAgentsConfig(ctx.configDir);
1047
+ const names = [...new Set([...Object.keys(config.workspaces), ...Object.values(config.projects).map(workspaceOf)])];
1048
+ if (p.values.json) {
1049
+ out(ctx, names.map((name) => ({ name, ...config.workspaces[name], projects: projectsIn(config, name) })));
1050
+ return 0;
1051
+ }
1052
+ if (!names.length)
1053
+ ctx.log('No workspaces yet. Every project runs in the default one; vsf agents workspace add creates another.');
1054
+ for (const name of names) {
1055
+ const w = config.workspaces[name];
1056
+ const projects = projectsIn(config, name);
1057
+ ctx.log(`${name} ${w ? tilde(w.root) : 'no root'}${w?.context ? `, context ${tilde(w.context)}` : ''}, ${projects.length ? projects.join(', ') : 'no projects'}`);
1058
+ }
1059
+ return 0;
1060
+ },
1061
+ async rm(args, ctx) {
1062
+ const p = begin('workspace rm', args, {}, ctx);
1063
+ if (typeof p === 'number')
1064
+ return p;
1065
+ if (!needArgs(p, 1, 'workspace rm', ctx))
1066
+ return 2;
1067
+ const config = readAgentsConfig(ctx.configDir);
1068
+ const name = p.positionals[0].trim();
1069
+ if (!config.workspaces[name]) {
1070
+ ctx.log(`No workspace named ${name}.`);
1071
+ return 1;
1072
+ }
1073
+ const used = projectsIn(config, name);
1074
+ if (used.length) {
1075
+ ctx.log(`Used by ${used.join(', ')}. Move those projects first: vsf agents project edit P --workspace OTHER.`);
1076
+ return 1;
1077
+ }
1078
+ delete config.workspaces[name];
1079
+ save(ctx, config);
1080
+ ctx.log(`Deleted workspace ${name}.`);
1081
+ return 0;
1082
+ },
1083
+ };
963
1084
  // ---- homes ----
964
1085
  // Per enabled agent: ours, taken by something else, or missing.
965
1086
  function homeReport(config, h) {
@@ -1242,6 +1363,7 @@ const agents = {
1242
1363
  return 0;
1243
1364
  },
1244
1365
  project: (args, ctx) => group('project', projectSubs, args, ctx),
1366
+ workspace: (args, ctx) => group('workspace', workspaceSubs, args, ctx),
1245
1367
  homes: homeCommands.homes,
1246
1368
  link: homeCommands.link,
1247
1369
  unlink: homeCommands.unlink,
package/dist/manage.d.ts CHANGED
@@ -18,4 +18,5 @@ export type Place = {
18
18
  };
19
19
  export declare function moveKey<T>(record: Record<string, T>, name: string, place: Place): Record<string, T>;
20
20
  export declare const workflowsUsing: (config: Pick<AgentsConfig, "workflows">, project: string) => string[];
21
- export declare function validateConfig(value: unknown, saved?: Pick<AgentsConfig, 'projects' | 'workflows'>): string[];
21
+ export declare function projectProblems(projects: AgentsConfig['projects'], workspaces: AgentsConfig['workspaces']): string[];
22
+ export declare function validateConfig(value: unknown, saved?: Pick<AgentsConfig, 'projects' | 'workflows'> & Partial<Pick<AgentsConfig, 'workspaces'>>): string[];
package/dist/manage.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { basename, join, resolve } from 'node:path';
3
- import { AGENTS, AGENT_FILE, OUTPUTS, STEP_ID, isBuiltin, readAgentFolder, stepFile, describeTrigger, triggerSpec } from "./config.js";
3
+ import { AGENTS, AGENT_FILE, DEFAULT_WORKSPACE, OUTPUTS, STEP_ID, WORKSPACE_NAME, isBuiltin, readAgentFolder, stepFile, describeTrigger, triggerSpec } from "./config.js";
4
4
  import { parseCron } from "./cron.js";
5
5
  const isInt = (v, min, max) => typeof v === 'number' && Number.isInteger(v) && v >= min && v <= max;
6
6
  // The line a step's prompt must ask for, so the runner can read the result.
@@ -185,6 +185,34 @@ export function moveKey(record, name, place) {
185
185
  }
186
186
  export const workflowsUsing = (config, project) => Object.entries(config.workflows).filter(([, w]) => w.project === project).map(([n]) => n);
187
187
  const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
188
+ // What is wrong with the projects' workspaces and matches: a workspace that
189
+ // does not exist, or a tracker project name two projects both claim.
190
+ export function projectProblems(projects, workspaces) {
191
+ const problems = [];
192
+ const claimed = new Map();
193
+ for (const [name, p] of Object.entries(projects)) {
194
+ if (!isObj(p))
195
+ continue;
196
+ if (p.workspace !== undefined && (typeof p.workspace !== 'string' || (p.workspace !== DEFAULT_WORKSPACE && !Object.hasOwn(workspaces, p.workspace)))) {
197
+ problems.push(`Project ${name}: workspace ${String(p.workspace)} does not exist. vsf agents workspace add creates it.`);
198
+ }
199
+ if (p.match === undefined)
200
+ continue;
201
+ if (!Array.isArray(p.match) || p.match.some((m) => typeof m !== 'string' || !m.trim())) {
202
+ problems.push(`Project ${name}: match must list tracker project names.`);
203
+ continue;
204
+ }
205
+ for (const m of p.match) {
206
+ const key = m.trim().toLowerCase();
207
+ const other = claimed.get(key);
208
+ if (other && other !== name)
209
+ problems.push(`Projects ${other} and ${name} both match ${m}.`);
210
+ else
211
+ claimed.set(key, name);
212
+ }
213
+ }
214
+ return problems;
215
+ }
188
216
  // Everything wrong with a whole agents config, e.g. one edited by hand.
189
217
  // `saved` is the config on disk: only what the write changes is checked, so a
190
218
  // moved folder or a retired agent elsewhere does not block an unrelated edit.
@@ -209,6 +237,23 @@ export function validateConfig(value, saved) {
209
237
  else if (saved?.projects?.[name]?.path !== p.path && !existsSync(join(p.path, '.git')))
210
238
  problems.push(`Project ${name}: ${p.path} is not a git repository.`);
211
239
  }
240
+ // Older configs have no workspaces: every project is in the default one.
241
+ const workspaces = value.workspaces === undefined ? {} : isObj(value.workspaces) ? value.workspaces : null;
242
+ if (!workspaces)
243
+ problems.push('workspaces must be an object.');
244
+ for (const [name, w] of Object.entries(workspaces ?? {})) {
245
+ if (!WORKSPACE_NAME.test(name))
246
+ problems.push(`Workspace ${name} must be letters, digits, dashes and underscores.`);
247
+ if (!isObj(w) || typeof w.root !== 'string' || !w.root)
248
+ problems.push(`Workspace ${name} needs a root folder.`);
249
+ else {
250
+ if (saved?.workspaces?.[name]?.root !== w.root && !existsSync(w.root))
251
+ problems.push(`Workspace ${name}: ${w.root} does not exist.`);
252
+ if (w.context !== undefined && typeof w.context !== 'string')
253
+ problems.push(`Workspace ${name} context must be a file name.`);
254
+ }
255
+ }
256
+ problems.push(...projectProblems((projects ?? {}), (workspaces ?? {})));
212
257
  if (!Array.isArray(value.enabled) || value.enabled.some((a) => typeof a !== 'string' || !(a in AGENTS)))
213
258
  problems.push(`enabled must list agents from: ${Object.keys(AGENTS).join(', ')}.`);
214
259
  if (value.commits !== undefined && (!isObj(value.commits) || Object.entries(value.commits).some(([k, v]) => !['signOff', 'dropAiTrailers'].includes(k) || typeof v !== 'boolean')))
package/dist/runner.d.ts CHANGED
@@ -16,15 +16,38 @@ type Job = {
16
16
  card: Card;
17
17
  trigger: Trigger;
18
18
  workflow: string;
19
+ workspace: string;
19
20
  };
20
21
  export declare function cronCard(workflow: string, task: string, at: Date): Card;
21
- export declare function nextJob(rc: RunContext, only?: string, workflowName?: string, opts?: {
22
+ export interface JobQuery {
23
+ only?: string;
24
+ workflow?: string;
25
+ schedule?: string;
22
26
  force?: boolean;
27
+ busy?: Set<string>;
28
+ skip?: Set<string>;
29
+ limit?: number;
23
30
  now?: Date;
24
- }): Promise<Job | null>;
31
+ }
32
+ export declare function findJobs(rc: RunContext, q?: JobQuery): Promise<Job[]>;
33
+ export declare function nextJob(rc: RunContext, only?: string, workflowName?: string, opts?: Omit<JobQuery, 'only' | 'workflow' | 'limit'>): Promise<Job | null>;
25
34
  export declare function cancelCard(tracker: Tracker, configDir: string, cardId: string, label: string, source?: 'linear' | 'voidtrack'): Promise<boolean>;
26
35
  export declare function tick(rc: RunContext, only?: string, opts?: {
27
36
  workflow?: string;
28
37
  from?: string;
38
+ schedule?: string;
39
+ child?: boolean;
29
40
  }): Promise<number>;
41
+ export interface ChildRun {
42
+ kill(signal: NodeJS.Signals): unknown;
43
+ once(event: 'exit' | 'error', listener: (err?: Error) => void): unknown;
44
+ exitCode: number | null;
45
+ }
46
+ export interface DispatchDeps {
47
+ start: (args: string[]) => ChildRun;
48
+ waitMs: number;
49
+ reload?: () => AgentsConfig;
50
+ }
51
+ export declare const jobArgs: (job: Job) => string[];
52
+ export declare function dispatch(rc: RunContext, over?: Partial<DispatchDeps>): Promise<number>;
30
53
  export {};