@gredescerber/agent-crew 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,127 @@
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
+
7
+ export const COLUMNS = ['ready', 'claimed', 'review', 'waiting', 'blocked', 'needs-spec'];
8
+ export const PRIORITIES = ['high', 'normal', 'low'];
9
+ /** Statuses a human may set from the board. claimed/review belong to the agent's lease and PR flow. */
10
+ export const BOARD_STATUSES = ['ready', 'blocked', 'needs-spec', 'waiting'];
11
+
12
+ const prio = (i) => labelValue(i, 'priority') ?? 'normal';
13
+
14
+ export function boardModel({ issues = [], prs = [], members = [], config = {}, now = Date.now() } = {}) {
15
+ const columns = Object.fromEntries(COLUMNS.map((c) => [c, []]));
16
+ const other = [];
17
+ for (const i of issues) {
18
+ const status = labelValue(i, 'status');
19
+ if ((i.labels ?? []).some((l) => l.name === 'epic')) continue;
20
+ const card = {
21
+ number: i.number,
22
+ title: i.title,
23
+ status,
24
+ area: labelValue(i, 'area'),
25
+ tier: labelValue(i, 'tier'),
26
+ risk: labelValue(i, 'risk'),
27
+ priority: prio(i),
28
+ assignees: (i.assignees ?? []).map((a) => a.login),
29
+ idle_min: Math.max(0, Math.round((now - Date.parse(i.updatedAt ?? '')) / 60000)) || 0,
30
+ };
31
+ (columns[status] ?? other).push(card);
32
+ }
33
+ const order = { high: 0, normal: 1, low: 2 };
34
+ for (const c of COLUMNS) columns[c].sort((a, b) => order[a.priority] - order[b.priority] || a.number - b.number);
35
+ const queue = prQueue(prs, { me: 'board', login: null, reviewers: [], config, now }).map((x) => ({
36
+ 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,
37
+ }));
38
+ return { columns, untracked: other.length, prs: queue, members: [...new Set(members)].sort((a, b) => a.localeCompare(b)), generated_at: new Date(now).toISOString() };
39
+ }
40
+
41
+ /**
42
+ * gh arguments for one board action, or { error }. Everything is validated here because the page
43
+ * is only a convenience: the server never passes page input to gh unchecked.
44
+ * current: the issue as listed ({ number, labels, assignees }).
45
+ */
46
+ export function actionArgs({ op, value }, current, { members = [] } = {}) {
47
+ if (!current || !Number.isInteger(current.number)) return { error: 'unknown issue' };
48
+ const n = String(current.number);
49
+ const labels = (current.labels ?? []).map((l) => l.name);
50
+ if (op === 'assign') {
51
+ const to = String(value ?? '');
52
+ const now = (current.assignees ?? []).map((a) => a.login);
53
+ if (to && (!LOGIN_RE.test(to) || !members.some((m) => m.toLowerCase() === to.toLowerCase()))) return { error: `${to} is not a member of this repo` };
54
+ const args = ['issue', 'edit', n];
55
+ for (const a of now) if (a.toLowerCase() !== to.toLowerCase()) args.push('--remove-assignee', a);
56
+ if (to && !now.some((a) => a.toLowerCase() === to.toLowerCase())) args.push('--add-assignee', to);
57
+ return args.length > 3 ? { args } : { noop: true };
58
+ }
59
+ if (op === 'status') {
60
+ const to = String(value ?? '');
61
+ if (!BOARD_STATUSES.includes(to)) return { error: `status must be one of ${BOARD_STATUSES.join(', ')}` };
62
+ const from = labels.filter((l) => l.startsWith('status:')).map((l) => l.slice(7));
63
+ if (from.some((s) => s === 'claimed' || s === 'review')) {
64
+ return { error: `#${n} is ${from.join(',')}: an agent holds it — use crew task release ${n} --force first` };
65
+ }
66
+ if (from.length === 1 && from[0] === to) return { noop: true };
67
+ const args = ['issue', 'edit', n, '--add-label', `status:${to}`];
68
+ for (const f of from) if (f !== to) args.push('--remove-label', `status:${f}`);
69
+ return { args };
70
+ }
71
+ if (op === 'priority') {
72
+ const to = String(value ?? '');
73
+ if (!PRIORITIES.includes(to)) return { error: `priority must be one of ${PRIORITIES.join(', ')}` };
74
+ const args = ['issue', 'edit', n];
75
+ for (const l of labels.filter((x) => x.startsWith('priority:') && x !== `priority:${to}`)) args.push('--remove-label', l);
76
+ if (to !== 'normal' && !labels.includes(`priority:${to}`)) args.push('--add-label', `priority:${to}`);
77
+ return args.length > 3 ? { args } : { noop: true };
78
+ }
79
+ return { error: `unknown action ${op}` };
80
+ }
81
+
82
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
83
+
84
+ /** The board page. Data comes from /api/board; actions go to /api/action with the per-run token. */
85
+ export function renderPage({ token, repo }) {
86
+ return `<!doctype html>
87
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
88
+ <title>crew board — ${esc(repo)}</title>
89
+ <style>
90
+ :root{--bg:#f6f7f9;--card:#fff;--ink:#1d2433;--muted:#667085;--line:#e3e6ec;--hi:#d92d20}
91
+ @media (prefers-color-scheme:dark){:root{--bg:#111418;--card:#1a1f26;--ink:#e6e9ef;--muted:#98a2b3;--line:#2a313b}}
92
+ *{box-sizing:border-box}body{margin:0;font:14px/1.4 system-ui,sans-serif;background:var(--bg);color:var(--ink)}
93
+ header{display:flex;gap:12px;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);flex-wrap:wrap}
94
+ header h1{font-size:16px;margin:0}.muted{color:var(--muted)}
95
+ main{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;padding:12px 16px}
96
+ section h2,#prs h2{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:4px 0 8px}
97
+ .card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:10px;margin-bottom:8px}
98
+ .card a{color:var(--ink);text-decoration:none;font-weight:600}.meta{color:var(--muted);font-size:12px;margin:4px 0 6px}
99
+ .prio-high{border-left:4px solid var(--hi)}.prio-low{opacity:.75}
100
+ .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)}
101
+ #prs{padding:0 16px 16px}#prs a{color:var(--ink)}#prs li{margin:4px 0}#msg{color:var(--hi)}
102
+ </style></head><body>
103
+ <header><h1>crew board</h1><span class="muted">${esc(repo)}</span><span class="muted" id="time"></span><span id="msg"></span></header>
104
+ <main id="cols"></main>
105
+ <div id="prs"><h2>Pull requests waiting</h2><ul id="prlist"></ul></div>
106
+ <script>
107
+ const TOKEN=${JSON.stringify(token)};const REPO=${JSON.stringify(repo)};
108
+ const COLS=${JSON.stringify(COLUMNS)};const ST=${JSON.stringify(BOARD_STATUSES)};const PR=${JSON.stringify(PRIORITIES)};
109
+ 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};
110
+ 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};
111
+ 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()}
112
+ 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}
113
+ document.getElementById('time').textContent='updated '+new Date(m.generated_at).toLocaleTimeString();
114
+ const cols=document.getElementById('cols');cols.replaceChildren();
115
+ for(const c of COLS){const s=e('section',{},e('h2',{},c+' ('+m.columns[c].length+')'));
116
+ for(const t of m.columns[c]){const who=[['','unassigned'],...m.members.map(x=>[x,x])];
117
+ 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)));
118
+ if(ST.includes(t.status))ctl.append(sel(ST.map(x=>[x,x]),t.status,v=>act(t.number,'status',v)));
119
+ 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),
120
+ 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))}
121
+ cols.append(s)}
122
+ const ul=document.getElementById('prlist');ul.replaceChildren();
123
+ 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))));
124
+ if(!m.prs.length)ul.append(e('li',{class:'muted'},'none'))}
125
+ load();setInterval(load,30000);
126
+ </script></body></html>`;
127
+ }
@@ -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} -->`;
@@ -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
- if (skeletonId && task.id !== skeletonId && !out.includes(skeletonId)) out.unshift(skeletonId);
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
- return { issue: i, owners, mine, rank: mine ? 0 : owners.length ? 2 : 1 };
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
  }
@@ -28,6 +28,11 @@ import kickoff from '../commands/kickoff.js';
28
28
  import guard from '../commands/guard.js';
29
29
  import up from '../commands/up.js';
30
30
  import shots from '../commands/shots.js';
31
+ import board from '../commands/board.js';
32
+ import upgrade from '../commands/upgrade.js';
33
+ import askCmds from '../commands/ask.js';
34
+ import mergeReady from '../commands/merge-ready.js';
35
+ import digest from '../commands/digest.js';
31
36
  import { existsSync, realpathSync } from 'node:fs';
32
37
  import { isAbsolute, resolve } from 'node:path';
33
38
  import { loadConfig } from './config.js';
@@ -43,7 +48,7 @@ export const CWD_ARG = {
43
48
  /** Command arguments including the shared ones (cwd). */
44
49
  export const argsOf = (cmd) => [...(cmd.args ?? []), ...(cmd.usesCwd && !(cmd.args ?? []).some((a) => a.name === 'cwd') ? [CWD_ARG] : [])];
45
50
 
46
- export const commands = [...task, ...kickoff, check, ...pr, guard, status, commit, init, up, adapters, labels, ...compliance, doctor, worker, github, metrics, evalCmd, shots];
51
+ export const commands = [...task, ...kickoff, check, ...pr, guard, status, commit, init, up, adapters, labels, ...compliance, doctor, worker, github, metrics, evalCmd, shots, board, upgrade, ...askCmds, mergeReady, digest];
47
52
 
48
53
  /** For MCP: only commands safe for agents (no init/labels/adapters; a human does those). */
49
54
  export const agentCommands = commands.filter((c) => c.agent !== false);