@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +28 -0
- package/hooks/budget-enforcer.ts +5 -4
- package/hooks/gdd-protected-paths.js +25 -2
- package/hooks/gdd-read-injection-scanner.ts +9 -1
- package/hooks/gdd-risk-gate.js +17 -7
- package/package.json +1 -1
- package/scripts/lib/design-search.cjs +20 -2
- package/scripts/lib/state/migrate-to-sqlite.cjs +23 -7
- package/scripts/lib/state/query-surface.cjs +19 -7
- package/scripts/lib/state/render-markdown.cjs +26 -14
- package/scripts/lib/state/state-store.cjs +73 -42
- package/sdk/cli/index.js +7 -1
- package/sdk/dashboard/data/_pkg-root.cjs +4 -4
- package/sdk/dashboard/data/risk-surface.cjs +54 -19
- package/sdk/dashboard/tui/index.cjs +28 -2
- package/sdk/mcp/gdd-state/server.js +7 -1
- package/sdk/state/index.ts +11 -1
|
@@ -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.
|
|
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.
|
|
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.
|
|
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)
|
package/hooks/budget-enforcer.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/hooks/gdd-risk-gate.js
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
351
|
+
event_id: randomUUID(),
|
|
352
|
+
tool_name: tool,
|
|
353
|
+
risk_score: assessment.score,
|
|
343
354
|
suggested_action: action,
|
|
344
|
-
reasons: assessment.reasons,
|
|
345
|
-
|
|
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.
|
|
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(
|
|
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]
|
|
218
|
-
* @param {
|
|
219
|
-
* @param {boolean} [opts.
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
} else
|
|
393
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
461
|
-
|
|
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
|
-
|
|
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
|
-
|
|
542
|
-
|
|
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
|
-
|
|
545
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
75
|
-
|
|
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.
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
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:
|
|
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
|
-
//
|
|
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),
|
|
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
|
-
|
|
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");
|
package/sdk/state/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|