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.
- package/CHANGELOG.md +170 -0
- package/README.md +66 -10
- package/package.json +9 -2
- package/packages/cli/dist/commands/init.js +73 -18
- package/packages/cli/dist/index.js +11 -1
- package/packages/cli/dist/lib/init-settings.js +52 -0
- package/packages/cli/dist/lib/summary.js +19 -5
- package/packages/cli/dist/templates.js +8 -0
- package/templates/agent-os/init/CLAUDE.md +133 -0
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +46 -0
- package/templates/agent-os/stack/aws-cdk/.claude/skills/ro-debug/SKILL.md +117 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +1 -1
- package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +12 -2
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +808 -0
- package/templates/agent-os/universal/.claude/queue.json +3 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +43 -0
- package/templates/agent-os/universal/.claude/rules/invariants.md +170 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +489 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +161 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +305 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +231 -0
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +175 -0
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +345 -0
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +239 -0
- package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +280 -0
- package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +62 -0
- package/templates/agent-os/universal/.claude/settings.json +4 -0
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +297 -40
- package/templates/agent-os/universal/.claude/skills/new-invariant/SKILL.md +102 -0
- package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.mjs +78 -0
- package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
- package/templates/agent-os/universal/.claude/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/CLAUDE.md +57 -7
- package/templates/agent-os/universal/PLAN.md +28 -2
- package/templates/agent-os/universal/layers.json +20 -1
- package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +6 -1
- package/templates/skeleton/aws-serverless/gitignore +8 -0
- package/templates/skeleton/node-service/.github/workflows/ci.yml +6 -1
- 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
|
+
};
|