create-agent-rig 0.4.0 → 0.5.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 +207 -8
- package/README.md +50 -17
- package/package.json +2 -1
- package/packages/cli/dist/commands/create.js +8 -3
- package/packages/cli/dist/commands/init.js +82 -34
- package/packages/cli/dist/commands/upgrade.js +112 -30
- package/packages/cli/dist/index.js +38 -14
- package/packages/cli/dist/lib/copy-tree.js +35 -6
- package/packages/cli/dist/lib/init-settings.js +12 -0
- package/packages/cli/dist/lib/install-set.js +6 -8
- package/packages/cli/dist/lib/manifest.js +21 -9
- package/packages/cli/dist/lib/safe-path.js +30 -0
- package/templates/agent-os/init/AGENTS.md +191 -0
- package/templates/agent-os/init/CLAUDE.md +61 -9
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +98 -0
- package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +34 -3
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +34 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +233 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +676 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +338 -0
- package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +33 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +64 -1
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +33 -0
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +274 -24
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +480 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +61 -1
- package/templates/agent-os/universal/.claude/rules/invariants.md +71 -16
- package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1242 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +433 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +6 -16
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +89 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +334 -23
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +7 -2
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +413 -8
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +75 -13
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +222 -21
- package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
- package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +441 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -0
- package/templates/agent-os/universal/.claude/settings.json +7 -2
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +115 -7
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +378 -31
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +300 -25
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
- package/templates/agent-os/universal/.codex/hooks.json +65 -0
- package/templates/agent-os/universal/AGENTS.md +164 -0
- package/templates/agent-os/universal/CLAUDE.md +47 -14
- package/templates/agent-os/universal/PLAN.md +7 -40
- package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
- package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
- package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
- package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
- package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
- package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
- package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +62 -0
- package/templates/agent-os/universal/journal/README.md +101 -0
- package/templates/agent-os/universal/layers.json +36 -2
- package/templates/hash-history.json +2 -1
- package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
- package/templates/skeleton/aws-serverless/README.md +91 -9
- package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
- package/templates/skeleton/aws-serverless/gitignore +37 -0
- package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
- package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
- package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
- package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
- package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
- package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
- package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
- package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
- package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
- package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
- package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
- package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
- package/templates/skeleton/node-service/README.md +11 -1
- package/templates/skeleton/node-service/gitignore +34 -0
- package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
- package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
- package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
- package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
- package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
- package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
- package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// node .claude/scripts/queue/index.mjs next --json
|
|
6
6
|
// node .claude/scripts/queue/index.mjs list # every item, with skip reasons
|
|
7
7
|
// node .claude/scripts/queue/index.mjs hygiene # stale labels and link anomalies
|
|
8
|
+
// node .claude/scripts/queue/index.mjs gate-round --branch <b> # count a gate round
|
|
8
9
|
//
|
|
9
10
|
// The adapter comes from `.claude/queue.json` (`{"adapter": "plan-md"}`) and
|
|
10
11
|
// defaults to `plan-md`, which is the only adapter that works in a freshly
|
|
@@ -12,8 +13,13 @@
|
|
|
12
13
|
// that silently reads the wrong queue is worse than one that refuses to start.
|
|
13
14
|
import { readFileSync, realpathSync } from 'node:fs';
|
|
14
15
|
import { fileURLToPath } from 'node:url';
|
|
15
|
-
import { dirname, join } from 'node:path';
|
|
16
|
+
import { basename, dirname, join } from 'node:path';
|
|
16
17
|
import { hygieneOf, selectNext, stopConditionOf } from './core.mjs';
|
|
18
|
+
// One resolver, imported rather than re-derived: writer and reader disagreeing
|
|
19
|
+
// about which checkout they are in is the whole of the worktree defect. It lives
|
|
20
|
+
// apart from `state.mjs` so the read path does not drag the tier computation —
|
|
21
|
+
// and `detect-missed-gate.mjs` behind it — into a CLI that never calls either.
|
|
22
|
+
import { mainCheckoutRoot } from './checkout.mjs';
|
|
17
23
|
|
|
18
24
|
const ADAPTERS = {
|
|
19
25
|
'plan-md': './plan-md.mjs',
|
|
@@ -33,7 +39,7 @@ export const resolveAdapter = async (adapterName) => {
|
|
|
33
39
|
return import(new URL(modulePath, import.meta.url).href);
|
|
34
40
|
};
|
|
35
41
|
|
|
36
|
-
export const COMMANDS = ['next', 'list', 'hygiene'];
|
|
42
|
+
export const COMMANDS = ['next', 'list', 'hygiene', 'gate-round'];
|
|
37
43
|
|
|
38
44
|
/**
|
|
39
45
|
* A missing config is the normal state of a fresh project. A config that exists
|
|
@@ -60,11 +66,158 @@ export const loadConfig = (configPath) => {
|
|
|
60
66
|
}
|
|
61
67
|
};
|
|
62
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The state file that travels with a config: `<name>.json` → `<name>.state.json`.
|
|
71
|
+
*
|
|
72
|
+
* Derived from the config path rather than from the project root on purpose. The
|
|
73
|
+
* two must stay a pair: a run pointed at a temp config by `--config` would
|
|
74
|
+
* otherwise read the developer's own leftover state and let a tier from an
|
|
75
|
+
* unrelated checkout decide its selection.
|
|
76
|
+
*/
|
|
77
|
+
export const statePathFor = (configPath) => configPath.replace(/(\.json)?$/, '.state.json');
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The project root a config path implies, or `null` when it implies none.
|
|
81
|
+
*
|
|
82
|
+
* A config inside a directory named `.claude` names a project: the rig puts it
|
|
83
|
+
* there, so its parent is the root. Anything else — `--config /tmp/x.json`, a
|
|
84
|
+
* config nested for a test fixture — is a bare file with no project around it,
|
|
85
|
+
* and this returns `null` so the caller keeps whatever default it had.
|
|
86
|
+
*
|
|
87
|
+
* 🔴 Deliberately NOT `join(dirname(configPath), '..')`, which assumes every
|
|
88
|
+
* config sits in a `.claude` directory: pointed at `<dir>/.claude-queue.json` it
|
|
89
|
+
* climbs a level out of the project and looks for the plan in the parent.
|
|
90
|
+
*/
|
|
91
|
+
export const projectRootOfConfig = (configPath) => {
|
|
92
|
+
const dir = dirname(configPath);
|
|
93
|
+
return basename(dir) === '.claude' ? dirname(dir) : null;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The adapter options the CLI hands down, with a plan path that does not depend
|
|
98
|
+
* on the directory the command was typed in.
|
|
99
|
+
*
|
|
100
|
+
* 🔴 The asymmetry this closes: this file resolves its config from
|
|
101
|
+
* `import.meta.url`, so the config is found from any directory — while
|
|
102
|
+
* `plan-md`'s default is the bare relative `'PLAN.md'`, resolved against `cwd`.
|
|
103
|
+
* Running the CLI from a subdirectory therefore found the adapter and then
|
|
104
|
+
* reported `queue-unreadable`, which a run reads as a broken queue rather than as
|
|
105
|
+
* a wrong directory.
|
|
106
|
+
*
|
|
107
|
+
* Two things are left exactly as they were, and both are load-bearing. An
|
|
108
|
+
* explicit `options.planPath` still wins. And when the config implies no project
|
|
109
|
+
* root, nothing is injected — so `plan-md`'s own `'PLAN.md'` still resolves
|
|
110
|
+
* against the caller's cwd, which is the right answer when the adapter is
|
|
111
|
+
* imported directly rather than through this CLI, as the `loop` skill does for
|
|
112
|
+
* `proposeTriage`.
|
|
113
|
+
*/
|
|
114
|
+
export const optionsWithPlanPath = (options, configPath) => {
|
|
115
|
+
const root = projectRootOfConfig(configPath);
|
|
116
|
+
if (options?.planPath || root === null) return { ...options };
|
|
117
|
+
return { ...options, planPath: join(root, 'PLAN.md') };
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Every tier a state file may carry, and the reason there are four.
|
|
122
|
+
*
|
|
123
|
+
* `state.mjs` writes three of them: `normal`, and the elevated tier split into
|
|
124
|
+
* `elevated-prose` and `elevated-mechanism` — only the last of which spaces the
|
|
125
|
+
* next item (`core.mjs`). The fourth, the bare `elevated`, is what a state file
|
|
126
|
+
* written before that split still holds; it stays **readable** because a
|
|
127
|
+
* checkout that upgrades mid-run would otherwise refuse to select at all, and
|
|
128
|
+
* `core.mjs` reads it restrictively, so the legacy value rations exactly as it
|
|
129
|
+
* did when it was written.
|
|
130
|
+
*/
|
|
131
|
+
const KNOWN_TIERS = ['normal', 'elevated', 'elevated-prose', 'elevated-mechanism'];
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Per-checkout state, kept OUT of the composed config.
|
|
135
|
+
*
|
|
136
|
+
* The config is a template-layer file: a runtime value written into it is drift
|
|
137
|
+
* here and an upgrade conflict in a generated project. So the tier of the last
|
|
138
|
+
* closed item lands beside it, in a gitignored file.
|
|
139
|
+
*
|
|
140
|
+
* Missing is not an error — it means no item has closed yet, and selection
|
|
141
|
+
* proceeds with no spacing. Present-and-unparseable IS: the tier is then
|
|
142
|
+
* *unknown*, and the silent reading of unknown is `null`, which is the
|
|
143
|
+
* permissive value that lets a second elevated item straight through. That is
|
|
144
|
+
* the exact failure this state file exists to end, so it refuses instead.
|
|
145
|
+
*/
|
|
146
|
+
export const loadState = (statePath) => {
|
|
147
|
+
let raw;
|
|
148
|
+
try {
|
|
149
|
+
raw = readFileSync(statePath, 'utf8');
|
|
150
|
+
} catch (error) {
|
|
151
|
+
// 🔴 ONLY a genuinely absent file means "nothing has closed yet". A bare
|
|
152
|
+
// `catch` here read EACCES, a directory at the path, and a file left by a
|
|
153
|
+
// container run under another uid as "absent" — each of them silently
|
|
154
|
+
// yielding the permissive `null`, with nothing printed. One failed write
|
|
155
|
+
// would have disabled the ration for good, and no adversary is needed.
|
|
156
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return {};
|
|
157
|
+
throw new Error(
|
|
158
|
+
`${statePath} exists but could not be read (${error?.code ?? 'unknown error'}), ` +
|
|
159
|
+
'so the last completed tier is unknown. Refusing rather than continuing: ' +
|
|
160
|
+
'unknown reads as "nothing has closed yet", which is the value that lets a ' +
|
|
161
|
+
'second elevated item through.',
|
|
162
|
+
{ cause: error },
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let parsed;
|
|
167
|
+
try {
|
|
168
|
+
parsed = JSON.parse(raw);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`${statePath} exists but is not valid JSON, so the last completed tier cannot ` +
|
|
172
|
+
`be read: ${String(error?.message ?? error).split('\n')[0]}. Delete the file ` +
|
|
173
|
+
'if you are unsure — an absent state means "nothing has closed yet", which is ' +
|
|
174
|
+
'safe; a tier that cannot be read would silently disable the elevated ration.',
|
|
175
|
+
{ cause: error },
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Syntax is not shape. `"elevated"`, `["elevated"]`, `123` and `"Elevated"` all
|
|
180
|
+
// parse, and a state file shaped like any of them carries no tier at all — the
|
|
181
|
+
// dereference below answers `undefined`, which reads as "nothing has closed
|
|
182
|
+
// yet" and hands out an elevated item while the file looks like a working one.
|
|
183
|
+
// (`core.mjs` now holds on an unrecognised tier rather than shrugging, but a
|
|
184
|
+
// top-level array or number never reaches it as a tier in the first place.)
|
|
185
|
+
// A top-level `null` was worse still: it parsed, then threw a raw TypeError
|
|
186
|
+
// from the dereference, past the message that says the file is safe to delete.
|
|
187
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`${statePath} is valid JSON but not a state object, so the last completed tier ` +
|
|
190
|
+
'cannot be read. Expected `{ "lastCompletedTier": "normal" | "elevated-prose" ' +
|
|
191
|
+
'| "elevated-mechanism" }` — the legacy `"elevated"` is still accepted, and ' +
|
|
192
|
+
'held. ' +
|
|
193
|
+
'Delete the file if you are unsure — an absent state means "nothing has ' +
|
|
194
|
+
'closed yet", which is safe.',
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// `null` is allowed and means exactly what an absent file means: nothing has
|
|
199
|
+
// closed yet. It is the honest spelling of an absence, and refusing it would
|
|
200
|
+
// protect nothing — deleting the file has the identical effect.
|
|
201
|
+
const tier = parsed.lastCompletedTier;
|
|
202
|
+
if (tier !== undefined && tier !== null && !KNOWN_TIERS.includes(tier)) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
`${statePath} carries lastCompletedTier: ${JSON.stringify(tier)}, which is not ` +
|
|
205
|
+
`one of ${KNOWN_TIERS.map((t) => `"${t}"`).join(' | ')}. A tier outside the ` +
|
|
206
|
+
'vocabulary rations on a value nobody wrote, so it would report a working ' +
|
|
207
|
+
'state file while the spacing rule ran on a guess. Delete the file if you ' +
|
|
208
|
+
'are unsure.',
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return parsed;
|
|
213
|
+
};
|
|
214
|
+
|
|
63
215
|
const parseArgs = (argv) => {
|
|
64
|
-
const args = { command: argv[0] ?? 'next', json: false, config: null };
|
|
216
|
+
const args = { command: argv[0] ?? 'next', json: false, config: null, branch: null };
|
|
65
217
|
for (let i = 1; i < argv.length; i += 1) {
|
|
66
218
|
if (argv[i] === '--json') args.json = true;
|
|
67
219
|
else if (argv[i] === '--config') args.config = argv[++i];
|
|
220
|
+
else if (argv[i] === '--branch') args.branch = argv[++i];
|
|
68
221
|
}
|
|
69
222
|
return args;
|
|
70
223
|
};
|
|
@@ -118,21 +271,193 @@ if (invokedDirectly()) {
|
|
|
118
271
|
process.exit(1);
|
|
119
272
|
}
|
|
120
273
|
|
|
274
|
+
// 🔴 **`gate-round` returns before the tracker is ever touched, and that is
|
|
275
|
+
// load-bearing rather than an optimisation.** `pr-ship` calls this once per
|
|
276
|
+
// round, and every other command below reaches the adapter — so routing this one
|
|
277
|
+
// through the normal path would spend a network call per gate round and, worse,
|
|
278
|
+
// would refuse to count a round while the tracker is unreachable. The counter is
|
|
279
|
+
// a local file and a local config; a Jira outage must not hand a run unlimited
|
|
280
|
+
// rounds.
|
|
281
|
+
if (args.command === 'gate-round') {
|
|
282
|
+
try {
|
|
283
|
+
// 🔴 Inside the `try`, and a review round is why. Left outside it, a rig whose
|
|
284
|
+
// `.claude/scripts/` predates this CLI printed a raw `ERR_MODULE_NOT_FOUND`
|
|
285
|
+
// and exited 1 — which `pr-ship` step 0 would have read as "rounds
|
|
286
|
+
// exhausted" and escalated a healthy item on. This file already records that
|
|
287
|
+
// exact lesson about `resolveAdapter` fifty lines below; the reason it
|
|
288
|
+
// repeated is that the lesson was written as prose and not as a test.
|
|
289
|
+
const { gateRoundVerdict } = await import('./core.mjs');
|
|
290
|
+
const { recordGateRound, gateRoundsPathFor } = await import('./gate-rounds.mjs');
|
|
291
|
+
|
|
292
|
+
const configPath = args.config ?? join(projectRoot, '.claude', 'queue.json');
|
|
293
|
+
const config = loadConfig(configPath);
|
|
294
|
+
// An explicit `--config` keeps its counter beside it, so a run pointed at a
|
|
295
|
+
// temp config never touches this checkout's real counts.
|
|
296
|
+
const roundsPath = args.config
|
|
297
|
+
? configPath.replace(/(\.json)?$/, '.gate-rounds.json')
|
|
298
|
+
: gateRoundsPathFor(projectRoot);
|
|
299
|
+
|
|
300
|
+
// 🔴 Validate the cap BEFORE counting. A review round measured what the other
|
|
301
|
+
// order costs: with `maxGateRounds: 0` every attempt recorded a round and then
|
|
302
|
+
// exited 1, while this command's own message told the caller to fix the cause
|
|
303
|
+
// and run step 0 again — so three attempts at a broken config left the branch
|
|
304
|
+
// at three rounds and the next honest call refused a healthy item. A failed
|
|
305
|
+
// run must not spend budget, and the message below must not have to lie about
|
|
306
|
+
// whether it did.
|
|
307
|
+
const verdictFor = (rounds) => gateRoundVerdict(rounds, config.options?.maxGateRounds);
|
|
308
|
+
verdictFor(0);
|
|
309
|
+
|
|
310
|
+
const { rounds } = recordGateRound({ branch: args.branch, roundsPath });
|
|
311
|
+
const verdict = verdictFor(rounds);
|
|
312
|
+
|
|
313
|
+
if (!verdict.exceeded) {
|
|
314
|
+
process.stdout.write(
|
|
315
|
+
`gate round ${verdict.rounds} of ${verdict.max} on ${args.branch}.\n` +
|
|
316
|
+
(verdict.rounds === verdict.max
|
|
317
|
+
? ' This is the last round this branch gets. If it holds again, the item ' +
|
|
318
|
+
'is escalated rather than re-reviewed.\n'
|
|
319
|
+
: ''),
|
|
320
|
+
);
|
|
321
|
+
process.exit(0);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
process.stderr.write(
|
|
325
|
+
`GATE ROUNDS EXHAUSTED — ${verdict.rounds} rounds on ${args.branch}, cap is ` +
|
|
326
|
+
`${verdict.max}: ${verdict.stop}.\n` +
|
|
327
|
+
' Do not run another round. The item stops here and goes back to a human ' +
|
|
328
|
+
'with the round count and whatever the last gate reported — the fixes are ' +
|
|
329
|
+
'not converging, and another pass buys a full reviewer fan-out to discover ' +
|
|
330
|
+
'that again.\n' +
|
|
331
|
+
' Raising the cap to get one more pass on THIS item is the move this ' +
|
|
332
|
+
'refusal exists to prevent.\n',
|
|
333
|
+
);
|
|
334
|
+
process.exit(2);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
// 🔴 Exit 1, never 2, and the message says so. Exit 2 means one thing only —
|
|
337
|
+
// the rounds are spent — because `pr-ship` acts on it by ending the task. A
|
|
338
|
+
// broken config, an unreadable counter or a detached checkout must not be
|
|
339
|
+
// read as a converging-failure stall.
|
|
340
|
+
process.stderr.write(
|
|
341
|
+
`gate-round could not run: ${error.message}\n` +
|
|
342
|
+
' This is NOT an exhausted cap (that is exit 2). Fix the cause and run step ' +
|
|
343
|
+
'0 again: a bad config, a bad cap or a detached checkout is refused before ' +
|
|
344
|
+
'any round is counted, so retrying costs nothing.\n',
|
|
345
|
+
);
|
|
346
|
+
process.exit(1);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// A missing module is NOT read as "no state". A run that could not read its
|
|
351
|
+
// state would silently lose the escalation streak and the regression verdict
|
|
352
|
+
// — the two conditions that exist to stop it — so this fails closed, into a
|
|
353
|
+
// message the operator can act on.
|
|
354
|
+
//
|
|
355
|
+
// 🔴 **Loaded unconditionally, and ahead of the adapter, because the adapters
|
|
356
|
+
// import it statically.** An earlier version loaded it dynamically and only
|
|
357
|
+
// behind `RIG_RUN_DIR`, reasoning that a rig carrying an older
|
|
358
|
+
// `.claude/scripts/` should still get a readable message. That reasoning was
|
|
359
|
+
// sound and the code did not deliver it: all three adapters now
|
|
360
|
+
// `import { recordEscalation } from '../run-state.mjs'`, so a rig missing the
|
|
361
|
+
// module died inside `resolveAdapter` below and the operator saw a raw
|
|
362
|
+
// `Cannot find module` naming an adapter. The refusal was unreachable — and
|
|
363
|
+
// its test passed anyway, because the module's own filename satisfied the
|
|
364
|
+
// pattern it matched on. The load lives here; the READ stays behind the
|
|
365
|
+
// declaration, because a session with no run directory has no state to read
|
|
366
|
+
// and must keep working exactly as before.
|
|
367
|
+
let readState;
|
|
368
|
+
let stopInputsOf;
|
|
369
|
+
try {
|
|
370
|
+
({ readState, stopInputsOf } = await import('../run-state.mjs'));
|
|
371
|
+
} catch (error) {
|
|
372
|
+
process.stderr.write(
|
|
373
|
+
`run state: ${error.message}\n` +
|
|
374
|
+
' the run state module could not be loaded, so the escalation streak and the ' +
|
|
375
|
+
'deploy verdict cannot be read. Those are stop conditions, and a run that ' +
|
|
376
|
+
'cannot read them must not select work.\n',
|
|
377
|
+
);
|
|
378
|
+
process.exit(1);
|
|
379
|
+
}
|
|
380
|
+
|
|
121
381
|
let config;
|
|
382
|
+
let state;
|
|
122
383
|
let adapter;
|
|
384
|
+
// Declared out here because the plan path is derived from it further down.
|
|
385
|
+
let configPath;
|
|
123
386
|
try {
|
|
124
|
-
|
|
387
|
+
configPath = args.config ?? join(projectRoot, '.claude', 'queue.json');
|
|
388
|
+
config = loadConfig(configPath);
|
|
389
|
+
// An explicit `--config` keeps its own state beside it, verbatim: a run
|
|
390
|
+
// pointed at a temp config must not pick up this checkout's real tier.
|
|
391
|
+
// Without one, the state comes from the MAIN checkout, so a selection made
|
|
392
|
+
// inside a worktree sees what a close made anywhere recorded.
|
|
393
|
+
state = loadState(
|
|
394
|
+
args.config
|
|
395
|
+
? statePathFor(configPath)
|
|
396
|
+
: join(mainCheckoutRoot(projectRoot), '.claude', 'queue.state.json'),
|
|
397
|
+
);
|
|
125
398
|
adapter = await resolveAdapter(config.adapter ?? 'plan-md');
|
|
126
399
|
} catch (error) {
|
|
127
400
|
process.stderr.write(`${error.message}\n`);
|
|
128
401
|
process.exit(1);
|
|
129
402
|
}
|
|
130
403
|
|
|
404
|
+
// 🔴 The run-level stop is decided BEFORE the queue is read, because those
|
|
405
|
+
// conditions are not about the queue. A run that has escalated twice in a row
|
|
406
|
+
// has hit a systemic wall, and a run that deployed a regression must start no
|
|
407
|
+
// new work on top of it — asking the tracker first would spend a network call
|
|
408
|
+
// and, worse, would report `queue-unreadable` for an unreachable tracker when
|
|
409
|
+
// the truthful answer is that this run was already over.
|
|
410
|
+
//
|
|
411
|
+
// The values come from the run's own state file rather than from the
|
|
412
|
+
// session's memory, which is what three of `stopConditionOf`'s parameters
|
|
413
|
+
// were waiting for: every branch was live, and nothing ever supplied them.
|
|
414
|
+
//
|
|
415
|
+
// The fourth, `killSwitch`, is still not passed here and that is deliberate —
|
|
416
|
+
// `guard-bash` denies the merge at the tool layer and preflight reads the flag,
|
|
417
|
+
// so a second answer to "is the brake on" would be the disagreement
|
|
418
|
+
// `invariants.md` forbids. Selection therefore does NOT stop on the brake, and
|
|
419
|
+
// the `loop` skill tells the run to keep checking it between tasks.
|
|
420
|
+
//
|
|
421
|
+
const runState = process.env.RIG_RUN_DIR ? readState(process.env.RIG_RUN_DIR) : {};
|
|
422
|
+
let stopInputs;
|
|
423
|
+
try {
|
|
424
|
+
// Read through the module that owns the vocabulary, never field by field
|
|
425
|
+
// here: a value the reader cannot interpret must not arrive as "no stop",
|
|
426
|
+
// and deciding that at the call site is how the two halves drift.
|
|
427
|
+
stopInputs = stopInputsOf(runState);
|
|
428
|
+
} catch (error) {
|
|
429
|
+
process.stderr.write(
|
|
430
|
+
`${error.message}\n the run declared a state file this cannot act on, so no ` +
|
|
431
|
+
'item is selected. Stop conditions read from it, and an unreadable one is ' +
|
|
432
|
+
'not the same as an absent one.\n',
|
|
433
|
+
);
|
|
434
|
+
process.exit(1);
|
|
435
|
+
}
|
|
436
|
+
const runStop = stopConditionOf({
|
|
437
|
+
// `candidates: 1` says "not the empty-queue case" — the queue has not been
|
|
438
|
+
// read yet and must not be reported on here. Only the conditions that
|
|
439
|
+
// outrank it can fire from this call.
|
|
440
|
+
candidates: 1,
|
|
441
|
+
...stopInputs,
|
|
442
|
+
});
|
|
443
|
+
if (runStop) {
|
|
444
|
+
// `renderNext`, not a second copy of its format — an operator reading two
|
|
445
|
+
// differently-worded stop lines has no way to know they came from one rule.
|
|
446
|
+
process.stdout.write(
|
|
447
|
+
args.json ? `${JSON.stringify({ stop: runStop }, null, 2)}\n` : renderNext(null, runStop),
|
|
448
|
+
);
|
|
449
|
+
// The exit code follows `stop.success`, exactly as the empty-queue path
|
|
450
|
+
// does. A budget stop is a clean end of session — reporting it as a failure
|
|
451
|
+
// would tell a wrapper that the run broke, and `--json` already says
|
|
452
|
+
// `"success": true` right beside the code that contradicted it.
|
|
453
|
+
process.exit(runStop.success ? 0 : 1);
|
|
454
|
+
}
|
|
455
|
+
|
|
131
456
|
let tickets;
|
|
132
457
|
try {
|
|
133
458
|
// Awaited so an adapter may be async (jira) or plain (plan-md, github-issues)
|
|
134
459
|
// without the CLI caring which.
|
|
135
|
-
tickets = await adapter.listEligible(config.options
|
|
460
|
+
tickets = await adapter.listEligible(optionsWithPlanPath(config.options, configPath));
|
|
136
461
|
} catch (error) {
|
|
137
462
|
// Never fall back to memory or to a stale copy for a queue.
|
|
138
463
|
const stop = stopConditionOf({ queueReadable: false });
|
|
@@ -157,16 +482,96 @@ if (invokedDirectly()) {
|
|
|
157
482
|
}
|
|
158
483
|
|
|
159
484
|
const result = selectNext(tickets, {
|
|
160
|
-
|
|
161
|
-
|
|
485
|
+
// The state file wins: it is what a close actually recorded. A tier left in
|
|
486
|
+
// the config is a hand-written hint at best, and it is the composed file, so
|
|
487
|
+
// it cannot be the live value.
|
|
488
|
+
lastCompletedTier: state.lastCompletedTier ?? config.lastCompletedTier ?? null,
|
|
489
|
+
// Same precedence, for the same reason as the tier above: `queue.json` is
|
|
490
|
+
// composed by the sync script and drift-checked, so declaring a trigger
|
|
491
|
+
// fired there means editing a generated file — and the declaration is a
|
|
492
|
+
// fact about THIS run, not about the rig's configuration.
|
|
493
|
+
//
|
|
494
|
+
// The config keeps working as a fallback rather than being dropped: nothing
|
|
495
|
+
// in this repository or its templates ever writes the key, but a rig owner
|
|
496
|
+
// who hand-added one would otherwise find their auto-trigger items silently
|
|
497
|
+
// unselectable, and an item that stops being offered announces itself
|
|
498
|
+
// nowhere.
|
|
499
|
+
//
|
|
500
|
+
// Replacement, not a merge: a per-key merge would make a stale config entry
|
|
501
|
+
// impossible to retract, so "not this time" would again require editing the
|
|
502
|
+
// generated file this move exists to get out of.
|
|
503
|
+
triggersFired: runState.triggersFired ?? config.triggersFired ?? null,
|
|
162
504
|
});
|
|
163
|
-
|
|
505
|
+
// The skipped records travel with the count: without them "nothing left" and
|
|
506
|
+
// "everything left is held back" both print as an empty queue, and only one of
|
|
507
|
+
// the two means the queue needs refilling.
|
|
508
|
+
const stop = result.ticket
|
|
509
|
+
? null
|
|
510
|
+
: stopConditionOf({ candidates: 0, skipped: result.skipped });
|
|
164
511
|
|
|
165
512
|
if (args.command === 'list') {
|
|
166
513
|
process.stdout.write(`${JSON.stringify({ tickets, ...result }, null, 2)}\n`);
|
|
167
514
|
process.exit(0);
|
|
168
515
|
}
|
|
169
516
|
|
|
517
|
+
// 🔴 A journal nothing calls records nothing. Selection is a gate — it decides
|
|
518
|
+
// what the run works on and why every other item was passed over — so this is
|
|
519
|
+
// the call site the run journal ships with, rather than a writer nobody
|
|
520
|
+
// invokes.
|
|
521
|
+
//
|
|
522
|
+
// It writes only when the run DECLARED its directory. Inventing one here would
|
|
523
|
+
// make this CLI a second owner of the `.claude/runs/<run-id>/` convention, and
|
|
524
|
+
// a default derived from `projectRoot` would land the trace inside the very
|
|
525
|
+
// template tree this repository publishes.
|
|
526
|
+
const runDir = process.env.RIG_RUN_DIR;
|
|
527
|
+
if (runDir) {
|
|
528
|
+
let journal = null;
|
|
529
|
+
try {
|
|
530
|
+
journal = await import('../run-journal.mjs');
|
|
531
|
+
journal.recordDecision({
|
|
532
|
+
runDir,
|
|
533
|
+
gate: 'item-selection',
|
|
534
|
+
verdict: result.ticket ? `taken ${result.ticket.id}` : `stopped ${stop.kind}`,
|
|
535
|
+
why: result.ticket ? result.ticket.title : stop.why,
|
|
536
|
+
// The edge is where the clock is read: the journal itself takes `now` as
|
|
537
|
+
// an argument, which is what keeps its records reproducible.
|
|
538
|
+
now: new Date().toISOString(),
|
|
539
|
+
});
|
|
540
|
+
} catch (error) {
|
|
541
|
+
// 🔴 Two failures wearing one face, and treating them alike was a defect
|
|
542
|
+
// this gate caught. The journal asks the module which one this is — never
|
|
543
|
+
// the message text, which would put the decision in two files and let them
|
|
544
|
+
// drift the day someone improves the wording.
|
|
545
|
+
// `?.` would guard a MISSING module and not a missing export. A rig can
|
|
546
|
+
// carry a run journal older than this CLI — `upgrade` leaves a
|
|
547
|
+
// locally-edited copy beside a new caller — and calling through to an
|
|
548
|
+
// absent export crashed the CLI inside its own error handler: a raw stack
|
|
549
|
+
// trace instead of the failure it was reporting.
|
|
550
|
+
const classify = journal?.isTraceExhausted;
|
|
551
|
+
if (typeof classify === 'function' && classify(error)) {
|
|
552
|
+
// The trace is over and the work is not. This journal cannot accept
|
|
553
|
+
// another record — it is append-only and every write re-reads it — so
|
|
554
|
+
// exiting here would make one collision between two sessions leave the
|
|
555
|
+
// queue unselectable FOREVER. Loud on stderr, and the selection still
|
|
556
|
+
// goes out on stdout, because the queue was never the thing that failed.
|
|
557
|
+
process.stderr.write(
|
|
558
|
+
`run journal: ${error.message}\n` +
|
|
559
|
+
` the selection below was NOT recorded in ${runDir}. This run's trace ends ` +
|
|
560
|
+
'here; the queue is fine, and a new run needs a new run directory.\n',
|
|
561
|
+
);
|
|
562
|
+
} else {
|
|
563
|
+
// The other half: the declaration is empty, its directory is not there,
|
|
564
|
+
// the path is not a directory, or the journal module is missing. Each is
|
|
565
|
+
// the run pointing at something that was never set up — one `mkdir` or
|
|
566
|
+
// one corrected variable away — and continuing would produce a run with
|
|
567
|
+
// no trace at all, so this half does stop the selection. (A directory
|
|
568
|
+
// deleted mid-run lands here too, and the same `mkdir` restores it.)
|
|
569
|
+
process.stderr.write(`run journal: ${error.message}\n`);
|
|
570
|
+
process.exit(1);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
170
575
|
process.stdout.write(
|
|
171
576
|
args.json
|
|
172
577
|
? `${JSON.stringify({ ticket: result.ticket, skipped: result.skipped, stop }, null, 2)}\n`
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// { "adapter": "jira", "options": { "project": "ABC" } }
|
|
19
19
|
// { "adapter": "jira", "options": { "jql": "project = ABC AND ..." } }
|
|
20
20
|
import { duplicateOf, fingerprintOf, validateProposal } from './core.mjs';
|
|
21
|
+
import { recordEscalation } from '../run-state.mjs';
|
|
21
22
|
|
|
22
23
|
export const name = 'jira';
|
|
23
24
|
|
|
@@ -91,7 +92,13 @@ export const toTicket = (issue) => {
|
|
|
91
92
|
url: issue.self ?? null,
|
|
92
93
|
state: category === 'done' ? 'closed' : category === 'indeterminate' ? 'in-progress' : 'open',
|
|
93
94
|
labels,
|
|
94
|
-
|
|
95
|
+
// The marker is `elevated`, the same word the plan-md adapter reads out of an
|
|
96
|
+
// `[elevated]` line — one name for one fact, so `core.mjs` rations the same
|
|
97
|
+
// way whichever tracker the item came from. It is deliberately NOT
|
|
98
|
+
// `human-review`: on a Jira board that is a workflow label meaning "a human
|
|
99
|
+
// is looking at it", which is a different claim entirely, and reading it as
|
|
100
|
+
// the tier rations the queue on a signal that means something else.
|
|
101
|
+
tier: labels.includes('elevated') ? 'elevated' : 'normal',
|
|
95
102
|
blockedBy,
|
|
96
103
|
blocks,
|
|
97
104
|
priority: PRIORITY[String(fields.priority?.name ?? '').toLowerCase()] ?? 999,
|
|
@@ -108,17 +115,38 @@ export const toTicket = (issue) => {
|
|
|
108
115
|
};
|
|
109
116
|
};
|
|
110
117
|
|
|
118
|
+
/**
|
|
119
|
+
* The lanes selection never takes, named once so the query and the post-filter
|
|
120
|
+
* below cannot drift apart (`invariants.md`: one mechanism, one implementation).
|
|
121
|
+
*
|
|
122
|
+
* - `triage` is the loop's own filed proposals. Excluding them only by the
|
|
123
|
+
* absence of a ready marker means one careless hand adding that marker closes
|
|
124
|
+
* the loop's feedback path into its own input.
|
|
125
|
+
* - `operator-queue` is the owner's lane. An item sitting there is work a HUMAN
|
|
126
|
+
* has taken, so the loop picking one up is two sessions on one task.
|
|
127
|
+
*/
|
|
128
|
+
export const EXCLUDED_LABELS = ['triage', 'operator-queue'];
|
|
129
|
+
|
|
111
130
|
/**
|
|
112
131
|
* The selection query.
|
|
113
132
|
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* that marker closes the loop's feedback path into its own input. Excluded
|
|
117
|
-
* explicitly, an item carrying BOTH markers is still unselectable.
|
|
133
|
+
* Both lanes are excluded here by label, in the adapter's own filter rather than
|
|
134
|
+
* in an `options.jql` a reinstall would drop.
|
|
118
135
|
*
|
|
119
136
|
* ⚠ JQL gotcha that makes the parenthesised form necessary: `labels != x` does
|
|
120
137
|
* **not** match issues whose labels field is empty. Without `OR labels IS EMPTY`
|
|
121
138
|
* this query would silently skip every unlabelled item — which is most of them.
|
|
139
|
+
* Both exclusions therefore live INSIDE that one group; a second, unguarded
|
|
140
|
+
* exclusion anywhere in the query reintroduces the hole while still reading right.
|
|
141
|
+
*
|
|
142
|
+
* ⚠ And the group stays FLAT — `labels != a AND labels != b OR labels IS EMPTY`,
|
|
143
|
+
* not `labels NOT IN (a, b) OR …`. Both forms are valid JQL and both were run
|
|
144
|
+
* against a live Jira, returning identical result sets — so this is a
|
|
145
|
+
* readability choice with no behaviour attached, and switching back would be
|
|
146
|
+
* behaviour-neutral. Flat wins because the `NOT IN` form's nested parentheses
|
|
147
|
+
* hide the `IS EMPTY` guard from any reader — human or test — that matches
|
|
148
|
+
* innermost groups. `AND` binds tighter than `OR` in JQL, so the flat form means
|
|
149
|
+
* `(a AND b) OR empty`, which is the intent.
|
|
122
150
|
*/
|
|
123
151
|
export const buildJql = ({ project = null, jql = null } = {}) => {
|
|
124
152
|
if (jql) return jql;
|
|
@@ -129,9 +157,10 @@ export const buildJql = ({ project = null, jql = null } = {}) => {
|
|
|
129
157
|
'is worse than refusing to start.',
|
|
130
158
|
);
|
|
131
159
|
}
|
|
160
|
+
const excluded = EXCLUDED_LABELS.map((label) => `labels != "${label}"`).join(' AND ');
|
|
132
161
|
return (
|
|
133
162
|
`project = ${project} AND statusCategory != Done ` +
|
|
134
|
-
|
|
163
|
+
`AND (${excluded} OR labels IS EMPTY) ` +
|
|
135
164
|
'ORDER BY priority DESC, created ASC'
|
|
136
165
|
);
|
|
137
166
|
};
|
|
@@ -179,7 +208,13 @@ const request = async (route, { method = 'GET', body = null, env = process.env }
|
|
|
179
208
|
// `description` is requested because the triage dedupe matches the fingerprint
|
|
180
209
|
// inside it. Without it the dedupe silently never matched, so every "queue empty"
|
|
181
210
|
// stop filed a fresh issue instead of incrementing the one already there.
|
|
182
|
-
|
|
211
|
+
//
|
|
212
|
+
// A LIST, not a comma-joined string: the replacement endpoint takes its arguments
|
|
213
|
+
// in a JSON body, where `fields` is an array. Measured, because the failure mode
|
|
214
|
+
// decides how hard this is to notice: sending the joined form the retired query
|
|
215
|
+
// parameter wanted answers `400 Invalid request payload`, not a 200 with empty
|
|
216
|
+
// fields. It fails loudly, so a wrong value here cannot sit undetected.
|
|
217
|
+
const FIELDS = ['summary', 'status', 'labels', 'priority', 'created', 'issuelinks', 'description'];
|
|
183
218
|
|
|
184
219
|
// --- the adapter contract ------------------------------------------------------
|
|
185
220
|
|
|
@@ -198,15 +233,39 @@ export const listEligible = async ({
|
|
|
198
233
|
// `issues` is the offline seam: the mapping is pure, so every shape it has to
|
|
199
234
|
// handle is testable without a network or a credential.
|
|
200
235
|
const response = issues ? { issues } : await search({ project, jql, limit, env });
|
|
201
|
-
return
|
|
236
|
+
return (
|
|
237
|
+
response.issues
|
|
238
|
+
.map(toTicket)
|
|
239
|
+
.filter((ticket) => ticket.state !== 'closed')
|
|
240
|
+
// Deliberately a SECOND enforcement of the same list the query already
|
|
241
|
+
// carries, and only here: `core.mjs` drops `triage` itself, with its own
|
|
242
|
+
// stated reason, but nothing downstream knows about `operator-queue`. This
|
|
243
|
+
// filter is what still holds when the query is bypassed — an `options.jql`
|
|
244
|
+
// override, or a board whose labels were renamed. Both read EXCLUDED_LABELS,
|
|
245
|
+
// so the two can disagree only by someone editing one of them.
|
|
246
|
+
.filter((ticket) => !ticket.labels.some((label) => EXCLUDED_LABELS.includes(label)))
|
|
247
|
+
);
|
|
202
248
|
};
|
|
203
249
|
|
|
250
|
+
/**
|
|
251
|
+
* 🔴 `POST /rest/api/3/search/jql`, never `GET /rest/api/3/search` — the latter
|
|
252
|
+
* was retired by Atlassian and answers `410 Gone`. Measured against a live Jira,
|
|
253
|
+
* with the same credential answering `200` on `/rest/api/3/myself`, so it is the
|
|
254
|
+
* path and not the auth. Both searching call sites (`listEligible` and the
|
|
255
|
+
* `proposeTriage` dedupe) come through here, which is why one fix covers both.
|
|
256
|
+
*
|
|
257
|
+
* Not handled here on purpose: the response also carries `nextPageToken` for
|
|
258
|
+
* cursor pagination, so a board with more open issues than `limit` still loses
|
|
259
|
+
* its tail — as does a retry policy and a request timeout. Those belong together
|
|
260
|
+
* in one change; half a pagination interface with nothing testing it is worse
|
|
261
|
+
* than none.
|
|
262
|
+
*/
|
|
204
263
|
export const search = async ({ project = null, jql = null, limit = 100, env = process.env } = {}) =>
|
|
205
|
-
request(
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
);
|
|
264
|
+
request('/rest/api/3/search/jql', {
|
|
265
|
+
method: 'POST',
|
|
266
|
+
body: { jql: buildJql({ project, jql }), maxResults: limit, fields: FIELDS },
|
|
267
|
+
env,
|
|
268
|
+
});
|
|
210
269
|
|
|
211
270
|
export const resolveBlockers = (ticket) => (ticket.blockedBy ?? []).filter((b) => !b.resolved);
|
|
212
271
|
|
|
@@ -272,6 +331,9 @@ export const escalate = async (ticket, diagnosis, { env = process.env } = {}) =>
|
|
|
272
331
|
body: { update: { labels: [{ add: 'escalated' }] } },
|
|
273
332
|
env,
|
|
274
333
|
});
|
|
334
|
+
// Counted through the one recorder, never a counter of this adapter's own —
|
|
335
|
+
// "twice in a row" has to mean the same thing on every tracker.
|
|
336
|
+
recordEscalation(env.RIG_RUN_DIR);
|
|
275
337
|
return { ok: true };
|
|
276
338
|
};
|
|
277
339
|
|