@skyf0xx/hedgehog 6.3.3 → 6.3.4

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
@@ -58,7 +58,7 @@ import {
58
58
  formatMissingRequirements,
59
59
  } from '../src/db/requires.mjs';
60
60
  import { whyPath, formatWhy } from '../src/db/why.mjs';
61
- import { addFriction, listFriction } from '../src/db/friction.mjs';
61
+ import { addFriction, listFriction, resolveFriction } from '../src/db/friction.mjs';
62
62
  import { addDebt, listDebt, resolveDebt } from '../src/db/debt.mjs';
63
63
  import { addDecision, listDecisions } from '../src/db/decision.mjs';
64
64
  import {
@@ -668,7 +668,8 @@ ${bold('Usage')}
668
668
  npx @skyf0xx/hedgehog graph --no-open start (or reuse) the server; print the URL instead
669
669
  npx @skyf0xx/hedgehog why <path> provenance chain for a file
670
670
  npx @skyf0xx/hedgehog friction add "<note>" log a friction note [--task <task-id>]
671
- npx @skyf0xx/hedgehog friction list list logged friction, oldest first
671
+ npx @skyf0xx/hedgehog friction list [--all] list open friction, oldest first (--all includes resolved)
672
+ npx @skyf0xx/hedgehog friction resolve <friction-id> --reason "<why>" mark a friction note resolved
672
673
  npx @skyf0xx/hedgehog debt add <task-id> "<note>" declare debt that lands in dependent tasks' packets
673
674
  npx @skyf0xx/hedgehog debt list [<task-id>] [--all] list open debt, oldest first (--all includes resolved)
674
675
  npx @skyf0xx/hedgehog debt resolve <debt-id> --reason "<why>" mark a debt note resolved
@@ -3299,27 +3300,60 @@ async function frictionCommand(args) {
3299
3300
  }
3300
3301
 
3301
3302
  if (sub === 'list') {
3303
+ const includeResolved = args.includes('--all') || args.includes('--resolved');
3302
3304
  const db = openDb();
3303
3305
  let entries;
3304
3306
  try {
3305
- entries = listFriction(db);
3307
+ entries = listFriction(db, { includeResolved });
3306
3308
  } finally {
3307
3309
  db.close();
3308
3310
  }
3309
3311
 
3310
3312
  if (entries.length === 0) {
3311
- console.log(`${dim('No friction logged.')}\n`);
3313
+ console.log(`${dim(includeResolved ? 'No friction logged.' : 'No open friction.')}\n`);
3312
3314
  return;
3313
3315
  }
3314
3316
  for (const entry of entries) {
3315
3317
  console.log(`#${entry.id} ${dim(entry.loggedAt)}${entry.taskId ? ` ${bold(entry.taskId)}` : ''}`);
3316
- console.log(` ${entry.note}\n`);
3318
+ console.log(` ${entry.note}`);
3319
+ if (entry.resolvedAt) {
3320
+ console.log(` ${green('resolved')} ${dim(entry.resolvedAt)} — ${entry.resolvedReason}`);
3321
+ }
3322
+ console.log('');
3317
3323
  }
3318
3324
  return;
3319
3325
  }
3320
3326
 
3327
+ if (sub === 'resolve') {
3328
+ const frictionId = args[1];
3329
+ const reasonIdx = args.indexOf('--reason');
3330
+ const reason = reasonIdx !== -1 ? args[reasonIdx + 1] : undefined;
3331
+
3332
+ if (!frictionId || frictionId.startsWith('--') || !reason) {
3333
+ console.error(`${red('Usage:')} hedgehog friction resolve <friction-id> --reason "<why>"\n`);
3334
+ process.exitCode = 1;
3335
+ return;
3336
+ }
3337
+
3338
+ const db = openDb();
3339
+ let result;
3340
+ try {
3341
+ result = await resolveFriction(db, { frictionId: Number(frictionId), reason });
3342
+ } catch (err) {
3343
+ console.error(`${red('Failed to resolve friction:')} ${err.message}\n`);
3344
+ process.exitCode = 1;
3345
+ return;
3346
+ } finally {
3347
+ db.close();
3348
+ }
3349
+
3350
+ console.log(` ${green('resolved')} #${result.id}${result.taskId ? ` (${result.taskId})` : ''}`);
3351
+ console.log(` ${dim(result.note)}`);
3352
+ return;
3353
+ }
3354
+
3321
3355
  console.error(
3322
- `${red('Unknown friction subcommand:')} ${sub ?? '(none)'}\n\nUsage: hedgehog friction add "<note>" [--task <task-id>]\n or: hedgehog friction list\n`,
3356
+ `${red('Unknown friction subcommand:')} ${sub ?? '(none)'}\n\nUsage: hedgehog friction add "<note>" [--task <task-id>]\n or: hedgehog friction list [--all]\n or: hedgehog friction resolve <friction-id> --reason "<why>"\n`,
3323
3357
  );
3324
3358
  process.exitCode = 1;
3325
3359
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "6.3.3",
3
+ "version": "6.3.4",
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": {
@@ -33,8 +33,44 @@ export async function addFriction(db, { note, taskId }) {
33
33
  }
34
34
 
35
35
  // Returns every friction row, oldest first, for tweaker's review pass.
36
- export function listFriction(db) {
36
+ // Resolved rows are excluded by default — same convention as
37
+ // listDebt — and included when `includeResolved` is set (`--all`).
38
+ export function listFriction(db, { includeResolved = false } = {}) {
39
+ const where = includeResolved ? '' : 'WHERE resolved_at IS NULL';
37
40
  return db
38
- .prepare(`SELECT id, task_id AS taskId, note, logged_at AS loggedAt FROM friction ORDER BY id ASC`)
41
+ .prepare(
42
+ `SELECT id, task_id AS taskId, note, logged_at AS loggedAt,
43
+ resolved_at AS resolvedAt, resolved_reason AS resolvedReason
44
+ FROM friction ${where} ORDER BY id ASC`,
45
+ )
39
46
  .all();
40
47
  }
48
+
49
+ // Resolves one friction row by id: marks it resolved in the DB and
50
+ // appends the marker to the same committed log (FRICTION_LOG_PATH) the
51
+ // original entry was written to — friction has no `.hedgehog/notes/`
52
+ // record the way debt does, so this is the row's only committed source
53
+ // and `db rebuild` leaves friction rows untouched (see rebuild.mjs).
54
+ export async function resolveFriction(db, { frictionId, reason }) {
55
+ if (!frictionId) throw new Error('friction resolve requires a friction id');
56
+ if (!reason) throw new Error('friction resolve requires a --reason');
57
+
58
+ const row = db
59
+ .prepare('SELECT id, task_id AS taskId, note, resolved_at AS resolvedAt FROM friction WHERE id = ?')
60
+ .get(frictionId);
61
+ if (!row) throw new Error(`no such friction entry: #${frictionId}`);
62
+ if (row.resolvedAt) throw new Error(`friction #${frictionId} is already resolved`);
63
+
64
+ const resolvedAt = new Date().toISOString();
65
+ db.prepare('UPDATE friction SET resolved_at = ?, resolved_reason = ? WHERE id = ?').run(
66
+ resolvedAt,
67
+ reason,
68
+ frictionId,
69
+ );
70
+
71
+ await mkdir(FRICTION_DIR, { recursive: true });
72
+ const header = `## ${resolvedAt} resolved #${frictionId}${row.taskId ? ` ${row.taskId}` : ''}`;
73
+ await appendFile(FRICTION_LOG_PATH, `${header}\n\n${reason}\n\n`);
74
+
75
+ return { id: row.id, taskId: row.taskId, note: row.note, resolvedAt, resolvedReason: reason };
76
+ }
@@ -0,0 +1,116 @@
1
+ // Possibly-satisfied detection: a `planned` task whose scope_globs are
2
+ // already fully covered by git-tracked, committed files with nothing
3
+ // pending inside that scope — the heuristic nudge toward `hedgehog
4
+ // reconcile` for work that landed under a different task's or a
5
+ // different intent's commit, so `db rebuild`'s exact-commit-subject
6
+ // attribution (rebuild.mjs#markCompletedTasks) never had a subject to
7
+ // credit it against and the task sits `planned` forever with no signal.
8
+ //
9
+ // This is evidence, not proof, exactly like reconcile.mjs's
10
+ // gatherEvidence: a scope glob resolving to tracked, clean paths says
11
+ // the files a task would have written already exist and nothing is
12
+ // mid-edit there. It says nothing about whether the task's objective was
13
+ // actually met by that content — a `planned` task can legitimately share
14
+ // a glob with pre-existing, unrelated code that still needs real work.
15
+ // False positives are expected and acceptable; this only flags "look at
16
+ // this", it never completes anything itself.
17
+ //
18
+ // Uses the same git pathspec mechanism verify.mjs's own scope gate uses
19
+ // (`:(glob)<glob>` handed to git literally, no in-process glob dialect of
20
+ // its own to keep in sync with core.mjs's), rather than matchesGlob's
21
+ // witness-path walk — a witness path proves non-containment against a
22
+ // *set* of globs (core.mjs's own use, and coverage's), not "which real
23
+ // files in this repo currently match one glob", which only git's own
24
+ // pathspec matcher can answer without re-walking the whole tree.
25
+
26
+ import { execFileSync } from 'node:child_process';
27
+ import { isEngineStatePath } from './engineState.mjs';
28
+
29
+ function git(args) {
30
+ return execFileSync('git', args, { encoding: 'utf8' });
31
+ }
32
+
33
+ function pathspecsFor(scopeGlobs) {
34
+ return scopeGlobs.map((glob) => `:(glob)${glob}`);
35
+ }
36
+
37
+ // Tracked files under a task's scope, minus engine-state paths — the
38
+ // same exclusion verify.mjs's gate applies, so a task whose scope happens
39
+ // to reach into `.hedgehog/` never gets flagged on the build graph's own
40
+ // bookkeeping.
41
+ function trackedPathsInScope(scopeGlobs) {
42
+ let output;
43
+ try {
44
+ output = git(['ls-files', '--', ...pathspecsFor(scopeGlobs)]);
45
+ } catch {
46
+ return [];
47
+ }
48
+ return output
49
+ .split('\n')
50
+ .map((p) => p.trim())
51
+ .filter(Boolean)
52
+ .filter((p) => !isEngineStatePath(p));
53
+ }
54
+
55
+ // Modified, staged, or untracked paths under a task's scope — anything
56
+ // `git status --porcelain` reports there means the scope is still
57
+ // mid-edit, the opposite of "already satisfied".
58
+ function dirtyPathsInScope(scopeGlobs) {
59
+ let output;
60
+ try {
61
+ output = git(['status', '--porcelain', '--', ...pathspecsFor(scopeGlobs)]);
62
+ } catch {
63
+ return [];
64
+ }
65
+ return output
66
+ .split('\n')
67
+ .map((line) => line.slice(3).trim())
68
+ .filter(Boolean)
69
+ .filter((p) => !isEngineStatePath(p));
70
+ }
71
+
72
+ // Every `planned` task whose scope_globs resolve to at least one tracked
73
+ // file and zero dirty ones — a task with an empty scope match (nothing in
74
+ // the repo yet touches its globs at all) is not flagged, since that is
75
+ // simply unstarted work, not possibly-satisfied work.
76
+ //
77
+ // Only `planned` is read, not `ready`: a `ready` task already cleared
78
+ // every dependency and is next.mjs/claim.mjs's own queue to hand out —
79
+ // flagging it here would tell an operator to reconcile work a claim is
80
+ // about to pick up anyway. `building`/`verifying` are leased and mid-flight
81
+ // by definition; `blocked` has its own NEEDS ATTENTION path; `complete` is
82
+ // done. `planned` is the one status this signal exists for: work nothing
83
+ // has looked at yet, sitting behind whatever unlocked it.
84
+ const PLANNED_TASKS_SQL = `
85
+ SELECT id, layer, objective, scope_globs FROM tasks
86
+ WHERE status = 'planned'
87
+ ORDER BY priority, id;
88
+ `;
89
+
90
+ export function possiblySatisfiedTasks(db) {
91
+ const tasks = db.prepare(PLANNED_TASKS_SQL).all();
92
+ const flagged = [];
93
+ for (const task of tasks) {
94
+ const scopeGlobs = JSON.parse(task.scope_globs);
95
+ if (scopeGlobs.length === 0) continue;
96
+ const tracked = trackedPathsInScope(scopeGlobs);
97
+ if (tracked.length === 0) continue;
98
+ const dirty = dirtyPathsInScope(scopeGlobs);
99
+ if (dirty.length > 0) continue;
100
+ flagged.push({ id: task.id, layer: task.layer, objective: task.objective, paths: tracked });
101
+ }
102
+ return flagged;
103
+ }
104
+
105
+ export function formatPossiblySatisfied(flagged) {
106
+ const lines = [`POSSIBLY SATISFIED ${flagged.length}`];
107
+ for (const { id, layer, paths } of flagged) {
108
+ lines.push(` ${id} ${layer} ${paths.length} tracked path${paths.length === 1 ? '' : 's'} in scope, none pending`);
109
+ }
110
+ lines.push('');
111
+ lines.push(
112
+ ' Scope glob already covered by clean, tracked files — not proof the objective was met.' +
113
+ ' See: hedgehog reconcile',
114
+ );
115
+ return lines.join('\n');
116
+ }
package/src/db/schema.mjs CHANGED
@@ -127,10 +127,12 @@ CREATE TABLE IF NOT EXISTS decisions (
127
127
  );
128
128
 
129
129
  CREATE TABLE IF NOT EXISTS friction (
130
- id INTEGER PRIMARY KEY,
131
- task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
132
- note TEXT NOT NULL,
133
- logged_at TEXT NOT NULL DEFAULT (datetime('now'))
130
+ id INTEGER PRIMARY KEY,
131
+ task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
132
+ note TEXT NOT NULL,
133
+ logged_at TEXT NOT NULL DEFAULT (datetime('now')),
134
+ resolved_at TEXT,
135
+ resolved_reason TEXT
134
136
  );
135
137
  `;
136
138
 
@@ -178,7 +180,7 @@ export function ensureTaskColumns(db) {
178
180
  // hand-set past what MIGRATIONS actually covers, since runMigrations
179
181
  // trusts this number to mean "every migration through this version has
180
182
  // run."
181
- export const CURRENT_SCHEMA_VERSION = 3;
183
+ export const CURRENT_SCHEMA_VERSION = 4;
182
184
 
183
185
  // Forward migrations, applied in order to bring a graph's user_version up
184
186
  // to CURRENT_SCHEMA_VERSION. Unlike the CREATE TABLE IF NOT EXISTS /
@@ -223,6 +225,18 @@ const MIGRATIONS = [
223
225
  }
224
226
  },
225
227
  },
228
+ {
229
+ version: 4,
230
+ // Same shape as version 3's debt columns, for friction — an existing
231
+ // graph's rows default to open the moment these columns exist.
232
+ migrate: (db) => {
233
+ const existing = new Set(db.prepare('PRAGMA table_info(friction)').all().map((row) => row.name));
234
+ if (!existing.has('resolved_at')) db.exec('ALTER TABLE friction ADD COLUMN resolved_at TEXT');
235
+ if (!existing.has('resolved_reason')) {
236
+ db.exec('ALTER TABLE friction ADD COLUMN resolved_reason TEXT');
237
+ }
238
+ },
239
+ },
226
240
  ];
227
241
 
228
242
  // Brings a graph's `PRAGMA user_version` up to CURRENT_SCHEMA_VERSION,
package/src/db/status.mjs CHANGED
@@ -17,6 +17,7 @@ import { listFriction } from './friction.mjs';
17
17
  import { orphanedOverrides } from './overrides.mjs';
18
18
  import { RECONCILED_DIR, RECONCILED_NOTE_PREFIX } from './reconcile.mjs';
19
19
  import { formatMissingRequirements } from './requires.mjs';
20
+ import { possiblySatisfiedTasks, formatPossiblySatisfied } from './satisfied.mjs';
20
21
  import { readyTasks, heldBackReason } from './ready.mjs';
21
22
  import { worktreeStatus } from './worktree.mjs';
22
23
  import { findClaimableTasks } from './claim.mjs';
@@ -219,6 +220,15 @@ function countFriction(db) {
219
220
  // in every count and list here. Reported unconditionally, not as a
220
221
  // warning: reconciling is a supported act, and the point is that the
221
222
  // distinction stays visible after the session that made it is gone.
223
+ //
224
+ // `possiblySatisfied` (satisfied.mjs) is a `planned` task whose scope
225
+ // globs already resolve to tracked, clean files — a heuristic nudge, not
226
+ // a completion: `db rebuild`'s attribution only credits a task whose own
227
+ // commit_message appears as some commit's subject (rebuild.mjs), so work
228
+ // that landed inside a commit made for a different task never has a
229
+ // subject to match, and the task stays `planned` with no other signal
230
+ // that its scope may already be covered. Read-only, like drift and
231
+ // orphaned overrides — nothing here is ever auto-completed.
222
232
  // Synchronous, as it always was: `boundary.mjs#boundaryState` (a hot path
223
233
  // that only ever reads `graph.inFlight`) depends on that, and every other
224
234
  // section here is a plain SQL read with no I/O to await. The worktree
@@ -236,6 +246,7 @@ export function graphStatus(db, { core = null, overrides = new Map() } = {}) {
236
246
  const debt = loadDebtByTask(db);
237
247
  const frictionCount = countFriction(db);
238
248
  const reconciled = loadReconciledTasks(db);
249
+ const possiblySatisfied = possiblySatisfiedTasks(db);
239
250
  const total = Object.values(counts).reduce((a, b) => a + b, 0);
240
251
  return {
241
252
  counts,
@@ -248,6 +259,7 @@ export function graphStatus(db, { core = null, overrides = new Map() } = {}) {
248
259
  debt,
249
260
  frictionCount,
250
261
  reconciled,
262
+ possiblySatisfied,
251
263
  total,
252
264
  };
253
265
  }
@@ -293,6 +305,7 @@ export function formatStatus({
293
305
  debt = [],
294
306
  frictionCount = 0,
295
307
  reconciled = [],
308
+ possiblySatisfied = [],
296
309
  worktrees = { active: [], orphaned: [] },
297
310
  total,
298
311
  missingRequirements,
@@ -381,6 +394,15 @@ export function formatStatus({
381
394
  lines.push(formatDrift(drift));
382
395
  }
383
396
 
397
+ // Below drift, same family of "condition of the graph as a whole" —
398
+ // and above orphaned overrides, since a possibly-satisfied task is work
399
+ // an operator can act on right now (`hedgehog reconcile`), where an
400
+ // orphaned override is only ever inert.
401
+ if (possiblySatisfied.length > 0) {
402
+ lines.push('');
403
+ lines.push(formatPossiblySatisfied(possiblySatisfied));
404
+ }
405
+
384
406
  // Below drift because it's the rarer of the two and never blocks the
385
407
  // build: an orphaned override is work the operator asked for that isn't
386
408
  // happening, not work the graph is getting wrong. `hedgehog override
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hedgehog",
3
- "version": "6.3.3",
3
+ "version": "6.3.4",
4
4
  "description": "Hedgehog build discipline: ordered, tested, verified build steps.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }
@@ -47,7 +47,7 @@
47
47
  {
48
48
  "name": "adopted",
49
49
  "package": "@skyf0xx/hedgehog-core-adopted",
50
- "version": "^1.1.0",
50
+ "version": ">=1.0.0 <2.0.0",
51
51
  "language": "typescript",
52
52
  "repository": "https://github.com/skyf0xx/hedgehog-core-adopted",
53
53
  "selects_when": "The description is about bringing Hedgehog's discipline to a codebase that already exists, rather than building something new — the repo already has real source files, or the user says so explicitly: \"adopt this repo\", \"add Hedgehog to my existing project\", \"I want scope/verify enforcement on my changes here\". Not chosen by matching a `when` paragraph the way a shipped core is: hedgehog-adopt reads the repo read-only, proposes a linear-chain .hedgehog/core.yaml whose verify commands are the repo's own, and writes only .hedgehog/ — never a workspace, never a stack migration. This is the core most often confused with authored: adopted brings discipline to an existing repo, while authored designs and scaffolds a workspace from scratch for something being built new — route here only when the work is landing on a codebase that already exists."