spectoflow 0.12.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 (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/bin/spectoflow.js +170 -0
  4. package/lib/adapters.js +120 -0
  5. package/lib/detect.js +34 -0
  6. package/lib/manifest.js +36 -0
  7. package/lib/ownership.js +34 -0
  8. package/lib/update.js +78 -0
  9. package/package.json +30 -0
  10. package/templates/AGENTS.md +66 -0
  11. package/templates/agents/architect.md +54 -0
  12. package/templates/agents/business-analyst.md +53 -0
  13. package/templates/agents/code-reviewer.md +58 -0
  14. package/templates/agents/developer.md +73 -0
  15. package/templates/agents/devops.md +59 -0
  16. package/templates/agents/product-manager.md +53 -0
  17. package/templates/agents/qa-engineer.md +63 -0
  18. package/templates/agents/security-engineer.md +59 -0
  19. package/templates/agents/tech-lead.md +52 -0
  20. package/templates/agents/ux-designer.md +50 -0
  21. package/templates/capabilities.md +15 -0
  22. package/templates/config.json +10 -0
  23. package/templates/dashboard/orchestrator.js +117 -0
  24. package/templates/dashboard/public/app.js +747 -0
  25. package/templates/dashboard/public/charts.js +192 -0
  26. package/templates/dashboard/public/icons.js +28 -0
  27. package/templates/dashboard/public/index.html +226 -0
  28. package/templates/dashboard/public/stats.js +32 -0
  29. package/templates/dashboard/public/styles.css +426 -0
  30. package/templates/dashboard/runner.js +77 -0
  31. package/templates/dashboard/server.js +115 -0
  32. package/templates/lib/store.js +289 -0
  33. package/templates/policy.md +11 -0
  34. package/templates/skills/analyze-requirements/SKILL.md +67 -0
  35. package/templates/skills/brainstorm/SKILL.md +58 -0
  36. package/templates/skills/code-review/SKILL.md +67 -0
  37. package/templates/skills/implement/SKILL.md +80 -0
  38. package/templates/skills/security-review/SKILL.md +70 -0
  39. package/templates/skills/write-adr/SKILL.md +61 -0
  40. package/templates/skills/write-e2e-tests/SKILL.md +99 -0
  41. package/templates/skills/write-plan/SKILL.md +66 -0
  42. package/templates/skills/write-spec/SKILL.md +66 -0
  43. package/templates/skills/write-tests/SKILL.md +80 -0
  44. package/templates/workflow.md +15 -0
@@ -0,0 +1,426 @@
1
+ /* spectoflow control plane. Direction: an engineering control room — calm, dense, precise.
2
+ One signature: the amber "signal" for what is live/active. Mono carries ids and statuses. */
3
+ :root {
4
+ --bg: #10151b; --surface: #171e26; --surface-2: #1e2732; --line: #2a3641;
5
+ --ink: #e9edf2; --muted: #94a2af; --faint: #63727e;
6
+ --signal: #e6a54b; --cool: #5fb2cc;
7
+ --s-todo: #7d8f97; --s-in_progress: #e6a54b; --s-to_validate: #5fb2cc;
8
+ --s-to_analyze: #ab8cd9; --s-done: #5fb67e; --s-blocked: #db7268;
9
+ --radius: 14px; --shadow: 0 10px 30px rgba(0,0,0,.38);
10
+ --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
11
+ --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
12
+ }
13
+ [data-theme="light"] { --bg:#f6f3ee; --surface:#fffdfa; --surface-2:#efe9e0; --line:#ddd4c6; --ink:#211b12; --muted:#6b6255; --faint:#948a7b; --signal:#c17d20; --cool:#2c7f99; --s-todo:#6b7680; --s-in_progress:#c17d20; --s-to_validate:#2c7f99; --s-to_analyze:#8a6bb0; --s-done:#3f8f5c; --s-blocked:#c14c3f; }
14
+ * { box-sizing: border-box; }
15
+ html,body { margin:0; height:100%; }
16
+ body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-size:14px; line-height:1.5; -webkit-font-smoothing:antialiased; }
17
+ @media (prefers-reduced-motion: reduce){ *{ animation:none!important; transition:none!important; } }
18
+
19
+ /* topbar — dense engineering-control-room header: brand+meter (left) / icon tabs (centre) / status+run (right) */
20
+ .topbar { display:grid; grid-template-columns:1fr auto 1fr; align-items:center; column-gap:18px; min-height:60px; padding:8px 18px; border-bottom:1px solid var(--line);
21
+ background:color-mix(in srgb,var(--surface) 92%,transparent); position:sticky; top:0; z-index:6; backdrop-filter:blur(8px); }
22
+ .brand { justify-self:start; display:flex; align-items:center; gap:11px; font-weight:600; min-width:0; }
23
+ .brand-mark { width:16px; height:16px; border-radius:4px; background:conic-gradient(from 210deg,var(--signal),var(--cool),var(--signal)); box-shadow:0 0 0 1px var(--line) inset; flex-shrink:0; }
24
+ .brand-text { display:flex; flex-direction:column; gap:2px; min-width:0; }
25
+ .brand-line { display:flex; align-items:center; gap:7px; min-width:0; }
26
+ .brand-name { font-weight:700; } .brand-sep{ color:var(--faint); } .brand-project{ color:var(--muted); font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
27
+ .brand-sub { font-family:var(--mono); font-size:9.5px; text-transform:uppercase; letter-spacing:.08em; color:var(--faint); }
28
+ .progress-meter { width:76px; height:5px; border-radius:999px; background:var(--surface-2); overflow:hidden; flex-shrink:0; margin-left:4px; }
29
+ .progress-meter-fill { height:100%; width:0%; background:linear-gradient(90deg,var(--cool),var(--signal)); border-radius:999px; transition:width .5s ease; }
30
+ .tabs { justify-self:center; display:flex; gap:2px; }
31
+ .tab { display:flex; align-items:center; gap:6px; font:inherit; font-size:12.5px; padding:7px 11px; border:0; background:transparent; color:var(--muted); cursor:pointer; border-radius:7px; white-space:nowrap; }
32
+ .tab-ico { width:16px; height:16px; flex-shrink:0; display:inline-flex; align-items:center; justify-content:center; }
33
+ .tab-ico svg { width:100%; height:100%; display:block; }
34
+ .tab:hover { color:var(--ink); background:var(--surface-2); }
35
+ .tab.is-active { color:var(--ink); background:var(--surface-2); box-shadow:inset 0 -2px 0 var(--signal); }
36
+ .top-right { justify-self:end; display:flex; align-items:center; gap:9px; }
37
+ .meta-chip,.mode-chip { font-family:var(--mono); font-size:11px; text-transform:uppercase; letter-spacing:.06em; padding:3px 8px; border:1px solid var(--line); border-radius:999px; color:var(--muted); }
38
+ .mode-chip { color:var(--signal); border-color:color-mix(in srgb,var(--signal) 40%,var(--line)); }
39
+ .sync { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--muted); }
40
+ .sync-dot { width:7px; height:7px; border-radius:50%; background:var(--s-done); animation:pulse 2.4s ease-out infinite; }
41
+ .sync.saving .sync-dot { background:var(--signal); }
42
+ .sync.offline .sync-dot { background:var(--s-blocked); animation:none; }
43
+ @keyframes pulse { 0%{ box-shadow:0 0 0 0 color-mix(in srgb,var(--s-done) 55%,transparent);} 70%{ box-shadow:0 0 0 6px transparent;} 100%{ box-shadow:0 0 0 0 transparent;} }
44
+ .run-btn { display:flex; align-items:center; gap:6px; padding:6px 12px 6px 10px; }
45
+ .theme-toggle { width:30px; height:30px; border:1px solid var(--line); border-radius:8px; background:var(--surface-2); cursor:pointer; display:grid; place-items:center; flex-shrink:0; }
46
+ .theme-ico { width:14px; height:14px; border-radius:50%; background:radial-gradient(circle at 32% 32%,var(--ink) 0 48%,transparent 49%),conic-gradient(var(--muted) 0 50%,transparent 0); }
47
+ @media (max-width:900px){ .brand-sub,.progress-meter { display:none; } }
48
+
49
+ /* panels */
50
+ .stage { min-height:calc(100% - 52px); }
51
+ .panel { display:none; } .panel.is-active { display:block; }
52
+ .panel[data-panel="board"].is-active { display:grid; grid-template-columns:1fr 300px; min-height:calc(100% - 52px); }
53
+ .panel-title { font-size:16px; font-weight:700; letter-spacing:-.01em; margin:0 0 2px; display:flex; align-items:center; gap:8px; }
54
+ .panel-sub { color:var(--muted); font-size:12.5px; margin:0 0 16px; }
55
+ .count { font-family:var(--mono); font-size:10px; color:var(--muted); border:1px solid var(--line); border-radius:999px; padding:1px 6px; }
56
+
57
+ /* main column + right sidebar */
58
+ .main { min-width:0; display:flex; flex-direction:column; }
59
+ .side { border-left:1px solid var(--line); padding:18px 16px; display:flex; flex-direction:column; gap:22px; }
60
+ .rail-title { font-size:11px; text-transform:uppercase; letter-spacing:.1em; color:var(--faint); margin:0 0 8px; font-weight:700; display:flex; gap:8px; align-items:center; }
61
+ .flatlist { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:5px; font-size:12.5px; }
62
+ .flatlist li { background:var(--surface); border:1px solid var(--line); border-radius:7px; padding:6px 9px; color:var(--muted); }
63
+ .flatlist li b { color:var(--ink); font-weight:600; }
64
+ .flatlist .empty { border-style:dashed; font-style:italic; color:var(--faint); }
65
+ .run-live { border-left:2px solid var(--signal)!important; }
66
+
67
+ /* Journal (read-only feed of runtime.messages, scrolls independently) */
68
+ .journal { max-height:340px; overflow-y:auto; display:flex; flex-direction:column; gap:7px; padding-right:2px; }
69
+ .journal-row { background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:7px 9px; }
70
+ .journal-head { font-family:var(--mono); font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:var(--cool); margin-bottom:3px; }
71
+ .journal-text { color:var(--ink); font-size:12.5px; line-height:1.4; white-space:pre-wrap; word-break:break-word; overflow-wrap:anywhere; }
72
+ .journal-row.j-you { border-left:2px solid var(--signal); }
73
+ .journal-row.j-you .journal-head { color:var(--signal); }
74
+ .journal-row.k-status .journal-head { color:var(--s-done); }
75
+ .journal-row.k-status { border-color:color-mix(in srgb,var(--s-done) 35%,var(--line)); }
76
+ .journal-row.k-question .journal-head { color:var(--signal); }
77
+ .journal-row.k-handoff .journal-head { color:var(--s-to_analyze); }
78
+
79
+ /* overview: KPI cards + status donut + workflow strip + phase bars */
80
+ .overview { padding:22px 24px 6px; display:flex; flex-direction:column; gap:14px; }
81
+ .kpi-row { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; }
82
+ .kpi { background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); padding:14px 16px; display:flex; flex-direction:column; gap:8px; transition:box-shadow .15s; }
83
+ .kpi:hover { box-shadow:var(--shadow); }
84
+ .kpi-label { font-size:11px; text-transform:uppercase; letter-spacing:.08em; color:var(--faint); font-weight:700; }
85
+ .kpi-body { display:flex; align-items:center; }
86
+ .kpi-num { font-family:var(--mono); font-size:30px; font-weight:700; line-height:1; color:var(--ink); }
87
+ .kpi-num.is-text { font-size:17px; text-transform:capitalize; }
88
+ .kpi-sub { font-size:11.5px; color:var(--muted); }
89
+
90
+ .ring-wrap { position:relative; display:inline-flex; align-items:center; justify-content:center; }
91
+ .ring-wrap .ring-label { position:absolute; font-family:var(--mono); font-size:13px; font-weight:700; color:var(--ink); }
92
+ .ring-svg circle { transition:stroke-dashoffset .5s ease; }
93
+
94
+ .ocard { background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); padding:16px 18px; }
95
+ .ocard-title { font-size:11px; text-transform:uppercase; letter-spacing:.08em; color:var(--faint); font-weight:700; margin:0 0 12px; }
96
+
97
+ .overview-top { display:grid; grid-template-columns:minmax(280px,1fr) minmax(320px,1.4fr); gap:14px; align-items:start; }
98
+ @media (max-width:900px){ .overview-top { grid-template-columns:1fr; } }
99
+
100
+ .area-wrap { width:100%; overflow-x:auto; }
101
+ .area-wrap .area-svg { width:100%; height:auto; display:block; }
102
+ .area-grid { stroke-opacity:.5; }
103
+ .area-ylabel,.area-xlabel { font-family:var(--mono); font-size:9px; }
104
+ .area-value { font-family:var(--mono); font-size:9px; font-weight:700; }
105
+ .area-dot { transition:r .15s ease; }
106
+ .area-hit { cursor:crosshair; }
107
+
108
+ .donut-row { display:flex; align-items:center; gap:26px; flex-wrap:wrap; }
109
+ .donut-wrap { position:relative; display:inline-flex; align-items:center; justify-content:center; flex-shrink:0; }
110
+ .donut-svg circle { transition:stroke-dasharray .5s ease; }
111
+ .donut-center { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; }
112
+ .donut-total { font-family:var(--mono); font-size:22px; font-weight:700; color:var(--ink); }
113
+ .donut-sub { font-size:10px; color:var(--faint); text-transform:uppercase; letter-spacing:.08em; }
114
+ .legend { display:flex; flex-direction:column; gap:7px; min-width:150px; flex:1; }
115
+ .legend-item { display:flex; align-items:center; gap:8px; font-size:12.5px; color:var(--muted); }
116
+ .legend-swatch { width:9px; height:9px; border-radius:3px; flex-shrink:0; }
117
+ .legend-label { flex:1; }
118
+ .legend-count { font-family:var(--mono); color:var(--ink); font-weight:600; }
119
+
120
+ .wf-strip { display:flex; flex-wrap:wrap; align-items:center; gap:0; }
121
+ .wf-mini { display:flex; align-items:center; gap:6px; padding:6px 11px; border:1px solid var(--line); border-radius:999px; background:var(--surface-2); font-size:11.5px; font-weight:600; white-space:nowrap; }
122
+ .wf-mini .dot { width:6px; height:6px; border-radius:50%; background:var(--s-done); }
123
+ .wf-mini.off { opacity:.42; border-style:dashed; }
124
+ .wf-mini.off .dot { background:var(--faint); }
125
+ .wf-strip .wf-arrow { width:22px; }
126
+
127
+ .bars { display:flex; flex-direction:column; gap:12px; }
128
+ .bar-row { display:flex; flex-direction:column; gap:5px; }
129
+ .bar-head { display:flex; align-items:baseline; gap:8px; font-size:12.5px; }
130
+ .bar-head .bar-label { font-weight:600; color:var(--ink); }
131
+ .bar-head .bar-sub { margin-left:auto; font-family:var(--mono); font-size:11px; color:var(--muted); }
132
+ .bar-track { height:8px; background:var(--surface-2); border-radius:999px; overflow:hidden; }
133
+ .bar-fill { height:100%; background:linear-gradient(90deg,var(--cool),var(--signal)); border-radius:999px; transition:width .5s ease; }
134
+
135
+ /* filters (status chips + search) */
136
+ .filters { display:flex; align-items:center; gap:12px; flex-wrap:wrap; padding:0 24px 14px; }
137
+ .chip-list { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
138
+ .fchip { font-family:var(--mono); font-size:11px; text-transform:uppercase; letter-spacing:.05em; padding:5px 11px; border-radius:999px; border:1px solid var(--line); background:var(--surface); color:var(--muted); cursor:pointer; transition:all .15s; }
139
+ .fchip:hover { color:var(--ink); border-color:var(--cool); }
140
+ .fchip.active { color:#10151b; border-color:transparent; font-weight:700; background:var(--muted); }
141
+ .fchip[data-status="all"].active { background:var(--signal); }
142
+ .fchip[data-status="todo"].active { background:var(--s-todo); }
143
+ .fchip[data-status="in_progress"].active { background:var(--s-in_progress); }
144
+ .fchip[data-status="to_validate"].active { background:var(--s-to_validate); }
145
+ .fchip[data-status="to_analyze"].active { background:var(--s-to_analyze); }
146
+ .fchip[data-status="done"].active { background:var(--s-done); }
147
+ .fchip[data-status="blocked"].active { background:var(--s-blocked); }
148
+ .search { font:inherit; font-size:12.5px; padding:6px 13px; border:1px solid var(--line); border-radius:999px; background:var(--surface); color:var(--ink); min-width:210px; margin-left:auto; }
149
+ .search:focus { outline:2px solid var(--cool); outline-offset:1px; }
150
+ .search::placeholder { color:var(--faint); }
151
+ @media (max-width:520px){ .search { margin-left:0; min-width:0; width:100%; } }
152
+
153
+ /* board */
154
+ .board { padding:22px 24px 60px; overflow-y:auto; }
155
+ .phase { margin-bottom:26px; }
156
+ .phase-head { display:flex; align-items:center; gap:10px; margin-bottom:12px; padding:9px 12px; border:1px solid var(--line); border-radius:11px; background:var(--surface); cursor:pointer; user-select:none; transition:border-color .15s,background .15s; }
157
+ .phase-head:hover { border-color:color-mix(in srgb,var(--cool) 45%,var(--line)); background:var(--surface-2); }
158
+ .phase-head:focus-visible { outline:2px solid var(--cool); outline-offset:2px; }
159
+ /* raw shape points right; expanded (default, no .is-collapsed) rotates it down — the
160
+ conventional direction — and collapsed rotates it back to its raw right-pointing state */
161
+ .chevron { width:0; height:0; flex-shrink:0; border-style:solid; border-width:5px 0 5px 7px; border-color:transparent transparent transparent var(--muted); transition:transform .18s ease; transform:rotate(90deg); }
162
+ .phase.is-collapsed .chevron { transform:rotate(0deg); }
163
+ .phase-title { font-size:14.5px; font-weight:700; }
164
+ .phase-src { font-family:var(--mono); font-size:10px; color:var(--faint); }
165
+ .phase-bar { width:84px; height:6px; border-radius:999px; background:var(--surface-2); overflow:hidden; flex-shrink:0; }
166
+ [data-theme="light"] .phase-bar { background:var(--line); }
167
+ .phase-bar-fill { height:100%; background:linear-gradient(90deg,var(--cool),var(--signal)); border-radius:999px; transition:width .4s ease; }
168
+ .phase-stat { margin-left:auto; font-family:var(--mono); font-size:11px; color:var(--faint); flex-shrink:0; }
169
+ .phase .tasks { display:grid; grid-template-columns:repeat(auto-fill,minmax(290px,1fr)); gap:12px; overflow:hidden; max-height:4000px; opacity:1; transition:max-height .25s ease,opacity .2s ease,margin-top .2s ease; margin-top:12px; }
170
+ .phase.is-collapsed .tasks { max-height:0; opacity:0; margin-top:0; pointer-events:none; }
171
+ .task { background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); padding:13px 14px; cursor:pointer; transition:border-color .15s,transform .15s,box-shadow .15s; display:flex; flex-direction:column; gap:9px; }
172
+ .task:hover { border-color:color-mix(in srgb,var(--cool) 55%,var(--line)); transform:translateY(-1px); box-shadow:var(--shadow); }
173
+ .task:focus-visible { outline:2px solid var(--cool); outline-offset:2px; }
174
+ .task.is-in_progress { border-left:2px solid var(--signal); }
175
+ .task-top { display:flex; align-items:center; gap:8px; }
176
+ .task-id { font-family:var(--mono); font-size:11px; color:var(--muted); background:var(--surface-2); border:1px solid var(--line); border-radius:5px; padding:1px 6px; }
177
+ .lvl { font-family:var(--mono); font-size:9px; text-transform:uppercase; letter-spacing:.06em; color:var(--faint); border:1px solid var(--line); border-radius:4px; padding:1px 4px; }
178
+ .chip { font-family:var(--mono); font-size:10px; text-transform:uppercase; letter-spacing:.06em; padding:2px 7px; border-radius:999px; margin-left:auto; color:#10151b; font-weight:600; }
179
+ .chip.s-todo{background:var(--s-todo)} .chip.s-in_progress{background:var(--s-in_progress)} .chip.s-to_validate{background:var(--s-to_validate)} .chip.s-to_analyze{background:var(--s-to_analyze)} .chip.s-done{background:var(--s-done)} .chip.s-blocked{background:var(--s-blocked)}
180
+ .task-title { font-size:14px; font-weight:600; line-height:1.35; }
181
+ .task-tags { display:flex; flex-wrap:wrap; gap:5px; }
182
+ .task-tags .tag { font-family:var(--mono); font-size:10px; color:var(--cool); border:1px solid color-mix(in srgb,var(--cool) 35%,var(--line)); border-radius:5px; padding:1px 6px; }
183
+ .task-foot { display:flex; align-items:center; gap:10px; font-size:11.5px; color:var(--muted); margin-top:auto; }
184
+ .owner { font-family:var(--mono); }
185
+ .cmt-count { font-family:var(--mono); font-size:11px; color:var(--muted); }
186
+ .tflag { font-family:var(--mono); font-size:10px; padding:1px 5px; border-radius:4px; margin-left:auto; }
187
+ .tflag.pass { color:var(--s-done); border:1px solid color-mix(in srgb,var(--s-done) 45%,var(--line)); }
188
+ .tflag.fail { color:var(--s-blocked); border:1px solid color-mix(in srgb,var(--s-blocked) 45%,var(--line)); }
189
+
190
+ /* Requests tab (tasks awaiting review: to_validate / to_analyze) */
191
+ .requests-wrap { padding:26px 28px 60px; max-width:900px; }
192
+ .requests-list { display:flex; flex-direction:column; gap:7px; }
193
+ .request-row { cursor:pointer; display:flex; align-items:center; gap:10px; background:var(--surface); border:1px solid var(--line); border-radius:10px; padding:9px 12px; font-size:13px; transition:border-color .15s; }
194
+ .request-row:hover,.request-row:focus-visible { border-color:color-mix(in srgb,var(--signal) 40%,var(--line)); }
195
+ .request-id { font-family:var(--mono); font-size:11px; color:var(--faint); flex-shrink:0; }
196
+ .request-title { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--ink); }
197
+ .request-file { font-family:var(--mono); font-size:10.5px; color:var(--faint); flex-shrink:0; }
198
+
199
+ /* placeholder panels (none left as of the Chat tab — kept for any future WIP panel) */
200
+ .placeholder-wrap { padding:26px 28px 60px; max-width:1000px; }
201
+ .placeholder-empty { margin-top:8px; padding:40px; border:1px dashed var(--line); border-radius:var(--radius); text-align:center; color:var(--faint); font-style:italic; }
202
+
203
+ /* Backlog tab — flat, sortable, filterable table of every task */
204
+ .backlog-wrap { padding:26px 28px 60px; max-width:1200px; }
205
+ .backlog-wrap .filters { padding:0 0 14px; }
206
+ .table-scroll { width:100%; overflow-x:auto; border:1px solid var(--line); border-radius:var(--radius); }
207
+ .backlog-table { width:100%; min-width:720px; border-collapse:collapse; font-size:12.5px; }
208
+ .backlog-table thead th { position:sticky; top:0; z-index:1; background:var(--surface-2); text-align:left; padding:9px 12px; font-family:var(--mono); font-size:10.5px; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); white-space:nowrap; cursor:pointer; user-select:none; border-bottom:1px solid var(--line); }
209
+ .backlog-table thead th:hover { color:var(--ink); }
210
+ .backlog-table thead th.is-sorted { color:var(--ink); }
211
+ .backlog-table thead th[data-dir="asc"]::after { content:'\25B2'; margin-left:5px; font-size:9px; }
212
+ .backlog-table thead th[data-dir="desc"]::after { content:'\25BC'; margin-left:5px; font-size:9px; }
213
+ .backlog-table tbody tr.backlog-row { cursor:pointer; border-bottom:1px solid var(--line); transition:background .12s; }
214
+ .backlog-table tbody tr.backlog-row:last-child { border-bottom:0; }
215
+ .backlog-table tbody tr.backlog-row:hover,.backlog-table tbody tr.backlog-row:focus-visible { background:var(--surface-2); outline:none; }
216
+ .backlog-table td { padding:8px 12px; color:var(--ink); vertical-align:middle; }
217
+ .bl-id { font-family:var(--mono); font-size:11px; color:var(--muted); white-space:nowrap; }
218
+ .bl-title { min-width:200px; max-width:420px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
219
+ .bl-phase,.bl-owner { color:var(--muted); white-space:nowrap; }
220
+ .bl-level { color:var(--muted); white-space:nowrap; text-transform:capitalize; }
221
+ .bl-comments { font-family:var(--mono); font-size:11px; color:var(--muted); white-space:nowrap; }
222
+ .backlog-table .chip { margin-left:0; }
223
+ .backlog-empty-row td { padding:40px; text-align:center; color:var(--faint); font-style:italic; border:0; }
224
+ @media (max-width:640px){ .backlog-wrap { padding:18px 14px 40px; } }
225
+
226
+ /* Info tab (project overview — config, runners, counts, specs, active workflow) */
227
+ .info-wrap { padding:26px 28px 60px; max-width:1000px; }
228
+ .info-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:14px; margin-top:8px; }
229
+ .info-section { background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); padding:16px 18px; }
230
+ .info-section-head { display:flex; align-items:center; gap:8px; margin:0 0 12px; }
231
+ .info-section-ico { width:16px; height:16px; flex-shrink:0; display:inline-flex; align-items:center; justify-content:center; color:var(--cool); }
232
+ .info-section-ico svg { width:100%; height:100%; display:block; }
233
+ .info-section-title { font-size:11px; text-transform:uppercase; letter-spacing:.08em; color:var(--faint); font-weight:700; }
234
+ .info-section:nth-child(3) { grid-column:1 / -1; }
235
+ @media (max-width:760px){ .info-grid { grid-template-columns:1fr; } .info-section:nth-child(3) { grid-column:auto; } }
236
+
237
+ .info-rows { display:flex; flex-direction:column; gap:2px; }
238
+ .info-row { display:flex; align-items:baseline; justify-content:space-between; gap:10px; padding:7px 0; border-bottom:1px solid var(--line); font-size:12.5px; }
239
+ .info-row:last-child { border-bottom:0; }
240
+ .info-row-label { color:var(--muted); }
241
+ .info-row-value { color:var(--ink); font-weight:600; text-align:right; }
242
+ .info-rows-mono .info-row-value { font-family:var(--mono); font-size:11.5px; font-weight:500; overflow-wrap:anywhere; }
243
+ .info-rows-mono .info-row-label { font-family:var(--mono); font-size:11.5px; color:var(--cool); flex-shrink:0; }
244
+
245
+ .stat-tiles { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); gap:10px; }
246
+ .stat-tile { background:var(--surface-2); border:1px solid var(--line); border-radius:10px; padding:11px 12px; display:flex; flex-direction:column; gap:2px; }
247
+ .stat-tile-val { font-family:var(--mono); font-size:20px; font-weight:700; color:var(--ink); line-height:1.1; }
248
+ .stat-tile-label { font-size:11px; text-transform:uppercase; letter-spacing:.06em; color:var(--faint); font-weight:700; }
249
+ .stat-tile-sub { font-size:11px; color:var(--muted); }
250
+
251
+ .info-wf-list { display:flex; flex-direction:column; gap:6px; }
252
+ .info-wf-row { display:flex; align-items:center; gap:10px; background:var(--surface-2); border:1px solid var(--line); border-radius:8px; padding:7px 10px; font-size:12.5px; }
253
+ .info-wf-name { font-weight:600; color:var(--ink); }
254
+ .info-wf-meta { margin-left:auto; font-family:var(--mono); font-size:11px; color:var(--muted); text-align:right; }
255
+
256
+ /* workflow diagram */
257
+ .wf-wrap,.team-wrap { padding:26px 28px 60px; max-width:1000px; }
258
+ .wf-diagram { display:flex; flex-wrap:wrap; align-items:center; gap:0; margin-top:8px; }
259
+ .wf-step { display:flex; align-items:center; }
260
+ .wf-node { display:flex; align-items:center; gap:8px; padding:11px 15px; border:1px solid var(--line); border-radius:var(--radius); background:var(--surface); cursor:pointer; transition:border-color .18s,transform .15s,box-shadow .15s; user-select:none; }
261
+ .wf-node:hover { border-color:var(--cool); transform:translateY(-1px); box-shadow:var(--shadow); }
262
+ .wf-node .dot { width:8px; height:8px; border-radius:50%; background:var(--s-done); flex-shrink:0; }
263
+ .wf-node.off { opacity:.42; border-style:dashed; background:transparent; }
264
+ .wf-node.off .dot { background:var(--faint); }
265
+ .wf-node .nm { font-size:13px; font-weight:600; }
266
+ .wf-node .opt { font-family:var(--mono); font-size:9px; color:var(--faint); text-transform:uppercase; border:1px solid var(--line); border-radius:4px; padding:1px 4px; }
267
+ .wf-arrow { width:34px; height:2px; position:relative; background:var(--line); margin:0 2px; overflow:hidden; }
268
+ .wf-arrow::after { content:""; position:absolute; inset:0; width:40%; background:linear-gradient(90deg,transparent,var(--signal),transparent); animation:flow 2.2s linear infinite; }
269
+ .wf-arrow.off::after { display:none; }
270
+ @keyframes flow { from{ transform:translateX(-100%);} to{ transform:translateX(250%);} }
271
+
272
+ /* cards (agents / skills) — same surface/radius/shadow tokens as .task/.kpi/.ocard */
273
+ .cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:12px; }
274
+ .card { background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); padding:14px; cursor:pointer; transition:border-color .15s,transform .15s,box-shadow .15s; }
275
+ .card:hover { border-color:color-mix(in srgb,var(--cool) 55%,var(--line)); transform:translateY(-1px); box-shadow:var(--shadow); }
276
+ .card:focus-visible { outline:2px solid var(--cool); outline-offset:2px; }
277
+ .card .ct { font-size:14px; font-weight:700; }
278
+ .card .cc { display:inline-block; font-family:var(--mono); font-size:10px; color:var(--cool); text-transform:uppercase; letter-spacing:.06em; margin-top:6px; border:1px solid color-mix(in srgb,var(--cool) 40%,var(--line)); border-radius:999px; padding:2px 8px; }
279
+ .card .cd { font-size:12.5px; color:var(--muted); margin-top:8px; line-height:1.45; }
280
+ .card .cu { margin-top:8px; display:flex; align-items:center; flex-wrap:wrap; gap:5px; }
281
+ .card .cu b { font-size:9.5px; text-transform:uppercase; letter-spacing:.07em; color:var(--faint); font-weight:700; margin-right:1px; }
282
+ .card .cu span { font-family:var(--mono); font-size:10px; color:var(--muted); border:1px solid var(--line); border-radius:5px; padding:1px 6px; }
283
+ .card .io { margin-top:8px; font-family:var(--mono); font-size:11px; color:var(--muted); }
284
+
285
+ /* drawer */
286
+ .drawer { position:fixed; inset:0; z-index:20; pointer-events:none; }
287
+ .drawer[aria-hidden="false"] { pointer-events:auto; }
288
+ .drawer-scrim { position:absolute; inset:0; background:#0007; opacity:0; transition:opacity .2s; }
289
+ .drawer[aria-hidden="false"] .drawer-scrim { opacity:1; }
290
+ .drawer-panel { position:absolute; top:0; right:0; height:100%; width:min(440px,92vw); background:var(--surface); border-left:1px solid var(--line); padding:22px 22px 40px; overflow-y:auto; transform:translateX(100%); transition:transform .22s ease; box-shadow:var(--shadow); }
291
+ .drawer[aria-hidden="false"] .drawer-panel { transform:translateX(0); }
292
+ .drawer-close { position:absolute; top:14px; right:14px; width:30px; height:30px; border:1px solid var(--line); background:var(--surface-2); color:var(--muted); border-radius:8px; font-size:18px; cursor:pointer; }
293
+ .d-id { font-family:var(--mono); font-size:12px; color:var(--muted); } .d-title { font-size:19px; font-weight:700; margin:6px 0 14px; }
294
+ .d-section { margin-top:18px; } .d-label { font-size:11px; text-transform:uppercase; letter-spacing:.1em; color:var(--faint); font-weight:700; margin-bottom:7px; }
295
+ .d-text { font-size:13.5px; } .status-row { display:flex; flex-wrap:wrap; gap:6px; }
296
+
297
+ /* file-body drawer (agents/skills full markdown, rendered by mdLite in app.js) */
298
+ .md-body { font-size:13px; line-height:1.55; color:var(--ink); }
299
+ .md-body h1,.md-body h2,.md-body h3 { margin:14px 0 6px; line-height:1.3; }
300
+ .md-body h1:first-child,.md-body h2:first-child,.md-body h3:first-child { margin-top:0; }
301
+ .md-body h1 { font-size:17px; font-weight:700; }
302
+ .md-body h2 { font-size:15px; font-weight:700; }
303
+ .md-body h3 { font-size:12px; font-weight:700; color:var(--cool); text-transform:uppercase; letter-spacing:.05em; }
304
+ .md-body p { margin:0 0 10px; }
305
+ .md-body ul { margin:0 0 10px; padding-left:20px; }
306
+ .md-body li { margin:2px 0; }
307
+ .md-body code { font-family:var(--mono); font-size:12px; background:var(--surface-2); border:1px solid var(--line); border-radius:4px; padding:1px 5px; }
308
+ .md-body pre { background:#05080b; border:1px solid var(--line); border-radius:8px; padding:10px 12px; overflow-x:auto; margin:0 0 10px; }
309
+ [data-theme="light"] .md-body pre { background:var(--surface-2); }
310
+ .md-body pre code { background:none; border:0; padding:0; font-size:12px; color:#cdd9de; }
311
+ [data-theme="light"] .md-body pre code { color:var(--ink); }
312
+ .md-body strong { font-weight:700; }
313
+ .status-btn { font-family:var(--mono); font-size:11px; padding:4px 9px; border:1px solid var(--line); border-radius:999px; background:var(--surface-2); color:var(--muted); cursor:pointer; }
314
+ .status-btn.active { color:#10151b; border-color:transparent; font-weight:600; }
315
+ .comments { display:flex; flex-direction:column; gap:8px; }
316
+ .comment { background:var(--surface-2); border:1px solid var(--line); border-radius:8px; padding:8px 10px; font-size:13px; }
317
+ .c-box { display:flex; flex-direction:column; gap:8px; margin-top:8px; }
318
+ .c-box textarea { width:100%; min-height:60px; resize:vertical; background:var(--bg); color:var(--ink); border:1px solid var(--line); border-radius:8px; padding:8px 10px; font:inherit; }
319
+ .c-actions { display:flex; gap:8px; }
320
+ .btn { font:inherit; font-size:13px; padding:7px 12px; border-radius:8px; cursor:pointer; border:1px solid var(--line); background:var(--surface-2); color:var(--ink); }
321
+ .btn.primary { background:var(--signal); color:#241705; border-color:transparent; font-weight:600; }
322
+ .empty { color:var(--faint); font-style:italic; font-size:12.5px; }
323
+ :focus-visible { outline:2px solid var(--cool); outline-offset:2px; }
324
+ @media (max-width:900px){
325
+ .panel[data-panel="board"].is-active { grid-template-columns:1fr; }
326
+ .side { border-left:0; border-top:1px solid var(--line); flex-direction:row; flex-wrap:wrap; gap:16px 28px; }
327
+ .side .rail-block { min-width:200px; flex:1; }
328
+ .kpi-row { grid-template-columns:repeat(2,1fr); }
329
+ }
330
+ @media (max-width:520px){
331
+ .kpi-row { grid-template-columns:1fr; }
332
+ .donut-row { flex-direction:column; align-items:flex-start; }
333
+ }
334
+ @media (max-width:760px){ .tabs .tab{ padding:7px 8px;} .tab-label{ display:none; } }
335
+
336
+ /* chat widget (floating agent runner) — same surface/radius/shadow tokens as the card system.
337
+ Bubble/log styles below (.msg, .bubble, .chat-idle, .approval…) are shared, unscoped classes
338
+ so the Chat tab's #chatTabLog renders identically via the same renderChatLog(container). */
339
+ .chat-fab { position:fixed; right:20px; bottom:20px; z-index:18; width:54px; height:54px; border-radius:50%; border:1px solid var(--line); background:var(--surface-2); color:var(--ink); font-size:23px; cursor:pointer; box-shadow:var(--shadow); display:grid; place-items:center; transition:transform .15s,border-color .15s; }
340
+ .chat-fab:hover { transform:translateY(-2px); border-color:color-mix(in srgb,var(--signal) 50%,var(--line)); }
341
+ .chat-fab.is-open { box-shadow:inset 0 0 0 2px var(--signal); }
342
+ .chat { position:fixed; right:20px; bottom:88px; z-index:18; width:min(420px,94vw); height:min(600px,78vh); background:var(--surface); border:1px solid var(--line); border-radius:calc(var(--radius) + 2px); box-shadow:var(--shadow); display:flex; flex-direction:column; overflow:hidden; transform-origin:bottom right; transition:opacity .16s,transform .16s; }
343
+ .chat[aria-hidden="true"] { opacity:0; transform:scale(.96) translateY(8px); pointer-events:none; }
344
+ .chat-head { display:flex; align-items:center; gap:10px; padding:14px 16px; border-bottom:1px solid var(--line); background:linear-gradient(180deg,color-mix(in srgb,var(--surface-2) 75%,transparent),transparent); }
345
+ .chat-head-text { display:flex; flex-direction:column; gap:1px; min-width:0; }
346
+ .chat-title { font-weight:700; font-size:15px; }
347
+ .chat-sub { font-size:10px; color:var(--faint); text-transform:uppercase; letter-spacing:.04em; }
348
+ .chat-agent { margin-left:auto; flex-shrink:0; font:inherit; font-size:12px; background:var(--surface); color:var(--ink); border:1px solid var(--line); border-radius:7px; padding:5px 8px; }
349
+ .chat-close { width:28px; height:28px; flex-shrink:0; border:1px solid var(--line); background:var(--surface-2); color:var(--muted); border-radius:8px; font-size:17px; cursor:pointer; transition:border-color .15s,color .15s; }
350
+ .chat-close:hover { border-color:color-mix(in srgb,var(--s-blocked) 45%,var(--line)); color:var(--ink); }
351
+ .chat-log { flex:1; overflow-y:auto; padding:16px; display:flex; flex-direction:column; gap:11px; }
352
+ .chat-idle { color:var(--faint); font-style:italic; font-size:12.5px; }
353
+ .msg { display:flex; } .msg.you { justify-content:flex-end; }
354
+ .msg.you .bubble { background:var(--signal); color:#241705; border-radius:12px 12px 3px 12px; padding:8px 11px; font-size:13px; max-width:82%; font-weight:500; }
355
+ .msg.agent { margin:0; align-self:flex-start; max-width:92%; background:#05080b; border:1px solid var(--line); border-radius:12px 12px 12px 3px; padding:10px 12px; font-family:var(--mono); font-size:12px; line-height:1.5; color:#cdd9de; white-space:pre-wrap; word-break:break-word; overflow-wrap:anywhere; }
356
+ [data-theme="light"] .msg.agent { background:#10151b; }
357
+ .msg.agentmsg { flex-direction:column; align-items:flex-start; gap:3px; max-width:88%; }
358
+ .msg-role { font-family:var(--mono); font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:var(--cool); }
359
+ .msg.agentmsg .bubble { background:var(--surface-2); border:1px solid var(--line); border-radius:12px 12px 12px 3px; padding:8px 11px; font-size:13px; color:var(--ink); }
360
+ .msg.agentmsg.k-status .msg-role { color:var(--s-done); }
361
+ .msg.agentmsg.k-status .bubble { border-color:color-mix(in srgb,var(--s-done) 35%,var(--line)); }
362
+ .msg.agentmsg.k-question .msg-role { color:var(--signal); }
363
+ .msg.agentmsg.k-handoff .msg-role { color:var(--s-to_analyze); }
364
+ .chat-input { display:flex; gap:8px; padding:12px 14px 4px; }
365
+ .chat-ta { flex:1; min-height:40px; max-height:120px; resize:vertical; font:inherit; background:var(--bg); color:var(--ink); border:1px solid var(--line); border-radius:9px; padding:9px 11px; transition:border-color .15s; }
366
+ .chat-ta:focus { outline:2px solid var(--cool); outline-offset:1px; }
367
+ .chat-send { align-self:stretch; }
368
+ .chat-warn { font-size:10.5px; color:var(--signal); margin:6px 12px 10px; }
369
+ .approval { display:flex; flex-direction:column; gap:8px; padding:10px 12px; background:var(--surface-2); border:1px solid color-mix(in srgb,var(--signal) 40%,var(--line)); border-radius:var(--radius); box-shadow:var(--shadow); }
370
+ .approval .c-actions { display:flex; gap:8px; }
371
+ #orchBtn,#tabOrchBtn { background:var(--cool); color:#04202a; border-color:transparent; font-weight:600; }
372
+ .btn { transition:border-color .15s,transform .1s; }
373
+ .btn:hover { border-color:color-mix(in srgb,var(--cool) 45%,var(--line)); }
374
+ .btn:active { transform:translateY(1px); }
375
+ .btn.primary:hover { border-color:transparent; filter:brightness(1.06); }
376
+
377
+ /* Chat tab — full-height, full-width surface sharing renderChatLog/doRun/doOrchestrate/approve
378
+ with the floating widget (same .msg/.bubble/.chat-idle/.approval classes, unscoped above). */
379
+ .panel[data-panel="chat"].is-active { display:flex; flex-direction:column; }
380
+ .chat-tab-wrap { flex:1; min-height:0; display:flex; flex-direction:column; padding:22px 28px 26px; max-width:1040px; width:100%; margin:0 auto; }
381
+ .chat-tab-head { display:flex; align-items:flex-start; gap:14px; margin-bottom:14px; }
382
+ .chat-tab-head .panel-title { margin:0; }
383
+ .chat-tab-head .panel-sub { margin:2px 0 0; }
384
+ .chat-tab-head .chat-agent { margin-left:auto; flex-shrink:0; }
385
+ .chat-tab-log { flex:1; min-height:220px; overflow-y:auto; display:flex; flex-direction:column; gap:12px; background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); padding:20px; box-shadow:var(--shadow); }
386
+ .chat-tab-input { display:flex; gap:10px; margin-top:14px; }
387
+ .chat-tab-input .chat-ta { flex:1; min-height:56px; max-height:180px; }
388
+ .chat-tab-actions { display:flex; flex-direction:column; gap:8px; flex-shrink:0; }
389
+ .chat-tab-wrap .chat-warn { margin:8px 0 0; }
390
+ @media (max-width:640px){ .chat-tab-input { flex-direction:column; } .chat-tab-actions { flex-direction:row; } }
391
+
392
+ /* ---- chart & panel motion --------------------------------------------- */
393
+ /* cards/panels rise in on render */
394
+ @keyframes rise { from{ opacity:0; transform:translateY(8px);} to{ opacity:1; transform:translateY(0);} }
395
+ .kpi,.ocard,.card { animation:rise .4s cubic-bezier(.2,.8,.2,1) both; }
396
+ .kpi-row .kpi:nth-child(1){ animation-delay:.02s; } .kpi-row .kpi:nth-child(2){ animation-delay:.06s; }
397
+ .kpi-row .kpi:nth-child(3){ animation-delay:.1s; } .kpi-row .kpi:nth-child(4){ animation-delay:.14s; }
398
+
399
+ /* area curve draws itself — pathLength="1" makes stroke-dash* fraction-based */
400
+ @keyframes draw { to{ stroke-dashoffset:0; } }
401
+ .area-line { stroke-dasharray:1; stroke-dashoffset:1; animation:draw 1.1s cubic-bezier(.2,.8,.2,1) forwards; animation-delay:calc(var(--i,0)*.12s); }
402
+ .area-fill { opacity:0; animation:fadein .6s ease forwards; animation-delay:calc(.4s + var(--i,0)*.12s); }
403
+ .area-dot,.area-value { opacity:0; animation:fadein .3s ease forwards; animation-delay:calc(1s + var(--i,0)*.12s); }
404
+ @keyframes fadein { to{ opacity:1; } }
405
+
406
+ /* donut arcs pop in, staggered by --i */
407
+ @keyframes pop { from{ opacity:0; transform:scale(.85); } to{ opacity:1; transform:scale(1); } }
408
+ .seg-anim { transform-origin:center; transform-box:fill-box; opacity:0; animation:pop .45s cubic-bezier(.2,.8,.2,1) forwards; animation-delay:calc(var(--i,0)*.08s); }
409
+
410
+ /* phase bars grow + the count-up badge is driven from JS (countUp in app.js) */
411
+ .bar-fill { animation:grow .6s cubic-bezier(.2,.8,.2,1) both; animation-delay:calc(var(--i,0)*.07s); }
412
+ @keyframes grow { from{ width:0; } }
413
+
414
+ /* gradient ring (stroke="url(#grad)") fades/scales in */
415
+ .ring-svg circle:last-of-type { transform-origin:center; animation:pop .5s cubic-bezier(.2,.8,.2,1) both; }
416
+
417
+ @media (prefers-reduced-motion: reduce){
418
+ *{ animation:none!important; transition:none!important; }
419
+ .area-line{ stroke-dashoffset:0!important; }
420
+ .area-fill,.area-dot,.area-value,.seg-anim{ opacity:1!important; }
421
+ }
422
+
423
+ /* chart tooltip layer — theme-aware, follows the cursor over [data-tip] hit targets */
424
+ .tooltip { position:fixed; z-index:30; pointer-events:none; background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:6px 10px; font-size:11.5px; line-height:1.4; color:var(--ink); box-shadow:var(--shadow); max-width:220px; }
425
+ .tooltip[hidden] { display:none; }
426
+ .tooltip b { color:var(--ink); }
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+ /*
3
+ * Agent run pipeline — spawns the configured agent headless and turns its output into the
4
+ * group-chat message log. The user prompt is logged as a message; the agent identifies itself by
5
+ * printing sentinel lines (::spectoflow role=… kind=… msg=…) which become structured messages;
6
+ * any other output streams raw as run-line events. `emit` publishes SSE events to the dashboard.
7
+ *
8
+ * Kept separate from server.js so the pipeline is unit-testable without an HTTP server.
9
+ */
10
+ const { spawn } = require('child_process');
11
+ const store = require('../lib/store');
12
+
13
+ function runStart(root, run) {
14
+ const rt = store.readRuntime(root); rt.agents = rt.agents || []; rt.agents.push(run); store.writeRuntime(root, rt);
15
+ }
16
+ function runEnd(root, id, code) {
17
+ const rt = store.readRuntime(root); const a = (rt.agents || []).find((x) => x.id === id);
18
+ if (a) { a.status = code === 0 ? 'done' : 'failed'; a.endedAt = new Date().toISOString(); }
19
+ store.writeRuntime(root, rt);
20
+ }
21
+ // Buffer a stream into whole lines; flush() emits any trailing partial line at close.
22
+ function makeFeeder(onLine) {
23
+ let buf = '';
24
+ return {
25
+ feed(chunk) { buf += chunk.toString(); let i; while ((i = buf.indexOf('\n')) >= 0) { onLine(buf.slice(0, i)); buf = buf.slice(i + 1); } },
26
+ flush() { if (buf) { onLine(buf); buf = ''; } },
27
+ };
28
+ }
29
+
30
+ // Start an agent run. Returns { runId, child } or { error } if no runner is configured.
31
+ function startRun(root, { prompt, agent }, emit) {
32
+ const cfg = store.readConfig(root);
33
+ const which = agent || cfg.agent || 'claude';
34
+ const cmdStr = cfg.runners && cfg.runners[which];
35
+ if (!cmdStr) return { error: `No runner configured for "${which}".` };
36
+ const parts = cmdStr.split(/\s+/).filter(Boolean);
37
+ const runId = 'r' + Date.now().toString(36);
38
+ const p = String(prompt).trim();
39
+
40
+ const um = store.appendMessage(root, { role: 'user', kind: 'message', text: p, agent: which, runId });
41
+ emit({ type: 'message', message: um });
42
+
43
+ const run = { id: runId, tool: which, prompt: p, status: 'running', startedAt: new Date().toISOString() };
44
+ runStart(root, run); emit({ type: 'run-start', run }); emit({ type: 'change' });
45
+
46
+ let child;
47
+ try { child = spawn(parts[0], [...parts.slice(1), p], { cwd: root, env: process.env }); }
48
+ catch (e) {
49
+ runEnd(root, runId, 1);
50
+ emit({ type: 'run-line', runId, chunk: 'spawn error: ' + e.message + '\n' });
51
+ emit({ type: 'run-end', runId, code: 1 }); emit({ type: 'change' });
52
+ return { runId };
53
+ }
54
+ // End the child's stdin immediately: a child that reads stdin (or a Windows pipe that
55
+ // otherwise keeps 'close' from firing) can't stall the run waiting on input that never comes.
56
+ try { child.stdin && child.stdin.end(); } catch {}
57
+
58
+ const onLine = (line) => {
59
+ const m = store.parseAgentLine(line);
60
+ if (m) { const full = store.appendMessage(root, { ...m, agent: which, runId }); emit({ type: 'message', message: full }); }
61
+ else emit({ type: 'run-line', runId, chunk: line + '\n' });
62
+ };
63
+ const out = makeFeeder(onLine), err = makeFeeder(onLine);
64
+ child.stdout && child.stdout.on('data', (d) => out.feed(d));
65
+ child.stderr && child.stderr.on('data', (d) => err.feed(d));
66
+ child.on('error', (e) => emit({ type: 'run-line', runId, chunk: 'error: ' + e.message + '\n' }));
67
+ child.on('close', (code) => {
68
+ out.flush(); err.flush();
69
+ runEnd(root, runId, code);
70
+ const sm = store.appendMessage(root, { role: which, kind: 'status', text: `finished (exit ${code})`, agent: which, runId });
71
+ emit({ type: 'message', message: sm });
72
+ emit({ type: 'run-end', runId, code }); emit({ type: 'change' });
73
+ });
74
+ return { runId, child };
75
+ }
76
+
77
+ module.exports = { startRun };
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+ /*
3
+ * spectoflow dashboard — ZERO-DEPENDENCY server, real-time (SSE + fs.watch).
4
+ * v0.4 adds an agent launcher: POST /api/run spawns the configured agent headless in the project
5
+ * root (with project memory: CLAUDE.md → AGENTS.md), streams its output over SSE, and records the
6
+ * run in .spectoflow/runtime.json. As the agent edits plans/*.md, the board refreshes live.
7
+ */
8
+ const http = require('http');
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const store = require('../lib/store');
12
+ const { startRun } = require('./runner');
13
+ const orchestrator = require('./orchestrator');
14
+
15
+ const PORT = process.env.SPECTOFLOW_PORT ? Number(process.env.SPECTOFLOW_PORT) : 4319;
16
+ const PUBLIC = path.join(__dirname, 'public');
17
+ const ROOT = process.env.SPECTOFLOW_ROOT || path.resolve(__dirname, '..', '..');
18
+ const MIME = { '.html':'text/html; charset=utf-8', '.css':'text/css; charset=utf-8', '.js':'application/javascript; charset=utf-8' };
19
+ const clients = new Set();
20
+
21
+ function project(){ return store.readProject(ROOT); }
22
+ function sendJSON(res,code,obj){ res.writeHead(code,{'Content-Type':'application/json; charset=utf-8'}); res.end(JSON.stringify(obj)); }
23
+ function body(req){ return new Promise(r=>{ let b=''; req.on('data',c=>b+=c); req.on('end',()=>{ try{r(JSON.parse(b||'{}'));}catch{r({});} }); }); }
24
+ function emit(obj){ const line='data: '+JSON.stringify(obj)+'\n\n'; for(const res of clients) res.write(line); }
25
+ function findPlanFileForTask(id){ for(const pl of store.readPlans(ROOT)) for(const ph of pl.phases) if(ph.tasks.find(t=>t.id===id)) return pl.file; return null; }
26
+
27
+ function watch(dir){ try{ fs.watch(dir,{recursive:false},()=>emit({type:'change'})); }catch(_){} }
28
+ ['plans','specs','.spectoflow'].forEach(d=>{ const p=path.join(ROOT,d); if(fs.existsSync(p)) watch(p); });
29
+
30
+ // A process restart loses any in-flight orchestration; without this, a stale 'running' or
31
+ // 'awaiting_approval' status wedges the /api/orchestrate 409 guard forever. Not a real
32
+ // resume — just clears the wedge so a fresh orchestration can start.
33
+ try { orchestrator.reconcileOnBoot(ROOT); } catch {}
34
+
35
+ const server = http.createServer(async (req,res)=>{
36
+ const u=new URL(req.url,`http://localhost:${PORT}`); const p=u.pathname;
37
+ try{
38
+ if(p==='/api/project') return sendJSON(res,200,project());
39
+
40
+ // ---- read-only agent/skill file viewer (scoped to .spectoflow/{agents,skills}/**) ----
41
+ if (p === '/api/agentfile' && req.method === 'GET') {
42
+ const rel = new URL(req.url, 'http://x').searchParams.get('path') || '';
43
+ const base = path.join(ROOT, '.spectoflow');
44
+ const abs = path.resolve(base, rel);
45
+ const okDir = abs.startsWith(path.join(base, 'agents') + path.sep) || abs.startsWith(path.join(base, 'skills') + path.sep);
46
+ if (!okDir || !abs.endsWith('.md') || !fs.existsSync(abs) || fs.statSync(abs).isDirectory())
47
+ return sendJSON(res, 400, { error: 'not an agent/skill file' });
48
+ return sendJSON(res, 200, { content: fs.readFileSync(abs, 'utf8') });
49
+ }
50
+
51
+ if(p==='/api/events'){
52
+ res.writeHead(200,{'Content-Type':'text/event-stream','Cache-Control':'no-cache',Connection:'keep-alive'});
53
+ res.write('data: '+JSON.stringify({type:'hello'})+'\n\n');
54
+ clients.add(res); req.on('close',()=>clients.delete(res)); return;
55
+ }
56
+
57
+ if(p.startsWith('/api/task/')&&req.method==='PATCH'){
58
+ const id=decodeURIComponent(p.split('/')[3]||''); const patch=await body(req);
59
+ const file=findPlanFileForTask(id); if(!file) return sendJSON(res,404,{error:`Task ${id} not found.`});
60
+ store.updateTaskLine(ROOT,file,id,patch); emit({type:'change'}); return sendJSON(res,200,{ok:true});
61
+ }
62
+ if(/^\/api\/task\/[^/]+\/comment$/.test(p)&&req.method==='POST'){
63
+ const id=decodeURIComponent(p.split('/')[3]||''); const {text,action}=await body(req);
64
+ if(!text||!String(text).trim()) return sendJSON(res,400,{error:'Empty comment.'});
65
+ const file=findPlanFileForTask(id); if(!file) return sendJSON(res,404,{error:`Task ${id} not found.`});
66
+ store.addTaskComment(ROOT,file,id,String(text).trim(),'me');
67
+ if(action==='analyze') store.updateTaskLine(ROOT,file,id,{status:'to_analyze'});
68
+ emit({type:'change'}); return sendJSON(res,200,{ok:true});
69
+ }
70
+ if(p==='/api/workflow/toggle'&&req.method==='POST'){
71
+ const {name}=await body(req); const wf=path.join(ROOT,'.spectoflow','workflow.md');
72
+ const lines=fs.readFileSync(wf,'utf8').split('\n');
73
+ for(let i=0;i<lines.length;i++){ const m=lines[i].match(/^(\s*- \[)( |x|X)(\]\s+)(.*)$/);
74
+ if(m&&m[4].replace(/\s*\(optional\)\s*$/i,'').trim()===name) lines[i]=m[1]+(m[2].trim()?' ':'x')+m[3]+m[4]; }
75
+ fs.writeFileSync(wf,lines.join('\n')); emit({type:'change'}); return sendJSON(res,200,{ok:true});
76
+ }
77
+
78
+ // ---- agent launcher (pipeline lives in runner.js; posts to the group-chat log) ----
79
+ if(p==='/api/run'&&req.method==='POST'){
80
+ const {prompt,agent}=await body(req);
81
+ if(!prompt||!String(prompt).trim()) return sendJSON(res,400,{error:'Empty request.'});
82
+ const r=startRun(ROOT,{prompt,agent},emit);
83
+ if(r.error) return sendJSON(res,400,{error:r.error});
84
+ return sendJSON(res,200,{runId:r.runId});
85
+ }
86
+
87
+ // ---- orchestrator ----
88
+ if (p === '/api/orchestrate' && req.method === 'POST') {
89
+ const { request } = await body(req);
90
+ if (!request || !String(request).trim()) return sendJSON(res, 400, { error: 'Empty request.' });
91
+ const active = store.readRuntime(ROOT).orchestration;
92
+ if (active && ['running', 'awaiting_approval'].includes(active.status))
93
+ return sendJSON(res, 409, { error: 'An orchestration is already active.' });
94
+ const mode = store.readConfig(ROOT).mode || 'semi';
95
+ // fire and forget; state + messages stream over SSE
96
+ orchestrator.runOrchestration({ root: ROOT, request: String(request).trim(), mode,
97
+ runStep: orchestrator.defaultRunStep, confirm: orchestrator.defaultConfirm }, emit)
98
+ .catch((e) => emit({ type: 'message', message: { role: 'orchestrator', kind: 'status', text: 'orchestration error: ' + e.message } }));
99
+ const o = store.readRuntime(ROOT).orchestration;
100
+ return sendJSON(res, 200, { orchestrationId: o && o.id });
101
+ }
102
+ if (p === '/api/orchestrate/approve' && req.method === 'POST') {
103
+ const { decision, note } = await body(req);
104
+ const ok = orchestrator.submitDecision(decision, note);
105
+ return sendJSON(res, ok ? 200 : 409, ok ? { ok: true } : { error: 'No pending approval.' });
106
+ }
107
+
108
+ let file=p==='/'?'/index.html':p;
109
+ const full=path.join(PUBLIC,path.normalize(file).replace(/^(\.\.[/\\])+/,''));
110
+ if(!full.startsWith(PUBLIC)){ res.writeHead(403); return res.end('Forbidden'); }
111
+ fs.readFile(full,(err,data)=>{ if(err){res.writeHead(404); return res.end('Not found');}
112
+ res.writeHead(200,{'Content-Type':MIME[path.extname(full)]||'application/octet-stream'}); res.end(data); });
113
+ }catch(e){ sendJSON(res,500,{error:String(e&&e.message||e)}); }
114
+ });
115
+ server.listen(PORT,()=>{ console.log(`spectoflow · dashboard → http://localhost:${PORT}`); console.log(`project root: ${ROOT}`); });