@hegemonart/get-design-done 1.24.2 → 1.25.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.
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * hooks/gdd-turn-closeout.js — Stop event hook (Plan 25-04, D-10).
5
+ *
6
+ * Fires when the assistant turn ends. Closes the events.jsonl gap at turn-end
7
+ * and surfaces a stage-completion or paused-mid-task nudge as additionalContext
8
+ * when the user is mid-pipeline and the last event is stale (>60s old).
9
+ *
10
+ * Contract (D-10):
11
+ * stdin : { tool_name, tool_input, cwd, ... } — Claude harness Stop payload
12
+ * stdout : { continue: true, hookSpecificOutput: { hookEventName: "Stop", additionalContext } }
13
+ * or { continue: true } when no nudge is warranted
14
+ * exit : always 0 (non-blocking — never gate the user's next turn)
15
+ * latency : ≤10ms typical (reads STATE.md + tails events.jsonl only)
16
+ * idempotent: re-running on the same (stage, task_progress) tuple after a
17
+ * turn_end has already been written is a no-op append-skip.
18
+ *
19
+ * Logic:
20
+ * 1. Try to read `.design/STATE.md`. Missing/unreadable → exit cleanly.
21
+ * 2. Lightweight-parse the <position> block (regex, no full state parser).
22
+ * If status != "in_progress" → exit cleanly.
23
+ * 3. Tail the last line of `.design/telemetry/events.jsonl`. Missing file
24
+ * counts as "stale by definition" (no events at all is exactly the gap
25
+ * this hook closes).
26
+ * 4. If last event is <60s old → exit cleanly (no gap to fill).
27
+ * 5. If last event is already a turn_end for the SAME (stage, task_progress)
28
+ * tuple → idempotent no-op (skip the append, still emit the nudge).
29
+ * 6. Else: append `{type: "turn_end", timestamp, sessionId, stage, payload:
30
+ * {task_progress}}` to events.jsonl.
31
+ * 7. Build additionalContext nudge:
32
+ * - task_progress matches `N/N` (stage complete) → "Stage <stage>
33
+ * complete — run /gdd:next or /gdd:reflect"
34
+ * - else → "Stage <stage> paused mid-task — resume with /gdd:resume"
35
+ *
36
+ * Out of scope (per Plan 25-04):
37
+ * - Wiring this hook into hooks.json (Plan 25-08).
38
+ * - Tail-calling from orchestrator skills — see skills/turn-closeout/SKILL.md
39
+ * for the portable mirror used by non-Claude runtimes.
40
+ */
41
+
42
+ const fs = require('fs');
43
+ const path = require('path');
44
+
45
+ const STALE_AFTER_MS = 60_000;
46
+ const TAIL_BYTES = 8_192; // last 8 KiB is plenty for one event line (<<64KiB cap)
47
+
48
+ /**
49
+ * Lightweight parse of the `<position>` block in STATE.md. Returns the fields
50
+ * we care about, or null if the block isn't present / well-formed.
51
+ *
52
+ * We intentionally avoid the full parser at scripts/lib/gdd-state/parser.ts
53
+ * because (a) it requires TS execution and (b) its overhead would blow the
54
+ * 10ms budget. The position block is k=v lines so a regex pass is fine.
55
+ */
56
+ function parsePosition(stateMd) {
57
+ const m = stateMd.match(/<position>([\s\S]*?)<\/position>/);
58
+ if (!m) return null;
59
+ const body = m[1];
60
+ const fields = {};
61
+ for (const line of body.split(/\r?\n/)) {
62
+ const kv = line.match(/^\s*([a-z_]+)\s*:\s*(.*?)\s*$/);
63
+ if (kv) fields[kv[1]] = kv[2];
64
+ }
65
+ if (!fields.stage || !fields.status) return null;
66
+ return {
67
+ stage: fields.stage,
68
+ status: fields.status,
69
+ task_progress: fields.task_progress || '0/0',
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Read the last line of a JSONL file without loading the whole file.
75
+ * Returns null if the file is missing, empty, or unreadable.
76
+ */
77
+ function tailLastLine(filePath) {
78
+ let fd;
79
+ try {
80
+ fd = fs.openSync(filePath, 'r');
81
+ const stat = fs.fstatSync(fd);
82
+ if (stat.size === 0) return null;
83
+ const readLen = Math.min(TAIL_BYTES, stat.size);
84
+ const buf = Buffer.alloc(readLen);
85
+ fs.readSync(fd, buf, 0, readLen, stat.size - readLen);
86
+ const tail = buf.toString('utf8');
87
+ // Trim any trailing newlines, then take the substring after the last newline.
88
+ const trimmed = tail.replace(/\s+$/, '');
89
+ const idx = trimmed.lastIndexOf('\n');
90
+ return idx === -1 ? trimmed : trimmed.slice(idx + 1);
91
+ } catch {
92
+ return null;
93
+ } finally {
94
+ if (fd !== undefined) {
95
+ try { fs.closeSync(fd); } catch { /* swallow */ }
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Decide whether the last-event line is "stale" — older than STALE_AFTER_MS,
102
+ * or unparseable / absent (which we also treat as stale, since the absence of
103
+ * a recent end-of-turn marker is exactly the condition this hook closes).
104
+ *
105
+ * Returns { stale: boolean, lastEvent: object|null }.
106
+ */
107
+ function classifyLastEvent(lastLine, nowMs) {
108
+ if (!lastLine) return { stale: true, lastEvent: null };
109
+ let ev;
110
+ try { ev = JSON.parse(lastLine); } catch { return { stale: true, lastEvent: null }; }
111
+ const ts = ev && typeof ev.timestamp === 'string' ? Date.parse(ev.timestamp) : NaN;
112
+ if (!Number.isFinite(ts)) return { stale: true, lastEvent: ev };
113
+ return { stale: nowMs - ts > STALE_AFTER_MS, lastEvent: ev };
114
+ }
115
+
116
+ /**
117
+ * Idempotence guard: if the most-recent line is already a turn_end for the
118
+ * exact (stage, task_progress) tuple, skip the append. Returns true if a
119
+ * duplicate-append should be suppressed.
120
+ */
121
+ function isDuplicateTurnEnd(lastEvent, stage, taskProgress) {
122
+ if (!lastEvent || lastEvent.type !== 'turn_end') return false;
123
+ if (lastEvent.stage !== stage) return false;
124
+ const lastProgress = lastEvent.payload && lastEvent.payload.task_progress;
125
+ return lastProgress === taskProgress;
126
+ }
127
+
128
+ /**
129
+ * Build the user-facing nudge string. `N/N` (numerator==denominator) signals
130
+ * stage-complete; anything else is paused-mid-task.
131
+ */
132
+ function buildNudge(stage, taskProgress) {
133
+ const m = taskProgress.match(/^(\d+)\s*\/\s*(\d+)$/);
134
+ const stageComplete = !!(m && m[1] === m[2] && Number(m[2]) > 0);
135
+ return stageComplete
136
+ ? `Stage ${stage} complete — run /gdd:next or /gdd:reflect`
137
+ : `Stage ${stage} paused mid-task — resume with /gdd:resume`;
138
+ }
139
+
140
+ /**
141
+ * Resolve a session id for the appended event. The Stop hook payload may
142
+ * include `session_id`; if absent (older harness or test fixtures) fall back
143
+ * to a synthetic marker so the line still parses against the BaseEvent shape.
144
+ */
145
+ function resolveSessionId(payload) {
146
+ return (payload && (payload.session_id || payload.sessionId)) || 'turn-closeout';
147
+ }
148
+
149
+ /**
150
+ * Append a single turn_end event to events.jsonl. Best-effort — any I/O
151
+ * failure is swallowed (the nudge still surfaces; we don't gate on writes).
152
+ */
153
+ function appendTurnEnd(eventsPath, stage, taskProgress, sessionId, nowIso) {
154
+ const event = {
155
+ type: 'turn_end',
156
+ timestamp: nowIso,
157
+ sessionId,
158
+ stage,
159
+ payload: { task_progress: taskProgress },
160
+ _meta: { source: 'gdd-turn-closeout' },
161
+ };
162
+ try {
163
+ const dir = path.dirname(eventsPath);
164
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
165
+ fs.appendFileSync(eventsPath, JSON.stringify(event) + '\n', { flag: 'a' });
166
+ } catch {
167
+ /* swallow — non-blocking */
168
+ }
169
+ }
170
+
171
+ function emitContinue(additionalContext) {
172
+ const out = additionalContext
173
+ ? { continue: true, hookSpecificOutput: { hookEventName: 'Stop', additionalContext } }
174
+ : { continue: true };
175
+ try { process.stdout.write(JSON.stringify(out)); } catch { /* swallow */ }
176
+ }
177
+
178
+ async function main() {
179
+ // Drain stdin even if we don't end up using it; the harness sends a JSON
180
+ // payload and orphaned EPIPE on close has bitten other hooks.
181
+ let buf = '';
182
+ try {
183
+ for await (const chunk of process.stdin) buf += chunk;
184
+ } catch { /* swallow */ }
185
+
186
+ let payload = {};
187
+ try { payload = buf.trim() ? JSON.parse(buf) : {}; } catch { payload = {}; }
188
+
189
+ const cwd = (payload && payload.cwd) || process.cwd();
190
+ const statePath = path.join(cwd, '.design', 'STATE.md');
191
+ const eventsPath = path.join(cwd, '.design', 'telemetry', 'events.jsonl');
192
+
193
+ // --- Branch 1: STATE.md missing or unreadable → silent continue.
194
+ let stateMd;
195
+ try { stateMd = fs.readFileSync(statePath, 'utf8'); }
196
+ catch { return emitContinue(null); }
197
+
198
+ // --- Branch 2: <position> not parseable or status != in_progress → silent continue.
199
+ const position = parsePosition(stateMd);
200
+ if (!position || position.status !== 'in_progress') return emitContinue(null);
201
+
202
+ const nowMs = Date.now();
203
+ const nowIso = new Date(nowMs).toISOString();
204
+
205
+ // --- Branch 3: last event is fresh (<60s) → silent continue (no gap).
206
+ const lastLine = tailLastLine(eventsPath);
207
+ const { stale, lastEvent } = classifyLastEvent(lastLine, nowMs);
208
+ if (!stale) return emitContinue(null);
209
+
210
+ // --- Branch 4: stale → idempotent append (skip if duplicate) + emit nudge.
211
+ if (!isDuplicateTurnEnd(lastEvent, position.stage, position.task_progress)) {
212
+ appendTurnEnd(
213
+ eventsPath,
214
+ position.stage,
215
+ position.task_progress,
216
+ resolveSessionId(payload),
217
+ nowIso,
218
+ );
219
+ }
220
+
221
+ emitContinue(buildNudge(position.stage, position.task_progress));
222
+ }
223
+
224
+ main().catch(() => {
225
+ // Never block the user's next turn — even on catastrophic failure.
226
+ try { process.stdout.write(JSON.stringify({ continue: true })); } catch { /* swallow */ }
227
+ });
228
+
229
+ // Exposed for unit tests (Plan 25-09). Intentionally no public runtime surface
230
+ // beyond the stdin/stdout contract above.
231
+ module.exports = {
232
+ parsePosition,
233
+ tailLastLine,
234
+ classifyLastEvent,
235
+ isDuplicateTurnEnd,
236
+ buildNudge,
237
+ STALE_AFTER_MS,
238
+ };
package/hooks/hooks.json CHANGED
@@ -100,6 +100,16 @@
100
100
  }
101
101
  ]
102
102
  }
103
+ ],
104
+ "Stop": [
105
+ {
106
+ "hooks": [
107
+ {
108
+ "type": "command",
109
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gdd-turn-closeout.js\""
110
+ }
111
+ ]
112
+ }
103
113
  ]
104
114
  }
105
115
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hegemonart/get-design-done",
3
- "version": "1.24.2",
4
- "description": "A Claude Code plugin for systematic design improvement",
3
+ "version": "1.25.0",
4
+ "description": "A design-quality pipeline for AI coding agents: brief, plan, implement, and verify UI work against your design system.",
5
5
  "author": "Hegemon",
6
6
  "homepage": "https://github.com/hegemonart/get-design-done",
7
7
  "repository": {
@@ -53,11 +53,11 @@
53
53
  "gdd-sdk": "node --experimental-strip-types scripts/lib/cli/index.ts"
54
54
  },
55
55
  "devDependencies": {
56
- "@types/node": "^22.0.0",
56
+ "@types/node": "^25.6.0",
57
57
  "ajv-cli": "^5.0.0",
58
58
  "ajv-formats": "^3.0.1",
59
59
  "json-schema-to-typescript": "^15.0.0",
60
- "typescript": "^5.5.0"
60
+ "typescript": "^6.0.3"
61
61
  },
62
62
  "keywords": [
63
63
  "claude",
@@ -84,7 +84,7 @@
84
84
  "hooks": "hooks/hooks.json",
85
85
  "dependencies": {
86
86
  "@anthropic-ai/claude-agent-sdk": "^0.2.119",
87
- "@clack/prompts": "^0.7.0",
87
+ "@clack/prompts": "^1.2.0",
88
88
  "@modelcontextprotocol/sdk": "^1.0.0"
89
89
  },
90
90
  "optionalDependencies": {
@@ -55,6 +55,25 @@ skipped_stages: ""
55
55
  <!-- Valid status values: pending | pass | fail -->
56
56
  </must_haves>
57
57
 
58
+ <prototyping>
59
+ <!-- Phase 25: appended by sketch-wrap-up / spike-wrap-up + the prototype-gate. -->
60
+ <!-- Three child element types, each on its own line: -->
61
+ <!-- <sketch slug="…" cycle="…" decision="D-XX" status="resolved"/> -->
62
+ <!-- <spike slug="…" cycle="…" decision="D-XX" verdict="yes|no|partial" status="resolved"/> -->
63
+ <!-- <skipped at="explore|plan" cycle="…" reason="…"/> -->
64
+ <!-- The block is omitted entirely on fresh files; add it only when the first -->
65
+ <!-- sketch / spike / skipped entry is appended. -->
66
+ </prototyping>
67
+
68
+ <quality_gate>
69
+ <!-- Phase 25 (Plan 25-03): written by the quality-gate skill (Stage 4.5). -->
70
+ <!-- Houses a single most-recent <run/> entry — append-mode would be overkill. -->
71
+ <!-- Format: -->
72
+ <!-- <run started_at="…" completed_at="…" status="pass|fail|timeout|skipped" iteration="N" commands_run="lint,typecheck,test"/> -->
73
+ <!-- The block is omitted entirely on fresh files; add it only when the first -->
74
+ <!-- gate completion overwrites the entry. -->
75
+ </quality_gate>
76
+
58
77
  <connections>
59
78
  <!-- Detected at scan entry or via /gdd:connections; updated if connections become available mid-pipeline. -->
60
79
  <!-- Format: <connection_name>: <available | unavailable | not_configured> -->
@@ -149,6 +168,28 @@ Discover stage populates with observable behaviors. Verify stage updates status.
149
168
  - `[description]`: testable behavior or artifact
150
169
  - `status`: `pending` (default), `pass` (verify confirmed), `fail` (verify rejected)
151
170
 
171
+ ### `<prototyping>`
172
+
173
+ Phase 25 surface (D-01). A checkpoint log — NOT a stage. Tracks sketch and spike outcomes plus cycle-scoped skip suppressions for the prototype gate.
174
+
175
+ - `<sketch slug=… cycle=… decision=D-XX status=resolved/>` — written by `sketch-wrap-up` after a sketch resolves into a D-XX decision.
176
+ - `<spike slug=… cycle=… decision=D-XX verdict=yes|no|partial status=resolved/>` — written by `spike-wrap-up` after a spike resolves; `verdict` captures the answer.
177
+ - `<skipped at=… cycle=… reason=…/>` — written by the prototype gate when the user declines to sketch/spike at a firing point. Cycle-scoped suppression (D-02): a `<skipped/>` entry suppresses re-asking for the rest of the named cycle.
178
+
179
+ The block is **optional** — fresh STATE.md files do not carry it. The serializer omits the block entirely when no entries exist; appending the first entry is what materializes the block.
180
+
181
+ ### `<quality_gate>`
182
+
183
+ Phase 25 surface (Plan 25-03 / D-06..D-09). Captures the most recent run of the Stage 4.5 quality gate (lint / typecheck / test / visual-regression) between Design and Verify. The block houses a single self-closing `<run/>` element — append-mode is overkill, so each gate completion overwrites the entry.
184
+
185
+ - `started_at` — ISO 8601 at which the parallel command run entered.
186
+ - `completed_at` — ISO 8601 at which the gate produced its terminal status.
187
+ - `status` — `pass | fail | timeout | skipped`. `pass` clears the verify-entry gate; `fail` blocks; `timeout` warns + proceeds (D-07); `skipped` indicates the detection chain resolved zero commands.
188
+ - `iteration` — non-negative integer fix-loop count (D-08). `1` = single clean pass; `N === max_iters` with `status === 'fail'` = bounded exhaustion.
189
+ - `commands_run` — comma-separated names of the commands actually executed in Step 2 (e.g., `lint,typecheck,test`). Empty string when `status === 'skipped'`.
190
+
191
+ The block is **optional** — fresh STATE.md files do not carry it. The serializer omits the block entirely when `quality_gate === null`; the SKILL writes the first `<run/>` to materialize it.
192
+
152
193
  ### `<connections>`
153
194
 
154
195
  One line per external connection. Detected at scan entry via MCP availability probes.
@@ -179,6 +179,36 @@ TTL driving `.design/cache-manifest.json` entry expiry per D-08 Layer B.
179
179
 
180
180
  `enforce | warn | log` per D-11. `enforce` (default) is D-02 behavior; `warn` prints warnings but allows spawn; `log` is advisory-only (useful for adoption on existing projects mid-flight).
181
181
 
182
+ ### `class_caps_usd` (Phase 25 / D-05, optional)
183
+
184
+ Optional per-class per-spawn cap map. Lets you set tighter caps for trivial commands and looser caps for autonomous flows independently. Read by `hooks/budget-enforcer.ts` when the router decision payload (`tool_input.context.router_decision.complexity_class`) is present. Falls back to `per_task_cap_usd` when the field is absent OR no router decision is supplied (full back-compat with pre-Phase-25 callers).
185
+
186
+ ```json
187
+ {
188
+ "class_caps_usd": {
189
+ "S": 0.05,
190
+ "M": 0.50,
191
+ "L": 1.50,
192
+ "XL": 5.00
193
+ }
194
+ }
195
+ ```
196
+
197
+ Schema:
198
+
199
+ ```ts
200
+ class_caps_usd?: { S?: number; M?: number; L?: number; XL?: number }
201
+ ```
202
+
203
+ Resolution order for the per-spawn cap:
204
+
205
+ 1. If `complexity_class` is in `tool_input.context.router_decision` AND `class_caps_usd[class]` is a positive finite number → use it.
206
+ 2. Otherwise → use `per_task_cap_usd`.
207
+
208
+ Class `S` is special: when `complexity_class === "S"` is supplied to the hook, enforcement is skipped entirely (no cap check, no auto-downgrade) — class-S commands typically short-circuit the router upstream so this hook never runs at all; the explicit S handling is the defensive path. See `skills/router/SKILL.md` for the canonical `S → fast (short-circuited)`, `M → fast`, `L → quick`, `XL → full` mapping.
209
+
210
+ `per_phase_cap_usd` is unchanged by this field — phase-cumulative enforcement always uses the global per-phase cap regardless of class.
211
+
182
212
  ## Bootstrap behavior
183
213
 
184
214
  If `.design/budget.json` is missing when any `/gdd:*` command runs, `scripts/bootstrap.sh` writes the Default Config values (per D-12). Don't block the spawn — defaults are sensible.