@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +92 -0
- package/README.md +6 -0
- package/SKILL.md +1 -0
- package/agents/design-fixer.md +16 -0
- package/bin/gdd-dashboard +91 -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 +2 -1
- package/reference/schemas/events.schema.json +61 -1
- package/reference/skill-graph.md +2 -1
- package/scripts/lib/dashboard/graph-html.cjs +0 -0
- package/scripts/lib/health-mirror/index.cjs +146 -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/sdk/cli/commands/dashboard.ts +419 -0
- package/sdk/cli/index.js +253 -2
- package/sdk/cli/index.ts +7 -0
- package/sdk/dashboard/data/_pkg-root.cjs +92 -0
- package/sdk/dashboard/data/cost-aggregator.cjs +187 -0
- package/sdk/dashboard/data/discovery.cjs +297 -0
- package/sdk/dashboard/data/risk-surface.cjs +136 -0
- package/sdk/dashboard/data/source.cjs +576 -0
- package/sdk/dashboard/tui/ansi.cjs +355 -0
- package/sdk/dashboard/tui/index.cjs +778 -0
- package/sdk/mcp/gdd-mcp/server.js +70 -0
- package/skills/override/SKILL.md +86 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* scripts/lib/risk/compute-risk.cjs — PURE, deterministic action-risk scorer
|
|
4
|
+
* for the Phase 56 risk gate.
|
|
5
|
+
*
|
|
6
|
+
* NO I/O. NO Date.now / Math.random. Given the same (tool_name, input,
|
|
7
|
+
* thresholds) it always returns the same result. Frozen static tables live in
|
|
8
|
+
* ./tables.cjs; config overrides are merged by the HOOK (which reads
|
|
9
|
+
* .design/config.json and passes the merged thresholds/tables in) — this
|
|
10
|
+
* module stays side-effect-free so the routing matrix is unit-testable.
|
|
11
|
+
*
|
|
12
|
+
* Contract:
|
|
13
|
+
* computeRisk(tool_name, input, thresholds = THRESHOLDS, tables = defaults)
|
|
14
|
+
* -> { score:0..1, reasons:string[], suggested_action, breakdown }
|
|
15
|
+
*
|
|
16
|
+
* score = clamp01( base * fileMult + fileAdd + sum(inputAdds) )
|
|
17
|
+
* suggested_action in 'allow' | 'review' | 'require_confirmation' | 'block'
|
|
18
|
+
*
|
|
19
|
+
* loadRiskConfig(cwd) is provided (mirrors blast-radius.loadConfig) so the hook
|
|
20
|
+
* can read `.design/config.json#risk.{thresholds, base_tool_extra,
|
|
21
|
+
* file_sensitivity_extra, input_pattern_extra}` and EXTEND the defaults
|
|
22
|
+
* (extend-only — protected-paths discipline). computeRisk itself never calls it.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
|
|
28
|
+
const TABLES = require('./tables.cjs');
|
|
29
|
+
const { BASE_TOOL_RISK, FILE_SENSITIVITY, INPUT_PATTERN_RISK, THRESHOLDS } = TABLES;
|
|
30
|
+
|
|
31
|
+
function clamp01(n) {
|
|
32
|
+
if (typeof n !== 'number' || Number.isNaN(n)) return 0;
|
|
33
|
+
if (n < 0) return 0;
|
|
34
|
+
if (n > 1) return 1;
|
|
35
|
+
return n;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normPath(p) {
|
|
39
|
+
return String(p == null ? '' : p).replace(/\\/g, '/').replace(/^\.\//, '');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* pathsFor(tool, input) — the file paths a tool action touches.
|
|
44
|
+
* Edit/Write/NotebookEdit -> file_path / notebook_path
|
|
45
|
+
* MultiEdit -> the shared file_path (edits[] all target it)
|
|
46
|
+
* Bash -> best-effort path-ish tokens extracted from the command
|
|
47
|
+
*/
|
|
48
|
+
function pathsFor(tool, input) {
|
|
49
|
+
const out = [];
|
|
50
|
+
if (!input || typeof input !== 'object') return out;
|
|
51
|
+
if (typeof input.file_path === 'string') out.push(normPath(input.file_path));
|
|
52
|
+
if (typeof input.notebook_path === 'string') out.push(normPath(input.notebook_path));
|
|
53
|
+
if (typeof input.path === 'string') out.push(normPath(input.path));
|
|
54
|
+
if (tool === 'Bash' && typeof input.command === 'string') {
|
|
55
|
+
for (const t of extractBashPaths(input.command)) out.push(normPath(t));
|
|
56
|
+
}
|
|
57
|
+
// de-dup, drop empties
|
|
58
|
+
return Array.from(new Set(out.filter(Boolean)));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Small, linear extractor: pull whitespace-delimited tokens that look like
|
|
62
|
+
// file paths (contain a slash or a dot-extension, no shell metachars). Linear
|
|
63
|
+
// scan — no backtracking-prone regex.
|
|
64
|
+
function extractBashPaths(command) {
|
|
65
|
+
const tokens = String(command).split(/\s+/);
|
|
66
|
+
const paths = [];
|
|
67
|
+
for (const raw of tokens) {
|
|
68
|
+
const t = raw.replace(/^['"]|['"]$/g, '');
|
|
69
|
+
if (!t || t.startsWith('-')) continue;
|
|
70
|
+
if (/[|;&$`(){}<>*?!]/.test(t)) continue; // skip shell-operator/glob tokens
|
|
71
|
+
if (t.includes('/') || /\.[A-Za-z0-9]{1,8}$/.test(t)) paths.push(t);
|
|
72
|
+
}
|
|
73
|
+
return paths;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* pickMaxFileSensitivity(paths, table) — the single highest-WEIGHT matching
|
|
78
|
+
* entry across all touched paths. "Weight" = mult + add so a clearly higher-mult
|
|
79
|
+
* entry wins over a low de-risking one even when both match (e.g. a file under
|
|
80
|
+
* both `tests/` and `hooks/` resolves to the hook entry). Returns
|
|
81
|
+
* { mult:1, add:0, label:null } when nothing matches.
|
|
82
|
+
*/
|
|
83
|
+
function pickMaxFileSensitivity(paths, table) {
|
|
84
|
+
let best = null;
|
|
85
|
+
let bestWeight = -Infinity;
|
|
86
|
+
for (const entry of table) {
|
|
87
|
+
for (const p of paths) {
|
|
88
|
+
if (entry.test.test(p)) {
|
|
89
|
+
const w = (typeof entry.mult === 'number' ? entry.mult : 1) + (typeof entry.add === 'number' ? entry.add : 0);
|
|
90
|
+
if (w > bestWeight) {
|
|
91
|
+
bestWeight = w;
|
|
92
|
+
best = entry;
|
|
93
|
+
}
|
|
94
|
+
break; // this entry already matched; move to the next entry
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!best) return { mult: 1, add: 0, label: null };
|
|
99
|
+
return { mult: typeof best.mult === 'number' ? best.mult : 1, add: typeof best.add === 'number' ? best.add : 0, label: best.label };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function actionFor(score, thresholds) {
|
|
103
|
+
const t = thresholds || THRESHOLDS;
|
|
104
|
+
if (score >= t.block) return 'block';
|
|
105
|
+
if (score >= t.require_confirmation) return 'require_confirmation';
|
|
106
|
+
if (score >= t.review) return 'review';
|
|
107
|
+
return 'allow';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* computeRisk — the pure scorer.
|
|
112
|
+
* @param {string} tool_name
|
|
113
|
+
* @param {object} input tool_input (Edit/Write/MultiEdit/Bash/...)
|
|
114
|
+
* @param {object} [thresholds] defaults to TABLES.THRESHOLDS
|
|
115
|
+
* @param {object} [tables] { BASE_TOOL_RISK, FILE_SENSITIVITY, INPUT_PATTERN_RISK } — defaults to the frozen tables
|
|
116
|
+
* @returns {{score:number, reasons:string[], suggested_action:string, breakdown:object}}
|
|
117
|
+
*/
|
|
118
|
+
function computeRisk(tool_name, input, thresholds = THRESHOLDS, tables) {
|
|
119
|
+
const baseTbl = (tables && tables.BASE_TOOL_RISK) || BASE_TOOL_RISK;
|
|
120
|
+
const fileTbl = (tables && tables.FILE_SENSITIVITY) || FILE_SENSITIVITY;
|
|
121
|
+
const inputTbl = (tables && tables.INPUT_PATTERN_RISK) || INPUT_PATTERN_RISK;
|
|
122
|
+
|
|
123
|
+
const reasons = [];
|
|
124
|
+
|
|
125
|
+
// 1. Base tool risk.
|
|
126
|
+
const base = typeof baseTbl[tool_name] === 'number' ? baseTbl[tool_name] : baseTbl.__default;
|
|
127
|
+
reasons.push(`base:${tool_name}=${round(base)}`);
|
|
128
|
+
|
|
129
|
+
// 2. File sensitivity (highest-weight match across touched paths).
|
|
130
|
+
const paths = pathsFor(tool_name, input);
|
|
131
|
+
const fs_ = pickMaxFileSensitivity(paths, fileTbl);
|
|
132
|
+
if (fs_.label) {
|
|
133
|
+
reasons.push(`file:${fs_.label}(x${fs_.mult}+${fs_.add})`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 3. Input-pattern addends (fixed table order).
|
|
137
|
+
const inputAdds = [];
|
|
138
|
+
let inputAddSum = 0;
|
|
139
|
+
for (const entry of inputTbl) {
|
|
140
|
+
let hit;
|
|
141
|
+
try {
|
|
142
|
+
hit = entry.when(tool_name, input);
|
|
143
|
+
} catch {
|
|
144
|
+
hit = false;
|
|
145
|
+
}
|
|
146
|
+
if (!hit) continue;
|
|
147
|
+
const add = typeof entry.add === 'function' ? entry.add(hit, tool_name, input) : entry.add;
|
|
148
|
+
const a = typeof add === 'number' && Number.isFinite(add) ? add : 0;
|
|
149
|
+
if (a === 0) continue;
|
|
150
|
+
inputAdds.push({ label: entry.label, add: a });
|
|
151
|
+
inputAddSum += a;
|
|
152
|
+
reasons.push(`input:${entry.label}=+${round(a)}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 4. Combine + clamp.
|
|
156
|
+
const rawScore = base * fs_.mult + fs_.add + inputAddSum;
|
|
157
|
+
const score = clamp01(rawScore);
|
|
158
|
+
|
|
159
|
+
const suggested_action = actionFor(score, thresholds);
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
score,
|
|
163
|
+
reasons,
|
|
164
|
+
suggested_action,
|
|
165
|
+
breakdown: {
|
|
166
|
+
base,
|
|
167
|
+
tool: tool_name,
|
|
168
|
+
paths,
|
|
169
|
+
file: { mult: fs_.mult, add: fs_.add, label: fs_.label },
|
|
170
|
+
inputAdds,
|
|
171
|
+
inputAddSum: round3(inputAddSum),
|
|
172
|
+
raw: round3(rawScore),
|
|
173
|
+
thresholds,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function round(n) {
|
|
179
|
+
return Math.round(n * 100) / 100;
|
|
180
|
+
}
|
|
181
|
+
function round3(n) {
|
|
182
|
+
return Math.round(n * 1000) / 1000;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Config loader (used by the HOOK, not by computeRisk) ────────────────────
|
|
186
|
+
// Mirrors blast-radius.loadConfig. Reads .design/config.json#risk and returns
|
|
187
|
+
// merged thresholds + EXTEND-only table extras. Defaults are returned when the
|
|
188
|
+
// file/keys are absent or malformed. This is the ONLY function here that does
|
|
189
|
+
// I/O; computeRisk stays pure.
|
|
190
|
+
function loadRiskConfig(cwd) {
|
|
191
|
+
const configPath = path.join(cwd || process.cwd(), '.design', 'config.json');
|
|
192
|
+
let cfg = {};
|
|
193
|
+
try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { cfg = {}; }
|
|
194
|
+
const risk = (cfg && typeof cfg === 'object' && cfg.risk) || {};
|
|
195
|
+
const t = (risk && typeof risk.thresholds === 'object' && risk.thresholds) || {};
|
|
196
|
+
return {
|
|
197
|
+
thresholds: {
|
|
198
|
+
review: numOrInRange(t.review, THRESHOLDS.review),
|
|
199
|
+
require_confirmation: numOrInRange(t.require_confirmation, THRESHOLDS.require_confirmation),
|
|
200
|
+
block: numOrInRange(t.block, THRESHOLDS.block),
|
|
201
|
+
},
|
|
202
|
+
// Extend-only table extras (the hook merges these onto the frozen defaults).
|
|
203
|
+
base_tool_extra: (risk && typeof risk.base_tool_extra === 'object' && risk.base_tool_extra) || {},
|
|
204
|
+
file_sensitivity_extra: Array.isArray(risk.file_sensitivity_extra) ? risk.file_sensitivity_extra : [],
|
|
205
|
+
input_pattern_extra: Array.isArray(risk.input_pattern_extra) ? risk.input_pattern_extra : [],
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function numOrInRange(v, fallback) {
|
|
210
|
+
if (typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1) return v;
|
|
211
|
+
return fallback;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
module.exports = {
|
|
215
|
+
computeRisk,
|
|
216
|
+
// helpers exported for the hook + tests
|
|
217
|
+
pathsFor,
|
|
218
|
+
pickMaxFileSensitivity,
|
|
219
|
+
actionFor,
|
|
220
|
+
clamp01,
|
|
221
|
+
loadRiskConfig,
|
|
222
|
+
_extractBashPaths: extractBashPaths,
|
|
223
|
+
// re-export the tables so consumers (B/C/D) can `require('./compute-risk')`
|
|
224
|
+
// and get THRESHOLDS without a second import.
|
|
225
|
+
THRESHOLDS,
|
|
226
|
+
BASE_TOOL_RISK,
|
|
227
|
+
FILE_SENSITIVITY,
|
|
228
|
+
INPUT_PATTERN_RISK,
|
|
229
|
+
};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* scripts/lib/risk/consumers.cjs — Phase 56 importer/consumer resolver for the
|
|
4
|
+
* fact-forcing gate (hooks/gdd-fact-force.js).
|
|
5
|
+
*
|
|
6
|
+
* Wraps the Phase 52 typed DesignContext graph query (design-context-query.cjs
|
|
7
|
+
* `load` + `consumersOf`) with a BEST-EFFORT file→node mapping and a
|
|
8
|
+
* SOFTEN-IF-ABSENT contract: when `.design/context-graph.json` is missing,
|
|
9
|
+
* unbuilt, or malformed, this returns `{ available:false, importers:[] }` and
|
|
10
|
+
* NEVER throws — so the gate degrades to a warning on greenfield projects
|
|
11
|
+
* rather than over-blocking (CONTEXT.md R3 / D3).
|
|
12
|
+
*
|
|
13
|
+
* Why a wrapper and not a direct consumersOf call: the graph indexes NODES
|
|
14
|
+
* (ids like `component:Button`, `token:color/primary/500`), not file paths. A
|
|
15
|
+
* writer action mutates a FILE (`src/components/Button.tsx`). We map the file
|
|
16
|
+
* to its node id by lowercased basename/slug match against the node set, then
|
|
17
|
+
* ask the Phase 52 query for that node's consumers, and finally surface the
|
|
18
|
+
* consumer NAMES so the gate can check whether their files were Read.
|
|
19
|
+
*
|
|
20
|
+
* Dependency-free. The only I/O is the graph file read (delegated to the
|
|
21
|
+
* Phase 52 `load`), and it is fully guarded.
|
|
22
|
+
*
|
|
23
|
+
* Public API:
|
|
24
|
+
* consumersOfFile(filePath, { root?, graph? })
|
|
25
|
+
* -> { available:boolean, importers:string[], targets:string[], nodeId?:string }
|
|
26
|
+
*
|
|
27
|
+
* `available` — true only when a graph loaded AND the file mapped to a node.
|
|
28
|
+
* `importers` — consumer node names/slugs (lowercased), best-effort file
|
|
29
|
+
* identifiers the gate matches against state.reads.
|
|
30
|
+
* `targets` — the consumer node ids (raw, for diagnostics).
|
|
31
|
+
* `nodeId` — the resolved node id for `filePath`, when one matched.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const path = require('path');
|
|
35
|
+
|
|
36
|
+
// Phase 52 query — sibling under scripts/lib/. Resolved by package-root walk-up
|
|
37
|
+
// (Phase 53/54 lesson) so the require survives regardless of this module's
|
|
38
|
+
// install location. Loaded lazily + guarded so an absent sibling SOFTENS.
|
|
39
|
+
let _query = null;
|
|
40
|
+
let _queryResolved = false;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Walk up from a start dir to the package root (the dir whose package.json
|
|
44
|
+
* `name` is this package), returning that root or null. Used to resolve the
|
|
45
|
+
* Phase 52 sibling robustly even when cwd differs from the install dir.
|
|
46
|
+
*/
|
|
47
|
+
function findPackageRoot(startDir) {
|
|
48
|
+
let dir = startDir;
|
|
49
|
+
for (let i = 0; i < 12; i++) {
|
|
50
|
+
try {
|
|
51
|
+
const pkg = require(path.join(dir, 'package.json'));
|
|
52
|
+
if (pkg && pkg.name === '@hegemonart/get-design-done') return dir;
|
|
53
|
+
} catch { /* not this level */ }
|
|
54
|
+
const parent = path.dirname(dir);
|
|
55
|
+
if (parent === dir) break;
|
|
56
|
+
dir = parent;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getQuery() {
|
|
62
|
+
if (_queryResolved) return _query;
|
|
63
|
+
_queryResolved = true;
|
|
64
|
+
// 1. Adjacent sibling (this file lives in scripts/lib/risk/, query in scripts/lib/).
|
|
65
|
+
const candidates = [path.join(__dirname, '..', 'design-context-query.cjs')];
|
|
66
|
+
// 2. Package-root walk-up fallback (robust to relocated installs).
|
|
67
|
+
const root = findPackageRoot(__dirname);
|
|
68
|
+
if (root) candidates.push(path.join(root, 'scripts', 'lib', 'design-context-query.cjs'));
|
|
69
|
+
for (const c of candidates) {
|
|
70
|
+
try {
|
|
71
|
+
_query = require(c);
|
|
72
|
+
if (_query && typeof _query.consumersOf === 'function' && typeof _query.load === 'function') {
|
|
73
|
+
return _query;
|
|
74
|
+
}
|
|
75
|
+
} catch { /* try next candidate */ }
|
|
76
|
+
}
|
|
77
|
+
_query = null;
|
|
78
|
+
return _query;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Lowercase + tokenize a file basename or a node id/name into comparable slug
|
|
83
|
+
* tokens. Splits on path separators, hyphens, underscores, dots, and colons
|
|
84
|
+
* (node ids are `<type>:<name>` with `/`-segmented names). Drops the file
|
|
85
|
+
* extension and short no-signal tokens.
|
|
86
|
+
*/
|
|
87
|
+
function slugTokens(s) {
|
|
88
|
+
if (!s) return [];
|
|
89
|
+
const lc = String(s).toLowerCase();
|
|
90
|
+
// Drop a trailing file extension (`.tsx`, `.css.ts` -> keep `css`+`ts` out).
|
|
91
|
+
const noExt = lc.replace(/\.[a-z0-9]+$/i, '');
|
|
92
|
+
return noExt
|
|
93
|
+
.split(/[\\/\-_.:]+/)
|
|
94
|
+
.map((t) => t.trim())
|
|
95
|
+
.filter((t) => t.length > 1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The most specific basename slug for a file (its basename, sans extension). */
|
|
99
|
+
function fileSlug(filePath) {
|
|
100
|
+
const base = path.basename(String(filePath || ''));
|
|
101
|
+
return base.replace(/\.[a-z0-9.]+$/i, '').toLowerCase();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Best-effort: map a file path to a graph node id by matching the file's
|
|
106
|
+
* basename slug against each node's id/name tokens. Prefers an exact basename
|
|
107
|
+
* == node-name(slug) match; falls back to any shared token. Returns the matched
|
|
108
|
+
* node id, or null when nothing matches.
|
|
109
|
+
*/
|
|
110
|
+
function fileToNodeId(filePath, graph) {
|
|
111
|
+
const nodes = Array.isArray(graph && graph.nodes) ? graph.nodes : [];
|
|
112
|
+
if (!nodes.length) return null;
|
|
113
|
+
const fSlug = fileSlug(filePath);
|
|
114
|
+
const fTokens = new Set(slugTokens(path.basename(String(filePath || ''))));
|
|
115
|
+
if (!fSlug && fTokens.size === 0) return null;
|
|
116
|
+
|
|
117
|
+
let exact = null;
|
|
118
|
+
let partial = null;
|
|
119
|
+
for (const n of nodes) {
|
|
120
|
+
if (!n || typeof n.id !== 'string') continue;
|
|
121
|
+
const nameTokens = slugTokens(n.name != null ? n.name : '');
|
|
122
|
+
const idTokens = slugTokens(n.id);
|
|
123
|
+
// The node's "leaf" identifier: last segment of name, else last of id.
|
|
124
|
+
const nameLeaf = nameTokens.length ? nameTokens[nameTokens.length - 1] : '';
|
|
125
|
+
const idLeaf = idTokens.length ? idTokens[idTokens.length - 1] : '';
|
|
126
|
+
if (fSlug && (fSlug === nameLeaf || fSlug === idLeaf)) {
|
|
127
|
+
exact = n.id;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
if (!partial) {
|
|
131
|
+
for (const t of fTokens) {
|
|
132
|
+
if (nameTokens.includes(t) || idTokens.includes(t)) { partial = n.id; break; }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return exact || partial;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Resolve the consumer/importer identifiers for a file via the Phase 52 graph.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} filePath the file being mutated
|
|
143
|
+
* @param {{root?:string, graph?:object}} [opts]
|
|
144
|
+
* root — project root holding `.design/context-graph.json`
|
|
145
|
+
* graph — pre-loaded graph object (test injection; bypasses disk read)
|
|
146
|
+
* @returns {{available:boolean, importers:string[], targets:string[], nodeId?:string}}
|
|
147
|
+
*/
|
|
148
|
+
function consumersOfFile(filePath, opts = {}) {
|
|
149
|
+
const SOFT = { available: false, importers: [], targets: [] };
|
|
150
|
+
try {
|
|
151
|
+
const q = getQuery();
|
|
152
|
+
if (!q) return SOFT;
|
|
153
|
+
|
|
154
|
+
// Obtain the graph: injected (tests) or loaded from disk (guarded).
|
|
155
|
+
let graph = opts && opts.graph;
|
|
156
|
+
if (!graph) {
|
|
157
|
+
const root = (opts && opts.root) || process.cwd();
|
|
158
|
+
const graphPath = path.join(root, '.design', 'context-graph.json');
|
|
159
|
+
try {
|
|
160
|
+
graph = q.load(graphPath);
|
|
161
|
+
} catch {
|
|
162
|
+
// Absent / unbuilt / malformed -> SOFTEN to a warning, never throw.
|
|
163
|
+
return SOFT;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (!graph || !Array.isArray(graph.nodes)) return SOFT;
|
|
167
|
+
|
|
168
|
+
const nodeId = fileToNodeId(filePath, graph);
|
|
169
|
+
if (!nodeId) {
|
|
170
|
+
// Graph exists but this file maps to no node — treat as "no known
|
|
171
|
+
// consumers" but mark available so the gate doesn't soften purely on a
|
|
172
|
+
// mapping miss (an unmapped file genuinely has no importer prerequisite).
|
|
173
|
+
return { available: true, importers: [], targets: [], nodeId: undefined };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const consumerNodes = q.consumersOf(graph, nodeId) || [];
|
|
177
|
+
const targets = consumerNodes
|
|
178
|
+
.map((n) => (n && typeof n.id === 'string' ? n.id : null))
|
|
179
|
+
.filter(Boolean);
|
|
180
|
+
// Importer identifiers the gate matches against state.reads: the consumer
|
|
181
|
+
// node's leaf name (lowercased), which is the strongest signal for the
|
|
182
|
+
// consumer's source-file basename.
|
|
183
|
+
const importers = [];
|
|
184
|
+
for (const n of consumerNodes) {
|
|
185
|
+
if (!n) continue;
|
|
186
|
+
const nameTokens = slugTokens(n.name != null ? n.name : '');
|
|
187
|
+
const idTokens = slugTokens(n.id);
|
|
188
|
+
const leaf = nameTokens.length ? nameTokens[nameTokens.length - 1]
|
|
189
|
+
: (idTokens.length ? idTokens[idTokens.length - 1] : null);
|
|
190
|
+
if (leaf) importers.push(leaf);
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
available: true,
|
|
194
|
+
importers: Array.from(new Set(importers)),
|
|
195
|
+
targets,
|
|
196
|
+
nodeId,
|
|
197
|
+
};
|
|
198
|
+
} catch {
|
|
199
|
+
// Any unexpected failure SOFTENS — the gate must never hard-fail on graph I/O.
|
|
200
|
+
return SOFT;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = {
|
|
205
|
+
consumersOfFile,
|
|
206
|
+
// exported for tests / reuse
|
|
207
|
+
fileToNodeId,
|
|
208
|
+
fileSlug,
|
|
209
|
+
slugTokens,
|
|
210
|
+
findPackageRoot,
|
|
211
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* scripts/lib/risk/override.cjs — PURE helpers for the `/gdd:override` skill
|
|
4
|
+
* (Phase 56). Mirrors the unlock-decision precedent (Phase 40): override is the
|
|
5
|
+
* deliberately heavyweight escape hatch from a risk-blocked action or a
|
|
6
|
+
* fact-force gate, and every use is audited.
|
|
7
|
+
*
|
|
8
|
+
* NO I/O, NO Date.now / Math.random. The SKILL.md (and C's fact-force hook) do
|
|
9
|
+
* the actual atomic file write; these functions only shape the data so the
|
|
10
|
+
* routing/override matrix stays unit-testable in isolation.
|
|
11
|
+
*
|
|
12
|
+
* Two modes, one per exported builder:
|
|
13
|
+
*
|
|
14
|
+
* 1. overrideDecisionEntry(findingId, { approver, reason })
|
|
15
|
+
* -> { text, status, tag } for `mcp__gdd_state__add_decision`.
|
|
16
|
+
* The audit invariant: a recorded approver is mandatory. The `override`
|
|
17
|
+
* tag is embedded in `text` (the Decision shape is { id, text, status } —
|
|
18
|
+
* it has no dedicated tags field), so the entry is greppable and the
|
|
19
|
+
* D-XX it becomes carries the override marker in STATE.md <decisions>.
|
|
20
|
+
*
|
|
21
|
+
* 2. setFactForceChecked(state, path)
|
|
22
|
+
* -> a NEW session-state object with checked[path] = true.
|
|
23
|
+
* The fact-force gate (hooks/gdd-fact-force.js) reads this map at
|
|
24
|
+
* `<cwd>/.design/locks/factforce-<session_id>.json`; once a path is
|
|
25
|
+
* checked it stops blocking the first mutation of that path.
|
|
26
|
+
*
|
|
27
|
+
* Dependency-free.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const OVERRIDE_TAG = 'override';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build the audited decision entry for a risk-blocked finding override.
|
|
34
|
+
* @param {string} findingId e.g. "G-12" or a risk finding id
|
|
35
|
+
* @param {object} opts
|
|
36
|
+
* @param {string} opts.approver REQUIRED non-empty approver name (audit invariant)
|
|
37
|
+
* @param {string} [opts.reason] optional rationale, recorded verbatim
|
|
38
|
+
* @returns {{ text:string, status:'locked', tag:'override' }}
|
|
39
|
+
* @throws {Error} when approver is missing/empty (override is never silent)
|
|
40
|
+
*/
|
|
41
|
+
function overrideDecisionEntry(findingId, opts = {}) {
|
|
42
|
+
const id = typeof findingId === 'string' ? findingId.trim() : '';
|
|
43
|
+
const approver = typeof opts.approver === 'string' ? opts.approver.trim() : '';
|
|
44
|
+
const reason = typeof opts.reason === 'string' ? opts.reason.trim() : '';
|
|
45
|
+
if (!id) throw new Error('override: a finding id is required');
|
|
46
|
+
if (!approver) throw new Error('override: --approver is required (audit invariant)');
|
|
47
|
+
// The tag prefix keeps the entry greppable; reason is recorded when present.
|
|
48
|
+
const base = `[${OVERRIDE_TAG}] ${id} risk-blocked action approved by ${approver}`;
|
|
49
|
+
const text = reason ? `${base}. Reason: ${reason}` : base;
|
|
50
|
+
return { text, status: 'locked', tag: OVERRIDE_TAG };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Mark a path as fact-force-checked in a session-state object. Pure: returns a
|
|
55
|
+
* new object, never mutates the input. A non-object/absent state seeds a fresh
|
|
56
|
+
* one ({ reads:{}, checked:{} }) so the first override on a greenfield session
|
|
57
|
+
* still produces a valid file for the hook to read.
|
|
58
|
+
* @param {object} state the parsed factforce session state (or null/undefined)
|
|
59
|
+
* @param {string} p the path to unblock
|
|
60
|
+
* @returns {{ reads:object, checked:object }}
|
|
61
|
+
*/
|
|
62
|
+
function setFactForceChecked(state, p) {
|
|
63
|
+
const key = typeof p === 'string' ? p.replace(/\\/g, '/').replace(/^\.\//, '') : '';
|
|
64
|
+
if (!key) throw new Error('override: a path is required for factforce mode');
|
|
65
|
+
const src = state && typeof state === 'object' ? state : {};
|
|
66
|
+
const reads = src.reads && typeof src.reads === 'object' ? { ...src.reads } : {};
|
|
67
|
+
const checked = src.checked && typeof src.checked === 'object' ? { ...src.checked } : {};
|
|
68
|
+
checked[key] = true;
|
|
69
|
+
return { ...src, reads, checked };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* isFactForceChecked(state, path) — read-side predicate the gate uses to decide
|
|
74
|
+
* whether a path was overridden. Pure.
|
|
75
|
+
*/
|
|
76
|
+
function isFactForceChecked(state, p) {
|
|
77
|
+
const key = typeof p === 'string' ? p.replace(/\\/g, '/').replace(/^\.\//, '') : '';
|
|
78
|
+
if (!key || !state || typeof state !== 'object' || !state.checked) return false;
|
|
79
|
+
return state.checked[key] === true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
overrideDecisionEntry,
|
|
84
|
+
setFactForceChecked,
|
|
85
|
+
isFactForceChecked,
|
|
86
|
+
OVERRIDE_TAG,
|
|
87
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* scripts/lib/risk/route.cjs — pure confidence×risk router for the Phase 56
|
|
4
|
+
* gate (the risk sibling of scripts/lib/confidence-route.cjs from Phase 49).
|
|
5
|
+
*
|
|
6
|
+
* Decides what a writer agent (design-fixer Step 2.5) should DO with an action,
|
|
7
|
+
* given (a) the agent's CONFIDENCE in the change [0..1] and (b) the risk
|
|
8
|
+
* scorer's suggested_action ('allow'|'review'|'require_confirmation'|'block').
|
|
9
|
+
*
|
|
10
|
+
* Canonical rule (mirrors the Phase 56 shared contract):
|
|
11
|
+
* 1. action === 'block' -> 'override' (always; block short-circuits)
|
|
12
|
+
* 2. confidence < 0.5 -> 'skip' (low-confidence floor; non-block)
|
|
13
|
+
* 3. confidence >= 0.8 && action in {allow,review} -> 'auto'
|
|
14
|
+
* 4. confidence >= 0.8 && action === 'require_confirmation'-> 'confirm'
|
|
15
|
+
* 5. else (0.5 <= confidence < 0.8, non-block) -> 'confirm'
|
|
16
|
+
*
|
|
17
|
+
* Note the ordering: BLOCK is checked before the low-confidence floor, so a
|
|
18
|
+
* block with low confidence still routes to 'override' (you cannot silently
|
|
19
|
+
* skip a blocked action — it must be explicitly overridden). A non-block action
|
|
20
|
+
* with confidence < 0.5 is skipped.
|
|
21
|
+
*
|
|
22
|
+
* Returns: 'auto' | 'confirm' | 'skip' | 'override'. Dependency-free, no I/O.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const AUTO_FLOOR = 0.8; // at/above this, low-risk actions auto-apply
|
|
26
|
+
const SKIP_FLOOR = 0.5; // below this, non-block actions are skipped
|
|
27
|
+
|
|
28
|
+
const AUTO_OK_ACTIONS = new Set(['allow', 'review']);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {number} confidence agent confidence in the change, 0.0-1.0
|
|
32
|
+
* @param {string} action risk suggested_action: allow|review|require_confirmation|block
|
|
33
|
+
* @returns {'auto'|'confirm'|'skip'|'override'}
|
|
34
|
+
*/
|
|
35
|
+
function route(confidence, action) {
|
|
36
|
+
const a = typeof action === 'string' ? action.trim().toLowerCase() : '';
|
|
37
|
+
// A missing/non-numeric confidence is treated as the lowest tier (0).
|
|
38
|
+
const c = typeof confidence === 'number' && Number.isFinite(confidence) ? confidence : 0;
|
|
39
|
+
|
|
40
|
+
// 1. Block short-circuits everything: it must be explicitly overridden,
|
|
41
|
+
// regardless of confidence.
|
|
42
|
+
if (a === 'block') return 'override';
|
|
43
|
+
|
|
44
|
+
// 2. Low-confidence floor (non-block): not worth surfacing — skip.
|
|
45
|
+
if (c < SKIP_FLOOR) return 'skip';
|
|
46
|
+
|
|
47
|
+
// 3-4. High confidence: auto-apply low-risk, confirm if confirmation asked.
|
|
48
|
+
if (c >= AUTO_FLOOR) {
|
|
49
|
+
if (AUTO_OK_ACTIONS.has(a)) return 'auto';
|
|
50
|
+
if (a === 'require_confirmation') return 'confirm';
|
|
51
|
+
// Any other (unknown) non-block action at high confidence: be conservative.
|
|
52
|
+
return 'confirm';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 5. Mid confidence [0.5, 0.8), non-block: surface for confirmation.
|
|
56
|
+
return 'confirm';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { route, AUTO_FLOOR, SKIP_FLOOR, AUTO_OK_ACTIONS };
|