@skyf0xx/hedgehog 6.3.1 → 6.3.2

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
@@ -100,6 +100,8 @@ import {
100
100
  formatReconciliations,
101
101
  RECONCILED_DIR,
102
102
  } from '../src/db/reconcile.mjs';
103
+ import { NOOP_DIR } from '../src/db/noop.mjs';
104
+ import { runFastpath, loadFastpaths, orphanedFastpathTasks, FASTPATH_DIR } from '../src/db/fastpath.mjs';
103
105
  import { HOSTS, HOST_FLAGS, DEFAULT_HOST, availableHosts } from '../src/hosts/index.mjs';
104
106
  import { recordHosts, installedHosts } from '../src/hosts/installed.mjs';
105
107
  import { wrapSection } from '../src/hosts/claude-md-merge.mjs';
@@ -625,6 +627,11 @@ ${bold('Usage')}
625
627
  close one task on your judgment — no scope gate and no
626
628
  verify command run; records it under .hedgehog/reconciled/
627
629
  npx @skyf0xx/hedgehog reconcile list list recorded reconciliations
630
+ npx @skyf0xx/hedgehog fast-path <intent-id> --reason "<why>" --verify "<command>"
631
+ close every remaining task of a small, already-committed
632
+ intent below the per-layer loop; still gated by scope
633
+ and by --verify; records it under .hedgehog/fastpath/
634
+ npx @skyf0xx/hedgehog fast-path list list recorded fast-paths
628
635
  npx @skyf0xx/hedgehog merge <intent-id> merge that intent's worktree branch into trunk,
629
636
  rebuild trunk's graph, remove the worktree — fails
630
637
  if the intent's tasks aren't all complete there
@@ -1313,6 +1320,33 @@ async function dbRebuildCommand() {
1313
1320
  `no task with this id exists in the rebuilt graph, so each closes nothing.\n`,
1314
1321
  );
1315
1322
  }
