@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 +136 -2
- package/package.json +1 -1
- package/src/db/engineState.mjs +53 -0
- package/src/db/fastpath.mjs +287 -0
- package/src/db/noop.mjs +108 -0
- package/src/db/rebuild.mjs +76 -1
- package/src/db/reconcile.mjs +2 -2
- package/src/db/verify.mjs +71 -45
- package/src/hosts/gemini/gemini-extension.json +1 -1
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.
|
|
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
|
@@ -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
|
+
}
|
package/src/db/noop.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/db/rebuild.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/db/reconcile.mjs
CHANGED
|
@@ -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.
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
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 {
|
|
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 {
|
|
65
|
-
import {
|
|
66
|
-
import {
|
|
67
|
-
import {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
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) {
|