@hegemonart/get-design-done 1.48.0 → 1.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,237 @@
1
+ # Visual Tells Catalog (v1)
2
+
3
+ The default-AI aesthetic has a fingerprint. When a model generates a front-end
4
+ without a brand brief, it falls back to the same handful of moves it saw most in
5
+ its training set. This catalog names those moves so the cheap regex floor in
6
+ `hooks/gdd-design-quality-check.js` can flag them on every `.tsx` / `.vue` /
7
+ `.svelte` / `.astro` write.
8
+
9
+ Severity here is WARN (advisory), in the vocabulary of `reference/audit-scoring.md`.
10
+ A WARN is not a FLAG: it does not block the write and it does not fail a gate. It
11
+ is a nudge that asks "did you choose this, or did the model default to it?" Each
12
+ category below maps 1:1 to a rule id in the hook. For the harder bans behind some
13
+ of these tells, see `reference/anti-patterns.md` (BAN-NN and SLOP-NN).
14
+
15
+ How to read each entry: a short description, three to five concrete instances of
16
+ the tell in the wild, the diagnostic regex the hook runs (where one applies), and
17
+ a remediation pattern you can paste in.
18
+
19
+ ---
20
+
21
+ ## default-AI-hero
22
+
23
+ Rule id: `generic-cta`
24
+
25
+ The stock landing-page hero: a centered headline, one line of filler subtext, and
26
+ a button that says "Get Started". The copy carries no subject and no verb specific
27
+ to the product. It reads like every template because it is every template.
28
+
29
+ Instances:
30
+
31
+ 1. Button label "Get Started" with no object ("Get started with what?").
32
+ 2. Headline opener "Welcome to [Product]" instead of a value statement.
33
+ 3. "Learn More" as the secondary call to action, pointing nowhere specific.
34
+ 4. "Lorem ipsum" body copy shipped past the mockup stage.
35
+ 5. The triplet subhead with no verb (see SLOP-11 in `reference/anti-patterns.md`).
36
+
37
+ Diagnostic regex (hook): `\b(?:Get Started|Welcome to|Lorem ipsum|Learn More)\b`
38
+ (case-insensitive, word-boundaried).
39
+
40
+ Remediation: write the specific promise and the specific next step. "Start a free
41
+ trial" beats "Get Started". "Ship your first audit in ten minutes" beats "Welcome
42
+ to GDD". Name the noun and the verb. Delete placeholder copy before review.
43
+
44
+ ---
45
+
46
+ ## gradient-spam
47
+
48
+ Rule id: `gradient-spam`
49
+
50
+ One tasteful gradient can anchor a page. Three or more on a single screen reads as
51
+ decoration standing in for hierarchy. The model reaches for `bg-gradient-to-r` on
52
+ the hero, again on the cards, again on the footer, because gradients look busy and
53
+ busy looks "designed".
54
+
55
+ Instances:
56
+
57
+ 1. Hero background, card backgrounds, and a CTA all using a direction gradient.
58
+ 2. `bg-gradient-to-br` on every feature tile in a grid.
59
+ 3. A gradient on text plus a gradient on its container (double application).
60
+ 4. Gradient borders faked with a gradient background and an inset child.
61
+
62
+ Diagnostic regex (hook): `\bbg-gradient-to-(?:r|br|tr|b|bl|l|tl|t)\b`, flagged at
63
+ a count of three or more occurrences in one file.
64
+
65
+ Remediation: pick one surface to carry a gradient and make the rest solid. Use
66
+ weight, size, and spacing for hierarchy instead of color washes. If you keep a
67
+ gradient, give it a documented role (one hero, one accent) rather than spraying it.
68
+ For the gradient-text ban specifically, see BAN-02.
69
+
70
+ ---
71
+
72
+ ## isometric-illustration-fallback
73
+
74
+ Rule id: `isometric-illustration-fallback`
75
+
76
+ Pastel isometric scenes with floating icons, usually pulled straight from a free
77
+ clip-art set. The undraw.co style is the strongest tell: flat shapes, two-tone
78
+ palette, no brand character. It signals that no real screenshot or photography was
79
+ available, so a stock scene filled the hole.
80
+
81
+ Instances:
82
+
83
+ 1. `src="/illustrations/undraw_dashboard.svg"` in an empty state.
84
+ 2. An `isometric-hero.png` asset behind the headline.
85
+ 3. A row of undraw spot illustrations as feature icons.
86
+ 4. The same illustration set reused across unrelated products.
87
+
88
+ Diagnostic regex (hook): `\b(?:undraw|isometric)[\w./-]*` (matches the marker in an
89
+ asset path or `src`).
90
+
91
+ Remediation: show the actual product. A real screenshot, a short screen capture,
92
+ or a purpose-drawn illustration with brand character all beat stock clip art. See
93
+ SLOP-12 in `reference/anti-patterns.md` for the longer argument.
94
+
95
+ ---
96
+
97
+ ## centered-everything-syndrome
98
+
99
+ Rule id: `centered-everything-syndrome`
100
+
101
+ `mx-auto` plus `text-center` on block after block. Centered text is fine for a
102
+ single short hero line. Applied to body copy, feature lists, and multi-line cards
103
+ it destroys the reading edge: the eye loses the left margin it scans against, and
104
+ every block competes for the same axis.
105
+
106
+ Instances:
107
+
108
+ 1. A hero, a feature grid, and a testimonial section all centered.
109
+ 2. Centered multi-line paragraphs longer than two lines.
110
+ 3. A centered card with centered heading, centered body, and a centered button.
111
+ 4. Centered form labels above left-aligned inputs (axis mismatch).
112
+
113
+ Diagnostic regex (hook): a quoted class string containing both `mx-auto` and
114
+ `text-center`, in either order.
115
+
116
+ Remediation: center the hero line only. Left-align body copy and lists so they
117
+ share a reading edge. Reserve centering for short, single-line, high-emphasis text.
118
+ Center the container for width control, but left-align its text content.
119
+
120
+ ---
121
+
122
+ ## inter-everything
123
+
124
+ Rule id: `inter-everything`
125
+
126
+ Inter as the default with no documented reason. Inter is a fine typeface, which is
127
+ exactly why it is the safe pick a model makes when no brand font is specified. The
128
+ tell is not Inter itself: it is Inter used alone, with no second font and no token
129
+ that records a choice.
130
+
131
+ Instances:
132
+
133
+ 1. `font-inter` on the root with no display or brand font anywhere.
134
+ 2. `font-family: 'Inter'` in CSS with no second family in the stack file.
135
+ 3. Inter paired with no `--font-display` or `--font-body` token.
136
+ 4. DM Sans, Space Grotesk, or Plus Jakarta Sans in the same role (see SLOP-05).
137
+
138
+ Diagnostic regex (hook): `\bfont-inter\b` or `font-family:\s*['"]?Inter`, warned
139
+ only when no sibling custom-font token (a `font-<name>` utility, a `--font-*`
140
+ variable, or a second `font-family`) appears in the same file.
141
+
142
+ Remediation: choose a typeface you can defend in three sentences against the brand,
143
+ and record it as a token (`--font-display`, `--font-body`). If Inter is the right
144
+ call, pair it with a distinct display face or a deliberate weight system so the
145
+ choice is visible. Keeping the token is what turns a default into a decision.
146
+
147
+ ---
148
+
149
+ ## purple-violet-default
150
+
151
+ Rule id: `purple-violet-default`
152
+
153
+ `bg-purple-600` and `bg-violet-600` are the colors a model picks when no palette is
154
+ given. Combined with indigo and cyan they form the exact accent set that ships on a
155
+ large share of generated UIs (SLOP-01). The tell is the raw Tailwind shade used as
156
+ the brand color with no theme token in sight.
157
+
158
+ Instances:
159
+
160
+ 1. `bg-violet-600` on the primary button with no `bg-primary` token defined.
161
+ 2. `bg-purple-500` headers across the app, hardcoded per component.
162
+ 3. Purple-to-blue accent pairing on hero plus buttons (SLOP-02).
163
+ 4. `text-violet-600` links with no semantic link color.
164
+
165
+ Diagnostic regex (hook): `\bbg-(?:purple|violet)-(?:500|600|700)\b`, warned only
166
+ when no theme-token class (`bg-primary`, `bg-brand`, `bg-accent`, or a
167
+ `bg-[var(--...)]` / `oklch` / `hsl` arbitrary value) is present in the file.
168
+
169
+ Remediation: route color through a token (`bg-primary`, `--color-accent`) so the
170
+ brand hue lives in one place. If purple is genuinely the brand, define it as the
171
+ token and reference the token, not the raw shade. Pick one primary accent and apply
172
+ it consistently. See the Color System rubric in `reference/audit-scoring.md`.
173
+
174
+ ---
175
+
176
+ ## glassmorphism-spam
177
+
178
+ Rule id: `glassmorphism-spam`
179
+
180
+ Frosted-glass panels stacked everywhere: `backdrop-blur` on cards, on the nav, on
181
+ modals, plus `bg-white/10` fills. One blurred overlay over busy content is a valid
182
+ move. Three or more blur or low-alpha-white treatments in one file is glass used as
183
+ the default surface, which hides the fact that no real layout system exists.
184
+
185
+ Instances:
186
+
187
+ 1. `backdrop-blur-lg` on every card in a grid.
188
+ 2. `bg-white/10` panels layered three deep.
189
+ 3. A blurred nav over a blurred hero over a blurred section.
190
+ 4. Glass cards on a flat background where there is nothing to blur.
191
+
192
+ Diagnostic regex (hook): `\bbackdrop-blur(?:-\w+)?\b` or `\bbg-white\/(?:10|20|30)\b`,
193
+ flagged at a count of three or more occurrences in one file.
194
+
195
+ Remediation: keep blur for cases where it has a job, such as a modal dimming the
196
+ content behind it, a floating command palette, or a sticky header over scrolling
197
+ content. Give other surfaces solid fills and a real elevation system. See SLOP-04
198
+ for the valid-use list.
199
+
200
+ ---
201
+
202
+ ## decorative-motion-without-intent
203
+
204
+ Rule id: `decorative-motion-without-intent`
205
+
206
+ `animate-pulse`, `animate-bounce`, and `animate-spin` are loading affordances. The
207
+ tell is using them as ambient decoration: a pulsing hero badge, a bouncing arrow
208
+ that never stops, a spinning accent that encodes no progress. Motion that loops
209
+ forever with no state behind it reads as filler and fights `prefers-reduced-motion`.
210
+
211
+ Instances:
212
+
213
+ 1. `animate-pulse` on a static "New" badge in the hero.
214
+ 2. `animate-bounce` on a scroll-down chevron looping with no end.
215
+ 3. `animate-spin` on a decorative ring that is not a spinner.
216
+ 4. A pulsing gradient blob behind the headline.
217
+
218
+ Diagnostic regex (hook, conservative): a quoted class string containing
219
+ `animate-(pulse|bounce|spin)` that does not also contain a loading signal
220
+ (`loading`, `loader`, `spinner`, `skeleton`, `icon`, `i-`, or `sr-only`).
221
+
222
+ Remediation: tie motion to a state. Use `animate-pulse` on skeletons while data
223
+ loads, `animate-spin` on a real spinner during a request, and drop ambient loops.
224
+ Respect `prefers-reduced-motion`. Enter with `ease-out`, exit shorter than enter,
225
+ and never animate keyboard-driven actions. See the Motion Anti-Patterns section of
226
+ `reference/anti-patterns.md`.
227
+
228
+ ---
229
+
230
+ ## Notes
231
+
232
+ This is a v1 floor, not a ceiling. The regexes favor precision over recall: they
233
+ aim to fire only on the obvious cases so the WARN stays trustworthy. A clean pass
234
+ here does not mean the design is good. It means the eight loudest default-AI tells
235
+ are absent. Real review still applies the full rubric in
236
+ `reference/audit-scoring.md` and the BAN / SLOP catalog in
237
+ `reference/anti-patterns.md`.
@@ -0,0 +1,60 @@
1
+ 'use strict';
2
+ /**
3
+ * scripts/lib/confidence-route.cjs — pure routing helper for the reviewer
4
+ * confidence gate (Phase 49). Decides where a single finding/gap goes based on
5
+ * its severity, its `confidence` score (0.0-1.0), and whether the reviewer
6
+ * parked it in the `## Tentative` section.
7
+ *
8
+ * Canonical rule (mirrors reference/reviewer-confidence-gate.md):
9
+ * - A finding in `## Tentative` -> 'drop' (never reaches design-fixer)
10
+ * - confidence < 0.5 -> 'drop' (low-confidence floor; stays tentative)
11
+ * - HIGH/CRITICAL (BLOCKER|MAJOR) needs -> 'fix' only when confidence >= 0.8,
12
+ * otherwise 'user-review'
13
+ * - confidence in [0.5, 0.8) -> 'user-review' (surfaced, not auto-fixed)
14
+ * - confidence >= 0.8 -> 'fix'
15
+ *
16
+ * Returns one of: 'fix' | 'user-review' | 'drop'. Dependency-free and side
17
+ * effect free so the routing matrix is unit-testable in isolation.
18
+ */
19
+
20
+ const HIGH_FLOOR = 0.8; // BLOCKER/MAJOR must clear this to auto-fix
21
+ const SURFACE_FLOOR = 0.5; // below this a finding is dropped (stays tentative)
22
+
23
+ // Severity labels that count as HIGH/CRITICAL for the auto-fix floor.
24
+ const HIGH_SEVERITIES = new Set(['blocker', 'major', 'high', 'critical']);
25
+
26
+ function isHighSeverity(severity) {
27
+ if (typeof severity !== 'string') return false;
28
+ return HIGH_SEVERITIES.has(severity.trim().toLowerCase());
29
+ }
30
+
31
+ /**
32
+ * Route a finding/gap.
33
+ * @param {object} finding
34
+ * @param {string} finding.severity - BLOCKER | MAJOR | MINOR | COSMETIC (case-insensitive).
35
+ * @param {number} finding.confidence - 0.0-1.0 confidence score.
36
+ * @param {boolean} [finding.tentative] - true when the finding sits in `## Tentative`.
37
+ * @returns {'fix'|'user-review'|'drop'}
38
+ */
39
+ function route({ severity, confidence, tentative = false } = {}) {
40
+ // 1. Tentative findings never reach the fixer, regardless of score.
41
+ if (tentative === true) return 'drop';
42
+
43
+ // 2. A missing/non-numeric confidence is treated as the lowest tier: surface
44
+ // for user review rather than silently auto-fixing or dropping.
45
+ const c = typeof confidence === 'number' && Number.isFinite(confidence) ? confidence : 0;
46
+
47
+ // 3. Low-confidence floor: anything under 0.5 is dropped (stays tentative).
48
+ if (c < SURFACE_FLOOR) return 'drop';
49
+
50
+ // 4. HIGH/CRITICAL findings must clear the 0.8 floor to auto-fix; otherwise
51
+ // they are routed to the user instead of the fixer.
52
+ if (isHighSeverity(severity)) {
53
+ return c >= HIGH_FLOOR ? 'fix' : 'user-review';
54
+ }
55
+
56
+ // 5. Lower-severity findings: 0.5-0.8 surfaces for review, >= 0.8 auto-fixes.
57
+ return c >= HIGH_FLOOR ? 'fix' : 'user-review';
58
+ }
59
+
60
+ module.exports = { route, isHighSeverity, HIGH_FLOOR, SURFACE_FLOOR };
@@ -0,0 +1,221 @@
1
+ 'use strict';
2
+ /**
3
+ * scripts/lib/worktree-resolve.cjs — Phase 49 (Quick Anti-Slop Floor).
4
+ *
5
+ * Redirect `.design/` and `.planning/` writes to the MAIN repo root when GDD
6
+ * runs inside a git WORKTREE. A worktree has its own ephemeral checkout dir; a
7
+ * naive `process.cwd()`-relative `.design/STATE.md` write would land inside the
8
+ * throwaway worktree and get lost (or leak) when the worktree is removed. We
9
+ * detect the worktree, resolve the main repo root, and point artifact writes
10
+ * there so state survives across worktree lifecycles.
11
+ *
12
+ * Detection: `git rev-parse --git-dir` and `--git-common-dir` DIFFER inside a
13
+ * linked worktree (git-dir is `<main>/.git/worktrees/<name>`, common-dir is
14
+ * `<main>/.git`); in the main checkout they are EQUAL. The main repo root is the
15
+ * PARENT of the common git dir.
16
+ *
17
+ * Pure + dependency-free except for spawning `git`, and the `git` call is fully
18
+ * injectable via an `exec` parameter so tests run without a real worktree. NEVER
19
+ * throws: when git is unavailable (no repo, git not on PATH) every resolver
20
+ * degrades gracefully to the caller's `cwd`, so non-git consumers are unaffected.
21
+ *
22
+ * No top-level `Date.now()` / `Math.random()` — the only module-level mutable
23
+ * state is the one-shot `noticeOnce` flag, intentionally per-process.
24
+ *
25
+ * CommonJS so it ships in the npm package and loads from `.cjs` callers and the
26
+ * dual-mode `.ts`/`.js` MCP servers alike.
27
+ */
28
+
29
+ const { spawnSync } = require('node:child_process');
30
+ const path = require('node:path');
31
+
32
+ /**
33
+ * Default `exec`: synchronously run `git <args...>` in `cwd` and return its
34
+ * trimmed stdout. Returns null on ANY failure (git missing, non-zero exit,
35
+ * not a repo) so callers can treat "no git" as "not a worktree".
36
+ *
37
+ * @param {string[]} args git arguments, e.g. ['rev-parse', '--git-dir']
38
+ * @param {string} cwd working directory to run git in
39
+ * @returns {string | null} trimmed stdout, or null on failure
40
+ */
41
+ function defaultExec(args, cwd) {
42
+ try {
43
+ const res = spawnSync('git', args, {
44
+ cwd,
45
+ encoding: 'utf8',
46
+ windowsHide: true,
47
+ });
48
+ if (!res || res.status !== 0 || typeof res.stdout !== 'string') return null;
49
+ const out = res.stdout.trim();
50
+ return out.length ? out : null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Normalize an injectable `exec` into a uniform `(args, cwd) => string|null`.
58
+ *
59
+ * The injectable form callers/tests pass is `(cmd, args) => string` where `cmd`
60
+ * is the literal `'git'` and `args` is the argv array (matching the prompt
61
+ * contract). We adapt it here and swallow throws into null so a test exec that
62
+ * throws models "git unavailable" exactly like the real one returning null.
63
+ *
64
+ * @param {undefined | ((cmd: string, args: string[]) => string)} exec
65
+ * @param {string} cwd
66
+ * @returns {(args: string[]) => string | null}
67
+ */
68
+ function makeRunner(exec, cwd) {
69
+ if (typeof exec === 'function') {
70
+ return (args) => {
71
+ try {
72
+ const out = exec('git', args);
73
+ if (typeof out !== 'string') return null;
74
+ const trimmed = out.trim();
75
+ return trimmed.length ? trimmed : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ };
80
+ }
81
+ return (args) => defaultExec(args, cwd);
82
+ }
83
+
84
+ /**
85
+ * Resolve the absolute git-dir and git-common-dir for `cwd`.
86
+ *
87
+ * `git rev-parse` prints these relative to `cwd` in some setups and absolute in
88
+ * others; we resolve both against `cwd` so comparison and parent-of logic is
89
+ * always done on absolute, normalized paths.
90
+ *
91
+ * @returns {{ gitDir: string, commonDir: string } | null} null when not a repo
92
+ */
93
+ function gitDirs(run, cwd) {
94
+ const gitDirRaw = run(['rev-parse', '--git-dir']);
95
+ if (gitDirRaw == null) return null;
96
+ const commonDirRaw = run(['rev-parse', '--git-common-dir']);
97
+ if (commonDirRaw == null) return null;
98
+ const gitDir = path.resolve(cwd, gitDirRaw);
99
+ const commonDir = path.resolve(cwd, commonDirRaw);
100
+ return { gitDir, commonDir };
101
+ }
102
+
103
+ /**
104
+ * True when `cwd` sits inside a linked git WORKTREE (git-dir !== git-common-dir).
105
+ * False in the main checkout, and false (degrade gracefully) when git is
106
+ * unavailable or `cwd` is not inside any repo.
107
+ *
108
+ * @param {string} [cwd=process.cwd()]
109
+ * @param {(cmd: string, args: string[]) => string} [exec] injectable git runner
110
+ * @returns {boolean}
111
+ */
112
+ function isWorktree(cwd = process.cwd(), exec) {
113
+ const run = makeRunner(exec, cwd);
114
+ const dirs = gitDirs(run, cwd);
115
+ if (dirs == null) return false;
116
+ return dirs.gitDir !== dirs.commonDir;
117
+ }
118
+
119
+ /**
120
+ * Resolve the MAIN repo root for `cwd`.
121
+ *
122
+ * - In a worktree: the parent of the git-common-dir (`<main>/.git` -> `<main>`).
123
+ * - In the main checkout: the toplevel (`git rev-parse --show-toplevel`).
124
+ * - git unavailable / not a repo: falls back to `path.resolve(cwd)`.
125
+ *
126
+ * Never throws.
127
+ *
128
+ * @param {string} [cwd=process.cwd()]
129
+ * @param {(cmd: string, args: string[]) => string} [exec] injectable git runner
130
+ * @returns {string} absolute main repo root
131
+ */
132
+ function resolveRepoRoot(cwd = process.cwd(), exec) {
133
+ const run = makeRunner(exec, cwd);
134
+ const dirs = gitDirs(run, cwd);
135
+ if (dirs == null) {
136
+ // Not a repo / git unavailable — degrade to cwd.
137
+ return path.resolve(cwd);
138
+ }
139
+ if (dirs.gitDir !== dirs.commonDir) {
140
+ // Worktree: the main repo root is the parent of the common `.git` dir.
141
+ // common-dir is typically `<main>/.git`; its dirname is `<main>`. Guard the
142
+ // (unusual) bare-repo case where common-dir has no `.git` basename by only
143
+ // climbing when the basename looks like a git dir.
144
+ const base = path.basename(dirs.commonDir);
145
+ if (base === '.git' || base.endsWith('.git')) {
146
+ return path.dirname(dirs.commonDir);
147
+ }
148
+ // Bare/relocated git dir: best effort — fall through to toplevel below.
149
+ }
150
+ // Main checkout (or odd common-dir shape): prefer the toplevel.
151
+ const top = run(['rev-parse', '--show-toplevel']);
152
+ if (top != null) return path.resolve(cwd, top);
153
+ // Last resort: parent of the git dir, else cwd.
154
+ const base = path.basename(dirs.commonDir);
155
+ if (base === '.git' || base.endsWith('.git')) return path.dirname(dirs.commonDir);
156
+ return path.resolve(cwd);
157
+ }
158
+
159
+ /**
160
+ * Absolute `.design` root in the MAIN repo (worktree-safe).
161
+ *
162
+ * @param {string} [cwd=process.cwd()]
163
+ * @param {(cmd: string, args: string[]) => string} [exec]
164
+ * @returns {string}
165
+ */
166
+ function resolveDesignRoot(cwd = process.cwd(), exec) {
167
+ return path.join(resolveRepoRoot(cwd, exec), '.design');
168
+ }
169
+
170
+ /**
171
+ * Absolute `.planning` root in the MAIN repo (worktree-safe).
172
+ *
173
+ * @param {string} [cwd=process.cwd()]
174
+ * @param {(cmd: string, args: string[]) => string} [exec]
175
+ * @returns {string}
176
+ */
177
+ function resolvePlanningRoot(cwd = process.cwd(), exec) {
178
+ return path.join(resolveRepoRoot(cwd, exec), '.planning');
179
+ }
180
+
181
+ /** One-shot guard so the redirect notice prints at most once per process. */
182
+ let NOTICE_EMITTED = false;
183
+
184
+ /**
185
+ * Emit a single one-line stderr notice — exactly once per process — announcing
186
+ * that worktree redirection is in effect. Subsequent calls are no-ops, so a
187
+ * caller can invoke this freely on every redirect without spamming stderr.
188
+ *
189
+ * @param {string} targetRoot the resolved MAIN repo root writes are redirected to
190
+ * @param {(line: string) => void} [write] injectable sink (default process.stderr)
191
+ * @returns {boolean} true if THIS call emitted the notice, false if already emitted
192
+ */
193
+ function noticeOnce(targetRoot, write) {
194
+ if (NOTICE_EMITTED) return false;
195
+ NOTICE_EMITTED = true;
196
+ const line = `worktree detected -> .design/.planning redirected to ${targetRoot}\n`;
197
+ try {
198
+ if (typeof write === 'function') {
199
+ write(line);
200
+ } else {
201
+ process.stderr.write(line);
202
+ }
203
+ } catch {
204
+ /* never let a logging failure break a write path */
205
+ }
206
+ return true;
207
+ }
208
+
209
+ /** Test-only: reset the one-shot notice flag. Not part of the public contract. */
210
+ function _resetNoticeForTests() {
211
+ NOTICE_EMITTED = false;
212
+ }
213
+
214
+ module.exports = {
215
+ isWorktree,
216
+ resolveRepoRoot,
217
+ resolveDesignRoot,
218
+ resolvePlanningRoot,
219
+ noticeOnce,
220
+ _resetNoticeForTests,
221
+ };
@@ -35,7 +35,7 @@ __export(server_exports, {
35
35
  runStdio: () => runStdio
36
36
  });
37
37
  module.exports = __toCommonJS(server_exports);
38
- var import_node_fs4 = require("node:fs");
38
+ var import_node_fs5 = require("node:fs");
39
39
  var import_node_path3 = require("node:path");
40
40
  var import_server = require("@modelcontextprotocol/sdk/server/index.js");
41
41
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
@@ -1799,6 +1799,8 @@ async function transition(path2, toStage) {
1799
1799
 
1800
1800
  // sdk/mcp/gdd-state/tools/shared.ts
1801
1801
  var import_node_path2 = __toESM(require("node:path"));
1802
+ var import_node_fs4 = require("node:fs");
1803
+ var import_node_module2 = require("node:module");
1802
1804
 
1803
1805
  // sdk/event-stream/index.ts
1804
1806
  var import_node_os2 = require("node:os");
@@ -2012,9 +2014,40 @@ function getSessionId() {
2012
2014
  if (CACHED_SESSION_ID === null) CACHED_SESSION_ID = makeSessionId();
2013
2015
  return CACHED_SESSION_ID;
2014
2016
  }
2017
+ function _findRepoRoot2() {
2018
+ let dir = process.cwd();
2019
+ for (let i = 0; i < 8; i++) {
2020
+ if ((0, import_node_fs4.existsSync)(import_node_path2.default.join(dir, "package.json"))) return dir;
2021
+ const parent = import_node_path2.default.dirname(dir);
2022
+ if (parent === dir) break;
2023
+ dir = parent;
2024
+ }
2025
+ return process.cwd();
2026
+ }
2027
+ var _worktree = (() => {
2028
+ try {
2029
+ const root = _findRepoRoot2();
2030
+ const candidate = import_node_path2.default.resolve(root, "scripts/lib/worktree-resolve.cjs");
2031
+ if (!(0, import_node_fs4.existsSync)(candidate)) return null;
2032
+ const req = (0, import_node_module2.createRequire)(import_node_path2.default.join(root, "package.json"));
2033
+ return req(candidate);
2034
+ } catch {
2035
+ return null;
2036
+ }
2037
+ })();
2015
2038
  function resolveStatePath() {
2016
2039
  const override = process.env["GDD_STATE_PATH"];
2017
2040
  if (typeof override !== "string" || override.length === 0) {
2041
+ if (_worktree !== null) {
2042
+ try {
2043
+ const designRoot = _worktree.resolveDesignRoot();
2044
+ if (_worktree.isWorktree()) {
2045
+ _worktree.noticeOnce(import_node_path2.default.dirname(designRoot));
2046
+ }
2047
+ return import_node_path2.default.join(designRoot, "STATE.md");
2048
+ } catch {
2049
+ }
2050
+ }
2018
2051
  return ".design/STATE.md";
2019
2052
  }
2020
2053
  if (import_node_path2.default.isAbsolute(override)) {
@@ -2734,12 +2767,12 @@ function here() {
2734
2767
  const entry = process.argv[1];
2735
2768
  if (typeof entry === "string" && entry.length > 0) {
2736
2769
  const entryDir = (0, import_node_path3.dirname)((0, import_node_path3.resolve)(entry));
2737
- if ((0, import_node_fs4.existsSync)((0, import_node_path3.join)(entryDir, "tools", "index.ts"))) {
2770
+ if ((0, import_node_fs5.existsSync)((0, import_node_path3.join)(entryDir, "tools", "index.ts"))) {
2738
2771
  return entryDir;
2739
2772
  }
2740
2773
  }
2741
2774
  const candidate = (0, import_node_path3.resolve)(process.cwd(), expectedRel);
2742
- if ((0, import_node_fs4.existsSync)((0, import_node_path3.join)(candidate, "tools", "index.ts"))) {
2775
+ if ((0, import_node_fs5.existsSync)((0, import_node_path3.join)(candidate, "tools", "index.ts"))) {
2743
2776
  return candidate;
2744
2777
  }
2745
2778
  return candidate;
@@ -2748,7 +2781,7 @@ function loadTools() {
2748
2781
  const baseDir = here();
2749
2782
  return TOOL_MODULES.map((m) => {
2750
2783
  const absPath = (0, import_node_path3.join)(baseDir, "tools", m.schemaPath);
2751
- const raw = (0, import_node_fs4.readFileSync)(absPath, "utf8");
2784
+ const raw = (0, import_node_fs5.readFileSync)(absPath, "utf8");
2752
2785
  const parsed = JSON.parse(raw);
2753
2786
  const rawInput = parsed.properties?.input;
2754
2787
  const inputSchema = rawInput !== void 0 && typeof rawInput === "object" ? rawInput : { type: "object" };
@@ -12,6 +12,8 @@
12
12
  // {success:false, error} — handlers never propagate exceptions."
13
13
 
14
14
  import path from 'node:path';
15
+ import { existsSync } from 'node:fs';
16
+ import { createRequire } from 'node:module';
15
17
  import {
16
18
  ValidationError,
17
19
  OperationFailedError,
@@ -50,6 +52,49 @@ export function getSessionId(): string {
50
52
  return CACHED_SESSION_ID;
51
53
  }
52
54
 
55
+ /**
56
+ * Worktree redirect (Phase 49). When GDD runs inside a git WORKTREE, a naive
57
+ * `process.cwd()`-relative `.design/STATE.md` write lands in the ephemeral
58
+ * worktree and is lost when it is removed. `scripts/lib/worktree-resolve.cjs`
59
+ * resolves the MAIN repo root and points STATE writes there.
60
+ *
61
+ * Soft-loaded via createRequire (mirrors sdk/event-stream/writer.ts): tsc's
62
+ * Node16 module mode classifies this .ts as CJS output, so `import.meta` is
63
+ * unavailable — we anchor createRequire on the repo-root package.json found by
64
+ * walking up from cwd. If the helper is unreachable (a test subprocess running
65
+ * far above the plugin tree), every resolver degrades to a null shim and the
66
+ * default falls back to the legacy relative path. Production callers always run
67
+ * inside the plugin tree.
68
+ */
69
+ interface WorktreeResolver {
70
+ resolveDesignRoot: (cwd?: string) => string;
71
+ isWorktree: (cwd?: string) => boolean;
72
+ noticeOnce: (targetRoot: string) => boolean;
73
+ }
74
+
75
+ function _findRepoRoot(): string {
76
+ let dir = process.cwd();
77
+ for (let i = 0; i < 8; i++) {
78
+ if (existsSync(path.join(dir, 'package.json'))) return dir;
79
+ const parent = path.dirname(dir);
80
+ if (parent === dir) break;
81
+ dir = parent;
82
+ }
83
+ return process.cwd();
84
+ }
85
+
86
+ const _worktree: WorktreeResolver | null = (() => {
87
+ try {
88
+ const root = _findRepoRoot();
89
+ const candidate = path.resolve(root, 'scripts/lib/worktree-resolve.cjs');
90
+ if (!existsSync(candidate)) return null;
91
+ const req = createRequire(path.join(root, 'package.json'));
92
+ return req(candidate) as WorktreeResolver;
93
+ } catch {
94
+ return null;
95
+ }
96
+ })();
97
+
53
98
  /**
54
99
  * Resolve the target STATE.md path from the environment, with a
55
100
  * PATH-TRAVERSAL guard (Plan 33.5-03, D-08).
@@ -69,6 +114,22 @@ export function getSessionId(): string {
69
114
  export function resolveStatePath(): string {
70
115
  const override = process.env['GDD_STATE_PATH'];
71
116
  if (typeof override !== 'string' || override.length === 0) {
117
+ // No override: resolve STATE.md under the worktree-aware `.design` root
118
+ // (Phase 49). Outside a worktree this equals the legacy `<cwd>/.design`
119
+ // (when cwd is the repo root) so behavior is unchanged; inside a worktree
120
+ // the design root points at the MAIN repo so state is not lost. The notice
121
+ // fires at most once per process, only on an actual worktree redirect.
122
+ if (_worktree !== null) {
123
+ try {
124
+ const designRoot = _worktree.resolveDesignRoot();
125
+ if (_worktree.isWorktree()) {
126
+ _worktree.noticeOnce(path.dirname(designRoot));
127
+ }
128
+ return path.join(designRoot, 'STATE.md');
129
+ } catch {
130
+ // Fall through to the legacy relative default on any resolver fault.
131
+ }
132
+ }
72
133
  return '.design/STATE.md';
73
134
  }
74
135