spectoflow 0.17.5 → 0.19.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.
@@ -107,6 +107,11 @@ en: {
107
107
  'task.failingPassing':'{f} failing, {p} passing','task.comments':'Comments','task.noComments':'No comments.',
108
108
  'task.addCommentPlaceholder':'Add a comment, a remark, feedback…',
109
109
  'task.toAnalyzeHint':'"To analyze" moves the task back so the agent picks it up next round.',
110
+ 'customize.title':'Customize','customize.sub':'Add project-specific dashboards, skills and agents — describe what you want, or let Auto propose candidates from this project.',
111
+ 'customize.dashboards':'Dashboards','customize.skills':'Skills','customize.agents':'Agents',
112
+ 'customize.add.dashboard':'Add dashboard','customize.add.skill':'Add skill','customize.add.agent':'Add agent',
113
+ 'customize.empty.dashboard':'No custom dashboards yet.','customize.empty.skill':'No custom skills yet.','customize.empty.agent':'No custom agents yet.',
114
+ 'customize.describePh':'Describe what you want…','customize.auto':'Auto','customize.generate':'Generate','customize.blocksSub':'block(s)',
110
115
  },
111
116
  fr: {
112
117
  'nav.board':'Tableau','nav.requests':'Demandes','nav.attention':'Attention','nav.backlog':'Backlog',
@@ -197,6 +202,11 @@ fr: {
197
202
  'task.failingPassing':'{f} en échec, {p} réussi(s)','task.comments':'Commentaires','task.noComments':'Aucun commentaire.',
198
203
  'task.addCommentPlaceholder':'Ajouter un commentaire, une remarque, un retour…',
199
204
  'task.toAnalyzeHint':'« À analyser » renvoie la tâche pour que l’agent la reprenne au tour suivant.',
205
+ 'customize.title':'Personnaliser','customize.sub':'Ajoutez des dashboards, compétences et agents propres au projet — décrivez ce que vous voulez, ou laissez Auto proposer des pistes à partir de ce projet.',
206
+ 'customize.dashboards':'Dashboards','customize.skills':'Compétences','customize.agents':'Agents',
207
+ 'customize.add.dashboard':'Ajouter un dashboard','customize.add.skill':'Ajouter une compétence','customize.add.agent':'Ajouter un agent',
208
+ 'customize.empty.dashboard':'Aucun dashboard personnalisé pour l’instant.','customize.empty.skill':'Aucune compétence personnalisée pour l’instant.','customize.empty.agent':'Aucun agent personnalisé pour l’instant.',
209
+ 'customize.describePh':'Décrivez ce que vous voulez…','customize.auto':'Auto','customize.generate':'Générer','customize.blocksSub':'bloc(s)',
200
210
  },
201
211
  es: {
202
212
  'nav.board':'Tablero','nav.requests':'Solicitudes','nav.attention':'Atención','nav.backlog':'Backlog',
@@ -287,6 +297,11 @@ es: {
287
297
  'task.failingPassing':'{f} fallando, {p} superadas','task.comments':'Comentarios','task.noComments':'Sin comentarios.',
288
298
  'task.addCommentPlaceholder':'Añade un comentario, una observación, feedback…',
289
299
  'task.toAnalyzeHint':'«Por analizar» devuelve la tarea para que el agente la retome en la siguiente ronda.',
300
+ 'customize.title':'Personalizar','customize.sub':'Añade dashboards, habilidades y agentes propios del proyecto — describe lo que quieres, o deja que Auto proponga candidatos a partir de este proyecto.',
301
+ 'customize.dashboards':'Dashboards','customize.skills':'Habilidades','customize.agents':'Agentes',
302
+ 'customize.add.dashboard':'Añadir dashboard','customize.add.skill':'Añadir habilidad','customize.add.agent':'Añadir agente',
303
+ 'customize.empty.dashboard':'Aún no hay dashboards personalizados.','customize.empty.skill':'Aún no hay habilidades personalizadas.','customize.empty.agent':'Aún no hay agentes personalizados.',
304
+ 'customize.describePh':'Describe lo que quieres…','customize.auto':'Auto','customize.generate':'Generar','customize.blocksSub':'bloque(s)',
290
305
  },
291
306
  de: {
292
307
  'nav.board':'Board','nav.requests':'Anfragen','nav.attention':'Hinweise','nav.backlog':'Backlog',
@@ -377,6 +392,11 @@ de: {
377
392
  'task.failingPassing':'{f} fehlgeschlagen, {p} bestanden','task.comments':'Kommentare','task.noComments':'Keine Kommentare.',
378
393
  'task.addCommentPlaceholder':'Kommentar, Anmerkung oder Feedback hinzufügen…',
379
394
  'task.toAnalyzeHint':'„Zu analysieren“ gibt die Aufgabe zurück, damit der Agent sie in der nächsten Runde aufgreift.',
395
+ 'customize.title':'Anpassen','customize.sub':'Fügen Sie projektspezifische Dashboards, Skills und Agenten hinzu — beschreiben Sie, was Sie wollen, oder lassen Sie Auto Kandidaten aus diesem Projekt vorschlagen.',
396
+ 'customize.dashboards':'Dashboards','customize.skills':'Skills','customize.agents':'Agenten',
397
+ 'customize.add.dashboard':'Dashboard hinzufügen','customize.add.skill':'Skill hinzufügen','customize.add.agent':'Agent hinzufügen',
398
+ 'customize.empty.dashboard':'Noch keine eigenen Dashboards.','customize.empty.skill':'Noch keine eigenen Skills.','customize.empty.agent':'Noch keine eigenen Agenten.',
399
+ 'customize.describePh':'Beschreiben Sie, was Sie wollen…','customize.auto':'Auto','customize.generate':'Generieren','customize.blocksSub':'Block/Blöcke',
380
400
  },
381
401
  pt: {
382
402
  'nav.board':'Painel','nav.requests':'Pedidos','nav.attention':'Atenção','nav.backlog':'Backlog',
@@ -467,6 +487,11 @@ pt: {
467
487
  'task.failingPassing':'{f} a falhar, {p} bem-sucedido(s)','task.comments':'Comentários','task.noComments':'Sem comentários.',
468
488
  'task.addCommentPlaceholder':'Adicione um comentário, uma observação, feedback…',
469
489
  'task.toAnalyzeHint':'«Por analisar» devolve a tarefa para o agente a retomar na ronda seguinte.',
490
+ 'customize.title':'Personalizar','customize.sub':'Adicione dashboards, habilidades e agentes específicos do projeto — descreva o que quer, ou deixe o Auto propor candidatos a partir deste projeto.',
491
+ 'customize.dashboards':'Dashboards','customize.skills':'Habilidades','customize.agents':'Agentes',
492
+ 'customize.add.dashboard':'Adicionar dashboard','customize.add.skill':'Adicionar habilidade','customize.add.agent':'Adicionar agente',
493
+ 'customize.empty.dashboard':'Ainda sem dashboards personalizados.','customize.empty.skill':'Ainda sem habilidades personalizadas.','customize.empty.agent':'Ainda sem agentes personalizados.',
494
+ 'customize.describePh':'Descreva o que quer…','customize.auto':'Auto','customize.generate':'Gerar','customize.blocksSub':'bloco(s)',
470
495
  },
471
496
  it: {
472
497
  'nav.board':'Bacheca','nav.requests':'Richieste','nav.attention':'Attenzione','nav.backlog':'Backlog',
@@ -557,6 +582,11 @@ it: {
557
582
  'task.failingPassing':'{f} falliti, {p} superati','task.comments':'Commenti','task.noComments':'Nessun commento.',
558
583
  'task.addCommentPlaceholder':'Aggiungi un commento, un’osservazione, un feedback…',
559
584
  'task.toAnalyzeHint':'«Da analizzare» rimanda l’attività così l’agente la riprende al giro successivo.',
585
+ 'customize.title':'Personalizza','customize.sub':'Aggiungi dashboard, skill e agenti specifici del progetto — descrivi cosa vuoi, oppure lascia che Auto proponga candidati a partire da questo progetto.',
586
+ 'customize.dashboards':'Dashboard','customize.skills':'Skill','customize.agents':'Agenti',
587
+ 'customize.add.dashboard':'Aggiungi dashboard','customize.add.skill':'Aggiungi skill','customize.add.agent':'Aggiungi agente',
588
+ 'customize.empty.dashboard':'Ancora nessuna dashboard personalizzata.','customize.empty.skill':'Ancora nessuna skill personalizzata.','customize.empty.agent':'Ancora nessun agente personalizzato.',
589
+ 'customize.describePh':'Descrivi cosa vuoi…','customize.auto':'Auto','customize.generate':'Genera','customize.blocksSub':'blocco/i',
560
590
  },
561
591
  };
562
592
 
@@ -4,19 +4,19 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>spectoflow · control</title>
7
- <link rel="icon" type="image/png" href="logo-dark.png" />
8
- <link rel="icon" type="image/png" media="(prefers-color-scheme: dark)" href="logo-white.png" />
9
- <link rel="stylesheet" href="styles.css" />
10
- <link rel="stylesheet" href="designs/console.css" />
11
- <link rel="stylesheet" href="designs/orbit.css" />
7
+ <link rel="icon" type="image/png" href="/logo-dark.png" />
8
+ <link rel="icon" type="image/png" media="(prefers-color-scheme: dark)" href="/logo-white.png" />
9
+ <link rel="stylesheet" href="/styles.css" />
10
+ <link rel="stylesheet" href="/designs/console.css" />
11
+ <link rel="stylesheet" href="/designs/orbit.css" />
12
12
  </head>
13
13
  <body>
14
14
  <header class="topbar">
15
15
  <div class="brand">
16
16
  <button class="nav-toggle" id="navToggle" data-i18n-aria="topbar.navToggle" aria-label="Toggle menu" aria-expanded="false"><span></span><span></span><span></span></button>
17
17
  <a class="brand-logo" href="/board" data-route="board" aria-label="spectoflow home">
18
- <img class="brand-logo-img is-dark" src="logo-white.png" alt="spectoflow" />
19
- <img class="brand-logo-img is-light" src="logo-dark.png" alt="spectoflow" />
18
+ <img class="brand-logo-img is-dark" src="/logo-white.png" alt="spectoflow" />
19
+ <img class="brand-logo-img is-light" src="/logo-dark.png" alt="spectoflow" />
20
20
  </a>
21
21
  <div class="brand-text">
22
22
  <div class="brand-line">
@@ -257,6 +257,14 @@
257
257
  <div class="settings-saved" id="settingsSaved" data-i18n="settings.saved" hidden>✓ saved</div>
258
258
  </div>
259
259
  <div class="settings-readonly" id="settingsReadonly"></div>
260
+
261
+ <!-- Customize: project-specific dashboards, skills and agents — described or auto-proposed,
262
+ generated by the configured agent through the same Run/Chat pipeline as any other ask. -->
263
+ <div class="cz-wrap">
264
+ <h2 class="panel-title" data-i18n="customize.title">Customize</h2>
265
+ <p class="panel-sub" data-i18n="customize.sub">Add project-specific dashboards, skills and agents — describe what you want, or let Auto propose candidates from this project.</p>
266
+ <div id="czRoot"></div>
267
+ </div>
260
268
  </div>
261
269
  </section>
262
270
  </main>
@@ -264,8 +272,8 @@
264
272
  <footer class="app-footer">
265
273
  <div class="footer-left">
266
274
  <a class="footer-logo" href="/board" data-route="board" aria-label="spectoflow">
267
- <img class="brand-logo-img is-dark" src="logo-white.png" alt="spectoflow" />
268
- <img class="brand-logo-img is-light" src="logo-dark.png" alt="spectoflow" />
275
+ <img class="brand-logo-img is-dark" src="/logo-white.png" alt="spectoflow" />
276
+ <img class="brand-logo-img is-light" src="/logo-dark.png" alt="spectoflow" />
269
277
  </a>
270
278
  <span class="footer-name">spectoflow</span>
271
279
  <span class="footer-ver" id="footerVer"></span>
@@ -323,13 +331,13 @@
323
331
  </defs>
324
332
  </svg>
325
333
 
326
- <script src="stats.js"></script>
327
- <script src="charts.js"></script>
328
- <script src="icons.js"></script>
329
- <script src="designs.js"></script>
330
- <script src="i18n.js"></script>
331
- <script src="app.js"></script>
332
- <script src="designs/console.js"></script>
333
- <script src="designs/orbit.js"></script>
334
+ <script src="/stats.js"></script>
335
+ <script src="/charts.js"></script>
336
+ <script src="/icons.js"></script>
337
+ <script src="/designs.js"></script>
338
+ <script src="/i18n.js"></script>
339
+ <script src="/app.js"></script>
340
+ <script src="/designs/console.js"></script>
341
+ <script src="/designs/orbit.js"></script>
334
342
  </body>
335
343
  </html>
@@ -557,6 +557,33 @@ body.booting .ring-svg circle:last-of-type { transform-origin:center; animation:
557
557
  .settings-ro-k { color:var(--muted); font-size:13px; }
558
558
  .settings-ro-v { font-family:var(--mono); font-size:12.5px; color:var(--ink); }
559
559
 
560
+ /* ---- Customize (Settings → dashboards/skills/agents) — reuses .card/.chat-ta/.btn styling so it
561
+ reads as part of the same system, not a bolted-on sub-app. ---- */
562
+ .cz-wrap { margin-top:28px; }
563
+ .cz-block { background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); padding:14px 16px; margin-top:12px; }
564
+ .cz-head { display:flex; align-items:center; justify-content:space-between; gap:12px; }
565
+ .cz-head h3 { margin:0; font-size:13.5px; font-weight:700; }
566
+ .cz-add { font-size:12.5px; padding:6px 13px; }
567
+ .cz-add[aria-expanded="true"] { background:var(--signal); color:var(--on-accent); border-color:transparent; }
568
+ .cz-list { display:flex; flex-direction:column; gap:6px; margin-top:10px; }
569
+ .cz-list .empty { padding:10px 2px; font-size:12.5px; color:var(--muted); }
570
+ .cz-item { display:flex; align-items:baseline; gap:10px; padding:8px 10px; border-radius:8px; cursor:pointer; transition:background .15s; }
571
+ .cz-item:hover, .cz-item:focus-visible { background:var(--surface-2); outline:none; }
572
+ .cz-item-title { font-size:13px; font-weight:600; }
573
+ .cz-item-sub { font-size:11.5px; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
574
+ .cz-form { margin-top:12px; padding-top:12px; border-top:1px solid var(--line); display:flex; flex-direction:column; gap:10px; }
575
+ .cz-form .chat-ta { min-height:64px; }
576
+ .cz-form-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
577
+ .cz-form-actions .chat-agent { flex-shrink:0; }
578
+
579
+ /* ---- Custom dashboard pages (Customize → generate-dashboard) — every block below is one of the
580
+ dashboard's own existing components (.ocard/.kpi-row/.stat-tiles/.flatlist/table), so a generated
581
+ page needs no page-specific CSS: it inherits the active design automatically. ---- */
582
+ .custom-dash-wrap { display:flex; flex-direction:column; gap:14px; }
583
+ .custom-dash-body { display:flex; flex-direction:column; gap:14px; }
584
+ .cd-markdown { background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); padding:16px 18px; font-size:13.5px; line-height:1.6; }
585
+ .cd-markdown :first-child { margin-top:0; }
586
+
560
587
  /* App footer — professional, always at the bottom of the content */
561
588
  .app-footer { display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; padding:14px 22px; border-top:1px solid var(--line); background:var(--surface); color:var(--muted); font-size:12.5px; }
562
589
  .footer-left { display:flex; align-items:center; gap:9px; }
@@ -70,7 +70,13 @@ function promoteAttention(item){
70
70
  }
71
71
 
72
72
  function watch(dir){ try{ fs.watch(dir,{recursive:false},()=>emit({type:'change'})); }catch(_){} }
73
- ['plans','specs','.spectoflow'].forEach(d=>{ const p=path.join(ROOT,d); if(fs.existsSync(p)) watch(p); });
73
+ // Custom dashboards (Customize page) live in their own subdirectory of .spectoflow, which the
74
+ // top-level `.spectoflow` watch below does NOT cover — fs.watch here is non-recursive on purpose
75
+ // (a recursive watch on the whole .spectoflow tree would also fire on every runtime.json write).
76
+ // Ensure the directory exists before watching it: a project that hasn't used Customize yet won't
77
+ // have it on disk, and `spectoflow init` on an older install won't have created it either.
78
+ try { fs.mkdirSync(path.join(ROOT,'.spectoflow','dashboard','custom'), { recursive: true }); } catch (_) {}
79
+ ['plans','specs','.spectoflow','.spectoflow/dashboard/custom'].forEach(d=>{ const p=path.join(ROOT,d); if(fs.existsSync(p)) watch(p); });
74
80
 
75
81
  // A process restart loses any in-flight orchestration; without this, a stale 'running' or
76
82
  // 'awaiting_approval' status wedges the /api/orchestrate 409 guard forever. Not a real
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+ /*
3
+ * Pure helpers for user-generated custom dashboards (.spectoflow/dashboard/custom/<id>.json).
4
+ *
5
+ * A custom dashboard is a DECLARATIVE block spec, never raw HTML/CSS/JS: the generating agent picks
6
+ * blocks from a fixed vocabulary (BLOCK_TYPES) that the dashboard already knows how to render, using
7
+ * the exact same token-driven components (kpi cards, bars, donut, tables…) the built-in Board uses.
8
+ * That is what guarantees a custom dashboard always matches the active design — including any design
9
+ * the user switches to later — with zero per-dashboard styling to keep in sync, and no arbitrary code
10
+ * ever running in the dashboard.
11
+ *
12
+ * Zero dependency; consumed by templates/dashboard/server.js (Node) via readCustomDashboards() in
13
+ * store.js. The browser-side renderer (dashboard/public/app.js) re-implements the tiny `resolveBind`
14
+ * walk independently — sharing code across the Node/browser boundary would need a build step, which
15
+ * this project avoids on purpose (see CLAUDE.md's zero-runtime-dependency invariant).
16
+ */
17
+
18
+ // Every block a generated dashboard may use. Adding a new type here is how the vocabulary grows —
19
+ // pair it with a matching case in app.js's renderCustomBlock().
20
+ const BLOCK_TYPES = new Set([
21
+ 'markdown', // rendered prose — a spec excerpt, an explanation, a summary
22
+ 'kpi-row', // a row of big-number stat cards, each optionally live-bound
23
+ 'chart-bars', // horizontal progress/comparison bars
24
+ 'chart-donut', // a status/category breakdown donut + legend
25
+ 'table', // a simple column/row data table
26
+ 'list', // a flat bullet list
27
+ 'stat-tile-row', // a row of compact stat tiles (value/label/sub)
28
+ ]);
29
+
30
+ // A small, explicit allow-list of live data paths a block may `bind` to, resolved against the same
31
+ // stats object SpectoStats.stats(P) already computes for the built-in Board — never an arbitrary
32
+ // expression, just a dotted property walk, so there is nothing to sandbox or evaluate.
33
+ // Mirrors the exact shape SpectoStats.stats(P) returns (dashboard/public/stats.js):
34
+ // { total, done, pct, byStatus, phases, toAsk, running, statuses }.
35
+ const BIND_ROOTS = new Set(['pct', 'done', 'total', 'byStatus', 'phases', 'toAsk', 'running', 'statuses']);
36
+
37
+ const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
38
+
39
+ // A conservative, curated icon key set — the same ICON map the rest of the dashboard already uses
40
+ // (icons.js), so a custom dashboard's tab never introduces a one-off, unstyled icon.
41
+ const ICON_KEYS = new Set(['board', 'requests', 'backlog', 'workflow', 'agents', 'chat', 'info', 'attention', 'settings']);
42
+
43
+ function isPlainObject(v) { return v != null && typeof v === 'object' && !Array.isArray(v); }
44
+
45
+ // Validates one block. Returns a list of error strings (empty = valid). Deliberately permissive on
46
+ // the *content* fields (labels, values, markdown text are free text) — it only enforces the block's
47
+ // *shape* (a known type, and that any `bind` path starts from an allowed root) so a slightly unusual
48
+ // but well-typed spec still renders rather than being rejected outright.
49
+ function validateBlock(b, i) {
50
+ const errs = [];
51
+ const at = `blocks[${i}]`;
52
+ if (!isPlainObject(b)) { errs.push(`${at} is not an object`); return errs; }
53
+ if (!BLOCK_TYPES.has(b.type)) errs.push(`${at}.type "${b.type}" is not a known block type (${[...BLOCK_TYPES].join(', ')})`);
54
+ const binds = [];
55
+ if (typeof b.bind === 'string') binds.push(b.bind);
56
+ if (Array.isArray(b.items)) b.items.forEach((it) => { if (it && typeof it.bind === 'string') binds.push(it.bind); });
57
+ if (Array.isArray(b.rows)) b.rows.forEach((r) => { if (r && typeof r.bind === 'string') binds.push(r.bind); });
58
+ if (Array.isArray(b.segments)) b.segments.forEach((s) => { if (s && typeof s.bind === 'string') binds.push(s.bind); });
59
+ binds.forEach((p) => { const root = String(p).split('.')[0]; if (!BIND_ROOTS.has(root)) errs.push(`${at} has an unbound bind path "${p}" (must start with one of: ${[...BIND_ROOTS].join(', ')})`); });
60
+ return errs;
61
+ }
62
+
63
+ // Validates a whole dashboard spec as read from disk. Never throws — callers (store.js) should skip
64
+ // an invalid file rather than let one bad custom dashboard break the whole /api/project response.
65
+ function validateSpec(spec) {
66
+ const errors = [];
67
+ if (!isPlainObject(spec)) return { valid: false, errors: ['not an object'] };
68
+ if (!ID_RE.test(String(spec.id || ''))) errors.push('id must be lowercase kebab-case, starting with a letter, 1-40 chars');
69
+ if (!spec.title || typeof spec.title !== 'string') errors.push('title is required (a short display name)');
70
+ if (spec.icon != null && !ICON_KEYS.has(spec.icon)) errors.push(`icon "${spec.icon}" is not one of: ${[...ICON_KEYS].join(', ')}`);
71
+ if (!Array.isArray(spec.blocks) || !spec.blocks.length) errors.push('blocks must be a non-empty array');
72
+ else spec.blocks.forEach((b, i) => errors.push(...validateBlock(b, i)));
73
+ return { valid: errors.length === 0, errors };
74
+ }
75
+
76
+ module.exports = { BLOCK_TYPES, BIND_ROOTS, ICON_KEYS, validateSpec, validateBlock };
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+ // Builds the exact natural-language prompts the dashboard's Settings → Customize UI posts to
3
+ // /api/run (see templates/dashboard/public/app.js's CZ_KINDS) — the single source of truth so the
4
+ // CLI (`spectoflow skill/agent/dashboard create`) and the dashboard button never drift apart. The
5
+ // browser side can't require this Node module (no build step), so its literal strings are mirrored
6
+ // there by hand; test/customize-prompts.test.js guards against the two falling out of sync.
7
+ const PROMPTS = {
8
+ dashboard: {
9
+ add: (d) => `Add a custom dashboard: ${d}`,
10
+ auto: 'Propose dashboard candidates for this project (Auto customize)',
11
+ },
12
+ skill: {
13
+ add: (d) => `Create a new skill: ${d}`,
14
+ auto: 'Propose skill candidates for this project (Auto customize)',
15
+ },
16
+ agent: {
17
+ add: (d) => `Create a new agent: ${d}`,
18
+ auto: 'Propose agent candidates for this project (Auto customize)',
19
+ },
20
+ };
21
+
22
+ // buildCustomizePrompt('skill', { description: 'reviews PRs for accessibility' })
23
+ // buildCustomizePrompt('skill', { auto: true })
24
+ function buildCustomizePrompt(kind, opts) {
25
+ const p = PROMPTS[kind];
26
+ if (!p) throw new Error(`Unknown customize kind "${kind}" (expected dashboard, skill or agent).`);
27
+ const o = opts || {};
28
+ if (o.auto) return p.auto;
29
+ const d = o.description && String(o.description).trim();
30
+ if (!d) throw new Error('A description is required unless --auto is passed.');
31
+ return p.add(d);
32
+ }
33
+
34
+ module.exports = { PROMPTS, buildCustomizePrompt };
@@ -17,6 +17,7 @@
17
17
  */
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
+ const { validateSpec } = require('./custom-dashboard');
20
21
 
21
22
  // ---- task line parsing -------------------------------------------------------
22
23
  // - [ ] T-012 Title here @owner ~level %status
@@ -226,6 +227,23 @@ function readWorkflow(projectRoot) {
226
227
  } catch { return []; }
227
228
  }
228
229
 
230
+ // ---- user-generated custom dashboards (.spectoflow/dashboard/custom/<id>.json) --------------
231
+ // One JSON file per custom dashboard page (see lib/custom-dashboard.js for the block schema this
232
+ // validates against). A malformed file is skipped, never thrown — one bad custom dashboard must
233
+ // never take down the whole /api/project response.
234
+ function readCustomDashboards(projectRoot) {
235
+ const dir = path.join(projectRoot, '.spectoflow', 'dashboard', 'custom');
236
+ if (!fs.existsSync(dir)) return [];
237
+ const out = [];
238
+ for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.json')).sort()) {
239
+ try {
240
+ const spec = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
241
+ if (validateSpec(spec).valid) out.push(spec);
242
+ } catch { /* skip malformed */ }
243
+ }
244
+ return out;
245
+ }
246
+
229
247
  // ---- unified read for the dashboard -----------------------------------------
230
248
  function readProject(projectRoot) {
231
249
  const config = readConfig(projectRoot);
@@ -235,6 +253,7 @@ function readProject(projectRoot) {
235
253
  const specs = readSpecs(projectRoot);
236
254
  const agents = listMd(path.join(projectRoot, '.spectoflow', 'agents'));
237
255
  const skills = listSkills(path.join(projectRoot, '.spectoflow', 'skills'));
256
+ const customDashboards = readCustomDashboards(projectRoot);
238
257
 
239
258
  // Write-guarded snapshot: readProject is polled continuously by the dashboard (and reacts to
240
259
  // fs.watch on .spectoflow). Recording unconditionally on every read would rewrite runtime.json
@@ -260,7 +279,7 @@ function readProject(projectRoot) {
260
279
  runtime = writeRuntime(projectRoot, cur);
261
280
  }
262
281
 
263
- return { config, plans, specs, workflow, agents, skills, runtime };
282
+ return { config, plans, specs, workflow, agents, skills, runtime, customDashboards };
264
283
  }
265
284
  function frontmatter(text) {
266
285
  const m = String(text).replace(/\r\n?/g, '\n').match(/^---\n([\s\S]*?)\n---/);
@@ -274,12 +293,17 @@ function parseFlatList(raw) {
274
293
  if (!raw) return [];
275
294
  return String(raw).replace(/[[\]]/g, '').split(',').map((s) => s.trim()).filter(Boolean);
276
295
  }
296
+ // `origin: user-generated` in a file's front-matter (written by generate-skill/generate-agent, see
297
+ // templates/skills/generate-skill and generate-agent) marks it as created through the Customize page
298
+ // rather than shipped by the framework — the dashboard's Customize section uses this `custom` flag to
299
+ // list only the user's own additions, distinct from the framework-shipped roster.
300
+ const isCustomOrigin = (fm) => fm.origin === 'user-generated';
277
301
  function listMd(dir) {
278
302
  if (!fs.existsSync(dir)) return [];
279
303
  return fs.readdirSync(dir).filter((f) => f.endsWith('.md')).map((f) => {
280
304
  const fm = frontmatter(fs.readFileSync(path.join(dir, f), 'utf8'));
281
305
  return { file: f, name: fm.name || f.replace(/\.md$/, ''), title: fm.title || fm.name || f, capability: fm.capability || '', description: fm.description || '',
282
- standards: parseFlatList(fm.standards), uses: parseFlatList(fm.uses) };
306
+ standards: parseFlatList(fm.standards), uses: parseFlatList(fm.uses), custom: isCustomOrigin(fm) };
283
307
  });
284
308
  }
285
309
  function listSkills(dir) {
@@ -288,7 +312,7 @@ function listSkills(dir) {
288
312
  const sk = path.join(dir, e.name, 'SKILL.md');
289
313
  const fm = fs.existsSync(sk) ? frontmatter(fs.readFileSync(sk, 'utf8')) : {};
290
314
  return { name: fm.name || e.name, description: fm.description || '', capability: fm.capability || '',
291
- inputs: fm.inputs || '', outputs: fm.outputs || '', standard: fm.standard || '' };
315
+ inputs: fm.inputs || '', outputs: fm.outputs || '', standard: fm.standard || '', custom: isCustomOrigin(fm) };
292
316
  });
293
317
  }
294
318
  function readAgents(projectRoot) {
@@ -298,7 +322,7 @@ function readAgents(projectRoot) {
298
322
  const fm = frontmatter(fs.readFileSync(path.join(dir, f), 'utf8'));
299
323
  return { name: fm.name || f.replace(/\.md$/, ''), capability: fm.capability || null,
300
324
  title: fm.title || '', description: fm.description || '',
301
- standards: parseFlatList(fm.standards), uses: parseFlatList(fm.uses) };
325
+ standards: parseFlatList(fm.standards), uses: parseFlatList(fm.uses), custom: isCustomOrigin(fm) };
302
326
  });
