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
@@ -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
+ };
@@ -14,7 +14,9 @@
14
14
  // be a snapshot that nothing updates when the blocker lands; this cannot go stale,
15
15
  // because it is re-resolved from the blockers themselves on every selection.
16
16
  import { execFileSync } from 'node:child_process';
17
- import { duplicateOf, fingerprintOf, validateProposal } from './core.mjs';
17
+ import { duplicateOf, fingerprintOf, lifecycleOf, ownerOfLabels, validateProposal } from './core.mjs';
18
+ import { withAsOf } from './as-of.mjs';
19
+ import { recordEscalation, recordTakeUp } from '../run-state.mjs';
18
20
 
19
21
  export const name = 'github-issues';
20
22
 
@@ -74,6 +76,11 @@ export const toTicket = (issue, states = {}) => {
74
76
  blocks: [],
75
77
  priority: priorityLabel ? Number(priorityLabel[1]) : 999,
76
78
  createdAt: issue.createdAt ?? null,
79
+ // The take-up marker for revalidation at SELECT: the tracker's own
80
+ // last-modified field, whose contract (moves on edits, comments and state
81
+ // changes) is assumed and not checked here. `null` when the listing did not
82
+ // carry it.
83
+ updatedAt: issue.updatedAt ?? null,
77
84
  // The body travels on the neutral shape so the hygiene checks live in one
78
85
  // place (core.mjs) instead of once per adapter. This adapter also parses it
79
86
  // internally for blocker links — the two readings are independent on
@@ -86,6 +93,10 @@ export const toTicket = (issue, states = {}) => {
86
93
  : labels.includes('trigger-human')
87
94
  ? 'human'
88
95
  : null,
96
+ // The repository this item belongs to (AR-132): `owner-<name>`, or null.
97
+ owner: ownerOfLabels(labels),
98
+ // The lifecycle and the scheduling flag (AR-144), read above the seam.
99
+ ...lifecycleOf(labels),
89
100
  };
90
101
  };
91
102
 
@@ -119,7 +130,7 @@ const ghText = (args) =>
119
130
 
120
131
  const ghJson = (args) => JSON.parse(ghText(args));
121
132
 
122
- const FIELDS = 'number,title,body,state,labels,url,createdAt';
133
+ const FIELDS = 'number,title,body,state,labels,url,createdAt,updatedAt';
123
134
 
124
135
  // --- the adapter contract ------------------------------------------------------
125
136
 
