@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +55 -0
- package/README.md +4 -13
- package/SKILL.md +1 -1
- package/agents/design-authority-watcher.md +24 -5
- package/bin/gdd-graph +4 -1
- package/docs/i18n/README.de.md +210 -527
- package/docs/i18n/README.fr.md +201 -518
- package/docs/i18n/README.it.md +209 -526
- package/docs/i18n/README.ja.md +207 -524
- package/docs/i18n/README.ko.md +208 -525
- package/docs/i18n/README.zh-CN.md +213 -551
- package/hooks/_hook-emit.js +113 -29
- package/hooks/budget-enforcer.ts +44 -5
- package/hooks/gdd-mcp-circuit-breaker.js +72 -3
- package/hooks/gdd-sessionstart-recap.js +23 -14
- package/hooks/hooks.json +2 -2
- package/package.json +2 -2
- package/reference/bandit-integration.md +13 -2
- package/scripts/bootstrap.cjs +40 -8
- package/scripts/install.cjs +23 -1
- package/scripts/lib/bandit-router.cjs +47 -5
- package/scripts/lib/detect/cli.cjs +13 -3
- package/scripts/lib/install/converters/cursor.cjs +11 -19
- package/scripts/lib/install/doctor-codex-plugin.cjs +1 -1
- package/scripts/lib/install/doctor-cursor-marketplace.cjs +2 -2
- package/scripts/lib/install/installer.cjs +72 -21
- package/scripts/lib/install/merge.cjs +31 -3
- package/scripts/lib/install/runtime-artifact-layout.cjs +42 -8
- package/scripts/lib/manifest/harnesses.json +29 -1
- package/scripts/lib/manifest/skills.json +1 -1
- package/scripts/skill-templates/bandit-reset/SKILL.md +2 -0
- package/scripts/skill-templates/bandit-status/SKILL.md +4 -1
- package/scripts/skill-templates/darkmode/SKILL.md +1 -1
- package/scripts/skill-templates/graphify/SKILL.md +6 -6
- package/scripts/skill-templates/quick/SKILL.md +3 -1
- package/scripts/skill-templates/reflect/SKILL.md +1 -1
- package/scripts/skill-templates/router/SKILL.md +4 -2
- package/sdk/cli/index.js +114 -47
- package/sdk/dashboard/data/source.cjs +50 -4
- package/sdk/event-stream/writer.ts +112 -30
- package/sdk/mcp/gdd-mcp/server.js +49 -36
- package/sdk/mcp/gdd-mcp/tools/shared.ts +20 -2
- package/sdk/mcp/gdd-state/server.js +107 -41
- package/sdk/primitives/lockfile.cjs +26 -5
- package/sdk/state/index.ts +91 -17
- package/sdk/state/lockfile.ts +47 -8
- package/skills/bandit-reset/SKILL.md +2 -0
- package/skills/bandit-status/SKILL.md +4 -1
- package/skills/darkmode/SKILL.md +1 -1
- package/skills/graphify/SKILL.md +6 -6
- package/skills/quick/SKILL.md +3 -1
- package/skills/reflect/SKILL.md +1 -1
- package/skills/router/SKILL.md +4 -2
package/hooks/_hook-emit.js
CHANGED
|
@@ -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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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 (
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
60
|
-
* @param {string} decision
|
|
61
|
-
* @param {Record<string, unknown>} [extras] — opaque additional payload fields
|
|
90
|
+
* @returns {string}
|
|
62
91
|
*/
|
|
63
|
-
function
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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 };
|
package/hooks/budget-enforcer.ts
CHANGED
|
@@ -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
|
-
|
|
737
|
-
|
|
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:
|
|
1127
|
+
continue: true,
|
|
1094
1128
|
suppressOutput: false,
|
|
1095
1129
|
message: `gdd-budget-enforcer: SkippedCached — returning cached result for ${agent}:${inputHash}`,
|
|
1096
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
67
|
+
function getEmitEvent() {
|
|
64
68
|
try {
|
|
65
|
-
const m = require('
|
|
66
|
-
if (m && typeof m.
|
|
69
|
+
const m = require('./_hook-emit.js');
|
|
70
|
+
if (m && typeof m.emitEvent === 'function') return m.emitEvent;
|
|
67
71
|
} catch {
|
|
68
|
-
/* swallow —
|
|
72
|
+
/* swallow — telemetry is optional infrastructure */
|
|
69
73
|
}
|
|
70
|
-
return function
|
|
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
|
-
|
|
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(
|
|
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
|
|
283
|
+
const emitEvent = getEmitEvent();
|
|
277
284
|
try {
|
|
278
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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.
|
|
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),
|
|
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
|
-
- `
|
|
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.
|
package/scripts/bootstrap.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
336
|
-
|
|
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
|
}
|
package/scripts/install.cjs
CHANGED
|
@@ -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
|
-
'
|
|
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
|
'',
|