@hegemonart/get-design-done 1.45.0 → 1.47.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.
Files changed (35) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +97 -0
  4. package/README.md +4 -0
  5. package/SKILL.md +5 -1
  6. package/dist/claude-code/.claude/skills/figma-extract/SKILL.md +1 -1
  7. package/dist/claude-code/.claude/skills/graphify/SKILL.md +1 -1
  8. package/dist/claude-code/.claude/skills/list-pins/SKILL.md +27 -0
  9. package/dist/claude-code/.claude/skills/live/SKILL.md +98 -0
  10. package/dist/claude-code/.claude/skills/pin/SKILL.md +37 -0
  11. package/dist/claude-code/.claude/skills/unpin/SKILL.md +31 -0
  12. package/package.json +3 -1
  13. package/reference/live-mode-integration.md +80 -0
  14. package/reference/registry.json +14 -0
  15. package/reference/schemas/events.schema.json +1 -1
  16. package/reference/schemas/live-session.schema.json +64 -0
  17. package/reference/skill-metadata.md +117 -0
  18. package/scripts/lib/live/bandit-feed.cjs +64 -0
  19. package/scripts/lib/live/events.cjs +86 -0
  20. package/scripts/lib/live/harness-mode.cjs +93 -0
  21. package/scripts/lib/live/postcheck.cjs +158 -0
  22. package/scripts/lib/live/runtime.cjs +233 -0
  23. package/scripts/lib/live/scope-guard.cjs +145 -0
  24. package/scripts/lib/live/session-store.cjs +364 -0
  25. package/scripts/lib/manifest/schemas/skills.schema.json +42 -1
  26. package/scripts/lib/manifest/skills.json +415 -83
  27. package/scripts/lib/pin/cli.cjs +145 -0
  28. package/scripts/lib/pin/harness-detect.cjs +75 -0
  29. package/scripts/lib/pin/store.cjs +288 -0
  30. package/skills/figma-extract/SKILL.md +1 -1
  31. package/skills/graphify/SKILL.md +1 -1
  32. package/skills/list-pins/SKILL.md +27 -0
  33. package/skills/live/SKILL.md +98 -0
  34. package/skills/pin/SKILL.md +37 -0
  35. package/skills/unpin/SKILL.md +31 -0
