@hegemonart/get-design-done 1.55.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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "Get Design Done — 5-stage agent-orchestrated design pipeline with 9 connections, handoff-first workflow, bidirectional Figma write-back, 22+ specialized agents, queryable knowledge layer (intel store, dependency analysis, learnings extraction), and a self-improvement loop (reflector, frontmatter + budget feedback, global-skills layer). v1.20.0 ships the SDK foundation: gdd-state MCP server (11 typed tools), lockfile-safe STATE.md mutations, event stream, and resilience primitives (jittered-backoff, rate-guard, error-classifier, iteration-budget) for rate-limit + 429 + context-overflow recovery. Full CI/CD pipeline (Node 22/24 × Linux/macOS/Windows) and release automation (auto-tag + GitHub Release + release-time smoke test).",
8
- "version": "1.55.0"
8
+ "version": "1.56.0"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "get-design-done",
13
13
  "source": "./",
14
14
  "description": "Agent-orchestrated 5-stage design pipeline: Brief → Explore → Plan → Design → Verify. 22+ specialized agents, 9 connections (Figma, Refero, Preview, Storybook, Chromatic, Figma Writer, Graphify, Pinterest, Claude Design), Claude Design handoff, bidirectional Figma write-back, and a queryable intel store (.design/intel/) for dependency and learnings queries. Standalone commands: style, darkmode, compare, figma-write, graphify, handoff, analyze-dependencies, skill-manifest, extract-learnings. Embeds NNG heuristics, WCAG thresholds, typographic systems, motion framework, and anti-pattern catalog. Ships with a full CI/CD pipeline (Node 22/24 × Linux/macOS/Windows) and release automation. Optimization layer (v1.0.4.1, retroactive): gdd-router + gdd-cache-manager skills, PreToolUse budget-enforcer hook, tier-aware agent frontmatter, lazy checker gates, streaming synthesizer, /gdd:warm-cache + /gdd:optimize commands, and cost telemetry at .design/telemetry/costs.jsonl — targeting 50-70% per-task token-cost reduction with no quality-floor regression. v1.20.0 SDK foundation: gdd-state MCP server (11 typed tools), lockfile-safe STATE.md mutations, event stream at .design/telemetry/events.jsonl, resilience primitives (jittered-backoff, rate-guard, error-classifier, iteration-budget) with rate-limit + 429 + context-overflow recovery, and TypeScript toolchain.",
