@hegemonart/get-design-done 1.55.0 → 1.57.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 +90 -0
  4. package/README.md +6 -0
  5. package/SKILL.md +2 -0
  6. package/agents/design-fixer.md +16 -0
  7. package/dist/claude-code/.claude/skills/override/SKILL.md +86 -0
  8. package/dist/claude-code/.claude/skills/state/SKILL.md +106 -0
  9. package/hooks/gdd-decision-injector.js +58 -0
  10. package/hooks/gdd-fact-force.js +434 -0
  11. package/hooks/gdd-risk-gate.js +406 -0
  12. package/hooks/hooks.json +18 -0
  13. package/package.json +1 -1
  14. package/reference/schemas/events.schema.json +61 -1
  15. package/reference/skill-graph.md +3 -1
  16. package/scripts/lib/manifest/skills.json +16 -0
  17. package/scripts/lib/risk/calibration.cjs +385 -0
  18. package/scripts/lib/risk/compute-risk.cjs +229 -0
  19. package/scripts/lib/risk/consumers.cjs +211 -0
  20. package/scripts/lib/risk/override.cjs +87 -0
  21. package/scripts/lib/risk/route.cjs +59 -0
  22. package/scripts/lib/risk/tables.cjs +221 -0
  23. package/scripts/lib/state/migrate-to-sqlite.cjs +664 -0
  24. package/scripts/lib/state/query-surface.cjs +391 -0
  25. package/scripts/lib/state/render-markdown.cjs +717 -0
  26. package/scripts/lib/state/state-backend.cjs +345 -0
  27. package/scripts/lib/state/state-store.cjs +735 -0
  28. package/sdk/cli/index.js +193 -96
  29. package/sdk/dashboard/data/source.cjs +44 -5
  30. package/sdk/mcp/gdd-state/server.js +127 -30
  31. package/sdk/mcp/gdd-state/tools/get.ts +8 -0
  32. package/sdk/state/index.ts +267 -13
  33. package/sdk/state/lockfile.ts +48 -0
  34. package/sdk/state/schema.sql +218 -0
  35. package/skills/override/SKILL.md +86 -0
  36. package/skills/state/SKILL.md +106 -0
