@skyf0xx/hedgehog 6.1.6 → 6.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.mjs CHANGED
@@ -3293,7 +3293,7 @@ async function debtCommand(args) {
3293
3293
  const db = openDb();
3294
3294
  let entry;
3295
3295
  try {
3296
- entry = addDebt(db, { taskId, note });
3296
+ entry = await addDebt(db, { taskId, note });
3297
3297
  } catch (err) {
3298
3298
  console.error(
3299
3299
  `${red('Failed to declare debt:')} ${err.message}\n\nRun ${bold('hedgehog status')} to see valid task ids.\n`,
@@ -3364,7 +3364,7 @@ async function decisionCommand(args) {
3364
3364
  const db = openDb();
3365
3365
  let entry;
3366
3366
  try {
3367
- entry = addDecision(db, { taskId, note });
3367
+ entry = await addDecision(db, { taskId, note });
3368
3368
  } catch (err) {
3369
3369
  console.error(
3370
3370
  `${red('Failed to declare decision:')} ${err.message}\n\nRun ${bold('hedgehog status')} to see valid task ids.\n`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "6.1.6",
3
+ "version": "6.1.7",
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": {
@@ -16,7 +16,10 @@ read it.
16
16
 
17
17
  You touch no build content on any core. That's the first build step,
18
18
  started after Bootstrap closes, run by the core's own loop skill and its
19
- agents.
19
+ agents. This is a scope discipline, not a tool restriction — your tool
20
+ grant includes `Edit`/`Write`, so nothing stops you mechanically from
21
+ writing domain code. Follow the chosen core's bootstrap skill exactly and
22
+ stop where it stops.
20
23
 
21
24
  ## Finding your step
22
25
 
package/src/db/debt.mjs CHANGED
@@ -10,30 +10,37 @@
10
10
  // arrives. `debt add` records the note against the declaring task, and
11
11
  // next.mjs renders it into the packet of every task that depends on it.
12
12
  //
13
- // Unlike friction.mjs, nothing is written to a committed markdown log.
14
- // Debt is in-build traffic between two tasks, and a file under
15
- // `.hedgehog/` written mid-task sits outside every task's scope globs —
16
- // it would trip verify's scope gate on the very task that declared 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).
13
+ // A note recorded here has a committed source behind it —
14
+ // `.hedgehog/notes/<task-id>.json` (notes.mjs) the same way
15
+ // `.hedgehog/reconciled/*.json` backs a reconciliation. Debt is in-build
16
+ // traffic between two tasks, and a file under `.hedgehog/` written
17
+ // mid-task sits outside every task's scope globs it would trip verify's
18
+ // scope gate on the very task that declared it so `hedgehog debt add`
19
+ // writes it directly rather than through the declaring task's own commit.
20
+ // `hedgehog db rebuild` replays it from there (rebuild.mjs), the same way
21
+ // it replays overrides and reconciliations.
20
22
 
21
23
  import { applySchema } from './schema.mjs';
24
+ import { appendNote } from './notes.mjs';
22
25
 
23
26
  const insertDebt = (db) =>
24
27
  db.prepare(`
25
- INSERT INTO debt (task_id, note)
26
- VALUES (?, ?)
28
+ INSERT INTO debt (task_id, note, logged_at)
29
+ VALUES (?, ?, ?)
27
30
  `);
28
31
 
29
32
  function taskExists(db, taskId) {
30
33
  return db.prepare('SELECT 1 FROM tasks WHERE id = ?').get(taskId) !== undefined;
31
34
  }
32
35
 
33
- // Writes one debt row against `taskId`. The task must exist — debt
34
- // addressed to nobody reaches nobody, and the schema's foreign key would
35
- // reject it anyway, less legibly.
36
- export function addDebt(db, { taskId, note }) {
36
+ // Writes one debt row against `taskId`, and its committed record. The
37
+ // task must exist — debt addressed to nobody reaches nobody, and the
38
+ // schema's foreign key would reject it anyway, less legibly.
39
+ //
40
+ // Committed file before DB row, deliberately — reconcile.mjs's same
41
+ // ordering, for the same reason: if the write fails, nothing has been
42
+ // recorded on a fact that would not survive the next rebuild.
43
+ export async function addDebt(db, { taskId, note }, notesDir = undefined) {
37
44
  // Idempotent, and the migration path for a build graph created before
38
45
  // the `debt` table existed: dbInit only applies the schema to a DB it
39
46
  // just created, so an in-flight project's DB would otherwise have no
@@ -44,7 +51,10 @@ export function addDebt(db, { taskId, note }) {
44
51
  if (!note) throw new Error('debt requires a note');
45
52
  if (!taskExists(db, taskId)) throw new Error(`no such task: ${taskId}`);
46
53
 
47
- const result = insertDebt(db).run(taskId, note);
54
+ const loggedAt = new Date().toISOString();
55
+ await appendNote(taskId, { kind: 'debt', note, loggedAt }, notesDir);
56
+
57
+ const result = insertDebt(db).run(taskId, note, loggedAt);
48
58
  return { id: Number(result.lastInsertRowid), taskId, note };
49
59
  }
50
60
 
@@ -12,27 +12,33 @@
12
12
  // records the note against the declaring task, and next.mjs renders it
13
13
  // into the packet of every task that depends on it.
14
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.
15
+ // Same as debt: a note recorded here has a committed source behind it
16
+ // `.hedgehog/notes/<task-id>.json` (notes.mjs), the same way
17
+ // `.hedgehog/reconciled/*.json` backs a reconciliation. `hedgehog db
18
+ // rebuild` replays it from there, alongside overrides and
19
+ // reconciliations.
19
20
 
20
21
  import { applySchema } from './schema.mjs';
22
+ import { appendNote } from './notes.mjs';
21
23
 
22
24
  const insertDecision = (db) =>
23
25
  db.prepare(`
24
- INSERT INTO decisions (task_id, note)
25
- VALUES (?, ?)
26
+ INSERT INTO decisions (task_id, note, logged_at)
27
+ VALUES (?, ?, ?)
26
28
  `);
27
29
 
28
30
  function taskExists(db, taskId) {
29
31
  return db.prepare('SELECT 1 FROM tasks WHERE id = ?').get(taskId) !== undefined;
30
32
  }
31
33
 
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 }) {
34
+ // Writes one decision row against `taskId`, and its committed record. The
35
+ // task must exist — a decision addressed to nobody reaches nobody, and
36
+ // the schema's foreign key would reject it anyway, less legibly.
37
+ //
38
+ // Committed file before DB row, deliberately — reconcile.mjs's same
39
+ // ordering, for the same reason: if the write fails, nothing has been
40
+ // recorded on a fact that would not survive the next rebuild.
41
+ export async function addDecision(db, { taskId, note }, notesDir = undefined) {
36
42
  // Idempotent, and the migration path for a build graph created before
37
43
  // the `decisions` table existed: dbInit only applies the schema to a DB
38
44
  // it just created, so an in-flight project's DB would otherwise have no
@@ -43,7 +49,10 @@ export function addDecision(db, { taskId, note }) {
43
49
  if (!note) throw new Error('decision requires a note');
44
50
  if (!taskExists(db, taskId)) throw new Error(`no such task: ${taskId}`);
45
51
 
46
- const result = insertDecision(db).run(taskId, note);
52
+ const loggedAt = new Date().toISOString();
53
+ await appendNote(taskId, { kind: 'decision', note, loggedAt }, notesDir);
54
+
55
+ const result = insertDecision(db).run(taskId, note, loggedAt);
47
56
  return { id: Number(result.lastInsertRowid), taskId, note };
48
57
  }
49
58
 
@@ -0,0 +1,121 @@
1
+ // `.hedgehog/notes/<task-id>.json` — the committed record behind `debt`
2
+ // and `decisions` rows.
3
+ //
4
+ // Both tables are operator- or agent-recorded notes with no committed
5
+ // source, in the same position `.hedgehog/reconciled/*.json` was in
6
+ // before reconcile.mjs existed: the only place they live is the DB, and
7
+ // `hedgehog db rebuild` clears and re-derives the DB from committed files
8
+ // on every run (rebuild.mjs#clearDerivedGraph). Before this file existed,
9
+ // rebuild carried debt/decisions across by reading them out of the *same*
10
+ // DB it was about to wipe — which only works because today there is
11
+ // always exactly one DB. A worktree gets its own DB with no memory of
12
+ // what a sibling worktree's DB held, so a note logged there and merged
13
+ // back has nothing for a post-merge rebuild to carry across, and is
14
+ // silently dropped. This file gives both tables the committed source
15
+ // `friction` already has (`.hedgehog/friction/log.md`) and `reconciled`
16
+ // records have (`.hedgehog/reconciled/*.json`), so a rebuild anywhere —
17
+ // worktree or trunk — replays the same notes from the same files.
18
+ //
19
+ // One file per task, holding every debt and decision note logged against
20
+ // it — unlike a reconciliation, which is one-shot per task, `debt add`
21
+ // and `decision add` are routinely called more than once against the same
22
+ // task, so the file is a list, not a single record, and a write appends
23
+ // rather than refusing to overwrite.
24
+
25
+ import { readdir, readFile, mkdir, writeFile, rename, rm } from 'node:fs/promises';
26
+
27
+ export const NOTES_DIR = '.hedgehog/notes';
28
+
29
+ function notesFilePath(taskId, notesDir = NOTES_DIR) {
30
+ return `${notesDir}/${taskId.toLowerCase()}.json`;
31
+ }
32
+
33
+ function validateNotesFile(record, path) {
34
+ if (record === null || typeof record !== 'object' || Array.isArray(record)) {
35
+ throw new Error(`${path}: notes file must be a JSON object`);
36
+ }
37
+ const { task, notes } = record;
38
+ if (!task || typeof task !== 'string') {
39
+ throw new Error(`${path}: notes file requires a "task" id (string)`);
40
+ }
41
+ if (!Array.isArray(notes)) {
42
+ throw new Error(`${path}: notes file requires a "notes" array`);
43
+ }
44
+ for (const entry of notes) {
45
+ if (entry === null || typeof entry !== 'object') {
46
+ throw new Error(`${path}: notes file "${task}" has a non-object entry in notes`);
47
+ }
48
+ if (entry.kind !== 'debt' && entry.kind !== 'decision') {
49
+ throw new Error(
50
+ `${path}: notes file "${task}" has an entry with kind "${entry.kind}" — expected "debt" or "decision"`,
51
+ );
52
+ }
53
+ if (!entry.note || typeof entry.note !== 'string') {
54
+ throw new Error(`${path}: notes file "${task}" has an entry with no "note" (string)`);
55
+ }
56
+ if (!entry.logged_at || typeof entry.logged_at !== 'string') {
57
+ throw new Error(`${path}: notes file "${task}" has an entry with no "logged_at" timestamp`);
58
+ }
59
+ }
60
+ return { task: task.toUpperCase(), notes };
61
+ }
62
+
63
+ // Every notes file in `notesDir`, validated, as a Map from task id to its
64
+ // full { debt: [], decisions: [] } note list — the shape rebuild.mjs
65
+ // replays directly. Absent directory reads as "no notes recorded", the
66
+ // same way overrides.mjs#loadOverrides treats a missing directory.
67
+ export async function loadNotes(notesDir = NOTES_DIR) {
68
+ let entries;
69
+ try {
70
+ entries = await readdir(notesDir);
71
+ } catch {
72
+ return new Map();
73
+ }
74
+
75
+ const byTask = new Map();
76
+ for (const name of entries.filter((n) => n.endsWith('.json')).sort()) {
77
+ const path = `${notesDir}/${name}`;
78
+ let parsed;
79
+ try {
80
+ parsed = JSON.parse(await readFile(path, 'utf8'));
81
+ } catch (err) {
82
+ throw new Error(`could not read notes file ${path}: ${err.message}`);
83
+ }
84
+ const { task, notes } = validateNotesFile(parsed, path);
85
+ byTask.set(task, notes);
86
+ }
87
+ return byTask;
88
+ }
89
+
90
+ // Appends one note to `taskId`'s file, creating it if this is the task's
91
+ // first. Read-modify-write via temp file + rename, so a crash mid-write
92
+ // never leaves a half-written file for loadNotes to trip on —
93
+ // reconcile.mjs#writeReconciledFile's pattern, applied to a file that
94
+ // grows instead of one written once.
95
+ export async function appendNote(taskId, { kind, note, loggedAt }, notesDir = NOTES_DIR) {
96
+ const path = notesFilePath(taskId, notesDir);
97
+
98
+ let existing = [];
99
+ try {
100
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
101
+ existing = validateNotesFile(parsed, path).notes;
102
+ } catch (err) {
103
+ if (!err || err.code !== 'ENOENT') throw err;
104
+ }
105
+
106
+ const record = {
107
+ task: taskId.toUpperCase(),
108
+ notes: [...existing, { kind, note, logged_at: loggedAt }],
109
+ };
110
+
111
+ await mkdir(notesDir, { recursive: true });
112
+ const tempPath = `${path}.tmp-${process.pid}`;
113
+ try {
114
+ await writeFile(tempPath, `${JSON.stringify(record, null, 2)}\n`);
115
+ await rename(tempPath, path);
116
+ } catch (err) {
117
+ await rm(tempPath, { force: true }).catch(() => {});
118
+ throw err;
119
+ }
120
+ return record;
121
+ }
@@ -30,8 +30,8 @@ import {
30
30
  orphanedReconciliations,
31
31
  reconciledNote,
32
32
  RECONCILED_DIR,
33
- RECONCILED_NOTE_PREFIX,
34
33
  } from './reconcile.mjs';
34
+ import { loadNotes, NOTES_DIR } from './notes.mjs';
35
35
 
36
36
  // Alphabetical by filename, purely to make the *tie-break* deterministic
37
37
  // across machines and runs. It is NOT the replay order — see
@@ -61,38 +61,32 @@ function intentExists(db, id) {
61
61
  // back, producing a graph no set of committed intents describes.
62
62
  //
63
63
  // Deleting `intents` cascades through requirements, tasks,
64
- // task_requirements, dependencies, artifacts and verifications every
65
- // one of which this run re-derives. `debt`, `decisions`, and `friction`
66
- // are the exception: they are operator- or agent-recorded notes with no
67
- // committed source, so they are carried across by task id (deterministic,
68
- // so a note re-attaches to the same task the replay recompiles). A note
69
- // whose task no longer exists in the new graph has nowhere to live and is
70
- // reported rather than silently dropped.
64
+ // task_requirements, dependencies, artifacts, verifications, `debt` and
65
+ // `decisions` (all `ON DELETE CASCADE` from `tasks`) every one of which
66
+ // this run re-derives. `debt` and `decisions` are then replayed from
67
+ // `.hedgehog/notes/*.json` (replayNotes, below) rather than carried
68
+ // across from the DB's own prior rows a worktree's own DB never held a
69
+ // sibling worktree's notes, so carrying across only the current DB's rows
70
+ // would silently drop everything logged elsewhere. `friction` keeps its
71
+ // own committed source (`.hedgehog/friction/log.md`, friction.mjs) and is
72
+ // left untouched here — its `task_id` is `ON DELETE SET NULL`, not
73
+ // CASCADE, so its rows outlive this delete unattached rather than being
74
+ // cleared.
71
75
  //
72
- // One class of decision row is excluded from that carry-across: the
73
- // provenance note a reconciliation writes (reconcile.mjs). That one DOES
74
- // have a committed source — `.hedgehog/reconciled/*.json` — and
75
- // replayReconciliations below re-writes it from that file. Carrying it
76
- // across as well would give a reconciled task two identical notes after
77
- // the first rebuild, and one more on every rebuild after that.
76
+ // One class of decision row needs no replay from notes.mjs: the
77
+ // provenance note a reconciliation writes (reconcile.mjs). That one has a
78
+ // different committed source — `.hedgehog/reconciled/*.json` — and
79
+ // replayReconciliations below re-writes it from that file instead.
78
80
  function clearDerivedGraph(db) {
79
- const debt = db.prepare('SELECT task_id, note, logged_at FROM debt').all();
80
- const decisions = db
81
- .prepare('SELECT task_id, note, logged_at FROM decisions')
82
- .all()
83
- .filter((row) => !row.note.startsWith(RECONCILED_NOTE_PREFIX));
84
- const friction = db.prepare('SELECT task_id, note, logged_at FROM friction').all();
85
-
86
81
  db.prepare('DELETE FROM intents').run();
87
- // `friction.task_id` is ON DELETE SET NULL rather than CASCADE, so its
88
- // rows outlive the delete above. Clear them too and let restoreNotes be
89
- // the single writer, so a note is not duplicated against its own copy.
90
- db.prepare('DELETE FROM friction').run();
91
-
92
- return { debt, decisions, friction };
93
82
  }
94
83
 
95
- function restoreNotes(db, { debt, decisions, friction }) {
84
+ // Replays `.hedgehog/notes/*.json` (notes.mjs) the committed record
85
+ // behind every `debt add` / `decision add` call — the same way
86
+ // replayReconciliations below replays `.hedgehog/reconciled/*.json`. A
87
+ // note whose task no longer exists in the new graph has nowhere to live
88
+ // and is reported rather than silently dropped.
89
+ function replayNotes(db, notesByTask) {
96
90
  const taskExists = db.prepare('SELECT 1 FROM tasks WHERE id = ?');
97
91
  const insertDebt = db.prepare(
98
92
  'INSERT INTO debt (task_id, note, logged_at) VALUES (?, ?, ?)',
@@ -100,38 +94,23 @@ function restoreNotes(db, { debt, decisions, friction }) {
100
94
  const insertDecision = db.prepare(
101
95
  'INSERT INTO decisions (task_id, note, logged_at) VALUES (?, ?, ?)',
102
96
  );
103
- const insertFriction = db.prepare(
104
- 'INSERT INTO friction (task_id, note, logged_at) VALUES (?, ?, ?)',
105
- );
106
97
 
107
98
  const orphaned = [];
108
-
109
- for (const row of debt) {
110
- if (taskExists.get(row.task_id) === undefined) {
111
- orphaned.push({ kind: 'debt', taskId: row.task_id, note: row.note });
112
- continue;
113
- }
114
- insertDebt.run(row.task_id, row.note, row.logged_at);
115
- }
116
-
117
- for (const row of decisions) {
118
- if (taskExists.get(row.task_id) === undefined) {
119
- orphaned.push({ kind: 'decision', taskId: row.task_id, note: row.note });
99
+ for (const [taskId, notes] of notesByTask) {
100
+ if (taskExists.get(taskId) === undefined) {
101
+ for (const entry of notes) {
102
+ orphaned.push({ kind: entry.kind, taskId, note: entry.note });
103
+ }
120
104
  continue;
121
105
  }
122
- insertDecision.run(row.task_id, row.note, row.logged_at);
123
- }
124
-
125
- for (const row of friction) {
126
- // friction.task_id is nullable — an unattached note always survives.
127
- if (row.task_id !== null && taskExists.get(row.task_id) === undefined) {
128
- insertFriction.run(null, row.note, row.logged_at);
129
- orphaned.push({ kind: 'friction', taskId: row.task_id, note: row.note });
130
- continue;
106
+ for (const entry of notes) {
107
+ if (entry.kind === 'debt') {
108
+ insertDebt.run(taskId, entry.note, entry.logged_at);
109
+ } else {
110
+ insertDecision.run(taskId, entry.note, entry.logged_at);
111
+ }
131
112
  }
132
- insertFriction.run(row.task_id, row.note, row.logged_at);
133
113
  }
134
-
135
114
  return orphaned;
136
115
  }
137
116
 
@@ -392,17 +371,19 @@ export async function rebuildDb(
392
371
  intentsDir = INTENTS_DIR,
393
372
  overridesDir = OVERRIDES_DIR,
394
373
  reconciledDir = RECONCILED_DIR,
374
+ notesDir = NOTES_DIR,
395
375
  } = {},
396
376
  ) {
397
377
  applySchema(db);
398
378
 
399
- const notes = clearDerivedGraph(db);
379
+ clearDerivedGraph(db);
400
380
 
401
381
  const intentsReplayed = await replayIntents(db, intentsDir);
402
382
 
403
383
  const core = await loadCore(corePath);
404
384
  const overrides = await loadOverrides(overridesDir);
405
385
  const reconciliations = await loadReconciliations(reconciledDir);
386
+ const notesByTask = await loadNotes(notesDir);
406
387
 
407
388
  planTasks(db, core, overrides);
408
389
 
@@ -412,7 +393,7 @@ export async function rebuildDb(
412
393
  const tasksReconciled = replayReconciliations(db, reconciliations);
413
394
  const orphanedReconciled = orphanedReconciliations(db, reconciliations);
414
395
 
415
- const orphanedNotes = restoreNotes(db, notes);
396
+ const orphanedNotes = replayNotes(db, notesByTask);
416
397
 
417
398
  const drift = detectDrift(db, core, { overrides });
418
399
 
package/src/db/verify.mjs CHANGED
@@ -66,18 +66,19 @@ import { OVERRIDES_DIR } 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';
69
+ import { NOTES_DIR } from './notes.mjs';
69
70
 
70
71
  // Build-graph state directories: written by their own command
71
72
  // (`friction add`, `override add`, `intent add`/`db rebuild`, `reconcile
72
- // confirm`), committed by that command's own next step, never by a
73
- // layer's verify_command. A
73
+ // confirm`, `debt add`/`decision add`), committed by that command's own
74
+ // next step, never by a layer's verify_command. A
74
75
  // layer's own work never lands here, so a path under one of these is
75
76
  // never this task's doing regardless of when it changed relative to
76
77
  // claim time — unlike attributedToTask's fingerprint check, which only
77
78
  // excludes a path unchanged since claim and so still attributes a
78
79
  // friction note logged mid-layer (exactly what the loop skill instructs)
79
80
  // to whichever task happened to be building when it was logged.
80
- const BUILD_GRAPH_STATE_DIRS = [FRICTION_DIR, OVERRIDES_DIR, INTENTS_DIR, RECONCILED_DIR];
81
+ const BUILD_GRAPH_STATE_DIRS = [FRICTION_DIR, OVERRIDES_DIR, INTENTS_DIR, RECONCILED_DIR, NOTES_DIR];
81
82
 
82
83
  function isBuildGraphStatePath(path) {
83
84
  return BUILD_GRAPH_STATE_DIRS.some((dir) => path === dir || path.startsWith(`${dir}/`));
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hedgehog",
3
- "version": "6.1.6",
3
+ "version": "6.1.7",
4
4
  "description": "Hedgehog build discipline: ordered, tested, verified build steps.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }
@@ -76,29 +76,22 @@ paragraph.
76
76
  land correctly, and that the specific thing you changed shows up as
77
77
  expected (a new skill directory copied over, a new host's files in the
78
78
  right place, a new blueprint reachable from `hedgehog-core-design`).
79
- 5. **Commit with Conventional Commits.** If the change is already one
80
- logical unit, commit it directly (`feat(hosts): add windsurf support`,
81
- `fix(tweaker): ...`, `docs(roadmap): ...`) Hedgehog's commit format,
82
- same as the one `hedgehog-loop` uses for a project build. If the working
83
- tree has accumulated several unrelated changes that need splitting into
84
- atomic commits, use the `conventional-commits` skill rather than
85
- hand-rolling the split.
79
+ 5. **Commit with Conventional Commits**, in the format the
80
+ `conventional-commits` skill states. If the change is already one
81
+ logical unit, commit it directly. If the working tree has accumulated
82
+ several unrelated changes that need splitting into atomic commits, use
83
+ the `conventional-commits` skill rather than hand-rolling the split.
86
84
  6. **Push and open the PR**, following `pr-writing`'s checklist and shape
87
85
  (CI passing, one change, only verified claims):
88
86
  ```bash
89
87
  git push -u origin <branch-name>
90
- gh pr create --repo skyf0xx/hedgehog --title "<type>(<scope>): <summary>" --body "$(cat <<'EOF'
91
- ## Summary
92
- <1-3 bullets: what changed and why>
93
-
94
- ## Test plan
95
- - [ ] Ran `node bin/cli.mjs init` in a scratch dir and confirmed the change lands correctly
96
- EOF
97
- )"
88
+ gh pr create --repo skyf0xx/hedgehog --title "<type>(<scope>): <summary>" --body "..."
98
89
  ```
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>`).
90
+ The body follows `pr-writing`'s shape (a short Summary, a Test plan
91
+ listing what was actually run for this repo, `node bin/cli.mjs init`
92
+ in a scratch dir, confirming the change lands correctly). If the PR
93
+ closes or addresses a `ROADMAP.md` item or a filed issue, reference it
94
+ (`Addresses the "<item name>" item in ROADMAP.md`, or `Fixes #<n>`).
102
95
  7. **Check CI** with `gh pr checks <number> --repo skyf0xx/hedgehog` after
103
96
  opening. Fix a red check before asking for review.
104
97
  8. **Report the PR URL** `gh` returns and stop — don't merge, don't push