@skyf0xx/hedgehog 6.1.4 → 6.1.6
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 +137 -0
- package/package.json +1 -1
- package/src/agents/planner.md +6 -6
- package/src/db/rebuild.mjs +79 -6
- package/src/db/reconcile.mjs +591 -0
- package/src/db/status.mjs +61 -3
- package/src/db/verify.mjs +5 -3
- package/src/hosts/gemini/gemini-extension.json +1 -1
package/bin/cli.mjs
CHANGED
|
@@ -62,6 +62,16 @@ import {
|
|
|
62
62
|
} from '../src/db/community.mjs';
|
|
63
63
|
import { rebuildDb } from '../src/db/rebuild.mjs';
|
|
64
64
|
import { loadOverrides, addOverride, orphanedOverrides, OVERRIDES_DIR } from '../src/db/overrides.mjs';
|
|
65
|
+
import {
|
|
66
|
+
gatherEvidence,
|
|
67
|
+
formatEvidence,
|
|
68
|
+
evidenceForTask,
|
|
69
|
+
confirmReconciliation,
|
|
70
|
+
loadReconciliations,
|
|
71
|
+
orphanedReconciliations,
|
|
72
|
+
formatReconciliations,
|
|
73
|
+
RECONCILED_DIR,
|
|
74
|
+
} from '../src/db/reconcile.mjs';
|
|
65
75
|
import { HOSTS, HOST_FLAGS, DEFAULT_HOST, availableHosts } from '../src/hosts/index.mjs';
|
|
66
76
|
import { recordHosts, installedHosts } from '../src/hosts/installed.mjs';
|
|
67
77
|
import { wrapSection } from '../src/hosts/claude-md-merge.mjs';
|
|
@@ -545,6 +555,12 @@ ${bold('Usage')}
|
|
|
545
555
|
npx @skyf0xx/hedgehog override add <task-id> --scope <glob> [--scope <glob>...] --reason "<why>"
|
|
546
556
|
record a committed, additive-only scope exception for one task
|
|
547
557
|
npx @skyf0xx/hedgehog override list list recorded scope overrides
|
|
558
|
+
npx @skyf0xx/hedgehog reconcile propose which open tasks hand-written commits may have
|
|
559
|
+
already satisfied; reads only, changes nothing
|
|
560
|
+
npx @skyf0xx/hedgehog reconcile confirm <task-id> --reason "<why>"
|
|
561
|
+
close one task on your judgment — no scope gate and no
|
|
562
|
+
verify command run; records it under .hedgehog/reconciled/
|
|
563
|
+
npx @skyf0xx/hedgehog reconcile list list recorded reconciliations
|
|
548
564
|
npx @skyf0xx/hedgehog intent add [flags] add an intent (rules/requirements/dependencies)
|
|
549
565
|
npx @skyf0xx/hedgehog intent add --file <path> add an intent from a JSON file
|
|
550
566
|
npx @skyf0xx/hedgehog next print the task packet for one ready task
|
|
@@ -1186,6 +1202,21 @@ async function dbRebuildCommand() {
|
|
|
1186
1202
|
console.log(
|
|
1187
1203
|
`${green('rebuilt')} ${dim(`${result.intentsReplayed} intent(s) replayed, ${result.tasksMarkedComplete} task(s) marked complete`)}\n`,
|
|
1188
1204
|
);
|
|
1205
|
+
// Reported separately from the count above, not folded into it: a task
|
|
1206
|
+
// closed from a committed reconciliation had no verify run behind it,
|
|
1207
|
+
// and a rebuild is exactly where that distinction would otherwise
|
|
1208
|
+
// vanish into a single "marked complete" number.
|
|
1209
|
+
if (result.tasksReconciled > 0) {
|
|
1210
|
+
console.log(
|
|
1211
|
+
`${dim(`${result.tasksReconciled} task(s) replayed from ${RECONCILED_DIR}/ — closed by reconciliation, not verification`)}\n`,
|
|
1212
|
+
);
|
|
1213
|
+
}
|
|
1214
|
+
if (result.orphanedReconciled?.length > 0) {
|
|
1215
|
+
console.log(
|
|
1216
|
+
`${yellow(bold('Reconciliations without a task.'))} ${result.orphanedReconciled.join(', ')} —\n` +
|
|
1217
|
+
`no task with this id exists in the rebuilt graph, so each closes nothing.\n`,
|
|
1218
|
+
);
|
|
1219
|
+
}
|
|
1189
1220
|
warnOrphanedNotes(result);
|
|
1190
1221
|
warnRebuildDrift(result, corePath);
|
|
1191
1222
|
}
|
|
@@ -3134,6 +3165,107 @@ async function overrideCommand(args) {
|
|
|
3134
3165
|
process.exitCode = 1;
|
|
3135
3166
|
}
|
|
3136
3167
|
|
|
3168
|
+
// `hedgehog reconcile` / `hedgehog reconcile confirm <task-id> --reason
|
|
3169
|
+
// "<why>"` / `hedgehog reconcile list` — absorbs work that landed outside
|
|
3170
|
+
// the loop into the build graph (see src/db/reconcile.mjs).
|
|
3171
|
+
//
|
|
3172
|
+
// The bare form only reads: it prints which commits since the newest
|
|
3173
|
+
// graph-written commit touched files inside each open task's scope, and
|
|
3174
|
+
// changes nothing. `confirm` takes exactly one task id — there is no
|
|
3175
|
+
// bulk form, because a single "yes to all" is the unexamined assertion
|
|
3176
|
+
// this command exists to avoid. Nothing else in the CLI calls into this;
|
|
3177
|
+
// `status`, `next`, and `claim` never reconcile on their own.
|
|
3178
|
+
async function reconcileCommand(args) {
|
|
3179
|
+
await ensureDb();
|
|
3180
|
+
|
|
3181
|
+
if (!(await exists(DB_PATH))) {
|
|
3182
|
+
console.error(`${red('No build graph found.')} Run ${bold('hedgehog db init')} first.\n`);
|
|
3183
|
+
process.exitCode = 1;
|
|
3184
|
+
return;
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
const sub = args[0];
|
|
3188
|
+
|
|
3189
|
+
if (sub === 'list') {
|
|
3190
|
+
const reconciliations = await loadReconciliations();
|
|
3191
|
+
const db = openDb({ readOnly: true });
|
|
3192
|
+
let orphaned = [];
|
|
3193
|
+
try {
|
|
3194
|
+
orphaned = orphanedReconciliations(db, reconciliations);
|
|
3195
|
+
} finally {
|
|
3196
|
+
db.close();
|
|
3197
|
+
}
|
|
3198
|
+
console.log(`${formatReconciliations(reconciliations, orphaned)}\n`);
|
|
3199
|
+
return;
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
if (sub === 'confirm') {
|
|
3203
|
+
const taskId = args[1];
|
|
3204
|
+
const reasonIdx = args.indexOf('--reason');
|
|
3205
|
+
const reason = reasonIdx !== -1 ? args[reasonIdx + 1] : undefined;
|
|
3206
|
+
|
|
3207
|
+
if (!taskId || taskId.startsWith('--') || !reason) {
|
|
3208
|
+
console.error(
|
|
3209
|
+
`${red('Usage:')} hedgehog reconcile confirm <task-id> --reason "<why this work satisfies it>"\n`,
|
|
3210
|
+
);
|
|
3211
|
+
process.exitCode = 1;
|
|
3212
|
+
return;
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3215
|
+
printDbTarget();
|
|
3216
|
+
const db = openDb();
|
|
3217
|
+
let result;
|
|
3218
|
+
try {
|
|
3219
|
+
const evidence = evidenceForTask(db, taskId);
|
|
3220
|
+
result = await confirmReconciliation(db, { taskId, reason, evidence });
|
|
3221
|
+
} catch (err) {
|
|
3222
|
+
console.error(`${red('Failed to reconcile:')} ${err.message}\n`);
|
|
3223
|
+
process.exitCode = 1;
|
|
3224
|
+
return;
|
|
3225
|
+
} finally {
|
|
3226
|
+
db.close();
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
const file = `${RECONCILED_DIR}/${result.record.task.toLowerCase()}.json`;
|
|
3230
|
+
console.log(` ${green('complete')} ${bold(result.record.task)} ${dim('(reconciled, not verified)')}`);
|
|
3231
|
+
console.log(` ${green('recorded')} ${file}`);
|
|
3232
|
+
if (result.unlocked.length > 0) {
|
|
3233
|
+
console.log(` ${dim(`unlocked: ${result.unlocked.join(', ')}`)}`);
|
|
3234
|
+
}
|
|
3235
|
+
console.log(
|
|
3236
|
+
`\n ${bold('Commit that file.')} ${dim('The build graph is derived and gitignored — an')}\n` +
|
|
3237
|
+
` ${dim('uncommitted reconciliation is reverted by the next `hedgehog db rebuild`.')}\n`,
|
|
3238
|
+
);
|
|
3239
|
+
return;
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
if (sub !== undefined) {
|
|
3243
|
+
console.error(
|
|
3244
|
+
`${red('Unknown reconcile subcommand:')} ${sub}\n\n` +
|
|
3245
|
+
`Usage: hedgehog reconcile\n` +
|
|
3246
|
+
` or: hedgehog reconcile confirm <task-id> --reason "<why>"\n` +
|
|
3247
|
+
` or: hedgehog reconcile list\n`,
|
|
3248
|
+
);
|
|
3249
|
+
process.exitCode = 1;
|
|
3250
|
+
return;
|
|
3251
|
+
}
|
|
3252
|
+
|
|
3253
|
+
// Bare `hedgehog reconcile` — read only.
|
|
3254
|
+
const reconciliations = await loadReconciliations();
|
|
3255
|
+
const db = openDb({ readOnly: true });
|
|
3256
|
+
let evidence;
|
|
3257
|
+
try {
|
|
3258
|
+
evidence = gatherEvidence(db, { reconciliations });
|
|
3259
|
+
} catch (err) {
|
|
3260
|
+
console.error(`${red('Failed to read evidence:')} ${err.message}\n`);
|
|
3261
|
+
process.exitCode = 1;
|
|
3262
|
+
return;
|
|
3263
|
+
} finally {
|
|
3264
|
+
db.close();
|
|
3265
|
+
}
|
|
3266
|
+
console.log(`${formatEvidence(evidence)}\n`);
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3137
3269
|
// `hedgehog debt add <task-id> "<note>"` / `hedgehog debt list [<task-id>]`
|
|
3138
3270
|
// — declared debt between tasks. A note recorded against a task is
|
|
3139
3271
|
// rendered into the INHERITED DEBT section of the packet of every task
|
|
@@ -3595,6 +3727,11 @@ async function main() {
|
|
|
3595
3727
|
return;
|
|
3596
3728
|
}
|
|
3597
3729
|
|
|
3730
|
+
if (cmd === 'reconcile') {
|
|
3731
|
+
await reconcileCommand(args.slice(1));
|
|
3732
|
+
return;
|
|
3733
|
+
}
|
|
3734
|
+
|
|
3598
3735
|
console.error(`${red('Unknown command:')} ${cmd}\n`);
|
|
3599
3736
|
await help();
|
|
3600
3737
|
process.exitCode = 1;
|
package/package.json
CHANGED
package/src/agents/planner.md
CHANGED
|
@@ -135,13 +135,13 @@ substitutes for `hedgehog-planning-intake`'s own elicitation once a
|
|
|
135
135
|
core is chosen — that's a full BMAD-driven pass; this is three
|
|
136
136
|
questions to pick which pass to run.
|
|
137
137
|
|
|
138
|
-
Data that gets stored is not, by itself, `full-stack-app` —
|
|
139
|
-
be the rule, and it swallowed every candidate for `pwa-app` (a tracker,
|
|
138
|
+
Data that gets stored is not, by itself, `full-stack-app` — a tracker,
|
|
140
139
|
journal, notebook, or planner whose data belongs on the user's own
|
|
141
|
-
device
|
|
142
|
-
enforce the rules around it. A
|
|
143
|
-
|
|
144
|
-
signal — is `pwa-app`, even
|
|
140
|
+
device is `pwa-app` even though it stores data. The real question is
|
|
141
|
+
where the data lives and who needs to enforce the rules around it. A
|
|
142
|
+
description naming a local-first app — offline capability or
|
|
143
|
+
installability named explicitly is a strong signal — is `pwa-app`, even
|
|
144
|
+
with sharing, accounts, or multi-device sync
|
|
145
145
|
in scope (Dexie Cloud covers that), and even with a small number of
|
|
146
146
|
entities that must be server-authoritative (those go `--remote`, backed
|
|
147
147
|
by Supabase, without moving the whole project off `pwa-app`). What
|
package/src/db/rebuild.mjs
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
// source-of-truth files, for a fresh clone (no `.hedgehog/hedgehog.db`)
|
|
3
3
|
// or after suspected corruption. The DB itself is a derived artifact:
|
|
4
4
|
// everything it holds is either replayable from `.hedgehog/intents/*.json`
|
|
5
|
-
// (via the same normalize/insert path `intent add`/`plan` already use)
|
|
6
|
-
// recoverable from git history (which tasks' commits already landed)
|
|
5
|
+
// (via the same normalize/insert path `intent add`/`plan` already use),
|
|
6
|
+
// recoverable from git history (which tasks' commits already landed), or
|
|
7
|
+
// replayable from `.hedgehog/reconciled/*.json` (which tasks a user
|
|
8
|
+
// confirmed as done by work git history cannot credit — reconcile.mjs).
|
|
7
9
|
// What isn't recoverable — `verifications.output`, the ephemeral
|
|
8
10
|
// diagnostics of a run that already passed — is an accepted loss; this
|
|
9
11
|
// only reconciles `tasks.status`.
|
|
@@ -23,6 +25,13 @@ import { planTasks, CORE_MODULE } from './plan.mjs';
|
|
|
23
25
|
import { loadCore } from './core.mjs';
|
|
24
26
|
import { detectDrift } from './drift.mjs';
|
|
25
27
|
import { loadOverrides, OVERRIDES_DIR } from './overrides.mjs';
|
|
28
|
+
import {
|
|
29
|
+
loadReconciliations,
|
|
30
|
+
orphanedReconciliations,
|
|
31
|
+
reconciledNote,
|
|
32
|
+
RECONCILED_DIR,
|
|
33
|
+
RECONCILED_NOTE_PREFIX,
|
|
34
|
+
} from './reconcile.mjs';
|
|
26
35
|
|
|
27
36
|
// Alphabetical by filename, purely to make the *tie-break* deterministic
|
|
28
37
|
// across machines and runs. It is NOT the replay order — see
|
|
@@ -59,9 +68,19 @@ function intentExists(db, id) {
|
|
|
59
68
|
// so a note re-attaches to the same task the replay recompiles). A note
|
|
60
69
|
// whose task no longer exists in the new graph has nowhere to live and is
|
|
61
70
|
// reported rather than silently dropped.
|
|
71
|
+
//
|
|
72
|
+
// One class of decision row is excluded from that carry-across: the
|
|
73
|
+
// provenance note a reconciliation writes (reconcile.mjs). That one DOES
|
|
74
|
+
// have a committed source — `.hedgehog/reconciled/*.json` — and
|
|
75
|
+
// replayReconciliations below re-writes it from that file. Carrying it
|
|
76
|
+
// across as well would give a reconciled task two identical notes after
|
|
77
|
+
// the first rebuild, and one more on every rebuild after that.
|
|
62
78
|
function clearDerivedGraph(db) {
|
|
63
79
|
const debt = db.prepare('SELECT task_id, note, logged_at FROM debt').all();
|
|
64
|
-
const decisions = db
|
|
80
|
+
const decisions = db
|
|
81
|
+
.prepare('SELECT task_id, note, logged_at FROM decisions')
|
|
82
|
+
.all()
|
|
83
|
+
.filter((row) => !row.note.startsWith(RECONCILED_NOTE_PREFIX));
|
|
65
84
|
const friction = db.prepare('SELECT task_id, note, logged_at FROM friction').all();
|
|
66
85
|
|
|
67
86
|
db.prepare('DELETE FROM intents').run();
|
|
@@ -312,10 +331,48 @@ function markCompletedTasks(db, commitSubjects) {
|
|
|
312
331
|
return complete.size;
|
|
313
332
|
}
|
|
314
333
|
|
|
334
|
+
// Replays `.hedgehog/reconciled/*.json` — the committed record of every
|
|
335
|
+
// task a user confirmed as already done by work that landed outside the
|
|
336
|
+
// loop (reconcile.mjs).
|
|
337
|
+
//
|
|
338
|
+
// This runs after markCompletedTasks and does the same job by a different
|
|
339
|
+
// route. markCompletedTasks credits a task only when some commit subject
|
|
340
|
+
// matches its `commit_message` exactly, which is the subject `verify`
|
|
341
|
+
// itself writes — a hand-written commit never matches, by construction,
|
|
342
|
+
// which is the whole reason reconcile exists. So a reconciled task needs
|
|
343
|
+
// no commit-message match here: the committed confirmation IS the source,
|
|
344
|
+
// exactly as an override file is the source for a widened scope.
|
|
345
|
+
//
|
|
346
|
+
// Without this step a rebuild silently reverts every confirmed
|
|
347
|
+
// reconciliation and reintroduces the problem reconcile was run to fix,
|
|
348
|
+
// which is worse than never reconciling — it looks like it worked.
|
|
349
|
+
//
|
|
350
|
+
// The provenance note is re-written here too, so a reconciled task's
|
|
351
|
+
// "closed without a verify run" fact reaches its dependents' packets on a
|
|
352
|
+
// fresh clone the same as it did on the machine that confirmed it.
|
|
353
|
+
function replayReconciliations(db, reconciliations) {
|
|
354
|
+
const setComplete = db.prepare(
|
|
355
|
+
"UPDATE tasks SET status = 'complete', blocked_reason = NULL WHERE id = ?",
|
|
356
|
+
);
|
|
357
|
+
const insertNote = db.prepare('INSERT INTO decisions (task_id, note) VALUES (?, ?)');
|
|
358
|
+
const taskExists = db.prepare('SELECT 1 FROM tasks WHERE id = ?');
|
|
359
|
+
|
|
360
|
+
let replayed = 0;
|
|
361
|
+
for (const [taskId, record] of reconciliations) {
|
|
362
|
+
if (taskExists.get(taskId) === undefined) continue;
|
|
363
|
+
setComplete.run(taskId);
|
|
364
|
+
insertNote.run(taskId, reconciledNote(record));
|
|
365
|
+
replayed++;
|
|
366
|
+
}
|
|
367
|
+
return replayed;
|
|
368
|
+
}
|
|
369
|
+
|
|
315
370
|
// Rebuilds `db` from scratch: schema, then every committed intent
|
|
316
371
|
// replayed in dependency order, then planTasks to re-derive tasks +
|
|
317
372
|
// dependencies, then git history to reconcile which tasks already
|
|
318
|
-
// completed
|
|
373
|
+
// completed, then `.hedgehog/reconciled/*.json` for the tasks a user
|
|
374
|
+
// confirmed as done by work that git history cannot credit. Returns a
|
|
375
|
+
// summary for the CLI to print.
|
|
319
376
|
//
|
|
320
377
|
// `drift` in the return is the honest disclosure this rebuild owes its
|
|
321
378
|
// caller. A rebuild re-derives every task's layer-derived fields from
|
|
@@ -330,7 +387,12 @@ function markCompletedTasks(db, commitSubjects) {
|
|
|
330
387
|
// of something they discover three layers later.
|
|
331
388
|
export async function rebuildDb(
|
|
332
389
|
db,
|
|
333
|
-
{
|
|
390
|
+
{
|
|
391
|
+
corePath,
|
|
392
|
+
intentsDir = INTENTS_DIR,
|
|
393
|
+
overridesDir = OVERRIDES_DIR,
|
|
394
|
+
reconciledDir = RECONCILED_DIR,
|
|
395
|
+
} = {},
|
|
334
396
|
) {
|
|
335
397
|
applySchema(db);
|
|
336
398
|
|
|
@@ -340,15 +402,26 @@ export async function rebuildDb(
|
|
|
340
402
|
|
|
341
403
|
const core = await loadCore(corePath);
|
|
342
404
|
const overrides = await loadOverrides(overridesDir);
|
|
405
|
+
const reconciliations = await loadReconciliations(reconciledDir);
|
|
343
406
|
|
|
344
407
|
planTasks(db, core, overrides);
|
|
345
408
|
|
|
346
409
|
const commitSubjects = loadCommitSubjects();
|
|
347
410
|
const tasksMarkedComplete = markCompletedTasks(db, commitSubjects);
|
|
348
411
|
|
|
412
|
+
const tasksReconciled = replayReconciliations(db, reconciliations);
|
|
413
|
+
const orphanedReconciled = orphanedReconciliations(db, reconciliations);
|
|
414
|
+
|
|
349
415
|
const orphanedNotes = restoreNotes(db, notes);
|
|
350
416
|
|
|
351
417
|
const drift = detectDrift(db, core, { overrides });
|
|
352
418
|
|
|
353
|
-
return {
|
|
419
|
+
return {
|
|
420
|
+
intentsReplayed,
|
|
421
|
+
tasksMarkedComplete,
|
|
422
|
+
tasksReconciled,
|
|
423
|
+
orphanedReconciled,
|
|
424
|
+
orphanedNotes,
|
|
425
|
+
drift,
|
|
426
|
+
};
|
|
354
427
|
}
|
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
// `hedgehog reconcile` — absorbs work that landed outside the loop into
|
|
2
|
+
// the build graph, on the user's word rather than on the engine's.
|
|
3
|
+
//
|
|
4
|
+
// `hedgehog claim` fingerprints the working tree at claim time and
|
|
5
|
+
// `verify` excludes every path that did not move during the lease
|
|
6
|
+
// (claim.mjs, verify.mjs#attributedToTask), so a hand edit is correctly
|
|
7
|
+
// never *blamed* on a task. It is also never *credited* to one.
|
|
8
|
+
// `hedgehog db rebuild` does not close that gap either: it recovers
|
|
9
|
+
// `tasks.status` by matching each task's `commit_message` against commit
|
|
10
|
+
// subjects exactly (rebuild.mjs#markCompletedTasks), and that subject is
|
|
11
|
+
// the one `verify` itself writes from core.yaml. A hand-written commit
|
|
12
|
+
// never matches, by construction. So `hedgehog next` and `hedgehog
|
|
13
|
+
// status` point at work that is already done, and the only remaining
|
|
14
|
+
// moves are to redo the work through the loop or to hand-patch a task row
|
|
15
|
+
// — which every loop skill forbids, because the graph is derived and
|
|
16
|
+
// gitignored and the patch dies at the next rebuild.
|
|
17
|
+
//
|
|
18
|
+
// Four properties, each load-bearing:
|
|
19
|
+
//
|
|
20
|
+
// - **It proposes; it never asserts.** `gatherEvidence` reports which
|
|
21
|
+
// commits since the newest graph-written commit touched files inside
|
|
22
|
+
// an open task's compiled scope_globs. A diff cannot tell you a
|
|
23
|
+
// task's intent was met, so nothing here closes a task on its own.
|
|
24
|
+
// - **The user confirms one task at a time.** `confirmReconciliation`
|
|
25
|
+
// takes exactly one task id and one reason. There is deliberately no
|
|
26
|
+
// bulk confirm: a single "yes to all" is exactly the unexamined
|
|
27
|
+
// assertion the evidence path refuses to make.
|
|
28
|
+
// - **A confirmed task records why.** Closing a task from reconciliation
|
|
29
|
+
// is not the fact `verify` records: no scope gate ran and no verify
|
|
30
|
+
// command ran. That distinction is inherited context for everything
|
|
31
|
+
// downstream, so `applyReconciliation` writes it as a `decisions` row
|
|
32
|
+
// (decision.mjs) which next.mjs renders into every dependent task's
|
|
33
|
+
// packet.
|
|
34
|
+
// - **It survives a rebuild.** The confirmation is a committed file
|
|
35
|
+
// under `.hedgehog/reconciled/`, in the same shape overrides.mjs uses
|
|
36
|
+
// for the same reason: a decision with no other committed source has
|
|
37
|
+
// to be replayable, or the next `db rebuild` silently reverts it and
|
|
38
|
+
// reintroduces the problem. `rebuild.mjs` replays these alongside
|
|
39
|
+
// `.hedgehog/overrides/*.json`.
|
|
40
|
+
//
|
|
41
|
+
// It never runs on its own. No `status`, `next`, or `claim` path calls
|
|
42
|
+
// into this file — reconciliation is a deliberate act, because it is the
|
|
43
|
+
// one way a task reaches `complete` without the engine having checked
|
|
44
|
+
// anything.
|
|
45
|
+
|
|
46
|
+
import { readdir, readFile, mkdir, writeFile, rename, rm } from 'node:fs/promises';
|
|
47
|
+
import { execFileSync } from 'node:child_process';
|
|
48
|
+
import { applySchema } from './schema.mjs';
|
|
49
|
+
|
|
50
|
+
export const RECONCILED_DIR = '.hedgehog/reconciled';
|
|
51
|
+
|
|
52
|
+
// The note attached to a reconciled task, and the prefix every such note
|
|
53
|
+
// carries. next.mjs renders `decisions` rows into each dependent task's
|
|
54
|
+
// INHERITED DECISIONS section, so a dependent's packet says outright that
|
|
55
|
+
// its prerequisite closed unverified. The prefix is also how the replay
|
|
56
|
+
// and the status surface recognize their own rows without a second table.
|
|
57
|
+
export const RECONCILED_NOTE_PREFIX = 'Closed by reconciliation, not verification';
|
|
58
|
+
|
|
59
|
+
export function reconciledNote(record) {
|
|
60
|
+
return (
|
|
61
|
+
`${RECONCILED_NOTE_PREFIX}: ${record.reason} ` +
|
|
62
|
+
`(no scope gate and no verify command ran; evidence: ${record.evidence.commits.length} commit(s), ` +
|
|
63
|
+
`${record.evidence.paths.length} path(s) in scope)`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function reconciledFilePath(taskId, reconciledDir = RECONCILED_DIR) {
|
|
68
|
+
return `${reconciledDir}/${taskId.toLowerCase()}.json`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Runs git with an argv array and no shell, so a path or a glob reaches
|
|
72
|
+
// git as one literal argument — the same rule verify.mjs#git follows for
|
|
73
|
+
// the same reason.
|
|
74
|
+
function git(args, options = {}) {
|
|
75
|
+
return execFileSync('git', args, { encoding: 'utf8', ...options });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Validates one parsed reconciliation record. Throws with the offending
|
|
79
|
+
// file's path, since this runs at load time over the whole directory and
|
|
80
|
+
// a bad record has to name itself to be findable — overrides.mjs's
|
|
81
|
+
// validateOverride, same contract.
|
|
82
|
+
function validateReconciled(record, path) {
|
|
83
|
+
if (record === null || typeof record !== 'object') {
|
|
84
|
+
throw new Error(`${path}: reconciliation must be a JSON object`);
|
|
85
|
+
}
|
|
86
|
+
let { task } = record;
|
|
87
|
+
const { reason, confirmed_at: confirmedAt, evidence } = record;
|
|
88
|
+
|
|
89
|
+
if (!task || typeof task !== 'string') {
|
|
90
|
+
throw new Error(`${path}: reconciliation requires a "task" id (string)`);
|
|
91
|
+
}
|
|
92
|
+
// plan.mjs#taskId upper-cases every id it compiles a task under, and
|
|
93
|
+
// the replay looks tasks up by that exact string. Normalizing here,
|
|
94
|
+
// once, keeps the id space exact-match everywhere else — the same
|
|
95
|
+
// reason overrides.mjs normalizes.
|
|
96
|
+
task = task.toUpperCase();
|
|
97
|
+
|
|
98
|
+
if (!reason || typeof reason !== 'string') {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`${path}: reconciliation "${task}" requires a "reason" (string) — this is the permanent record of why a task closed without a verify run`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (!confirmedAt || typeof confirmedAt !== 'string') {
|
|
104
|
+
throw new Error(`${path}: reconciliation "${task}" requires a "confirmed_at" timestamp (string)`);
|
|
105
|
+
}
|
|
106
|
+
if (evidence === null || typeof evidence !== 'object' || Array.isArray(evidence)) {
|
|
107
|
+
throw new Error(`${path}: reconciliation "${task}" requires an "evidence" object`);
|
|
108
|
+
}
|
|
109
|
+
for (const field of ['commits', 'paths']) {
|
|
110
|
+
if (!Array.isArray(evidence[field])) {
|
|
111
|
+
throw new Error(`${path}: reconciliation "${task}" requires "evidence.${field}" (array)`);
|
|
112
|
+
}
|
|
113
|
+
for (const entry of evidence[field]) {
|
|
114
|
+
if (typeof entry !== 'string' || entry.trim() === '') {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`${path}: reconciliation "${task}" has a non-string or empty entry in evidence.${field}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
task,
|
|
124
|
+
reason,
|
|
125
|
+
confirmed_at: confirmedAt,
|
|
126
|
+
evidence: { commits: [...evidence.commits], paths: [...evidence.paths] },
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Every *.json in `reconciledDir`, validated, as a Map from task id to
|
|
131
|
+
// its record. One file per task: a second confirmation of the same task
|
|
132
|
+
// is a mistake rather than a second distinct fact, unlike an override,
|
|
133
|
+
// where two separately-reasoned widenings of one task are both real.
|
|
134
|
+
// Absent directory reads as "nothing reconciled", the same way
|
|
135
|
+
// overrides.mjs#loadOverrides treats a missing overrides directory.
|
|
136
|
+
export async function loadReconciliations(reconciledDir = RECONCILED_DIR) {
|
|
137
|
+
let entries;
|
|
138
|
+
try {
|
|
139
|
+
entries = await readdir(reconciledDir);
|
|
140
|
+
} catch {
|
|
141
|
+
return new Map();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const byTask = new Map();
|
|
145
|
+
for (const name of entries.filter((n) => n.endsWith('.json')).sort()) {
|
|
146
|
+
const path = `${reconciledDir}/${name}`;
|
|
147
|
+
let parsed;
|
|
148
|
+
try {
|
|
149
|
+
parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
150
|
+
} catch (err) {
|
|
151
|
+
throw new Error(`could not read reconciliation file ${path}: ${err.message}`);
|
|
152
|
+
}
|
|
153
|
+
const record = validateReconciled(parsed, path);
|
|
154
|
+
byTask.set(record.task, record);
|
|
155
|
+
}
|
|
156
|
+
return byTask;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Reconciled task ids matching no row in `tasks` — a typo'd id, a task
|
|
160
|
+
// from a renamed module or layer, or one whose intent file is gone. The
|
|
161
|
+
// read side that keeps a dead record discoverable, exactly as
|
|
162
|
+
// overrides.mjs#orphanedOverrides does: the replay skipping an unknown id
|
|
163
|
+
// is a no-op, not a throw, so without this the file would sit there
|
|
164
|
+
// closing nothing forever.
|
|
165
|
+
export function orphanedReconciliations(db, reconciliations) {
|
|
166
|
+
const known = new Set(db.prepare('SELECT id FROM tasks').all().map((r) => r.id));
|
|
167
|
+
return [...reconciliations.keys()].filter((taskId) => !known.has(taskId)).sort();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── evidence ──────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
// The newest commit the graph itself wrote — the newest commit whose
|
|
173
|
+
// subject matches some task's `commit_message`, which is the exact
|
|
174
|
+
// predicate rebuild.mjs#markCompletedTasks uses to decide a task ran.
|
|
175
|
+
// Everything above it in history is the window this command reads: it is
|
|
176
|
+
// where hand-written work necessarily sits, because a graph-written
|
|
177
|
+
// commit below it has already been credited by rebuild.
|
|
178
|
+
//
|
|
179
|
+
// Returns null when no commit matches any task's message (nothing has
|
|
180
|
+
// been verified yet) — the caller then reads the whole history, which is
|
|
181
|
+
// the honest window for a project whose loop has not closed a task.
|
|
182
|
+
function newestGraphCommit(db) {
|
|
183
|
+
const messages = new Set(
|
|
184
|
+
db.prepare('SELECT commit_message FROM tasks').all().map((r) => r.commit_message),
|
|
185
|
+
);
|
|
186
|
+
if (messages.size === 0) return null;
|
|
187
|
+
|
|
188
|
+
const output = git(['log', '--topo-order', '--format=%H%x00%s']);
|
|
189
|
+
for (const line of output.split('\n')) {
|
|
190
|
+
if (!line) continue;
|
|
191
|
+
const [sha, subject] = line.split('\0');
|
|
192
|
+
if (subject !== undefined && messages.has(subject)) return sha;
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Every commit after `sinceSha` (exclusive), newest first, with the paths
|
|
198
|
+
// it touched. `sinceSha` null means the whole history.
|
|
199
|
+
function commitsSince(sinceSha) {
|
|
200
|
+
const range = sinceSha ? [`${sinceSha}..HEAD`] : ['HEAD'];
|
|
201
|
+
let output;
|
|
202
|
+
try {
|
|
203
|
+
output = git(['log', '--topo-order', '--name-only', '--format=%x01%H%x00%s', ...range]);
|
|
204
|
+
} catch {
|
|
205
|
+
// An empty repository has no HEAD to log.
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const commits = [];
|
|
210
|
+
for (const block of output.split('\x01')) {
|
|
211
|
+
if (!block.trim()) continue;
|
|
212
|
+
const [header, ...rest] = block.split('\n');
|
|
213
|
+
const [sha, subject] = header.split('\0');
|
|
214
|
+
if (!sha) continue;
|
|
215
|
+
const paths = rest.map((p) => p.trim()).filter(Boolean);
|
|
216
|
+
commits.push({ sha, subject: subject ?? '', paths });
|
|
217
|
+
}
|
|
218
|
+
return commits;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// True when `path` matches `glob`.
|
|
222
|
+
//
|
|
223
|
+
// The scope globs compiled onto a task are git pathspec globs
|
|
224
|
+
// (`apps/api/src/orders/**`), and verify.mjs's gate hands them straight
|
|
225
|
+
// to git as `:(glob)…` pathspecs. Here the paths already came out of `git
|
|
226
|
+
// log --name-only`, so there is no second git call to make: the match is
|
|
227
|
+
// done in-process against the same syntax git implements — `**` spans
|
|
228
|
+
// separators, a single `*` and `?` do not, and a trailing `/**` also
|
|
229
|
+
// matches the directory's own path, which is what makes a glob and the
|
|
230
|
+
// directory it names agree.
|
|
231
|
+
//
|
|
232
|
+
// Segment-by-segment rather than character-by-character, so the two `**`
|
|
233
|
+
// forms (a whole segment, versus a `*` pair inside one) can't be confused
|
|
234
|
+
// for each other.
|
|
235
|
+
function globToRegExp(glob) {
|
|
236
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
237
|
+
|
|
238
|
+
// A `*` or `?` inside one path segment never crosses a separator.
|
|
239
|
+
const segmentPattern = (segment) =>
|
|
240
|
+
segment
|
|
241
|
+
.split(/(\*|\?)/)
|
|
242
|
+
.map((part) => (part === '*' ? '[^/]*' : part === '?' ? '[^/]' : escape(part)))
|
|
243
|
+
.join('');
|
|
244
|
+
|
|
245
|
+
const segments = glob.split('/');
|
|
246
|
+
let out = '';
|
|
247
|
+
for (const [i, segment] of segments.entries()) {
|
|
248
|
+
if (segment === '**') {
|
|
249
|
+
// `**` and the separator on one side of it are optional together,
|
|
250
|
+
// so `a/**` also matches `a` and `**/x.ts` also matches `x.ts`.
|
|
251
|
+
if (i === segments.length - 1) {
|
|
252
|
+
// Trailing: the separator *before* it goes optional with it —
|
|
253
|
+
// unless there is nothing before it, and the glob is the bare
|
|
254
|
+
// `**` that matches everything.
|
|
255
|
+
out += i === 0 ? '.*' : '(?:/.*)?';
|
|
256
|
+
} else {
|
|
257
|
+
// Interior or leading: the separator *after* it goes optional
|
|
258
|
+
// with it. A preceding literal segment still needs its own
|
|
259
|
+
// separator written first.
|
|
260
|
+
if (i > 0) out += '/';
|
|
261
|
+
out += '(?:.*/)?';
|
|
262
|
+
}
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
// Only a preceding literal segment contributes the separator — a
|
|
266
|
+
// preceding `**` already carried its own.
|
|
267
|
+
if (i > 0 && segments[i - 1] !== '**') out += '/';
|
|
268
|
+
out += segmentPattern(segment);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return new RegExp(`^${out}$`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function pathInScope(path, scopeGlobs) {
|
|
275
|
+
return scopeGlobs.some((glob) => globToRegExp(glob).test(path));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Tasks a reconciliation could apply to: `planned` or `ready`, the two
|
|
279
|
+
// statuses `hedgehog next` would still hand out. A `building`/`verifying`
|
|
280
|
+
// task is leased and belongs to whoever holds it; a `blocked` task failed
|
|
281
|
+
// a gate the loop already ran and has `retry` as its way back; a
|
|
282
|
+
// `complete` task is done.
|
|
283
|
+
const OPEN_TASKS_SQL = `
|
|
284
|
+
SELECT id, layer, module, objective, scope_globs, status
|
|
285
|
+
FROM tasks
|
|
286
|
+
WHERE status IN ('planned', 'ready')
|
|
287
|
+
ORDER BY priority, id;
|
|
288
|
+
`;
|
|
289
|
+
|
|
290
|
+
// The read path. For every open task, which of the commits since the
|
|
291
|
+
// newest graph-written commit touched files inside that task's compiled
|
|
292
|
+
// scope_globs.
|
|
293
|
+
//
|
|
294
|
+
// Returns { since, commits, candidates, alreadyReconciled }:
|
|
295
|
+
// - `since` the sha the window starts above, or null for whole history
|
|
296
|
+
// - `commits` every commit in the window (so a caller can report a
|
|
297
|
+
// window that contained nothing)
|
|
298
|
+
// - `candidates` one entry per open task with at least one matching
|
|
299
|
+
// path: { task, commits: [{sha, subject, paths}], paths }
|
|
300
|
+
// - `alreadyReconciled` open task ids that already have a committed
|
|
301
|
+
// confirmation on disk (their file exists but the graph has not been
|
|
302
|
+
// rebuilt since)
|
|
303
|
+
//
|
|
304
|
+
// This is evidence, not proof. A commit touching a task's scope says
|
|
305
|
+
// files moved where that task would have moved them; it says nothing
|
|
306
|
+
// about whether the task's objective was met. Every caller must put the
|
|
307
|
+
// judgment to the user.
|
|
308
|
+
export function gatherEvidence(db, { reconciliations = new Map() } = {}) {
|
|
309
|
+
const since = newestGraphCommit(db);
|
|
310
|
+
const commits = commitsSince(since);
|
|
311
|
+
const openTasks = db.prepare(OPEN_TASKS_SQL).all();
|
|
312
|
+
|
|
313
|
+
const candidates = [];
|
|
314
|
+
const alreadyReconciled = [];
|
|
315
|
+
for (const task of openTasks) {
|
|
316
|
+
if (reconciliations.has(task.id)) alreadyReconciled.push(task.id);
|
|
317
|
+
|
|
318
|
+
const scopeGlobs = JSON.parse(task.scope_globs);
|
|
319
|
+
const matched = [];
|
|
320
|
+
const paths = new Set();
|
|
321
|
+
for (const commit of commits) {
|
|
322
|
+
const hits = commit.paths.filter((p) => pathInScope(p, scopeGlobs));
|
|
323
|
+
if (hits.length === 0) continue;
|
|
324
|
+
matched.push({ sha: commit.sha, subject: commit.subject, paths: hits });
|
|
325
|
+
for (const p of hits) paths.add(p);
|
|
326
|
+
}
|
|
327
|
+
if (matched.length > 0) {
|
|
328
|
+
candidates.push({ task, commits: matched, paths: [...paths].sort() });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return { since, commits, candidates, alreadyReconciled };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── confirmation ──────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
// Writes one reconciliation record to
|
|
338
|
+
// RECONCILED_DIR/<task-id-lowercased>.json via temp file + rename, so a
|
|
339
|
+
// crash mid-write can never leave a half-written file for
|
|
340
|
+
// loadReconciliations to trip on — overrides.mjs#writeOverrideFile and
|
|
341
|
+
// intent.mjs#writeIntentFile use the same pattern for the same reason.
|
|
342
|
+
//
|
|
343
|
+
// Refuses to overwrite silently. A task is reconciled once; a second
|
|
344
|
+
// confirmation for the same id is a wrong id or a forgotten first run,
|
|
345
|
+
// and either is worth stopping for.
|
|
346
|
+
export async function writeReconciledFile(record, reconciledDir = RECONCILED_DIR) {
|
|
347
|
+
const path = reconciledFilePath(record.task, reconciledDir);
|
|
348
|
+
try {
|
|
349
|
+
await readFile(path, 'utf8');
|
|
350
|
+
throw new Error(
|
|
351
|
+
`${path} already exists — ${record.task} is already recorded as reconciled. Edit that file directly rather than re-confirming.`,
|
|
352
|
+
);
|
|
353
|
+
} catch (err) {
|
|
354
|
+
if (!err || err.code !== 'ENOENT') throw err;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
await mkdir(reconciledDir, { recursive: true });
|
|
358
|
+
const tempPath = `${path}.tmp-${process.pid}`;
|
|
359
|
+
try {
|
|
360
|
+
await writeFile(tempPath, `${JSON.stringify(record, null, 2)}\n`);
|
|
361
|
+
await rename(tempPath, path);
|
|
362
|
+
} catch (err) {
|
|
363
|
+
await rm(tempPath, { force: true }).catch(() => {});
|
|
364
|
+
throw err;
|
|
365
|
+
}
|
|
366
|
+
return record;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Applies one confirmed reconciliation to the graph: the task goes
|
|
370
|
+
// `complete`, its provenance note is written as a `decisions` row, and
|
|
371
|
+
// its dependents are re-evaluated for readiness the same way verify.mjs
|
|
372
|
+
// does on a pass.
|
|
373
|
+
//
|
|
374
|
+
// Marking the status directly is correct here for the same reason
|
|
375
|
+
// rebuild.mjs#markCompletedTasks does it: there is no lease to check, no
|
|
376
|
+
// working-tree diff to gate, and no verify_command to run. The difference
|
|
377
|
+
// from verify is exactly what the note records.
|
|
378
|
+
export function applyReconciliation(db, record) {
|
|
379
|
+
applySchema(db);
|
|
380
|
+
|
|
381
|
+
const task = db.prepare('SELECT id, status FROM tasks WHERE id = ?').get(record.task);
|
|
382
|
+
if (!task) throw new Error(`no such task: ${record.task}`);
|
|
383
|
+
if (task.status === 'complete') return { taskId: record.task, unlocked: [], alreadyComplete: true };
|
|
384
|
+
if (task.status === 'building' || task.status === 'verifying') {
|
|
385
|
+
throw new Error(
|
|
386
|
+
`Task ${record.task} is leased (${task.status}) — release it with \`hedgehog release ${record.task} --owner <owner>\` before reconciling it.`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
let unlocked;
|
|
391
|
+
db.exec('BEGIN IMMEDIATE');
|
|
392
|
+
try {
|
|
393
|
+
db.prepare(
|
|
394
|
+
"UPDATE tasks SET status = 'complete', blocked_reason = NULL WHERE id = ?",
|
|
395
|
+
).run(record.task);
|
|
396
|
+
db.prepare('INSERT INTO decisions (task_id, note) VALUES (?, ?)').run(
|
|
397
|
+
record.task,
|
|
398
|
+
reconciledNote(record),
|
|
399
|
+
);
|
|
400
|
+
unlocked = unlockDependents(db, record.task);
|
|
401
|
+
completeIntentIfDone(db, record.task);
|
|
402
|
+
db.exec('COMMIT');
|
|
403
|
+
} catch (err) {
|
|
404
|
+
try {
|
|
405
|
+
db.exec('ROLLBACK');
|
|
406
|
+
} catch {
|
|
407
|
+
// Rollback failing must not mask the original error.
|
|
408
|
+
}
|
|
409
|
+
throw err;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return { taskId: record.task, unlocked, alreadyComplete: false };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Marks `taskId`'s direct dependents `ready` wherever every one of their
|
|
416
|
+
// dependencies is now complete — the same rule and the same restriction
|
|
417
|
+
// verify.mjs#unlockReadyDependents applies: a dependent already `blocked`
|
|
418
|
+
// is stalled on its own failure, not on this dependency, and must not be
|
|
419
|
+
// cleared back to ready here.
|
|
420
|
+
function unlockDependents(db, taskId) {
|
|
421
|
+
const dependents = db
|
|
422
|
+
.prepare(
|
|
423
|
+
`SELECT t.id, t.status FROM tasks t
|
|
424
|
+
JOIN dependencies d ON d.task_id = t.id
|
|
425
|
+
WHERE d.depends_on_task_id = ?
|
|
426
|
+
ORDER BY t.priority, t.id`,
|
|
427
|
+
)
|
|
428
|
+
.all(taskId);
|
|
429
|
+
|
|
430
|
+
const unlocked = [];
|
|
431
|
+
for (const dependent of dependents) {
|
|
432
|
+
if (dependent.status !== 'planned') continue;
|
|
433
|
+
const blocker = db
|
|
434
|
+
.prepare(
|
|
435
|
+
`SELECT 1 FROM dependencies d
|
|
436
|
+
JOIN tasks dep ON dep.id = d.depends_on_task_id
|
|
437
|
+
WHERE d.task_id = ? AND dep.status <> 'complete'`,
|
|
438
|
+
)
|
|
439
|
+
.get(dependent.id);
|
|
440
|
+
if (blocker !== undefined) continue;
|
|
441
|
+
db.prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(dependent.id);
|
|
442
|
+
unlocked.push(dependent.id);
|
|
443
|
+
}
|
|
444
|
+
return unlocked;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Closes the task's intent once every task compiled from it is complete
|
|
448
|
+
// — the same terminal bookkeeping verify.mjs#completeIntentIfDone does,
|
|
449
|
+
// so an intent whose last open task closes by reconciliation does not sit
|
|
450
|
+
// `active` forever.
|
|
451
|
+
function completeIntentIfDone(db, taskId) {
|
|
452
|
+
const row = db.prepare('SELECT intent_id FROM tasks WHERE id = ?').get(taskId);
|
|
453
|
+
if (!row) return;
|
|
454
|
+
const openTask = db
|
|
455
|
+
.prepare("SELECT 1 FROM tasks WHERE intent_id = ? AND status <> 'complete'")
|
|
456
|
+
.get(row.intent_id);
|
|
457
|
+
if (openTask !== undefined) return;
|
|
458
|
+
db.prepare("UPDATE intents SET status = 'complete' WHERE id = ?").run(row.intent_id);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// The `hedgehog reconcile confirm <task-id> --reason "<why>"` entry
|
|
462
|
+
// point: builds the record from the task's own evidence, writes the
|
|
463
|
+
// committed file first, then applies it to the graph.
|
|
464
|
+
//
|
|
465
|
+
// File before graph, deliberately. The graph is derived and gitignored;
|
|
466
|
+
// the file is the permanent record. If the write fails, nothing has been
|
|
467
|
+
// closed on a fact that would not survive the next rebuild.
|
|
468
|
+
export async function confirmReconciliation(
|
|
469
|
+
db,
|
|
470
|
+
{ taskId, reason, evidence },
|
|
471
|
+
reconciledDir = RECONCILED_DIR,
|
|
472
|
+
) {
|
|
473
|
+
if (!taskId) throw new Error('reconcile requires a task id');
|
|
474
|
+
if (!reason) throw new Error('reconcile requires a --reason');
|
|
475
|
+
|
|
476
|
+
const id = taskId.toUpperCase();
|
|
477
|
+
const task = db.prepare('SELECT id, status FROM tasks WHERE id = ?').get(id);
|
|
478
|
+
if (!task) throw new Error(`no such task: ${id}`);
|
|
479
|
+
if (task.status === 'complete') {
|
|
480
|
+
throw new Error(`Task ${id} is already complete — there is nothing to reconcile.`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const record = validateReconciled(
|
|
484
|
+
{
|
|
485
|
+
task: id,
|
|
486
|
+
reason,
|
|
487
|
+
confirmed_at: new Date().toISOString(),
|
|
488
|
+
evidence: {
|
|
489
|
+
commits: evidence?.commits ?? [],
|
|
490
|
+
paths: evidence?.paths ?? [],
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
'(new reconciliation)',
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
await writeReconciledFile(record, reconciledDir);
|
|
497
|
+
const applied = applyReconciliation(db, record);
|
|
498
|
+
return { record, ...applied };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Evidence for exactly one task, in the shape confirmReconciliation
|
|
502
|
+
// wants. Returns null when that task is not an open candidate — a caller
|
|
503
|
+
// confirming a task the evidence path never proposed still gets to
|
|
504
|
+
// record the confirmation, with an empty evidence set that says so.
|
|
505
|
+
export function evidenceForTask(db, taskId) {
|
|
506
|
+
const { candidates } = gatherEvidence(db);
|
|
507
|
+
const entry = candidates.find((c) => c.task.id === taskId.toUpperCase());
|
|
508
|
+
if (!entry) return null;
|
|
509
|
+
return {
|
|
510
|
+
commits: entry.commits.map((c) => c.sha),
|
|
511
|
+
paths: entry.paths,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ── rendering ─────────────────────────────────────────────────────────
|
|
516
|
+
|
|
517
|
+
// Renders a gatherEvidence() result as a proposal. Every line is written
|
|
518
|
+
// to read as a question the user answers, not as a finding the command
|
|
519
|
+
// acted on — the confirm command is printed per task, one at a time, and
|
|
520
|
+
// no "confirm all" form exists to print.
|
|
521
|
+
export function formatEvidence({ since, commits, candidates, alreadyReconciled }) {
|
|
522
|
+
const lines = [];
|
|
523
|
+
|
|
524
|
+
lines.push(
|
|
525
|
+
since
|
|
526
|
+
? `Reading ${commits.length} commit(s) since ${since.slice(0, 8)} — the newest commit the build graph itself wrote.`
|
|
527
|
+
: `Reading ${commits.length} commit(s) — no commit in this history was written by the build graph.`,
|
|
528
|
+
);
|
|
529
|
+
lines.push('');
|
|
530
|
+
|
|
531
|
+
if (candidates.length === 0) {
|
|
532
|
+
lines.push('No open task has files in its scope touched by those commits.');
|
|
533
|
+
lines.push('');
|
|
534
|
+
lines.push('Nothing to propose. No task was changed.');
|
|
535
|
+
return lines.join('\n');
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
lines.push('PROPOSED — evidence only. None of these tasks has been changed.');
|
|
539
|
+
lines.push('');
|
|
540
|
+
for (const { task, commits: matched, paths } of candidates) {
|
|
541
|
+
lines.push(` ${task.id} ${task.layer} ${task.objective}`);
|
|
542
|
+
for (const commit of matched) {
|
|
543
|
+
lines.push(` ${commit.sha.slice(0, 8)} ${commit.subject}`);
|
|
544
|
+
}
|
|
545
|
+
for (const path of paths) {
|
|
546
|
+
lines.push(` in scope: ${path}`);
|
|
547
|
+
}
|
|
548
|
+
lines.push('');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
lines.push('A commit touching a task\'s scope is not proof the task\'s objective was met.');
|
|
552
|
+
lines.push('Read the work, then confirm each task you judge done, one at a time:');
|
|
553
|
+
lines.push('');
|
|
554
|
+
lines.push(' hedgehog reconcile confirm <task-id> --reason "<why this work satisfies it>"');
|
|
555
|
+
lines.push('');
|
|
556
|
+
lines.push('Confirming closes the task without a scope gate or a verify run, and records');
|
|
557
|
+
lines.push(`that in ${RECONCILED_DIR}/<task-id>.json — commit that file, or the next`);
|
|
558
|
+
lines.push('`hedgehog db rebuild` reverts the reconciliation.');
|
|
559
|
+
|
|
560
|
+
if (alreadyReconciled.length > 0) {
|
|
561
|
+
lines.push('');
|
|
562
|
+
lines.push('ALREADY CONFIRMED (still open in this graph — run `hedgehog db rebuild`)');
|
|
563
|
+
for (const taskId of alreadyReconciled) lines.push(` ${taskId}`);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return lines.join('\n');
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Renders loadReconciliations() as a listing — `hedgehog reconcile list`.
|
|
570
|
+
export function formatReconciliations(reconciliations, orphaned = []) {
|
|
571
|
+
if (reconciliations.size === 0) return 'No reconciliations recorded.';
|
|
572
|
+
|
|
573
|
+
const lines = [];
|
|
574
|
+
for (const [taskId, record] of reconciliations) {
|
|
575
|
+
lines.push(taskId);
|
|
576
|
+
lines.push(` ${record.reason}`);
|
|
577
|
+
lines.push(` confirmed ${record.confirmed_at}`);
|
|
578
|
+
for (const sha of record.evidence.commits) lines.push(` commit ${sha.slice(0, 8)}`);
|
|
579
|
+
for (const path of record.evidence.paths) lines.push(` path ${path}`);
|
|
580
|
+
lines.push('');
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (orphaned.length > 0) {
|
|
584
|
+
lines.push(
|
|
585
|
+
`Orphaned: ${orphaned.join(', ')} — no task with this id exists in the build graph. ` +
|
|
586
|
+
`Each closes nothing until the id matches.`,
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return lines.join('\n').trimEnd();
|
|
591
|
+
}
|
package/src/db/status.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { listDebt } from './debt.mjs';
|
|
|
15
15
|
import { detectDrift, formatDrift } from './drift.mjs';
|
|
16
16
|
import { listFriction } from './friction.mjs';
|
|
17
17
|
import { orphanedOverrides } from './overrides.mjs';
|
|
18
|
+
import { RECONCILED_DIR, RECONCILED_NOTE_PREFIX } from './reconcile.mjs';
|
|
18
19
|
import { formatMissingRequirements } from './requires.mjs';
|
|
19
20
|
import { readyTasks, heldBackReason } from './ready.mjs';
|
|
20
21
|
|
|
@@ -123,6 +124,34 @@ function loadDebtByTask(db) {
|
|
|
123
124
|
.sort((a, b) => a.taskId.localeCompare(b.taskId));
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
// Every task that reached `complete` through `hedgehog reconcile` rather
|
|
128
|
+
// than through `hedgehog verify`, in task-id order.
|
|
129
|
+
//
|
|
130
|
+
// A reconciled task is `complete` like any other, so it is invisible in
|
|
131
|
+
// the counts above and in every list below them — and it is the one
|
|
132
|
+
// `complete` status the engine never checked: no scope gate ran and no
|
|
133
|
+
// verify command ran on it. Reading it back off the provenance note
|
|
134
|
+
// reconcile.mjs writes (a `decisions` row carrying
|
|
135
|
+
// RECONCILED_NOTE_PREFIX) keeps that fact in one place rather than adding
|
|
136
|
+
// a task column that every other command would then have to know about,
|
|
137
|
+
// and it survives a rebuild for free, since the replay re-writes the same
|
|
138
|
+
// note from the committed file.
|
|
139
|
+
function loadReconciledTasks(db) {
|
|
140
|
+
try {
|
|
141
|
+
return db
|
|
142
|
+
.prepare(
|
|
143
|
+
`SELECT DISTINCT d.task_id AS taskId, t.layer AS layer
|
|
144
|
+
FROM decisions d JOIN tasks t ON t.id = d.task_id
|
|
145
|
+
WHERE d.note LIKE ? || '%'
|
|
146
|
+
ORDER BY d.task_id`,
|
|
147
|
+
)
|
|
148
|
+
.all(RECONCILED_NOTE_PREFIX);
|
|
149
|
+
} catch {
|
|
150
|
+
// No `decisions` table yet (a build graph from before it existed).
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
126
155
|
// The friction row count, or 0. `listFriction` reads the table
|
|
127
156
|
// unguarded, so a build graph predating it throws here where `listDebt`
|
|
128
157
|
// would return [] — caught rather than propagated for the same reason
|
|
@@ -138,7 +167,7 @@ function countFriction(db) {
|
|
|
138
167
|
}
|
|
139
168
|
|
|
140
169
|
// Returns { counts, ready, heldBack, inFlight, attention, drift,
|
|
141
|
-
// orphanedOverrides, debt, frictionCount, total } —
|
|
170
|
+
// orphanedOverrides, debt, frictionCount, reconciled, total } —
|
|
142
171
|
// counts keyed by every status in the tasks CHECK constraint (present
|
|
143
172
|
// even at zero), ready the full list of currently-pickable tasks,
|
|
144
173
|
// heldBack the subset of those that `hedgehog claim` would skip over
|
|
@@ -181,6 +210,14 @@ function countFriction(db) {
|
|
|
181
210
|
// only the existence signal: `debt list` needs a task id the operator
|
|
182
211
|
// has no way to guess, and `friction list` needs the operator to
|
|
183
212
|
// already suspect there is something to read.
|
|
213
|
+
//
|
|
214
|
+
// `reconciled` is the tasks that reached `complete` through `hedgehog
|
|
215
|
+
// reconcile` rather than through `hedgehog verify`. Those are the only
|
|
216
|
+
// `complete` tasks the engine never checked — no scope gate, no verify
|
|
217
|
+
// command — and they are otherwise indistinguishable from verified ones
|
|
218
|
+
// in every count and list here. Reported unconditionally, not as a
|
|
219
|
+
// warning: reconciling is a supported act, and the point is that the
|
|
220
|
+
// distinction stays visible after the session that made it is gone.
|
|
184
221
|
export function graphStatus(db, { core = null, overrides = new Map() } = {}) {
|
|
185
222
|
const counts = countTasksByStatus(db);
|
|
186
223
|
const ready = loadReadyTasks(db);
|
|
@@ -191,6 +228,7 @@ export function graphStatus(db, { core = null, overrides = new Map() } = {}) {
|
|
|
191
228
|
const orphaned = orphanedOverrides(db, overrides);
|
|
192
229
|
const debt = loadDebtByTask(db);
|
|
193
230
|
const frictionCount = countFriction(db);
|
|
231
|
+
const reconciled = loadReconciledTasks(db);
|
|
194
232
|
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
|
195
233
|
return {
|
|
196
234
|
counts,
|
|
@@ -202,6 +240,7 @@ export function graphStatus(db, { core = null, overrides = new Map() } = {}) {
|
|
|
202
240
|
orphanedOverrides: orphaned,
|
|
203
241
|
debt,
|
|
204
242
|
frictionCount,
|
|
243
|
+
reconciled,
|
|
205
244
|
total,
|
|
206
245
|
};
|
|
207
246
|
}
|
|
@@ -216,8 +255,9 @@ const BLOCKED_REASON_LABELS = {
|
|
|
216
255
|
// status (only non-zero ones, in lifecycle order), any declared binary
|
|
217
256
|
// this environment can't resolve, the ready list, tasks currently in
|
|
218
257
|
// flight, anything needing attention, core.yaml drift, overrides
|
|
219
|
-
// pointing at no task,
|
|
220
|
-
//
|
|
258
|
+
// pointing at no task, what has been recorded in the two append-only
|
|
259
|
+
// side channels (declared debt, logged friction), and which complete
|
|
260
|
+
// tasks closed by reconciliation rather than by verification.
|
|
221
261
|
//
|
|
222
262
|
// `missingRequirements` comes from the core definition rather than the
|
|
223
263
|
// database (src/db/requires.mjs#coreMissingRequirements), so the caller
|
|
@@ -235,6 +275,7 @@ export function formatStatus({
|
|
|
235
275
|
orphanedOverrides = [],
|
|
236
276
|
debt = [],
|
|
237
277
|
frictionCount = 0,
|
|
278
|
+
reconciled = [],
|
|
238
279
|
total,
|
|
239
280
|
missingRequirements,
|
|
240
281
|
}) {
|
|
@@ -346,5 +387,22 @@ export function formatStatus({
|
|
|
346
387
|
lines.push(' Reviewed as a batch at the end of a build. See: hedgehog friction list');
|
|
347
388
|
}
|
|
348
389
|
|
|
390
|
+
// Last, because it is the only section here that reports something
|
|
391
|
+
// already settled rather than something outstanding. It is reported at
|
|
392
|
+
// all because a reconciled task is `complete` in every count above and
|
|
393
|
+
// is the one `complete` the engine never checked — the distinction is
|
|
394
|
+
// invisible without this line, and it is exactly what a reader deciding
|
|
395
|
+
// how much to trust the graph needs.
|
|
396
|
+
if (reconciled.length > 0) {
|
|
397
|
+
lines.push('');
|
|
398
|
+
lines.push(`CLOSED BY RECONCILIATION ${reconciled.length}`);
|
|
399
|
+
for (const { taskId, layer } of reconciled) {
|
|
400
|
+
lines.push(` ${taskId} ${layer} confirmed by the user, not verified`);
|
|
401
|
+
}
|
|
402
|
+
lines.push('');
|
|
403
|
+
lines.push(` No scope gate and no verify command ran on these. Recorded in ${RECONCILED_DIR}/.`);
|
|
404
|
+
lines.push(' See: hedgehog reconcile list');
|
|
405
|
+
}
|
|
406
|
+
|
|
349
407
|
return lines.join('\n');
|
|
350
408
|
}
|
package/src/db/verify.mjs
CHANGED
|
@@ -63,19 +63,21 @@ import { reapExpiredLeases, pathFingerprint } from './claim.mjs';
|
|
|
63
63
|
import { ensureTaskColumns } from './schema.mjs';
|
|
64
64
|
import { FRICTION_DIR } from './friction.mjs';
|
|
65
65
|
import { OVERRIDES_DIR } from './overrides.mjs';
|
|
66
|
+
import { RECONCILED_DIR } from './reconcile.mjs';
|
|
66
67
|
import { INTENTS_DIR } from './intent.mjs';
|
|
67
68
|
import { COMMUNITY_PATH } from './community.mjs';
|
|
68
69
|
|
|
69
70
|
// Build-graph state directories: written by their own command
|
|
70
|
-
// (`friction add`, `override add`, `intent add`/`db rebuild`
|
|
71
|
-
// by that command's own next step, never by a
|
|
71
|
+
// (`friction add`, `override add`, `intent add`/`db rebuild`, `reconcile
|
|
72
|
+
// confirm`), committed by that command's own next step, never by a
|
|
73
|
+
// layer's verify_command. A
|
|
72
74
|
// layer's own work never lands here, so a path under one of these is
|
|
73
75
|
// never this task's doing regardless of when it changed relative to
|
|
74
76
|
// claim time — unlike attributedToTask's fingerprint check, which only
|
|
75
77
|
// excludes a path unchanged since claim and so still attributes a
|
|
76
78
|
// friction note logged mid-layer (exactly what the loop skill instructs)
|
|
77
79
|
// to whichever task happened to be building when it was logged.
|
|
78
|
-
const BUILD_GRAPH_STATE_DIRS = [FRICTION_DIR, OVERRIDES_DIR, INTENTS_DIR];
|
|
80
|
+
const BUILD_GRAPH_STATE_DIRS = [FRICTION_DIR, OVERRIDES_DIR, INTENTS_DIR, RECONCILED_DIR];
|
|
79
81
|
|
|
80
82
|
function isBuildGraphStatePath(path) {
|
|
81
83
|
return BUILD_GRAPH_STATE_DIRS.some((dir) => path === dir || path.startsWith(`${dir}/`));
|