@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/manage.js CHANGED
@@ -1,42 +1,154 @@
1
- import { existsSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { AGENTS, STEPS } from "./config.js";
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { basename, join, resolve } from 'node:path';
3
+ import { AGENTS, AGENT_FILE, OUTPUTS, STEP_ID, isBuiltin, readAgentFolder, stepFile, describeTrigger, triggerSpec } from "./config.js";
4
+ import { parseCron } from "./cron.js";
4
5
  const isInt = (v, min, max) => typeof v === 'number' && Number.isInteger(v) && v >= min && v <= max;
6
+ // The line a step's prompt must ask for, so the runner can read the result.
7
+ const RESULT_LINE = { plan: 'RESULT:', commits: 'RESULT:', verdict: 'VERDICT:' };
8
+ // Everything wrong with one step. `dir` is the agent folder its files are in.
9
+ export function validateStep(step, config, dir) {
10
+ if (typeof step === 'string') {
11
+ if (!isBuiltin(step))
12
+ return [`Unknown step ${step}.`];
13
+ return config.enabled.length && !config.enabled.includes(step) ? [`Step ${step} is not enabled.`] : [];
14
+ }
15
+ if (!isObj(step))
16
+ return ['A step must be a step id or an object.'];
17
+ const problems = [];
18
+ const id = typeof step.id === 'string' && STEP_ID.test(step.id) ? step.id : null;
19
+ if (!id)
20
+ problems.push(`Step id ${String(step.id)} must be lowercase letters, digits and dashes.`);
21
+ else if (isBuiltin(id))
22
+ problems.push(`Step id ${id} is a built-in step; use "${id}" on its own, or another id.`);
23
+ const name = id ?? 'A step';
24
+ if (!OUTPUTS.includes(step.output))
25
+ problems.push(`${name} output must be one of: ${OUTPUTS.join(', ')}.`);
26
+ if (step.label !== undefined && typeof step.label !== 'string')
27
+ problems.push(`${name} label must be text.`);
28
+ if (step.prompt !== undefined && typeof step.prompt !== 'string')
29
+ problems.push(`${name} prompt must be a file name.`);
30
+ if (step.skill !== undefined && typeof step.skill !== 'string')
31
+ problems.push(`${name} skill must be a name or a folder.`);
32
+ const sources = [step.skill, step.prompt].filter((v) => v !== undefined).length;
33
+ if (step.output === 'publish') {
34
+ if (sources)
35
+ problems.push(`${name} only pushes the branch, so it takes no skill or prompt.`);
36
+ }
37
+ else if (sources !== 1)
38
+ problems.push(`${name} needs exactly one of skill or prompt.`);
39
+ else if (typeof (step.prompt ?? step.skill) === 'string') {
40
+ const file = stepFile(step, dir);
41
+ if (!file || !existsSync(file))
42
+ problems.push(`${name} ${step.prompt ? 'prompt' : 'skill'} ${file ?? ''} does not exist.`);
43
+ else {
44
+ const want = RESULT_LINE[step.output];
45
+ if (want && !readFileSync(file, 'utf8').includes(want))
46
+ problems.push(`${name} prompt never asks for a ${want} line.`);
47
+ }
48
+ }
49
+ if (step.engine !== undefined && step.engine !== 'claude' && step.engine !== 'codex')
50
+ problems.push(`${name} engine must be claude or codex.`);
51
+ if (step.model !== undefined && typeof step.model !== 'string')
52
+ problems.push(`${name} model must be text.`);
53
+ if (step.timeoutMinutes !== undefined && !isInt(step.timeoutMinutes, 1, 480))
54
+ problems.push(`${name} timeoutMinutes must be a whole number from 1 to 480.`);
55
+ if (step.write !== undefined && typeof step.write !== 'boolean')
56
+ problems.push(`${name} write must be true or false.`);
57
+ else if (step.write === false && step.output === 'commits')
58
+ problems.push(`${name} makes commits, so write cannot be false.`);
59
+ return problems;
60
+ }
5
61
  // Everything wrong with one workflow, in plain words. Empty means it is fine.
6
- export function validateWorkflow(config, workflow) {
62
+ // `dir` is the agent folder a folder workflow comes from.
63
+ export function validateWorkflow(config, workflow, dir) {
7
64
  const problems = [];
8
65
  if (!workflow.triggers?.length)
9
66
  problems.push('Needs at least one trigger.');
10
67
  for (const t of workflow.triggers ?? []) {
11
- if (!t.team?.trim())
12
- problems.push('A trigger has no team.');
13
- if (!t.label?.trim())
14
- problems.push('A trigger has no label.');
68
+ if (t.source === 'cron') {
69
+ if (!parseCron(t.schedule ?? ''))
70
+ problems.push(`Schedule ${t.schedule} is not a five field cron schedule, like 0 5 * * 1.`);
71
+ if (!t.task?.trim())
72
+ problems.push('A schedule has no task.');
73
+ }
74
+ else if (t.source !== 'linear' && t.source !== 'voidtrack')
75
+ problems.push(`Unknown trigger source ${t.source}.`);
76
+ else {
77
+ if (!t.team?.trim())
78
+ problems.push('A trigger has no team.');
79
+ if (!t.label?.trim())
80
+ problems.push('A trigger has no label.');
81
+ }
15
82
  }
16
83
  if (!workflow.steps?.length)
17
84
  problems.push('Needs at least one step.');
18
- for (const s of workflow.steps ?? []) {
19
- if (!(s in STEPS))
20
- problems.push(`Unknown step ${s}.`);
21
- else if (config.enabled.length && !config.enabled.includes(s))
22
- problems.push(`Step ${s} is not enabled.`);
85
+ const ids = new Set();
86
+ for (const s of Array.isArray(workflow.steps) ? workflow.steps : []) {
87
+ problems.push(...validateStep(s, config, dir));
88
+ const id = typeof s === 'string' ? s : isObj(s) ? s.id : undefined;
89
+ if (typeof id === 'string' && !isBuiltin(id)) {
90
+ if (ids.has(id))
91
+ problems.push(`Step id ${id} is used twice.`);
92
+ ids.add(id);
93
+ }
23
94
  }
24
95
  if (!config.projects[workflow.project])
25
96
  problems.push(`Project ${workflow.project} does not exist.`);
26
97
  if (!isInt(workflow.maxRounds, 1, 9))
27
98
  problems.push('Rounds must be a whole number from 1 to 9.');
99
+ const wb = workflow.writeBack;
100
+ if (wb !== undefined) {
101
+ if (!isObj(wb))
102
+ problems.push('writeBack must be an object.');
103
+ else {
104
+ if (wb.labels !== undefined && typeof wb.labels !== 'boolean')
105
+ problems.push('writeBack.labels must be true or false.');
106
+ if (wb.comment !== undefined && typeof wb.comment !== 'boolean')
107
+ problems.push('writeBack.comment must be true or false.');
108
+ if (wb.state !== undefined && wb.state !== null && (typeof wb.state !== 'string' || !wb.state.trim()))
109
+ problems.push('writeBack.state must be a state name, or null to never move the card.');
110
+ }
111
+ }
28
112
  return problems;
29
113
  }
30
- // "ENG:vs-plan" into a trigger; the team is upper-cased.
114
+ // Everything wrong with an agent folder: its agent.json, its workflow and
115
+ // prompt files, and a name config.json already uses.
116
+ export function validateAgentFolder(dir, config) {
117
+ let workflow;
118
+ try {
119
+ workflow = readAgentFolder(dir);
120
+ }
121
+ catch (err) {
122
+ return [`${AGENT_FILE}: ${err.message}`];
123
+ }
124
+ const problems = validateWorkflow(config, workflow, dir);
125
+ const name = basename(dir);
126
+ const from = config.folders?.[name];
127
+ if (config.workflows[name] && (!from || resolve(from) !== resolve(dir)))
128
+ problems.push(`config.json already has a workflow named ${name}, and it wins. Rename the folder.`);
129
+ return problems;
130
+ }
131
+ export const TRIGGER_FORMS = 'TEAM:LABEL (Linear), voidtrack:TEAM:LABEL, or cron:SCHEDULE:TASK';
132
+ // "ENG:vs-plan" (Linear, also "linear:ENG:vs-plan"), "voidtrack:ORG:vs-start"
133
+ // or "cron:0 5 * * 1:Update the dependencies" into a trigger. Teams are
134
+ // upper-cased; a schedule must parse.
31
135
  export function parseTrigger(input) {
32
- const i = input.indexOf(':');
33
- if (i < 0)
136
+ const text = input.trim();
137
+ // A source prefix decides the form; a bare TEAM:LABEL is Linear.
138
+ if (text.startsWith('cron:')) {
139
+ const cron = /^cron:([^:]+):([\s\S]+)$/.exec(text);
140
+ const schedule = cron?.[1].trim().replace(/\s+/g, ' ') ?? '';
141
+ const task = cron?.[2].trim() ?? '';
142
+ return parseCron(schedule) && task ? { source: 'cron', schedule, task } : null;
143
+ }
144
+ const m = /^(linear|voidtrack):/.test(text) ? /^(linear|voidtrack):([^:]+):(.+)$/.exec(text) : /^()([^:]+):(.+)$/.exec(text);
145
+ if (!m)
34
146
  return null;
35
- const team = input.slice(0, i).trim().toUpperCase();
36
- const label = input.slice(i + 1).trim();
37
- return team && label ? { source: 'linear', team, label } : null;
147
+ const team = m[2].trim().toUpperCase();
148
+ const label = m[3].trim();
149
+ return team && label ? { source: (m[1] || 'linear'), team, label } : null;
38
150
  }
39
- export const sameTrigger = (a, b) => a.team === b.team && a.label === b.label;
151
+ export const sameTrigger = (a, b) => triggerSpec(a) === triggerSpec(b);
40
152
  // Warning lines for triggers this workflow shares with another one. Only the
41
153
  // first workflow in order starts a card, so the rest never get it.
42
154
  export function duplicateTriggerWarnings(workflows, name, workflow) {
@@ -47,7 +159,7 @@ export function duplicateTriggerWarnings(workflows, name, workflow) {
47
159
  for (const other of owners) {
48
160
  if (other === name)
49
161
  continue;
50
- lines.push(`Warning: ${other} also starts on ${t.team} + ${t.label}, and ${first} wins. vsf agents workflow move changes the order.`);
162
+ lines.push(`Warning: ${other} also starts on ${describeTrigger(t)}, and ${first} wins. vsf agents workflow move changes the order.`);
51
163
  }
52
164
  }
53
165
  return lines;
@@ -99,6 +211,8 @@ export function validateConfig(value, saved) {
99
211
  }
100
212
  if (!Array.isArray(value.enabled) || value.enabled.some((a) => typeof a !== 'string' || !(a in AGENTS)))
101
213
  problems.push(`enabled must list agents from: ${Object.keys(AGENTS).join(', ')}.`);
214
+ if (value.commits !== undefined && (!isObj(value.commits) || Object.entries(value.commits).some(([k, v]) => !['signOff', 'dropAiTrailers'].includes(k) || typeof v !== 'boolean')))
215
+ problems.push('commits may only set signOff and dropAiTrailers, each true or false.');
102
216
  if (!Array.isArray(value.homes) || value.homes.some((h) => !isObj(h) || (h.kind !== 'claude' && h.kind !== 'codex') || typeof h.dir !== 'string'))
103
217
  problems.push('homes must be a list of { kind: claude or codex, dir }.');
104
218
  if (!isObj(value.workflows))
@@ -106,6 +220,9 @@ export function validateConfig(value, saved) {
106
220
  else {
107
221
  const ctx = { enabled: Array.isArray(value.enabled) ? value.enabled : [], projects: (projects ?? {}) };
108
222
  for (const [name, w] of Object.entries(value.workflows)) {
223
+ // Folder workflows are checked with their folder, by vsf agents validate.
224
+ if (isObj(value.folders) && value.folders[name])
225
+ continue;
109
226
  if (!isObj(w))
110
227
  problems.push(`Workflow ${name} must be an object.`);
111
228
  else {
package/dist/names.d.ts CHANGED
@@ -5,4 +5,5 @@ export declare function resolveName(names: string[], input: string, kind: string
5
5
  error: string;
6
6
  };
7
7
  export declare function parseStep(input: string): StepId | null;
8
+ export declare const parseFrom: (input: string) => string | null;
8
9
  export declare const stepNames: () => string;
package/dist/names.js CHANGED
@@ -1,4 +1,4 @@
1
- import { STEPS } from "./config.js";
1
+ import { STEPS, STEP_ID } from "./config.js";
2
2
  // A workflow or project named in a command: the exact name, or the start of
3
3
  // exactly one name, e.g. "tether" for tether-relay-build.
4
4
  export function resolveName(names, input, kind) {
@@ -23,4 +23,7 @@ export function parseStep(input) {
23
23
  }
24
24
  return null;
25
25
  }
26
+ // A step for --from: a built-in step by any of its names, or any id a custom
27
+ // step could have. Whether the workflow has it is checked once it is known.
28
+ export const parseFrom = (input) => parseStep(input) ?? (STEP_ID.test(input) ? input : null);
26
29
  export const stepNames = () => Object.keys(STEPS).map((id) => id.replace(/^vs-/, '')).join(', ');
@@ -0,0 +1,10 @@
1
+ import type { Check } from './types.ts';
2
+ export declare const profileDir: (configDir: string) => string;
3
+ export declare const RULES_FILE = "RULES.md";
4
+ export declare const CLAUDE_SETTINGS = "claude-settings.json";
5
+ export declare function readRules(configDir: string): string;
6
+ export declare function renderClaudeSettings(configDir: string): string | null;
7
+ export declare function claudeSettingsFile(configDir: string, runDir: string): string | undefined;
8
+ export declare const rulesSection: (rules: string) => string;
9
+ export declare function initProfile(configDir: string): string[];
10
+ export declare function profileChecks(configDir: string): Check[];
@@ -0,0 +1,70 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { agentsDir } from "./config.js";
4
+ // Your profile: what every agent run carries, on any machine, without touching
5
+ // a Claude or Codex folder. RULES.md is added to every step's prompt, and
6
+ // claude-settings.json is passed to Claude runs, with {{profile}} standing for
7
+ // this folder so hooks can live in it.
8
+ export const profileDir = (configDir) => join(agentsDir(configDir), 'profile');
9
+ export const RULES_FILE = 'RULES.md';
10
+ export const CLAUDE_SETTINGS = 'claude-settings.json';
11
+ export function readRules(configDir) {
12
+ const path = join(profileDir(configDir), RULES_FILE);
13
+ return existsSync(path) ? readFileSync(path, 'utf8').trim() : '';
14
+ }
15
+ // The Claude settings with {{profile}} filled in, as text, or null when there
16
+ // are none. Throws when the file is not a JSON object.
17
+ export function renderClaudeSettings(configDir) {
18
+ const dir = profileDir(configDir);
19
+ const path = join(dir, CLAUDE_SETTINGS);
20
+ if (!existsSync(path))
21
+ return null;
22
+ const text = readFileSync(path, 'utf8').replaceAll('{{profile}}', dir);
23
+ const value = JSON.parse(text);
24
+ if (!value || typeof value !== 'object' || Array.isArray(value))
25
+ throw new Error(`${CLAUDE_SETTINGS} must be a JSON object`);
26
+ return text;
27
+ }
28
+ // Writes the rendered settings into a run folder and returns the path, or
29
+ // undefined when the profile has none.
30
+ export function claudeSettingsFile(configDir, runDir) {
31
+ const text = renderClaudeSettings(configDir);
32
+ if (text === null)
33
+ return undefined;
34
+ const out = join(runDir, CLAUDE_SETTINGS);
35
+ writeFileSync(out, text);
36
+ return out;
37
+ }
38
+ // The prompt section for your rules, empty when there are none.
39
+ export const rulesSection = (rules) => (rules ? `# Your rules\n\nFollow these in everything you write, including commits and comments.\n\n${rules}` : '');
40
+ const STARTER_RULES = `<!-- Rules every agent follows on every run, on every machine.
41
+ Write them as you would tell a teammate. Delete this comment. -->
42
+ `;
43
+ // Creates the folder with a starter RULES.md. Existing files are kept.
44
+ export function initProfile(configDir) {
45
+ const dir = profileDir(configDir);
46
+ mkdirSync(dir, { recursive: true });
47
+ const made = [];
48
+ const rules = join(dir, RULES_FILE);
49
+ if (!existsSync(rules)) {
50
+ writeFileSync(rules, STARTER_RULES);
51
+ made.push(RULES_FILE);
52
+ }
53
+ return made;
54
+ }
55
+ export function profileChecks(configDir) {
56
+ const dir = profileDir(configDir);
57
+ if (!existsSync(dir))
58
+ return [];
59
+ const parts = [];
60
+ if (readRules(configDir).replace(/<!--[\s\S]*?-->/g, '').trim())
61
+ parts.push('rules');
62
+ try {
63
+ if (renderClaudeSettings(configDir) !== null)
64
+ parts.push('Claude settings');
65
+ }
66
+ catch (err) {
67
+ return [{ name: 'agent profile', status: 'fail', detail: `${CLAUDE_SETTINGS}: ${err.message}`, fix: `fix ${join(dir, CLAUDE_SETTINGS)}` }];
68
+ }
69
+ return [parts.length ? { name: 'agent profile', status: 'ok', detail: parts.join(', ') } : { name: 'agent profile', status: 'warn', detail: 'empty', fix: `add rules to ${join(dir, RULES_FILE)}` }];
70
+ }
package/dist/runner.d.ts CHANGED
@@ -1,23 +1,30 @@
1
- import { type AgentsConfig, type Workflow, type Project, type StepId, type Trigger } from './config.ts';
2
- import type { Card, Linear } from './linear.ts';
1
+ import { type AgentsConfig, type Workflow, type Project, type Trigger } from './config.ts';
2
+ import type { Card } from './linear.ts';
3
+ import { type Tracker, type Trackers } from './tracker.ts';
3
4
  export interface RunContext {
4
5
  configDir: string;
5
6
  config: AgentsConfig;
6
- linear: Linear;
7
+ trackers: Trackers;
7
8
  log: (line: string) => void;
8
9
  selfCommand: string[];
9
10
  }
10
11
  export declare function summarize(output: string): string;
11
12
  export declare function runCard(card: Card, trigger: Trigger, workflowName: string, workflow: Workflow, project: Project, rc: RunContext, opts?: {
12
- from?: StepId;
13
+ from?: string;
13
14
  }): Promise<'verified' | 'changes' | 'parked'>;
14
- export declare function nextJob(rc: RunContext, only?: string, workflowName?: string): Promise<{
15
+ type Job = {
15
16
  card: Card;
16
17
  trigger: Trigger;
17
18
  workflow: string;
18
- } | null>;
19
- export declare function cancelCard(linear: Linear, configDir: string, cardId: string, label: string): Promise<boolean>;
19
+ };
20
+ export declare function cronCard(workflow: string, task: string, at: Date): Card;
21
+ export declare function nextJob(rc: RunContext, only?: string, workflowName?: string, opts?: {
22
+ force?: boolean;
23
+ now?: Date;
24
+ }): Promise<Job | null>;
25
+ export declare function cancelCard(tracker: Tracker, configDir: string, cardId: string, label: string, source?: 'linear' | 'voidtrack'): Promise<boolean>;
20
26
  export declare function tick(rc: RunContext, only?: string, opts?: {
21
27
  workflow?: string;
22
- from?: StepId;
28
+ from?: string;
23
29
  }): Promise<number>;
30
+ export {};