@voidlabs.gg/voidshift-agents 0.4.4 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -86,8 +86,33 @@ vsf agents workflow add|edit|show|rename|move|rm
86
86
  vsf agents projects | project add|edit|show|rename|rm
87
87
  vsf agents homes | link DIR # where the agents are installed
88
88
  vsf agents clean # old worktrees and run logs
89
+ vsf agents validate [DIR] # check the config and agent folders
89
90
  ```
90
91
 
92
+ A workflow can also be a folder you write by hand,
93
+ `~/.config/voidshift/agents/defs/NAME/agent.json`, with its prompt files next to
94
+ it. Steps are the built-in ids above or your own:
95
+
96
+ ```json
97
+ {
98
+ "triggers": [{ "source": "cron", "schedule": "0 7 * * 1-5", "task": "Morning brief" }],
99
+ "steps": [{ "id": "brief", "prompt": "brief.md", "output": "none", "model": "haiku", "timeoutMinutes": 10 }],
100
+ "maxRounds": 1,
101
+ "project": "tether-relay",
102
+ "writeBack": { "labels": true, "state": null, "comment": true }
103
+ }
104
+ ```
105
+
106
+ A step's `output` is `plan`, `verdict`, `commits`, `publish` or `none`; a failed
107
+ `verdict` sends the work back to the nearest `plan` or `commits` step. `engine`,
108
+ `model` and `timeoutMinutes` fall back to the config, and `write` to true only
109
+ for `commits`. A `publish` step takes no prompt; the others take one `prompt`
110
+ file or `skill`, which must ask for a `RESULT:` line (`plan`, `commits`) or a
111
+ `VERDICT:` line (`verdict`). `writeBack` applies to card runs: `state` moves a
112
+ verified card there (`null` never moves it), `labels: false` only takes the
113
+ trigger label off, and `comment: false` skips the closing comment. Edit the file,
114
+ then `vsf agents validate`.
115
+
91
116
  ### memory
92
117
 
93
118
  A folder of markdown notes, one topic per note, with an `INDEX.md` holding one
@@ -108,6 +133,20 @@ vsf memory on | off # turn recall on or off
108
133
  Setup creates a vault at `~/memory` or uses one you already have. Codex asks you
109
134
  to trust the recall hook once: run `/hooks` in Codex.
110
135
 
136
+ ### Your profile
137
+
138
+ Every agent run carries your profile, on any machine, without touching your
139
+ Claude or Codex folder. `vsf agents profile init` creates
140
+ `~/.config/voidshift/agents/profile/`:
141
+
142
+ - `RULES.md` is added to every step's prompt (style, commit rules, tone).
143
+ - `claude-settings.json` is passed to Claude runs with `--settings`, so hooks
144
+ come along. `{{profile}}` stands for the folder, so hook scripts can live in it.
145
+
146
+ Setup also asks what the runner does to agent commits before it pushes them:
147
+ remove AI co-author lines, and add `Signed-off-by` for repos that ask for a
148
+ DCO. Only the new commits of that step are rewritten, never pushed ones.
149
+
111
150
  ## What to know
112
151
 
113
152
  - **Your own logins.** Agents run the official `claude` and `codex` CLIs you
package/dist/config.d.ts CHANGED
@@ -1,12 +1,33 @@
1
1
  import type { Engine } from './engine.ts';
2
2
  export type StepId = 'vs-plan' | 'vs-plan-review' | 'vs-publish' | 'vs-dev' | 'vs-qa';
3
- export type Output = 'plan' | 'verdict' | 'commits' | 'publish';
3
+ export type Output = 'plan' | 'verdict' | 'commits' | 'publish' | 'none';
4
+ export declare const OUTPUTS: Output[];
4
5
  export declare const STEPS: Record<StepId, {
5
6
  label: string;
6
7
  output: Output;
7
8
  hint: string;
9
+ write: boolean;
10
+ role: 'dev' | 'review';
8
11
  }>;
9
- export declare const isProducer: (s: StepId) => boolean;
12
+ export interface StepDef {
13
+ id: string;
14
+ label?: string;
15
+ skill?: string;
16
+ prompt?: string;
17
+ output: Output;
18
+ engine?: Engine;
19
+ model?: string;
20
+ timeoutMinutes?: number;
21
+ write?: boolean;
22
+ }
23
+ export type Step = StepId | StepDef;
24
+ export declare const STEP_ID: RegExp;
25
+ export declare const isBuiltin: (id: string) => id is StepId;
26
+ export declare const outputOf: (s: Step) => Output;
27
+ export declare const stepId: (s: Step) => string;
28
+ export declare const stepLabel: (s: Step) => string;
29
+ export declare const stepIdLabel: (id: string) => string;
30
+ export declare const isProducer: (s: Step) => boolean;
10
31
  export declare const AGENTS: Record<string, {
11
32
  label: string;
12
33
  hint: string;
@@ -32,11 +53,17 @@ export interface Project {
32
53
  path: string;
33
54
  base?: string;
34
55
  }
56
+ export interface WriteBack {
57
+ labels?: boolean;
58
+ state?: string | null;
59
+ comment?: boolean;
60
+ }
35
61
  export interface Workflow {
36
62
  triggers: Trigger[];
37
- steps: StepId[];
63
+ steps: Step[];
38
64
  maxRounds: number;
39
65
  project: string;
66
+ writeBack?: WriteBack;
40
67
  }
41
68
  export interface AgentsConfig {
42
69
  engine: Engine;
@@ -53,6 +80,11 @@ export interface AgentsConfig {
53
80
  dir: string;
54
81
  }[];
55
82
  enabled: string[];
83
+ commits?: {
84
+ signOff?: boolean;
85
+ dropAiTrailers?: boolean;
86
+ };
87
+ folders?: Record<string, string>;
56
88
  }
57
89
  export declare const TEMPLATES: Record<string, {
58
90
  steps: StepId[];
@@ -62,9 +94,26 @@ export declare const DEFAULTS: AgentsConfig;
62
94
  export declare const agentsDir: (configDir: string) => string;
63
95
  export declare const hasWork: (workflow: Pick<Workflow, "steps">) => boolean;
64
96
  export declare const hasProducer: (workflow: Pick<Workflow, "steps">) => boolean;
65
- export declare const describeSteps: (steps: StepId[]) => string;
97
+ export declare const describeSteps: (steps: Step[]) => string;
66
98
  export declare const describeTriggers: (triggers: Trigger[]) => string;
67
99
  export declare const describeWorkflow: (c: Workflow) => string;
68
100
  export declare function migrate(saved: Record<string, any>): Partial<AgentsConfig>;
101
+ export declare const defsDir: (configDir: string) => string;
102
+ export declare const AGENT_FILE = "agent.json";
103
+ export declare function agentFolders(configDir: string): string[];
104
+ export declare function readAgentFolder(dir: string): Workflow;
69
105
  export declare function readAgentsConfig(configDir: string): AgentsConfig;
70
106
  export declare function writeAgentsConfig(configDir: string, config: AgentsConfig): void;
107
+ export interface ResolvedStep {
108
+ id: string;
109
+ label: string;
110
+ output: Output;
111
+ engine: Engine;
112
+ model?: string;
113
+ timeoutMs: number;
114
+ write: boolean;
115
+ prompt: () => string;
116
+ builtin?: StepId;
117
+ }
118
+ export declare function stepFile(step: StepDef, dir?: string): string | null;
119
+ export declare function resolveStep(step: Step, config: Pick<AgentsConfig, 'engine' | 'models' | 'timeoutMinutes'>, dir?: string): ResolvedStep;
package/dist/config.js CHANGED
@@ -1,14 +1,25 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { basename, join } from 'node:path';
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { basename, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ export const OUTPUTS = ['plan', 'verdict', 'commits', 'publish', 'none'];
5
+ // The built-in steps, the vs-* template. `write` is whether the agent may
6
+ // edit files; `role` picks the model from config.models.
3
7
  export const STEPS = {
4
- 'vs-plan': { label: 'Plan', output: 'plan', hint: 'writes a plan and posts it to the card' },
5
- 'vs-plan-review': { label: 'Plan review', output: 'verdict', hint: 'checks the plan; on FIX the workflow goes back to Plan' },
6
- 'vs-publish': { label: 'Publish', output: 'publish', hint: 'creates and pushes the branch for the card' },
7
- 'vs-dev': { label: 'Dev', output: 'commits', hint: 'implements the plan, commits, opens a draft PR' },
8
- 'vs-qa': { label: 'QA', output: 'verdict', hint: 'CI, then code review, then a usability check; on FIX back to Dev' },
8
+ 'vs-plan': { label: 'Plan', output: 'plan', hint: 'writes a plan and posts it to the card', write: false, role: 'dev' },
9
+ 'vs-plan-review': { label: 'Plan review', output: 'verdict', hint: 'checks the plan; on FIX the workflow goes back to Plan', write: false, role: 'review' },
10
+ 'vs-publish': { label: 'Publish', output: 'publish', hint: 'creates and pushes the branch for the card', write: false, role: 'dev' },
11
+ 'vs-dev': { label: 'Dev', output: 'commits', hint: 'implements the plan, commits, opens a draft PR', write: true, role: 'dev' },
12
+ 'vs-qa': { label: 'QA', output: 'verdict', hint: 'CI, then code review, then a usability check; on FIX back to Dev', write: true, role: 'review' },
9
13
  };
14
+ // What a custom step id may look like.
15
+ export const STEP_ID = /^[a-z0-9][a-z0-9-]*$/;
16
+ export const isBuiltin = (id) => Object.hasOwn(STEPS, id);
17
+ export const outputOf = (s) => (typeof s === 'string' ? STEPS[s].output : s.output);
18
+ export const stepId = (s) => (typeof s === 'string' ? s : s.id);
19
+ export const stepLabel = (s) => (typeof s === 'string' ? STEPS[s]?.label ?? s : s.label ?? s.id);
20
+ export const stepIdLabel = (id) => (isBuiltin(id) ? STEPS[id].label : id);
10
21
  // Steps a failed verdict can send work back to.
11
- export const isProducer = (s) => STEPS[s].output === 'plan' || STEPS[s].output === 'commits';
22
+ export const isProducer = (s) => outputOf(s) === 'plan' || outputOf(s) === 'commits';
12
23
  // The agents in the pack, as setup describes them.
13
24
  export const AGENTS = {
14
25
  'vs-plan': { label: 'Plan', hint: 'plans a card against the code' },
@@ -50,9 +61,9 @@ export const DEFAULTS = {
50
61
  };
51
62
  export const agentsDir = (configDir) => join(configDir, 'agents');
52
63
  const configPath = (configDir) => join(agentsDir(configDir), 'config.json');
53
- export const hasWork = (workflow) => workflow.steps.some((s) => STEPS[s].output === 'commits');
64
+ export const hasWork = (workflow) => workflow.steps.some((s) => outputOf(s) === 'commits');
54
65
  export const hasProducer = (workflow) => workflow.steps.some(isProducer);
55
- export const describeSteps = (steps) => steps.map((s) => STEPS[s].label).join(' → ');
66
+ export const describeSteps = (steps) => steps.map(stepLabel).join(' → ');
56
67
  export const describeTriggers = (triggers) => triggers.map(describeTrigger).join(', ');
57
68
  export const describeWorkflow = (c) => `when ${describeTriggers(c.triggers)}: ${describeSteps(c.steps)} in ${c.project}`;
58
69
  // Step ids that were folded into QA.
@@ -98,14 +109,88 @@ export function migrate(saved) {
98
109
  const enabled = [...new Set((saved.enabled ?? []).map((a) => (a in RENAMED ? RENAMED[a] : a)).filter(Boolean))];
99
110
  return { ...rest, ...(models ? { models } : {}), projects, workflows, enabled };
100
111
  }
112
+ // Agent folders: defs/<name>/agent.json, with its prompt files next to it.
113
+ export const defsDir = (configDir) => join(agentsDir(configDir), 'defs');
114
+ export const AGENT_FILE = 'agent.json';
115
+ export function agentFolders(configDir) {
116
+ try {
117
+ return readdirSync(defsDir(configDir), { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => join(defsDir(configDir), e.name));
118
+ }
119
+ catch {
120
+ return [];
121
+ }
122
+ }
123
+ // The workflow in an agent folder. Throws when agent.json is missing or not JSON.
124
+ export function readAgentFolder(dir) {
125
+ const value = JSON.parse(readFileSync(join(dir, AGENT_FILE), 'utf8'));
126
+ if (!value || typeof value !== 'object' || Array.isArray(value))
127
+ throw new Error(`${AGENT_FILE} must be an object`);
128
+ const { triggers, steps, maxRounds, project, writeBack } = value;
129
+ return { triggers, steps, maxRounds, project, ...(writeBack !== undefined ? { writeBack } : {}) };
130
+ }
101
131
  export function readAgentsConfig(configDir) {
102
132
  const path = configPath(configDir);
103
- if (!existsSync(path))
104
- return structuredClone(DEFAULTS);
105
- const saved = migrate(JSON.parse(readFileSync(path, 'utf8')));
106
- return { ...structuredClone(DEFAULTS), ...saved, models: { ...DEFAULTS.models, ...saved.models } };
133
+ let config = structuredClone(DEFAULTS);
134
+ if (existsSync(path)) {
135
+ const saved = migrate(JSON.parse(readFileSync(path, 'utf8')));
136
+ config = { ...config, ...saved, models: { ...DEFAULTS.models, ...saved.models } };
137
+ }
138
+ // A name config.json already uses wins, and a broken folder is skipped:
139
+ // `vsf agents validate` reports both.
140
+ for (const dir of agentFolders(configDir)) {
141
+ const name = basename(dir);
142
+ if (config.workflows[name])
143
+ continue;
144
+ try {
145
+ config.workflows[name] = readAgentFolder(dir);
146
+ config.folders = { ...config.folders, [name]: dir };
147
+ }
148
+ catch {
149
+ // skipped
150
+ }
151
+ }
152
+ return config;
107
153
  }
108
154
  export function writeAgentsConfig(configDir, config) {
109
155
  mkdirSync(agentsDir(configDir), { recursive: true });
110
- writeFileSync(configPath(configDir), JSON.stringify(config, null, 2) + '\n');
156
+ const { folders, ...rest } = config;
157
+ const workflows = Object.fromEntries(Object.entries(config.workflows).filter(([name]) => !folders?.[name]));
158
+ writeFileSync(configPath(configDir), JSON.stringify({ ...rest, workflows }, null, 2) + '\n');
159
+ }
160
+ const packDir = fileURLToPath(new URL('../pack/', import.meta.url));
161
+ const packSkill = (name) => join(packDir, name, 'SKILL.md');
162
+ // The file a custom step's prompt comes from: its prompt file, a pack skill,
163
+ // or a skill folder next to agent.json. Null for a step with neither.
164
+ export function stepFile(step, dir = '.') {
165
+ if (step.prompt)
166
+ return resolve(dir, step.prompt);
167
+ if (!step.skill)
168
+ return null;
169
+ if (STEP_ID.test(step.skill) && existsSync(packSkill(step.skill)))
170
+ return packSkill(step.skill);
171
+ return resolve(dir, step.skill, 'SKILL.md');
172
+ }
173
+ export function resolveStep(step, config, dir) {
174
+ if (typeof step === 'string') {
175
+ const s = STEPS[step];
176
+ return {
177
+ id: step, label: s.label, output: s.output, engine: config.engine, model: config.models[s.role],
178
+ timeoutMs: config.timeoutMinutes * 60_000, write: s.write, prompt: () => readFileSync(packSkill(step), 'utf8'), builtin: step,
179
+ };
180
+ }
181
+ return {
182
+ id: step.id,
183
+ label: step.label ?? step.id,
184
+ output: step.output,
185
+ engine: step.engine ?? config.engine,
186
+ model: step.model ?? config.models[step.output === 'verdict' ? 'review' : 'dev'],
187
+ timeoutMs: (step.timeoutMinutes ?? config.timeoutMinutes) * 60_000,
188
+ write: step.write ?? step.output === 'commits',
189
+ prompt: () => {
190
+ const file = stepFile(step, dir);
191
+ if (!file)
192
+ throw new Error(`Step ${step.id} has no prompt or skill.`);
193
+ return readFileSync(file, 'utf8');
194
+ },
195
+ };
111
196
  }
package/dist/control.js CHANGED
@@ -6,7 +6,7 @@ import { agentsDir, isCardTrigger, readAgentsConfig } from "./config.js";
6
6
  import { isPaused, listRuns, ours, clearCurrent, liveCurrent, readCurrent, setPaused, writeRun } from "./state.js";
7
7
  import { runnerService } from "./service.js";
8
8
  import { TRACKERS, trackersFor } from "./tracker.js";
9
- import { parseStep, resolveName, stepNames } from "./names.js";
9
+ import { parseFrom, resolveName, stepNames } from "./names.js";
10
10
  import { isStatusLabel } from "./workflow.js";
11
11
  import { cancelCard, tick } from "./runner.js";
12
12
  const HOUR = 3_600_000;
@@ -373,7 +373,7 @@ const run = guard(async (args, ctx) => {
373
373
  }
374
374
  let from;
375
375
  if (p.values.from) {
376
- from = parseStep(p.values.from);
376
+ from = parseFrom(p.values.from);
377
377
  if (!from) {
378
378
  ctx.log(`Unknown step ${p.values.from}. Steps: ${stepNames()}.`);
379
379
  return 2;
package/dist/engine.d.ts CHANGED
@@ -9,6 +9,7 @@ export interface RunOptions {
9
9
  logFile: string;
10
10
  timeoutMs?: number;
11
11
  vault?: string;
12
+ settingsFile?: string;
12
13
  }
13
14
  export declare function engineArgs(o: RunOptions, outFile: string): {
14
15
  cmd: string;
package/dist/engine.js CHANGED
@@ -17,6 +17,8 @@ export function engineArgs(o, outFile) {
17
17
  args.push('--permission-mode', 'acceptEdits');
18
18
  if (o.model)
19
19
  args.push('--model', o.model);
20
+ if (o.settingsFile)
21
+ args.push('--settings', o.settingsFile);
20
22
  return { cmd: 'claude', args };
21
23
  }
22
24
  const args = ['exec', '-C', o.cwd, '-s', o.write ? 'workspace-write' : 'read-only', '-o', outFile];
package/dist/git.d.ts CHANGED
@@ -28,3 +28,9 @@ export declare function waitForChecks(worktree: string, branch: string, timeoutM
28
28
  report: string;
29
29
  }>;
30
30
  export declare function resetWorktree(worktree: string): void;
31
+ export interface CommitPolicy {
32
+ signOff?: boolean;
33
+ dropAiTrailers?: boolean;
34
+ }
35
+ export declare function applyPolicy(message: string, policy: CommitPolicy, signer: string | null, cwd: string): string;
36
+ export declare function enforceCommitPolicy(worktree: string, from: string, policy: CommitPolicy): number;
package/dist/git.js CHANGED
@@ -134,3 +134,63 @@ export function resetWorktree(worktree) {
134
134
  sh('git', ['reset', '--hard', 'HEAD'], worktree);
135
135
  sh('git', ['clean', '-fd'], worktree);
136
136
  }
137
+ const AI_LINE = /^(co-authored-by:.*(claude|anthropic|codex|openai|copilot|gemini|cursor)|.*generated with \[?(claude|codex)).*$/i;
138
+ // The message with AI lines removed and a sign-off added, as the policy asks.
139
+ export function applyPolicy(message, policy, signer, cwd) {
140
+ let msg = message.replace(/\r\n/g, '\n');
141
+ if (policy.dropAiTrailers)
142
+ msg = msg.split('\n').filter((l) => !AI_LINE.test(l.trim())).join('\n');
143
+ msg = msg.replace(/\s+$/, '') + '\n';
144
+ if (policy.signOff && signer) {
145
+ // git's own trailer rules decide the blank line and skip a duplicate.
146
+ const r = spawnSync('git', ['interpret-trailers', '--if-exists', 'addIfDifferent', '--trailer', `Signed-off-by: ${signer}`], { cwd, input: msg, encoding: 'utf8' });
147
+ if (r.status === 0)
148
+ msg = r.stdout;
149
+ }
150
+ return msg;
151
+ }
152
+ // Rewrites the commits after `from` up to HEAD so their messages follow the
153
+ // policy. Trees, authors and dates stay as they were; only commits not yet
154
+ // pushed may be passed. Returns how many changed.
155
+ export function enforceCommitPolicy(worktree, from, policy) {
156
+ if (!policy.signOff && !policy.dropAiTrailers)
157
+ return 0;
158
+ const name = sh('git', ['config', 'user.name'], worktree).stdout;
159
+ const email = sh('git', ['config', 'user.email'], worktree).stdout;
160
+ const signer = name && email ? `${name} <${email}>` : null;
161
+ const list = sh('git', ['rev-list', '--reverse', '--parents', `${from}..HEAD`], worktree);
162
+ if (!list.ok || !list.stdout)
163
+ return 0;
164
+ const map = new Map();
165
+ let changed = 0;
166
+ let head = '';
167
+ for (const line of list.stdout.split('\n')) {
168
+ const [sha, ...parents] = line.split(' ');
169
+ const info = must(sh('git', ['log', '-1', '--format=%T%x00%an%x00%ae%x00%aD%x00%cn%x00%ce%x00%cD', sha], worktree), 'git log');
170
+ const [tree, an, ae, ad, cn, ce, cd] = info.split('\0');
171
+ const raw = spawnSync('git', ['log', '-1', '--format=%B', sha], { cwd: worktree, encoding: 'utf8' }).stdout ?? '';
172
+ const message = applyPolicy(raw, policy, signer, worktree);
173
+ const newParents = parents.map((p) => map.get(p) ?? p);
174
+ if (message === raw.replace(/\s+$/, '') + '\n' && newParents.every((p, i) => p === parents[i])) {
175
+ map.set(sha, sha);
176
+ head = sha;
177
+ continue;
178
+ }
179
+ const r = spawnSync('git', ['commit-tree', tree, ...newParents.flatMap((p) => ['-p', p]), '-F', '-'], {
180
+ cwd: worktree,
181
+ input: message,
182
+ encoding: 'utf8',
183
+ env: { ...process.env, GIT_AUTHOR_NAME: an, GIT_AUTHOR_EMAIL: ae, GIT_AUTHOR_DATE: ad, GIT_COMMITTER_NAME: cn, GIT_COMMITTER_EMAIL: ce, GIT_COMMITTER_DATE: cd },
184
+ });
185
+ if (r.status !== 0)
186
+ throw new Error(`git commit-tree failed: ${r.stderr}`);
187
+ const next = r.stdout.trim();
188
+ map.set(sha, next);
189
+ head = next;
190
+ changed++;
191
+ }
192
+ // Same tree at the tip, so moving the branch leaves the files untouched.
193
+ if (changed)
194
+ must(sh('git', ['reset', '-q', '--soft', head], worktree), 'git reset');
195
+ return changed;
196
+ }
package/dist/index.js CHANGED
@@ -4,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, STEPS, TEMPLATES, isProducer, agentsDir, describeSteps, describeTrigger, describeTriggers, describeWorkflow, stepEnabled, visibleAgents, readAgentsConfig, writeAgentsConfig, DEFAULTS } from "./config.js";
7
+ import { AGENTS, AGENT_FILE, STEPS, TEMPLATES, isProducer, agentFolders, agentsDir, describeSteps, describeTrigger, describeTriggers, describeWorkflow, stepEnabled, stepId, visibleAgents, readAgentsConfig, writeAgentsConfig, DEFAULTS } from "./config.js";
8
8
  import { controlChecks, controlCommands, controlUsage } from "./control.js";
9
9
  import { keyStore, looksLikeLinearKey, readLinearKey, readSecret, storeLinearKey, storeSecret } from "./secrets.js";
10
10
  import { TRACKERS } from "./tracker.js";
@@ -12,8 +12,9 @@ import { createVoidTrack } from "./voidtrack.js";
12
12
  import { parseCron } from "./cron.js";
13
13
  import { trackersInUse } from "./control.js";
14
14
  import { runnerService } from "./service.js";
15
+ import { initProfile, profileChecks, profileDir, readRules, renderClaudeSettings, CLAUDE_SETTINGS, RULES_FILE } from "./profile.js";
15
16
  import { createLinear } from "./linear.js";
16
- import { TRIGGER_FORMS, duplicateTriggerWarnings, moveKey, parseTrigger, renameKey, sameTrigger, validateConfig, validateWorkflow, workflowsUsing } from "./manage.js";
17
+ import { TRIGGER_FORMS, duplicateTriggerWarnings, moveKey, parseTrigger, renameKey, sameTrigger, validateAgentFolder, validateConfig, validateWorkflow, workflowsUsing } from "./manage.js";
17
18
  import { parseStep, resolveName, stepNames } from "./names.js";
18
19
  import { findRepos } from "./repos.js";
19
20
  import { defaultBranch, sh } from "./git.js";
@@ -374,6 +375,8 @@ const USAGE = [
374
375
  ['link DIR [--claude | --codex]', 'Link the agents into a config folder'],
375
376
  ['unlink DIR', 'Remove the agents from a config folder'],
376
377
  ['key [--voidtrack]', 'Store or replace the Linear or VoidTrack API key (reads stdin without a terminal)'],
378
+ ['profile [init]', `Your profile: rules (${RULES_FILE}) and Claude settings (${CLAUDE_SETTINGS}) every agent run carries; init creates it`],
379
+ ['validate [DIR]', `Check an agent folder (DIR/${AGENT_FILE} and its prompts), or the config and every folder under agents/defs`],
377
380
  ['help [COMMAND]', 'Show this list, or one command'],
378
381
  ];
379
382
  const usageOf = (name) => USAGE.filter(([c]) => c === name || c.startsWith(`${name} `))
@@ -504,6 +507,14 @@ function pickWorkflow(config, input, ctx) {
504
507
  }
505
508
  return r.name;
506
509
  }
510
+ // A workflow from an agent folder is changed by editing its file. True (and
511
+ // says so) when `name` is one.
512
+ function inFolder(config, name, ctx) {
513
+ const dir = config.folders?.[name];
514
+ if (dir)
515
+ ctx.log(`${name} is defined in ${tilde(join(dir, AGENT_FILE))}; edit that file.`);
516
+ return !!dir;
517
+ }
507
518
  async function teamsOrNull(ctx) {
508
519
  try {
509
520
  return await linear(ctx.configDir).teams();
@@ -628,6 +639,8 @@ const workflowSubs = {
628
639
  const name = pickWorkflow(config, p.positionals[0], ctx);
629
640
  if (!name)
630
641
  return 1;
642
+ if (inFolder(config, name, ctx))
643
+ return 1;
631
644
  const flags = readWorkflowFlags(p.values, config);
632
645
  if ('error' in flags) {
633
646
  ctx.log(flags.error);
@@ -688,6 +701,8 @@ const workflowSubs = {
688
701
  const old = pickWorkflow(config, p.positionals[0], ctx);
689
702
  if (!old)
690
703
  return 1;
704
+ if (inFolder(config, old, ctx))
705
+ return 1;
691
706
  const next = p.positionals[1].trim();
692
707
  if (!next) {
693
708
  ctx.log('The new name cannot be empty.');
@@ -712,6 +727,8 @@ const workflowSubs = {
712
727
  const name = pickWorkflow(config, p.positionals[0], ctx);
713
728
  if (!name)
714
729
  return 1;
730
+ if (inFolder(config, name, ctx))
731
+ return 1;
715
732
  const ok = await confirmed(`Delete workflow ${name}?`, !!p.values.yes, ctx);
716
733
  if (ok === 'refused')
717
734
  return 2;
@@ -740,6 +757,8 @@ const workflowSubs = {
740
757
  const name = pickWorkflow(config, p.positionals[0], ctx);
741
758
  if (!name)
742
759
  return 1;
760
+ if (inFolder(config, name, ctx))
761
+ return 1;
743
762
  let place;
744
763
  if (v.first)
745
764
  place = { first: true };
@@ -1062,6 +1081,11 @@ const agents = {
1062
1081
  config.models = { ...DEFAULTS.models };
1063
1082
  }
1064
1083
  config.engine = engine;
1084
+ // What the runner does to agent commits before pushing them.
1085
+ const commits = config.commits ?? {};
1086
+ commits.dropAiTrailers = await ui.confirm('Remove AI co-author lines from agent commits?', commits.dropAiTrailers ?? true);
1087
+ commits.signOff = await ui.confirm('Sign off agent commits (Signed-off-by, for repos that ask for a DCO)?', commits.signOff ?? false);
1088
+ config.commits = commits;
1065
1089
  const name = await currentLinearUser(ctx.configDir);
1066
1090
  if (!name || !(await ui.confirm(`Keep using Linear as ${name}?`))) {
1067
1091
  if (!(await setupKey(ui, ctx.configDir)))
@@ -1119,7 +1143,14 @@ const agents = {
1119
1143
  : broken.length
1120
1144
  ? { name: 'agent workflows', status: 'fail', detail: `${broken.join(', ')} point at a missing project`, fix: 'vsf agents project edit' }
1121
1145
  : { name: 'agent workflows', status: 'ok', detail: `${workflows.length} ready` });
1122
- return [...checks, ...(await controlChecks(ctx))];
1146
+ const folders = agentFolders(ctx.configDir);
1147
+ if (folders.length) {
1148
+ const bad = folders.filter((d) => validateAgentFolder(d, saved).length).map((d) => basename(d));
1149
+ checks.push(bad.length
1150
+ ? { name: 'agent folders', status: 'fail', detail: `${bad.join(', ')} ${bad.length === 1 ? 'has' : 'have'} problems`, fix: 'vsf agents validate' }
1151
+ : { name: 'agent folders', status: 'ok', detail: `${folders.length} valid` });
1152
+ }
1153
+ return [...checks, ...profileChecks(ctx.configDir), ...(await controlChecks(ctx))];
1123
1154
  },
1124
1155
  commands: {
1125
1156
  ...controlCommands,
@@ -1129,7 +1160,7 @@ const agents = {
1129
1160
  return p;
1130
1161
  const config = readAgentsConfig(ctx.configDir);
1131
1162
  if (p.values.json)
1132
- out(ctx, Object.entries(config.workflows).map(([name, w]) => ({ name, ...w })));
1163
+ out(ctx, Object.entries(config.workflows).map(([name, w]) => ({ name, ...w, steps: w.steps.map(stepId) })));
1133
1164
  else if (!Object.keys(config.workflows).length)
1134
1165
  ctx.log('No workflows yet. vsf agents workflow add creates one.');
1135
1166
  else
@@ -1143,6 +1174,58 @@ const agents = {
1143
1174
  return 0;
1144
1175
  },
1145
1176
  workflow: (args, ctx) => group('workflow', workflowSubs, args, ctx),
1177
+ async profile(args, ctx) {
1178
+ const p = begin('profile', args, {}, ctx);
1179
+ if (typeof p === 'number')
1180
+ return p;
1181
+ const dir = profileDir(ctx.configDir);
1182
+ if (p.positionals[0] === 'init') {
1183
+ const made = initProfile(ctx.configDir);
1184
+ ctx.log(made.length ? `Created ${made.join(', ')} in ${tilde(dir)}.` : `${tilde(dir)} already exists; nothing changed.`);
1185
+ return 0;
1186
+ }
1187
+ if (p.positionals.length) {
1188
+ ctx.log(usageOf('profile'));
1189
+ return 2;
1190
+ }
1191
+ ctx.log(`Profile: ${tilde(dir)}${existsSync(dir) ? '' : ' (not created yet: vsf agents profile init)'}`);
1192
+ const rules = readRules(ctx.configDir).replace(/<!--[\s\S]*?-->/g, '').trim();
1193
+ ctx.log(`Rules: ${rules ? `${rules.split('\n').length} lines in ${RULES_FILE}, added to every step's prompt` : 'none'}`);
1194
+ let settings = 'none';
1195
+ try {
1196
+ settings = renderClaudeSettings(ctx.configDir) === null ? 'none' : `${CLAUDE_SETTINGS}, passed to every Claude run`;
1197
+ }
1198
+ catch (err) {
1199
+ settings = `broken: ${err.message}`;
1200
+ }
1201
+ ctx.log(`Claude settings: ${settings}`);
1202
+ const c = readAgentsConfig(ctx.configDir).commits ?? {};
1203
+ ctx.log(`Commits: ${[c.dropAiTrailers ? 'AI co-author lines removed' : 'AI lines kept', c.signOff ? 'signed off' : 'not signed off'].join(', ')} (commits in the agents config)`);
1204
+ return 0;
1205
+ },
1206
+ async validate(args, ctx) {
1207
+ const p = begin('validate', args, {}, ctx);
1208
+ if (typeof p === 'number')
1209
+ return p;
1210
+ if (p.positionals.length > 1) {
1211
+ ctx.log('Give one folder at most.');
1212
+ ctx.log(usageOf('validate'));
1213
+ return 2;
1214
+ }
1215
+ const config = readAgentsConfig(ctx.configDir);
1216
+ const problems = [];
1217
+ const dirs = p.positionals.length ? [resolve(expandHome(p.positionals[0]))] : agentFolders(ctx.configDir);
1218
+ if (!p.positionals.length)
1219
+ problems.push(...validateConfig(config));
1220
+ for (const dir of dirs)
1221
+ for (const line of validateAgentFolder(dir, config))
1222
+ problems.push(`${basename(dir)}: ${line}`);
1223
+ for (const line of problems)
1224
+ ctx.log(line);
1225
+ if (!problems.length)
1226
+ ctx.log(p.positionals.length ? `${basename(dirs[0])} is valid.` : `Config and ${plural(dirs.length, 'agent folder')} are valid.`);
1227
+ return problems.length ? 1 : 0;
1228
+ },
1146
1229
  async projects(args, ctx) {
1147
1230
  const p = begin('projects', args, { json: { type: 'boolean' } }, ctx);
1148
1231
  if (typeof p === 'number')
package/dist/manage.d.ts CHANGED
@@ -1,5 +1,7 @@
1
- import { type AgentsConfig, type Trigger, type Workflow } from './config.ts';
2
- export declare function validateWorkflow(config: Pick<AgentsConfig, 'enabled' | 'projects'>, workflow: Workflow): string[];
1
+ import { type AgentsConfig, type Step, type Trigger, type Workflow } from './config.ts';
2
+ export declare function validateStep(step: Step, config: Pick<AgentsConfig, 'enabled'>, dir?: string): string[];
3
+ export declare function validateWorkflow(config: Pick<AgentsConfig, 'enabled' | 'projects'>, workflow: Workflow, dir?: string): string[];
4
+ export declare function validateAgentFolder(dir: string, config: Pick<AgentsConfig, 'enabled' | 'projects' | 'workflows' | 'folders'>): string[];
3
5
  export declare const TRIGGER_FORMS = "TEAM:LABEL (Linear), voidtrack:TEAM:LABEL, or cron:SCHEDULE:TASK";
4
6
  export declare function parseTrigger(input: string): Trigger | null;
5
7
  export declare const sameTrigger: (a: Trigger, b: Trigger) => boolean;
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/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
9
  import { recall, 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,51 @@ 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
+ const known = [recall(rc.selfCommand, rc.configDir, `${workflow.project} ${card.title}\n${card.description ?? ''}`), vault ? memoryNote(vault) : '', rules].filter(Boolean).join('\n\n');
140
160
  const worktree = join(agentsDir(rc.configDir), 'worktrees', card.identifier);
141
161
  ensureWorktree(project.path, worktree, card.branchName, base);
142
162
  say(`working in ${worktree} on ${card.branchName}`);
@@ -145,13 +165,12 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
145
165
  let prUrl = '';
146
166
  let logs = 0;
147
167
  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
168
  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);
169
+ async function check(def, round) {
170
+ const label = def.label;
171
+ const rec = begin(def);
153
172
  let ciNote = '';
154
- if (step === 'vs-qa') {
173
+ if (def.builtin === 'vs-qa') {
155
174
  if (commitsAhead(worktree, base) === 0) {
156
175
  end(rec, 'SKIP', 'No changes on the branch yet.');
157
176
  return { step: label, verdict: 'SKIP', report: 'No changes on the branch yet.' };
@@ -179,12 +198,12 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
179
198
  ciNote = ci.verdict === 'SHIP' ? '# CI\n\nCI passed on this branch.' : '# CI\n\nThis repo has no CI checks.';
180
199
  }
181
200
  }
182
- const log = logFile(step);
201
+ const log = logFile(def.id);
183
202
  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') }),
203
+ engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs,
204
+ prompt: buildPrompt({ skill: def.prompt(), card, plan: planText(), base, round, extra: [known, ciNote, UNATTENDED].filter(Boolean).join('\n\n') }),
186
205
  });
187
- if (step === 'vs-qa')
206
+ if (def.builtin === 'vs-qa')
188
207
  resetWorktree(worktree);
189
208
  const verdict = parseVerdict(r.output);
190
209
  end(rec, verdict, r.output, log);
@@ -192,18 +211,18 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
192
211
  }
193
212
  let i = from;
194
213
  while (i < steps.length) {
195
- const step = steps[i];
196
- say(`${STEPS[step].label}`);
197
- if (isVerdict(step)) {
214
+ const def = steps[i];
215
+ say(def.label);
216
+ if (isVerdict(def)) {
198
217
  const [start, end] = verdictBlock(steps, i, isVerdict);
199
218
  const target = fixTarget(steps, start, isProducer);
200
219
  const producer = target >= 0 ? steps[target] : undefined;
201
220
  // The round being checked: how many times its producer has run.
202
- const round = Math.max(producer === 'vs-plan' ? runs.plan : runs.dev, 1);
221
+ const round = Math.max(producer?.output === 'plan' ? runs.plan : runs.dev, 1);
203
222
  setRound(round);
204
223
  const results = [];
205
224
  for (const s of steps.slice(start, end)) {
206
- await stage(LABELS.stage[s], round);
225
+ await stage(stageOf(s), round);
207
226
  results.push(await check(s, round));
208
227
  }
209
228
  if (qaPassed(results)) {
@@ -215,26 +234,26 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
215
234
  if (target < 0) {
216
235
  await postPlan();
217
236
  await linear.comment(card, `**Changes needed.**\n\n${notes}`);
218
- await linear.setLabels(card, stageLabels(card.labels, label, LABELS.changes, round));
237
+ await endLabels(LABELS.changes, round);
219
238
  say('changes requested');
220
239
  return done('changes');
221
240
  }
222
241
  // Rounds caps how many times Plan or Dev runs, counting the first.
223
242
  if (round >= workflow.maxRounds) {
224
- throw new Park(`Checks still failed after ${round} ${producer === 'vs-plan' ? 'plan' : 'dev'} rounds.${prUrl ? ` PR: ${prUrl}` : ''}`);
243
+ throw new Park(`Checks still failed after ${round} ${producer?.output === 'plan' ? 'plan' : 'dev'} rounds.${prUrl ? ` PR: ${prUrl}` : ''}`);
225
244
  }
226
245
  findings.set(target, notes);
227
246
  i = target;
228
247
  continue;
229
248
  }
230
- if (step === 'vs-plan') {
249
+ if (def.output === 'plan') {
231
250
  setRound(runs.plan + 1);
232
- await stage(LABELS.stage[step], runs.plan + 1);
233
- const rec = begin(step);
234
- const log = logFile('plan');
251
+ await stage(stageOf(def), runs.plan + 1);
252
+ const rec = begin(def);
253
+ const log = logFile(def.builtin ? 'plan' : def.id);
235
254
  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') }),
255
+ engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs,
256
+ 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
257
  });
239
258
  const result = parseDevResult(r.output);
240
259
  end(rec, result.status === 'blocked' ? 'blocked' : 'done', r.output, log);
@@ -244,22 +263,22 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
244
263
  planToPost = plan;
245
264
  runs.plan++;
246
265
  }
247
- else if (step === 'vs-publish') {
266
+ else if (def.output === 'publish') {
248
267
  await postPlan();
249
- const rec = begin(step);
268
+ const rec = begin(def);
250
269
  push(worktree, card.branchName);
251
270
  end(rec, 'pushed');
252
271
  }
253
- else if (step === 'vs-dev') {
272
+ else if (def.output === 'commits') {
254
273
  await postPlan();
255
274
  setRound(runs.dev + 1);
256
- await stage(LABELS.stage[step], runs.dev + 1);
257
- const rec = begin(step);
258
- const log = logFile('dev');
275
+ await stage(stageOf(def), runs.dev + 1);
276
+ const rec = begin(def);
277
+ const log = logFile(def.builtin ? 'dev' : def.id);
259
278
  const before = headSha(worktree);
260
279
  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') }),
280
+ engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs,
281
+ 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
282
  });
264
283
  const result = parseDevResult(dev.output);
265
284
  end(rec, result.status === 'blocked' ? 'blocked' : 'done', dev.output, log);
@@ -271,23 +290,42 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
271
290
  // just check the same commit again.
272
291
  if (runs.dev > 0 && headSha(worktree) === before)
273
292
  throw new Park('The dev agent made no new commits this round.');
293
+ // Only this round's commits, which are not pushed yet.
294
+ const fixed = enforceCommitPolicy(worktree, before, config.commits ?? {});
295
+ if (fixed)
296
+ say(`applied the commit policy to ${fixed} commit${fixed === 1 ? '' : 's'}`);
274
297
  push(worktree, card.branchName);
275
298
  prUrl = ensureDraftPr(worktree, card.branchName, base, `${card.identifier}: ${card.title}`, card.url);
276
299
  record.prUrl = prUrl;
277
300
  save();
278
301
  runs.dev++;
279
302
  }
303
+ else {
304
+ // A step with no output the workflow reads: it runs once and must finish.
305
+ await stage(stageOf(def), loops || undefined);
306
+ const rec = begin(def);
307
+ const log = logFile(def.id);
308
+ const r = await runAgent({
309
+ engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs,
310
+ prompt: buildPrompt({ skill: def.prompt(), card, plan: plan ?? '(none)', base, round: loops || 1, extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }),
311
+ });
312
+ end(rec, r.ok ? 'done' : 'failed', r.output, log);
313
+ if (!r.ok)
314
+ throw new Park(`The ${def.label} step failed or timed out.`);
315
+ }
280
316
  findings.delete(i);
281
317
  i++;
282
318
  }
283
319
  await postPlan();
284
- let moved = false;
285
- if (hasWork(workflow)) {
320
+ // Unset moves a card with commits to reviewState; a state always moves it; null never does.
321
+ const state = writeBack.state === undefined ? (hasWork(workflow) ? config.reviewState : null) : writeBack.state;
322
+ if (hasWork(workflow))
286
323
  markReady(worktree, card.branchName);
287
- moved = await linear.moveTo(card, config.reviewState);
324
+ const moved = state ? await linear.moveTo(card, state) : true;
325
+ await endLabels(LABELS.verified, loops || undefined);
326
+ if (writeBack.comment !== false) {
327
+ 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
328
  }
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
329
  say('verified');
292
330
  return done('verified');
293
331
  }
@@ -504,8 +542,8 @@ export async function tick(rc, only, opts = {}) {
504
542
  rc.log(`Workflow ${job.workflow} points at a missing project ${workflow.project}. Run vsf agents workflow edit to fix it.`);
505
543
  return 1;
506
544
  }
507
- if (opts.from && !workflow.steps.includes(opts.from)) {
508
- rc.log(`Workflow ${job.workflow} has no ${STEPS[opts.from].label} step.`);
545
+ if (opts.from && !workflow.steps.some((s) => stepId(s) === opts.from)) {
546
+ rc.log(`Workflow ${job.workflow} has no ${stepIdLabel(opts.from)} step.`);
509
547
  return 2;
510
548
  }
511
549
  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;
@@ -47,6 +47,6 @@ export interface PromptInput {
47
47
  extra?: string;
48
48
  }
49
49
  export declare function buildPrompt(p: PromptInput): string;
50
- export declare function verdictBlock<T extends string>(steps: T[], i: number, isVerdict: (s: T) => boolean): [number, number];
51
- export declare function fixTarget<T extends string>(steps: T[], blockStart: number, isProducer: (s: T) => boolean): number;
50
+ export declare function verdictBlock<T>(steps: T[], i: number, isVerdict: (s: T) => boolean): [number, number];
51
+ export declare function fixTarget<T>(steps: T[], blockStart: number, isProducer: (s: T) => boolean): number;
52
52
  export declare function planBody(output: string): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voidlabs.gg/voidshift-agents",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "Agents for Claude Code and Codex that plan, build and QA Linear cards, chained and run in the background.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {