@gredescerber/agent-crew 0.0.0-stage → 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.
- package/AGENTS.md +25 -0
- package/LICENSE +201 -0
- package/README.md +418 -2
- package/README.ru.md +421 -0
- package/adapters/claude/adapter.js +107 -0
- package/adapters/codex/adapter.js +101 -0
- package/bin/crew.js +60 -0
- package/docs/AGENT-SETUP.md +138 -0
- package/ecc/profile.json +16 -0
- package/evals/README.md +123 -0
- package/evals/fixtures/mini-app/README.md +10 -0
- package/evals/fixtures/mini-app/contracts/health.json +11 -0
- package/evals/fixtures/mini-app/package.json +10 -0
- package/evals/fixtures/mini-app/src/cart.js +14 -0
- package/evals/fixtures/mini-app/src/handlers.js +16 -0
- package/evals/fixtures/mini-app/src/pager.js +7 -0
- package/evals/fixtures/mini-app/test/cart.test.js +10 -0
- package/evals/fixtures/mini-app/test/handlers.test.js +13 -0
- package/evals/fixtures/mini-app/test/pager.test.js +8 -0
- package/evals/tasks/add-input-validation.json +15 -0
- package/evals/tasks/fix-off-by-one.json +14 -0
- package/evals/tasks/handler-per-contract.json +15 -0
- package/evals/tasks/judges/add-input-validation.judge.mjs +28 -0
- package/evals/tasks/judges/fix-off-by-one.judge.mjs +22 -0
- package/evals/tasks/judges/handler-per-contract.judge.mjs +18 -0
- package/package.json +46 -4
- package/roles/analyst.md +11 -0
- package/roles/builder.md +16 -0
- package/roles/implementer.md +17 -0
- package/roles/integrator.md +12 -0
- package/roles/planner.md +19 -0
- package/roles/reviewer.md +16 -0
- package/roles/scout.md +15 -0
- package/skills/crew-brief-analysis/SKILL.md +32 -0
- package/skills/crew-kickoff/SKILL.md +77 -0
- package/skills/crew-kickoff/task-format.md +215 -0
- package/skills/crew-review/SKILL.md +22 -0
- package/skills/crew-workflow/SKILL.md +46 -0
- package/src/commands/adapters.js +85 -0
- package/src/commands/ask.js +123 -0
- package/src/commands/board.js +88 -0
- package/src/commands/check.js +148 -0
- package/src/commands/commit.js +105 -0
- package/src/commands/compliance.js +109 -0
- package/src/commands/digest.js +64 -0
- package/src/commands/doctor.js +48 -0
- package/src/commands/eval.js +507 -0
- package/src/commands/github.js +169 -0
- package/src/commands/guard.js +101 -0
- package/src/commands/init.js +76 -0
- package/src/commands/kickoff.js +275 -0
- package/src/commands/labels.js +28 -0
- package/src/commands/merge-ready.js +33 -0
- package/src/commands/metrics.js +48 -0
- package/src/commands/pr.js +273 -0
- package/src/commands/shots.js +51 -0
- package/src/commands/status.js +48 -0
- package/src/commands/task.js +590 -0
- package/src/commands/up.js +355 -0
- package/src/commands/upgrade.js +178 -0
- package/src/commands/worker.js +322 -0
- package/src/core/board.js +127 -0
- package/src/core/config.js +83 -0
- package/src/core/digest.js +67 -0
- package/src/core/exec.js +50 -0
- package/src/core/guard.js +273 -0
- package/src/core/issues.js +154 -0
- package/src/core/lease.js +48 -0
- package/src/core/metrics.js +232 -0
- package/src/core/output.js +56 -0
- package/src/core/prd.js +612 -0
- package/src/core/proc.js +52 -0
- package/src/core/questions.js +51 -0
- package/src/core/queue.js +94 -0
- package/src/core/registry.js +143 -0
- package/src/core/session.js +61 -0
- package/src/core/shots.js +68 -0
- package/src/core/supervisor.js +56 -0
- package/src/core/version.js +44 -0
- package/src/mcp/server.js +81 -0
- package/templates/labels.json +23 -0
- package/templates/mcp.optional.json +23 -0
- package/templates/product/.github/ISSUE_TEMPLATE/epic.yml +22 -0
- package/templates/product/.github/ISSUE_TEMPLATE/feedback.yml +36 -0
- package/templates/product/.github/ISSUE_TEMPLATE/task.yml +62 -0
- package/templates/product/.github/pull_request_template.md +16 -0
- package/templates/product/.github/workflows/crew-guard.yml +45 -0
- package/templates/product/.github/workflows/crew.yml +118 -0
- package/templates/product/AGENTS.md +109 -0
- package/templates/product/contracts/README.md +8 -0
- package/templates/product/docs/architecture.md +41 -0
- package/templates/product/docs/brief-analysis.md +62 -0
- package/templates/product/docs/brief.md +25 -0
- package/templates/product/docs/playbooks/issues.md +91 -0
- package/templates/product/docs/playbooks/preflight.md +28 -0
- package/templates/product/docs/playbooks/timeline.md +52 -0
- package/templates/product/docs/prd.md +58 -0
- package/templates/product/events/event.example.json +10 -0
- package/templates/product/tests/acceptance/README.md +9 -0
- package/templates/stacks/web-fastapi.json +74 -0
- package/templates/stacks/web-node.json +70 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// crew-guard: PR rules without a human "approve button". Pure functions, no network (tests run them).
|
|
2
|
+
//
|
|
3
|
+
// A human does not approve every PR. Instead, automatically:
|
|
4
|
+
// 1. Agents never change agent rules and CI (PROTECTED_PATHS): a PR touching them will not pass.
|
|
5
|
+
// Only the harness developer changes them (harness update), merging past protection as admin.
|
|
6
|
+
// 2. Acceptance tests: new ones may be added; changing/deleting existing ones is allowed only if
|
|
7
|
+
// the PR's task (Closes #N) explicitly lists those files in the "Changes acceptance" section.
|
|
8
|
+
// Such a task is written by the planner/analyst (usually from human feedback), not by a builder.
|
|
9
|
+
// 3. Review is done by an agent: a crew:review marker from ANOTHER agent on the PR's current commit
|
|
10
|
+
// (by default from another vendor; if there is none for longer than fallback_minutes, from any other agent).
|
|
11
|
+
//
|
|
12
|
+
// Limitation: agents work under human accounts, so this does not stop a deliberate agent forging a marker.
|
|
13
|
+
// The goal is to catch laziness and mistakes (weakened a test, "fixed" CI, approved itself).
|
|
14
|
+
|
|
15
|
+
import { isTrustedComment } from './issues.js';
|
|
16
|
+
|
|
17
|
+
/** Agent rules and CI files: agents do not change them. */
|
|
18
|
+
export const PROTECTED_PATHS = ['crew.config.json', '.github/', 'AGENTS.md', 'CLAUDE.md', 'agents/', 'events/'];
|
|
19
|
+
export const ACCEPTANCE_DIR = 'tests/acceptance/';
|
|
20
|
+
/** Tests from a tests task (tests_for), until a code task moves them. CI does not run them. */
|
|
21
|
+
export const PENDING_DIR = 'tests/pending/';
|
|
22
|
+
|
|
23
|
+
/** Rename tests/pending/... -> tests/acceptance/... without a single changed line. */
|
|
24
|
+
export function isPureMove(f = {}) {
|
|
25
|
+
const changes = Number.isFinite(f.changes) ? f.changes : (f.additions ?? 0) + (f.deletions ?? 0);
|
|
26
|
+
return (
|
|
27
|
+
f.status === 'renamed' &&
|
|
28
|
+
!!f.previous_filename &&
|
|
29
|
+
norm(f.previous_filename).startsWith(PENDING_DIR) &&
|
|
30
|
+
norm(f.filename).startsWith(ACCEPTANCE_DIR) &&
|
|
31
|
+
(Number.isFinite(f.changes) || Number.isFinite(f.additions) || Number.isFinite(f.deletions)) &&
|
|
32
|
+
changes === 0
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Markers of a task from the issue body: { taskId, testsFor, testsIssue }. */
|
|
37
|
+
export function taskMarkers(issueBody = '') {
|
|
38
|
+
const pick = (kind, key) => new RegExp(`<!-- (?:crew|hk):${kind}\\b[^>]*?\\b${key}=(\\S+?)[\\s>]`).exec(issueBody ?? '')?.[1] ?? null;
|
|
39
|
+
return { taskId: pick('task', 'id'), testsFor: pick('tests-for', 'task'), testsIssue: Number(pick('tests', 'issue')) || null };
|
|
40
|
+
}
|
|
41
|
+
export const ACCEPTANCE_SECTION = 'Changes acceptance';
|
|
42
|
+
export const REVIEW_MODES = ['other-vendor', 'other-agent'];
|
|
43
|
+
export const GUARD_CONTEXT = 'crew-guard';
|
|
44
|
+
|
|
45
|
+
const norm = (p) => String(p).replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\//, '');
|
|
46
|
+
|
|
47
|
+
export function isProtected(path) {
|
|
48
|
+
const p = norm(path);
|
|
49
|
+
return PROTECTED_PATHS.some((x) => (x.endsWith('/') ? p.startsWith(x) : p === x));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Review settings from crew.config.json -> review. Default: other-vendor when there is more than one host. */
|
|
53
|
+
export function reviewPolicy(config = {}) {
|
|
54
|
+
const r = config.review ?? {};
|
|
55
|
+
const multi = (config.hosts ?? []).length > 1;
|
|
56
|
+
return {
|
|
57
|
+
require: REVIEW_MODES.includes(r.require) ? r.require : multi ? 'other-vendor' : 'other-agent',
|
|
58
|
+
approvals: Number.isInteger(r.approvals) && r.approvals >= 0 ? r.approvals : 1,
|
|
59
|
+
approvals_high: Number.isInteger(r.approvals_high) && r.approvals_high >= 0 ? r.approvals_high : 1,
|
|
60
|
+
fallback_minutes: Number.isFinite(r.fallback_minutes) && r.fallback_minutes >= 0 ? r.fallback_minutes : 30,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Task number from the PR body (Closes/Fixes/Resolves #N). */
|
|
65
|
+
export function linkedIssue(body = '') {
|
|
66
|
+
return Number(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?) #(\d+)/i.exec(body ?? '')?.[1]) || null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Author agent from the PR body (crew pr open writes **Agent:** `codex/x`). */
|
|
70
|
+
export function authorAgent(body = '') {
|
|
71
|
+
return /\*\*Agent:\*\*\s*`([^`]+)`/.exec(body ?? '')?.[1] ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Vendor from an agent id: claude/opus@petya -> claude. */
|
|
75
|
+
export const vendorOf = (agent) => String(agent ?? '').split(/[/@]/)[0] || null;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Which existing acceptance tests the task allows changing: the "Changes acceptance" section of the issue body.
|
|
79
|
+
* Lines with paths inside tests/acceptance/; `dir/**` means the whole folder. Everything else is ignored.
|
|
80
|
+
*/
|
|
81
|
+
export function allowedAcceptanceChanges(issueBody = '') {
|
|
82
|
+
const lines = String(issueBody ?? '').split(/\r?\n/);
|
|
83
|
+
const start = lines.findIndex((l) => /^#{2,4}\s+/.test(l) && l.replace(/^#+\s+/, '').trim() === ACCEPTANCE_SECTION);
|
|
84
|
+
if (start < 0) return [];
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const l of lines.slice(start + 1)) {
|
|
87
|
+
if (/^#{1,4}\s+/.test(l)) break;
|
|
88
|
+
for (const m of l.matchAll(/(?:^|[\s`,(])((?:\.\/)?tests\/acceptance\/[^\s`,)]+)/g)) out.push(norm(m[1]));
|
|
89
|
+
}
|
|
90
|
+
return [...new Set(out)];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const covered = (path, allowed) =>
|
|
94
|
+
allowed.some((a) => (a.endsWith('/**') ? path.startsWith(a.slice(0, -2)) : a.endsWith('/') ? path.startsWith(a) : path === a));
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Changed PR files (as in GitHub API pulls/N/files: filename, status, previous_filename)
|
|
98
|
+
* -> violations of protected paths and acceptance tests.
|
|
99
|
+
*/
|
|
100
|
+
export function fileViolations(files = [], { allowedAcceptance = [] } = {}) {
|
|
101
|
+
const protectedHits = [];
|
|
102
|
+
const acceptanceHits = [];
|
|
103
|
+
for (const f of files) {
|
|
104
|
+
const paths = [f.filename, f.previous_filename].filter(Boolean).map(norm);
|
|
105
|
+
if (paths.some(isProtected)) protectedHits.push(norm(f.filename));
|
|
106
|
+
// Moving tests written by another agent from tests/pending/ to tests/acceptance/ unchanged is allowed.
|
|
107
|
+
if (isPureMove(f)) continue;
|
|
108
|
+
// tests/pending/ (tests from a tests task) are frozen the same way as tests/acceptance/.
|
|
109
|
+
const prevPending = f.previous_filename && norm(f.previous_filename).startsWith(PENDING_DIR);
|
|
110
|
+
if ((f.status !== 'added' && norm(f.filename).startsWith(PENDING_DIR)) || prevPending) {
|
|
111
|
+
acceptanceHits.push(norm(f.previous_filename ?? f.filename));
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
// new files in tests/acceptance are allowed; changing/deleting/renaming an existing one only by task
|
|
115
|
+
const touchesExisting =
|
|
116
|
+
(f.status !== 'added' && norm(f.filename).startsWith(ACCEPTANCE_DIR)) ||
|
|
117
|
+
(f.previous_filename && norm(f.previous_filename).startsWith(ACCEPTANCE_DIR));
|
|
118
|
+
if (touchesExisting) {
|
|
119
|
+
const target = norm(f.previous_filename ?? f.filename);
|
|
120
|
+
if (!covered(target, allowedAcceptance)) acceptanceHits.push(target);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { protectedHits: [...new Set(protectedHits)], acceptanceHits: [...new Set(acceptanceHits)] };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Review markers: <!-- crew:review verdict=approve|changes agent=<id> sha=<commit> -->.
|
|
128
|
+
* The last verdict of each agent is taken; only verdicts on the current head count.
|
|
129
|
+
*/
|
|
130
|
+
export function reviewState(comments = [], { headSha, author, policy, prCreatedAt, now = Date.now() }) {
|
|
131
|
+
const latest = new Map();
|
|
132
|
+
for (const c of comments) {
|
|
133
|
+
// in a public repo anyone can comment: review markers only count from repo members
|
|
134
|
+
if (!isTrustedComment(c)) continue;
|
|
135
|
+
for (const m of String(c.body ?? '').matchAll(/<!-- (?:crew|hk):review((?: \w+=\S+)*) -->/g)) {
|
|
136
|
+
const f = Object.fromEntries([...m[1].matchAll(/(\w+)=(\S+)/g)].map((x) => [x[1], x[2]]));
|
|
137
|
+
if (!f.agent || !f.verdict || !f.sha) continue;
|
|
138
|
+
latest.set(f.agent, { agent: f.agent, verdict: f.verdict, sha: f.sha, at: c.createdAt });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const onHead = [...latest.values()].filter((r) => headSha && r.sha.length >= 7 && headSha.startsWith(r.sha));
|
|
142
|
+
const notAuthor = onHead.filter((r) => r.agent !== author);
|
|
143
|
+
const otherVendor = notAuthor.filter((r) => vendorOf(r.agent) !== vendorOf(author));
|
|
144
|
+
const created = Date.parse(prCreatedAt ?? '');
|
|
145
|
+
const fallback = policy.require === 'other-vendor' && Number.isFinite(created) && now - created > policy.fallback_minutes * 60_000;
|
|
146
|
+
const eligible = policy.require === 'other-agent' || fallback ? notAuthor : otherVendor;
|
|
147
|
+
return {
|
|
148
|
+
approvals: eligible.filter((r) => r.verdict === 'approve').map((r) => r.agent),
|
|
149
|
+
changes: eligible.filter((r) => r.verdict === 'changes').map((r) => r.agent),
|
|
150
|
+
stale: [...latest.values()].filter((r) => !onHead.includes(r)).map((r) => r.agent),
|
|
151
|
+
self: onHead.some((r) => r.agent === author),
|
|
152
|
+
fallback,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* PR summary -> { state: success|pending|failure, description (<=140 chars for the status), problems[] }.
|
|
158
|
+
* failure: a rule is violated (the agent must redo it); pending: all fine, waiting for an agent review.
|
|
159
|
+
*/
|
|
160
|
+
export function evaluateGuard({ files = [], body = '', issueBody = '', comments = [], commits = [], headSha, risk = 'med', createdAt, config = {}, now = Date.now(), pendingFiles = [] }) {
|
|
161
|
+
const problems = [];
|
|
162
|
+
const issue = linkedIssue(body);
|
|
163
|
+
if (!issue) problems.push({ rule: 'closes', message: 'the PR description has no "Closes #N": a PR must close a task' });
|
|
164
|
+
|
|
165
|
+
const noAgent = commits.filter((c) => !String(c.agent ?? '').trim()).map((c) => c.sha);
|
|
166
|
+
if (noAgent.length) {
|
|
167
|
+
problems.push({
|
|
168
|
+
rule: 'agent_trailer',
|
|
169
|
+
message: `commits without an Agent trailer: ${noAgent.map((sha) => sha.slice(0, 7)).join(', ')} (use crew commit)`,
|
|
170
|
+
commits: noAgent,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Tests and code are by different agents: a tests task touches only its own tests/pending/<T>/ folder,
|
|
175
|
+
// a code task must move all those tests to tests/acceptance/ unchanged.
|
|
176
|
+
const tm = taskMarkers(issueBody);
|
|
177
|
+
if (tm.testsFor) {
|
|
178
|
+
const dir = `${PENDING_DIR}${tm.testsFor}/`;
|
|
179
|
+
const outside = files.map((f) => norm(f.filename)).filter((p) => !p.startsWith(dir));
|
|
180
|
+
if (outside.length) {
|
|
181
|
+
problems.push({ rule: 'tests_task_scope', message: `the tests task only changes ${dir}, but the PR touches: ${outside.join(', ')}`, files: outside });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (pendingFiles.length) {
|
|
185
|
+
const moved = new Set(files.filter(isPureMove).map((f) => norm(f.previous_filename)));
|
|
186
|
+
const left = pendingFiles.map(norm).filter((p) => !moved.has(p));
|
|
187
|
+
if (left.length) {
|
|
188
|
+
problems.push({
|
|
189
|
+
rule: 'pending_tests_not_moved',
|
|
190
|
+
message: `tests from the tests task were not moved to tests/acceptance/ unchanged: ${left.join(', ')} (git mv, no edits)`,
|
|
191
|
+
files: left,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const allowedAcceptance = allowedAcceptanceChanges(issueBody);
|
|
197
|
+
const { protectedHits, acceptanceHits } = fileViolations(files, { allowedAcceptance });
|
|
198
|
+
if (protectedHits.length) {
|
|
199
|
+
problems.push({
|
|
200
|
+
rule: 'protected_paths',
|
|
201
|
+
message: `the PR changes agent rules or CI (${protectedHits.join(', ')}): not allowed for agents; only the harness developer changes these`,
|
|
202
|
+
files: protectedHits,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
if (acceptanceHits.length) {
|
|
206
|
+
problems.push({
|
|
207
|
+
rule: 'acceptance_changed',
|
|
208
|
+
message: `the PR changes existing acceptance tests (${acceptanceHits.join(', ')}), but task #${issue ?? '?'} does not allow it (section "${ACCEPTANCE_SECTION}")`,
|
|
209
|
+
files: acceptanceHits,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const policy = reviewPolicy(config);
|
|
214
|
+
const author = authorAgent(body);
|
|
215
|
+
if (!author) {
|
|
216
|
+
problems.push({
|
|
217
|
+
rule: 'not_crew_pr',
|
|
218
|
+
message: 'the PR description has no **Agent:** line: the PR was not opened via crew pr open (without crew check and a known author the review cannot count)',
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
const need = risk === 'high' ? policy.approvals_high : policy.approvals;
|
|
222
|
+
const review = reviewState(comments, { headSha, author, policy, prCreatedAt: createdAt, now });
|
|
223
|
+
if (review.changes.length) {
|
|
224
|
+
problems.push({ rule: 'changes_requested', message: `reviewer requests changes: ${review.changes.join(', ')}` });
|
|
225
|
+
}
|
|
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
|
+
|
|
233
|
+
const fail = problems.length > 0;
|
|
234
|
+
const reviewed = review.approvals.length >= need;
|
|
235
|
+
const state = fail ? 'failure' : reviewed ? 'success' : 'pending';
|
|
236
|
+
const who = policy.require === 'other-vendor' && !review.fallback ? 'an agent from another vendor' : 'another agent';
|
|
237
|
+
const description = (fail
|
|
238
|
+
? problems.map((p) => p.rule).join(', ')
|
|
239
|
+
: reviewed
|
|
240
|
+
? `ok: review ${review.approvals.join(', ') || 'not required'}`
|
|
241
|
+
: `waiting for review by ${who}: ${review.approvals.length}/${need}`) + (waived.length && !fail ? ` (waived: ${waived.join(', ')})` : '');
|
|
242
|
+
return {
|
|
243
|
+
state,
|
|
244
|
+
description: description.slice(0, 140),
|
|
245
|
+
problems,
|
|
246
|
+
waived,
|
|
247
|
+
issue,
|
|
248
|
+
author,
|
|
249
|
+
review: { ...review, need, require: policy.require },
|
|
250
|
+
allowed_acceptance: allowedAcceptance,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
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
|
+
|
|
272
|
+
/** Review marker text for a comment. */
|
|
273
|
+
export const reviewMarker = ({ verdict, agent, sha }) => `<!-- crew:review verdict=${verdict} agent=${agent} sha=${sha} -->`;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Working with the task queue in GitHub Issues through the gh CLI.
|
|
2
|
+
import { gh, ghJson } from './exec.js';
|
|
3
|
+
|
|
4
|
+
// waiting: waits for dependencies (crew:after marker in the body); crew task promote moves it to ready.
|
|
5
|
+
export const STATUS = ['ready', 'claimed', 'review', 'blocked', 'needs-spec', 'waiting', 'done'];
|
|
6
|
+
export const TIERS = ['low', 'mid', 'high'];
|
|
7
|
+
|
|
8
|
+
export const labelValue = (issue, prefix) =>
|
|
9
|
+
issue.labels.map((l) => l.name).find((n) => n.startsWith(prefix + ':'))?.slice(prefix.length + 1) ?? null;
|
|
10
|
+
|
|
11
|
+
export function listIssues({ label, limit = 200 } = {}) {
|
|
12
|
+
const args = ['issue', 'list', '--state', 'open', '--limit', String(limit), '--json', 'number,title,labels,assignees,updatedAt'];
|
|
13
|
+
if (label) args.push('--label', label);
|
|
14
|
+
return ghJson(args) ?? [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Emergency stop label: while such an issue is open, agents do not take tasks. */
|
|
18
|
+
export const PAUSE_LABEL = 'crew:pause';
|
|
19
|
+
|
|
20
|
+
/** Open issues with the crew:pause label (pure function over the list from listIssues). */
|
|
21
|
+
export function pauseIssues(issues = []) {
|
|
22
|
+
return issues.filter(
|
|
23
|
+
(i) => (i.state ?? 'OPEN').toUpperCase() === 'OPEN' && (i.labels ?? []).some((l) => (l?.name ?? l) === PAUSE_LABEL),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether the emergency stop is on. Without an argument it asks GitHub; tests pass a list. */
|
|
28
|
+
export function isPaused(issues = listIssues({ label: PAUSE_LABEL })) {
|
|
29
|
+
return pauseIssues(issues).length > 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function viewIssue(n) {
|
|
33
|
+
return ghJson(['issue', 'view', String(n), '--json', 'number,title,labels,assignees,body,updatedAt,state,comments']);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function setStatus(n, to, { from = [] } = {}) {
|
|
37
|
+
const args = ['issue', 'edit', String(n), '--add-label', `status:${to}`];
|
|
38
|
+
for (const f of from) if (f !== to) args.push('--remove-label', `status:${f}`);
|
|
39
|
+
gh(args);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function comment(n, body) {
|
|
43
|
+
gh(['issue', 'comment', String(n), '--body', body]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Machine-readable markers in comments: <!-- crew:claim agent=codex/x at=ISO --> */
|
|
47
|
+
export const marker = (kind, fields) =>
|
|
48
|
+
`<!-- crew:${kind} ${Object.entries(fields).map(([k, v]) => `${k}=${v}`).join(' ')} -->`;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Text from an agent/human (--note) into a comment: escapes HTML comments
|
|
52
|
+
* so a quoted marker in the note does not become a real marker.
|
|
53
|
+
*/
|
|
54
|
+
export const safeText = (s = '') => String(s ?? '').replace(/<!--/g, '<!--').replace(/-->/g, '-->');
|
|
55
|
+
|
|
56
|
+
/** Repo members (write access) and the Actions bot. Markers from anyone else are ignored. */
|
|
57
|
+
export const TRUSTED_ASSOCIATIONS = ['OWNER', 'MEMBER', 'COLLABORATOR'];
|
|
58
|
+
const BOT_LOGINS = ['github-actions', 'github-actions[bot]'];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether markers in a comment can be trusted. In a public repo anyone can comment;
|
|
62
|
+
* without this check an outsider could take a task from an agent (crew:release) or "approve" a PR (crew:review).
|
|
63
|
+
* A comment without author/authorAssociation fields (synthetic, in tests) is trusted.
|
|
64
|
+
*/
|
|
65
|
+
export function isTrustedComment(c = {}) {
|
|
66
|
+
if (!('authorAssociation' in c) && !c.author) return true;
|
|
67
|
+
if (BOT_LOGINS.includes(c.author?.login)) return true;
|
|
68
|
+
return TRUSTED_ASSOCIATIONS.includes(String(c.authorAssociation ?? '').toUpperCase());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function parseMarkers(comments = [], { trustedOnly = true } = {}) {
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const c of comments) {
|
|
74
|
+
if (trustedOnly && !isTrustedComment(c)) continue;
|
|
75
|
+
for (const m of (c.body ?? '').matchAll(/<!-- (?:crew|hk):(\w+)((?: \w+=\S+)*) -->/g)) {
|
|
76
|
+
const fields = Object.fromEntries([...m[2].matchAll(/(\w+)=(\S+)/g)].map((x) => [x[1], x[2]]));
|
|
77
|
+
out.push({ kind: m[1], ...fields, createdAt: c.createdAt, by: c.author?.login ?? null });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Who currently owns the task: the first claim after the last release. */
|
|
84
|
+
export function currentOwner(comments) {
|
|
85
|
+
const ms = parseMarkers(comments);
|
|
86
|
+
let owner = null;
|
|
87
|
+
for (const m of ms) {
|
|
88
|
+
if (m.kind === 'release') owner = null;
|
|
89
|
+
else if (m.kind === 'claim' && !owner) owner = m;
|
|
90
|
+
}
|
|
91
|
+
return owner;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const markerTime = (m) => m?.createdAt ?? m?.at ?? null;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Latest activity of the OWNER: their markers (claim, heartbeat, status) after their claim.
|
|
98
|
+
* Markers from other agents (e.g. the loser of a claim_conflict) do not extend the lease.
|
|
99
|
+
*/
|
|
100
|
+
export function ownerActivityAt(comments, owner) {
|
|
101
|
+
if (!owner) return null;
|
|
102
|
+
const ms = parseMarkers(comments);
|
|
103
|
+
const start = ms.findIndex((m) => m.kind === 'claim' && m.agent === owner.agent && m.at === owner.at);
|
|
104
|
+
let last = markerTime(owner);
|
|
105
|
+
for (const m of ms.slice(Math.max(start, 0))) if (m.agent === owner.agent && m.kind !== 'release') last = markerTime(m) ?? last;
|
|
106
|
+
return last;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Owner and whether their lease went stale (pure function).
|
|
111
|
+
* -> { owner, lastActivityAt, stale }; with no owner stale = false.
|
|
112
|
+
*/
|
|
113
|
+
export function ownerState(comments, { lastCommitAt = null, now = Date.now(), leaseMinutes }) {
|
|
114
|
+
const owner = currentOwner(comments);
|
|
115
|
+
if (!owner) return { owner: null, lastActivityAt: null, stale: false };
|
|
116
|
+
const lastActivityAt = ownerActivityAt(comments, owner);
|
|
117
|
+
return { owner, lastActivityAt, stale: isStale({ lastMarkerAt: lastActivityAt, lastCommitAt, now, leaseMinutes }) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** ISO string / Date / ms number -> ms or null. */
|
|
121
|
+
const toMs = (x) => {
|
|
122
|
+
if (x === null || x === undefined || x === '') return null;
|
|
123
|
+
const t = x instanceof Date ? x.getTime() : typeof x === 'number' ? x : Date.parse(x);
|
|
124
|
+
return Number.isFinite(t) ? t : null;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Whether the task lease went stale (pure function, tests run it).
|
|
129
|
+
* Activity = the last crew marker in comments (if none, the issue's updatedAt)
|
|
130
|
+
* OR the last commit on the task branch origin/crew/<id>-*, whichever is fresher.
|
|
131
|
+
* So an agent that pushes but sends no heartbeat does not lose the task.
|
|
132
|
+
* If there is no date at all, it is not stale (we do not take a task blindly).
|
|
133
|
+
*/
|
|
134
|
+
export function isStale({ lastMarkerAt, issueUpdatedAt, lastCommitAt, now = Date.now(), leaseMinutes }) {
|
|
135
|
+
const times = [toMs(lastMarkerAt) ?? toMs(issueUpdatedAt), toMs(lastCommitAt)].filter((t) => t !== null);
|
|
136
|
+
if (!times.length) return false;
|
|
137
|
+
return toMs(now) - Math.max(...times) > leaseMinutes * 60_000;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function tierAllows(workerTier, taskTier) {
|
|
141
|
+
return TIERS.indexOf(taskTier ?? 'mid') <= TIERS.indexOf(workerTier ?? 'high');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const CYRILLIC = {
|
|
145
|
+
'\u0430': 'a', '\u0431': 'b', '\u0432': 'v', '\u0433': 'g', '\u0434': 'd', '\u0435': 'e', '\u0451': 'yo', '\u0436': 'zh', '\u0437': 'z', '\u0438': 'i', '\u0439': 'y',
|
|
146
|
+
'\u043a': 'k', '\u043b': 'l', '\u043c': 'm', '\u043d': 'n', '\u043e': 'o', '\u043f': 'p', '\u0440': 'r', '\u0441': 's', '\u0442': 't', '\u0443': 'u', '\u0444': 'f',
|
|
147
|
+
'\u0445': 'kh', '\u0446': 'ts', '\u0447': 'ch', '\u0448': 'sh', '\u0449': 'shch', '\u044a': '', '\u044b': 'y', '\u044c': '', '\u044d': 'e', '\u044e': 'yu', '\u044f': 'ya',
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export function slugify(title) {
|
|
151
|
+
const s = title.normalize('NFC').toLowerCase().replace(/[\u0430-\u044f\u0451]/g, (c) => CYRILLIC[c])
|
|
152
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40).replace(/-$/, '');
|
|
153
|
+
return s || 'task';
|
|
154
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Task lease from inside working commands (crew check, crew commit, crew pr open).
|
|
2
|
+
// 1) Renews the lease itself (heartbeat) so an agent working silently for a long time does not lose the task.
|
|
3
|
+
// 2) Checks the task is still mine: if sweep returned it to the queue and another agent took it,
|
|
4
|
+
// committing and opening a PR is not allowed, otherwise two agents would open two PRs for one task.
|
|
5
|
+
import { viewIssue, comment, currentOwner, ownerActivityAt, marker } from './issues.js';
|
|
6
|
+
|
|
7
|
+
/** At most one heartbeat comment per this many minutes. */
|
|
8
|
+
export const HEARTBEAT_EVERY_MIN = 5;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* -> { state: 'ok' | 'lost' | 'unknown', owner, heartbeat, error }
|
|
12
|
+
* ok - the task is mine (heartbeat: whether a renewal marker was posted);
|
|
13
|
+
* lost - another owner, or the task went back to the queue;
|
|
14
|
+
* unknown - GitHub unavailable (no network, no gh); do not block work, only warn.
|
|
15
|
+
* view/post are replaced in tests.
|
|
16
|
+
*/
|
|
17
|
+
export function checkLease({ id, me, view = viewIssue, post = comment, now = Date.now(), heartbeat = true, everyMin = HEARTBEAT_EVERY_MIN }) {
|
|
18
|
+
let issue;
|
|
19
|
+
try {
|
|
20
|
+
issue = view(id);
|
|
21
|
+
} catch (e) {
|
|
22
|
+
return { state: 'unknown', owner: null, heartbeat: false, error: e?.recovery?.cause_hint || e?.message || String(e) };
|
|
23
|
+
}
|
|
24
|
+
if (!issue) return { state: 'unknown', owner: null, heartbeat: false, error: `issue #${id} not found` };
|
|
25
|
+
const owner = currentOwner(issue.comments ?? []);
|
|
26
|
+
if (!owner || owner.agent !== me) return { state: 'lost', owner: owner?.agent ?? null, heartbeat: false };
|
|
27
|
+
let beat = false;
|
|
28
|
+
const last = Date.parse(ownerActivityAt(issue.comments, owner) ?? '');
|
|
29
|
+
if (heartbeat && (!Number.isFinite(last) || now - last > everyMin * 60_000)) {
|
|
30
|
+
try {
|
|
31
|
+
post(id, marker('heartbeat', { agent: me, at: new Date(now).toISOString() }));
|
|
32
|
+
beat = true;
|
|
33
|
+
} catch {
|
|
34
|
+
/* renewal is best effort */
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { state: 'ok', owner: owner.agent, heartbeat: beat };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Shared "task was taken" response for commit / pr open. */
|
|
41
|
+
export function leaseLostError(fail, id, lease, me) {
|
|
42
|
+
return fail('lease_lost', `#${id} is no longer your task${lease.owner ? ` - held by ${lease.owner}` : ' - it was returned to the queue'}`, {
|
|
43
|
+
cause_hint: `the lease expired (no activity for a long time) and the task went back to the queue; or CREW_AGENT (${me}) differs from the one used at claim`,
|
|
44
|
+
retry: `crew task claim ${id} - if the task is status:ready again; otherwise crew task next`,
|
|
45
|
+
stop_when: 'another agent took the task: do not push your branch, leave a note in the issue about what you finished',
|
|
46
|
+
next_actions: [`gh issue view ${id}`, `crew task claim ${id} (if ready)`, 'crew task next'],
|
|
47
|
+
});
|
|
48
|
+
}
|