@skyf0xx/hedgehog 6.3.0 → 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 +177 -9
- package/package.json +1 -1
- package/src/db/debt.mjs +67 -5
- package/src/db/engineState.mjs +53 -0
- package/src/db/fastpath.mjs +287 -0
- package/src/db/next.mjs +25 -1
- package/src/db/noop.mjs +108 -0
- package/src/db/notes.mjs +22 -5
- package/src/db/rebuild.mjs +95 -3
- package/src/db/reconcile.mjs +2 -2
- package/src/db/schema.mjs +19 -5
- package/src/db/verify.mjs +71 -45
- package/src/hosts/gemini/gemini-extension.json +1 -1
package/bin/cli.mjs
CHANGED
|
@@ -59,7 +59,7 @@ import {
|
|
|
59
59
|
} from '../src/db/requires.mjs';
|
|
60
60
|
import { whyPath, formatWhy } from '../src/db/why.mjs';
|
|
61
61
|
import { addFriction, listFriction } from '../src/db/friction.mjs';
|
|
62
|
-
import { addDebt, listDebt } from '../src/db/debt.mjs';
|
|
62
|
+
import { addDebt, listDebt, resolveDebt } from '../src/db/debt.mjs';
|
|
63
63
|
import { addDecision, listDecisions } from '../src/db/decision.mjs';
|
|
64
64
|
import {
|
|
65
65
|
shouldPromptForStar,
|
|
@@ -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
|
|
@@ -663,7 +670,8 @@ ${bold('Usage')}
|
|
|
663
670
|
npx @skyf0xx/hedgehog friction add "<note>" log a friction note [--task <task-id>]
|
|
664
671
|
npx @skyf0xx/hedgehog friction list list logged friction, oldest first
|
|
665
672
|
npx @skyf0xx/hedgehog debt add <task-id> "<note>" declare debt that lands in dependent tasks' packets
|
|
666
|
-
npx @skyf0xx/hedgehog debt list [<task-id>]
|
|
673
|
+
npx @skyf0xx/hedgehog debt list [<task-id>] [--all] list open debt, oldest first (--all includes resolved)
|
|
674
|
+
npx @skyf0xx/hedgehog debt resolve <debt-id> --reason "<why>" mark a debt note resolved
|
|
667
675
|
npx @skyf0xx/hedgehog decision add <task-id> "<note>" declare a decision that lands in dependent tasks' packets
|
|
668
676
|
npx @skyf0xx/hedgehog decision list [<task-id>] list declared decisions, oldest first
|
|
669
677
|
npx @skyf0xx/hedgehog db migrate bring the graph's schema up to the latest version
|
|
@@ -1312,6 +1320,33 @@ async function dbRebuildCommand() {
|
|
|
1312
1320
|
`no task with this id exists in the rebuilt graph, so each closes nothing.\n`,
|
|
1313
1321
|
);
|
|
1314
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
|
+
}
|
|
1315
1350
|
if (result.abandonmentsReplayed?.length > 0) {
|
|
1316
1351
|
console.log(
|
|
1317
1352
|
`${dim(`${result.abandonmentsReplayed.length} intent(s) replayed from ${ABANDONED_DIR}/ — kept at planned, not active:`)}\n` +
|
|
@@ -2256,7 +2291,7 @@ async function verifyCommand(args) {
|
|
|
2256
2291
|
return;
|
|
2257
2292
|
}
|
|
2258
2293
|
const overrides = await loadOverrides();
|
|
2259
|
-
result = verifyTask(db, taskId, owner, overrides);
|
|
2294
|
+
result = await verifyTask(db, taskId, owner, overrides);
|
|
2260
2295
|
} catch (err) {
|
|
2261
2296
|
console.error(`${red('Verify failed:')} ${err.message}\n`);
|
|
2262
2297
|
process.exitCode = 1;
|
|
@@ -2286,7 +2321,11 @@ async function verifyCommand(args) {
|
|
|
2286
2321
|
}
|
|
2287
2322
|
|
|
2288
2323
|
console.log(`${green(bold('Verified.'))} Task ${bold(taskId)} is now ${bold('complete')}.`);
|
|
2289
|
-
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
|
+
}
|
|
2290
2329
|
if (result.unlocked.length === 0) {
|
|
2291
2330
|
console.log(` ${dim('no dependents unlocked')}`);
|
|
2292
2331
|
} else {
|
|
@@ -3502,6 +3541,97 @@ async function reconcileCommand(args) {
|
|
|
3502
3541
|
console.log(`${formatEvidence(evidence)}\n`);
|
|
3503
3542
|
}
|
|
3504
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
|
+
|
|
3505
3635
|
// `hedgehog merge <intent-id>` — merges `hedgehog/<intent-id>` into trunk
|
|
3506
3636
|
// with `git merge --no-ff`, rebuilds trunk's graph from what merged, then
|
|
3507
3637
|
// removes the worktree and its branch. See src/db/worktree.mjs for why a
|
|
@@ -3844,28 +3974,61 @@ async function debtCommand(args) {
|
|
|
3844
3974
|
}
|
|
3845
3975
|
|
|
3846
3976
|
if (sub === 'list') {
|
|
3847
|
-
const
|
|
3977
|
+
const includeResolved = args.includes('--all') || args.includes('--resolved');
|
|
3978
|
+
const taskId = args.slice(1).find((a) => !a.startsWith('--'));
|
|
3848
3979
|
const db = openDb();
|
|
3849
3980
|
let entries;
|
|
3850
3981
|
try {
|
|
3851
|
-
entries = listDebt(db, taskId);
|
|
3982
|
+
entries = listDebt(db, taskId, { includeResolved });
|
|
3852
3983
|
} finally {
|
|
3853
3984
|
db.close();
|
|
3854
3985
|
}
|
|
3855
3986
|
|
|
3856
3987
|
if (entries.length === 0) {
|
|
3857
|
-
console.log(`${dim('No debt declared.')}\n`);
|
|
3988
|
+
console.log(`${dim(includeResolved ? 'No debt declared.' : 'No open debt.')}\n`);
|
|
3858
3989
|
return;
|
|
3859
3990
|
}
|
|
3860
3991
|
for (const entry of entries) {
|
|
3861
3992
|
console.log(`#${entry.id} ${dim(entry.loggedAt)} ${bold(entry.taskId)}`);
|
|
3862
|
-
console.log(` ${entry.note}
|
|
3993
|
+
console.log(` ${entry.note}`);
|
|
3994
|
+
if (entry.resolvedAt) {
|
|
3995
|
+
console.log(` ${green('resolved')} ${dim(entry.resolvedAt)} — ${entry.resolvedReason}`);
|
|
3996
|
+
}
|
|
3997
|
+
console.log('');
|
|
3998
|
+
}
|
|
3999
|
+
return;
|
|
4000
|
+
}
|
|
4001
|
+
|
|
4002
|
+
if (sub === 'resolve') {
|
|
4003
|
+
const debtId = args[1];
|
|
4004
|
+
const reasonIdx = args.indexOf('--reason');
|
|
4005
|
+
const reason = reasonIdx !== -1 ? args[reasonIdx + 1] : undefined;
|
|
4006
|
+
|
|
4007
|
+
if (!debtId || debtId.startsWith('--') || !reason) {
|
|
4008
|
+
console.error(`${red('Usage:')} hedgehog debt resolve <debt-id> --reason "<why>"\n`);
|
|
4009
|
+
process.exitCode = 1;
|
|
4010
|
+
return;
|
|
4011
|
+
}
|
|
4012
|
+
|
|
4013
|
+
const db = openDb();
|
|
4014
|
+
let result;
|
|
4015
|
+
try {
|
|
4016
|
+
result = await resolveDebt(db, { debtId: Number(debtId), reason });
|
|
4017
|
+
} catch (err) {
|
|
4018
|
+
console.error(`${red('Failed to resolve debt:')} ${err.message}\n`);
|
|
4019
|
+
process.exitCode = 1;
|
|
4020
|
+
return;
|
|
4021
|
+
} finally {
|
|
4022
|
+
db.close();
|
|
3863
4023
|
}
|
|
4024
|
+
|
|
4025
|
+
console.log(` ${green('resolved')} #${result.id} ${bold(result.taskId)}`);
|
|
4026
|
+
console.log(` ${dim(result.note)}`);
|
|
3864
4027
|
return;
|
|
3865
4028
|
}
|
|
3866
4029
|
|
|
3867
4030
|
console.error(
|
|
3868
|
-
`${red('Unknown debt subcommand:')} ${sub ?? '(none)'}\n\nUsage: hedgehog debt add <task-id> "<note>"\n or: hedgehog debt list [<task-id>]\n`,
|
|
4031
|
+
`${red('Unknown debt subcommand:')} ${sub ?? '(none)'}\n\nUsage: hedgehog debt add <task-id> "<note>"\n or: hedgehog debt list [<task-id>] [--all]\n or: hedgehog debt resolve <debt-id> --reason "<why>"\n`,
|
|
3869
4032
|
);
|
|
3870
4033
|
process.exitCode = 1;
|
|
3871
4034
|
}
|
|
@@ -4335,6 +4498,11 @@ async function main() {
|
|
|
4335
4498
|
return;
|
|
4336
4499
|
}
|
|
4337
4500
|
|
|
4501
|
+
if (cmd === 'fast-path') {
|
|
4502
|
+
await fastpathCommand(args.slice(1));
|
|
4503
|
+
return;
|
|
4504
|
+
}
|
|
4505
|
+
|
|
4338
4506
|
if (cmd === 'merge') {
|
|
4339
4507
|
await mergeCommand(args.slice(1));
|
|
4340
4508
|
return;
|
package/package.json
CHANGED
package/src/db/debt.mjs
CHANGED
|
@@ -58,14 +58,25 @@ export async function addDebt(db, { taskId, note }, notesDir = undefined) {
|
|
|
58
58
|
return { id: Number(result.lastInsertRowid), taskId, note };
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
// Every debt row, oldest first, optionally narrowed to one task.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
// Every debt row, oldest first, optionally narrowed to one task. Resolved
|
|
62
|
+
// rows are excluded by default — `debt list` is meant to answer "what's
|
|
63
|
+
// still open", the same way `reconcile list` never re-surfaces a task once
|
|
64
|
+
// it has closed — and included when `includeResolved` is set (`--all`).
|
|
65
|
+
export function listDebt(db, taskId, { includeResolved = false } = {}) {
|
|
66
|
+
const conditions = [];
|
|
67
|
+
const params = [];
|
|
68
|
+
if (taskId) {
|
|
69
|
+
conditions.push('task_id = ?');
|
|
70
|
+
params.push(taskId);
|
|
71
|
+
}
|
|
72
|
+
if (!includeResolved) conditions.push('resolved_at IS NULL');
|
|
73
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
65
74
|
try {
|
|
66
75
|
return db
|
|
67
76
|
.prepare(
|
|
68
|
-
`SELECT id, task_id AS taskId, note, logged_at AS loggedAt
|
|
77
|
+
`SELECT id, task_id AS taskId, note, logged_at AS loggedAt,
|
|
78
|
+
resolved_at AS resolvedAt, resolved_reason AS resolvedReason
|
|
79
|
+
FROM debt ${where} ORDER BY id ASC`,
|
|
69
80
|
)
|
|
70
81
|
.all(...params);
|
|
71
82
|
} catch {
|
|
@@ -73,3 +84,54 @@ export function listDebt(db, taskId) {
|
|
|
73
84
|
return [];
|
|
74
85
|
}
|
|
75
86
|
}
|
|
87
|
+
|
|
88
|
+
// Count of open (unresolved) debt across the whole graph — the one-line
|
|
89
|
+
// figure `hedgehog next`/`hedgehog claim` surface so debt doesn't
|
|
90
|
+
// accumulate silently with nothing prompting a look at it.
|
|
91
|
+
export function openDebtCount(db) {
|
|
92
|
+
try {
|
|
93
|
+
const row = db.prepare('SELECT COUNT(*) AS n FROM debt WHERE resolved_at IS NULL').get();
|
|
94
|
+
return row.n;
|
|
95
|
+
} catch {
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Resolves one debt row by id: marks it resolved in the DB and writes the
|
|
101
|
+
// committed record behind it — same ordering as addDebt (file before row)
|
|
102
|
+
// and the same reason: if the write fails, nothing has been resolved on a
|
|
103
|
+
// fact that would not survive the next rebuild.
|
|
104
|
+
//
|
|
105
|
+
// The committed note is keyed by the debt row's own (task_id, note,
|
|
106
|
+
// logged_at) rather than its DB `id`, because that id is a fresh
|
|
107
|
+
// autoincrement every time rebuild.mjs#replayNotes re-inserts debt rows —
|
|
108
|
+
// it is not stable across a rebuild, so a resolution referencing it would
|
|
109
|
+
// point at nothing once replayed. The triple already committed for the
|
|
110
|
+
// debt note itself is the one part of its identity that *is* stable.
|
|
111
|
+
export async function resolveDebt(db, { debtId, reason }, notesDir = undefined) {
|
|
112
|
+
applySchema(db);
|
|
113
|
+
|
|
114
|
+
if (!debtId) throw new Error('debt resolve requires a debt id');
|
|
115
|
+
if (!reason) throw new Error('debt resolve requires a --reason');
|
|
116
|
+
|
|
117
|
+
const row = db
|
|
118
|
+
.prepare('SELECT id, task_id AS taskId, note, logged_at AS loggedAt, resolved_at AS resolvedAt FROM debt WHERE id = ?')
|
|
119
|
+
.get(debtId);
|
|
120
|
+
if (!row) throw new Error(`no such debt: #${debtId}`);
|
|
121
|
+
if (row.resolvedAt) throw new Error(`debt #${debtId} is already resolved`);
|
|
122
|
+
|
|
123
|
+
const resolvedAt = new Date().toISOString();
|
|
124
|
+
await appendNote(
|
|
125
|
+
row.taskId,
|
|
126
|
+
{ kind: 'debt-resolve', resolves: row.loggedAt, reason, loggedAt: resolvedAt },
|
|
127
|
+
notesDir,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
db.prepare('UPDATE debt SET resolved_at = ?, resolved_reason = ? WHERE id = ?').run(
|
|
131
|
+
resolvedAt,
|
|
132
|
+
reason,
|
|
133
|
+
debtId,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return { id: row.id, taskId: row.taskId, note: row.note, resolvedAt, resolvedReason: reason };
|
|
137
|
+
}
|
|
@@ -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/next.mjs
CHANGED
|
@@ -105,7 +105,9 @@ function loadInheritedDebt(db, taskId) {
|
|
|
105
105
|
try {
|
|
106
106
|
return db
|
|
107
107
|
.prepare(
|
|
108
|
-
`SELECT task_id AS taskId, note FROM debt
|
|
108
|
+
`SELECT task_id AS taskId, note FROM debt
|
|
109
|
+
WHERE task_id IN (${placeholders}) AND resolved_at IS NULL
|
|
110
|
+
ORDER BY id ASC`,
|
|
109
111
|
)
|
|
110
112
|
.all(...upstream);
|
|
111
113
|
} catch {
|
|
@@ -113,6 +115,20 @@ function loadInheritedDebt(db, taskId) {
|
|
|
113
115
|
}
|
|
114
116
|
}
|
|
115
117
|
|
|
118
|
+
// Total open (unresolved) debt across the whole graph — not just this
|
|
119
|
+
// task's ancestors — so a packet can say plainly that debt exists even
|
|
120
|
+
// where none of it happens to be inherited here. Tolerates a `debt` table
|
|
121
|
+
// or `resolved_at` column that doesn't exist yet, the same way
|
|
122
|
+
// loadInheritedDebt does.
|
|
123
|
+
function loadOpenDebtCount(db) {
|
|
124
|
+
try {
|
|
125
|
+
const row = db.prepare('SELECT COUNT(*) AS n FROM debt WHERE resolved_at IS NULL').get();
|
|
126
|
+
return row.n;
|
|
127
|
+
} catch {
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
116
132
|
// Decisions declared by anything this task inherits from (see
|
|
117
133
|
// decision.mjs) — same upstream walk as loadInheritedDebt, same tolerance
|
|
118
134
|
// for a `decisions` table that doesn't exist yet on an older graph.
|
|
@@ -181,6 +197,7 @@ function assemblePacket(db, task) {
|
|
|
181
197
|
const incompleteDeps = incompleteDependencies(db, task.id);
|
|
182
198
|
const inheritedDebt = loadInheritedDebt(db, task.id);
|
|
183
199
|
const inheritedDecisions = loadInheritedDecisions(db, task.id);
|
|
200
|
+
const openDebtCount = loadOpenDebtCount(db);
|
|
184
201
|
|
|
185
202
|
return {
|
|
186
203
|
task,
|
|
@@ -190,6 +207,7 @@ function assemblePacket(db, task) {
|
|
|
190
207
|
incompleteDeps,
|
|
191
208
|
inheritedDebt,
|
|
192
209
|
inheritedDecisions,
|
|
210
|
+
openDebtCount,
|
|
193
211
|
};
|
|
194
212
|
}
|
|
195
213
|
|
|
@@ -484,6 +502,7 @@ export function formatPacket(packet, statusLine, coreId = null, exists = null) {
|
|
|
484
502
|
incompleteDeps = [],
|
|
485
503
|
inheritedDebt = [],
|
|
486
504
|
inheritedDecisions = [],
|
|
505
|
+
openDebtCount = 0,
|
|
487
506
|
} = packet;
|
|
488
507
|
const scopeGlobs = JSON.parse(task.scope_globs);
|
|
489
508
|
const firstArrival = firstArrivalPackages(task, exists);
|
|
@@ -521,6 +540,11 @@ export function formatPacket(packet, statusLine, coreId = null, exists = null) {
|
|
|
521
540
|
lines.push(` ! ${entry.taskId} ${entry.note}`);
|
|
522
541
|
}
|
|
523
542
|
}
|
|
543
|
+
if (openDebtCount > 0) {
|
|
544
|
+
lines.push(
|
|
545
|
+
` ${openDebtCount} open debt note(s) across the whole graph — \`hedgehog debt list --all\` to see them all`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
524
548
|
lines.push('');
|
|
525
549
|
lines.push('INHERITED DECISIONS');
|
|
526
550
|
if (inheritedDecisions.length === 0) {
|
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/notes.mjs
CHANGED
|
@@ -45,12 +45,21 @@ function validateNotesFile(record, path) {
|
|
|
45
45
|
if (entry === null || typeof entry !== 'object') {
|
|
46
46
|
throw new Error(`${path}: notes file "${task}" has a non-object entry in notes`);
|
|
47
47
|
}
|
|
48
|
-
if (entry.kind !== 'debt' && entry.kind !== 'decision') {
|
|
48
|
+
if (entry.kind !== 'debt' && entry.kind !== 'decision' && entry.kind !== 'debt-resolve') {
|
|
49
49
|
throw new Error(
|
|
50
|
-
`${path}: notes file "${task}" has an entry with kind "${entry.kind}" — expected "debt" or "
|
|
50
|
+
`${path}: notes file "${task}" has an entry with kind "${entry.kind}" — expected "debt", "decision", or "debt-resolve"`,
|
|
51
51
|
);
|
|
52
52
|
}
|
|
53
|
-
if (
|
|
53
|
+
if (entry.kind === 'debt-resolve') {
|
|
54
|
+
if (!entry.resolves || typeof entry.resolves !== 'string') {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`${path}: notes file "${task}" has a "debt-resolve" entry with no "resolves" (the logged_at of the debt note it closes)`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (!entry.reason || typeof entry.reason !== 'string') {
|
|
60
|
+
throw new Error(`${path}: notes file "${task}" has a "debt-resolve" entry with no "reason"`);
|
|
61
|
+
}
|
|
62
|
+
} else if (!entry.note || typeof entry.note !== 'string') {
|
|
54
63
|
throw new Error(`${path}: notes file "${task}" has an entry with no "note" (string)`);
|
|
55
64
|
}
|
|
56
65
|
if (!entry.logged_at || typeof entry.logged_at !== 'string') {
|
|
@@ -92,7 +101,7 @@ export async function loadNotes(notesDir = NOTES_DIR) {
|
|
|
92
101
|
// never leaves a half-written file for loadNotes to trip on —
|
|
93
102
|
// reconcile.mjs#writeReconciledFile's pattern, applied to a file that
|
|
94
103
|
// grows instead of one written once.
|
|
95
|
-
export async function appendNote(taskId, { kind, note, loggedAt }, notesDir = NOTES_DIR) {
|
|
104
|
+
export async function appendNote(taskId, { kind, note, loggedAt, resolves, reason }, notesDir = NOTES_DIR) {
|
|
96
105
|
const path = notesFilePath(taskId, notesDir);
|
|
97
106
|
|
|
98
107
|
let existing = [];
|
|
@@ -103,9 +112,17 @@ export async function appendNote(taskId, { kind, note, loggedAt }, notesDir = NO
|
|
|
103
112
|
if (!err || err.code !== 'ENOENT') throw err;
|
|
104
113
|
}
|
|
105
114
|
|
|
115
|
+
// `debt-resolve` entries carry `resolves`/`reason` instead of `note` —
|
|
116
|
+
// omit `note` entirely rather than writing it as undefined/null, so a
|
|
117
|
+
// resolve entry's shape matches what validateNotesFile expects back.
|
|
118
|
+
const entry =
|
|
119
|
+
kind === 'debt-resolve'
|
|
120
|
+
? { kind, resolves, reason, logged_at: loggedAt }
|
|
121
|
+
: { kind, note, logged_at: loggedAt };
|
|
122
|
+
|
|
106
123
|
const record = {
|
|
107
124
|
task: taskId.toUpperCase(),
|
|
108
|
-
notes: [...existing,
|
|
125
|
+
notes: [...existing, entry],
|
|
109
126
|
};
|
|
110
127
|
|
|
111
128
|
await mkdir(notesDir, { recursive: true });
|
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,
|
|
@@ -110,23 +112,40 @@ function replayNotes(db, notesByTask) {
|
|
|
110
112
|
const insertDecision = db.prepare(
|
|
111
113
|
'INSERT INTO decisions (task_id, note, logged_at) VALUES (?, ?, ?)',
|
|
112
114
|
);
|
|
115
|
+
const resolveDebtRow = db.prepare(
|
|
116
|
+
'UPDATE debt SET resolved_at = ?, resolved_reason = ? WHERE task_id = ? AND logged_at = ? AND resolved_at IS NULL',
|
|
117
|
+
);
|
|
113
118
|
|
|
114
119
|
const orphaned = [];
|
|
120
|
+
// Two passes: every `debt`/`decision` entry inserted first, then every
|
|
121
|
+
// `debt-resolve` entry applied — a resolve entry can appear anywhere
|
|
122
|
+
// after its debt entry in the same file, but the row it references must
|
|
123
|
+
// already exist for the UPDATE to find it.
|
|
115
124
|
for (const [taskId, notes] of notesByTask) {
|
|
116
125
|
if (taskExists.get(taskId) === undefined) {
|
|
117
126
|
for (const entry of notes) {
|
|
118
|
-
orphaned.push({ kind: entry.kind, taskId, note: entry.note });
|
|
127
|
+
if (entry.kind !== 'debt-resolve') orphaned.push({ kind: entry.kind, taskId, note: entry.note });
|
|
119
128
|
}
|
|
120
129
|
continue;
|
|
121
130
|
}
|
|
122
131
|
for (const entry of notes) {
|
|
123
132
|
if (entry.kind === 'debt') {
|
|
124
133
|
insertDebt.run(taskId, entry.note, entry.logged_at);
|
|
125
|
-
} else {
|
|
134
|
+
} else if (entry.kind === 'decision') {
|
|
126
135
|
insertDecision.run(taskId, entry.note, entry.logged_at);
|
|
127
136
|
}
|
|
128
137
|
}
|
|
129
138
|
}
|
|
139
|
+
for (const [taskId, notes] of notesByTask) {
|
|
140
|
+
if (taskExists.get(taskId) === undefined) continue;
|
|
141
|
+
for (const entry of notes) {
|
|
142
|
+
if (entry.kind !== 'debt-resolve') continue;
|
|
143
|
+
const result = resolveDebtRow.run(entry.logged_at, entry.reason, taskId, entry.resolves);
|
|
144
|
+
if (result.changes === 0) {
|
|
145
|
+
orphaned.push({ kind: entry.kind, taskId, note: `resolve for ${entry.resolves}` });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
130
149
|
return orphaned;
|
|
131
150
|
}
|
|
132
151
|
|
|
@@ -477,6 +496,57 @@ function replayReconciliations(db, reconciliations) {
|
|
|
477
496
|
return replayed;
|
|
478
497
|
}
|
|
479
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
|
+
|
|
480
550
|
// Rebuilds `db` from scratch: schema, then every committed intent
|
|
481
551
|
// replayed in dependency order, then planTasks to re-derive tasks +
|
|
482
552
|
// dependencies, then git history to reconcile which tasks already
|
|
@@ -503,6 +573,8 @@ export async function rebuildDb(
|
|
|
503
573
|
overridesDir = OVERRIDES_DIR,
|
|
504
574
|
reconciledDir = RECONCILED_DIR,
|
|
505
575
|
notesDir = NOTES_DIR,
|
|
576
|
+
noopDir = NOOP_DIR,
|
|
577
|
+
fastpathDir = FASTPATH_DIR,
|
|
506
578
|
abandonedDir = ABANDONED_DIR,
|
|
507
579
|
// `hedgehog merge <id>`'s own rebuild call (bin/cli.mjs#mergeCommand):
|
|
508
580
|
// at the point it calls rebuildDb, `git merge --no-ff` has already
|
|
@@ -527,6 +599,8 @@ export async function rebuildDb(
|
|
|
527
599
|
const overrides = await loadOverrides(overridesDir);
|
|
528
600
|
const reconciliations = await loadReconciliations(reconciledDir);
|
|
529
601
|
const notesByTask = await loadNotes(notesDir);
|
|
602
|
+
const noopRecords = await loadNoopRecords(noopDir);
|
|
603
|
+
const fastpaths = await loadFastpaths(fastpathDir);
|
|
530
604
|
const abandonments = await loadAbandoned(abandonedDir);
|
|
531
605
|
|
|
532
606
|
// Mirrors bin/cli.mjs#planCommand's own exclusion set: an intent still
|
|
@@ -571,11 +645,25 @@ export async function rebuildDb(
|
|
|
571
645
|
// (below) still runs afterward to write the provenance note and cover
|
|
572
646
|
// any reconciled task markCompletedTasks doesn't touch (module = CORE_MODULE
|
|
573
647
|
// edge cases aside, every reconciled id ends up here either way).
|
|
574
|
-
|
|
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
|
+
]);
|
|
575
659
|
const tasksMarkedComplete = markCompletedTasks(db, commitSubjects, reconciledTaskIds);
|
|
576
660
|
|
|
577
661
|
const tasksReconciled = replayReconciliations(db, reconciliations);
|
|
578
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);
|
|
579
667
|
|
|
580
668
|
// After every task-status recovery path above (history-matched commits,
|
|
581
669
|
// then reconciliation) — either can close an intent's last open task,
|
|
@@ -608,6 +696,10 @@ export async function rebuildDb(
|
|
|
608
696
|
intentsMarkedComplete,
|
|
609
697
|
tasksReconciled,
|
|
610
698
|
orphanedReconciled,
|
|
699
|
+
tasksNoop,
|
|
700
|
+
orphanedNoop,
|
|
701
|
+
tasksFastpathed,
|
|
702
|
+
orphanedFastpath,
|
|
611
703
|
orphanedNotes,
|
|
612
704
|
abandonmentsReplayed,
|
|
613
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/schema.mjs
CHANGED
|
@@ -103,10 +103,12 @@ CREATE TABLE IF NOT EXISTS verifications (
|
|
|
103
103
|
-- every task that (transitively) depends on task_id, so the limitation
|
|
104
104
|
-- travels down the chain the same way the dependency does.
|
|
105
105
|
CREATE TABLE IF NOT EXISTS debt (
|
|
106
|
-
id
|
|
107
|
-
task_id
|
|
108
|
-
note
|
|
109
|
-
logged_at
|
|
106
|
+
id INTEGER PRIMARY KEY,
|
|
107
|
+
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
108
|
+
note TEXT NOT NULL,
|
|
109
|
+
logged_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
110
|
+
resolved_at TEXT,
|
|
111
|
+
resolved_reason TEXT
|
|
110
112
|
);
|
|
111
113
|
|
|
112
114
|
-- Declared decision: a note one task leaves for the tasks that inherit
|
|
@@ -176,7 +178,7 @@ export function ensureTaskColumns(db) {
|
|
|
176
178
|
// hand-set past what MIGRATIONS actually covers, since runMigrations
|
|
177
179
|
// trusts this number to mean "every migration through this version has
|
|
178
180
|
// run."
|
|
179
|
-
export const CURRENT_SCHEMA_VERSION =
|
|
181
|
+
export const CURRENT_SCHEMA_VERSION = 3;
|
|
180
182
|
|
|
181
183
|
// Forward migrations, applied in order to bring a graph's user_version up
|
|
182
184
|
// to CURRENT_SCHEMA_VERSION. Unlike the CREATE TABLE IF NOT EXISTS /
|
|
@@ -209,6 +211,18 @@ const MIGRATIONS = [
|
|
|
209
211
|
`);
|
|
210
212
|
},
|
|
211
213
|
},
|
|
214
|
+
{
|
|
215
|
+
version: 3,
|
|
216
|
+
// A `debt` row with no `resolved_at` reads back as unresolved, so an
|
|
217
|
+
// existing graph's rows default to open the moment this column exists.
|
|
218
|
+
migrate: (db) => {
|
|
219
|
+
const existing = new Set(db.prepare('PRAGMA table_info(debt)').all().map((row) => row.name));
|
|
220
|
+
if (!existing.has('resolved_at')) db.exec('ALTER TABLE debt ADD COLUMN resolved_at TEXT');
|
|
221
|
+
if (!existing.has('resolved_reason')) {
|
|
222
|
+
db.exec('ALTER TABLE debt ADD COLUMN resolved_reason TEXT');
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
},
|
|
212
226
|
];
|
|
213
227
|
|
|
214
228
|
// Brings a graph's `PRAGMA user_version` up to CURRENT_SCHEMA_VERSION,
|
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) {
|