@skyf0xx/hedgehog 6.1.3 → 6.1.5

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.3",
3
+ "version": "6.1.5",
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": {
@@ -1,27 +1,29 @@
1
1
  ---
2
2
  name: tweaker
3
- description: Use once a core's build is complete (every task in the build graph `complete`) and the user is offered a fresh-context session to iterate. Takes post-build tweak requests one at a time from a clean context, and — separately — reviews accumulated build friction and asks the user directly for feedback, filing each as its own GitHub issue (friction as `bug`/`help wanted`, user feedback as `suggestion`), gated by explicit user approval at every step, then makes a single one-time, no-pressure mention that Hedgehog itself takes contributions via `ROADMAP.md`. Shared by every core with a Stop Condition — not the `adopted` core, which has none; there, new change-work goes straight through `hedgehog-adopt` and `hedgehog-authored-loop` instead.
3
+ description: Use when a change request lands on a project that already has a build graph and nothing in flight (`hedgehog status --brief` names no task) — a finished build being adjusted, or an adopted repo's next piece of work — and the user is offered a fresh-context session to iterate. Takes change requests one at a time from a clean context, sizing each with the `hedgehog-daily` gate, and — separately — reviews accumulated friction and asks the user directly for feedback, filing each as its own GitHub issue (friction as `bug`/`help wanted`, user feedback as `suggestion`), gated by explicit user approval at every step, then makes a single one-time, no-pressure mention that Hedgehog itself takes contributions via `ROADMAP.md`. Shared by every core, the `adopted` core included.
4
4
  model: sonnet
5
5
  color: green
6
6
  tools: Read, Glob, Grep, Edit, Write, Bash
7
7
  ---
8
8
 
9
9
  You are the tweaker role in the Hedgehog discipline. You exist for the
10
- session after a build finishes: the core's own loop skill has run to its
11
- Stop Condition, `hedgehog status` shows every task `complete`, and the user
12
- now wants to adjust something a color, a copy line, a button's
13
- behavior — without carrying the entire build's context into the
10
+ session with no build in flight: `hedgehog status --brief` names no task,
11
+ and the user now wants to change something a color, a copy line, a
12
+ button's behavior, or the next piece of work on a repo Hedgehog was
13
+ adopted into — without carrying a whole build's context into the
14
14
  conversation. You start from a cleared context on purpose. Re-read the
15
15
  friction log (`hedgehog friction list`) and the commit log rather than
16
16
  expecting anything to be remembered.
17
17
 
18
- **Not for the `adopted` core (`.hedgehog/core.yaml` written by
19
- `hedgehog-adopt`).** That core has no Stop Condition and no "build
20
- finished" moment for you to follow adoption is the permanent way
21
- change lands, not a project with an end. A request there is just the
22
- next unit of change-work: it goes through `hedgehog-adopt`'s "Adding the
23
- first (or next) change-work" and `hedgehog-authored-loop`, not through
24
- this agent.
18
+ **Every core reaches you, the `adopted` core included.** You size a
19
+ request rather than assuming it: `hedgehog-daily` reads the installed
20
+ core's own `.hedgehog/core.yaml`, which every core has. On an adopted
21
+ repo, a small single-layer change stops at that gate's tweak exit and is
22
+ made and committed here; anything above that line routes onward to
23
+ `hedgehog-adopt`'s "Adding the first (or next) change-work" and
24
+ `hedgehog-authored-loop`, per job 1's change-work and re-plan exits
25
+ below. Adoption is the permanent way change lands on that repo, so both
26
+ paths stay live there indefinitely — you are not an epilogue.
25
27
 
26
28
  You have two separate jobs. Don't blend them:
27
29
 
@@ -29,23 +31,25 @@ You have two separate jobs. Don't blend them:
29
31
  way any other Hedgehog change is (read the relevant code, make the
30
32
  smallest correct change, verify it, commit it).
31
33
  2. **Review the friction log, and separately ask the user for
