@hegemonart/get-design-done 1.54.0 → 1.56.0

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.
Files changed (36) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +92 -0
  4. package/README.md +6 -0
  5. package/SKILL.md +1 -0
  6. package/agents/design-fixer.md +16 -0
  7. package/bin/gdd-dashboard +91 -0
  8. package/dist/claude-code/.claude/skills/override/SKILL.md +86 -0
  9. package/hooks/gdd-decision-injector.js +58 -0
  10. package/hooks/gdd-fact-force.js +345 -0
  11. package/hooks/gdd-risk-gate.js +406 -0
  12. package/hooks/hooks.json +18 -0
  13. package/package.json +2 -1
  14. package/reference/schemas/events.schema.json +61 -1
  15. package/reference/skill-graph.md +2 -1
  16. package/scripts/lib/dashboard/graph-html.cjs +0 -0
  17. package/scripts/lib/health-mirror/index.cjs +146 -1
  18. package/scripts/lib/manifest/skills.json +8 -0
  19. package/scripts/lib/risk/calibration.cjs +385 -0
  20. package/scripts/lib/risk/compute-risk.cjs +229 -0
  21. package/scripts/lib/risk/consumers.cjs +211 -0
  22. package/scripts/lib/risk/override.cjs +87 -0
  23. package/scripts/lib/risk/route.cjs +59 -0
  24. package/scripts/lib/risk/tables.cjs +221 -0
  25. package/sdk/cli/commands/dashboard.ts +419 -0
  26. package/sdk/cli/index.js +253 -2
  27. package/sdk/cli/index.ts +7 -0
  28. package/sdk/dashboard/data/_pkg-root.cjs +92 -0
  29. package/sdk/dashboard/data/cost-aggregator.cjs +187 -0
  30. package/sdk/dashboard/data/discovery.cjs +297 -0
  31. package/sdk/dashboard/data/risk-surface.cjs +136 -0
  32. package/sdk/dashboard/data/source.cjs +576 -0
  33. package/sdk/dashboard/tui/ansi.cjs +355 -0
  34. package/sdk/dashboard/tui/index.cjs +778 -0
  35. package/sdk/mcp/gdd-mcp/server.js +70 -0
  36. package/skills/override/SKILL.md +86 -0
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * hooks/gdd-fact-force.js — PreToolUse:Edit|Write|MultiEdit fact-forcing gate.
5
+ *
6
+ * Forces an agent to establish the FACTS before the FIRST mutation of a file in
7
+ * a session: the file's importers/consumers (from the Phase 52 DesignContext
8
+ * graph) must have been Read, and any decisions/blockers tagged with the file
9
+ * must have been surfaced. Until those prerequisites are met, the first write
10
+ * is SOFT-blocked (`{continue:false, stopReason}` listing the missing facts);
11
+ * the agent can satisfy them (Read the importers) or escape via
12
+ * `/gdd:override factforce <path>` which sets `checked[path]`.
13
+ *
14
+ * Tiering (CONTEXT.md shared contract):
15
+ * - prerequisites met OR checked[path] set -> { continue:true }
16
+ * - prerequisites UNMET, computeRisk != block -> SOFT block (continue:false)
17
+ * - prerequisites UNMET, computeRisk == block -> HARD block (continue:false);
18
+ * only escape is /gdd:override (same JSON shape, stronger stopReason)
19
+ * - graph ABSENT/unbuilt -> importer prereq SOFTENS to a
20
+ * warning, never a hard block (do not over-block greenfield)
21
+ *
22
+ * Session-state (worktree-safe, CONTEXT.md R5):
23
+ * <cwd>/.design/locks/factforce-<sanitized session_id>.json
24
+ * { reads: { <normPath>: <ISO> }, first_mutation_seen: { <normPath>: <ISO> },
25
+ * checked: { <normPath>: true } }
26
+ * Atomic tmp+rename. session_id from payload.session_id ?? GDD_SESSION_ID ?? 'hook'.
27
+ *
28
+ * Contract (PreToolUse): stdin { tool_name, tool_input:{file_path}, cwd, session_id? }
29
+ * stdout: { continue:true } | { continue:false, stopReason }
30
+ * exit : always 0. NEVER throws (fail-open { continue:true }).
31
+ */
32
+
33
+ const fs = require('fs');
34
+ const path = require('path');
35
+
36
+ const GATED_TOOLS = new Set(['Edit', 'Write', 'MultiEdit']);
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Package-root walk-up (Phase 53/54 lesson) for robust sibling resolution.
40
+ // ---------------------------------------------------------------------------
41
+ function findPackageRoot(startDir) {
42
+ let dir = startDir;
43
+ for (let i = 0; i < 12; i++) {
44
+ try {
45
+ const pkg = require(path.join(dir, 'package.json'));
46
+ if (pkg && pkg.name === '@hegemonart/get-design-done') return dir;
47
+ } catch { /* not this level */ }
48
+ const parent = path.dirname(dir);
49
+ if (parent === dir) break;
50
+ dir = parent;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * Lazily resolve a sibling lib module by name, trying the adjacent path first
57
+ * then the package-root walk-up. Returns null when unresolvable (the gate then
58
+ * SOFTENS rather than crashing).
59
+ */
60
+ function requireSibling(relFromLib, validate) {
61
+ const candidates = [path.join(__dirname, '..', 'scripts', 'lib', relFromLib)];
62
+ const root = findPackageRoot(__dirname);
63
+ if (root) candidates.push(path.join(root, 'scripts', 'lib', relFromLib));
64
+ for (const c of candidates) {
65
+ try {
66
+ const m = require(c);
67
+ if (!validate || validate(m)) return m;
68
+ } catch { /* try next */ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ const _risk = requireSibling('risk/compute-risk.cjs', (m) => m && typeof m.computeRisk === 'function');
74
+ const _consumers = requireSibling('risk/consumers.cjs', (m) => m && typeof m.consumersOfFile === 'function');
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Path normalization
78
+ // ---------------------------------------------------------------------------
79
+ function normPath(p, cwd) {
80
+ if (!p) return '';
81
+ let s = String(p);
82
+ // Make absolute paths relative to cwd so reads[] keys match across the
83
+ // (absolute file_path the agent passes) and (relative paths we derive).
84
+ if (s.startsWith('/') || /^[A-Za-z]:[\\/]/.test(s)) {
85
+ try { s = path.relative(cwd || process.cwd(), s); } catch { /* keep s */ }
86
+ }
87
+ return s.replace(/\\/g, '/').replace(/^\.\//, '');
88
+ }
89
+
90
+ function leafSlug(p) {
91
+ const base = path.basename(String(p || ''));
92
+ return base.replace(/\.[a-z0-9.]+$/i, '').toLowerCase();
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Session-state (atomic tmp+rename; mirrors bandit-router's write pattern)
97
+ // ---------------------------------------------------------------------------
98
+ function sessionIdFrom(payload) {
99
+ const raw = (payload && (payload.session_id || payload.sessionId))
100
+ || process.env.GDD_SESSION_ID
101
+ || 'hook';
102
+ // Sanitize for a filename: keep alnum/dash/underscore, collapse the rest.
103
+ return String(raw).replace(/[^A-Za-z0-9_-]+/g, '-').slice(0, 120) || 'hook';
104
+ }
105
+
106
+ function stateFileFor(cwd, sessionId) {
107
+ return path.join(cwd || process.cwd(), '.design', 'locks', `factforce-${sessionId}.json`);
108
+ }
109
+
110
+ function loadState(stateFile) {
111
+ const empty = { reads: {}, first_mutation_seen: {}, checked: {} };
112
+ try {
113
+ const parsed = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
114
+ return {
115
+ reads: (parsed && typeof parsed.reads === 'object' && parsed.reads) || {},
116
+ first_mutation_seen: (parsed && typeof parsed.first_mutation_seen === 'object' && parsed.first_mutation_seen) || {},
117
+ checked: (parsed && typeof parsed.checked === 'object' && parsed.checked) || {},
118
+ };
119
+ } catch {
120
+ return empty;
121
+ }
122
+ }
123
+
124
+ function saveState(stateFile, state) {
125
+ try {
126
+ fs.mkdirSync(path.dirname(stateFile), { recursive: true });
127
+ const tmp = `${stateFile}.tmp`;
128
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
129
+ fs.renameSync(tmp, stateFile);
130
+ } catch { /* best-effort: a state-write failure must not break the gate */ }
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Decisions/blockers grep (reuses the decision-injector idiom: scan the small
135
+ // canonical design docs for lines mentioning the file's basename/relPath).
136
+ // ---------------------------------------------------------------------------
137
+ function decisionSources(cwd) {
138
+ const roots = [];
139
+ for (const rel of [
140
+ ['.design', 'STATE.md'],
141
+ ['.design', 'CYCLES.md'],
142
+ ['.design', 'learnings', 'LEARNINGS.md'],
143
+ ]) {
144
+ const p = path.join(cwd, ...rel);
145
+ try { if (fs.statSync(p).isFile()) roots.push(p); } catch { /* skip */ }
146
+ }
147
+ return roots;
148
+ }
149
+
150
+ /**
151
+ * Does any decision/blocker line mention this file? Best-effort substring grep
152
+ * over the canonical docs for the file's basename or relPath (the same terms
153
+ * the decision-injector greps on). Returns { found:boolean, where:string|null }.
154
+ */
155
+ function decisionMentions(cwd, relPath) {
156
+ const basename = path.basename(relPath);
157
+ const terms = Array.from(new Set([basename, relPath].filter(Boolean)));
158
+ for (const src of decisionSources(cwd)) {
159
+ let content;
160
+ try { content = fs.readFileSync(src, 'utf8'); } catch { continue; }
161
+ for (const t of terms) {
162
+ if (t && content.includes(t)) return { found: true, where: path.basename(src) };
163
+ }
164
+ }
165
+ return { found: false, where: null };
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Importer prerequisite: were the file's consumers Read this session?
170
+ // SOFTENS when the graph is absent (available:false).
171
+ // ---------------------------------------------------------------------------
172
+ function readSlugs(state, cwd) {
173
+ // Index the session reads by their leaf slug for token matching against
174
+ // consumer node names.
175
+ const slugs = new Set();
176
+ for (const k of Object.keys(state.reads || {})) {
177
+ const s = leafSlug(k);
178
+ if (s) slugs.add(s);
179
+ }
180
+ return slugs;
181
+ }
182
+
183
+ /**
184
+ * @returns {{ softened:boolean, unread:string[] }}
185
+ * softened — true when the graph is unavailable (importer check downgraded
186
+ * to a non-blocking warning).
187
+ * unread — importer slugs that were NOT found in this session's reads.
188
+ */
189
+ function importerPrereq(filePath, cwd, state) {
190
+ if (!_consumers) return { softened: true, unread: [] };
191
+ let res;
192
+ try {
193
+ res = _consumers.consumersOfFile(filePath, { root: cwd });
194
+ } catch {
195
+ return { softened: true, unread: [] };
196
+ }
197
+ if (!res || res.available !== true) {
198
+ // Graph absent / unbuilt / file unmapped-with-no-graph -> SOFTEN.
199
+ return { softened: true, unread: [] };
200
+ }
201
+ const importers = Array.isArray(res.importers) ? res.importers : [];
202
+ if (importers.length === 0) return { softened: false, unread: [] };
203
+ const reads = readSlugs(state, cwd);
204
+ const unread = importers.filter((imp) => !reads.has(String(imp).toLowerCase()));
205
+ return { softened: false, unread };
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Risk tier (imports A's compute-risk; SOFTENS to non-block when unavailable)
210
+ // ---------------------------------------------------------------------------
211
+ function riskIsBlock(tool, input, cwd) {
212
+ if (!_risk) return false;
213
+ try {
214
+ const cfg = typeof _risk.loadRiskConfig === 'function' ? _risk.loadRiskConfig(cwd) : null;
215
+ const thresholds = cfg && cfg.thresholds ? cfg.thresholds : undefined;
216
+ const r = _risk.computeRisk(tool, input, thresholds);
217
+ return !!(r && r.suggested_action === 'block');
218
+ } catch {
219
+ return false;
220
+ }
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Main
225
+ // ---------------------------------------------------------------------------
226
+ async function main() {
227
+ let buf = '';
228
+ for await (const chunk of process.stdin) buf += chunk;
229
+
230
+ let payload;
231
+ try { payload = JSON.parse(buf || '{}'); } catch {
232
+ process.stdout.write(JSON.stringify({ continue: true }));
233
+ return;
234
+ }
235
+
236
+ const tool = (payload && payload.tool_name) || '';
237
+ if (!GATED_TOOLS.has(tool)) {
238
+ process.stdout.write(JSON.stringify({ continue: true }));
239
+ return;
240
+ }
241
+
242
+ const cwd = (payload && payload.cwd) || process.cwd();
243
+ const rawPath = payload && payload.tool_input && payload.tool_input.file_path;
244
+ if (!rawPath) {
245
+ process.stdout.write(JSON.stringify({ continue: true }));
246
+ return;
247
+ }
248
+ const relPath = normPath(rawPath, cwd);
249
+
250
+ const sessionId = sessionIdFrom(payload);
251
+ const stateFile = stateFileFor(cwd, sessionId);
252
+ const state = loadState(stateFile);
253
+
254
+ // (1) Already overridden for this path -> always pass (and record the seen).
255
+ if (state.checked && state.checked[relPath]) {
256
+ if (!state.first_mutation_seen[relPath]) {
257
+ state.first_mutation_seen[relPath] = new Date().toISOString();
258
+ saveState(stateFile, state);
259
+ }
260
+ emit('allow', { reason: 'checked', path: relPath });
261
+ process.stdout.write(JSON.stringify({ continue: true }));
262
+ return;
263
+ }
264
+
265
+ // (2) Not the FIRST mutation of this file this session -> not re-gated.
266
+ if (state.first_mutation_seen && state.first_mutation_seen[relPath]) {
267
+ emit('allow', { reason: 'already-mutated', path: relPath });
268
+ process.stdout.write(JSON.stringify({ continue: true }));
269
+ return;
270
+ }
271
+
272
+ // (3) First mutation: evaluate prerequisites.
273
+ const missing = [];
274
+
275
+ const imp = importerPrereq(rawPath, cwd, state);
276
+ if (!imp.softened && imp.unread.length > 0) {
277
+ missing.push(`unread importers: ${imp.unread.join(', ')} (Read the file(s) that consume '${relPath}')`);
278
+ }
279
+
280
+ const dec = decisionMentions(cwd, relPath);
281
+ // A decision/blocker is "tagged with X" when a canonical doc mentions the
282
+ // file. If one exists, it must have been surfaced (Read) this session — we
283
+ // approximate "surfaced" by the doc itself being in reads[], else flag it.
284
+ if (dec.found) {
285
+ const docReadKnown = Object.keys(state.reads || {}).some((k) => {
286
+ const b = path.basename(k);
287
+ return b === dec.where || b === 'STATE.md' || b === 'CYCLES.md' || b === 'LEARNINGS.md';
288
+ });
289
+ if (!docReadKnown) {
290
+ missing.push(`unreviewed decisions/blockers tagged '${path.basename(relPath)}' in ${dec.where} (Read it first)`);
291
+ }
292
+ }
293
+
294
+ // Record that we have now SEEN the first mutation attempt for this file (so a
295
+ // subsequent retry after the agent satisfies prereqs flows through gate (2)
296
+ // only AFTER a pass; we set the marker on the allow path below to avoid
297
+ // permanently disarming on a blocked attempt).
298
+ if (missing.length === 0) {
299
+ state.first_mutation_seen[relPath] = new Date().toISOString();
300
+ saveState(stateFile, state);
301
+ emit('allow', { reason: 'prereqs-met', path: relPath, softened: imp.softened });
302
+ process.stdout.write(JSON.stringify({ continue: true }));
303
+ return;
304
+ }
305
+
306
+ // Prerequisites unmet -> block. SOFT unless risk == block (then HARD).
307
+ const hard = riskIsBlock(tool, payload.tool_input, cwd);
308
+ const factsList = missing.join('; ');
309
+ const stopReason = hard
310
+ ? `gdd-fact-force (HARD — risk=block): cannot mutate '${relPath}' until facts are established — ${factsList}. The only escape is \`/gdd:override factforce ${relPath} --approver <who>\`.`
311
+ : `gdd-fact-force: establish the facts before the first edit to '${relPath}' — ${factsList}. Read them, or run \`/gdd:override factforce ${relPath}\` to mark checked.`;
312
+
313
+ emit(hard ? 'block-hard' : 'block-soft', { path: relPath, missing: missing.length });
314
+ process.stdout.write(JSON.stringify({ continue: false, stopReason }));
315
+ }
316
+
317
+ // Best-effort telemetry — never throws, swallowed if the emitter is absent.
318
+ function emit(decision, detail) {
319
+ try {
320
+ require('./_hook-emit.js').emitHookFired('gdd-fact-force', decision, detail || {});
321
+ } catch { /* swallow */ }
322
+ }
323
+
324
+ // Auto-run when invoked directly (hooks.json runs `node hooks/gdd-fact-force.js`).
325
+ // Guarded so tests can require() the module to unit-test the pure helpers.
326
+ if (require.main === module) {
327
+ main().catch(() => {
328
+ process.stdout.write(JSON.stringify({ continue: true }));
329
+ });
330
+ }
331
+
332
+ module.exports = {
333
+ // pure-ish helpers exported for tests; main() owns the I/O + contract.
334
+ normPath,
335
+ leafSlug,
336
+ sessionIdFrom,
337
+ stateFileFor,
338
+ loadState,
339
+ saveState,
340
+ decisionMentions,
341
+ importerPrereq,
342
+ riskIsBlock,
343
+ findPackageRoot,
344
+ main,
345
+ };