@hegemonart/get-design-done 1.55.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +45 -0
- package/README.md +4 -0
- package/SKILL.md +1 -0
- package/agents/design-fixer.md +16 -0
- package/dist/claude-code/.claude/skills/override/SKILL.md +86 -0
- package/hooks/gdd-decision-injector.js +58 -0
- package/hooks/gdd-fact-force.js +345 -0
- package/hooks/gdd-risk-gate.js +406 -0
- package/hooks/hooks.json +18 -0
- package/package.json +1 -1
- package/reference/schemas/events.schema.json +61 -1
- package/reference/skill-graph.md +2 -1
- package/scripts/lib/manifest/skills.json +8 -0
- package/scripts/lib/risk/calibration.cjs +385 -0
- package/scripts/lib/risk/compute-risk.cjs +229 -0
- package/scripts/lib/risk/consumers.cjs +211 -0
- package/scripts/lib/risk/override.cjs +87 -0
- package/scripts/lib/risk/route.cjs +59 -0
- package/scripts/lib/risk/tables.cjs +221 -0
- package/skills/override/SKILL.md +86 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* hooks/gdd-risk-gate.js — PreToolUse:Write|Edit|MultiEdit|Bash risk gate (Phase 56, RISK-02).
|
|
5
|
+
*
|
|
6
|
+
* Quantifies the confidence/risk of a writer action with the PURE scorer
|
|
7
|
+
* `scripts/lib/risk/compute-risk.cjs` (executor A), emits a `risk_assessment`
|
|
8
|
+
* telemetry event, and routes by the scorer's `suggested_action`:
|
|
9
|
+
*
|
|
10
|
+
* allow -> { continue: true } (silent)
|
|
11
|
+
* review -> { continue: true, hookSpecificOutput: { … } } (advisory, non-blocking)
|
|
12
|
+
* require_confirmation -> { continue: true, hookSpecificOutput: { … } } (advisory; the AGENT — design-fixer —
|
|
13
|
+
* does the AskUserQuestion, NOT the hook; R2)
|
|
14
|
+
* block -> { continue: false, stopReason: '…' } (the block: continue:false at EXIT 0)
|
|
15
|
+
*
|
|
16
|
+
* Contract (the repo house-style — gdd-bash-guard / gdd-protected-paths, R1):
|
|
17
|
+
* Input (stdin JSON): { tool_name, tool_input, cwd, session_id? }
|
|
18
|
+
* Output (stdout JSON):
|
|
19
|
+
* - allow/review/confirm -> { continue: true [, hookSpecificOutput] }
|
|
20
|
+
* - block -> { continue: false, stopReason }
|
|
21
|
+
* Exit: ALWAYS 0. `continue:false` is the block — never `process.exit(2)`.
|
|
22
|
+
*
|
|
23
|
+
* Resilience: best-effort. ANY error (bad stdin, unresolvable sibling, scorer
|
|
24
|
+
* throw, telemetry failure) fails OPEN -> { continue: true } and a logged note.
|
|
25
|
+
* A risk scorer can never be the reason a tool call is hard-blocked by accident.
|
|
26
|
+
*
|
|
27
|
+
* Sibling resolution: package-root WALK-UP (Phase 53/54 lesson — hooks run from
|
|
28
|
+
* varied cwds and installed-plugin layouts; a fixed `__dirname/..` is fragile).
|
|
29
|
+
* We walk up from this file to the directory that actually contains
|
|
30
|
+
* `scripts/lib/risk/compute-risk.cjs` and require it from there.
|
|
31
|
+
*
|
|
32
|
+
* Writer-agent gate: best-effort. If the repo signals the active agent
|
|
33
|
+
* (`payload.agent` / GDD_AGENT) AND it is a known READ-ONLY agent, we skip
|
|
34
|
+
* scoring (a read-only agent should not see write-risk advisories). When the
|
|
35
|
+
* agent is unknowable — the common case for a PreToolUse hook — we score ALL
|
|
36
|
+
* matched calls: advisory output is non-blocking and the score for low-risk
|
|
37
|
+
* work stays in the allow band, so scoring-all is safe (per the plan).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const fs = require('fs');
|
|
41
|
+
const path = require('path');
|
|
42
|
+
|
|
43
|
+
// ── Package-root walk-up: locate scripts/lib/risk/compute-risk.cjs ──────────
|
|
44
|
+
// Start at this file's dir and climb until we find the risk module (or a
|
|
45
|
+
// package.json that owns it). Cwd-independent; survives installed-plugin
|
|
46
|
+
// layouts where __dirname is not simply <pkg>/hooks.
|
|
47
|
+
const RISK_REL = path.join('scripts', 'lib', 'risk', 'compute-risk.cjs');
|
|
48
|
+
|
|
49
|
+
function findRiskModule(startDir) {
|
|
50
|
+
let dir = startDir;
|
|
51
|
+
// Bound the climb to the filesystem root.
|
|
52
|
+
for (let i = 0; i < 64; i++) {
|
|
53
|
+
const candidate = path.join(dir, RISK_REL);
|
|
54
|
+
try {
|
|
55
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
56
|
+
} catch { /* stat error — keep climbing */ }
|
|
57
|
+
const parent = path.dirname(dir);
|
|
58
|
+
if (parent === dir) break; // reached the root
|
|
59
|
+
dir = parent;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Resolve once at module load. If it cannot be found, `compute` stays null and
|
|
65
|
+
// the hook fails open on every call (logged note).
|
|
66
|
+
let _risk = null;
|
|
67
|
+
let _riskLoadError = null;
|
|
68
|
+
(function loadRisk() {
|
|
69
|
+
try {
|
|
70
|
+
const modPath = findRiskModule(__dirname);
|
|
71
|
+
if (!modPath) {
|
|
72
|
+
_riskLoadError = `compute-risk.cjs not found above ${__dirname}`;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
// eslint-disable-next-line global-require, import/no-dynamic-require
|
|
76
|
+
_risk = require(modPath);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
_riskLoadError = err && err.message ? err.message : String(err);
|
|
79
|
+
}
|
|
80
|
+
})();
|
|
81
|
+
|
|
82
|
+
// ── Best-effort `risk_assessment` event emit ────────────────────────────────
|
|
83
|
+
// The firehose (`appendEvent`, sdk/event-stream) is the sink the wire-in tests
|
|
84
|
+
// read via GDD_EVENTS_PATH. `type` is free-form on the envelope, so emitting
|
|
85
|
+
// `risk_assessment` is valid today; executor E adds it to KNOWN_EVENT_TYPES +
|
|
86
|
+
// the schema. Lazily resolved + fully swallowed — telemetry never throws into
|
|
87
|
+
// the hot path (mirrors hooks/_hook-emit.js's lazy .ts require).
|
|
88
|
+
let _appendEvent = null;
|
|
89
|
+
let _appendResolved = false;
|
|
90
|
+
|
|
91
|
+
function getAppendEvent() {
|
|
92
|
+
if (_appendResolved) return _appendEvent || (() => {});
|
|
93
|
+
_appendResolved = true;
|
|
94
|
+
// Resolve the event-stream sibling via the same package-root walk-up so we
|
|
95
|
+
// do not depend on a fixed `hooks/..` layout.
|
|
96
|
+
const candidates = [
|
|
97
|
+
path.join('sdk', 'event-stream', 'index.ts'),
|
|
98
|
+
path.join('scripts', 'lib', 'event-stream', 'index.ts'),
|
|
99
|
+
];
|
|
100
|
+
let dir = __dirname;
|
|
101
|
+
for (let i = 0; i < 64; i++) {
|
|
102
|
+
for (const rel of candidates) {
|
|
103
|
+
const p = path.join(dir, rel);
|
|
104
|
+
try {
|
|
105
|
+
if (fs.existsSync(p)) {
|
|
106
|
+
// eslint-disable-next-line global-require, import/no-dynamic-require
|
|
107
|
+
_appendEvent = require(p).appendEvent;
|
|
108
|
+
return _appendEvent || (() => {});
|
|
109
|
+
}
|
|
110
|
+
} catch { /* not loadable in this runtime (plain node + .ts) → keep trying */ }
|
|
111
|
+
}
|
|
112
|
+
const parent = path.dirname(dir);
|
|
113
|
+
if (parent === dir) break;
|
|
114
|
+
dir = parent;
|
|
115
|
+
}
|
|
116
|
+
_appendEvent = null;
|
|
117
|
+
return () => {};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function emitRiskAssessment(payload, sessionId) {
|
|
121
|
+
try {
|
|
122
|
+
const appendEvent = getAppendEvent();
|
|
123
|
+
appendEvent({
|
|
124
|
+
type: 'risk_assessment',
|
|
125
|
+
timestamp: new Date().toISOString(),
|
|
126
|
+
sessionId: sessionId || process.env.GDD_SESSION_ID || 'hook',
|
|
127
|
+
payload,
|
|
128
|
+
});
|
|
129
|
+
} catch {
|
|
130
|
+
/* telemetry must never throw into the gate */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Also emit the canonical `hook.fired` row (same as gdd-bash-guard) so the
|
|
135
|
+
// Phase 22 wire-in baselines stay uniform. Best-effort.
|
|
136
|
+
function emitHookFired(decision, extras) {
|
|
137
|
+
try {
|
|
138
|
+
// _hook-emit.js lives beside this hook; resolve it relatively but
|
|
139
|
+
// defensively (it is part of the same hooks/ dir in every layout).
|
|
140
|
+
// eslint-disable-next-line global-require
|
|
141
|
+
require('./_hook-emit.js').emitHookFired('gdd-risk-gate', decision, extras);
|
|
142
|
+
} catch {
|
|
143
|
+
/* swallow */
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Writer-agent gate (best-effort, inclusive) ──────────────────────────────
|
|
148
|
+
// Known read-only agent ids that should NOT receive write-risk advisories.
|
|
149
|
+
// Conservative + small: only agents whose whole job is reading/analysis. When
|
|
150
|
+
// the agent is unknown we score anyway (advisory is safe; the plan: "if
|
|
151
|
+
// unknowable, score all matched calls").
|
|
152
|
+
const READ_ONLY_AGENTS = new Set([
|
|
153
|
+
'design-context-checker',
|
|
154
|
+
'design-context-reviewer',
|
|
155
|
+
'design-plan-checker',
|
|
156
|
+
'design-verifier-gate',
|
|
157
|
+
'design-integration-checker',
|
|
158
|
+
'brief-auditor',
|
|
159
|
+
'copy-auditor',
|
|
160
|
+
'design-auditor',
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
function agentFrom(payload) {
|
|
164
|
+
const a = (payload && typeof payload.agent === 'string' && payload.agent)
|
|
165
|
+
|| process.env.GDD_AGENT
|
|
166
|
+
|| '';
|
|
167
|
+
return String(a).trim();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isReadOnlyAgent(agent) {
|
|
171
|
+
if (!agent) return false; // unknown -> not read-only -> score it
|
|
172
|
+
return READ_ONLY_AGENTS.has(agent);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const MATCHED_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'Bash']);
|
|
176
|
+
|
|
177
|
+
// ── Extend-only table merge (D7 / protected-paths discipline) ───────────────
|
|
178
|
+
// loadRiskConfig returns numeric `base_tool_extra`, plus `file_sensitivity_extra`
|
|
179
|
+
// / `input_pattern_extra` arrays sourced from JSON. We EXTEND the frozen
|
|
180
|
+
// defaults (never shrink). JSON-sourced file-sensitivity entries carry a STRING
|
|
181
|
+
// `test`; compile it to a linear RegExp defensively (malformed entries are
|
|
182
|
+
// dropped, never thrown). input_pattern_extra needs a callable `when` (cannot
|
|
183
|
+
// come from JSON) so it is passed through only when already callable.
|
|
184
|
+
function compileFileSensitivityExtra(extra) {
|
|
185
|
+
const out = [];
|
|
186
|
+
if (!Array.isArray(extra)) return out;
|
|
187
|
+
for (const e of extra) {
|
|
188
|
+
if (!e || typeof e !== 'object') continue;
|
|
189
|
+
let test = e.test;
|
|
190
|
+
if (typeof test === 'string') {
|
|
191
|
+
try { test = new RegExp(test, 'i'); } catch { continue; }
|
|
192
|
+
}
|
|
193
|
+
if (!(test instanceof RegExp)) continue;
|
|
194
|
+
out.push({
|
|
195
|
+
test,
|
|
196
|
+
mult: typeof e.mult === 'number' && Number.isFinite(e.mult) ? e.mult : 1,
|
|
197
|
+
add: typeof e.add === 'number' && Number.isFinite(e.add) ? e.add : 0,
|
|
198
|
+
label: typeof e.label === 'string' ? e.label : 'config',
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function buildMergedTables(cfg) {
|
|
205
|
+
// Frozen defaults are re-exported from compute-risk for exactly this.
|
|
206
|
+
const baseDefault = _risk.BASE_TOOL_RISK || {};
|
|
207
|
+
const fileDefault = _risk.FILE_SENSITIVITY || [];
|
|
208
|
+
const inputDefault = _risk.INPUT_PATTERN_RISK || [];
|
|
209
|
+
|
|
210
|
+
const baseExtra = (cfg && cfg.base_tool_extra && typeof cfg.base_tool_extra === 'object') ? cfg.base_tool_extra : {};
|
|
211
|
+
const fileExtra = compileFileSensitivityExtra(cfg && cfg.file_sensitivity_extra);
|
|
212
|
+
const inputExtra = Array.isArray(cfg && cfg.input_pattern_extra)
|
|
213
|
+
? cfg.input_pattern_extra.filter((e) => e && typeof e.when === 'function')
|
|
214
|
+
: [];
|
|
215
|
+
|
|
216
|
+
// Only allocate new tables when there is something to extend; otherwise reuse
|
|
217
|
+
// the frozen defaults so the common path stays allocation-free + identical to
|
|
218
|
+
// the pure unit tests.
|
|
219
|
+
const haveBaseExtra = Object.keys(baseExtra).length > 0;
|
|
220
|
+
if (!haveBaseExtra && fileExtra.length === 0 && inputExtra.length === 0) {
|
|
221
|
+
return undefined; // computeRisk defaults to the frozen tables
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
BASE_TOOL_RISK: haveBaseExtra ? { ...baseDefault, ...sanitizeNumeric(baseExtra) } : baseDefault,
|
|
225
|
+
// Config entries are appended AFTER defaults; pickMaxFileSensitivity takes
|
|
226
|
+
// the highest-weight match across the union, so order is immaterial.
|
|
227
|
+
FILE_SENSITIVITY: fileExtra.length ? [...fileDefault, ...fileExtra] : fileDefault,
|
|
228
|
+
INPUT_PATTERN_RISK: inputExtra.length ? [...inputDefault, ...inputExtra] : inputDefault,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function sanitizeNumeric(obj) {
|
|
233
|
+
const out = {};
|
|
234
|
+
for (const k of Object.keys(obj)) {
|
|
235
|
+
if (typeof obj[k] === 'number' && Number.isFinite(obj[k])) out[k] = obj[k];
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Rationale / advisory rendering ──────────────────────────────────────────
|
|
241
|
+
function rationaleLine(tool, assessment) {
|
|
242
|
+
const score = typeof assessment.score === 'number' ? assessment.score.toFixed(2) : '?';
|
|
243
|
+
const reasons = Array.isArray(assessment.reasons) ? assessment.reasons.join(', ') : '';
|
|
244
|
+
return `gdd-risk-gate: ${tool} risk=${score} (${assessment.suggested_action}) — ${reasons}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function buildAdvisory(tool, assessment, extraNote) {
|
|
248
|
+
const head = rationaleLine(tool, assessment);
|
|
249
|
+
const body = extraNote ? `${head}\n${extraNote}` : head;
|
|
250
|
+
return {
|
|
251
|
+
continue: true,
|
|
252
|
+
hookSpecificOutput: {
|
|
253
|
+
hookEventName: 'PreToolUse',
|
|
254
|
+
additionalContext: body,
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function buildBlock(tool, assessment) {
|
|
260
|
+
return {
|
|
261
|
+
continue: false,
|
|
262
|
+
stopReason: `${rationaleLine(tool, assessment)} — run /gdd:override to escalate`,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const ALLOW = { continue: true };
|
|
267
|
+
|
|
268
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
269
|
+
async function main() {
|
|
270
|
+
let buf = '';
|
|
271
|
+
for await (const chunk of process.stdin) buf += chunk;
|
|
272
|
+
|
|
273
|
+
let payload;
|
|
274
|
+
try {
|
|
275
|
+
payload = JSON.parse(buf || '{}');
|
|
276
|
+
} catch {
|
|
277
|
+
// Malformed stdin -> fail open.
|
|
278
|
+
emitHookFired('allow', { reason: 'parse-error' });
|
|
279
|
+
process.stdout.write(JSON.stringify(ALLOW));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const tool = payload && typeof payload.tool_name === 'string' ? payload.tool_name : '';
|
|
284
|
+
if (!MATCHED_TOOLS.has(tool)) {
|
|
285
|
+
// Not a writer action we gate (the matcher should already exclude these,
|
|
286
|
+
// but be defensive).
|
|
287
|
+
emitHookFired('allow', { reason: 'unmatched-tool', tool });
|
|
288
|
+
process.stdout.write(JSON.stringify(ALLOW));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const agent = agentFrom(payload);
|
|
293
|
+
if (isReadOnlyAgent(agent)) {
|
|
294
|
+
emitHookFired('allow', { reason: 'read-only-agent', agent });
|
|
295
|
+
process.stdout.write(JSON.stringify(ALLOW));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// If the scorer could not be located/loaded, fail open with a logged note.
|
|
300
|
+
if (!_risk || typeof _risk.computeRisk !== 'function') {
|
|
301
|
+
try {
|
|
302
|
+
process.stderr.write(
|
|
303
|
+
`[gdd-risk-gate] risk scorer unavailable (${_riskLoadError || 'unknown'}) — failing open\n`,
|
|
304
|
+
);
|
|
305
|
+
} catch { /* swallow */ }
|
|
306
|
+
emitHookFired('allow', { reason: 'scorer-unavailable' });
|
|
307
|
+
process.stdout.write(JSON.stringify(ALLOW));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const cwd = (payload && typeof payload.cwd === 'string' && payload.cwd) || process.cwd();
|
|
312
|
+
const input = (payload && payload.tool_input && typeof payload.tool_input === 'object') ? payload.tool_input : {};
|
|
313
|
+
const sessionId = (payload && typeof payload.session_id === 'string' && payload.session_id) || undefined;
|
|
314
|
+
|
|
315
|
+
let assessment;
|
|
316
|
+
try {
|
|
317
|
+
const cfg = _risk.loadRiskConfig(cwd);
|
|
318
|
+
const mergedTables = buildMergedTables(cfg);
|
|
319
|
+
assessment = _risk.computeRisk(tool, input, cfg.thresholds, mergedTables);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
// Any scorer failure -> fail open with a logged note.
|
|
322
|
+
try {
|
|
323
|
+
process.stderr.write(
|
|
324
|
+
`[gdd-risk-gate] computeRisk threw (${err && err.message ? err.message : String(err)}) — failing open\n`,
|
|
325
|
+
);
|
|
326
|
+
} catch { /* swallow */ }
|
|
327
|
+
emitHookFired('allow', { reason: 'scorer-error' });
|
|
328
|
+
emitRiskAssessment({ tool, agent: agent || undefined, error: 'scorer-error', suggested_action: 'allow' }, sessionId);
|
|
329
|
+
process.stdout.write(JSON.stringify(ALLOW));
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const action = assessment && assessment.suggested_action;
|
|
334
|
+
|
|
335
|
+
// Emit the typed risk_assessment event for EVERY scored call (allow→block),
|
|
336
|
+
// so the dashboard (risk-surface, Phase 55) + calibration (E) see the full
|
|
337
|
+
// distribution. Best-effort.
|
|
338
|
+
emitRiskAssessment(
|
|
339
|
+
{
|
|
340
|
+
tool,
|
|
341
|
+
agent: agent || undefined,
|
|
342
|
+
score: assessment.score,
|
|
343
|
+
suggested_action: action,
|
|
344
|
+
reasons: assessment.reasons,
|
|
345
|
+
breakdown: assessment.breakdown,
|
|
346
|
+
paths: assessment.breakdown && assessment.breakdown.paths,
|
|
347
|
+
},
|
|
348
|
+
sessionId,
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
// Mirror the decision onto the hook.fired row (allow|review|confirm|block).
|
|
352
|
+
const firedDecision = action === 'block' ? 'block' : 'allow';
|
|
353
|
+
emitHookFired(firedDecision, { suggested_action: action, score: assessment.score });
|
|
354
|
+
|
|
355
|
+
switch (action) {
|
|
356
|
+
case 'block':
|
|
357
|
+
process.stdout.write(JSON.stringify(buildBlock(tool, assessment)));
|
|
358
|
+
return;
|
|
359
|
+
case 'require_confirmation':
|
|
360
|
+
// Advisory + flag. The hook does NOT prompt (R2) — design-fixer's
|
|
361
|
+
// confidence×risk routing will surface the AskUserQuestion with the diff.
|
|
362
|
+
process.stdout.write(JSON.stringify(buildAdvisory(
|
|
363
|
+
tool,
|
|
364
|
+
assessment,
|
|
365
|
+
'High-risk action — design-fixer will confirm with you (AskUserQuestion) before applying; or run /gdd:override to escalate.',
|
|
366
|
+
)));
|
|
367
|
+
return;
|
|
368
|
+
case 'review':
|
|
369
|
+
// Advisory, non-blocking: surface the rationale so the agent can weigh it.
|
|
370
|
+
process.stdout.write(JSON.stringify(buildAdvisory(tool, assessment, null)));
|
|
371
|
+
return;
|
|
372
|
+
case 'allow':
|
|
373
|
+
default:
|
|
374
|
+
process.stdout.write(JSON.stringify(ALLOW));
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Auto-run only when invoked directly (hooks.json runs `node hooks/gdd-risk-gate.js`,
|
|
380
|
+
// where require.main === module). Guarding the auto-run lets tests require() the
|
|
381
|
+
// module in-process to unit-test the pure helpers without a stdin read.
|
|
382
|
+
if (require.main === module) {
|
|
383
|
+
main().catch((err) => {
|
|
384
|
+
// Last-resort fail-open. Never throw out of the hook.
|
|
385
|
+
try {
|
|
386
|
+
process.stderr.write(
|
|
387
|
+
`[gdd-risk-gate] unexpected error (${err && err.message ? err.message : String(err)}) — failing open\n`,
|
|
388
|
+
);
|
|
389
|
+
} catch { /* swallow */ }
|
|
390
|
+
process.stdout.write(JSON.stringify({ continue: true }));
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Exported for tests — pure helpers + the resolver. main() owns the I/O + contract.
|
|
395
|
+
module.exports = {
|
|
396
|
+
findRiskModule,
|
|
397
|
+
buildMergedTables,
|
|
398
|
+
compileFileSensitivityExtra,
|
|
399
|
+
isReadOnlyAgent,
|
|
400
|
+
agentFrom,
|
|
401
|
+
rationaleLine,
|
|
402
|
+
buildAdvisory,
|
|
403
|
+
buildBlock,
|
|
404
|
+
READ_ONLY_AGENTS,
|
|
405
|
+
main,
|
|
406
|
+
};
|
package/hooks/hooks.json
CHANGED
|
@@ -71,6 +71,24 @@
|
|
|
71
71
|
}
|
|
72
72
|
]
|
|
73
73
|
},
|
|
74
|
+
{
|
|
75
|
+
"matcher": "Write|Edit|MultiEdit|Bash",
|
|
76
|
+
"hooks": [
|
|
77
|
+
{
|
|
78
|
+
"type": "command",
|
|
79
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gdd-risk-gate.js\""
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"matcher": "Edit|Write|MultiEdit",
|
|
85
|
+
"hooks": [
|
|
86
|
+
{
|
|
87
|
+
"type": "command",
|
|
88
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gdd-fact-force.js\""
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
},
|
|
74
92
|
{
|
|
75
93
|
"matcher": "Read",
|
|
76
94
|
"hooks": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hegemonart/get-design-done",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.56.0",
|
|
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",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"type": {
|
|
11
11
|
"type": "string",
|
|
12
12
|
"minLength": 1,
|
|
13
|
-
"description": "Free-form event type identifier. Pre-registered seeds: state.mutation, state.transition, stage.entered, stage.exited, hook.fired, error, capability_gap, kfm-candidate, router_pick, verify_outcome, rollout_started, rollout_advanced, rollout_stuck, budget_forecast, project_cap_warning, project_cap_halt, live_session_start, live_pick, live_generate, live_accept, live_discard, live_session_end, instinct_emitted, instinct_promoted, instinct_decayed."
|
|
13
|
+
"description": "Free-form event type identifier. Pre-registered seeds: state.mutation, state.transition, stage.entered, stage.exited, hook.fired, error, capability_gap, kfm-candidate, router_pick, verify_outcome, rollout_started, rollout_advanced, rollout_stuck, budget_forecast, project_cap_warning, project_cap_halt, live_session_start, live_pick, live_generate, live_accept, live_discard, live_session_end, instinct_emitted, instinct_promoted, instinct_decayed, risk_assessment."
|
|
14
14
|
},
|
|
15
15
|
"timestamp": {
|
|
16
16
|
"type": "string",
|
|
@@ -232,6 +232,55 @@
|
|
|
232
232
|
}
|
|
233
233
|
},
|
|
234
234
|
"description": "Phase 32-08 D-02 router_pick payload — 7 fields, additionalProperties: false, NO PII (context_hash only). Records which skill the router auto-picked per intent — the instrument that surfaces under-reached skills. Validated when the envelope's type === 'router_pick' via the allOf[2] conditional."
|
|
235
|
+
},
|
|
236
|
+
"RiskAssessmentPayload": {
|
|
237
|
+
"type": "object",
|
|
238
|
+
"additionalProperties": false,
|
|
239
|
+
"required": [
|
|
240
|
+
"event_id",
|
|
241
|
+
"tool_name",
|
|
242
|
+
"risk_score",
|
|
243
|
+
"suggested_action",
|
|
244
|
+
"reasons"
|
|
245
|
+
],
|
|
246
|
+
"properties": {
|
|
247
|
+
"event_id": {
|
|
248
|
+
"type": "string",
|
|
249
|
+
"minLength": 1,
|
|
250
|
+
"description": "Stable identifier for this risk_assessment event. Used by Phase 56 calibration to de-dupe re-emissions and to pair an assessment with its post-apply outcome (accept / reject / undo)."
|
|
251
|
+
},
|
|
252
|
+
"tool_name": {
|
|
253
|
+
"type": "string",
|
|
254
|
+
"minLength": 1,
|
|
255
|
+
"description": "The tool the risk gate scored — Write | Edit | MultiEdit | Bash | NotebookEdit (or any writer tool). Mirrors the PreToolUse tool_name the gdd-risk-gate hook received."
|
|
256
|
+
},
|
|
257
|
+
"risk_score": {
|
|
258
|
+
"type": "number",
|
|
259
|
+
"minimum": 0,
|
|
260
|
+
"maximum": 1,
|
|
261
|
+
"description": "The clamped 0..1 score from scripts/lib/risk/compute-risk.cjs computeRisk(). 0 = no risk, 1 = maximal. Drives suggested_action via the THRESHOLDS table."
|
|
262
|
+
},
|
|
263
|
+
"suggested_action": {
|
|
264
|
+
"type": "string",
|
|
265
|
+
"enum": ["allow", "review", "require_confirmation", "block"],
|
|
266
|
+
"description": "The action the risk scorer recommends for this score. allow < review < require_confirmation < block (ascending threshold bands). The gdd-risk-gate hook silences 'allow', advises on 'review'/'require_confirmation', and blocks on 'block'."
|
|
267
|
+
},
|
|
268
|
+
"reasons": {
|
|
269
|
+
"type": "array",
|
|
270
|
+
"items": { "type": "string" },
|
|
271
|
+
"description": "Ordered, human-readable contributing factors from computeRisk().reasons (e.g. 'base:Bash=0.55', 'file:STATE.md(x2+0)', 'input:destructive-bash=+0.3'). May be empty for a zero-risk assessment."
|
|
272
|
+
},
|
|
273
|
+
"agent": {
|
|
274
|
+
"type": "string",
|
|
275
|
+
"minLength": 1,
|
|
276
|
+
"description": "Optional — the writer agent whose action was scored (e.g. 'design-fixer'). Used by Phase 56 calibration to maintain per-agent rolling statistics. Absent when the gate fired outside an agent context."
|
|
277
|
+
},
|
|
278
|
+
"decision_context": {
|
|
279
|
+
"type": "string",
|
|
280
|
+
"description": "Optional — a short, PII-free context tag the agent layer attaches when routing the assessment (e.g. a finding id or routing verdict). Free-form; never the raw prompt."
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
"description": "Phase 56 (R8) risk_assessment payload — 5 required fields + 2 optional (agent, decision_context), additionalProperties: false. Emitted by the gdd-risk-gate PreToolUse hook for every scored writer action; read by sdk/dashboard/data/risk-surface.cjs ({risk_score, suggested_action}) and gdd-events --type risk_assessment. Validated when the envelope's type === 'risk_assessment' via the allOf[3] conditional."
|
|
235
284
|
}
|
|
236
285
|
},
|
|
237
286
|
"allOf": [
|
|
@@ -267,6 +316,17 @@
|
|
|
267
316
|
"payload": { "$ref": "#/definitions/RouterPickPayload" }
|
|
268
317
|
}
|
|
269
318
|
}
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
"if": {
|
|
322
|
+
"properties": { "type": { "const": "risk_assessment" } },
|
|
323
|
+
"required": ["type"]
|
|
324
|
+
},
|
|
325
|
+
"then": {
|
|
326
|
+
"properties": {
|
|
327
|
+
"payload": { "$ref": "#/definitions/RiskAssessmentPayload" }
|
|
328
|
+
}
|
|
329
|
+
}
|
|
270
330
|
}
|
|
271
331
|
]
|
|
272
332
|
}
|
package/reference/skill-graph.md
CHANGED
|
@@ -9,7 +9,7 @@ is a `composes_with` edge (the source calls the target as sub-orchestration); a
|
|
|
9
9
|
a `next_skills` edge (a pipeline hint for what runs next). Stage grouping is best-effort and
|
|
10
10
|
inferred from the skill name; skills with no stage keyword fall under Utility.
|
|
11
11
|
|
|
12
|
-
Skills:
|
|
12
|
+
Skills: 93. Composition edges: 0 composes_with, 6 next_skills.
|
|
13
13
|
|
|
14
14
|
```mermaid
|
|
15
15
|
flowchart TD
|
|
@@ -90,6 +90,7 @@ flowchart TD
|
|
|
90
90
|
n_next["next"]
|
|
91
91
|
n_note["note"]
|
|
92
92
|
n_openrouter_status["openrouter-status"]
|
|
93
|
+
n_override["override"]
|
|
93
94
|
n_pause["pause"]
|
|
94
95
|
n_peer_cli_add["peer-cli-add"]
|
|
95
96
|
n_peer_cli_customize["peer-cli-customize"]
|
|
@@ -319,6 +319,14 @@
|
|
|
319
319
|
"user_invocable": true,
|
|
320
320
|
"tools": "Read, Bash, Grep, Write"
|
|
321
321
|
},
|
|
322
|
+
{
|
|
323
|
+
"name": "override",
|
|
324
|
+
"description": "Escalation surface for a risk-blocked action or a fact-force gate. Use when the Phase 56 risk gate blocked a writer action (suggested_action=block) and a reviewer has signed off, or when the first-write fact-force gate is holding a file you have legitimately reviewed. Activates for requests involving overriding a blocked edit, approving a high-risk change, or clearing a fact-force hold on a path.",
|
|
325
|
+
"argument_hint": "<finding-id | factforce <path>> [--approver <who>] [--reason <text>]",
|
|
326
|
+
"user_invocable": true,
|
|
327
|
+
"tools": "Read, Write, Bash, Grep, Glob",
|
|
328
|
+
"registered_in_phase": "56"
|
|
329
|
+
},
|
|
322
330
|
{
|
|
323
331
|
"name": "pause",
|
|
324
332
|
"description": "Write a numbered checkpoint so work can resume in a new session without re-running completed stages.",
|