@@ -145,21 +156,63 @@ export const listEligible = ({ limit = 100, issues = null } = {}) => {
145
156
  export const resolveBlockers = (ticket) => (ticket.blockedBy ?? []).filter((b) => !b.resolved);
146
157
 
147
158
  /** `To Do → In Progress` before the first file is edited, not when the PR opens. */
159
+ /**
160
+ * Re-record the item's marker after a write of this adapter's own (AR-140) —
161
+ * the same rule and the same limit as `jira.mjs` › rebaseline: only writes
162
+ * made through this adapter re-baseline, and a read-back that fails is
163
+ * announced, never thrown.
164
+ */
165
+ const rebaseline = (ticket) => {
166
+ const runDir = process.env.RIG_RUN_DIR;
167
+ if (!runDir) return;
168
+ try {
169
+ const after = ghJson(['issue', 'view', ticket.id, '--json', 'updatedAt']);
170
+ recordTakeUp(runDir, { id: ticket.id, updatedAt: after?.updatedAt ?? null });
171
+ } catch (error) {
172
+ process.stderr.write(
173
+ `#${ticket.id}: the write landed, but its marker was NOT re-recorded in ${runDir} — ` +
174
+ `${error.message}\n`,
175
+ );
176
+ }
177
+ };
178
+
148
179
  export const claim = (ticket) => {
149
180
  ghText(['issue', 'edit', ticket.id, '--add-label', 'in-progress']);
181
+ rebaseline(ticket);
150
182
  return { ok: true };
151
183
  };
152
184
 
185
+ /**
186
+ * One issue by number, closed included — `gh issue view` sees every state,
187
+ * where `listEligible` drops CLOSED for selection's sake. The offline `issues`
188
+ * seam is honoured. `blocks` is empty here: the cross-index needs the whole
189
+ * list, and a single view does not carry it.
190
+ */
191
+ export const find = (id, { issues = null } = {}) => {
192
+ const raw = issues
193
+ ? (issues.find((issue) => String(issue.number) === String(id)) ?? null)
194
+ : ghJson(['issue', 'view', String(id), '--json', FIELDS]);
195
+ return raw ? toTicket(raw, {}) : null;
196
+ };
197
+
153
198
  export const close = (ticket, { prUrl = null } = {}) => {
154
199
  const note = prUrl ? `Landed in ${prUrl}.` : 'Closed by the run.';
155
200
  ghText(['issue', 'comment', ticket.id, '--body', note]);
156
201
  ghText(['issue', 'close', ticket.id]);
157
- ghText(['issue', 'edit', ticket.id, '--remove-label', 'in-progress']);
158
- return { ok: true };
202
+ // Read back, never inferred: `gh issue close` exits 0 on an issue that was
203
+ // already closed, or that a workflow reopened a moment later (AR-135). The
204
+ // claim label goes only once the read-back says CLOSED — a close that did
205
+ // not land leaves the item claimed, exactly as it was found.
206
+ const after = ghJson(['issue', 'view', ticket.id, '--json', 'state']);
207
+ const transitioned = String(after?.state ?? '').toUpperCase() === 'CLOSED';
208
+ if (transitioned) ghText(['issue', 'edit', ticket.id, '--remove-label', 'in-progress']);
209
+ rebaseline(ticket);
210
+ return { ok: true, transitioned };
159
211
  };
160
212
 
161
213
  export const comment = (ticket, body) => {
162
214
  ghText(['issue', 'comment', ticket.id, '--body', body]);
215
+ rebaseline(ticket);
163
216
  return { ok: true };
164
217
  };
165
218
 
@@ -168,9 +221,13 @@ export const comment = (ticket, body) => {
168
221
  * selection cannot pick it up again. It stays OPEN and stays claimed — moving it
169
222
  * back to a selectable state is how one stuck task gets worked three times.
170
223
  */
171
- export const escalate = (ticket, diagnosis) => {
224
+ export const escalate = (ticket, diagnosis, { env = process.env } = {}) => {
172
225
  ghText(['issue', 'comment', ticket.id, '--body', diagnosis]);
173
226
  ghText(['issue', 'edit', ticket.id, '--add-label', 'escalated']);
227
+ rebaseline(ticket);
228
+ // Counted through the one recorder, never a counter of this adapter's own —
229
+ // "twice in a row" has to mean the same thing on every tracker.
230
+ recordEscalation(env.RIG_RUN_DIR);
174
231
  return { ok: true };
175
232
  };
176
233
 
@@ -192,8 +249,10 @@ export const triageItemFor = (proposal) => {
192
249
  `- **part to change** — ${proposal.part}`,
193
250
  `- **proposed change** — ${proposal.change}`,
194
251
  `- **how the next run proves it** — ${proposal.proof}`,
252
+ ...(proposal.measured ? [`- **measured** — ${proposal.measured}`, `- **inferred** — ${proposal.inferred}`] : []),
195
253
  '',
196
254
  `fingerprint: ${fingerprint}`,
255
+ ...(proposal.asOf ? [`asOf: ${proposal.asOf}`] : []),
197
256
  '',
198
257
  'The loop proposes; the owner patches. Self-applying a change to its own',
199
258
  'rulebook is how an unattended run drifts irreversibly — and it collides with',
@@ -208,18 +267,21 @@ export const triageItemFor = (proposal) => {
208
267
  /**
209
268
  * File the proposal — or increment the one already there.
210
269
  *
211
- * Under a scheduler against a finite queue the most common stop is "queue empty";
270
+ * Under a scheduler against a finite queue the most common stops are the two that
271
+ * hand out nothing — "queue empty" and "nothing selectable";
212
272
  * twenty such stops must produce one proposal with a count of twenty.
213
273
  */
214
- export const proposeTriage = (proposal, { existing = null } = {}) => {
215
- const item = triageItemFor(proposal);
216
- const found =
274
+ /** The proposals on file, as `{ id, body }` every `triage`-labelled issue. */
275
+ export const listProposals = ({ existing = null } = {}) =>
276
+ (
217
277
  existing ??
218
- ghJson(['issue', 'list', '--label', 'triage', '--state', 'all', '--limit', '100', '--json', FIELDS]);
219
- const duplicate = duplicateOf(
220
- item,
221
- found.map((issue) => ({ id: String(issue.number), body: issue.body })),
222
- );
278
+ ghJson(['issue', 'list', '--label', 'triage', '--state', 'all', '--limit', '100', '--json', FIELDS])
279
+ ).map((issue) => ({ id: String(issue.number), body: issue.body }));
280
+
281
+ export const proposeTriage = (rawProposal, { existing = null } = {}) => {
282
+ const proposal = withAsOf(rawProposal);
283
+ const item = triageItemFor(proposal);
284
+ const duplicate = duplicateOf(item, listProposals({ existing }));
223
285
 
224
286
  if (duplicate) {
225
287
  ghText([
@@ -232,6 +294,23 @@ export const proposeTriage = (proposal, { existing = null } = {}) => {
232
294
  return { ok: true, incremented: String(duplicate.number), item };
233
295
  }
234
296
 
235
- ghText(['issue', 'create', '--title', item.title, '--body', item.body, '--label', 'triage']);
236
- return { ok: true, filed: item.title, item };
297
+ const url = ghText(['issue', 'create', '--title', item.title, '--body', item.body, '--label', 'triage']);
298
+ // The proposal's own baseline (AR-138), as in jira.mjs: `gh issue create`
299
+ // prints the new issue's URL, whose last segment is its number; the marker
300
+ // is read back with `gh issue view`. No run directory → nothing recorded.
301
+ // Best-effort for the same reason as there: the issue exists by now, and a
302
+ // throw would make the caller file it again.
303
+ const id = /\/(\d+)\s*$/.exec(String(url ?? ''))?.[1] ?? null;
304
+ if (id && process.env.RIG_RUN_DIR) {
305
+ try {
306
+ const after = ghJson(['issue', 'view', id, '--json', 'updatedAt']);
307
+ recordTakeUp(process.env.RIG_RUN_DIR, { id, updatedAt: after?.updatedAt ?? null });
308
+ } catch (error) {
309
+ process.stderr.write(
310
+ `proposeTriage: #${id} is filed, but its baseline was NOT recorded in ` +
311
+ `${process.env.RIG_RUN_DIR} — ${error.message}\n`,
312
+ );
313
+ }
314
+ }
315
+ return { ok: true, filed: item.title, id, item };
237
316
  };