@selvakumaresra/specship 0.3.0 → 0.4.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.
- package/commands/ss-design-implement.md +5 -0
- package/commands/ss-design-loop.md +125 -0
- package/dist/bin/specship.js +66 -0
- package/dist/bin/specship.js.map +1 -1
- package/dist/designer/artifact-store.js +54 -0
- package/dist/designer/browser.js +141 -0
- package/dist/designer/cdp-ensure.js +60 -0
- package/dist/designer/cdp-env.js +18 -0
- package/dist/designer/cdp-trace.js +599 -0
- package/dist/designer/cross-platform.js +74 -0
- package/dist/designer/designer-controller.js +1413 -0
- package/dist/designer/file-panel.js +39 -0
- package/dist/designer/interstitials.js +97 -0
- package/dist/designer/oopif-reader.js +176 -0
- package/dist/designer/package-meta.js +18 -0
- package/dist/designer/preview-host.js +50 -0
- package/dist/designer/repo-root.js +31 -0
- package/dist/designer/run-state.js +353 -0
- package/dist/designer/session-store.js +59 -0
- package/dist/designer/ui-anchors.js +651 -0
- package/dist/installer/index.d.ts +5 -0
- package/dist/installer/index.d.ts.map +1 -1
- package/dist/installer/index.js +3 -2
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/instructions-template.d.ts +17 -0
- package/dist/installer/instructions-template.d.ts.map +1 -1
- package/dist/installer/instructions-template.js +31 -1
- package/dist/installer/instructions-template.js.map +1 -1
- package/dist/installer/targets/claude.d.ts +19 -0
- package/dist/installer/targets/claude.d.ts.map +1 -1
- package/dist/installer/targets/claude.js +98 -1
- package/dist/installer/targets/claude.js.map +1 -1
- package/dist/installer/targets/shared.d.ts +14 -0
- package/dist/installer/targets/shared.d.ts.map +1 -1
- package/dist/installer/targets/shared.js +49 -0
- package/dist/installer/targets/shared.js.map +1 -1
- package/dist/installer/targets/types.d.ts +8 -0
- package/dist/installer/targets/types.d.ts.map +1 -1
- package/dist/mcp/designer-tools.d.ts +33 -0
- package/dist/mcp/designer-tools.d.ts.map +1 -0
- package/dist/mcp/designer-tools.js +313 -0
- package/dist/mcp/designer-tools.js.map +1 -0
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +22 -1
- package/dist/mcp/tools.js.map +1 -1
- package/dist/web/{chunk-JT7P3DEK.js → chunk-2YUJNZ2Y.js} +3 -3
- package/dist/web/{chunk-JN6W7HCN.js → chunk-45QHGCB4.js} +1 -1
- package/dist/web/{chunk-RAAMPHPJ.js → chunk-A5R3MJMO.js} +1 -1
- package/dist/web/{chunk-2DHIGIOI.js → chunk-ASZ77FMZ.js} +1 -1
- package/dist/web/{chunk-TWXZK6XM.js → chunk-B3YPFY6A.js} +1 -1
- package/dist/web/chunk-D5OCNEJA.js +2 -0
- package/dist/web/{chunk-3SEJX2BK.js → chunk-FHZHD2ZG.js} +1 -1
- package/dist/web/chunk-GR72OOCN.js +1 -0
- package/dist/web/{chunk-DA6SNNAF.js → chunk-GWPVKJIY.js} +1 -1
- package/dist/web/{chunk-YAWCRPHV.js → chunk-NZEZCT65.js} +1 -1
- package/dist/web/{chunk-BCZM5AXU.js → chunk-UBOZGQNK.js} +1 -1
- package/dist/web/{chunk-BPECIDVO.js → chunk-WCKHQIYN.js} +1 -1
- package/dist/web/{chunk-JFYVCXK3.js → chunk-WLIMNDS3.js} +1 -1
- package/dist/web/{chunk-LV4G6QFG.js → chunk-YAMRN47K.js} +1 -1
- package/dist/web/index.html +1 -1
- package/dist/web/main-R53HA54V.js +1 -0
- package/dist/web/sw.js +69 -0
- package/dist/workflows/defaults/claude-design-implement.yaml +138 -49
- package/hooks/hooks.json +11 -0
- package/package.json +7 -3
- package/selectors.json +41 -0
- package/dist/web/chunk-2OKMB4KX.js +0 -2
- package/dist/web/chunk-4N5DWG46.js +0 -1
- package/dist/web/main-WVI3YTDU.js +0 -1
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.UI_ANCHORS = void 0;
|
|
4
|
+
exports.runHealth = runHealth;
|
|
5
|
+
const run_state_1 = require("./run-state");
|
|
6
|
+
const preview_host_1 = require("./preview-host");
|
|
7
|
+
const cdp_env_1 = require("./cdp-env");
|
|
8
|
+
const oopif_reader_1 = require("./oopif-reader");
|
|
9
|
+
const file_panel_1 = require("./file-panel");
|
|
10
|
+
async function hasSelector(browser, sel) {
|
|
11
|
+
return !!(await browser
|
|
12
|
+
.evalValue(`!!document.querySelector(${JSON.stringify(sel)})`)
|
|
13
|
+
.catch(() => false));
|
|
14
|
+
}
|
|
15
|
+
async function hasButtonMatching(browser, pattern) {
|
|
16
|
+
return !!(await browser
|
|
17
|
+
.evalValue(`(() => { const re = new RegExp(${JSON.stringify(pattern.source)}, ${JSON.stringify(pattern.flags)}); return Array.from(document.querySelectorAll('button')).some(b => re.test((b.textContent || '').trim())); })()`)
|
|
18
|
+
.catch(() => false));
|
|
19
|
+
}
|
|
20
|
+
// The design-preview iframe's src. Shared by the preview anchors below
|
|
21
|
+
// (iframeSrcPattern / previewBootstrap / oopifPreviewRead) so they read the
|
|
22
|
+
// element the same way. '' when absent (caller decides skip vs fail).
|
|
23
|
+
async function getPreviewIframeSrc(browser) {
|
|
24
|
+
return ((await browser
|
|
25
|
+
.evalValue(`(() => { const el = document.querySelector('[data-testid="html-viewer-iframe"]'); return (el && el.src) || ''; })()`)
|
|
26
|
+
.catch(() => '')) || '');
|
|
27
|
+
}
|
|
28
|
+
function sleep(ms) {
|
|
29
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
30
|
+
}
|
|
31
|
+
async function submitTurnRpcCanary(browser) {
|
|
32
|
+
const prompt = 'Health check: answer in chat only with the single word ok. Do not create, modify, or delete files.';
|
|
33
|
+
const filled = await browser
|
|
34
|
+
.evalValue(`(() => {
|
|
35
|
+
const el = document.querySelector('[data-testid="chat-composer-input"]');
|
|
36
|
+
if (!el) return false;
|
|
37
|
+
const text = ${JSON.stringify(prompt)};
|
|
38
|
+
if (el instanceof HTMLTextAreaElement) {
|
|
39
|
+
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
|
|
40
|
+
setter.call(el, text);
|
|
41
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
42
|
+
el.focus();
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
if (el.isContentEditable) {
|
|
46
|
+
el.focus();
|
|
47
|
+
const sel = window.getSelection();
|
|
48
|
+
const range = document.createRange();
|
|
49
|
+
range.selectNodeContents(el);
|
|
50
|
+
sel.removeAllRanges();
|
|
51
|
+
sel.addRange(range);
|
|
52
|
+
const dt = new DataTransfer();
|
|
53
|
+
dt.setData('text/plain', text);
|
|
54
|
+
const unhandled = el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
|
|
55
|
+
if (unhandled) document.execCommand('insertText', false, text);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
})()`)
|
|
60
|
+
.catch(() => false);
|
|
61
|
+
if (!filled)
|
|
62
|
+
return { ok: false, detail: 'composer not fillable for canary prompt' };
|
|
63
|
+
for (let i = 0; i < 30; i++) {
|
|
64
|
+
const disabled = await browser
|
|
65
|
+
.evalValue(`(() => {
|
|
66
|
+
const b = document.querySelector('[data-testid="chat-send-button"], button[title^="Send ("]');
|
|
67
|
+
return !b || b.disabled || b.getAttribute('aria-disabled') === 'true';
|
|
68
|
+
})()`)
|
|
69
|
+
.catch(() => true);
|
|
70
|
+
if (!disabled)
|
|
71
|
+
break;
|
|
72
|
+
await sleep(150);
|
|
73
|
+
}
|
|
74
|
+
const clicked = await browser
|
|
75
|
+
.evalValue(`(() => {
|
|
76
|
+
const b = document.querySelector('[data-testid="chat-send-button"], button[title^="Send ("]');
|
|
77
|
+
if (!b || b.disabled || b.getAttribute('aria-disabled') === 'true') return false;
|
|
78
|
+
b.click();
|
|
79
|
+
return true;
|
|
80
|
+
})()`)
|
|
81
|
+
.catch(() => false);
|
|
82
|
+
return clicked ? { ok: true } : { ok: false, detail: 'send button unavailable for canary prompt' };
|
|
83
|
+
}
|
|
84
|
+
async function checkTurnRpcContract(_browser, currentUrl) {
|
|
85
|
+
if (process.env.DESIGNER_TURN_RPC_CANARY !== '1') {
|
|
86
|
+
return { ok: true, status: 'skip', detail: 'turn-RPC canary disabled (DESIGNER_TURN_RPC_CANARY!=1)' };
|
|
87
|
+
}
|
|
88
|
+
if (!(0, cdp_env_1.isCdpEnabled)()) {
|
|
89
|
+
return { ok: true, status: 'skip', detail: "CDP disabled (DESIGNER_CDP=''); turn-RPC canary not probed" };
|
|
90
|
+
}
|
|
91
|
+
const observer = await run_state_1.RunStateObserver.attach({ preferUrlPrefix: currentUrl.split('?')[0] || null });
|
|
92
|
+
if (!observer) {
|
|
93
|
+
return { ok: true, status: 'skip', detail: 'CDP observer unavailable; turn-RPC canary not probed' };
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
observer.beginRun();
|
|
97
|
+
const submitted = await submitTurnRpcCanary(_browser);
|
|
98
|
+
if (!submitted.ok)
|
|
99
|
+
return { ok: true, status: 'skip', detail: submitted.detail };
|
|
100
|
+
const terminal = await observer.awaitTerminal({ stallMs: 25_000, hardTimeoutMs: 75_000 });
|
|
101
|
+
const summary = observer.signalSummary();
|
|
102
|
+
const detail = `heartbeat x${summary.heartbeat}, release ${summary.release > 0 ? 'seen' : 'missing'}, ` +
|
|
103
|
+
`chat x${summary.chatOpen}, chunks x${summary.chatChunk}, terminal=${terminal.terminal}` +
|
|
104
|
+
(summary.observedRpcPaths.length ? `, observed=[${summary.observedRpcPaths.join(', ')}]` : ', observed=[]');
|
|
105
|
+
return {
|
|
106
|
+
// A healthy fast chat-only turn can finish before the first RenewTurn
|
|
107
|
+
// (~14.5s in, per trace findings), so heartbeat>0 is not a contract
|
|
108
|
+
// requirement — gate on the discrete signals (chat opened + released +
|
|
109
|
+
// finished). heartbeat count stays visible in `detail` as soft signal.
|
|
110
|
+
ok: terminal.terminal === 'finished' && summary.release > 0 && summary.chatOpen > 0,
|
|
111
|
+
detail
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
observer.close();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
exports.UI_ANCHORS = [
|
|
119
|
+
// --- login state (first so a signed-out session tops the report) ---
|
|
120
|
+
{
|
|
121
|
+
// Issue #32: signed out, `designer health` showed only skips/cryptic
|
|
122
|
+
// fails and read as "everything OK". This anchor calls the signed-out
|
|
123
|
+
// state out explicitly.
|
|
124
|
+
//
|
|
125
|
+
// A URL-only check is not enough: a logged-out visit to claude.ai/design
|
|
126
|
+
// sometimes redirects to /login, but sometimes renders the login wall AT
|
|
127
|
+
// the /design URL with no /login substring (the #16 false positive that
|
|
128
|
+
// setup's DOM-based verifier exists to catch — see setup.ts). So gate on
|
|
129
|
+
// the DOM app-shell marker setup uses, not the URL alone.
|
|
130
|
+
id: 'login.signedIn',
|
|
131
|
+
category: 'pattern',
|
|
132
|
+
description: 'signed in (claude.ai is rendering the app shell, not the login wall)',
|
|
133
|
+
requires: 'any',
|
|
134
|
+
check: async (b, url) => {
|
|
135
|
+
// Explicit login wall in the URL — unambiguously signed out.
|
|
136
|
+
if (/claude\.ai\/login/.test(url)) {
|
|
137
|
+
return { ok: false, detail: `signed out — Chrome is on the login wall (${url.slice(0, 80)}). Run: designer setup` };
|
|
138
|
+
}
|
|
139
|
+
// On a design surface, the signed-in app shell renders the chat composer
|
|
140
|
+
// (chat-composer-input) on BOTH the home (creation composer, post-2026-06
|
|
141
|
+
// redesign #61) and inside a session. Its absence here means the login
|
|
142
|
+
// wall is being served at the /design URL — fail loudly.
|
|
143
|
+
if (/claude\.ai\/design/.test(url)) {
|
|
144
|
+
const signedIn = await hasSelector(b, '[data-testid="chat-composer-input"]');
|
|
145
|
+
return signedIn
|
|
146
|
+
? { ok: true }
|
|
147
|
+
: { ok: false, detail: `login wall rendered at ${url.slice(0, 80)} (no app shell) — signed out. Run: designer setup` };
|
|
148
|
+
}
|
|
149
|
+
// Off the claude.ai/design surface entirely (e.g. an unrelated tab) —
|
|
150
|
+
// sign-in can't be judged from this tab, so don't false-fail.
|
|
151
|
+
return { ok: true, detail: `not on a claude.ai/design surface (url=${url.slice(0, 60)}) — sign-in not checked here` };
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
// --- home page ---
|
|
155
|
+
// 2026-06 redesign (#61): the home is composer-driven — no project-name input
|
|
156
|
+
// and no wireframe/high-fi toggle. Creation = seed the chat composer
|
|
157
|
+
// (chat-composer-input) + click "Start project" (chat-send-button, same
|
|
158
|
+
// testids as the in-session composer/send). The old home.nameInput anchor was
|
|
159
|
+
// dropped (no equivalent); home.wireframeButton/highFiButton are repurposed to
|
|
160
|
+
// the surviving creation-type cards (text-only buttons) so they still detect
|
|
161
|
+
// drift of the creation UI. Captured live from Chrome 149.
|
|
162
|
+
{
|
|
163
|
+
id: 'home.creator',
|
|
164
|
+
category: 'home',
|
|
165
|
+
description: 'creation composer (chat-composer-input)',
|
|
166
|
+
requires: 'home',
|
|
167
|
+
check: async (b) => ({ ok: await hasSelector(b, '[data-testid="chat-composer-input"]') })
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: 'home.wireframeButton',
|
|
171
|
+
category: 'home',
|
|
172
|
+
description: 'Product wireframe creation-type card',
|
|
173
|
+
requires: 'home',
|
|
174
|
+
check: async (b) => ({ ok: await hasButtonMatching(b, /^Product wireframe/) })
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
id: 'home.highFiButton',
|
|
178
|
+
category: 'home',
|
|
179
|
+
// Renamed 'Prototype' → 'Product prototype' in the 2026-06-19 home build
|
|
180
|
+
// (auto-heal PR #75/#76). Off the create path — a drift sentinel only.
|
|
181
|
+
description: 'Product prototype creation-type card',
|
|
182
|
+
requires: 'home',
|
|
183
|
+
check: async (b) => ({ ok: await hasButtonMatching(b, /^Product prototype/) })
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: 'home.createButton',
|
|
187
|
+
category: 'home',
|
|
188
|
+
description: '"Start project" create button (chat-send-button)',
|
|
189
|
+
requires: 'home',
|
|
190
|
+
check: async (b) => ({ ok: await hasSelector(b, '[data-testid="chat-send-button"], button[title^="Send ("]') })
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
id: 'home.projectsList',
|
|
194
|
+
category: 'home',
|
|
195
|
+
description: 'project list (>=1 /design/p/ link)',
|
|
196
|
+
requires: 'home',
|
|
197
|
+
check: async (b) => ({ ok: await hasSelector(b, 'a[href*="/design/p/"]') })
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: 'home.projectCard',
|
|
201
|
+
category: 'home',
|
|
202
|
+
description: 'project card (a[href*="/design/p/"])',
|
|
203
|
+
requires: 'home',
|
|
204
|
+
check: async (b) => ({ ok: await hasSelector(b, 'a[href*="/design/p/"]') })
|
|
205
|
+
},
|
|
206
|
+
// --- inside a session (after /design/p/{uuid}) ---
|
|
207
|
+
{
|
|
208
|
+
id: 'session.promptTextarea',
|
|
209
|
+
category: 'session',
|
|
210
|
+
description: 'chat composer textarea',
|
|
211
|
+
requires: 'session',
|
|
212
|
+
check: async (b) => ({ ok: await hasSelector(b, '[data-testid="chat-composer-input"]') })
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
// Existence (above) isn't enough — _submitPrompt can only fill a composer
|
|
216
|
+
// that is a <textarea> or a contenteditable element, and it branches on
|
|
217
|
+
// exactly that. The 2026-06 build shipped the composer as a ProseMirror
|
|
218
|
+
// contenteditable <div>; if it drifts to a shape that's neither (a bare
|
|
219
|
+
// wrapper, a web component, a readonly node), submission silently stalls
|
|
220
|
+
// and callers fall back to driving the page by hand. That's the regression
|
|
221
|
+
// fract-ai hit on a pre-0.3.9 build (designer/.inbox 2026-06-10). This
|
|
222
|
+
// anchor asserts the composer is in a shape _submitPrompt actually handles.
|
|
223
|
+
//
|
|
224
|
+
// Scope: this checks the composer's SHAPE, not that a paste actually lands
|
|
225
|
+
// (verifying that would mean typing into a live session). A contenteditable
|
|
226
|
+
// whose editor rejects synthetic paste would still pass here.
|
|
227
|
+
//
|
|
228
|
+
// Maintenance: this is a block-bodied evalValue check, so it is NOT
|
|
229
|
+
// auto-heal-patchable (anchor-patcher's canPatch only rewrites the simple
|
|
230
|
+
// `hasSelector(b, '<sel>')` shape). The chat-composer-input selector is
|
|
231
|
+
// duplicated from session.promptTextarea above — if it drifts, auto-heal
|
|
232
|
+
// will self-heal promptTextarea but skip this one; update the selector in
|
|
233
|
+
// the eval below by hand to match. (Same limitation as the other rich
|
|
234
|
+
// anchors here: hasButtonMatching, iframeSrcPattern, fileListScrape.)
|
|
235
|
+
id: 'session.composerFillable',
|
|
236
|
+
category: 'session',
|
|
237
|
+
description: 'composer is fillable (textarea or contenteditable, per _submitPrompt)',
|
|
238
|
+
requires: 'session',
|
|
239
|
+
check: async (b) => {
|
|
240
|
+
const shape = await b
|
|
241
|
+
.evalValue(`(() => {
|
|
242
|
+
const el = document.querySelector('[data-testid="chat-composer-input"]');
|
|
243
|
+
if (!el) return { found: false };
|
|
244
|
+
const fillable = el instanceof HTMLTextAreaElement || el.isContentEditable;
|
|
245
|
+
return { found: true, tag: el.tagName, contentEditable: el.isContentEditable, fillable };
|
|
246
|
+
})()`)
|
|
247
|
+
.catch(() => ({ found: false }));
|
|
248
|
+
if (!shape.found)
|
|
249
|
+
return { ok: false, detail: 'composer not found' };
|
|
250
|
+
if (shape.fillable) {
|
|
251
|
+
return { ok: true, detail: shape.contentEditable ? 'contenteditable' : `<${(shape.tag || '').toLowerCase()}>` };
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
ok: false,
|
|
255
|
+
detail: `composer is <${(shape.tag || '?').toLowerCase()}> — neither textarea nor contenteditable; _submitPrompt cannot fill it (composer shape drifted)`
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
id: 'session.sendButton',
|
|
261
|
+
category: 'session',
|
|
262
|
+
description: 'send button',
|
|
263
|
+
requires: 'session',
|
|
264
|
+
// The 2026-06 build dropped data-testid="chat-send-button"; the button is
|
|
265
|
+
// now only identifiable by its title="Send (Enter)". Match either.
|
|
266
|
+
check: async (b) => ({ ok: await hasSelector(b, '[data-testid="chat-send-button"], button[title^="Send ("]') })
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
id: 'session.htmlViewerIframe',
|
|
270
|
+
category: 'session',
|
|
271
|
+
description: 'html-viewer-iframe (design preview)',
|
|
272
|
+
requires: 'session',
|
|
273
|
+
check: async (b, url) => {
|
|
274
|
+
// The iframe only renders when a file is open. Without ?file= in the URL,
|
|
275
|
+
// its absence is expected, not a regression.
|
|
276
|
+
if (!/[?&]file=/.test(url))
|
|
277
|
+
return { ok: true, detail: '(no file open — iframe not expected)' };
|
|
278
|
+
return { ok: await hasSelector(b, '[data-testid="html-viewer-iframe"]') };
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
id: 'session.chatMessages',
|
|
283
|
+
category: 'session',
|
|
284
|
+
description: 'chat-messages container',
|
|
285
|
+
requires: 'session',
|
|
286
|
+
check: async (b) => ({ ok: await hasSelector(b, '[data-testid="chat-messages"]') })
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
id: 'network.turnRpcContract',
|
|
290
|
+
category: 'pattern',
|
|
291
|
+
description: 'OmeletteService Chat/RenewTurn/ReleaseTurn network contract',
|
|
292
|
+
requires: 'session',
|
|
293
|
+
check: checkTurnRpcContract
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
id: 'session.iframeSrcPattern',
|
|
297
|
+
category: 'pattern',
|
|
298
|
+
description: 'iframe src serves from claudeusercontent.com (signed-token or bootstrap-subdomain)',
|
|
299
|
+
requires: 'session',
|
|
300
|
+
check: async (b, url) => {
|
|
301
|
+
if (!/[?&]file=/.test(url))
|
|
302
|
+
return { ok: true, detail: '(no file open — iframe not expected)' };
|
|
303
|
+
const src = await getPreviewIframeSrc(b);
|
|
304
|
+
if (!src)
|
|
305
|
+
return { ok: false, detail: 'file param present but iframe missing src' };
|
|
306
|
+
const ok = (0, preview_host_1.isPreviewIframeSrc)(src);
|
|
307
|
+
return { ok, detail: ok ? `variant=${(0, preview_host_1.previewIframeVariant)(src)}` : `src=${src.slice(0, 120)}...` };
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
// Drift sentinel for the OOPIF preview-HTML capture path (issue #61 / review
|
|
312
|
+
// #4). fetchServedHtml branches on previewIframeVariant: signed-token keeps
|
|
313
|
+
// the legacy node fetch; bootstrap-subdomain reads the cross-origin OOPIF's
|
|
314
|
+
// rendered DOM over CDP. This anchor records which regime the live preview
|
|
315
|
+
// is in so a swing back to signed-token (or to an unrecognized 'other'
|
|
316
|
+
// shape) — which would silently route capture down the wrong path — is
|
|
317
|
+
// visible in the daily health probe. This anchor records the regime only;
|
|
318
|
+
// the sibling `session.oopifPreviewRead` below actually attaches CDP and
|
|
319
|
+
// verifies the bootstrap-subdomain capture returns rendered HTML.
|
|
320
|
+
id: 'network.previewBootstrap',
|
|
321
|
+
category: 'pattern',
|
|
322
|
+
description: 'preview iframe regime (bootstrap-subdomain => OOPIF CDP capture; signed-token => node fetch)',
|
|
323
|
+
requires: 'session',
|
|
324
|
+
check: async (b, url) => {
|
|
325
|
+
if (!/[?&]file=/.test(url))
|
|
326
|
+
return { ok: true, detail: '(no file open — preview regime not checked)' };
|
|
327
|
+
const src = await getPreviewIframeSrc(b);
|
|
328
|
+
if (!src)
|
|
329
|
+
return { ok: false, detail: 'file param present but iframe missing src' };
|
|
330
|
+
if (!(0, preview_host_1.isPreviewIframeSrc)(src))
|
|
331
|
+
return { ok: false, detail: `preview left claudeusercontent.com: ${src.slice(0, 120)}` };
|
|
332
|
+
const variant = (0, preview_host_1.previewIframeVariant)(src);
|
|
333
|
+
return {
|
|
334
|
+
ok: variant === 'bootstrap-subdomain' || variant === 'signed-token',
|
|
335
|
+
detail: variant === 'bootstrap-subdomain'
|
|
336
|
+
? 'variant=bootstrap-subdomain (OOPIF CDP capture path)'
|
|
337
|
+
: variant === 'signed-token'
|
|
338
|
+
? 'variant=signed-token (legacy node-fetch path)'
|
|
339
|
+
: `variant=other — unrecognized preview src shape (${src.slice(0, 120)}); capture path may be wrong`
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
// End-to-end check of the OOPIF capture itself. iframeSrcPattern /
|
|
345
|
+
// previewBootstrap only inspect the src STRING — the CDP auto-attach read
|
|
346
|
+
// could silently return the ~1.1KB loader shell (or null) while both pass,
|
|
347
|
+
// handing snapshot/fetch/iterate empty HTML (inbox finding #3). This anchor
|
|
348
|
+
// attaches its own OopifHtmlReader (like checkTurnRpcContract attaches a
|
|
349
|
+
// RunStateObserver) and asserts the read returns rendered HTML, not the
|
|
350
|
+
// shell. Only the bootstrap-subdomain regime uses the OOPIF path; the
|
|
351
|
+
// signed-token / 'other' regimes use a node fetch, so they skip here.
|
|
352
|
+
id: 'session.oopifPreviewRead',
|
|
353
|
+
category: 'pattern',
|
|
354
|
+
description: 'OOPIF CDP read returns rendered preview HTML (not the bootstrap loader shell)',
|
|
355
|
+
requires: 'session',
|
|
356
|
+
check: async (b, url) => {
|
|
357
|
+
// Gate on a RENDERED preview iframe, not on ?file= in the URL: the daily-
|
|
358
|
+
// health canary (DESIGNER_PROBE_PROJECT_URL) is a BARE project URL, and
|
|
359
|
+
// claude.ai auto-opens a default file + renders its preview there — so a
|
|
360
|
+
// ?file= gate would skip the OOPIF check in exactly the CI run it exists to
|
|
361
|
+
// protect (PR #77 Codex P2). Wait briefly for the iframe to paint after nav.
|
|
362
|
+
let src = await getPreviewIframeSrc(b);
|
|
363
|
+
for (let i = 0; i < 6 && !(0, preview_host_1.isPreviewIframeSrc)(src); i++) {
|
|
364
|
+
await sleep(500);
|
|
365
|
+
src = await getPreviewIframeSrc(b);
|
|
366
|
+
}
|
|
367
|
+
if (!(0, preview_host_1.isPreviewIframeSrc)(src))
|
|
368
|
+
return { ok: true, status: 'skip', detail: 'no preview iframe rendered (no file open)' };
|
|
369
|
+
const variant = (0, preview_host_1.previewIframeVariant)(src);
|
|
370
|
+
if (variant !== 'bootstrap-subdomain')
|
|
371
|
+
return { ok: true, status: 'skip', detail: `variant=${variant} — node-fetch path, OOPIF read not used` };
|
|
372
|
+
if (!(0, cdp_env_1.isCdpEnabled)())
|
|
373
|
+
return { ok: true, status: 'skip', detail: "CDP disabled (DESIGNER_CDP=''); OOPIF read not probed" };
|
|
374
|
+
// By here CDP is enabled AND the preview is on the bootstrap-subdomain
|
|
375
|
+
// (OOPIF) path — so an attach failure is NOT inconclusive. Production
|
|
376
|
+
// fetchServedHtml uses the same reader and falls back to EMPTY html on
|
|
377
|
+
// attach failure, so snapshot/fetch/iterate would silently get no content.
|
|
378
|
+
// Fail the probe (don't skip) — this is the exact regression it exists to
|
|
379
|
+
// catch (PR #77 Codex P2).
|
|
380
|
+
const reader = await oopif_reader_1.OopifHtmlReader.attach({ preferUrlPrefix: url || null }).catch(() => null);
|
|
381
|
+
if (!reader)
|
|
382
|
+
return { ok: false, detail: 'OOPIF reader attach failed while CDP is enabled on the bootstrap-subdomain path — snapshot/fetch/iterate would get empty HTML' };
|
|
383
|
+
try {
|
|
384
|
+
const html = await reader.readPreviewHtml().catch(() => null);
|
|
385
|
+
if (!html)
|
|
386
|
+
return { ok: false, detail: 'OOPIF read returned null — CDP capture path broken (snapshot/fetch/iterate would get empty HTML)' };
|
|
387
|
+
if ((0, preview_host_1.isBootstrapShellHtml)(html))
|
|
388
|
+
return { ok: false, detail: `OOPIF read returned the bootstrap loader shell (${html.length}B), not rendered HTML` };
|
|
389
|
+
return { ok: true, detail: `read ${html.length}B of rendered HTML via OOPIF CDP capture` };
|
|
390
|
+
}
|
|
391
|
+
finally {
|
|
392
|
+
reader.close();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
// Legacy id (kept to avoid resetting the persisted streak counter). The
|
|
398
|
+
// original check asserted a 'You\n' / 'Claude\n' text prefix on each
|
|
399
|
+
// chat turn, but Claude's May 2026 chat redesign removed the in-text
|
|
400
|
+
// speaker label — turns are now distinguished by Claude's intentional
|
|
401
|
+
// `data-index="N"` API on each turn row.
|
|
402
|
+
//
|
|
403
|
+
// It originally matched the SPECIFIC `[data-index="1"]`, but the chat list
|
|
404
|
+
// is VIRTUALIZED: once a conversation grows past the render window, only a
|
|
405
|
+
// sliding window of rows is in the DOM (live-probed indices were 8–15 with
|
|
406
|
+
// 0/1 evicted), so `[data-index="1"]` vanishes even though there are clearly
|
|
407
|
+
// >=2 turns — a recurring false drift, same class as fileListScrape (#69).
|
|
408
|
+
// Assert the COUNT of `[data-index]` rows instead: any window of a >=2-turn
|
|
409
|
+
// chat renders >=2 rows, so count>=2 confirms both "the indexing API exists"
|
|
410
|
+
// and ">=2 turns" without depending on which window is visible. Soft anchor:
|
|
411
|
+
// a 1-turn chat (count 1) is a short conversation, not drift -> skip; a
|
|
412
|
+
// missing API/testid after settle is the real drift signal -> fail.
|
|
413
|
+
id: 'session.chatTurnPrefix',
|
|
414
|
+
category: 'pattern',
|
|
415
|
+
description: 'chat-messages renders >=2 turn rows (data-index API)',
|
|
416
|
+
requires: 'session',
|
|
417
|
+
check: async (b) => {
|
|
418
|
+
const countRows = () => b
|
|
419
|
+
.evalValue(`(() => { const cm = document.querySelector('[data-testid="chat-messages"]'); if (!cm) return -1; return cm.querySelectorAll('[data-index]').length; })()`)
|
|
420
|
+
.catch(() => -1);
|
|
421
|
+
// The chat renders progressively after navigation; settle before judging.
|
|
422
|
+
let n = -1;
|
|
423
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
424
|
+
n = await countRows();
|
|
425
|
+
if (n >= 2)
|
|
426
|
+
break;
|
|
427
|
+
if (attempt < 5)
|
|
428
|
+
await sleep(1000);
|
|
429
|
+
}
|
|
430
|
+
if (n >= 2)
|
|
431
|
+
return { ok: true };
|
|
432
|
+
if (n === 1)
|
|
433
|
+
return { ok: true, status: 'skip', detail: 'only 1 turn row (short conversation) — data-index API present, >=2 unverifiable' };
|
|
434
|
+
if (n === 0)
|
|
435
|
+
return { ok: false, detail: 'chat-messages present but 0 [data-index] rows after ~5s settle — turn-row data-index API drifted' };
|
|
436
|
+
return { ok: false, detail: 'chat-messages testid not found after ~5s settle — testid drifted' };
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
// --- share dialog (formerly the Export dropdown; moved under Share ~2026-04-19) ---
|
|
440
|
+
{
|
|
441
|
+
id: 'share.shareButton',
|
|
442
|
+
category: 'share',
|
|
443
|
+
description: 'Share button (opens the dropdown containing handoff/export actions)',
|
|
444
|
+
requires: 'session',
|
|
445
|
+
check: async (b) => ({ ok: await hasButtonMatching(b, /^Share$/) })
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
// Id kept (not renamed) to preserve the persisted health-streak counter.
|
|
449
|
+
// Validates the path `designer handoff` actually takes: the same-origin
|
|
450
|
+
// project export endpoint returns a zip. The old check clicked the Share
|
|
451
|
+
// dialog and asserted "claude code" TEXT existed — which false-passed (it
|
|
452
|
+
// stayed green while handoff threw) because it never exercised the real
|
|
453
|
+
// mechanism (PR: handoff Share-redesign rework).
|
|
454
|
+
id: 'share.handoffMenuItem',
|
|
455
|
+
category: 'share',
|
|
456
|
+
description: 'Project export endpoint (/design/v1/design/projects/<id>/download) returns a zip — the path designer handoff fetches',
|
|
457
|
+
requires: 'session',
|
|
458
|
+
check: async (b, url) => {
|
|
459
|
+
const m = url.match(/\/design\/p\/([a-f0-9-]+)/i);
|
|
460
|
+
if (!m || !m[1])
|
|
461
|
+
return { ok: true, status: 'skip', detail: 'not in a /design/p/<uuid> session' };
|
|
462
|
+
const projectId = m[1];
|
|
463
|
+
// In-page GET (auth + Cloudflare just work there); read headers, cancel the
|
|
464
|
+
// body so health doesn't pull the multi-MB zip every run. Bounded by an
|
|
465
|
+
// abort deadline so a hung endpoint can't stall the whole health sweep.
|
|
466
|
+
const res = await b
|
|
467
|
+
.evalValue(`(async () => {
|
|
468
|
+
const ctrl = new AbortController();
|
|
469
|
+
const to = setTimeout(() => ctrl.abort(), 15000);
|
|
470
|
+
try {
|
|
471
|
+
const r = await fetch('/design/v1/design/projects/' + ${JSON.stringify(projectId)} + '/download', { headers: { Accept: '*/*' }, signal: ctrl.signal });
|
|
472
|
+
const o = { status: r.status, ct: r.headers.get('content-type') || '' };
|
|
473
|
+
try { await r.body.cancel(); } catch {}
|
|
474
|
+
return o;
|
|
475
|
+
} catch (e) { return { status: 0, ct: '', err: String((e && e.message) || e) }; }
|
|
476
|
+
finally { clearTimeout(to); }
|
|
477
|
+
})()`)
|
|
478
|
+
.catch(() => ({ status: 0, ct: '', err: 'eval failed' }));
|
|
479
|
+
// Accept zip OR octet-stream: _downloadProjectZip validates by PK magic and
|
|
480
|
+
// ignores content-type, so a 200 octet-stream is a real success — don't go
|
|
481
|
+
// red where the download would succeed (the inverse of the old false-pass).
|
|
482
|
+
const ok = res.status === 200 && /(zip|octet-stream)/i.test(res.ct);
|
|
483
|
+
return {
|
|
484
|
+
ok,
|
|
485
|
+
detail: ok ? `200 ${res.ct}` : `download endpoint status=${res.status} ct=${res.ct}${res.err ? ' err=' + res.err : ''}`
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
// --- URL / pattern anchors ---
|
|
490
|
+
{
|
|
491
|
+
id: 'pattern.sessionUrl',
|
|
492
|
+
category: 'pattern',
|
|
493
|
+
description: 'session URL matches /design/p/<uuid>',
|
|
494
|
+
requires: 'any',
|
|
495
|
+
check: async (_b, url) => {
|
|
496
|
+
const inSession = /\/design\/p\/[a-f0-9-]+/i.test(url);
|
|
497
|
+
return { ok: inSession || /claude\.ai\/design\/?(\?|$)/.test(url), detail: `url=${url.slice(0, 100)}` };
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
id: 'pattern.fileQueryParam',
|
|
502
|
+
category: 'pattern',
|
|
503
|
+
description: '?file=<name> opens a specific file (URL-based file switching)',
|
|
504
|
+
requires: 'session',
|
|
505
|
+
check: async (_b, url) => {
|
|
506
|
+
const ok = /[?&]file=/.test(url);
|
|
507
|
+
return { ok: true, detail: ok ? 'file param present' : '(no file open — not a regression)' };
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
id: 'session.fileListScrape',
|
|
512
|
+
category: 'session',
|
|
513
|
+
description: 'filename text nodes detectable (listFiles scrape still works)',
|
|
514
|
+
requires: 'session',
|
|
515
|
+
check: async (b, url) => {
|
|
516
|
+
const scrape = () => b
|
|
517
|
+
.evalValue(`(() => {
|
|
518
|
+
const seen = new Set();
|
|
519
|
+
const files = [];
|
|
520
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
521
|
+
let node;
|
|
522
|
+
while ((node = walker.nextNode())) {
|
|
523
|
+
const t = (node.textContent || '').trim();
|
|
524
|
+
if (!/^[A-Za-z0-9 _.()\\-]+\\.(html|js|css|jsx|tsx|ts|md|json|svg)$/i.test(t)) continue;
|
|
525
|
+
if (t.length > 80 || seen.has(t)) continue;
|
|
526
|
+
seen.add(t);
|
|
527
|
+
files.push(t);
|
|
528
|
+
}
|
|
529
|
+
return { files };
|
|
530
|
+
})()`)
|
|
531
|
+
.catch(() => ({ files: [] }));
|
|
532
|
+
// Production listFilesDetailed OPENS the "Design Files" panel before
|
|
533
|
+
// scraping; this anchor used to scrape the bare page, so on a project whose
|
|
534
|
+
// panel wasn't already rendered (e.g. a single-file standalone — PR #75/#76
|
|
535
|
+
// hit "Signup Wireframes (standalone)") it found 0 and false-failed while
|
|
536
|
+
// `designer files` worked. Open the panel first so the anchor exercises the
|
|
537
|
+
// same path. Idempotent + best-effort (matches listFilesDetailed).
|
|
538
|
+
// Shared, idempotent opener (file-panel.ts) — identical to the production
|
|
539
|
+
// listFilesDetailed opener so this probe exercises the real path.
|
|
540
|
+
const openFilesPanel = () => b.evalValue(file_panel_1.OPEN_FILES_PANEL_EXPR).catch(() => false);
|
|
541
|
+
// Open the panel ONCE up front. Clicking it on every retry would toggle an
|
|
542
|
+
// already-open panel closed mid-settle (oscillation — review below-gate);
|
|
543
|
+
// the panel header renders immediately, its file rows a beat later, so one
|
|
544
|
+
// click + the retry-scrape settle covers the late render. The file-list
|
|
545
|
+
// panel renders a few seconds after navigation; scraping immediately races
|
|
546
|
+
// it — the recurring false "0 filenames" the daily probe filed
|
|
547
|
+
// (#64/#65/#68), even though `designer files` and a live scrape find the
|
|
548
|
+
// files once the panel is up. Retry with a bounded settle before concluding
|
|
549
|
+
// a regression.
|
|
550
|
+
await openFilesPanel();
|
|
551
|
+
let files = [];
|
|
552
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
553
|
+
await sleep(attempt === 0 ? 300 : 700);
|
|
554
|
+
const result = await scrape();
|
|
555
|
+
files = Array.isArray(result.files) ? result.files : [];
|
|
556
|
+
if (files.length > 0)
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
if (files.length === 0) {
|
|
560
|
+
// With no file open the file-list panel may legitimately be absent — don't
|
|
561
|
+
// hard-fail a soft anchor on an inconclusive state; only a populated
|
|
562
|
+
// session (a file open) is expected to list filenames.
|
|
563
|
+
if (!/[?&]file=/.test(url)) {
|
|
564
|
+
return { ok: true, status: 'skip', detail: 'no file open; file-list panel not rendered — inconclusive' };
|
|
565
|
+
}
|
|
566
|
+
return { ok: false, detail: 'found 0 filenames after ~5s settle — scraper regex or DOM layout regressed' };
|
|
567
|
+
}
|
|
568
|
+
// The anchor's invariant is "the scraper still detects filenames" — ≥1
|
|
569
|
+
// filename means the regex + DOM walk work. Whether the URL's ?file=
|
|
570
|
+
// appears among them is NOT a reliable sub-assertion: the panel lists the
|
|
571
|
+
// authoritative project files, and the active ?file= can legitimately be
|
|
572
|
+
// absent from it (a stale/virtual URL file — observed live: ?file=
|
|
573
|
+
// direction-dock.html while the panel lists casefile-*.html). So treat an
|
|
574
|
+
// active-file mismatch as informational, not a failure.
|
|
575
|
+
const match = url.match(/[?&]file=([^&]+)/);
|
|
576
|
+
if (match && match[1]) {
|
|
577
|
+
// Claude Design's URL bar form-encodes spaces as '+'. decodeURIComponent
|
|
578
|
+
// only handles %xx, so normalize '+' → ' ' first before comparing
|
|
579
|
+
// against the scraper's text-node output (which uses real spaces).
|
|
580
|
+
const activeFile = decodeURIComponent(match[1].replace(/\+/g, ' '));
|
|
581
|
+
if (!files.includes(activeFile)) {
|
|
582
|
+
return {
|
|
583
|
+
ok: true,
|
|
584
|
+
detail: `${files.length} file(s) detected; active "${activeFile}" not among them (URL file may be stale/virtual)`
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return { ok: true, detail: `${files.length} file(s) detected` };
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
];
|
|
592
|
+
async function runHealth(browser, opts = {}) {
|
|
593
|
+
const currentUrl = (await browser.url().catch(() => '')) || '';
|
|
594
|
+
// When `opts.phase` is supplied the caller has already navigated to the
|
|
595
|
+
// matching surface — filter strictly by that phase, tag every result with
|
|
596
|
+
// it, and suppress skips (a `home`-only anchor probed during a `session`
|
|
597
|
+
// phase isn't a skip-with-detail, it's just not part of this phase's run).
|
|
598
|
+
// When omitted, fall back to URL-inferred state for back-compat with
|
|
599
|
+
// single-phase callers (cli.ts `designer health`).
|
|
600
|
+
if (opts.phase) {
|
|
601
|
+
const phase = opts.phase;
|
|
602
|
+
const results = [];
|
|
603
|
+
for (const a of exports.UI_ANCHORS) {
|
|
604
|
+
const applicable = a.requires === 'any' ||
|
|
605
|
+
(phase === 'home' && a.requires === 'home') ||
|
|
606
|
+
(phase === 'session' && a.requires === 'session');
|
|
607
|
+
if (!applicable)
|
|
608
|
+
continue;
|
|
609
|
+
const base = {
|
|
610
|
+
id: a.id,
|
|
611
|
+
category: a.category,
|
|
612
|
+
description: a.description,
|
|
613
|
+
requires: a.requires,
|
|
614
|
+
phase
|
|
615
|
+
};
|
|
616
|
+
try {
|
|
617
|
+
const r = await a.check(browser, currentUrl);
|
|
618
|
+
results.push({ ...base, status: r.status ?? (r.ok ? 'ok' : 'fail'), detail: r.detail });
|
|
619
|
+
}
|
|
620
|
+
catch (e) {
|
|
621
|
+
results.push({ ...base, status: 'fail', detail: `threw: ${e.message}` });
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return results;
|
|
625
|
+
}
|
|
626
|
+
// Legacy URL-inferred path. Single-phase callers see the same behavior as
|
|
627
|
+
// before — skips emitted for anchors that don't match the inferred state,
|
|
628
|
+
// no `phase` field on results.
|
|
629
|
+
const inSession = /\/design\/p\/[a-f0-9-]+/i.test(currentUrl);
|
|
630
|
+
const onHome = /\/design\/?$/.test(currentUrl) || currentUrl.endsWith('/design');
|
|
631
|
+
const state = inSession ? 'session' : onHome ? 'home' : 'other';
|
|
632
|
+
const results = [];
|
|
633
|
+
for (const a of exports.UI_ANCHORS) {
|
|
634
|
+
const base = { id: a.id, category: a.category, description: a.description, requires: a.requires };
|
|
635
|
+
const applicable = a.requires === 'any' ||
|
|
636
|
+
(a.requires === 'home' && state === 'home') ||
|
|
637
|
+
(a.requires === 'session' && state === 'session');
|
|
638
|
+
if (!applicable) {
|
|
639
|
+
results.push({ ...base, status: 'skip', detail: `needs ${a.requires} state; current=${state}` });
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
try {
|
|
643
|
+
const r = await a.check(browser, currentUrl);
|
|
644
|
+
results.push({ ...base, status: r.status ?? (r.ok ? 'ok' : 'fail'), detail: r.detail });
|
|
645
|
+
}
|
|
646
|
+
catch (e) {
|
|
647
|
+
results.push({ ...base, status: 'fail', detail: `threw: ${e.message}` });
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return results;
|
|
651
|
+
}
|
|
@@ -22,6 +22,11 @@ export interface RunInstallerOptions {
|
|
|
22
22
|
location?: Location;
|
|
23
23
|
/** Skip the auto-allow prompt; use this value directly. */
|
|
24
24
|
autoAllow?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Spec-driven-development steering (CLAUDE.md rule + nudge hook). On by
|
|
27
|
+
* default; pass `false` (the `--no-sdd` flag) to skip. Undefined ⇒ on.
|
|
28
|
+
*/
|
|
29
|
+
sdd?: boolean;
|
|
25
30
|
/**
|
|
26
31
|
* Skip every confirm and use defaults: location=global,
|
|
27
32
|
* autoAllow=true. For scripting / CI.
|