@voidlabs.gg/voidshift-agents 0.4.4 → 0.5.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/manage.js CHANGED
@@ -1,10 +1,66 @@
1
- import { existsSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { AGENTS, STEPS, describeTrigger, triggerSpec } 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
4
  import { parseCron } from "./cron.js";
5
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
+ }
6
61
  // Everything wrong with one workflow, in plain words. Empty means it is fine.
7
- export function validateWorkflow(config, workflow) {
62
+ // `dir` is the agent folder a folder workflow comes from.
63
+ export function validateWorkflow(config, workflow, dir) {
8
64
  const problems = [];
9
65
  if (!workflow.triggers?.length)
10
66
  problems.push('Needs at least one trigger.');
@@ -26,16 +82,50 @@ export function validateWorkflow(config, workflow) {
26
82
  }
27
83
  if (!workflow.steps?.length)
28
84
  problems.push('Needs at least one step.');
29
- for (const s of workflow.steps ?? []) {
30
- if (!(s in STEPS))
31
- problems.push(`Unknown step ${s}.`);
32
- else if (config.enabled.length && !config.enabled.includes(s))
33
- 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
+ }
34
94
  }
35
95
  if (!config.projects[workflow.project])
36
96
  problems.push(`Project ${workflow.project} does not exist.`);
37
97
  if (!isInt(workflow.maxRounds, 1, 9))
38
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
+ }
112
+ return problems;
113
+ }
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.`);
39
129
  return problems;
40
130
  }
41
131
  export const TRIGGER_FORMS = 'TEAM:LABEL (Linear), voidtrack:TEAM:LABEL, or cron:SCHEDULE:TASK';
@@ -121,6 +211,8 @@ export function validateConfig(value, saved) {
121
211
  }
122
212
  if (!Array.isArray(value.enabled) || value.enabled.some((a) => typeof a !== 'string' || !(a in AGENTS)))
123
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.');
124
216
  if (!Array.isArray(value.homes) || value.homes.some((h) => !isObj(h) || (h.kind !== 'claude' && h.kind !== 'codex') || typeof h.dir !== 'string'))
125
217
  problems.push('homes must be a list of { kind: claude or codex, dir }.');
126
218
  if (!isObj(value.workflows))
@@ -128,6 +220,9 @@ export function validateConfig(value, saved) {
128
220
  else {
129
221
  const ctx = { enabled: Array.isArray(value.enabled) ? value.enabled : [], projects: (projects ?? {}) };
130
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;
131
226
  if (!isObj(w))
132
227
  problems.push(`Workflow ${name} must be an object.`);
133
228
  else {
package/dist/memory.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export declare function vaultPath(configDir: string): string | undefined;
2
2
  export declare function recall(selfCommand: string[], configDir: string, text: string): string;
3
+ export declare function syncMemory(selfCommand: string[], configDir: string): string;
package/dist/memory.js CHANGED
@@ -35,3 +35,15 @@ export function recall(selfCommand, configDir, text) {
35
35
  return '';
36
36
  }
37
37
  }
38
+ // Brings the vault up to date through its git remote, or shares the notes
39
+ // agents wrote. Never fails a run: returns a line worth logging, or ''.
40
+ export function syncMemory(selfCommand, configDir) {
41
+ if (!vaultPath(configDir) || !selfCommand.length)
42
+ return '';
43
+ const [cmd, ...args] = selfCommand;
44
+ const r = spawnSync(cmd, [...args, 'memory', 'sync', '--quiet'], { encoding: 'utf8', timeout: 90_000 });
45
+ const out = `${r.stdout ?? ''}${r.stderr ?? ''}`.trim();
46
+ if (r.error)
47
+ return `memory sync did not run (${r.error.message})`;
48
+ return r.status === 0 ? out : out || 'memory sync failed';
49
+ }
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,4 +1,4 @@
1
- import { type AgentsConfig, type Workflow, type Project, type StepId, type Trigger } from './config.ts';
1
+ import { type AgentsConfig, type Workflow, type Project, type Trigger } from './config.ts';
2
2
  import type { Card } from './linear.ts';
3
3
  import { type Tracker, type Trackers } from './tracker.ts';
4
4
  export interface RunContext {
@@ -10,7 +10,7 @@ export interface RunContext {
10
10
  }
11
11
  export declare function summarize(output: string): string;
12
12
  export declare function runCard(card: Card, trigger: Trigger, workflowName: string, workflow: Workflow, project: Project, rc: RunContext, opts?: {
13
- from?: StepId;
13
+ from?: string;
14
14
  }): Promise<'verified' | 'changes' | 'parked'>;
15
15
  type Job = {
16
16
  card: Card;
@@ -25,6 +25,6 @@ export declare function nextJob(rc: RunContext, only?: string, workflowName?: st
25
25
  export declare function cancelCard(tracker: Tracker, configDir: string, cardId: string, label: string, source?: 'linear' | 'voidtrack'): Promise<boolean>;
26
26
  export declare function tick(rc: RunContext, only?: string, opts?: {
27
27
  workflow?: string;
28
- from?: StepId;
28
+ from?: string;
29
29
  }): Promise<number>;
30
30
  export {};
package/dist/runner.js CHANGED
@@ -1,16 +1,14 @@
1
- import { existsSync, mkdirSync, readFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync } from 'node:fs';
2
2
  import { basename, join } from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
3
  import { LABELS, PLAN_HEADING, buildPrompt, findingsFor, fixTarget, isStatusLabel, latestPlan, parseDevResult, parseVerdict, planBody, qaPassed, stageLabels, verdictBlock, } from "./workflow.js";
5
- import { STEPS, agentsDir, hasWork, isCardTrigger, isProducer } from "./config.js";
4
+ import { agentsDir, hasWork, isCardTrigger, resolveStep, stepId, stepIdLabel } from "./config.js";
6
5
  import { lastDue, parseCron } from "./cron.js";
7
6
  import { runAgent, stopAgents } from "./engine.js";
8
- import { commitsAhead, defaultBranch, ensureDraftPr, ensureWorktree, headSha, markReady, push, resetWorktree, waitForChecks, waitForPrHead } from "./git.js";
7
+ import { commitsAhead, enforceCommitPolicy, defaultBranch, ensureDraftPr, ensureWorktree, headSha, markReady, push, resetWorktree, waitForChecks, waitForPrHead } from "./git.js";
9
8
  import { noTracker } from "./tracker.js";
10
- import { recall, vaultPath } from "./memory.js";
9
+ import { recall, syncMemory, vaultPath } from "./memory.js";
10
+ import { claudeSettingsFile, readRules, rulesSection } from "./profile.js";
11
11
  import { bootTime, clearCurrent, isPaused, ours, readCurrent, readPending, writeCurrent, writePending, writeRun, readCronState, writeCronState } from "./state.js";
12
- const packDir = fileURLToPath(new URL('../pack/', import.meta.url));
13
- const skill = (name) => readFileSync(join(packDir, name, 'SKILL.md'), 'utf8');
14
12
  const stamp = () => new Date().toISOString().replace(/[:.]/g, '-');
15
13
  class Park extends Error {
16
14
  }
@@ -37,7 +35,10 @@ export function summarize(output) {
37
35
  }
38
36
  // The run in progress, so a cancel signal can mark it.
39
37
  let active = null;
40
- const isVerdict = (s) => STEPS[s].output === 'verdict';
38
+ const isVerdict = (d) => d.output === 'verdict';
39
+ const isProducer = (d) => d.output === 'plan' || d.output === 'commits';
40
+ // The card label for a step: its own for a built-in step, a generic one otherwise.
41
+ const stageOf = (d) => LABELS.stage[d.builtin ?? ''] ?? LABELS.working;
41
42
  // One card through a trigger's workflow. Steps run in order. A failed check (or a
42
43
  // run of consecutive checks) sends its findings back to the nearest earlier
43
44
  // step that produces something, Plan or Dev, and the workflow continues from
@@ -50,7 +51,9 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
50
51
  const runDir = join(agentsDir(rc.configDir), 'runs', card.identifier, stamp());
51
52
  mkdirSync(runDir, { recursive: true });
52
53
  const say = (line) => log(`${card.identifier} ${line}`);
53
- const steps = workflow.steps;
54
+ // Write back settings apply to card runs; a scheduled run has nothing to write to.
55
+ const writeBack = isCardTrigger(trigger) ? (workflow.writeBack ?? {}) : {};
56
+ const labelsOn = writeBack.labels !== false;
54
57
  const startedAt = new Date().toISOString();
55
58
  const record = { card: card.identifier, title: card.title, url: card.url || undefined, workflow: workflowName, trigger: label || (trigger.source === 'cron' ? trigger.schedule : ''), source: trigger.source, steps: [], outcome: 'running', startedAt };
56
59
  active = { record, runDir };
@@ -61,8 +64,8 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
61
64
  const mark = (step) => writeCurrent(rc.configDir, { pid: process.pid, card: card.identifier, title: card.title, workflow: workflowName, step, round: loops, startedAt, runDir, source: trigger.source });
62
65
  // Written before the first tracker call, so a cancel always knows the card.
63
66
  mark('Starting');
64
- const begin = (step) => {
65
- const rec = { step, label: STEPS[step].label, startedAt: new Date().toISOString() };
67
+ const begin = (def) => {
68
+ const rec = { step: def.id, label: def.label, startedAt: new Date().toISOString() };
66
69
  record.steps.push(rec);
67
70
  mark(rec.label);
68
71
  save();
@@ -89,7 +92,13 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
89
92
  const setRound = (n) => {
90
93
  loops = n;
91
94
  };
92
- const stage = (name, round) => linear.setLabels(card, stageLabels(card.labels, label, name, round));
95
+ const stage = async (name, round) => {
96
+ if (labelsOn)
97
+ await linear.setLabels(card, stageLabels(card.labels, label, name, round));
98
+ };
99
+ // How a run ends on the card: a stage label, or with labels off only the
100
+ // trigger taken away so the card is not picked up again.
101
+ const endLabels = (name, round) => linear.setLabels(card, labelsOn ? stageLabels(card.labels, label, name, round) : card.labels.filter((l) => l !== label));
93
102
  // The tracker hears from the runner twice: the plan once it has passed review,
94
103
  // and one final comment. Findings stay here until the final one.
95
104
  let lastFindings = '';
@@ -103,40 +112,55 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
103
112
  async function park(reason) {
104
113
  say(`parked: ${reason}`);
105
114
  const comment = `**Parked:** ${reason}${lastFindings ? `\n\n${lastFindings}` : ''}\n\nFix the cause, then add \`${label}\` again to retry.`;
