create-agent-rig 0.4.0 → 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.
Files changed (118) hide show
  1. package/CHANGELOG.md +316 -11
  2. package/README.md +58 -19
  3. package/package.json +2 -1
  4. package/packages/cli/dist/commands/create.js +8 -3
  5. package/packages/cli/dist/commands/init.js +85 -34
  6. package/packages/cli/dist/commands/upgrade.js +112 -30
  7. package/packages/cli/dist/index.js +81 -19
  8. package/packages/cli/dist/lib/copy-tree.js +35 -6
  9. package/packages/cli/dist/lib/init-settings.js +12 -0
  10. package/packages/cli/dist/lib/install-set.js +6 -8
  11. package/packages/cli/dist/lib/manifest.js +31 -9
  12. package/packages/cli/dist/lib/safe-path.js +30 -0
  13. package/scripts/prepare.mjs +1 -1
  14. package/templates/agent-os/init/AGENTS.md +199 -0
  15. package/templates/agent-os/init/CLAUDE.md +69 -9
  16. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +105 -0
  17. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
  18. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +41 -3
  19. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
  20. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +41 -0
  21. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
  22. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
  23. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +236 -0
  24. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +958 -0
  25. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
  26. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
  27. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  28. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +396 -0
  29. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
  30. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +40 -1
  31. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +71 -1
  32. package/templates/agent-os/universal/.claude/agents/security-scanner.md +40 -0
  33. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +301 -26
  34. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
  35. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
  36. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
  37. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
  38. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
  39. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
  40. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +503 -0
  41. package/templates/agent-os/universal/.claude/rules/autonomy.md +69 -1
  42. package/templates/agent-os/universal/.claude/rules/invariants.md +104 -19
  43. package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
  44. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1260 -0
  45. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
  46. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
  47. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
  48. package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
  49. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
  50. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
  51. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +462 -0
  52. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +33 -17
  53. package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
  54. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +149 -0
  55. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +809 -28
  56. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
  57. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +95 -16
  58. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +543 -15
  59. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +457 -47
  60. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +289 -25
  61. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
  62. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
  63. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
  64. package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
  65. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +539 -0
  66. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
  67. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
  68. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +198 -0
  69. package/templates/agent-os/universal/.claude/settings.json +11 -2
  70. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +118 -7
  71. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +667 -38
  72. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +359 -26
  73. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
  74. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
  75. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
  76. package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
  77. package/templates/agent-os/universal/.codex/hooks.json +70 -0
  78. package/templates/agent-os/universal/AGENTS.md +166 -0
  79. package/templates/agent-os/universal/CLAUDE.md +49 -14
  80. package/templates/agent-os/universal/PLAN.md +7 -40
  81. package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
  82. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
  83. package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
  84. package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
  85. package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
  86. package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
  87. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
  88. package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
  89. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +74 -0
  90. package/templates/agent-os/universal/journal/README.md +101 -0
  91. package/templates/agent-os/universal/layers.json +45 -2
  92. package/templates/hash-history.json +310 -49
  93. package/templates/release-ledger.json +9 -0
  94. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
  95. package/templates/skeleton/aws-serverless/README.md +91 -9
  96. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
  97. package/templates/skeleton/aws-serverless/gitignore +37 -0
  98. package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
  99. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
  100. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
  101. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
  102. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
  103. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
  104. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
  105. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
  106. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
  107. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
  108. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
  109. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
  110. package/templates/skeleton/node-service/README.md +11 -1
  111. package/templates/skeleton/node-service/gitignore +34 -0
  112. package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
  113. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
  114. package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
  115. package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
  116. package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
  117. package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
  118. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
@@ -17,7 +17,14 @@
17
17
  //
18
18
  // { "adapter": "jira", "options": { "project": "ABC" } }
19
19
  // { "adapter": "jira", "options": { "jql": "project = ABC AND ..." } }
20
- import { duplicateOf, fingerprintOf, validateProposal } from './core.mjs';
20
+ // { "adapter": "jira", "options": { "project": "ABC", "owner": "my-repo" } }
21
+ //
22
+ // `owner` names this checkout for the `owner-<name>` label (AR-132): an item
23
+ // marked for another repository is held, and a checkout that declares no
24
+ // owner holds every marked item, since it cannot confirm a match.
25
+ import { duplicateOf, fingerprintOf, validateProposal, ownerOfLabels, lifecycleOf } from './core.mjs';
26
+ import { withAsOf } from './as-of.mjs';
27
+ import { recordEscalation, recordTakeUp } from '../run-state.mjs';
21
28
 
22
29
  export const name = 'jira';
23
30
 
