@skyf0xx/hedgehog 4.0.15 → 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.15",
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 id — see 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
 
@@ -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 id — see 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