@voidlabs.gg/voidshift-agents 0.5.1 → 0.7.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,17 +4,18 @@ 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";
11
11
  import { createVoidTrack } from "./voidtrack.js";
12
+ import { addWebhook, dropWebhook, looksLikeWebhook, moveWebhook, removeWebhook, testWebhook, webhookFor } from "./notify.js";
12
13
  import { parseCron } from "./cron.js";
13
14
  import { trackersInUse } from "./control.js";
14
15
  import { runnerService } from "./service.js";
15
16
  import { initProfile, profileChecks, profileDir, readRules, renderClaudeSettings, CLAUDE_SETTINGS, RULES_FILE } from "./profile.js";
16
17
  import { createLinear } from "./linear.js";
17
- import { TRIGGER_FORMS, duplicateTriggerWarnings, moveKey, parseTrigger, renameKey, sameTrigger, validateAgentFolder, validateConfig, validateWorkflow, workflowsUsing } from "./manage.js";
18
+ import { TRIGGER_FORMS, duplicateTriggerWarnings, moveKey, parseTrigger, projectProblems, renameKey, sameTrigger, validateAgentFolder, validateConfig, validateWorkflow, workflowsUsing } from "./manage.js";
18
19
  import { parseStep, resolveName, stepNames } from "./names.js";
19
20
  import { findRepos } from "./repos.js";
20
21
  import { defaultBranch, sh } from "./git.js";
