@the-bearded-bear/claude-craft 7.35.0 → 8.1.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 (30) hide show
  1. package/Dev/scripts/install-php-rules.sh +1 -1
  2. package/Dev/scripts/validate-skills-spec.sh +121 -0
  3. package/README.md +13 -11
  4. package/cli/index.js +6 -0
  5. package/cli/kanban/client/index.html +17 -0
  6. package/cli/kanban/client/src/App.svelte +106 -0
  7. package/cli/kanban/client/src/app.css +175 -0
  8. package/cli/kanban/client/src/lib/router.svelte.js +19 -0
  9. package/cli/kanban/client/src/lib/store.svelte.js +132 -0
  10. package/cli/kanban/client/src/main.js +6 -0
  11. package/cli/kanban/client/src/views/BacklogView.svelte +344 -0
  12. package/cli/kanban/client/src/views/BurndownView.svelte +189 -0
  13. package/cli/kanban/client/src/views/DepsView.svelte +334 -0
  14. package/cli/kanban/client/src/views/DocsView.svelte +451 -0
  15. package/cli/kanban/client/src/views/KanbanView.svelte +227 -0
  16. package/cli/kanban/client/vite.config.js +21 -0
  17. package/cli/kanban/server/app.js +201 -0
  18. package/cli/kanban/server/middleware/security.js +53 -0
  19. package/cli/kanban/server/services/event-bus.js +33 -0
  20. package/cli/kanban/server/services/file-scanner.js +113 -0
  21. package/cli/kanban/server/services/file-watcher.js +68 -0
  22. package/cli/kanban/server/services/file-writer.js +107 -0
  23. package/cli/kanban/server/services/frontmatter.js +55 -0
  24. package/cli/kanban/server/services/repository.js +173 -0
  25. package/cli/kanban/server/services/sprint-cache.js +208 -0
  26. package/cli/kanban/server/services/state-machine.js +156 -0
  27. package/cli/kanban/shared/schemas.js +127 -0
  28. package/cli/lib/help.js +4 -0
  29. package/cli/lib/kanban.js +103 -0
  30. package/package.json +21 -3