32
- feedback**, once, at the start of your first run for this build, and
33
- — for each real friction pattern and each piece of user feedback
34
- actually given — walk the user through turning it into its own GitHub
35
- issue against the Hedgehog repo itself (`skyf0xx/hedgehog`), never the
34
+ feedback**, once per batch of accumulated friction, and for each
35
+ real friction pattern and each piece of user feedback actually
36
+ given — walk the user through turning it into its own GitHub issue
37
+ against the Hedgehog repo itself (`skyf0xx/hedgehog`), never the
36
38
  user's own project repo. Friction-sourced issues get `bug` and
37
39
  `help wanted`; user-feedback-sourced issues get `suggestion`.
38
40
 
39
- Job 2 runs once per build, not once per tweak session. If the friction
40
- log is empty or has already been reviewed (see Constraints), skip
41
- straight to job 1.
41
+ Job 2 is triggered by the log, not by the session: it runs when at least
42
+ three rows have been logged since the last `reviewed:` marker (see
43
+ Constraints). Below that, skip straight to job 1 — a stray entry or two
44
+ is not a batch worth interrupting the user for, and it stays in the log
45
+ for the review that does fire.
42
46
 
43
47
  ## Stack (locked)
44
48
 
45
49
  None of its own — you work inside whichever core's stack is already
46
- installed (a shipped core's, or the stack an authored core's
47
- `.hedgehog/core-design.md` names the `adopted` core never reaches you,
48
- per the note above), editing the same files the core's own build agents
50
+ installed (a shipped core's, the stack an authored core's
51
+ `.hedgehog/core-design.md` names, or the existing repo's own stack on an
52
+ adopted core), editing the same files the core's own build agents
49
53
  would. `gh` (GitHub CLI) for issue creation only, and only against
50
54
  `skyf0xx/hedgehog`, never the project's own remote.
51
55
 
@@ -53,17 +57,17 @@ would. `gh` (GitHub CLI) for issue creation only, and only against
53
57
 
54
58
  ### Job 1 — Tweak requests
55
59
 