106
- const add = [LABELS.parked, ...(loops ? [LABELS.version(loops)] : [])];
115
+ const add = labelsOn ? [LABELS.parked, ...(loops ? [LABELS.version(loops)] : [])] : [];
107
116
  let commented = false;
108
117
  try {
109
118
  await postPlan();
110
119
  await linear.comment(card, comment);
111
120
  commented = true;
112
- await linear.setLabels(card, stageLabels(card.labels, label, LABELS.parked, loops || undefined));
121
+ await endLabels(LABELS.parked, loops || undefined);
113
122
  }
114
123
  catch (err) {
115
124
  if (!isCardTrigger(trigger))
116
125
  return done('parked', reason);
117
126
  say(`could not reach ${trigger.source === 'voidtrack' ? 'VoidTrack' : 'Linear'} to park it (${err.message}). Will retry next minute.`);
118
127
  // An empty comment is skipped on retry, so it is not posted twice.
119
- writePending(rc.configDir, [...readPending(rc.configDir), { card: card.identifier, comment: commented ? '' : comment, drop: [label], add, dropStatus: true, source: trigger.source }]);
128
+ writePending(rc.configDir, [...readPending(rc.configDir), { card: card.identifier, comment: commented ? '' : comment, drop: [label], add, dropStatus: labelsOn, source: trigger.source }]);
120
129
  }