1323
+ // Same reporting split as tasksReconciled above, for the same reason: a
1324
+ // no-op-completed task had no verify_command run and no commit made,
1325
+ // and folding it into the plain "marked complete" count would hide that.
1326
+ if (result.tasksNoop > 0) {
1327
+ console.log(
1328
+ `${dim(`${result.tasksNoop} task(s) replayed from ${NOOP_DIR}/ — closed as a no-op, no commit`)}\n`,
1329
+ );
1330
+ }
1331
+ if (result.orphanedNoop?.length > 0) {
1332
+ console.log(
1333
+ `${yellow(bold('No-op records without a task.'))} ${result.orphanedNoop.join(', ')} —\n` +
1334
+ `no task with this id exists in the rebuilt graph, so each closes nothing.\n`,
1335
+ );
1336
+ }
1337
+ // Same split as tasksReconciled/tasksNoop above: a fast-pathed task had
1338
+ // no per-layer verify_command run against it individually.
1339
+ if (result.tasksFastpathed > 0) {
1340
+ console.log(
1341
+ `${dim(`${result.tasksFastpathed} task(s) replayed from ${FASTPATH_DIR}/ — closed by fast-path, not per-layer verification`)}\n`,
1342
+ );
1343
+ }
1344
+ if (result.orphanedFastpath?.length > 0) {
1345
+ console.log(
1346
+ `${yellow(bold('Fast-path records without a task.'))} ${result.orphanedFastpath.join(', ')} —\n` +
1347
+ `no task with this id exists in the rebuilt graph, so each closes nothing.\n`,
1348
+ );
1349
+ }
1316
1350
  if (result.abandonmentsReplayed?.length > 0) {
1317
1351
  console.log(
1318
1352
  `${dim(`${result.abandonmentsReplayed.length} intent(s) replayed from ${ABANDONED_DIR}/ — kept at planned, not active:`)}\n` +
@@ -2257,7 +2291,7 @@ async function verifyCommand(args) {
2257
2291
  return;
2258
2292
  }
2259
2293
  const overrides = await loadOverrides();
2260
- result = verifyTask(db, taskId, owner, overrides);
2294
+ result = await verifyTask(db, taskId, owner, overrides);
2261
2295
  } catch (err) {
2262
2296
  console.error(`${red('Verify failed:')} ${err.message}\n`);
2263
2297
  process.exitCode = 1;
@@ -2287,7 +2321,11 @@ async function verifyCommand(args) {
2287
2321
  }
2288
2322
 
2289
2323
  console.log(`${green(bold('Verified.'))} Task ${bold(taskId)} is now ${bold('complete')}.`);
2290
- if (result.commitSha) console.log(` ${dim('commit')} ${result.commitSha}`);
2324
+ if (result.noop) {
2325
+ console.log(` ${dim('no-op — nothing in scope to verify or commit')}`);
2326
+ } else if (result.commitSha) {
2327
+ console.log(` ${dim('commit')} ${result.commitSha}`);
2328
+ }
2291
2329
  if (result.unlocked.length === 0) {
2292
2330
  console.log(` ${dim('no dependents unlocked')}`);
2293
2331
  } else {
@@ -3503,6 +3541,97 @@ async function reconcileCommand(args) {
3503
3541
  console.log(`${formatEvidence(evidence)}\n`);
3504
3542
  }
3505
3543
 
3544
+ // `hedgehog fast-path <intent-id> --reason "<why>" --verify "<command>"` —
3545
+ // closes every remaining task of an intent below the normal per-layer
3546
+ // claim/verify ceremony (see src/db/fastpath.mjs). Requires a clean
3547
+ // working tree, checks the fix against the union of the remaining tasks'
3548
+ // own scope, and runs `--verify` for real before completing anything.
3549
+ async function fastpathCommand(args) {
3550
+ await ensureDb();
3551
+
3552
+ if (!(await exists(DB_PATH))) {
3553
+ console.error(`${red('No build graph found.')} Run ${bold('hedgehog db init')} first.\n`);
3554
+ process.exitCode = 1;
3555
+ return;
3556
+ }
3557
+
3558
+ const sub = args[0];
3559
+
3560
+ if (sub === 'list') {
3561
+ const fastpaths = await loadFastpaths();
3562
+ const db = openDb({ readOnly: true });
3563
+ let orphaned;
3564
+ try {
3565
+ orphaned = orphanedFastpathTasks(db, fastpaths);
3566
+ } finally {
3567
+ db.close();
3568
+ }
3569
+ if (fastpaths.size === 0) {
3570
+ console.log(`${dim('No intent has been fast-pathed.')}\n`);
3571
+ return;
3572
+ }
3573
+ for (const record of fastpaths.values()) {
3574
+ console.log(`${bold(record.intent)}`);
3575
+ console.log(` ${record.reason}`);
3576
+ console.log(` verified with: ${record.verify_command}`);
3577
+ console.log(` confirmed ${record.confirmed_at}`);
3578
+ console.log(` tasks: ${record.tasks.join(', ')}`);
3579
+ console.log();
3580
+ }
3581
+ if (orphaned.length > 0) {
3582
+ console.log(
3583
+ `${dim(`Orphaned: ${orphaned.join(', ')} — no task with this id exists in the build graph.`)}\n`,
3584
+ );
3585
+ }
3586
+ return;
3587
+ }
3588
+
3589
+ const intentId = args[0];
3590
+ const reasonIdx = args.indexOf('--reason');
3591
+ const reason = reasonIdx !== -1 ? args[reasonIdx + 1] : undefined;
3592
+ const verifyIdx = args.indexOf('--verify');
3593
+ const verifyCommand = verifyIdx !== -1 ? args[verifyIdx + 1] : undefined;
3594
+
3595
+ if (!intentId || intentId.startsWith('--') || !reason || !verifyCommand) {
3596
+ console.error(
3597
+ `${red('Usage:')} hedgehog fast-path <intent-id> --reason "<why>" --verify "<command>"\n` +
3598
+ ` or: hedgehog fast-path list\n\n` +
3599
+ `${dim('A fast-path is a narrow exception, not a default: use it only for a change')}\n` +
3600
+ `${dim('small enough that the per-layer loop costs more than the risk it prevents —')}\n` +
3601
+ `${dim('already committed, already tested, low risk. --verify names one real command')}\n` +
3602
+ `${dim('that must pass; the working tree must already be clean; and the diff since')}\n` +
3603
+ `${dim("the graph's last credited commit is still checked against the union of the")}\n` +
3604
+ `${dim('remaining tasks\' own scope — a fast-path never bypasses scope gating.')}\n`,
3605
+ );
3606
+ process.exitCode = 1;
3607
+ return;
3608
+ }
3609
+
3610
+ printDbTarget();
3611
+ const db = openDb();
3612
+ let result;
3613
+ try {
3614
+ result = await runFastpath(db, { intentId, reason, verifyCommand });
3615
+ } catch (err) {
3616
+ console.error(`${red('Failed to fast-path:')} ${err.message}\n`);
3617
+ process.exitCode = 1;
3618
+ return;
3619
+ } finally {
3620
+ db.close();
3621
+ }
3622
+
3623
+ const file = `${FASTPATH_DIR}/${result.record.intent.toLowerCase()}.json`;
3624
+ console.log(
3625
+ ` ${green('complete')} ${bold(result.record.intent)} ${dim(`(${result.record.tasks.length} task(s), fast-pathed, not per-layer verified)`)}`,
3626
+ );
3627
+ for (const taskId of result.record.tasks) console.log(` ${dim('closed')} ${taskId}`);
3628
+ console.log(` ${green('recorded')} ${file}`);
3629
+ console.log(
3630
+ `\n ${bold('Commit that file.')} ${dim('The build graph is derived and gitignored — an')}\n` +
3631
+ ` ${dim('uncommitted fast-path is reverted by the next `hedgehog db rebuild`.')}\n`,
3632
+ );
3633
+ }
3634
+
3506
3635
  // `hedgehog merge <intent-id>` — merges `hedgehog/<intent-id>` into trunk
3507
3636
  // with `git merge --no-ff`, rebuilds trunk's graph from what merged, then
3508
3637
  // removes the worktree and its branch. See src/db/worktree.mjs for why a
@@ -4369,6 +4498,11 @@ async function main() {
4369
4498
  return;
4370
4499
  }
4371
4500
 
4501
+ if (cmd === 'fast-path') {
4502
+ await fastpathCommand(args.slice(1));
4503
+ return;
4504
+ }
4505
+
4372
4506
  if (cmd === 'merge') {
4373
4507
  await mergeCommand(args.slice(1));
4374
4508
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "6.3.1",
3
+ "version": "6.3.2",
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": {
@@ -0,0 +1,53 @@
1
+ // The set of paths every scope-facing check in this engine excludes
2
+ // before judging what an agent (or a fast-path) actually touched: the
3
+ // build graph file and its sidecars, the commit lock, the star-prompt
4
+ // state file, and every directory a build-graph command writes its own
5
+ // committed record into (`friction add`, `override add`, `intent add`/
6
+ // `db rebuild`, `reconcile confirm`, `debt add`/`decision add`, a no-op
7
+ // completion, a fast-path). None of these is ever a layer's own work, so
8
+ // a path under one of them is never attributable to whatever task or
9
+ // intent happened to be active when it changed.
10
+ //
11
+ // verify.mjs's scope gate and fastpath.mjs's scope check both need
12
+ // exactly this predicate; this module exists so it has one definition
13
+ // instead of two that could drift apart.
14
+
15
+ import { DB_PATH } from './init.mjs';
16
+ import { LOCK_PATH } from './commitLock.mjs';
17
+ import { FRICTION_DIR } from './friction.mjs';
18
+ import { OVERRIDES_DIR } from './overrides.mjs';
19
+ import { INTENTS_DIR } from './intent.mjs';
20
+ import { RECONCILED_DIR } from './reconcile.mjs';
21
+ import { NOTES_DIR } from './notes.mjs';
22
+ import { NOOP_DIR } from './noop.mjs';
23
+ import { COMMUNITY_PATH } from './community.mjs';
24
+
25
+ // `FASTPATH_DIR` is deliberately not imported here: fastpath.mjs needs
26
+ // this module's predicates for its own scope check, and importing
27
+ // FASTPATH_DIR from fastpath.mjs here would make that a cycle. Its caller
28
+ // there passes it in instead (see fastpath.mjs's own use of
29
+ // isBuildGraphStatePath).
30
+ export const BUILD_GRAPH_STATE_DIRS = [
31
+ FRICTION_DIR,
32
+ OVERRIDES_DIR,
33
+ INTENTS_DIR,
34
+ RECONCILED_DIR,
35
+ NOTES_DIR,
36
+ NOOP_DIR,
37
+ ];
38
+
39
+ export function isBuildGraphStatePath(path, extraDirs = []) {
40
+ return [...BUILD_GRAPH_STATE_DIRS, ...extraDirs].some(
41
+ (dir) => path === dir || path.startsWith(`${dir}/`),
42
+ );
43
+ }
44
+
45
+ export function isEngineStatePath(path, extraDirs = []) {
46
+ return (
47
+ path === DB_PATH ||
48
+ path.startsWith(`${DB_PATH}-`) ||
49
+ path === LOCK_PATH ||
50
+ path === COMMUNITY_PATH ||
51
+ isBuildGraphStatePath(path, extraDirs)
52
+ );
53
+ }
@@ -0,0 +1,287 @@
1
+ // `hedgehog fast-path <intent-id> --reason "<why>"` — a sanctioned way to
2
+ // close every remaining task of an intent below the normal claim/verify
3
+ // ceremony, for a change small enough that walking it through every
4
+ // remaining layer costs more than the risk the loop exists to catch.
5
+ //
6
+ // This is not a second way to skip verification. It is a narrower,
7
+ // explicit substitute for it:
8
+ //
9
+ // - The working tree must be clean (everything already committed) —
10
+ // fast-pathing an intent whose fix is still sitting uncommitted has
11
+ // nothing for the scope check below to check.
12
+ // - The union of every remaining task's own scope_globs still gates the
13
+ // diff, exactly the way `hedgehog verify`'s own scope gate does: a
14
+ // commit that touched anything outside that union blocks the
15
+ // fast-path the same way it would block a normal verify. Discretion
16
+ // over ceremony never extends to discretion over the one guarantee
17
+ // that matters — a layer only writes inside its own boundary.
18
+ // - `--verify` names one real command that must pass before anything
19
+ // completes; the caller states it explicitly (the union of the
20
+ // skipped layers' own verify_commands is the honest default, but a
21
+ // narrower substitute is allowed, per that call being the same kind
22
+ // of judgment call `hedgehog verify` doesn't need a human for).
23
+ // - The decision is a committed record under `.hedgehog/fastpath/`, the
24
+ // same file-before-row shape reconcile.mjs uses, and for the same
25
+ // reason: without it, `hedgehog db rebuild` has nothing to replay and
26
+ // silently reintroduces every task this closed.
27
+ //
28
+ // A fast-pathed task is functionally identical to a reconciled one from
29
+ // markCompletedTasks's point of view — no commit_message of its own to
30
+ // match, closed by a committed decision instead — so rebuild.mjs seeds it
31
+ // into the same `reconciledTaskIds`-shaped set reconciliation uses.
32
+
33
+ import { execFileSync, execSync } from 'node:child_process';
34
+ import { readdir, readFile, mkdir, writeFile, rename, rm } from 'node:fs/promises';
35
+ import { pathInScope, newestGraphCommit, commitsSince } from './reconcile.mjs';
36
+ import { isEngineStatePath } from './engineState.mjs';
37
+
38
+ export const FASTPATH_DIR = '.hedgehog/fastpath';
39
+
40
+ function fastpathFilePath(intentId, fastpathDir = FASTPATH_DIR) {
41
+ return `${fastpathDir}/${intentId.toLowerCase()}.json`;
42
+ }
43
+
44
+ function git(args, options = {}) {
45
+ return execFileSync('git', args, { encoding: 'utf8', ...options });
46
+ }
47
+
48
+ function validateFastpath(record, path) {
49
+ if (record === null || typeof record !== 'object') {
50
+ throw new Error(`${path}: fast-path record must be a JSON object`);
51
+ }
52
+ const { intent, reason, verify_command: verifyCommand, confirmed_at: confirmedAt, tasks } = record;
53
+
54
+ // Not upper-cased: an intent id, unlike a task id, is stored exactly as
55
+ // given at `intent add` time (intent.mjs#normalizeIntent never cases
56
+ // it) — casing this would make the record's own lookup key disagree
57
+ // with `intents.id`.
58
+ if (!intent || typeof intent !== 'string') {
59
+ throw new Error(`${path}: fast-path record requires an "intent" id (string)`);
60
+ }
61
+
62
+ if (!reason || typeof reason !== 'string') {
63
+ throw new Error(`${path}: fast-path "${intent}" requires a "reason" (string)`);
64
+ }
65
+ if (!verifyCommand || typeof verifyCommand !== 'string') {
66
+ throw new Error(`${path}: fast-path "${intent}" requires a "verify_command" (string)`);
67
+ }
68
+ if (!confirmedAt || typeof confirmedAt !== 'string') {
69
+ throw new Error(`${path}: fast-path "${intent}" requires a "confirmed_at" timestamp (string)`);
70
+ }
71
+ if (!Array.isArray(tasks) || tasks.length === 0) {
72
+ throw new Error(`${path}: fast-path "${intent}" requires a non-empty "tasks" array`);
73
+ }
74
+ for (const t of tasks) {
75
+ if (typeof t !== 'string' || t.trim() === '') {
76
+ throw new Error(`${path}: fast-path "${intent}" has a non-string or empty entry in tasks`);
77
+ }
78
+ }
79
+
80
+ return {
81
+ intent,
82
+ reason,
83
+ verify_command: verifyCommand,
84
+ confirmed_at: confirmedAt,
85
+ tasks: tasks.map((t) => t.toUpperCase()),
86
+ };
87
+ }
88
+
89
+ // Every *.json in `fastpathDir`, validated, as a Map from intent id to its
90
+ // record — same convention as reconcile.mjs#loadReconciliations.
91
+ export async function loadFastpaths(fastpathDir = FASTPATH_DIR) {
92
+ let entries;
93
+ try {
94
+ entries = await readdir(fastpathDir);
95
+ } catch {
96
+ return new Map();
97
+ }
98
+
99
+ const byIntent = new Map();
100
+ for (const name of entries.filter((n) => n.endsWith('.json')).sort()) {
101
+ const path = `${fastpathDir}/${name}`;
102
+ let parsed;
103
+ try {
104
+ parsed = JSON.parse(await readFile(path, 'utf8'));
105
+ } catch (err) {
106
+ throw new Error(`could not read fast-path record ${path}: ${err.message}`, { cause: err });
107
+ }
108
+ const record = validateFastpath(parsed, path);
109
+ byIntent.set(record.intent, record);
110
+ }
111
+ return byIntent;
112
+ }
113
+
114
+ // Fast-pathed task ids matching no row in `tasks` — same read-side hygiene
115
+ // as reconcile.mjs#orphanedReconciliations.
116
+ export function orphanedFastpathTasks(db, fastpaths) {
117
+ const known = new Set(db.prepare('SELECT id FROM tasks').all().map((r) => r.id));
118
+ const orphaned = [];
119
+ for (const record of fastpaths.values()) {
120
+ for (const taskId of record.tasks) {
121
+ if (!known.has(taskId)) orphaned.push(taskId);
122
+ }
123
+ }
124
+ return orphaned.sort();
125
+ }
126
+
127
+ export async function writeFastpathFile(record, fastpathDir = FASTPATH_DIR) {
128
+ const path = fastpathFilePath(record.intent, fastpathDir);
129
+ try {
130
+ await readFile(path, 'utf8');
131
+ throw new Error(`${path} already exists — ${record.intent} is already recorded as fast-pathed.`);
132
+ } catch (err) {
133
+ if (!err || err.code !== 'ENOENT') throw err;
134
+ }
135
+
136
+ await mkdir(fastpathDir, { recursive: true });
137
+ const tempPath = `${path}.tmp-${process.pid}`;
138
+ try {
139
+ await writeFile(tempPath, `${JSON.stringify(record, null, 2)}\n`);
140
+ await rename(tempPath, path);
141
+ } catch (err) {
142
+ await rm(tempPath, { force: true }).catch(() => {});
143
+ throw err;
144
+ }
145
+ return record;
146
+ }
147
+
148
+ // Every task belonging to `intentId` that isn't already `complete` —
149
+ // exactly the set a fast-path is closing.
150
+ function loadRemainingTasks(db, intentId) {
151
+ return db
152
+ .prepare("SELECT id, scope_globs, verify_command FROM tasks WHERE intent_id = ? AND status <> 'complete'")
153
+ .all(intentId);
154
+ }
155
+
156
+ // The provenance note fast-pathing writes into `decisions`, rendered into
157
+ // every dependent task's packet the same way reconcile.mjs#reconciledNote
158
+ // is — a dependent must be told outright that its prerequisite closed by
159
+ // explicit fast-path, not by a normal verify run.
160
+ export function fastpathNote(record) {
161
+ return (
162
+ `Closed by fast-path, not per-layer verification: ${record.reason} ` +
163
+ `(verified with: ${record.verify_command}; ${record.tasks.length} task(s) closed together)`
164
+ );
165
+ }
166
+
167
+ // Runs `hedgehog fast-path <intent-id> --reason "<why>"`.
168
+ //
169
+ // Refuses when the working tree is dirty — a fast-pathed intent's fix must
170
+ // already be committed, or there is nothing for the scope check below to
171
+ // check against. Refuses when any commit since the graph's own newest
172
+ // credited commit touched a path outside the union of the remaining
173
+ // tasks' own scope_globs — a fast-path is not a way around the one
174
+ // guarantee `hedgehog verify` exists to hold. Runs `verifyCommand` for
175
+ // real and refuses on a nonzero exit, exactly like `hedgehog verify`
176
+ // refuses to complete a task whose verify_command fails.
177
+ export async function runFastpath(
178
+ db,
179
+ { intentId, reason, verifyCommand },
180
+ fastpathDir = FASTPATH_DIR,
181
+ ) {
182
+ if (!intentId) throw new Error('fast-path requires an intent id');
183
+ if (!reason) throw new Error('fast-path requires a --reason');
184
+ if (!verifyCommand) throw new Error('fast-path requires a --verify command');
185
+
186
+ // Unlike a task id (always upper-cased — plan.mjs#taskId), an intent id
187
+ // is stored exactly as given at `intent add` time (intent.mjs#normalizeIntent
188
+ // never cases it), so it is looked up verbatim here too.
189
+ const id = intentId;
190
+ const intent = db.prepare('SELECT id FROM intents WHERE id = ?').get(id);
191
+ if (!intent) throw new Error(`no such intent: ${id}`);
192
+
193
+ const remaining = loadRemainingTasks(db, id);
194
+ if (remaining.length === 0) {
195
+ throw new Error(`Intent ${id} has no remaining tasks — there is nothing to fast-path.`);
196
+ }
197
+
198
+ const dirty = git(['status', '--porcelain']).trim();
199
+ if (dirty !== '') {
200
+ throw new Error(
201
+ `The working tree has uncommitted changes. Fast-pathing requires the fix to already be ` +
202
+ `committed, so the scope check below has something real to check:\n\n${dirty}`,
203
+ );
204
+ }
205
+
206
+ const scopeGlobs = remaining.flatMap((t) => JSON.parse(t.scope_globs));
207
+ const since = newestGraphCommit(db);
208
+ const commits = commitsSince(since);
209
+ // Excludes the same build-graph/engine state every scope-facing check
210
+ // in this engine excludes (engineState.mjs) — the bootstrap commit that
211
+ // first added core.yaml, .gitignore, or a committed intent file sits
212
+ // inside this window on a project with nothing verified yet
213
+ // (newestGraphCommit returns null there), and none of that is this
214
+ // fast-path's own fix to be judged against.
215
+ const offending = [];
216
+ for (const commit of commits) {
217
+ for (const path of commit.paths) {
218
+ if (isEngineStatePath(path)) continue;
219
+ if (!pathInScope(path, scopeGlobs)) offending.push(path);
220
+ }
221
+ }
222
+ if (offending.length > 0) {
223
+ throw new Error(
224
+ `Scope violation. The following path(s) touched since the graph's last credited commit ` +
225
+ `fall outside the union of ${id}'s remaining tasks' scope:\n\n` +
226
+ [...new Set(offending)].map((p) => ` ${p}`).join('\n') +
227
+ `\n\nA fast-path cannot close tasks whose scope wasn't actually where the change landed.`,
228
+ );
229
+ }
230
+
231
+ let exitCode = 0;
232
+ let output;
233
+ try {
234
+ output = execSync(verifyCommand, { encoding: 'utf8', stdio: 'pipe' });
235
+ } catch (err) {
236
+ exitCode = err.status ?? 1;
237
+ output = `${err.stdout ?? ''}${err.stderr ?? ''}` || err.message;
238
+ }
239
+ if (exitCode !== 0) {
240
+ throw new Error(`Fast-path verification failed (exit ${exitCode}):\n\n${output}`);
241
+ }
242
+
243
+ const record = validateFastpath(
244
+ {
245
+ intent: id,
246
+ reason,
247
+ verify_command: verifyCommand,
248
+ confirmed_at: new Date().toISOString(),
249
+ tasks: remaining.map((t) => t.id),
250
+ },
251
+ '(new fast-path)',
252
+ );
253
+
254
+ await writeFastpathFile(record, fastpathDir);
255
+ applyFastpath(db, record);
256
+ return { record, output };
257
+ }
258
+
259
+ // Applies a confirmed fast-path record to the graph: every task it names
260
+ // goes `complete`, a provenance note lands on each, and the intent closes
261
+ // once nothing remains — the same bookkeeping reconcile.mjs#applyReconciliation
262
+ // does for a single task, run here over the whole set at once.
263
+ export function applyFastpath(db, record) {
264
+ const note = fastpathNote(record);
265
+ db.exec('BEGIN IMMEDIATE');
266
+ try {
267
+ const setComplete = db.prepare(
268
+ "UPDATE tasks SET status = 'complete', blocked_reason = NULL WHERE id = ?",
269
+ );
270
+ const insertNote = db.prepare('INSERT INTO decisions (task_id, note) VALUES (?, ?)');
271
+ for (const taskId of record.tasks) {
272
+ setComplete.run(taskId);
273
+ insertNote.run(taskId, note);
274
+ }
275
+ db.prepare(
276
+ "UPDATE intents SET status = 'complete' WHERE id = ? AND NOT EXISTS (SELECT 1 FROM tasks WHERE intent_id = ? AND status <> 'complete')",
277
+ ).run(record.intent, record.intent);
278
+ db.exec('COMMIT');
279
+ } catch (err) {
280
+ try {
281
+ db.exec('ROLLBACK');
282
+ } catch {
283
+ // Rollback failing must not mask the original error.
284
+ }
285
+ throw err;
286
+ }
287
+ }
@@ -0,0 +1,108 @@
1
+ // `.hedgehog/noop/<task-id>.json` — the committed record behind a task
2
+ // verify.mjs closes `complete` without a git commit, because its scope had
3
+ // nothing left to touch before verify_command even ran.
4
+ //
5
+ // A completed task is normally recoverable on `hedgehog db rebuild`
6
+ // because its own commit's subject matches its `commit_message`
7
+ // (rebuild.mjs#markCompletedTasks). A task closed with no commit at all
8
+ // has nothing there to match, so — the same gap reconcile.mjs closes for a
9
+ // hand-written commit that doesn't match — this file is the committed
10
+ // source rebuild.mjs replays instead.
11
+ //
12
+ // One file per task, written once: a task closes as a no-op at most once,
13
+ // the same reason reconcile.mjs's record refuses to overwrite rather than
14
+ // growing like notes.mjs's does.
15
+
16
+ import { readdir, readFile, mkdir, writeFile, rename, rm } from 'node:fs/promises';
17
+
18
+ export const NOOP_DIR = '.hedgehog/noop';
19
+
20
+ function noopFilePath(taskId, noopDir = NOOP_DIR) {
21
+ return `${noopDir}/${taskId.toLowerCase()}.json`;
22
+ }
23
+
24
+ function validateNoop(record, path) {
25
+ if (record === null || typeof record !== 'object') {
26
+ throw new Error(`${path}: no-op record must be a JSON object`);
27
+ }
28
+ let { task } = record;
29
+ const { commit_message: commitMessage, verified_at: verifiedAt } = record;
30
+
31
+ if (!task || typeof task !== 'string') {
32
+ throw new Error(`${path}: no-op record requires a "task" id (string)`);
33
+ }
34
+ task = task.toUpperCase();
35
+
36
+ if (!commitMessage || typeof commitMessage !== 'string') {
37
+ throw new Error(`${path}: no-op record "${task}" requires a "commit_message" (string)`);
38
+ }
39
+ if (!verifiedAt || typeof verifiedAt !== 'string') {
40
+ throw new Error(`${path}: no-op record "${task}" requires a "verified_at" timestamp (string)`);
41
+ }
42
+
43
+ return { task, commit_message: commitMessage, verified_at: verifiedAt };
44
+ }
45
+
46
+ // Every *.json in `noopDir`, validated, as a Map from task id to its
47
+ // record. Absent directory reads as "nothing closed no-op" — the same
48
+ // convention overrides.mjs#loadOverrides and reconcile.mjs#loadReconciliations
49
+ // use for their own missing directories.
50
+ export async function loadNoopRecords(noopDir = NOOP_DIR) {
51
+ let entries;
52
+ try {
53
+ entries = await readdir(noopDir);
54
+ } catch {
55
+ return new Map();
56
+ }
57
+
58
+ const byTask = new Map();
59
+ for (const name of entries.filter((n) => n.endsWith('.json')).sort()) {
60
+ const path = `${noopDir}/${name}`;
61
+ let parsed;
62
+ try {
63
+ parsed = JSON.parse(await readFile(path, 'utf8'));
64
+ } catch (err) {
65
+ throw new Error(`could not read no-op record ${path}: ${err.message}`, { cause: err });
66
+ }
67
+ const record = validateNoop(parsed, path);
68
+ byTask.set(record.task, record);
69
+ }
70
+ return byTask;
71
+ }
72
+
73
+ // Writes one no-op record via temp file + rename, so a crash mid-write
74
+ // never leaves a half-written file for loadNoopRecords to trip on —
75
+ // reconcile.mjs#writeReconciledFile's pattern, applied to the same
76
+ // one-shot-per-task shape.
77
+ //
78
+ // Refuses to overwrite silently: a task closes no-op once, and a second
79
+ // attempt for the same id is a wrong id or a re-run worth stopping for,
80
+ // not a second distinct fact.
81
+ export async function writeNoopFile(record, noopDir = NOOP_DIR) {
82
+ const path = noopFilePath(record.task, noopDir);
83
+ try {
84
+ await readFile(path, 'utf8');
85
+ throw new Error(`${path} already exists — ${record.task} is already recorded as a no-op completion.`);
86
+ } catch (err) {
87
+ if (!err || err.code !== 'ENOENT') throw err;
88
+ }
89
+
90
+ await mkdir(noopDir, { recursive: true });
91
+ const tempPath = `${path}.tmp-${process.pid}`;
92
+ try {
93
+ await writeFile(tempPath, `${JSON.stringify(record, null, 2)}\n`);
94
+ await rename(tempPath, path);
95
+ } catch (err) {
96
+ await rm(tempPath, { force: true }).catch(() => {});
97
+ throw err;
98
+ }
99
+ return record;
100
+ }
101
+
102
+ // No-op-record task ids matching no row in `tasks` — same read-side
103
+ // hygiene as reconcile.mjs#orphanedReconciliations: a dead record must
104
+ // stay discoverable rather than silently completing nothing forever.
105
+ export function orphanedNoopRecords(db, records) {
106
+ const known = new Set(db.prepare('SELECT id FROM tasks').all().map((r) => r.id));
107
+ return [...records.keys()].filter((taskId) => !known.has(taskId)).sort();
108
+ }
@@ -41,6 +41,8 @@ import {
41
41
  RECONCILED_DIR,
42
42
  } from './reconcile.mjs';
43
43
  import { loadNotes, NOTES_DIR } from './notes.mjs';
44
+ import { loadNoopRecords, orphanedNoopRecords, NOOP_DIR } from './noop.mjs';
45
+ import { loadFastpaths, orphanedFastpathTasks, fastpathNote, FASTPATH_DIR } from './fastpath.mjs';
44
46
  import {
45
47
  loadAbandoned,
46
48
  replayAbandonments,
@@ -494,6 +496,57 @@ function replayReconciliations(db, reconciliations) {
494
496
  return replayed;
495
497
  }
496
498
 
499
+ // Replays `.hedgehog/fastpath/*.json` — the committed record of every
500
+ // intent a user closed below the normal per-layer ceremony (fastpath.mjs).
501
+ // Same shape and same reason as replayReconciliations: a fast-pathed task
502
+ // has no commit_message of its own to match in commitSubjects (the fix it
503
+ // covers landed as one real commit, not a per-layer one), so without this
504
+ // replay a rebuild would silently revert every fast-pathed task back to
505
+ // its pre-completion status.
506
+ function replayFastpaths(db, fastpaths) {
507
+ const setComplete = db.prepare(
508
+ "UPDATE tasks SET status = 'complete', blocked_reason = NULL WHERE id = ?",
509
+ );
510
+ const insertNote = db.prepare('INSERT INTO decisions (task_id, note) VALUES (?, ?)');
511
+ const taskExists = db.prepare('SELECT 1 FROM tasks WHERE id = ?');
512
+
513
+ let replayed = 0;
514
+ for (const record of fastpaths.values()) {
515
+ const note = fastpathNote(record);
516
+ for (const taskId of record.tasks) {
517
+ if (taskExists.get(taskId) === undefined) continue;
518
+ setComplete.run(taskId);
519
+ insertNote.run(taskId, note);
520
+ replayed++;
521
+ }
522
+ }
523
+ return replayed;
524
+ }
525
+
526
+ // Replays `.hedgehog/noop/*.json` — the committed record of every task
527
+ // verify.mjs closed `complete` with no commit, because its scope had
528
+ // nothing left to touch before verify_command even ran (noop.mjs).
529
+ //
530
+ // Same shape and same reason as replayReconciliations above: a task
531
+ // recorded here has no commit for markCompletedTasks to match by
532
+ // construction, so without this replay a rebuild would leave it (and
533
+ // everything depending on it) stuck `planned` forever, silently reverting
534
+ // a completion that already happened.
535
+ function replayNoopRecords(db, records) {
536
+ const setComplete = db.prepare(
537
+ "UPDATE tasks SET status = 'complete', blocked_reason = NULL WHERE id = ?",
538
+ );
539
+ const taskExists = db.prepare('SELECT 1 FROM tasks WHERE id = ?');
540
+
541
+ let replayed = 0;
542
+ for (const taskId of records.keys()) {
543
+ if (taskExists.get(taskId) === undefined) continue;
544
+ setComplete.run(taskId);
545
+ replayed++;
546
+ }
547
+ return replayed;
548
+ }
549
+
497
550
  // Rebuilds `db` from scratch: schema, then every committed intent
498
551
  // replayed in dependency order, then planTasks to re-derive tasks +
499
552
  // dependencies, then git history to reconcile which tasks already
@@ -520,6 +573,8 @@ export async function rebuildDb(
520
573
  overridesDir = OVERRIDES_DIR,
521
574
  reconciledDir = RECONCILED_DIR,
522
575
  notesDir = NOTES_DIR,
576
+ noopDir = NOOP_DIR,
577
+ fastpathDir = FASTPATH_DIR,
523
578
  abandonedDir = ABANDONED_DIR,
524
579
  // `hedgehog merge <id>`'s own rebuild call (bin/cli.mjs#mergeCommand):
525
580
  // at the point it calls rebuildDb, `git merge --no-ff` has already
@@ -544,6 +599,8 @@ export async function rebuildDb(
544
599
  const overrides = await loadOverrides(overridesDir);
545
600
  const reconciliations = await loadReconciliations(reconciledDir);
546
601
  const notesByTask = await loadNotes(notesDir);
602
+ const noopRecords = await loadNoopRecords(noopDir);
603
+ const fastpaths = await loadFastpaths(fastpathDir);
547
604
  const abandonments = await loadAbandoned(abandonedDir);
548
605
 
549
606
  // Mirrors bin/cli.mjs#planCommand's own exclusion set: an intent still
@@ -588,11 +645,25 @@ export async function rebuildDb(
588
645
  // (below) still runs afterward to write the provenance note and cover
589
646
  // any reconciled task markCompletedTasks doesn't touch (module = CORE_MODULE
590
647
  // edge cases aside, every reconciled id ends up here either way).
591
- const reconciledTaskIds = new Set(reconciliations.keys());
648
+ // A no-op-completed task and a fast-pathed task have no commit of their
649
+ // own either, for the same reason a reconciled task doesn't — all
650
+ // seeded into the same set, before the same fixpoint walk, so each
651
+ // satisfies its dependents' "every prerequisite complete" check exactly
652
+ // like a reconciled task does.
653
+ const fastpathTaskIds = [...fastpaths.values()].flatMap((record) => record.tasks);
654
+ const reconciledTaskIds = new Set([
655
+ ...reconciliations.keys(),
656
+ ...noopRecords.keys(),
657
+ ...fastpathTaskIds,
658
+ ]);
592
659
  const tasksMarkedComplete = markCompletedTasks(db, commitSubjects, reconciledTaskIds);
593
660
 
594
661
  const tasksReconciled = replayReconciliations(db, reconciliations);
595
662
  const orphanedReconciled = orphanedReconciliations(db, reconciliations);
663
+ const tasksNoop = replayNoopRecords(db, noopRecords);
664
+ const orphanedNoop = orphanedNoopRecords(db, noopRecords);
665
+ const tasksFastpathed = replayFastpaths(db, fastpaths);
666
+ const orphanedFastpath = orphanedFastpathTasks(db, fastpaths);
596
667
 
597
668
  // After every task-status recovery path above (history-matched commits,
598
669
  // then reconciliation) — either can close an intent's last open task,
@@ -625,6 +696,10 @@ export async function rebuildDb(
625
696
  intentsMarkedComplete,
626
697
  tasksReconciled,
627
698
  orphanedReconciled,
699
+ tasksNoop,
700
+ orphanedNoop,
701
+ tasksFastpathed,
702
+ orphanedFastpath,
628
703
  orphanedNotes,
629
704
  abandonmentsReplayed,
630
705
  orphanedAbandonments,
@@ -202,7 +202,7 @@ export function orphanedReconciliations(db, reconciliations) {
202
202
  // forward-scanning window (commitsSince) needs the wider net, to catch a
203
203
  // hand-written commit that landed elsewhere; the floor it scans from stays
204
204
  // anchored to what this branch itself has already credited.
205
- function newestGraphCommit(db) {
205
+ export function newestGraphCommit(db) {
206
206
  const messages = new Set(
207
207
  db.prepare('SELECT commit_message FROM tasks').all().map((r) => r.commit_message),
208
208
  );
@@ -236,7 +236,7 @@ function newestGraphCommit(db) {
236
236
  // `claim`/`ready`/`status`. A project that has never opened a worktree has
237
237
  // exactly one branch with any commits, so `--all` and `HEAD` name the
238
238
  // same set there and this is a no-op for it.
239
- function commitsSince(sinceSha) {
239
+ export function commitsSince(sinceSha) {
240
240
  const range = sinceSha ? [`${sinceSha}..`, '--all'] : ['--all'];
241
241
  let output;
242
242
  try {
package/src/db/verify.mjs CHANGED
@@ -23,12 +23,18 @@
23
23
  // verification at all — the task moves to `blocked` with
24
24
  // blocked_reason `scope_violation`, no `verifications` row written,
25
25
  // lease released. This is a scope violation, not a failing check.
26
- // 2. Only once every touched path matches scope does verify_command run.
27
- // Exit 0: verifications row (passed) artifacts recorded git
28
- // commit with commit_message complete, lease released direct
29
- // dependents re-evaluated (a dependent is ready once every
30
- // dependency is complete same check as the readiness SELECT in
31
- // next.mjs).
26
+ // 2. Once every touched path matches scope, gate 1's own scan already
27
+ // knows whether this task's scope has anything touched at all. If
28
+ // not a genuine no-op, the layer's work already satisfied upstream
29
+ // verify_command never runs and no commit is made: the task closes
30
+ // `complete` directly, and a committed record under `.hedgehog/noop/`
31
+ // is what `hedgehog db rebuild` replays to recover it (noop.mjs),
32
+ // since there is no commit for rebuild to match against.
33
+ // Otherwise verify_command runs. Exit 0: verifications row (passed)
34
+ // → artifacts recorded → git commit with commit_message → complete,
35
+ // lease released → direct dependents re-evaluated (a dependent is
36
+ // ready once every dependency is complete — same check as the
37
+ // readiness SELECT in next.mjs).
32
38
  // Nonzero: verifications row (failed, output retained) → blocked
33
39
  // with blocked_reason `verification_failed`, lease released,
34
40
  // dependents stay blocked.
@@ -57,46 +63,25 @@
57
63
  // verify_command that is a shell command by definition.
58
64
 
59
65
  import { execSync, execFileSync } from 'node:child_process';
60
- import { DB_PATH } from './init.mjs';
61
- import { withCommitLock, LOCK_PATH } from './commitLock.mjs';
66
+ import { withCommitLock } from './commitLock.mjs';
62
67
  import { reapExpiredLeases, pathFingerprint } from './claim.mjs';
63
68
  import { ensureTaskColumns } from './schema.mjs';
64
- import { FRICTION_DIR } from './friction.mjs';
65
- import { OVERRIDES_DIR, composeScope } from './overrides.mjs';
66
- import { RECONCILED_DIR } from './reconcile.mjs';
67
- import { INTENTS_DIR } from './intent.mjs';
68
- import { COMMUNITY_PATH } from './community.mjs';
69
- import { NOTES_DIR } from './notes.mjs';
70
-
71
- // Build-graph state directories: written by their own command
72
- // (`friction add`, `override add`, `intent add`/`db rebuild`, `reconcile
73
- // confirm`, `debt add`/`decision add`), committed by that command's own
74
- // next step, never by a layer's verify_command. A
75
- // layer's own work never lands here, so a path under one of these is
76
- // never this task's doing regardless of when it changed relative to
77
- // claim time unlike attributedToTask's fingerprint check, which only
78
- // excludes a path unchanged since claim and so still attributes a
79
- // friction note logged mid-layer (exactly what the loop skill instructs)
80
- // to whichever task happened to be building when it was logged.
81
- const BUILD_GRAPH_STATE_DIRS = [FRICTION_DIR, OVERRIDES_DIR, INTENTS_DIR, RECONCILED_DIR, NOTES_DIR];
82
-
83
- function isBuildGraphStatePath(path) {
84
- return BUILD_GRAPH_STATE_DIRS.some((dir) => path === dir || path.startsWith(`${dir}/`));
85
- }
86
-
87
- // The build graph file, the commit lock, and the star-prompt state file
88
- // are engine state, written only by this CLI, never by an agent — all
89
- // are excluded from every task's scope check (and from artifacts/
90
- // commits), or verify's own writes would trip the very check they're
91
- // performing. Covers SQLite's journal/WAL/SHM sidecars too.
69
+ import { composeScope } from './overrides.mjs';
70
+ import { FASTPATH_DIR } from './fastpath.mjs';
71
+ import { writeNoopFile } from './noop.mjs';
72
+ import { isEngineStatePath as isEngineStatePathShared } from './engineState.mjs';
73
+
74
+ // The build graph file, the commit lock, the star-prompt state file, and
75
+ // every build-graph-state directory (friction, overrides, intents,
76
+ // reconciled, notes, noop, fast-path) are engine state, written only by
77
+ // their own command, never by a layer's verify_command — all excluded
78
+ // from every task's scope check (and from artifacts/commits), or a
79
+ // build-graph command's own writes would trip the very check it's
80
+ // performing. `FASTPATH_DIR` isn't part of engineState.mjs's own list
81
+ // (importing it there from fastpath.mjs would cycle back here see that
82
+ // module's header), so it's passed in as this call's own extra dir.
92
83
  function isEngineStatePath(path) {
93
- return (
94
- path === DB_PATH ||
95
- path.startsWith(`${DB_PATH}-`) ||
96
- path === LOCK_PATH ||
97
- path === COMMUNITY_PATH ||
98
- isBuildGraphStatePath(path)
99
- );
84
+ return isEngineStatePathShared(path, [FASTPATH_DIR]);
100
85
  }
101
86
 
102
87
  // Runs git with an argv array and no shell, so every element of `args`
@@ -476,7 +461,7 @@ function claimForVerify(db, taskId, owner) {
476
461
  // planRecompileCommand, so it does the same here and passes the
477
462
  // resulting Map in — defaulting to an empty Map keeps every other/test
478
463
  // caller's behavior unchanged.
479
- export function verifyTask(db, taskId, owner, overrides = new Map()) {
464
+ export async function verifyTask(db, taskId, owner, overrides = new Map()) {
480
465
  const task = claimForVerify(db, taskId, owner);
481
466
 
482
467
  const scopeGlobs = JSON.parse(composeScope({ scope_globs: task.scope_globs }, taskId, overrides).scope_globs);
@@ -490,7 +475,7 @@ export function verifyTask(db, taskId, owner, overrides = new Map()) {
490
475
  // in between) can itself touch files inside this task's own scope
491
476
  // (generated lockfiles, formatted output), and committing a stale
492
477
  // pre-verify_command snapshot would silently drop those.
493
- const { offending } = withCommitLock(() => {
478
+ const { offending, inScope: inScopeBeforeVerify } = withCommitLock(() => {
494
479
  // Only what changed during this task's lease is this task's to answer
495
480
  // for; everything else in the shared working tree was already there
496
481
  // when the task was handed out. The commit set below is deliberately
@@ -517,6 +502,47 @@ export function verifyTask(db, taskId, owner, overrides = new Map()) {
517
502
  return { outcome: 'scope_violation', offending };
518
503
  }
519
504
 
505
+ // Genuine no-op: nothing this task's scope claims has changed even
506
+ // before verify_command runs — the layer's work was already satisfied
507
+ // by an earlier layer, or this intent never touches this module at all.
508
+ // verify_command has nothing to check here, so it never runs, and no
509
+ // commit is made. The completion still needs to survive `hedgehog db
510
+ // rebuild`, which recovers completion from a matching commit subject;
511
+ // with no commit to match, the committed record under NOOP_DIR is what
512
+ // rebuild replays instead (see noop.mjs, rebuild.mjs#replayNoopRecords).
513
+ if (inScopeBeforeVerify.length === 0) {
514
+ const verifiedAt = new Date().toISOString();
515
+ await writeNoopFile({ task: task.id, commit_message: task.commit_message, verified_at: verifiedAt });
516
+
517
+ let unlocked;
518
+ let completedIntent;
519
+ db.exec('BEGIN IMMEDIATE');
520
+ try {
521
+ setTaskStatus(db, task.id, 'complete');
522
+ unlocked = unlockReadyDependents(db, task.id);
523
+ completedIntent = completeIntentIfDone(db, task.intent_id);
524
+ db.exec('COMMIT');
525
+ } catch (err) {
526
+ try {
527
+ db.exec('ROLLBACK');
528
+ } catch {
529
+ // Rollback failing must not mask the original error.
530
+ }
531
+ throw err;
532
+ }
533
+
534
+ return {
535
+ outcome: 'complete',
536
+ exitCode: null,
537
+ output: null,
538
+ commitSha: null,
539
+ noop: true,
540
+ unlocked,
541
+ intentComplete: completedIntent !== null,
542
+ completedIntent: completedIntent ?? null,
543
+ };
544
+ }
545
+
520
546
  const { exitCode, output } = runVerifyCommand(task.verify_command);
521
547
 
522
548
  if (exitCode !== 0) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hedgehog",
3
- "version": "6.3.1",
3
+ "version": "6.3.2",
4
4
  "description": "Hedgehog build discipline: ordered, tested, verified build steps.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }