create-agent-rig 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +170 -0
  2. package/README.md +66 -10
  3. package/package.json +9 -2
  4. package/packages/cli/dist/commands/init.js +73 -18
  5. package/packages/cli/dist/index.js +11 -1
  6. package/packages/cli/dist/lib/init-settings.js +52 -0
  7. package/packages/cli/dist/lib/summary.js +19 -5
  8. package/packages/cli/dist/templates.js +8 -0
  9. package/templates/agent-os/init/CLAUDE.md +133 -0
  10. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +46 -0
  11. package/templates/agent-os/stack/aws-cdk/.claude/skills/ro-debug/SKILL.md +117 -0
  12. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +1 -1
  13. package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +12 -2
  14. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +808 -0
  15. package/templates/agent-os/universal/.claude/queue.json +3 -0
  16. package/templates/agent-os/universal/.claude/rules/autonomy.md +43 -0
  17. package/templates/agent-os/universal/.claude/rules/invariants.md +170 -0
  18. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +489 -0
  19. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +161 -0
  20. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +305 -0
  21. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +231 -0
  22. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +175 -0
  23. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +345 -0
  24. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +239 -0
  25. package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +280 -0
  26. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +62 -0
  27. package/templates/agent-os/universal/.claude/settings.json +4 -0
  28. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +297 -40
  29. package/templates/agent-os/universal/.claude/skills/new-invariant/SKILL.md +102 -0
  30. package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.mjs +78 -0
  31. package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  32. package/templates/agent-os/universal/.claude/skills/worktree-task/SKILL.md +73 -0
  33. package/templates/agent-os/universal/CLAUDE.md +57 -7
  34. package/templates/agent-os/universal/PLAN.md +28 -2
  35. package/templates/agent-os/universal/layers.json +20 -1
  36. package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +6 -1
  37. package/templates/skeleton/aws-serverless/gitignore +8 -0
  38. package/templates/skeleton/node-service/.github/workflows/ci.yml +6 -1
  39. package/templates/skeleton/node-service/gitignore +8 -0
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ // The queue CLI — one command over whichever adapter this project uses.
3
+ //
4
+ // node .claude/scripts/queue/index.mjs next # the item to take, and why
5
+ // node .claude/scripts/queue/index.mjs next --json
6
+ // node .claude/scripts/queue/index.mjs list # every item, with skip reasons
7
+ // node .claude/scripts/queue/index.mjs hygiene # stale labels and link anomalies
8
+ //
9
+ // The adapter comes from `.claude/queue.json` (`{"adapter": "plan-md"}`) and
10
+ // defaults to `plan-md`, which is the only adapter that works in a freshly
11
+ // generated project. An unknown adapter is a hard error, never a fallback: a loop
12
+ // that silently reads the wrong queue is worse than one that refuses to start.
13
+ import { readFileSync, realpathSync } from 'node:fs';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { dirname, join } from 'node:path';
16
+ import { hygieneOf, selectNext, stopConditionOf } from './core.mjs';
17
+
18
+ const ADAPTERS = {
19
+ 'plan-md': './plan-md.mjs',
20
+ 'github-issues': './github-issues.mjs',
21
+ jira: './jira.mjs',
22
+ };
23
+
24
+ export const resolveAdapter = async (adapterName) => {
25
+ const modulePath = ADAPTERS[adapterName];
26
+ if (!modulePath) {
27
+ throw new Error(
28
+ `unknown queue adapter: ${adapterName}. Known adapters: ${Object.keys(ADAPTERS).join(', ')}.`,
29
+ );
30
+ }
31
+ // Resolved against this file's own URL, not the cwd: the CLI runs from the
32
+ // project root, from a worktree, and from a test harness.
33
+ return import(new URL(modulePath, import.meta.url).href);
34
+ };
35
+
36
+ export const COMMANDS = ['next', 'list', 'hygiene'];
37
+
38
+ /**
39
+ * A missing config is the normal state of a fresh project. A config that exists
40
+ * and does not parse is NOT — it used to fall back to `plan-md` silently, so a
41
+ * trailing comma in `queue.json` made the loop read a different queue than the one
42
+ * configured, which is the exact failure this file's header refuses for adapters.
43
+ */
44
+ export const loadConfig = (configPath) => {
45
+ let raw;
46
+ try {
47
+ raw = readFileSync(configPath, 'utf8');
48
+ } catch {
49
+ return {};
50
+ }
51
+ try {
52
+ return JSON.parse(raw);
53
+ } catch (error) {
54
+ throw new Error(
55
+ `${configPath} exists but is not valid JSON, so the configured queue cannot be ` +
56
+ `read: ${String(error?.message ?? error).split('\n')[0]}. Fix the file — ` +
57
+ 'silently reading a different queue is worse than refusing to start.',
58
+ { cause: error },
59
+ );
60
+ }
61
+ };
62
+
63
+ const parseArgs = (argv) => {
64
+ const args = { command: argv[0] ?? 'next', json: false, config: null };
65
+ for (let i = 1; i < argv.length; i += 1) {
66
+ if (argv[i] === '--json') args.json = true;
67
+ else if (argv[i] === '--config') args.config = argv[++i];
68
+ }
69
+ return args;
70
+ };
71
+
72
+ const renderNext = (result, stop) => {
73
+ if (stop) {
74
+ const label = stop.kind.replaceAll('-', ' ');
75
+ return `queue: ${label}${stop.success ? '' : ' (needs attention)'}\n ${stop.why}\n`;
76
+ }
77
+ const lines = [`next: ${result.ticket.id} — ${result.ticket.title} [${result.ticket.tier}]`];
78
+ if (result.skipped.length > 0) {
79
+ lines.push('', 'skipped:');
80
+ for (const skip of result.skipped) lines.push(` ${skip.id} — ${skip.reason}`);
81
+ }
82
+ return `${lines.join('\n')}\n`;
83
+ };
84
+
85
+ /**
86
+ * Was this file invoked directly?
87
+ *
88
+ * Compared by REALPATH on both sides: ESM resolves `import.meta.url` through
89
+ * symlinks while `process.argv[1]` keeps the path as typed, so a project living
90
+ * under a symlinked directory (a macOS temp dir, a symlinked home, a checkout
91
+ * behind a link) would fail a naive equality check — and the script would exit 0
92
+ * having printed nothing, which reads exactly like "no findings".
93
+ */
94
+ const invokedDirectly = () => {
95
+ if (!process.argv[1]) return false;
96
+ const real = (p) => {
97
+ try {
98
+ return realpathSync(p);
99
+ } catch {
100
+ return p;
101
+ }
102
+ };
103
+ return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
104
+ };
105
+
106
+ if (invokedDirectly()) {
107
+ const args = parseArgs(process.argv.slice(2));
108
+ const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
109
+
110
+ // An unrecognised command used to behave as `next`, discarding its argument —
111
+ // so `claim 1` silently printed a selection and claimed nothing.
112
+ if (!COMMANDS.includes(args.command)) {
113
+ process.stderr.write(
114
+ `unknown command: ${args.command}. Known commands: ${COMMANDS.join(', ')}. ` +
115
+ 'The write operations (claim, close, comment, escalate, proposeTriage) are ' +
116
+ "the adapter's own API — import the adapter module rather than this CLI.\n",
117
+ );
118
+ process.exit(1);
119
+ }
120
+
121
+ let config;
122
+ let adapter;
123
+ try {
124
+ config = loadConfig(args.config ?? join(projectRoot, '.claude', 'queue.json'));
125
+ adapter = await resolveAdapter(config.adapter ?? 'plan-md');
126
+ } catch (error) {
127
+ process.stderr.write(`${error.message}\n`);
128
+ process.exit(1);
129
+ }
130
+
131
+ let tickets;
132
+ try {
133
+ // Awaited so an adapter may be async (jira) or plain (plan-md, github-issues)
134
+ // without the CLI caring which.
135
+ tickets = await adapter.listEligible(config.options ?? {});
136
+ } catch (error) {
137
+ // Never fall back to memory or to a stale copy for a queue.
138
+ const stop = stopConditionOf({ queueReadable: false });
139
+ process.stdout.write(
140
+ args.json
141
+ ? `${JSON.stringify({ stop, error: String(error.message ?? error) }, null, 2)}\n`
142
+ : `queue: ${stop.kind}\n ${stop.why}\n ${error.message ?? error}\n`,
143
+ );
144
+ process.exit(1);
145
+ }
146
+
147
+ if (args.command === 'hygiene') {
148
+ const findings = tickets.map(hygieneOf).filter(Boolean);
149
+ process.stdout.write(
150
+ args.json
151
+ ? `${JSON.stringify({ findings }, null, 2)}\n`
152
+ : findings.length === 0
153
+ ? `queue hygiene: ${tickets.length} item(s) checked — nothing stale.\n`
154
+ : `${findings.map((f) => ` [${f.kind}] ${f.id} — ${f.why}`).join('\n')}\n`,
155
+ );
156
+ process.exit(0);
157
+ }
158
+
159
+ const result = selectNext(tickets, {
160
+ lastCompletedTier: config.lastCompletedTier ?? null,
161
+ triggersFired: config.triggersFired ?? null,
162
+ });
163
+ const stop = result.ticket ? null : stopConditionOf({ candidates: 0 });
164
+
165
+ if (args.command === 'list') {
166
+ process.stdout.write(`${JSON.stringify({ tickets, ...result }, null, 2)}\n`);
167
+ process.exit(0);
168
+ }
169
+
170
+ process.stdout.write(
171
+ args.json
172
+ ? `${JSON.stringify({ ticket: result.ticket, skipped: result.skipped, stop }, null, 2)}\n`
173
+ : renderNext(result, stop),
174
+ );
175
+ }
@@ -0,0 +1,345 @@
1
+ // Queue adapter: Jira issues, via the REST API.
2
+ //
3
+ // The second adapter exists to prove the seam holds: everything about *selection*
4
+ // lives in `core.mjs` and is imported, not re-derived. An adapter that answers
5
+ // "is this takeable?" for itself is a second answer to the same question, and the
6
+ // two will disagree the first time one of them is edited.
7
+ //
8
+ // Credentials come from the environment and nowhere else:
9
+ //
10
+ // JIRA_BASE_URL https://your-site.atlassian.net
11
+ // JIRA_EMAIL the account the token belongs to
12
+ // JIRA_API_TOKEN an API token, never a password
13
+ //
14
+ // There is deliberately no default, no fallback and no example value: a
15
+ // placeholder that looks like a credential is a credential someone will commit.
16
+ // Configure the project in `.claude/queue.json`:
17
+ //
18
+ // { "adapter": "jira", "options": { "project": "ABC" } }
19
+ // { "adapter": "jira", "options": { "jql": "project = ABC AND ..." } }
20
+ import { duplicateOf, fingerprintOf, validateProposal } from './core.mjs';
21
+
22
+ export const name = 'jira';
23
+
24
+ /** Jira's own default priority ladder. An unrecognised name sorts last, never first. */
25
+ const PRIORITY = { highest: 1, high: 2, medium: 3, low: 4, lowest: 5 };
26
+
27
+ /**
28
+ * The link types that express a dependency. "relates to" and "duplicates" are
29
+ * neither, and treating them as blockers would stall the queue on commentary.
30
+ */
31
+ const BLOCKED_BY = /^(is blocked by|blocked by)$/i;
32
+ const BLOCKS = /^blocks$/i;
33
+
34
+ const statusCategory = (fields) => String(fields?.status?.statusCategory?.key ?? '').toLowerCase();
35
+
36
+ /** Jira timestamps use +0000 rather than Z; normalise so string compare sorts right. */
37
+ const toIso = (created) => {
38
+ if (!created) return null;
39
+ const parsed = new Date(created);
40
+ return Number.isNaN(parsed.getTime()) ? String(created) : parsed.toISOString();
41
+ };
42
+
43
+ /**
44
+ * Flatten an Atlassian-document description down to its text.
45
+ *
46
+ * Only the fingerprint line needs to be findable, so a recursive text harvest is
47
+ * enough — and it tolerates a plain-string description from an older API shape.
48
+ */
49
+ export const descriptionTextOf = (issue) => {
50
+ // Depth-capped: the document is written by whoever filed the issue, and an
51
+ // unbounded walk overflows the stack at ~10k levels — which would stop the loop
52
+ // filing or deduplicating any proposal at all.
53
+ const walk = (node, depth) => {
54
+ if (depth > 64) return '';
55
+ if (typeof node === 'string') return node;
56
+ if (Array.isArray(node)) return node.map((child) => walk(child, depth + 1)).join('\n');
57
+ if (node && typeof node === 'object') {
58
+ return [node.text ?? '', walk(node.content ?? [], depth + 1)].filter(Boolean).join('\n');
59
+ }
60
+ return '';
61
+ };
62
+ return walk(issue?.fields?.description ?? '', 0);
63
+ };
64
+
65
+ /** Map one Jira issue onto the neutral Ticket shape. */
66
+ export const toTicket = (issue) => {
67
+ const fields = issue?.fields ?? {};
68
+ const labels = fields.labels ?? [];
69
+ const links = fields.issuelinks ?? [];
70
+ const category = statusCategory(fields);
71
+
72
+ // 🔴 INVARIANT 1: the dependency is the LINK, and the blocker's own status
73
+ // decides. A `blocked` label is a snapshot nobody updates when the blocker
74
+ // lands; this is re-read from the blocker every time selection runs. A blocker
75
+ // whose status is not readable counts as unresolved — "could not look" is never
76
+ // "it is fine".
77
+ const blockedBy = links
78
+ .filter((link) => BLOCKED_BY.test(String(link?.type?.inward ?? '')) && link?.inwardIssue)
79
+ .map((link) => ({
80
+ id: link.inwardIssue.key,
81
+ resolved: statusCategory(link.inwardIssue.fields) === 'done',
82
+ }));
83
+
84
+ const blocks = links
85
+ .filter((link) => BLOCKS.test(String(link?.type?.outward ?? '')) && link?.outwardIssue)
86
+ .map((link) => link.outwardIssue.key);
87
+
88
+ return {
89
+ id: issue.key,
90
+ title: fields.summary ?? '',
91
+ url: issue.self ?? null,
92
+ state: category === 'done' ? 'closed' : category === 'indeterminate' ? 'in-progress' : 'open',
93
+ labels,
94
+ tier: labels.includes('human-review') ? 'elevated' : 'normal',
95
+ blockedBy,
96
+ blocks,
97
+ priority: PRIORITY[String(fields.priority?.name ?? '').toLowerCase()] ?? 999,
98
+ createdAt: toIso(fields.created),
99
+ triage: labels.includes('triage'),
100
+ trigger: labels.includes('trigger-auto')
101
+ ? 'auto'
102
+ : labels.includes('trigger-human')
103
+ ? 'human'
104
+ : null,
105
+ };
106
+ };
107
+
108
+ /**
109
+ * The selection query.
110
+ *
111
+ * `labels != triage` is belt and braces, and it is deliberate: excluding a
112
+ * proposal only by the absence of a ready marker means one careless hand adding
113
+ * that marker closes the loop's feedback path into its own input. Excluded
114
+ * explicitly, an item carrying BOTH markers is still unselectable.
115
+ *
116
+ * ⚠ JQL gotcha that makes the parenthesised form necessary: `labels != x` does
117
+ * **not** match issues whose labels field is empty. Without `OR labels IS EMPTY`
118
+ * this query would silently skip every unlabelled item — which is most of them.
119
+ */
120
+ export const buildJql = ({ project = null, jql = null } = {}) => {
121
+ if (jql) return jql;
122
+ if (!project) {
123
+ throw new Error(
124
+ 'the jira adapter needs either options.project or options.jql in ' +
125
+ '.claude/queue.json. It will not guess a project: reading the wrong queue ' +
126
+ 'is worse than refusing to start.',
127
+ );
128
+ }
129
+ return (
130
+ `project = ${project} AND statusCategory != Done ` +
131
+ 'AND (labels != triage OR labels IS EMPTY) ' +
132
+ 'ORDER BY priority DESC, created ASC'
133
+ );
134
+ };
135
+
136
+ export const requireCredentials = (env = process.env) => {
137
+ const missing = ['JIRA_BASE_URL', 'JIRA_EMAIL', 'JIRA_API_TOKEN'].filter((key) => !env[key]);
138
+ if (missing.length > 0) {
139
+ throw new Error(
140
+ `the jira adapter needs ${missing.join(', ')} in the environment. ` +
141
+ 'Set them in your shell or your secret manager — never in a file in this repo.',
142
+ );
143
+ }
144
+ const baseUrl = String(env.JIRA_BASE_URL).replace(/\/$/, '');
145
+ // Basic auth carries the token in a trivially reversible header, so the
146
+ // transport is part of the credential handling: a mis-set or tampered
147
+ // JIRA_BASE_URL over http would put it on the wire in clear.
148
+ if (!/^https:\/\//i.test(baseUrl)) {
149
+ throw new Error(
150
+ `JIRA_BASE_URL must use https (got ${baseUrl.split(':')[0]}://…). Basic auth ` +
151
+ 'sends the API token on every request; over http it is readable in transit.',
152
+ );
153
+ }
154
+ return { baseUrl, email: env.JIRA_EMAIL, token: env.JIRA_API_TOKEN };
155
+ };
156
+
157
+ const request = async (route, { method = 'GET', body = null, env = process.env } = {}) => {
158
+ const { baseUrl, email, token } = requireCredentials(env);
159
+ const response = await fetch(`${baseUrl}${route}`, {
160
+ method,
161
+ headers: {
162
+ Authorization: `Basic ${Buffer.from(`${email}:${token}`).toString('base64')}`,
163
+ Accept: 'application/json',
164
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
165
+ },
166
+ ...(body ? { body: JSON.stringify(body) } : {}),
167
+ });
168
+ if (!response.ok) {
169
+ // The status alone; never echo the response body, which can carry the token
170
+ // back in an error envelope.
171
+ throw new Error(`jira ${method} ${route} failed: ${response.status} ${response.statusText}`);
172
+ }
173
+ return response.status === 204 ? null : response.json();
174
+ };
175
+
176
+ // `description` is requested because the triage dedupe matches the fingerprint
177
+ // inside it. Without it the dedupe silently never matched, so every "queue empty"
178
+ // stop filed a fresh issue instead of incrementing the one already there.
179
+ const FIELDS = 'summary,status,labels,priority,created,issuelinks,description';
180
+
181
+ // --- the adapter contract ------------------------------------------------------
182
+
183
+ /**
184
+ * Query fresh every time — the queue changes as the loop closes items and
185
+ * unblocks their dependents, so a list read at the start of a run is wrong by the
186
+ * second task. `issues` is the offline seam the tests use.
187
+ */
188
+ export const listEligible = async ({
189
+ issues = null,
190
+ project = null,
191
+ jql = null,
192
+ limit = 100,
193
+ env = process.env,
194
+ } = {}) => {
195
+ // `issues` is the offline seam: the mapping is pure, so every shape it has to
196
+ // handle is testable without a network or a credential.
197
+ const response = issues ? { issues } : await search({ project, jql, limit, env });
198
+ return response.issues.map(toTicket).filter((ticket) => ticket.state !== 'closed');
199
+ };
200
+
201
+ export const search = async ({ project = null, jql = null, limit = 100, env = process.env } = {}) =>
202
+ request(
203
+ `/rest/api/3/search?jql=${encodeURIComponent(buildJql({ project, jql }))}` +
204
+ `&maxResults=${limit}&fields=${FIELDS}`,
205
+ { env },
206
+ );
207
+
208
+ export const resolveBlockers = (ticket) => (ticket.blockedBy ?? []).filter((b) => !b.resolved);
209
+
210
+ /**
211
+ * Claim it before the first file is edited, not when the PR opens: an item being
212
+ * worked while it still reads as available is invisible to the human and
213
+ * re-selectable by the very next query.
214
+ */
215
+ export const claim = async (ticket, { transitionId = null, env = process.env } = {}) => {
216
+ if (!transitionId) {
217
+ const available = await request(`/rest/api/3/issue/${ticket.id}/transitions`, { env });
218
+ const target = available.transitions.find(
219
+ (transition) => statusCategory(transition.to ? { status: transition.to } : {}) === 'indeterminate',
220
+ );
221
+ if (!target) {
222
+ throw new Error(
223
+ `no in-progress transition available for ${ticket.id} — the board's workflow ` +
224
+ 'differs from the default. Pass options.transitionId.',
225
+ );
226
+ }
227
+ transitionId = target.id;
228
+ }
229
+ await request(`/rest/api/3/issue/${ticket.id}/transitions`, {
230
+ method: 'POST',
231
+ body: { transition: { id: transitionId } },
232
+ env,
233
+ });
234
+ return { ok: true };
235
+ };
236
+
237
+ export const comment = async (ticket, body, { env = process.env } = {}) => {
238
+ await request(`/rest/api/3/issue/${ticket.id}/comment`, {
239
+ method: 'POST',
240
+ body: {
241
+ body: { type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text: body }] }] },
242
+ },
243
+ env,
244
+ });
245
+ return { ok: true };
246
+ };
247
+
248
+ export const close = async (ticket, { prUrl = null, transitionId = null, env = process.env } = {}) => {
249
+ await comment(ticket, prUrl ? `Landed in ${prUrl}.` : 'Closed by the run.', { env });
250
+ if (transitionId) {
251
+ await request(`/rest/api/3/issue/${ticket.id}/transitions`, {
252
+ method: 'POST',
253
+ body: { transition: { id: transitionId } },
254
+ env,
255
+ });
256
+ }
257
+ return { ok: true, transitioned: Boolean(transitionId) };
258
+ };
259
+
260
+ /**
261
+ * Escalate: the diagnosis goes on the item and the item is labelled so the next
262
+ * selection cannot pick it up. It stays IN PROGRESS on purpose — moving it back to
263
+ * a selectable state is how one stuck task gets worked three times.
264
+ */
265
+ export const escalate = async (ticket, diagnosis, { env = process.env } = {}) => {
266
+ await comment(ticket, diagnosis, { env });
267
+ await request(`/rest/api/3/issue/${ticket.id}`, {
268
+ method: 'PUT',
269
+ body: { update: { labels: [{ add: 'escalated' }] } },
270
+ env,
271
+ });
272
+ return { ok: true };
273
+ };
274
+
275
+ /**
276
+ * 🔴 INVARIANT 2: the agent never creates its own work. A proposal is labelled
277
+ * `triage`, which `buildJql` excludes explicitly, and it never receives a ready
278
+ * marker — so the only route from proposal to work runs through a human.
279
+ */
280
+ export const triageItemFor = (proposal) => {
281
+ validateProposal(proposal);
282
+ const fingerprint = fingerprintOf(proposal);
283
+ return {
284
+ title: `proposal: ${proposal.change}`,
285
+ body: [
286
+ `- finding — ${proposal.finding}`,
287
+ `- part to change — ${proposal.part}`,
288
+ `- proposed change — ${proposal.change}`,
289
+ `- how the next run proves it — ${proposal.proof}`,
290
+ '',
291
+ `fingerprint: ${fingerprint}`,
292
+ '',
293
+ 'The loop proposes; the owner patches. Self-applying a change to its own',
294
+ 'rulebook is how an unattended run drifts irreversibly.',
295
+ ].join('\n'),
296
+ labels: ['triage'],
297
+ selectable: false,
298
+ fingerprint,
299
+ };
300
+ };
301
+
302
+ /** File the proposal, or increment the one already carrying this fingerprint. */
303
+ export const proposeTriage = async (
304
+ proposal,
305
+ { project = null, existing = null, env = process.env } = {},
306
+ ) => {
307
+ const item = triageItemFor(proposal);
308
+ // Compared against the issue's DESCRIPTION, which is where the fingerprint was
309
+ // written. The previous version mapped candidates through `toTicket` — which
310
+ // emits no body at all — so the predicate was always false and twenty identical
311
+ // stops filed twenty issues against the tracker.
312
+ const found =
313
+ existing ??
314
+ (await search({ jql: 'labels = triage ORDER BY created DESC', env })).issues.map((issue) => ({
315
+ id: issue.key,
316
+ body: descriptionTextOf(issue),
317
+ }));
318
+ const duplicate = duplicateOf(item, found);
319
+
320
+ if (duplicate) {
321
+ await comment(duplicate, `Seen again (fingerprint ${item.fingerprint}). Incrementing.`, { env });
322
+ return { ok: true, incremented: duplicate.id, item };
323
+ }
324
+ if (!project) {
325
+ throw new Error('filing a triage proposal needs options.project');
326
+ }
327
+ await request('/rest/api/3/issue', {
328
+ method: 'POST',
329
+ body: {
330
+ fields: {
331
+ project: { key: project },
332
+ summary: item.title,
333
+ issuetype: { name: 'Task' },
334
+ labels: item.labels,
335
+ description: {
336
+ type: 'doc',
337
+ version: 1,
338
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: item.body }] }],
339
+ },
340
+ },
341
+ },
342
+ env,
343
+ });
344
+ return { ok: true, filed: item.title, item };
345
+ };