@skyf0xx/hedgehog 6.0.4 → 6.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -93,7 +93,7 @@ Every dependency is explicit, so Hedgehog knows which tasks can run in parallel.
93
93
 
94
94
  Agents fan out to give you great outcomes at **faster speeds**.
95
95
 
96
- ## Your Code is a Graph
96
+ ## Live Dependency Awareness
97
97
 
98
98
  Hedgehog reaches for your editor's own Language Server Protocol integration to see what depends on what.
99
99
 
@@ -104,10 +104,9 @@ flowchart TD
104
104
  C --> D[Hedgehog]
105
105
  ```
106
106
 
107
- - **No index to go stale**: the language server's view of the code is always current
108
- - **No time spent hunting through the codebase for context**: find references and callers directly
109
- - **No more surprise breakage**: every task can check what depends on it before it edits
110
- - **Tests cover what changed**: verification that misses affected code gets caught
107
+ - **Lower token cost**: a targeted symbol lookup replaces reading or grepping whole files for context
108
+ - **Impact known before editing**: every task checks what depends on it before it changes anything
109
+ - **Verification matches the real surface**: tests target what actually changed
111
110
 
112
111
  ## Deterministic Code Generation
113
112
 
package/README.zh-CN.md CHANGED
@@ -93,7 +93,7 @@ npx @skyf0xx/hedgehog graph # 显示构建图
93
93
 
94
94
  多个 agent 并行展开工作,以**更快的速度**交付出色的结果。
95
95
 
96
- ## 你的代码是一张图
96
+ ## 实时感知依赖关系
97
97
 
98
98
  Hedgehog 借助你编辑器自带的 Language Server Protocol 集成来查看代码间的依赖关系。
99
99
 
@@ -104,10 +104,9 @@ flowchart TD
104
104
  C --> D[Hedgehog]
105
105
  ```
106
106
 
107
- - **索引永不过期**:语言服务器看到的代码状态始终是最新的
108
- - **不必在代码库中翻找上下文**:直接查找引用和调用方
109
- - **不再意外改坏**:每个任务在动手前都能查一查有哪些代码依赖它
110
- - **测试覆盖改动**:遗漏受影响代码的验证会被发现
107
+ - **更低的 token 成本**:精准的符号查找取代了为获取上下文而阅读或搜索整个文件
108
+ - **改动前先知影响**:每个任务在动手前都能查清有哪些代码依赖它
109
+ - **验证匹配真实改动范围**:测试针对实际发生变化的部分运行
111
110
 
112
111
  ## 确定性代码生成
113
112
 