@@ -0,0 +1,364 @@
1
+ 'use strict';
2
+ /**
3
+ * scripts/lib/live/session-store.cjs — Phase 47 (Live Mode) session persistence.
4
+ *
5
+ * The substrate behind `/gdd:live`: the user picks a DOM element on a dev
6
+ * server, the agent generates N design variants, the user accepts/discards, and
7
+ * the session survives a crash / `--resume`. This module owns the per-session
8
+ * record on disk at:
9
+ *
10
+ * <projectRoot>/.design/live-sessions/<session-id>.json
11
+ *
12
+ * Each record is a single JSON document conforming to
13
+ * reference/schemas/live-session.schema.json — `{schema_version, session_id,
14
+ * started_at, ended_at|null, status, url?, dev_server?, events:[...]}` — where
15
+ * `events` is an append-only log of `{kind:'pick'|'generate'|'accept'|'discard',
16
+ * at, ...}` entries.
17
+ *
18
+ * Design constraints (mirrors scripts/lib/pin/store.cjs + ds-arms store):
19
+ * - Pure, dependency-free CommonJS. Only `fs` + `path`. No network.
20
+ * - NO top-level Date.now()/Math.random()/new Date(). Time + id are injected
21
+ * (`now` / `id` / `sessionId`) so every behaviour is deterministic and
22
+ * hermetically testable. The clock is only ever read from caller input.
23
+ * - Atomic writes: contents go to `<dest>.tmp` then fs.renameSync into place,
24
+ * so an interrupted write never leaves a half-written session (and never a
25
+ * stray `.tmp`).
26
+ * - Every entry point takes an explicit `projectRoot`; all paths are built
27
+ * with path.join so the module is cross-platform.
28
+ *
29
+ * Ships in the npm package (scripts/lib/ is in package.json `files`), so it must
30
+ * stay runtime-safe — no dev-only requires.
31
+ */
32
+
33
+ const fs = require('fs');
34
+ const path = require('path');
35
+
36
+ const SCHEMA_VERSION = '47.0';
37
+
38
+ /** Statuses an active session can hold. */
39
+ const STATUS_IN_PROGRESS = 'in_progress';
40
+ const STATUS_COMPLETED = 'completed';
41
+ const STATUS_ABANDONED = 'abandoned';
42
+
43
+ /** Event kinds recognised in the append-only log. */
44
+ const EVENT_KINDS = Object.freeze(['pick', 'generate', 'accept', 'discard']);
45
+
46
+ /** Directory (relative to projectRoot) that holds every session record. */
47
+ const SESSIONS_SUBDIR = path.join('.design', 'live-sessions');
48
+
49
+ /**
50
+ * Absolute path to the live-sessions directory for a project.
51
+ * @param {string} projectRoot
52
+ * @returns {string}
53
+ */
54
+ function sessionsDir(projectRoot) {
55
+ if (!projectRoot) throw new TypeError('session-store: projectRoot is required');
56
+ return path.join(projectRoot, SESSIONS_SUBDIR);
57
+ }
58
+
59
+ /**
60
+ * Absolute path to a single session record.
61
+ * @param {string} projectRoot
62
+ * @param {string} sessionId
63
+ * @returns {string}
64
+ */
65
+ function sessionPath(projectRoot, sessionId) {
66
+ if (!sessionId) throw new TypeError('session-store: sessionId is required');
67
+ // Guard against path traversal in the id — a session id is a flat token, never
68
+ // a path. We reject any separator or `..` rather than silently joining it.
69
+ const id = String(sessionId);
70
+ if (id.includes('/') || id.includes('\\') || id === '.' || id === '..') {
71
+ throw new Error(`session-store: invalid sessionId "${id}" (must not contain path separators)`);
72
+ }
73
+ return path.join(sessionsDir(projectRoot), `${id}.json`);
74
+ }
75
+
76
+ /** Atomic write: write to `<dest>.tmp` then rename into place. */
77
+ function atomicWriteJson(dest, value) {
78
+ const dir = path.dirname(dest);
79
+ fs.mkdirSync(dir, { recursive: true });
80
+ const tmp = `${dest}.tmp`;
81
+ fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
82
+ try {
83
+ fs.renameSync(tmp, dest);
84
+ } catch (e) {
85
+ // Never leave a stray .tmp behind on a failed rename.
86
+ try { fs.unlinkSync(tmp); } catch { /* ignore */ }
87
+ throw e;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Derive a stable session id WITHOUT touching a global clock or RNG. The id is
93
+ * built purely from caller-supplied inputs so it is deterministic in tests.
94
+ *
95
+ * Preference order:
96
+ * 1. explicit `id` (the injectable)
97
+ * 2. a slug of `now` (the injected timestamp) — e.g. "2026-06-03T01:02:03Z"
98
+ * becomes "session-2026-06-03t01-02-03z"
99
+ *
100
+ * @param {{ id?: string, now?: string }} args
101
+ * @returns {string}
102
+ */
103
+ function deriveSessionId(args = {}) {
104
+ if (args.id != null && String(args.id).length) return String(args.id);
105
+ if (args.now != null && String(args.now).length) {
106
+ const slug = String(args.now)
107
+ .toLowerCase()
108
+ .replace(/[^a-z0-9]+/g, '-')
109
+ .replace(/^-+|-+$/g, '');
110
+ if (slug.length) return `session-${slug}`;
111
+ }
112
+ throw new TypeError(
113
+ 'session-store.newSession: provide an explicit sessionId, or inject `id`/`now` to derive one ' +
114
+ '(no internal clock/RNG by design)',
115
+ );
116
+ }
117
+
118
+ /**
119
+ * Create a new session record on disk.
120
+ *
121
+ * @param {object} args
122
+ * @param {string} args.projectRoot
123
+ * @param {string} [args.sessionId] explicit id; if omitted, derived from `id`/`now`
124
+ * @param {string} [args.id] injectable id source (used when sessionId omitted)
125
+ * @param {string} [args.now] ISO timestamp for started_at + id derivation
126
+ * @param {string} [args.url] the page the element was picked from
127
+ * @param {string|object} [args.devServer] dev-server descriptor (url/port/command)
128
+ * @returns {{ sessionId: string, path: string, session: object }}
129
+ */
130
+ function newSession(args = {}) {
131
+ const { projectRoot } = args;
132
+ if (!projectRoot) throw new TypeError('newSession: projectRoot is required');
133
+
134
+ const sessionId = args.sessionId != null && String(args.sessionId).length
135
+ ? String(args.sessionId)
136
+ : deriveSessionId({ id: args.id, now: args.now });
137
+
138
+ const startedAt = args.now != null && String(args.now).length ? String(args.now) : null;
139
+ if (!startedAt) {
140
+ throw new TypeError(
141
+ 'newSession: `now` (ISO timestamp) is required for started_at (no internal clock by design)',
142
+ );
143
+ }
144
+
145
+ const session = {
146
+ schema_version: SCHEMA_VERSION,
147
+ session_id: sessionId,
148
+ status: STATUS_IN_PROGRESS,
149
+ started_at: startedAt,
150
+ ended_at: null,
151
+ events: [],
152
+ };
153
+ if (args.url != null) session.url = String(args.url);
154
+ if (args.devServer != null) session.dev_server = args.devServer;
155
+
156
+ const dest = sessionPath(projectRoot, sessionId);
157
+ atomicWriteJson(dest, session);
158
+ return { sessionId, path: dest, session };
159
+ }
160
+
161
+ /**
162
+ * Load a session record, or null if it does not exist / is unparseable.
163
+ * @param {{ projectRoot: string, sessionId: string }} args
164
+ * @returns {object|null}
165
+ */
166
+ function loadSession(args = {}) {
167
+ const { projectRoot, sessionId } = args;
168
+ if (!projectRoot) throw new TypeError('loadSession: projectRoot is required');
169
+ if (!sessionId) throw new TypeError('loadSession: sessionId is required');
170
+ const file = sessionPath(projectRoot, sessionId);
171
+ let raw;
172
+ try {
173
+ raw = fs.readFileSync(file, 'utf8');
174
+ } catch {
175
+ return null;
176
+ }
177
+ try {
178
+ const data = JSON.parse(raw);
179
+ if (!Array.isArray(data.events)) data.events = [];
180
+ return data;
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Append an event to a session's `events` log and atomically rewrite the record.
188
+ *
189
+ * The event MUST carry a recognised `kind` and an `at` timestamp — the `at`
190
+ * comes from the caller (no internal clock). Any extra payload fields (e.g.
191
+ * a `generate` event's variant list, a `pick` event's selector + implicated
192
+ * files) are preserved verbatim.
193
+ *
194
+ * @param {object} args
195
+ * @param {string} args.projectRoot
196
+ * @param {string} args.sessionId
197
+ * @param {object} args.event { kind, at, ... }
198
+ * @returns {{ sessionId: string, path: string, session: object, eventIndex: number }}
199
+ */
200
+ function appendEvent(args = {}) {
201
+ const { projectRoot, sessionId, event } = args;
202
+ if (!projectRoot) throw new TypeError('appendEvent: projectRoot is required');
203
+ if (!sessionId) throw new TypeError('appendEvent: sessionId is required');
204
+ if (!event || typeof event !== 'object') throw new TypeError('appendEvent: event object is required');
205
+ if (!EVENT_KINDS.includes(event.kind)) {
206
+ throw new Error(
207
+ `appendEvent: unknown event.kind "${event.kind}" (expected one of ${EVENT_KINDS.join(', ')})`,
208
+ );
209
+ }
210
+ if (event.at == null || !String(event.at).length) {
211
+ throw new TypeError('appendEvent: event.at (ISO timestamp) is required (no internal clock by design)');
212
+ }
213
+
214
+ const session = loadSession({ projectRoot, sessionId });
215
+ if (!session) {
216
+ throw new Error(`appendEvent: no session "${sessionId}" under ${sessionsDir(projectRoot)}`);
217
+ }
218
+ // Normalise the stored event so `kind`/`at` lead, then spread the rest.
219
+ const stored = { kind: event.kind, at: String(event.at) };
220
+ for (const [k, v] of Object.entries(event)) {
221
+ if (k === 'kind' || k === 'at') continue;
222
+ stored[k] = v;
223
+ }
224
+ session.events.push(stored);
225
+
226
+ const dest = sessionPath(projectRoot, sessionId);
227
+ atomicWriteJson(dest, session);
228
+ return { sessionId, path: dest, session, eventIndex: session.events.length - 1 };
229
+ }
230
+
231
+ /** The last event in a session, or null if none. */
232
+ function lastEventOf(session) {
233
+ if (!session || !Array.isArray(session.events) || session.events.length === 0) return null;
234
+ return session.events[session.events.length - 1];
235
+ }
236
+
237
+ /**
238
+ * List every session for a project, newest activity first, for resume offers.
239
+ *
240
+ * Each entry: `{ sessionId, status, started_at, lastEvent }`. Unparseable /
241
+ * non-session files are skipped. Returns [] when the directory is absent.
242
+ *
243
+ * @param {string} projectRoot
244
+ * @returns {Array<{ sessionId: string, status: string, started_at: string|null, lastEvent: object|null }>}
245
+ */
246
+ function listSessions(projectRoot) {
247
+ if (!projectRoot) throw new TypeError('listSessions: projectRoot is required');
248
+ const dir = sessionsDir(projectRoot);
249
+ let entries;
250
+ try {
251
+ entries = fs.readdirSync(dir, { withFileTypes: true });
252
+ } catch {
253
+ return [];
254
+ }
255
+ const out = [];
256
+ for (const e of entries) {
257
+ if (!e.isFile()) continue;
258
+ if (!e.name.endsWith('.json')) continue;
259
+ if (e.name.endsWith('.tmp')) continue;
260
+ const sessionId = e.name.slice(0, -'.json'.length);
261
+ const session = loadSession({ projectRoot, sessionId });
262
+ if (!session || session.session_id == null) continue;
263
+ out.push({
264
+ sessionId: session.session_id,
265
+ status: session.status || null,
266
+ started_at: session.started_at || null,
267
+ lastEvent: lastEventOf(session),
268
+ });
269
+ }
270
+ // Stable order: most recently started first; ties broken by id for determinism.
271
+ out.sort((a, b) => {
272
+ const sa = a.started_at || '';
273
+ const sb = b.started_at || '';
274
+ if (sa === sb) return a.sessionId.localeCompare(b.sessionId);
275
+ return sa < sb ? 1 : -1;
276
+ });
277
+ return out;
278
+ }
279
+
280
+ /**
281
+ * Resume information for a single session, so the skill can offer
282
+ * "continue from <last_event>" vs "start fresh".
283
+ *
284
+ * @param {{ projectRoot: string, sessionId: string }} args
285
+ * @returns {{ canResume: boolean, lastEvent: object|null, summary: string }}
286
+ */
287
+ function resumeInfo(args = {}) {
288
+ const { projectRoot, sessionId } = args;
289
+ if (!projectRoot) throw new TypeError('resumeInfo: projectRoot is required');
290
+ if (!sessionId) throw new TypeError('resumeInfo: sessionId is required');
291
+
292
+ const session = loadSession({ projectRoot, sessionId });
293
+ if (!session) {
294
+ return { canResume: false, lastEvent: null, summary: `no session "${sessionId}"` };
295
+ }
296
+ const last = lastEventOf(session);
297
+ const eventCount = Array.isArray(session.events) ? session.events.length : 0;
298
+ // Only in-progress sessions can be resumed; completed/abandoned ones are done.
299
+ const canResume = session.status === STATUS_IN_PROGRESS;
300
+
301
+ let summary;
302
+ if (!canResume) {
303
+ summary = `session "${sessionId}" is ${session.status} — start fresh`;
304
+ } else if (last) {
305
+ summary = `continue from "${last.kind}" (${eventCount} event${eventCount === 1 ? '' : 's'} recorded)`;
306
+ } else {
307
+ summary = `session "${sessionId}" started but has no events yet — continue or start fresh`;
308
+ }
309
+ return { canResume, lastEvent: last, summary };
310
+ }
311
+
312
+ /**
313
+ * Close a session: set `ended_at` and a terminal status.
314
+ *
315
+ * @param {object} args
316
+ * @param {string} args.projectRoot
317
+ * @param {string} args.sessionId
318
+ * @param {('completed'|'abandoned')} args.status
319
+ * @param {string} args.now ISO timestamp for ended_at (no internal clock)
320
+ * @returns {{ sessionId: string, path: string, session: object }}
321
+ */
322
+ function endSession(args = {}) {
323
+ const { projectRoot, sessionId } = args;
324
+ if (!projectRoot) throw new TypeError('endSession: projectRoot is required');
325
+ if (!sessionId) throw new TypeError('endSession: sessionId is required');
326
+ const status = args.status || STATUS_COMPLETED;
327
+ if (status !== STATUS_COMPLETED && status !== STATUS_ABANDONED) {
328
+ throw new Error(`endSession: status must be "${STATUS_COMPLETED}" or "${STATUS_ABANDONED}", got "${status}"`);
329
+ }
330
+ if (args.now == null || !String(args.now).length) {
331
+ throw new TypeError('endSession: `now` (ISO timestamp) is required for ended_at (no internal clock by design)');
332
+ }
333
+
334
+ const session = loadSession({ projectRoot, sessionId });
335
+ if (!session) {
336
+ throw new Error(`endSession: no session "${sessionId}" under ${sessionsDir(projectRoot)}`);
337
+ }
338
+ session.status = status;
339
+ session.ended_at = String(args.now);
340
+
341
+ const dest = sessionPath(projectRoot, sessionId);
342
+ atomicWriteJson(dest, session);
343
+ return { sessionId, path: dest, session };
344
+ }
345
+
346
+ module.exports = {
347
+ newSession,
348
+ appendEvent,
349
+ loadSession,
350
+ listSessions,
351
+ resumeInfo,
352
+ endSession,
353
+ // exported for callers + tests
354
+ sessionsDir,
355
+ sessionPath,
356
+ deriveSessionId,
357
+ lastEventOf,
358
+ SCHEMA_VERSION,
359
+ SESSIONS_SUBDIR,
360
+ EVENT_KINDS,
361
+ STATUS_IN_PROGRESS,
362
+ STATUS_COMPLETED,
363
+ STATUS_ABANDONED,
364
+ };
@@ -24,7 +24,48 @@
24
24
  "properties": {
25
25
  "name": {
26
26
  "type": "string",
27
- "minLength": 1
27
+ "minLength": 1,
28
+ "description": "Skill id = source/skills/<name>/ directory name."
29
+ },
30
+ "frontmatter_name": {
31
+ "type": "string",
32
+ "description": "Frontmatter name: value when it is not the default gdd-<name>."
33
+ },
34
+ "description": {
35
+ "type": "string",
36
+ "minLength": 20,
37
+ "maxLength": 1024,
38
+ "description": "Skill description (Phase 28.5 budget: 20..1024 chars)."
39
+ },
40
+ "argument_hint": {
41
+ "type": "string"
42
+ },
43
+ "tools": {
44
+ "type": "string",
45
+ "description": "Comma-separated allow-list emitted as the frontmatter tools: line."
46
+ },
47
+ "user_invocable": {
48
+ "type": "boolean"
49
+ },
50
+ "disable_model_invocation": {
51
+ "type": "boolean"
52
+ },
53
+ "registered_in_phase": {
54
+ "type": "string"
55
+ },
56
+ "aliases": {
57
+ "type": "array",
58
+ "items": {
59
+ "type": "string"
60
+ },
61
+ "description": "Reserved for pin shortcuts; honored by the pin metadata catalogue."
62
+ },
63
+ "extra_frontmatter": {
64
+ "type": "array",
65
+ "items": {
66
+ "type": "string"
67
+ },
68
+ "description": "Non-managed frontmatter lines preserved verbatim (color, model, writes:, ...)."
28
69
  }
29
70
  }
30
71
  }