@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,434 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* hooks/gdd-fact-force.js — PreToolUse:Edit|Write|MultiEdit fact-forcing gate.
|
|
5
|
+
*
|
|
6
|
+
* Forces an agent to establish the FACTS before the FIRST mutation of a file in
|
|
7
|
+
* a session: the file's importers/consumers (from the Phase 52 DesignContext
|
|
8
|
+
* graph) must have been Read, and any decisions/blockers tagged with the file
|
|
9
|
+
* must have been surfaced. Until those prerequisites are met, the first write
|
|
10
|
+
* is SOFT-blocked (`{continue:false, stopReason}` listing the missing facts);
|
|
11
|
+
* the agent can satisfy them (Read the importers) or escape via
|
|
12
|
+
* `/gdd:override factforce <path>` which sets `checked[path]`.
|
|
13
|
+
*
|
|
14
|
+
* Tiering (CONTEXT.md shared contract):
|
|
15
|
+
* - prerequisites met OR checked[path] set -> { continue:true }
|
|
16
|
+
* - prerequisites UNMET, computeRisk != block -> SOFT block (continue:false)
|
|
17
|
+
* - prerequisites UNMET, computeRisk == block -> HARD block (continue:false);
|
|
18
|
+
* only escape is /gdd:override (same JSON shape, stronger stopReason)
|
|
19
|
+
* - graph ABSENT/unbuilt -> importer prereq SOFTENS to a
|
|
20
|
+
* warning, never a hard block (do not over-block greenfield)
|
|
21
|
+
*
|
|
22
|
+
* Session-state (worktree-safe, CONTEXT.md R5):
|
|
23
|
+
* <cwd>/.design/locks/factforce-<sanitized session_id>.json
|
|
24
|
+
* { reads: { <normPath>: <ISO> }, first_mutation_seen: { <normPath>: <ISO> },
|
|
25
|
+
* checked: { <normPath>: true } }
|
|
26
|
+
* Atomic tmp+rename. session_id from payload.session_id ?? GDD_SESSION_ID ?? 'hook'.
|
|
27
|
+
*
|
|
28
|
+
* Contract (PreToolUse): stdin { tool_name, tool_input:{file_path}, cwd, session_id? }
|
|
29
|
+
* stdout: { continue:true } | { continue:false, stopReason }
|
|
30
|
+
* exit : always 0. NEVER throws (fail-open { continue:true }).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const fs = require('fs');
|
|
34
|
+
const path = require('path');
|
|
35
|
+
|
|
36
|
+
const GATED_TOOLS = new Set(['Edit', 'Write', 'MultiEdit']);
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Package-root walk-up (Phase 53/54 lesson) for robust sibling resolution.
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
function findPackageRoot(startDir) {
|
|
42
|
+
let dir = startDir;
|
|
43
|
+
for (let i = 0; i < 12; i++) {
|
|
44
|
+
try {
|
|
45
|
+
const pkg = require(path.join(dir, 'package.json'));
|
|
46
|
+
if (pkg && pkg.name === '@hegemonart/get-design-done') return dir;
|
|
47
|
+
} catch { /* not this level */ }
|
|
48
|
+
const parent = path.dirname(dir);
|
|
49
|
+
if (parent === dir) break;
|
|
50
|
+
dir = parent;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Lazily resolve a sibling lib module by name, trying the adjacent path first
|
|
57
|
+
* then the package-root walk-up. Returns null when unresolvable (the gate then
|
|
58
|
+
* SOFTENS rather than crashing).
|
|
59
|
+
*/
|
|
60
|
+
function requireSibling(relFromLib, validate) {
|
|
61
|
+
const candidates = [path.join(__dirname, '..', 'scripts', 'lib', relFromLib)];
|
|
62
|
+
const root = findPackageRoot(__dirname);
|
|
63
|
+
if (root) candidates.push(path.join(root, 'scripts', 'lib', relFromLib));
|
|
64
|
+
for (const c of candidates) {
|
|
65
|
+
try {
|
|
66
|
+
const m = require(c);
|
|
67
|
+
if (!validate || validate(m)) return m;
|
|
68
|
+
} catch { /* try next */ }
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const _risk = requireSibling('risk/compute-risk.cjs', (m) => m && typeof m.computeRisk === 'function');
|
|
74
|
+
const _consumers = requireSibling('risk/consumers.cjs', (m) => m && typeof m.consumersOfFile === 'function');
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Path normalization
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
function normPath(p, cwd) {
|
|
80
|
+
if (!p) return '';
|
|
81
|
+
let s = String(p);
|
|
82
|
+
// Make absolute paths relative to cwd so reads[] keys match across the
|
|
83
|
+
// (absolute file_path the agent passes) and (relative paths we derive).
|
|
84
|
+
if (s.startsWith('/') || /^[A-Za-z]:[\\/]/.test(s)) {
|
|
85
|
+
try { s = path.relative(cwd || process.cwd(), s); } catch { /* keep s */ }
|
|
86
|
+
}
|
|
87
|
+
return s.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function leafSlug(p) {
|
|
91
|
+
const base = path.basename(String(p || ''));
|
|
92
|
+
return base.replace(/\.[a-z0-9.]+$/i, '').toLowerCase();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Session-state (atomic tmp+rename; mirrors bandit-router's write pattern)
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
function sessionIdFrom(payload) {
|
|
99
|
+
const raw = (payload && (payload.session_id || payload.sessionId))
|
|
100
|
+
|| process.env.GDD_SESSION_ID
|
|
101
|
+
|| 'hook';
|
|
102
|
+
// Sanitize for a filename: keep alnum/dash/underscore, collapse the rest.
|
|
103
|
+
return String(raw).replace(/[^A-Za-z0-9_-]+/g, '-').slice(0, 120) || 'hook';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function stateFileFor(cwd, sessionId) {
|
|
107
|
+
return path.join(cwd || process.cwd(), '.design', 'locks', `factforce-${sessionId}.json`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function loadState(stateFile) {
|
|
111
|
+
const empty = { reads: {}, first_mutation_seen: {}, checked: {} };
|
|
112
|
+
try {
|
|
113
|
+
const parsed = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
114
|
+
return {
|
|
115
|
+
reads: (parsed && typeof parsed.reads === 'object' && parsed.reads) || {},
|
|
116
|
+
first_mutation_seen: (parsed && typeof parsed.first_mutation_seen === 'object' && parsed.first_mutation_seen) || {},
|
|
117
|
+
checked: (parsed && typeof parsed.checked === 'object' && parsed.checked) || {},
|
|
118
|
+
};
|
|
119
|
+
} catch {
|
|
120
|
+
return empty;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function saveState(stateFile, state) {
|
|
125
|
+
try {
|
|
126
|
+
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
127
|
+
const tmp = `${stateFile}.tmp`;
|
|
128
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
129
|
+
fs.renameSync(tmp, stateFile);
|
|
130
|
+
} catch { /* best-effort: a state-write failure must not break the gate */ }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Decisions/blockers grep (reuses the decision-injector idiom: scan the small
|
|
135
|
+
// canonical design docs for lines mentioning the file's basename/relPath).
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
function decisionSources(cwd) {
|
|
138
|
+
const roots = [];
|
|
139
|
+
for (const rel of [
|
|
140
|
+
['.design', 'STATE.md'],
|
|
141
|
+
['.design', 'CYCLES.md'],
|
|
142
|
+
['.design', 'learnings', 'LEARNINGS.md'],
|
|
143
|
+
]) {
|
|
144
|
+
const p = path.join(cwd, ...rel);
|
|
145
|
+
try { if (fs.statSync(p).isFile()) roots.push(p); } catch { /* skip */ }
|
|
146
|
+
}
|
|
147
|
+
return roots;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Lazy-require state-store.cjs (Phase 57 dual-backend layer).
|
|
152
|
+
* Returns null if not yet available (degrade to grep).
|
|
153
|
+
*/
|
|
154
|
+
function _requireStateStore() {
|
|
155
|
+
try {
|
|
156
|
+
const candidates = [
|
|
157
|
+
path.join(__dirname, '..', 'scripts', 'lib', 'state', 'state-store.cjs'),
|
|
158
|
+
];
|
|
159
|
+
const root = findPackageRoot(__dirname);
|
|
160
|
+
if (root) candidates.push(path.join(root, 'scripts', 'lib', 'state', 'state-store.cjs'));
|
|
161
|
+
for (const c of candidates) {
|
|
162
|
+
try {
|
|
163
|
+
const m = require(c);
|
|
164
|
+
if (m && typeof m.queryDecisions === 'function') return m;
|
|
165
|
+
} catch { /* try next */ }
|
|
166
|
+
}
|
|
167
|
+
} catch { /* never throw */ }
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Lazy-require state-backend.cjs to check if migration is active.
|
|
173
|
+
* Migration is active when BACKEND==='sqlite' AND the sibling .design/state.sqlite exists.
|
|
174
|
+
*/
|
|
175
|
+
function _isMigrationActive(cwd) {
|
|
176
|
+
try {
|
|
177
|
+
const candidates = [
|
|
178
|
+
path.join(__dirname, '..', 'scripts', 'lib', 'state', 'state-backend.cjs'),
|
|
179
|
+
];
|
|
180
|
+
const root = findPackageRoot(__dirname);
|
|
181
|
+
if (root) candidates.push(path.join(root, 'scripts', 'lib', 'state', 'state-backend.cjs'));
|
|
182
|
+
let backend = null;
|
|
183
|
+
for (const c of candidates) {
|
|
184
|
+
try {
|
|
185
|
+
const m = require(c);
|
|
186
|
+
if (m && typeof m.BACKEND === 'string' && typeof m.sqlitePath === 'function') {
|
|
187
|
+
backend = m;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
} catch { /* try next */ }
|
|
191
|
+
}
|
|
192
|
+
if (!backend || backend.BACKEND !== 'sqlite') return false;
|
|
193
|
+
// Verify that the sibling .design/state.sqlite actually exists (migration-active gate).
|
|
194
|
+
const dbPath = backend.sqlitePath(cwd);
|
|
195
|
+
return fs.existsSync(dbPath);
|
|
196
|
+
} catch {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Does any decision/blocker line mention this file?
|
|
203
|
+
*
|
|
204
|
+
* When migration is active (BACKEND==='sqlite' AND .design/state.sqlite exists):
|
|
205
|
+
* - Tier-0: query state-store.cjs queryDecisions(term) for each search term.
|
|
206
|
+
* Falls back to grep if the store query throws.
|
|
207
|
+
* When migration is NOT active (default, un-migrated):
|
|
208
|
+
* - Substring grep over STATE.md/CYCLES.md/LEARNINGS.md (UNCHANGED).
|
|
209
|
+
*
|
|
210
|
+
* Returns { found:boolean, where:string|null }.
|
|
211
|
+
* The return shape and the soften-if-absent behavior are UNCHANGED.
|
|
212
|
+
*/
|
|
213
|
+
function decisionMentions(cwd, relPath) {
|
|
214
|
+
const basename = path.basename(relPath);
|
|
215
|
+
const terms = Array.from(new Set([basename, relPath].filter(Boolean)));
|
|
216
|
+
|
|
217
|
+
// Tier-0: FTS5 path (migration-active only).
|
|
218
|
+
if (_isMigrationActive(cwd)) {
|
|
219
|
+
const store = _requireStateStore();
|
|
220
|
+
if (store) {
|
|
221
|
+
try {
|
|
222
|
+
for (const t of terms) {
|
|
223
|
+
if (!t) continue;
|
|
224
|
+
const rows = store.queryDecisions(t, { projectRoot: cwd, limit: 1 });
|
|
225
|
+
if (Array.isArray(rows) && rows.length > 0) {
|
|
226
|
+
return { found: true, where: 'state.sqlite' };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// FTS5 returned no matches; check blockers via getBlockers substring.
|
|
230
|
+
const blockers = store.getBlockers ? store.getBlockers({ projectRoot: cwd }) : [];
|
|
231
|
+
if (Array.isArray(blockers) && blockers.length > 0) {
|
|
232
|
+
for (const b of blockers) {
|
|
233
|
+
const body = (b.body_md || b.raw_line || '');
|
|
234
|
+
for (const t of terms) {
|
|
235
|
+
if (t && body.includes(t)) return { found: true, where: 'state.sqlite' };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return { found: false, where: null };
|
|
240
|
+
} catch {
|
|
241
|
+
// FTS5 query failed: fall through to grep.
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Tier-1 (always-on fallback): substring grep over canonical docs.
|
|
247
|
+
for (const src of decisionSources(cwd)) {
|
|
248
|
+
let content;
|
|
249
|
+
try { content = fs.readFileSync(src, 'utf8'); } catch { continue; }
|
|
250
|
+
for (const t of terms) {
|
|
251
|
+
if (t && content.includes(t)) return { found: true, where: path.basename(src) };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { found: false, where: null };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Importer prerequisite: were the file's consumers Read this session?
|
|
259
|
+
// SOFTENS when the graph is absent (available:false).
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
function readSlugs(state, cwd) {
|
|
262
|
+
// Index the session reads by their leaf slug for token matching against
|
|
263
|
+
// consumer node names.
|
|
264
|
+
const slugs = new Set();
|
|
265
|
+
for (const k of Object.keys(state.reads || {})) {
|
|
266
|
+
const s = leafSlug(k);
|
|
267
|
+
if (s) slugs.add(s);
|
|
268
|
+
}
|
|
269
|
+
return slugs;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* @returns {{ softened:boolean, unread:string[] }}
|
|
274
|
+
* softened — true when the graph is unavailable (importer check downgraded
|
|
275
|
+
* to a non-blocking warning).
|
|
276
|
+
* unread — importer slugs that were NOT found in this session's reads.
|
|
277
|
+
*/
|
|
278
|
+
function importerPrereq(filePath, cwd, state) {
|
|
279
|
+
if (!_consumers) return { softened: true, unread: [] };
|
|
280
|
+
let res;
|
|
281
|
+
try {
|
|
282
|
+
res = _consumers.consumersOfFile(filePath, { root: cwd });
|
|
283
|
+
} catch {
|
|
284
|
+
return { softened: true, unread: [] };
|
|
285
|
+
}
|
|
286
|
+
if (!res || res.available !== true) {
|
|
287
|
+
// Graph absent / unbuilt / file unmapped-with-no-graph -> SOFTEN.
|
|
288
|
+
return { softened: true, unread: [] };
|
|
289
|
+
}
|
|
290
|
+
const importers = Array.isArray(res.importers) ? res.importers : [];
|
|
291
|
+
if (importers.length === 0) return { softened: false, unread: [] };
|
|
292
|
+
const reads = readSlugs(state, cwd);
|
|
293
|
+
const unread = importers.filter((imp) => !reads.has(String(imp).toLowerCase()));
|
|
294
|
+
return { softened: false, unread };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// Risk tier (imports A's compute-risk; SOFTENS to non-block when unavailable)
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
function riskIsBlock(tool, input, cwd) {
|
|
301
|
+
if (!_risk) return false;
|
|
302
|
+
try {
|
|
303
|
+
const cfg = typeof _risk.loadRiskConfig === 'function' ? _risk.loadRiskConfig(cwd) : null;
|
|
304
|
+
const thresholds = cfg && cfg.thresholds ? cfg.thresholds : undefined;
|
|
305
|
+
const r = _risk.computeRisk(tool, input, thresholds);
|
|
306
|
+
return !!(r && r.suggested_action === 'block');
|
|
307
|
+
} catch {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
// Main
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
async function main() {
|
|
316
|
+
let buf = '';
|
|
317
|
+
for await (const chunk of process.stdin) buf += chunk;
|
|
318
|
+
|
|
319
|
+
let payload;
|
|
320
|
+
try { payload = JSON.parse(buf || '{}'); } catch {
|
|
321
|
+
process.stdout.write(JSON.stringify({ continue: true }));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const tool = (payload && payload.tool_name) || '';
|
|
326
|
+
if (!GATED_TOOLS.has(tool)) {
|
|
327
|
+
process.stdout.write(JSON.stringify({ continue: true }));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const cwd = (payload && payload.cwd) || process.cwd();
|
|
332
|
+
const rawPath = payload && payload.tool_input && payload.tool_input.file_path;
|
|
333
|
+
if (!rawPath) {
|
|
334
|
+
process.stdout.write(JSON.stringify({ continue: true }));
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const relPath = normPath(rawPath, cwd);
|
|
338
|
+
|
|
339
|
+
const sessionId = sessionIdFrom(payload);
|
|
340
|
+
const stateFile = stateFileFor(cwd, sessionId);
|
|
341
|
+
const state = loadState(stateFile);
|
|
342
|
+
|
|
343
|
+
// (1) Already overridden for this path -> always pass (and record the seen).
|
|
344
|
+
if (state.checked && state.checked[relPath]) {
|
|
345
|
+
if (!state.first_mutation_seen[relPath]) {
|
|
346
|
+
state.first_mutation_seen[relPath] = new Date().toISOString();
|
|
347
|
+
saveState(stateFile, state);
|
|
348
|
+
}
|
|
349
|
+
emit('allow', { reason: 'checked', path: relPath });
|
|
350
|
+
process.stdout.write(JSON.stringify({ continue: true }));
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// (2) Not the FIRST mutation of this file this session -> not re-gated.
|
|
355
|
+
if (state.first_mutation_seen && state.first_mutation_seen[relPath]) {
|
|
356
|
+
emit('allow', { reason: 'already-mutated', path: relPath });
|
|
357
|
+
process.stdout.write(JSON.stringify({ continue: true }));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// (3) First mutation: evaluate prerequisites.
|
|
362
|
+
const missing = [];
|
|
363
|
+
|
|
364
|
+
const imp = importerPrereq(rawPath, cwd, state);
|
|
365
|
+
if (!imp.softened && imp.unread.length > 0) {
|
|
366
|
+
missing.push(`unread importers: ${imp.unread.join(', ')} (Read the file(s) that consume '${relPath}')`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const dec = decisionMentions(cwd, relPath);
|
|
370
|
+
// A decision/blocker is "tagged with X" when a canonical doc mentions the
|
|
371
|
+
// file. If one exists, it must have been surfaced (Read) this session — we
|
|
372
|
+
// approximate "surfaced" by the doc itself being in reads[], else flag it.
|
|
373
|
+
if (dec.found) {
|
|
374
|
+
const docReadKnown = Object.keys(state.reads || {}).some((k) => {
|
|
375
|
+
const b = path.basename(k);
|
|
376
|
+
return b === dec.where || b === 'STATE.md' || b === 'CYCLES.md' || b === 'LEARNINGS.md';
|
|
377
|
+
});
|
|
378
|
+
if (!docReadKnown) {
|
|
379
|
+
missing.push(`unreviewed decisions/blockers tagged '${path.basename(relPath)}' in ${dec.where} (Read it first)`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Record that we have now SEEN the first mutation attempt for this file (so a
|
|
384
|
+
// subsequent retry after the agent satisfies prereqs flows through gate (2)
|
|
385
|
+
// only AFTER a pass; we set the marker on the allow path below to avoid
|
|
386
|
+
// permanently disarming on a blocked attempt).
|
|
387
|
+
if (missing.length === 0) {
|
|
388
|
+
state.first_mutation_seen[relPath] = new Date().toISOString();
|
|
389
|
+
saveState(stateFile, state);
|
|
390
|
+
emit('allow', { reason: 'prereqs-met', path: relPath, softened: imp.softened });
|
|
391
|
+
process.stdout.write(JSON.stringify({ continue: true }));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Prerequisites unmet -> block. SOFT unless risk == block (then HARD).
|
|
396
|
+
const hard = riskIsBlock(tool, payload.tool_input, cwd);
|
|
397
|
+
const factsList = missing.join('; ');
|
|
398
|
+
const stopReason = hard
|
|
399
|
+
? `gdd-fact-force (HARD — risk=block): cannot mutate '${relPath}' until facts are established — ${factsList}. The only escape is \`/gdd:override factforce ${relPath} --approver <who>\`.`
|
|
400
|
+
: `gdd-fact-force: establish the facts before the first edit to '${relPath}' — ${factsList}. Read them, or run \`/gdd:override factforce ${relPath}\` to mark checked.`;
|
|
401
|
+
|
|
402
|
+
emit(hard ? 'block-hard' : 'block-soft', { path: relPath, missing: missing.length });
|
|
403
|
+
process.stdout.write(JSON.stringify({ continue: false, stopReason }));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Best-effort telemetry — never throws, swallowed if the emitter is absent.
|
|
407
|
+
function emit(decision, detail) {
|
|
408
|
+
try {
|
|
409
|
+
require('./_hook-emit.js').emitHookFired('gdd-fact-force', decision, detail || {});
|
|
410
|
+
} catch { /* swallow */ }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Auto-run when invoked directly (hooks.json runs `node hooks/gdd-fact-force.js`).
|
|
414
|
+
// Guarded so tests can require() the module to unit-test the pure helpers.
|
|
415
|
+
if (require.main === module) {
|
|
416
|
+
main().catch(() => {
|
|
417
|
+
process.stdout.write(JSON.stringify({ continue: true }));
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
module.exports = {
|
|
422
|
+
// pure-ish helpers exported for tests; main() owns the I/O + contract.
|
|
423
|
+
normPath,
|
|
424
|
+
leafSlug,
|
|
425
|
+
sessionIdFrom,
|
|
426
|
+
stateFileFor,
|
|
427
|
+
loadState,
|
|
428
|
+
saveState,
|
|
429
|
+
decisionMentions,
|
|
430
|
+
importerPrereq,
|
|
431
|
+
riskIsBlock,
|
|
432
|
+
findPackageRoot,
|
|
433
|
+
main,
|
|
434
|
+
};
|