@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +90 -0
- package/README.md +6 -0
- package/SKILL.md +2 -0
- package/agents/design-fixer.md +16 -0
- package/dist/claude-code/.claude/skills/override/SKILL.md +86 -0
- package/dist/claude-code/.claude/skills/state/SKILL.md +106 -0
- package/hooks/gdd-decision-injector.js +58 -0
- package/hooks/gdd-fact-force.js +434 -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 +3 -1
- package/scripts/lib/manifest/skills.json +16 -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/scripts/lib/state/migrate-to-sqlite.cjs +664 -0
- package/scripts/lib/state/query-surface.cjs +391 -0
- package/scripts/lib/state/render-markdown.cjs +717 -0
- package/scripts/lib/state/state-backend.cjs +345 -0
- package/scripts/lib/state/state-store.cjs +735 -0
- package/sdk/cli/index.js +193 -96
- package/sdk/dashboard/data/source.cjs +44 -5
- package/sdk/mcp/gdd-state/server.js +127 -30
- package/sdk/mcp/gdd-state/tools/get.ts +8 -0
- package/sdk/state/index.ts +267 -13
- package/sdk/state/lockfile.ts +48 -0
- package/sdk/state/schema.sql +218 -0
- package/skills/override/SKILL.md +86 -0
- 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
|
+
};
|