@skyf0xx/hedgehog 6.2.17 → 6.3.1
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 +62 -12
- package/package.json +1 -1
- package/src/db/claim.mjs +116 -5
- package/src/db/crossBranch.mjs +186 -0
- package/src/db/debt.mjs +67 -5
- package/src/db/next.mjs +25 -1
- package/src/db/notes.mjs +22 -5
- package/src/db/rebuild.mjs +42 -5
- package/src/db/reconcile.mjs +42 -2
- package/src/db/schema.mjs +19 -5
- package/src/db/status.mjs +12 -12
- package/src/db/verify.mjs +16 -3
- package/src/db/worktree.mjs +27 -1
- package/src/hosts/gemini/gemini-extension.json +1 -1
- package/src/skills/code-comment-discipline/SKILL.md +47 -0
package/bin/cli.mjs
CHANGED
|
@@ -59,7 +59,7 @@ import {
|
|
|
59
59
|
} from '../src/db/requires.mjs';
|
|
60
60
|
import { whyPath, formatWhy } from '../src/db/why.mjs';
|
|
61
61
|
import { addFriction, listFriction } from '../src/db/friction.mjs';
|
|
62
|
-
import { addDebt, listDebt } from '../src/db/debt.mjs';
|
|
62
|
+
import { addDebt, listDebt, resolveDebt } from '../src/db/debt.mjs';
|
|
63
63
|
import { addDecision, listDecisions } from '../src/db/decision.mjs';
|
|
64
64
|
import {
|
|
65
65
|
shouldPromptForStar,
|
|
@@ -86,6 +86,7 @@ import {
|
|
|
86
86
|
onHedgehogBranch,
|
|
87
87
|
writeAbandonedFile,
|
|
88
88
|
applyAbandonment,
|
|
89
|
+
loadAbandoned,
|
|
89
90
|
ABANDONED_DIR,
|
|
90
91
|
} from '../src/db/worktree.mjs';
|
|
91
92
|
import { loadOverrides, addOverride, orphanedOverrides, OVERRIDES_DIR } from '../src/db/overrides.mjs';
|
|
@@ -662,7 +663,8 @@ ${bold('Usage')}
|
|
|
662
663
|
npx @skyf0xx/hedgehog friction add "<note>" log a friction note [--task <task-id>]
|
|
663
664
|
npx @skyf0xx/hedgehog friction list list logged friction, oldest first
|
|
664
665
|
npx @skyf0xx/hedgehog debt add <task-id> "<note>" declare debt that lands in dependent tasks' packets
|
|
665
|
-
npx @skyf0xx/hedgehog debt list [<task-id>]
|
|
666
|
+
npx @skyf0xx/hedgehog debt list [<task-id>] [--all] list open debt, oldest first (--all includes resolved)
|
|
667
|
+
npx @skyf0xx/hedgehog debt resolve <debt-id> --reason "<why>" mark a debt note resolved
|
|
666
668
|
npx @skyf0xx/hedgehog decision add <task-id> "<note>" declare a decision that lands in dependent tasks' packets
|
|
667
669
|
npx @skyf0xx/hedgehog decision list [<task-id>] list declared decisions, oldest first
|
|
668
670
|
npx @skyf0xx/hedgehog db migrate bring the graph's schema up to the latest version
|
|
@@ -1557,11 +1559,21 @@ async function planCommand(args = []) {
|
|
|
1557
1559
|
// by the same rule that got it a worktree in the first place. Without
|
|
1558
1560
|
// this guard, that recursive call would try to create a second,
|
|
1559
1561
|
// colliding worktree for itself instead of just compiling normally.
|
|
1562
|
+
// A committed `.hedgehog/abandoned/<id>.json` record outlives the
|
|
1563
|
+
// `intent_dependencies` reset that comes with it (issue #431) — see
|
|
1564
|
+
// worktree.mjs#eligibleIntents for why the abandonment record, not the
|
|
1565
|
+
// (re-clearable) dependency table, has to be the thing checked there.
|
|
1566
|
+
// Loaded unconditionally (not just under the onHedgehogBranch guard
|
|
1567
|
+
// below) because it also feeds excludeIntentIds just below: an abandoned
|
|
1568
|
+
// intent must never fall through to a plain trunk compile just because
|
|
1569
|
+
// eligibleIntents excluded it from the worktree path.
|
|
1570
|
+
const abandonedIntentIds = new Set((await loadAbandoned()).keys());
|
|
1571
|
+
|
|
1560
1572
|
let eligible = [];
|
|
1561
1573
|
if (!onHedgehogBranch()) {
|
|
1562
1574
|
const eligibilityDb = openDb({ readOnly: true });
|
|
1563
1575
|
try {
|
|
1564
|
-
eligible = eligibleIntents(eligibilityDb);
|
|
1576
|
+
eligible = eligibleIntents(eligibilityDb, abandonedIntentIds);
|
|
1565
1577
|
} finally {
|
|
1566
1578
|
eligibilityDb.close();
|
|
1567
1579
|
}
|
|
@@ -1572,8 +1584,12 @@ async function planCommand(args = []) {
|
|
|
1572
1584
|
// it a worktree this run (hasWorktree true already, or the commit check
|
|
1573
1585
|
// below defers it) — an eligible intent must never fall through to
|
|
1574
1586
|
// compiling on trunk, or it would sit there just like any pre-feature
|
|
1575
|
-
// intent and this feature would have done nothing for it.
|
|
1576
|
-
|
|
1587
|
+
// intent and this feature would have done nothing for it. Abandoned
|
|
1588
|
+
// intents are unioned in for the same reason (issue #431): excluded from
|
|
1589
|
+
// `eligible` itself now, they'd otherwise read as "not eligible, so
|
|
1590
|
+
// compile normally" and get recompiled straight onto trunk instead of
|
|
1591
|
+
// being left alone — the abandonment record makes that impossible.
|
|
1592
|
+
const excludeIntentIds = new Set([...eligible.map((i) => i.id), ...abandonedIntentIds]);
|
|
1577
1593
|
|
|
1578
1594
|
const worktreesCreated = [];
|
|
1579
1595
|
for (const intent of eligible) {
|
|
@@ -2240,7 +2256,8 @@ async function verifyCommand(args) {
|
|
|
2240
2256
|
process.exitCode = 1;
|
|
2241
2257
|
return;
|
|
2242
2258
|
}
|
|
2243
|
-
|
|
2259
|
+
const overrides = await loadOverrides();
|
|
2260
|
+
result = verifyTask(db, taskId, owner, overrides);
|
|
2244
2261
|
} catch (err) {
|
|
2245
2262
|
console.error(`${red('Verify failed:')} ${err.message}\n`);
|
|
2246
2263
|
process.exitCode = 1;
|
|
@@ -3332,7 +3349,7 @@ async function overrideCommand(args) {
|
|
|
3332
3349
|
console.log(` ${green('added')} ${OVERRIDES_DIR}/${record.task.toLowerCase()}.json`);
|
|
3333
3350
|
for (const glob of record.scope_add) console.log(` + ${glob}`);
|
|
3334
3351
|
console.log(
|
|
3335
|
-
` ${dim(
|
|
3352
|
+
` ${dim(`\`hedgehog verify\` picks this up immediately; run \`hedgehog plan --recompile\` to also widen ${record.task}'s stored scope_globs (task listing, drift detection) now, if it's already compiled`)}\n`,
|
|
3336
3353
|
);
|
|
3337
3354
|
return;
|
|
3338
3355
|
}
|
|
@@ -3828,28 +3845,61 @@ async function debtCommand(args) {
|
|
|
3828
3845
|
}
|
|
3829
3846
|
|
|
3830
3847
|
if (sub === 'list') {
|
|
3831
|
-
const
|
|
3848
|
+
const includeResolved = args.includes('--all') || args.includes('--resolved');
|
|
3849
|
+
const taskId = args.slice(1).find((a) => !a.startsWith('--'));
|
|
3832
3850
|
const db = openDb();
|
|
3833
3851
|
let entries;
|
|
3834
3852
|
try {
|
|
3835
|
-
entries = listDebt(db, taskId);
|
|
3853
|
+
entries = listDebt(db, taskId, { includeResolved });
|
|
3836
3854
|
} finally {
|
|
3837
3855
|
db.close();
|
|
3838
3856
|
}
|
|
3839
3857
|
|
|
3840
3858
|
if (entries.length === 0) {
|
|
3841
|
-
console.log(`${dim('No debt declared.')}\n`);
|
|
3859
|
+
console.log(`${dim(includeResolved ? 'No debt declared.' : 'No open debt.')}\n`);
|
|
3842
3860
|
return;
|
|
3843
3861
|
}
|
|
3844
3862
|
for (const entry of entries) {
|
|
3845
3863
|
console.log(`#${entry.id} ${dim(entry.loggedAt)} ${bold(entry.taskId)}`);
|
|
3846
|
-
console.log(` ${entry.note}
|
|
3864
|
+
console.log(` ${entry.note}`);
|
|
3865
|
+
if (entry.resolvedAt) {
|
|
3866
|
+
console.log(` ${green('resolved')} ${dim(entry.resolvedAt)} — ${entry.resolvedReason}`);
|
|
3867
|
+
}
|
|
3868
|
+
console.log('');
|
|
3847
3869
|
}
|
|
3848
3870
|
return;
|
|
3849
3871
|
}
|
|
3850
3872
|
|
|
3873
|
+
if (sub === 'resolve') {
|
|
3874
|
+
const debtId = args[1];
|
|
3875
|
+
const reasonIdx = args.indexOf('--reason');
|
|
3876
|
+
const reason = reasonIdx !== -1 ? args[reasonIdx + 1] : undefined;
|
|
3877
|
+
|
|
3878
|
+
if (!debtId || debtId.startsWith('--') || !reason) {
|
|
3879
|
+
console.error(`${red('Usage:')} hedgehog debt resolve <debt-id> --reason "<why>"\n`);
|
|
3880
|
+
process.exitCode = 1;
|
|
3881
|
+
return;
|
|
3882
|
+
}
|
|
3883
|
+
|
|
3884
|
+
const db = openDb();
|
|
3885
|
+
let result;
|
|
3886
|
+
try {
|
|
3887
|
+
result = await resolveDebt(db, { debtId: Number(debtId), reason });
|
|
3888
|
+
} catch (err) {
|
|
3889
|
+
console.error(`${red('Failed to resolve debt:')} ${err.message}\n`);
|
|
3890
|
+
process.exitCode = 1;
|
|
3891
|
+
return;
|
|
3892
|
+
} finally {
|
|
3893
|
+
db.close();
|
|
3894
|
+
}
|
|
3895
|
+
|
|
3896
|
+
console.log(` ${green('resolved')} #${result.id} ${bold(result.taskId)}`);
|
|
3897
|
+
console.log(` ${dim(result.note)}`);
|
|
3898
|
+
return;
|
|
3899
|
+
}
|
|
3900
|
+
|
|
3851
3901
|
console.error(
|
|
3852
|
-
`${red('Unknown debt subcommand:')} ${sub ?? '(none)'}\n\nUsage: hedgehog debt add <task-id> "<note>"\n or: hedgehog debt list [<task-id>]\n`,
|
|
3902
|
+
`${red('Unknown debt subcommand:')} ${sub ?? '(none)'}\n\nUsage: hedgehog debt add <task-id> "<note>"\n or: hedgehog debt list [<task-id>] [--all]\n or: hedgehog debt resolve <debt-id> --reason "<why>"\n`,
|
|
3853
3903
|
);
|
|
3854
3904
|
process.exitCode = 1;
|
|
3855
3905
|
}
|
package/package.json
CHANGED
package/src/db/claim.mjs
CHANGED
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
// — by setting status and the three lease columns in the same UPDATE:
|
|
9
9
|
// a task entering `building` gets an owner, and a task leaving it (to
|
|
10
10
|
// ready, planned, blocked) has all three cleared.
|
|
11
|
+
//
|
|
12
|
+
// Both `findClaimableTasks` (the fan-out's candidate set) and `claimTask`
|
|
13
|
+
// (a targeted, named claim) additionally treat a dependency as satisfied
|
|
14
|
+
// when it is complete anywhere reachable from a local git ref, not only
|
|
15
|
+
// in this worktree's own DB — see crossBranch.mjs. This is a read-only
|
|
16
|
+
// widening of "is this dependency done", never a write across worktrees:
|
|
17
|
+
// no DB row in this file ever crosses from one worktree's
|
|
18
|
+
// `.hedgehog/hedgehog.db` to another's (worktree.mjs's file header).
|
|
11
19
|
|
|
12
20
|
import { execFileSync } from 'node:child_process';
|
|
13
21
|
import { readFileSync, readlinkSync, lstatSync } from 'node:fs';
|
|
@@ -17,6 +25,7 @@ import { inTransaction } from './init.mjs';
|
|
|
17
25
|
import { conflicts } from './conflict.mjs';
|
|
18
26
|
import { incompleteDependencies } from './next.mjs';
|
|
19
27
|
import { ensureTaskColumns } from './schema.mjs';
|
|
28
|
+
import { buildCrossBranchIndex, commitMessageExistsAnywhere } from './crossBranch.mjs';
|
|
20
29
|
|
|
21
30
|
// ── Claim-time working-tree snapshot ──────────────────────────────────
|
|
22
31
|
//
|
|
@@ -154,10 +163,94 @@ const CLAIMABLE_TASKS_SQL = `
|
|
|
154
163
|
ORDER BY t.priority, t.exclusive DESC, t.id
|
|
155
164
|
`;
|
|
156
165
|
|
|
166
|
+
// The same shape as CLAIMABLE_TASKS_SQL's own NOT EXISTS clause, but
|
|
167
|
+
// naming which dependency is unsatisfied *in this DB* rather than merely
|
|
168
|
+
// excluding the candidate — this is the raw material
|
|
169
|
+
// findUnsatisfiedDependencies (below) needs to then ask crossBranch.mjs
|
|
170
|
+
// whether that specific dependency is satisfied elsewhere instead.
|
|
171
|
+
const UNSATISFIED_DEPENDENCIES_SQL = `
|
|
172
|
+
SELECT d.task_id AS taskId, dep.id AS dependsOnId, dep.commit_message AS commitMessage
|
|
173
|
+
FROM dependencies d
|
|
174
|
+
JOIN tasks dep ON dep.id = d.depends_on_task_id
|
|
175
|
+
WHERE dep.status <> 'complete'
|
|
176
|
+
`;
|
|
177
|
+
|
|
178
|
+
// Candidate tasks CLAIMABLE_TASKS_SQL's own NOT EXISTS would exclude
|
|
179
|
+
// (status planned/ready, unleased, but blocked on a dependency this DB
|
|
180
|
+
// still shows incomplete) that become claimable once that dependency's
|
|
181
|
+
// commit is credited from ANY local branch's history — see crossBranch.mjs
|
|
182
|
+
// for why this is exactly `git log --all` widened from the current
|
|
183
|
+
// branch's own `git log`, and for the documented ambiguous-commit-message
|
|
184
|
+
// gap this can't resolve.
|
|
185
|
+
//
|
|
186
|
+
// This is a live, read-only re-derivation: nothing here writes
|
|
187
|
+
// `tasks.status` in the current worktree's DB (that would be exactly the
|
|
188
|
+
// "no DB row crosses worktrees" invariant this feature deliberately does
|
|
189
|
+
// not touch — see worktree.mjs's file header). A future claim/verify pass
|
|
190
|
+
// in this same worktree still needs its own copy of that fact; this only
|
|
191
|
+
// answers "is it claimable right now".
|
|
192
|
+
//
|
|
193
|
+
// `index` is always caller-supplied (buildCrossBranchIndex shells out to
|
|
194
|
+
// `git log`, and this file's own `inTransaction` contract — see
|
|
195
|
+
// claimTasks/claimTask below — forbids running a subprocess while a sqlite
|
|
196
|
+
// transaction is open; findClaimableTasks builds the index before ever
|
|
197
|
+
// entering one).
|
|
198
|
+
function crossBranchClaimableCandidates(db, index) {
|
|
199
|
+
const candidates = db
|
|
200
|
+
.prepare(
|
|
201
|
+
`SELECT t.* FROM tasks t
|
|
202
|
+
WHERE t.status IN ('planned', 'ready')
|
|
203
|
+
AND t.lease_owner IS NULL
|
|
204
|
+
AND EXISTS (
|
|
205
|
+
SELECT 1 FROM dependencies d
|
|
206
|
+
JOIN tasks dep ON dep.id = d.depends_on_task_id
|
|
207
|
+
WHERE d.task_id = t.id AND dep.status <> 'complete'
|
|
208
|
+
)
|
|
209
|
+
ORDER BY t.priority, t.exclusive DESC, t.id`,
|
|
210
|
+
)
|
|
211
|
+
.all();
|
|
212
|
+
if (candidates.length === 0) return [];
|
|
213
|
+
|
|
214
|
+
const unsatisfied = db.prepare(UNSATISFIED_DEPENDENCIES_SQL).all();
|
|
215
|
+
const unsatisfiedByTask = new Map();
|
|
216
|
+
for (const row of unsatisfied) {
|
|
217
|
+
if (!unsatisfiedByTask.has(row.taskId)) unsatisfiedByTask.set(row.taskId, []);
|
|
218
|
+
unsatisfiedByTask.get(row.taskId).push(row);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return candidates.filter((candidate) =>
|
|
222
|
+
unsatisfiedByTask
|
|
223
|
+
.get(candidate.id)
|
|
224
|
+
.every((dep) => commitMessageExistsAnywhere({ commit_message: dep.commitMessage }, index)),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
157
228
|
// Exported for ready.mjs, which walks the identical candidate set to
|
|
158
229
|
// simulate this same fan-out without claiming anything.
|
|
159
|
-
|
|
160
|
-
|
|
230
|
+
//
|
|
231
|
+
// Merges CLAIMABLE_TASKS_SQL's own current-DB-only candidates with the
|
|
232
|
+
// cross-branch set above, then re-sorts to the same `priority, exclusive
|
|
233
|
+
// DESC, id` order the fan-out and every caller depend on — the two source
|
|
234
|
+
// queries are individually ordered but interleaving them requires a single
|
|
235
|
+
// re-sort over the union.
|
|
236
|
+
//
|
|
237
|
+
// `index` is optional: a caller outside any sqlite transaction (ready.mjs,
|
|
238
|
+
// status.mjs) can omit it and this builds one itself. A caller that runs
|
|
239
|
+
// inside `inTransaction` (claimTasks/claimTask, below) MUST build the
|
|
240
|
+
// index first and pass it in — building it here would shell out to `git
|
|
241
|
+
// log` with a `BEGIN IMMEDIATE` already open, which this codebase
|
|
242
|
+
// forbids (see init.mjs#inTransaction's own contract, restated in
|
|
243
|
+
// claimTasks's comment).
|
|
244
|
+
export function findClaimableTasks(db, index = buildCrossBranchIndex(db)) {
|
|
245
|
+
const local = db.prepare(CLAIMABLE_TASKS_SQL).all();
|
|
246
|
+
const crossBranch = crossBranchClaimableCandidates(db, index);
|
|
247
|
+
if (crossBranch.length === 0) return local;
|
|
248
|
+
|
|
249
|
+
return [...local, ...crossBranch].sort((a, b) => {
|
|
250
|
+
if (a.priority !== b.priority) return a.priority - b.priority;
|
|
251
|
+
if (a.exclusive !== b.exclusive) return b.exclusive - a.exclusive;
|
|
252
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
253
|
+
});
|
|
161
254
|
}
|
|
162
255
|
|
|
163
256
|
// Tasks another call already holds a lease on — the conflict check's other
|
|
@@ -266,6 +359,11 @@ export function claimTasks(db, { owner, count = 1, leaseMinutes = 45 }) {
|
|
|
266
359
|
// Taking it a moment early can only miss a path dirtied in between,
|
|
267
360
|
// which leaves that path attributed — the strict direction.
|
|
268
361
|
const claimSnapshot = snapshotWorkingTree();
|
|
362
|
+
// Same rule, same reason, for the cross-branch index: buildCrossBranchIndex
|
|
363
|
+
// shells out to `git log --all` (twice — see crossBranch.mjs), so it is
|
|
364
|
+
// built here, before BEGIN, and threaded into findClaimableTasks rather
|
|
365
|
+
// than left to build itself once inside the transaction below.
|
|
366
|
+
const crossBranchIndex = buildCrossBranchIndex(db);
|
|
269
367
|
|
|
270
368
|
return inTransaction(db, () => {
|
|
271
369
|
const justReaped = reapExpiredLeases(db);
|
|
@@ -275,7 +373,7 @@ export function claimTasks(db, { owner, count = 1, leaseMinutes = 45 }) {
|
|
|
275
373
|
return { claimed: [], blocked };
|
|
276
374
|
}
|
|
277
375
|
|
|
278
|
-
const candidates = findClaimableTasks(db);
|
|
376
|
+
const candidates = findClaimableTasks(db, crossBranchIndex);
|
|
279
377
|
const inFlight = findInFlightTasks(db);
|
|
280
378
|
const runClaim = claimOne(db);
|
|
281
379
|
const claimed = [];
|
|
@@ -317,8 +415,13 @@ export function claimTasks(db, { owner, count = 1, leaseMinutes = 45 }) {
|
|
|
317
415
|
export function claimTask(db, taskId, { owner, leaseMinutes = 45 }) {
|
|
318
416
|
ensureTaskColumns(db);
|
|
319
417
|
// Same rule as claimTasks: read before BEGIN, no subprocess inside a
|
|
320
|
-
// transaction.
|
|
418
|
+
// transaction. buildCrossBranchIndex shells out to `git log --all`, so
|
|
419
|
+
// it is built here unconditionally (cheap relative to a `git` process
|
|
420
|
+
// spawn either way) rather than only on the branch that turns out to
|
|
421
|
+
// need it, which would otherwise tempt building it lazily from inside
|
|
422
|
+
// the transaction below.
|
|
321
423
|
const claimSnapshot = snapshotWorkingTree();
|
|
424
|
+
const crossBranchIndex = buildCrossBranchIndex(db);
|
|
322
425
|
|
|
323
426
|
return inTransaction(db, () => {
|
|
324
427
|
reapExpiredLeases(db);
|
|
@@ -330,7 +433,15 @@ export function claimTask(db, taskId, { owner, leaseMinutes = 45 }) {
|
|
|
330
433
|
return { claimed: false, reason: 'not_claimable', task };
|
|
331
434
|
}
|
|
332
435
|
|
|
333
|
-
|
|
436
|
+
// Same cross-branch widening findClaimableTasks's fan-out applies: a
|
|
437
|
+
// dependency this DB still shows incomplete may already be complete on
|
|
438
|
+
// trunk or a sibling worktree's branch. Checked here too so a targeted
|
|
439
|
+
// `hedgehog claim <task-id>` doesn't refuse a task the fan-out would
|
|
440
|
+
// have happily claimed.
|
|
441
|
+
const stillIncomplete = incompleteDependencies(db, taskId);
|
|
442
|
+
const incomplete = stillIncomplete.filter(
|
|
443
|
+
(dep) => !commitMessageExistsAnywhere(dep, crossBranchIndex),
|
|
444
|
+
);
|
|
334
445
|
if (incomplete.length > 0) {
|
|
335
446
|
return { claimed: false, reason: 'incomplete_dependencies', task, incomplete };
|
|
336
447
|
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Cross-branch commit-history visibility for `hedgehog ready`/`claim`/
|
|
2
|
+
// `status`/`reconcile`, run live inside a `git worktree` checkout — where
|
|
3
|
+
// `hedgehog db rebuild` (rebuild.mjs#loadCommitSubjects) intentionally
|
|
4
|
+
// only ever reads the CURRENT branch's own `git log`.
|
|
5
|
+
//
|
|
6
|
+
// The gap this closes: each worktree carries its own `.hedgehog/hedgehog.db`
|
|
7
|
+
// (worktree.mjs's file header), and no DB row is ever copied between them
|
|
8
|
+
// or from a worktree to trunk — that invariant is the reason the build
|
|
9
|
+
// graph is a pure function of committed files plus git history in the
|
|
10
|
+
// first place. But "current git history" was, until now, silently read as
|
|
11
|
+
// "the current branch's history" everywhere a live command checked whether
|
|
12
|
+
// a dependency was done: claim.mjs's CLAIMABLE_TASKS_SQL joins only the
|
|
13
|
+
// current DB's own `tasks.status`, and reconcile.mjs's `commitsSince`
|
|
14
|
+
// ranges over `HEAD` alone. A task completed and committed on trunk (or on
|
|
15
|
+
// a sibling worktree's branch) is therefore invisible to a different
|
|
16
|
+
// worktree's own DB even though the *commit* — the actual source of truth
|
|
17
|
+
// — is sitting right there in the same repository's object store, reachable
|
|
18
|
+
// from a local ref this checkout can already see.
|
|
19
|
+
//
|
|
20
|
+
// This module answers the same question rebuild.mjs#loadCommitSubjects
|
|
21
|
+
// answers for the current branch (did a commit with this exact subject
|
|
22
|
+
// ever happen), widened to `git log --all` — every local branch's
|
|
23
|
+
// reachable history, which covers trunk and every open worktree's branch,
|
|
24
|
+
// since a worktree's branch is still a local ref of the same checkout.
|
|
25
|
+
// `--all` is exactly the right and only widening: it costs nothing beyond
|
|
26
|
+
// one extra `git log` flag, needs no new git remotes or fetches, and a
|
|
27
|
+
// project that has never used worktrees has exactly one branch with any
|
|
28
|
+
// commits — `git log --all` degenerates to `git log` for it, so this
|
|
29
|
+
// function is a strict no-op there (acceptance criterion: zero behavior
|
|
30
|
+
// change for a project that never opened a worktree).
|
|
31
|
+
//
|
|
32
|
+
// ── the ambiguous-commit-message case: a documented, deliberate gap ────
|
|
33
|
+
//
|
|
34
|
+
// rebuild.mjs#markCompletedTasks resolves a commit_message shared by more
|
|
35
|
+
// than one task (the linear-chain core case — authored/adopted, no
|
|
36
|
+
// `{module}` axis) via an ordering+consumption fixpoint walk over
|
|
37
|
+
// `dependencies` rows, mutating `tasks.status` directly as it goes. That
|
|
38
|
+
// walk is inseparable from the DB write path it drives (UPDATE statements
|
|
39
|
+
// interleaved with the fixpoint, `reconciledTaskIds` seeded in up front,
|
|
40
|
+
// re-open-on-failed-claim at the end) and rebuild.mjs is explicitly out of
|
|
41
|
+
// scope for this change — re-deriving its `positionOf`/`available`/
|
|
42
|
+
// `ranAfterPrerequisites` machinery here as a read-only, no-mutation
|
|
43
|
+
// sibling would either (a) silently drift from the real algorithm the
|
|
44
|
+
// first time either copy changes, which is exactly the "three divergent
|
|
45
|
+
// implementations" this file exists to avoid, or (b) require touching
|
|
46
|
+
// rebuild.mjs to extract a shared core, which the task constraints forbid.
|
|
47
|
+
//
|
|
48
|
+
// So: a task whose `commit_message` is NOT unique across the whole graph
|
|
49
|
+
// (the ambiguous / linear-chain case) is left OUT of `subjectsAllBranches`
|
|
50
|
+
// entirely by this module — see `buildCrossBranchIndex`'s filtering below.
|
|
51
|
+
// Every caller (claim.mjs, ready.mjs indirectly, status.mjs, reconcile.mjs)
|
|
52
|
+
// then treats "not present in the index" as "can't determine — leave
|
|
53
|
+
// blocked", the safe direction: a cross-worktree dependency this module
|
|
54
|
+
// can't resolve stays exactly as blocked as it already was, never
|
|
55
|
+
// incorrectly unblocked. The module-axis case — the overwhelmingly common
|
|
56
|
+
// one, since it's what `full-stack-app`/`pwa-app`/`landing-page` compile —
|
|
57
|
+
// has a `commit_message` unique to each task by construction (the layer's
|
|
58
|
+
// `commit` template interpolates `{module}`) and is fully solved.
|
|
59
|
+
import { execSync } from 'node:child_process';
|
|
60
|
+
|
|
61
|
+
// Memoized per `git log --all` call: a Map from commit subject to the
|
|
62
|
+
// number of times it occurs, newest-history-order irrelevant here (unlike
|
|
63
|
+
// rebuild.mjs's positional Map, this module never needs to order two
|
|
64
|
+
// candidate commits against each other, since it only ever answers for the
|
|
65
|
+
// unambiguous case where any one occurrence is as good as any other).
|
|
66
|
+
// Re-running `git log --all` per call rather than caching across calls:
|
|
67
|
+
// every entry point here (claim, ready, status, reconcile) is invoked at
|
|
68
|
+
// most once per CLI process, so there is no repeated-call cost to amortize
|
|
69
|
+
// within a run, and caching across separate `hedgehog` invocations would
|
|
70
|
+
// risk serving a stale answer to a long-lived process (the graph server)
|
|
71
|
+
// after a sibling worktree commits.
|
|
72
|
+
function subjectCountsFrom(gitArgs) {
|
|
73
|
+
let output;
|
|
74
|
+
try {
|
|
75
|
+
output = execSync(`git log ${gitArgs} --topo-order --format=%H%x00%s`, { encoding: 'utf8' });
|
|
76
|
+
} catch {
|
|
77
|
+
// No commits reachable from the requested ref set yet (a brand-new
|
|
78
|
+
// repo, or — for the current-branch-only call — a worktree whose
|
|
79
|
+
// branch predates its very first commit). Every subject is absent,
|
|
80
|
+
// which is the correct, honest answer.
|
|
81
|
+
return new Map();
|
|
82
|
+
}
|
|
83
|
+
const counts = new Map();
|
|
84
|
+
for (const line of output.split('\n')) {
|
|
85
|
+
if (!line) continue;
|
|
86
|
+
const [, subject] = line.split('\0');
|
|
87
|
+
if (subject === undefined) continue;
|
|
88
|
+
counts.set(subject, (counts.get(subject) ?? 0) + 1);
|
|
89
|
+
}
|
|
90
|
+
return counts;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function loadAllBranchesSubjectCounts() {
|
|
94
|
+
return subjectCountsFrom('--all');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The current branch's own subject counts — i.e. exactly what
|
|
98
|
+
// rebuild.mjs#loadCommitSubjects would see (membership only; this module
|
|
99
|
+
// never needs commit position, only "did this subject occur here at
|
|
100
|
+
// all"). Used by buildCrossBranchIndex below to detect the one case
|
|
101
|
+
// cross-branch crediting must refuse: a task reopened via the Correction
|
|
102
|
+
// Protocol (claim.mjs#reopenTask) after an EARLIER commit with the same
|
|
103
|
+
// subject already landed on this very branch. That earlier commit is
|
|
104
|
+
// exactly as visible via `--all` as any genuine cross-branch completion —
|
|
105
|
+
// nothing about the subject string distinguishes "this is a fresh
|
|
106
|
+
// completion on another branch" from "this is the stale completion this
|
|
107
|
+
// branch itself already reset". A task whose current DB status is not
|
|
108
|
+
// `complete` but whose commit_message already appears on THIS branch is
|
|
109
|
+
// therefore excluded from resolvableMessages entirely: crediting it would
|
|
110
|
+
// resurrect a completion this worktree's own history has already
|
|
111
|
+
// superseded, which is worse than the safe "leave it blocked, the
|
|
112
|
+
// dependency isn't obviously satisfied" default this whole module commits
|
|
113
|
+
// to elsewhere.
|
|
114
|
+
function loadCurrentBranchSubjectCounts() {
|
|
115
|
+
return subjectCountsFrom('');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Builds the set of commit_messages this module can safely credit as
|
|
119
|
+
// "happened somewhere in this repository's visible history" — i.e. every
|
|
120
|
+
// subject that occurs in `git log --all` AND belongs, in `db`, to exactly
|
|
121
|
+
// one task. A subject occurring for two distinct reasons (two different
|
|
122
|
+
// tasks in this graph legitimately share a commit_message — the ambiguous
|
|
123
|
+
// linear-chain case) is deliberately excluded even though it does appear
|
|
124
|
+
// in history, per the module-header comment above: this function cannot
|
|
125
|
+
// tell which of the sharing tasks a given commit actually credits, and
|
|
126
|
+
// guessing is worse than leaving both blocked.
|
|
127
|
+
//
|
|
128
|
+
// `db` is read only for `tasks.commit_message` grouping — never written.
|
|
129
|
+
export function buildCrossBranchIndex(db) {
|
|
130
|
+
const tasks = db.prepare('SELECT id, commit_message, status FROM tasks').all();
|
|
131
|
+
const messageCounts = new Map();
|
|
132
|
+
const statusByMessage = new Map();
|
|
133
|
+
for (const task of tasks) {
|
|
134
|
+
messageCounts.set(task.commit_message, (messageCounts.get(task.commit_message) ?? 0) + 1);
|
|
135
|
+
statusByMessage.set(task.commit_message, task.status);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const subjectCounts = loadAllBranchesSubjectCounts();
|
|
139
|
+
const currentBranchCounts = loadCurrentBranchSubjectCounts();
|
|
140
|
+
const resolvableMessages = new Set();
|
|
141
|
+
for (const [message, taskCount] of messageCounts) {
|
|
142
|
+
if (taskCount !== 1) continue; // ambiguous in THIS graph — see header comment
|
|
143
|
+
const total = subjectCounts.get(message) ?? 0;
|
|
144
|
+
if (total === 0) continue; // not seen on any branch
|
|
145
|
+
const onThisBranch = currentBranchCounts.get(message) ?? 0;
|
|
146
|
+
// Reopen guard (see loadCurrentBranchSubjectCounts): if every
|
|
147
|
+
// occurrence of this subject anywhere (`total`) is already accounted
|
|
148
|
+
// for on THIS branch (`onThisBranch >= total`) and the task is still
|
|
149
|
+
// not `complete` here, there is no occurrence anywhere else to credit
|
|
150
|
+
// — the one this branch already has is exactly the completion a
|
|
151
|
+
// Correction Protocol reopen (claim.mjs#reopenTask) superseded, and
|
|
152
|
+
// `--all` has nothing further to offer. Strictly fewer occurrences on
|
|
153
|
+
// this branch than `total` means at least one occurrence exists on
|
|
154
|
+
// SOME other branch this worktree cannot otherwise see, which is
|
|
155
|
+
// exactly the genuine cross-branch completion this module exists to
|
|
156
|
+
// surface — trusted even when this branch also independently carries
|
|
157
|
+
// its own earlier, now-superseded copy of the same subject.
|
|
158
|
+
if (onThisBranch >= total && statusByMessage.get(message) !== 'complete') {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
resolvableMessages.add(message);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { resolvableMessages };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Returns true if `task` (a `tasks` row carrying `commit_message`, and
|
|
168
|
+
// unique in `db` for that message) can be credited complete from
|
|
169
|
+
// cross-branch history, per the index built above. Callers pass the same
|
|
170
|
+
// `index` across many tasks in one call rather than rebuilding it per
|
|
171
|
+
// task — one `git log --all` per CLI invocation, mirroring
|
|
172
|
+
// rebuild.mjs#loadCommitSubjects's own one-call-per-run shape.
|
|
173
|
+
export function commitMessageExistsAnywhere(task, index) {
|
|
174
|
+
return index.resolvableMessages.has(task.commit_message);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Convenience for a caller that only has a raw commit_message string (no
|
|
178
|
+
// task row) — reconcile.mjs's evidence path doesn't need the
|
|
179
|
+
// per-task-uniqueness guard, since it isn't resolving a *dependency*'s
|
|
180
|
+
// status, only widening the commit window it reads from. Kept separate
|
|
181
|
+
// from commitMessageExistsAnywhere (which is intentionally conservative
|
|
182
|
+
// about ambiguity) so this narrower use doesn't inherit a guard it has no
|
|
183
|
+
// use for.
|
|
184
|
+
export function anyCommitAnywhereWithSubject(subject) {
|
|
185
|
+
return (loadAllBranchesSubjectCounts().get(subject) ?? 0) > 0;
|
|
186
|
+
}
|
package/src/db/debt.mjs
CHANGED
|
@@ -58,14 +58,25 @@ export async function addDebt(db, { taskId, note }, notesDir = undefined) {
|
|
|
58
58
|
return { id: Number(result.lastInsertRowid), taskId, note };
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
// Every debt row, oldest first, optionally narrowed to one task.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
// Every debt row, oldest first, optionally narrowed to one task. Resolved
|
|
62
|
+
// rows are excluded by default — `debt list` is meant to answer "what's
|
|
63
|
+
// still open", the same way `reconcile list` never re-surfaces a task once
|
|
64
|
+
// it has closed — and included when `includeResolved` is set (`--all`).
|
|
65
|
+
export function listDebt(db, taskId, { includeResolved = false } = {}) {
|
|
66
|
+
const conditions = [];
|
|
67
|
+
const params = [];
|
|
68
|
+
if (taskId) {
|
|
69
|
+
conditions.push('task_id = ?');
|
|
70
|
+
params.push(taskId);
|
|
71
|
+
}
|
|
72
|
+
if (!includeResolved) conditions.push('resolved_at IS NULL');
|
|
73
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
65
74
|
try {
|
|
66
75
|
return db
|
|
67
76
|
.prepare(
|
|
68
|
-
`SELECT id, task_id AS taskId, note, logged_at AS loggedAt
|
|
77
|
+
`SELECT id, task_id AS taskId, note, logged_at AS loggedAt,
|
|
78
|
+
resolved_at AS resolvedAt, resolved_reason AS resolvedReason
|
|
79
|
+
FROM debt ${where} ORDER BY id ASC`,
|
|
69
80
|
)
|
|
70
81
|
.all(...params);
|
|
71
82
|
} catch {
|
|
@@ -73,3 +84,54 @@ export function listDebt(db, taskId) {
|
|
|
73
84
|
return [];
|
|
74
85
|
}
|
|
75
86
|
}
|
|
87
|
+
|
|
88
|
+
// Count of open (unresolved) debt across the whole graph — the one-line
|
|
89
|
+
// figure `hedgehog next`/`hedgehog claim` surface so debt doesn't
|
|
90
|
+
// accumulate silently with nothing prompting a look at it.
|
|
91
|
+
export function openDebtCount(db) {
|
|
92
|
+
try {
|
|
93
|
+
const row = db.prepare('SELECT COUNT(*) AS n FROM debt WHERE resolved_at IS NULL').get();
|
|
94
|
+
return row.n;
|
|
95
|
+
} catch {
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Resolves one debt row by id: marks it resolved in the DB and writes the
|
|
101
|
+
// committed record behind it — same ordering as addDebt (file before row)
|
|
102
|
+
// and the same reason: if the write fails, nothing has been resolved on a
|
|
103
|
+
// fact that would not survive the next rebuild.
|
|
104
|
+
//
|
|
105
|
+
// The committed note is keyed by the debt row's own (task_id, note,
|
|
106
|
+
// logged_at) rather than its DB `id`, because that id is a fresh
|
|
107
|
+
// autoincrement every time rebuild.mjs#replayNotes re-inserts debt rows —
|
|
108
|
+
// it is not stable across a rebuild, so a resolution referencing it would
|
|
109
|
+
// point at nothing once replayed. The triple already committed for the
|
|
110
|
+
// debt note itself is the one part of its identity that *is* stable.
|
|
111
|
+
export async function resolveDebt(db, { debtId, reason }, notesDir = undefined) {
|
|
112
|
+
applySchema(db);
|
|
113
|
+
|
|
114
|
+
if (!debtId) throw new Error('debt resolve requires a debt id');
|
|
115
|
+
if (!reason) throw new Error('debt resolve requires a --reason');
|
|
116
|
+
|
|
117
|
+
const row = db
|
|
118
|
+
.prepare('SELECT id, task_id AS taskId, note, logged_at AS loggedAt, resolved_at AS resolvedAt FROM debt WHERE id = ?')
|
|
119
|
+
.get(debtId);
|
|
120
|
+
if (!row) throw new Error(`no such debt: #${debtId}`);
|
|
121
|
+
if (row.resolvedAt) throw new Error(`debt #${debtId} is already resolved`);
|
|
122
|
+
|
|
123
|
+
const resolvedAt = new Date().toISOString();
|
|
124
|
+
await appendNote(
|
|
125
|
+
row.taskId,
|
|
126
|
+
{ kind: 'debt-resolve', resolves: row.loggedAt, reason, loggedAt: resolvedAt },
|
|
127
|
+
notesDir,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
db.prepare('UPDATE debt SET resolved_at = ?, resolved_reason = ? WHERE id = ?').run(
|
|
131
|
+
resolvedAt,
|
|
132
|
+
reason,
|
|
133
|
+
debtId,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return { id: row.id, taskId: row.taskId, note: row.note, resolvedAt, resolvedReason: reason };
|
|
137
|
+
}
|
package/src/db/next.mjs
CHANGED
|
@@ -105,7 +105,9 @@ function loadInheritedDebt(db, taskId) {
|
|
|
105
105
|
try {
|
|
106
106
|
return db
|
|
107
107
|
.prepare(
|
|
108
|
-
`SELECT task_id AS taskId, note FROM debt
|
|
108
|
+
`SELECT task_id AS taskId, note FROM debt
|
|
109
|
+
WHERE task_id IN (${placeholders}) AND resolved_at IS NULL
|
|
110
|
+
ORDER BY id ASC`,
|
|
109
111
|
)
|
|
110
112
|
.all(...upstream);
|
|
111
113
|
} catch {
|
|
@@ -113,6 +115,20 @@ function loadInheritedDebt(db, taskId) {
|
|
|
113
115
|
}
|
|
114
116
|
}
|
|
115
117
|
|
|
118
|
+
// Total open (unresolved) debt across the whole graph — not just this
|
|
119
|
+
// task's ancestors — so a packet can say plainly that debt exists even
|
|
120
|
+
// where none of it happens to be inherited here. Tolerates a `debt` table
|
|
121
|
+
// or `resolved_at` column that doesn't exist yet, the same way
|
|
122
|
+
// loadInheritedDebt does.
|
|
123
|
+
function loadOpenDebtCount(db) {
|
|
124
|
+
try {
|
|
125
|
+
const row = db.prepare('SELECT COUNT(*) AS n FROM debt WHERE resolved_at IS NULL').get();
|
|
126
|
+
return row.n;
|
|
127
|
+
} catch {
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
116
132
|
// Decisions declared by anything this task inherits from (see
|
|
117
133
|
// decision.mjs) — same upstream walk as loadInheritedDebt, same tolerance
|
|
118
134
|
// for a `decisions` table that doesn't exist yet on an older graph.
|
|
@@ -181,6 +197,7 @@ function assemblePacket(db, task) {
|
|
|
181
197
|
const incompleteDeps = incompleteDependencies(db, task.id);
|
|
182
198
|
const inheritedDebt = loadInheritedDebt(db, task.id);
|
|
183
199
|
const inheritedDecisions = loadInheritedDecisions(db, task.id);
|
|
200
|
+
const openDebtCount = loadOpenDebtCount(db);
|
|
184
201
|
|
|
185
202
|
return {
|
|
186
203
|
task,
|
|
@@ -190,6 +207,7 @@ function assemblePacket(db, task) {
|
|
|
190
207
|
incompleteDeps,
|
|
191
208
|
inheritedDebt,
|
|
192
209
|
inheritedDecisions,
|
|
210
|
+
openDebtCount,
|
|
193
211
|
};
|
|
194
212
|
}
|
|
195
213
|
|
|
@@ -484,6 +502,7 @@ export function formatPacket(packet, statusLine, coreId = null, exists = null) {
|
|
|
484
502
|
incompleteDeps = [],
|
|
485
503
|
inheritedDebt = [],
|
|
486
504
|
inheritedDecisions = [],
|
|
505
|
+
openDebtCount = 0,
|
|
487
506
|
} = packet;
|
|
488
507
|
const scopeGlobs = JSON.parse(task.scope_globs);
|
|
489
508
|
const firstArrival = firstArrivalPackages(task, exists);
|
|
@@ -521,6 +540,11 @@ export function formatPacket(packet, statusLine, coreId = null, exists = null) {
|
|
|
521
540
|
lines.push(` ! ${entry.taskId} ${entry.note}`);
|
|
522
541
|
}
|
|
523
542
|
}
|
|
543
|
+
if (openDebtCount > 0) {
|
|
544
|
+
lines.push(
|
|
545
|
+
` ${openDebtCount} open debt note(s) across the whole graph — \`hedgehog debt list --all\` to see them all`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
524
548
|
lines.push('');
|
|
525
549
|
lines.push('INHERITED DECISIONS');
|
|
526
550
|
if (inheritedDecisions.length === 0) {
|
package/src/db/notes.mjs
CHANGED
|
@@ -45,12 +45,21 @@ function validateNotesFile(record, path) {
|
|
|
45
45
|
if (entry === null || typeof entry !== 'object') {
|
|
46
46
|
throw new Error(`${path}: notes file "${task}" has a non-object entry in notes`);
|
|
47
47
|
}
|
|
48
|
-
if (entry.kind !== 'debt' && entry.kind !== 'decision') {
|
|
48
|
+
if (entry.kind !== 'debt' && entry.kind !== 'decision' && entry.kind !== 'debt-resolve') {
|
|
49
49
|
throw new Error(
|
|
50
|
-
`${path}: notes file "${task}" has an entry with kind "${entry.kind}" — expected "debt" or "
|
|
50
|
+
`${path}: notes file "${task}" has an entry with kind "${entry.kind}" — expected "debt", "decision", or "debt-resolve"`,
|
|
51
51
|
);
|
|
52
52
|
}
|
|
53
|
-
if (
|
|
53
|
+
if (entry.kind === 'debt-resolve') {
|
|
54
|
+
if (!entry.resolves || typeof entry.resolves !== 'string') {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`${path}: notes file "${task}" has a "debt-resolve" entry with no "resolves" (the logged_at of the debt note it closes)`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (!entry.reason || typeof entry.reason !== 'string') {
|
|
60
|
+
throw new Error(`${path}: notes file "${task}" has a "debt-resolve" entry with no "reason"`);
|
|
61
|
+
}
|
|
62
|
+
} else if (!entry.note || typeof entry.note !== 'string') {
|
|
54
63
|
throw new Error(`${path}: notes file "${task}" has an entry with no "note" (string)`);
|
|
55
64
|
}
|
|
56
65
|
if (!entry.logged_at || typeof entry.logged_at !== 'string') {
|
|
@@ -92,7 +101,7 @@ export async function loadNotes(notesDir = NOTES_DIR) {
|
|
|
92
101
|
// never leaves a half-written file for loadNotes to trip on —
|
|
93
102
|
// reconcile.mjs#writeReconciledFile's pattern, applied to a file that
|
|
94
103
|
// grows instead of one written once.
|
|
95
|
-
export async function appendNote(taskId, { kind, note, loggedAt }, notesDir = NOTES_DIR) {
|
|
104
|
+
export async function appendNote(taskId, { kind, note, loggedAt, resolves, reason }, notesDir = NOTES_DIR) {
|
|
96
105
|
const path = notesFilePath(taskId, notesDir);
|
|
97
106
|
|
|
98
107
|
let existing = [];
|
|
@@ -103,9 +112,17 @@ export async function appendNote(taskId, { kind, note, loggedAt }, notesDir = NO
|
|
|
103
112
|
if (!err || err.code !== 'ENOENT') throw err;
|
|
104
113
|
}
|
|
105
114
|
|
|
115
|
+
// `debt-resolve` entries carry `resolves`/`reason` instead of `note` —
|
|
116
|
+
// omit `note` entirely rather than writing it as undefined/null, so a
|
|
117
|
+
// resolve entry's shape matches what validateNotesFile expects back.
|
|
118
|
+
const entry =
|
|
119
|
+
kind === 'debt-resolve'
|
|
120
|
+
? { kind, resolves, reason, logged_at: loggedAt }
|
|
121
|
+
: { kind, note, logged_at: loggedAt };
|
|
122
|
+
|
|
106
123
|
const record = {
|
|
107
124
|
task: taskId.toUpperCase(),
|
|
108
|
-
notes: [...existing,
|
|
125
|
+
notes: [...existing, entry],
|
|
109
126
|
};
|
|
110
127
|
|
|
111
128
|
await mkdir(notesDir, { recursive: true });
|
package/src/db/rebuild.mjs
CHANGED
|
@@ -110,23 +110,40 @@ function replayNotes(db, notesByTask) {
|
|
|
110
110
|
const insertDecision = db.prepare(
|
|
111
111
|
'INSERT INTO decisions (task_id, note, logged_at) VALUES (?, ?, ?)',
|
|
112
112
|
);
|
|
113
|
+
const resolveDebtRow = db.prepare(
|
|
114
|
+
'UPDATE debt SET resolved_at = ?, resolved_reason = ? WHERE task_id = ? AND logged_at = ? AND resolved_at IS NULL',
|
|
115
|
+
);
|
|
113
116
|
|
|
114
117
|
const orphaned = [];
|
|
118
|
+
// Two passes: every `debt`/`decision` entry inserted first, then every
|
|
119
|
+
// `debt-resolve` entry applied — a resolve entry can appear anywhere
|
|
120
|
+
// after its debt entry in the same file, but the row it references must
|
|
121
|
+
// already exist for the UPDATE to find it.
|
|
115
122
|
for (const [taskId, notes] of notesByTask) {
|
|
116
123
|
if (taskExists.get(taskId) === undefined) {
|
|
117
124
|
for (const entry of notes) {
|
|
118
|
-
orphaned.push({ kind: entry.kind, taskId, note: entry.note });
|
|
125
|
+
if (entry.kind !== 'debt-resolve') orphaned.push({ kind: entry.kind, taskId, note: entry.note });
|
|
119
126
|
}
|
|
120
127
|
continue;
|
|
121
128
|
}
|
|
122
129
|
for (const entry of notes) {
|
|
123
130
|
if (entry.kind === 'debt') {
|
|
124
131
|
insertDebt.run(taskId, entry.note, entry.logged_at);
|
|
125
|
-
} else {
|
|
132
|
+
} else if (entry.kind === 'decision') {
|
|
126
133
|
insertDecision.run(taskId, entry.note, entry.logged_at);
|
|
127
134
|
}
|
|
128
135
|
}
|
|
129
136
|
}
|
|
137
|
+
for (const [taskId, notes] of notesByTask) {
|
|
138
|
+
if (taskExists.get(taskId) === undefined) continue;
|
|
139
|
+
for (const entry of notes) {
|
|
140
|
+
if (entry.kind !== 'debt-resolve') continue;
|
|
141
|
+
const result = resolveDebtRow.run(entry.logged_at, entry.reason, taskId, entry.resolves);
|
|
142
|
+
if (result.changes === 0) {
|
|
143
|
+
orphaned.push({ kind: entry.kind, taskId, note: `resolve for ${entry.resolves}` });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
130
147
|
return orphaned;
|
|
131
148
|
}
|
|
132
149
|
|
|
@@ -311,7 +328,7 @@ function loadCommitSubjects() {
|
|
|
311
328
|
// and applying either check there would cascade that gap through the
|
|
312
329
|
// whole chain and reset already-built modules that have no ambiguity to
|
|
313
330
|
// resolve in the first place.
|
|
314
|
-
function markCompletedTasks(db, commitSubjects) {
|
|
331
|
+
function markCompletedTasks(db, commitSubjects, reconciledTaskIds = new Set()) {
|
|
315
332
|
const tasks = db.prepare('SELECT id, module, commit_message FROM tasks').all();
|
|
316
333
|
const prerequisites = new Map(tasks.map((t) => [t.id, []]));
|
|
317
334
|
for (const d of db.prepare('SELECT task_id, depends_on_task_id FROM dependencies').all()) {
|
|
@@ -329,7 +346,16 @@ function markCompletedTasks(db, commitSubjects) {
|
|
|
329
346
|
// resolved separately below, since "the" matching commit for its
|
|
330
347
|
// subject isn't decided until a claim succeeds.
|
|
331
348
|
const positionOf = new Map();
|
|
332
|
-
|
|
349
|
+
// Seeded with every reconciled task up front, before the ambiguous-task
|
|
350
|
+
// fixpoint walk below ever runs: a reconciled task was closed precisely
|
|
351
|
+
// because it has no commit of its own to match in commitSubjects (a
|
|
352
|
+
// hand-written commit predating the reconciliation, or none at all), so
|
|
353
|
+
// without this seed it can never enter `complete` from inside this
|
|
354
|
+
// function — leaving every task that depends on it, directly or
|
|
355
|
+
// transitively, stuck `planned` forever, since replayReconciliations
|
|
356
|
+
// (the only other path that would mark it complete) runs after this
|
|
357
|
+
// function returns.
|
|
358
|
+
const complete = new Set(reconciledTaskIds);
|
|
333
359
|
const ambiguousTasks = [];
|
|
334
360
|
for (const task of tasks) {
|
|
335
361
|
if (task.module === CORE_MODULE || isAmbiguous(task)) {
|
|
@@ -552,7 +578,18 @@ export async function rebuildDb(
|
|
|
552
578
|
planTasks(db, core, overrides, { excludeIntentIds: openWorktreeIntentIds });
|
|
553
579
|
|
|
554
580
|
const commitSubjects = loadCommitSubjects();
|
|
555
|
-
|
|
581
|
+
// Reconciled task ids are seeded into markCompletedTasks's own `complete`
|
|
582
|
+
// set before its ambiguous-task fixpoint walk runs, not after: a
|
|
583
|
+
// reconciled task's whole reason for being in .hedgehog/reconciled/ is
|
|
584
|
+
// that it has no commit of its own in commitSubjects to match, so
|
|
585
|
+
// without this it can never satisfy `every prerequisite is complete` for
|
|
586
|
+
// whatever depends on it — see markCompletedTasks's own comment on
|
|
587
|
+
// `reconciledTaskIds` for the full mechanics. replayReconciliations
|
|
588
|
+
// (below) still runs afterward to write the provenance note and cover
|
|
589
|
+
// any reconciled task markCompletedTasks doesn't touch (module = CORE_MODULE
|
|
590
|
+
// edge cases aside, every reconciled id ends up here either way).
|
|
591
|
+
const reconciledTaskIds = new Set(reconciliations.keys());
|
|
592
|
+
const tasksMarkedComplete = markCompletedTasks(db, commitSubjects, reconciledTaskIds);
|
|
556
593
|
|
|
557
594
|
const tasksReconciled = replayReconciliations(db, reconciliations);
|
|
558
595
|
const orphanedReconciled = orphanedReconciliations(db, reconciliations);
|
package/src/db/reconcile.mjs
CHANGED
|
@@ -15,6 +15,16 @@
|
|
|
15
15
|
// — which every loop skill forbids, because the graph is derived and
|
|
16
16
|
// gitignored and the patch dies at the next rebuild.
|
|
17
17
|
//
|
|
18
|
+
// `commitsSince`'s scan window is `git log --all`, not just the current
|
|
19
|
+
// branch's `HEAD` — a hand-written commit that satisfies an open task's
|
|
20
|
+
// scope can just as well sit on trunk or a sibling worktree's branch as on
|
|
21
|
+
// this checkout's own branch, and `gatherEvidence` has to be able to
|
|
22
|
+
// propose reconciling that task from whichever worktree happens to run
|
|
23
|
+
// `hedgehog reconcile`, not only from the one that made the commit. See
|
|
24
|
+
// `commitsSince` below for why the *start* of that window
|
|
25
|
+
// (`newestGraphCommit`) stays scoped to the current branch's own history
|
|
26
|
+
// rather than widening the same way.
|
|
27
|
+
//
|
|
18
28
|
// Four properties, each load-bearing:
|
|
19
29
|
//
|
|
20
30
|
// - **It proposes; it never asserts.** `gatherEvidence` reports which
|
|
@@ -179,6 +189,19 @@ export function orphanedReconciliations(db, reconciliations) {
|
|
|
179
189
|
// Returns null when no commit matches any task's message (nothing has
|
|
180
190
|
// been verified yet) — the caller then reads the whole history, which is
|
|
181
191
|
// the honest window for a project whose loop has not closed a task.
|
|
192
|
+
//
|
|
193
|
+
// Scoped to the current branch's own `git log` (not `--all`), deliberately
|
|
194
|
+
// unlike `commitsSince` below: this names the boundary of THIS worktree's
|
|
195
|
+
// own graph-written history — the newest commit that credits some task as
|
|
196
|
+
// far as this checkout's own branch has progressed — and that is a fact
|
|
197
|
+
// about this branch specifically, not about the repository as a whole. A
|
|
198
|
+
// sibling branch can be ahead or behind this one in ways that have nothing
|
|
199
|
+
// to do with where this worktree's own evidence window should start; using
|
|
200
|
+
// `--all` here would let a commit on an unrelated, unmerged branch move
|
|
201
|
+
// this worktree's own "since" boundary out from under it. Only the
|
|
202
|
+
// forward-scanning window (commitsSince) needs the wider net, to catch a
|
|
203
|
+
// hand-written commit that landed elsewhere; the floor it scans from stays
|
|
204
|
+
// anchored to what this branch itself has already credited.
|
|
182
205
|
function newestGraphCommit(db) {
|
|
183
206
|
const messages = new Set(
|
|
184
207
|
db.prepare('SELECT commit_message FROM tasks').all().map((r) => r.commit_message),
|
|
@@ -196,13 +219,30 @@ function newestGraphCommit(db) {
|
|
|
196
219
|
|
|
197
220
|
// Every commit after `sinceSha` (exclusive), newest first, with the paths
|
|
198
221
|
// it touched. `sinceSha` null means the whole history.
|
|
222
|
+
//
|
|
223
|
+
// The window's floor (`sinceSha`, from `newestGraphCommit` above) is
|
|
224
|
+
// deliberately still scoped to whatever the caller resolved it against —
|
|
225
|
+
// it names a specific commit, and `<sha>..` is unambiguous regardless of
|
|
226
|
+
// which branch's `git log` produced that sha. It is only the window's
|
|
227
|
+
// *ceiling* that widens here: `--all` in place of the bare `HEAD` ref,
|
|
228
|
+
// so the scan reaches every commit on every local branch newer than
|
|
229
|
+
// `sinceSha`, not only the ones that happen to be reachable from this
|
|
230
|
+
// worktree's own checked-out branch. A hand-written commit sitting on
|
|
231
|
+
// trunk while this runs inside an intent's own worktree (or vice versa)
|
|
232
|
+
// is exactly the case `--all` exists to reach — without it, `hedgehog
|
|
233
|
+
// reconcile` run from worktree B can never see a commit that only landed
|
|
234
|
+
// on worktree A's branch or on trunk, which is the same blind spot
|
|
235
|
+
// `commitMessageExistsAnywhere` (crossBranch.mjs) exists to close for
|
|
236
|
+
// `claim`/`ready`/`status`. A project that has never opened a worktree has
|
|
237
|
+
// exactly one branch with any commits, so `--all` and `HEAD` name the
|
|
238
|
+
// same set there and this is a no-op for it.
|
|
199
239
|
function commitsSince(sinceSha) {
|
|
200
|
-
const range = sinceSha ? [`${sinceSha}
|
|
240
|
+
const range = sinceSha ? [`${sinceSha}..`, '--all'] : ['--all'];
|
|
201
241
|
let output;
|
|
202
242
|
try {
|
|
203
243
|
output = git(['log', '--topo-order', '--name-only', '--format=%x01%H%x00%s', ...range]);
|
|
204
244
|
} catch {
|
|
205
|
-
// An empty repository has no
|
|
245
|
+
// An empty repository has no ref to log at all.
|
|
206
246
|
return [];
|
|
207
247
|
}
|
|
208
248
|
|
package/src/db/schema.mjs
CHANGED
|
@@ -103,10 +103,12 @@ CREATE TABLE IF NOT EXISTS verifications (
|
|
|
103
103
|
-- every task that (transitively) depends on task_id, so the limitation
|
|
104
104
|
-- travels down the chain the same way the dependency does.
|
|
105
105
|
CREATE TABLE IF NOT EXISTS debt (
|
|
106
|
-
id
|
|
107
|
-
task_id
|
|
108
|
-
note
|
|
109
|
-
logged_at
|
|
106
|
+
id INTEGER PRIMARY KEY,
|
|
107
|
+
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
108
|
+
note TEXT NOT NULL,
|
|
109
|
+
logged_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
110
|
+
resolved_at TEXT,
|
|
111
|
+
resolved_reason TEXT
|
|
110
112
|
);
|
|
111
113
|
|
|
112
114
|
-- Declared decision: a note one task leaves for the tasks that inherit
|
|
@@ -176,7 +178,7 @@ export function ensureTaskColumns(db) {
|
|
|
176
178
|
// hand-set past what MIGRATIONS actually covers, since runMigrations
|
|
177
179
|
// trusts this number to mean "every migration through this version has
|
|
178
180
|
// run."
|
|
179
|
-
export const CURRENT_SCHEMA_VERSION =
|
|
181
|
+
export const CURRENT_SCHEMA_VERSION = 3;
|
|
180
182
|
|
|
181
183
|
// Forward migrations, applied in order to bring a graph's user_version up
|
|
182
184
|
// to CURRENT_SCHEMA_VERSION. Unlike the CREATE TABLE IF NOT EXISTS /
|
|
@@ -209,6 +211,18 @@ const MIGRATIONS = [
|
|
|
209
211
|
`);
|
|
210
212
|
},
|
|
211
213
|
},
|
|
214
|
+
{
|
|
215
|
+
version: 3,
|
|
216
|
+
// A `debt` row with no `resolved_at` reads back as unresolved, so an
|
|
217
|
+
// existing graph's rows default to open the moment this column exists.
|
|
218
|
+
migrate: (db) => {
|
|
219
|
+
const existing = new Set(db.prepare('PRAGMA table_info(debt)').all().map((row) => row.name));
|
|
220
|
+
if (!existing.has('resolved_at')) db.exec('ALTER TABLE debt ADD COLUMN resolved_at TEXT');
|
|
221
|
+
if (!existing.has('resolved_reason')) {
|
|
222
|
+
db.exec('ALTER TABLE debt ADD COLUMN resolved_reason TEXT');
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
},
|
|
212
226
|
];
|
|
213
227
|
|
|
214
228
|
// Brings a graph's `PRAGMA user_version` up to CURRENT_SCHEMA_VERSION,
|
package/src/db/status.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { RECONCILED_DIR, RECONCILED_NOTE_PREFIX } from './reconcile.mjs';
|
|
|
19
19
|
import { formatMissingRequirements } from './requires.mjs';
|
|
20
20
|
import { readyTasks, heldBackReason } from './ready.mjs';
|
|
21
21
|
import { worktreeStatus } from './worktree.mjs';
|
|
22
|
+
import { findClaimableTasks } from './claim.mjs';
|
|
22
23
|
|
|
23
24
|
// The task lifecycle in order, matching the tasks CHECK constraint in
|
|
24
25
|
// schema.mjs exactly — every status the engine can write, and no others.
|
|
@@ -35,17 +36,16 @@ const TASK_STATUSES = [
|
|
|
35
36
|
// `exclusive DESC` matches claim.mjs's CLAIMABLE_TASKS_SQL and next.mjs's
|
|
36
37
|
// READY_TASK_SQL, so this list is in the order those two would actually
|
|
37
38
|
// take the work — see claim.mjs for why exclusive sorts first.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
`;
|
|
39
|
+
//
|
|
40
|
+
// Reuses claim.mjs#findClaimableTasks rather than a duplicate SQL string —
|
|
41
|
+
// this used to run its own local-DB-only query, which meant `hedgehog
|
|
42
|
+
// status` and `hedgehog claim` could disagree about which tasks are ready
|
|
43
|
+
// the moment a dependency's completing commit exists only cross-branch
|
|
44
|
+
// (crossBranch.mjs): a task the fan-out would happily claim would still
|
|
45
|
+
// show as blocked here. findClaimableTasks already returns exactly the
|
|
46
|
+
// same candidate set `hedgehog claim`'s fan-out and `hedgehog ready` use,
|
|
47
|
+
// merged and re-sorted, so this file no longer needs its own copy of the
|
|
48
|
+
// query.
|
|
49
49
|
|
|
50
50
|
const IN_FLIGHT_TASKS_SQL = `
|
|
51
51
|
SELECT t.* FROM tasks t
|
|
@@ -88,7 +88,7 @@ function countTasksByStatus(db) {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
function loadReadyTasks(db) {
|
|
91
|
-
return db
|
|
91
|
+
return findClaimableTasks(db);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
// Tasks that need a human/agent decision before the graph can move again
|
package/src/db/verify.mjs
CHANGED
|
@@ -62,7 +62,7 @@ import { withCommitLock, LOCK_PATH } from './commitLock.mjs';
|
|
|
62
62
|
import { reapExpiredLeases, pathFingerprint } from './claim.mjs';
|
|
63
63
|
import { ensureTaskColumns } from './schema.mjs';
|
|
64
64
|
import { FRICTION_DIR } from './friction.mjs';
|
|
65
|
-
import { OVERRIDES_DIR } from './overrides.mjs';
|
|
65
|
+
import { OVERRIDES_DIR, composeScope } from './overrides.mjs';
|
|
66
66
|
import { RECONCILED_DIR } from './reconcile.mjs';
|
|
67
67
|
import { INTENTS_DIR } from './intent.mjs';
|
|
68
68
|
import { COMMUNITY_PATH } from './community.mjs';
|
|
@@ -463,10 +463,23 @@ function claimForVerify(db, taskId, owner) {
|
|
|
463
463
|
// `completedIntent` is the intent row (id/goal/outcome) when this task
|
|
464
464
|
// was the last one of its intent, else null — the CLI prints it back as
|
|
465
465
|
// an INTENT CHECK.
|
|
466
|
-
|
|
466
|
+
//
|
|
467
|
+
// `overrides` (a Map from loadOverrides(), same shape recompileTasks and
|
|
468
|
+
// detectDrift already take from their own callers) composes live into
|
|
469
|
+
// this task's scope for both gates below, rather than trusting
|
|
470
|
+
// task.scope_globs alone — that DB column is only ever widened by a
|
|
471
|
+
// `hedgehog plan --recompile` the caller may not have run yet, so an
|
|
472
|
+
// override written after claim but before recompile would otherwise
|
|
473
|
+
// flag its own newly-allowed paths as scope violations. verifyTask
|
|
474
|
+
// itself stays synchronous (loadOverrides is async, reading a
|
|
475
|
+
// directory); the CLI already awaits loadOverrides() for
|
|
476
|
+
// planRecompileCommand, so it does the same here and passes the
|
|
477
|
+
// resulting Map in — defaulting to an empty Map keeps every other/test
|
|
478
|
+
// caller's behavior unchanged.
|
|
479
|
+
export function verifyTask(db, taskId, owner, overrides = new Map()) {
|
|
467
480
|
const task = claimForVerify(db, taskId, owner);
|
|
468
481
|
|
|
469
|
-
const scopeGlobs = JSON.parse(task.scope_globs);
|
|
482
|
+
const scopeGlobs = JSON.parse(composeScope({ scope_globs: task.scope_globs }, taskId, overrides).scope_globs);
|
|
470
483
|
// Gate 1 runs inside the commit lock: the diff has to see a working
|
|
471
484
|
// tree no other task's commit is landing into mid-read, and the
|
|
472
485
|
// neighbor-scope split has to be computed against a snapshot, not a
|
package/src/db/worktree.mjs
CHANGED
|
@@ -11,6 +11,17 @@
|
|
|
11
11
|
// re-derives trunk's graph from what merged. No DB row ever crosses from
|
|
12
12
|
// one worktree's `.hedgehog/hedgehog.db` to another's, or to trunk's.
|
|
13
13
|
//
|
|
14
|
+
// That invariant is about DB rows, not about what a live command may read
|
|
15
|
+
// from git itself: `hedgehog ready`/`claim`/`status` (claim.mjs) and
|
|
16
|
+
// `hedgehog reconcile` (reconcile.mjs) additionally check whether a
|
|
17
|
+
// dependency's commit exists anywhere reachable from a local ref
|
|
18
|
+
// (crossBranch.mjs's `git log --all`), not only on this worktree's own
|
|
19
|
+
// checked-out branch — so a task completed and committed on trunk or on a
|
|
20
|
+
// sibling worktree's branch is reflected as complete here without any DB
|
|
21
|
+
// row ever having moved. The two facts are independent: this widens what
|
|
22
|
+
// a query is willing to read from git history, never what a write copies
|
|
23
|
+
// between databases.
|
|
24
|
+
//
|
|
14
25
|
// Trigger: task ids are `<intent>-<layer>` (plan.mjs's taskId), so the
|
|
15
26
|
// intent is the natural partition — every task at a layer belongs to
|
|
16
27
|
// exactly one intent, and a layer-boundary trigger would instead fan the
|
|
@@ -113,7 +124,21 @@ export function onHedgehogBranch({ repoRoot = process.cwd() } = {}) {
|
|
|
113
124
|
// `--depends-on` on `hedgehog intent add` never trips this path, full
|
|
114
125
|
// stop. Ordered by priority, id so a repeat `hedgehog plan` run always
|
|
115
126
|
// considers the same intents in the same order.
|
|
116
|
-
|
|
127
|
+
//
|
|
128
|
+
// `abandonedIntentIds` excludes an intent with a committed
|
|
129
|
+
// `.hedgehog/abandoned/<id>.json` record (issue #431): applyAbandonment
|
|
130
|
+
// resets an abandoned intent to `status = 'planned'` by design —
|
|
131
|
+
// abandonment is a separate committed fact, not a status value — but that
|
|
132
|
+
// reset also clears its `intent_dependencies` rows (clearIntentDependencies
|
|
133
|
+
// in this file), and a later `hedgehog intent add` re-declaring the same
|
|
134
|
+
// `--depends-on` (or a rebuild replaying intent files back onto trunk)
|
|
135
|
+
// repopulates that table. Once the now-again-declared dependency
|
|
136
|
+
// completes, this function's own readiness rule alone would read the
|
|
137
|
+
// abandoned intent as eligible again and hand it a fresh worktree — the
|
|
138
|
+
// committed abandonment record is the one place that fact survives, so
|
|
139
|
+
// checking it here is what stops the recompile-and-offer cycle from
|
|
140
|
+
// repeating on every subsequent `hedgehog plan`.
|
|
141
|
+
export function eligibleIntents(db, abandonedIntentIds = new Set()) {
|
|
117
142
|
const intents = db
|
|
118
143
|
.prepare(
|
|
119
144
|
`SELECT * FROM intents WHERE status IN ('proposed','planned') AND id <> '_core'
|
|
@@ -131,6 +156,7 @@ export function eligibleIntents(db) {
|
|
|
131
156
|
);
|
|
132
157
|
|
|
133
158
|
return intents.filter((intent) => {
|
|
159
|
+
if (abandonedIntentIds.has(intent.id)) return false;
|
|
134
160
|
const deps = dependsOnByIntent.get(intent.id);
|
|
135
161
|
if (!deps || deps.length === 0) return false;
|
|
136
162
|
return deps.every((depId) => statusById.get(depId) === 'complete');
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: code-comment-discipline
|
|
3
|
+
description: Apply whenever writing or editing source code in any core's build agent (backend-eng, ui-builder, or equivalent). Governs when a comment belongs at all and what it may say. Default is no comment; a comment is only for the non-obvious. Never restates what the code does, never narrates the task, fix, or conversation that produced it.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Code Comment Discipline
|
|
7
|
+
|
|
8
|
+
Well-named identifiers and clear structure explain what code does. A
|
|
9
|
+
comment earns its place only when it explains something the code cannot:
|
|
10
|
+
a hidden constraint, a non-obvious invariant, a workaround for a specific
|
|
11
|
+
external bug, or behavior that would surprise a reader.
|
|
12
|
+
|
|
13
|
+
## Default: no comment
|
|
14
|
+
|
|
15
|
+
Most lines, functions, and blocks need zero comments. Before writing one,
|
|
16
|
+
check whether the same clarity is reachable by renaming a variable or
|
|
17
|
+
function instead — prefer that over a comment every time.
|
|
18
|
+
|
|
19
|
+
## When a comment is allowed
|
|
20
|
+
|
|
21
|
+
Only for the non-obvious:
|
|
22
|
+
|
|
23
|
+
- A constraint imposed from outside the code (an API's undocumented
|
|
24
|
+
limit, a browser quirk, a platform requirement) that isn't visible at
|
|
25
|
+
the call site.
|
|
26
|
+
- An invariant the code relies on that isn't implied by types or names
|
|
27
|
+
(e.g. "callers must hold the lock before this runs").
|
|
28
|
+
- A deliberate workaround for a specific bug in a dependency, with enough
|
|
29
|
+
detail to know when it's safe to remove.
|
|
30
|
+
- Behavior that looks like a mistake but is intentional.
|
|
31
|
+
|
|
32
|
+
## What a comment must never say
|
|
33
|
+
|
|
34
|
+
- What the code does — that's the identifier's job, not the comment's.
|
|
35
|
+
- Why *this* task needed the change, who asked for it, or which ticket,
|
|
36
|
+
issue, or conversation prompted it.
|
|
37
|
+
- History: "used to be X," "changed from Y," "removed Z," "previously,"
|
|
38
|
+
"now we," or any other before/after narration. A comment states the
|
|
39
|
+
current state and its non-obvious reason only, never how it got there.
|
|
40
|
+
- A restatement of the function/variable name in prose ("increments the
|
|
41
|
+
counter" above `counter++`).
|
|
42
|
+
|
|
43
|
+
## Applying to existing comments
|
|
44
|
+
|
|
45
|
+
When editing a file that already has comments violating these rules,
|
|
46
|
+
remove or rewrite them as part of the same change rather than leaving
|
|
47
|
+
them in place — don't let a nearby edit normalize the pattern.
|