drafted 1.14.1 → 1.14.3
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/cli/drafted.mjs +15 -4
- package/mcp/server.mjs +104 -25
- package/package.json +1 -1
- package/src/shared/minion-presets.mjs +67 -0
package/cli/drafted.mjs
CHANGED
|
@@ -579,10 +579,22 @@ program
|
|
|
579
579
|
|
|
580
580
|
// Start server in background
|
|
581
581
|
const serverPath = join(__dirname, '../server/server.mjs');
|
|
582
|
-
const { openSync } = await import('fs');
|
|
583
|
-
const outLog = openSync(join(DEFAULT_STATE_DIR, 'server.log'), 'w');
|
|
584
582
|
const tsxPath = join(__dirname, '../node_modules/.bin/tsx');
|
|
585
583
|
const instrumentPath = join(__dirname, '../server/instrument.mjs');
|
|
584
|
+
// A global `npm install -g drafted` only ships the CLI/MCP client (see
|
|
585
|
+
// package.json "files") — the Express server and its src/db, src/auth,
|
|
586
|
+
// src/middleware sources, plus the tsx devDependency needed to run them,
|
|
587
|
+
// are not part of that install. This only works from a full git checkout.
|
|
588
|
+
if (!existsSync(tsxPath) || !existsSync(serverPath)) {
|
|
589
|
+
console.error('Error: cannot start the server from this installation.');
|
|
590
|
+
console.error('This looks like a global `npm install -g drafted` install, which ships only the CLI/MCP client, not the full server.');
|
|
591
|
+
console.error('To bring the local server back up, either:');
|
|
592
|
+
console.error(' - reopen the Drafted desktop app, or');
|
|
593
|
+
console.error(' - run from a full source checkout: git clone the drafted repo, then `npm install && npm run dev`');
|
|
594
|
+
process.exit(1);
|
|
595
|
+
}
|
|
596
|
+
const { openSync } = await import('fs');
|
|
597
|
+
const outLog = openSync(join(DEFAULT_STATE_DIR, 'server.log'), 'w');
|
|
586
598
|
const child = spawn(tsxPath, ['--import', instrumentPath, serverPath], {
|
|
587
599
|
detached: true,
|
|
588
600
|
stdio: ['ignore', outLog, outLog],
|
|
@@ -647,11 +659,10 @@ program
|
|
|
647
659
|
console.log('✅ Drafted server is running');
|
|
648
660
|
console.log(` PID: ${pid}`);
|
|
649
661
|
console.log(` URL: http://localhost:${port}`);
|
|
650
|
-
console.log(` Frames: ${state.frames.length}`);
|
|
651
662
|
|
|
652
663
|
// Try to ping server
|
|
653
664
|
try {
|
|
654
|
-
const response = await authFetch(`http://localhost:${
|
|
665
|
+
const response = await authFetch(`http://localhost:${port}/status`);
|
|
655
666
|
const data = await response.json();
|
|
656
667
|
console.log(` Status: ${data.status}`);
|
|
657
668
|
} catch (error) {
|
package/mcp/server.mjs
CHANGED
|
@@ -225,6 +225,9 @@ const TOOL_ANNOTATIONS = {
|
|
|
225
225
|
// Identity — read-only introspection of THIS agent's session
|
|
226
226
|
whoami: { title: 'Session identity', readOnlyHint: true, destructiveHint: false, openWorldHint: false, description: 'Return THIS agent session\'s identity: its server-assigned human-readable name (the correlation key between an agent window and its web-app session tab), sessionId, userId, orgId, active projectId, editor label, server URL, and surfaced/alive state. Read-only. Use this — not guesses from the host environment — to report which session you are.' },
|
|
227
227
|
|
|
228
|
+
// Health — call once per session, right after whoami and before any real work
|
|
229
|
+
health: { title: 'Server health', readOnlyHint: true, destructiveHint: false, openWorldHint: true, description: 'Server reachability + installed MCP version/update status. Call this once per session, right after whoami and before doing any real work, so a required update surfaces before you act on stale tool behavior.' },
|
|
230
|
+
|
|
228
231
|
// Projects
|
|
229
232
|
project: { title: 'Projects', readOnlyHint: false, destructiveHint: false, openWorldHint: false, widgetUri: 'ui://widget/drafted-canvas-overview.html', description: 'Manage projects: list (start here), open (bind this agent session to a project — org derives from it), create (org= names where it is born), update, move to another org.' },
|
|
230
233
|
get_org: { title: 'Organization', readOnlyHint: true, destructiveHint: false, openWorldHint: false, description: 'List your orgs, the default org, and Google Drive availability (action="get", default), or fetch installed MCP update instructions (action="update_mcp"). There is no org switching — org derives from the resource you address; creates/searches take org=. When googleDrive.connected is true, strongly prefer Google Workspace frames for documents, sheets, and slides.' },
|
|
@@ -371,6 +374,10 @@ function tool(name, descOrSchema, schemaOrHandler, handler) {
|
|
|
371
374
|
state.currentTool = name;
|
|
372
375
|
trackUmamiEvent(UMAMI_EVENTS.MCP_TOOL_CALLED, { tool: name, projectId: state.projectId || undefined, source: 'mcp' });
|
|
373
376
|
reportInstallationEvent(UMAMI_EVENTS.DRAFTED_MCP_REQUEST, { tool: name });
|
|
377
|
+
if (!hasAnnouncedSubstantiveWork && !NON_SUBSTANTIVE_TOOLS.has(name)) {
|
|
378
|
+
hasAnnouncedSubstantiveWork = true;
|
|
379
|
+
announceSubstantiveWork().catch(() => {});
|
|
380
|
+
}
|
|
374
381
|
try {
|
|
375
382
|
const requiredUpdateError = await getRequiredMcpUpdateError(name, args?.[0] || {});
|
|
376
383
|
if (requiredUpdateError) return err(new Error(requiredUpdateError));
|
|
@@ -1140,15 +1147,27 @@ function getCurrentProjectContext() {
|
|
|
1140
1147
|
return s.projectMeta || { id: s.projectId, slug: null, name: null, orgId: null };
|
|
1141
1148
|
}
|
|
1142
1149
|
|
|
1150
|
+
function scheduleAgentWsRetry() {
|
|
1151
|
+
clearTimeout(agentWsReconnectTimer);
|
|
1152
|
+
agentWsReconnectTimer = setTimeout(() => {
|
|
1153
|
+
connectAgentWs().catch((e) => {
|
|
1154
|
+
console.error('[MCP-WS] Reconnect failed:', e?.message || e);
|
|
1155
|
+
});
|
|
1156
|
+
}, 5000);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1143
1159
|
async function connectAgentWs() {
|
|
1144
1160
|
await ensureSession();
|
|
1145
1161
|
const auth = getAuthHeaders();
|
|
1146
|
-
|
|
1162
|
+
// No session yet (e.g. desktop sign-in still in progress) — retry instead of stranding
|
|
1163
|
+
// the handshake forever, since there's no WebSocket here yet to trigger the close-based
|
|
1164
|
+
// reconnect below.
|
|
1165
|
+
if (!auth.Cookie) { scheduleAgentWsRetry(); return; }
|
|
1147
1166
|
|
|
1148
1167
|
const serverUrl = getServerUrl().replace(/^http/, 'ws');
|
|
1149
1168
|
try {
|
|
1150
1169
|
agentWs = new WebSocket(serverUrl, { headers: auth });
|
|
1151
|
-
} catch { return; }
|
|
1170
|
+
} catch { scheduleAgentWsRetry(); return; }
|
|
1152
1171
|
|
|
1153
1172
|
agentWs.on('open', () => {
|
|
1154
1173
|
console.error('[MCP-WS] Connected');
|
|
@@ -1182,12 +1201,7 @@ async function connectAgentWs() {
|
|
|
1182
1201
|
// and survive server restarts, so reusing the clone keeps this agent's surface (and
|
|
1183
1202
|
// its playful tab name) stable across blips — otherwise every reconnect re-clones a
|
|
1184
1203
|
// fresh session → a NEW greyed tab with a NEW name. A real 401 still triggers re-clone.
|
|
1185
|
-
|
|
1186
|
-
agentWsReconnectTimer = setTimeout(() => {
|
|
1187
|
-
connectAgentWs().catch((e) => {
|
|
1188
|
-
console.error('[MCP-WS] Reconnect failed:', e?.message || e);
|
|
1189
|
-
});
|
|
1190
|
-
}, 5000);
|
|
1204
|
+
scheduleAgentWsRetry();
|
|
1191
1205
|
});
|
|
1192
1206
|
|
|
1193
1207
|
agentWs.on('error', () => {
|
|
@@ -1223,6 +1237,28 @@ async function joinAgentWsRoom(projectId) {
|
|
|
1223
1237
|
}
|
|
1224
1238
|
}
|
|
1225
1239
|
|
|
1240
|
+
// Tools that are pure introspection/sign-in, not "the agent started working" — excluded from
|
|
1241
|
+
// the first-substantive-action ping below so a bare whoami/health/auth never yanks the
|
|
1242
|
+
// desktop app's window to the front.
|
|
1243
|
+
const NON_SUBSTANTIVE_TOOLS = new Set(['whoami', 'health', 'auth']);
|
|
1244
|
+
let hasAnnouncedSubstantiveWork = false;
|
|
1245
|
+
|
|
1246
|
+
// Tell the server this agent has started real work (first call past whoami/health/auth this
|
|
1247
|
+
// process), so it can foreground the desktop app's window. Fire-and-forget — never blocks or
|
|
1248
|
+
// fails the tool call that triggered it.
|
|
1249
|
+
async function announceSubstantiveWork() {
|
|
1250
|
+
if (!agentWs || agentWs.readyState > WebSocket.OPEN) {
|
|
1251
|
+
await connectAgentWs();
|
|
1252
|
+
}
|
|
1253
|
+
if (!agentWs) return;
|
|
1254
|
+
const msg = JSON.stringify({ type: 'agent-active', projectId: getState().projectId || null });
|
|
1255
|
+
if (agentWs.readyState === WebSocket.OPEN) {
|
|
1256
|
+
agentWs.send(msg);
|
|
1257
|
+
} else if (agentWs.readyState === WebSocket.CONNECTING) {
|
|
1258
|
+
agentWs.once('open', () => agentWs.send(msg));
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1226
1262
|
// Clone session and connect WebSocket on startup (delayed to let server be ready).
|
|
1227
1263
|
// Guarded because createMcpServer() runs per HTTP request — the bootstrap must
|
|
1228
1264
|
// fire exactly once per process, not per request.
|
|
@@ -1536,7 +1572,17 @@ if (!isRemote) tool('auth', 'Sign in to Drafted. On a local install the DESKTOP
|
|
|
1536
1572
|
const sid = await waitForBootstrapAuth(Date.now() + 180000);
|
|
1537
1573
|
if (!sid) throw new Error('Timed out waiting for sign-in. Complete sign-in in the Drafted app window, then retry.');
|
|
1538
1574
|
getState().sessionId = null;
|
|
1539
|
-
|
|
1575
|
+
// cloneSession() can fail transiently right after the app plants the bootstrap
|
|
1576
|
+
// session (e.g. the server hasn't finished committing it yet) — retry briefly
|
|
1577
|
+
// before giving up, and never report logged_in unless a session actually landed.
|
|
1578
|
+
let cloned = await cloneSession();
|
|
1579
|
+
for (let attempt = 0; !cloned && attempt < 3; attempt++) {
|
|
1580
|
+
await new Promise((r) => setTimeout(r, 750));
|
|
1581
|
+
cloned = await cloneSession();
|
|
1582
|
+
}
|
|
1583
|
+
if (!cloned || !getState().sessionId) {
|
|
1584
|
+
throw new Error('Signed in, but could not establish a session yet. Retry your request in a moment.');
|
|
1585
|
+
}
|
|
1540
1586
|
connectAgentWs();
|
|
1541
1587
|
return ok({ status: 'logged_in', via: 'desktop-app' });
|
|
1542
1588
|
}
|
|
@@ -1684,11 +1730,35 @@ async function sessionSurfaceBlock() {
|
|
|
1684
1730
|
// Identity: report THIS agent session's own surface identity. Read-only — no state changed.
|
|
1685
1731
|
tool('whoami', 'Return THIS agent session\'s identity: its server-assigned human-readable name (the correlation key between an agent window and its web-app session tab), sessionId, userId, orgId, active projectId, editor label, server URL, and surfaced/alive state. Read-only.', {}, async () => {
|
|
1686
1732
|
try {
|
|
1733
|
+
const block = await sessionSurfaceBlock();
|
|
1734
|
+
// Tell the agent to actually surface its name to the user — returning `name` in the JSON isn't
|
|
1735
|
+
// enough; without an explicit instruction agents rarely say which session they are, so users
|
|
1736
|
+
// can't match them to their tab on the Drafted surface.
|
|
1737
|
+
const instruction = block.name
|
|
1738
|
+
? `You are the session named "${block.name}". Tell the user you're "${block.name}" in your reply so they can match you to your tab on the Drafted surface.`
|
|
1739
|
+
: undefined;
|
|
1687
1740
|
return ok({
|
|
1688
1741
|
server: getServerUrl(),
|
|
1689
1742
|
editor: (process.env.DRAFTED_AGENT_NAME || '').trim() || null,
|
|
1690
1743
|
agentLabel: getAgentLabel(),
|
|
1691
|
-
...
|
|
1744
|
+
...block,
|
|
1745
|
+
...(instruction ? { instruction } : {}),
|
|
1746
|
+
});
|
|
1747
|
+
} catch (error) { return err(error); }
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
// Health: server reachability + installed-MCP staleness, meant to be the second call of a
|
|
1751
|
+
// session (right after whoami, before real work) so a required update surfaces early instead
|
|
1752
|
+
// of depending on an agent remembering to call get_org. Cached per-process — `whoami` stays
|
|
1753
|
+
// network-free between health checks, and repeat `health` calls in one session are free too.
|
|
1754
|
+
tool('health', {}, async () => {
|
|
1755
|
+
try {
|
|
1756
|
+
const mcpUpdate = await getCachedMcpUpdateMetadata();
|
|
1757
|
+
return ok({
|
|
1758
|
+
server: getServerUrl(),
|
|
1759
|
+
ok: mcpUpdate.status !== 'unknown',
|
|
1760
|
+
mcpVersion: PACKAGE_VERSION,
|
|
1761
|
+
mcpUpdate,
|
|
1692
1762
|
});
|
|
1693
1763
|
} catch (error) { return err(error); }
|
|
1694
1764
|
});
|
|
@@ -1705,7 +1775,6 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
|
|
|
1705
1775
|
folder: z.string().nullable().optional().describe('[update] folder name (null to remove from folder)'),
|
|
1706
1776
|
layers: z.array(z.object({}).passthrough()).optional().describe('[update] full layers array replacement. Use ls / to read current layers first.'),
|
|
1707
1777
|
targetOrgId: z.string().optional().describe('[move] destination organization ID. Get org IDs from action=list (each project has an orgId field) or get_org. Both source and target org must include the current user.'),
|
|
1708
|
-
skipBrowser: z.boolean().optional().describe('[open] skip opening/navigating a browser tab (use when the user already has the project open, e.g. from an invite snippet)'),
|
|
1709
1778
|
format: z.string().optional().describe('[export] "files" returns {files:[{path,content}]} paginated via limit/offset (compact=true for paths only) instead of writing a local dir (stdio) or returning a download URL (remote).'),
|
|
1710
1779
|
limit: z.number().optional().describe('[export] max files per page for format="files" (default 100, max 500)'),
|
|
1711
1780
|
offset: z.number().optional().describe('[export] pagination offset for format="files"'),
|
|
@@ -1750,7 +1819,7 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
|
|
|
1750
1819
|
return ok(data, { structuredContent });
|
|
1751
1820
|
}
|
|
1752
1821
|
case 'open': {
|
|
1753
|
-
const { projectId
|
|
1822
|
+
const { projectId } = args;
|
|
1754
1823
|
if (!projectId) throw new Error('projectId required for action=open');
|
|
1755
1824
|
const result = await api('POST', '/api/project/switch', { projectId });
|
|
1756
1825
|
joinAgentWsRoom(projectId);
|
|
@@ -1767,18 +1836,17 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
|
|
|
1767
1836
|
} catch { /* fall back to projectId */ }
|
|
1768
1837
|
setMcpActiveProject(projectId, projectMeta);
|
|
1769
1838
|
const url = `${base}/project/${projectSlug}`;
|
|
1839
|
+
// Surfacing to the user is the focus mechanism's job now (agent-active ping ->
|
|
1840
|
+
// desktop window / notification+glow for browser tabs) — this used to also force
|
|
1841
|
+
// `exec('open <url>')` on the MCP host machine, an unsolicited GUI action that both
|
|
1842
|
+
// duplicated the focus mechanism and did nothing useful for remote/hosted MCP mode
|
|
1843
|
+
// (no GUI to open on Drafted's own server). `url` is still returned below for the
|
|
1844
|
+
// agent/human to open manually.
|
|
1770
1845
|
let navigated = 0;
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
} catch { /* server may not support navigate yet */ }
|
|
1776
|
-
if (navigated === 0) {
|
|
1777
|
-
const { exec } = await import('child_process');
|
|
1778
|
-
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
1779
|
-
exec(`${cmd} ${JSON.stringify(url)}`);
|
|
1780
|
-
}
|
|
1781
|
-
}
|
|
1846
|
+
try {
|
|
1847
|
+
const nav = await api('POST', '/api/project/navigate', { projectId });
|
|
1848
|
+
navigated = nav.navigated || 0;
|
|
1849
|
+
} catch { /* server may not support navigate yet */ }
|
|
1782
1850
|
// G4/G5 auto-inject (locked design): the project's attached skills + anchors
|
|
1783
1851
|
// are pushed into the open response within the per-project context budget,
|
|
1784
1852
|
// replacing the reject-style gate. Prefer the server-computed `priming`
|
|
@@ -2318,12 +2386,23 @@ async function getMcpUpdateMetadata() {
|
|
|
2318
2386
|
mode,
|
|
2319
2387
|
distribution: mode === 'stdio' ? 'npm-stdio' : 'hosted-http',
|
|
2320
2388
|
update: { command: null, helper: null, packageManager: 'npm' },
|
|
2321
|
-
restart: { required: false, guidance: 'Drafted MCP update status is unavailable;
|
|
2389
|
+
restart: { required: false, guidance: 'Drafted MCP update status is unavailable; this call still succeeded.' },
|
|
2322
2390
|
checkedAt: null,
|
|
2323
2391
|
};
|
|
2324
2392
|
}
|
|
2325
2393
|
}
|
|
2326
2394
|
|
|
2395
|
+
// Process-lifetime cache: `health` is meant to be called every session, so avoid a network
|
|
2396
|
+
// round-trip on repeat calls. `get_org` shares the cache too (same underlying data).
|
|
2397
|
+
let mcpUpdateCache = null; // { data, fetchedAt }
|
|
2398
|
+
const MCP_UPDATE_CACHE_MS = 5 * 60_000;
|
|
2399
|
+
async function getCachedMcpUpdateMetadata() {
|
|
2400
|
+
if (mcpUpdateCache && (Date.now() - mcpUpdateCache.fetchedAt) < MCP_UPDATE_CACHE_MS) return mcpUpdateCache.data;
|
|
2401
|
+
const data = await getMcpUpdateMetadata();
|
|
2402
|
+
mcpUpdateCache = { data, fetchedAt: Date.now() };
|
|
2403
|
+
return data;
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2327
2406
|
|
|
2328
2407
|
tool('get_org', {
|
|
2329
2408
|
action: z.enum(['get', 'update_mcp']).optional().describe('Default: "get" returns your orgs, the default org, and Google Drive availability. Use "update_mcp" to get explicit installed stdio MCP update instructions. There is no org switching: org derives from the resource you address (projectId/pageId/skillId), and creates/searches take an explicit org param.'),
|
|
@@ -2347,7 +2426,7 @@ tool('get_org', {
|
|
|
2347
2426
|
const activeOrg = sessionOrgId ? (orgs.find(o => o.id === sessionOrgId) || null) : null;
|
|
2348
2427
|
|
|
2349
2428
|
const googleDrive = await getGoogleDriveAvailability();
|
|
2350
|
-
const mcpUpdate = await
|
|
2429
|
+
const mcpUpdate = await getCachedMcpUpdateMetadata();
|
|
2351
2430
|
|
|
2352
2431
|
let members = [];
|
|
2353
2432
|
if (sessionOrgId) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.14.
|
|
3
|
+
"version": "1.14.3",
|
|
4
4
|
"description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Deploy-seeded starting points for the Minion builder ("presets"). Pure data,
|
|
2
|
+
// shared for every org (no per-org copy). Each preset pre-wires the builder
|
|
3
|
+
// config for a use case; the user then edits any field. `config: null` means
|
|
4
|
+
// the full blank/advanced form. See feature card: contexts/minions/feature-card.
|
|
5
|
+
//
|
|
6
|
+
// A preset's `config` is a partial minion the builder hydrates via showConfig:
|
|
7
|
+
// { description, checklist:[{label,evidence,required}], output:{mode,grouping,format,register} }
|
|
8
|
+
// Drive-only bits (format:google-*, register) degrade to a markdown record when
|
|
9
|
+
// the org has no Google Drive connected — the builder simply hides those fields.
|
|
10
|
+
export const MINION_PRESETS = [
|
|
11
|
+
{
|
|
12
|
+
key: 'reports-sheet',
|
|
13
|
+
name: 'Collect reports → spreadsheet',
|
|
14
|
+
icon: '▤',
|
|
15
|
+
tagline: 'A form people fill out. Each one becomes a document + a new row in a master sheet.',
|
|
16
|
+
config: {
|
|
17
|
+
description: '',
|
|
18
|
+
checklist: [
|
|
19
|
+
{ label: 'Department', evidence: 'text', required: true },
|
|
20
|
+
{ label: 'What happened', evidence: 'text', required: true },
|
|
21
|
+
{ label: 'Attachment (file or photo)', evidence: 'file', required: false },
|
|
22
|
+
],
|
|
23
|
+
output: {
|
|
24
|
+
mode: 'generate',
|
|
25
|
+
grouping: 'frame',
|
|
26
|
+
format: 'google-doc',
|
|
27
|
+
register: { columns: ['Date', 'Department', 'Summary', 'Report link'] },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
key: 'files',
|
|
33
|
+
name: 'Gather files / evidence',
|
|
34
|
+
icon: '▦',
|
|
35
|
+
tagline: 'Collect photos & documents into one organized folder, with a short summary.',
|
|
36
|
+
config: {
|
|
37
|
+
description: '',
|
|
38
|
+
checklist: [
|
|
39
|
+
{ label: 'Files or photos', evidence: 'file', required: true },
|
|
40
|
+
{ label: 'A short note about them', evidence: 'text', required: false },
|
|
41
|
+
],
|
|
42
|
+
output: { mode: 'generate', grouping: 'lane' },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
key: 'survey',
|
|
47
|
+
name: 'Survey / intake form',
|
|
48
|
+
icon: '≡',
|
|
49
|
+
tagline: 'Ask a set of questions. One tidy record per response.',
|
|
50
|
+
config: {
|
|
51
|
+
description: '',
|
|
52
|
+
checklist: [
|
|
53
|
+
{ label: 'Question 1', evidence: 'text', required: true },
|
|
54
|
+
{ label: 'Question 2', evidence: 'text', required: true },
|
|
55
|
+
{ label: 'Question 3', evidence: 'text', required: false },
|
|
56
|
+
],
|
|
57
|
+
output: { mode: 'generate', grouping: 'frame' },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
key: 'blank',
|
|
62
|
+
name: 'Blank (advanced)',
|
|
63
|
+
icon: '+',
|
|
64
|
+
tagline: 'Start from raw settings — full control.',
|
|
65
|
+
config: null,
|
|
66
|
+
},
|
|
67
|
+
];
|