@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,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 };