@gredescerber/agent-crew 0.5.0 → 0.6.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.
- package/AGENTS.md +1 -0
- package/README.md +8 -1
- package/README.ru.md +8 -1
- package/package.json +1 -1
- package/skills/crew-workflow/SKILL.md +4 -1
- package/src/commands/ask.js +123 -0
- package/src/commands/board.js +91 -0
- package/src/commands/check.js +21 -3
- package/src/commands/digest.js +72 -0
- package/src/commands/merge-ready.js +33 -0
- package/src/commands/pr.js +65 -3
- package/src/commands/task.js +22 -1
- package/src/commands/upgrade.js +178 -0
- package/src/core/board.js +141 -0
- package/src/core/config.js +6 -0
- package/src/core/digest.js +67 -0
- package/src/core/guard.js +29 -4
- package/src/core/issues.js +1 -1
- package/src/core/prd.js +23 -4
- package/src/core/questions.js +51 -0
- package/src/core/queue.js +4 -2
- package/src/core/registry.js +6 -1
- package/src/core/session.js +61 -0
- package/src/mcp/server.js +10 -0
- package/templates/labels.json +21 -16
- package/templates/product/.github/workflows/crew-guard.yml +1 -1
- package/templates/product/.github/workflows/crew.yml +13 -3
- package/templates/product/AGENTS.md +1 -1
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// crew upgrade [--apply] — bring an existing project's crew files up to this version of agent-crew:
|
|
2
|
+
// AGENTS.md, roles, skills, GitHub workflows and templates, playbooks, generated agent configs, new config keys.
|
|
3
|
+
// Also migrates projects made by hackathon-harness (hk): hk.config.json, hk*.yml, hk-* skills, docs/tz*.md.
|
|
4
|
+
// Without --apply it only shows the plan. With --apply: branch crew/upgrade-<version>, one commit, a PR.
|
|
5
|
+
// The PR changes protected files (AGENTS.md, .github/, agents/), so crew-guard rejects it by design:
|
|
6
|
+
// a human (admin) reviews and merges it.
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, rmSync } from 'node:fs';
|
|
8
|
+
import { dirname, join, relative } from 'node:path';
|
|
9
|
+
import { ok, fail } from '../core/output.js';
|
|
10
|
+
import { git, gh } from '../core/exec.js';
|
|
11
|
+
import { HARNESS_ROOT, DEFAULT_CONFIG } from '../core/config.js';
|
|
12
|
+
import { LOCAL_VERSION } from '../core/version.js';
|
|
13
|
+
import { buildFiles } from './adapters.js';
|
|
14
|
+
|
|
15
|
+
/** Files agent-crew owns in a product repo: [source in the package, destination in the repo]. */
|
|
16
|
+
export const OWNED = [
|
|
17
|
+
['templates/product/AGENTS.md', 'AGENTS.md'],
|
|
18
|
+
['templates/product/.github', '.github'],
|
|
19
|
+
['templates/product/docs/playbooks', 'docs/playbooks'],
|
|
20
|
+
['roles', 'agents/roles'],
|
|
21
|
+
['skills', 'agents/skills'],
|
|
22
|
+
];
|
|
23
|
+
const LEGACY_DELETE = ['.github/workflows/hk.yml', '.github/workflows/hk-guard.yml'];
|
|
24
|
+
/** Skills shipped by hackathon-harness; a project's own skills (even named hk-*) are never touched. */
|
|
25
|
+
const LEGACY_SKILLS = ['hk-kickoff', 'hk-review', 'hk-tz-analysis', 'hk-workflow'];
|
|
26
|
+
const LEGACY_RENAME = [['hk.config.json', 'crew.config.json'], ['docs/tz.md', 'docs/brief.md'], ['docs/tz-analysis.md', 'docs/brief-analysis.md']];
|
|
27
|
+
|
|
28
|
+
const walk = (abs, base = abs) => {
|
|
29
|
+
if (!existsSync(abs)) return [];
|
|
30
|
+
if (statSync(abs).isFile()) return [relative(base, abs).replace(/\\/g, '/')];
|
|
31
|
+
return readdirSync(abs).flatMap((n) => walk(join(abs, n), base));
|
|
32
|
+
};
|
|
33
|
+
const norm = (s) => String(s ?? '').replace(/\r\n/g, '\n');
|
|
34
|
+
|
|
35
|
+
/** Package files to put into the repo: [{ path, content }]. */
|
|
36
|
+
export function ownedFiles(harnessRoot = HARNESS_ROOT) {
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const [src, dest] of OWNED) {
|
|
39
|
+
const abs = join(harnessRoot, src);
|
|
40
|
+
if (statSync(abs).isFile()) out.push({ path: dest, content: readFileSync(abs, 'utf8') });
|
|
41
|
+
else for (const f of walk(abs)) out.push({ path: `${dest}/${f}`, content: readFileSync(join(abs, f), 'utf8') });
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Config after upgrade: user values kept; missing top-level keys and missing areas added. */
|
|
47
|
+
export function mergeConfig(cfg = {}, defaults = DEFAULT_CONFIG) {
|
|
48
|
+
const out = { ...cfg };
|
|
49
|
+
const added = [];
|
|
50
|
+
for (const [k, v] of Object.entries(defaults)) if (!(k in out)) { out[k] = v; added.push(k); }
|
|
51
|
+
if (defaults.areas && out.areas) {
|
|
52
|
+
for (const [a, globs] of Object.entries(defaults.areas)) {
|
|
53
|
+
if (!(a in out.areas)) { out.areas = { ...out.areas, [a]: globs }; added.push(`areas.${a}`); }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { config: out, added };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** .gitignore: the legacy local-files section header and .hk/ become their crew names. */
|
|
60
|
+
export function migrateGitignore(text = '') {
|
|
61
|
+
return norm(text)
|
|
62
|
+
.replace(/^# hk: (локальные файлы|local files)$/m, '# crew: local files')
|
|
63
|
+
.replace(/^\.hk\/$/m, '.crew/');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The plan (pure): repo = { exists(p), read(p), list(dir) }. → { ops, legacy, config_added }
|
|
68
|
+
* op: { op: 'rename', from, path } | { op: 'delete', path } | { op: 'write', path, content, kind: 'create'|'update' }
|
|
69
|
+
*/
|
|
70
|
+
export function planUpgrade(repo, { files = ownedFiles(), defaults = DEFAULT_CONFIG } = {}) {
|
|
71
|
+
const ops = [];
|
|
72
|
+
const legacy = repo.exists('hk.config.json') && !repo.exists('crew.config.json');
|
|
73
|
+
const moved = new Map(); // destination path -> content (after legacy renames)
|
|
74
|
+
for (const [from, to] of LEGACY_RENAME) {
|
|
75
|
+
if (repo.exists(from) && !repo.exists(to)) { ops.push({ op: 'rename', from, path: to }); moved.set(to, repo.read(from)); }
|
|
76
|
+
}
|
|
77
|
+
for (const p of LEGACY_DELETE) if (repo.exists(p)) ops.push({ op: 'delete', path: p });
|
|
78
|
+
for (const p of repo.list?.('agents/skills') ?? []) {
|
|
79
|
+
if (LEGACY_SKILLS.some((s) => p.startsWith(`agents/skills/${s}/`))) ops.push({ op: 'delete', path: p });
|
|
80
|
+
}
|
|
81
|
+
for (const f of files) {
|
|
82
|
+
const cur = repo.exists(f.path) ? repo.read(f.path) : null;
|
|
83
|
+
if (cur === null) ops.push({ op: 'write', path: f.path, content: f.content, kind: 'create' });
|
|
84
|
+
else if (norm(cur) !== norm(f.content)) ops.push({ op: 'write', path: f.path, content: f.content, kind: 'update' });
|
|
85
|
+
}
|
|
86
|
+
const cfgText = moved.get('crew.config.json') ?? (repo.exists('crew.config.json') ? repo.read('crew.config.json') : null);
|
|
87
|
+
let config_added = [];
|
|
88
|
+
if (cfgText !== null) {
|
|
89
|
+
const { config, added } = mergeConfig(JSON.parse(cfgText), defaults);
|
|
90
|
+
config_added = added;
|
|
91
|
+
if (added.length) ops.push({ op: 'write', path: 'crew.config.json', content: JSON.stringify(config, null, 2) + '\n', kind: 'update' });
|
|
92
|
+
}
|
|
93
|
+
if (repo.exists('.gitignore')) {
|
|
94
|
+
const gi = repo.read('.gitignore');
|
|
95
|
+
const next = migrateGitignore(gi);
|
|
96
|
+
if (next !== norm(gi)) ops.push({ op: 'write', path: '.gitignore', content: next, kind: 'update' });
|
|
97
|
+
}
|
|
98
|
+
return { ops, legacy, config_added };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const summarize = (ops) => ops.map((o) => (o.op === 'rename' ? `rename ${o.from} -> ${o.path}` : `${o.op === 'delete' ? 'delete' : o.kind} ${o.path}`));
|
|
102
|
+
|
|
103
|
+
export default {
|
|
104
|
+
name: 'upgrade',
|
|
105
|
+
summary: 'Update this project to the installed agent-crew: rules, skills, workflows, configs (and migrate from hk); --apply opens a PR',
|
|
106
|
+
agent: false,
|
|
107
|
+
args: [{ name: 'apply', type: 'boolean', description: 'create the branch, commit and open the PR (without it: only show the plan)' }],
|
|
108
|
+
async run({ apply = false } = {}) {
|
|
109
|
+
const top = git(['rev-parse', '--show-toplevel'], { allowFail: true });
|
|
110
|
+
if (top.code !== 0) return fail('not_a_repo', 'Run crew upgrade inside the project repo', { retry: 'cd <project> && crew upgrade' });
|
|
111
|
+
const root = top.stdout.trim();
|
|
112
|
+
if (!existsSync(join(root, 'crew.config.json')) && !existsSync(join(root, 'hk.config.json'))) {
|
|
113
|
+
return fail('not_a_crew_project', 'No crew.config.json (or legacy hk.config.json) here', { retry: 'crew init <dir> for a new project' });
|
|
114
|
+
}
|
|
115
|
+
const repo = {
|
|
116
|
+
exists: (p) => existsSync(join(root, p)),
|
|
117
|
+
read: (p) => readFileSync(join(root, p), 'utf8'),
|
|
118
|
+
list: (p) => walk(join(root, p)).map((f) => `${p}/${f}`),
|
|
119
|
+
};
|
|
120
|
+
const plan = planUpgrade(repo);
|
|
121
|
+
const branch = `crew/upgrade-${LOCAL_VERSION}`;
|
|
122
|
+
const lines = summarize(plan.ops);
|
|
123
|
+
if (!apply) {
|
|
124
|
+
return ok(plan.ops.length ? `crew upgrade to ${LOCAL_VERSION}: ${plan.ops.length} changes (dry run)${plan.legacy ? ', migrating from hk' : ''}` : `Already up to date with agent-crew ${LOCAL_VERSION}`, {
|
|
125
|
+
artifacts: { version: LOCAL_VERSION, legacy: plan.legacy, changes: lines, config_added: plan.config_added },
|
|
126
|
+
next_actions: plan.ops.length ? ['crew upgrade --apply (creates a branch and a PR; agent configs are regenerated too)'] : ['nothing to do'],
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (!plan.ops.length) return ok(`Already up to date with agent-crew ${LOCAL_VERSION}`);
|
|
130
|
+
if (git(['status', '--porcelain'], { cwd: root }).stdout.trim()) {
|
|
131
|
+
return fail('dirty_tree', 'Uncommitted changes in the project', { retry: 'commit or stash them, then crew upgrade --apply' });
|
|
132
|
+
}
|
|
133
|
+
git(['fetch', '--quiet', 'origin'], { cwd: root, allowFail: true });
|
|
134
|
+
const base = (gh(['repo', 'view', '--json', 'defaultBranchRef', '--jq', '.defaultBranchRef.name'], { cwd: root, allowFail: true }).stdout || '').trim() || 'main';
|
|
135
|
+
const sw = git(['switch', '-c', branch, `origin/${base}`], { cwd: root, allowFail: true });
|
|
136
|
+
if (sw.code !== 0) return fail('branch_exists', `Could not create ${branch}`, { cause_hint: (sw.stderr || sw.stdout).split('\n')[0], retry: `git branch -D ${branch} (if it is a leftover), then crew upgrade --apply` });
|
|
137
|
+
for (const o of plan.ops) {
|
|
138
|
+
if (o.op === 'rename') git(['mv', o.from, o.path], { cwd: root });
|
|
139
|
+
else if (o.op === 'delete') rmSync(join(root, o.path), { force: true });
|
|
140
|
+
}
|
|
141
|
+
for (const o of plan.ops.filter((x) => x.op === 'write')) {
|
|
142
|
+
mkdirSync(dirname(join(root, o.path)), { recursive: true });
|
|
143
|
+
writeFileSync(join(root, o.path), o.content);
|
|
144
|
+
}
|
|
145
|
+
// Generated agent configs from the new rules (same as crew adapters build), committed ones only.
|
|
146
|
+
const config = JSON.parse(readFileSync(join(root, 'crew.config.json'), 'utf8'));
|
|
147
|
+
const { files } = await buildFiles(root, config);
|
|
148
|
+
for (const f of files.filter((x) => x.commit)) {
|
|
149
|
+
mkdirSync(dirname(join(root, f.path)), { recursive: true });
|
|
150
|
+
writeFileSync(join(root, f.path), f.content);
|
|
151
|
+
}
|
|
152
|
+
git(['add', '-A'], { cwd: root });
|
|
153
|
+
git(['commit', '-q', '-m', `chore: upgrade to agent-crew ${LOCAL_VERSION}`, '-m', 'Agent: crew-upgrade'], { cwd: root });
|
|
154
|
+
const push = git(['push', '-u', 'origin', branch], { cwd: root, allowFail: true });
|
|
155
|
+
if (push.code !== 0) return fail('push_failed', `Committed on ${branch}, push failed`, { cause_hint: (push.stderr || push.stdout).split('\n')[0], retry: `git push -u origin ${branch}` });
|
|
156
|
+
const body = [
|
|
157
|
+
`Upgrade to agent-crew ${LOCAL_VERSION}${plan.legacy ? ' (migration from hackathon-harness / hk)' : ''}.`,
|
|
158
|
+
'',
|
|
159
|
+
'Changes:',
|
|
160
|
+
...lines.map((l) => `- ${l}`),
|
|
161
|
+
'- regenerated agent configs (crew adapters build)',
|
|
162
|
+
'',
|
|
163
|
+
'This PR changes protected files (AGENTS.md, .github/, agents/), so crew-guard fails it on purpose: a human reviews and merges it.',
|
|
164
|
+
'Check the diff of updated and deleted files: local customizations of AGENTS.md, workflows or roles are replaced by the package versions — re-apply the ones you need.',
|
|
165
|
+
'After merging: `crew github setup --apply` (labels, CREW_HARNESS variable)' + (plan.legacy ? '; delete the old variable: `gh variable delete HK_HARNESS`.' : '.'),
|
|
166
|
+
].join('\n');
|
|
167
|
+
const pr = gh(['pr', 'create', '--base', base, '--head', branch, '--title', `chore: upgrade to agent-crew ${LOCAL_VERSION}`, '--body', body], { cwd: root, allowFail: true });
|
|
168
|
+
return ok(`Upgrade PR: ${(pr.stdout || '').split('\n').pop() || '(gh pr create failed — open it by hand)'}`, {
|
|
169
|
+
status: pr.code === 0 ? 'success' : 'warning',
|
|
170
|
+
artifacts: { branch, version: LOCAL_VERSION, legacy: plan.legacy, changes: lines },
|
|
171
|
+
next_actions: [
|
|
172
|
+
'human: review and merge the PR (crew-guard rejects protected-file changes by design)',
|
|
173
|
+
'after merge: crew github setup --apply' + (plan.legacy ? ' and gh variable delete HK_HARNESS' : ''),
|
|
174
|
+
'everyone: update the local crew (npm i -g @gredescerber/agent-crew) and restart agent sessions',
|
|
175
|
+
],
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Task board for humans (crew board): see the queue and steer it by hand without spending agent tokens.
|
|
2
|
+
// Pure functions: the model shown on the page, and the gh arguments for each action the page can request.
|
|
3
|
+
import { labelValue } from './issues.js';
|
|
4
|
+
import { prQueue } from './queue.js';
|
|
5
|
+
import { LOGIN_RE } from './prd.js';
|
|
6
|
+
import { digestModel } from './digest.js';
|
|
7
|
+
|
|
8
|
+
export const COLUMNS = ['ready', 'claimed', 'review', 'waiting', 'blocked', 'needs-spec'];
|
|
9
|
+
export const PRIORITIES = ['high', 'normal', 'low'];
|
|
10
|
+
/** Statuses a human may set from the board. claimed/review belong to the agent's lease and PR flow. */
|
|
11
|
+
export const BOARD_STATUSES = ['ready', 'blocked', 'needs-spec', 'waiting'];
|
|
12
|
+
|
|
13
|
+
const prio = (i) => labelValue(i, 'priority') ?? 'normal';
|
|
14
|
+
|
|
15
|
+
export function boardModel({ issues = [], prs = [], members = [], questions = [], team = null, config = {}, now = Date.now() } = {}) {
|
|
16
|
+
const columns = Object.fromEntries(COLUMNS.map((c) => [c, []]));
|
|
17
|
+
const other = [];
|
|
18
|
+
for (const i of issues) {
|
|
19
|
+
const status = labelValue(i, 'status');
|
|
20
|
+
if ((i.labels ?? []).some((l) => l.name === 'epic')) continue;
|
|
21
|
+
const card = {
|
|
22
|
+
number: i.number,
|
|
23
|
+
title: i.title,
|
|
24
|
+
status,
|
|
25
|
+
area: labelValue(i, 'area'),
|
|
26
|
+
tier: labelValue(i, 'tier'),
|
|
27
|
+
risk: labelValue(i, 'risk'),
|
|
28
|
+
priority: prio(i),
|
|
29
|
+
assignees: (i.assignees ?? []).map((a) => a.login),
|
|
30
|
+
idle_min: Math.max(0, Math.round((now - Date.parse(i.updatedAt ?? '')) / 60000)) || 0,
|
|
31
|
+
};
|
|
32
|
+
(columns[status] ?? other).push(card);
|
|
33
|
+
}
|
|
34
|
+
const order = { high: 0, normal: 1, low: 2 };
|
|
35
|
+
for (const c of COLUMNS) columns[c].sort((a, b) => order[a.priority] - order[b.priority] || a.number - b.number);
|
|
36
|
+
const queue = prQueue(prs, { me: 'board', login: null, reviewers: [], config, now }).map((x) => ({
|
|
37
|
+
kind: x.kind, number: x.number, title: x.title, risk: x.risk, age_min: x.age, reason: x.reason ?? null, have: x.have ?? null, need: x.need ?? null,
|
|
38
|
+
}));
|
|
39
|
+
// Captain panel: the same status crew digest posts to the pinned issue.
|
|
40
|
+
const digest = digestModel({ issues, prs, questions, team, config, now });
|
|
41
|
+
return { columns, untracked: other.length, prs: queue, digest, members: [...new Set(members)].sort((a, b) => a.localeCompare(b)), generated_at: new Date(now).toISOString() };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* gh arguments for one board action, or { error }. Everything is validated here because the page
|
|
46
|
+
* is only a convenience: the server never passes page input to gh unchecked.
|
|
47
|
+
* current: the issue as listed ({ number, labels, assignees }).
|
|
48
|
+
*/
|
|
49
|
+
export function actionArgs({ op, value }, current, { members = [] } = {}) {
|
|
50
|
+
if (!current || !Number.isInteger(current.number)) return { error: 'unknown issue' };
|
|
51
|
+
const n = String(current.number);
|
|
52
|
+
const labels = (current.labels ?? []).map((l) => l.name);
|
|
53
|
+
if (op === 'assign') {
|
|
54
|
+
const to = String(value ?? '');
|
|
55
|
+
const now = (current.assignees ?? []).map((a) => a.login);
|
|
56
|
+
if (to && (!LOGIN_RE.test(to) || !members.some((m) => m.toLowerCase() === to.toLowerCase()))) return { error: `${to} is not a member of this repo` };
|
|
57
|
+
const args = ['issue', 'edit', n];
|
|
58
|
+
for (const a of now) if (a.toLowerCase() !== to.toLowerCase()) args.push('--remove-assignee', a);
|
|
59
|
+
if (to && !now.some((a) => a.toLowerCase() === to.toLowerCase())) args.push('--add-assignee', to);
|
|
60
|
+
return args.length > 3 ? { args } : { noop: true };
|
|
61
|
+
}
|
|
62
|
+
if (op === 'status') {
|
|
63
|
+
const to = String(value ?? '');
|
|
64
|
+
if (!BOARD_STATUSES.includes(to)) return { error: `status must be one of ${BOARD_STATUSES.join(', ')}` };
|
|
65
|
+
const from = labels.filter((l) => l.startsWith('status:')).map((l) => l.slice(7));
|
|
66
|
+
if (from.some((s) => s === 'claimed' || s === 'review')) {
|
|
67
|
+
return { error: `#${n} is ${from.join(',')}: an agent holds it — use crew task release ${n} --force first` };
|
|
68
|
+
}
|
|
69
|
+
if (from.length === 1 && from[0] === to) return { noop: true };
|
|
70
|
+
const args = ['issue', 'edit', n, '--add-label', `status:${to}`];
|
|
71
|
+
for (const f of from) if (f !== to) args.push('--remove-label', `status:${f}`);
|
|
72
|
+
return { args };
|
|
73
|
+
}
|
|
74
|
+
if (op === 'priority') {
|
|
75
|
+
const to = String(value ?? '');
|
|
76
|
+
if (!PRIORITIES.includes(to)) return { error: `priority must be one of ${PRIORITIES.join(', ')}` };
|
|
77
|
+
const args = ['issue', 'edit', n];
|
|
78
|
+
for (const l of labels.filter((x) => x.startsWith('priority:') && x !== `priority:${to}`)) args.push('--remove-label', l);
|
|
79
|
+
if (to !== 'normal' && !labels.includes(`priority:${to}`)) args.push('--add-label', `priority:${to}`);
|
|
80
|
+
return args.length > 3 ? { args } : { noop: true };
|
|
81
|
+
}
|
|
82
|
+
return { error: `unknown action ${op}` };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
86
|
+
|
|
87
|
+
/** The board page. Data comes from /api/board; actions go to /api/action with the per-run token. */
|
|
88
|
+
export function renderPage({ token, repo }) {
|
|
89
|
+
return `<!doctype html>
|
|
90
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
91
|
+
<title>crew board — ${esc(repo)}</title>
|
|
92
|
+
<style>
|
|
93
|
+
:root{--bg:#f6f7f9;--card:#fff;--ink:#1d2433;--muted:#667085;--line:#e3e6ec;--hi:#d92d20;--warn:#b54708;--good:#067647}
|
|
94
|
+
@media (prefers-color-scheme:dark){:root{--bg:#111418;--card:#1a1f26;--ink:#e6e9ef;--muted:#98a2b3;--line:#2a313b;--hi:#f97066;--warn:#fdb022;--good:#47cd89}}
|
|
95
|
+
*{box-sizing:border-box}body{margin:0;font:14px/1.4 system-ui,sans-serif;background:var(--bg);color:var(--ink)}
|
|
96
|
+
header{display:flex;gap:12px;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);flex-wrap:wrap}
|
|
97
|
+
header h1{font-size:16px;margin:0}.muted{color:var(--muted)}
|
|
98
|
+
main{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;padding:12px 16px}
|
|
99
|
+
section h2,#prs h2,.alerts h2{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:4px 0 8px}
|
|
100
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:10px;margin-bottom:8px}
|
|
101
|
+
.card a{color:var(--ink);text-decoration:none;font-weight:600}.meta{color:var(--muted);font-size:12px;margin:4px 0 6px}
|
|
102
|
+
.prio-high{border-left:4px solid var(--hi)}.prio-low{opacity:.75}
|
|
103
|
+
.row{display:flex;gap:6px;flex-wrap:wrap}select{font:inherit;font-size:12px;padding:2px 4px;border-radius:6px;border:1px solid var(--line);background:var(--card);color:var(--ink)}
|
|
104
|
+
#prs{padding:0 16px 16px}#prs a{color:var(--ink)}#prs li{margin:4px 0}#msg{color:var(--hi)}
|
|
105
|
+
#cap{padding:12px 16px 0}.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px}
|
|
106
|
+
.kpi{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:8px 10px}.kpi b{display:block;font-size:22px;font-variant-numeric:tabular-nums}
|
|
107
|
+
.kpi span{color:var(--muted);font-size:12px}.kpi.bad{border-color:var(--hi)}.kpi.bad b{color:var(--hi)}.kpi.warn{border-color:var(--warn)}.kpi.warn b{color:var(--warn)}
|
|
108
|
+
.alerts{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:8px;margin-top:8px}.alerts ul{margin:0;padding-left:18px}.alerts li{margin:2px 0}
|
|
109
|
+
.ok{color:var(--good)}
|
|
110
|
+
</style></head><body>
|
|
111
|
+
<header><h1>crew board</h1><span class="muted">${esc(repo)}</span><span class="muted" id="time"></span><span id="msg"></span></header>
|
|
112
|
+
<div id="cap"><div class="kpis" id="kpis"></div><div class="alerts" id="alerts"></div></div>
|
|
113
|
+
<main id="cols"></main>
|
|
114
|
+
<div id="prs"><h2>Pull requests waiting</h2><ul id="prlist"></ul></div>
|
|
115
|
+
<script>
|
|
116
|
+
const TOKEN=${JSON.stringify(token)};const REPO=${JSON.stringify(repo)};
|
|
117
|
+
const COLS=${JSON.stringify(COLUMNS)};const ST=${JSON.stringify(BOARD_STATUSES)};const PR=${JSON.stringify(PRIORITIES)};
|
|
118
|
+
const e=(t,a={},...k)=>{const n=document.createElement(t);for(const[x,y]of Object.entries(a)){if(x==='on')for(const[ev,f]of Object.entries(y))n.addEventListener(ev,f);else n.setAttribute(x,y)}for(const c of k)n.append(c);return n};
|
|
119
|
+
const sel=(opts,cur,on)=>{const s=e('select',{on:{change:()=>on(s.value)}});for(const[v,l]of opts){const o=e('option',{value:v},l);if(v===cur)o.selected=true;s.append(o)}return s};
|
|
120
|
+
async function act(issue,op,value){const r=await fetch('/api/action',{method:'POST',headers:{'content-type':'application/json','x-crew-token':TOKEN},body:JSON.stringify({issue,op,value})});const j=await r.json();document.getElementById('msg').textContent=j.error||'';load()}
|
|
121
|
+
async function load(){const r=await fetch('/api/board',{headers:{'x-crew-token':TOKEN}});const m=await r.json();if(m.error){document.getElementById('msg').textContent=m.error;return}
|
|
122
|
+
document.getElementById('time').textContent='updated '+new Date(m.generated_at).toLocaleTimeString();
|
|
123
|
+
const d=m.digest,c=d.counts,k=document.getElementById('kpis');k.replaceChildren();
|
|
124
|
+
for(const[n,l,cls]of[[c.ready,'ready',''],[c.claimed,'in progress',''],[c.human,'need a human',c.human?'bad':''],[c.stale,'stale leases',c.stale?'warn':''],[c.prs,'PRs waiting',''],[c.questions,'open questions',''],[c.idle,'idle members',c.idle?'warn':'']])k.append(e('div',{class:'kpi '+cls},e('b',{},String(n)),e('span',{},l)));
|
|
125
|
+
const al=document.getElementById('alerts');al.replaceChildren();const busy=d.sections.filter(s=>s.key!=='prs'&&s.lines.length);
|
|
126
|
+
for(const s of busy){const ul=e('ul');for(const l of s.lines)ul.append(e('li',{},l));al.append(e('div',{class:'card'},e('h2',{},s.title),ul))}
|
|
127
|
+
if(!busy.length)al.append(e('div',{class:'card ok'},'All clear: nobody waits for a human, no stale leases, everyone is busy.'));
|
|
128
|
+
const cols=document.getElementById('cols');cols.replaceChildren();
|
|
129
|
+
for(const c of COLS){const s=e('section',{},e('h2',{},c+' ('+m.columns[c].length+')'));
|
|
130
|
+
for(const t of m.columns[c]){const who=[['','unassigned'],...m.members.map(x=>[x,x])];
|
|
131
|
+
const ctl=e('div',{class:'row'},sel(who,t.assignees[0]||'',v=>act(t.number,'assign',v)),sel(PR.map(p=>[p,p]),t.priority,v=>act(t.number,'priority',v)));
|
|
132
|
+
if(ST.includes(t.status))ctl.append(sel(ST.map(x=>[x,x]),t.status,v=>act(t.number,'status',v)));
|
|
133
|
+
s.append(e('div',{class:'card prio-'+t.priority},e('a',{href:'https://github.com/'+REPO+'/issues/'+t.number,target:'_blank',rel:'noopener'},'#'+t.number+' '+t.title),
|
|
134
|
+
e('div',{class:'meta'},[t.area&&('area:'+t.area),t.risk&&('risk:'+t.risk),t.tier&&('tier:'+t.tier),t.assignees.length?('@'+t.assignees.join(' @')):'no owner',t.idle_min+' min'].filter(Boolean).join(' · ')),ctl))}
|
|
135
|
+
cols.append(s)}
|
|
136
|
+
const ul=document.getElementById('prlist');ul.replaceChildren();
|
|
137
|
+
for(const p of m.prs)ul.append(e('li',{},e('a',{href:'https://github.com/'+REPO+'/pull/'+p.number,target:'_blank',rel:'noopener'},'#'+p.number+' '+p.title),' — '+(p.kind==='review'?('waiting for review '+p.age_min+' min ('+p.have+'/'+p.need+')'):p.kind==='merge'?'ready to merge':('waiting for the author: '+p.reason))));
|
|
138
|
+
if(!m.prs.length)ul.append(e('li',{class:'muted'},'none'))}
|
|
139
|
+
load();setInterval(load,30000);
|
|
140
|
+
</script></body></html>`;
|
|
141
|
+
}
|
package/src/core/config.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { hostname } from 'node:os';
|
|
3
|
+
import { pinnedId } from './session.js';
|
|
3
4
|
import { join, dirname, resolve } from 'node:path';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
6
|
|
|
@@ -72,6 +73,11 @@ export const machineName = () =>
|
|
|
72
73
|
*/
|
|
73
74
|
export function agentId(env = process.env) {
|
|
74
75
|
if (env.CREW_AGENT_ID) return env.CREW_AGENT_ID;
|
|
76
|
+
// A task worktree remembers who claimed it (crew task claim), so CLI calls there act as the same session.
|
|
77
|
+
if (env === process.env) {
|
|
78
|
+
const pinned = pinnedId();
|
|
79
|
+
if (pinned) return pinned;
|
|
80
|
+
}
|
|
75
81
|
const base = env.CREW_AGENT || 'human';
|
|
76
82
|
return base.includes('@') ? base : `${base}@${machineName()}`;
|
|
77
83
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Team digest without an LLM: what waits for a human, stale leases, PRs, idle members. `crew digest` prints it;
|
|
2
|
+
// the sweep job posts it to one pinned issue every 15 minutes, so no agent session has to poll the repo.
|
|
3
|
+
import { labelValue } from './issues.js';
|
|
4
|
+
import { prQueue } from './queue.js';
|
|
5
|
+
import { questionMeta, HUMAN_LABEL, QUESTION_LABEL } from './questions.js';
|
|
6
|
+
|
|
7
|
+
export const DIGEST_LABEL = 'crew:digest';
|
|
8
|
+
export const DIGEST_TITLE = 'crew digest: team status';
|
|
9
|
+
export const DIGEST_MARKER = '<!-- crew:digest -->';
|
|
10
|
+
|
|
11
|
+
const minsSince = (now, iso) => Math.max(0, Math.round((now - Date.parse(iso ?? '')) / 60000)) || 0;
|
|
12
|
+
const names = (xs) => (xs ?? []).map((x) => x.name);
|
|
13
|
+
|
|
14
|
+
/** -> { sections: [{ key, title, lines }], counts } (pure, no network). */
|
|
15
|
+
export function digestModel({ issues = [], prs = [], questions = [], team = null, config = {}, now = Date.now() } = {}) {
|
|
16
|
+
const lease = config.lease_minutes ?? 45;
|
|
17
|
+
const reviewWait = config.review?.fallback_minutes ?? 30;
|
|
18
|
+
const st = (i) => labelValue(i, 'status');
|
|
19
|
+
const who = (i) => (i.assignees ?? []).map((a) => `@${a.login}`).join(' ') || 'no owner';
|
|
20
|
+
const tasks = issues.filter((i) => !names(i.labels).some((n) => ['epic', QUESTION_LABEL, DIGEST_LABEL].includes(n)));
|
|
21
|
+
const ready = tasks.filter((i) => st(i) === 'ready');
|
|
22
|
+
const claimed = tasks.filter((i) => st(i) === 'claimed');
|
|
23
|
+
|
|
24
|
+
const human = questions.filter((q) => names(q.labels).includes(HUMAN_LABEL))
|
|
25
|
+
.map((q) => `#${q.number} ${q.title} (asked by ${questionMeta(q)?.from ?? 'an agent'})`);
|
|
26
|
+
const blocked = tasks.filter((i) => ['blocked', 'needs-spec'].includes(st(i))).map((i) => `#${i.number} ${i.title} (${st(i)}, ${who(i)})`);
|
|
27
|
+
const stale = claimed.filter((i) => minsSince(now, i.updatedAt) > lease)
|
|
28
|
+
.map((i) => `#${i.number} ${i.title} (${who(i)}, quiet ${minsSince(now, i.updatedAt)} min, lease ${lease})`);
|
|
29
|
+
const prLines = prQueue(prs, { me: 'crew-digest', login: null, reviewers: [], config, now }).map((x) =>
|
|
30
|
+
x.kind === 'fix' ? `#${x.number} ${x.title}: waits for its author (${x.reason})`
|
|
31
|
+
: x.kind === 'merge' ? `#${x.number} ${x.title}: ready to merge`
|
|
32
|
+
: `#${x.number} ${x.title}: waits for review ${x.age} min${x.age > reviewWait ? ' (slow)' : ''}`);
|
|
33
|
+
const unanswered = questions.filter((q) => !names(q.labels).includes(HUMAN_LABEL))
|
|
34
|
+
.map((q) => `#${q.number} ${q.title} (to ${who(q)}, ${minsSince(now, q.createdAt ?? q.updatedAt)} min)`);
|
|
35
|
+
const idle = [];
|
|
36
|
+
for (const m of team?.members ?? []) {
|
|
37
|
+
const lc = m.login.toLowerCase();
|
|
38
|
+
const mine = (i) => (i.assignees ?? []).some((a) => a.login.toLowerCase() === lc);
|
|
39
|
+
if (claimed.some(mine)) continue;
|
|
40
|
+
const waiting = ready.filter(mine).length;
|
|
41
|
+
idle.push(`@${m.login}: nothing in progress, ${waiting ? `${waiting} ready task(s) assigned` : 'no ready tasks assigned'}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sections = [
|
|
45
|
+
{ key: 'human', title: 'Needs a human', lines: [...human, ...blocked] },
|
|
46
|
+
{ key: 'stale', title: 'Stale leases', lines: stale },
|
|
47
|
+
{ key: 'prs', title: 'Pull requests', lines: prLines },
|
|
48
|
+
{ key: 'questions', title: 'Open questions between agents', lines: unanswered },
|
|
49
|
+
{ key: 'idle', title: 'Idle members', lines: idle },
|
|
50
|
+
];
|
|
51
|
+
return {
|
|
52
|
+
sections,
|
|
53
|
+
counts: { ready: ready.length, claimed: claimed.length, human: human.length + blocked.length, stale: stale.length, prs: prLines.length, questions: unanswered.length, idle: idle.length },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Markdown for the pinned issue (and the terminal). */
|
|
58
|
+
export function renderDigest(model, { now = Date.now() } = {}) {
|
|
59
|
+
const c = model.counts;
|
|
60
|
+
const stamp = new Date(now).toISOString().replace('T', ' ').slice(0, 16);
|
|
61
|
+
const out = [DIGEST_MARKER, `_Updated ${stamp} UTC by \`crew digest\` (no LLM). Edits here are overwritten._`, '',
|
|
62
|
+
`**${c.ready}** ready · **${c.claimed}** in progress · **${c.human}** need a human · **${c.stale}** stale · **${c.prs}** PRs waiting`, ''];
|
|
63
|
+
// The issue is rewritten every 15 minutes: break @mentions (ours and any in titles) so nobody gets pinged each time.
|
|
64
|
+
const quiet = (l) => l.replace(/@(?=[\w-])/g, '@');
|
|
65
|
+
for (const s of model.sections) out.push(`### ${s.title}`, ...(s.lines.length ? s.lines.map((l) => `- ${quiet(l)}`) : ['- none']), '');
|
|
66
|
+
return out.join('\n');
|
|
67
|
+
}
|
package/src/core/guard.js
CHANGED
|
@@ -35,7 +35,7 @@ export function isPureMove(f = {}) {
|
|
|
35
35
|
|
|
36
36
|
/** Markers of a task from the issue body: { taskId, testsFor, testsIssue }. */
|
|
37
37
|
export function taskMarkers(issueBody = '') {
|
|
38
|
-
const pick = (kind, key) => new RegExp(`<!-- crew:${kind}\\b[^>]*?\\b${key}=(\\S+?)[\\s>]`).exec(issueBody ?? '')?.[1] ?? null;
|
|
38
|
+
const pick = (kind, key) => new RegExp(`<!-- (?:crew|hk):${kind}\\b[^>]*?\\b${key}=(\\S+?)[\\s>]`).exec(issueBody ?? '')?.[1] ?? null;
|
|
39
39
|
return { taskId: pick('task', 'id'), testsFor: pick('tests-for', 'task'), testsIssue: Number(pick('tests', 'issue')) || null };
|
|
40
40
|
}
|
|
41
41
|
export const ACCEPTANCE_SECTION = 'Changes acceptance';
|
|
@@ -132,7 +132,7 @@ export function reviewState(comments = [], { headSha, author, policy, prCreatedA
|
|
|
132
132
|
for (const c of comments) {
|
|
133
133
|
// in a public repo anyone can comment: review markers only count from repo members
|
|
134
134
|
if (!isTrustedComment(c)) continue;
|
|
135
|
-
for (const m of String(c.body ?? '').matchAll(/<!-- crew:review((?: \w+=\S+)*) -->/g)) {
|
|
135
|
+
for (const m of String(c.body ?? '').matchAll(/<!-- (?:crew|hk):review((?: \w+=\S+)*) -->/g)) {
|
|
136
136
|
const f = Object.fromEntries([...m[1].matchAll(/(\w+)=(\S+)/g)].map((x) => [x[1], x[2]]));
|
|
137
137
|
if (!f.agent || !f.verdict || !f.sha) continue;
|
|
138
138
|
latest.set(f.agent, { agent: f.agent, verdict: f.verdict, sha: f.sha, at: c.createdAt });
|
|
@@ -224,19 +224,26 @@ export function evaluateGuard({ files = [], body = '', issueBody = '', comments
|
|
|
224
224
|
problems.push({ rule: 'changes_requested', message: `reviewer requests changes: ${review.changes.join(', ')}` });
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
// A human may waive "paperwork" rules for this commit (crew pr waive): a PR made outside crew (cloud session,
|
|
228
|
+
// plain git). Protected files, acceptance tests and the review itself can never be waived.
|
|
229
|
+
const waivers = waivedRules(comments, headSha);
|
|
230
|
+
const waived = problems.filter((p) => WAIVABLE.includes(p.rule) && waivers.has(p.rule)).map((p) => p.rule);
|
|
231
|
+
for (const r of waived) problems.splice(problems.findIndex((p) => p.rule === r), 1);
|
|
232
|
+
|
|
227
233
|
const fail = problems.length > 0;
|
|
228
234
|
const reviewed = review.approvals.length >= need;
|
|
229
235
|
const state = fail ? 'failure' : reviewed ? 'success' : 'pending';
|
|
230
236
|
const who = policy.require === 'other-vendor' && !review.fallback ? 'an agent from another vendor' : 'another agent';
|
|
231
|
-
const description = fail
|
|
237
|
+
const description = (fail
|
|
232
238
|
? problems.map((p) => p.rule).join(', ')
|
|
233
239
|
: reviewed
|
|
234
240
|
? `ok: review ${review.approvals.join(', ') || 'not required'}`
|
|
235
|
-
: `waiting for review by ${who}: ${review.approvals.length}/${need}
|
|
241
|
+
: `waiting for review by ${who}: ${review.approvals.length}/${need}`) + (waived.length && !fail ? ` (waived: ${waived.join(', ')})` : '');
|
|
236
242
|
return {
|
|
237
243
|
state,
|
|
238
244
|
description: description.slice(0, 140),
|
|
239
245
|
problems,
|
|
246
|
+
waived,
|
|
240
247
|
issue,
|
|
241
248
|
author,
|
|
242
249
|
review: { ...review, need, require: policy.require },
|
|
@@ -244,5 +251,23 @@ export function evaluateGuard({ files = [], body = '', issueBody = '', comments
|
|
|
244
251
|
};
|
|
245
252
|
}
|
|
246
253
|
|
|
254
|
+
/** Rules a human can waive with crew pr waive (paperwork only). */
|
|
255
|
+
export const WAIVABLE = ['closes', 'agent_trailer', 'not_crew_pr'];
|
|
256
|
+
|
|
257
|
+
/** Waived rules for this head commit: <!-- crew:waive rule=<r> sha=<head> --> in trusted, non-bot comments. */
|
|
258
|
+
export function waivedRules(comments = [], headSha = '') {
|
|
259
|
+
const out = new Set();
|
|
260
|
+
for (const c of comments) {
|
|
261
|
+
if (!isTrustedComment(c) || /\[bot\]$|^github-actions$/.test(c.author?.login ?? '')) continue;
|
|
262
|
+
for (const m of String(c.body ?? '').matchAll(/<!-- crew:waive((?: \w+=\S+)*) -->/g)) {
|
|
263
|
+
const f = Object.fromEntries([...m[1].matchAll(/(\w+)=(\S+)/g)].map((x) => [x[1], x[2]]));
|
|
264
|
+
if (f.rule && f.sha && f.sha.length >= 7 && String(headSha).startsWith(f.sha)) out.add(f.rule);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export const waiveMarker = ({ rule, sha }) => `<!-- crew:waive rule=${rule} sha=${sha} -->`;
|
|
271
|
+
|
|
247
272
|
/** Review marker text for a comment. */
|
|
248
273
|
export const reviewMarker = ({ verdict, agent, sha }) => `<!-- crew:review verdict=${verdict} agent=${agent} sha=${sha} -->`;
|
package/src/core/issues.js
CHANGED
|
@@ -72,7 +72,7 @@ export function parseMarkers(comments = [], { trustedOnly = true } = {}) {
|
|
|
72
72
|
const out = [];
|
|
73
73
|
for (const c of comments) {
|
|
74
74
|
if (trustedOnly && !isTrustedComment(c)) continue;
|
|
75
|
-
for (const m of (c.body ?? '').matchAll(/<!-- crew:(\w+)((?: \w+=\S+)*) -->/g)) {
|
|
75
|
+
for (const m of (c.body ?? '').matchAll(/<!-- (?:crew|hk):(\w+)((?: \w+=\S+)*) -->/g)) {
|
|
76
76
|
const fields = Object.fromEntries([...m[2].matchAll(/(\w+)=(\S+)/g)].map((x) => [x[1], x[2]]));
|
|
77
77
|
out.push({ kind: m[1], ...fields, createdAt: c.createdAt, by: c.author?.login ?? null });
|
|
78
78
|
}
|
package/src/core/prd.js
CHANGED
|
@@ -12,12 +12,17 @@ export const HIGH_STAKES_AREAS = ['contracts', 'infra'];
|
|
|
12
12
|
export const MAX_TASKS = 25;
|
|
13
13
|
export const DEFAULT_PRD = 'docs/prd.md';
|
|
14
14
|
|
|
15
|
-
const TASK_KEYS = ['id', 'title', 'epic', 'area', 'tier', 'risk', 'after', 'paths', 'contract', 'goal', 'acceptance', 'out_of_scope', 'tests', 'req', 'changes_acceptance', 'tests_for', 'skeleton', 'ready'];
|
|
15
|
+
const TASK_KEYS = ['id', 'title', 'epic', 'area', 'tier', 'risk', 'after', 'paths', 'contract', 'goal', 'acceptance', 'out_of_scope', 'tests', 'req', 'changes_acceptance', 'tests_for', 'skeleton', 'ready', 'independent'];
|
|
16
|
+
/**
|
|
17
|
+
* Areas whose tasks do not wait for the skeleton (no code: mockups, research, docs). At the hackathon the
|
|
18
|
+
* designer idled until the skeleton merged. A task elsewhere can opt out with `independent: true`.
|
|
19
|
+
*/
|
|
20
|
+
export const INDEPENDENT_AREAS = ['design', 'research'];
|
|
16
21
|
const EPIC_KEYS = ['id', 'title', 'scenario', 'demo'];
|
|
17
22
|
const TASK_REQUIRED = ['id', 'title', 'area', 'tier', 'goal', 'acceptance'];
|
|
18
23
|
const EPIC_REQUIRED = ['id', 'title', 'scenario'];
|
|
19
24
|
const LIST_KEYS = ['acceptance', 'after', 'paths', 'out_of_scope', 'tests', 'req', 'changes_acceptance'];
|
|
20
|
-
const BOOL_KEYS = ['skeleton', 'ready'];
|
|
25
|
+
const BOOL_KEYS = ['skeleton', 'ready', 'independent'];
|
|
21
26
|
|
|
22
27
|
const err = (line, id, rule, message) => ({ line, id: id ?? null, rule, message });
|
|
23
28
|
|
|
@@ -168,7 +173,7 @@ export function areaOwners(team) {
|
|
|
168
173
|
export const teamReviewers = (team) => (team?.members ?? []).filter((m) => m.review).map((m) => m.login);
|
|
169
174
|
|
|
170
175
|
/** PRD approval header: <!-- crew:prd status: approved by: <login|auto-timeout> at: ISO -->. */
|
|
171
|
-
export const PRD_HEADER_RE = /^\s*<!--\s*crew:prd\s+status:\s*([\w-]+)(?:\s+by:\s*(\S+))?(?:\s+at:\s*(\S+))?\s*-->\s*$/m;
|
|
176
|
+
export const PRD_HEADER_RE = /^\s*<!--\s*(?:crew|hk):prd\s+status:\s*([\w-]+)(?:\s+by:\s*(\S+))?(?:\s+at:\s*(\S+))?\s*-->\s*$/m;
|
|
172
177
|
|
|
173
178
|
export function prdApproval(text = '') {
|
|
174
179
|
const m = PRD_HEADER_RE.exec(text);
|
|
@@ -194,7 +199,8 @@ const skeletonOf = (tasks) => tasks.find((t) => t.fields.skeleton === true) ?? n
|
|
|
194
199
|
*/
|
|
195
200
|
export function depsOf(task, skeletonId, tasks = []) {
|
|
196
201
|
const out = [...(task.fields.after ?? [])];
|
|
197
|
-
|
|
202
|
+
const independent = task.fields.independent === true || INDEPENDENT_AREAS.includes(task.fields.area);
|
|
203
|
+
if (skeletonId && task.id !== skeletonId && !independent && !out.includes(skeletonId)) out.unshift(skeletonId);
|
|
198
204
|
for (const t of tasks) if (t.fields.tests_for === task.id && t.id && !out.includes(t.id)) out.push(t.id);
|
|
199
205
|
return out;
|
|
200
206
|
}
|
|
@@ -433,6 +439,19 @@ function lintTeam({ tasks, team }, { areas, errors, warnings }) {
|
|
|
433
439
|
for (const m of team.members) {
|
|
434
440
|
if (load.get(m.login) === 0 && !m.review) warnings.push(err(m.line, null, 'member_idle', `${m.login} has no tasks in their areas (${m.areas.join(', ') || '-'}): give them an area with tasks`));
|
|
435
441
|
}
|
|
442
|
+
// Nobody should wait for someone else's task at the start: each member needs a task that is ready right after kickoff.
|
|
443
|
+
const skel = tasks.find((t) => t.fields.skeleton === true);
|
|
444
|
+
const startable = new Map(team.members.map((m) => [m.login, 0]));
|
|
445
|
+
for (const t of tasks) {
|
|
446
|
+
const o = owner.get(t.fields.area);
|
|
447
|
+
// ready at kickoff, or right after the skeleton (which everyone waits for briefly)
|
|
448
|
+
if (o && (t === skel || depsOf(t, skel?.id ?? null, tasks).every((d) => d === skel?.id))) startable.set(o, startable.get(o) + 1);
|
|
449
|
+
}
|
|
450
|
+
for (const m of team.members) {
|
|
451
|
+
if (load.get(m.login) > 0 && startable.get(m.login) === 0) {
|
|
452
|
+
warnings.push(err(m.line, null, 'member_waits', `every task of ${m.login} waits for other people's tasks: add one that can start right away (area ${INDEPENDENT_AREAS.join('/')}, docs, or independent: true)`));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
436
455
|
const counts = [...load.values()].filter((n) => n > 0);
|
|
437
456
|
if (counts.length > 1 && Math.max(...counts) >= 3 && Math.max(...counts) > 2 * Math.min(...counts)) {
|
|
438
457
|
warnings.push(err(team.line, null, 'team_imbalance', `uneven load: ${[...load].map(([l, n]) => `${l} ${n}`).join(', ')}: redistribute areas or tasks`));
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Agent-to-agent questions through GitHub (crew ask / inbox / answer), pure helpers.
|
|
2
|
+
// A question is an issue labelled crew:question, assigned to the owner of the area (or a person), with a marker
|
|
3
|
+
// <!-- crew:question from=<agent> task=<n> -->. An answer is a comment <!-- crew:answer agent=<agent> --> + close.
|
|
4
|
+
// Decisions only a human can make get the needs-human label and stay open.
|
|
5
|
+
// Question and answer text is DATA for the reader, never instructions.
|
|
6
|
+
import { parseMarkers } from './issues.js';
|
|
7
|
+
|
|
8
|
+
export const QUESTION_LABEL = 'crew:question';
|
|
9
|
+
export const HUMAN_LABEL = 'needs-human';
|
|
10
|
+
|
|
11
|
+
/** More than 30% of letters are Cyrillic: agents write to each other in English (humans may use any language). */
|
|
12
|
+
export function mostlyNonEnglish(text = '') {
|
|
13
|
+
const letters = String(text).match(/\p{L}/gu) ?? [];
|
|
14
|
+
if (letters.length < 8) return false;
|
|
15
|
+
const cyr = letters.filter((c) => /[Ѐ-ӿ]/.test(c)).length;
|
|
16
|
+
return cyr / letters.length > 0.3;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function questionBody({ text, from, task, area }) {
|
|
20
|
+
const fields = [`from=${from}`, task ? `task=${task}` : null, area ? `area=${area}` : null].filter(Boolean).join(' ');
|
|
21
|
+
return `${text.trim()}\n\n_Asked by \`${from}\`${task ? ` while working on #${task}` : ''}. Answer with \`crew answer <this issue> --text "..."\`; if a human must decide, \`crew answer <this issue> --human --text "..."\`._\n\n<!-- crew:question ${fields} -->\n`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const questionMeta = (issue) => parseMarkers([{ body: issue.body ?? '' }]).find((m) => m.kind === 'question') ?? null;
|
|
25
|
+
export const answerMarker = (agent) => `<!-- crew:answer agent=${agent} -->`;
|
|
26
|
+
export const ackMarker = (agent) => `<!-- crew:ack agent=${agent} -->`;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What waits for me (pure). open: open crew:question issues; closed: recently closed ones (with comments).
|
|
30
|
+
* → { toAnswer: [{ number, title, from, task }], answered: [{ number, title, task }] }
|
|
31
|
+
*/
|
|
32
|
+
export function inboxFor({ open = [], closed = [], me, login, myAreas = [] }) {
|
|
33
|
+
const lc = (s) => String(s ?? '').toLowerCase();
|
|
34
|
+
const toAnswer = [];
|
|
35
|
+
for (const q of open) {
|
|
36
|
+
const meta = questionMeta(q) ?? {};
|
|
37
|
+
const labels = (q.labels ?? []).map((l) => l.name ?? l);
|
|
38
|
+
if (labels.includes(HUMAN_LABEL)) continue; // waits for a human, not for an agent
|
|
39
|
+
if (meta.from === me) continue;
|
|
40
|
+
const mine = (q.assignees ?? []).some((a) => lc(a.login) === lc(login)) || (meta.area && myAreas.includes(meta.area));
|
|
41
|
+
if (mine) toAnswer.push({ number: q.number, title: q.title, from: meta.from ?? null, task: meta.task ? Number(meta.task) : null });
|
|
42
|
+
}
|
|
43
|
+
const answered = [];
|
|
44
|
+
for (const q of closed) {
|
|
45
|
+
const meta = questionMeta(q);
|
|
46
|
+
if (!meta || meta.from !== me) continue;
|
|
47
|
+
const acked = parseMarkers(q.comments ?? []).some((m) => m.kind === 'ack' && m.agent === me);
|
|
48
|
+
if (!acked) answered.push({ number: q.number, title: q.title, task: meta.task ? Number(meta.task) : null });
|
|
49
|
+
}
|
|
50
|
+
return { toAnswer: toAnswer.sort((a, b) => a.number - b.number), answered: answered.sort((a, b) => a.number - b.number) };
|
|
51
|
+
}
|
package/src/core/queue.js
CHANGED
|
@@ -63,7 +63,7 @@ export function prQueue(prs = [], { me, login, reviewers = [], config = {}, now
|
|
|
63
63
|
continue;
|
|
64
64
|
}
|
|
65
65
|
const sha7 = String(pr.headRefOid ?? '').slice(0, 7);
|
|
66
|
-
const reviewedHead = !!sha7 && (pr.comments ?? []).some((c) => new RegExp(`<!-- crew:review [^>]*agent=${esc(me)} [^>]*sha=${esc(sha7)}`).test(c.body ?? ''));
|
|
66
|
+
const reviewedHead = !!sha7 && (pr.comments ?? []).some((c) => new RegExp(`<!-- (?:crew|hk):review [^>]*agent=${esc(me)} [^>]*sha=${esc(sha7)}`).test(c.body ?? ''));
|
|
67
67
|
const counts = policy.require === 'other-agent' || rs.fallback || !author || vendorOf(author) !== vendorOf(me);
|
|
68
68
|
const authorIsReviewer = reviewers.some((r) => lc(r) === lc(pr.author?.login));
|
|
69
69
|
const turn = isReviewer || !reviewers.length || authorIsReviewer || age >= policy.fallback_minutes;
|
|
@@ -86,7 +86,9 @@ export function taskOrder(ready = [], { login, area, tier = 'high' } = {}) {
|
|
|
86
86
|
.map((i) => {
|
|
87
87
|
const owners = (i.assignees ?? []).map((a) => a.login);
|
|
88
88
|
const mine = !!me && owners.some((o) => o.toLowerCase() === me);
|
|
89
|
-
|
|
89
|
+
// priority:high / priority:low (set by a human, e.g. on crew board) order tasks inside each group
|
|
90
|
+
const p = { high: 0, low: 2 }[labelValue(i, 'priority')] ?? 1;
|
|
91
|
+
return { issue: i, owners, mine, rank: (mine ? 0 : owners.length ? 2 : 1) * 10 + p };
|
|
90
92
|
})
|
|
91
93
|
.sort((a, b) => a.rank - b.rank || a.issue.number - b.issue.number);
|
|
92
94
|
}
|