@gredescerber/agent-crew 0.0.0-stage → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/AGENTS.md +24 -0
  2. package/LICENSE +201 -0
  3. package/README.md +411 -2
  4. package/README.ru.md +414 -0
  5. package/adapters/claude/adapter.js +107 -0
  6. package/adapters/codex/adapter.js +101 -0
  7. package/bin/crew.js +60 -0
  8. package/docs/AGENT-SETUP.md +138 -0
  9. package/ecc/profile.json +16 -0
  10. package/evals/README.md +123 -0
  11. package/evals/fixtures/mini-app/README.md +10 -0
  12. package/evals/fixtures/mini-app/contracts/health.json +11 -0
  13. package/evals/fixtures/mini-app/package.json +10 -0
  14. package/evals/fixtures/mini-app/src/cart.js +14 -0
  15. package/evals/fixtures/mini-app/src/handlers.js +16 -0
  16. package/evals/fixtures/mini-app/src/pager.js +7 -0
  17. package/evals/fixtures/mini-app/test/cart.test.js +10 -0
  18. package/evals/fixtures/mini-app/test/handlers.test.js +13 -0
  19. package/evals/fixtures/mini-app/test/pager.test.js +8 -0
  20. package/evals/tasks/add-input-validation.json +15 -0
  21. package/evals/tasks/fix-off-by-one.json +14 -0
  22. package/evals/tasks/handler-per-contract.json +15 -0
  23. package/evals/tasks/judges/add-input-validation.judge.mjs +28 -0
  24. package/evals/tasks/judges/fix-off-by-one.judge.mjs +22 -0
  25. package/evals/tasks/judges/handler-per-contract.judge.mjs +18 -0
  26. package/package.json +46 -4
  27. package/roles/analyst.md +11 -0
  28. package/roles/builder.md +16 -0
  29. package/roles/implementer.md +17 -0
  30. package/roles/integrator.md +12 -0
  31. package/roles/planner.md +19 -0
  32. package/roles/reviewer.md +16 -0
  33. package/roles/scout.md +15 -0
  34. package/skills/crew-brief-analysis/SKILL.md +32 -0
  35. package/skills/crew-kickoff/SKILL.md +77 -0
  36. package/skills/crew-kickoff/task-format.md +215 -0
  37. package/skills/crew-review/SKILL.md +22 -0
  38. package/skills/crew-workflow/SKILL.md +43 -0
  39. package/src/commands/adapters.js +85 -0
  40. package/src/commands/check.js +130 -0
  41. package/src/commands/commit.js +105 -0
  42. package/src/commands/compliance.js +109 -0
  43. package/src/commands/doctor.js +48 -0
  44. package/src/commands/eval.js +507 -0
  45. package/src/commands/github.js +169 -0
  46. package/src/commands/guard.js +101 -0
  47. package/src/commands/init.js +76 -0
  48. package/src/commands/kickoff.js +275 -0
  49. package/src/commands/labels.js +28 -0
  50. package/src/commands/metrics.js +48 -0
  51. package/src/commands/pr.js +211 -0
  52. package/src/commands/shots.js +51 -0
  53. package/src/commands/status.js +48 -0
  54. package/src/commands/task.js +569 -0
  55. package/src/commands/up.js +355 -0
  56. package/src/commands/worker.js +322 -0
  57. package/src/core/config.js +77 -0
  58. package/src/core/exec.js +50 -0
  59. package/src/core/guard.js +248 -0
  60. package/src/core/issues.js +154 -0
  61. package/src/core/lease.js +48 -0
  62. package/src/core/metrics.js +232 -0
  63. package/src/core/output.js +56 -0
  64. package/src/core/prd.js +593 -0
  65. package/src/core/proc.js +52 -0
  66. package/src/core/queue.js +92 -0
  67. package/src/core/registry.js +138 -0
  68. package/src/core/shots.js +68 -0
  69. package/src/core/supervisor.js +56 -0
  70. package/src/core/version.js +44 -0
  71. package/src/mcp/server.js +71 -0
  72. package/templates/labels.json +18 -0
  73. package/templates/mcp.optional.json +23 -0
  74. package/templates/product/.github/ISSUE_TEMPLATE/epic.yml +22 -0
  75. package/templates/product/.github/ISSUE_TEMPLATE/feedback.yml +36 -0
  76. package/templates/product/.github/ISSUE_TEMPLATE/task.yml +62 -0
  77. package/templates/product/.github/pull_request_template.md +16 -0
  78. package/templates/product/.github/workflows/crew-guard.yml +45 -0
  79. package/templates/product/.github/workflows/crew.yml +108 -0
  80. package/templates/product/AGENTS.md +109 -0
  81. package/templates/product/contracts/README.md +8 -0
  82. package/templates/product/docs/architecture.md +41 -0
  83. package/templates/product/docs/brief-analysis.md +62 -0
  84. package/templates/product/docs/brief.md +25 -0
  85. package/templates/product/docs/playbooks/issues.md +91 -0
  86. package/templates/product/docs/playbooks/preflight.md +28 -0
  87. package/templates/product/docs/playbooks/timeline.md +52 -0
  88. package/templates/product/docs/prd.md +58 -0
  89. package/templates/product/events/event.example.json +10 -0
  90. package/templates/product/tests/acceptance/README.md +9 -0
  91. package/templates/stacks/web-fastapi.json +74 -0
  92. package/templates/stacks/web-node.json +70 -0