@@ -91,11 +98,30 @@ export const toTicket = (issue) => {
91
98
  url: issue.self ?? null,
92
99
  state: category === 'done' ? 'closed' : category === 'indeterminate' ? 'in-progress' : 'open',
93
100
  labels,
94
- tier: labels.includes('human-review') ? 'elevated' : 'normal',
101
+ // The marker is `elevated`, the same word the plan-md adapter reads out of an
102
+ // `[elevated]` line — one name for one fact, so `core.mjs` rations the same
103
+ // way whichever tracker the item came from. It is deliberately NOT
104
+ // `human-review`: on a Jira board that is a workflow label meaning "a human
105
+ // is looking at it", which is a different claim entirely, and reading it as
106
+ // the tier rations the queue on a signal that means something else.
107
+ tier: labels.includes('elevated') ? 'elevated' : 'normal',
95
108
  blockedBy,
96
109
  blocks,
97
- priority: PRIORITY[String(fields.priority?.name ?? '').toLowerCase()] ?? 999,
110
+ // English names first; a localised board ("Höchste", "Mittel") falls back
111
+ // to the numeric priority id. Neither → 999.
112
+ priority:
113
+ PRIORITY[String(fields.priority?.name ?? '').toLowerCase()] ??
114
+ (Number.isFinite(Number(fields.priority?.id)) && Number(fields.priority?.id) > 0
115
+ ? Number(fields.priority?.id)
116
+ : 999),
98
117
  createdAt: toIso(fields.created),
118
+ // The take-up marker for revalidation at SELECT (`core.mjs` › revalidationOf):
119
+ // the tracker's own last-modified field. That it moves on every status
120
+ // change, edit and comment is Jira's contract, assumed and not checked
121
+ // here. `null` when the search did not carry it — never `''`, which would
122
+ // compare equal to itself and read as "unchanged" where the truth is "not
123
+ // looked".
124
+ updatedAt: toIso(fields.updated),
99
125
  // Flattened from the document description — the same text this adapter
100
126
  // already reads internally, now visible to the shared hygiene checks.
101
127
  body: descriptionTextOf(issue) || null,
@@ -105,23 +131,96 @@ export const toTicket = (issue) => {
105
131
  : labels.includes('trigger-human')
106
132
  ? 'human'
107
133
  : null,
134
+ // The repository this item belongs to (AR-132): `owner-<name>`, or null.
135
+ owner: ownerOfLabels(labels),
136
+ // The lifecycle and the scheduling flag (AR-144): `lifecycleOf` above the seam
137
+ // owns the semantics; this adapter only hands it the labels.
138
+ ...lifecycleOf(labels),
108
139
  };
109
140
  };
110
141
 
142
+ /**
143
+ * The lanes selection never takes, named once so the query and the post-filter
144
+ * below cannot drift apart (`invariants.md`: one mechanism, one implementation).
145
+ *
146
+ * - `triage` is the loop's own filed proposals. Excluding them only by the
147
+ * absence of a ready marker means one careless hand adding that marker closes
148
+ * the loop's feedback path into its own input.
149
+ * - `operator-queue` is the owner's lane. An item sitting there is work a HUMAN
150
+ * has taken, so the loop picking one up is two sessions on one task.
151
+ */
152
+ export const EXCLUDED_LABELS = ['triage', 'operator-queue'];
153
+
111
154
  /**
112
155
  * The selection query.
113
156
  *
114
- * `labels != triage` is belt and braces, and it is deliberate: excluding a
115
- * proposal only by the absence of a ready marker means one careless hand adding
116
- * that marker closes the loop's feedback path into its own input. Excluded
117
- * explicitly, an item carrying BOTH markers is still unselectable.
157
+ * Both lanes are excluded here by label, in the adapter's own filter rather than
158
+ * in an `options.jql` a reinstall would drop.
118
159
  *
119
160
  * ⚠ JQL gotcha that makes the parenthesised form necessary: `labels != x` does
120
161
  * **not** match issues whose labels field is empty. Without `OR labels IS EMPTY`
121
162
  * this query would silently skip every unlabelled item — which is most of them.
163
+ * Both exclusions therefore live INSIDE that one group; a second, unguarded
164
+ * exclusion anywhere in the query reintroduces the hole while still reading right.
165
+ *
166
+ * ⚠ And the group stays FLAT — `labels != a AND labels != b OR labels IS EMPTY`,
167
+ * not `labels NOT IN (a, b) OR …`. Both forms are valid JQL and both were run
168
+ * against a live Jira, returning identical result sets — so this is a
169
+ * readability choice with no behaviour attached, and switching back would be
170
+ * behaviour-neutral. Flat wins because the `NOT IN` form's nested parentheses
171
+ * hide the `IS EMPTY` guard from any reader — human or test — that matches
172
+ * innermost groups. `AND` binds tighter than `OR` in JQL, so the flat form means
173
+ * `(a AND b) OR empty`, which is the intent.
122
174
  */