15
- "version": "1.55.0",
15
+ "version": "1.56.0",
16
16
  "author": {
17
17
  "name": "hegemonart"
18
18
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "get-design-done",
3
3
  "short_name": "gdd",
4
- "version": "1.55.0",
4
+ "version": "1.56.0",
5
5
  "description": "Agent-orchestrated 5-stage design pipeline: Brief → Explore → Plan → Design → Verify. 59 specialized agents, 88 skills, 41 connection integrations (Figma, Refero, Preview, Storybook, Chromatic, Graphify, Slack, Linear, Jira, Notion, and more), handoff-first workflow via Claude Design bundles, bidirectional Figma write-back (annotations, Code Connect), queryable intel store (`.design/intel/`) for O(1) design surface lookups, and self-improvement loop (reflector agent, frontmatter + budget feedback, global-skills layer at `~/.claude/gdd/global-skills/`). Standalone commands: style, darkmode, compare, figma-write, graphify, handoff, analyze-dependencies, skill-manifest, extract-learnings, reflect, apply-reflections. Embeds NNG heuristics, WCAG thresholds, typographic systems, motion framework, and anti-pattern catalog. Ships with a full CI/CD pipeline (Node 22/24 × Linux/macOS/Windows, lint + schema + frontmatter + stale-ref + shellcheck + gitleaks + injection-scan + blocking size-budget) and release automation (auto-tag + GitHub Release + release-time smoke test). Optimization layer (v1.0.4.1, retroactive): gdd-router + gdd-cache-manager skills, PreToolUse budget-enforcer hook, tier-aware agent frontmatter, lazy checker gates, streaming synthesizer, /gdd:warm-cache + /gdd:optimize commands, and cost telemetry at .design/telemetry/costs.jsonl — targeting 50-70% per-task token-cost reduction with no quality-floor regression. v1.20.0 SDK foundation: gdd-state MCP server (11 typed tools), lockfile-safe STATE.md mutations, event stream at .design/telemetry/events.jsonl, resilience primitives (jittered-backoff, rate-guard, error-classifier, iteration-budget) with rate-limit + 429 + context-overflow recovery, and TypeScript toolchain. v1.27.7 ships gdd-mcp (Phase 27.7): 12 read-only MCP tools for sub-3s priming. v1.28.0 (Phase 28): Foundational References Tier 2 — 5 new reference files (color-theory, composition, proportion-systems, i18n, contrast-advanced), 2 verifier i18n probes + 1 explore i18n-readiness probe, 12 additive cross-link insertions across 10 existing references, 2 orthogonal audit-scoring lens-tags (composition_alignment + i18n_readiness).",
6
6
  "author": {
7
7
  "name": "hegemonart",
package/CHANGELOG.md CHANGED
@@ -4,6 +4,51 @@ All notable changes to get-design-done are documented here. Versions follow [sem
4
4
 
5
5
  ---
6
6
 
7
+ ## [1.56.0] - 2026-06-03
8
+
9
+ ### Phase 56 - Risk-Scoring + Fact-Forcing Gate (Quantified Action Confidence)
10
+
11
+ Every writer action now carries a quantified risk score instead of a binary allow/deny. Phase 56 adds a pure,
12
+ deterministic risk scorer (no I/O, frozen tables) that grades each Write / Edit / MultiEdit / Bash by tool, file
13
+ sensitivity, and input shape, then routes it through two PreToolUse hooks: a risk gate that emits a `risk_assessment`
14
+ event and blocks only genuinely dangerous actions, and a fact-force gate that holds the FIRST write to a file until its
15
+ graph consumers and recorded decisions have actually been read. Both are **dep-free** (a maintainer Rule-4 decision: a
16
+ pure scorer plus static tables, no ML, no new dependency). The gate softens to a warning whenever the Phase 52
17
+ DesignContext graph is absent, so greenfield projects are never over-blocked. A new `/gdd:override` escalation skill
18
+ clears a block or a fact-force hold with an approver and reason (audit-trailed as a `D-XX` override decision), and
19
+ `design-fixer` gains a confidence-times-risk routing step. Planned and executed via the GSD pipeline (3 + 2 parallel
20
+ executors).
21
+
22
+ ### Breaking changes
23
+
24
+ - **Writer actions are now risk-gated.** A new PreToolUse hook (`hooks/gdd-risk-gate.js`, matcher
25
+ `Write|Edit|MultiEdit|Bash`) scores every writer action and blocks the few that score at or above 0.85 (destructive
26
+ bash, high-sensitivity-file rewrites). Blocking uses the house-style `{continue:false, stopReason}` contract; `allow`
27
+ is silent, `review` and `require_confirmation` attach advisory context for the agent to surface. Read-only agents are
28
+ allowlisted through.
29
+ - **The first write to a file is fact-forced.** A second PreToolUse hook (`hooks/gdd-fact-force.js`, matcher
30
+ `Edit|Write|MultiEdit`) holds the first mutation of a file until its DesignContext consumers and any recorded
31
+ decisions or blockers for it have been Read this session. The hold is soft (a `stopReason` listing the missing facts)
32
+ and softens to a warning when no graph exists; clear it deliberately with `/gdd:override factforce <path>`.
33
+
34
+ ### Added
35
+
36
+ - **Risk scorer** `scripts/lib/risk/` - `compute-risk.cjs` (`computeRisk(tool, input) -> {score, reasons, suggested_action, breakdown}`,
37
+ pure and deterministic), `tables.cjs` (frozen BASE_TOOL_RISK / FILE_SENSITIVITY / INPUT_PATTERN_RISK / THRESHOLDS,
38
+ config-overridable extend-only), `route.cjs` (`route(confidence, action) -> auto|confirm|skip|override`),
39
+ `consumers.cjs` (best-effort file-to-node consumers lookup, soften-if-absent), `calibration.cjs` (rolling-50 per-agent
40
+ calibration plus drift detection feeding the bandit reward), `override.cjs`.
41
+ - **`/gdd:override`** - escalation surface for a risk-gate block or a fact-force hold; writes a `D-XX` override-tagged
42
+ decision (audit trail) or clears the `checked[path]` lock, always with an approver and reason.
43
+ - **`risk_assessment` event type** in `reference/schemas/events.schema.json` (score, suggested_action, reasons),
44
+ surfaced by the Phase 55 dashboard risk pane.
45
+ - **`design-fixer` Step 2.5** - a confidence-times-risk routing filter (auto-apply / confirm-with-diff / skip / escalate).
46
+
47
+ ### Changed
48
+
49
+ - **`hooks/gdd-decision-injector.js`** now records per-file reads so the fact-force gate can tell which files you have
50
+ legitimately reviewed this session.
51
+
7
52
  ## [1.55.0] - 2026-06-03
8
53
 
9
54
  ### Phase 55 - GDD Dashboard (Multi-Harness Control Plane + Graph Visualization + Session Surface)
package/README.md CHANGED
@@ -271,6 +271,8 @@ All 14 runtimes receive their native artifact layout (`skills/`, `command/`, `ag
271
271
 
272
272
  **GDD dashboard (v1.55.0).** A read-only multi-harness control plane. `gdd-dashboard` opens a terminal TUI with five panes (sessions per runtime, current cycle, cost telemetry, findings, a DesignContext tree); `gdd dashboard --web` opens an interactive browser view of the Phase 52 graph (layered Atomic/Molecular/Organism/Template layout, pan/zoom, click-to-inspect, type/tag filters, find-consumers highlight, PNG export, minimap). It is **built fully dep-free** (a maintainer decision over the roadmap's Ink + React Flow + Vite stack, which would have added ~100 packages): the TUI is a hand-rolled ANSI renderer, the graph view is a self-contained HTML file (inline SVG + vanilla JS, extending the export builder), and the data plane reads GDD state through the existing shared libraries in-process. Read-only by design (the action surface is open-file / copy-command / run-skill); the `--web` server is a local loopback that serves to your own browser and exits. **No new runtime dependency.**
273
273
 
274
+ **Risk-scoring and fact-forcing gate (v1.56.0).** Writer actions now carry a quantified risk score instead of a binary allow/deny. A pure, deterministic scorer (`scripts/lib/risk/compute-risk.cjs`, frozen tables, no I/O) grades each Write / Edit / MultiEdit / Bash by tool, file sensitivity, and input shape, then two PreToolUse hooks act on it: `gdd-risk-gate.js` emits a `risk_assessment` event and blocks only the genuinely dangerous actions (destructive bash, high-sensitivity-file rewrites at or above 0.85), while `gdd-fact-force.js` holds the first write to a file until its DesignContext consumers and recorded decisions have actually been read this session. The fact-force hold is soft and softens to a warning when the Phase 52 graph is absent, so greenfield projects are never over-blocked. `/gdd:override` clears a block or a fact-force hold with an approver and reason (audit-trailed as a `D-XX` override decision), and `design-fixer` routes findings by confidence times risk. **Built dep-free** (a maintainer decision: a pure scorer plus static tables, no ML). **No new runtime dependency.**
275
+
274
276
  Verify with:
275
277
 
276
278
  ```
@@ -845,6 +847,8 @@ GDD ships defense-in-depth security since Phase 14.5:
845
847
 
846
848
  - **`hooks/gdd-bash-guard.js`** - PreToolUse:Bash blocks ~50 dangerous patterns (`rm -rf /`, `chmod 777`, `curl | sh`, `git reset --hard`, fork bombs) after Unicode NFKC + ANSI normalization.
847
849
  - **`hooks/gdd-protected-paths.js`** - PreToolUse:Edit/Write/Bash enforces `protected_paths` glob list (defaults: `reference/**`, `.design/archive/**`, `skills/**`, `commands/**`, `hooks/**`, `.design/config.json`, `.design/telemetry/**`).
850
+ - **`hooks/gdd-risk-gate.js`** - PreToolUse:Write/Edit/MultiEdit/Bash scores each writer action via the pure `scripts/lib/risk/compute-risk.cjs` (tool x file-sensitivity x input shape), emits a `risk_assessment` event, and blocks only actions at or above 0.85; `allow` is silent, `review` / `require_confirmation` attach advisory context. Clear a block with `/gdd:override`.
851
+ - **`hooks/gdd-fact-force.js`** - PreToolUse:Edit/Write/MultiEdit holds the first write to a file until its DesignContext consumers and recorded decisions have been Read this session; soft block, softens to a warning when the graph is absent, cleared with `/gdd:override factforce <path>`.
848
852
  - **`hooks/gdd-read-injection-scanner.ts`** - scans inbound Read content for invisible-Unicode (zero-width, word-joiner, BOM, bidi overrides) + HTML-comment + secret-exfil patterns.
849
853
  - **`scripts/lib/blast-radius.cjs`** - `design-executor` preflight refuses tasks above `max_files_per_task: 10` / `max_lines_per_task: 400`.
850
854
  - **`hooks/gdd-mcp-circuit-breaker.js`** - breaks consecutive-timeout loops on `use_figma` / `use_paper` / `use_pencil`.
package/SKILL.md CHANGED
@@ -116,6 +116,7 @@ Each stage produces artifacts in `.design/` inside the current project.
116
116
  | `locale [<code>]` | `get-design-done:gdd-locale` | Phase 40.5 - inspect or set the GDD CLI locale (en/ru/uk/de/fr/zh/ja) for `--help`, errors, and skill prompt headers; missing keys fall back to English. No arg reports the resolved locale + coverage |
117
117
  | `context [nodes --type X \| edges --type Z \| path <a> <b> \| consumers-of <id> \| unreachable \| cycles \| coverage]` | `get-design-done:gdd-context` | Phase 52 - read-only query front end for the typed DesignContext graph at `.design/context-graph.json`; lists/filters nodes and edges, traces a path between two nodes, finds a node's consumers, and reports unreachable nodes, dependency cycles, and coverage. Never writes |
118
118
  | `migrate-context [--dry-run]` | `get-design-done:gdd-migrate-context` | Phase 52 - migrate a pre-Phase-52 project from flat `.design/map/*.md` mapper notes to the typed DesignContext graph; runs the extract-*.mjs passes, merges fragments, validates with `validate-design-context.cjs`, and flags low-confidence transforms for review. Preview-first; `--dry-run` previews without writing |
119
+ | `override <finding-id \| factforce <path>> [--approver <who>] [--reason <text>]` | `get-design-done:gdd-override` | Phase 56 - escalation surface for a risk-gate block or a first-write fact-force hold; with an approver and reason, writes a `D-XX` override-tagged decision (audit trail) for a blocked finding, or clears the fact-force `checked[path]` lock for a path you have legitimately reviewed. Mirrors unlock-decision; never overrides silently |
119
120
 
120
121
  ## Handoff Routing
121
122
 
@@ -125,6 +125,22 @@ f. **Record status.** Note `G-NN: fixed` in your running tracker.
125
125
  - **Rule 3 - Blocking issue:** If something prevents applying this specific fix (missing import, wrong file structure), resolve the blocking issue first, then apply the fix → continue.
126
126
  - **Rule 4 - Architectural change required:** If resolving the gap requires a new DB table, major schema change, switching libraries, or breaking API changes → DO NOT force a fix. Classify as unresolvable and proceed to Step 3 for this gap.
127
127
 
128
+ ### Step 2.5 - Confidence x risk routing (Phase 56)
129
+
130
+ Step 1's confidence filter (`scripts/lib/confidence-route.cjs`) already dropped tentative and low-confidence gaps. Step 2.5 adds the action-risk dimension: a fix that is correct can still be dangerous to APPLY (touching STATE.md, a schema, a hook, a large diff). Score the write, then combine score and confidence into one routing decision per gap.
131
+
132
+ For each in-scope gap, before applying its edit:
133
+
134
+ 1. **Score the write.** `risk = computeRisk('Edit', { file_path, new_string })` from `scripts/lib/risk/compute-risk.cjs` (use `MultiEdit` with `edits[]` for multi-hunk fixes, `Write` for full rewrites). `risk.suggested_action` is one of `allow | review | require_confirmation | block`.
135
+ 2. **Route.** `decision = route(gap.confidence, risk.suggested_action)` from `scripts/lib/risk/route.cjs`:
136
+ - `auto` (high confidence, low risk): apply the fix via the Step 2 sequence and commit.
137
+ - `confirm` (medium confidence, or `require_confirmation` risk): propose the fix with its diff via `AskUserQuestion` before writing. Apply only on approval; otherwise treat it as `skip`. This agent is the only place the confirmation prompt happens (the writer hooks just score and flag).
138
+ - `skip` (confidence below 0.5, non-block): leave the gap as a deferred finding; do not write. Note it in the tracker.
139
+ - `override` (risk `block`, at any confidence): do NOT auto-apply a block-risk write. Route the user to `{{command_prefix}}override <G-NN> --approver <who> --reason <text>`; apply only after the audited override is recorded.
140
+ 3. **Record.** Note each gap as `G-NN: auto | confirm | skip | override` in your running tracker, then carry the `auto` and approved-`confirm` gaps into the Step 2 fix sequence.
141
+
142
+ `route` and `computeRisk` are pure and dependency-free, so this filter is deterministic. A gap whose confidence field is missing is treated as the lowest tier (skip, unless the action is `block`, which routes to override).
143
+
128
144
  ### Step 3 - Handle unresolvable gaps
129
145
 
130
146
  A gap is unresolvable if:
@@ -0,0 +1,86 @@
1
+ ---
2
+ name: gdd-override
3
+ description: "Escalation surface for a risk-blocked action or a fact-force gate. Use when the Phase 56 risk gate blocked a writer action (suggested_action=block) and a reviewer has signed off, or when the first-write fact-force gate is holding a file you have legitimately reviewed. Activates for requests involving overriding a blocked edit, approving a high-risk change, or clearing a fact-force hold on a path."
4
+ argument-hint: "<finding-id | factforce <path>> [--approver <who>] [--reason <text>]"
5
+ user-invocable: true
6
+ tools: Read, Write, Bash, Grep, Glob
7
+ ---
8
+
9
+ # /gdd:override
10
+
11
+ A risk-blocked action is hard: the Phase 56 risk gate routes `suggested_action=block`
12
+ to `override` (see `scripts/lib/risk/route.cjs`), and the fact-force gate holds the
13
+ first write to a file until its facts are read. This skill is the audited way past
14
+ either hold. It mirrors `/gdd:unlock-decision`: a named approver plus a
15
+ reason, recorded before anything is let through. Override is never silent.
16
+
17
+ ## Invocation
18
+
19
+ | Command | Behavior |
20
+ |---|---|
21
+ | `/gdd:override <finding-id> --approver <who> --reason <text>` | Record a `D-XX` `override`-tagged decision in STATE.md `<decisions>` and let the risk-blocked action through. |
22
+ | `/gdd:override factforce <path> --approver <who> --reason <text>` | Set `checked[path]` in the session fact-force state so the fact-force gate stops holding that path. |
23
+
24
+ Both modes ask for a rationale: the audit trail is the reason override exists.
25
+
26
+ ## Steps
27
+
28
+ 1. **Parse args.** Mode is `factforce` when the first token is the literal `factforce`
29
+ (the next token is the `<path>`); otherwise the first token is a `<finding-id>`.
30
+ `--approver` is required (a non-empty name). Missing `--approver` prints the usage
31
+ and changes nothing. If `--reason` is absent, ASK for one (AskUserQuestion or a
32
+ prompt) before continuing: an override with no rationale is rejected.
33
+
34
+ 2. **Preview.** Show what will be written and stop for confirmation:
35
+ - finding mode: the decision entry from `overrideDecisionEntry(<id>, {approver, reason})`
36
+ (its `text`, `status: locked`, and `override` tag) plus the action it unblocks.
37
+ - factforce mode: the `<path>` that will gain `checked[path] = true` and the
38
+ session-state file it lands in.
39
+
40
+ 3. **Apply (finding mode).** Record the audited decision via the STATE writer
41
+ `mcp__gdd_state__add_decision` (it auto-assigns the next `D-N`). Pass the `text`
42
+ from the pure builder so the `override` tag is embedded and greppable:
43
+
44
+ ```bash
45
+ node -e '
46
+ const o = require("./scripts/lib/risk/override.cjs");
47
+ const [id, who, reason] = process.argv.slice(1);
48
+ const entry = o.overrideDecisionEntry(id, { approver: who, reason });
49
+ console.log(JSON.stringify(entry));
50
+ ' "<finding-id>" "<who>" "<reason>"
51
+ ```
52
+
53
+ Then call `mcp__gdd_state__add_decision` with `{ text: <entry.text>, status: "locked" }`.
54
+ The blocked action is now approved on the audit record; proceed with it.
55
+
56
+ 4. **Apply (factforce mode).** Set `checked[path]` in the session state file at
57
+ `<cwd>/.design/locks/factforce-<session_id>.json` (atomic tmp then rename), using
58
+ the pure helper so the shape matches what the fact-force gate reads:
59
+
60
+ ```bash
61
+ node -e '
62
+ const fs = require("fs"); const path = require("path");
63
+ const o = require("./scripts/lib/risk/override.cjs");
64
+ const [file, p] = process.argv.slice(1);
65
+ let state = {}; try { state = JSON.parse(fs.readFileSync(file, "utf8")); } catch {}
66
+ const next = o.setFactForceChecked(state, p);
67
+ fs.mkdirSync(path.dirname(file), { recursive: true });
68
+ const tmp = file + ".tmp";
69
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n");
70
+ fs.renameSync(tmp, file);
71
+ console.log(JSON.stringify(next.checked));
72
+ ' "<cwd>/.design/locks/factforce-<session_id>.json" "<path>"
73
+ ```
74
+
75
+ The fact-force gate stops holding `<path>` for the rest of the session.
76
+
77
+ 5. **Report** the recorded approver, the reason, and either the new `D-XX` id (finding
78
+ mode) or the unblocked path (factforce mode).
79
+
80
+ ## Do Not
81
+
82
+ - Do not skip the rationale: every override is audited.
83
+ - Do not override a finding that the risk gate did not actually block.
84
+ - Do not edit `scripts/lib/risk/route.cjs` or `compute-risk.cjs`: this skill consumes them.
85
+
86
+ ## OVERRIDE COMPLETE
@@ -524,6 +524,58 @@ function buildRecallBlock(matches, basename, backendLabel) {
524
524
  return lines.join('\n');
525
525
  }
526
526
 
527
+ /**
528
+ * Phase 56 (fact-force) read-tracking — ADDITIVE, best-effort, non-blocking.
529
+ *
530
+ * On every Read, record `reads[<normalizedRelPath>] = <ISO>` into the SAME
531
+ * session-state file the fact-forcing gate consults
532
+ * (`<cwd>/.design/locks/factforce-<sanitized session_id>.json`). This is how the
533
+ * gate knows which importer files an agent has already opened before its first
534
+ * mutation. Fully swallowed on any error so it can NEVER change this Read hook's
535
+ * existing decision-injection behavior or its `{ continue: true }` contract.
536
+ *
537
+ * Self-contained (no new import): mirrors the gate's session_id derivation
538
+ * (`payload.session_id ?? GDD_SESSION_ID ?? 'hook'`), path normalization, and
539
+ * atomic tmp+rename write so the two hooks agree byte-for-byte on the file.
540
+ */
541
+ function recordReadForFactForce(payload) {
542
+ try {
543
+ const fp = payload && payload.tool_input && payload.tool_input.file_path;
544
+ if (!fp) return;
545
+ const cwd = (payload && payload.cwd) || process.cwd();
546
+
547
+ let rel = String(fp);
548
+ if (rel.startsWith('/') || /^[A-Za-z]:[\\/]/.test(rel)) {
549
+ try { rel = path.relative(cwd, rel); } catch { /* keep rel */ }
550
+ }
551
+ rel = rel.replace(/\\/g, '/').replace(/^\.\//, '');
552
+ if (!rel) return;
553
+
554
+ const rawSid = (payload && (payload.session_id || payload.sessionId))
555
+ || process.env.GDD_SESSION_ID
556
+ || 'hook';
557
+ const sid = String(rawSid).replace(/[^A-Za-z0-9_-]+/g, '-').slice(0, 120) || 'hook';
558
+ const stateFile = path.join(cwd, '.design', 'locks', `factforce-${sid}.json`);
559
+
560
+ let state = { reads: {}, first_mutation_seen: {}, checked: {} };
561
+ try {
562
+ const parsed = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
563
+ if (parsed && typeof parsed === 'object') {
564
+ state.reads = (typeof parsed.reads === 'object' && parsed.reads) || {};
565
+ state.first_mutation_seen = (typeof parsed.first_mutation_seen === 'object' && parsed.first_mutation_seen) || {};
566
+ state.checked = (typeof parsed.checked === 'object' && parsed.checked) || {};
567
+ }
568
+ } catch { /* missing/corrupt -> start fresh */ }
569
+
570
+ state.reads[rel] = new Date().toISOString();
571
+
572
+ fs.mkdirSync(path.dirname(stateFile), { recursive: true });
573
+ const tmp = `${stateFile}.tmp`;
574
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
575
+ fs.renameSync(tmp, stateFile);
576
+ } catch { /* best-effort: never let read-tracking affect the Read hook */ }
577
+ }
578
+
527
579
  async function main() {
528
580
  let buf = '';
529
581
  for await (const chunk of process.stdin) buf += chunk;
@@ -539,6 +591,11 @@ async function main() {
539
591
  return;
540
592
  }
541
593
 
594
+ // Phase 56: record this Read into the fact-force session state for EVERY
595
+ // Read (not just recall-matching .md files), before the recall matcher gate.
596
+ // Best-effort + fully swallowed — does not alter the behavior below.
597
+ recordReadForFactForce(payload);
598
+
542
599
  const fp = payload?.tool_input?.file_path || '';
543
600
  if (!MATCHER_RE.test(fp)) {
544
601
  process.stdout.write(JSON.stringify({ continue: true }));
@@ -651,5 +708,6 @@ module.exports = {
651
708
  buildInstinctsBlock,
652
709
  instinctTokens,
653
710
  queryInstinctsBlock,
711
+ recordReadForFactForce,
654
712
  main,
655
713
  };
@@ -0,0 +1,345 @@
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
+ * Does any decision/blocker line mention this file? Best-effort substring grep
152
+ * over the canonical docs for the file's basename or relPath (the same terms
153
+ * the decision-injector greps on). Returns { found:boolean, where:string|null }.
154
+ */
155
+ function decisionMentions(cwd, relPath) {
156
+ const basename = path.basename(relPath);
157
+ const terms = Array.from(new Set([basename, relPath].filter(Boolean)));
158
+ for (const src of decisionSources(cwd)) {
159
+ let content;
160
+ try { content = fs.readFileSync(src, 'utf8'); } catch { continue; }
161
+ for (const t of terms) {
162
+ if (t && content.includes(t)) return { found: true, where: path.basename(src) };
163
+ }
164
+ }
165
+ return { found: false, where: null };
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Importer prerequisite: were the file's consumers Read this session?
170
+ // SOFTENS when the graph is absent (available:false).
171
+ // ---------------------------------------------------------------------------
172
+ function readSlugs(state, cwd) {
173
+ // Index the session reads by their leaf slug for token matching against
174
+ // consumer node names.
175
+ const slugs = new Set();
176
+ for (const k of Object.keys(state.reads || {})) {
177
+ const s = leafSlug(k);
178
+ if (s) slugs.add(s);
179
+ }
180
+ return slugs;
181
+ }
182
+
183
+ /**
184
+ * @returns {{ softened:boolean, unread:string[] }}
185
+ * softened — true when the graph is unavailable (importer check downgraded
186
+ * to a non-blocking warning).
187
+ * unread — importer slugs that were NOT found in this session's reads.
188
+ */
189
+ function importerPrereq(filePath, cwd, state) {
190
+ if (!_consumers) return { softened: true, unread: [] };
191
+ let res;
192
+ try {
193
+ res = _consumers.consumersOfFile(filePath, { root: cwd });
194
+ } catch {
195
+ return { softened: true, unread: [] };
196
+ }
197
+ if (!res || res.available !== true) {
198
+ // Graph absent / unbuilt / file unmapped-with-no-graph -> SOFTEN.
199
+ return { softened: true, unread: [] };
200
+ }
201
+ const importers = Array.isArray(res.importers) ? res.importers : [];
202
+ if (importers.length === 0) return { softened: false, unread: [] };
203
+ const reads = readSlugs(state, cwd);
204
+ const unread = importers.filter((imp) => !reads.has(String(imp).toLowerCase()));
205
+ return { softened: false, unread };
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Risk tier (imports A's compute-risk; SOFTENS to non-block when unavailable)
210
+ // ---------------------------------------------------------------------------
211
+ function riskIsBlock(tool, input, cwd) {
212
+ if (!_risk) return false;
213
+ try {
214
+ const cfg = typeof _risk.loadRiskConfig === 'function' ? _risk.loadRiskConfig(cwd) : null;
215
+ const thresholds = cfg && cfg.thresholds ? cfg.thresholds : undefined;
216
+ const r = _risk.computeRisk(tool, input, thresholds);
217
+ return !!(r && r.suggested_action === 'block');
218
+ } catch {
219
+ return false;
220
+ }
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Main
225
+ // ---------------------------------------------------------------------------
226
+ async function main() {
227
+ let buf = '';
228
+ for await (const chunk of process.stdin) buf += chunk;
229
+
230
+ let payload;
231
+ try { payload = JSON.parse(buf || '{}'); } catch {
232
+ process.stdout.write(JSON.stringify({ continue: true }));
233
+ return;
234
+ }
235
+
236
+ const tool = (payload && payload.tool_name) || '';
237
+ if (!GATED_TOOLS.has(tool)) {
238
+ process.stdout.write(JSON.stringify({ continue: true }));
239
+ return;
240
+ }
241
+
242
+ const cwd = (payload && payload.cwd) || process.cwd();
243
+ const rawPath = payload && payload.tool_input && payload.tool_input.file_path;
244
+ if (!rawPath) {
245
+ process.stdout.write(JSON.stringify({ continue: true }));
246
+ return;
247
+ }
248
+ const relPath = normPath(rawPath, cwd);
249
+
250
+ const sessionId = sessionIdFrom(payload);
251
+ const stateFile = stateFileFor(cwd, sessionId);
252
+ const state = loadState(stateFile);
253
+
254
+ // (1) Already overridden for this path -> always pass (and record the seen).
255
+ if (state.checked && state.checked[relPath]) {
256
+ if (!state.first_mutation_seen[relPath]) {
257
+ state.first_mutation_seen[relPath] = new Date().toISOString();
258
+ saveState(stateFile, state);
259
+ }
260
+ emit('allow', { reason: 'checked', path: relPath });
261
+ process.stdout.write(JSON.stringify({ continue: true }));
262
+ return;
263
+ }
264
+
265
+ // (2) Not the FIRST mutation of this file this session -> not re-gated.
266
+ if (state.first_mutation_seen && state.first_mutation_seen[relPath]) {
267
+ emit('allow', { reason: 'already-mutated', path: relPath });
268
+ process.stdout.write(JSON.stringify({ continue: true }));
269
+ return;
270
+ }
271
+
272
+ // (3) First mutation: evaluate prerequisites.
273
+ const missing = [];
274
+
275
+ const imp = importerPrereq(rawPath, cwd, state);
276
+ if (!imp.softened && imp.unread.length > 0) {
277
+ missing.push(`unread importers: ${imp.unread.join(', ')} (Read the file(s) that consume '${relPath}')`);
278
+ }
279
+
280
+ const dec = decisionMentions(cwd, relPath);
281
+ // A decision/blocker is "tagged with X" when a canonical doc mentions the
282
+ // file. If one exists, it must have been surfaced (Read) this session — we
283
+ // approximate "surfaced" by the doc itself being in reads[], else flag it.
284
+ if (dec.found) {
285
+ const docReadKnown = Object.keys(state.reads || {}).some((k) => {
286
+ const b = path.basename(k);
287
+ return b === dec.where || b === 'STATE.md' || b === 'CYCLES.md' || b === 'LEARNINGS.md';
288
+ });
289
+ if (!docReadKnown) {
290
+ missing.push(`unreviewed decisions/blockers tagged '${path.basename(relPath)}' in ${dec.where} (Read it first)`);
291
+ }
292
+ }
293
+
294
+ // Record that we have now SEEN the first mutation attempt for this file (so a
295
+ // subsequent retry after the agent satisfies prereqs flows through gate (2)
296
+ // only AFTER a pass; we set the marker on the allow path below to avoid
297
+ // permanently disarming on a blocked attempt).
298
+ if (missing.length === 0) {
299
+ state.first_mutation_seen[relPath] = new Date().toISOString();
300
+ saveState(stateFile, state);
301
+ emit('allow', { reason: 'prereqs-met', path: relPath, softened: imp.softened });
302
+ process.stdout.write(JSON.stringify({ continue: true }));
303
+ return;
304
+ }
305
+
306
+ // Prerequisites unmet -> block. SOFT unless risk == block (then HARD).
307
+ const hard = riskIsBlock(tool, payload.tool_input, cwd);
308
+ const factsList = missing.join('; ');
309
+ const stopReason = hard
310
+ ? `gdd-fact-force (HARD — risk=block): cannot mutate '${relPath}' until facts are established — ${factsList}. The only escape is \`/gdd:override factforce ${relPath} --approver <who>\`.`
311
+ : `gdd-fact-force: establish the facts before the first edit to '${relPath}' — ${factsList}. Read them, or run \`/gdd:override factforce ${relPath}\` to mark checked.`;
312
+
313
+ emit(hard ? 'block-hard' : 'block-soft', { path: relPath, missing: missing.length });
314
+ process.stdout.write(JSON.stringify({ continue: false, stopReason }));
315
+ }
316
+
317
+ // Best-effort telemetry — never throws, swallowed if the emitter is absent.
318
+ function emit(decision, detail) {
319
+ try {
320
+ require('./_hook-emit.js').emitHookFired('gdd-fact-force', decision, detail || {});
321
+ } catch { /* swallow */ }
322
+ }
323
+
324
+ // Auto-run when invoked directly (hooks.json runs `node hooks/gdd-fact-force.js`).
325
+ // Guarded so tests can require() the module to unit-test the pure helpers.
326
+ if (require.main === module) {
327
+ main().catch(() => {
328
+ process.stdout.write(JSON.stringify({ continue: true }));
329
+ });
330
+ }
331
+
332
+ module.exports = {
333
+ // pure-ish helpers exported for tests; main() owns the I/O + contract.
334
+ normPath,
335
+ leafSlug,
336
+ sessionIdFrom,
337
+ stateFileFor,
338
+ loadState,
339
+ saveState,
340
+ decisionMentions,
341
+ importerPrereq,
342
+ riskIsBlock,
343
+ findPackageRoot,
344
+ main,
345
+ };