@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 +39 -0
- 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 +87 -4
- package/dist/manage.d.ts +4 -2
- package/dist/manage.js +104 -9
- 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 +95 -57
- package/dist/state.d.ts +2 -2
- 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
|
|
@@ -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
|
|
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 };
|
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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[];
|
package/dist/profile.js
ADDED
|
@@ -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
|
|
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?:
|
|
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?:
|
|
28
|
+
from?: string;
|
|
29
29
|
}): Promise<number>;
|
|
30
30
|
export {};
|
package/dist/runner.js
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
|
-
import { existsSync, mkdirSync
|
|
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 {
|
|
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 = (
|
|
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
|
-
|
|
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 = (
|
|
65
|
-
const rec = { step, label:
|
|
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) =>
|
|
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
|
|
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:
|
|
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
|
|
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 ${
|
|
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.
|
|
132
|
-
|
|
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(
|
|
147
|
+
await stage(stageOf(steps[from]));
|
|
137
148
|
const base = project.base || defaultBranch(project.path);
|
|
138
149
|
const vault = vaultPath(rc.configDir);
|
|
139
|
-
|
|
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(
|
|
151
|
-
const label =
|
|
152
|
-
const rec = begin(
|
|
169
|
+
async function check(def, round) {
|
|
170
|
+
const label = def.label;
|
|
171
|
+
const rec = begin(def);
|
|
153
172
|
let ciNote = '';
|
|
154
|
-
if (
|
|
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(
|
|
201
|
+
const log = logFile(def.id);
|
|
183
202
|
const r = await runAgent({
|
|
184
|
-
engine:
|
|
185
|
-
prompt: buildPrompt({ skill:
|
|
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 (
|
|
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
|
|
196
|
-
say(
|
|
197
|
-
if (isVerdict(
|
|
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 === '
|
|
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(
|
|
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
|
|
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 === '
|
|
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 (
|
|
249
|
+
if (def.output === 'plan') {
|
|
231
250
|
setRound(runs.plan + 1);
|
|
232
|
-
await stage(
|
|
233
|
-
const rec = begin(
|
|
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:
|
|
237
|
-
prompt: buildPrompt({ skill:
|
|
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 (
|
|
266
|
+
else if (def.output === 'publish') {
|
|
248
267
|
await postPlan();
|
|
249
|
-
const rec = begin(
|
|
268
|
+
const rec = begin(def);
|
|
250
269
|
push(worktree, card.branchName);
|
|
251
270
|
end(rec, 'pushed');
|
|
252
271
|
}
|
|
253
|
-
else if (
|
|
272
|
+
else if (def.output === 'commits') {
|
|
254
273
|
await postPlan();
|
|
255
274
|
setRound(runs.dev + 1);
|
|
256
|
-
await stage(
|
|
257
|
-
const rec = begin(
|
|
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:
|
|
262
|
-
prompt: buildPrompt({ skill:
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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.
|
|
508
|
-
rc.log(`Workflow ${job.workflow} has no ${
|
|
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
|
|
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:
|
|
35
|
+
step: string;
|
|
36
36
|
label: string;
|
|
37
37
|
startedAt: string;
|
|
38
38
|
endedAt?: string;
|
package/dist/workflow.d.ts
CHANGED
|
@@ -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
|
|
51
|
-
export declare function fixTarget<T
|
|
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