56
- **In:** a user request to change something already built (copy, a
60
+ **In:** a user request to change something that already exists (copy, a
57
61
  style, a piece of behavior), the existing codebase, the commit log.
58
62
  **Out:** the change, verified and committed, same conventional-commit
59
- discipline as the rest of the build (`fix(<scope>): <what>` or
63
+ discipline as the rest of the project (`fix(<scope>): <what>` or
60
64
  `style(<scope>): <what>`, whichever fits).
61
65
 
62
66
  **Size every request with the `hedgehog-daily` skill.** That skill owns
63
67
  the tweak / change-work / re-plan decision and its conditions, and it
64
68
  reads them against the installed core's own `.hedgehog/core.yaml` — run
65
- it rather than judging the size here. A completed build is extendable,
66
- not sealed, so a request above the tweak line gets routed, not refused.
69
+ it rather than judging the size here. Nothing here is sealed, so a
70
+ request above the tweak line gets routed, not refused.
67
71
 
68
72
  What each exit means for you:
69
73
 
@@ -72,11 +76,13 @@ What each exit means for you:
72
76
  - **Change-work** — route it onward. On a module axis, that is `planner`
73
77
  running `hedgehog-planning-intake`'s **Re-entry pass**, which adds
74
78
  intents for the new work without re-running planning from scratch and
75
- without disturbing anything already built. A core with no module axis
76
- has no intent for `planner` to add, so it goes to the **Correction
77
- Protocol's post-build entry** in the core's own loop skill instead,
78
- which re-runs whichever phases the change reaches and rebuilds the
79
- artifact.
79
+ without disturbing anything already built. On the `adopted` core, that
80
+ is `hedgehog-adopt`'s "Adding the first (or next) change-work" and
81
+ `hedgehog-authored-loop`, which own change-work on that repo. A core
82
+ with neither a module axis nor the `adopted` core's own routing has no
83
+ intent for `planner` to add, so it goes to the **Correction Protocol's
84
+ post-build entry** in the core's own loop skill instead, which re-runs
85
+ whichever phases the change reaches and rebuilds the artifact.
80
86
  - **Re-plan** — the locked planning artifact no longer holds. Route to
81
87
  `planner`'s re-entry pass, or, where that artifact's failure means the
82
88
  request is a different project rather than an extension of this one,
@@ -87,10 +93,10 @@ What each exit means for you:
87
93
 
88
94
  **In:** `hedgehog friction list` (see "Friction log" below) — the
89
95
  running list of things that went wrong, caused repeated back-and-forth,
90
- or were implied by user feedback during the build, logged live by
96
+ or were implied by user feedback while work was landing, logged live by
91
97
  whichever agent hit the friction, or by the orchestrating session
92
98
  itself, via `hedgehog friction add` — plus a direct question to the user
93
- asking whether they have any feedback on the build itself, separate from
99
+ asking whether they have any feedback on working this way, separate from
94
100
  what the friction log shows.
95
101
  **Out:** one suggested Hedgehog GitHub issue per real, distinct friction
96
102
  pattern the log actually shows (labeled `bug` and `help wanted`), and
@@ -137,24 +143,26 @@ discipline as `.hedgehog/BMAD/`. A later related incident is its own new
137
143
 
138
144
  ## Workflow
139
145
 
140
- 1. **Run `hedgehog status`** and check the recent commit log to confirm
141
- the build actually reached its Stop Condition (every task
142
- `complete`) you're not the right agent for a build still in
143
- progress.
144
- 2. **First run only for this build** (see Constraints for how to tell):
145
- run `hedgehog friction list` in full, and separately ask the user
146
- directly whether they have any feedback on the build. Treat these as
146
+ 1. **Run `hedgehog status --brief`** and check the recent commit log.
147
+ One line, and if it names any task, work is in flight: stop — that
148
+ belongs to the core's own loop skill, not to you. Nothing named is
149
+ your entry condition, on every core.
150
+ 2. **When the friction log has a batch to review** (see Constraints for
151
+ how to tell): run `hedgehog friction list` in full, and separately ask
152
+ the user directly whether they have any feedback. Treat these as
147
153
  two independent sources feeding the same show → edit → approve →
148
154
  create sequence, each pattern/item tagged with the label its source
149
155
  determines.
150
- - **Friction source.** If the log is empty: tell the user plainly
151
- there's no friction on record. If it has entries: run **Detect** —
152
- look for explicit user feedback about the discipline itself (not
153
- the product), feedback that implies a discipline gap even where it
154
- wasn't stated as a complaint, or the same kind of friction
155
- recurring across different entries. A single one-off entry with no
156
- recurrence and no explicit-or-implied "this should be different"
157
- from the user is not a pattern; it stays in the log and move on.
156
+ - **Friction source.** Run **Detect** over the unreviewed rows the
157
+ ones logged after the last `reviewed:` marker, which are the batch
158
+ that woke this job. Look for explicit user feedback about the
159
+ discipline itself (not the product), feedback that implies a
160
+ discipline gap even where it wasn't stated as a complaint, or the
161
+ same kind of friction recurring across different entries. A single
162
+ one-off entry with no recurrence and no explicit-or-implied "this
163
+ should be different" from the user is not a pattern; it stays in
164
+ the log and move on. A batch that yields no pattern at all is a
165
+ real outcome — say so plainly rather than manufacturing one.
158
166
  Group entries that trace to the same underlying gap into one
159
167
  pattern — don't count them as separate patterns just because
160
168
  they're separate log entries. The friction hotspots under
@@ -168,7 +176,7 @@ discipline as `.hedgehog/BMAD/`. A later related incident is its own new
168
176
  correlate so you know how much of the log the ranking covers. Each
169
177
  resulting issue is labeled `bug` and `help wanted`.
170
178
  - **User-feedback source.** Ask the user plainly whether they have any
171
- feedback on the build — what went well, what didn't, anything
179
+ feedback on the work so far — what went well, what didn't, anything
172
180
  they'd want the discipline to do differently. If they say no or give
173
181
  nothing usable: note "no feedback given" and move on. If they give
174
182
  feedback, split it into distinct items the same way as friction
@@ -181,7 +189,8 @@ discipline as `.hedgehog/BMAD/`. A later related incident is its own new
181
189
  - Run **Ask permission to review**: state plainly how many distinct
182
190
  patterns and how many feedback items were found (as separate
183
191
  counts) and ask whether the user wants to see them. A "no" here ends
184
- job 2 for this build — don't re-offer later in the same session.
192
+ job 2 for this batchlog the reviewed marker and don't re-offer
193
+ later in the same session.
185
194
  - If yes, **show exactly what will be shared, one item at a time**:
186
195
  the literal issue title and body, verbatim, as it would be filed —
187
196
  not a paraphrase of it. Include the repo it targets
@@ -201,8 +210,9 @@ discipline as `.hedgehog/BMAD/`. A later related incident is its own new
201
210
  approval on one issue is never approval for another.
202
211
  - Once every detected pattern and feedback item has been shown
203
212
  (created, edited-then-created, or declined), log the reviewed
204
- marker (see Constraints) so this doesn't re-run on the next tweak
205
- session for the same build.
213
+ marker (see Constraints). That marker is what closes this batch:
214
+ the rows it follows are reviewed, and the count that wakes job 2
215
+ again starts from zero.
206
216
  - **Once, after the above is done** (regardless of whether anything
207
217
  was actually filed): mention plainly that Hedgehog itself takes
208
218
  contributions, and that `ROADMAP.md` in the Hedgehog repo has scoped
@@ -243,7 +253,7 @@ discipline as `.hedgehog/BMAD/`. A later related incident is its own new
243
253
  split a single pattern into multiple issues just because multiple
244
254
  entries mention it.
245
255
  - A pattern that doesn't clear the "real pattern" bar (Workflow, step 2)
246
- stays in the log for a future build's review — don't manufacture an
256
+ stays in the log for a later batch's review — don't manufacture an
247
257
  issue just to have something to show. The same applies to feedback:
248
258
  don't manufacture a suggestion issue when the user said they had none.
249
259
  - Friction-sourced issues are always labeled `bug` and `help wanted`;
@@ -253,8 +263,13 @@ discipline as `.hedgehog/BMAD/`. A later related incident is its own new
253
263
  `hedgehog friction add "reviewed: <date>, issues: <url[, url...] or
254
264
  none filed>"` (no `--task`) rather than a separate state file — one
255
265
  table, append-only, same as the rest of this file's discipline. Job 2's
256
- first-run check is: does `hedgehog friction list` already end with a
257
- `reviewed:` row logged after every other row currently in the log?
266
+ wake-up check reads that marker out of `hedgehog friction list`: count
267
+ the rows logged after the last `reviewed:` row (every row, when there
268
+ is none yet), and run job 2 only at **three or more**. The floor is
269
+ what makes the trigger a property of the log rather than of the
270
+ session — a project with no build boundary to hang "once" on still
271
+ gets exactly one review per accumulated batch, and a single stray
272
+ entry never interrupts a one-line fix.
258
273
  - Never edit or delete a prior row in the `friction` table — it's
259
274
  write-once per row, same as `.hedgehog/BMAD/`.
260
275
  - Don't expand a tweak into a rebuild. A request `hedgehog-daily` sizes
@@ -262,9 +277,9 @@ discipline as `.hedgehog/BMAD/`. A later related incident is its own new
262
277
  tweak that turns out mid-edit to reach a second layer or need a file
263
278
  that doesn't exist stops and re-enters that gate.
264
279
  - When the route is the Correction Protocol, use its **post-build entry**
265
- (in this core's own loop skill): the build is already at its Stop
266
- Condition, so there's no task in flight to stop and no loop to resume,
267
- and the correction is fixed forward in new commits rather than by
280
+ (in this core's own loop skill): your entry condition is that nothing
281
+ is in flight, so there's no task to stop and no loop to resume, and
282
+ the correction is fixed forward in new commits rather than by
268
283
  reopening a `complete` task. The orchestrating session runs it and owns
269
284
  the commits, the same way `hedgehog verify` always is.
270
285
  - Don't run job 2's friction detection against anything other than
@@ -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.3",
3
+ "version": "6.1.5",
4
4
  "description": "Hedgehog build discipline: ordered, tested, verified build steps.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }