@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 +54 -2
- package/dist/config.d.ts +53 -4
- package/dist/config.js +100 -15
- package/dist/control.js +2 -2
- package/dist/engine.d.ts +1 -0
- package/dist/engine.js +2 -0
- package/dist/git.d.ts +6 -0
- package/dist/git.js +60 -0
- package/dist/index.js +88 -4
- package/dist/manage.d.ts +4 -2
- package/dist/manage.js +104 -9
- package/dist/memory.d.ts +1 -0
- package/dist/memory.js +12 -0
- package/dist/names.d.ts +1 -0
- package/dist/names.js +4 -1
- package/dist/profile.d.ts +10 -0
- package/dist/profile.js +70 -0
- package/dist/runner.d.ts +3 -3
- package/dist/runner.js +104 -58
- package/dist/state.d.ts +2 -2
- package/dist/types.d.ts +1 -0
- package/dist/workflow.d.ts +2 -2
- package/package.json +1 -1
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.
|
|
145
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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) =>
|
|
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) =>
|
|
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(
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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
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
|
-
|
|
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
|
|
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;
|