create-agent-rig 0.2.0 → 0.3.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/CHANGELOG.md +134 -0
- package/README.md +57 -9
- package/package.json +9 -2
- package/packages/cli/dist/lib/summary.js +19 -5
- 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/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 +169 -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,305 @@
|
|
|
1
|
+
// The queue seam — everything ABOVE it.
|
|
2
|
+
//
|
|
3
|
+
// Selection, blocker resolution, the tier ration, the sort and the stop
|
|
4
|
+
// conditions are domain-free: they are the same whether the queue lives in
|
|
5
|
+
// PLAN.md, in an issue tracker or in a spreadsheet. This file holds all of it and
|
|
6
|
+
// touches nothing outside itself — no I/O, no clock, no network — so it is
|
|
7
|
+
// exhaustively testable and identical for every adapter.
|
|
8
|
+
//
|
|
9
|
+
// Below the seam sits one adapter per tracker (`plan-md.mjs`,
|
|
10
|
+
// `github-issues.mjs`, …), whose only job is to map that tracker's records onto
|
|
11
|
+
// the neutral Ticket shape below and to perform the six write operations.
|
|
12
|
+
//
|
|
13
|
+
// A Ticket:
|
|
14
|
+
// {
|
|
15
|
+
// id, title, url,
|
|
16
|
+
// state: 'open' | 'in-progress' | 'closed',
|
|
17
|
+
// labels: string[], // informational, never decisive
|
|
18
|
+
// tier: 'normal' | 'elevated',
|
|
19
|
+
// blockedBy: [{ id, resolved }], // FROM LINKS — see invariant 1
|
|
20
|
+
// blocks: string[], // ids this one unblocks
|
|
21
|
+
// priority: number, // lower is more urgent
|
|
22
|
+
// createdAt: ISO string | null,
|
|
23
|
+
// triage: boolean, // a proposal — never selectable
|
|
24
|
+
// trigger: 'auto' | 'human' | null, // null means unconditional
|
|
25
|
+
// }
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The operations every adapter provides. A second tracker is an adapter, not a
|
|
29
|
+
* rewrite — and this list is what "an adapter" means, mechanically.
|
|
30
|
+
*/
|
|
31
|
+
export const ADAPTER_CONTRACT = [
|
|
32
|
+
'listEligible',
|
|
33
|
+
'resolveBlockers',
|
|
34
|
+
'claim',
|
|
35
|
+
'close',
|
|
36
|
+
'comment',
|
|
37
|
+
'escalate',
|
|
38
|
+
'proposeTriage',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Is this item takeable, and if not, why not?
|
|
43
|
+
*
|
|
44
|
+
* The filters run in order and every rejection carries a reason: an unexplained
|
|
45
|
+
* skip is indistinguishable from a bug in the filter.
|
|
46
|
+
*/
|
|
47
|
+
export const selectionOf = (ticket, { triggersFired = null } = {}) => {
|
|
48
|
+
const reasons = [];
|
|
49
|
+
const labels = ticket.labels ?? [];
|
|
50
|
+
|
|
51
|
+
if (ticket.state === 'closed') reasons.push('already closed');
|
|
52
|
+
if (ticket.state === 'in-progress') {
|
|
53
|
+
reasons.push('already in progress — another session may be on it');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Belt and braces, and deliberately so. A triage item is a proposal the loop
|
|
57
|
+
// itself wrote; excluding it only by the ABSENCE of a ready marker would mean
|
|
58
|
+
// one careless hand adding that marker closes the loop's feedback path into its
|
|
59
|
+
// own input — the exact circuit the firewall exists to break.
|
|
60
|
+
if (ticket.triage || labels.includes('triage')) {
|
|
61
|
+
reasons.push('a triage proposal: promotion to work is a human act');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (labels.includes('escalated')) {
|
|
65
|
+
reasons.push('escalated — it is waiting on a human, not on another attempt');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 🔴 INVARIANT 1: blockers resolve from LINKS, never from labels.
|
|
69
|
+
//
|
|
70
|
+
// A `ready`/`blocked` label is a hand-maintained snapshot; the links are the
|
|
71
|
+
// dependency. This matters most in continuous mode, because the loop is what
|
|
72
|
+
// closes the blockers — and nothing updates a dependent's label when its
|
|
73
|
+
// blocker lands. A label-driven loop stalls on work it just unblocked itself,
|
|
74
|
+
// and takes work whose blocker is still open. Both directions have been seen.
|
|
75
|
+
const open = (ticket.blockedBy ?? []).filter((blocker) => !blocker.resolved);
|
|
76
|
+
if (open.length > 0) {
|
|
77
|
+
reasons.push(`blocked by ${open.map((b) => b.id).join(', ')} (from links, not labels)`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// No trigger label means unconditional, not missing data. Work that is
|
|
81
|
+
// genuinely conditional says so.
|
|
82
|
+
if (ticket.trigger === 'human') {
|
|
83
|
+
reasons.push(
|
|
84
|
+
'trigger-human: a window, a demand or a "pass" is a human declaration — ' +
|
|
85
|
+
'never self-taken, only handed over explicitly',
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (ticket.trigger === 'auto') {
|
|
89
|
+
const fired = triggersFired?.[ticket.id];
|
|
90
|
+
if (fired !== true) {
|
|
91
|
+
reasons.push(
|
|
92
|
+
fired === undefined
|
|
93
|
+
? 'trigger-auto with no verification of the trigger this run — ' +
|
|
94
|
+
'unverified is not fired'
|
|
95
|
+
: 'trigger-auto and the trigger has not fired',
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { eligible: reasons.length === 0, reasons };
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The queue-hygiene finding for this item, or null.
|
|
105
|
+
*
|
|
106
|
+
* Reported, never silently corrected: a loop that quietly rewrites the queue's
|
|
107
|
+
* own metadata removes the evidence that the metadata is unreliable.
|
|
108
|
+
*/
|
|
109
|
+
export const hygieneOf = (ticket) => {
|
|
110
|
+
const labels = ticket.labels ?? [];
|
|
111
|
+
const open = (ticket.blockedBy ?? []).filter((blocker) => !blocker.resolved);
|
|
112
|
+
|
|
113
|
+
if (labels.includes('blocked') && open.length === 0) {
|
|
114
|
+
return {
|
|
115
|
+
kind: 'stale-blocked-label',
|
|
116
|
+
id: ticket.id,
|
|
117
|
+
why:
|
|
118
|
+
(ticket.blockedBy ?? []).length === 0
|
|
119
|
+
? 'labelled blocked with no blocker links at all — a data bug, not a dependency'
|
|
120
|
+
: 'labelled blocked, but every blocker it links to is resolved',
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (labels.includes('ready') && open.length > 0) {
|
|
124
|
+
return {
|
|
125
|
+
kind: 'stale-ready-label',
|
|
126
|
+
id: ticket.id,
|
|
127
|
+
why: `labelled ready while ${open.map((b) => b.id).join(', ')} still blocks it`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The sort among survivors.
|
|
135
|
+
*
|
|
136
|
+
* Unblocking the queue comes first: it keeps the loop fed, which is the whole
|
|
137
|
+
* point of running one. Then the tracker's priority, then creation order — the
|
|
138
|
+
* last tiebreak exists so selection is deterministic rather than incidental.
|
|
139
|
+
*/
|
|
140
|
+
export const sortCandidates = (tickets) =>
|
|
141
|
+
[...tickets].sort((a, b) => {
|
|
142
|
+
const unblocks = (t) => ((t.blocks ?? []).length > 0 ? 0 : 1);
|
|
143
|
+
if (unblocks(a) !== unblocks(b)) return unblocks(a) - unblocks(b);
|
|
144
|
+
if ((a.priority ?? 999) !== (b.priority ?? 999)) return (a.priority ?? 999) - (b.priority ?? 999);
|
|
145
|
+
return String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? ''));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Pick the next item, or explain why nothing was taken.
|
|
150
|
+
*
|
|
151
|
+
* The elevated tier is rationed by **spacing, not counting**: a per-run count is
|
|
152
|
+
* meaningless when the run has no end. Never two elevated items back to back —
|
|
153
|
+
* one unreviewed schema or permissions change is recoverable; a chain of them
|
|
154
|
+
* compounding overnight is not.
|
|
155
|
+
*/
|
|
156
|
+
export const selectNext = (tickets, { lastCompletedTier = null, triggersFired = null } = {}) => {
|
|
157
|
+
const skipped = [];
|
|
158
|
+
const candidates = [];
|
|
159
|
+
|
|
160
|
+
for (const ticket of tickets) {
|
|
161
|
+
const selection = selectionOf(ticket, { triggersFired });
|
|
162
|
+
if (!selection.eligible) {
|
|
163
|
+
skipped.push({ id: ticket.id, reason: selection.reasons.join('; ') });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (ticket.tier === 'elevated' && lastCompletedTier === 'elevated') {
|
|
167
|
+
skipped.push({
|
|
168
|
+
id: ticket.id,
|
|
169
|
+
reason:
|
|
170
|
+
'elevated, and the last completed item was elevated too — never two back ' +
|
|
171
|
+
'to back. Land a normal item on a healthy runtime first.',
|
|
172
|
+
});
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
candidates.push(ticket);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const [ticket = null] = sortCandidates(candidates);
|
|
179
|
+
return { ticket, skipped, candidates: candidates.length };
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Should the whole run stop? Checked in severity order, because a regression must
|
|
184
|
+
* not be reported as an empty queue.
|
|
185
|
+
*
|
|
186
|
+
* A per-task stop (three strikes, invariant conflict, a blocking reviewer
|
|
187
|
+
* verdict) does NOT end the run — escalate that item and take the next one. Only
|
|
188
|
+
* the conditions here end it.
|
|
189
|
+
*/
|
|
190
|
+
export const stopConditionOf = ({
|
|
191
|
+
candidates = 0,
|
|
192
|
+
lastDeployVerdict = null,
|
|
193
|
+
consecutiveEscalations = 0,
|
|
194
|
+
killSwitch = false,
|
|
195
|
+
budgetExhausted = false,
|
|
196
|
+
queueReadable = true,
|
|
197
|
+
} = {}) => {
|
|
198
|
+
if (!queueReadable) {
|
|
199
|
+
return {
|
|
200
|
+
kind: 'queue-unreadable',
|
|
201
|
+
success: false,
|
|
202
|
+
why:
|
|
203
|
+
'the queue could not be read. Stop and say so — never fall back to memory ' +
|
|
204
|
+
'or to a stale copy for a queue; a remembered queue is how a loop works on ' +
|
|
205
|
+
'items that no longer exist.',
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (lastDeployVerdict === 'REGRESSION') {
|
|
209
|
+
return {
|
|
210
|
+
kind: 'runtime-regression',
|
|
211
|
+
success: false,
|
|
212
|
+
why:
|
|
213
|
+
'the deployed surface came back unhealthy. Deploy the revert first, ' +
|
|
214
|
+
'diagnose second, and start no new work on top of it — a regression ' +
|
|
215
|
+
'compounds into everything built above it.',
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
if (killSwitch) {
|
|
219
|
+
return {
|
|
220
|
+
kind: 'kill-switch',
|
|
221
|
+
success: true,
|
|
222
|
+
why:
|
|
223
|
+
'the kill switch is set. Stop at the current task boundary: finish it, ' +
|
|
224
|
+
'push the branch, open the PR, write the journal entry, exit. Losing ' +
|
|
225
|
+
'in-flight work is not what stopping cleanly means.',
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
if (consecutiveEscalations >= 2) {
|
|
229
|
+
return {
|
|
230
|
+
kind: 'repeated-escalation',
|
|
231
|
+
success: false,
|
|
232
|
+
why:
|
|
233
|
+
'two tasks in a row hit a wall, so the third likely will too — the wall is ' +
|
|
234
|
+
'systemic rather than task-local. This is the main guard against grinding a ' +
|
|
235
|
+
'broken assumption for hours.',
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
if (budgetExhausted) {
|
|
239
|
+
return {
|
|
240
|
+
kind: 'budget',
|
|
241
|
+
success: true,
|
|
242
|
+
why:
|
|
243
|
+
'the declared budget cannot plausibly fit another task. Stop now rather ' +
|
|
244
|
+
'than starting something that will be abandoned half-done.',
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
if (candidates === 0) {
|
|
248
|
+
return {
|
|
249
|
+
kind: 'queue-empty',
|
|
250
|
+
success: true,
|
|
251
|
+
why:
|
|
252
|
+
'no item survives the filters. This is a legitimate end of session, not an ' +
|
|
253
|
+
'invitation to refactor: **do not invent work**. Refilling the queue is the ' +
|
|
254
|
+
"owner's job.",
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The stable fingerprint of an improvement proposal.
|
|
262
|
+
*
|
|
263
|
+
* Under a scheduler against a finite queue the most common stop is "queue empty";
|
|
264
|
+
* twenty such stops must produce ONE proposal with a count of twenty, not twenty
|
|
265
|
+
* proposals. Dedupe by fingerprint, then increment.
|
|
266
|
+
*/
|
|
267
|
+
export const fingerprintOf = ({ finding, part, change }) =>
|
|
268
|
+
[finding, part, change]
|
|
269
|
+
.map((piece) =>
|
|
270
|
+
String(piece ?? '')
|
|
271
|
+
.toLowerCase()
|
|
272
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
273
|
+
.replace(/^-|-$/g, '')
|
|
274
|
+
.slice(0, 40),
|
|
275
|
+
)
|
|
276
|
+
.join(':');
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The already-filed proposal carrying this fingerprint, or null.
|
|
280
|
+
*
|
|
281
|
+
* Pure and shared by every adapter, so the dedupe DECISION is testable without a
|
|
282
|
+
* tracker, a credential or a network call — and so the three adapters cannot drift
|
|
283
|
+
* into three different answers. Candidates are `{ id, body }`.
|
|
284
|
+
*/
|
|
285
|
+
export const duplicateOf = (item, candidates = []) =>
|
|
286
|
+
(Array.isArray(candidates) ? candidates : []).find((candidate) =>
|
|
287
|
+
String(candidate?.body ?? '').includes(item.fingerprint),
|
|
288
|
+
) ?? null;
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The four parts a proposal must name. A proposal missing any of them is not
|
|
292
|
+
* ready to file — and the cap of three elsewhere is the mechanism, not a budget:
|
|
293
|
+
* an unbounded improvement list is another diary, and three forces a choice.
|
|
294
|
+
*/
|
|
295
|
+
export const validateProposal = (proposal) => {
|
|
296
|
+
const missing = ['finding', 'part', 'change', 'proof'].filter((key) => !proposal?.[key]);
|
|
297
|
+
if (missing.length > 0) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
`a proposal must name all four parts; missing: ${missing.join(', ')}. ` +
|
|
300
|
+
'(finding it came from, part to change, the change itself, and how the next ' +
|
|
301
|
+
'run would prove it worked)',
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
return proposal;
|
|
305
|
+
};
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// Queue adapter: issues in the project's own repository, via the `gh` CLI.
|
|
2
|
+
//
|
|
3
|
+
// The upgrade from `plan-md` once a project has a remote: issues carry per-item
|
|
4
|
+
// state, a comment thread, labels and — crucially — a dependency that can be
|
|
5
|
+
// written down and then READ FRESH, which is what invariant 1 needs.
|
|
6
|
+
//
|
|
7
|
+
// How a dependency is expressed here: a line in the issue body naming the blocker.
|
|
8
|
+
//
|
|
9
|
+
// Blocked by #7
|
|
10
|
+
// Depends on #9
|
|
11
|
+
//
|
|
12
|
+
// That is a **link, not a label**: the line names the blocker, and the blocker's
|
|
13
|
+
// own current state decides whether this item is takeable. A `blocked` label would
|
|
14
|
+
// be a snapshot that nothing updates when the blocker lands; this cannot go stale,
|
|
15
|
+
// because it is re-resolved from the blockers themselves on every selection.
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
import { duplicateOf, fingerprintOf, validateProposal } from './core.mjs';
|
|
18
|
+
|
|
19
|
+
export const name = 'github-issues';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A dependency line, and everything after the keyword on it.
|
|
23
|
+
*
|
|
24
|
+
* Two defects fixed here at once. It used to anchor `$` right after the number,
|
|
25
|
+
* so `Blocked by #7, waiting on design` matched nothing and the dependent read as
|
|
26
|
+
* unblocked — a silent miss, not a safe failure. And `\s*:?\s*` put two unbounded
|
|
27
|
+
* quantifiers side by side, which backtracks quadratically: a 64k issue body that
|
|
28
|
+
* matched the keyword but never reached a `#` cost ~13s, and the queue is re-read
|
|
29
|
+
* on every task, so anyone able to open an issue could tax every selection.
|
|
30
|
+
*/
|
|
31
|
+
const BLOCKED_BY = /^[ \t]*(?:blocked by|depends on|blocker)[ \t:]*(.*)$/gim;
|
|
32
|
+
const ISSUE_REF = /#(\d+)/g;
|
|
33
|
+
const PRIORITY = /^(?:priority[:-]|p)(\d+)$/i;
|
|
34
|
+
|
|
35
|
+
const labelNames = (issue) =>
|
|
36
|
+
(issue?.labels ?? []).map((label) => (typeof label === 'string' ? label : (label?.name ?? '')));
|
|
37
|
+
|
|
38
|
+
/** The blocker ids this issue's body links to — several per line is fine. */
|
|
39
|
+
export const blockerIdsOf = (issue) => {
|
|
40
|
+
const ids = [];
|
|
41
|
+
for (const line of String(issue?.body ?? '').matchAll(BLOCKED_BY)) {
|
|
42
|
+
for (const ref of String(line[1] ?? '').matchAll(ISSUE_REF)) ids.push(ref[1]);
|
|
43
|
+
}
|
|
44
|
+
return [...new Set(ids)];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Map one issue onto the neutral Ticket shape.
|
|
49
|
+
*
|
|
50
|
+
* `states` maps a blocker id to its state, as read in the same pass. A blocker
|
|
51
|
+
* whose state is not in the map counts as UNRESOLVED: "I could not look" must
|
|
52
|
+
* never resolve to "it is fine".
|
|
53
|
+
*/
|
|
54
|
+
export const toTicket = (issue, states = {}) => {
|
|
55
|
+
const labels = labelNames(issue);
|
|
56
|
+
const priorityLabel = labels.map((label) => PRIORITY.exec(label)).find(Boolean);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
id: String(issue.number),
|
|
60
|
+
title: issue.title,
|
|
61
|
+
url: issue.url ?? null,
|
|
62
|
+
state:
|
|
63
|
+
String(issue.state ?? '').toUpperCase() === 'CLOSED'
|
|
64
|
+
? 'closed'
|
|
65
|
+
: labels.includes('in-progress')
|
|
66
|
+
? 'in-progress'
|
|
67
|
+
: 'open',
|
|
68
|
+
labels,
|
|
69
|
+
tier: labels.includes('human-review') ? 'elevated' : 'normal',
|
|
70
|
+
blockedBy: blockerIdsOf(issue).map((id) => ({
|
|
71
|
+
id,
|
|
72
|
+
resolved: String(states[id] ?? '').toUpperCase() === 'CLOSED',
|
|
73
|
+
})),
|
|
74
|
+
blocks: [],
|
|
75
|
+
priority: priorityLabel ? Number(priorityLabel[1]) : 999,
|
|
76
|
+
createdAt: issue.createdAt ?? null,
|
|
77
|
+
triage: labels.includes('triage'),
|
|
78
|
+
trigger: labels.includes('trigger-auto')
|
|
79
|
+
? 'auto'
|
|
80
|
+
: labels.includes('trigger-human')
|
|
81
|
+
? 'human'
|
|
82
|
+
: null,
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* blocker id → the ids it blocks. Feeds the sort rule that puts an item which
|
|
88
|
+
* unblocks others first: unblocking the queue is what keeps the loop fed.
|
|
89
|
+
*/
|
|
90
|
+
export const blocksIndex = (issues) => {
|
|
91
|
+
const index = {};
|
|
92
|
+
for (const issue of issues) {
|
|
93
|
+
for (const blockerId of blockerIdsOf(issue)) {
|
|
94
|
+
(index[blockerId] ??= []).push(String(issue.number));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return index;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Run `gh` and return its raw output.
|
|
102
|
+
*
|
|
103
|
+
* The write commands — `issue edit|close|comment|create` — have **no `--json`
|
|
104
|
+
* flag**; they print plain text (an issue URL, or `✓ Closed issue #12`). Parsing
|
|
105
|
+
* that as JSON threw *after* the mutation had already been applied, which was the
|
|
106
|
+
* worst possible shape of failure: `escalate()` posted its diagnosis and then died
|
|
107
|
+
* before adding the label that keeps the item out of the next selection, so the
|
|
108
|
+
* loop re-picked the stuck task — the exact thing this adapter documents as
|
|
109
|
+
* prevented. So JSON parsing now happens only where JSON is actually produced.
|
|
110
|
+
*/
|
|
111
|
+
const ghText = (args) =>
|
|
112
|
+
execFileSync('gh', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
113
|
+
|
|
114
|
+
const ghJson = (args) => JSON.parse(ghText(args));
|
|
115
|
+
|
|
116
|
+
const FIELDS = 'number,title,body,state,labels,url,createdAt';
|
|
117
|
+
|
|
118
|
+
// --- the adapter contract ------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Every open issue, mapped and cross-linked.
|
|
122
|
+
*
|
|
123
|
+
* Deliberately queries fresh on every call and never caches: the queue changes as
|
|
124
|
+
* the loop itself closes items and unblocks their dependents.
|
|
125
|
+
*/
|
|
126
|
+
export const listEligible = ({ limit = 100, issues = null } = {}) => {
|
|
127
|
+
const raw =
|
|
128
|
+
issues ?? ghJson(['issue', 'list', '--state', 'all', '--limit', String(limit), '--json', FIELDS]);
|
|
129
|
+
const states = Object.fromEntries(raw.map((issue) => [String(issue.number), issue.state]));
|
|
130
|
+
const blocks = blocksIndex(raw);
|
|
131
|
+
return raw
|
|
132
|
+
.filter((issue) => String(issue.state ?? '').toUpperCase() !== 'CLOSED')
|
|
133
|
+
.map((issue) => {
|
|
134
|
+
const ticket = toTicket(issue, states);
|
|
135
|
+
return { ...ticket, blocks: blocks[ticket.id] ?? [] };
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export const resolveBlockers = (ticket) => (ticket.blockedBy ?? []).filter((b) => !b.resolved);
|
|
140
|
+
|
|
141
|
+
/** `To Do → In Progress` before the first file is edited, not when the PR opens. */
|
|
142
|
+
export const claim = (ticket) => {
|
|
143
|
+
ghText(['issue', 'edit', ticket.id, '--add-label', 'in-progress']);
|
|
144
|
+
return { ok: true };
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export const close = (ticket, { prUrl = null } = {}) => {
|
|
148
|
+
const note = prUrl ? `Landed in ${prUrl}.` : 'Closed by the run.';
|
|
149
|
+
ghText(['issue', 'comment', ticket.id, '--body', note]);
|
|
150
|
+
ghText(['issue', 'close', ticket.id]);
|
|
151
|
+
ghText(['issue', 'edit', ticket.id, '--remove-label', 'in-progress']);
|
|
152
|
+
return { ok: true };
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export const comment = (ticket, body) => {
|
|
156
|
+
ghText(['issue', 'comment', ticket.id, '--body', body]);
|
|
157
|
+
return { ok: true };
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Escalate: the diagnosis goes on the item, and the item is labelled so the next
|
|
162
|
+
* selection cannot pick it up again. It stays OPEN and stays claimed — moving it
|
|
163
|
+
* back to a selectable state is how one stuck task gets worked three times.
|
|
164
|
+
*/
|
|
165
|
+
export const escalate = (ticket, diagnosis) => {
|
|
166
|
+
ghText(['issue', 'comment', ticket.id, '--body', diagnosis]);
|
|
167
|
+
ghText(['issue', 'edit', ticket.id, '--add-label', 'escalated']);
|
|
168
|
+
return { ok: true };
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A proposal, forced into triage.
|
|
173
|
+
*
|
|
174
|
+
* 🔴 INVARIANT 2: the agent never creates its own work. `triage` is excluded from
|
|
175
|
+
* selection twice over (by the label filter and by `selectionOf`), and the item
|
|
176
|
+
* never gets a ready marker — so the only route from proposal to work runs through
|
|
177
|
+
* a human. Without that, a scheduler plus an improvement loop is a closed circuit.
|
|
178
|
+
*/
|
|
179
|
+
export const triageItemFor = (proposal) => {
|
|
180
|
+
validateProposal(proposal);
|
|
181
|
+
const fingerprint = fingerprintOf(proposal);
|
|
182
|
+
return {
|
|
183
|
+
title: `proposal: ${proposal.change}`,
|
|
184
|
+
body: [
|
|
185
|
+
`- **finding** — ${proposal.finding}`,
|
|
186
|
+
`- **part to change** — ${proposal.part}`,
|
|
187
|
+
`- **proposed change** — ${proposal.change}`,
|
|
188
|
+
`- **how the next run proves it** — ${proposal.proof}`,
|
|
189
|
+
'',
|
|
190
|
+
`fingerprint: ${fingerprint}`,
|
|
191
|
+
'',
|
|
192
|
+
'The loop proposes; the owner patches. Self-applying a change to its own',
|
|
193
|
+
'rulebook is how an unattended run drifts irreversibly — and it collides with',
|
|
194
|
+
'the rule that the agent authors no work for itself.',
|
|
195
|
+
].join('\n'),
|
|
196
|
+
labels: ['triage'],
|
|
197
|
+
selectable: false,
|
|
198
|
+
fingerprint,
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* File the proposal — or increment the one already there.
|
|
204
|
+
*
|
|
205
|
+
* Under a scheduler against a finite queue the most common stop is "queue empty";
|
|
206
|
+
* twenty such stops must produce one proposal with a count of twenty.
|
|
207
|
+
*/
|
|
208
|
+
export const proposeTriage = (proposal, { existing = null } = {}) => {
|
|
209
|
+
const item = triageItemFor(proposal);
|
|
210
|
+
const found =
|
|
211
|
+
existing ??
|
|
212
|
+
ghJson(['issue', 'list', '--label', 'triage', '--state', 'all', '--limit', '100', '--json', FIELDS]);
|
|
213
|
+
const duplicate = duplicateOf(
|
|
214
|
+
item,
|
|
215
|
+
found.map((issue) => ({ id: String(issue.number), body: issue.body })),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
if (duplicate) {
|
|
219
|
+
ghText([
|
|
220
|
+
'issue',
|
|
221
|
+
'comment',
|
|
222
|
+
duplicate.id,
|
|
223
|
+
'--body',
|
|
224
|
+
`Seen again this session (fingerprint \`${item.fingerprint}\`). Incrementing rather than filing a duplicate.`,
|
|
225
|
+
]);
|
|
226
|
+
return { ok: true, incremented: String(duplicate.number), item };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
ghText(['issue', 'create', '--title', item.title, '--body', item.body, '--label', 'triage']);
|
|
230
|
+
return { ok: true, filed: item.title, item };
|
|
231
|
+
};
|