@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "6.1.4",
3
+ "version": "6.1.6",
4
4
  "description": "Install the Hedgehog build discipline (agents + skills) into a repo, for Claude Code, Cursor, or Gemini CLI.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -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` — that used to
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). The real question is where the data lives and who needs to
142
- enforce the rules around it. A description naming a local-first app —
143
- offline capability or installability named explicitly is a strong
144
- signal — is `pwa-app`, even with sharing, accounts, or multi-device sync
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
@@ -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) or
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.prepare('SELECT task_id, note, logged_at FROM decisions').all();
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. Returns a summary for the CLI to print.
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
- { corePath, intentsDir = INTENTS_DIR, overridesDir = OVERRIDES_DIR } = {},
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 { intentsReplayed, tasksMarkedComplete, orphanedNotes, drift };
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, and what has been recorded in the two
220
- // append-only side channels (declared debt, logged friction).
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`), committed
71
- // by that command's own next step, never by a layer's verify_command. 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}/`));
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hedgehog",
3
- "version": "6.1.4",
3
+ "version": "6.1.6",
4
4
  "description": "Hedgehog build discipline: ordered, tested, verified build steps.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }