@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,101 @@
1
+ // crew guard --pr N [--report] — PR rules without manual approval (see src/core/guard.js).
2
+ // In CI it runs from the crew-guard workflow (pull_request_target / issue_comment): the guard config and code come
3
+ // from main, not from the PR branch, so a PR cannot weaken the check for itself.
4
+ // --report sets the crew-guard commit status on the PR head (a required check of main protection).
5
+ // An agent can run it without --report to understand why its PR is not merging.
6
+ import { ok, warn, fail } from '../core/output.js';
7
+ import { gh, ghJson, git } from '../core/exec.js';
8
+ import { loadConfig } from '../core/config.js';
9
+ import { evaluateGuard, linkedIssue, taskMarkers, GUARD_CONTEXT, PENDING_DIR } from '../core/guard.js';
10
+
11
+ /** PR files from the GitHub API (with status and previous_filename; gh pr view does not return them). */
12
+ function prFiles(pr, cwd) {
13
+ const out = gh(['api', '--paginate', `repos/{owner}/{repo}/pulls/${pr}/files`, '--jq', '.[] | {filename, status, previous_filename, additions, deletions, changes}'], { cwd }).stdout;
14
+ return out.split('\n').filter(Boolean).map((l) => JSON.parse(l));
15
+ }
16
+
17
+ /** PR commits without merge commits, including further API pages. PR code is not executed. */
18
+ export function prCommits(pr, cwd, fetch = gh) {
19
+ const out = fetch(['api', '--paginate', `repos/{owner}/{repo}/pulls/${pr}/commits`, '--jq', '.[] | {sha, message: .commit.message, parents}'], { cwd }).stdout;
20
+ // GitHub merge / Update branch commits need not have Agent; a regular commit titled Merge must.
21
+ return out.split('\n').filter(Boolean).map((line) => JSON.parse(line)).filter((c) => (c.parents ?? []).length <= 1).map(({ sha, message }) => {
22
+ // Git distinguishes a real trailer from an Agent: line in the subject or plain text.
23
+ const trailers = git(['interpret-trailers', '--parse'], { cwd, input: message }).stdout;
24
+ const agent = /^Agent:[ \t]*(\S[^\r\n]*)$/im.exec(trailers)?.[1]?.trim() ?? null;
25
+ return { sha, agent };
26
+ });
27
+ }
28
+
29
+ /** Files tests/pending/<T>/ in the base commit (the task with code must move them). */
30
+ function pendingInBase(baseSha, taskId, cwd) {
31
+ if (!baseSha || !taskId) return [];
32
+ const dir = `${PENDING_DIR}${taskId}/`;
33
+ const r = gh(['api', `repos/{owner}/{repo}/git/trees/${baseSha}?recursive=1`, '--jq', '.tree[] | select(.type == "blob") | .path'], { cwd, allowFail: true });
34
+ return r.code === 0 ? r.stdout.split('\n').filter((p) => p.startsWith(dir)) : [];
35
+ }
36
+
37
+ function runUrl() {
38
+ const { GITHUB_SERVER_URL: s, GITHUB_REPOSITORY: r, GITHUB_RUN_ID: id } = process.env;
39
+ return s && r && id ? `${s}/${r}/actions/runs/${id}` : undefined;
40
+ }
41
+
42
+ export default {
43
+ name: 'guard',
44
+ summary: 'Check a PR against the crew rules (protected files, acceptance tests, agent review); --report sets the crew-guard status',
45
+ args: [
46
+ { name: 'pr', type: 'number', required: true, description: 'PR number' },
47
+ { name: 'report', type: 'boolean', description: 'write the crew-guard commit status (done by CI)' },
48
+ ],
49
+ run({ pr, report = false }) {
50
+ const { config, root } = loadConfig();
51
+ const view = ghJson(['pr', 'view', String(pr), '--json', 'number,body,headRefOid,baseRefOid,labels,createdAt,comments,state'], { cwd: root });
52
+ if (!view) return fail('pr_not_found', `PR #${pr} not found`, { retry: 'gh pr list', stop_when: 'PR is closed or belongs to another repo' });
53
+ const files = prFiles(pr, root);
54
+ const commits = prCommits(pr, root);
55
+ const issueNo = linkedIssue(view.body);
56
+ const issue = issueNo ? ghJson(['issue', 'view', String(issueNo), '--json', 'body'], { cwd: root, allowFail: true }) : null;
57
+ const risk = (view.labels ?? []).map((l) => l.name).find((n) => n.startsWith('risk:'))?.slice(5) ?? 'med';
58
+ const tm = taskMarkers(issue?.body ?? '');
59
+ const pendingFiles = tm.testsIssue ? pendingInBase(view.baseRefOid, tm.taskId, root) : [];
60
+
61
+ const res = evaluateGuard({
62
+ pendingFiles,
63
+ files,
64
+ commits,
65
+ body: view.body,
66
+ issueBody: issue?.body ?? '',
67
+ comments: view.comments ?? [],
68
+ headSha: view.headRefOid,
69
+ risk,
70
+ createdAt: view.createdAt,
71
+ config,
72
+ });
73
+
74
+ if (report) {
75
+ const args = ['api', '-X', 'POST', `repos/{owner}/{repo}/statuses/${view.headRefOid}`, '-f', `state=${res.state}`, '-f', `context=${GUARD_CONTEXT}`, '-f', `description=${res.description}`];
76
+ const url = runUrl();
77
+ if (url) args.push('-f', `target_url=${url}`);
78
+ gh(args, { cwd: root });
79
+ }
80
+
81
+ const artifacts = { pr, sha: view.headRefOid, state: res.state, risk, problems: res.problems, review: res.review, author: res.author, issue: res.issue, reported: report };
82
+ if (res.state === 'failure') {
83
+ return fail('guard_failed', `PR #${pr}: ${res.problems.map((p) => p.message).join('; ')}`, {
84
+ cause_hint: res.problems[0].message,
85
+ retry: res.problems.some((p) => p.rule === 'agent_trailer') ? 'tell the team captain about commits without Agent; make new commits via crew commit' : res.problems.some((p) => p.rule === 'changes_requested') ? 'apply the reviewer changes, crew check, crew commit, git push' : 'remove the forbidden changes from the PR, crew commit, git push',
86
+ stop_when: res.problems.some((p) => p.rule === 'agent_trailer') ? 'agents do not rewrite git history — a human agrees on the fix' : 'cannot be done without changing the rules or other people tests → crew task status <id> blocked --note "need a task that changes acceptance"',
87
+ artifacts,
88
+ });
89
+ }
90
+ if (res.state === 'pending') {
91
+ return warn(`PR #${pr}: ${res.description}`, {
92
+ artifacts,
93
+ next_actions: [`reviewer: skill crew-review → crew pr review ${pr} --verdict approve|changes --note "..."`],
94
+ });
95
+ }
96
+ return ok(`PR #${pr}: ${res.description}`, {
97
+ artifacts,
98
+ next_actions: risk === 'high' ? ['integrator merges first: gh pr merge <n> --merge --delete-branch'] : ['auto-merge will merge the PR itself'],
99
+ });
100
+ },
101
+ };
@@ -0,0 +1,76 @@
1
+ // crew init <dir> [--stack web-fastapi] — create an empty product repo with all configs.
2
+ // Creates no product code: only rules, templates and checks (also compatible with event rules that forbid code written before the start).
3
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { ok, fail } from '../core/output.js';
6
+ import { git } from '../core/exec.js';
7
+ import { HARNESS_ROOT, DEFAULT_CONFIG } from '../core/config.js';
8
+ import { buildFiles } from './adapters.js';
9
+
10
+ export const STACKS_DIR = join(HARNESS_ROOT, 'templates', 'stacks');
11
+
12
+ export default {
13
+ name: 'init',
14
+ summary: 'Create a product repo: AGENTS.md, roles, skills, GitHub templates, CI, agent configs',
15
+ agent: false,
16
+ args: [
17
+ { name: 'dir', type: 'string', positional: true, required: true, description: 'new project folder' },
18
+ { name: 'stack', type: 'string', description: 'stack preset from templates/stacks (default web-fastapi)' },
19
+ { name: 'hosts', type: 'string', description: 'hosts, comma-separated (default claude,codex)' },
20
+ { name: 'force', type: 'boolean', description: 'write into a non-empty folder' },
21
+ ],
22
+ async run({ dir, stack = 'web-fastapi', hosts, force }) {
23
+ const root = resolve(dir);
24
+ if (existsSync(root) && readdirSync(root).length && !force) {
25
+ return fail('dir_not_empty', `${root} is not empty`, { retry: `crew init ${dir} --force (if you really need to)` });
26
+ }
27
+ const stackPath = join(STACKS_DIR, `${stack}.json`);
28
+ if (!existsSync(stackPath)) {
29
+ const list = readdirSync(STACKS_DIR).map((f) => f.replace(/\.json$/, ''));
30
+ return fail('unknown_stack', `No preset ${stack}`, { cause_hint: `available: ${list.join(', ')}`, retry: `crew init ${dir} --stack ${list[0]}` });
31
+ }
32
+ const preset = JSON.parse(readFileSync(stackPath, 'utf8'));
33
+ mkdirSync(root, { recursive: true });
34
+
35
+ // 1. Product template (AGENTS.md, .github, contracts/, events/, ...)
36
+ cpSync(join(HARNESS_ROOT, 'templates', 'product'), root, { recursive: true, force: true });
37
+ // 2. Vendor-neutral roles and skills → agents/ (source for adapters)
38
+ cpSync(join(HARNESS_ROOT, 'roles'), join(root, 'agents', 'roles'), { recursive: true });
39
+ cpSync(join(HARNESS_ROOT, 'skills'), join(root, 'agents', 'skills'), { recursive: true });
40
+ // 3. Config
41
+ const config = {
42
+ ...DEFAULT_CONFIG,
43
+ stack,
44
+ areas: preset.areas ?? DEFAULT_CONFIG.areas,
45
+ check: preset.check ?? [],
46
+ scaffold: preset.scaffold ?? [],
47
+ hosts: hosts ? hosts.split(',').map((h) => h.trim()) : DEFAULT_CONFIG.hosts,
48
+ ecc: JSON.parse(readFileSync(join(HARNESS_ROOT, 'ecc', 'profile.json'), 'utf8')),
49
+ mcp: JSON.parse(readFileSync(join(HARNESS_ROOT, 'templates', 'mcp.optional.json'), 'utf8')),
50
+ };
51
+ writeFileSync(join(root, 'crew.config.json'), JSON.stringify(config, null, 2) + '\n');
52
+ // 4. Per-host configs; machine-specific ones go to .gitignore
53
+ const { files } = await buildFiles(root, config);
54
+ for (const f of files) {
55
+ mkdirSync(dirname(join(root, f.path)), { recursive: true });
56
+ writeFileSync(join(root, f.path), f.content);
57
+ }
58
+ const local = [...new Set(files.filter((f) => !f.commit).map((f) => (f.path.includes('/skills/') ? f.path.split('/skills/')[0] + '/skills/' : f.path)))];
59
+ const gi = ['# crew: local files', '.crew/', ...local, '', '# secrets', '.env', '.env.*', '!.env.example', '', 'node_modules/', '__pycache__/', '.venv/', 'dist/', ''];
60
+ writeFileSync(join(root, '.gitignore'), gi.join('\n'));
61
+ // 5. git
62
+ if (!existsSync(join(root, '.git'))) git(['init', '-b', 'main'], { cwd: root });
63
+
64
+ return ok(`Project created: ${root} (stack ${stack}, hosts ${config.hosts.join(', ')})`, {
65
+ artifacts: { root, stack, hosts: config.hosts, files: files.length },
66
+ next_actions: [
67
+ `cd ${dir}`,
68
+ 'git add -A && git commit -m "chore: crew init"',
69
+ 'gh repo create <org>/<name> --private --source . --push',
70
+ 'crew github setup --apply (main protection, auto-merge, labels, CREW_HARNESS)',
71
+ 'optional event mode: fill in events/event.json (start time, team) and set "event" in crew.config.json',
72
+ 'paste the organizers brief into docs/brief.md as is → tell the agent: "analyze the brief with the crew-brief-analysis skill" → then crew-kickoff',
73
+ ],
74
+ });
75
+ },
76
+ };
@@ -0,0 +1,275 @@
1
+ // crew task lint | crew kickoff [--apply] — tasks and epics in Issues from docs/prd.md (deterministic).
2
+ // The core is the pure functions in src/core/prd.js; gh and sleep are passed in as dependencies (tests run without a network).
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import { ok, warn, fail } from '../core/output.js';
6
+ import { run } from '../core/exec.js';
7
+ import { loadConfig, agentId } from '../core/config.js';
8
+ import {
9
+ DEFAULT_PRD, parsePrd, lintPrd, planKickoff, prdApproval, renderTaskBody, renderEpicBody, formatLintItem,
10
+ } from '../core/prd.js';
11
+
12
+ const FILE_ARG = { name: 'file', type: 'string', description: `path to the PRD from the project root (default ${DEFAULT_PRD})` };
13
+ const APPROVE_LINE = '<!-- crew:prd status: approved by: <login|auto-timeout> at: <ISO> -->';
14
+
15
+ /** Pause between gh issue create calls and retry pauses on rate limit (ms). */
16
+ export const CREATE_DELAY_MS = 1000;
17
+ export const RATE_LIMIT_BACKOFF_MS = [10_000, 30_000, 60_000];
18
+ const RATE_LIMIT_RE = /secondary rate limit|rate limit|abuse|HTTP 403/i;
19
+
20
+ const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
21
+ const defaultGh = (root) => (args, { input } = {}) => run('gh', args, { cwd: root, allowFail: true, input });
22
+
23
+ /** Read and validate the PRD. → { text, parsed, lint } or fail(...). */
24
+ export function loadPrd({ file = DEFAULT_PRD, root, areas }) {
25
+ const path = resolve(root, file);
26
+ if (!existsSync(path)) {
27
+ return {
28
+ result: fail('prd_missing', `File ${file} not found`, {
29
+ cause_hint: `expected a PRD with crew-task blocks: ${path}`,
30
+ retry: `write ${file} following skills/crew-kickoff/task-format.md and run crew task lint again`,
31
+ stop_when: 'the file is missing and it is unclear where the PRD is — ask the human',
32
+ }),
33
+ };
34
+ }
35
+ const text = readFileSync(path, 'utf8');
36
+ const parsed = parsePrd(text);
37
+ const lint = lintPrd(parsed, { areas });
38
+ if (lint.errors.length) {
39
+ return {
40
+ result: fail('prd_invalid', `PRD ${file}: errors ${lint.errors.length}${lint.warnings.length ? `, warnings ${lint.warnings.length}` : ''}`, {
41
+ cause_hint: lint.errors.slice(0, 3).map(formatLintItem).join(' | '),
42
+ retry: `fix ${file} and run crew task lint again`,
43
+ stop_when: 'the same errors after two fixes in a row — show the human',
44
+ artifacts: { errors: lint.errors, warnings: lint.warnings },
45
+ }),
46
+ };
47
+ }
48
+ return { text, parsed, lint };
49
+ }
50
+
51
+ const taskLint = {
52
+ name: 'task lint',
53
+ summary: 'Validate docs/prd.md: crew-task/crew-epic blocks, acceptance criteria, areas, dependencies (no network)',
54
+ args: [FILE_ARG],
55
+ run({ file = DEFAULT_PRD }) {
56
+ const { config, root } = loadConfig();
57
+ const r = loadPrd({ file, root, areas: config.areas });
58
+ if (r.result) return r.result;
59
+ const { parsed, lint, text } = r;
60
+ const approval = prdApproval(text);
61
+ const artifacts = { tasks: parsed.tasks.length, epics: parsed.epics.length, order: lint.order, warnings: lint.warnings, approved: approval.approved };
62
+ const next_actions = [approval.approved ? 'crew kickoff — plan (dry-run), then crew kickoff --apply' : `show the PRD and crew kickoff (dry-run) to the product lead; once approved, put this in the ${file} header: ${APPROVE_LINE}`];
63
+ const summary = `PRD ${file}: tasks ${parsed.tasks.length}, epics ${parsed.epics.length}`;
64
+ return lint.warnings.length
65
+ ? warn(`${summary}, warnings ${lint.warnings.length}`, { artifacts, next_actions: [...lint.warnings.map(formatLintItem), ...next_actions] })
66
+ : ok(`${summary} — no errors`, { artifacts, next_actions });
67
+ },
68
+ };
69
+
70
+ const LIST_ARGS = ['issue', 'list', '--state', 'all', '--limit', '1000', '--json', 'number,title,body,labels,state,stateReason'];
71
+
72
+ /** Plan documents: after kickoff --apply they must be in main, otherwise the team works without the PRD. */
73
+ export const PLAN_DOCS = ['docs/brief.md', 'docs/brief-analysis.md', DEFAULT_PRD];
74
+
75
+ /**
76
+ * Publish the plan to main: commit the changed plan documents (with the Agent trailer) and push.
77
+ * The captain is a repo admin; main protection lets them through. → { state: 'done'|'clean'|'failed', note }
78
+ */
79
+ export function publishPlan({ root, me, files = PLAN_DOCS, sh = (cmd, args) => run(cmd, args, { cwd: root, allowFail: true }) }) {
80
+ const first = (r) => (r.stderr || r.stdout || '').split('\n')[0];
81
+ // We push to main only from main and only the plan documents: an admin bypasses protection, so other code would reach main without review.
82
+ const branch = sh('git', ['branch', '--show-current']).stdout.trim();
83
+ if (branch !== 'main') return { state: 'failed', note: `kickoff was run on branch ${branch || '?'}, not main — plan not published; captain: go to the main project folder (main) and re-run crew kickoff --apply` };
84
+ const changed = sh('git', ['status', '--porcelain', '--', ...files]).stdout.split('\n').filter(Boolean);
85
+ if (changed.length) {
86
+ // Fresh clone without user.name/email: the commit would fail. Same as crew up — a noreply identity from the gh account, in this repo only.
87
+ if (!sh('git', ['config', 'user.email']).stdout.trim() || !sh('git', ['config', 'user.name']).stdout.trim()) {
88
+ const [login, id] = sh('gh', ['api', 'user', '--jq', '.login + " " + (.id|tostring)']).stdout.trim().split(/\s+/);
89
+ if (!login || !id) return { state: 'failed', note: 'git has no user.name/user.email and the GitHub login could not be determined — captain: set git config user.name/user.email and re-run crew kickoff --apply' };
90
+ if (!sh('git', ['config', 'user.name']).stdout.trim()) sh('git', ['config', 'user.name', login]);
91
+ if (!sh('git', ['config', 'user.email']).stdout.trim()) sh('git', ['config', 'user.email', `${id}+${login}@users.noreply.github.com`]);
92
+ }
93
+ sh('git', ['add', '--', ...files]);
94
+ const c = sh('git', ['commit', '-q', '-m', 'docs: project plan (brief, analysis, PRD)', '-m', `Agent: ${me}`, '--', ...files]);
95
+ if (c.code !== 0) return { state: 'failed', note: `could not commit the plan: ${first(c)}` };
96
+ }
97
+ const pull = sh('git', ['pull', '--rebase', '-q', 'origin', 'main']);
98
+ if (pull.code !== 0) return { state: 'failed', note: `could not pull main before publishing the plan: ${first(pull)} — captain: run git status, resolve, then git push origin HEAD:main` };
99
+ const ahead = sh('git', ['diff', '--name-only', 'origin/main..HEAD']).stdout.split('\n').map((s) => s.trim()).filter(Boolean);
100
+ if (!ahead.length) return { state: 'clean', note: 'the plan is already in main' };
101
+ const foreign = ahead.filter((f) => !files.includes(f));
102
+ if (foreign.length) return { state: 'failed', note: `local main has unpushed changes that are not part of the plan (${foreign.slice(0, 3).join(', ')}) — not pushing; the captain sorts it out` };
103
+ const p = sh('git', ['push', 'origin', 'HEAD:main']);
104
+ if (p.code !== 0) return { state: 'failed', note: `plan committed but not pushed to main: ${(p.stderr || p.stdout).split('\n')[0]} — captain: git push origin HEAD:main` };
105
+ return { state: 'done', note: 'brief, analysis and PRD pushed to main — the team can see them' };
106
+ }
107
+
108
+ /**
109
+ * Execute the plan: check labels, create tasks in order, then epics. Existing issues are left untouched.
110
+ * gh(args, { input }) → { code, stdout, stderr }; sleep(ms) → Promise. Both are stubbed in tests.
111
+ * A failure midway → kickoff_partial (a retry is idempotent — created issues are found by their markers).
112
+ * → { created, map } on success or a fail(...) result (status: 'error').
113
+ */
114
+ export async function applyKickoff(plan, { parsed, prd = DEFAULT_PRD, gh, sleep = realSleep }) {
115
+ const creates = plan.ops.filter((o) => o.op === 'create');
116
+ const needed = [...new Set(creates.flatMap((o) => o.labels))];
117
+ const lr = gh(['label', 'list', '--limit', '500', '--json', 'name']);
118
+ if (lr.code !== 0) {
119
+ return fail('gh_failed', 'Could not read the repo labels', {
120
+ cause_hint: (lr.stderr || lr.stdout).split('\n')[0],
121
+ retry: 'crew doctor, then crew kickoff --apply',
122
+ stop_when: 'gh is not authenticated or there is no repo — tell the human',
123
+ });
124
+ }
125
+ const have = new Set((JSON.parse(lr.stdout || '[]') ?? []).map((l) => l.name));
126
+ const missing = needed.filter((l) => !have.has(l));
127
+ if (missing.length) {
128
+ return fail('labels_missing', `The repo is missing labels: ${missing.join(', ')}`, {
129
+ cause_hint: 'labels are created by crew labels sync (status:*, area:*, tier:*, risk:*, epic)',
130
+ retry: 'crew labels sync, then crew kickoff --apply',
131
+ stop_when: 'crew labels sync is unavailable to the agent — ask the human',
132
+ artifacts: { missing },
133
+ });
134
+ }
135
+
136
+ const map = { ...plan.map };
137
+ const created = [];
138
+ const skeletonId = parsed.tasks.find((t) => t.fields.skeleton === true)?.id ?? null;
139
+ const blocks = new Map([...parsed.tasks, ...parsed.epics].map((b) => [b.id, b]));
140
+ let first = true;
141
+ for (const op of creates) {
142
+ const block = blocks.get(op.id);
143
+ const body = op.kind === 'task'
144
+ ? renderTaskBody(block, { issueOf: map, prd, skeletonId, epics: parsed.epics, tasks: parsed.tasks })
145
+ : renderEpicBody(block, parsed.tasks, { issueOf: map, prd });
146
+ const args = ['issue', 'create', '--title', op.title, '--label', op.labels.join(','), '--body-file', '-', ...(op.assignee ? ['--assignee', op.assignee] : [])];
147
+ if (!first) await sleep(CREATE_DELAY_MS); // avoid hitting the GitHub secondary rate limit
148
+ first = false;
149
+ let r = gh(args, { input: body });
150
+ for (let attempt = 0; r.code !== 0 && attempt < RATE_LIMIT_BACKOFF_MS.length && RATE_LIMIT_RE.test(`${r.stderr}\n${r.stdout}`); attempt++) {
151
+ await sleep(RATE_LIMIT_BACKOFF_MS[attempt]);
152
+ r = gh(args, { input: body });
153
+ }
154
+ const number = Number(/\/issues\/(\d+)/.exec(r.stdout ?? '')?.[1]);
155
+ if (r.code !== 0 || !Number.isFinite(number)) {
156
+ const limited = RATE_LIMIT_RE.test(`${r.stderr}\n${r.stdout}`);
157
+ return fail('kickoff_partial', `Created ${created.length} of ${creates.length}, failed on ${op.id} "${op.title}"`, {
158
+ cause_hint: limited ? 'GitHub rate limit persisted after 3 retries' : (r.stderr || r.stdout || 'gh did not return an issue link').split('\n')[0],
159
+ retry: limited ? 'wait 5–10 minutes and run crew kickoff --apply again (created issues will be skipped)' : 'crew kickoff --apply (created issues are skipped by their markers)',
160
+ stop_when: 'fails on the same task twice — tell the human',
161
+ artifacts: { created, failed: op.id, map },
162
+ });
163
+ }
164
+ map[op.id] = number;
165
+ created.push({ id: op.id, number });
166
+ }
167
+ return { created, map };
168
+ }
169
+
170
+ /**
171
+ * crew kickoff: lint → existing issues → plan → (with --apply and an approved PRD) creation.
172
+ * deps: { gh, sleep, root, areas } — for tests.
173
+ */
174
+ export async function runKickoff({ file = DEFAULT_PRD, apply = false } = {}, deps = {}) {
175
+ const cfg = deps.root ? null : loadConfig();
176
+ const root = deps.root ?? cfg.root;
177
+ const r = loadPrd({ file, root, areas: deps.areas ?? cfg?.config.areas });
178
+ if (r.result) return r.result;
179
+ const { parsed, lint, text } = r;
180
+ const approval = prdApproval(text);
181
+ // The approval gate comes before any GitHub calls.
182
+ if (apply && !approval.approved) {
183
+ return fail('plan_not_approved', `PRD ${file} is not approved — not creating issues`, {
184
+ cause_hint: approval.status ? `PRD header says status: ${approval.status}` : `${file} has no approval header ${APPROVE_LINE}`,
185
+ retry: 'show the PRD and crew kickoff (dry-run) to the product lead, once approved set status: approved and run crew kickoff --apply again',
186
+ stop_when: 'no answer from the product lead for 20 minutes — the planner sets status: approved by: auto-timeout; do not run --apply without the header',
187
+ next_actions: ['crew kickoff (dry-run) — show the plan to the human', `after approval: ${APPROVE_LINE} at the top of ${file}`, 'crew kickoff --apply'],
188
+ artifacts: { approval },
189
+ });
190
+ }
191
+ const gh = deps.gh ?? defaultGh(root);
192
+ const warnings = lint.warnings.map(formatLintItem);
193
+ let existing = [];
194
+ const lr = gh(LIST_ARGS);
195
+ if (lr.code === 0) existing = JSON.parse(lr.stdout || '[]') ?? [];
196
+ else if (apply) {
197
+ return fail('gh_failed', 'Could not read the repo issues', {
198
+ cause_hint: (lr.stderr || lr.stdout).split('\n')[0],
199
+ retry: 'crew doctor, then crew kickoff --apply',
200
+ stop_when: 'gh is not authenticated or there is no repo — tell the human',
201
+ });
202
+ } else warnings.push(`could not read issues (${(lr.stderr || lr.stdout).split('\n')[0]}) — the plan ignores existing ones`);
203
+
204
+ const plan = planKickoff(parsed, existing, { prd: file });
205
+ warnings.push(...plan.warnings);
206
+ const count = (op) => plan.ops.filter((o) => o.op === op).length;
207
+ const planView = plan.ops.map(({ op, kind, id, title, labels, after, tasks, number, assignee }) => ({ op, kind, id, title, ...(number ? { number } : {}), ...(assignee ? { assignee } : {}), ...(labels ? { labels } : {}), ...(after?.length ? { after } : {}), ...(tasks ? { tasks } : {}) }));
208
+
209
+ if (!apply) {
210
+ const artifacts = { apply: false, approved: approval.approved, approval, plan: planView, map: plan.map, warnings };
211
+ const summary = `Kickoff plan: create ${count('create')}, skip ${count('skip') + count('changed')}${count('orphan') ? `, orphans ${count('orphan')}` : ''} (dry-run)`;
212
+ const next_actions = approval.approved
213
+ ? ['crew kickoff --apply']
214
+ : ['show the PRD and this plan to the product lead', `once approved: ${APPROVE_LINE} at the top of ${file}`, 'crew kickoff --apply'];
215
+ return warnings.length ? warn(summary, { artifacts, next_actions }) : ok(summary, { artifacts, next_actions });
216
+ }
217
+
218
+ // One person drives the plan — the captain (crew.config.json → captain, written by crew up). Members do not plan.
219
+ const captain = deps.captain !== undefined ? deps.captain : cfg?.config.captain ?? null;
220
+ if (captain) {
221
+ const who = gh(['api', 'user', '--jq', '.login']);
222
+ const me = who.code === 0 ? who.stdout.trim() : null;
223
+ if (!me || me.toLowerCase() !== String(captain).toLowerCase()) {
224
+ return fail('not_captain', `Only the captain ${captain} creates the plan and tasks${me ? `, and you are ${me}` : ''}`, {
225
+ cause_hint: 'the team has one plan: a second kickoff creates duplicate tasks and a foreign skeleton',
226
+ retry: 'members: do not plan, work through the crew-workflow cycle (crew task next)',
227
+ stop_when: 'immediately: tell the human that the captain makes the plan',
228
+ artifacts: { captain, me },
229
+ });
230
+ }
231
+ }
232
+ // Kickoff runs from main: otherwise the issues get created but the plan does not reach main (or another branch does).
233
+ const branch = deps.branch !== undefined ? deps.branch : deps.gh ? 'main' : run('git', ['branch', '--show-current'], { cwd: root, allowFail: true }).stdout.trim();
234
+ if (branch !== 'main') {
235
+ return fail('not_main', `crew kickoff --apply must be run on main, but the current branch is ${branch || '?'}`, {
236
+ cause_hint: 'the plan is published to main together with the tasks; this is not possible from a task branch (worktree)',
237
+ retry: 'go to the main project folder (git switch main) and run crew kickoff --apply again',
238
+ stop_when: 'there is no main folder on main — tell the human',
239
+ });
240
+ }
241
+ const assignees = [...new Set(plan.ops.filter((o) => o.op === 'create' && o.assignee).map((o) => o.assignee))];
242
+ const outsiders = assignees.filter((l) => gh(['api', `repos/{owner}/{repo}/collaborators/${l}`]).code !== 0);
243
+ if (outsiders.length) {
244
+ return fail('team_not_collaborator', `crew-team contains people who are not repo members: ${outsiders.join(', ')}`, {
245
+ cause_hint: 'a task can only be assigned to a member with access (the invitation must be accepted)',
246
+ retry: `fix the logins in the crew-team block or invite them: crew up --repo <owner/name> --invite ${outsiders.join(',')}; then crew kickoff --apply`,
247
+ stop_when: 'the login is correct but the invitation is not accepted — ask the human to accept it',
248
+ artifacts: { outsiders },
249
+ });
250
+ }
251
+
252
+ const res = await applyKickoff(plan, { parsed, prd: file, gh, sleep: deps.sleep ?? realSleep });
253
+ if (res.status === 'error') return res;
254
+ // In tests (gh is stubbed) we do not touch git unless publishing is stubbed explicitly.
255
+ const pub = deps.publish ? deps.publish() : deps.gh ? { state: 'skipped' } : publishPlan({ root, me: agentId() });
256
+ if (pub.state === 'failed') warnings.push(pub.note);
257
+ const artifacts = { apply: true, created: res.created, map: res.map, skipped: plan.ops.filter((o) => o.op === 'skip' || o.op === 'changed').map((o) => o.id), warnings, plan_published: pub.state };
258
+ const summary = `Kickoff: created ${res.created.length}, skipped ${artifacts.skipped.length}`;
259
+ const next_actions = ['crew status', 'briefly tell the humans (in their language): how many tasks, who owns what, what is on the demo path, what was cut; members start the cycle: /loop 10m Work through the crew-workflow cycle'];
260
+ return warnings.length ? warn(summary, { artifacts, next_actions: [...warnings, ...next_actions] }) : ok(summary, { artifacts, next_actions });
261
+ }
262
+
263
+ const kickoff = {
264
+ name: 'kickoff',
265
+ summary: 'Tasks and epics in Issues from docs/prd.md: plan (dry-run) by default, --apply to create (the PRD must be approved)',
266
+ args: [
267
+ FILE_ARG,
268
+ { name: 'apply', type: 'boolean', description: 'create issues (only if the PRD header says status: approved)' },
269
+ ],
270
+ run(opts) {
271
+ return runKickoff(opts);
272
+ },
273
+ };
274
+
275
+ export default [taskLint, kickoff];
@@ -0,0 +1,28 @@
1
+ // crew labels sync — create/update labels in the GitHub repo (status:*, area:*, tier:*, risk:*, agent:*).
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { ok } from '../core/output.js';
5
+ import { gh } from '../core/exec.js';
6
+ import { HARNESS_ROOT, loadConfig } from '../core/config.js';
7
+
8
+ export function allLabels(config) {
9
+ const base = JSON.parse(readFileSync(join(HARNESS_ROOT, 'templates', 'labels.json'), 'utf8'));
10
+ const areas = Object.keys(config.areas ?? {}).map((a) => ({ name: `area:${a}`, color: 'c5def5', description: `area ${a}` }));
11
+ const agents = (config.hosts ?? []).map((h) => ({ name: `agent:${h}`, color: 'ededed', description: `done by agent ${h}` }));
12
+ return [...base, ...areas, ...agents];
13
+ }
14
+
15
+ export default {
16
+ name: 'labels sync',
17
+ summary: 'Create status, area, tier and agent labels in the GitHub repo',
18
+ agent: false,
19
+ args: [],
20
+ run() {
21
+ const { config, root } = loadConfig();
22
+ const labels = allLabels(config);
23
+ for (const l of labels) {
24
+ gh(['label', 'create', l.name, '--color', l.color, '--description', l.description, '--force'], { cwd: root });
25
+ }
26
+ return ok(`Labels synced: ${labels.length}`, { artifacts: { labels: labels.map((l) => l.name) } });
27
+ },
28
+ };
@@ -0,0 +1,48 @@
1
+ // crew metrics [--agent codex] [--since 2d] — summary of the .crew/metrics.jsonl log:
2
+ // completion rate, check reruns per task, median claim→PR per agent, top-5 errors.
3
+ import { join } from 'node:path';
4
+ import { ok, warn, fail } from '../core/output.js';
5
+ import { loadConfig } from '../core/config.js';
6
+ import { readEvents, filterEvents, parseSince, aggregate, metricsRoot, humanMs, table, METRICS_FILE } from '../core/metrics.js';
7
+
8
+ const pct = (x) => (x == null ? null : `${Math.round(x * 100)}%`);
9
+
10
+ export default {
11
+ name: 'metrics',
12
+ summary: 'Team metrics: share of tasks taken to a PR, check reruns, claim→PR time, frequent errors',
13
+ args: [
14
+ { name: 'agent', type: 'string', description: 'only this agent: codex or codex/gpt-5 (empty = all)' },
15
+ { name: 'since', type: 'string', description: 'since when: 2026-12-05, ISO time or 30m / 6h / 2d' },
16
+ ],
17
+ run({ agent, since } = {}) {
18
+ const { root } = loadConfig();
19
+ const sinceMs = parseSince(since);
20
+ if (since !== undefined && sinceMs === null) {
21
+ return fail('bad_input', `--since: cannot parse "${since}"`, {
22
+ cause_hint: 'expected a date (2026-12-05), ISO time or 30m / 6h / 2d',
23
+ retry: 'crew metrics --since 1d',
24
+ });
25
+ }
26
+ const file = join(metricsRoot(root), METRICS_FILE);
27
+ const events = filterEvents(readEvents(root), { agent, sinceMs });
28
+ if (!events.length) {
29
+ return warn('No events in the metrics log yet', {
30
+ artifacts: { file },
31
+ next_actions: ['work with crew commands (task claim, check, pr open) — events are recorded automatically', 'crew metrics without filters'],
32
+ });
33
+ }
34
+ const agg = aggregate(events);
35
+ const t = table(
36
+ ['agent', 'claim', 'PR', 'completed', 'check/task', 'claim→PR (median)', 'errors'],
37
+ agg.agents.map((a) => [a.agent, a.claims, a.prs, pct(a.completion_rate), a.retries_per_task, humanMs(a.median_claim_to_pr_ms), a.errors]),
38
+ );
39
+ const errs = agg.top_errors.length ? `\nFrequent errors: ${agg.top_errors.map((e) => `${e.code} ×${e.count}`).join(', ')}` : '';
40
+ const weak = agg.agents.filter((a) => a.completion_rate != null && a.completion_rate < 0.5).map((a) => a.agent);
41
+ return ok(`Metrics: ${agg.total} events, agents: ${agg.agents.length}\n${t}${errs}`, {
42
+ artifacts: { file, events: agg.total, agents: agg.agents, top_errors: agg.top_errors },
43
+ next_actions: weak.length
44
+ ? [`find out why ${weak.join(', ')} drop tasks: gh issue list --label status:blocked`, 'crew eval --dry-run — compare agents on reference tasks']
45
+ : ['crew status'],
46
+ });
47
+ },
48
+ };