@@ -239,7 +240,8 @@ async function promptProject(ui, config, current = {}, edit = false, allowExisti
239
240
  const describeProject = (config, name) => {
240
241
  const p = config.projects[name];
241
242
  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'}`;
243
+ const where = [p.workspace ? `workspace ${p.workspace}` : '', p.match?.length ? `matches ${p.match.join(', ')}` : ''].filter(Boolean);
244
+ 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
245
  };
244
246
  // Named local repos the workflows run in. Triggers point at them by name.
245
247
  async function setupProjects(ui, config, log) {
@@ -366,15 +368,19 @@ const USAGE = [
366
368
  ['workflow rm NAME [--yes]', 'Delete a workflow'],
367
369
  ['workflow move NAME (--first | --last | --before OTHER | --after OTHER)', 'Change the match order'],
368
370
  ['projects [--json]', 'List projects'],
369
- ['project add [NAME] [--path P] [--base B]', 'Add a project; prompts for what is missing'],
371
+ ['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
372
  ['project show NAME [--json]', 'Show one project'],
371
- ['project edit NAME [--path P] [--base B]', 'Change a project; no flags asks'],
373
+ ['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
374
  ['project rename OLD NEW', 'Rename a project and the workflows that use it'],
373
375
  ['project rm NAME [--yes]', 'Delete a project nothing uses'],
376
+ ['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'],
377
+ ['workspace list [--json]', 'List workspaces and their projects'],
378
+ ['workspace rm NAME', 'Delete a workspace no project uses'],
374
379
  ['homes [--json]', 'Show where the agents are linked'],
375
380
  ['link DIR [--claude | --codex]', 'Link the agents into a config folder'],
376
381
  ['unlink DIR', 'Remove the agents from a config folder'],
377
382
  ['key [--voidtrack]', 'Store or replace the Linear or VoidTrack API key (reads stdin without a terminal)'],
383
+ ['discord [--workflow W] [--test] [--rm]', 'Store, test or remove the Discord webhook for run pings, global or for one workflow; it is stored only after a test message goes through (reads stdin without a terminal)'],
378
384
  ['profile [init]', `Your profile: rules (${RULES_FILE}) and Claude settings (${CLAUDE_SETTINGS}) every agent run carries; init creates it`],
379
385
  ['validate [DIR]', `Check an agent folder (DIR/${AGENT_FILE} and its prompts), or the config and every folder under agents/defs`],
380
386
  ['help [COMMAND]', 'Show this list, or one command'],
@@ -658,8 +664,14 @@ const workflowSubs = {
658
664
  if (!teams)
659
665
  return 1;
660
666
  const r = await promptWorkflow(ctx.ui, config, teams, { name, workflow: current }, true);
661
- if (r.name !== name)
667
+ if (r.name !== name) {
668
+ const refused = moveWebhook(ctx.configDir, name, r.name);
669
+ if (refused) {
670
+ ctx.log(refused);
671
+ return 1;
672
+ }
662
673
  config.workflows = renameKey(config.workflows, name, r.name);
674
+ }
663
675
  config.workflows[r.name] = r.workflow;
664
676
  return saveWorkflow(config, r.name, r.workflow, ctx);
665
677
  }
@@ -712,6 +724,11 @@ const workflowSubs = {
712
724
  ctx.log(`A workflow named ${next} already exists.`);
713
725
  return 1;
714
726
  }
727
+ const refused = moveWebhook(ctx.configDir, old, next);
728
+ if (refused) {
729
+ ctx.log(refused);
730
+ return 1;
731
+ }
715
732
  config.workflows = renameKey(config.workflows, old, next);
716
733
  save(ctx, config);
717
734
  ctx.log(`Workflow ${old} is now ${next}.`);
@@ -738,6 +755,7 @@ const workflowSubs = {
738
755
  }
739
756
  delete config.workflows[name];
740
757
  save(ctx, config);
758
+ dropWebhook(ctx.configDir, name);
741
759
  ctx.log(`Deleted workflow ${name}.`);
742
760
  return 0;
743
761
  },
@@ -789,9 +807,31 @@ function pickProject(config, input, ctx) {
789
807
  }
790
808
  return r.name;
791
809
  }
810
+ const PROJECT_FLAGS = { path: { type: 'string' }, base: { type: 'string' }, workspace: { type: 'string' }, match: { type: 'string', multiple: true } };
811
+ // Applies --workspace and --match to a project. An empty value clears either.
812
+ // Returns a complaint when the workspace or a match does not fit.
813
+ function applyWorkspaceFlags(config, name, project, v) {
814
+ if (v.workspace !== undefined) {
815
+ const ws = String(v.workspace).trim();
816
+ if (ws && ws !== DEFAULT_WORKSPACE && !config.workspaces[ws])
817
+ return `No workspace named ${ws}. vsf agents workspace add creates it.`;
818
+ if (ws && ws !== DEFAULT_WORKSPACE)
819
+ project.workspace = ws;
820
+ else
821
+ delete project.workspace;
822
+ }
823
+ if (v.match !== undefined) {
824
+ const match = v.match.map((m) => m.trim()).filter(Boolean);
825
+ if (match.length)
826
+ project.match = match;
827
+ else
828
+ delete project.match;
829
+ }
830
+ return projectProblems({ ...config.projects, [name]: project }, config.workspaces)[0] ?? null;
831
+ }
792
832
  const projectSubs = {
793
833
  async add(args, ctx) {
794
- const p = begin('project add', args, { path: { type: 'string' }, base: { type: 'string' } }, ctx);
834
+ const p = begin('project add', args, PROJECT_FLAGS, ctx);
795
835
  if (typeof p === 'number')
796
836
  return p;
797
837
  if (p.positionals.length > 1) {
@@ -838,6 +878,11 @@ const projectSubs = {
838
878
  name = r.name;
839
879
  project = r.project;
840
880
  }
881
+ const bad = applyWorkspaceFlags(config, name, project, p.values);
882
+ if (bad) {
883
+ ctx.log(bad);
884
+ return 1;
885
+ }
841
886
  config.projects[name] = project;
842
887
  save(ctx, config);
843
888
  ctx.log(`Project ${name}: ${tilde(project.path)}${project.base ? `, PRs into ${project.base}` : ''}`);
@@ -861,12 +906,15 @@ const projectSubs = {
861
906
  ctx.log(`Project ${name}`);
862
907
  ctx.log(`Path: ${tilde(proj.path)}`);
863
908
  ctx.log(`PRs into: ${proj.base ?? 'the default branch'}`);
909
+ ctx.log(`Workspace: ${workspaceOf(proj)}`);
910
+ if (proj.match?.length)
911
+ ctx.log(`Matches: ${proj.match.join(', ')}`);
864
912
  ctx.log(`Used by: ${used.length ? used.join(', ') : 'no workflows'}`);
865
913
  }
866
914
  return 0;
867
915
  },
868
916
  async edit(args, ctx) {
869
- const p = begin('project edit', args, { path: { type: 'string' }, base: { type: 'string' } }, ctx);
917
+ const p = begin('project edit', args, PROJECT_FLAGS, ctx);
870
918
  if (typeof p === 'number')
871
919
  return p;
872
920
  if (!needArgs(p, 1, 'project edit', ctx))
@@ -877,9 +925,9 @@ const projectSubs = {
877
925
  return 1;
878
926
  const current = config.projects[name];
879
927
  const v = p.values;
880
- if (v.path === undefined && v.base === undefined) {
928
+ if (v.path === undefined && v.base === undefined && v.workspace === undefined && v.match === undefined) {
881
929
  if (!interactive()) {
882
- ctx.log('Nothing to change. Give --path or --base, or run this in a terminal to be asked.');
930
+ ctx.log('Nothing to change. Give --path, --base, --workspace or --match, or run this in a terminal to be asked.');
883
931
  ctx.log(usageOf('project edit'));
884
932
  return 2;
885
933
  }
@@ -887,7 +935,8 @@ const projectSubs = {
887
935
  }
888
936
  else {
889
937
  const path = v.path !== undefined ? resolve(expandHome(v.path)) : current.path;
890
- if (!isRepo(path)) {
938
+ // Only a new path needs checking; a workspace or match change keeps the repo.
939
+ if (v.path !== undefined && !isRepo(path)) {
891
940
  ctx.log(`${tilde(path)} is not a git repository.`);
892
941
  return 1;
893
942
  }
@@ -899,6 +948,11 @@ const projectSubs = {
899
948
  else
900
949
  delete next.base;
901
950
  }
951
+ const bad = applyWorkspaceFlags(config, name, next, v);
952
+ if (bad) {
953
+ ctx.log(bad);
954
+ return 1;
955
+ }
902
956
  config.projects[name] = next;
903
957
  }
904
958
  save(ctx, config);
@@ -960,6 +1014,87 @@ const projectSubs = {
960
1014
  return 0;
961
1015
  },
962
1016
  };
1017
+ // ---- workspaces ----
1018
+ const projectsIn = (config, ws) => Object.entries(config.projects).filter(([, p]) => workspaceOf(p) === ws).map(([n]) => n);
1019
+ const workspaceSubs = {
1020
+ async add(args, ctx) {
1021
+ const p = begin('workspace add', args, { root: { type: 'string' }, context: { type: 'string' } }, ctx);
1022
+ if (typeof p === 'number')
1023
+ return p;
1024
+ if (!needArgs(p, 1, 'workspace add', ctx))
1025
+ return 2;
1026
+ const name = p.positionals[0].trim();
1027
+ if (!WORKSPACE_NAME.test(name)) {
1028
+ ctx.log('A workspace name is letters, digits, dashes and underscores.');
1029
+ return 2;
1030
+ }
1031
+ if (!p.values.root) {
1032
+ ctx.log('Missing --root.');
1033
+ ctx.log(usageOf('workspace add'));
1034
+ return 2;
1035
+ }
1036
+ const config = readAgentsConfig(ctx.configDir);
1037
+ if (config.workspaces[name]) {
1038
+ ctx.log(`A workspace named ${name} already exists.`);
1039
+ return 1;
1040
+ }
1041
+ const root = resolve(expandHome(p.values.root));
1042
+ if (!existsSync(root)) {
1043
+ ctx.log(`${tilde(root)} does not exist.`);
1044
+ return 1;
1045
+ }
1046
+ const context = p.values.context ? resolve(root, expandHome(p.values.context)) : undefined;
1047
+ if (context && !existsSync(context)) {
1048
+ ctx.log(`${tilde(context)} does not exist.`);
1049
+ return 1;
1050
+ }
1051
+ config.workspaces[name] = { root, ...(context ? { context } : {}) };
1052
+ save(ctx, config);
1053
+ ctx.log(`Workspace ${name}: ${tilde(root)}. vsf agents project edit P --workspace ${name} puts a project in it.`);
1054
+ return 0;
1055
+ },
1056
+ async list(args, ctx) {
1057
+ const p = begin('workspace list', args, { json: { type: 'boolean' } }, ctx);
1058
+ if (typeof p === 'number')
1059
+ return p;
1060
+ const config = readAgentsConfig(ctx.configDir);
1061
+ const names = [...new Set([...Object.keys(config.workspaces), ...Object.values(config.projects).map(workspaceOf)])];
1062
+ if (p.values.json) {
1063
+ out(ctx, names.map((name) => ({ name, ...config.workspaces[name], projects: projectsIn(config, name) })));
1064
+ return 0;
1065
+ }
1066
+ if (!names.length)
1067
+ ctx.log('No workspaces yet. Every project runs in the default one; vsf agents workspace add creates another.');
1068
+ for (const name of names) {
1069
+ const w = config.workspaces[name];
1070
+ const projects = projectsIn(config, name);
1071
+ ctx.log(`${name} ${w ? tilde(w.root) : 'no root'}${w?.context ? `, context ${tilde(w.context)}` : ''}, ${projects.length ? projects.join(', ') : 'no projects'}`);
1072
+ }
1073
+ return 0;
1074
+ },
1075
+ async rm(args, ctx) {
1076
+ const p = begin('workspace rm', args, {}, ctx);
1077
+ if (typeof p === 'number')
1078
+ return p;
1079
+ if (!needArgs(p, 1, 'workspace rm', ctx))
1080
+ return 2;
1081
+ const config = readAgentsConfig(ctx.configDir);
1082
+ const name = p.positionals[0].trim();
1083
+ if (!config.workspaces[name]) {
1084
+ ctx.log(`No workspace named ${name}.`);
1085
+ return 1;
1086
+ }
1087
+ const used = projectsIn(config, name);
1088
+ if (used.length) {
1089
+ ctx.log(`Used by ${used.join(', ')}. Move those projects first: vsf agents project edit P --workspace OTHER.`);
1090
+ return 1;
1091
+ }
1092
+ delete config.workspaces[name];
1093
+ save(ctx, config);
1094
+ ctx.log(`Deleted workspace ${name}.`);
1095
+ return 0;
1096
+ },
1097
+ };
963
1098
  // ---- homes ----
964
1099
  // Per enabled agent: ours, taken by something else, or missing.
965
1100
  function homeReport(config, h) {
@@ -1099,6 +1234,11 @@ const agents = {
1099
1234
  if (!(await setupKey(ui, ctx.configDir, 'voidtrack')))
1100
1235
  ctx.log('VoidTrack is not connected yet. Run vsf agents key --voidtrack.');
1101
1236
  }
1237
+ if (!webhookFor(ctx.configDir) && (await ui.confirm('Send run pings to Discord?', false))) {
1238
+ const url = await ui.secret('Paste the Discord webhook URL', (v) => (looksLikeWebhook(v.trim()) ? undefined : 'Not a Discord webhook URL'));
1239
+ if (!(await addWebhook(ctx.configDir, url.trim(), undefined, ui.info)))
1240
+ ctx.log('Discord is not connected. Run vsf agents discord to retry.');
1241
+ }
1102
1242
  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.'))) {
1103
1243
  const ok = runnerService().start(ctx.home, [...ctx.selfCommand, 'agents', 'tick'], join(agentsDir(ctx.configDir), 'runner.log'));
1104
1244
  ctx.log(ok ? 'Runner started.' : 'Could not start the runner. Try vsf agents start.');
@@ -1137,6 +1277,8 @@ const agents = {
1137
1277
  checks.push({ name, status: 'fail', detail: err.message, fix: `vsf agents key${kind === 'linear' ? '' : ` --${kind}`}` });
1138
1278
  }
1139
1279
  }
1280
+ if (webhookFor(ctx.configDir))
1281
+ checks.push({ name: 'discord', status: 'ok', detail: 'webhook set' });
1140
1282
  const workflows = Object.entries(saved.workflows);
1141
1283
  const broken = workflows.filter(([, c]) => !saved.projects[c.project] || !isRepo(saved.projects[c.project].path)).map(([n]) => n);
1142
1284
  checks.push(!workflows.length
@@ -1242,6 +1384,7 @@ const agents = {
1242
1384
  return 0;
1243
1385
  },
1244
1386
  project: (args, ctx) => group('project', projectSubs, args, ctx),
1387
+ workspace: (args, ctx) => group('workspace', workspaceSubs, args, ctx),
1245
1388
  homes: homeCommands.homes,
1246
1389
  link: homeCommands.link,
1247
1390
  unlink: homeCommands.unlink,
@@ -1267,6 +1410,38 @@ const agents = {
1267
1410
  }
1268
1411
  return (await verifyAndStore(ctx.configDir, key, ctx.log, ctx.log, kind)) ? 0 : 1;
1269
1412
  },
1413
+ async discord(args, ctx) {
1414
+ const p = begin('discord', args, { workflow: { type: 'string' }, test: { type: 'boolean' }, rm: { type: 'boolean' } }, ctx);
1415
+ if (typeof p === 'number')
1416
+ return p;
1417
+ if (p.positionals.length) {
1418
+ ctx.log('Webhooks are not accepted on the command line. Pipe the URL in instead.');
1419
+ return 2;
1420
+ }
1421
+ if (p.values.test && p.values.rm) {
1422
+ ctx.log('Give --test or --rm, not both.');
1423
+ return 2;
1424
+ }
1425
+ let workflow;
1426
+ if (p.values.workflow) {
1427
+ const name = pickWorkflow(readAgentsConfig(ctx.configDir), p.values.workflow, ctx);
1428
+ if (!name)
1429
+ return 1;
1430
+ workflow = name;
1431
+ }
1432
+ if (p.values.test)
1433
+ return (await testWebhook(ctx.configDir, workflow, ctx.log)) ? 0 : 1;
1434
+ if (p.values.rm)
1435
+ return removeWebhook(ctx.configDir, workflow, ctx.log) ? 0 : 1;
1436
+ const url = interactive()
1437
+ ? await ctx.ui.secret('Paste the Discord webhook URL', (v) => (looksLikeWebhook(v.trim()) ? undefined : 'Not a Discord webhook URL'))
1438
+ : readFileSync(0, 'utf8');
1439
+ if (!url.trim()) {
1440
+ ctx.log('No webhook on stdin.');
1441
+ return 2;
1442
+ }
1443
+ return (await addWebhook(ctx.configDir, url.trim(), workflow, ctx.log)) ? 0 : 1;
1444
+ },
1270
1445
  async help(args, ctx) {
1271
1446
  const want = args.join(' ').trim();
1272
1447
  if (!want) {
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')))
@@ -0,0 +1,30 @@
1
+ export declare const looksLikeWebhook: (url: string) => boolean;
2
+ export declare const slug: (name: string) => string;
3
+ export declare const webhookFor: (configDir: string, workflow?: string, platform?: NodeJS.Platform) => string | null;
4
+ export declare function postDiscord(url: string, content: string, fetcher?: typeof fetch, timeoutMs?: number): Promise<string | null>;
5
+ export type RunKind = 'ready' | 'passed' | 'parked' | 'failed';
6
+ export interface RunEvent {
7
+ kind: RunKind;
8
+ card: string;
9
+ title: string;
10
+ workflow: string;
11
+ url?: string;
12
+ prUrl?: string;
13
+ reason?: string;
14
+ }
15
+ export declare function runMessage(e: RunEvent): string;
16
+ export declare function notifyRun(configDir: string, e: RunEvent, log: (line: string) => void, fetcher?: typeof fetch, platform?: NodeJS.Platform): Promise<void>;
17
+ export declare function notifyRunner(configDir: string, paused: boolean, reason: string, log: (line: string) => void, fetcher?: typeof fetch, platform?: NodeJS.Platform): Promise<void>;
18
+ export interface NotifyState {
19
+ summaryDay?: string;
20
+ summaryAt?: string;
21
+ }
22
+ export declare function readNotifyState(configDir: string): NotifyState;
23
+ export declare function writeNotifyState(configDir: string, state: NotifyState): void;
24
+ export declare function summaryMessage(configDir: string, since: Date, paused: boolean): string | null;
25
+ export declare function sendSummary(configDir: string, paused: boolean, log: (line: string) => void, now?: Date, fetcher?: typeof fetch, platform?: NodeJS.Platform): Promise<void>;
26
+ export declare function addWebhook(configDir: string, url: string, workflow: string | undefined, log: (line: string) => void, fetcher?: typeof fetch, platform?: NodeJS.Platform): Promise<boolean>;
27
+ export declare function testWebhook(configDir: string, workflow: string | undefined, log: (line: string) => void, fetcher?: typeof fetch, platform?: NodeJS.Platform): Promise<boolean>;
28
+ export declare function removeWebhook(configDir: string, workflow: string | undefined, log: (line: string) => void, platform?: NodeJS.Platform): boolean;
29
+ export declare function moveWebhook(configDir: string, from: string, to: string, platform?: NodeJS.Platform): string | null;
30
+ export declare function dropWebhook(configDir: string, workflow: string, platform?: NodeJS.Platform): void;