@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/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
@@ -102,12 +127,35 @@ vsf memory status # the vault and where recall is on
102
127
  vsf memory search WORDS # notes recall would bring up
103
128
  vsf memory try "a prompt" # exactly what recall would add to that prompt
104
129
  vsf memory check # notes missing from INDEX.md, broken links
130
+ vsf memory sync # pull and push the vault through its git remote
105
131
  vsf memory on | off # turn recall on or off
106
132
  ```
107
133
 
134
+ To share the vault between machines, make it a git repository with a remote
135
+ named `origin` (any host; a private repository). `vsf memory sync` commits new
136
+ notes, rebases onto the remote and pushes. It also runs in the background from
137
+ the recall hook when the last sync is over ten minutes old, and the agents
138
+ runner syncs before and after every run. When a note changed on both sides,
139
+ the remote's version stays and this machine's copy is saved beside it as
140
+ `note.conflict-<machine>-<time>.md`, so nothing is lost.
141
+
108
142
  Setup creates a vault at `~/memory` or uses one you already have. Codex asks you
109
143
  to trust the recall hook once: run `/hooks` in Codex.
110
144
 
145
+ ### Your profile
146
+
147
+ Every agent run carries your profile, on any machine, without touching your
148
+ Claude or Codex folder. `vsf agents profile init` creates
149
+ `~/.config/voidshift/agents/profile/`:
150
+
151
+ - `RULES.md` is added to every step's prompt (style, commit rules, tone).
152
+ - `claude-settings.json` is passed to Claude runs with `--settings`, so hooks
153
+ come along. `{{profile}}` stands for the folder, so hook scripts can live in it.
154
+
155
+ Setup also asks what the runner does to agent commits before it pushes them:
156
+ remove AI co-author lines, and add `Signed-off-by` for repos that ask for a
157
+ DCO. Only the new commits of that step are rewritten, never pushed ones.
158
+
111
159
  ## What to know
112
160
 
113
161
  - **Your own logins.** Agents run the official `claude` and `codex` CLIs you
@@ -141,8 +189,12 @@ pnpm test
141
189
  node packages/cli/src/main.ts
142
190
  ```
143
191
 
144
- Node 22.18 or newer runs the TypeScript sources directly. Releases are cut by
145
- pushing a `v*` tag; CI publishes every package at that version.
192
+ Node 22.18 or newer runs the TypeScript sources directly. Each package
193
+ releases on its own: bump its `package.json` and push `agents-v0.5.0` (or
194
+ `cli-v`, `memory-v`, `awake-v`), and CI publishes only that package. A plain
195
+ `v*` tag publishes all four at one version. Tools declare the CLI contract
196
+ they were built for; a CLI that is too old refuses to load them and says to
197
+ run `vsf update`.
146
198
 
147
199
  ## License