@@ -0,0 +1,451 @@
1
+ <script>
2
+ import { marked } from 'marked';
3
+ import DOMPurify from 'dompurify';
4
+
5
+ let docs = $state([]);
6
+ let selectedDoc = $state(null);
7
+ let docContent = $state('');
8
+ let loading = $state(true);
9
+ let loadingContent = $state(false);
10
+ let error = $state(null);
11
+ let contentContainer = $state(null);
12
+
13
+ const tree = $derived.by(() => {
14
+ const root = [];
15
+ const groups = {};
16
+
17
+ for (const doc of docs) {
18
+ const parts = doc.rel.split('/');
19
+ if (parts.length === 1) {
20
+ root.push(doc);
21
+ } else {
22
+ const topLevel = parts[0];
23
+ if (!groups[topLevel]) {
24
+ groups[topLevel] = { name: topLevel, children: [] };
25
+ }
26
+ groups[topLevel].children.push(doc);
27
+ }
28
+ }
29
+
30
+ return { root, groups: Object.values(groups) };
31
+ });
32
+
33
+ async function loadDocs() {
34
+ loading = true;
35
+ error = null;
36
+ try {
37
+ const res = await fetch('/api/docs');
38
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
39
+ const data = await res.json();
40
+ docs = data.docs || [];
41
+ } catch (err) {
42
+ error = err.message;
43
+ } finally {
44
+ loading = false;
45
+ }
46
+ }
47
+
48
+ async function selectDoc(doc) {
49
+ selectedDoc = doc;
50
+ loadingContent = true;
51
+ try {
52
+ const res = await fetch(`/api/docs/${encodeURIComponent(doc.rel)}`);
53
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
54
+ docContent = await res.text();
55
+ } catch (err) {
56
+ docContent = `Error loading document: ${err.message}`;
57
+ } finally {
58
+ loadingContent = false;
59
+ }
60
+ }
61
+
62
+ const renderedContent = $derived.by(() => {
63
+ if (!docContent) return '';
64
+ try {
65
+ const html = marked.parse(docContent);
66
+ return DOMPurify.sanitize(html);
67
+ } catch {
68
+ return '<p>Error rendering markdown</p>';
69
+ }
70
+ });
71
+
72
+ function copyContent() {
73
+ if (docContent && navigator.clipboard) {
74
+ navigator.clipboard.writeText(docContent).then(() => {
75
+ // Success - could show a toast but we don't have access to store here
76
+ });
77
+ }
78
+ }
79
+
80
+ $effect(() => {
81
+ loadDocs();
82
+ });
83
+
84
+ $effect(() => {
85
+ if (!contentContainer || !renderedContent) return;
86
+
87
+ const links = contentContainer.querySelectorAll('a');
88
+ for (const link of links) {
89
+ const text = link.textContent?.trim() || '';
90
+ if (/^US-\d+$/.test(text)) {
91
+ link.href = '#/kanban';
92
+ link.dataset.storyId = text;
93
+ link.addEventListener('click', (e) => {
94
+ e.preventDefault();
95
+ window.location.hash = '#/kanban';
96
+ });
97
+ }
98
+ }
99
+ });
100
+
101
+ let expandedGroups = $state(new Set());
102
+
103
+ function toggleGroup(groupName) {
104
+ if (expandedGroups.has(groupName)) {
105
+ expandedGroups.delete(groupName);
106
+ } else {
107
+ expandedGroups.add(groupName);
108
+ }
109
+ expandedGroups = new Set(expandedGroups);
110
+ }
111
+ </script>
112
+
113
+ <div class="docs-container">
114
+ <aside class="docs-sidebar">
115
+ {#if loading}
116
+ <div class="sidebar-loading">Loading...</div>
117
+ {:else if error}
118
+ <div class="sidebar-error">Error: {error}</div>
119
+ {:else}
120
+ <nav class="docs-tree" aria-label="Document navigation">
121
+ {#each tree.root as doc}
122
+ <button
123
+ class="tree-item"
124
+ class:active={selectedDoc?.rel === doc.rel}
125
+ onclick={() => selectDoc(doc)}
126
+ tabindex="0"
127
+ onkeydown={(e) => {
128
+ if (e.key === 'Enter' || e.key === ' ') {
129
+ e.preventDefault();
130
+ selectDoc(doc);
131
+ }
132
+ }}
133
+ >
134
+ {doc.rel}
135
+ </button>
136
+ {/each}
137
+
138
+ {#each tree.groups as group}
139
+ <div class="tree-group">
140
+ <button
141
+ class="group-header"
142
+ onclick={() => toggleGroup(group.name)}
143
+ aria-expanded={expandedGroups.has(group.name)}
144
+ tabindex="0"
145
+ onkeydown={(e) => {
146
+ if (e.key === 'Enter' || e.key === ' ') {
147
+ e.preventDefault();
148
+ toggleGroup(group.name);
149
+ }
150
+ }}
151
+ >
152
+ <span class="group-icon">
153
+ {expandedGroups.has(group.name) ? '▼' : '▶'}
154
+ </span>
155
+ {group.name}/
156
+ </button>
157
+ {#if expandedGroups.has(group.name)}
158
+ <div class="group-children">
159
+ {#each group.children as doc}
160
+ <button
161
+ class="tree-item nested"
162
+ class:active={selectedDoc?.rel === doc.rel}
163
+ onclick={() => selectDoc(doc)}
164
+ tabindex="0"
165
+ onkeydown={(e) => {
166
+ if (e.key === 'Enter' || e.key === ' ') {
167
+ e.preventDefault();
168
+ selectDoc(doc);
169
+ }
170
+ }}
171
+ >
172
+ {doc.rel.split('/').slice(1).join('/')}
173
+ </button>
174
+ {/each}
175
+ </div>
176
+ {/if}
177
+ </div>
178
+ {/each}
179
+ </nav>
180
+ {/if}
181
+ </aside>
182
+
183
+ <main class="docs-content">
184
+ {#if !selectedDoc}
185
+ <div class="empty">Select a document</div>
186
+ {:else}
187
+ <header class="content-header">
188
+ <h1>{selectedDoc.rel}</h1>
189
+ <button class="copy-btn" onclick={copyContent} aria-label="Copy content">
190
+ Copy
191
+ </button>
192
+ </header>
193
+
194
+ {#if loadingContent}
195
+ <div class="content-loading">Loading document...</div>
196
+ {:else}
197
+ <div class="markdown-body" bind:this={contentContainer}>
198
+ {@html renderedContent}
199
+ </div>
200
+ {/if}
201
+ {/if}
202
+ </main>
203
+ </div>
204
+
205
+ <style>
206
+ .docs-container {
207
+ display: flex;
208
+ gap: 0;
209
+ height: 100%;
210
+ overflow: hidden;
211
+ }
212
+
213
+ .docs-sidebar {
214
+ width: 220px;
215
+ background: var(--bg-sidebar);
216
+ border-right: 1px solid var(--border);
217
+ overflow-y: auto;
218
+ padding: 12px 8px;
219
+ }
220
+
221
+ .sidebar-loading,
222
+ .sidebar-error {
223
+ padding: 12px;
224
+ text-align: center;
225
+ font-size: 12px;
226
+ color: var(--fg-dim);
227
+ }
228
+
229
+ .sidebar-error {
230
+ color: var(--danger);
231
+ }
232
+
233
+ .docs-tree {
234
+ display: flex;
235
+ flex-direction: column;
236
+ gap: 2px;
237
+ }
238
+
239
+ .tree-item,
240
+ .group-header {
241
+ background: none;
242
+ border: none;
243
+ padding: 6px 10px;
244
+ text-align: left;
245
+ cursor: pointer;
246
+ border-radius: var(--radius);
247
+ font-size: 13px;
248
+ width: 100%;
249
+ transition: background 0.1s;
250
+ }
251
+
252
+ .tree-item {
253
+ color: var(--fg);
254
+ }
255
+
256
+ .tree-item:hover,
257
+ .group-header:hover {
258
+ background: var(--border);
259
+ }
260
+
261
+ .tree-item.active {
262
+ background: var(--accent);
263
+ color: var(--accent-fg);
264
+ font-weight: 600;
265
+ }
266
+
267
+ .tree-item:focus,
268
+ .group-header:focus {
269
+ outline: 2px solid var(--accent);
270
+ outline-offset: -2px;
271
+ }
272
+
273
+ .tree-item.nested {
274
+ padding-left: 24px;
275
+ font-size: 12px;
276
+ }
277
+
278
+ .group-header {
279
+ display: flex;
280
+ align-items: center;
281
+ gap: 4px;
282
+ font-weight: 600;
283
+ color: var(--fg-dim);
284
+ }
285
+
286
+ .group-icon {
287
+ font-size: 10px;
288
+ width: 12px;
289
+ display: inline-block;
290
+ }
291
+
292
+ .group-children {
293
+ display: flex;
294
+ flex-direction: column;
295
+ gap: 2px;
296
+ }
297
+
298
+ .docs-content {
299
+ flex: 1;
300
+ overflow-y: auto;
301
+ padding: 20px;
302
+ background: var(--bg);
303
+ }
304
+
305
+ .content-header {
306
+ display: flex;
307
+ justify-content: space-between;
308
+ align-items: center;
309
+ margin-bottom: 20px;
310
+ padding-bottom: 12px;
311
+ border-bottom: 1px solid var(--border);
312
+ }
313
+
314
+ .content-header h1 {
315
+ font-size: 20px;
316
+ font-weight: 600;
317
+ margin: 0;
318
+ color: var(--fg);
319
+ }
320
+
321
+ .copy-btn {
322
+ background: var(--bg-elev);
323
+ border: 1px solid var(--border);
324
+ padding: 6px 12px;
325
+ border-radius: var(--radius);
326
+ cursor: pointer;
327
+ font-size: 12px;
328
+ font-weight: 600;
329
+ color: var(--fg);
330
+ transition: background 0.1s;
331
+ }
332
+
333
+ .copy-btn:hover {
334
+ background: var(--border);
335
+ }
336
+
337
+ .copy-btn:active {
338
+ transform: translateY(1px);
339
+ }
340
+
341
+ .content-loading {
342
+ text-align: center;
343
+ padding: 40px;
344
+ color: var(--fg-dim);
345
+ }
346
+
347
+ .markdown-body {
348
+ line-height: 1.7;
349
+ }
350
+
351
+ .markdown-body :global(h1) {
352
+ font-size: 28px;
353
+ font-weight: 700;
354
+ margin: 24px 0 16px;
355
+ color: var(--fg);
356
+ border-bottom: 1px solid var(--border);
357
+ padding-bottom: 8px;
358
+ }
359
+
360
+ .markdown-body :global(h2) {
361
+ font-size: 22px;
362
+ font-weight: 600;
363
+ margin: 20px 0 12px;
364
+ color: var(--fg);
365
+ }
366
+
367
+ .markdown-body :global(h3) {
368
+ font-size: 18px;
369
+ font-weight: 600;
370
+ margin: 16px 0 10px;
371
+ color: var(--fg);
372
+ }
373
+
374
+ .markdown-body :global(p) {
375
+ margin: 0 0 12px;
376
+ color: var(--fg);
377
+ }
378
+
379
+ .markdown-body :global(code) {
380
+ background: var(--bg-sidebar);
381
+ padding: 2px 4px;
382
+ border-radius: 3px;
383
+ font-family: var(--mono);
384
+ font-size: 0.9em;
385
+ }
386
+
387
+ .markdown-body :global(pre) {
388
+ background: var(--bg-sidebar);
389
+ padding: 12px;
390
+ border-radius: var(--radius);
391
+ overflow-x: auto;
392
+ margin: 0 0 12px;
393
+ border: 1px solid var(--border);
394
+ }
395
+
396
+ .markdown-body :global(pre code) {
397
+ background: none;
398
+ padding: 0;
399
+ }
400
+
401
+ .markdown-body :global(a) {
402
+ color: var(--accent);
403
+ text-decoration: underline;
404
+ }
405
+
406
+ .markdown-body :global(a:hover) {
407
+ opacity: 0.8;
408
+ }
409
+
410
+ .markdown-body :global(ul),
411
+ .markdown-body :global(ol) {
412
+ margin: 0 0 12px;
413
+ padding-left: 24px;
414
+ }
415
+
416
+ .markdown-body :global(li) {
417
+ margin: 4px 0;
418
+ }
419
+
420
+ .markdown-body :global(table) {
421
+ border-collapse: collapse;
422
+ width: 100%;
423
+ margin: 0 0 12px;
424
+ }
425
+
426
+ .markdown-body :global(th),
427
+ .markdown-body :global(td) {
428
+ border: 1px solid var(--border);
429
+ padding: 8px 12px;
430
+ text-align: left;
431
+ }
432
+
433
+ .markdown-body :global(th) {
434
+ background: var(--bg-sidebar);
435
+ font-weight: 600;
436
+ }
437
+
438
+ .markdown-body :global(blockquote) {
439
+ border-left: 3px solid var(--accent);
440
+ padding-left: 12px;
441
+ margin: 0 0 12px;
442
+ color: var(--fg-dim);
443
+ font-style: italic;
444
+ }
445
+
446
+ .markdown-body :global(hr) {
447
+ border: none;
448
+ border-top: 1px solid var(--border);
449
+ margin: 20px 0;
450
+ }
451
+ </style>
@@ -0,0 +1,227 @@
1
+ <script>
2
+ import { store, patchStatus } from '../lib/store.svelte.js';
3
+
4
+ const COLUMNS = [
5
+ { key: 'backlog', label: 'Backlog' },
6
+ { key: 'ready-for-dev', label: 'Ready for Dev' },
7
+ { key: 'in-progress', label: 'In Progress' },
8
+ { key: 'review', label: 'Review' },
9
+ { key: 'done', label: 'Done' },
10
+ { key: 'blocked', label: 'Blocked' },
11
+ ];
12
+
13
+ const storiesByStatus = $derived.by(() => {
14
+ const map = Object.fromEntries(COLUMNS.map((c) => [c.key, []]));
15
+ for (const s of store.stories) (map[s.status] ??= []).push(s);
16
+ return map;
17
+ });
18
+
19
+ function tddBadge(phase) {
20
+ if (phase === 'red') return { char: '●', color: 'var(--danger)', label: 'red' };
21
+ if (phase === 'green') return { char: '●', color: 'var(--ok)', label: 'green' };
22
+ if (phase === 'refactor') return { char: '●', color: 'var(--info)', label: 'refactor' };
23
+ if (phase === 'done') return { char: '✓', color: 'var(--ok)', label: 'done' };
24
+ return null;
25
+ }
26
+
27
+ function priorityColor(p) {
28
+ return p === 'must' ? 'var(--danger)' : p === 'should' ? 'var(--warn)' : p === 'could' ? 'var(--info)' : 'var(--fg-dim)';
29
+ }
30
+
31
+ let dragId = $state(null);
32
+
33
+ function onDragStart(e, id) {
34
+ dragId = id;
35
+ e.dataTransfer.effectAllowed = 'move';
36
+ e.dataTransfer.setData('text/plain', id);
37
+ }
38
+
39
+ function onDragOver(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }
40
+
41
+ async function onDrop(e, targetStatus) {
42
+ e.preventDefault();
43
+ const id = e.dataTransfer.getData('text/plain') || dragId;
44
+ dragId = null;
45
+ if (!id) return;
46
+ const story = store.stories.find((s) => s.id === id);
47
+ if (!story || story.status === targetStatus) return;
48
+ const body = { status: targetStatus };
49
+ if (targetStatus === 'blocked') {
50
+ const reason = window.prompt('Blocked reason?');
51
+ if (!reason) return;
52
+ body.blocked_reason = reason;
53
+ }
54
+ try { await patchStatus(id, body); } catch { /* toast already shown */ }
55
+ }
56
+ </script>
57
+
58
+ <div class="board">
59
+ {#each COLUMNS as col}
60
+ <section
61
+ class="column"
62
+ aria-label={col.label}
63
+ ondragover={onDragOver}
64
+ ondrop={(e) => onDrop(e, col.key)}
65
+ >
66
+ <header class="column-header">
67
+ <span>{col.label}</span>
68
+ <span class="count">{storiesByStatus[col.key].length}</span>
69
+ </header>
70
+ <div class="column-body">
71
+ {#each storiesByStatus[col.key] as s (s.id)}
72
+ {@const tdd = tddBadge(s.tdd_phase)}
73
+ <article
74
+ class="card"
75
+ draggable="true"
76
+ ondragstart={(e) => onDragStart(e, s.id)}
77
+ aria-label="{s.id} {s.title}"
78
+ >
79
+ <header class="card-top">
80
+ <span class="id">{s.id}</span>
81
+ <span class="priority" style="color: {priorityColor(s.priority)}">{s.priority}</span>
82
+ <span class="points">{s.story_points}p</span>
83
+ {#if tdd}<span class="tdd" style="color: {tdd.color}" title="TDD: {tdd.label}">{tdd.char}</span>{/if}
84
+ </header>
85
+ <div class="title">{s.title}</div>
86
+ <div class="meta">
87
+ {#if s.epic_id}<span class="epic">{s.epic_id}</span>{/if}
88
+ {#if s.persona}<span class="persona">{s.persona}</span>{/if}
89
+ </div>
90
+ {#if s.tasks?.total > 0}
91
+ <div class="progress" aria-label="tasks progress">
92
+ <div class="bar" style="width: {(s.tasks.completed / s.tasks.total) * 100}%"></div>
93
+ <span class="progress-label">{s.tasks.completed}/{s.tasks.total}</span>
94
+ </div>
95
+ {/if}
96
+ <footer class="card-foot">
97
+ {#if s.assigned_to}
98
+ <span class="assignee" title={s.assigned_to}>{s.assigned_to.slice(0, 2).toUpperCase()}</span>
99
+ {/if}
100
+ {#if s.status === 'blocked' && s.blocked_reason}
101
+ <span class="blocked" title={s.blocked_reason}>⚠ blocked</span>
102
+ {/if}
103
+ </footer>
104
+ </article>
105
+ {/each}
106
+ </div>
107
+ </section>
108
+ {/each}
109
+ </div>
110
+
111
+ <style>
112
+ .board {
113
+ display: flex;
114
+ gap: 12px;
115
+ min-height: 100%;
116
+ overflow-x: auto;
117
+ }
118
+ .column {
119
+ flex: 0 0 280px;
120
+ background: var(--bg-sidebar);
121
+ border: 1px solid var(--border);
122
+ border-radius: var(--radius);
123
+ display: flex;
124
+ flex-direction: column;
125
+ }
126
+ .column-header {
127
+ display: flex;
128
+ justify-content: space-between;
129
+ align-items: center;
130
+ padding: 10px 12px;
131
+ font-weight: 600;
132
+ font-size: 12px;
133
+ text-transform: uppercase;
134
+ letter-spacing: 0.04em;
135
+ color: var(--fg-dim);
136
+ border-bottom: 1px solid var(--border);
137
+ }
138
+ .count {
139
+ background: var(--bg-elev);
140
+ border-radius: 10px;
141
+ padding: 2px 8px;
142
+ font-size: 11px;
143
+ }
144
+ .column-body {
145
+ padding: 8px;
146
+ display: flex;
147
+ flex-direction: column;
148
+ gap: 8px;
149
+ min-height: 120px;
150
+ }
151
+ .card {
152
+ background: var(--bg-elev);
153
+ border: 1px solid var(--border);
154
+ border-radius: var(--radius);
155
+ padding: 10px;
156
+ cursor: grab;
157
+ box-shadow: var(--shadow);
158
+ }
159
+ .card:active { cursor: grabbing; }
160
+ .card:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
161
+ .card-top {
162
+ display: flex;
163
+ align-items: center;
164
+ gap: 6px;
165
+ font-size: 11px;
166
+ margin-bottom: 4px;
167
+ }
168
+ .card-top .id {
169
+ font-family: var(--mono);
170
+ font-weight: 600;
171
+ }
172
+ .card-top .priority { text-transform: uppercase; font-weight: 600; }
173
+ .card-top .points {
174
+ margin-left: auto;
175
+ font-family: var(--mono);
176
+ color: var(--fg-dim);
177
+ }
178
+ .card-top .tdd { font-size: 14px; }
179
+ .title { font-weight: 500; margin-bottom: 4px; }
180
+ .meta {
181
+ display: flex;
182
+ gap: 6px;
183
+ font-size: 11px;
184
+ color: var(--fg-dim);
185
+ font-family: var(--mono);
186
+ margin-bottom: 6px;
187
+ }
188
+ .progress {
189
+ position: relative;
190
+ background: var(--bg-sidebar);
191
+ height: 6px;
192
+ border-radius: 3px;
193
+ overflow: hidden;
194
+ margin-bottom: 6px;
195
+ }
196
+ .bar { background: var(--accent); height: 100%; }
197
+ .progress-label {
198
+ position: absolute;
199
+ right: 0;
200
+ top: -14px;
201
+ font-size: 10px;
202
+ color: var(--fg-dim);
203
+ }
204
+ .card-foot {
205
+ display: flex;
206
+ align-items: center;
207
+ gap: 6px;
208
+ font-size: 11px;
209
+ }
210
+ .assignee {
211
+ width: 20px;
212
+ height: 20px;
213
+ border-radius: 50%;
214
+ background: var(--accent);
215
+ color: var(--accent-fg);
216
+ display: inline-flex;
217
+ align-items: center;
218
+ justify-content: center;
219
+ font-size: 10px;
220
+ font-weight: 600;
221
+ }
222
+ .blocked {
223
+ color: var(--danger);
224
+ font-weight: 600;
225
+ margin-left: auto;
226
+ }
227
+ </style>
@@ -0,0 +1,21 @@
1
+ import { defineConfig } from 'vite';
2
+ import { svelte } from '@sveltejs/vite-plugin-svelte';
3
+ import path from 'node:path';
4
+
5
+ export default defineConfig({
6
+ root: path.resolve(import.meta.dirname),
7
+ plugins: [svelte()],
8
+ build: {
9
+ outDir: path.resolve(import.meta.dirname, 'dist'),
10
+ emptyOutDir: true,
11
+ sourcemap: false,
12
+ target: 'es2022',
13
+ },
14
+ server: {
15
+ port: 5173,
16
+ host: '127.0.0.1',
17
+ proxy: {
18
+ '/api': 'http://127.0.0.1:3737',
19
+ },
20
+ },
21
+ });