@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 +41 -8
- package/package.json +2 -2
- package/src/db/claim.mjs +51 -7
- package/src/db/ready.mjs +5 -3
- package/src/db/status.mjs +21 -7
- package/src/skills/hedgehog-authored-loop/SKILL.md +15 -8
- package/src/skills/hedgehog-landing-loop/SKILL.md +13 -4
- package/src/skills/hedgehog-loop/SKILL.md +15 -8
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
208
|
-
//
|
|
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
|
-
|
|
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)}${
|
|
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 } —
|
|
85
|
-
// keyed by every status in the tasks CHECK constraint (present even
|
|
86
|
-
// zero), ready the full list of currently-pickable tasks,
|
|
87
|
-
//
|
|
88
|
-
//
|
|
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
|
-
|
|
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
|
|
121
|
-
|
|
122
|
-
back through `retry` and
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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 —
|
|
191
|
-
verify` only accepts a task you currently hold in
|
|
192
|
-
blocked task has to go back through `retry` and
|
|
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
|
|
173
|
-
|
|
174
|
-
back through `retry` and
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|