@@ -0,0 +1,569 @@
1
+ // crew task next | claim | release | status | heartbeat | sweep | promote
2
+ import { basename, join, resolve } from 'node:path';
3
+ import { cpSync, existsSync, readFileSync, realpathSync } from 'node:fs';
4
+ import { ok, warn, fail } from '../core/output.js';
5
+ import { git, run, ghJson } from '../core/exec.js';
6
+ import { loadConfig, agentId } from '../core/config.js';
7
+ import {
8
+ listIssues, viewIssue, setStatus, comment, marker, currentOwner, parseMarkers, ownerState, safeText,
9
+ labelValue, tierAllows, slugify, isStale, pauseIssues, PAUSE_LABEL, STATUS, TIERS,
10
+ } from '../core/issues.js';
11
+ import { prQueue, taskOrder } from '../core/queue.js';
12
+ import { updateHint } from '../core/version.js';
13
+ import { DEFAULT_PRD, parsePrd, teamReviewers } from '../core/prd.js';
14
+
15
+ /** claimed without a claim marker (the label was set by hand): activity is the issue updatedAt or a push to the branch. */
16
+ const isStaleNoOwner = (issue, lastCommitAt, now, leaseMinutes) =>
17
+ isStale({ issueUpdatedAt: issue.updatedAt, lastCommitAt, now, leaseMinutes });
18
+
19
+ /**
20
+ * Local (uncommitted) agent configs from the .gitignore section "# crew: local files":
21
+ * .mcp.json, .codex/config.toml, .claude/skills/, .claude/agents/, .agents/skills/ …
22
+ * git worktree does not carry them over, and without them the agent in a task worktree is left without the crew MCP and skills.
23
+ */
24
+ export function localAgentFiles(root) {
25
+ const p = join(root, '.gitignore');
26
+ if (!existsSync(p)) return [];
27
+ const lines = readFileSync(p, 'utf8').split(/\r?\n/);
28
+ const start = lines.findIndex((l) => l.trim() === '# crew: local files');
29
+ if (start < 0) return [];
30
+ const out = [];
31
+ for (const l of lines.slice(start + 1)) {
32
+ const t = l.trim();
33
+ if (!t || t.startsWith('#')) break;
34
+ if (t === '.crew/' || t.startsWith('!')) continue;
35
+ out.push(t.replace(/\/+$/, ''));
36
+ }
37
+ return out;
38
+ }
39
+
40
+ /** Copy the local agent configs from the root into the worktree (overwriting, so they stay fresh). */
41
+ export function syncLocalFiles(root, worktree) {
42
+ if (samePath(root, worktree)) return [];
43
+ const copied = [];
44
+ for (const rel of localAgentFiles(root)) {
45
+ const src = join(root, rel);
46
+ if (!existsSync(src)) continue;
47
+ cpSync(src, join(worktree, rel), { recursive: true, force: true });
48
+ copied.push(rel);
49
+ }
50
+ return copied;
51
+ }
52
+
53
+ import { taskMarkers } from '../core/guard.js';
54
+
55
+ /** The "task is not yours" response for release / heartbeat / status. */
56
+ const notOwner = (id, owner, me, cmd) =>
57
+ fail('not_owner', `#${id} is held by ${owner}, not you (${me})`, {
58
+ cause_hint: 'wrong task number, or CREW_AGENT differs from the one used at claim',
59
+ retry: 'crew status — check your task number',
60
+ stop_when: `this is really needed (a human is clearing a backlog) — ${cmd} --force`,
61
+ });
62
+
63
+ /**
64
+ * Emergency stop in code, not only in the AGENTS.md text: while an issue crew:pause is open,
65
+ * task next / task claim refuse. Returns fail('paused') or null.
66
+ */
67
+ export function pauseGuard(issues = listIssues({ label: PAUSE_LABEL })) {
68
+ const p = pauseIssues(issues);
69
+ if (!p.length) return null;
70
+ const refs = p.map((i) => `#${i.number}`).join(', ');
71
+ return fail('paused', `Emergency stop: issue ${refs} with label ${PAUSE_LABEL} is open`, {
72
+ cause_hint: `a human stopped all agents: ${p.map((i) => `#${i.number} ${i.title ?? ''}`.trim()).join('; ')}`,
73
+ retry: 'crew task next — only after a human closes the crew:pause issue',
74
+ stop_when: 'always — wait until a human closes the crew:pause issue',
75
+ next_actions: ['finish the current step (crew commit), do not take new tasks', 'stop and wait for the human'],
76
+ artifacts: { pause_issues: p.map((i) => i.number) },
77
+ });
78
+ }
79
+
80
+ /** Date of the last commit on the task branch origin/crew/<id>-* (ISO) or null. Needs a fresh fetch. */
81
+ export function lastBranchCommitAt(id, root) {
82
+ const r = git(
83
+ ['for-each-ref', '--sort=-committerdate', '--count=1', '--format=%(committerdate:iso-strict)', `refs/remotes/origin/crew/${id}-*`],
84
+ { cwd: root, allowFail: true },
85
+ );
86
+ return r.code === 0 && r.stdout ? r.stdout.split('\n')[0].trim() : null;
87
+ }
88
+
89
+ const next = {
90
+ name: 'task next',
91
+ summary: 'Hand out the next free task (status:ready) for an area and model tier',
92
+ args: [
93
+ { name: 'area', type: 'string', description: 'area: web, api, ... (empty = any)' },
94
+ { name: 'tier', type: 'string', enum: TIERS, description: 'worker model tier: low takes only simple tasks' },
95
+ { name: 'tasks_only', type: 'boolean', description: 'tasks only, no PR review or merge (for crew worker)' },
96
+ { name: 'wait', type: 'number', description: 'no work — wait up to N minutes, checking once a minute (loop in a Codex chat: crew task next --wait 20)' },
97
+ ],
98
+ run(opts = {}, deps = {}) {
99
+ const minutes = Math.max(0, Math.min(Number(opts.wait) || 0, 120));
100
+ const now = deps.now ?? Date.now;
101
+ const sleep = deps.sleepSync ?? ((ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms));
102
+ const until = now() + minutes * 60_000;
103
+ // Outdated crew on a member machine: fixes do not apply. We tell the human and do not block the task.
104
+ const hint = deps.updateHint ? deps.updateHint() : deps.config ? null : updateHint();
105
+ const withHint = (r) => (hint && r.next_actions ? { ...r, next_actions: [...r.next_actions, `tell the human: ${hint}`] } : r);
106
+ for (;;) {
107
+ const r = this.once(opts, deps);
108
+ const idle = r.status === 'warning' && r.artifacts?.idle === true;
109
+ if (!idle || now() >= until) return withHint(idle && minutes ? { ...r, summary: `${r.summary} (waited ${minutes} min)` } : r);
110
+ sleep(Math.min(60_000, until - now()));
111
+ }
112
+ },
113
+ once({ area, tier = 'high', tasks_only = false } = {}, deps = {}) {
114
+ const paused = (deps.pauseGuard ?? pauseGuard)();
115
+ if (paused) return paused;
116
+ const { config, root } = deps.config ? { config: deps.config, root: deps.root } : loadConfig();
117
+ const me = deps.me ?? agentId();
118
+ const login = deps.login !== undefined ? deps.login : myLogin();
119
+ const team = deps.team !== undefined ? deps.team : teamFromPrd(root);
120
+
121
+ // 1. First unblock the team: merge a ready risk:high PR, do a review.
122
+ if (!tasks_only) {
123
+ const prs = deps.prs ?? openPrs(root);
124
+ const q = prQueue(prs, { me, login, reviewers: teamReviewers(team), config });
125
+ // My own PR that is waiting on me (fixes, red check, conflict) comes before everything: other tasks are queued behind it.
126
+ const fix = q.find((x) => x.kind === 'fix' && x.mine);
127
+ if (fix) {
128
+ const id = fix.issue ?? '<issue>';
129
+ return ok(`Your own PR first: #${fix.number} ${fix.title}: ${fix.reason}`, {
130
+ artifacts: { kind: 'fix', pr: fix.number, issue: fix.issue, reason: fix.reason },
131
+ next_actions: [
132
+ `go back to the worktree of task #${id} (lease lost — crew task claim ${id})`,
133
+ fix.reason === 'conflict with main' ? 'git fetch origin && git merge origin/main — resolve the conflict in your area' : `gh pr view ${fix.number} --comments — what the reviewer asks for / why the check is red`,
134
+ 'crew commit -m "fix: ..." → crew check → crew pr open (pushes to the same PR)',
135
+ 'crew task next',
136
+ ],
137
+ });
138
+ }
139
+ const merge = q.find((x) => x.kind === 'merge');
140
+ if (merge) {
141
+ return ok(`Merge the ready PR #${merge.number} ${merge.title} (${merge.risk}, review and checks are green)`, {
142
+ artifacts: { kind: 'merge', pr: merge.number, risk: merge.risk },
143
+ next_actions: [`gh pr merge ${merge.number} --merge --delete-branch`, 'crew task next'],
144
+ });
145
+ }
146
+ const rv = q.find((x) => x.kind === 'review' && x.reviewable);
147
+ if (rv) {
148
+ return ok(`Review first: PR #${rv.number} ${rv.title} (${rv.risk}, approvals ${rv.have}/${rv.need}, waiting ${rv.age} min)`, {
149
+ artifacts: { kind: 'review', pr: rv.number, risk: rv.risk, waiting_min: rv.age },
150
+ next_actions: [`skill crew-review: gh pr view ${rv.number}, gh pr diff ${rv.number}`, `crew pr review ${rv.number} --verdict approve|changes --note "..."`, 'crew task next'],
151
+ });
152
+ }
153
+ }
154
+
155
+ // 2. Tasks: assigned to me (my area from crew-team) → unassigned → other areas.
156
+ const ready = taskOrder(deps.ready ?? listIssues({ label: 'status:ready' }), { login, area, tier });
157
+ if (!ready.length) {
158
+ return warn('No free tasks', {
159
+ artifacts: { idle: true },
160
+ next_actions: ['crew task next --wait 20 — wait for work (it checks once a minute)', 'or crew status — see what is blocked'],
161
+ });
162
+ }
163
+ const { issue: t, owners, mine } = ready[0];
164
+ const whose = mine || !owners.length ? '' : ` — area ${labelValue(t, 'area')}, owner ${owners.join(', ')}: they have no tasks of their own, help them`;
165
+ return ok(`Next task: #${t.number} ${t.title}${whose}`, {
166
+ artifacts: { kind: 'task', issue: t.number, area: labelValue(t, 'area'), tier: labelValue(t, 'tier') ?? 'mid', queue: ready.length, mine, owners },
167
+ next_actions: [`crew task claim ${t.number}`],
168
+ });
169
+ },
170
+ };
171
+
172
+ /** My GitHub login (for tasks assigned to me). No network/gh — null: ordering is just by number. */
173
+ function myLogin() {
174
+ const r = run('gh', ['api', 'user', '--jq', '.login'], { allowFail: true });
175
+ return r.code === 0 ? r.stdout.trim() || null : null;
176
+ }
177
+
178
+ /** The crew-team block from docs/prd.md in the current checkout (the captain publishes the PRD to main during kickoff --apply). */
179
+ function teamFromPrd(root) {
180
+ const p = join(root ?? '.', DEFAULT_PRD);
181
+ return existsSync(p) ? parsePrd(readFileSync(p, 'utf8')).team : null;
182
+ }
183
+
184
+ /** Open PRs with what the queue needs: body (author agent), comments (reviews), checks. */
185
+ function openPrs(root) {
186
+ return ghJson(['pr', 'list', '--state', 'open', '--limit', '50', '--json', 'number,title,body,headRefOid,createdAt,isDraft,author,labels,comments,statusCheckRollup,mergeable,isCrossRepository,autoMergeRequest'], { cwd: root, allowFail: true }) ?? [];
187
+ }
188
+
189
+ /**
190
+ * What to do after claim. A CLI user does cd, while the MCP server lives in the project root —
191
+ * so the crew_check / crew_commit / crew_pr_open tools must be passed cwd = worktree explicitly.
192
+ */
193
+ export function claimNextActions(id, worktree) {
194
+ const cwdJson = JSON.stringify(worktree);
195
+ return [
196
+ `cd ${worktree}`,
197
+ `MCP: pass cwd: ${cwdJson} to every crew_check, crew_commit, crew_pr_open (otherwise they run in the project root, not on the task branch)`,
198
+ `read the task: gh issue view ${id}`,
199
+ 'tests from the acceptance criteria first, then the implementation',
200
+ 'crew check',
201
+ 'crew commit -m "..." and crew pr open',
202
+ ];
203
+ }
204
+
205
+ const claim = {
206
+ name: 'task claim',
207
+ summary: 'Claim a task: status:claimed label, lease, branch and worktree',
208
+ usesCwd: true,
209
+ args: [
210
+ { name: 'id', type: 'number', positional: true, required: true, description: 'issue number' },
211
+ { name: 'no_worktree', type: 'boolean', description: 'do not create a worktree, just a branch in the current folder' },
212
+ ],
213
+ run({ id, no_worktree, cwd }) {
214
+ const paused = pauseGuard();
215
+ if (paused) return paused;
216
+ const { config, root } = loadConfig(cwd);
217
+ const issue = viewIssue(id);
218
+ const st = labelValue(issue, 'status');
219
+ if (st !== 'ready') {
220
+ return fail('not_ready', `#${id} has status ${st ?? 'none'}, cannot be claimed`, {
221
+ cause_hint: st === 'needs-spec' ? 'the task has no proper acceptance criteria'
222
+ : st === 'waiting' ? 'the task is waiting on dependencies (after #N) — crew task promote moves it to ready when they close'
223
+ : 'the task is already claimed or is not ready',
224
+ retry: 'crew task next',
225
+ stop_when: '3 conflicts in a row — tell the human',
226
+ });
227
+ }
228
+ const me = agentId();
229
+ // Tests and code are written by different agents: whoever took the tests task for this task does not take its code.
230
+ const tm = taskMarkers(issue.body ?? '');
231
+ if (tm.testsIssue) {
232
+ const testsIssue = viewIssue(tm.testsIssue);
233
+ if (parseMarkers(testsIssue?.comments ?? []).some((m) => m.kind === 'claim' && m.agent === me)) {
234
+ return fail('same_agent_tests', `You wrote the acceptance tests for #${id} (#${tm.testsIssue}) — another agent writes its code`, {
235
+ cause_hint: 'for risk:high, tests and implementation are done by different agents, otherwise the tests get fitted to the code',
236
+ retry: 'crew task next',
237
+ stop_when: 'there are no other agents — a human decides who takes the task',
238
+ });
239
+ }
240
+ }
241
+ // Dangling owner: the agent crashed between the claim marker and the label change (task is ready but "taken"),
242
+ // or it is my own old claim. A live foreign owner is left alone; an expired one is cleared.
243
+ git(['fetch', '--quiet', 'origin', `+refs/heads/crew/${id}-*:refs/remotes/origin/crew/${id}-*`], { cwd: root, allowFail: true });
244
+ const prev = ownerState(issue.comments ?? [], { lastCommitAt: lastBranchCommitAt(id, root), leaseMinutes: config.lease_minutes });
245
+ if (prev.owner && prev.owner.agent !== me && !prev.stale) {
246
+ return fail('claim_conflict', `#${id} is already claimed by ${prev.owner.agent} (activity ${prev.lastActivityAt ?? '?'})`, {
247
+ cause_hint: 'the task has a live owner — the status label has not been updated yet or the agent is about to set it',
248
+ retry: 'crew task next',
249
+ stop_when: `3 conflicts in a row; the task frees itself after ${config.lease_minutes} min without owner activity`,
250
+ });
251
+ }
252
+ if (prev.owner) {
253
+ const why = prev.owner.agent === me ? 'my previous claim' : `the lease of ${prev.owner.agent} went stale`;
254
+ comment(id, `${marker('release', { agent: me, at: new Date().toISOString(), of: prev.owner.agent })}\n↩️ Cleared a dangling claim (${why}).`);
255
+ }
256
+ const at = new Date().toISOString();
257
+ comment(id, `${marker('claim', { agent: me, at })}\n🤖 Claimed the task: \`${me}\`. Lease ${config.lease_minutes} min — extended by \`crew check\`, \`crew commit\` and \`crew task heartbeat\`.`);
258
+ // Race protection: the first claim after the last release wins.
259
+ const owner = currentOwner(viewIssue(id).comments);
260
+ if (owner && (owner.agent !== me || owner.at !== at)) {
261
+ return fail('claim_conflict', `#${id} is already claimed by ${owner.agent}`, {
262
+ cause_hint: 'two agents claimed the task at the same time, the first one won',
263
+ retry: 'crew task next',
264
+ stop_when: '3 conflicts in a row',
265
+ });
266
+ }
267
+ setStatus(id, 'claimed', { from: ['ready'] });
268
+ run('gh', ['issue', 'edit', String(id), '--add-assignee', '@me'], { allowFail: true });
269
+
270
+ let ws;
271
+ try {
272
+ ws = prepareWorkspace({ root, id, title: issue.title, no_worktree });
273
+ } catch (e) {
274
+ // Rollback: the task must not stay stuck in claimed until sweep (~45 min).
275
+ comment(id, `${marker('release', { agent: me, at: new Date().toISOString() })}\n↩️ Could not prepare the branch/worktree; the task was returned to the queue.`);
276
+ setStatus(id, 'ready', { from: ['claimed'] });
277
+ run('gh', ['issue', 'edit', String(id), '--remove-assignee', '@me'], { allowFail: true });
278
+ return fail('workspace_failed', `Could not prepare a working copy for #${id}`, {
279
+ cause_hint: e.recovery?.cause_hint || e.message,
280
+ retry: 'crew task next',
281
+ stop_when: 'the same error twice — tell the human (git worktree list, git branch)',
282
+ });
283
+ }
284
+ const next_actions = claimNextActions(id, ws.worktree);
285
+ if (ws.reused) next_actions.unshift('the branch/worktree remain from a previous attempt — run git status and git log first, continue from what is there');
286
+ return ok(`Claimed #${id}: ${issue.title}${ws.reused ? ' (continuing a previous attempt)' : ''}`, {
287
+ artifacts: { issue: id, branch: ws.branch, worktree: ws.worktree, agent: me, reused: ws.reused, local_files: ws.copied ?? [] },
288
+ next_actions,
289
+ });
290
+ },
291
+ };
292
+
293
+ /**
294
+ * Branch crew/<id>-* and worktree for a task. Claiming again (after release/timeout) reuses
295
+ * the existing branch and worktree instead of failing on `git worktree add -b`. Throws CrewError on a git failure.
296
+ * → { branch, worktree, reused }
297
+ */
298
+ export function prepareWorkspace({ root, id, title, no_worktree = false }) {
299
+ git(['fetch', 'origin'], { cwd: root, allowFail: true });
300
+ const existing = git(['for-each-ref', '--format=%(refname:short)', `refs/heads/crew/${id}-*`], { cwd: root, allowFail: true })
301
+ .stdout.split('\n').map((s) => s.trim()).filter((b) => b.startsWith(`crew/${id}-`));
302
+ const branch = existing[0] ?? `crew/${id}-${slugify(title ?? '')}`;
303
+ const base = git(['rev-parse', '--verify', 'origin/main'], { cwd: root, allowFail: true }).code === 0 ? 'origin/main' : 'HEAD';
304
+
305
+ if (no_worktree) {
306
+ git(existing.length ? ['checkout', branch] : ['checkout', '-b', branch, base], { cwd: root });
307
+ return { branch, worktree: root, reused: existing.length > 0 };
308
+ }
309
+
310
+ const worktree = resolve(root, '..', `${basename(root)}-wt-${id}`);
311
+ git(['worktree', 'prune'], { cwd: root, allowFail: true }); // forget worktrees whose folders were deleted
312
+ const registered = parseWorktrees(git(['worktree', 'list', '--porcelain'], { cwd: root }).stdout);
313
+ const here = registered.find((w) => samePath(w.path, worktree));
314
+ if (here) {
315
+ if (here.branch !== branch) {
316
+ git(['checkout', branch], { cwd: worktree });
317
+ }
318
+ return { branch, worktree, reused: true, copied: syncLocalFiles(root, worktree) };
319
+ }
320
+ git(existing.length ? ['worktree', 'add', worktree, branch] : ['worktree', 'add', '-b', branch, worktree, base], { cwd: root });
321
+ return { branch, worktree, reused: existing.length > 0, copied: syncLocalFiles(root, worktree) };
322
+ }
323
+
324
+ function parseWorktrees(porcelain) {
325
+ const list = [];
326
+ for (const block of porcelain.split(/\r?\n\r?\n/)) {
327
+ const path = /^worktree (.+)$/m.exec(block)?.[1];
328
+ if (!path) continue;
329
+ list.push({ path, branch: /^branch refs\/heads\/(.+)$/m.exec(block)?.[1] ?? null });
330
+ }
331
+ return list;
332
+ }
333
+
334
+ // realpath: on Windows the same path can appear in short (GREDES~1) and full form.
335
+ const samePath = (a, b) => {
336
+ const real = (p) => { try { return realpathSync.native(p); } catch { return resolve(p); } };
337
+ const norm = (p) => real(p).replace(/\\/g, '/').replace(/\/+$/, '');
338
+ return process.platform === 'win32' ? norm(a).toLowerCase() === norm(b).toLowerCase() : norm(a) === norm(b);
339
+ };
340
+
341
+ /** Return to the queue; dependencies are injected in tests, with no GitHub calls. */
342
+ export function releaseTask({ id, note = '', force = false }, {
343
+ me = agentId(), view = viewIssue, post = comment, updateStatus = setStatus, execute = run,
344
+ } = {}) {
345
+ const issue = view(id);
346
+ if (!force) {
347
+ const owner = currentOwner(issue?.comments ?? []);
348
+ if (owner && owner.agent !== me) return notOwner(id, owner.agent, me, `crew task release ${id}`);
349
+ }
350
+ post(id, `${marker('release', { agent: me, at: new Date().toISOString() })}\n↩️ The task was returned to the queue. ${safeText(note)}`);
351
+ updateStatus(id, 'ready', { from: ['claimed', 'blocked'] });
352
+ // @me in sweep is the Actions bot. The task is free again: we remove its actual assignees.
353
+ const assignees = (issue?.assignees ?? []).map((a) => a.login).filter(Boolean);
354
+ if (assignees.length) execute('gh', ['issue', 'edit', String(id), '--remove-assignee', assignees.join(',')], { allowFail: true });
355
+ return ok(`#${id} is back in the queue`, { next_actions: ['crew task next'] });
356
+ }
357
+
358
+ const release = {
359
+ name: 'task release',
360
+ summary: 'Return a task to the queue (could not cope / not my area)',
361
+ args: [
362
+ { name: 'id', type: 'number', positional: true, required: true, description: 'issue number' },
363
+ { name: 'note', type: 'string', description: 'what I got done and why I am handing it back' },
364
+ { name: 'force', type: 'boolean', description: 'return someone else task (for humans and sweep)' },
365
+ ],
366
+ run: releaseTask,
367
+ };
368
+
369
+ const status = {
370
+ name: 'task status',
371
+ summary: 'Change a task status and leave a handoff (what is done, what is left)',
372
+ args: [
373
+ { name: 'id', type: 'number', positional: true, required: true, description: 'issue number' },
374
+ { name: 'state', type: 'string', positional: true, required: true, enum: STATUS, description: 'new status' },
375
+ { name: 'note', type: 'string', description: 'handoff: what is done, what is left, where the artifacts are' },
376
+ { name: 'force', type: 'boolean', description: 'change the status of someone else task (for humans)' },
377
+ ],
378
+ run({ id, state, note = '', force = false }) {
379
+ if (state === 'claimed') {
380
+ return fail('use_claim', 'Status claimed is set only via crew task claim', {
381
+ cause_hint: 'a claim is a lease with a marker and a worktree, not just a label',
382
+ retry: `crew task claim ${id}`,
383
+ });
384
+ }
385
+ const me = agentId();
386
+ if (!force) {
387
+ const owner = currentOwner(viewIssue(id)?.comments ?? []);
388
+ if (owner && owner.agent !== me) return notOwner(id, owner.agent, me, `crew task status ${id} ${state}`);
389
+ }
390
+ if (['blocked', 'needs-spec'].includes(state) && !note) {
391
+ return fail('note_required', `status ${state} requires --note`, {
392
+ cause_hint: 'without a description the next agent or human will not understand what happened',
393
+ retry: `crew task status ${id} ${state} --note "..."`,
394
+ });
395
+ }
396
+ setStatus(id, state, { from: STATUS });
397
+ comment(id, `${marker('status', { agent: me, state, at: new Date().toISOString() })}\n📋 **${state}** — ${safeText(note)}`);
398
+ return ok(`#${id} → ${state}`, { next_actions: state === 'review' || state === 'done' ? ['crew task next'] : ['crew status'] });
399
+ },
400
+ };
401
+
402
+ const heartbeat = {
403
+ name: 'task heartbeat',
404
+ summary: 'Extend the task lease (I am still working)',
405
+ args: [{ name: 'id', type: 'number', positional: true, required: true, description: 'issue number' }],
406
+ run({ id }) {
407
+ const me = agentId();
408
+ const owner = currentOwner(viewIssue(id)?.comments ?? []);
409
+ if (!owner || owner.agent !== me) {
410
+ return fail('lease_lost', `#${id} is not your task${owner ? ` — held by ${owner.agent}` : ' — it was returned to the queue'}, nothing to extend`, {
411
+ retry: `crew task claim ${id} (if it is ready again) or crew task next`,
412
+ stop_when: 'another agent claimed the task — do not push your branch',
413
+ });
414
+ }
415
+ comment(id, marker('heartbeat', { agent: me, at: new Date().toISOString() }));
416
+ return ok(`Lease of #${id} extended`);
417
+ },
418
+ };
419
+
420
+ const sweep = {
421
+ name: 'task sweep',
422
+ summary: 'Return tasks with an expired lease to the queue (for scheduled GitHub Actions)',
423
+ agent: false,
424
+ args: [{ name: 'dry_run', type: 'boolean', description: 'show only' }],
425
+ run({ dry_run }) {
426
+ const { config, root } = loadConfig();
427
+ // Pushes to the task branch also count as activity: we fetch only crew/* branches (no noise, no checkout).
428
+ git(['fetch', '--quiet', 'origin', '+refs/heads/crew/*:refs/remotes/origin/crew/*'], { cwd: root, allowFail: true });
429
+ const now = Date.now();
430
+ const stale = [];
431
+ for (const i of listIssues({ label: 'status:claimed' })) {
432
+ const full = viewIssue(i.number);
433
+ // Activity is counted only from owner markers (and pushes to the task branch):
434
+ // the marker of the loser in a claim_conflict does not extend the winner lease.
435
+ // Without a claim marker (label set by hand) — by the issue updatedAt.
436
+ const st = ownerState(full.comments ?? [], { lastCommitAt: lastBranchCommitAt(i.number, root), now, leaseMinutes: config.lease_minutes });
437
+ const verdict = st.owner ? st.stale : isStaleNoOwner(full, lastBranchCommitAt(i.number, root), now, config.lease_minutes);
438
+ if (verdict) stale.push(i.number);
439
+ }
440
+ if (!dry_run) for (const n of stale) release.run({ id: n, force: true, note: `lease expired (> ${config.lease_minutes} min without activity)` });
441
+ // Also — waiting → ready for tasks whose dependencies are closed (a safety net for the promote job in crew.yml).
442
+ const pr = promote.run({ dry_run });
443
+ const promoted = pr.artifacts?.promoted ?? [];
444
+ const summary = [
445
+ stale.length ? `Expired leases: ${stale.length}${dry_run ? ' (dry run)' : ', returned'}` : 'No expired leases',
446
+ promoted.length ? `to ready: ${promoted.map((n) => `#${n}`).join(', ')}` : null,
447
+ ].filter(Boolean).join('; ');
448
+ return ok(summary, { artifacts: { stale, promoted } });
449
+ },
450
+ };
451
+
452
+ /** Numbers from the marker <!-- crew:after issues=12,13 --> in the issue body (or null if there is no marker). */
453
+ export function afterIssues(body = '') {
454
+ const m = parseMarkers([{ body }]).find((x) => x.kind === 'after');
455
+ if (!m) return null;
456
+ return String(m.issues ?? '').split(',').map((x) => Number(x)).filter((n) => Number.isInteger(n) && n > 0);
457
+ }
458
+
459
+ /**
460
+ * Which waiting tasks to move to ready (pure function).
461
+ * waiting: [{ number, body }]; stateOf: Map(number → 'OPEN'|'CLOSED').
462
+ * A dependency missing from the list counts as not closed — we do not unlock blindly.
463
+ * → { promote: [{ number, after }], wait: [{ number, pending }], no_marker: [number] }
464
+ */
465
+ export function decidePromotions(waiting = [], stateOf = new Map()) {
466
+ const out = { promote: [], wait: [], no_marker: [] };
467
+ for (const i of waiting) {
468
+ const after = afterIssues(i.body ?? '');
469
+ if (!after) { out.no_marker.push(i.number); continue; }
470
+ const pending = after.filter((n) => String(stateOf.get(n) ?? '').toUpperCase() !== 'CLOSED');
471
+ if (pending.length) out.wait.push({ number: i.number, pending });
472
+ else out.promote.push({ number: i.number, after });
473
+ }
474
+ return out;
475
+ }
476
+
477
+ const promote = {
478
+ name: 'task promote',
479
+ summary: 'Move status:waiting → ready when all dependencies (crew:after) are closed (for GitHub Actions)',
480
+ agent: false,
481
+ args: [{ name: 'dry_run', type: 'boolean', description: 'show only' }],
482
+ run({ dry_run }) {
483
+ const waiting = ghJson(['issue', 'list', '--state', 'open', '--label', 'status:waiting', '--limit', '500', '--json', 'number,title,body,labels']) ?? [];
484
+ if (!waiting.length) return ok('No tasks in waiting', { artifacts: { promoted: [], waiting: [] } });
485
+ const all = ghJson(['issue', 'list', '--state', 'all', '--limit', '1000', '--json', 'number,state']) ?? [];
486
+ const d = decidePromotions(waiting, new Map(all.map((i) => [i.number, i.state])));
487
+ if (!dry_run) {
488
+ for (const p of d.promote) {
489
+ setStatus(p.number, 'ready', { from: ['waiting'] });
490
+ comment(p.number, `${marker('status', { agent: agentId(), state: 'ready', at: new Date().toISOString() })}
491
+ 🟢 Dependencies closed (${p.after.map((n) => `#${n}`).join(', ')}) — the task is ready to be claimed.`);
492
+ }
493
+ }
494
+ const promoted = d.promote.map((p) => p.number);
495
+ const artifacts = { promoted, waiting: d.wait, no_marker: d.no_marker };
496
+ const summary = promoted.length ? `To ready: ${promoted.map((n) => `#${n}`).join(', ')}${dry_run ? ' (dry run)' : ''}` : 'No waiting task is ready yet';
497
+ return d.no_marker.length
498
+ ? warn(`${summary}; without a crew:after marker: ${d.no_marker.map((n) => `#${n}`).join(', ')}`, {
499
+ artifacts,
500
+ next_actions: ['dependencies of tasks without a marker are unknown — a human moves them to ready by hand (crew task status <id> ready)'],
501
+ })
502
+ : ok(summary, { artifacts });
503
+ },
504
+ };
505
+
506
+ /** `git worktree list --porcelain` → [{ path, branch, id }] only for task branches crew/<id>-* (unlike parseWorktrees). */
507
+ export function taskWorktrees(porcelain = '') {
508
+ const out = [];
509
+ for (const block of porcelain.split(/\r?\n\r?\n/)) {
510
+ const path = /^worktree (.+)$/m.exec(block)?.[1]?.trim();
511
+ const branch = /^branch refs\/heads\/(.+)$/m.exec(block)?.[1]?.trim();
512
+ const id = Number(/^crew\/(\d+)-/.exec(branch ?? '')?.[1]);
513
+ if (path && branch && id) out.push({ path, branch, id });
514
+ }
515
+ return out;
516
+ }
517
+
518
+ /**
519
+ * What to delete (pure function). We delete only the folder of a task that is closed, with no uncommitted changes
520
+ * and no commits missing from origin/main or from the branch on GitHub. Everything else is kept, with the reason.
521
+ */
522
+ export function planCleanup(worktrees, { issueState, dirty, unsaved }) {
523
+ return worktrees.map((w) => {
524
+ const state = issueState(w.id);
525
+ const why = state !== 'CLOSED' ? `task #${w.id} is still ${state ? state.toLowerCase() : 'not found'}`
526
+ : dirty(w) ? 'has uncommitted changes'
527
+ : unsaved(w) ? 'has commits that are neither in main nor on GitHub'
528
+ : null;
529
+ return { ...w, remove: !why, why };
530
+ });
531
+ }
532
+
533
+ const cleanup = {
534
+ name: 'task cleanup',
535
+ summary: 'Remove the folders (worktrees) of closed tasks: with no uncommitted changes and with the work already saved in main or on GitHub',
536
+ args: [{ name: 'dry_run', type: 'boolean', description: 'show only what would be removed' }],
537
+ run({ dry_run = false } = {}) {
538
+ const { root } = loadConfig();
539
+ git(['fetch', '--quiet', 'origin'], { cwd: root, allowFail: true });
540
+ const wts = taskWorktrees(git(['worktree', 'list', '--porcelain'], { cwd: root }).stdout);
541
+ const states = new Map();
542
+ const plan = planCleanup(wts, {
543
+ issueState: (id) => {
544
+ if (!states.has(id)) states.set(id, ghJson(['issue', 'view', String(id), '--json', 'state'], { cwd: root, allowFail: true })?.state ?? null);
545
+ return states.get(id);
546
+ },
547
+ dirty: (w) => git(['status', '--porcelain'], { cwd: w.path, allowFail: true }).stdout.trim() !== '',
548
+ // branch commits that are neither in origin/main nor in its copy on GitHub (if the branch was not deleted)
549
+ unsaved: (w) => git(['log', '--oneline', `${w.branch}`, '--not', 'origin/main', '--remotes=origin'], { cwd: root, allowFail: true }).stdout.trim() !== '',
550
+ });
551
+ const removed = [];
552
+ if (!dry_run) {
553
+ for (const w of plan.filter((p) => p.remove)) {
554
+ const r = git(['worktree', 'remove', w.path], { cwd: root, allowFail: true });
555
+ if (r.code !== 0) { w.remove = false; w.why = `git worktree remove: ${(r.stderr || r.stdout).split('\n')[0]}`; continue; }
556
+ git(['branch', '-D', w.branch], { cwd: root, allowFail: true }); // the work is already in main or on GitHub (checked above)
557
+ removed.push(w.path);
558
+ }
559
+ }
560
+ const kept = plan.filter((p) => !p.remove).map((p) => `${p.path} — ${p.why}`);
561
+ const would = plan.filter((p) => p.remove).map((p) => p.path);
562
+ return ok(dry_run ? `Task folders to remove: ${would.length}, kept: ${kept.length} (dry-run)` : `Task folders removed: ${removed.length}, kept: ${kept.length}`, {
563
+ artifacts: { removed, would_remove: dry_run ? would : [], kept },
564
+ next_actions: kept.length ? ['kept ones — see the reason in kept; a human needs to save or discard uncommitted work'] : ['crew task next'],
565
+ });
566
+ },
567
+ };
568
+
569
+ export default [next, claim, release, status, heartbeat, sweep, promote, cleanup];