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