303
327
  }
304
328
  function readSkills(projectRoot) {
@@ -308,5 +332,5 @@ function readSkills(projectRoot) {
308
332
  module.exports = {
309
333
  parseTaskLine, buildTaskLine, parsePlan, readPlans, readSpecs, updateTaskLine, addTaskComment,
310
334
  readRuntime, writeRuntime, parseAgentLine, appendMessage, readConfig, readWorkflow, readProject,
311
- readAgents, readSkills, recordSnapshot, resolvePlansDir, resolveSpecsDir,
335
+ readAgents, readSkills, readCustomDashboards, recordSnapshot, resolvePlansDir, resolveSpecsDir,
312
336
  };
@@ -0,0 +1,135 @@
1
+ ---
2
+ name: generate-agent
3
+ description: Turn a description (or an auto-analysis) into a new agent persona, grounded in real named methods and matching the framework's gold-standard shape.
4
+ capability: customization
5
+ inputs: A description of the role needed (from the Customize page or chat), or a chosen candidate from propose-customizations; the project's existing agents as worked examples.
6
+ outputs: A new .spectoflow/agents/<slug>.md matching docs/agents-skills-standard.md's shape, listed in the dashboard's Agents & Skills tab on the next tick.
7
+ standard: docs/agents-skills-standard.md gold-standard shape
8
+ ---
9
+ # Generate agent
10
+
11
+ Turn a described role into a real agent persona — a stable team member with a clear mandate, named
12
+ operating standards, and guardrails, that reads like it shipped with the framework's own roster.
13
+
14
+ ## When to use
15
+
16
+ Whenever the user asks (from the Customize page, or directly in chat) to **add an agent** — "I want a
17
+ data-migration specialist", "add an accessibility reviewer", "create an agent for API contract
18
+ reviews" — or when `propose-customizations` proposed an agent candidate the user picked.
19
+
20
+ ## Method
21
+
22
+ ### 1. Clarify before generating
23
+
24
+ A one-line ask ("add a data agent") is under-specified. Use `.spectoflow/skills/clarify`'s reflex —
25
+ one targeted question at a time, each with a recommended default — until you know:
26
+ - **What this role owns that no existing agent already owns.** Read `.spectoflow/agents/*.md` first —
27
+ a new agent for a capability an existing one already covers is redundant; either the existing agent
28
+ should gain a skill instead (see `generate-skill`), or this really is a distinct capability.
29
+ - **Which capability it serves.** Pick the closest match from `.spectoflow/capabilities.md`'s palette,
30
+ or note that this genuinely needs a new capability name (rare — most real needs fit the existing
31
+ palette; propose adding to the palette only when nothing fits).
32
+ - **What skill(s) it runs.** An agent without at least one skill in `uses` has no procedure to
33
+ execute — either an existing skill fits, or this request also needs `generate-skill` (sequence the
34
+ two: skill first, so the agent's `uses` list is accurate from the start).
35
+
36
+ ### 2. Remember the agent/skill split
37
+
38
+ Per the framework's own core invariant: **agents are stable personas (the who); skills are the
39
+ evolving procedures (the how).** This agent's file should describe *who* the role is and what it's
40
+ accountable for — the actual step-by-step method belongs in its skill(s), referenced via `uses`, not
41
+ duplicated here. An agent file heavy with procedural detail has blurred the split; move that content
42
+ into a skill instead.
43
+
44
+ ### 3. Ground the operating standards in named, real methods
45
+
46
+ Per `docs/agents-skills-standard.md`, `## Operating standards` names **cited methods**, each with a
47
+ one-line *why* — the same discipline every shipped agent already follows (open a couple as worked
48
+ examples: `qa-engineer` cites Kent Beck's TDD and Meszaros's xUnit Test Patterns; `security-engineer`
49
+ cites OWASP ASVS and the Top 10; `architect` cites C4 and ADRs). Identify the real, current, named
50
+ authority for this role's domain the same way `generate-skill`'s Method (step 2 there) describes —
51
+ verify it with your environment's research tools rather than relying purely on memory for a
52
+ fast-moving domain, and if no real standard exists for the role's specific angle, say so explicitly
53
+ and reason from first principles instead of fabricating a citation.
54
+
55
+ ### 4. Write the agent in the gold-standard shape
56
+
57
+ Follow `docs/agents-skills-standard.md`'s agent shape exactly:
58
+
59
+ ```yaml
60
+ ---
61
+ name: <slug>
62
+ title: <Team title>
63
+ capability: <the palette capability chosen in step 1>
64
+ uses: [<skill-slug>, ...]
65
+ description: <one line>
66
+ standards: [<named method or source>, ...]
67
+ ---
68
+ # <Title>
69
+ <1-2 line intro naming the persona and the capability it serves>
70
+
71
+ ## Mandate
72
+ <who/why, 1-2 lines — what this role owns>
73
+
74
+ ## Operating standards
75
+ <named, cited methods this role applies, each with a one-line why — from step 3>
76
+
77
+ ## Definition of done
78
+ <concrete, checkable exit criteria for this role's contribution>
79
+
80
+ ## Handoff
81
+ <what it produces and to whom — feeds the group-chat identity + orchestrator>
82
+
83
+ ## Guardrails
84
+ <what it must never do — ties to .spectoflow/policy.md where relevant>
85
+
86
+ ## References
87
+ <the real, verified sources from step 3, as titled links>
88
+ ```
89
+
90
+ ### 5. Mark it as user-generated
91
+
92
+ Add `origin: user-generated` to the front-matter (an extra key — never remove or rename the required
93
+ ones: `name`, `title`, `capability`, `uses`, `description`). This is how the dashboard's Customize
94
+ page distinguishes what the user added from the shipped roster; omitting it hides the agent from that
95
+ list.
96
+
97
+ ### 6. Resolve capability collisions explicitly
98
+
99
+ `.spectoflow/AGENTS.md`'s routing assumes one agent per capability unless a `priority` is set (see the
100
+ front-matter rules in `docs/agents-skills-standard.md`). If the chosen capability already has an
101
+ agent, either pick a different, more precise capability for this role, or set `priority` deliberately
102
+ and tell the user which agent now wins ties — never leave two agents silently competing for the same
103
+ capability with no way to tell which runs.
104
+
105
+ ## Output contract
106
+
107
+ - One file: `.spectoflow/agents/<slug>.md`, matching the gold-standard shape, with
108
+ `origin: user-generated` in its front-matter.
109
+ - Progress and completion reported to the orchestrator and group chat:
110
+
111
+ ```
112
+ ::spectoflow role=customization kind=progress msg=Drafting agent "<title>" (capability <capability>)
113
+ ::spectoflow role=customization kind=need msg=<what's missing, e.g. no skill yet for this agent to use>
114
+ ::spectoflow role=customization kind=done msg=Agent "<title>" added — see it in Agents & Skills
115
+ ```
116
+
117
+ ## Quality bar
118
+
119
+ - [ ] Front-matter matches the gold-standard shape exactly, plus `origin: user-generated`.
120
+ - [ ] Body has exactly the five required `##` headings, in order.
121
+ - [ ] `uses` lists at least one real, existing (or just-generated) skill — never an empty list.
122
+ - [ ] `## Operating standards` names real, verified, cited methods — or explicitly says none exist for
123
+ this angle and reasons from first principles instead. Never a fabricated citation.
124
+ - [ ] No capability collision left unresolved (step 6) — or the `priority` tie-break is explicit and
125
+ explained to the user.
126
+ - [ ] The role is genuinely distinct from every existing agent — not a duplicate the user could have
127
+ gotten by adding a skill to one that already exists.
128
+ - [ ] If the ask was ambiguous, it was clarified one question at a time before any file was written.
129
+
130
+ ## References
131
+
132
+ - `docs/agents-skills-standard.md` — the gold-standard shape this agent's output must match exactly.
133
+ - Any shipped agent under `.spectoflow/agents/` (e.g. `qa-engineer`, `security-engineer`,
134
+ `spec-source-guardian`) — worked examples of real citation density in `## Operating standards`.
135
+ - `.spectoflow/capabilities.md` — the capability palette a new agent's `capability` must fit.