@hegemonart/get-design-done 1.59.6 → 1.59.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +55 -0
  4. package/README.md +4 -13
  5. package/SKILL.md +1 -1
  6. package/agents/design-authority-watcher.md +24 -5
  7. package/bin/gdd-graph +4 -1
  8. package/docs/i18n/README.de.md +210 -527
  9. package/docs/i18n/README.fr.md +201 -518
  10. package/docs/i18n/README.it.md +209 -526
  11. package/docs/i18n/README.ja.md +207 -524
  12. package/docs/i18n/README.ko.md +208 -525
  13. package/docs/i18n/README.zh-CN.md +213 -551
  14. package/hooks/_hook-emit.js +113 -29
  15. package/hooks/budget-enforcer.ts +44 -5
  16. package/hooks/gdd-mcp-circuit-breaker.js +72 -3
  17. package/hooks/gdd-sessionstart-recap.js +23 -14
  18. package/hooks/hooks.json +2 -2
  19. package/package.json +2 -2
  20. package/reference/bandit-integration.md +13 -2
  21. package/scripts/bootstrap.cjs +40 -8
  22. package/scripts/install.cjs +23 -1
  23. package/scripts/lib/bandit-router.cjs +47 -5
  24. package/scripts/lib/detect/cli.cjs +13 -3
  25. package/scripts/lib/install/converters/cursor.cjs +11 -19
  26. package/scripts/lib/install/doctor-codex-plugin.cjs +1 -1
  27. package/scripts/lib/install/doctor-cursor-marketplace.cjs +2 -2
  28. package/scripts/lib/install/installer.cjs +72 -21
  29. package/scripts/lib/install/merge.cjs +31 -3
  30. package/scripts/lib/install/runtime-artifact-layout.cjs +42 -8
  31. package/scripts/lib/manifest/harnesses.json +29 -1
  32. package/scripts/lib/manifest/skills.json +1 -1
  33. package/scripts/skill-templates/bandit-reset/SKILL.md +2 -0
  34. package/scripts/skill-templates/bandit-status/SKILL.md +4 -1
  35. package/scripts/skill-templates/darkmode/SKILL.md +1 -1
  36. package/scripts/skill-templates/graphify/SKILL.md +6 -6
  37. package/scripts/skill-templates/quick/SKILL.md +3 -1
  38. package/scripts/skill-templates/reflect/SKILL.md +1 -1
  39. package/scripts/skill-templates/router/SKILL.md +4 -2
  40. package/sdk/cli/index.js +114 -47
  41. package/sdk/dashboard/data/source.cjs +50 -4
  42. package/sdk/event-stream/writer.ts +112 -30
  43. package/sdk/mcp/gdd-mcp/server.js +49 -36
  44. package/sdk/mcp/gdd-mcp/tools/shared.ts +20 -2
  45. package/sdk/mcp/gdd-state/server.js +107 -41
  46. package/sdk/primitives/lockfile.cjs +26 -5
  47. package/sdk/state/index.ts +91 -17
  48. package/sdk/state/lockfile.ts +47 -8
  49. package/skills/bandit-reset/SKILL.md +2 -0
  50. package/skills/bandit-status/SKILL.md +4 -1
  51. package/skills/darkmode/SKILL.md +1 -1
  52. package/skills/graphify/SKILL.md +6 -6
  53. package/skills/quick/SKILL.md +3 -1
  54. package/skills/reflect/SKILL.md +1 -1
  55. package/skills/router/SKILL.md +4 -2
@@ -24,58 +24,142 @@
24
24
 
25
25
  'use strict';
26
26
 
27
+ const fs = require('node:fs');
28
+ const path = require('node:path');
29
+
27
30
  let cachedAppendEvent = null;
28
31
  let resolutionAttempted = false;
29
32
 
30
33
  /**
31
- * Lazy-resolve `appendEvent` only loads the event-stream module the
32
- * first time a hook fires. Falls back to a no-op if the module is not
33
- * loadable in the current runtime (e.g. plain `node` without
34
- * --experimental-strip-types).
34
+ * Best-effort resolve of the SDK `appendEvent`. On modern Node (≥22.18,
35
+ * which supports `require()` of ESM/`.ts` via type-stripping) this loads
36
+ * the full event-stream writer giving us bus broadcast + the SDK's
37
+ * truncation/redaction logic for free. On older Node (22.0–22.17), the
38
+ * `.ts` require throws and we fall back to `null`; the inline appender
39
+ * below takes over so `hook.fired` STILL lands on disk.
40
+ *
41
+ * Returns `null` (not a no-op) when unavailable so the caller knows to
42
+ * use the inline path instead of silently dropping the event.
35
43
  *
36
- * @returns {(ev: unknown) => void}
44
+ * @returns {((ev: unknown) => void) | null}
37
45
  */