175
+ /**
176
+ * A Jira project key: one uppercase letter, then up to nine of [A-Z0-9_].
177
+ * Exported so the refusal below can name the rule it applied (AR-51).
178
+ */
179
+ export const PROJECT_KEY = /^[A-Z][A-Z0-9_]{1,9}$/;
180
+
181
+ /**
182
+ * The one place `options.project` and `options.jql` from `.claude/queue.json`
183
+ * reach the query. Both used to be interpolated raw (AR-51): a committed
184
+ * `queue.json` — a file a pull request can edit — could make this adapter read
185
+ * another board, or anything the JQL grammar allows. A project key is now
186
+ * validated against PROJECT_KEY, and an explicit `jql` must begin with
187
+ * `project = <KEY>` — the same key when `options.project` is also given — so
188
+ * an override has to NAME this board. ⚠ Naming is not confinement: a query
189
+ * that leads with `project = AR` may still say `OR project = X` after it, and
190
+ * the credential's own scope is what bounds that. The residual is accepted
191
+ * because `.claude/queue.json` is part of the rulebook (`guard-rulebook`) and
192
+ * a declared elevated path, so a change widening it reaches the model lane.
193
+ */
194
+ /** The project key a config names — options.project, or the key options.jql leads with. */
195
+ export const projectKeyOf = ({ project = null, jql = null } = {}) => {
196
+ buildJql({ project, jql }); // the same refusals, once
197
+ if (project) return String(project);
198
+ return /^\s*project\s*=\s*([A-Z][A-Z0-9_]{1,9})\b/.exec(String(jql))[1];
199
+ };
200
+
123
201
  export const buildJql = ({ project = null, jql = null } = {}) => {
124
- if (jql) return jql;
202
+ if (project !== null && project !== undefined && !PROJECT_KEY.test(String(project))) {
203
+ throw new Error(
204
+ `options.project ${JSON.stringify(project)} is not a Jira project key — it must match ` +
205
+ `${PROJECT_KEY.source}. It is interpolated into JQL, so anything else is refused, not quoted.`,
206
+ );
207
+ }
208
+ if (jql) {
209
+ const lead = /^\s*project\s*=\s*([A-Z][A-Z0-9_]{1,9})\b/.exec(String(jql));
210
+ if (!lead) {
211
+ throw new Error(
212
+ 'options.jql must begin with `project = <KEY>` (a key matching ' +
213
+ `${PROJECT_KEY.source}) — a query that does not name its project can read any board.`,
214
+ );
215
+ }
216
+ if (project && lead[1] !== project) {
217
+ throw new Error(
218
+ `options.jql names project ${lead[1]} while options.project is ${project} — an override ` +
219
+ 'may narrow the query, never point it at another board.',
220
+ );
221
+ }
222
+ return jql;
223
+ }
125
224
  if (!project) {
126
225
  throw new Error(
127
226
  'the jira adapter needs either options.project or options.jql in ' +
@@ -129,9 +228,10 @@ export const buildJql = ({ project = null, jql = null } = {}) => {
129
228
  'is worse than refusing to start.',
130
229
  );
131
230
  }
231
+ const excluded = EXCLUDED_LABELS.map((label) => `labels != "${label}"`).join(' AND ');
132
232
  return (
133
233
  `project = ${project} AND statusCategory != Done ` +
134
- 'AND (labels != triage OR labels IS EMPTY) ' +
234
+ `AND (${excluded} OR labels IS EMPTY) ` +
135
235
  'ORDER BY priority DESC, created ASC'
136
236
  );
137
237
  };
@@ -157,36 +257,122 @@ export const requireCredentials = (env = process.env) => {
157
257
  return { baseUrl, email: env.JIRA_EMAIL, token: env.JIRA_API_TOKEN };
158
258
  };
159
259
 
160
- const request = async (route, { method = 'GET', body = null, env = process.env } = {}) => {
260
+ /** Statuses worth one more try: rate-limited, or a gateway that will be back. */
261
+ const TRANSIENT = new Set([429, 502, 503, 504]);
262
+ const MAX_ATTEMPTS = 4;
263
+ const DEFAULT_TIMEOUT_MS = 20_000;
264
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
265
+
266
+ /**
267
+ * The wait before the next attempt: `Retry-After` in seconds when the server
268
+ * names one, otherwise 500 ms doubling per attempt. Bounded by the attempt cap.
269
+ */
270
+ const MAX_RETRY_AFTER_MS = 60_000;
271
+ const retryDelayMs = (response, attempt) => {
272
+ // Seconds form only; the HTTP-date form is not parsed and falls to backoff
273
+ // (› "falls back to the backoff when Retry-After is an HTTP-date, which it
274
+ // does not parse"). Capped, because a header is input like any other:
275
+ // `Retry-After: 86400` must not sleep the loop for a day (› "caps Retry-After
276
+ // so a hostile header cannot sleep the loop for a day").
277
+ const header = Number(response?.headers?.get?.('Retry-After'));
278
+ if (Number.isFinite(header) && header > 0) return Math.min(header * 1000, MAX_RETRY_AFTER_MS);
279
+ return 500 * 2 ** (attempt - 1);
280
+ };
281
+
282
+ /**
283
+ * One HTTP call to Jira, with the three things AR-54 added to it:
284
+ *
285
+ * - a timeout (`timeoutMs`, default 20 s) through an AbortController — a stalled
286
+ * connection used to block selection forever, and a loop that cannot read its
287
+ * queue must stop, not hang;
288
+ * - a retry on 429/502/503/504, at most `MAX_ATTEMPTS`, honouring `Retry-After`;
289
+ * 401/403/404 and every other status fail at once — a bad credential is not
290
+ * transient, and retrying it only delays the diagnosis;
291
+ * - `retry.sleep` injectable, so a test measures the delay it would have waited
292
+ * instead of waiting it.
293
+ *
294
+ * Pinned in the generator's `test/template/queue-jira.test.ts` (absent in a
295
+ * generated rig) › "hands fetch an AbortSignal", › "rejects naming the timeout
296
+ * and the route when fetch never resolves", › "a 429 followed by a 200 yields
297
+ * the 200 body", › "sleeps for the Retry-After the 429 carried, in
298
+ * milliseconds", › "gives up after four consecutive 503s, naming the status and
299
+ * the attempts" and › "does not retry a 401 — a bad credential is not transient".
300
+ */
301
+ const request = async (
302
+ route,
303
+ { method = 'GET', body = null, env = process.env, timeoutMs = DEFAULT_TIMEOUT_MS, retry = {} } = {},
304
+ ) => {
161
305
  const { baseUrl, email, token } = requireCredentials(env);
162
- const response = await fetch(`${baseUrl}${route}`, {
163
- method,
164
- headers: {
165
- Authorization: `Basic ${Buffer.from(`${email}:${token}`).toString('base64')}`,
166
- Accept: 'application/json',
167
- ...(body ? { 'Content-Type': 'application/json' } : {}),
168
- },
169
- ...(body ? { body: JSON.stringify(body) } : {}),
170
- });
171
- if (!response.ok) {
306
+ const sleep = retry.sleep ?? defaultSleep;
307
+ for (let attempt = 1; ; attempt += 1) {
308
+ const controller = new AbortController();
309
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
310
+ let response;
311
+ let payload = null;
312
+ try {
313
+ response = await fetch(`${baseUrl}${route}`, {
314
+ method,
315
+ headers: {
316
+ Authorization: `Basic ${Buffer.from(`${email}:${token}`).toString('base64')}`,
317
+ Accept: 'application/json',
318
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
319
+ },
320
+ ...(body ? { body: JSON.stringify(body) } : {}),
321
+ signal: controller.signal,
322
+ });
323
+ // The body is read INSIDE the timed region: headers can arrive and the
324
+ // body then stall, which is the same hung connection with a 200 on it
325
+ // (› "keeps the timeout armed while the body is read").
326
+ if (response.ok && response.status !== 204) payload = await response.json();
327
+ } catch (error) {
328
+ if (error?.name === 'AbortError' || controller.signal.aborted) {
329
+ throw new Error(`jira ${method} ${route} timed out after ${timeoutMs} ms`, { cause: error });
330
+ }
331
+ throw error;
332
+ } finally {
333
+ clearTimeout(timer);
334
+ }
335
+ if (response.ok) return payload;
336
+ if (TRANSIENT.has(response.status) && attempt < MAX_ATTEMPTS) {
337
+ await sleep(retryDelayMs(response, attempt));
338
+ continue;
339
+ }
172
340
  // The status alone; never echo the response body, which can carry the token
173
341
  // back in an error envelope.
174
- throw new Error(`jira ${method} ${route} failed: ${response.status} ${response.statusText}`);
342
+ const attempts = TRANSIENT.has(response.status) ? ` after ${attempt} attempts` : '';
343
+ throw new Error(`jira ${method} ${route} failed: ${response.status} ${response.statusText}${attempts}`);
175
344
  }
176
- return response.status === 204 ? null : response.json();
177
345
  };
178
346
 
179
347
  // `description` is requested because the triage dedupe matches the fingerprint
180
348
  // inside it. Without it the dedupe silently never matched, so every "queue empty"
181
349
  // stop filed a fresh issue instead of incrementing the one already there.
182
- const FIELDS = 'summary,status,labels,priority,created,issuelinks,description';
350
+ //
351
+ // A LIST, not a comma-joined string: the replacement endpoint takes its arguments
352
+ // in a JSON body, where `fields` is an array. Measured, because the failure mode
353
+ // decides how hard this is to notice: sending the joined form the retired query
354
+ // parameter wanted answers `400 Invalid request payload`, not a 200 with empty
355
+ // fields. It fails loudly, so a wrong value here cannot sit undetected.
356
+ const FIELDS = [
357
+ 'summary',
358
+ 'status',
359
+ 'labels',
360
+ 'priority',
361
+ 'created',
362
+ 'updated',
363
+ 'issuelinks',
364
+ 'description',
365
+ ];
183
366
 
184
367
  // --- the adapter contract ------------------------------------------------------
185
368
 
186
369
  /**
187
370
  * Query fresh every time — the queue changes as the loop closes items and
188
371
  * unblocks their dependents, so a list read at the start of a run is wrong by the
189
- * second task. `issues` is the offline seam the tests use.
372
+ * second task. `issues` is the offline seam the tests use. Since AR-54 `limit`
373
+ * is the PAGE size, not a result cap: `search` walks every page up to its own
374
+ * `hardCap`, which this function leaves at the default (› "returns both pages
375
+ * as one list").
190
376
  */
191
377
  export const listEligible = async ({
192
378
  issues = null,
@@ -198,15 +384,127 @@ export const listEligible = async ({
198
384
  // `issues` is the offline seam: the mapping is pure, so every shape it has to
199
385
  // handle is testable without a network or a credential.
200
386
  const response = issues ? { issues } : await search({ project, jql, limit, env });
201
- return response.issues.map(toTicket).filter((ticket) => ticket.state !== 'closed');
387
+ return (
388
+ response.issues
389
+ .map(toTicket)
390
+ .filter((ticket) => ticket.state !== 'closed')
391
+ // Deliberately a SECOND enforcement of the same list the query already
392
+ // carries, and only here: `core.mjs` drops `triage` itself, with its own
393
+ // stated reason, but nothing downstream knows about `operator-queue`. This
394
+ // filter is what still holds when the query is bypassed — an `options.jql`
395
+ // override, or a board whose labels were renamed. Both read EXCLUDED_LABELS,
396
+ // so the two can disagree only by someone editing one of them.
397
+ .filter((ticket) => !ticket.labels.some((label) => EXCLUDED_LABELS.includes(label)))
398
+ );
202
399
  };
203
400
 
204
- export const search = async ({ project = null, jql = null, limit = 100, env = process.env } = {}) =>
205
- request(
206
- `/rest/api/3/search?jql=${encodeURIComponent(buildJql({ project, jql }))}` +
207
- `&maxResults=${limit}&fields=${FIELDS}`,
208
- { env },
209
- );
401
+ /**
402
+ * 🔴 `POST /rest/api/3/search/jql`, never `GET /rest/api/3/search` — the latter
403
+ * was retired by Atlassian and answers `410 Gone`. Measured against a live Jira,
404
+ * with the same credential answering `200` on `/rest/api/3/myself`, so it is the
405
+ * path and not the auth. Both searching call sites (`listEligible` and the
406
+ * `proposeTriage` dedupe) come through here, which is why one fix covers both.
407
+ *
408
+ * Pages through `nextPageToken` until the server sends none, or `hardCap`
409
+ * issues (default 1000) are in hand, or `maxPages` requests (default 100) have
410
+ * been made — each cap is announced on stderr, never
411
+ * silent, because a board whose tail is dropped is exactly the board the loop
412
+ * would otherwise believe it had read. `timeoutMs` and `retry` travel down to
413
+ * every page. Pinned in the generator's `test/template/queue-jira.test.ts`
414
+ * (absent in a generated rig) › "returns both pages as one list", › "sends the
415
+ * token from page 1 in the body of the request for page 2" and › "stops at
416
+ * hardCap and says on stderr that the list was capped".
417
+ */
418
+ export const search = async ({
419
+ project = null,
420
+ jql = null,
421
+ limit = 100,
422
+ env = process.env,
423
+ timeoutMs = DEFAULT_TIMEOUT_MS,
424
+ retry = {},
425
+ hardCap = 1000,
426
+ maxPages = 100,
427
+ } = {}) => {
428
+ const query = buildJql({ project, jql });
429
+ const issues = [];
430
+ // Bounds beside hardCap, because a cap on issues alone is no bound at all
431
+ // against a server that repeats a token: a token equal to the one just sent
432
+ // ends the walk, and so does `maxPages` (default 100 requests), each with a
433
+ // stderr line. An EMPTY page with a fresh token is NOT a stop — the enhanced
434
+ // search endpoint may return short or empty pages while later pages exist,
435
+ // and stopping there drops a real tail (› "keeps walking past an empty page
436
+ // that carries a fresh token", › "stops paging when a page brings no issues,
437
+ // even if the token repeats", › "caps the number of requests outright, and
438
+ // says so on stderr").
439
+ let nextPageToken = null;
440
+ let pages = 0;
441
+ do {
442
+ pages += 1;
443
+ const page = await request('/rest/api/3/search/jql', {
444
+ method: 'POST',
445
+ body: {
446
+ jql: query,
447
+ maxResults: limit,
448
+ fields: FIELDS,
449
+ ...(nextPageToken ? { nextPageToken } : {}),
450
+ },
451
+ env,
452
+ timeoutMs,
453
+ retry,
454
+ });
455
+ const received = page?.issues ?? [];
456
+ issues.push(...received.slice(0, Math.max(0, hardCap - issues.length)));
457
+ const sent = nextPageToken;
458
+ nextPageToken = page?.isLast === true ? null : (page?.nextPageToken ?? null);
459
+ if (nextPageToken && nextPageToken === sent) {
460
+ process.stderr.write(
461
+ `jira search: the server repeated page token ${JSON.stringify(sent)} — ` +
462
+ 'stopping the walk; the tail of this board may not have been read\n',
463
+ );
464
+ break;
465
+ }
466
+ if (nextPageToken && pages >= maxPages) {
467
+ process.stderr.write(
468
+ `jira search: capped at ${maxPages} requests with more pages available — ` +
469
+ 'the tail of this board was not read; raise maxPages or narrow the JQL\n',
470
+ );
471
+ break;
472
+ }
473
+ if (issues.length >= hardCap && nextPageToken) {
474
+ process.stderr.write(
475
+ `jira search: capped at ${hardCap} issues with more pages available — ` +
476
+ 'the tail of this board was not read; raise hardCap or narrow the JQL\n',
477
+ );
478
+ break;
479
+ }
480
+ } while (nextPageToken);
481
+ return { issues };
482
+ };
483
+
484
+ /**
485
+ * One item by key, mapped raw — closed included. `listEligible` drops closed
486
+ * items because selection must never take one; the close point needs to see
487
+ * exactly that one. Honours the same offline `issues` seam.
488
+ */
489
+ export const find = async (id, { issues = null, env = process.env } = {}) => {
490
+ if (issues) {
491
+ return issues.map(toTicket).find((ticket) => String(ticket.id) === String(id)) ?? null;
492
+ }
493
+ // 🔴 By key, never through `search`: `buildJql` carries `statusCategory != Done`
494
+ // for selection's sake, so a search can never return the closed item this
495
+ // point exists to see. A 404 is "the tracker has no such item" — `null`;
496
+ // every other failure is raised as it is.
497
+ try {
498
+ const issue = await request(
499
+ `/rest/api/3/issue/${encodeURIComponent(String(id))}?fields=${FIELDS.join(',')}`,
500
+ { env },
501
+ );
502
+ return issue ? toTicket(issue) : null;
503
+ } catch (error) {
504
+ if (/ 404 /.test(String(error?.message))) return null;
505
+ throw error;
506
+ }
507
+ };
210
508
 
211
509
  export const resolveBlockers = (ticket) => (ticket.blockedBy ?? []).filter((b) => !b.resolved);
212
510
 
@@ -215,6 +513,52 @@ export const resolveBlockers = (ticket) => (ticket.blockedBy ?? []).filter((b) =
215
513
  * worked while it still reads as available is invisible to the human and
216
514
  * re-selectable by the very next query.
217
515
  */
516
+ /**
517
+ * Re-record the item's marker after a write of this adapter's own (AR-140).
518
+ *
519
+ * Every write here — a claim, a comment, a close, an escalation — moves the
520
+ * tracker's `updated`, and the next revalidation compared against the take-up
521
+ * from before it — the generator's journal records one run whose every
522
+ * BEFORE_PR catch was a hold on its own comment (`revalidation-report.mjs`
523
+ * over that run). So the marker is read back after the write
524
+ * and recorded as the take-up in the declared run; a hold that still fires is
525
+ * a move by something other than this adapter.
526
+ *
527
+ * ⚠ Limit: only writes made THROUGH this adapter re-baseline. A comment the
528
+ * session posts by another route — a REST call by hand, a connector — moves
529
+ * the marker like anyone else's, and the next check holds on it.
530
+ *
531
+ * Best-effort, like `proposeTriage`'s baseline: the write has landed by now,
532
+ * and a read-back the tracker refused or a stale run directory is announced on
533
+ * stderr, never thrown — a thrown write is retried and lands twice.
534
+ */
535
+ const recordMarker = (ticket, updatedAt, env) => {
536
+ try {
537
+ recordTakeUp(env.RIG_RUN_DIR, { id: ticket.id, updatedAt });
538
+ } catch (error) {
539
+ process.stderr.write(
540
+ `${ticket.id}: the write landed, but its marker was NOT re-recorded in ` +
541
+ `${env.RIG_RUN_DIR} — ${error.message}\n`,
542
+ );
543
+ }
544
+ };
545
+
546
+ const rebaseline = async (ticket, env) => {
547
+ if (!env?.RIG_RUN_DIR) return;
548
+ let updatedAt;
549
+ try {
550
+ const after = await request(`/rest/api/3/issue/${ticket.id}?fields=updated`, { env });
551
+ updatedAt = toIso(after?.fields?.updated);
552
+ } catch (error) {
553
+ process.stderr.write(
554
+ `${ticket.id}: the write landed, but its marker was NOT re-recorded in ` +
555
+ `${env.RIG_RUN_DIR} — ${error.message}\n`,
556
+ );
557
+ return;
558
+ }
559
+ recordMarker(ticket, updatedAt, env);
560
+ };
561
+
218
562
  export const claim = async (ticket, { transitionId = null, env = process.env } = {}) => {
219
563
  if (!transitionId) {
220
564
  const available = await request(`/rest/api/3/issue/${ticket.id}/transitions`, { env });
@@ -234,6 +578,7 @@ export const claim = async (ticket, { transitionId = null, env = process.env } =
234
578
  body: { transition: { id: transitionId } },
235
579
  env,
236
580
  });
581
+ await rebaseline(ticket, env);
237
582
  return { ok: true };
238
583
  };
239
584
 
@@ -245,9 +590,17 @@ export const comment = async (ticket, body, { env = process.env } = {}) => {
245
590
  },
246
591
  env,
247
592
  });
593
+ await rebaseline(ticket, env);
248
594
  return { ok: true };
249
595
  };
250
596
 
597
+ /**
598
+ * 🔴 `transitioned` is read back from the tracker, never inferred from the
599
+ * argument. The first version returned `Boolean(transitionId)` — a fact about
600
+ * the call, reported as a fact about the issue — so a transition the workflow
601
+ * rejected, or one that landed in a status outside the `done` category, was
602
+ * published as a close (AR-135).
603
+ */
251
604
  export const close = async (ticket, { prUrl = null, transitionId = null, env = process.env } = {}) => {
252
605
  await comment(ticket, prUrl ? `Landed in ${prUrl}.` : 'Closed by the run.', { env });
253
606
  if (transitionId) {
@@ -257,7 +610,20 @@ export const close = async (ticket, { prUrl = null, transitionId = null, env = p
257
610
  env,
258
611
  });
259
612
  }
260
- return { ok: true, transitioned: Boolean(transitionId) };
613
+ // One read-back for both facts the close needs: the status that proves the
614
+ // transition, and the marker the write produced (AR-140) — `comment` above
615
+ // already re-baselined once; this is the read after the transition.
616
+ const after = await request(`/rest/api/3/issue/${ticket.id}?fields=status,updated`, { env });
617
+ const fields = after?.fields ?? {};
618
+ // Through the same announce-never-throw path as `rebaseline`: the round
619
+ // that inlined `recordTakeUp` here reintroduced a throw on a stale run
620
+ // directory after the transition had landed.
621
+ if (env?.RIG_RUN_DIR) recordMarker(ticket, toIso(fields.updated), env);
622
+ return {
623
+ ok: true,
624
+ transitioned: statusCategory(fields) === 'done',
625
+ status: fields.status?.name ?? null,
626
+ };
261
627
  };
262
628
 
263
629
  /**
@@ -272,6 +638,10 @@ export const escalate = async (ticket, diagnosis, { env = process.env } = {}) =>
272
638
  body: { update: { labels: [{ add: 'escalated' }] } },
273
639
  env,
274
640
  });
641
+ await rebaseline(ticket, env);
642
+ // Counted through the one recorder, never a counter of this adapter's own —
643
+ // "twice in a row" has to mean the same thing on every tracker.
644
+ recordEscalation(env.RIG_RUN_DIR);
275
645
  return { ok: true };
276
646
  };
277
647
 
@@ -290,8 +660,10 @@ export const triageItemFor = (proposal) => {
290
660
  `- part to change — ${proposal.part}`,
291
661
  `- proposed change — ${proposal.change}`,
292
662
  `- how the next run proves it — ${proposal.proof}`,
663
+ ...(proposal.measured ? [`- measured — ${proposal.measured}`, `- inferred — ${proposal.inferred}`] : []),
293
664
  '',
294
665
  `fingerprint: ${fingerprint}`,
666
+ ...(proposal.asOf ? [`asOf: ${proposal.asOf}`] : []),
295
667
  '',
296
668
  'The loop proposes; the owner patches. Self-applying a change to its own',
297
669
  'rulebook is how an unattended run drifts irreversibly.',
@@ -303,22 +675,36 @@ export const triageItemFor = (proposal) => {
303
675
  };
304
676
 
305
677
  /** File the proposal, or increment the one already carrying this fingerprint. */
678
+ /**
679
+ * The proposals on file, as `{ id, body }` — every `triage`-labelled issue, the
680
+ * body being its DESCRIPTION, which is where the fingerprint and `asOf` were
681
+ * written. An earlier dedupe mapped candidates through `toTicket` — which emits
682
+ * no body at all — so the predicate was always false and twenty identical stops
683
+ * filed twenty issues against the tracker.
684
+ */
685
+ export const listProposals = async ({
686
+ existing = null,
687
+ project = null,
688
+ jql = null,
689
+ env = process.env,
690
+ retry = {},
691
+ } = {}) => {
692
+ if (existing) return existing;
693
+ // Project-qualified, like every query this adapter sends (AR-51): the key is
694
+ // options.project, or the one options.jql leads with — `buildJql` refuses
695
+ // both when they disagree, so the triage query can only read this board.
696
+ const key = projectKeyOf({ project, jql });
697
+ const response = await search({ jql: `project = ${key} AND labels = triage ORDER BY created DESC`, env, retry });
698
+ return response.issues.map((issue) => ({ id: issue.key, body: descriptionTextOf(issue) }));
699
+ };
700
+
306
701
  export const proposeTriage = async (
307
- proposal,
308
- { project = null, existing = null, env = process.env } = {},
702
+ rawProposal,
703
+ { project = null, jql = null, existing = null, env = process.env, retry = {} } = {},
309
704
  ) => {
705
+ const proposal = withAsOf(rawProposal);
310
706
  const item = triageItemFor(proposal);
311
- // Compared against the issue's DESCRIPTION, which is where the fingerprint was
312
- // written. The previous version mapped candidates through `toTicket` — which
313
- // emits no body at all — so the predicate was always false and twenty identical
314
- // stops filed twenty issues against the tracker.
315
- const found =
316
- existing ??
317
- (await search({ jql: 'labels = triage ORDER BY created DESC', env })).issues.map((issue) => ({
318
- id: issue.key,
319
- body: descriptionTextOf(issue),
320
- }));
321
- const duplicate = duplicateOf(item, found);
707
+ const duplicate = duplicateOf(item, await listProposals({ existing, project, jql, env, retry }));
322
708
 
323
709
  if (duplicate) {
324
710
  await comment(duplicate, `Seen again (fingerprint ${item.fingerprint}). Incrementing.`, { env });
@@ -327,7 +713,7 @@ export const proposeTriage = async (
327
713
  if (!project) {
328
714
  throw new Error('filing a triage proposal needs options.project');
329
715
  }
330
- await request('/rest/api/3/issue', {
716
+ const created = await request('/rest/api/3/issue', {
331
717
  method: 'POST',
332
718
  body: {
333
719
  fields: {
@@ -344,5 +730,29 @@ export const proposeTriage = async (
344
730
  },
345
731
  env,
346
732
  });
347
- return { ok: true, filed: item.title, item };
733
+ // The proposal's own baseline (AR-138): its marker as filed, recorded as a
734
+ // take-up in the run that filed it, so the next run that is offered it
735
+ // compares against something. Read back rather than assumed — the marker is
736
+ // the tracker's, and `created` carries only the key. No run directory →
737
+ // nothing recorded, and the proposal is still filed.
738
+ //
739
+ // 🔴 Best-effort, and it says so: the proposal is FILED by now, and a throw
740
+ // here — a read-back the tracker refused, a stale or unwritable run
741
+ // directory in `updateState` — would tell the caller the filing failed when
742
+ // it succeeded, and the natural response (file again) double-files. The same
743
+ // defect `recordEscalation` closes for escalations; announced on stderr,
744
+ // because a baseline silently missing reads as a first sight later.
745
+ const id = created?.key ?? null;
746
+ if (id && env.RIG_RUN_DIR) {
747
+ try {
748
+ const after = await request(`/rest/api/3/issue/${id}?fields=updated`, { env });
749
+ recordTakeUp(env.RIG_RUN_DIR, { id, updatedAt: toIso(after?.fields?.updated) });
750
+ } catch (error) {
751
+ process.stderr.write(
752
+ `proposeTriage: ${id} is filed, but its baseline was NOT recorded in ` +
753
+ `${env.RIG_RUN_DIR} — ${error.message}\n`,
754
+ );
755
+ }
756
+ }
757
+ return { ok: true, filed: item.title, id, item };
348
758
  };