@hegemonart/get-design-done 1.57.0 → 1.57.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "Get Design Done — 5-stage agent-orchestrated design pipeline with 9 connections, handoff-first workflow, bidirectional Figma write-back, 22+ specialized agents, queryable knowledge layer (intel store, dependency analysis, learnings extraction), and a self-improvement loop (reflector, frontmatter + budget feedback, global-skills layer). v1.20.0 ships the SDK foundation: gdd-state MCP server (11 typed tools), lockfile-safe STATE.md mutations, event stream, and resilience primitives (jittered-backoff, rate-guard, error-classifier, iteration-budget) for rate-limit + 429 + context-overflow recovery. Full CI/CD pipeline (Node 22/24 × Linux/macOS/Windows) and release automation (auto-tag + GitHub Release + release-time smoke test).",
8
- "version": "1.57.0"
8
+ "version": "1.57.1"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "get-design-done",
13
13
  "source": "./",
14
14
  "description": "Agent-orchestrated 5-stage design pipeline: Brief → Explore → Plan → Design → Verify. 22+ specialized agents, 9 connections (Figma, Refero, Preview, Storybook, Chromatic, Figma Writer, Graphify, Pinterest, Claude Design), Claude Design handoff, bidirectional Figma write-back, and a queryable intel store (.design/intel/) for dependency and learnings queries. Standalone commands: style, darkmode, compare, figma-write, graphify, handoff, analyze-dependencies, skill-manifest, extract-learnings. Embeds NNG heuristics, WCAG thresholds, typographic systems, motion framework, and anti-pattern catalog. Ships with a full CI/CD pipeline (Node 22/24 × Linux/macOS/Windows) and release automation. Optimization layer (v1.0.4.1, retroactive): gdd-router + gdd-cache-manager skills, PreToolUse budget-enforcer hook, tier-aware agent frontmatter, lazy checker gates, streaming synthesizer, /gdd:warm-cache + /gdd:optimize commands, and cost telemetry at .design/telemetry/costs.jsonl — targeting 50-70% per-task token-cost reduction with no quality-floor regression. v1.20.0 SDK foundation: gdd-state MCP server (11 typed tools), lockfile-safe STATE.md mutations, event stream at .design/telemetry/events.jsonl, resilience primitives (jittered-backoff, rate-guard, error-classifier, iteration-budget) with rate-limit + 429 + context-overflow recovery, and TypeScript toolchain.",