package/bin/cli.mjs CHANGED
@@ -53,6 +53,7 @@ import {
53
53
  import { whyPath, formatWhy } from '../src/db/why.mjs';
54
54
  import { addFriction, listFriction } from '../src/db/friction.mjs';
55
55
  import { addDebt, listDebt } from '../src/db/debt.mjs';
56
+ import { addDecision, listDecisions } from '../src/db/decision.mjs';
56
57
  import {
57
58
  shouldPromptForStar,
58
59
  recordStarAnswer,
@@ -397,19 +398,19 @@ function warnRebuildDrift({ drift }, corePath) {
397
398
  );
398
399
  }
399
400
 
400
- // Debt and friction notes are operator-recorded and have no committed
401
- // source, so a rebuild carries them across by task id. A note whose task
402
- // is no longer in the recompiled graph — its intent file was renamed,
403
- // deleted, or its layer sequence changed — has nowhere to re-attach.
404
- // Friction notes survive unattached (their task_id is nullable); debt
405
- // notes are lost, so both are printed with their text rather than
406
- // disappearing into a count.
401
+ // Debt, decision, and friction notes are operator- or agent-recorded and
402
+ // have no committed source, so a rebuild carries them across by task id.
403
+ // A note whose task is no longer in the recompiled graph — its intent
404
+ // file was renamed, deleted, or its layer sequence changed — has nowhere
405
+ // to re-attach. Friction notes survive unattached (their task_id is
406
+ // nullable); debt and decision notes are lost, so all three are printed
407
+ // with their text rather than disappearing into a count.
407
408
  function warnOrphanedNotes({ orphanedNotes }) {
408
409
  if (!orphanedNotes || orphanedNotes.length === 0) return;
409
410
  console.log(
410
411
  `${yellow(bold('Notes without a task after rebuild.'))} ${orphanedNotes.length} note(s) referenced a\n` +
411
412
  'task the recompiled graph no longer holds. Friction notes were kept unattached;\n' +
412
- 'debt notes could not be, so they are reproduced here:\n',
413
+ 'debt and decision notes could not be, so they are reproduced here:\n',
413
414
  );
414
415
  for (const note of orphanedNotes) {
415
416
  console.log(` ${dim(note.kind)} ${bold(note.taskId)} ${note.note}`);
@@ -573,6 +574,9 @@ ${bold('Usage')}
573
574
  npx @skyf0xx/hedgehog friction list list logged friction, oldest first
574
575
  npx @skyf0xx/hedgehog debt add <task-id> "<note>" declare debt that lands in dependent tasks' packets
575
576
  npx @skyf0xx/hedgehog debt list [<task-id>] list declared debt, oldest first
577
+ npx @skyf0xx/hedgehog decision add <task-id> "<note>" declare a decision that lands in dependent tasks' packets
578
+ npx @skyf0xx/hedgehog decision list [<task-id>] list declared decisions, oldest first
579
+ npx @skyf0xx/hedgehog db migrate bring the graph's schema up to the latest version
576
580
  npx @skyf0xx/hedgehog community star --answer <a> record the star prompt's answer
577
581
  npx @skyf0xx/hedgehog --help
578
582
 
@@ -1155,9 +1159,13 @@ async function dbCommand(args) {
1155
1159
  await dbRebuildCommand();
1156
1160
  return;
1157
1161
  }
1162
+ if (sub === 'migrate') {
1163
+ await dbMigrateCommand();
1164
+ return;
1165
+ }
1158
1166
  if (sub !== 'init') {
1159
1167
  console.error(
1160
- `${red('Unknown db subcommand:')} ${sub ?? '(none)'}\n\nUsage: hedgehog db init\n or: hedgehog db rebuild\n`,
1168
+ `${red('Unknown db subcommand:')} ${sub ?? '(none)'}\n\nUsage: hedgehog db init\n or: hedgehog db rebuild\n or: hedgehog db migrate\n`,
1161
1169
  );
1162
1170
  process.exitCode = 1;
1163
1171
  return;
@@ -1179,6 +1187,44 @@ async function dbCommand(args) {
1179
1187
  }
1180
1188
  }
1181
1189
 
1190
+ // `hedgehog db migrate` — brings an existing build graph's schema up to
1191
+ // CURRENT_SCHEMA_VERSION on demand (see schema.mjs's runMigrations),
1192
+ // rather than only as a side effect of the next command that happens to
1193
+ // open the graph writably. Reports what moved, since an upgrade
1194
+ // shouldn't be a silent side effect the first time some other command
1195
+ // happens to trigger it.
1196
+ async function dbMigrateCommand() {
1197
+ if (!(await exists(DB_PATH))) {
1198
+ console.error(`${red('No build graph found.')} Run ${bold('hedgehog db init')} first.\n`);
1199
+ process.exitCode = 1;
1200
+ return;
1201
+ }
1202
+
1203
+ const before = openDb({ readOnly: true });
1204
+ const { user_version: fromVersion } = before.prepare('PRAGMA user_version').get();
1205
+ before.close();
1206
+
1207
+ let toVersion;
1208
+ try {
1209
+ const db = openDb();
1210
+ try {
1211
+ ({ user_version: toVersion } = db.prepare('PRAGMA user_version').get());
1212
+ } finally {
1213
+ db.close();
1214
+ }
1215
+ } catch (err) {
1216
+ console.error(`${red(err.message)}\n`);
1217
+ process.exitCode = 1;
1218
+ return;
1219
+ }
1220
+
1221
+ if (fromVersion === toVersion) {
1222
+ console.log(`${dim(`Build graph is already at the latest schema (v${toVersion}). Nothing to do.`)}\n`);
1223
+ return;
1224
+ }
1225
+ console.log(`${green('Migrated')} build graph from schema v${fromVersion} to v${toVersion}.\n`);
1226
+ }
1227
+
1182
1228
  // Resolves the project's core definition: an authored .hedgehog/core.yaml
1183
1229
  // takes precedence (spec: "Authored cores"); otherwise the core's
1184
1230
  // own core.yaml, which lands at repo root along with the rest of that
@@ -3095,6 +3141,77 @@ async function debtCommand(args) {
3095
3141
  process.exitCode = 1;
3096
3142
  }
3097
3143
 
3144
+ // `hedgehog decision add <task-id> "<note>"` / `hedgehog decision list
3145
+ // [<task-id>]` — declared decisions between tasks. A note recorded
3146
+ // against a task is rendered into the INHERITED DECISIONS section of the
3147
+ // packet of every task that depends on it (see src/db/decision.mjs and
3148
+ // src/db/next.mjs).
3149
+ async function decisionCommand(args) {
3150
+ await ensureDb();
3151
+
3152
+ const sub = args[0];
3153
+
3154
+ if (!(await exists(DB_PATH))) {
3155
+ console.error(`${red('No build graph found.')} Run ${bold('hedgehog db init')} first.\n`);
3156
+ process.exitCode = 1;
3157
+ return;
3158
+ }
3159
+
3160
+ if (sub === 'add') {
3161
+ const taskId = args[1];
3162
+ const note = args.slice(2).join(' ');
3163
+ if (!taskId || !note) {
3164
+ console.error(`${red('Usage:')} hedgehog decision add <task-id> "<note>"\n`);
3165
+ process.exitCode = 1;
3166
+ return;
3167
+ }
3168
+
3169
+ const db = openDb();
3170
+ let entry;
3171
+ try {
3172
+ entry = addDecision(db, { taskId, note });
3173
+ } catch (err) {
3174
+ console.error(
3175
+ `${red('Failed to declare decision:')} ${err.message}\n\nRun ${bold('hedgehog status')} to see valid task ids.\n`,
3176
+ );
3177
+ process.exitCode = 1;
3178
+ return;
3179
+ } finally {
3180
+ db.close();
3181
+ }
3182
+
3183
+ console.log(` ${green('declared')} #${entry.id} ${bold(entry.taskId)}`);
3184
+ console.log(` ${dim('reaches the packet of every task depending on it')}`);
3185
+ return;
3186
+ }
3187
+
3188
+ if (sub === 'list') {
3189
+ const taskId = args[1];
3190
+ const db = openDb();
3191
+ let entries;
3192
+ try {
3193
+ entries = listDecisions(db, taskId);
3194
+ } finally {
3195
+ db.close();
3196
+ }
3197
+
3198
+ if (entries.length === 0) {
3199
+ console.log(`${dim('No decisions declared.')}\n`);
3200
+ return;
3201
+ }
3202
+ for (const entry of entries) {
3203
+ console.log(`#${entry.id} ${dim(entry.loggedAt)} ${bold(entry.taskId)}`);
3204
+ console.log(` ${entry.note}\n`);
3205
+ }
3206
+ return;
3207
+ }
3208
+
3209
+ console.error(
3210
+ `${red('Unknown decision subcommand:')} ${sub ?? '(none)'}\n\nUsage: hedgehog decision add <task-id> "<note>"\n or: hedgehog decision list [<task-id>]\n`,
3211
+ );
3212
+ process.exitCode = 1;
3213
+ }
3214
+
3098
3215
  // `hedgehog community star --answer starred|later|dismissed` — records
3099
3216
  // the star prompt's answer. No build graph or core needed: this is
3100
3217
  // project state about a question asked, not about the build.
@@ -3400,6 +3517,11 @@ async function main() {
3400
3517
  return;
3401
3518
  }
3402
3519
 
3520
+ if (cmd === 'decision') {
3521
+ await decisionCommand(args.slice(1));
3522
+ return;
3523
+ }
3524
+
3403
3525
  if (cmd === 'community') {
3404
3526
  await communityCommand(args.slice(1));
3405
3527
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "6.0.4",
3
+ "version": "6.0.5",
4
4
  "description": "Install the Hedgehog build discipline (agents + skills) into a repo, for Claude Code, Cursor, or Gemini CLI.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/db/debt.mjs CHANGED
@@ -14,9 +14,9 @@
14
14
  // Debt is in-build traffic between two tasks, and a file under
15
15
  // `.hedgehog/` written mid-task sits outside every task's scope globs —
16
16
  // it would trip verify's scope gate on the very task that declared it.
17
- // The consequence is that debt does not survive `hedgehog db rebuild`
18
- // (which replays only `.hedgehog/intents/*.json`); by then the
19
- // inheriting task has usually already consumed it.
17
+ // Debt has no committed source `hedgehog db rebuild` could replay it
18
+ // from, so rebuild.mjs carries it across a rebuild by task id instead
19
+ // (a note whose task no longer exists there is reported, not dropped).
20
20
 
21
21
  import { applySchema } from './schema.mjs';
22
22
 
@@ -0,0 +1,64 @@
1
+ // `hedgehog decision add` / `hedgehog decision list` — declared decisions
2
+ // between tasks. See schema.mjs's `decisions` table and next.mjs's
3
+ // INHERITED DECISIONS packet section.
4
+ //
5
+ // A layer that chose a pattern, a library, or a trade-off while building
6
+ // has no way to tell the layer that inherits from it *why* — only debt.mjs
7
+ // exists for that, and debt is specifically a known limitation, not a
8
+ // decision that was made correctly and simply needs to be known. A
9
+ // "we chose X because Y" comment in a source file is not a mechanism: the
10
+ // inheriting task's packet is assembled from the graph, not from reading
11
+ // its dependencies' comments, so the note never arrives. `decision add`
12
+ // records the note against the declaring task, and next.mjs renders it
13
+ // into the packet of every task that depends on it.
14
+ //
15
+ // Same as debt: nothing is written to a committed markdown log, so a
16
+ // decision has no source `hedgehog db rebuild` could replay it from —
17
+ // rebuild.mjs carries debt, decisions, and friction across a rebuild by
18
+ // task id instead, for exactly this reason.
19
+
20
+ import { applySchema } from './schema.mjs';
21
+
22
+ const insertDecision = (db) =>
23
+ db.prepare(`
24
+ INSERT INTO decisions (task_id, note)
25
+ VALUES (?, ?)
26
+ `);
27
+
28
+ function taskExists(db, taskId) {
29
+ return db.prepare('SELECT 1 FROM tasks WHERE id = ?').get(taskId) !== undefined;
30
+ }
31
+
32
+ // Writes one decision row against `taskId`. The task must exist — a
33
+ // decision addressed to nobody reaches nobody, and the schema's foreign
34
+ // key would reject it anyway, less legibly.
35
+ export function addDecision(db, { taskId, note }) {
36
+ // Idempotent, and the migration path for a build graph created before
37
+ // the `decisions` table existed: dbInit only applies the schema to a DB
38
+ // it just created, so an in-flight project's DB would otherwise have no
39
+ // table to insert into.
40
+ applySchema(db);
41
+
42
+ if (!taskId) throw new Error('decision requires a task id');
43
+ if (!note) throw new Error('decision requires a note');
44
+ if (!taskExists(db, taskId)) throw new Error(`no such task: ${taskId}`);
45
+
46
+ const result = insertDecision(db).run(taskId, note);
47
+ return { id: Number(result.lastInsertRowid), taskId, note };
48
+ }
49
+
50
+ // Every decision row, oldest first, optionally narrowed to one task.
51
+ export function listDecisions(db, taskId) {
52
+ const where = taskId ? 'WHERE task_id = ?' : '';
53
+ const params = taskId ? [taskId] : [];
54
+ try {
55
+ return db
56
+ .prepare(
57
+ `SELECT id, task_id AS taskId, note, logged_at AS loggedAt FROM decisions ${where} ORDER BY id ASC`,
58
+ )
59
+ .all(...params);
60
+ } catch {
61
+ // No `decisions` table yet (a build graph from before this table existed).
62
+ return [];
63
+ }
64
+ }
package/src/db/init.mjs CHANGED
@@ -24,12 +24,24 @@ export const dbAbsPath = (root = process.cwd()) => resolve(root, DB_PATH);
24
24
  // a call site that forgot them. journal_mode is skipped for read-only
25
25
  // handles — it requires write access and a readOnly connection has no
26
26
  // business changing the file's journal mode anyway.
27
+ //
28
+ // applySchema also runs here, not only from `dbInit`: a graph created by
29
+ // an older CLI version is missing whatever tables/columns shipped since,
30
+ // and every command besides `init`/`update` opens the graph straight
31
+ // from here rather than going through dbInit first. Without this, those
32
+ // commands only see the fix after someone thinks to rerun `init` on an
33
+ // already-initialized project — which nothing prompts them to do — and
34
+ // until then every query naming a newer column fails with "no such
35
+ // column". Skipped for read-only handles for the same reason
36
+ // journal_mode is: it requires write access, and a readOnly caller is
37
+ // only ever reached after a writable open earlier in the same command.
27
38
  export function openDb({ readOnly = false } = {}) {
28
39
  const db = new DatabaseSync(dbAbsPath(), { readOnly });
29
40
  db.exec('PRAGMA foreign_keys = ON');
30
41
  db.exec('PRAGMA busy_timeout = 10000');
31
42
  if (!readOnly) db.exec('PRAGMA journal_mode = WAL');
32
43
  db.exec('PRAGMA synchronous = NORMAL');
44
+ if (!readOnly) applySchema(db);
33
45
  return db;
34
46
  }
35
47
 
package/src/db/next.mjs CHANGED
@@ -113,6 +113,24 @@ function loadInheritedDebt(db, taskId) {
113
113
  }
114
114
  }
115
115
 
116
+ // Decisions declared by anything this task inherits from (see
117
+ // decision.mjs) — same upstream walk as loadInheritedDebt, same tolerance
118
+ // for a `decisions` table that doesn't exist yet on an older graph.
119
+ function loadInheritedDecisions(db, taskId) {
120
+ const upstream = loadUpstreamTaskIds(db, taskId);
121
+ if (upstream.length === 0) return [];
122
+ const placeholders = upstream.map(() => '?').join(',');
123
+ try {
124
+ return db
125
+ .prepare(
126
+ `SELECT task_id AS taskId, note FROM decisions WHERE task_id IN (${placeholders}) ORDER BY id ASC`,
127
+ )
128
+ .all(...upstream);
129
+ } catch {
130
+ return [];
131
+ }
132
+ }
133
+
116
134
  function loadDirectDependents(db, taskId) {
117
135
  return db
118
136
  .prepare(
@@ -162,6 +180,7 @@ function assemblePacket(db, task) {
162
180
  const dependents = loadBlockedDownstream(db, task.id);
163
181
  const incompleteDeps = incompleteDependencies(db, task.id);
164
182
  const inheritedDebt = loadInheritedDebt(db, task.id);
183
+ const inheritedDecisions = loadInheritedDecisions(db, task.id);
165
184
 
166
185
  return {
167
186
  task,
@@ -170,6 +189,7 @@ function assemblePacket(db, task) {
170
189
  dependents,
171
190
  incompleteDeps,
172
191
  inheritedDebt,
192
+ inheritedDecisions,
173
193
  };
174
194
  }
175
195
 
@@ -344,8 +364,8 @@ const HONESTY = [
344
364
  ];
345
365
 
346
366
  // Renders a packet into the STATUS / INTENT / RELEVANT RULES /
347
- // INHERITED DEBT / WHY NOW / BLOCKED DOWNSTREAM / ALLOWED SCOPE /
348
- // PRE-READ / LAYER SHAPE / VERIFICATION / HONESTY format. The spec
367
+ // INHERITED DEBT / INHERITED DECISIONS / WHY NOW / BLOCKED DOWNSTREAM /
368
+ // ALLOWED SCOPE / PRE-READ / LAYER SHAPE / VERIFICATION / HONESTY format. The spec
349
369
  // splits this across two examples — the `hedgehog next` display and "The
350
370
  // task packet" (which carries the intent and its rules) — but an agent
351
371
  // receives one thing, so the packet is one thing: everything the worker
@@ -456,7 +476,15 @@ function firstArrivalLines(task, roots) {
456
476
  }
457
477
 
458
478
  export function formatPacket(packet, statusLine, coreId = null, exists = null) {
459
- const { task, intent, requirements, dependents, incompleteDeps = [], inheritedDebt = [] } = packet;
479
+ const {
480
+ task,
481
+ intent,
482
+ requirements,
483
+ dependents,
484
+ incompleteDeps = [],
485
+ inheritedDebt = [],
486
+ inheritedDecisions = [],
487
+ } = packet;
460
488
  const scopeGlobs = JSON.parse(task.scope_globs);
461
489
  const firstArrival = firstArrivalPackages(task, exists);
462
490
 
@@ -494,6 +522,15 @@ export function formatPacket(packet, statusLine, coreId = null, exists = null) {
494
522
  }
495
523
  }
496
524
  lines.push('');
525
+ lines.push('INHERITED DECISIONS');
526
+ if (inheritedDecisions.length === 0) {
527
+ lines.push(' (none declared)');
528
+ } else {
529
+ for (const entry of inheritedDecisions) {
530
+ lines.push(` * ${entry.taskId} ${entry.note}`);
531
+ }
532
+ }
533
+ lines.push('');
497
534
  lines.push('WHY NOW');
498
535
  lines.push(` ✓ Intent "${intent.id}" compiled into the graph`);
499
536
  // A once: true layer has no module — it compiled one task for the whole
@@ -53,14 +53,15 @@ function intentExists(db, id) {
53
53
  //
54
54
  // Deleting `intents` cascades through requirements, tasks,
55
55
  // task_requirements, dependencies, artifacts and verifications — every
56
- // one of which this run re-derives. `debt` and `friction` are the
57
- // exception: they are operator-recorded notes with no committed source,
58
- // so they are carried across by task id (deterministic, so a note
59
- // re-attaches to the same task the replay recompiles). A note whose task
60
- // no longer exists in the new graph has nowhere to live and is reported
61
- // rather than silently dropped.
56
+ // one of which this run re-derives. `debt`, `decisions`, and `friction`
57
+ // are the exception: they are operator- or agent-recorded notes with no
58
+ // committed source, so they are carried across by task id (deterministic,
59
+ // so a note re-attaches to the same task the replay recompiles). A note
60
+ // whose task no longer exists in the new graph has nowhere to live and is
61
+ // reported rather than silently dropped.
62
62
  function clearDerivedGraph(db) {
63
63
  const debt = db.prepare('SELECT task_id, note, logged_at FROM debt').all();
64
+ const decisions = db.prepare('SELECT task_id, note, logged_at FROM decisions').all();
64
65
  const friction = db.prepare('SELECT task_id, note, logged_at FROM friction').all();
65
66
 
66
67
  db.prepare('DELETE FROM intents').run();
@@ -69,14 +70,17 @@ function clearDerivedGraph(db) {
69
70
  // the single writer, so a note is not duplicated against its own copy.
70
71
  db.prepare('DELETE FROM friction').run();
71
72
 
72
- return { debt, friction };
73
+ return { debt, decisions, friction };
73
74
  }
74
75
 
75
- function restoreNotes(db, { debt, friction }) {
76
+ function restoreNotes(db, { debt, decisions, friction }) {
76
77
  const taskExists = db.prepare('SELECT 1 FROM tasks WHERE id = ?');
77
78
  const insertDebt = db.prepare(
78
79
  'INSERT INTO debt (task_id, note, logged_at) VALUES (?, ?, ?)',
79
80
  );
81
+ const insertDecision = db.prepare(
82
+ 'INSERT INTO decisions (task_id, note, logged_at) VALUES (?, ?, ?)',
83
+ );
80
84
  const insertFriction = db.prepare(
81
85
  'INSERT INTO friction (task_id, note, logged_at) VALUES (?, ?, ?)',
82
86
  );
@@ -91,6 +95,14 @@ function restoreNotes(db, { debt, friction }) {
91
95
  insertDebt.run(row.task_id, row.note, row.logged_at);
92
96
  }
93
97
 
98
+ for (const row of decisions) {
99
+ if (taskExists.get(row.task_id) === undefined) {
100
+ orphaned.push({ kind: 'decision', taskId: row.task_id, note: row.note });
101
+ continue;
102
+ }
103
+ insertDecision.run(row.task_id, row.note, row.logged_at);
104
+ }
105
+
94
106
  for (const row of friction) {
95
107
  // friction.task_id is nullable — an unattached note always survives.
96
108
  if (row.task_id !== null && taskExists.get(row.task_id) === undefined) {
package/src/db/schema.mjs CHANGED
@@ -109,6 +109,21 @@ CREATE TABLE IF NOT EXISTS debt (
109
109
  logged_at TEXT NOT NULL DEFAULT (datetime('now'))
110
110
  );
111
111
 
112
+ -- Declared decision: a note one task leaves for the tasks that inherit
113
+ -- from it, delivered the same way debt is (rendered into every dependent
114
+ -- task's packet) but for a different purpose. Debt records what's still
115
+ -- wrong with a task; a decision records why it was built the way it was
116
+ -- — the pattern, library, or trade-off chosen. Without it, that
117
+ -- reasoning lives only in the agent session that made the choice, and a
118
+ -- dependent task built by a different, isolated session has no way to
119
+ -- learn it beyond reading the diff and guessing.
120
+ CREATE TABLE IF NOT EXISTS decisions (
121
+ id INTEGER PRIMARY KEY,
122
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
123
+ note TEXT NOT NULL,
124
+ logged_at TEXT NOT NULL DEFAULT (datetime('now'))
125
+ );
126
+
112
127
  CREATE TABLE IF NOT EXISTS friction (
113
128
  id INTEGER PRIMARY KEY,
114
129
  task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
@@ -121,7 +136,25 @@ CREATE TABLE IF NOT EXISTS friction (
121
136
  // IF NOT EXISTS` above is a no-op against a DB created by an earlier
122
137
  // version, so a new column has to be ALTERed in explicitly or every
123
138
  // statement naming it fails on that DB. Each entry is `[name, ddl]`.
124
- const TASK_COLUMN_MIGRATIONS = [['claim_snapshot', 'claim_snapshot TEXT']];
139
+ //
140
+ // The lease columns (lease_owner, lease_expires_at, leased_at) and their
141
+ // siblings (exclusive, verify_radius, blocked_reason) shipped together in
142
+ // the same commit that introduced them to SCHEMA_SQL above, but were
143
+ // never added here — so a graph created before that commit still lacks
144
+ // them today, with every query naming lease_owner failing "no such
145
+ // column" instead of self-healing the way claim_snapshot already does.
146
+ const TASK_COLUMN_MIGRATIONS = [
147
+ ['exclusive', 'exclusive INTEGER NOT NULL DEFAULT 0'],
148
+ ['verify_radius', 'verify_radius TEXT'],
149
+ [
150
+ 'blocked_reason',
151
+ "blocked_reason TEXT CHECK (blocked_reason IS NULL OR blocked_reason IN ('scope_violation','verification_failed','lease_expired'))",
152
+ ],
153
+ ['lease_owner', 'lease_owner TEXT'],
154
+ ['lease_expires_at', 'lease_expires_at TEXT'],
155
+ ['leased_at', 'leased_at TEXT'],
156
+ ['claim_snapshot', 'claim_snapshot TEXT'],
157
+ ];
125
158
 
126
159
  // Brings an already-created `tasks` table up to the current column set.
127
160
  // Idempotent and cheap (one PRAGMA), so callers that must not fail on a
@@ -136,9 +169,76 @@ export function ensureTaskColumns(db) {
136
169
  }
137
170
  }
138
171
 
139
- // Applies the schema to an already-open node:sqlite DatabaseSync instance.
140
- // Idempotent: safe to call against a DB that already has these tables.
172
+ // Schema version this installed CLI knows about, tracked in the graph's
173
+ // own `PRAGMA user_version` (an integer SQLite stores in the file header
174
+ // — no table required, so it reads back even on a brand-new file).
175
+ // Bumped by one for every entry added to MIGRATIONS below; never
176
+ // hand-set past what MIGRATIONS actually covers, since runMigrations
177
+ // trusts this number to mean "every migration through this version has
178
+ // run."
179
+ export const CURRENT_SCHEMA_VERSION = 2;
180
+
181
+ // Forward migrations, applied in order to bring a graph's user_version up
182
+ // to CURRENT_SCHEMA_VERSION. Unlike the CREATE TABLE IF NOT EXISTS /
183
+ // ensureTaskColumns pair above — which only ever reconciles present-day
184
+ // shape against however old a graph is, and can't touch anything already
185
+ // baked into an existing column (a CHECK constraint, a rename, a data
186
+ // transform) — this is versioned, so a future change too structural for
187
+ // a bare ADD COLUMN has somewhere to go, and upgrading is one fail-loud
188
+ // step instead of every command's queries silently assuming a shape
189
+ // that might not be there yet.
190
+ const MIGRATIONS = [
191
+ {
192
+ version: 1,
193
+ // The lease/task columns TASK_COLUMN_MIGRATIONS covers already had to
194
+ // be idempotent against a fresh CREATE TABLE (which includes them
195
+ // from the start) — ensureTaskColumns' own presence check already
196
+ // does exactly what a migration step needs.
197
+ migrate: (db) => ensureTaskColumns(db),
198
+ },
199
+ {
200
+ version: 2,
201
+ migrate: (db) => {
202
+ db.exec(`
203
+ CREATE TABLE IF NOT EXISTS decisions (
204
+ id INTEGER PRIMARY KEY,
205
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
206
+ note TEXT NOT NULL,
207
+ logged_at TEXT NOT NULL DEFAULT (datetime('now'))
208
+ );
209
+ `);
210
+ },
211
+ },
212
+ ];
213
+
214
+ // Brings a graph's `PRAGMA user_version` up to CURRENT_SCHEMA_VERSION,
215
+ // running only the migrations it hasn't seen yet. A graph newer than
216
+ // this installed CLI knows about (its user_version already ahead of
217
+ // CURRENT_SCHEMA_VERSION — created by, or migrated with, a later
218
+ // Hedgehog) fails loudly here with a plain-English fix, instead of every
219
+ // later query failing confusingly on a column or table shape this code
220
+ // has never heard of.
221
+ export function runMigrations(db) {
222
+ const { user_version: current } = db.prepare('PRAGMA user_version').get();
223
+
224
+ if (current > CURRENT_SCHEMA_VERSION) {
225
+ throw new Error(
226
+ `This build graph was created by a newer version of Hedgehog (schema v${current}) than the one installed here (schema v${CURRENT_SCHEMA_VERSION}). Upgrade Hedgehog (\`npx @skyf0xx/hedgehog@latest update\`) before running commands against this graph.`,
227
+ );
228
+ }
229
+
230
+ for (const { version, migrate } of MIGRATIONS) {
231
+ if (version > current) {
232
+ migrate(db);
233
+ db.exec(`PRAGMA user_version = ${version}`);
234
+ }
235
+ }
236
+ }
237
+
238
+ // Applies the schema to an already-open node:sqlite DatabaseSync instance,
239
+ // then brings it up to CURRENT_SCHEMA_VERSION. Idempotent: safe to call
240
+ // against a DB that already has these tables and has already migrated.
141
241
  export function applySchema(db) {
142
242
  db.exec(SCHEMA_SQL);
143
- ensureTaskColumns(db);
243
+ runMigrations(db);
144
244
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hedgehog",
3
- "version": "6.0.4",
3
+ "version": "6.0.5",
4
4
  "description": "Hedgehog build discipline: ordered, tested, verified build steps.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }
@@ -40,25 +40,16 @@ it.
40
40
 
41
41
  ## Writing the issue or PR
42
42
 
43
- Most of Hedgehog's inbound queue is read first by `inbound-triage`, an
44
- agent, before a human ever sees it the same register `inbound-triage`
45
- uses when it comments back (see that skill's "Comment style" section).
46
- Write plainly for both readers at once:
43
+ Use the `pr-writing` skill for style: brief, info-dense, Simplified
44
+ Technical English, only verified claims. Most of Hedgehog's inbound queue
45
+ is read first by `inbound-triage`, an agent, before a human ever sees it —
46
+ the same register `inbound-triage` uses when it comments back (see that
47
+ skill's "Comment style" section) — so write plainly for both readers at
48
+ once.
47
49
 
48
- - Plain technical English, one claim per sentence, no hedging or
49
- marketing language.
50
- - Lead with the concrete fact: "`hedgehog init` writes `.claude/agents/`
51
- twice on Windows" beats "I noticed there might be an issue with how
52
- the installer handles paths."
53
- - Fill every field the bug-report template asks for as its own labeled
54
- fact (symptom, expected behavior, exact repro steps) rather than one
55
- collapsed paragraph.
56
- - Cite `file:line` for anything about existing behavior — an
57
- uncited claim is a hypothesis both readers have to re-derive.
58
- - One issue, one problem; one PR, one change.
59
- - Say what you verified, not what you assume: "Ran `node bin/cli.mjs
60
- init` in a scratch dir, `.claude/skills/` is missing the new
61
- directory" beats "this probably breaks the install."
50
+ Fill every field the bug-report template asks for as its own labeled fact
51
+ (symptom, expected behavior, exact repro steps) rather than one collapsed
52
+ paragraph.
62
53
 
63
54
  ## Workflow
64
55
 
@@ -92,7 +83,8 @@ Write plainly for both readers at once:
92
83
  tree has accumulated several unrelated changes that need splitting into
93
84
  atomic commits, use the `conventional-commits` skill rather than
94
85
  hand-rolling the split.
95
- 6. **Push and open the PR.**
86
+ 6. **Push and open the PR**, following `pr-writing`'s checklist and shape
87
+ (CI passing, one change, only verified claims):
96
88
  ```bash
97
89
  git push -u origin <branch-name>
98
90
  gh pr create --repo skyf0xx/hedgehog --title "<type>(<scope>): <summary>" --body "$(cat <<'EOF'
@@ -104,9 +96,10 @@ Write plainly for both readers at once:
104
96
  EOF
105
97
  )"
106
98
  ```
107
- Describe the *why* in the PR body, not in the file being changed — same
108
- rule `CONTRIBUTING.md` states for the content itself. If the PR closes
109
- or addresses a `ROADMAP.md` item or a filed issue, reference it
110
- (`Addresses the "<item name>" item in ROADMAP.md`, or `Fixes #<n>`).
111
- 7. **Report the PR URL** `gh` returns and stop — don't merge, don't push
99
+ If the PR closes or addresses a `ROADMAP.md` item or a filed issue,
100
+ reference it (`Addresses the "<item name>" item in ROADMAP.md`, or
101
+ `Fixes #<n>`).
102
+ 7. **Check CI** with `gh pr checks <number> --repo skyf0xx/hedgehog` after
103
+ opening. Fix a red check before asking for review.
104
+ 8. **Report the PR URL** `gh` returns and stop — don't merge, don't push
112
105
  further commits without being asked.
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: pr-writing
3
+ description: Use whenever writing a PR title/description, a commit message body, a code review comment, or an issue — in Hedgehog's own repo or any consuming project. Triggers on "open a PR", "write the PR description", "comment on this PR", "file an issue". Covers writing style (terse, info-dense, Simplified Technical English) and the pre-open checklist (CI status, scope, verified claims only).
4
+ ---
5
+
6
+ # PR Writing
7
+
8
+ A PR description, commit message, or review comment is read by a human
9
+ deciding whether to trust and merge the change. Write for that reader, not
10
+ as a record of the work session.
11
+
12
+ ## Style rules
13
+
14
+ - **Brief.** State the change and the reason. Skip the narrative of how you
15
+ got there.
16
+ - **Info-dense, not verbose.** Every sentence carries a fact. Cut sentences
17
+ that restate the diff, the title, or each other.
18
+ - **Don't write what's inferable.** A reviewer can read the diff — don't
19
+ describe what a line change does if the code already says so. State only
20
+ what the diff can't show: intent, a non-obvious constraint, a fact you
21
+ verified.
22
+ - **Simplified Technical English.** One claim per sentence. Concrete
23
+ subjects, active voice, present tense for current behavior. No hedging
24
+ ("might", "could potentially", "it seems"), no filler ("simply",
25
+ "basically", "just"), no marketing language. Say "X fails when Y" — not
26
+ "there might be an issue where X could fail if Y happens."
27
+ - **Write for a human, not an AI reviewer.** No emoji, no "Generated by",
28
+ no restating the obvious for machine parsing. Plain prose a teammate
29
+ would send in Slack.
30
+
31
+ ## Pre-open checklist
32
+
33
+ - **CI must pass before you ask for review.** Run the project's checks
34
+ locally first — lint, tests, build. If a check is red after pushing, fix
35
+ it or say plainly in the PR why it's expected (a known, unrelated
36
+ flake), never leave it unexplained.
37
+ - **One PR, one change.** A second unrelated fix noticed along the way is a
38
+ separate PR, not scope creep on this one.
39
+ - **State only what you verified.** "Ran `X`, confirmed `Y`" — never "this
40
+ should work" or "this probably fixes it." An unverified claim in a test
41
+ plan is itself a defect; a reviewer trusts it and later finds it was
42
+ false.
43
+ - **Cite `file:line` for claims about existing behavior.** An uncited claim
44
+ is a hypothesis the reviewer has to re-derive themselves.
45
+ - **Reference the issue it closes**, if any (`Fixes #123`), instead of
46
+ restating the issue's content.
47
+
48
+ ## Shape
49
+
50
+ - **Title**: `<type>(<scope>): <summary>`, imperative mood, under ~70
51
+ chars.
52
+ - **Description**: 1-3 bullets — what changed, why. A test plan section
53
+ listing what you actually ran, not what should theoretically pass.
54
+ - **Comments**: lead with the concrete finding, then (if needed) the fix
55
+ requested. No preamble.
56
+
57
+ ## When NOT to apply
58
+
59
+ - Internal scratch notes, planning docs, or anything not read by another
60
+ person — write those however is fastest for you.
61
+ - The user asks for a different register explicitly (e.g. a detailed
62
+ design-doc-style PR description for an architectural change that needs
63
+ the extra context).
@@ -104,8 +104,8 @@ re-derive build state from prose. To work from it:
104
104
 
105
105
  1. Run `hedgehog claim --count N --owner <owner>`. It's atomic and
106
106
  lease-based, and returns up to N tasks (each with its own full
107
- STATUS/INTENT/RELEVANT RULES/INHERITED DEBT/WHY NOW/BLOCKED
108
- DOWNSTREAM/ALLOWED SCOPE/VERIFICATION packet) that the
107
+ STATUS/INTENT/RELEVANT RULES/INHERITED DEBT/INHERITED DECISIONS/WHY
108
+ NOW/BLOCKED DOWNSTREAM/ALLOWED SCOPE/VERIFICATION packet) that the
109
109
  scheduler has already verified are safe to run together right now —
110
110
  scope and verify-radius disjoint. `--count` is a maximum, not a
111
111
  promise: a call may return fewer than N, or zero. `hedgehog ready` is
@@ -141,6 +141,13 @@ records it with `hedgehog debt add <task-id> "<note>"` — it lands in the
141
141
  **INHERITED DEBT** section of every packet that depends on that task. A
142
142
  comment in a source file is not a mechanism; nothing reads it.
143
143
 
144
+ A layer that makes a choice a dependent layer needs to know about — a
145
+ pattern, a library, a trade-off, anything the next task should follow
146
+ rather than reinvent or contradict — records it with `hedgehog decision
147
+ add <task-id> "<note>"`, landing in the **INHERITED DECISIONS** section
148
+ the same way. Debt is what's still wrong with a task; a decision is why
149
+ it was built the way it was.
150
+
144
151
  `planner` owns writing intents (`hedgehog intent add`) at planning
145
152
  intake; `hedgehog plan` compiles them into the task graph the loop
146
153
  consumes. Nothing checks a box — there is no checklist, only queryable