38
46
  function getAppendEvent() {
39
- if (cachedAppendEvent !== null || resolutionAttempted) {
40
- return cachedAppendEvent || (() => {});
41
- }
47
+ if (resolutionAttempted) return cachedAppendEvent;
42
48
  resolutionAttempted = true;
43
49
  try {
44
- // event-stream/index.ts requires --experimental-strip-types. Try
45
- // require()'ing — if Node refuses to parse `.ts`, we silently fall
46
- // back to no-op.
47
50
  // eslint-disable-next-line node/no-missing-require, global-require
48
- cachedAppendEvent = require('../sdk/event-stream/index.ts').appendEvent;
49
- return cachedAppendEvent;
51
+ const m = require('../sdk/event-stream/index.ts');
52
+ if (m && typeof m.appendEvent === 'function') {
53
+ cachedAppendEvent = m.appendEvent;
54
+ }
50
55
  } catch {
51
56
  cachedAppendEvent = null;
52
- return () => {};
53
57
  }
58
+ return cachedAppendEvent;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Inline redaction (best-effort). The SDK writer scrubs secrets at the
63
+ // serialize boundary via scripts/lib/redact.cjs. When we take the inline
64
+ // append path (older Node), replicate that scrubbing so the fallback never
65
+ // leaks secrets that the SDK path would have caught. redact.cjs is plain
66
+ // CommonJS, so it loads under any Node version. If unreachable, identity.
67
+ // ---------------------------------------------------------------------------
68
+
69
+ let cachedRedact = null;
70
+ let redactResolved = false;
71
+
72
+ function getRedact() {
73
+ if (redactResolved) return cachedRedact;
74
+ redactResolved = true;
75
+ try {
76
+ // eslint-disable-next-line global-require
77
+ const m = require('../scripts/lib/redact.cjs');
78
+ if (m && typeof m.redact === 'function') cachedRedact = m.redact;
79
+ } catch {
80
+ cachedRedact = null;
81
+ }
82
+ return cachedRedact;
54
83
  }
55
84
 
56
85
  /**
57
- * Emit a `hook.fired` event. Silent on every failure mode.
86
+ * Resolve the on-disk events.jsonl path the same way the SDK writer does:
87
+ * honor GDD_EVENTS_PATH (absolute path used by tests/E2E to steer the
88
+ * stream), else default to `<cwd>/.design/telemetry/events.jsonl`.
58
89
  *
59
- * @param {string} hookName
60
- * @param {string} decision
61
- * @param {Record<string, unknown>} [extras] — opaque additional payload fields
90
+ * @returns {string}
62
91
  */
63
- function emitHookFired(hookName, decision, extras) {
92
+ function resolveEventsPath() {
93
+ const envPath = process.env.GDD_EVENTS_PATH;
94
+ if (typeof envPath === 'string' && envPath.length > 0) {
95
+ return path.isAbsolute(envPath) ? envPath : path.resolve(process.cwd(), envPath);
96
+ }
97
+ return path.resolve(process.cwd(), '.design', 'telemetry', 'events.jsonl');
98
+ }
99
+
100
+ /**
101
+ * Inline append of one event as a JSONL line. Mirrors the SDK
102
+ * EventWriter.append minimal envelope contract: redact → JSON.stringify →
103
+ * appendFileSync with O_APPEND. NEVER throws.
104
+ *
105
+ * @param {Record<string, unknown>} ev
106
+ */
107
+ function inlineAppend(ev) {
64
108
  try {
109
+ const redact = getRedact();
110
+ const scrubbed = redact ? redact(ev) : ev;
111
+ const dest = resolveEventsPath();
112
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
113
+ fs.appendFileSync(dest, JSON.stringify(scrubbed) + '\n', { flag: 'a' });
114
+ } catch {
115
+ /* hooks must never throw on telemetry */
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Persist an arbitrary event envelope. Silent on every failure mode.
121
+ * Uses the SDK writer when loadable (modern Node), else the inline
122
+ * appender (older Node) — so the event ACTUALLY lands on disk on every
123
+ * supported Node version instead of no-op'ing.
124
+ *
125
+ * @param {Record<string, unknown>} ev — must carry at least `type`
126
+ */
127
+ function emitEvent(ev) {
128
+ try {
129
+ if (!ev || typeof ev !== 'object') return;
65
130
  const appendEvent = getAppendEvent();
66
- const payload = { hook: hookName, decision };
67
- if (extras && typeof extras === 'object') {
68
- Object.assign(payload, extras);
131
+ if (appendEvent) {
132
+ appendEvent(ev);
133
+ } else {
134
+ inlineAppend(ev);
69
135
  }
70
- appendEvent({
71
- type: 'hook.fired',
72
- timestamp: new Date().toISOString(),
73
- sessionId: process.env.GDD_SESSION_ID || 'hook',
74
- payload,
75
- });
76
136
  } catch {
77
137
  /* hooks must never throw on telemetry */
78
138
  }
79
139
  }
80
140
 
81
- module.exports = { emitHookFired };
141
+ /**
142
+ * Emit a `hook.fired` event. Silent on every failure mode.
143
+ *
144
+ * Happy path actually lands a line in `.design/telemetry/events.jsonl`
145
+ * (or GDD_EVENTS_PATH) on EVERY supported Node version — via the SDK
146
+ * writer when loadable, else via the inline appender.
147
+ *
148
+ * @param {string} hookName
149
+ * @param {string} decision
150
+ * @param {Record<string, unknown>} [extras] — opaque additional payload fields
151
+ */
152
+ function emitHookFired(hookName, decision, extras) {
153
+ const payload = { hook: hookName, decision };
154
+ if (extras && typeof extras === 'object') {
155
+ Object.assign(payload, extras);
156
+ }
157
+ emitEvent({
158
+ type: 'hook.fired',
159
+ timestamp: new Date().toISOString(),
160
+ sessionId: process.env.GDD_SESSION_ID || 'hook',
161
+ payload,
162
+ });
163
+ }
164
+
165
+ module.exports = { emitHookFired, emitEvent };
@@ -350,6 +350,19 @@ interface ToolOutput {
350
350
  stopReason?: string;
351
351
  modified_tool_input?: ToolInput;
352
352
  cached_result?: unknown;
353
+ /**
354
+ * Claude Code PreToolUse hook-specific envelope. This is the ONLY
355
+ * supported mechanism on current Claude Code for mutating a tool's
356
+ * input (`updatedInput`) or blocking a call (`permissionDecision`).
357
+ * The top-level `modified_tool_input` / `cached_result` fields are
358
+ * retained for backward-compat but are silently ignored by the harness.
359
+ */
360
+ hookSpecificOutput?: {
361
+ hookEventName: 'PreToolUse';
362
+ permissionDecision?: 'allow' | 'deny' | 'ask';
363
+ permissionDecisionReason?: string;
364
+ updatedInput?: ToolInput;
365
+ };
353
366
  }
354
367
 
355
368
  /** Shape of .design/cache-manifest.json — D-05 cache short-circuit. */
@@ -733,8 +746,28 @@ export function resolveTier(
733
746
  */
734
747
  function spawnAggregator(): void {
735
748
  try {
736
- const aggregatorPath = join(
737
- process.cwd(),
749
+ // Opt-out: when GDD_NO_AGGREGATOR is set (truthy), skip the detached
750
+ // child entirely. Production leaves this unset so the rollups stay
751
+ // current; tests that scaffold a throwaway temp cwd set it so the
752
+ // fire-and-forget child doesn't hold a handle on the dir they delete
753
+ // immediately after (a Windows rmSync EPERM race surfaced once the C3
754
+ // fix made this spawn actually resolve the script). No effect on the
755
+ // production code path.
756
+ const optOut = process.env['GDD_NO_AGGREGATOR'];
757
+ if (typeof optOut === 'string' && optOut !== '' && optOut !== '0' && optOut !== 'false') {
758
+ return;
759
+ }
760
+ // C3 fix: resolve the aggregator script relative to THIS hook file's
761
+ // location (the plugin's own tree), not process.cwd(). When an installed
762
+ // user runs from their project root, cwd is NOT the plugin repo, so
763
+ // `join(process.cwd(), 'scripts', ...)` never exists and the aggregator
764
+ // silently never runs — leaving phase-totals.json unbuilt and forcing a
765
+ // full costs.jsonl re-parse on every spawn. Anchor on the hook file via
766
+ // the same resolveHookPath() idiom used for createRequire above
767
+ // (hooks/budget-enforcer.ts → ../scripts/aggregate-agent-metrics.ts).
768
+ const aggregatorPath = resolve(
769
+ dirname(resolveHookPath()),
770
+ '..',
738
771
  'scripts',
739
772
  'aggregate-agent-metrics.ts',
740
773
  );
@@ -976,7 +1009,7 @@ export async function main(): Promise<void> {
976
1009
  process.exit(0);
977
1010
  }
978
1011
 
979
- if (parsed.tool_name !== 'Agent') process.exit(0);
1012
+ if (parsed.tool_name !== 'Agent' && parsed.tool_name !== 'Task') process.exit(0);
980
1013
 
981
1014
  const toolInput: ToolInput = parsed.tool_input ?? {};
982
1015
  const agent =
@@ -1059,6 +1092,7 @@ export async function main(): Promise<void> {
1059
1092
  continue: true,
1060
1093
  suppressOutput: true,
1061
1094
  modified_tool_input: toolInput,
1095
+ hookSpecificOutput: { hookEventName: 'PreToolUse', updatedInput: toolInput },
1062
1096
  };
1063
1097
  process.stdout.write(JSON.stringify(response));
1064
1098
  return;
@@ -1090,10 +1124,14 @@ export async function main(): Promise<void> {
1090
1124
  });
1091
1125
  emitHookFired('cache', cycle);
1092
1126
  const response: ToolOutput = {
1093
- continue: false,
1127
+ continue: true,
1094
1128
  suppressOutput: false,
1095
1129
  message: `gdd-budget-enforcer: SkippedCached — returning cached result for ${agent}:${inputHash}`,
1096
- cached_result: cached,
1130
+ hookSpecificOutput: {
1131
+ hookEventName: 'PreToolUse',
1132
+ permissionDecision: 'deny',
1133
+ permissionDecisionReason: `SkippedCached — a prior identical spawn already produced a result. Reuse it instead of re-spawning. Cached: ${JSON.stringify(cached).slice(0, 2000)}`,
1134
+ },
1097
1135
  };
1098
1136
  process.stdout.write(JSON.stringify(response));
1099
1137
  return;
@@ -1581,6 +1619,7 @@ export async function main(): Promise<void> {
1581
1619
  continue: true,
1582
1620
  suppressOutput: true,
1583
1621
  modified_tool_input: toolInput,
1622
+ hookSpecificOutput: { hookEventName: 'PreToolUse', updatedInput: toolInput },
1584
1623
  };
1585
1624
  process.stdout.write(JSON.stringify(response));
1586
1625
  }
@@ -25,6 +25,32 @@ const DEFAULT_FILE = path.join(REPO_ROOT, 'reference', 'mcp-budget.default.json'
25
25
 
26
26
  const TRACKED_TOOL_RE = /^mcp__.*use_(figma|paper|pencil)$/;
27
27
 
28
+ // Bounded fallback window (ms) for counting volume when no session id is
29
+ // available on the payload. Without this, `total_calls` would count every row
30
+ // ever appended to the ledger — so after `max_calls_per_task` cumulative calls
31
+ // across ALL sessions for the lifetime of the file, every mutation is blocked
32
+ // forever (and a BLOCKER is appended to STATE.md each time). The volume gate is
33
+ // meant to be PER-TASK; this window keeps the fallback path per-task-ish so a
34
+ // long-lived user is never permanently locked out.
35
+ const SESSIONLESS_WINDOW_MS = 6 * 60 * 60 * 1000; // 6 hours
36
+
37
+ /**
38
+ * Resolve the current session id from the hook payload (Claude Code passes
39
+ * `session_id`; tolerate `sessionId`), falling back to GDD_SESSION_ID, else
40
+ * null. A non-null id makes the volume window exact (count only this session's
41
+ * rows); null falls back to the bounded time window.
42
+ *
43
+ * @param {any} payload
44
+ * @returns {string|null}
45
+ */
46
+ function resolveSessionId(payload) {
47
+ const fromPayload = payload && (payload.session_id || payload.sessionId);
48
+ if (typeof fromPayload === 'string' && fromPayload.length > 0) return fromPayload;
49
+ const fromEnv = process.env.GDD_SESSION_ID;
50
+ if (typeof fromEnv === 'string' && fromEnv.length > 0) return fromEnv;
51
+ return null;
52
+ }
53
+
28
54
  function loadBudget(cwd) {
29
55
  let defaults = { max_calls_per_task: 30, max_consecutive_timeouts: 3, reset_on_success: true };
30
56
  try {
@@ -106,7 +132,25 @@ function classifyOutcome(toolResponse) {
106
132
  return 'error';
107
133
  }
108
134
 
109
- function readJsonlTail(filePath) {
135
+ /**
136
+ * Read the ledger and compute the prior volume + consecutive-timeout state
137
+ * for the CURRENT task window only — not the whole-file lifetime.
138
+ *
139
+ * Window membership for a row:
140
+ * - If a current session id is known AND the row carries a `session` field:
141
+ * the row counts iff `row.session === sessionId`.
142
+ * - Otherwise (sessionless harness/tests, or legacy rows without `session`):
143
+ * the row counts iff its timestamp is within SESSIONLESS_WINDOW_MS of now.
144
+ *
145
+ * This bounds the volume count so a long-lived ledger can never permanently
146
+ * trip `volumeBreak`, while keeping rapid same-task calls (the common case and
147
+ * the existing test scenario) counted together.
148
+ *
149
+ * @param {string} filePath
150
+ * @param {string|null} sessionId
151
+ * @param {number} nowMs
152
+ */
153
+ function readJsonlTail(filePath, sessionId, nowMs) {
110
154
  if (!fs.existsSync(filePath)) return { lastRow: null, total_calls: 0, consecutive_timeouts: 0 };
111
155
  let total = 0;
112
156
  let lastTimeoutsChain = 0;
@@ -118,6 +162,25 @@ function readJsonlTail(filePath) {
118
162
  if (!t) continue;
119
163
  let row;
120
164
  try { row = JSON.parse(t); } catch { continue; }
165
+
166
+ // Decide whether this row belongs to the current task window.
167
+ let inWindow;
168
+ if (sessionId !== null && typeof row.session === 'string' && row.session.length > 0) {
169
+ inWindow = row.session === sessionId;
170
+ } else {
171
+ const rowMs = typeof row.ts === 'string' ? Date.parse(row.ts) : NaN;
172
+ // Unparseable timestamps fall back to "in window" so we never
173
+ // under-count; a malformed-ts row is treated as recent.
174
+ inWindow = Number.isNaN(rowMs) ? true : (nowMs - rowMs) <= SESSIONLESS_WINDOW_MS;
175
+ }
176
+
177
+ if (!inWindow) {
178
+ // Out-of-window rows reset the streak — a new task/session must not
179
+ // inherit a stale consecutive-timeout chain.
180
+ lastTimeoutsChain = 0;
181
+ continue;
182
+ }
183
+
121
184
  total++;
122
185
  if (row.outcome === 'timeout') lastTimeoutsChain++;
123
186
  else lastTimeoutsChain = 0;
@@ -158,7 +221,9 @@ async function main() {
158
221
  const budget = loadBudget(cwd);
159
222
  const ledgerPath = path.join(cwd, '.design', 'telemetry', 'mcp-budget.jsonl');
160
223
 
161
- const prior = readJsonlTail(ledgerPath);
224
+ const sessionId = resolveSessionId(payload);
225
+ const nowMs = Date.now();
226
+ const prior = readJsonlTail(ledgerPath, sessionId, nowMs);
162
227
  const outcome = classifyOutcome(payload?.tool_response);
163
228
  const total_calls = prior.total_calls + 1;
164
229
  const consecutive_timeouts = outcome === 'timeout'
@@ -166,12 +231,16 @@ async function main() {
166
231
  : (budget.reset_on_success && outcome === 'success' ? 0 : prior.consecutive_timeouts);
167
232
 
168
233
  const row = {
169
- ts: new Date().toISOString(),
234
+ ts: new Date(nowMs).toISOString(),
170
235
  tool,
171
236
  outcome,
172
237
  consecutive_timeouts,
173
238
  total_calls,
174
239
  };
240
+ // Stamp the session id so future calls can scope the volume window exactly.
241
+ // Omitted when unknown (keeps the row schema stable for the sessionless path,
242
+ // which relies on the time window instead).
243
+ if (sessionId !== null) row.session = sessionId;
175
244
  appendJsonl(ledgerPath, row);
176
245
 
177
246
  const timeoutBreak = consecutive_timeouts >= budget.max_consecutive_timeouts;
@@ -57,17 +57,21 @@ function detectHarness() {
57
57
  }
58
58
 
59
59
  // ---------------------------------------------------------------------------
60
- // Lazy event-stream emit (best-effort)
60
+ // Event emit (best-effort) — delegate to the shared _hook-emit helper, which
61
+ // uses the SDK writer when loadable (modern Node) and an inline JSONL appender
62
+ // otherwise. The previous direct `require('../sdk/event-stream')` resolved to
63
+ // the `.ts` ESM index and threw under plain `node` on Node 22.0–22.17, leaving
64
+ // recap.emitted permanently no-op'd. emitEvent lands the line on every Node.
61
65
  // ---------------------------------------------------------------------------
62
66
 
63
- function getAppendEvent() {
67
+ function getEmitEvent() {
64
68
  try {
65
- const m = require('../sdk/event-stream');
66
- if (m && typeof m.appendEvent === 'function') return m.appendEvent;
69
+ const m = require('./_hook-emit.js');
70
+ if (m && typeof m.emitEvent === 'function') return m.emitEvent;
67
71
  } catch {
68
- /* swallow — event-stream is optional infrastructure */
72
+ /* swallow — telemetry is optional infrastructure */
69
73
  }
70
- return function noopAppend(_ev) {
74
+ return function noopEmit(_ev) {
71
75
  /* no-op */
72
76
  };
73
77
  }
@@ -87,9 +91,12 @@ function readStateMd(paths) {
87
91
  }
88
92
 
89
93
  const frontmatter = {};
90
- const fmMatch = body.match(/^---\n([\s\S]*?)\n---\n/);
94
+ // Tolerate CRLF line endings — the STATE.md mutator preserves CRLF, so a
95
+ // strict `\n`-only anchor fails to match the frontmatter block on Windows
96
+ // checkouts and the recap silently reports an empty cycle/decisions diff.
97
+ const fmMatch = body.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
91
98
  if (fmMatch) {
92
- for (const line of fmMatch[1].split('\n')) {
99
+ for (const line of fmMatch[1].split(/\r?\n/)) {
93
100
  const m = line.match(/^(\w+):\s*(.+)$/);
94
101
  if (m) frontmatter[m[1]] = m[2].trim();
95
102
  }
@@ -273,9 +280,9 @@ async function main() {
273
280
  }
274
281
 
275
282
  // Best-effort event emit.
276
- const appendEvent = getAppendEvent();
283
+ const emitEvent = getEmitEvent();
277
284
  try {
278
- appendEvent({
285
+ emitEvent({
279
286
  type: 'recap.emitted',
280
287
  timestamp: new Date().toISOString(),
281
288
  sessionId: process.env.GDD_SESSION_ID || 'sessionstart-hook',
@@ -300,9 +307,11 @@ async function main() {
300
307
  process.exit(0);
301
308
  }
302
309
 
303
- try {
304
- main();
305
- } catch (err) {
310
+ // `main` is async: a sync try/catch cannot observe a rejected promise, so a
311
+ // throw inside an `await` boundary would escape as an unhandled rejection and
312
+ // exit non-zero — violating the silent-exit-0 contract for SessionStart hooks.
313
+ // Attach `.catch` so every failure mode is swallowed and we exit 0.
314
+ main().catch((err) => {
306
315
  try {
307
316
  process.stderr.write(
308
317
  '[gdd-sessionstart-recap] uncaught: ' +
@@ -313,4 +322,4 @@ try {
313
322
  /* swallow */
314
323
  }
315
324
  process.exit(0);
316
- }
325
+ });
package/hooks/hooks.json CHANGED
@@ -45,7 +45,7 @@
45
45
  ],
46
46
  "PreToolUse": [
47
47
  {
48
- "matcher": "Agent",
48
+ "matcher": "Task|Agent",
49
49
  "hooks": [
50
50
  {
51
51
  "type": "command",
@@ -119,7 +119,7 @@
119
119
  ]
120
120
  },
121
121
  {
122
- "matcher": "Agent",
122
+ "matcher": "Task|Agent",
123
123
  "hooks": [
124
124
  {
125
125
  "type": "command",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hegemonart/get-design-done",
3
- "version": "1.59.6",
3
+ "version": "1.59.8",
4
4
  "description": "A design-quality pipeline for AI coding agents: brief, explore, plan, design, and verify UI work against your design system.",
5
5
  "author": "Hegemon",
6
6
  "homepage": "https://github.com/hegemonart/get-design-done",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "license": "MIT",
12
12
  "engines": {
13
- "node": ">=22"
13
+ "node": ">=22.6.0"
14
14
  },
15
15
  "files": [
16
16
  ".claude-plugin/",
@@ -10,7 +10,7 @@ description: Bandit posterior + production-integration shim cheat sheet - signat
10
10
 
11
11
  **Phase 27.5 (v1.27.5).** Reference for the bandit production-integration surface. Authoring or modifying a caller of the bandit posterior? Debugging a routing decision at the code level? Start here.
12
12
 
13
- For ops-level guidance (when bandit fires, how to disable, posterior inspection), see `docs/BANDIT-INTEGRATION.md`.
13
+ For ops-level guidance (when bandit fires, how to disable, posterior inspection), use the read-only diagnostic surfaces: `/gdd:bandit-status` (per-arm posterior snapshots) and `/gdd:bandit-reset` (confirm-then-reset). The `adaptive_mode` gate below covers enable/disable.
14
14
 
15
15
  In-scope modules:
16
16
 
@@ -104,6 +104,17 @@ Phase 27.5 passes `wallTimeMs: 0` always (D-08 unchanged from Phase 23.5).
104
104
 
105
105
  ---
106
106
 
107
+ ## Where adaptive routing actually learns
108
+
109
+ This is a deliberate design boundary, not a bug - read it before assuming the bandit "learns" in every runtime.
110
+
111
+ - **The posterior is updated only on the SDK / headless path.** `recordOutcome` (the learning update that moves `alpha`/`beta`) is called from `scripts/lib/session-runner/index.ts` after a session terminates. That path runs in the SDK / headless `session-runner` execution model. It is the only place a reward is folded back into the posterior.
112
+ - **In interactive Claude Code with `adaptive_mode: full`, the bandit samples but does not currently learn from in-session outcomes.** When a plugin/interactive run consults the bandit, `consultBandit` performs a Thompson sample from the *configured priors* (and whatever the SDK path has already written), and `pull()` bumps `last_used` + `count` - but no `recordOutcome` fires from an interactive Claude Code hook, so the success/fail posterior does not move within the interactive session. With an un-seeded posterior, sampling therefore reflects the informed `TIER_PRIOR` (which leans toward the higher tiers, e.g. opus). Wiring `recordOutcome` into an interactive hook is intentionally out of scope for this phase.
113
+ - **`adaptive_mode` defaults to `static` - the feature is opt-in.** Per `scripts/lib/adaptive-mode.cjs`, the default mode is `static`, in which the bandit is fully silent (no reads, no writes) and `default-tier:` is authoritative. Adaptive routing only engages when an operator explicitly sets `adaptive_mode: full` in `.design/budget.json`.
114
+ - **Contextual dimensions are supplied by the caller, not inferred here.** The `bin` (glob-count bucket via `binForGlobCount`) and `delegate` dimensions are passed in at the call site; the router does not derive them from ambient session state.
115
+
116
+ Net: enable `adaptive_mode: full` and run the SDK/headless `session-runner` path to accumulate a posterior that genuinely reflects observed outcomes. In interactive Claude Code, `full` mode gives you prior-driven Thompson sampling, not in-session reinforcement.
117
+
107
118
  ## `adaptive_mode` gate semantics
108
119
 
109
120
  Phase 23.5 ladder (D-07):
@@ -154,7 +165,7 @@ Phase 27.5 wires these consumers:
154
165
 
155
166
  ## Cross-references
156
167
 
157
- - `docs/BANDIT-INTEGRATION.md` - operator guide (when bandit fires, how to disable, troubleshooting).
168
+ - `/gdd:bandit-status` + `/gdd:bandit-reset` - read-only operator surfaces (when bandit fires, posterior inspection, reset). Disable/enable is the `adaptive_mode` gate in `.design/budget.json` (see above).
158
169
  - `reference/peer-protocols.md` - Phase 27 ACP/ASP cheat sheet (peer-CLI delegation transport).
159
170
  - `scripts/lib/bandit-router.cjs` - Phase 23.5 primitives surface.
160
171
  - `scripts/lib/bandit-router/integration.cjs` - Phase 27.5 production shim.
@@ -148,6 +148,14 @@ function filesEqual(a, b) {
148
148
  }
149
149
  }
150
150
 
151
+ /**
152
+ * Network timeout (ms) for the git clone/pull. SessionStart hooks must never
153
+ * block the harness: without a timeout, a hung network connection would stall
154
+ * the whole session-start sequence indefinitely. spawnSync kills the child
155
+ * with `killSignal` once this elapses and reports it as a failure.
156
+ */
157
+ const GIT_TIMEOUT_MS = 15000;
158
+
151
159
  /**
152
160
  * Match the .sh `clone_or_update`:
153
161
  * - target/.git exists → `git -C target pull --quiet --ff-only`, log on fail
@@ -157,8 +165,14 @@ function filesEqual(a, b) {
157
165
  * We invoke the `git` CLI directly via spawnSync. spawnSync('git', …) is fine —
158
166
  * the prohibition is on spawnSync('bash', …).
159
167
  *
168
+ * Returns true ONLY when the repo is in a good post-condition (pull/clone
169
+ * succeeded, or a pre-existing non-git dir we intentionally skip). Returns
170
+ * false when a network op failed or timed out — so the caller can withhold the
171
+ * success marker and retry next session instead of recording failure as done.
172
+ *
160
173
  * @param {string} repoUrl
161
174
  * @param {string} target
175
+ * @returns {boolean} success
162
176
  */
163
177
  function cloneOrUpdate(repoUrl, target) {
164
178
  let isGitCheckout = false;
@@ -177,16 +191,22 @@ function cloneOrUpdate(repoUrl, target) {
177
191
  const r = spawnSync('git', ['-C', target, 'pull', '--quiet', '--ff-only'], {
178
192
  stdio: ['ignore', 'ignore', 'ignore'],
179
193
  windowsHide: true,
194
+ timeout: GIT_TIMEOUT_MS,
195
+ killSignal: 'SIGKILL',
180
196
  });
181
197
  if (r.error || r.status !== 0) {
182
- log(`pull failed for ${target} (continuing)`);
198
+ const why = r.error && r.error.code === 'ETIMEDOUT' ? 'timed out' : 'failed';
199
+ log(`pull ${why} for ${target} (continuing)`);
200
+ return false;
183
201
  }
184
- return;
202
+ return true;
185
203
  }
186
204
 
187
205
  if (targetExists) {
188
206
  log(`${target} exists and is not a git checkout — skipping`);
189
- return;
207
+ // A pre-existing non-git dir is a stable post-condition, not a failure:
208
+ // re-running won't change it, so don't force a retry every session.
209
+ return true;
190
210
  }
191
211
 
192
212
  // Defense in depth: refuse repoUrl / target arguments that look like git
@@ -196,7 +216,7 @@ function cloneOrUpdate(repoUrl, target) {
196
216
  if (typeof repoUrl !== 'string' || repoUrl.startsWith('-') ||
197
217
  typeof target !== 'string' || target.startsWith('-')) {
198
218
  log(`refusing suspicious clone args for ${repoUrl} -> ${target}`);
199
- return;
219
+ return false;
200
220
  }
201
221
 
202
222
  log(`cloning ${repoUrl} -> ${target}`);
@@ -205,10 +225,15 @@ function cloneOrUpdate(repoUrl, target) {
205
225
  const r = spawnSync('git', ['clone', '--quiet', '--depth', '1', '--', repoUrl, target], {
206
226
  stdio: ['ignore', 'ignore', 'ignore'],
207
227
  windowsHide: true,
228
+ timeout: GIT_TIMEOUT_MS,
229
+ killSignal: 'SIGKILL',
208
230
  });
209
231
  if (r.error || r.status !== 0) {
210
- log(`clone failed for ${repoUrl}`);
232
+ const why = r.error && r.error.code === 'ETIMEDOUT' ? 'timed out' : 'failed';
233
+ log(`clone ${why} for ${repoUrl}`);
234
+ return false;
211
235
  }
236
+ return true;
212
237
  }
213
238
 
214
239
  /**
@@ -315,7 +340,7 @@ function run(opts = {}) {
315
340
  }
316
341
 
317
342
  // Required library: VoltAgent/awesome-design-md.
318
- cloneOrUpdate(
343
+ const repoOk = cloneOrUpdate(
319
344
  'https://github.com/VoltAgent/awesome-design-md.git',
320
345
  ctx.awesomeRepoTarget
321
346
  );
@@ -332,8 +357,15 @@ function run(opts = {}) {
332
357
  // Phase 10.1: .design/budget.json + .design/telemetry/ (D-12).
333
358
  ensureDesignDir(cwd);
334
359
 
335
- // Record success so we don't re-run until the bundled manifest changes.
336
- copyManifestToMarker(ctx.manifest, ctx.marker);
360
+ // Record success ONLY when the network provisioning actually succeeded.
361
+ // Writing the marker unconditionally records a failed clone as "done" and
362
+ // never retries — leaving the required library permanently absent. Gating on
363
+ // repoOk means a transient network failure/timeout is retried next session.
364
+ if (repoOk) {
365
+ copyManifestToMarker(ctx.manifest, ctx.marker);
366
+ } else {
367
+ log('skipping success marker — provisioning incomplete, will retry next session');
368
+ }
337
369
 
338
370
  return 0;
339
371
  }
@@ -211,6 +211,28 @@ async function main() {
211
211
  }
212
212
  runtimes = picked.runtimes;
213
213
  if (picked.location) location = picked.location;
214
+ } else if (uninstall) {
215
+ // B4 fix (Phase 59.8): bare `--uninstall` in a non-TTY context must NOT
216
+ // silently default to removing claude. The interactive path is the only
217
+ // safe way to pick what to remove without an explicit flag; in non-TTY
218
+ // we refuse and require an explicit runtime flag so a scripted/CI
219
+ // invocation can never destroy an install the operator didn't name.
220
+ // (See the comment at shouldUseInteractive: bare --uninstall is meant to
221
+ // trigger the interactive select-which-to-remove flow.)
222
+ process.stderr.write(
223
+ [
224
+ 'Refusing to uninstall: no runtime specified and not running in an',
225
+ 'interactive terminal.',
226
+ '',
227
+ 'Re-run with an explicit runtime flag, e.g.:',
228
+ ' npx @hegemonart/get-design-done --uninstall --claude',
229
+ ' npx @hegemonart/get-design-done --uninstall --all',
230
+ '',
231
+ 'Run with --help to list available runtime flags.',
232
+ '',
233
+ ].join('\n'),
234
+ );
235
+ process.exit(2);
214
236
  } else {
215
237
  // Non-TTY zero-flag fallback: back-compat with v1.23.5 behaviour.
216
238
  runtimes = ['claude'];
@@ -359,7 +381,7 @@ async function maybeNudgePeerCli({ flags }) {
359
381
  '✓ Detected peer CLIs: ' + detectedDisplay,
360
382
  '',
361
383
  'gdd v1.27.0 introduced optional peer-CLI delegation. With your',
362
- 'agents\\u2019 frontmatter `delegate_to:` set, gdd can route specific',
384
+ "agents' frontmatter `delegate_to:` set, gdd can route specific",
363
385
  'roles through these peer CLIs (cost or quality wins per Phase 23.5',
364
386
  'bandit). You can change this anytime via .design/config.json.',
365
387
  '',