15
- "version": "1.57.0",
15
+ "version": "1.57.1",
16
16
  "author": {
17
17
  "name": "hegemonart"
18
18
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "get-design-done",
3
3
  "short_name": "gdd",
4
- "version": "1.57.0",
4
+ "version": "1.57.1",
5
5
  "description": "Agent-orchestrated 5-stage design pipeline: Brief → Explore → Plan → Design → Verify. 59 specialized agents, 88 skills, 41 connection integrations (Figma, Refero, Preview, Storybook, Chromatic, Graphify, Slack, Linear, Jira, Notion, and more), handoff-first workflow via Claude Design bundles, bidirectional Figma write-back (annotations, Code Connect), queryable intel store (`.design/intel/`) for O(1) design surface lookups, and self-improvement loop (reflector agent, frontmatter + budget feedback, global-skills layer at `~/.claude/gdd/global-skills/`). Standalone commands: style, darkmode, compare, figma-write, graphify, handoff, analyze-dependencies, skill-manifest, extract-learnings, reflect, apply-reflections. Embeds NNG heuristics, WCAG thresholds, typographic systems, motion framework, and anti-pattern catalog. Ships with a full CI/CD pipeline (Node 22/24 × Linux/macOS/Windows, lint + schema + frontmatter + stale-ref + shellcheck + gitleaks + injection-scan + blocking size-budget) and release automation (auto-tag + GitHub Release + release-time smoke test). Optimization layer (v1.0.4.1, retroactive): gdd-router + gdd-cache-manager skills, PreToolUse budget-enforcer hook, tier-aware agent frontmatter, lazy checker gates, streaming synthesizer, /gdd:warm-cache + /gdd:optimize commands, and cost telemetry at .design/telemetry/costs.jsonl — targeting 50-70% per-task token-cost reduction with no quality-floor regression. v1.20.0 SDK foundation: gdd-state MCP server (11 typed tools), lockfile-safe STATE.md mutations, event stream at .design/telemetry/events.jsonl, resilience primitives (jittered-backoff, rate-guard, error-classifier, iteration-budget) with rate-limit + 429 + context-overflow recovery, and TypeScript toolchain. v1.27.7 ships gdd-mcp (Phase 27.7): 12 read-only MCP tools for sub-3s priming. v1.28.0 (Phase 28): Foundational References Tier 2 — 5 new reference files (color-theory, composition, proportion-systems, i18n, contrast-advanced), 2 verifier i18n probes + 1 explore i18n-readiness probe, 12 additive cross-link insertions across 10 existing references, 2 orthogonal audit-scoring lens-tags (composition_alignment + i18n_readiness).",
6
6
  "author": {
7
7
  "name": "hegemonart",
package/CHANGELOG.md CHANGED
@@ -4,6 +4,34 @@ All notable changes to get-design-done are documented here. Versions follow [sem
4
4
 
5
5
  ---
6
6
 
7
+ ## [1.57.1] - 2026-06-03
8
+
9
+ ### Fixed
10
+
11
+ Post-wave debug analysis (a 4-agent sweep after Phase 57) found and fixed a set of latent bugs that surface only when
12
+ `better-sqlite3` is installed (the CI surface, which has no module, was unaffected - so these never failed CI but did
13
+ degrade real users who have the module). No new dependency; the markdown floor is unchanged.
14
+
15
+ - **Recall returned nothing for every `.md` file when better-sqlite3 was present.** `scripts/lib/design-search.cjs`
16
+ passed unquoted query terms (e.g. `heuristics.md OR reference/heuristics.md`) to FTS5, whose trigram tokenizer rejects
17
+ `.` and `/` as a syntax error; the error was swallowed and recall came back empty. Each term is now double-quoted
18
+ (matching the instinct-store pattern), so FTS5 recall matches the JS-scan fallback.
19
+ - **The Phase 57 fact-force freshness guard silently discarded hand-edits.** `state-store.cjs` detected an out-of-band
20
+ STATE.md edit but its re-sync body was empty, so the edit was overwritten and lost. It now folds the hand-edit back
21
+ into SQLite before the next mutation.
22
+ - **Blocker rows duplicated on every re-migration** (no `ON CONFLICT`); migration now clears a cycle's blockers before
23
+ re-inserting. **FTS5 virtual tables were never populated** by the migration; they are now. **`/gdd:state recover`**
24
+ never awaited the async migration (always reported corruption); it is now async. **State getters threw** on an absent
25
+ `state.sqlite` (exposing the fact-force hook); they now return empty. **`migrationActive`** guards against a directory
26
+ named `state.sqlite`. **`WITH ... SELECT` CTEs** are allowed by the read-only query surface.
27
+ - **The `risk_assessment` event did not conform to its own schema** (`tool`/`score` instead of `tool_name`/`risk_score`,
28
+ no `event_id`, extra fields); `hooks/gdd-risk-gate.js` now emits the schema-correct shape, and an Ajv validation test
29
+ guards it. The **dashboard risk column** read the wrong fields and case-mismatched the action vocabulary, so it was
30
+ permanently blank; it is now wired and case-correct.
31
+ - **`budget-enforcer` PreToolUse blocks** used `message` instead of `stopReason`, so the block reason was invisible to
32
+ the user; they now use `stopReason`. The **read-injection scanner** loads its pattern file fail-open (a missing file
33
+ no longer crashes the hook). Three package-root walk-ups now match the scoped package name.
34
+
7
35
  ## [1.57.0] - 2026-06-03
8
36
 
9
37
  ### Phase 57 - SQLite State Backbone (Cross-Session Query Layer)
@@ -332,6 +332,7 @@ interface ToolOutput {
332
332
  continue: boolean;
333
333
  suppressOutput?: boolean;
334
334
  message?: string;
335
+ stopReason?: string;
335
336
  modified_tool_input?: ToolInput;
336
337
  cached_result?: unknown;
337
338
  }
@@ -1046,7 +1047,7 @@ export async function main(): Promise<void> {
1046
1047
  const response: ToolOutput = {
1047
1048
  continue: false,
1048
1049
  suppressOutput: false,
1049
- message: `gdd-budget-enforcer: rate-limited on anthropic, retry in ${waitSeconds}s (resetAt=${rateState.resetAt})`,
1050
+ stopReason: `gdd-budget-enforcer: rate-limited on anthropic, retry in ${waitSeconds}s (resetAt=${rateState.resetAt})`,
1050
1051
  };
1051
1052
  process.stdout.write(JSON.stringify(response));
1052
1053
  return;
@@ -1121,7 +1122,7 @@ export async function main(): Promise<void> {
1121
1122
  const response: ToolOutput = {
1122
1123
  continue: false,
1123
1124
  suppressOutput: false,
1124
- message: `Project budget cap reached: $${projClass.spend.toFixed(2)} of $${budget.project_cap_usd.toFixed(2)} (${projClass.pct.toFixed(0)}%). Raise project_cap_usd in .design/budget.json, or set project_cap_enforcement_mode to "warn" to keep going. (Graceful halt — the current stage's earlier spawns already completed; this blocks the next one.)`,
1125
+ stopReason: `Project budget cap reached: $${projClass.spend.toFixed(2)} of $${budget.project_cap_usd.toFixed(2)} (${projClass.pct.toFixed(0)}%). Raise project_cap_usd in .design/budget.json, or set project_cap_enforcement_mode to "warn" to keep going. (Graceful halt — the current stage's earlier spawns already completed; this blocks the next one.)`,
1125
1126
  };
1126
1127
  process.stdout.write(JSON.stringify(response));
1127
1128
  return;
@@ -1162,7 +1163,7 @@ export async function main(): Promise<void> {
1162
1163
  const response: ToolOutput = {
1163
1164
  continue: false,
1164
1165
  suppressOutput: false,
1165
- message: `Budget cap reached for ${capLabel}. Estimated: $${estCost.toFixed(4)}, cap: $${perSpawnCap.toFixed(2)}. Raise cap in .design/budget.json or retry after next task.`,
1166
+ stopReason: `Budget cap reached for ${capLabel}. Estimated: $${estCost.toFixed(4)}, cap: $${perSpawnCap.toFixed(2)}. Raise cap in .design/budget.json or retry after next task.`,
1166
1167
  };
1167
1168
  process.stdout.write(JSON.stringify(response));
1168
1169
  return;
@@ -1187,7 +1188,7 @@ export async function main(): Promise<void> {
1187
1188
  const response: ToolOutput = {
1188
1189
  continue: false,
1189
1190
  suppressOutput: false,
1190
- message: `Budget cap reached for per-phase (${phase}). Cumulative: $${(phaseSpend + estCost).toFixed(4)}, cap: $${budget.per_phase_cap_usd.toFixed(2)}. Raise cap in .design/budget.json or retry after next phase.`,
1191
+ stopReason: `Budget cap reached for per-phase (${phase}). Cumulative: $${(phaseSpend + estCost).toFixed(4)}, cap: $${budget.per_phase_cap_usd.toFixed(2)}. Raise cap in .design/budget.json or retry after next phase.`,
1191
1192
  };
1192
1193
  process.stdout.write(JSON.stringify(response));
1193
1194
  return;
@@ -13,9 +13,32 @@
13
13
 
14
14
  const fs = require('fs');
15
15
  const path = require('path');
16
- const { matches } = require(path.join(__dirname, '..', 'scripts', 'lib', 'glob-match.cjs'));
17
16
 
18
- const REPO_ROOT = path.resolve(__dirname, '..');
17
+ /**
18
+ * Walk up from startDir to find the package root by looking for a
19
+ * package.json with name '@hegemonart/get-design-done'. Returns null
20
+ * when the root cannot be found (e.g. in unusual installed layouts).
21
+ * Mirrors the pattern used by gdd-fact-force.js / gdd-risk-gate.js
22
+ * (Phase 56+) to be robust against esbuild/installed layouts that
23
+ * may relocate or rewrite __dirname.
24
+ */
25
+ function findPackageRoot(startDir) {
26
+ let dir = startDir;
27
+ for (let i = 0; i < 12; i++) {
28
+ try {
29
+ const pkg = require(path.join(dir, 'package.json'));
30
+ if (pkg && pkg.name === '@hegemonart/get-design-done') return dir;
31
+ } catch { /* not this level */ }
32
+ const parent = path.dirname(dir);
33
+ if (parent === dir) break;
34
+ dir = parent;
35
+ }
36
+ return null;
37
+ }
38
+
39
+ const REPO_ROOT = findPackageRoot(__dirname) || path.resolve(__dirname, '..');
40
+
41
+ const { matches } = require(path.join(REPO_ROOT, 'scripts', 'lib', 'glob-match.cjs'));
19
42
 
20
43
  function loadProtectedPaths(cwd) {
21
44
  const defaultFile = path.join(REPO_ROOT, 'reference', 'protected-paths.default.json');
@@ -79,7 +79,15 @@ function loadPatterns(): readonly RegExp[] {
79
79
  );
80
80
  }
81
81
 
82
- const INJECTION_PATTERNS: readonly RegExp[] = loadPatterns();
82
+ // Wrapped in try/catch so a missing injection-patterns.cjs does not throw
83
+ // at import time (before main()'s catch guard is active). On failure the
84
+ // hook falls back to an empty pattern list and emits a passthrough result.
85
+ let INJECTION_PATTERNS: readonly RegExp[];
86
+ try {
87
+ INJECTION_PATTERNS = loadPatterns();
88
+ } catch {
89
+ INJECTION_PATTERNS = [];
90
+ }
83
91
 
84
92
  // ── Types ───────────────────────────────────────────────────────────────────
85
93
 
@@ -2,6 +2,9 @@
2
2
  'use strict';
3
3
  /**
4
4
  * hooks/gdd-risk-gate.js — PreToolUse:Write|Edit|MultiEdit|Bash risk gate (Phase 56, RISK-02).
5
+ * Payload shape locked to RiskAssessmentPayload (events.schema.json): event_id, tool_name,
6
+ * risk_score, suggested_action, reasons (required). Optional: agent, decision_context.
7
+ * additionalProperties:false — do NOT add breakdown/paths/score/tool to the payload.
5
8
  *
6
9
  * Quantifies the confidence/risk of a writer action with the PURE scorer
7
10
  * `scripts/lib/risk/compute-risk.cjs` (executor A), emits a `risk_assessment`
@@ -39,6 +42,7 @@
39
42
 
40
43
  const fs = require('fs');
41
44
  const path = require('path');
45
+ const { randomUUID } = require('node:crypto');
42
46
 
43
47
  // ── Package-root walk-up: locate scripts/lib/risk/compute-risk.cjs ──────────
44
48
  // Start at this file's dir and climb until we find the risk module (or a
@@ -325,7 +329,14 @@ async function main() {
325
329
  );
326
330
  } catch { /* swallow */ }
327
331
  emitHookFired('allow', { reason: 'scorer-error' });
328
- emitRiskAssessment({ tool, agent: agent || undefined, error: 'scorer-error', suggested_action: 'allow' }, sessionId);
332
+ emitRiskAssessment({
333
+ event_id: randomUUID(),
334
+ tool_name: tool,
335
+ risk_score: 0,
336
+ suggested_action: 'allow',
337
+ reasons: [],
338
+ agent: agent || undefined,
339
+ }, sessionId);
329
340
  process.stdout.write(JSON.stringify(ALLOW));
330
341
  return;
331
342
  }
@@ -337,13 +348,12 @@ async function main() {
337
348
  // distribution. Best-effort.
338
349
  emitRiskAssessment(
339
350
  {
340
- tool,
341
- agent: agent || undefined,
342
- score: assessment.score,
351
+ event_id: randomUUID(),
352
+ tool_name: tool,
353
+ risk_score: assessment.score,
343
354
  suggested_action: action,
344
- reasons: assessment.reasons,
345
- breakdown: assessment.breakdown,
346
- paths: assessment.breakdown && assessment.breakdown.paths,
355
+ reasons: Array.isArray(assessment.reasons) ? assessment.reasons : [],
356
+ agent: agent || undefined,
347
357
  },
348
358
  sessionId,
349
359
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hegemonart/get-design-done",
3
- "version": "1.57.0",
3
+ "version": "1.57.1",
4
4
  "description": "A design-quality pipeline for AI coding agents: brief, plan, implement, and verify UI work against your design system.",
5
5
  "author": "Hegemon",
6
6
  "homepage": "https://github.com/hegemonart/get-design-done",
@@ -124,14 +124,32 @@ function reindex(projectRoot) {
124
124
  db.close();
125
125
  }
126
126
 
127
+ /**
128
+ * Re-wrap each whitespace/OR-delimited term in FTS5 double-quote escaping so
129
+ * that tokens containing `.`, `/`, `-`, or other punctuation that the trigram
130
+ * tokenizer treats as illegal bare-term characters are accepted as quoted
131
+ * phrase queries. This matches the pattern used in instinct-store.cjs.
132
+ *
133
+ * Examples:
134
+ * "heuristics.md OR reference/heuristics.md"
135
+ * -> '"heuristics.md" OR "reference/heuristics.md"'
136
+ * "color tokens"
137
+ * -> '"color" OR "tokens"'
138
+ */
139
+ function _quoteFts5Query(rawQuery) {
140
+ const terms = rawQuery.split(/\s+OR\s+|\s+/).filter(Boolean);
141
+ return terms.map(t => '"' + t.replace(/"/g, '""') + '"').join(' OR ');
142
+ }
143
+
127
144
  function _searchFts5(query, projectRoot, limit) {
128
145
  const dbPath = _dbPath(projectRoot);
129
146
  if (!fs.existsSync(dbPath)) reindex(projectRoot);
130
147
  const db = new Database(dbPath, { readonly: true });
131
148
  try {
149
+ const matchExpr = _quoteFts5Query(query);
132
150
  const rows = db.prepare(
133
151
  `SELECT file, line, text FROM docs WHERE docs MATCH ? ORDER BY rank LIMIT ?`
134
- ).all(query, limit);
152
+ ).all(matchExpr, limit);
135
153
  return rows.map(r => ({ file: r.file, line: r.line, text: r.text }));
136
154
  } finally {
137
155
  db.close();
@@ -203,4 +221,4 @@ function search(query, projectRoot, opts = {}) {
203
221
  return _searchNode(query, projectRoot, limit);
204
222
  }
205
223
 
206
- module.exports = { search, reindex, backendName };
224
+ module.exports = { search, reindex, backendName, _quoteFts5Query };
@@ -47,7 +47,7 @@ function findPackageRoot(startDir) {
47
47
  try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); } catch { pkg = null; }
48
48
  if (pkg) {
49
49
  if (firstWithPkg === null) firstWithPkg = dir;
50
- if (pkg.name === 'get-design-done') return dir;
50
+ if (pkg.name === '@hegemonart/get-design-done') return dir;
51
51
  }
52
52
  const parent = path.dirname(dir);
53
53
  if (parent === dir) break;
@@ -214,19 +214,24 @@ function buildDryRunDiff(ops) {
214
214
  * Migrate .design/STATE.md (and supplementary stores) into the SQLite database.
215
215
  *
216
216
  * @param {object} opts
217
- * @param {string} [opts.projectRoot] - project root dir (defaults to cwd / env)
218
- * @param {boolean} [opts.dryRun=false] - wrap writes in BEGIN/ROLLBACK + print diff
219
- * @param {boolean} [opts.force=false] - same as --migrate-state flag; required to actually write
217
+ * @param {string} [opts.projectRoot] - project root dir (defaults to cwd / env)
218
+ * @param {string} [opts.statePath] - explicit path to STATE.md (overrides projectRoot lookup)
219
+ * @param {boolean} [opts.dryRun=false] - wrap writes in BEGIN/ROLLBACK + print diff
220
+ * @param {boolean} [opts.force=false] - same as --migrate-state flag; required to actually write
221
+ * @param {boolean} [opts.upsertOnly=false] - re-parse markdown and UPSERT without wiping unrelated rows
222
+ * (used by the R8 freshness guard to fold hand-edits into SQLite)
220
223
  * @returns {Promise<{migrated:boolean, tables:object, dryRun:boolean, skipped:boolean, reason:string}>}
221
224
  */
222
225
  async function migrateToSqlite(opts = {}) {
223
- const { dryRun = false, force = false } = opts;
226
+ const { dryRun = false, force = false, upsertOnly = false } = opts;
227
+ // upsertOnly implies force (it's always an internal call, not user-facing opt-in).
228
+ const effectiveForce = force || upsertOnly;
224
229
  const projectRoot = resolveProjectRoot(opts.projectRoot);
225
230
 
226
231
  // Opt-in guard: --migrate-state / force required.
227
232
  // This fires first (before the SQLite probe) so the message is consistent
228
233
  // regardless of whether better-sqlite3 is installed.
229
- if (!force) {
234
+ if (!effectiveForce) {
230
235
  const notice =
231
236
  'Migration is opt-in in v1.57.0. Re-run with --migrate-state to proceed.';
232
237
  return {
@@ -254,7 +259,7 @@ async function migrateToSqlite(opts = {}) {
254
259
  }
255
260
 
256
261
  // Read STATE.md.
257
- const statePath = path.join(projectRoot, '.design', 'STATE.md');
262
+ const statePath = opts.statePath || path.join(projectRoot, '.design', 'STATE.md');
258
263
  if (!fs.existsSync(statePath)) {
259
264
  return {
260
265
  migrated: false,
@@ -440,9 +445,20 @@ async function migrateToSqlite(opts = {}) {
440
445
  );
441
446
  counts.decisions++;
442
447
  ops.push({ action: 'upsert', table: 'decisions', id: d.id, fields: { status: d.status, body_md: d.text, raw_line: rawLine } });
448
+ // BUG-05: populate FTS5 table so queryDecisions returns hits.
449
+ // FTS5 virtual tables do not support ON CONFLICT — use DELETE + INSERT pattern.
450
+ // Guard: if FTS5 tables are absent (no-fts5 build), skip without throwing.
451
+ try {
452
+ db.prepare('DELETE FROM decisions_fts WHERE id = ?').run(d.id);
453
+ db.prepare('INSERT INTO decisions_fts (id, body_md, tags) VALUES (?, ?, ?)').run(d.id, d.text, null);
454
+ } catch { /* FTS5 table absent - skip */ }
443
455
  }
444
456
 
445
457
  // --- blockers ---
458
+ // BUG-02: DELETE existing rows for this cycle_id before re-inserting to prevent duplication.
459
+ // The blockers table uses AUTOINCREMENT PK with no natural-key ON CONFLICT, so
460
+ // re-running migrate without this delete would DUPLICATE every blocker row.
461
+ db.prepare('DELETE FROM blockers WHERE cycle_id = ?').run(cycleId);
446
462
  for (let i = 0; i < blockers.length; i++) {
447
463
  const b = blockers[i];
448
464
  const rawLine = `[${b.stage}] [${b.date}]: ${b.text}`;
@@ -128,8 +128,13 @@ function _firstToken(sql) {
128
128
  }
129
129
 
130
130
  /**
131
- * Assert that the SQL query is a safe readonly SELECT.
132
- * Throws with a descriptive message when the first token is denied or not SELECT.
131
+ * Assert that the SQL query is a safe readonly SELECT (or CTE: WITH ... SELECT).
132
+ * Throws with a descriptive message when the first token is denied or not SELECT/WITH.
133
+ *
134
+ * BUG-11: allow a leading WITH token for CTEs (WITH ... SELECT ...).
135
+ * The engine-level readonly connection already blocks any write CTE.
136
+ * We keep blocking all other non-SELECT first tokens.
137
+ *
133
138
  * @param {string} sql
134
139
  */
135
140
  function _assertReadonly(sql) {
@@ -142,7 +147,8 @@ function _assertReadonly(sql) {
142
147
  `query-surface: statement type '${token}' is not allowed (denylist). Only SELECT is permitted.`
143
148
  );
144
149
  }
145
- if (token !== 'SELECT') {
150
+ // Allow WITH for CTEs (WITH ... SELECT ...) — engine readonly blocks any write CTE.
151
+ if (token !== 'SELECT' && token !== 'WITH') {
146
152
  throw new Error(
147
153
  `query-surface: first token '${token}' is not SELECT. Only SELECT queries are permitted.`
148
154
  );
@@ -305,10 +311,13 @@ function demigrate(opts = {}) {
305
311
  * 3. Invoke migrate-to-sqlite with force:true to rebuild from markdown.
306
312
  * 4. Run integrity_check on the new database.
307
313
  *
314
+ * BUG-03/08: recover() is now async — it awaits migrateToSqlite() so that
315
+ * state.sqlite actually exists before the integrity check is run.
316
+ *
308
317
  * @param {{ projectRoot?: string, dbPath?: string }} [opts]
309
- * @returns {{ recovered: boolean, message: string, integrity?: boolean }}
318
+ * @returns {Promise<{ recovered: boolean, message: string, integrity?: boolean }>}
310
319
  */
311
- function recover(opts = {}) {
320
+ async function recover(opts = {}) {
312
321
  const backend = _requireBackend();
313
322
  if (!backend || backend.BACKEND !== 'sqlite') {
314
323
  return {
@@ -343,13 +352,16 @@ function recover(opts = {}) {
343
352
  let migrateResult = null;
344
353
  try {
345
354
  if (typeof migrate.migrateToSqlite === 'function') {
346
- migrateResult = migrate.migrateToSqlite({
355
+ // BUG-03/08: await the async migrateToSqlite so state.sqlite is written before
356
+ // the integrity check below. Without await, recover() returned before the DB
357
+ // existed, causing integrity:false on every call.
358
+ migrateResult = await migrate.migrateToSqlite({
347
359
  projectRoot: opts.projectRoot,
348
360
  dbPath,
349
361
  force: true,
350
362
  });
351
363
  } else if (typeof migrate.migrate === 'function') {
352
- migrateResult = migrate.migrate({ projectRoot: opts.projectRoot, dbPath, force: true });
364
+ migrateResult = await migrate.migrate({ projectRoot: opts.projectRoot, dbPath, force: true });
353
365
  } else {
354
366
  return { recovered: false, message: 'recover: migrate-to-sqlite.cjs has no recognized export.' };
355
367
  }
@@ -376,21 +376,33 @@ function renderStateMarkdown(db, cycle_id, sdk) {
376
376
  }
377
377
 
378
378
  // --- blockers raw_body ---
379
- // Only unresolved blockers go in the STATE.md <blockers> block.
379
+ // activeBlockers is used both for raw_bodies reconstruction and the blockers state array below.
380
380
  const activeBlockers = blockerRows.filter((r) => !r.resolved_at);
381
- if (activeBlockers.length > 0) {
382
- const lines = activeBlockers.map((row) => {
383
- // ALWAYS prefer raw_line for blockers (date-format hazard).
384
- if (row.raw_line) return row.raw_line;
385
- return canonicalBlocker({ stage: row.stage || '', date: row.date || '', text: row.body_md || '' });
386
- });
387
- raw_bodies.blockers = lines.join('\n');
388
- } else if ('blockers' in blockGaps) {
389
- raw_bodies.blockers = blockRawBodies['blockers'] !== undefined
390
- ? blockRawBodies['blockers']
391
- : '';
392
- } else if (blockRawBodies['blockers'] !== undefined) {
393
- raw_bodies.blockers = blockRawBodies['blockers'];
381
+
382
+ // BUG-09: when _block_meta stores a non-null raw_body for 'blockers', emit it
383
+ // verbatim (like unstructured blocks). This preserves comment lines inside
384
+ // <blockers> that would otherwise be silently dropped when rebuilding from rows.
385
+ //
386
+ // Fall through to row-reconstruction only when raw_body is absent (null), which
387
+ // happens after an appendBlocker() call that doesn't update _block_meta.raw_body.
388
+ const blockersRawBody = blockRawBodies['blockers'];
389
+ if (blockersRawBody !== undefined && blockersRawBody !== null) {
390
+ // Verbatim round-trip: emit stored raw_body (preserves comments).
391
+ raw_bodies.blockers = blockersRawBody;
392
+ } else {
393
+ // Reconstruct from rows (no stored raw_body).
394
+ // Only unresolved blockers go in the STATE.md <blockers> block.
395
+ if (activeBlockers.length > 0) {
396
+ const lines = activeBlockers.map((row) => {
397
+ // ALWAYS prefer raw_line for blockers (date-format hazard).
398
+ if (row.raw_line) return row.raw_line;
399
+ return canonicalBlocker({ stage: row.stage || '', date: row.date || '', text: row.body_md || '' });
400
+ });
401
+ raw_bodies.blockers = lines.join('\n');
402
+ } else if ('blockers' in blockGaps) {
403
+ raw_bodies.blockers = '';
404
+ }
405
+ // If no blockGaps entry for blockers, raw_bodies.blockers stays null (block omitted).
394
406
  }
395
407
 
396
408
  // --- unstructured blocks: verbatim from _block_meta.raw_body ---
@@ -56,7 +56,7 @@ function findPackageRoot(startDir) {
56
56
  try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); } catch { pkg = null; }
57
57
  if (pkg) {
58
58
  if (firstWithPkg === null) firstWithPkg = dir;
59
- if (pkg.name === 'get-design-done') return dir;
59
+ if (pkg.name === '@hegemonart/get-design-done') return dir;
60
60
  }
61
61
  const parent = path.dirname(dir);
62
62
  if (parent === dir) break;
@@ -198,12 +198,12 @@ function _onDiskSha(statePath) {
198
198
  * If they differ, the user has hand-edited STATE.md since the last SQLite write.
199
199
  * In that case, run a mini-migration (upsert SQLite from markdown) before proceeding.
200
200
  *
201
- * This is called BEFORE the db.transaction() so it can be async.
201
+ * This is ASYNC - must be awaited before entering the db.transaction().
202
202
  *
203
203
  * @param {import('better-sqlite3').Database} db
204
204
  * @param {string} statePath absolute path to STATE.md
205
205
  */
206
- function _applyFreshnessGuard(db, statePath) {
206
+ async function _applyFreshnessGuard(db, statePath) {
207
207
  try {
208
208
  const onDisk = _onDiskSha(statePath);
209
209
  if (onDisk === null) return; // STATE.md doesn't exist yet - skip
@@ -211,20 +211,21 @@ function _applyFreshnessGuard(db, statePath) {
211
211
  const stored = metaRow ? metaRow.value : null;
212
212
  if (stored === onDisk) return; // No drift - proceed normally
213
213
  // Drift detected: user hand-edited STATE.md.
214
- // Run mini-migration to upsert SQLite from the current markdown state.
214
+ // Run mini-migration (upsertOnly) to fold the hand-edit into SQLite before
215
+ // proceeding with the intended mutation.
215
216
  const migrate = _requireMigrate();
216
217
  if (migrate && typeof migrate.migrateToSqlite === 'function') {
217
218
  try {
218
- // migrateToSqlite is async but we handle it best-effort here.
219
- // For the freshness guard we do a best-effort synchronous approach:
220
- // if the module is loaded, call it. If it's async we can't await here
221
- // (this is called outside the transaction but may be called from sync context).
222
- // The mini-migration is best-effort.
219
+ // Close db first if needed - migrateToSqlite opens its own connection.
220
+ // We pass the resolved projectRoot (dirname of .design/STATE.md's parent).
221
+ const projectRoot = path.resolve(path.dirname(statePath), '..');
222
+ await migrate.migrateToSqlite({ statePath, projectRoot, force: true, upsertOnly: true });
223
223
  } catch {
224
224
  // Migration failed - log and continue rather than blocking the write.
225
+ // Still update the sha to prevent infinite re-triggering.
225
226
  }
226
227
  }
227
- // Update stored sha to prevent re-triggering on every call even if migrate fails.
228
+ // Update stored sha to reflect the current on-disk content.
228
229
  db.prepare('INSERT OR REPLACE INTO _meta(key, value) VALUES (?, ?)').run('last_render_sha256', onDisk);
229
230
  } catch {
230
231
  /* Freshness guard must never break a write path */
@@ -326,8 +327,12 @@ async function appendDecision(decision, opts = {}) {
326
327
  const statePath = _statePath(opts.projectRoot);
327
328
  const db = openStateDb(dbPath);
328
329
  try {
329
- _applyFreshnessGuard(db, statePath);
330
+ await _applyFreshnessGuard(db, statePath);
330
331
  const cycleId = decision.cycleId || _currentCycleId(db);
332
+ // BUG-10: if no cycle is active, skip rather than inserting cycle_id=null (NOT NULL throw).
333
+ if (!cycleId) {
334
+ return { backend: 'sqlite', skipped: true, reason: 'no active cycle_id - call setPosition first' };
335
+ }
331
336
  _dualWrite(db, statePath, cycleId, () => {
332
337
  db.prepare(`
333
338
  INSERT INTO decisions
@@ -349,6 +354,17 @@ async function appendDecision(decision, opts = {}) {
349
354
  decision.rawLine || null,
350
355
  decision.createdAt || new Date().toISOString(),
351
356
  );
357
+ // BUG-05: populate decisions_fts so FTS5 queries return hits.
358
+ // FTS5 virtual tables do not support ON CONFLICT — use DELETE + INSERT pattern.
359
+ // Guard: if FTS5 tables are absent (no-fts5 build), skip without throwing.
360
+ try {
361
+ db.prepare(`DELETE FROM decisions_fts WHERE id = ?`).run(decision.id);
362
+ db.prepare(`INSERT INTO decisions_fts (id, body_md, tags) VALUES (?, ?, ?)`).run(
363
+ decision.id,
364
+ decision.bodyMd || '',
365
+ decision.tags ? JSON.stringify(decision.tags) : null,
366
+ );
367
+ } catch { /* FTS5 table absent in no-fts5 build - skip */ }
352
368
  }, sdk);
353
369
  return { backend: 'sqlite', id: decision.id };
354
370
  } finally {
@@ -373,16 +389,21 @@ function getDecisions(opts = {}) {
373
389
  if (BACKEND !== 'sqlite') {
374
390
  return [];
375
391
  }
376
- const dbPath = _resolveDbPath(opts);
377
- const db = openStateDb(dbPath, { readonly: true });
392
+ // BUG-06: wrap in try/catch — openStateDb(readonly) throws on a missing file.
378
393
  try {
379
- const cycleId = opts.cycleId || _currentCycleId(db);
380
- if (!cycleId) return [];
381
- return db.prepare(
382
- 'SELECT * FROM decisions WHERE cycle_id = ? ORDER BY ordinal ASC'
383
- ).all(cycleId);
384
- } finally {
385
- db.close();
394
+ const dbPath = _resolveDbPath(opts);
395
+ const db = openStateDb(dbPath, { readonly: true });
396
+ try {
397
+ const cycleId = opts.cycleId || _currentCycleId(db);
398
+ if (!cycleId) return [];
399
+ return db.prepare(
400
+ 'SELECT * FROM decisions WHERE cycle_id = ? ORDER BY ordinal ASC'
401
+ ).all(cycleId);
402
+ } finally {
403
+ db.close();
404
+ }
405
+ } catch {
406
+ return [];
386
407
  }
387
408
  }
388
409
 
@@ -408,7 +429,7 @@ async function appendBlocker(blocker, opts = {}) {
408
429
  const statePath = _statePath(opts.projectRoot);
409
430
  const db = openStateDb(dbPath);
410
431
  try {
411
- _applyFreshnessGuard(db, statePath);
432
+ await _applyFreshnessGuard(db, statePath);
412
433
  const cycleId = blocker.cycleId || _currentCycleId(db);
413
434
  let rowid = null;
414
435
  _dualWrite(db, statePath, cycleId, () => {
@@ -447,21 +468,26 @@ function getBlockers(opts = {}) {
447
468
  if (BACKEND !== 'sqlite') {
448
469
  return [];
449
470
  }
450
- const dbPath = _resolveDbPath(opts);
451
- const db = openStateDb(dbPath, { readonly: true });
471
+ // BUG-06: wrap in try/catch — openStateDb(readonly) throws on a missing file.
452
472
  try {
453
- const cycleId = opts.cycleId || _currentCycleId(db);
454
- if (!cycleId) return [];
455
- if (opts.includeResolved) {
473
+ const dbPath = _resolveDbPath(opts);
474
+ const db = openStateDb(dbPath, { readonly: true });
475
+ try {
476
+ const cycleId = opts.cycleId || _currentCycleId(db);
477
+ if (!cycleId) return [];
478
+ if (opts.includeResolved) {
479
+ return db.prepare(
480
+ 'SELECT * FROM blockers WHERE cycle_id = ? ORDER BY ordinal ASC'
481
+ ).all(cycleId);
482
+ }
456
483
  return db.prepare(
457
- 'SELECT * FROM blockers WHERE cycle_id = ? ORDER BY ordinal ASC'
484
+ 'SELECT * FROM blockers WHERE cycle_id = ? AND resolved_at IS NULL ORDER BY ordinal ASC'
458
485
  ).all(cycleId);
486
+ } finally {
487
+ db.close();
459
488
  }
460
- return db.prepare(
461
- 'SELECT * FROM blockers WHERE cycle_id = ? AND resolved_at IS NULL ORDER BY ordinal ASC'
462
- ).all(cycleId);
463
- } finally {
464
- db.close();
489
+ } catch {
490
+ return [];
465
491
  }
466
492
  }
467
493
 
@@ -488,7 +514,7 @@ async function setPosition(position, opts = {}) {
488
514
  const statePath = _statePath(opts.projectRoot);
489
515
  const db = openStateDb(dbPath);
490
516
  try {
491
- _applyFreshnessGuard(db, statePath);
517
+ await _applyFreshnessGuard(db, statePath);
492
518
  _dualWrite(db, statePath, position.cycleId, () => {
493
519
  db.prepare(`
494
520
  INSERT INTO state_position
@@ -535,17 +561,22 @@ function getPosition(opts = {}) {
535
561
  if (BACKEND !== 'sqlite') {
536
562
  return null;
537
563
  }
538
- const dbPath = _resolveDbPath(opts);
539
- const db = openStateDb(dbPath, { readonly: true });
564
+ // BUG-06: wrap in try/catch — openStateDb(readonly) throws on a missing file.
540
565
  try {
541
- if (opts.cycleId) {
542
- return db.prepare('SELECT * FROM state_position WHERE cycle_id = ?').get(opts.cycleId) || null;
566
+ const dbPath = _resolveDbPath(opts);
567
+ const db = openStateDb(dbPath, { readonly: true });
568
+ try {
569
+ if (opts.cycleId) {
570
+ return db.prepare('SELECT * FROM state_position WHERE cycle_id = ?').get(opts.cycleId) || null;
571
+ }
572
+ return db.prepare(
573
+ 'SELECT * FROM state_position ORDER BY updated_at DESC LIMIT 1'
574
+ ).get() || null;
575
+ } finally {
576
+ db.close();
543
577
  }
544
- return db.prepare(
545
- 'SELECT * FROM state_position ORDER BY updated_at DESC LIMIT 1'
546
- ).get() || null;
547
- } finally {
548
- db.close();
578
+ } catch {
579
+ return null;
549
580
  }
550
581
  }
551
582
 
package/sdk/cli/index.js CHANGED
@@ -4014,7 +4014,13 @@ function migrationActive(statePath) {
4014
4014
  const backend = _loadBackend();
4015
4015
  if (backend === null || backend.BACKEND !== "sqlite") return false;
4016
4016
  const sqliteSibling = (0, import_node_path4.join)((0, import_node_path4.dirname)(statePath), "state.sqlite");
4017
- return (0, import_node_fs5.existsSync)(sqliteSibling);
4017
+ if (!(0, import_node_fs5.existsSync)(sqliteSibling)) return false;
4018
+ try {
4019
+ if ((0, import_node_fs5.statSync)(sqliteSibling).isDirectory()) return false;
4020
+ } catch {
4021
+ return false;
4022
+ }
4023
+ return true;
4018
4024
  }
4019
4025
  async function read(path) {
4020
4026
  const raw = (0, import_node_fs5.readFileSync)(path, "utf8");
@@ -6,8 +6,8 @@
6
6
  * resolve a cross-tree sibling via a fixed `__dirname`-relative `../../..`
7
7
  * jump, because that breaks the moment a file is copied/moved or the layout
8
8
  * shifts. Instead, walk UP from this file's directory until we find the GDD
9
- * package.json (identified by `name === 'get-design-done'`), and resolve all
10
- * in-repo siblings relative to that root.
9
+ * package.json (identified by `name === '@hegemonart/get-design-done'`), and
10
+ * resolve all in-repo siblings relative to that root.
11
11
  *
12
12
  * Even though these dashboard `.cjs` files are NOT esbuild-bundled (R8 — the
13
13
  * bin trampoline runs them directly so the Phase 53 __dirname-rewrite trap
@@ -23,7 +23,7 @@ let _cachedRoot = null;
23
23
 
24
24
  /**
25
25
  * Walk up from `startDir` looking for the GDD package root. The GDD root is
26
- * the first ancestor whose package.json declares `name: "get-design-done"`;
26
+ * the first ancestor whose package.json declares `name: "@hegemonart/get-design-done"`;
27
27
  * if no such marker is found (e.g. running from an unusual layout), fall back
28
28
  * to the FIRST ancestor that has any package.json, then to `startDir`.
29
29
  *
@@ -44,7 +44,7 @@ function findPackageRoot(startDir) {
44
44
  }
45
45
  if (pkg) {
46
46
  if (firstWithPkg === null) firstWithPkg = dir;
47
- if (pkg.name === 'get-design-done') return dir;
47
+ if (pkg.name === '@hegemonart/get-design-done') return dir;
48
48
  }
49
49
  const parent = path.dirname(dir);
50
50
  if (parent === dir) break;
@@ -33,15 +33,20 @@
33
33
  * (absent / unknown) -> default
34
34
  */
35
35
 
36
- /** Canonical action → color map (the only colors the Findings pane uses). */
36
+ /**
37
+ * Canonical action -> color map. Keys are lowercase (matching the emitter's
38
+ * suggested_action values from events.schema.json: allow/review/
39
+ * require_confirmation/block). The display label is separate from the key so
40
+ * the map can do a single case-insensitive lookup.
41
+ */
37
42
  const ACTION_COLOR = Object.freeze({
38
- Allow: 'green',
39
- Review: 'yellow',
40
- RequireConfirmation: 'orange',
41
- Block: 'red',
43
+ allow: 'green',
44
+ review: 'yellow',
45
+ require_confirmation: 'orange',
46
+ block: 'red',
42
47
  });
43
48
 
44
- /** The set of recognized suggested-action values (Phase 56 vocabulary). */
49
+ /** The set of recognized suggested-action values (Phase 56 vocabulary, lowercase). */
45
50
  const VALID_ACTIONS = Object.freeze(Object.keys(ACTION_COLOR));
46
51
 
47
52
  /** The blank placeholder row emitted pre-56 (or for malformed/absent input). */
@@ -66,34 +71,64 @@ function finiteOrNull(v) {
66
71
  }
67
72
 
68
73
  /**
69
- * Map a suggested_action to its display color. Unknown / absent -> 'default'.
74
+ * Canonicalize a suggested_action to its lowercase snake_case key for lookup.
75
+ * Handles the emitter's lowercase values (allow/review/require_confirmation/block)
76
+ * AND legacy CamelCase (Allow/Review/RequireConfirmation/Block) - the CamelCase
77
+ * 'RequireConfirmation' lowercases to 'requireconfirmation' with no separator, so
78
+ * map that explicitly to 'require_confirmation'. Returns null for non-strings.
79
+ * @param {*} action
80
+ * @returns {string|null}
81
+ */
82
+ function canonAction(action) {
83
+ if (typeof action !== 'string') return null;
84
+ let s = action.trim().toLowerCase().replace(/[\s-]+/g, '_');
85
+ if (s === 'requireconfirmation') s = 'require_confirmation';
86
+ return s;
87
+ }
88
+
89
+ /**
90
+ * Map a suggested_action to its display color. Case-insensitive so both
91
+ * 'allow' (emitter) and 'Allow' (legacy) resolve correctly. Unknown / absent -> 'default'.
70
92
  * @param {*} action
71
93
  * @returns {'green'|'yellow'|'orange'|'red'|'default'}
72
94
  */
73
95
  function colorForAction(action) {
74
- if (typeof action === 'string' && Object.prototype.hasOwnProperty.call(ACTION_COLOR, action)) {
75
- return ACTION_COLOR[action];
96
+ const canon = canonAction(action);
97
+ if (canon !== null && Object.prototype.hasOwnProperty.call(ACTION_COLOR, canon)) {
98
+ return ACTION_COLOR[canon];
76
99
  }
77
100
  return 'default';
78
101
  }
79
102
 
80
103
  /**
81
- * Surface the risk fields on ONE event/finding. Reads `risk_score`,
82
- * `confidence`, `suggested_action` WHEN PRESENT; otherwise returns the blank
83
- * placeholder. PURE; NEVER throws.
104
+ * Surface the risk fields on ONE event/finding. Accepts either a raw
105
+ * risk_assessment envelope (with a `.payload` sub-object) OR a bare payload
106
+ * object. When `.payload` is present it is used as the source of risk fields;
107
+ * otherwise `item` itself is inspected (bare-payload / legacy path).
84
108
  *
85
- * @param {*} item an event or finding (may be missing the risk fields pre-56)
109
+ * Reads `risk_score`, `confidence`, `suggested_action` WHEN PRESENT; otherwise
110
+ * returns the blank placeholder. PURE; NEVER throws.
111
+ *
112
+ * @param {*} item an event envelope or bare payload (may be missing risk fields pre-56)
86
113
  * @returns {{risk_score:number|null, confidence:number|null,
87
114
  * suggested_action:string|null, color:string}}
88
115
  */
89
116
  function surfaceRiskOne(item) {
90
117
  if (!item || typeof item !== 'object') return blankRow();
91
118
 
92
- const risk_score = finiteOrNull(item.risk_score);
93
- const confidence = finiteOrNull(item.confidence);
94
- const rawAction = item.suggested_action;
95
- const suggested_action =
96
- typeof rawAction === 'string' && VALID_ACTIONS.includes(rawAction) ? rawAction : null;
119
+ // Normalize: if the caller passed a full event envelope (with .payload), use the payload.
120
+ const src = (item.payload && typeof item.payload === 'object') ? item.payload : item;
121
+
122
+ const risk_score = finiteOrNull(src.risk_score);
123
+ const confidence = finiteOrNull(src.confidence);
124
+ const rawAction = src.suggested_action;
125
+ const canon = canonAction(rawAction);
126
+ const recognized =
127
+ canon !== null && Object.prototype.hasOwnProperty.call(ACTION_COLOR, canon);
128
+ // Preserve the action VERBATIM when recognized: the emitter sends lowercase
129
+ // (allow/review/require_confirmation/block), legacy callers may send CamelCase,
130
+ // and either is echoed back unchanged. Unrecognized / absent -> null.
131
+ const suggested_action = recognized ? rawAction : null;
97
132
 
98
133
  // Pre-56: when NONE of the risk fields are present, emit the blank placeholder
99
134
  // verbatim (color 'default') so the column reads as "not yet scored".
@@ -105,7 +140,7 @@ function surfaceRiskOne(item) {
105
140
  risk_score,
106
141
  confidence,
107
142
  suggested_action,
108
- color: colorForAction(suggested_action),
143
+ color: recognized ? ACTION_COLOR[canon] : 'default',
109
144
  };
110
145
  }
111
146
 
@@ -37,6 +37,21 @@ const readline = require('node:readline');
37
37
 
38
38
  const ansi = require('./ansi.cjs');
39
39
 
40
+ // Lazily require the risk-surface helper (same dep-free constraint as the data plane).
41
+ let _surfaceRisk = null;
42
+ function getSurfaceRisk() {
43
+ if (_surfaceRisk === null) {
44
+ try {
45
+ ({ surfaceRisk: _surfaceRisk } = require('../data/risk-surface.cjs'));
46
+ } catch {
47
+ // If the module is unavailable for any reason, fall back to a no-op that returns
48
+ // blank placeholder rows so the column still renders cleanly.
49
+ _surfaceRisk = () => ({ risk_score: null, confidence: null, suggested_action: null, color: 'default' });
50
+ }
51
+ }
52
+ return _surfaceRisk;
53
+ }
54
+
40
55
  // Lazily require the data plane so `renderFrame` (the pure path) can be imported + unit-tested
41
56
  // without paying for the data module's transitive requires. `run` resolves it on demand.
42
57
  let _loadDashboardModel = null;
@@ -351,11 +366,22 @@ function bodyFindings(model, inner, scroll) {
351
366
  if (tail.length === 0) {
352
367
  lines.push(' ' + ansi.color('no events', { dim: true }));
353
368
  } else {
369
+ const surfaceRisk = getSurfaceRisk();
354
370
  for (const ev of tail) {
355
371
  const name = (ev && (ev.event || ev.type || ev.kind)) || 'event';
356
- // Pre-Phase-56: risk/confidence are blank placeholders (D8).
372
+ // Phase-56+: surface risk/confidence from risk_assessment events.
373
+ // For pre-56 events that lack risk fields, surfaceRiskOne returns the blank placeholder.
374
+ const surfaced = (ev && ev.type === 'risk_assessment')
375
+ ? surfaceRisk(ev)
376
+ : { risk_score: null, confidence: null, suggested_action: null, color: 'default' };
377
+ const riskText = surfaced.risk_score !== null
378
+ ? ansi.color(surfaced.risk_score.toFixed(2), { fg: surfaced.color !== 'default' ? surfaced.color : undefined })
379
+ : ansi.color('·', { dim: true });
380
+ const confText = surfaced.confidence !== null
381
+ ? String(surfaced.confidence.toFixed(2))
382
+ : ansi.color('·', { dim: true });
357
383
  lines.push(ansi.columns(
358
- [String(name), ansi.color('·', { dim: true }), ansi.color('·', { dim: true })],
384
+ [String(name), riskText, confText],
359
385
  [Math.max(8, inner - 14), 5, 5],
360
386
  ));
361
387
  }
@@ -1776,7 +1776,13 @@ function migrationActive(statePath) {
1776
1776
  const backend = _loadBackend();
1777
1777
  if (backend === null || backend.BACKEND !== "sqlite") return false;
1778
1778
  const sqliteSibling = (0, import_node_path.join)((0, import_node_path.dirname)(statePath), "state.sqlite");
1779
- return (0, import_node_fs2.existsSync)(sqliteSibling);
1779
+ if (!(0, import_node_fs2.existsSync)(sqliteSibling)) return false;
1780
+ try {
1781
+ if ((0, import_node_fs2.statSync)(sqliteSibling).isDirectory()) return false;
1782
+ } catch {
1783
+ return false;
1784
+ }
1785
+ return true;
1780
1786
  }
1781
1787
  async function read(path2) {
1782
1788
  const raw = (0, import_node_fs2.readFileSync)(path2, "utf8");
@@ -33,6 +33,7 @@ import {
33
33
  renameSync,
34
34
  unlinkSync,
35
35
  existsSync,
36
+ statSync,
36
37
  } from 'node:fs';
37
38
  import { dirname, join, resolve } from 'node:path';
38
39
  import { pathToFileURL } from 'node:url';
@@ -176,7 +177,16 @@ function migrationActive(statePath: string): boolean {
176
177
  const backend = _loadBackend();
177
178
  if (backend === null || backend.BACKEND !== 'sqlite') return false;
178
179
  const sqliteSibling = join(dirname(statePath), 'state.sqlite');
179
- return existsSync(sqliteSibling);
180
+ if (!existsSync(sqliteSibling)) return false;
181
+ // BUG-07: a DIRECTORY named state.sqlite would cause existsSync to return true,
182
+ // and then every mutate() would throw when trying to open it as a database.
183
+ // Guard: if the path is a directory, treat migration as inactive.
184
+ try {
185
+ if (statSync(sqliteSibling).isDirectory()) return false;
186
+ } catch {
187
+ return false;
188
+ }
189
+ return true;
180
190
  }
181
191
 
182
192
  /**