@hegemonart/get-design-done 1.57.0 → 1.57.2
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 +26 -41
- package/.claude-plugin/plugin.json +23 -48
- package/CHANGELOG.md +119 -0
- package/README.md +166 -511
- package/SKILL.md +2 -0
- package/agents/README.md +33 -36
- package/agents/a11y-mapper.md +3 -3
- package/agents/component-benchmark-harvester.md +6 -6
- package/agents/component-benchmark-synthesizer.md +3 -3
- package/agents/compose-executor.md +3 -3
- package/agents/cost-forecaster.md +2 -2
- package/agents/design-auditor.md +7 -7
- package/agents/design-authority-watcher.md +15 -15
- package/agents/design-context-builder.md +4 -4
- package/agents/design-context-checker-gate.md +1 -1
- package/agents/design-discussant.md +2 -2
- package/agents/design-doc-writer.md +1 -1
- package/agents/design-executor.md +2 -2
- package/agents/design-figma-writer.md +2 -2
- package/agents/design-fixer.md +7 -7
- package/agents/design-integration-checker-gate.md +1 -1
- package/agents/design-integration-checker.md +1 -1
- package/agents/design-paper-writer.md +3 -3
- package/agents/design-pencil-writer.md +1 -1
- package/agents/design-planner.md +21 -0
- package/agents/design-reflector.md +39 -39
- package/agents/design-research-synthesizer.md +1 -0
- package/agents/design-start-writer.md +1 -1
- package/agents/design-update-checker.md +5 -5
- package/agents/design-verifier-gate.md +1 -1
- package/agents/design-verifier.md +52 -48
- package/agents/ds-generator.md +2 -2
- package/agents/ds-migration-planner.md +4 -4
- package/agents/email-executor.md +9 -9
- package/agents/experiment-result-ingester.md +3 -3
- package/agents/flutter-executor.md +5 -5
- package/agents/gdd-graph-refresh.md +3 -3
- package/agents/gdd-intel-updater.md +2 -2
- package/agents/motion-mapper.md +2 -2
- package/agents/motion-verifier.md +4 -4
- package/agents/pdf-executor.md +8 -8
- package/agents/perf-analyzer.md +17 -17
- package/agents/pr-commenter.md +9 -9
- package/agents/prototype-gate.md +2 -2
- package/agents/quality-gate-runner.md +1 -1
- package/agents/rollout-coordinator.md +3 -3
- package/agents/swift-executor.md +4 -4
- package/agents/ticket-sync-agent.md +6 -6
- package/agents/user-research-synthesizer.md +2 -2
- package/connections/connections.md +44 -45
- package/connections/cursor.md +73 -0
- package/connections/preview.md +3 -3
- package/dist/claude-code/.claude/skills/cache-manager/SKILL.md +3 -3
- package/dist/claude-code/.claude/skills/cache-manager/cache-policy.md +1 -1
- package/dist/claude-code/.claude/skills/design/SKILL.md +19 -0
- package/dist/claude-code/.claude/skills/explore/SKILL.md +11 -0
- package/dist/claude-code/.claude/skills/figma-write/SKILL.md +13 -2
- package/dist/claude-code/.claude/skills/paper-write/SKILL.md +54 -0
- package/dist/claude-code/.claude/skills/pencil-write/SKILL.md +54 -0
- package/dist/claude-code/.claude/skills/report-issue/SKILL.md +2 -2
- package/dist/claude-code/.claude/skills/router/SKILL.md +2 -2
- package/dist/claude-code/.claude/skills/verify/verify-procedure.md +10 -11
- package/dist/claude-code/.claude/skills/warm-cache/SKILL.md +1 -1
- package/hooks/budget-enforcer.ts +5 -4
- package/hooks/first-run-nudge.cjs +171 -0
- package/hooks/gdd-intel-trigger.js +243 -0
- package/hooks/gdd-mcp-circuit-breaker.js +62 -7
- package/hooks/gdd-precompact-snapshot.js +50 -29
- package/hooks/gdd-protected-paths.js +175 -20
- package/hooks/gdd-read-injection-scanner.ts +9 -1
- package/hooks/gdd-risk-gate.js +110 -8
- package/hooks/gdd-sessionstart-recap.js +59 -24
- package/hooks/hooks.json +13 -4
- package/hooks/inject-using-gdd.cjs +188 -0
- package/hooks/update-check.cjs +511 -0
- package/package.json +9 -2
- package/reference/STATE-TEMPLATE.md +10 -13
- package/reference/audit-scoring.md +1 -1
- package/reference/cache-tier-doctrine.md +46 -0
- package/reference/config-schema.md +9 -9
- package/reference/i18n.md +1 -1
- package/reference/intel-schema.md +37 -2
- package/reference/meta-rules.md +4 -4
- package/reference/model-tiers.md +2 -2
- package/reference/registry.json +101 -94
- package/reference/runtime-models.md +11 -1
- package/reference/shared-preamble.md +13 -14
- package/reference/skill-graph.md +24 -1
- package/scripts/bootstrap.cjs +373 -0
- package/scripts/injection-patterns.cjs +58 -0
- package/scripts/lib/apply-reflections/incubator-proposals.cjs +57 -26
- package/scripts/lib/design-search.cjs +20 -2
- package/scripts/lib/install/converters/codex-plugin.cjs +5 -2
- package/scripts/lib/install/converters/cursor.cjs +20 -0
- package/scripts/lib/issue-reporter/report-flow.cjs +1 -1
- package/scripts/lib/manifest/skills.json +80 -13
- package/scripts/lib/state/migrate-to-sqlite.cjs +23 -7
- package/scripts/lib/state/query-surface.cjs +86 -16
- package/scripts/lib/state/render-markdown.cjs +26 -14
- package/scripts/lib/state/state-store.cjs +141 -68
- package/sdk/cli/commands/stage.ts +17 -0
- package/sdk/cli/index.js +21 -1
- package/sdk/dashboard/data/_pkg-root.cjs +4 -4
- package/sdk/dashboard/data/risk-surface.cjs +54 -19
- package/sdk/dashboard/tui/index.cjs +28 -2
- package/sdk/mcp/gdd-state/server.js +7 -1
- package/sdk/state/index.ts +11 -1
- package/skills/cache-manager/SKILL.md +3 -3
- package/skills/cache-manager/cache-policy.md +1 -1
- package/skills/design/SKILL.md +19 -0
- package/skills/explore/SKILL.md +11 -0
- package/skills/figma-write/SKILL.md +13 -2
- package/skills/paper-write/SKILL.md +54 -0
- package/skills/pencil-write/SKILL.md +54 -0
- package/skills/report-issue/SKILL.md +2 -2
- package/skills/router/SKILL.md +2 -2
- package/skills/verify/verify-procedure.md +10 -11
- package/skills/warm-cache/SKILL.md +1 -1
- package/hooks/first-run-nudge.sh +0 -82
- package/hooks/inject-using-gdd.sh +0 -72
- package/hooks/update-check.sh +0 -251
- package/scripts/lib/audit-aggregator/index.cjs +0 -219
- package/scripts/lib/hedge-ensemble.cjs +0 -217
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* get-design-done — first-run nudge (Phase 14.7)
|
|
6
|
+
*
|
|
7
|
+
* Port of hooks/first-run-nudge.sh to pure Node CommonJS (Phase 28.x).
|
|
8
|
+
* SessionStart hook. Silent-on-failure by policy: exits 0 on every error path.
|
|
9
|
+
* Prints exactly one restrained line pointing at /gdd:start when all gates
|
|
10
|
+
* pass, and nothing otherwise.
|
|
11
|
+
*
|
|
12
|
+
* Non-obvious behavior preserved:
|
|
13
|
+
* - Logger is silent unless GDD_NUDGE_DEBUG=1 (matches bash `${VAR:-0}`).
|
|
14
|
+
* - HOME falls back to USERPROFILE (Windows). Mirrors bash `${HOME:-$USERPROFILE}`.
|
|
15
|
+
* - read_state_stage uses the same regex shape as the bash sed: drops an
|
|
16
|
+
* optional surrounding double-quote and stops at the first whitespace.
|
|
17
|
+
* - has_recent_gdd_command is a placeholder that always returns false (matches
|
|
18
|
+
* the bash `return 1` → `is_active` boolean false).
|
|
19
|
+
* - Sourcing guard: helpers are exported on module.exports; main() only runs
|
|
20
|
+
* when invoked as the entry point (require.main === module).
|
|
21
|
+
* - Always exit 0 — every error path is swallowed.
|
|
22
|
+
* - The locked nudge copy appears exactly once in this file (test asserts
|
|
23
|
+
* the nudge string occurs exactly once in the hook source).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
|
|
29
|
+
const NUDGE_LINE =
|
|
30
|
+
'Tip: run /gdd:start to let GDD inspect this codebase and suggest one first fix.\n';
|
|
31
|
+
|
|
32
|
+
function log(msg) {
|
|
33
|
+
if (process.env.GDD_NUDGE_DEBUG === '1') {
|
|
34
|
+
process.stderr.write(`[gdd first-run-nudge] ${msg}\n`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Gate 1 — repo already has GDD state, suppress.
|
|
40
|
+
* @param {string} designDir absolute path to <cwd>/.design
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
function hasDesignState(designDir) {
|
|
44
|
+
try {
|
|
45
|
+
const config = path.join(designDir, 'config.json');
|
|
46
|
+
const state = path.join(designDir, 'STATE.md');
|
|
47
|
+
return isFile(config) || isFile(state);
|
|
48
|
+
} catch (_e) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Gate 2 — per-install dismissal flag.
|
|
55
|
+
* @param {string} homeDir resolved HOME (or USERPROFILE on Windows)
|
|
56
|
+
* @returns {boolean}
|
|
57
|
+
*/
|
|
58
|
+
function isDismissed(homeDir) {
|
|
59
|
+
try {
|
|
60
|
+
if (!homeDir) return false;
|
|
61
|
+
return isFile(path.join(homeDir, '.claude', 'gdd-nudge-dismissed'));
|
|
62
|
+
} catch (_e) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Reads the first `stage:` line out of STATE.md and strips surrounding quoting
|
|
69
|
+
* the way the bash sed expression does (drops an optional surrounding double
|
|
70
|
+
* quote, captures non-quote/non-whitespace chars, ignores any trailing text).
|
|
71
|
+
*
|
|
72
|
+
* @param {string} stateFilePath absolute path to STATE.md
|
|
73
|
+
* @returns {string} the captured stage value, or '' if unavailable
|
|
74
|
+
*/
|
|
75
|
+
function readStateStage(stateFilePath) {
|
|
76
|
+
try {
|
|
77
|
+
if (!isFile(stateFilePath)) return '';
|
|
78
|
+
const text = fs.readFileSync(stateFilePath, 'utf8');
|
|
79
|
+
const lines = text.split(/\r?\n/);
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
if (/^stage:/.test(line)) {
|
|
82
|
+
const m = line.match(/^stage:[ \t]*"?([^"\s]+)"?.*/);
|
|
83
|
+
if (m && m[1]) return m[1];
|
|
84
|
+
return '';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return '';
|
|
88
|
+
} catch (_e) {
|
|
89
|
+
return '';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const ACTIVE_STAGES = new Set(['plan', 'design', 'verify', 'executing', 'discussing']);
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Gate 3 — STATE.md stage belongs to an active pipeline window.
|
|
97
|
+
* @param {string} stateFilePath absolute path to STATE.md
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
function isActiveStage(stateFilePath) {
|
|
101
|
+
const s = readStateStage(stateFilePath);
|
|
102
|
+
return ACTIVE_STAGES.has(s);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Gate 4 — recent session history has a gdd:* command.
|
|
107
|
+
* Placeholder: no portable transcript path exposed to SessionStart hooks today.
|
|
108
|
+
* Mirrors the bash version's `return 1` (false) so we never falsely suppress.
|
|
109
|
+
* @returns {boolean}
|
|
110
|
+
*/
|
|
111
|
+
function hasRecentGddCommand() {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isFile(p) {
|
|
116
|
+
try {
|
|
117
|
+
const st = fs.statSync(p);
|
|
118
|
+
return st.isFile();
|
|
119
|
+
} catch (_e) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function resolveHomeDir() {
|
|
125
|
+
// bash: ${HOME:-$USERPROFILE} — HOME wins if set (even on Windows), else USERPROFILE.
|
|
126
|
+
return process.env.HOME || process.env.USERPROFILE || '';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function main() {
|
|
130
|
+
try {
|
|
131
|
+
const cwd = process.cwd();
|
|
132
|
+
const designDir = path.join(cwd, '.design');
|
|
133
|
+
const stateFile = path.join(designDir, 'STATE.md');
|
|
134
|
+
const homeDir = resolveHomeDir();
|
|
135
|
+
|
|
136
|
+
if (hasDesignState(designDir)) {
|
|
137
|
+
log('design state present — suppress');
|
|
138
|
+
process.exit(0);
|
|
139
|
+
}
|
|
140
|
+
if (isDismissed(homeDir)) {
|
|
141
|
+
log('dismissal flag present — suppress');
|
|
142
|
+
process.exit(0);
|
|
143
|
+
}
|
|
144
|
+
if (isActiveStage(stateFile)) {
|
|
145
|
+
log('active stage — suppress');
|
|
146
|
+
process.exit(0);
|
|
147
|
+
}
|
|
148
|
+
if (hasRecentGddCommand()) {
|
|
149
|
+
log('recent gdd:* command detected — suppress');
|
|
150
|
+
process.exit(0);
|
|
151
|
+
}
|
|
152
|
+
// All gates passed — emit the locked one-line nudge.
|
|
153
|
+
process.stdout.write(NUDGE_LINE);
|
|
154
|
+
process.exit(0);
|
|
155
|
+
} catch (_e) {
|
|
156
|
+
// Silent-on-failure: every error path exits 0.
|
|
157
|
+
process.exit(0);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = {
|
|
162
|
+
hasDesignState,
|
|
163
|
+
isDismissed,
|
|
164
|
+
readStateStage,
|
|
165
|
+
isActiveStage,
|
|
166
|
+
hasRecentGddCommand,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
if (require.main === module) {
|
|
170
|
+
main();
|
|
171
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* hooks/gdd-intel-trigger.js — D5 (PostToolUse on Edit|Write)
|
|
5
|
+
*
|
|
6
|
+
* On every Edit/Write that touches a design-authoritative surface
|
|
7
|
+
* (skills/**, agents/**, reference/**, source/skills/**), spawn a
|
|
8
|
+
* background, detached refresh of the .design/intel/ store so downstream
|
|
9
|
+
* consumers (router, planner, audits) see the latest extracts without the
|
|
10
|
+
* user paying for a full rebuild on the next /gdd run.
|
|
11
|
+
*
|
|
12
|
+
* Contract:
|
|
13
|
+
* 1. Read the PostToolUse payload from stdin. Tolerate snake_case and
|
|
14
|
+
* camelCase field names (tool_name/toolName, tool_input/toolInput,
|
|
15
|
+
* file_path/filePath/path).
|
|
16
|
+
* 2. If the edited path matches
|
|
17
|
+
* ^(skills|agents|reference|source/skills)/.*\.(md|json)$
|
|
18
|
+
* (path-separator-agnostic), schedule a background refresh.
|
|
19
|
+
* 3. Otherwise no-op — write {continue:true} and exit 0.
|
|
20
|
+
* 4. Always exit 0. Never block. Never surface errors. Errors only ever
|
|
21
|
+
* land as a stderr breadcrumb (best-effort).
|
|
22
|
+
*
|
|
23
|
+
* Opt-out:
|
|
24
|
+
* Set GDD_DISABLE_INTEL_TRIGGER=1 to silence this hook completely
|
|
25
|
+
* (still writes {continue:true}, exits 0, spawns nothing).
|
|
26
|
+
*
|
|
27
|
+
* Dedup lock:
|
|
28
|
+
* .design/.intel-trigger.lock — a JSON file with {ts: <epoch_ms>}.
|
|
29
|
+
* If the lock is younger than 5 minutes, we assume a refresh is already
|
|
30
|
+
* in flight (or recently ran) and skip spawning again. Rapid sequential
|
|
31
|
+
* edits coalesce into one background rebuild. The lock is best-effort:
|
|
32
|
+
* if the .design/ dir does not exist or we cannot read/write the lock,
|
|
33
|
+
* we still proceed (or still no-op safely).
|
|
34
|
+
*
|
|
35
|
+
* Refresh path:
|
|
36
|
+
* scripts/build-intel.cjs is the rebuilder. It has no `--incremental`
|
|
37
|
+
* flag (incremental is its DEFAULT behavior — invoking it without
|
|
38
|
+
* `--force` re-extracts only changed files via mtime/git-hash). The
|
|
39
|
+
* task spec said "if --incremental exists, spawn it; else emit a
|
|
40
|
+
* breadcrumb." Since the script DOES do incremental by default, we
|
|
41
|
+
* spawn it as `node scripts/build-intel.cjs` (no flags) and surface
|
|
42
|
+
* the convention in the breadcrumb so future maintainers know why
|
|
43
|
+
* no `--incremental` was passed. If the script is ever missing, we
|
|
44
|
+
* emit only a breadcrumb and continue.
|
|
45
|
+
*
|
|
46
|
+
* Spawn shape:
|
|
47
|
+
* child_process.spawn('node', [script], { detached: true,
|
|
48
|
+
* stdio: 'ignore', windowsHide: true }) followed by child.unref().
|
|
49
|
+
* This decouples the child from our process tree so the hook returns
|
|
50
|
+
* immediately and the rebuild happens out of band.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
const fs = require('node:fs');
|
|
54
|
+
const path = require('node:path');
|
|
55
|
+
const { spawn } = require('node:child_process');
|
|
56
|
+
|
|
57
|
+
const LOCK_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
58
|
+
const TARGET_RE = /^(?:skills|agents|reference|source\/skills)\/.*\.(?:md|json)$/;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Extract the edited file path + tool name from a PostToolUse payload.
|
|
62
|
+
* Returns { tool, filename } or null when nothing usable was found.
|
|
63
|
+
*/
|
|
64
|
+
function extractTarget(payload) {
|
|
65
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
66
|
+
const tool = payload.tool_name || payload.toolName;
|
|
67
|
+
if (tool !== 'Write' && tool !== 'Edit') return null;
|
|
68
|
+
const input = payload.tool_input || payload.toolInput || {};
|
|
69
|
+
const filename =
|
|
70
|
+
input.file_path ||
|
|
71
|
+
input.filePath ||
|
|
72
|
+
input.path ||
|
|
73
|
+
(payload.tool_response &&
|
|
74
|
+
(payload.tool_response.filePath || payload.tool_response.file_path)) ||
|
|
75
|
+
'';
|
|
76
|
+
if (!filename) return null;
|
|
77
|
+
return { tool, filename: String(filename) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Decide whether the given (absolute or relative) filename, considered
|
|
82
|
+
* relative to `cwd`, lives under one of the design-authoritative roots
|
|
83
|
+
* and is .md or .json. Returns true/false. Path-separator-agnostic.
|
|
84
|
+
*/
|
|
85
|
+
function isDesignSurface(filename, cwd) {
|
|
86
|
+
if (!filename) return false;
|
|
87
|
+
let rel;
|
|
88
|
+
try {
|
|
89
|
+
rel = path.isAbsolute(filename)
|
|
90
|
+
? path.relative(cwd, filename)
|
|
91
|
+
: filename;
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
if (!rel || rel.startsWith('..')) return false;
|
|
96
|
+
const normalised = rel.replace(/\\/g, '/');
|
|
97
|
+
return TARGET_RE.test(normalised);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Best-effort: returns true if the lockfile exists AND its timestamp is
|
|
102
|
+
* younger than LOCK_TTL_MS. False on any error (missing dir, parse fail,
|
|
103
|
+
* stat fail) — fail-open so we still trigger the rebuild.
|
|
104
|
+
*/
|
|
105
|
+
function lockIsFresh(lockPath) {
|
|
106
|
+
try {
|
|
107
|
+
if (!fs.existsSync(lockPath)) return false;
|
|
108
|
+
const raw = fs.readFileSync(lockPath, 'utf8');
|
|
109
|
+
const parsed = JSON.parse(raw);
|
|
110
|
+
const ts = Number(parsed && parsed.ts);
|
|
111
|
+
if (!Number.isFinite(ts)) return false;
|
|
112
|
+
return Date.now() - ts < LOCK_TTL_MS;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Best-effort: write {ts: Date.now()} to the lockfile, ensuring its
|
|
120
|
+
* parent dir exists. Swallows all errors — locking is purely an
|
|
121
|
+
* optimisation; failure to lock just means the next edit may re-trigger.
|
|
122
|
+
*/
|
|
123
|
+
function writeLock(lockPath) {
|
|
124
|
+
try {
|
|
125
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
126
|
+
fs.writeFileSync(lockPath, JSON.stringify({ ts: Date.now() }), 'utf8');
|
|
127
|
+
} catch {
|
|
128
|
+
/* swallow */
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Spawn the intel rebuild as a detached background process. The child is
|
|
134
|
+
* fully decoupled (stdio:'ignore', detached:true, unref()) so the hook
|
|
135
|
+
* returns immediately. Errors are swallowed — the worst case is a stale
|
|
136
|
+
* intel store, which is no worse than the pre-hook baseline.
|
|
137
|
+
*/
|
|
138
|
+
function spawnRebuild(cwd, script) {
|
|
139
|
+
try {
|
|
140
|
+
const child = spawn(process.execPath, [script], {
|
|
141
|
+
cwd,
|
|
142
|
+
detached: true,
|
|
143
|
+
stdio: 'ignore',
|
|
144
|
+
windowsHide: true,
|
|
145
|
+
env: process.env,
|
|
146
|
+
});
|
|
147
|
+
child.unref();
|
|
148
|
+
return true;
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Core hook entry. Returns the decision object to write to stdout.
|
|
156
|
+
* Always returns {continue: true}. Exported for unit testing.
|
|
157
|
+
*
|
|
158
|
+
* Optional `opts.spawnImpl` overrides the spawn-rebuild side effect
|
|
159
|
+
* (so tests can assert it was called without forking a real node).
|
|
160
|
+
*/
|
|
161
|
+
function main(payload, opts = {}) {
|
|
162
|
+
// Opt-out shortcut — read here (not at module top) so tests can flip the
|
|
163
|
+
// env between calls without re-requiring the module.
|
|
164
|
+
if (process.env.GDD_DISABLE_INTEL_TRIGGER === '1') {
|
|
165
|
+
return { continue: true };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const cwd = (payload && payload.cwd) || opts.cwd || process.cwd();
|
|
169
|
+
const target = extractTarget(payload);
|
|
170
|
+
if (!target) return { continue: true };
|
|
171
|
+
if (!isDesignSurface(target.filename, cwd)) return { continue: true };
|
|
172
|
+
|
|
173
|
+
const lockPath = path.join(cwd, '.design', '.intel-trigger.lock');
|
|
174
|
+
if (lockIsFresh(lockPath)) return { continue: true };
|
|
175
|
+
|
|
176
|
+
const script = path.join(cwd, 'scripts', 'build-intel.cjs');
|
|
177
|
+
if (!fs.existsSync(script)) {
|
|
178
|
+
// Follow-up: a missing rebuilder is not this hook's problem to fix.
|
|
179
|
+
try {
|
|
180
|
+
process.stderr.write(
|
|
181
|
+
'[gdd-intel-trigger] would refresh .design/intel/ if scripts/build-intel.cjs --incremental existed\n'
|
|
182
|
+
);
|
|
183
|
+
} catch {
|
|
184
|
+
/* swallow */
|
|
185
|
+
}
|
|
186
|
+
return { continue: true };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Lock first (idempotent if write fails), then spawn. Locking first
|
|
190
|
+
// means a sibling Edit racing this one will see a fresh lock and skip
|
|
191
|
+
// even if our spawn has not yet completed.
|
|
192
|
+
writeLock(lockPath);
|
|
193
|
+
const doSpawn = typeof opts.spawnImpl === 'function' ? opts.spawnImpl : spawnRebuild;
|
|
194
|
+
doSpawn(cwd, script);
|
|
195
|
+
|
|
196
|
+
return { continue: true };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** CLI entrypoint — read JSON from stdin, decide, write {continue:true}. */
|
|
200
|
+
async function run(stdin = process.stdin, stdout = process.stdout) {
|
|
201
|
+
let buf = '';
|
|
202
|
+
try {
|
|
203
|
+
for await (const chunk of stdin) buf += chunk;
|
|
204
|
+
} catch {
|
|
205
|
+
stdout.write(JSON.stringify({ continue: true }));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
let payload;
|
|
209
|
+
try {
|
|
210
|
+
payload = JSON.parse(buf || '{}');
|
|
211
|
+
} catch {
|
|
212
|
+
stdout.write(JSON.stringify({ continue: true }));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
let decision;
|
|
216
|
+
try {
|
|
217
|
+
decision = main(payload);
|
|
218
|
+
} catch {
|
|
219
|
+
decision = { continue: true };
|
|
220
|
+
}
|
|
221
|
+
stdout.write(JSON.stringify(decision || { continue: true }));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (require.main === module) {
|
|
225
|
+
run().catch(() => {
|
|
226
|
+
try {
|
|
227
|
+
process.stdout.write(JSON.stringify({ continue: true }));
|
|
228
|
+
} catch {
|
|
229
|
+
/* swallow */
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = {
|
|
235
|
+
main,
|
|
236
|
+
extractTarget,
|
|
237
|
+
isDesignSurface,
|
|
238
|
+
lockIsFresh,
|
|
239
|
+
writeLock,
|
|
240
|
+
spawnRebuild,
|
|
241
|
+
LOCK_TTL_MS,
|
|
242
|
+
TARGET_RE,
|
|
243
|
+
};
|
|
@@ -40,15 +40,70 @@ function loadBudget(cwd) {
|
|
|
40
40
|
return defaults;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Classify the outcome of an MCP tool call as 'success' | 'timeout' | 'error'.
|
|
45
|
+
*
|
|
46
|
+
* The previous implementation substring-matched 'timeout' / 'failed' against
|
|
47
|
+
* the ENTIRE JSON-stringified response. That fired false positives on legit
|
|
48
|
+
* successful payloads whose content happens to mention those words — e.g. a
|
|
49
|
+
* Figma node literally named "TimeoutBanner", or a summary line "2 of 5 nodes
|
|
50
|
+
* failed to update, retrying...". When the breaker false-positives, it
|
|
51
|
+
* advances consecutive_timeouts and eventually trips on healthy traffic.
|
|
52
|
+
*
|
|
53
|
+
* The fix: use the structured isError / is_error envelope as the primary
|
|
54
|
+
* signal. MCP tool results carry isError=true|false. Anything without an
|
|
55
|
+
* explicit error flag is treated as success — full stop. Only when the
|
|
56
|
+
* envelope says "error" do we then inspect the ERROR-message fields
|
|
57
|
+
* (content[*].text + error.message + error.code + top-level message) to
|
|
58
|
+
* decide between 'timeout' and generic 'error'. Arbitrary content text is
|
|
59
|
+
* never scanned.
|
|
60
|
+
*/
|
|
43
61
|
function classifyOutcome(toolResponse) {
|
|
44
62
|
if (!toolResponse || typeof toolResponse !== 'object') return 'error';
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return 'success';
|
|
63
|
+
|
|
64
|
+
// MCP standard envelope: isError (camelCase). Claude Code historically
|
|
65
|
+
// passes is_error (snake_case). Accept either; treat absence as success.
|
|
66
|
+
const isError =
|
|
67
|
+
toolResponse.isError === true || toolResponse.is_error === true;
|
|
68
|
+
|
|
69
|
+
if (!isError) return 'success';
|
|
70
|
+
|
|
71
|
+
// Error path: classify timeout vs generic by reading ONLY the dedicated
|
|
72
|
+
// error-message fields, not the entire payload.
|
|
73
|
+
const messageBits = [];
|
|
74
|
+
|
|
75
|
+
// content[] may be a string (legacy) or an array of {type,text} (spec).
|
|
76
|
+
if (typeof toolResponse.content === 'string') {
|
|
77
|
+
messageBits.push(toolResponse.content);
|
|
78
|
+
} else if (Array.isArray(toolResponse.content)) {
|
|
79
|
+
for (const c of toolResponse.content) {
|
|
80
|
+
if (c && typeof c.text === 'string') messageBits.push(c.text);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (toolResponse.error && typeof toolResponse.error === 'object') {
|
|
85
|
+
if (typeof toolResponse.error.message === 'string') {
|
|
86
|
+
messageBits.push(toolResponse.error.message);
|
|
87
|
+
}
|
|
88
|
+
if (typeof toolResponse.error.code === 'string') {
|
|
89
|
+
messageBits.push(toolResponse.error.code);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (typeof toolResponse.message === 'string') {
|
|
93
|
+
messageBits.push(toolResponse.message);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const combined = messageBits.join(' ').toLowerCase();
|
|
97
|
+
// \btimeout\b matches "timeout" and "request timeout"; \btimed?\s*out\b
|
|
98
|
+
// matches "timed out"; deadline exceeded is gRPC; etimedout is Node fs.
|
|
99
|
+
if (
|
|
100
|
+
/\btimeout\b|\btimed?\s*out\b|\bdeadline\s+exceeded\b|\betimedout\b/.test(
|
|
101
|
+
combined,
|
|
102
|
+
)
|
|
103
|
+
) {
|
|
104
|
+
return 'timeout';
|
|
105
|
+
}
|
|
106
|
+
return 'error';
|
|
52
107
|
}
|
|
53
108
|
|
|
54
109
|
function readJsonlTail(filePath) {
|
|
@@ -25,14 +25,27 @@
|
|
|
25
25
|
const fs = require('node:fs');
|
|
26
26
|
const path = require('node:path');
|
|
27
27
|
|
|
28
|
-
const SNAPSHOT_DIR = path.resolve(process.cwd(), '.design', 'snapshots');
|
|
29
|
-
const STATE_MD_PATH = path.resolve(process.cwd(), '.design', 'STATE.md');
|
|
30
|
-
const EVENTS_PATH = path.resolve(process.cwd(), '.design', 'telemetry', 'events.jsonl');
|
|
31
28
|
const RETENTION_COUNT = 10;
|
|
32
29
|
const EVENTS_TAIL_COUNT = 50;
|
|
33
30
|
const DECISIONS_TAIL_COUNT = 10;
|
|
34
31
|
const SCHEMA_VERSION = '1.0.0';
|
|
35
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the bundle of paths the hook reads/writes, anchored at `cwd`.
|
|
35
|
+
*
|
|
36
|
+
* Originally these resolved at module load via `process.cwd()`, which is
|
|
37
|
+
* the wrong anchor when Claude Code invokes the hook from a worktree
|
|
38
|
+
* (the harness's cwd at module load can differ from the project root).
|
|
39
|
+
* Resolving against `payload.cwd` matches how 8 sibling hooks already work.
|
|
40
|
+
*/
|
|
41
|
+
function computePaths(cwd) {
|
|
42
|
+
return {
|
|
43
|
+
snapshotDir: path.resolve(cwd, '.design', 'snapshots'),
|
|
44
|
+
stateMdPath: path.resolve(cwd, '.design', 'STATE.md'),
|
|
45
|
+
eventsPath: path.resolve(cwd, '.design', 'telemetry', 'events.jsonl'),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
// ---------------------------------------------------------------------------
|
|
37
50
|
// Harness detection (D-10)
|
|
38
51
|
// ---------------------------------------------------------------------------
|
|
@@ -66,13 +79,13 @@ function getAppendEvent() {
|
|
|
66
79
|
// STATE.md tolerant parser — extracts frontmatter + decisions + blockers
|
|
67
80
|
// ---------------------------------------------------------------------------
|
|
68
81
|
|
|
69
|
-
function readStateSections() {
|
|
70
|
-
if (!fs.existsSync(
|
|
82
|
+
function readStateSections(paths) {
|
|
83
|
+
if (!fs.existsSync(paths.stateMdPath)) {
|
|
71
84
|
return { frontmatter: {}, decisions: [], blockers: [], session: '' };
|
|
72
85
|
}
|
|
73
86
|
let body;
|
|
74
87
|
try {
|
|
75
|
-
body = fs.readFileSync(
|
|
88
|
+
body = fs.readFileSync(paths.stateMdPath, 'utf8');
|
|
76
89
|
} catch {
|
|
77
90
|
return { frontmatter: {}, decisions: [], blockers: [], session: '' };
|
|
78
91
|
}
|
|
@@ -124,11 +137,11 @@ function readStateSections() {
|
|
|
124
137
|
// Events tail reader — JSONL-tolerant (malformed lines are skipped)
|
|
125
138
|
// ---------------------------------------------------------------------------
|
|
126
139
|
|
|
127
|
-
function readEventsTail(count) {
|
|
128
|
-
if (!fs.existsSync(
|
|
140
|
+
function readEventsTail(paths, count) {
|
|
141
|
+
if (!fs.existsSync(paths.eventsPath)) return [];
|
|
129
142
|
let body;
|
|
130
143
|
try {
|
|
131
|
-
body = fs.readFileSync(
|
|
144
|
+
body = fs.readFileSync(paths.eventsPath, 'utf8');
|
|
132
145
|
} catch {
|
|
133
146
|
return [];
|
|
134
147
|
}
|
|
@@ -149,16 +162,16 @@ function readEventsTail(count) {
|
|
|
149
162
|
// Retention prune — LRU by mtime, keep last RETENTION_COUNT (D-08)
|
|
150
163
|
// ---------------------------------------------------------------------------
|
|
151
164
|
|
|
152
|
-
function pruneSnapshots() {
|
|
165
|
+
function pruneSnapshots(paths) {
|
|
153
166
|
let files;
|
|
154
167
|
try {
|
|
155
|
-
files = fs.readdirSync(
|
|
168
|
+
files = fs.readdirSync(paths.snapshotDir);
|
|
156
169
|
} catch {
|
|
157
170
|
return;
|
|
158
171
|
}
|
|
159
172
|
const jsonFiles = files
|
|
160
173
|
.filter((f) => f.endsWith('.json') && f !== 'last-recap.json')
|
|
161
|
-
.map((f) => ({ name: f, full: path.join(
|
|
174
|
+
.map((f) => ({ name: f, full: path.join(paths.snapshotDir, f), mtime: 0 }));
|
|
162
175
|
|
|
163
176
|
for (const entry of jsonFiles) {
|
|
164
177
|
try {
|
|
@@ -186,35 +199,43 @@ function pruneSnapshots() {
|
|
|
186
199
|
async function main() {
|
|
187
200
|
const harness = detectHarness();
|
|
188
201
|
if (harness === 'codex') {
|
|
189
|
-
// D-10: Codex has no PreCompact event; emit notice + exit.
|
|
190
|
-
//
|
|
202
|
+
// D-10: Codex has no PreCompact event; emit notice + exit. Tracked in
|
|
203
|
+
// the runtime-parity matrix; full pre-large-context-action interception
|
|
204
|
+
// is on the roadmap.
|
|
191
205
|
process.stderr.write(
|
|
192
206
|
'[gdd-precompact-snapshot] this harness does not emit PreCompact; snapshots disabled\n',
|
|
193
207
|
);
|
|
194
208
|
process.exit(0);
|
|
195
209
|
}
|
|
196
210
|
|
|
197
|
-
// Drain stdin
|
|
198
|
-
//
|
|
211
|
+
// Drain stdin and extract payload.cwd. Claude Code pipes a hook-event JSON
|
|
212
|
+
// envelope including the project cwd; we need it to anchor SNAPSHOT_DIR and
|
|
213
|
+
// friends correctly when the harness's process.cwd() at module load may not
|
|
214
|
+
// match (e.g. when invoked from a worktree). Draining also avoids EPIPE on
|
|
215
|
+
// the writer side. Falls back to process.cwd() when stdin is empty or
|
|
216
|
+
// malformed (unit tests, direct invocation).
|
|
217
|
+
let buf = '';
|
|
199
218
|
try {
|
|
200
|
-
|
|
201
|
-
// Best-effort, non-blocking — we have nothing time-sensitive in stdin.
|
|
202
|
-
process.stdin.on('error', () => {
|
|
203
|
-
/* swallow */
|
|
204
|
-
});
|
|
205
|
-
process.stdin.resume();
|
|
206
|
-
}
|
|
219
|
+
for await (const chunk of process.stdin) buf += chunk;
|
|
207
220
|
} catch {
|
|
208
|
-
/* swallow */
|
|
221
|
+
/* swallow — empty stdin is fine */
|
|
222
|
+
}
|
|
223
|
+
let payload = {};
|
|
224
|
+
try {
|
|
225
|
+
payload = JSON.parse(buf || '{}');
|
|
226
|
+
} catch {
|
|
227
|
+
/* malformed stdin → fall through with empty payload */
|
|
209
228
|
}
|
|
229
|
+
const cwd = (payload && typeof payload.cwd === 'string') ? payload.cwd : process.cwd();
|
|
230
|
+
const paths = computePaths(cwd);
|
|
210
231
|
|
|
211
232
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
212
|
-
const snapshotPath = path.join(
|
|
233
|
+
const snapshotPath = path.join(paths.snapshotDir, ts + '.json');
|
|
213
234
|
const tmpPath = snapshotPath + '.tmp';
|
|
214
235
|
|
|
215
236
|
// Ensure snapshot dir exists (mkdir -p semantics).
|
|
216
237
|
try {
|
|
217
|
-
fs.mkdirSync(
|
|
238
|
+
fs.mkdirSync(paths.snapshotDir, { recursive: true });
|
|
218
239
|
} catch {
|
|
219
240
|
/* swallow — write will fail loudly below if truly missing */
|
|
220
241
|
}
|
|
@@ -240,8 +261,8 @@ async function main() {
|
|
|
240
261
|
}
|
|
241
262
|
|
|
242
263
|
try {
|
|
243
|
-
const sections = readStateSections();
|
|
244
|
-
const events = readEventsTail(EVENTS_TAIL_COUNT);
|
|
264
|
+
const sections = readStateSections(paths);
|
|
265
|
+
const events = readEventsTail(paths, EVENTS_TAIL_COUNT);
|
|
245
266
|
const decisions = sections.decisions.slice(-DECISIONS_TAIL_COUNT);
|
|
246
267
|
const cycleId =
|
|
247
268
|
sections.frontmatter && sections.frontmatter.milestone
|
|
@@ -280,7 +301,7 @@ async function main() {
|
|
|
280
301
|
}
|
|
281
302
|
|
|
282
303
|
// Retention prune (T-27.6.05-04 DoS mitigation).
|
|
283
|
-
pruneSnapshots();
|
|
304
|
+
pruneSnapshots(paths);
|
|
284
305
|
|
|
285
306
|
// Best-effort event emit.
|
|
286
307
|
const appendEvent = getAppendEvent();
|