148
200
 
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 };
@@ -1023,6 +1042,7 @@ const homeCommands = {
1023
1042
  const sameModels = (a, b) => a.dev === b.dev && a.review === b.review;
1024
1043
  const agents = {
1025
1044
  name: 'agents',
1045
+ contract: 1,
1026
1046
  summary: 'Agents for Claude Code and Codex that plan, build and QA Linear cards',
1027
1047
  config: {
1028
1048
  read: (dir) => readAgentsConfig(dir),
@@ -1062,6 +1082,11 @@ const agents = {
1062
1082
  config.models = { ...DEFAULTS.models };
1063
1083
  }
1064
1084
  config.engine = engine;
1085
+ // What the runner does to agent commits before pushing them.
1086
+ const commits = config.commits ?? {};
1087
+ commits.dropAiTrailers = await ui.confirm('Remove AI co-author lines from agent commits?', commits.dropAiTrailers ?? true);
1088
+ commits.signOff = await ui.confirm('Sign off agent commits (Signed-off-by, for repos that ask for a DCO)?', commits.signOff ?? false);
1089
+ config.commits = commits;
1065
1090
  const name = await currentLinearUser(ctx.configDir);
1066
1091
  if (!name || !(await ui.confirm(`Keep using Linear as ${name}?`))) {
1067
1092
  if (!(await setupKey(ui, ctx.configDir)))
@@ -1119,7 +1144,14 @@ const agents = {
1119
1144
  : broken.length
1120
1145
  ? { name: 'agent workflows', status: 'fail', detail: `${broken.join(', ')} point at a missing project`, fix: 'vsf agents project edit' }
1121
1146
  : { name: 'agent workflows', status: 'ok', detail: `${workflows.length} ready` });
1122
- return [...checks, ...(await controlChecks(ctx))];
1147
+ const folders = agentFolders(ctx.configDir);
1148
+ if (folders.length) {
1149
+ const bad = folders.filter((d) => validateAgentFolder(d, saved).length).map((d) => basename(d));
1150
+ checks.push(bad.length
1151
+ ? { name: 'agent folders', status: 'fail', detail: `${bad.join(', ')} ${bad.length === 1 ? 'has' : 'have'} problems`, fix: 'vsf agents validate' }
1152
+ : { name: 'agent folders', status: 'ok', detail: `${folders.length} valid` });
1153
+ }
1154
+ return [...checks, ...profileChecks(ctx.configDir), ...(await controlChecks(ctx))];
1123
1155
  },
1124
1156
  commands: {
1125
1157
  ...controlCommands,
@@ -1129,7 +1161,7 @@ const agents = {
1129
1161
  return p;
1130
1162
  const config = readAgentsConfig(ctx.configDir);
1131
1163
  if (p.values.json)
1132
- out(ctx, Object.entries(config.workflows).map(([name, w]) => ({ name, ...w })));
1164
+ out(ctx, Object.entries(config.workflows).map(([name, w]) => ({ name, ...w, steps: w.steps.map(stepId) })));
1133
1165
  else if (!Object.keys(config.workflows).length)
1134
1166
  ctx.log('No workflows yet. vsf agents workflow add creates one.');
1135
1167
  else
@@ -1143,6 +1175,58 @@ const agents = {
1143
1175
  return 0;
1144
1176
  },
1145
1177
  workflow: (args, ctx) => group('workflow', workflowSubs, args, ctx),
1178
+ async profile(args, ctx) {
1179
+ const p = begin('profile', args, {}, ctx);
1180
+ if (typeof p === 'number')
1181
+ return p;
1182
+ const dir = profileDir(ctx.configDir);
1183
+ if (p.positionals[0] === 'init') {
1184
+ const made = initProfile(ctx.configDir);
1185
+ ctx.log(made.length ? `Created ${made.join(', ')} in ${tilde(dir)}.` : `${tilde(dir)} already exists; nothing changed.`);
1186
+ return 0;
1187
+ }
1188
+ if (p.positionals.length) {
1189
+ ctx.log(usageOf('profile'));
1190
+ return 2;
1191
+ }
1192
+ ctx.log(`Profile: ${tilde(dir)}${existsSync(dir) ? '' : ' (not created yet: vsf agents profile init)'}`);
1193
+ const rules = readRules(ctx.configDir).replace(/<!--[\s\S]*?-->/g, '').trim();
1194
+ ctx.log(`Rules: ${rules ? `${rules.split('\n').length} lines in ${RULES_FILE}, added to every step's prompt` : 'none'}`);
1195
+ let settings = 'none';
1196
+ try {
1197
+ settings = renderClaudeSettings(ctx.configDir) === null ? 'none' : `${CLAUDE_SETTINGS}, passed to every Claude run`;
1198
+ }
1199
+ catch (err) {
1200
+ settings = `broken: ${err.message}`;
1201
+ }
1202
+ ctx.log(`Claude settings: ${settings}`);
1203
+ const c = readAgentsConfig(ctx.configDir).commits ?? {};
1204
+ 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)`);
1205
+ return 0;
1206
+ },
1207
+ async validate(args, ctx) {
1208
+ const p = begin('validate', args, {}, ctx);
1209
+ if (typeof p === 'number')
1210
+ return p;
1211
+ if (p.positionals.length > 1) {
1212
+ ctx.log('Give one folder at most.');
1213
+ ctx.log(usageOf('validate'));
1214
+ return 2;
1215
+ }
1216
+ const config = readAgentsConfig(ctx.configDir);
1217
+ const problems = [];
1218
+ const dirs = p.positionals.length ? [resolve(expandHome(p.positionals[0]))] : agentFolders(ctx.configDir);
1219
+ if (!p.positionals.length)
1220
+ problems.push(...validateConfig(config));
1221
+ for (const dir of dirs)
1222
+ for (const line of validateAgentFolder(dir, config))
1223
+ problems.push(`${basename(dir)}: ${line}`);
1224
+ for (const line of problems)
1225
+ ctx.log(line);
1226
+ if (!problems.length)
1227
+ ctx.log(p.positionals.length ? `${basename(dirs[0])} is valid.` : `Config and ${plural(dirs.length, 'agent folder')} are valid.`);
1228
+ return problems.length ? 1 : 0;
1229
+ },
1146
1230
  async projects(args, ctx) {
1147
1231
  const p = begin('projects', args, { json: { type: 'boolean' } }, ctx);
1148
1232
  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;