@voidlabs.gg/voidshift-agents 0.0.0-stage → 0.4.1

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.
@@ -0,0 +1,3 @@
1
+ export declare function readLinearKey(): string | null;
2
+ export declare const looksLikeLinearKey: (key: string) => boolean;
3
+ export declare function storeLinearKey(key: string): boolean;
@@ -0,0 +1,19 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { userInfo } from 'node:os';
3
+ const SERVICE = 'voidshift-linear';
4
+ const KEY_SHAPE = /^lin_api_[A-Za-z0-9]+$/;
5
+ export function readLinearKey() {
6
+ const r = spawnSync('/usr/bin/security', ['find-generic-password', '-s', SERVICE, '-w'], { encoding: 'utf8' });
7
+ const key = r.status === 0 ? r.stdout.trim() : '';
8
+ return key || null;
9
+ }
10
+ export const looksLikeLinearKey = (key) => KEY_SHAPE.test(key);
11
+ // Stores the key through `security -i` on stdin, so it never appears in a
12
+ // process list. The shape check above rules out anything that could break
13
+ // out of the command line.
14
+ export function storeLinearKey(key) {
15
+ if (!looksLikeLinearKey(key))
16
+ return false;
17
+ const command = `add-generic-password -U -s ${SERVICE} -a ${userInfo().username} -w ${key}\n`;
18
+ return spawnSync('/usr/bin/security', ['-i'], { input: command, encoding: 'utf8' }).status === 0;
19
+ }
@@ -0,0 +1,7 @@
1
+ export declare const LABEL = "org.devvoid.voidshift.agents";
2
+ export declare function plistPath(home: string): string;
3
+ export declare function runnerPlist(program: string[], env: Record<string, string>, logPath: string): string;
4
+ export declare function runnerEnv(): Record<string, string>;
5
+ export declare const runnerLoaded: () => boolean;
6
+ export declare function startRunner(home: string, program: string[], logPath: string): boolean;
7
+ export declare function stopRunner(home: string): void;
@@ -0,0 +1,68 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { userInfo } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ export const LABEL = 'org.devvoid.voidshift.agents';
6
+ const xml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
7
+ const domain = () => `gui/${userInfo().uid}`;
8
+ const launchctl = (args) => spawnSync('/bin/launchctl', args, { encoding: 'utf8' }).status === 0;
9
+ export function plistPath(home) {
10
+ return join(home, 'Library', 'LaunchAgents', `${LABEL}.plist`);
11
+ }
12
+ // launchd starts jobs with a bare environment, so carry over what the agent
13
+ // CLIs need to find themselves and their logins.
14
+ export function runnerPlist(program, env, logPath) {
15
+ const args = program.map((a) => ` <string>${xml(a)}</string>`).join('\n');
16
+ const vars = Object.entries(env)
17
+ .map(([k, v]) => ` <key>${xml(k)}</key>\n <string>${xml(v)}</string>`)
18
+ .join('\n');
19
+ return `<?xml version="1.0" encoding="UTF-8"?>
20
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
21
+ <plist version="1.0">
22
+ <dict>
23
+ <key>Label</key>
24
+ <string>${LABEL}</string>
25
+ <key>ProgramArguments</key>
26
+ <array>
27
+ ${args}
28
+ </array>
29
+ <key>EnvironmentVariables</key>
30
+ <dict>
31
+ ${vars}
32
+ </dict>
33
+ <key>StartInterval</key>
34
+ <integer>60</integer>
35
+ <key>RunAtLoad</key>
36
+ <true/>
37
+ <key>StandardOutPath</key>
38
+ <string>${xml(logPath)}</string>
39
+ <key>StandardErrorPath</key>
40
+ <string>${xml(logPath)}</string>
41
+ </dict>
42
+ </plist>
43
+ `;
44
+ }
45
+ export function runnerEnv() {
46
+ const env = {};
47
+ for (const k of ['PATH', 'HOME', 'CLAUDE_CONFIG_DIR', 'CODEX_HOME', 'VOIDSHIFT_HOME']) {
48
+ const v = process.env[k];
49
+ if (v)
50
+ env[k] = v;
51
+ }
52
+ return env;
53
+ }
54
+ export const runnerLoaded = () => launchctl(['print', `${domain()}/${LABEL}`]);
55
+ export function startRunner(home, program, logPath) {
56
+ const path = plistPath(home);
57
+ mkdirSync(dirname(path), { recursive: true });
58
+ mkdirSync(dirname(logPath), { recursive: true });
59
+ writeFileSync(path, runnerPlist(program, runnerEnv(), logPath));
60
+ if (runnerLoaded())
61
+ launchctl(['bootout', `${domain()}/${LABEL}`]);
62
+ return launchctl(['bootstrap', domain(), path]);
63
+ }
64
+ export function stopRunner(home) {
65
+ if (runnerLoaded())
66
+ launchctl(['bootout', `${domain()}/${LABEL}`]);
67
+ rmSync(plistPath(home), { force: true });
68
+ }
@@ -0,0 +1,33 @@
1
+ export interface Card {
2
+ id: string;
3
+ identifier: string;
4
+ title: string;
5
+ description?: string;
6
+ url: string;
7
+ branchName: string;
8
+ teamId: string;
9
+ teamKey: string;
10
+ projectName?: string;
11
+ labels: string[];
12
+ }
13
+ export declare function createLinear(apiKey: string): {
14
+ viewer(): Promise<{
15
+ id: string;
16
+ name: string;
17
+ }>;
18
+ teams(): Promise<{
19
+ key: string;
20
+ name: string;
21
+ mine: boolean;
22
+ }[]>;
23
+ myCardsWithLabel(label: string): Promise<Card[]>;
24
+ card(identifier: string): Promise<Card>;
25
+ comments(identifier: string): Promise<{
26
+ body: string;
27
+ createdAt: string;
28
+ }[]>;
29
+ comment(card: Card, body: string): Promise<void>;
30
+ setLabels(card: Card, next: string[]): Promise<void>;
31
+ moveTo(card: Card, stateName: string): Promise<boolean>;
32
+ };
33
+ export type Linear = ReturnType<typeof createLinear>;
package/dist/linear.js ADDED
@@ -0,0 +1,122 @@
1
+ // Linear over GraphQL with a personal API key (sent raw, no Bearer prefix).
2
+ // Only the background runner uses this; interactive agents use the Linear
3
+ // connection inside Claude Code or Codex.
4
+ const ENDPOINT = 'https://api.linear.app/graphql';
5
+ const CARD_FIELDS = `
6
+ id identifier title description url branchName
7
+ team { id key }
8
+ project { name }
9
+ labels { nodes { name } }
10
+ `;
11
+ function mapCard(n) {
12
+ return {
13
+ id: n.id,
14
+ identifier: n.identifier,
15
+ title: n.title,
16
+ description: n.description ?? undefined,
17
+ url: n.url,
18
+ branchName: n.branchName,
19
+ teamId: n.team.id,
20
+ teamKey: n.team.key,
21
+ projectName: n.project?.name ?? undefined,
22
+ labels: (n.labels?.nodes ?? []).map((l) => l.name),
23
+ };
24
+ }
25
+ export function createLinear(apiKey) {
26
+ async function gql(operation, query, variables = {}) {
27
+ let res;
28
+ try {
29
+ res = await fetch(ENDPOINT, {
30
+ method: 'POST',
31
+ signal: AbortSignal.timeout(30_000),
32
+ headers: { Authorization: apiKey, 'Content-Type': 'application/json' },
33
+ body: JSON.stringify({ query, variables }),
34
+ });
35
+ }
36
+ catch (err) {
37
+ const cause = err.cause;
38
+ throw new Error(`could not reach Linear (${cause?.code ?? cause?.message ?? err.message})`);
39
+ }
40
+ const text = await res.text();
41
+ let json = null;
42
+ try {
43
+ json = JSON.parse(text);
44
+ }
45
+ catch {
46
+ // reported below
47
+ }
48
+ const error = json?.errors?.[0]?.message;
49
+ if (!res.ok || error || !json)
50
+ throw new Error(`linear ${operation} ${res.status}: ${error ?? text.slice(0, 200)}`);
51
+ return json.data;
52
+ }
53
+ async function labelId(name, teamId) {
54
+ const found = await gql('findLabel', `query FindLabel($name: String!, $teamId: ID) {
55
+ issueLabels(filter: { name: { eq: $name }, or: [{ team: { id: { eq: $teamId } } }, { team: { null: true } }] }) {
56
+ nodes { id name }
57
+ }
58
+ }`, { name, teamId });
59
+ const existing = (found?.issueLabels?.nodes ?? []).find((l) => l.name === name);
60
+ if (existing)
61
+ return existing.id;
62
+ const created = await gql('createLabel', `mutation CreateLabel($name: String!, $teamId: String) {
63
+ issueLabelCreate(input: { name: $name, teamId: $teamId }) { issueLabel { id } }
64
+ }`, { name, teamId });
65
+ const id = created?.issueLabelCreate?.issueLabel?.id;
66
+ if (!id)
67
+ throw new Error(`linear could not create label ${name}`);
68
+ return id;
69
+ }
70
+ return {
71
+ async viewer() {
72
+ const d = await gql('viewer', 'query { viewer { id name } }');
73
+ return d.viewer;
74
+ },
75
+ // Every team, with whether I am a member, members first.
76
+ async teams() {
77
+ const d = await gql('teams', 'query { teams(first: 100) { nodes { key name } } viewer { teams(first: 100) { nodes { key } } } }');
78
+ const mine = new Set((d?.viewer?.teams?.nodes ?? []).map((t) => t.key));
79
+ const all = d?.teams?.nodes ?? [];
80
+ return all.map((t) => ({ ...t, mine: mine.has(t.key) })).sort((a, b) => Number(b.mine) - Number(a.mine));
81
+ },
82
+ // My cards carrying the trigger label.
83
+ async myCardsWithLabel(label) {
84
+ const d = await gql('myCards', `query MyCards($label: String!) {
85
+ issues(first: 20, orderBy: createdAt, filter: { assignee: { isMe: { eq: true } }, labels: { name: { eq: $label } } }) {
86
+ nodes { ${CARD_FIELDS} }
87
+ }
88
+ }`, { label });
89
+ return (d?.issues?.nodes ?? []).map(mapCard);
90
+ },
91
+ async card(identifier) {
92
+ const d = await gql('card', `query Card($id: String!) { issue(id: $id) { ${CARD_FIELDS} } }`, { id: identifier });
93
+ if (!d?.issue)
94
+ throw new Error(`linear has no card ${identifier}`);
95
+ return mapCard(d.issue);
96
+ },
97
+ async comments(identifier) {
98
+ const d = await gql('comments', `query Comments($id: String!) { issue(id: $id) { comments(first: 100) { nodes { body createdAt } } } }`, { id: identifier });
99
+ return d?.issue?.comments?.nodes ?? [];
100
+ },
101
+ async comment(card, body) {
102
+ await gql('comment', `mutation Comment($issueId: String!, $body: String!) { commentCreate(input: { issueId: $issueId, body: $body }) { success } }`, { issueId: card.id, body });
103
+ },
104
+ // Replace the card's label set with `next`, creating labels as needed.
105
+ async setLabels(card, next) {
106
+ const ids = [];
107
+ for (const name of next)
108
+ ids.push(await labelId(name, card.teamId));
109
+ await gql('setLabels', `mutation SetLabels($id: String!, $labelIds: [String!]) { issueUpdate(id: $id, input: { labelIds: $labelIds }) { success } }`, { id: card.id, labelIds: ids });
110
+ card.labels = next;
111
+ },
112
+ // Move to a named workflow state; false if the team has no such state.
113
+ async moveTo(card, stateName) {
114
+ const d = await gql('states', `query States($teamId: ID!) { workflowStates(filter: { team: { id: { eq: $teamId } } }) { nodes { id name } } }`, { teamId: card.teamId });
115
+ const state = (d?.workflowStates?.nodes ?? []).find((s) => s.name.toLowerCase() === stateName.toLowerCase());
116
+ if (!state)
117
+ return false;
118
+ await gql('move', `mutation Move($id: String!, $stateId: String!) { issueUpdate(id: $id, input: { stateId: $stateId }) { success } }`, { id: card.id, stateId: state.id });
119
+ return true;
120
+ },
121
+ };
122
+ }
@@ -0,0 +1,18 @@
1
+ import { type AgentsConfig, type Trigger, type Workflow } from './config.ts';
2
+ export declare function validateWorkflow(config: Pick<AgentsConfig, 'enabled' | 'projects'>, workflow: Workflow): string[];
3
+ export declare function parseTrigger(input: string): Trigger | null;
4
+ export declare const sameTrigger: (a: Trigger, b: Trigger) => boolean;
5
+ export declare function duplicateTriggerWarnings(workflows: Record<string, Workflow>, name: string, workflow: Workflow): string[];
6
+ export declare function renameKey<T>(record: Record<string, T>, from: string, to: string): Record<string, T>;
7
+ export type Place = {
8
+ first: true;
9
+ } | {
10
+ last: true;
11
+ } | {
12
+ before: string;
13
+ } | {
14
+ after: string;
15
+ };
16
+ export declare function moveKey<T>(record: Record<string, T>, name: string, place: Place): Record<string, T>;
17
+ export declare const workflowsUsing: (config: Pick<AgentsConfig, "workflows">, project: string) => string[];
18
+ export declare function validateConfig(value: unknown, saved?: Pick<AgentsConfig, 'projects' | 'workflows'>): string[];
package/dist/manage.js ADDED
@@ -0,0 +1,120 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { AGENTS, STEPS } from "./config.js";
4
+ const isInt = (v, min, max) => typeof v === 'number' && Number.isInteger(v) && v >= min && v <= max;
5
+ // Everything wrong with one workflow, in plain words. Empty means it is fine.
6
+ export function validateWorkflow(config, workflow) {
7
+ const problems = [];
8
+ if (!workflow.triggers?.length)
9
+ problems.push('Needs at least one trigger.');
10
+ for (const t of workflow.triggers ?? []) {
11
+ if (!t.team?.trim())
12
+ problems.push('A trigger has no team.');
13
+ if (!t.label?.trim())
14
+ problems.push('A trigger has no label.');
15
+ }
16
+ if (!workflow.steps?.length)
17
+ problems.push('Needs at least one step.');
18
+ for (const s of workflow.steps ?? []) {
19
+ if (!(s in STEPS))
20
+ problems.push(`Unknown step ${s}.`);
21
+ else if (config.enabled.length && !config.enabled.includes(s))
22
+ problems.push(`Step ${s} is not enabled.`);
23
+ }
24
+ if (!config.projects[workflow.project])
25
+ problems.push(`Project ${workflow.project} does not exist.`);
26
+ if (!isInt(workflow.maxRounds, 1, 9))
27
+ problems.push('Rounds must be a whole number from 1 to 9.');
28
+ return problems;
29
+ }
30
+ // "ENG:vs-plan" into a trigger; the team is upper-cased.
31
+ export function parseTrigger(input) {
32
+ const i = input.indexOf(':');
33
+ if (i < 0)
34
+ return null;
35
+ const team = input.slice(0, i).trim().toUpperCase();
36
+ const label = input.slice(i + 1).trim();
37
+ return team && label ? { source: 'linear', team, label } : null;
38
+ }
39
+ export const sameTrigger = (a, b) => a.team === b.team && a.label === b.label;
40
+ // Warning lines for triggers this workflow shares with another one. Only the
41
+ // first workflow in order starts a card, so the rest never get it.
42
+ export function duplicateTriggerWarnings(workflows, name, workflow) {
43
+ const lines = [];
44
+ for (const t of workflow.triggers) {
45
+ const owners = Object.entries(workflows).filter(([n, w]) => n === name || w.triggers.some((x) => sameTrigger(x, t))).map(([n]) => n);
46
+ const first = owners[0];
47
+ for (const other of owners) {
48
+ if (other === name)
49
+ continue;
50
+ lines.push(`Warning: ${other} also starts on ${t.team} + ${t.label}, and ${first} wins. vsf agents workflow move changes the order.`);
51
+ }
52
+ }
53
+ return lines;
54
+ }
55
+ // Renames a key without moving it.
56
+ export function renameKey(record, from, to) {
57
+ return Object.fromEntries(Object.entries(record).map(([k, v]) => [k === from ? to : k, v]));
58
+ }
59
+ // Moves one entry to a new place in the order.
60
+ export function moveKey(record, name, place) {
61
+ const rest = Object.keys(record).filter((k) => k !== name);
62
+ let at = 0;
63
+ if ('last' in place)
64
+ at = rest.length;
65
+ else if ('before' in place)
66
+ at = rest.indexOf(place.before);
67
+ else if ('after' in place)
68
+ at = rest.indexOf(place.after) + 1;
69
+ if (at < 0 || (at === 0 && 'after' in place))
70
+ return record;
71
+ rest.splice(at, 0, name);
72
+ return Object.fromEntries(rest.map((k) => [k, record[k]]));
73
+ }
74
+ export const workflowsUsing = (config, project) => Object.entries(config.workflows).filter(([, w]) => w.project === project).map(([n]) => n);
75
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
76
+ // Everything wrong with a whole agents config, e.g. one edited by hand.
77
+ // `saved` is the config on disk: only what the write changes is checked, so a
78
+ // moved folder or a retired agent elsewhere does not block an unrelated edit.
79
+ export function validateConfig(value, saved) {
80
+ if (!isObj(value))
81
+ return ['The config must be an object.'];
82
+ const problems = [];
83
+ if (value.engine !== 'claude' && value.engine !== 'codex')
84
+ problems.push('engine must be claude or codex.');
85
+ if (!isObj(value.models) || Object.values(value.models).some((m) => typeof m !== 'string'))
86
+ problems.push('models must map names to text.');
87
+ if (typeof value.reviewState !== 'string' || !value.reviewState.trim())
88
+ problems.push('reviewState must be a non-empty string.');
89
+ if (!isInt(value.timeoutMinutes, 1, 480))
90
+ problems.push('timeoutMinutes must be a whole number from 1 to 480.');
91
+ const projects = isObj(value.projects) ? value.projects : null;
92
+ if (!projects)
93
+ problems.push('projects must be an object.');
94
+ for (const [name, p] of Object.entries(projects ?? {})) {
95
+ if (!isObj(p) || typeof p.path !== 'string' || !p.path)
96
+ problems.push(`Project ${name} needs a path.`);
97
+ else if (saved?.projects?.[name]?.path !== p.path && !existsSync(join(p.path, '.git')))
98
+ problems.push(`Project ${name}: ${p.path} is not a git repository.`);
99
+ }
100
+ if (!Array.isArray(value.enabled) || value.enabled.some((a) => typeof a !== 'string' || !(a in AGENTS)))
101
+ problems.push(`enabled must list agents from: ${Object.keys(AGENTS).join(', ')}.`);
102
+ if (!Array.isArray(value.homes) || value.homes.some((h) => !isObj(h) || (h.kind !== 'claude' && h.kind !== 'codex') || typeof h.dir !== 'string'))
103
+ problems.push('homes must be a list of { kind: claude or codex, dir }.');
104
+ if (!isObj(value.workflows))
105
+ problems.push('workflows must be an object.');
106
+ else {
107
+ const ctx = { enabled: Array.isArray(value.enabled) ? value.enabled : [], projects: (projects ?? {}) };
108
+ for (const [name, w] of Object.entries(value.workflows)) {
109
+ if (!isObj(w))
110
+ problems.push(`Workflow ${name} must be an object.`);
111
+ else {
112
+ // An unchanged workflow using a step that is not enabled is only a warning.
113
+ const unchanged = !!saved?.workflows?.[name] && JSON.stringify(saved.workflows[name]) === JSON.stringify(w);
114
+ for (const p of validateWorkflow(unchanged ? { ...ctx, enabled: [] } : ctx, w))
115
+ problems.push(`Workflow ${name}: ${p}`);
116
+ }
117
+ }
118
+ }
119
+ return problems;
120
+ }
@@ -0,0 +1,2 @@
1
+ export declare function vaultPath(configDir: string): string | undefined;
2
+ export declare function recall(selfCommand: string[], configDir: string, text: string): string;
package/dist/memory.js ADDED
@@ -0,0 +1,37 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ // The memory tool's vault, when it is set up. Agents read it through
5
+ // `voidshift memory recall` and write notes into it directly.
6
+ export function vaultPath(configDir) {
7
+ const path = join(configDir, 'memory', 'config.json');
8
+ if (!existsSync(path))
9
+ return undefined;
10
+ try {
11
+ const vault = JSON.parse(readFileSync(path, 'utf8')).vault;
12
+ return vault && existsSync(join(vault, 'INDEX.md')) ? vault : undefined;
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ // Notes matching the text, recalled the same way as before each of your
19
+ // prompts. Empty when memory is not set up or nothing matches.
20
+ export function recall(selfCommand, configDir, text) {
21
+ if (!vaultPath(configDir) || !selfCommand.length)
22
+ return '';
23
+ const [cmd, ...args] = selfCommand;
24
+ const r = spawnSync(cmd, [...args, 'memory', 'recall'], {
25
+ input: JSON.stringify({ prompt: text, session_id: `agents-${Date.now()}` }),
26
+ encoding: 'utf8',
27
+ timeout: 10_000,
28
+ });
29
+ if (r.status !== 0 || !r.stdout.trim())
30
+ return '';
31
+ try {
32
+ return JSON.parse(r.stdout).hookSpecificOutput?.additionalContext ?? '';
33
+ }
34
+ catch {
35
+ return '';
36
+ }
37
+ }
@@ -0,0 +1,8 @@
1
+ import { type StepId } from './config.ts';
2
+ export declare function resolveName(names: string[], input: string, kind: string): {
3
+ name: string;
4
+ } | {
5
+ error: string;
6
+ };
7
+ export declare function parseStep(input: string): StepId | null;
8
+ export declare const stepNames: () => string;
package/dist/names.js ADDED
@@ -0,0 +1,26 @@
1
+ import { STEPS } from "./config.js";
2
+ // A workflow or project named in a command: the exact name, or the start of
3
+ // exactly one name, e.g. "tether" for tether-relay-build.
4
+ export function resolveName(names, input, kind) {
5
+ if (!input.trim())
6
+ return { error: `Give a ${kind} name.` };
7
+ if (names.includes(input))
8
+ return { name: input };
9
+ const matches = names.filter((n) => n.startsWith(input));
10
+ if (matches.length === 1)
11
+ return { name: matches[0] };
12
+ if (!matches.length)
13
+ return { error: `No ${kind} named ${input}.${names.length ? ` There are: ${names.join(', ')}.` : ''}` };
14
+ return { error: `${input} matches ${matches.join(', ')}. Type more of the name.` };
15
+ }
16
+ // Short step names for commands: plan, plan-review, publish, dev, qa. The
17
+ // full ids (vs-dev) and labels (Dev, "Plan review") work too.
18
+ export function parseStep(input) {
19
+ const v = input.trim().toLowerCase().replace(/\s+/g, '-');
20
+ for (const id of Object.keys(STEPS)) {
21
+ if (v === id || `vs-${v}` === id || STEPS[id].label.toLowerCase().replace(/\s+/g, '-') === v)
22
+ return id;
23
+ }
24
+ return null;
25
+ }
26
+ export const stepNames = () => Object.keys(STEPS).map((id) => id.replace(/^vs-/, '')).join(', ');
@@ -0,0 +1 @@
1
+ export declare function findRepos(extraRoots?: string[], maxDepth?: number, limit?: number): string[];
package/dist/repos.js ADDED
@@ -0,0 +1,45 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ const ROOTS = ['me', 'code', 'Code', 'projects', 'Projects', 'dev', 'Developer', 'src', 'work', 'repos', 'git', 'GitHub', 'Documents/GitHub'];
5
+ const SKIP = new Set(['node_modules', 'Library', 'Applications', 'dist', 'build', 'vendor', 'target']);
6
+ // Git repos under the usual code folders, a few levels deep. A folder with a
7
+ // .git directory is a repo and is not searched further; worktrees (a .git
8
+ // file) are left out so each repo shows once.
9
+ export function findRepos(extraRoots = [], maxDepth = 4, limit = 400) {
10
+ const found = new Set();
11
+ const roots = [...extraRoots, ...ROOTS.map((r) => join(homedir(), r))].filter((r) => existsSync(r));
12
+ const walk = (dir, depth) => {
13
+ if (found.size >= limit)
14
+ return;
15
+ const git = join(dir, '.git');
16
+ if (existsSync(git) && statSync(git).isDirectory()) {
17
+ found.add(dir);
18
+ return;
19
+ }
20
+ if (depth >= maxDepth)
21
+ return;
22
+ let entries;
23
+ try {
24
+ entries = readdirSync(dir);
25
+ }
26
+ catch {
27
+ return;
28
+ }
29
+ for (const name of entries) {
30
+ if (name.startsWith('.') || SKIP.has(name))
31
+ continue;
32
+ const full = join(dir, name);
33
+ try {
34
+ if (statSync(full).isDirectory())
35
+ walk(full, depth + 1);
36
+ }
37
+ catch {
38
+ // unreadable, skip
39
+ }
40
+ }
41
+ };
42
+ for (const root of roots)
43
+ walk(root, 0);
44
+ return [...found].sort((a, b) => a.localeCompare(b));
45
+ }
@@ -0,0 +1,23 @@
1
+ import { type AgentsConfig, type Workflow, type Project, type StepId, type Trigger } from './config.ts';
2
+ import type { Card, Linear } from './linear.ts';
3
+ export interface RunContext {
4
+ configDir: string;
5
+ config: AgentsConfig;
6
+ linear: Linear;
7
+ log: (line: string) => void;
8
+ selfCommand: string[];
9
+ }
10
+ export declare function summarize(output: string): string;
11
+ export declare function runCard(card: Card, trigger: Trigger, workflowName: string, workflow: Workflow, project: Project, rc: RunContext, opts?: {
12
+ from?: StepId;
13
+ }): Promise<'verified' | 'changes' | 'parked'>;
14
+ export declare function nextJob(rc: RunContext, only?: string, workflowName?: string): Promise<{
15
+ card: Card;
16
+ trigger: Trigger;
17
+ workflow: string;
18
+ } | null>;
19
+ export declare function cancelCard(linear: Linear, configDir: string, cardId: string, label: string): Promise<boolean>;
20
+ export declare function tick(rc: RunContext, only?: string, opts?: {
21
+ workflow?: string;
22
+ from?: StepId;
23
+ }): Promise<number>;