@skyf0xx/hedgehog 6.2.17 → 6.3.0
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 +21 -5
- package/package.json +1 -1
- package/src/db/claim.mjs +116 -5
- package/src/db/crossBranch.mjs +186 -0
- package/src/db/rebuild.mjs +23 -3
- package/src/db/reconcile.mjs +42 -2
- 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
|
@@ -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';
|
|
@@ -1557,11 +1558,21 @@ async function planCommand(args = []) {
|
|
|
1557
1558
|
// by the same rule that got it a worktree in the first place. Without
|
|
1558
1559
|
// this guard, that recursive call would try to create a second,
|
|
1559
1560
|
// colliding worktree for itself instead of just compiling normally.
|
|
1561
|
+
// A committed `.hedgehog/abandoned/<id>.json` record outlives the
|
|
1562
|
+
// `intent_dependencies` reset that comes with it (issue #431) — see
|
|
1563
|
+
// worktree.mjs#eligibleIntents for why the abandonment record, not the
|
|
1564
|
+
// (re-clearable) dependency table, has to be the thing checked there.
|
|
1565
|
+
// Loaded unconditionally (not just under the onHedgehogBranch guard
|
|
1566
|
+
// below) because it also feeds excludeIntentIds just below: an abandoned
|
|
1567
|
+
// intent must never fall through to a plain trunk compile just because
|
|
1568
|
+
// eligibleIntents excluded it from the worktree path.
|
|
1569
|
+
const abandonedIntentIds = new Set((await loadAbandoned()).keys());
|
|
1570
|
+
|
|
1560
1571
|
let eligible = [];
|
|
1561
1572
|
if (!onHedgehogBranch()) {
|
|
1562
1573
|
const eligibilityDb = openDb({ readOnly: true });
|
|
1563
1574
|
try {
|
|
1564
|
-
eligible = eligibleIntents(eligibilityDb);
|
|
1575
|
+
eligible = eligibleIntents(eligibilityDb, abandonedIntentIds);
|
|
1565
1576
|
} finally {
|
|
1566
1577
|
eligibilityDb.close();
|
|
1567
1578
|
}
|
|
@@ -1572,8 +1583,12 @@ async function planCommand(args = []) {
|
|
|
1572
1583
|
// it a worktree this run (hasWorktree true already, or the commit check
|
|
1573
1584
|
// below defers it) — an eligible intent must never fall through to
|
|
1574
1585
|
// 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
|
-
|
|
1586
|
+
// intent and this feature would have done nothing for it. Abandoned
|
|
1587
|
+
// intents are unioned in for the same reason (issue #431): excluded from
|
|
1588
|
+
// `eligible` itself now, they'd otherwise read as "not eligible, so
|
|
1589
|
+
// compile normally" and get recompiled straight onto trunk instead of
|
|
1590
|
+
// being left alone — the abandonment record makes that impossible.
|
|
1591
|
+
const excludeIntentIds = new Set([...eligible.map((i) => i.id), ...abandonedIntentIds]);
|
|
1577
1592
|
|
|
1578
1593
|
const worktreesCreated = [];
|
|
1579
1594
|
for (const intent of eligible) {
|
|
@@ -2240,7 +2255,8 @@ async function verifyCommand(args) {
|
|
|
2240
2255
|
process.exitCode = 1;
|
|
2241
2256
|
return;
|
|
2242
2257
|
}
|
|
2243
|
-
|
|
2258
|
+
const overrides = await loadOverrides();
|
|
2259
|
+
result = verifyTask(db, taskId, owner, overrides);
|
|
2244
2260
|
} catch (err) {
|
|
2245
2261
|
console.error(`${red('Verify failed:')} ${err.message}\n`);
|
|
2246
2262
|
process.exitCode = 1;
|
|
@@ -3332,7 +3348,7 @@ async function overrideCommand(args) {
|
|
|
3332
3348
|
console.log(` ${green('added')} ${OVERRIDES_DIR}/${record.task.toLowerCase()}.json`);
|
|
3333
3349
|
for (const glob of record.scope_add) console.log(` + ${glob}`);
|
|
3334
3350
|
console.log(
|
|
3335
|
-
` ${dim(
|
|
3351
|
+
` ${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
3352
|
);
|
|
3337
3353
|
return;
|
|
3338
3354
|
}
|
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/rebuild.mjs
CHANGED
|
@@ -311,7 +311,7 @@ function loadCommitSubjects() {
|
|
|
311
311
|
// and applying either check there would cascade that gap through the
|
|
312
312
|
// whole chain and reset already-built modules that have no ambiguity to
|
|
313
313
|
// resolve in the first place.
|
|
314
|
-
function markCompletedTasks(db, commitSubjects) {
|
|
314
|
+
function markCompletedTasks(db, commitSubjects, reconciledTaskIds = new Set()) {
|
|
315
315
|
const tasks = db.prepare('SELECT id, module, commit_message FROM tasks').all();
|
|
316
316
|
const prerequisites = new Map(tasks.map((t) => [t.id, []]));
|
|
317
317
|
for (const d of db.prepare('SELECT task_id, depends_on_task_id FROM dependencies').all()) {
|
|
@@ -329,7 +329,16 @@ function markCompletedTasks(db, commitSubjects) {
|
|
|
329
329
|
// resolved separately below, since "the" matching commit for its
|
|
330
330
|
// subject isn't decided until a claim succeeds.
|
|
331
331
|
const positionOf = new Map();
|
|
332
|
-
|
|
332
|
+
// Seeded with every reconciled task up front, before the ambiguous-task
|
|
333
|
+
// fixpoint walk below ever runs: a reconciled task was closed precisely
|
|
334
|
+
// because it has no commit of its own to match in commitSubjects (a
|
|
335
|
+
// hand-written commit predating the reconciliation, or none at all), so
|
|
336
|
+
// without this seed it can never enter `complete` from inside this
|
|
337
|
+
// function — leaving every task that depends on it, directly or
|
|
338
|
+
// transitively, stuck `planned` forever, since replayReconciliations
|
|
339
|
+
// (the only other path that would mark it complete) runs after this
|
|
340
|
+
// function returns.
|
|
341
|
+
const complete = new Set(reconciledTaskIds);
|
|
333
342
|
const ambiguousTasks = [];
|
|
334
343
|
for (const task of tasks) {
|
|
335
344
|
if (task.module === CORE_MODULE || isAmbiguous(task)) {
|
|
@@ -552,7 +561,18 @@ export async function rebuildDb(
|
|
|
552
561
|
planTasks(db, core, overrides, { excludeIntentIds: openWorktreeIntentIds });
|
|
553
562
|
|
|
554
563
|
const commitSubjects = loadCommitSubjects();
|
|
555
|
-
|
|
564
|
+
// Reconciled task ids are seeded into markCompletedTasks's own `complete`
|
|
565
|
+
// set before its ambiguous-task fixpoint walk runs, not after: a
|
|
566
|
+
// reconciled task's whole reason for being in .hedgehog/reconciled/ is
|
|
567
|
+
// that it has no commit of its own in commitSubjects to match, so
|
|
568
|
+
// without this it can never satisfy `every prerequisite is complete` for
|
|
569
|
+
// whatever depends on it — see markCompletedTasks's own comment on
|
|
570
|
+
// `reconciledTaskIds` for the full mechanics. replayReconciliations
|
|
571
|
+
// (below) still runs afterward to write the provenance note and cover
|
|
572
|
+
// any reconciled task markCompletedTasks doesn't touch (module = CORE_MODULE
|
|
573
|
+
// edge cases aside, every reconciled id ends up here either way).
|
|
574
|
+
const reconciledTaskIds = new Set(reconciliations.keys());
|
|
575
|
+
const tasksMarkedComplete = markCompletedTasks(db, commitSubjects, reconciledTaskIds);
|
|
556
576
|
|
|
557
577
|
const tasksReconciled = replayReconciliations(db, reconciliations);
|
|
558
578
|
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/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.
|