121
130
  return done('parked', reason);
122
131
  }
123
132
  try {
124
133
  if (!existsSync(join(project.path, '.git')))
125
134
  throw new Park(`The project folder ${project.path} is not a git repository on this Mac.`);
126
- const from = opts.from ? steps.indexOf(opts.from) : 0;
135
+ const steps = workflow.steps.map((s) => resolveStep(s, config, config.folders?.[workflowName]));
136
+ const from = opts.from ? steps.findIndex((d) => d.id === opts.from) : 0;
127
137
  if (from < 0)
128
- throw new Park(`Workflow ${workflowName} has no ${STEPS[opts.from].label} step.`);
138
+ throw new Park(`Workflow ${workflowName} has no ${stepIdLabel(opts.from)} step.`);
129
139
  // A scheduled run has no card to plan on, so its task is the plan unless a Plan step writes one.
130
140
  let plan = isCardTrigger(trigger) ? latestPlan(await linear.comments(card.identifier)) : card.description ?? null;
131
- const firstPlan = steps.indexOf('vs-plan', from);
132
- const needsPlan = steps.findIndex((s, n) => n >= from && (s === 'vs-dev' || s === 'vs-plan-review'));
141
+ const firstPlan = steps.findIndex((d, n) => n >= from && d.output === 'plan');
142
+ // A check before any commits is a check on the plan, like Plan review.
143
+ const needsPlan = steps.findIndex((d, n) => n >= from && (d.output === 'commits' || (d.output === 'verdict' && d.builtin !== 'vs-qa')));
133
144
  if (!plan && needsPlan >= 0 && (firstPlan < 0 || firstPlan > needsPlan)) {
134
145
  throw new Park('No plan comment (starting with "## Plan") was found. Publish a plan first, or start the workflow with Plan.');
135
146
  }
136
- await stage(LABELS.stage[steps[from]]);
147
+ await stage(stageOf(steps[from]));
137
148
  const base = project.base || defaultBranch(project.path);
138
149
  const vault = vaultPath(rc.configDir);
139
- const known = [recall(rc.selfCommand, rc.configDir, `${workflow.project} ${card.title}\n${card.description ?? ''}`), vault ? memoryNote(vault) : ''].filter(Boolean).join('\n\n');
150
+ // Your profile rides along on every step: rules in the prompt, Claude settings as a file.
151
+ let settingsFile;
152
+ try {
153
+ settingsFile = claudeSettingsFile(rc.configDir, runDir);
154
+ }
155
+ catch (err) {
156
+ throw new Park(`Your agent profile's Claude settings are broken: ${err.message}`);
157
+ }
158
+ const rules = rulesSection(readRules(rc.configDir));
159
+ // Latest notes from every machine before recall reads them.
160
+ const pulled = syncMemory(rc.selfCommand, rc.configDir);
161
+ if (pulled)
162
+ say(pulled);
163
+ const known = [recall(rc.selfCommand, rc.configDir, `${workflow.project} ${card.title}\n${card.description ?? ''}`), vault ? memoryNote(vault) : '', rules].filter(Boolean).join('\n\n');
140
164
  const worktree = join(agentsDir(rc.configDir), 'worktrees', card.identifier);
141
165
  ensureWorktree(project.path, worktree, card.branchName, base);
142
166
  say(`working in ${worktree} on ${card.branchName}`);
@@ -145,13 +169,12 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
145
169
  let prUrl = '';
146
170
  let logs = 0;
147
171
  const planText = () => plan ?? '(No plan was posted on this card. Check the work against the card itself.)';
148
- const timeoutMs = config.timeoutMinutes * 60_000;
149
172
  const logFile = (name) => join(runDir, `${String(logs++).padStart(2, '0')}-${name}.log`);
150
- async function check(step, round) {
151
- const label = STEPS[step].label;
152
- const rec = begin(step);
173
+ async function check(def, round) {
174
+ const label = def.label;
175
+ const rec = begin(def);
153
176
  let ciNote = '';
154
- if (step === 'vs-qa') {
177
+ if (def.builtin === 'vs-qa') {
155
178
  if (commitsAhead(worktree, base) === 0) {
156
179
  end(rec, 'SKIP', 'No changes on the branch yet.');
157
180
  return { step: label, verdict: 'SKIP', report: 'No changes on the branch yet.' };
@@ -179,12 +202,12 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
179
202
  ciNote = ci.verdict === 'SHIP' ? '# CI\n\nCI passed on this branch.' : '# CI\n\nThis repo has no CI checks.';
180
203
  }
181
204
  }
182
- const log = logFile(step);
205
+ const log = logFile(def.id);
183
206
  const r = await runAgent({
184
- engine: config.engine, model: config.models.review, cwd: worktree, vault, write: step === 'vs-qa', logFile: log, timeoutMs,
185
- prompt: buildPrompt({ skill: skill(step), card, plan: planText(), base, round, extra: [known, ciNote, UNATTENDED].filter(Boolean).join('\n\n') }),
207
+ engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs,
208
+ prompt: buildPrompt({ skill: def.prompt(), card, plan: planText(), base, round, extra: [known, ciNote, UNATTENDED].filter(Boolean).join('\n\n') }),
186
209
  });
187
- if (step === 'vs-qa')
210
+ if (def.builtin === 'vs-qa')
188
211
  resetWorktree(worktree);
189
212
  const verdict = parseVerdict(r.output);
190
213
  end(rec, verdict, r.output, log);
@@ -192,18 +215,18 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
192
215
  }
193
216
  let i = from;
194
217
  while (i < steps.length) {
195
- const step = steps[i];
196
- say(`${STEPS[step].label}`);
197
- if (isVerdict(step)) {
218
+ const def = steps[i];
219
+ say(def.label);
220
+ if (isVerdict(def)) {
198
221
  const [start, end] = verdictBlock(steps, i, isVerdict);
199
222
  const target = fixTarget(steps, start, isProducer);
200
223
  const producer = target >= 0 ? steps[target] : undefined;
201
224
  // The round being checked: how many times its producer has run.
202
- const round = Math.max(producer === 'vs-plan' ? runs.plan : runs.dev, 1);
225
+ const round = Math.max(producer?.output === 'plan' ? runs.plan : runs.dev, 1);
203
226
  setRound(round);
204
227
  const results = [];
205
228
  for (const s of steps.slice(start, end)) {
206
- await stage(LABELS.stage[s], round);
229
+ await stage(stageOf(s), round);
207
230
  results.push(await check(s, round));
208
231
  }
209
232
  if (qaPassed(results)) {
@@ -215,26 +238,26 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
215
238
  if (target < 0) {
216
239
  await postPlan();
217
240
  await linear.comment(card, `**Changes needed.**\n\n${notes}`);
218
- await linear.setLabels(card, stageLabels(card.labels, label, LABELS.changes, round));
241
+ await endLabels(LABELS.changes, round);
219
242
  say('changes requested');
220
243
  return done('changes');
221
244
  }
222
245
  // Rounds caps how many times Plan or Dev runs, counting the first.
223
246
  if (round >= workflow.maxRounds) {
224
- throw new Park(`Checks still failed after ${round} ${producer === 'vs-plan' ? 'plan' : 'dev'} rounds.${prUrl ? ` PR: ${prUrl}` : ''}`);
247
+ throw new Park(`Checks still failed after ${round} ${producer?.output === 'plan' ? 'plan' : 'dev'} rounds.${prUrl ? ` PR: ${prUrl}` : ''}`);
225
248
  }
226
249
  findings.set(target, notes);
227
250
  i = target;
228
251
  continue;
229
252
  }
230
- if (step === 'vs-plan') {
253
+ if (def.output === 'plan') {
231
254
  setRound(runs.plan + 1);
232
- await stage(LABELS.stage[step], runs.plan + 1);
233
- const rec = begin(step);
234
- const log = logFile('plan');
255
+ await stage(stageOf(def), runs.plan + 1);
256
+ const rec = begin(def);
257
+ const log = logFile(def.builtin ? 'plan' : def.id);
235
258
  const r = await runAgent({
236
- engine: config.engine, model: config.models.dev, cwd: worktree, vault, write: false, logFile: log, timeoutMs,
237
- prompt: buildPrompt({ skill: skill('vs-plan'), card, plan: plan ?? '(none yet, you are writing it)', base, round: runs.plan + 1, findings: findings.get(i), extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }),
259
+ engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs,
260
+ prompt: buildPrompt({ skill: def.prompt(), card, plan: plan ?? '(none yet, you are writing it)', base, round: runs.plan + 1, findings: findings.get(i), extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }),
238
261
  });
239
262
  const result = parseDevResult(r.output);
240
263
  end(rec, result.status === 'blocked' ? 'blocked' : 'done', r.output, log);
@@ -244,22 +267,22 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
244
267
  planToPost = plan;
245
268
  runs.plan++;
246
269
  }
247
- else if (step === 'vs-publish') {
270
+ else if (def.output === 'publish') {
248
271
  await postPlan();
249
- const rec = begin(step);
272
+ const rec = begin(def);
250
273
  push(worktree, card.branchName);
251
274
  end(rec, 'pushed');
252
275
  }
253
- else if (step === 'vs-dev') {
276
+ else if (def.output === 'commits') {
254
277
  await postPlan();
255
278
  setRound(runs.dev + 1);
256
- await stage(LABELS.stage[step], runs.dev + 1);
257
- const rec = begin(step);
258
- const log = logFile('dev');
279
+ await stage(stageOf(def), runs.dev + 1);
280
+ const rec = begin(def);
281
+ const log = logFile(def.builtin ? 'dev' : def.id);
259
282
  const before = headSha(worktree);
260
283
  const dev = await runAgent({
261
- engine: config.engine, model: config.models.dev, cwd: worktree, vault, write: true, logFile: log, timeoutMs,
262
- prompt: buildPrompt({ skill: skill('vs-dev'), card, plan: planText(), base, round: runs.dev + 1, findings: findings.get(i), extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }),
284
+ engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs,
285
+ prompt: buildPrompt({ skill: def.prompt(), card, plan: planText(), base, round: runs.dev + 1, findings: findings.get(i), extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }),
263
286
  });
264
287
  const result = parseDevResult(dev.output);
265
288
  end(rec, result.status === 'blocked' ? 'blocked' : 'done', dev.output, log);
@@ -271,23 +294,42 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
271
294
  // just check the same commit again.
272
295
  if (runs.dev > 0 && headSha(worktree) === before)
273
296
  throw new Park('The dev agent made no new commits this round.');
297
+ // Only this round's commits, which are not pushed yet.
298
+ const fixed = enforceCommitPolicy(worktree, before, config.commits ?? {});
299
+ if (fixed)
300
+ say(`applied the commit policy to ${fixed} commit${fixed === 1 ? '' : 's'}`);
274
301
  push(worktree, card.branchName);
275
302
  prUrl = ensureDraftPr(worktree, card.branchName, base, `${card.identifier}: ${card.title}`, card.url);
276
303
  record.prUrl = prUrl;
277
304
  save();
278
305
  runs.dev++;
279
306
  }
307
+ else {
308
+ // A step with no output the workflow reads: it runs once and must finish.
309
+ await stage(stageOf(def), loops || undefined);
310
+ const rec = begin(def);
311
+ const log = logFile(def.id);
312
+ const r = await runAgent({
313
+ engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs,
314
+ prompt: buildPrompt({ skill: def.prompt(), card, plan: plan ?? '(none)', base, round: loops || 1, extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }),
315
+ });
316
+ end(rec, r.ok ? 'done' : 'failed', r.output, log);
317
+ if (!r.ok)
318
+ throw new Park(`The ${def.label} step failed or timed out.`);
319
+ }
280
320
  findings.delete(i);
281
321
  i++;
282
322
  }
283
323
  await postPlan();
284
- let moved = false;
285
- if (hasWork(workflow)) {
324
+ // Unset moves a card with commits to reviewState; a state always moves it; null never does.
325
+ const state = writeBack.state === undefined ? (hasWork(workflow) ? config.reviewState : null) : writeBack.state;
326
+ if (hasWork(workflow))
286
327
  markReady(worktree, card.branchName);
287
- moved = await linear.moveTo(card, config.reviewState);
328
+ const moved = state ? await linear.moveTo(card, state) : true;
329
+ await endLabels(LABELS.verified, loops || undefined);
330
+ if (writeBack.comment !== false) {
331
+ await linear.comment(card, `**All checks passed.**${prUrl ? ` PR ready: ${prUrl}` : ''}${!moved ? `\n\n(No "${state}" state on this team, so the card was not moved.)` : ''}`);
288
332
  }
289
- await linear.setLabels(card, stageLabels(card.labels, label, LABELS.verified, loops || undefined));
290
- await linear.comment(card, `**All checks passed.**${prUrl ? ` PR ready: ${prUrl}` : ''}${hasWork(workflow) && !moved ? `\n\n(No "${config.reviewState}" state on this team, so the card was not moved.)` : ''}`);
291
333
  say('verified');
292
334
  return done('verified');
293
335
  }
@@ -298,6 +340,10 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
298
340
  }
299
341
  finally {
300
342
  active = null;
343
+ // Share whatever notes the agents wrote, whatever the outcome.
344
+ const shared = syncMemory(rc.selfCommand, rc.configDir);
345
+ if (shared)
346
+ say(shared);
301
347
  }
302
348
  }
303
349
  // A scheduled run's stand-in for a card: the task, on a branch of its own.
@@ -504,8 +550,8 @@ export async function tick(rc, only, opts = {}) {
504
550
  rc.log(`Workflow ${job.workflow} points at a missing project ${workflow.project}. Run vsf agents workflow edit to fix it.`);
505
551
  return 1;
506
552
  }
507
- if (opts.from && !workflow.steps.includes(opts.from)) {
508
- rc.log(`Workflow ${job.workflow} has no ${STEPS[opts.from].label} step.`);
553
+ if (opts.from && !workflow.steps.some((s) => stepId(s) === opts.from)) {
554
+ rc.log(`Workflow ${job.workflow} has no ${stepIdLabel(opts.from)} step.`);
509
555
  return 2;
510
556
  }
511
557
  const outcome = await runCard(job.card, job.trigger, job.workflow, workflow, project, rc, { from: opts.from });
package/dist/state.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type StepId, type Trigger } from './config.ts';
1
+ import { type Trigger } from './config.ts';
2
2
  export interface Current {
3
3
  pid: number;
4
4
  card?: string;
@@ -32,7 +32,7 @@ export declare function readPending(configDir: string): Pending[];
32
32
  export declare function writePending(configDir: string, pending: Pending[]): void;
33
33
  export type Outcome = 'running' | 'verified' | 'changes' | 'parked' | 'cancelled';
34
34
  export interface StepRecord {
35
- step: StepId;
35
+ step: string;
36
36
  label: string;
37
37
  startedAt: string;
38
38
  endedAt?: string;