@@ -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 };
@@ -0,0 +1,221 @@
1
+ 'use strict';
2
+ /**
3
+ * scripts/lib/risk/tables.cjs — frozen, dependency-free static tables for the
4
+ * Phase 56 risk scorer. PURE DATA + linear-only regexes (CodeQL js/redos safe:
5
+ * no nested quantifiers, no `(a+)+`, no `(.*)*`; the secret-shaped pattern is
6
+ * anchored on fixed prefixes with bounded character classes).
7
+ *
8
+ * Consumed by scripts/lib/risk/compute-risk.cjs. Tables are
9
+ * `Object.freeze`-d so a downstream consumer cannot mutate the shared defaults;
10
+ * config overrides EXTEND (never shrink) these via the loadConfig pattern in
11
+ * compute-risk.cjs (protected-paths discipline — D7).
12
+ *
13
+ * Exports:
14
+ * BASE_TOOL_RISK — { [toolName]: number, __default: number }
15
+ * FILE_SENSITIVITY — ordered [{ test:RegExp, mult, add, label }]
16
+ * INPUT_PATTERN_RISK— ordered [{ when:(tool,input)=>bool|hit, add:number|fn, label }]
17
+ * THRESHOLDS — { review, require_confirmation, block }
18
+ * SECRET_SHAPED_RE — the (linear) secret detector, exported for reuse/tests
19
+ * _SEVERITY_ADD — dangerous-bash severity -> addend map
20
+ */
21
+
22
+ const dangerous = require('../dangerous-patterns.cjs');
23
+ const blast = require('../blast-radius.cjs');
24
+
25
+ // ── Base per-tool risk ─────────────────────────────────────────────────────
26
+ // Bash is the riskiest (arbitrary shell), then bulk edits, then single edits,
27
+ // then whole-file writes; read-only tools are ~zero. __default covers unknown
28
+ // tools conservatively.
29
+ const BASE_TOOL_RISK = Object.freeze({
30
+ Bash: 0.55,
31
+ MultiEdit: 0.40,
32
+ Edit: 0.35,
33
+ NotebookEdit: 0.35,
34
+ Write: 0.30,
35
+ Read: 0.02,
36
+ Glob: 0,
37
+ Grep: 0,
38
+ __default: 0.20,
39
+ });
40
+
41
+ // ── Secret-shaped content detector ─────────────────────────────────────────
42
+ // Linear: each alternative is a fixed prefix + a bounded/anchored class. No
43
+ // alternative can backtrack into another (distinct literal prefixes).
44
+ // AWS access key id | PEM private-key header | OpenAI sk- | GitHub ghp_ | Slack xox?-
45
+ const SECRET_SHAPED_RE =
46
+ /AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----|sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{36}|xox[baprs]-/;
47
+
48
+ // ── File-sensitivity table (mirrors reference/protected-paths.default.json) ──
49
+ // ORDERED, highest-weight first. compute-risk.cjs picks the single
50
+ // highest-weight matching entry (pickMaxFileSensitivity). `test` matches a
51
+ // forward-slash-normalized path. All regexes linear.
52
+ //
53
+ // mult multiplies the base tool risk; add is a flat addend. De-risking entries
54
+ // (tests/fixtures, docs) use mult<1 + add 0 to pull benign edits below review.
55
+ const FILE_SENSITIVITY = Object.freeze([
56
+ // State + config: the audit/control spine.
57
+ { test: /(^|\/)STATE\.md$/i, mult: 1.6, add: 0.25, label: 'planning-state' },
58
+ { test: /(^|\/)config\.json$/i, mult: 1.6, add: 0.25, label: 'config' },
59
+ // Schemas + lockfiles + generated styling contracts.
60
+ { test: /\.schema\.json$/i, mult: 1.5, add: 0.25, label: 'schema' },
61
+ { test: /(^|\/)package-lock\.json$/i, mult: 1.5, add: 0.20, label: 'lockfile' },
62
+ { test: /(^|\/)package\.json$/i, mult: 1.5, add: 0.20, label: 'package-manifest' },
63
+ { test: /\.css\.ts$/i, mult: 1.5, add: 0.20, label: 'css-in-ts' },
64
+ // Hooks + CI: execution surface.
65
+ { test: /(^|\/)hooks\//i, mult: 1.5, add: 0.20, label: 'hook' },
66
+ { test: /(^|\/)\.github\/workflows\//i, mult: 1.5, add: 0.20, label: 'ci-workflow' },
67
+ // Design-token / theme sources.
68
+ { test: /(^|\/)(tokens|theme)(\/|[.-])/i, mult: 1.4, add: 0.18, label: 'design-tokens' },
69
+ // Build/runtime config files.
70
+ { test: /(^|\/)(tsconfig[^/]*\.json|\.npmrc|Dockerfile|\.gitleaks(\.toml)?)$/i, mult: 1.3, add: 0.15, label: 'build-config' },
71
+ // Plugin authoring surface (skills/commands/agents).
72
+ { test: /(^|\/)(skills|commands|agents)\//i, mult: 1.3, add: 0.12, label: 'authoring-surface' },
73
+ // De-risking: tests + fixtures are low-stakes.
74
+ { test: /(^|\/)(tests?|fixtures?|__tests__|__fixtures__)\//i, mult: 0.6, add: 0, label: 'test-or-fixture' },
75
+ // De-risking: docs / markdown.
76
+ { test: /(^|\/)docs?\/|\.mdx?$/i, mult: 0.5, add: 0, label: 'docs' },
77
+ ]);
78
+
79
+ // ── Severity -> addend for destructive bash (via dangerous-patterns.cjs) ────
80
+ const _SEVERITY_ADD = Object.freeze({ critical: 0.6, high: 0.4, medium: 0.2 });
81
+
82
+ // ── Helpers shared by INPUT_PATTERN_RISK predicates ─────────────────────────
83
+ function textOf(input) {
84
+ if (!input || typeof input !== 'object') return '';
85
+ const parts = [];
86
+ if (typeof input.content === 'string') parts.push(input.content);
87
+ if (typeof input.new_string === 'string') parts.push(input.new_string);
88
+ if (typeof input.new_str === 'string') parts.push(input.new_str);
89
+ if (Array.isArray(input.edits)) {
90
+ for (const e of input.edits) {
91
+ if (e && typeof e.new_string === 'string') parts.push(e.new_string);
92
+ }
93
+ }
94
+ if (typeof input.command === 'string') parts.push(input.command);
95
+ return parts.join('\n');
96
+ }
97
+
98
+ // Approximate the changed-line count for a Write/Edit/MultiEdit input by
99
+ // counting newlines in the new text. Reuses blast-radius.estimate for the
100
+ // capped addend math so the large-diff curve matches the blast-radius primitive.
101
+ function changedLineCount(tool, input) {
102
+ if (!input || typeof input !== 'object') return 0;
103
+ let lines = 0;
104
+ if (typeof input.content === 'string') lines += countLines(input.content);
105
+ if (typeof input.new_string === 'string') lines += countLines(input.new_string);
106
+ if (Array.isArray(input.edits)) {
107
+ for (const e of input.edits) {
108
+ if (e && typeof e.new_string === 'string') lines += countLines(e.new_string);
109
+ }
110
+ }
111
+ return lines;
112
+ }
113
+
114
+ function countLines(s) {
115
+ if (typeof s !== 'string' || s.length === 0) return 0;
116
+ // A non-empty string is at least one line; each newline adds one.
117
+ let n = 1;
118
+ for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 10) n++;
119
+ return n;
120
+ }
121
+
122
+ // File-path-ish fields, normalized to forward slashes (for schema/migration sniff).
123
+ function pathHintsOf(input) {
124
+ if (!input || typeof input !== 'object') return '';
125
+ const parts = [];
126
+ for (const k of ['file_path', 'notebook_path', 'path']) {
127
+ if (typeof input[k] === 'string') parts.push(input[k]);
128
+ }
129
+ return parts.join('\n').replace(/\\/g, '/');
130
+ }
131
+
132
+ // Linear regexes only.
133
+ const SCHEMA_MIGRATION_RE = /(^|\/)migrations?\/|\.schema\.json$|\bALTER\s+TABLE\b|\bCREATE\s+TABLE\b|\bDROP\s+TABLE\b/i;
134
+ const DEP_MUTATION_RE = /\b(npm|pnpm|yarn|bun)\s+(install|add|i|remove|rm|uninstall|up|update|upgrade)\b|\b(pip|pip3)\s+install\b|\bcargo\s+(add|install)\b/;
135
+ // Broad glob: a pattern argument touching the repo root with ** or a bare /* .
136
+ const BROAD_GLOB_RE = /(\*\*|(^|\s)\.?\/?\*(\s|$)|--include=\*|\s-r\b.*\*)/;
137
+
138
+ // ── Input-pattern risk table ────────────────────────────────────────────────
139
+ // ORDERED. Each `when(tool, input)` returns a truthy value (bool or a "hit"
140
+ // object) when it applies; `add` is either a flat number or a function of the
141
+ // hit/(tool,input) returning the addend. compute-risk.cjs accumulates every
142
+ // applicable entry in this fixed order.
143
+ const INPUT_PATTERN_RISK = Object.freeze([
144
+ {
145
+ label: 'dangerous-bash',
146
+ when: (tool, input) => {
147
+ if (tool !== 'Bash' || !input || typeof input.command !== 'string') return false;
148
+ const hit = dangerous.match(input.command);
149
+ return hit.matched ? hit : false;
150
+ },
151
+ add: (hit) => _SEVERITY_ADD[hit && hit.severity] || 0.2,
152
+ },
153
+ {
154
+ label: 'large-diff',
155
+ when: (tool, input) => {
156
+ const lines = changedLineCount(tool, input);
157
+ return lines > 0 ? lines : false;
158
+ },
159
+ // Cap at +0.30; curve = lines / 1500 (matches the shared contract).
160
+ add: (lines) => {
161
+ // Route through blast-radius.estimate so the line accounting stays in
162
+ // lockstep with the blast-radius primitive (pure: explicit DEFAULTS-like
163
+ // config, no disk read).
164
+ const est = blast.estimate({ diffStats: { insertions: lines, deletions: 0 }, config: { max_files_per_task: 0, max_lines_per_task: 0, max_mcp_calls_per_task: 0 } });
165
+ return Math.min(0.30, est.lines / 1500);
166
+ },
167
+ },
168
+ {
169
+ label: 'schema-migration',
170
+ when: (tool, input) => SCHEMA_MIGRATION_RE.test(pathHintsOf(input)) || SCHEMA_MIGRATION_RE.test(textOf(input)),
171
+ add: 0.25,
172
+ },
173
+ {
174
+ label: 'secret-shaped',
175
+ when: (tool, input) => SECRET_SHAPED_RE.test(textOf(input)),
176
+ add: 0.5,
177
+ },
178
+ {
179
+ label: 'broad-glob',
180
+ when: (tool, input) => {
181
+ if (tool === 'Bash' && input && typeof input.command === 'string') return BROAD_GLOB_RE.test(input.command);
182
+ if ((tool === 'Glob' || tool === 'Grep') && input && typeof input.pattern === 'string') return BROAD_GLOB_RE.test(input.pattern);
183
+ return false;
184
+ },
185
+ add: 0.15,
186
+ },
187
+ {
188
+ label: 'dependency-mutation',
189
+ when: (tool, input) => {
190
+ if (tool === 'Bash' && input && typeof input.command === 'string' && DEP_MUTATION_RE.test(input.command)) return true;
191
+ // Editing a manifest/lockfile is also a dependency mutation surface.
192
+ const hints = pathHintsOf(input);
193
+ return /(^|\/)(package\.json|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.toml|requirements\.txt)$/i.test(hints);
194
+ },
195
+ add: 0.15,
196
+ },
197
+ ]);
198
+
199
+ // ── Thresholds ──────────────────────────────────────────────────────────────
200
+ // score < review -> allow
201
+ // review <= score < require_confirmation -> review
202
+ // require_confirmation <= score < block -> require_confirmation
203
+ // score >= block -> block
204
+ const THRESHOLDS = Object.freeze({
205
+ review: 0.30,
206
+ require_confirmation: 0.60,
207
+ block: 0.85,
208
+ });
209
+
210
+ module.exports = {
211
+ BASE_TOOL_RISK,
212
+ FILE_SENSITIVITY,
213
+ INPUT_PATTERN_RISK,
214
+ THRESHOLDS,
215
+ SECRET_SHAPED_RE,
216
+ _SEVERITY_ADD,
217
+ // internal helpers exported for compute-risk.cjs + unit visibility
218
+ _textOf: textOf,
219
+ _changedLineCount: changedLineCount,
220
+ _pathHintsOf: pathHintsOf,
221
+ };