@skyf0xx/hedgehog 4.0.14 → 4.1.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/bin/cli.mjs CHANGED
@@ -933,10 +933,10 @@ async function nextCommand() {
933
933
 
934
934
  const db = openDb();
935
935
  let packet;
936
- let stalled = [];
936
+ let stalled;
937
937
  try {
938
938
  packet = nextTask(db);
939
- if (!packet) stalled = stalledTasks(db);
939
+ stalled = stalledTasks(db);
940
940
  } finally {
941
941
  db.close();
942
942
  }
@@ -947,10 +947,7 @@ async function nextCommand() {
947
947
  // a failed verification.
948
948
  if (stalled.length > 0) {
949
949
  console.error(`${red(bold('No ready task, but the graph is blocked.'))}\n`);
950
- for (const task of stalled) {
951
- const reason = BLOCKED_REASON_LABELS[task.blocked_reason] ?? task.blocked_reason;
952
- console.error(` ${red('✗')} ${bold(task.id)} ${task.layer} ${dim(reason)}`);
953
- }
950
+ printStalledTasks(stalled);
954
951
  // `verify` refuses anything that isn't `building` and leased to
955
952
  // the caller, so a blocked task has to go back through the queue
956
953
  // — retry, claim, then verify — rather than straight to verify.
@@ -964,9 +961,28 @@ async function nextCommand() {
964
961
  return;
965
962
  }
966
963
 
964
+ // A blocked task elsewhere in the graph doesn't stop this ready task
965
+ // from being handed out — leases are scoped to disjoint work, so an
966
+ // unrelated block is no reason to halt everything else. But it's easy
967
+ // to miss otherwise: the queue keeps producing ready tasks right up
968
+ // until it doesn't, and a block can sit unnoticed the whole time. This
969
+ // warns without withholding the packet.
970
+ if (stalled.length > 0) {
971
+ console.error(`${yellow(bold(`${stalled.length} task(s) blocked elsewhere in the graph:`))}`);
972
+ printStalledTasks(stalled);
973
+ console.error('');
974
+ }
975
+
967
976
  console.log(formatNext(packet));
968
977
  }
969
978
 
979
+ function printStalledTasks(stalled) {
980
+ for (const task of stalled) {
981
+ const reason = BLOCKED_REASON_LABELS[task.blocked_reason] ?? task.blocked_reason;
982
+ console.error(` ${red('✗')} ${bold(task.id)} ${task.layer} ${dim(reason)}`);
983
+ }
984
+ }
985
+
970
986
  // Mirrors, read-only, the condition verifyTask's own Phase 0
971
987
  // (claimForVerify) enforces: after expired leases are reaped, the task
972
988
  // must be `building` and leased to `owner`. The expiry predicate is
@@ -1185,13 +1201,30 @@ async function claimCommand(args) {
1185
1201
  }
1186
1202
 
1187
1203
  const db = openDb();
1188
- let claimed;
1204
+ let claimed, blocked;
1189
1205
  try {
1190
- claimed = claimTasks(db, { owner, count });
1206
+ ({ claimed, blocked } = claimTasks(db, { owner, count }));
1191
1207
  } finally {
1192
1208
  db.close();
1193
1209
  }
1194
1210
 
1211
+ // Stop-the-line: claimTasks refuses the whole batch, in any module,
1212
+ // while any task anywhere is blocked — see its own comment in
1213
+ // claim.mjs. A targeted `hedgehog claim <task-id>` is unaffected, which
1214
+ // is how the blocked task itself gets reclaimed after `hedgehog retry`.
1215
+ if (blocked.length > 0) {
1216
+ console.error(`${red(bold('Claim refused.'))} ${blocked.length} task(s) blocked:\n`);
1217
+ for (const task of blocked) {
1218
+ const reason = BLOCKED_REASON_LABELS[task.blocked_reason] ?? task.blocked_reason;
1219
+ console.error(` ${red('✗')} ${bold(task.id)} ${task.layer} ${dim(reason)}`);
1220
+ }
1221
+ console.error(
1222
+ `\nFix the work, then ${bold('hedgehog retry <task-id>')} before claiming more.\n`,
1223
+ );
1224
+ process.exitCode = 1;
1225
+ return;
1226
+ }
1227
+
1195
1228
  if (claimed.length === 0) {
1196
1229
  console.log(`${dim('No claimable task.')} Nothing is ready with no lease held.\n`);
1197
1230
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "4.0.14",
3
+ "version": "4.1.0",
4
4
  "description": "Install the Hedgehog build discipline (agents + skills) into a repo, for Claude Code, Cursor, or Gemini CLI.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -44,4 +44,4 @@
44
44
  "hedgehog"
45
45
  ],
46
46
  "license": "MIT"
47
- }
47
+ }
package/src/db/claim.mjs CHANGED
@@ -180,16 +180,25 @@ function loadTask(db, taskId) {
180
180
  // concurrent `claim` call in the interim would otherwise reach `verify`
181
181
  // with a technically-expired lease that still matches status/lease_owner
182
182
  // and sail through unreaped.
183
+ //
184
+ // Returns the ids it just flipped, as a Set — claimTasks's stop-the-line
185
+ // check uses this to tell a block this very call produced from one that
186
+ // was already sitting there, so a dead agent's lease lapsing doesn't
187
+ // itself become the reason every other module's fan-out refuses.
183
188
  export function reapExpiredLeases(db) {
184
- db.prepare(
185
- `
189
+ const rows = db
190
+ .prepare(
191
+ `
186
192
  UPDATE tasks
187
193
  SET status = 'blocked', blocked_reason = 'lease_expired',
188
194
  lease_owner = NULL, lease_expires_at = NULL, leased_at = NULL,
189
195
  claim_snapshot = NULL
190
196
  WHERE status IN ('building', 'verifying') AND lease_expires_at < datetime('now')
197
+ RETURNING id
191
198
  `,
192
- ).run();
199
+ )
200
+ .all();
201
+ return new Set(rows.map((r) => r.id));
193
202
  }
194
203
 
195
204
  // The atomic claim primitive: the WHERE clause re-checks status and
@@ -204,8 +213,38 @@ const claimOne = (db) =>
204
213
  RETURNING id
205
214
  `);
206
215
 
207
- // Claims up to `count` ready tasks for `owner`. `count` is a maximum, not
208
- // a promise returns however many were actually claimed, possibly zero.
216
+ // Every `blocked` task in the graph, regardless of module or reason —
217
+ // the fan-out claim's stop-the-line check. Ordered the same as the
218
+ // NEEDS ATTENTION list in status.mjs, so the two never disagree about
219
+ // which tasks are outstanding.
220
+ function findBlockedTasks(db) {
221
+ return db.prepare(`SELECT * FROM tasks WHERE status = 'blocked' ORDER BY priority, id`).all();
222
+ }
223
+
224
+ // Claims up to `count` ready tasks for `owner`, returning
225
+ // `{ claimed, blocked }`. `count` is a maximum, not a promise — `claimed`
226
+ // may hold fewer tasks than `count`, or none. `blocked` is non-empty only
227
+ // on the stop-the-line refusal below, in which case `claimed` is always
228
+ // empty.
229
+ //
230
+ // Stop-the-line: if any task anywhere in the graph was already `blocked`
231
+ // before this call, the fan-out refuses to claim anything at all, in any
232
+ // module — a blocked task needs a human/agent decision (retry after a
233
+ // fix, or leave it), and handing out fresh work around it makes that easy
234
+ // to ignore indefinitely. Deliberately stricter than dependency-based
235
+ // blocking: an unrelated module isn't literally stuck on the blocked
236
+ // task, but the fan-out stops anyway until it's retried. A targeted
237
+ // `hedgehog claim <task-id>` (claimTask, below) is exempt — that's how
238
+ // the blocked task itself gets reclaimed after `hedgehog retry`.
239
+ //
240
+ // "Already blocked" excludes a lease this same call's reapExpiredLeases
241
+ // just flipped: a dead agent's lease can lapse on an unrelated module,
242
+ // and the first `claim` call after that lapse is whichever one happens to
243
+ // discover it. That call still claims normally; the reaped task still
244
+ // lands in `blocked`/`lease_expired` and still needs `hedgehog retry`
245
+ // before it's claimable — only this one call's refusal is skipped. Every
246
+ // `claim` call after it sees the same task still blocked and stops as
247
+ // usual.
209
248
  //
210
249
  // Fan-out keeps the batch mutually non-conflicting, and non-conflicting
211
250
  // with every task already in flight, per conflict.mjs's
@@ -229,7 +268,12 @@ export function claimTasks(db, { owner, count = 1, leaseMinutes = 45 }) {
229
268
  const claimSnapshot = snapshotWorkingTree();
230
269
 
231
270
  return inTransaction(db, () => {
232
- reapExpiredLeases(db);
271
+ const justReaped = reapExpiredLeases(db);
272
+
273
+ const blocked = findBlockedTasks(db).filter((task) => !justReaped.has(task.id));
274
+ if (blocked.length > 0) {
275
+ return { claimed: [], blocked };
276
+ }
233
277
 
234
278
  const candidates = findClaimableTasks(db);
235
279
  const inFlight = findInFlightTasks(db);
@@ -245,7 +289,7 @@ export function claimTasks(db, { owner, count = 1, leaseMinutes = 45 }) {
245
289
  claimed.push(loadTask(db, candidate.id));
246
290
  }
247
291
 
248
- return claimed;
292
+ return { claimed, blocked: [] };
249
293
  });
250
294
  }
251
295
 
package/src/db/ready.mjs CHANGED
@@ -46,8 +46,10 @@ export function readyTasks(db) {
46
46
  // Renders one HELD BACK reason. `exclusive` splits on which side of the
47
47
  // pair is exclusive: the candidate itself (runs alone, full stop) vs. the
48
48
  // already-CLAIMABLE task it lost the slot to (named, so the reason points
49
- // at what's actually occupying the batch).
50
- function formatReason(candidate, conflict) {
49
+ // at what's actually occupying the batch). Exported for status.mjs, which
50
+ // annotates the same held-back tasks inline in its own READY section
51
+ // rather than duplicating this formatting.
52
+ export function heldBackReason(candidate, conflict) {
51
53
  const { with: other, kind } = conflict;
52
54
 
53
55
  if (kind === 'exclusive') {
@@ -86,7 +88,7 @@ export function formatReady({ claimable, heldBack }) {
86
88
  lines.push('');
87
89
  lines.push('HELD BACK');
88
90
  for (const { task, conflict } of heldBack) {
89
- lines.push(` ${task.id.padEnd(20)}${task.layer.padEnd(13)}${formatReason(task, conflict)}`);
91
+ lines.push(` ${task.id.padEnd(20)}${task.layer.padEnd(13)}${heldBackReason(task, conflict)}`);
90
92
  }
91
93
  }
92
94
 
package/src/db/status.mjs CHANGED
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { detectDrift, formatDrift } from './drift.mjs';
15
15
  import { formatMissingRequirements } from './requires.mjs';
16
+ import { readyTasks, heldBackReason } from './ready.mjs';
16
17
 
17
18
  // The task lifecycle in order, matching the tasks CHECK constraint in
18
19
  // schema.mjs exactly — every status the engine can write, and no others.
@@ -81,11 +82,16 @@ function loadAttentionTasks(db) {
81
82
  return db.prepare(ATTENTION_TASKS_SQL).all();
82
83
  }
83
84
 
84
- // Returns { counts, ready, inFlight, attention, drift, total } — counts
85
- // keyed by every status in the tasks CHECK constraint (present even at
86
- // zero), ready the full list of currently-pickable tasks, inFlight the
87
- // tasks currently leased (building or verifying), attention the stalled
88
- // tasks needing a fix, total the sum across all statuses.
85
+ // Returns { counts, ready, heldBack, inFlight, attention, drift, total } —
86
+ // counts keyed by every status in the tasks CHECK constraint (present even
87
+ // at zero), ready the full list of currently-pickable tasks, heldBack the
88
+ // subset of those that `hedgehog claim` would skip over right now because
89
+ // they conflict with in-flight work or another ready task ahead of them
90
+ // (ready.mjs's own simulation, reused rather than reimplemented — without
91
+ // this a task can sit in READY indefinitely with no visible reason, the
92
+ // same invisible-stall shape blocked tasks had before `attention` existed),
93
+ // inFlight the tasks currently leased (building or verifying), attention
94
+ // the stalled tasks needing a fix, total the sum across all statuses.
89
95
  //
90
96
  // `core` is optional and, when given, adds `drift`: the tasks whose
91
97
  // layer-derived fields no longer match core.yaml (drift.mjs). It's a
@@ -100,11 +106,12 @@ function loadAttentionTasks(db) {
100
106
  export function graphStatus(db, { core = null, overrides = new Map() } = {}) {
101
107
  const counts = countTasksByStatus(db);
102
108
  const ready = loadReadyTasks(db);
109
+ const { heldBack } = readyTasks(db);
103
110
  const inFlight = loadInFlightTasks(db);
104
111
  const attention = loadAttentionTasks(db);
105
112
  const drift = core ? detectDrift(db, core, { overrides }) : [];
106
113
  const total = Object.values(counts).reduce((a, b) => a + b, 0);
107
- return { counts, ready, inFlight, attention, drift, total };
114
+ return { counts, ready, heldBack, inFlight, attention, drift, total };
108
115
  }
109
116
 
110
117
  const BLOCKED_REASON_LABELS = {
@@ -127,6 +134,7 @@ const BLOCKED_REASON_LABELS = {
127
134
  export function formatStatus({
128
135
  counts,
129
136
  ready,
137
+ heldBack = [],
130
138
  inFlight,
131
139
  attention,
132
140
  drift,
@@ -146,12 +154,18 @@ export function formatStatus({
146
154
  lines.push(...missingLines);
147
155
  lines.push('');
148
156
  }
157
+ // Each ready task is annotated inline with why `hedgehog claim` would
158
+ // skip it right now, rather than listed flatly — a task can otherwise
159
+ // sit in READY indefinitely with no visible reason (see graphStatus).
160
+ const heldBackById = new Map(heldBack.map(({ task, conflict }) => [task.id, conflict]));
149
161
  lines.push('READY');
150
162
  if (ready.length === 0) {
151
163
  lines.push(' (none)');
152
164
  } else {
153
165
  for (const task of ready) {
154
- lines.push(` ${task.id} ${task.layer} ${task.objective}`);
166
+ const conflict = heldBackById.get(task.id);
167
+ const note = conflict ? ` (held back — ${heldBackReason(task, conflict)})` : '';
168
+ lines.push(` ${task.id} ${task.layer} ${task.objective}${note}`);
155
169
  }
156
170
  }
157
171
 
@@ -117,14 +117,21 @@ runtime detail.
117
117
  moves to `blocked` with a `blocked_reason` of `scope_violation` or
118
118
  `verification_failed`, and nothing downstream unlocks. Fix the work,
119
119
  then run `hedgehog retry <task-id>` to return the task to `planned`,
120
- claim it again, and verify again`hedgehog verify` only accepts a
121
- task you currently hold in `building`, so a blocked task has to go
122
- back through `retry` and `claim` first. Don't hand-commit around it.
123
-
124
- A `blocked` task is not pickable by `hedgehog claim`, so `hedgehog
125
- status` lists it under NEEDS ATTENTION with the task id to retry.
126
- If `hedgehog claim` returns nothing and the graph isn't actually done,
127
- fix that task don't treat it as "nothing left to do."
120
+ claim it again (by task idsee below), and verify again
121
+ `hedgehog verify` only accepts a task you currently hold in
122
+ `building`, so a blocked task has to go back through `retry` and
123
+ `claim` first. Don't hand-commit around it.
124
+
125
+ A `blocked` task anywhere in the graph in this layer or any other —
126
+ makes `hedgehog claim --count N` refuse to hand out anything at all,
127
+ with a non-zero exit naming the blocked task(s). `hedgehog status`
128
+ lists them too, under NEEDS ATTENTION. Fix and `retry` the named
129
+ task(s) before claiming more. A **targeted** `hedgehog claim <task-id>
130
+ --owner <owner>` is exempt — that's how the just-retried task gets
131
+ reclaimed in the step above. A lease the same `claim` call reaps for
132
+ having just expired is exempt too: that call still claims whatever
133
+ else is ready, and the reaped task lands in NEEDS ATTENTION for the
134
+ next `claim` call to stop on.
128
135
  5. **Repeat** — `hedgehog claim --count N --owner <owner>` again for the
129
136
  next batch.
130
137
 
@@ -358,6 +358,61 @@ expressible: it requires a commit before the verify that gates the
358
358
  commit. Choose a model that applies directly rather than designing a
359
359
  layer around it.
360
360
 
361
+ ## Step 4d — make the product's own surface reachable
362
+
363
+ Skip this only if nothing the project publishes is reached through an
364
+ **arbitrator**: an ingress, a router, a gateway, a reverse proxy, a
365
+ process supervisor — anything that decides which backend answers a
366
+ request, and that no layer publishing a surface owns. For every core
367
+ with one, add a `once: true` **reachability layer** at the tail,
368
+ `depends_on` the last per-module layer, whose `verify` makes a real
369
+ request to each intent's primary surface and asserts something specific
370
+ about the answer.
371
+
372
+ Every other check in this skill judges one layer against its own claim.
373
+ This one exists because the defect it catches belongs to no layer. On a
374
+ module axis each layer's `scope` is a disjoint per-module subtree, so an
375
+ API layer choosing a route and a web layer choosing a screen path make
376
+ independent choices that only ever meet in the arbitrator's config —
377
+ owned by a third layer that knows neither. Each layer is individually
378
+ correct, each verify passes in isolation, and the request 404s. That is
379
+ a whole-graph property, and only a task that runs after the whole graph
380
+ can hold it.
381
+
382
+ What the layer asserts is the intent's `outcome` read back from outside:
383
+ the route answers, with a status and a body that could not come from the
384
+ wrong backend. Assert something the *other* side of the arbitrator
385
+ cannot produce — a JSON shape, a header, a field the API returns and the
386
+ web app does not. A bare 200 is satisfied by the fall-through app's own
387
+ 200, which is the exact confusion this layer exists to detect.
388
+
389
+ The tail position is doing real work here, and the same three properties
390
+ that make `once: true` right for a composition seam (Step 4) make it
391
+ right here:
392
+
393
+ - **One task, after everything.** `depends_on` the last per-module layer
394
+ makes it wait on *every* module's copy, so it runs once, with the
395
+ whole product standing up.
396
+ - **Re-entrant by construction.** When `planner`'s Re-entry pass adds a
397
+ module to a finished build, that module's task becomes a prerequisite
398
+ of this already-complete layer, so `hedgehog plan` reopens it — a new
399
+ module's surface gets checked for reachability without anyone
400
+ remembering to ask.
401
+ - **It closes the graph's own claim.** Without it, "every task complete"
402
+ is a statement about tasks. With it, the last task completing means
403
+ the product answers.
404
+
405
+ Two failure modes to design out. The layer must carry no `{module}`
406
+ anywhere — `validateCore` rejects a `once` layer that does — so it
407
+ enumerates the surfaces it checks in one command rather than
408
+ substituting a module in. And it needs the arbitrator's routing to
409
+ already be applied, which makes it strictly later than the deploy layer,
410
+ never merged into it: a deploy layer verifies its own manifests landed,
411
+ which is what six green deploy layers over a 404 already looked like.
412
+
413
+ Record in `core-design.md` which layer is the reachability gate and what
414
+ each surface it checks is asserting.
415
+
361
416
  ## Step 5 — write `.hedgehog/core.yaml`
362
417
 
363
418
  The loader parses `id` plus a `layers` list of flat maps. Every layer
@@ -487,6 +542,16 @@ if missed:
487
542
  deployment/app` read the same thing regardless of which module's task
488
543
  is running, so six modules' deploy tasks assert the identical claim six
489
544
  times rather than each module's own deployment.
545
+ - **A reachability layer's `verify` asserts the product answers, not
546
+ that a layer deployed.** The tail `once: true` layer Step 4d calls for
547
+ is the one place a `verify` command is about the whole build rather
548
+ than its own scope, so the usual scope/filter cross-checks above have
549
+ nothing to say about it. Its command makes a real request through the
550
+ arbitrator to each intent's primary surface and asserts a response
551
+ only the intended backend could produce. Its `scope` is whatever
552
+ fixture or script the check itself lives in — a layer that writes
553
+ nothing still needs a scope glob it may write, since `hedgehog verify`
554
+ rejects writes outside it.
490
555
  - **Declare the binaries `verify` needs, in `requires`.** Optional, an
491
556
  inline list alongside `scope`/`verify`/`commit`
492
557
  (`requires: ["terraform", "kubectl"]`), and only for tools that come
@@ -549,6 +614,9 @@ to change only until the file lands. Hard stop.
549
614
  (intents × layers tasks, or one task per layer).
550
615
  - For any layer that deploys or publishes: where the push happens
551
616
  relative to that layer's commit (Step 4c) — or that no layer deploys.
617
+ - Which layer is the reachability gate and what each surface it checks
618
+ asserts (Step 4d) — or that nothing published is reached through an
619
+ arbitrator.
552
620
  - That this is an authored core: the sequence was designed for this
553
621
  project, not battle-tested across many, and it carries the same
554
622
  enforcement as a Golden Core but a weaker guarantee.
@@ -187,10 +187,19 @@ paragraph algorithm, and their self-tests.
187
187
  check, the task moves to `blocked` with a `blocked_reason` of
188
188
  `scope_violation` or `verification_failed`, and nothing downstream
189
189
  unlocks. Fix the work, then run `hedgehog retry <task-id>` to return
190
- the task to `planned`, claim it again, and verify again — `hedgehog
191
- verify` only accepts a task you currently hold in `building`, so a
192
- blocked task has to go back through `retry` and `claim` first. Don't
193
- hand-commit around it.
190
+ the task to `planned`, claim it again by task id, and verify again —
191
+ `hedgehog verify` only accepts a task you currently hold in
192
+ `building`, so a blocked task has to go back through `retry` and
193
+ `claim` first. Don't hand-commit around it.
194
+
195
+ A `blocked` task anywhere in the graph makes `hedgehog claim --count
196
+ <n>` refuse to hand out anything at all, with a non-zero exit naming
197
+ the blocked task(s). Fix and `retry` it before claiming more. A
198
+ **targeted** `hedgehog claim <task-id> --owner <owner>` is exempt —
199
+ that's how the just-retried task gets reclaimed above. A lease the
200
+ same `claim` call reaps for having just expired is exempt too: that
201
+ call still claims whatever else is ready, and the reaped task lands in
202
+ NEEDS ATTENTION for the next `claim` call to stop on.
194
203
  5. **Repeat** — `hedgehog claim --owner <owner> --count <n>` again for
195
204
  the following layer.
196
205
 
@@ -169,14 +169,21 @@ writes `docs/design/<module>.md`, not its own compiled layer — the
169
169
  `blocked` with a `blocked_reason` of `scope_violation` or
170
170
  `verification_failed`, and nothing downstream unlocks. Fix the work,
171
171
  then run `hedgehog retry <task-id>` to return the task to `planned`,
172
- claim it again, and verify again`hedgehog verify` only accepts a
173
- task you currently hold in `building`, so a blocked task has to go
174
- back through `retry` and `claim` first. Don't hand-commit around it.
175
-
176
- A `blocked` task is not pickable by `hedgehog claim`, so `hedgehog
177
- status` lists it under NEEDS ATTENTION with the task id to retry.
178
- If `hedgehog claim` returns nothing and the graph isn't actually done,
179
- fix that task don't treat it as "nothing left to do."
172
+ claim it again (by task idsee below), and verify again
173
+ `hedgehog verify` only accepts a task you currently hold in
174
+ `building`, so a blocked task has to go back through `retry` and
175
+ `claim` first. Don't hand-commit around it.
176
+
177
+ A `blocked` task anywhere in the graph in this module or any other —
178
+ makes `hedgehog claim --count N` refuse to hand out anything at all,
179
+ with a non-zero exit naming the blocked task(s). `hedgehog status`
180
+ lists them too, under NEEDS ATTENTION. Fix and `retry` the named
181
+ task(s) before claiming more. A **targeted** `hedgehog claim <task-id>
182
+ --owner <owner>` is exempt — that's how the just-retried task gets
183
+ reclaimed in the step above. A lease the same `claim` call reaps for
184
+ having just expired is exempt too: that call still claims whatever
185
+ else is ready, and the reaped task lands in NEEDS ATTENTION for the
186
+ next `claim` call to stop on.
180
187
  5. **Repeat** — `hedgehog claim --count N --owner <owner>` again for the
181
188
  next batch.
182
189