create-agent-rig 0.3.2 → 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 +294 -10
- package/README.md +84 -13
- package/package.json +2 -1
- package/packages/cli/dist/commands/create.js +41 -8
- package/packages/cli/dist/commands/init.js +120 -34
- package/packages/cli/dist/commands/upgrade.js +382 -0
- package/packages/cli/dist/index.js +132 -21
- package/packages/cli/dist/lib/copy-tree.js +44 -7
- package/packages/cli/dist/lib/history.js +49 -0
- package/packages/cli/dist/lib/init-settings.js +12 -0
- package/packages/cli/dist/lib/install-set.js +44 -0
- package/packages/cli/dist/lib/manifest.js +111 -0
- package/packages/cli/dist/lib/prompts.js +20 -0
- package/packages/cli/dist/lib/safe-path.js +71 -0
- package/packages/cli/dist/lib/substitute.js +32 -0
- package/packages/cli/dist/lib/version.js +15 -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 +395 -33
- 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 -29
- 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 +264 -0
- 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
|
@@ -63,19 +63,88 @@ export const ADAPTER_CONTRACT = [
|
|
|
63
63
|
'proposeTriage',
|
|
64
64
|
];
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Why an item was passed over, as a closed vocabulary.
|
|
68
|
+
*
|
|
69
|
+
* The reason strings below are written for a human and change freely; a counter
|
|
70
|
+
* that grouped them by re-parsing that prose would break the first time a word
|
|
71
|
+
* did. `SPACING` is the one cause no filter produces — it belongs to the tier
|
|
72
|
+
* ration in `selectNext`, which is why it lives here rather than in `selectionOf`.
|
|
73
|
+
*/
|
|
74
|
+
export const SKIP_CAUSES = Object.freeze([
|
|
75
|
+
'closed',
|
|
76
|
+
'in-progress',
|
|
77
|
+
'triage',
|
|
78
|
+
'escalated',
|
|
79
|
+
'blocked',
|
|
80
|
+
'trigger-auto',
|
|
81
|
+
'trigger-human',
|
|
82
|
+
'spacing',
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The causes that hold a takeable item back — and the reason the stop conditions
|
|
87
|
+
* cannot treat "something was skipped" as one thing.
|
|
88
|
+
*
|
|
89
|
+
* Each of these clears without the QUEUE being refilled: a normal item lands, a
|
|
90
|
+
* blocker closes, another session finishes, a human declares the window. Two of
|
|
91
|
+
* them do need something written, and the distinction cost three rewrites of the
|
|
92
|
+
* stop line to get right — a `trigger-auto` item stays held until the
|
|
93
|
+
* declaration is recorded in the run state, and a `trigger-human` item is freed
|
|
94
|
+
* only by a human editing the item's own marker. They are separate tags for
|
|
95
|
+
* that reason: one tag standing for two remedies makes every sentence about it
|
|
96
|
+
* wrong for one of them. The
|
|
97
|
+
* three causes NOT in this list — `closed`, `triage`, `escalated` — are items
|
|
98
|
+
* out of play, waiting on a human. On a tracker-backed adapter they accumulate:
|
|
99
|
+
* an escalated issue stays open and merely gains a label, and a proposal the
|
|
100
|
+
* loop files stays open too. Counting those as "the queue is full, wait"
|
|
101
|
+
* would make `queue-empty` unreachable from the first stop that escalated or
|
|
102
|
+
* proposed anything — so a drained queue would report "wait and interleave" and
|
|
103
|
+
* the owner would never be told to refill. That is the same refill-versus-wait
|
|
104
|
+
* inversion this split exists to remove, pointing the other way.
|
|
105
|
+
*
|
|
106
|
+
* Under `plan-md` only `triage` is reachable of the three, and it matters that it
|
|
107
|
+
* is: `parsePlan` reads the marker out of the bullet text, which is exactly the
|
|
108
|
+
* case of a proposal that ended up under the wrong heading. `escalated` and
|
|
109
|
+
* `closed` cannot appear there at all — a flat list carries no per-item state, so
|
|
110
|
+
* `parsePlan` hands back `labels: []` and `state: 'open'` for every line. That is
|
|
111
|
+
* an absence of state, NOT an adapter that filed the escalation somewhere safe.
|
|
112
|
+
* `plan-md`'s own `escalate` says so: it writes nothing, returns `ok: false`, and
|
|
113
|
+
* hands back the instruction to move the item to the Operator queue in the same
|
|
114
|
+
* edit — because if that move is not made, the next run picks the item straight
|
|
115
|
+
* back up.
|
|
116
|
+
*/
|
|
117
|
+
export const HOLDING_CAUSES = Object.freeze([
|
|
118
|
+
'blocked',
|
|
119
|
+
'in-progress',
|
|
120
|
+
'spacing',
|
|
121
|
+
'trigger-auto',
|
|
122
|
+
'trigger-human',
|
|
123
|
+
]);
|
|
124
|
+
|
|
66
125
|
/**
|
|
67
126
|
* Is this item takeable, and if not, why not?
|
|
68
127
|
*
|
|
69
128
|
* The filters run in order and every rejection carries a reason: an unexplained
|
|
70
|
-
* skip is indistinguishable from a bug in the filter.
|
|
129
|
+
* skip is indistinguishable from a bug in the filter. Each reason also carries a
|
|
130
|
+
* `cause` tag, so the stop line can say what is holding the queue back without
|
|
131
|
+
* reading the prose back.
|
|
71
132
|
*/
|
|
72
133
|
export const selectionOf = (ticket, { triggersFired = null } = {}) => {
|
|
73
134
|
const reasons = [];
|
|
135
|
+
const causes = [];
|
|
74
136
|
const labels = ticket.labels ?? [];
|
|
75
137
|
|
|
76
|
-
|
|
138
|
+
// Filter order is the order these are pushed in; a cause repeats at most once,
|
|
139
|
+
// because two trigger reasons are still one thing holding the item back.
|
|
140
|
+
const reject = (cause, why) => {
|
|
141
|
+
reasons.push(why);
|
|
142
|
+
if (!causes.includes(cause)) causes.push(cause);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
if (ticket.state === 'closed') reject('closed', 'already closed');
|
|
77
146
|
if (ticket.state === 'in-progress') {
|
|
78
|
-
|
|
147
|
+
reject('in-progress', 'already in progress — another session may be on it');
|
|
79
148
|
}
|
|
80
149
|
|
|
81
150
|
// Belt and braces, and deliberately so. A triage item is a proposal the loop
|
|
@@ -83,11 +152,11 @@ export const selectionOf = (ticket, { triggersFired = null } = {}) => {
|
|
|
83
152
|
// one careless hand adding that marker closes the loop's feedback path into its
|
|
84
153
|
// own input — the exact circuit the firewall exists to break.
|
|
85
154
|
if (ticket.triage || labels.includes('triage')) {
|
|
86
|
-
|
|
155
|
+
reject('triage', 'a triage proposal: promotion to work is a human act');
|
|
87
156
|
}
|
|
88
157
|
|
|
89
158
|
if (labels.includes('escalated')) {
|
|
90
|
-
|
|
159
|
+
reject('escalated', 'escalated — it is waiting on a human, not on another attempt');
|
|
91
160
|
}
|
|
92
161
|
|
|
93
162
|
// 🔴 INVARIANT 1: blockers resolve from LINKS, never from labels.
|
|
@@ -99,21 +168,31 @@ export const selectionOf = (ticket, { triggersFired = null } = {}) => {
|
|
|
99
168
|
// and takes work whose blocker is still open. Both directions have been seen.
|
|
100
169
|
const open = (ticket.blockedBy ?? []).filter((blocker) => !blocker.resolved);
|
|
101
170
|
if (open.length > 0) {
|
|
102
|
-
|
|
171
|
+
reject('blocked', `blocked by ${open.map((b) => b.id).join(', ')} (from links, not labels)`);
|
|
103
172
|
}
|
|
104
173
|
|
|
105
174
|
// No trigger label means unconditional, not missing data. Work that is
|
|
106
175
|
// genuinely conditional says so.
|
|
176
|
+
// ⚠ **The markers are resolved by the adapter, `auto` first**, so an item
|
|
177
|
+
// carrying BOTH reaches here as `auto` and one recorded declaration takes it.
|
|
178
|
+
// Nothing refuses that combination and no hygiene check reports it — so this
|
|
179
|
+
// branch describes the item as the adapter classified it, and claims nothing
|
|
180
|
+
// about what the item's author wrote. "Never self-taken" would be exactly
|
|
181
|
+
// that claim, and it would be false for the item most likely to carry both:
|
|
182
|
+
// one an owner tightened from auto-gated to human-gated without deleting the
|
|
183
|
+
// old marker, where the silent resolution goes to the LESS restrictive gate.
|
|
107
184
|
if (ticket.trigger === 'human') {
|
|
108
|
-
|
|
185
|
+
reject(
|
|
186
|
+
'trigger-human',
|
|
109
187
|
'trigger-human: a window, a demand or a "pass" is a human declaration — ' +
|
|
110
|
-
'never
|
|
188
|
+
'handed over explicitly, never taken on this marker alone',
|
|
111
189
|
);
|
|
112
190
|
}
|
|
113
191
|
if (ticket.trigger === 'auto') {
|
|
114
192
|
const fired = triggersFired?.[ticket.id];
|
|
115
193
|
if (fired !== true) {
|
|
116
|
-
|
|
194
|
+
reject(
|
|
195
|
+
'trigger-auto',
|
|
117
196
|
fired === undefined
|
|
118
197
|
? 'trigger-auto with no verification of the trigger this run — ' +
|
|
119
198
|
'unverified is not fired'
|
|
@@ -122,7 +201,7 @@ export const selectionOf = (ticket, { triggersFired = null } = {}) => {
|
|
|
122
201
|
}
|
|
123
202
|
}
|
|
124
203
|
|
|
125
|
-
return { eligible: reasons.length === 0, reasons };
|
|
204
|
+
return { eligible: reasons.length === 0, reasons, causes };
|
|
126
205
|
};
|
|
127
206
|
|
|
128
207
|
/**
|
|
@@ -270,13 +349,98 @@ export const sortCandidates = (tickets) =>
|
|
|
270
349
|
return String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? ''));
|
|
271
350
|
});
|
|
272
351
|
|
|
352
|
+
/**
|
|
353
|
+
* The tiers a close can record that leave the next elevated item selectable.
|
|
354
|
+
*
|
|
355
|
+
* `normal` is the obvious one. `elevated-prose` is the narrowing: an elevated
|
|
356
|
+
* change every one of whose elevated paths is a document (`state.mjs` decides
|
|
357
|
+
* this from the diff, never from the item's marker). It is still elevated for
|
|
358
|
+
* review — model lane, cold readers, the `human-review` label, the gate sweep —
|
|
359
|
+
* and it does not space the next item, because the ration buys protection from
|
|
360
|
+
* *unreviewed changes compounding overnight* and a document does not run.
|
|
361
|
+
*/
|
|
362
|
+
const CLEARS_SPACING = new Set(['normal', 'elevated-prose']);
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Does the last recorded tier leave the elevated ration open?
|
|
366
|
+
*
|
|
367
|
+
* 🔴 **Unknown means the RESTRICTIVE reading, never the permissive one.** Absent
|
|
368
|
+
* is not unknown: `null`/`undefined` is the honest statement that nothing has
|
|
369
|
+
* closed yet — what a fresh checkout with no state file says — and refusing it
|
|
370
|
+
* would make a clone unable to take its first item. Everything else outside the
|
|
371
|
+
* vocabulary holds: the legacy `'elevated'` an older state file still carries, a
|
|
372
|
+
* word in the wrong case, a value that is not a string at all. An `===`
|
|
373
|
+
* comparison against `'elevated-mechanism'` would read every one of those as
|
|
374
|
+
* "nothing elevated closed" and hand out the next elevated item — un-rationing
|
|
375
|
+
* the queue silently, which is the exact failure this seam exists to end.
|
|
376
|
+
*
|
|
377
|
+
* The CLI refuses an out-of-vocabulary tier before selection ever runs
|
|
378
|
+
* (`index.mjs`), so this is the second of two layers rather than the only one —
|
|
379
|
+
* and it is the layer that holds when `selectNext` is called directly.
|
|
380
|
+
*/
|
|
381
|
+
const clearsSpacing = (lastCompletedTier) =>
|
|
382
|
+
lastCompletedTier === null ||
|
|
383
|
+
lastCompletedTier === undefined ||
|
|
384
|
+
CLEARS_SPACING.has(lastCompletedTier);
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* How many times one branch may enter the review gate before the item stops.
|
|
388
|
+
*
|
|
389
|
+
* 🔴 **Why a cap exists.** Every other stop in `autonomy.md` has something red
|
|
390
|
+
* behind it — a failing check, a conflicting rule, a false premise. A gate that
|
|
391
|
+
* keeps finding fixable prose has nothing red at all, so three strikes never fires
|
|
392
|
+
* and the run has no reason to stop re-entering it. That is not hypothetical: the
|
|
393
|
+
* repository this rulebook was extracted from journalled multi-round gates on single
|
|
394
|
+
* items with the whole suite green throughout, and its `budget` stop arriving "later
|
|
395
|
+
* than it should have".
|
|
396
|
+
*
|
|
397
|
+
* Two is the cap because the second round is what verifies the first round's fixes.
|
|
398
|
+
* A third means they are not converging, which is a diagnosis for a human rather
|
|
399
|
+
* than another pass to buy.
|
|
400
|
+
*/
|
|
401
|
+
export const DEFAULT_MAX_GATE_ROUNDS = 2;
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Is this round allowed, and if not, what stops?
|
|
405
|
+
*
|
|
406
|
+
* Pure, and separate from the counter on disk, because the rule and the storage
|
|
407
|
+
* fail differently: a wrong count is a bug in one file, a wrong rule is a bug in
|
|
408
|
+
* every caller. `gate-rounds.mjs` owns the count; this owns the verdict.
|
|
409
|
+
*
|
|
410
|
+
* `rounds` is the number of rounds **including the one about to run**, so a cap of
|
|
411
|
+
* 2 allows rounds 1 and 2 and refuses 3.
|
|
412
|
+
*
|
|
413
|
+
* The refusal is `documented-stall`, and what its diagnosis can contain is narrower
|
|
414
|
+
* than the name suggests: the previous rounds' blockers are not persisted anywhere, so
|
|
415
|
+
* what survives a compaction is the round count. The `loop` skill states the gap; do
|
|
416
|
+
* not write this as if the blockers were available.
|
|
417
|
+
*
|
|
418
|
+
* It is deliberately NOT in `SKIP_CAUSES`: those are reasons an item was passed over
|
|
419
|
+
* during selection, and this is a reason a task ends. One vocabulary holding both
|
|
420
|
+
* would make every sentence about either one wrong.
|
|
421
|
+
*/
|
|
422
|
+
export const gateRoundVerdict = (rounds, max = DEFAULT_MAX_GATE_ROUNDS) => {
|
|
423
|
+
// A cap under 1 refuses the FIRST round, which turns the gate off rather than
|
|
424
|
+
// bounding it — the opposite of what this is for. Fail loudly on the
|
|
425
|
+
// configuration rather than quietly on every PR.
|
|
426
|
+
if (!Number.isInteger(max) || max < 1) {
|
|
427
|
+
throw new Error(
|
|
428
|
+
`maxGateRounds must be an integer of at least 1, got ${JSON.stringify(max)}. ` +
|
|
429
|
+
'A cap below 1 would refuse the first gate round, which disables the gate ' +
|
|
430
|
+
'instead of bounding it.',
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
const exceeded = rounds > max;
|
|
434
|
+
return { rounds, max, exceeded, stop: exceeded ? 'documented-stall' : null };
|
|
435
|
+
};
|
|
436
|
+
|
|
273
437
|
/**
|
|
274
438
|
* Pick the next item, or explain why nothing was taken.
|
|
275
439
|
*
|
|
276
440
|
* The elevated tier is rationed by **spacing, not counting**: a per-run count is
|
|
277
|
-
* meaningless when the run has no end. Never two elevated
|
|
278
|
-
* one unreviewed schema or permissions change is
|
|
279
|
-
* compounding overnight is not.
|
|
441
|
+
* meaningless when the run has no end. Never two mechanism-touching elevated
|
|
442
|
+
* items back to back — one unreviewed schema or permissions change is
|
|
443
|
+
* recoverable; a chain of them compounding overnight is not.
|
|
280
444
|
*/
|
|
281
445
|
export const selectNext = (tickets, { lastCompletedTier = null, triggersFired = null } = {}) => {
|
|
282
446
|
const skipped = [];
|
|
@@ -285,15 +449,21 @@ export const selectNext = (tickets, { lastCompletedTier = null, triggersFired =
|
|
|
285
449
|
for (const ticket of tickets) {
|
|
286
450
|
const selection = selectionOf(ticket, { triggersFired });
|
|
287
451
|
if (!selection.eligible) {
|
|
288
|
-
skipped.push({
|
|
452
|
+
skipped.push({
|
|
453
|
+
id: ticket.id,
|
|
454
|
+
reason: selection.reasons.join('; '),
|
|
455
|
+
causes: selection.causes,
|
|
456
|
+
});
|
|
289
457
|
continue;
|
|
290
458
|
}
|
|
291
|
-
if (ticket.tier === 'elevated' && lastCompletedTier
|
|
459
|
+
if (ticket.tier === 'elevated' && !clearsSpacing(lastCompletedTier)) {
|
|
292
460
|
skipped.push({
|
|
293
461
|
id: ticket.id,
|
|
294
462
|
reason:
|
|
295
|
-
|
|
296
|
-
'to back. Land a normal item
|
|
463
|
+
`elevated, and the last completed change (${JSON.stringify(lastCompletedTier)}) ` +
|
|
464
|
+
'did not clear the ration — never two back to back. Land a normal item, or ' +
|
|
465
|
+
'an elevated change that is only prose, on a healthy runtime first.',
|
|
466
|
+
causes: ['spacing'],
|
|
297
467
|
});
|
|
298
468
|
continue;
|
|
299
469
|
}
|
|
@@ -304,6 +474,129 @@ export const selectNext = (tickets, { lastCompletedTier = null, triggersFired =
|
|
|
304
474
|
return { ticket, skipped, candidates: candidates.length };
|
|
305
475
|
};
|
|
306
476
|
|
|
477
|
+
/**
|
|
478
|
+
* Split the skipped records into the ones holding takeable work back and the
|
|
479
|
+
* ones that are simply out of play.
|
|
480
|
+
*
|
|
481
|
+
* 🔴 **A parked cause outranks a holding one, per record** — and this precedence
|
|
482
|
+
* is the whole mechanism, not a detail. An escalated item is left CLAIMED on
|
|
483
|
+
* purpose (`escalate` labels it and nothing more), so on a tracker-backed
|
|
484
|
+
* adapter it arrives carrying `['in-progress', 'escalated']`. Let the holding
|
|
485
|
+
* cause win and every escalated item reads as "another session will finish it" —
|
|
486
|
+
* an item no session is on, which only a human clears — and `queue-empty` is
|
|
487
|
+
* unreachable from the first escalation onward. That is the exact defect this
|
|
488
|
+
* split exists to remove, one label over.
|
|
489
|
+
*
|
|
490
|
+
* The rule generalises past that case: a parked cause says the item does not
|
|
491
|
+
* come back into play without a human, and that outlasts any condition which
|
|
492
|
+
* would clear on its own.
|
|
493
|
+
*
|
|
494
|
+
* A record with no cause this module recognises counts as **held**: it is the
|
|
495
|
+
* reading that stops rather than the one that declares the queue drained, and an
|
|
496
|
+
* unclassified skip is exactly where a wrong declaration would come from.
|
|
497
|
+
*
|
|
498
|
+
* Bounded by construction: one forward pass, no recursion, and every array it
|
|
499
|
+
* builds is bounded by the input it walks.
|
|
500
|
+
*/
|
|
501
|
+
const partitionSkipped = (skipped) => {
|
|
502
|
+
const held = [];
|
|
503
|
+
const parked = [];
|
|
504
|
+
for (const skip of skipped) {
|
|
505
|
+
const causes = Array.isArray(skip?.causes) ? skip.causes : [];
|
|
506
|
+
const parking = causes.find(
|
|
507
|
+
(cause) => SKIP_CAUSES.includes(cause) && !HOLDING_CAUSES.includes(cause),
|
|
508
|
+
);
|
|
509
|
+
if (parking) {
|
|
510
|
+
parked.push(parking);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
held.push(causes.find((cause) => HOLDING_CAUSES.includes(cause)) ?? 'an unnamed filter');
|
|
514
|
+
}
|
|
515
|
+
return { held, parked };
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* "3 X, 1 Y" — a list of causes counted, as a partition.
|
|
520
|
+
*
|
|
521
|
+
* Each item counts ONCE, under the one cause it was classified by, so the parts
|
|
522
|
+
* foot to the total beside them. Counting it under every cause it carries would
|
|
523
|
+
* read as a breakdown and silently sum past that number.
|
|
524
|
+
*
|
|
525
|
+
* Bounded by construction: one forward pass, and a tally whose key set is the
|
|
526
|
+
* closed vocabulary plus one bucket for a record that carries no tag at all.
|
|
527
|
+
*/
|
|
528
|
+
const breakdownOf = (causes, label = (count, tag) => `${count} ${tag}`) => {
|
|
529
|
+
const tally = new Map();
|
|
530
|
+
for (const cause of causes) tally.set(cause, (tally.get(cause) ?? 0) + 1);
|
|
531
|
+
return [...tally.entries()]
|
|
532
|
+
.sort(([tagA, countA], [tagB, countB]) => countB - countA || tagA.localeCompare(tagB))
|
|
533
|
+
.map(([tag, count]) => label(count, tag))
|
|
534
|
+
.join(', ');
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
const heldBreakdown = (held) => breakdownOf(held, (count, tag) => `${count} held by ${tag}`);
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* The parked pile, named rather than left to grow unseen.
|
|
541
|
+
*
|
|
542
|
+
* Reported beside the held count and never summed with it: they ask the owner
|
|
543
|
+
* for different things, and one number covering both would ask for neither.
|
|
544
|
+
*
|
|
545
|
+
* It names the causes the pile actually carries rather than the vocabulary it
|
|
546
|
+
* could have carried. A fixed list would announce `closed` — which no adapter
|
|
547
|
+
* can present, since every one of them drops closed items before selection —
|
|
548
|
+
* while staying silent about which of the reachable ones this pile is made of.
|
|
549
|
+
*
|
|
550
|
+
* It also stops at what it can see. How the pile GROWS differs per adapter (see
|
|
551
|
+
* `HOLDING_CAUSES` above), so a line claiming one mechanism would be false on
|
|
552
|
+
* another, and the stop line is not where that belongs.
|
|
553
|
+
*/
|
|
554
|
+
const parkedNote = (parked) =>
|
|
555
|
+
parked.length === 0
|
|
556
|
+
? ''
|
|
557
|
+
: ` A further ${parked.length} item(s) are parked — ${breakdownOf(parked)}. ` +
|
|
558
|
+
'Those are not work this run can take and they wait on a human, never on ' +
|
|
559
|
+
'time.';
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* The trigger remedies, composed from the tags actually present.
|
|
563
|
+
*
|
|
564
|
+
* 🔴 **Why this is a function and not a sentence.** The two trigger kinds hold an
|
|
565
|
+
* item back through different mechanisms and are freed by different acts: an
|
|
566
|
+
* `auto` item waits for a declaration to be RECORDED, a `human` item is never
|
|
567
|
+
* self-taken at all and only a human editing the item's own marker frees it.
|
|
568
|
+
* When one cause tag stood for both, every fixed sentence keyed off it was
|
|
569
|
+
* wrong for one kind — the clause was rewritten three times, each revision
|
|
570
|
+
* repairing one sub-case and leaving the other, until the tags were split. So
|
|
571
|
+
* the remedy now follows the pile: unreachable unless the tag that earns it is
|
|
572
|
+
* in it, which makes a one-sided line structurally unavailable rather than
|
|
573
|
+
* merely discouraged.
|
|
574
|
+
*
|
|
575
|
+
* The cost of getting this wrong is not a confusing sentence. Told to record a
|
|
576
|
+
* declaration for a `human` item, an operator runs a command that reports
|
|
577
|
+
* success, changes nothing, and — under an adapter whose ids are list positions
|
|
578
|
+
* — leaves a live record that arms whatever occupies that slot next.
|
|
579
|
+
*/
|
|
580
|
+
const triggerNote = (held) => {
|
|
581
|
+
const auto = held.includes('trigger-auto');
|
|
582
|
+
const human = held.includes('trigger-human');
|
|
583
|
+
if (!auto && !human) return '';
|
|
584
|
+
return (
|
|
585
|
+
' A trigger is the exception, and the two kinds are freed differently:' +
|
|
586
|
+
(auto
|
|
587
|
+
? ' a trigger-auto item waits for the declaration to be RECORDED — ' +
|
|
588
|
+
'`node .claude/scripts/run-state.mjs trigger <item-id>` — so waiting it ' +
|
|
589
|
+
'out waits forever;'
|
|
590
|
+
: '') +
|
|
591
|
+
(human
|
|
592
|
+
? ' an item held as trigger-human is not freed by recording a declaration ' +
|
|
593
|
+
"— that does nothing here; only a human changing the item's own marker " +
|
|
594
|
+
'frees it;'
|
|
595
|
+
: '') +
|
|
596
|
+
' both are declarations, not delays.'
|
|
597
|
+
);
|
|
598
|
+
};
|
|
599
|
+
|
|
307
600
|
/**
|
|
308
601
|
* Should the whole run stop? Checked in severity order, because a regression must
|
|
309
602
|
* not be reported as an empty queue.
|
|
@@ -314,6 +607,7 @@ export const selectNext = (tickets, { lastCompletedTier = null, triggersFired =
|
|
|
314
607
|
*/
|
|
315
608
|
export const stopConditionOf = ({
|
|
316
609
|
candidates = 0,
|
|
610
|
+
skipped = [],
|
|
317
611
|
lastDeployVerdict = null,
|
|
318
612
|
consecutiveEscalations = 0,
|
|
319
613
|
killSwitch = false,
|
|
@@ -370,13 +664,29 @@ export const stopConditionOf = ({
|
|
|
370
664
|
};
|
|
371
665
|
}
|
|
372
666
|
if (candidates === 0) {
|
|
667
|
+
const { held, parked } = partitionSkipped(skipped);
|
|
668
|
+
if (held.length > 0) {
|
|
669
|
+
return {
|
|
670
|
+
kind: 'nothing-selectable',
|
|
671
|
+
success: true,
|
|
672
|
+
why:
|
|
673
|
+
`${held.length} item(s) are takeable work held back right now — ` +
|
|
674
|
+
`${heldBreakdown(held)}.${parkedNote(parked)} This is NOT an empty queue, ` +
|
|
675
|
+
'and the two ask for opposite things: an empty queue wants refilling, ' +
|
|
676
|
+
'whereas this one still holds work. Spacing clears when a normal item ' +
|
|
677
|
+
'lands, a blocker when its item closes, in-progress when the other ' +
|
|
678
|
+
`session finishes.${triggerNote(held)} Otherwise the action is to ` +
|
|
679
|
+
'interleave or to wait, never to refill and never to invent work.',
|
|
680
|
+
};
|
|
681
|
+
}
|
|
373
682
|
return {
|
|
374
683
|
kind: 'queue-empty',
|
|
375
684
|
success: true,
|
|
376
685
|
why:
|
|
377
|
-
'no item survives the filters
|
|
378
|
-
|
|
379
|
-
|
|
686
|
+
'no item survives the filters and nothing is merely held back — the queue ' +
|
|
687
|
+
`is genuinely out of work.${parkedNote(parked)} This is a legitimate end of ` +
|
|
688
|
+
'session, not an invitation to refactor: **do not invent work**. Refilling ' +
|
|
689
|
+
"the queue is the owner's job.",
|
|
380
690
|
};
|
|
381
691
|
}
|
|
382
692
|
return null;
|
|
@@ -385,9 +695,10 @@ export const stopConditionOf = ({
|
|
|
385
695
|
/**
|
|
386
696
|
* The stable fingerprint of an improvement proposal.
|
|
387
697
|
*
|
|
388
|
-
* Under a scheduler against a finite queue the most common
|
|
389
|
-
*
|
|
390
|
-
*
|
|
698
|
+
* Under a scheduler against a finite queue the most common stops are the two that
|
|
699
|
+
* hand out nothing — "queue empty" and "nothing selectable"; twenty such stops
|
|
700
|
+
* must produce ONE proposal with a count of twenty, not twenty proposals. Dedupe
|
|
701
|
+
* by fingerprint, then increment.
|
|
391
702
|
*/
|
|
392
703
|
export const fingerprintOf = ({ finding, part, change }) =>
|
|
393
704
|
[finding, part, change]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many times a branch has entered the review gate.
|
|
3
|
+
*
|
|
4
|
+
* 🔴 **Its own file, and that is the whole design.** The item that asked for this
|
|
5
|
+
* named `queue.state.json` — the file holding `lastCompletedTier` — as the place to
|
|
6
|
+
* put the count. A review round measured what that costs: `recordCompletedTier`
|
|
7
|
+
* writes that file whole, so either the two writers merge or the first close of any
|
|
8
|
+
* item deletes the round count of every branch still in gate. Merging was
|
|
9
|
+
* implemented, and then it turned out to be worse: a state file that fails to parse
|
|
10
|
+
* reads as `{}` to a merging writer, which then writes its snapshot back and the
|
|
11
|
+
* tier is **silently gone** — and a missing tier is the permissive value that lets a
|
|
12
|
+
* second elevated item straight through. A lost round costs one extra review; a lost
|
|
13
|
+
* tier disables the ration the queue is spaced by.
|
|
14
|
+
*
|
|
15
|
+
* Two files, one writer each. The counter cannot damage the ration and `state.mjs`
|
|
16
|
+
* goes back to owning its file outright — so a lost round is now the worst this
|
|
17
|
+
* mechanism can do, where before it could lose the tier.
|
|
18
|
+
*
|
|
19
|
+
* ⚠ **What that does NOT fix, measured rather than reasoned:** `recordGateRound` is
|
|
20
|
+
* read-modify-write with no lock. Eight concurrent calls on one counter recorded
|
|
21
|
+
* **four** rounds — the file is shared across every worktree of a repo by design
|
|
22
|
+
* (`gateRoundsPathFor`), so this is reachable whenever two loops run in one
|
|
23
|
+
* repository at once, not only in one directory. Each lost increment buys one extra
|
|
24
|
+
* allowed round. A lock is not worth it for that: the loop is sequential per task,
|
|
25
|
+
* and the failure is bounded and in the generous direction. What was worth fixing is
|
|
26
|
+
* the crash it came with — a fixed temp filename made the losers of that race fail
|
|
27
|
+
* with `ENOENT` on rename, reporting "could not run" for a condition nothing named.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { renameSync, readFileSync, writeFileSync } from 'node:fs';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
|
|
33
|
+
import { mainCheckoutRoot } from './checkout.mjs';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Where the counter lives, unless a caller names the file.
|
|
37
|
+
*
|
|
38
|
+
* The default lands in the MAIN checkout even when the gate runs inside a worktree
|
|
39
|
+
* — the same rule `state.mjs` follows, and for the same reason: a task's worktree is
|
|
40
|
+
* shorter-lived than the task.
|
|
41
|
+
*/
|
|
42
|
+
export const gateRoundsPathFor = (projectRoot) =>
|
|
43
|
+
join(mainCheckoutRoot(projectRoot), '.claude', 'gate-rounds.json');
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The counts, keyed by branch.
|
|
47
|
+
*
|
|
48
|
+
* An absent file is zero rounds — the normal state of a fresh checkout. A file that
|
|
49
|
+
* exists and does not parse is **refused**, not read as zero: reading it as zero
|
|
50
|
+
* hands the branch a full cap again, and it does so at the exact moment something is
|
|
51
|
+
* already wrong with the file.
|
|
52
|
+
*/
|
|
53
|
+
const readRounds = (file) => {
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = readFileSync(file, 'utf8');
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return Object.create(null);
|
|
59
|
+
throw new Error(
|
|
60
|
+
`${file} exists but could not be read (${error?.code ?? 'unknown error'}), so the ` +
|
|
61
|
+
'gate rounds for this branch are unknown. Refusing rather than starting the ' +
|
|
62
|
+
'count again, which would hand this branch a full cap.',
|
|
63
|
+
{ cause: error },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let parsed;
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(raw);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`${file} is not valid JSON, so the gate rounds cannot be read: ` +
|
|
73
|
+
`${String(error?.message ?? error).split('\n')[0]}. Delete the file to start ` +
|
|
74
|
+
'the count over — deliberately, rather than by accident.',
|
|
75
|
+
{ cause: error },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`${file} is valid JSON but not an object of branch counts, so the gate rounds ` +
|
|
81
|
+
'cannot be read. Expected `{ "<branch>": <positive integer> }`. Delete the ' +
|
|
82
|
+
'file to start the count over.',
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A single malformed entry is dropped rather than refused: one bad key costs that
|
|
87
|
+
// branch a fresh cap, while refusing would block every branch in the checkout.
|
|
88
|
+
//
|
|
89
|
+
// 🔴 `Object.create(null)`, not `{}`. A branch named `__proto__` or `constructor`
|
|
90
|
+
// otherwise resolves to an inherited value rather than a count, so the increment
|
|
91
|
+
// produced `"[object Object]1"` — a string, which every numeric comparison against
|
|
92
|
+
// the cap answers `false`, i.e. unlimited rounds. Absurd branch name, real class of
|
|
93
|
+
// defect, and the failure was silent.
|
|
94
|
+
const clean = Object.create(null);
|
|
95
|
+
for (const [branch, count] of Object.entries(parsed)) {
|
|
96
|
+
if (Number.isInteger(count) && count > 0) clean[branch] = count;
|
|
97
|
+
}
|
|
98
|
+
return clean;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The branch a count belongs to.
|
|
103
|
+
*
|
|
104
|
+
* 🔴 `HEAD` is refused, and it is the case this check exists for. On a detached
|
|
105
|
+
* checkout — mid-rebase, after `gh pr checkout` of a fork, in CI —
|
|
106
|
+
* `git rev-parse --abbrev-ref HEAD` prints the literal string `HEAD`, which is a
|
|
107
|
+
* non-empty string and would key one shared counter for every task in the
|
|
108
|
+
* directory. A review round found exactly that hole under a comment claiming it was
|
|
109
|
+
* closed.
|
|
110
|
+
*/
|
|
111
|
+
const requireBranch = (branch) => {
|
|
112
|
+
if (typeof branch !== 'string' || branch.trim() === '') {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`a gate round is counted per branch, and the branch was ${JSON.stringify(branch)}.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (branch.trim() === 'HEAD') {
|
|
118
|
+
throw new Error(
|
|
119
|
+
'the branch is the literal "HEAD", which is what a detached checkout reports. ' +
|
|
120
|
+
'Counting under it would share one budget across every task in this ' +
|
|
121
|
+
'directory. Check out the branch under review, or pass its name explicitly.',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return branch.trim();
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** How many rounds this branch has recorded. Zero when the file is absent. */
|
|
128
|
+
export const gateRoundsFor = ({ branch, projectRoot, roundsPath } = {}) =>
|
|
129
|
+
readRounds(roundsPath ?? gateRoundsPathFor(projectRoot))[requireBranch(branch)] ?? 0;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Count one round for this branch and return the new total.
|
|
133
|
+
*
|
|
134
|
+
* Write-then-rename, because a plain write leaves a window where the file on disk is
|
|
135
|
+
* half a JSON document — and the reader above refuses such a file, which would turn
|
|
136
|
+
* a crash mid-write into a gate nobody can run. `run-state.mjs` reached the same
|
|
137
|
+
* conclusion about the same class of file.
|
|
138
|
+
*
|
|
139
|
+
* The temp name carries the pid. A fixed `${file}.tmp` was measured failing: under
|
|
140
|
+
* concurrent calls the first rename consumed the shared temp file and the rest died
|
|
141
|
+
* with `ENOENT` on rename, which this command reports as "could not run" — a
|
|
142
|
+
* diagnosis for a cause nothing named. Per-pid temps make a concurrent loser lose
|
|
143
|
+
* only its increment, which is the bounded failure the docblock above states.
|
|
144
|
+
*/
|
|
145
|
+
export const recordGateRound = ({ branch, projectRoot, roundsPath } = {}) => {
|
|
146
|
+
const key = requireBranch(branch);
|
|
147
|
+
const file = roundsPath ?? gateRoundsPathFor(projectRoot);
|
|
148
|
+
const rounds = readRounds(file);
|
|
149
|
+
const next = (rounds[key] ?? 0) + 1;
|
|
150
|
+
|
|
151
|
+
// Spread into a null-prototype object for the same reason `readRounds` builds one:
|
|
152
|
+
// `{ ...rounds }` would give the result a prototype again, and `JSON.stringify`
|
|
153
|
+
// then writes a `__proto__` key that the next read cannot see as its own.
|
|
154
|
+
const updated = Object.assign(Object.create(null), rounds, { [key]: next });
|
|
155
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
156
|
+
writeFileSync(temp, `${JSON.stringify(updated, null, 2)}\n`);
|
|
157
|
+
renameSync(temp, file);
|
|
158
|
+
|
|
159
|
+
return { rounds: next };
|
|
160
|
+
};
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// because it is re-resolved from the blockers themselves on every selection.
|
|
16
16
|
import { execFileSync } from 'node:child_process';
|
|
17
17
|
import { duplicateOf, fingerprintOf, validateProposal } from './core.mjs';
|
|
18
|
+
import { recordEscalation } from '../run-state.mjs';
|
|
18
19
|
|
|
19
20
|
export const name = 'github-issues';
|
|
20
21
|
|
|
@@ -168,9 +169,12 @@ export const comment = (ticket, body) => {
|
|
|
168
169
|
* selection cannot pick it up again. It stays OPEN and stays claimed — moving it
|
|
169
170
|
* back to a selectable state is how one stuck task gets worked three times.
|
|
170
171
|
*/
|
|
171
|
-
export const escalate = (ticket, diagnosis) => {
|
|
172
|
+
export const escalate = (ticket, diagnosis, { env = process.env } = {}) => {
|
|
172
173
|
ghText(['issue', 'comment', ticket.id, '--body', diagnosis]);
|
|
173
174
|
ghText(['issue', 'edit', ticket.id, '--add-label', 'escalated']);
|
|
175
|
+
// Counted through the one recorder, never a counter of this adapter's own —
|
|
176
|
+
// "twice in a row" has to mean the same thing on every tracker.
|
|
177
|
+
recordEscalation(env.RIG_RUN_DIR);
|
|
174
178
|
return { ok: true };
|
|
175
179
|
};
|
|
176
180
|
|
|
@@ -208,7 +212,8 @@ export const triageItemFor = (proposal) => {
|
|
|
208
212
|
/**
|
|
209
213
|
* File the proposal — or increment the one already there.
|
|
210
214
|
*
|
|
211
|
-
* Under a scheduler against a finite queue the most common
|
|
215
|
+
* Under a scheduler against a finite queue the most common stops are the two that
|
|
216
|
+
* hand out nothing — "queue empty" and "nothing selectable";
|
|
212
217
|
* twenty such stops must produce one proposal with a count of twenty.
|
|
213
218
|
*/
|
|
214
219
|
export const proposeTriage = (proposal, { existing = null } = {}) => {
|