@hanzlaa/rcode 4.6.0 → 4.7.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.
package/README.md CHANGED
@@ -23,7 +23,7 @@ pnpm dlx @hanzlaa/rcode install
23
23
  [![CI](https://github.com/hanzlahabib/rcode/actions/workflows/test.yml/badge.svg)](https://github.com/hanzlahabib/rcode/actions/workflows/test.yml)
24
24
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
25
25
 
26
- Status: `@hanzlaa/rcode` v4.4.4 on npm. 45 agents · 117 commands · 129 workflows · **1 runtime dependency**. Test status tracked by CI badge above. Actively dogfooded on real projects every week.
26
+ Status: `@hanzlaa/rcode` v4.7.0 on npm. 45 agents · 117 commands · 129 workflows · **1 runtime dependency**. Test status tracked by CI badge above. Actively dogfooded on real projects every week.
27
27
 
28
28
  ---
29
29
 
@@ -49,7 +49,19 @@ If you're a solo dev or small team using Claude Code (or Cursor, Codex, VS Code)
49
49
 
50
50
  <p align="center"><img src="brand/hero-org-in-folder.png" alt="rcode is an engineering org in a folder: agents, commands, workflows, and skills as plain markdown" width="760"></p>
51
51
 
52
- Three layers, specialised for software delivery:
52
+ In plain words: **rcode is a folder of instructions your AI already knows how to read.** No new app to install, no server to run, no separate "agent brain" — just files. Your IDE (Claude Code, Cursor, whichever) is already an agent; rcode just hands it a really good playbook and a notebook that never forgets.
53
+
54
+ ```mermaid
55
+ flowchart LR
56
+ A["You type a command<br/>/rcode-plan"] --> B["rcode's files answer:<br/>what to do, in what order"]
57
+ B --> C["Your IDE's own agent<br/>does the actual work"]
58
+ C --> D["Result gets saved to<br/>.rcode/memory/"]
59
+ D -.->|"next session starts here"| A
60
+ ```
61
+
62
+ That loop — command in, memory out, memory feeds the next command — is the whole idea. Everything else in this repo is detail on top of that loop.
63
+
64
+ Three layers make it up:
53
65
 
54
66
  | Layer | What lives here | Example |
55
67
  |-------|-----------------|---------|
@@ -63,15 +75,26 @@ Single agent navigates the structure. No LangChain, no AutoGen, no orchestrator
63
75
 
64
76
  ## Why I built it
65
77
 
66
- I've shipped products solo for years and watched the same failure repeat in every project:
67
-
68
- - **Session 1:** Productive — agent helps me design auth, picks Postgres, plans the schema.
69
- - **Session 5:** Agent has no idea I picked Postgres. Suggests Mongo. We argue. I copy-paste the decision back in.
70
- - **Session 20:** I'm pasting in 4K tokens of "here's what we decided" every session. The agent never learns. The project file structure isn't enough — decisions live in chat.
78
+ I've shipped products solo for years and watched the same failure repeat in every project. In one sentence: **the AI forgets everything the moment the chat window closes, so I kept re-explaining the same decisions forever.**
79
+
80
+ ```mermaid
81
+ flowchart LR
82
+ subgraph without["😩 Without rcode"]
83
+ direction TB
84
+ w1["Session 1 — pick Postgres"] --> w2["Session 5 — agent forgot,<br/>suggests Mongo, you argue"]
85
+ w2 --> w3["Session 20 — you're pasting<br/>4K tokens of 'here's what<br/>we decided' every time"]
86
+ end
87
+ subgraph with["✅ With rcode"]
88
+ direction TB
89
+ r1["Session 1 — pick Postgres"] --> r2["Decision saved once to<br/>.rcode/memory/decisions.md"]
90
+ r2 --> r3["Session 20 — agent reads it<br/>automatically, no re-explaining"]
91
+ end
92
+ without ~~~ with
93
+ ```
71
94
 
72
- rcode is the answer I built for myself. **The decision lives in `.rcode/memory/decisions.md`. The agent reads it. Done.**
95
+ That's it. That's the whole pitch. **Write the decision down once, in a file. The agent reads the file. Done.**
73
96
 
74
- Same problem at team scale: onboarding takes 30 minutes of archaeology. Late requirements shift goalposts with no audit trail. MVPs work but can't be revamped because the original "why" is gone. rcode checks the context in, so the next person (or session) starts oriented.
97
+ The same problem shows up at team scale, just wearing a different costume: onboarding a new hire takes 30 minutes of Slack archaeology, a late requirement quietly shifts the goalposts with no record of why, and six months later nobody remembers why the MVP was built the way it was. rcode's fix is the same either way — write the context down where the agent (and the next human) will actually see it.
75
98
 
76
99
  ---
77
100
 
@@ -81,6 +104,7 @@ What you'll feel in week one:
81
104
 
82
105
  - **No more re-explaining.** Decisions, blockers, conventions live in `.rcode/memory/` — agent reads them at session start automatically (~5K tokens, fully oriented).
83
106
  - **Phased delivery without ceremony.** `/rcode-new-project` produces a roadmap with phases → sprints → tasks. `/rcode-plan` produces SPRINT.md files. `/rcode-execute` runs them with atomic commits. No Jira required.
107
+ - **No blank-page starts.** Already know you're building an API, a SaaS product, or a mobile app? `/rcode-from-template api-backend` seeds a real roadmap + requirements doc for that project type — edit it down instead of writing it up from nothing.
84
108
  - **Specialist review on tap.** Want a Karpathy-style review of your last commit? `/rcode-review --karpathy`. Want a council debate on a decision? `/rcode-council should I rewrite auth?` — 5 agents answer in parallel, round 2 they challenge each other.
85
109
  - **Intent guards.** Run the wrong command and get a one-line redirect, not a useless output.
86
110
  - **Health check.** `rcode-tools health` returns JSON — milestone health, state snapshot, project status. Wire it into your dashboard.
@@ -159,12 +183,21 @@ pnpm dlx @hanzlaa/rcode install
159
183
 
160
184
  ### The full loop
161
185
 
186
+ Four commands cover most of a real week of work — decide, plan, build, check in:
187
+
188
+ ```mermaid
189
+ flowchart LR
190
+ A["🗣️ /rcode-council<br/>should I rewrite auth?"] --> B["📋 /rcode-plan --research<br/>build a rental app"]
191
+ B --> C["⚙️ /rcode-execute<br/>PLAN.md"]
192
+ C --> D["📊 /rcode-status<br/>phases · decisions · blockers"]
162
193
  ```
163
- /rcode-council should I rewrite auth? → 5 agents debate, 2 rounds
164
- /rcode-plan --research build a rental app → researcher grounds, sprint-checker verifies
165
- /rcode-execute .planning/plans/01/PLAN.md → atomic commits + post-gates
166
- /rcode-status → phases, decisions, blockers, sessions
167
- ```
194
+
195
+ | Command | What it does |
196
+ |---|---|
197
+ | `/rcode-council should I rewrite auth?` | 5 specialist agents debate it, 2 rounds — you get a decision, not a monologue |
198
+ | `/rcode-plan --research build a rental app` | A researcher grounds the plan in your real codebase, a checker verifies it before you build anything |
199
+ | `/rcode-execute .planning/plans/01/PLAN.md` | Runs the plan as atomic git commits, with pass/fail gates between steps |
200
+ | `/rcode-status` | One glance at phases, decisions, and blockers — no digging through chat history |
168
201
 
169
202
  Full install flavors and IDE options: [`docs/install.md`](docs/install.md). Step-by-step first project: [`docs/getting-started.md`](docs/getting-started.md).
170
203
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzlaa/rcode",
3
- "version": "4.6.0",
3
+ "version": "4.7.0",
4
4
  "description": "rcode — the AI team that never forgets. Persistent memory, specialist agents, and slash commands for AI IDEs. Works in Claude Code, Cursor, Gemini, VS Code, and Antigravity.",
5
5
  "main": "cli/index.js",
6
6
  "bin": {
@@ -14,7 +14,9 @@
14
14
 
15
15
  import { html, useState, useEffect, useRef, useCallback } from '../preact.js';
16
16
  import { parseFilters } from '../filter-state.js';
17
- import { getState, setState, subscribe, registerRefresh } from '../store.js';
17
+ import { getState, setState, subscribe, registerRefresh, closeFileViewer, closeDecisionViewer, closeBlockerViewer } from '../store.js';
18
+ import { FileReader } from './FileReader.js';
19
+ import { DecisionDrawer, BlockerDrawer } from './shared.js';
18
20
  import { startSessionsPoll, refreshOrchToken } from '../orchestrator.js';
19
21
  import { Sidebar } from './Sidebar.js';
20
22
  import { Topbar } from './Topbar.js';
@@ -32,6 +34,7 @@ import { PhasesView } from '../views/PhasesView.js';
32
34
  import { SprintsView } from '../views/SprintsView.js';
33
35
  import { TasksView } from '../views/TasksView.js';
34
36
  import { KanbanView } from '../views/KanbanView.js';
37
+ import { BacklogView } from '../views/BacklogView.js';
35
38
  import { FilesView } from '../views/FilesView.js';
36
39
  import { AgentsView } from '../views/AgentsView.js';
37
40
  import { MemoryView } from '../views/MemoryView.js';
@@ -48,6 +51,7 @@ const PREACT_VIEWS = {
48
51
  sprints: SprintsView,
49
52
  tasks: TasksView,
50
53
  kanban: KanbanView,
54
+ backlog: BacklogView,
51
55
  files: FilesView,
52
56
  agents: AgentsView,
53
57
  memory: MemoryView,
@@ -213,6 +217,7 @@ export function App() {
213
217
  timeline: d.timeline || null,
214
218
  tasks: d.tasks || null,
215
219
  health: d.health || null,
220
+ backlog: d.backlog || [],
216
221
  });
217
222
  if (newState.raw) {
218
223
  Object.assign(patch, {
@@ -310,6 +315,15 @@ export function App() {
310
315
  <${RunnerPicker} />
311
316
  <${RunConfirmDialog} pending=${storeState.runConfirm} />
312
317
  <${CommandPalette} open=${paletteOpen} onClose=${() => setPaletteOpen(false)} />
318
+ ${storeState.fileViewer && html`
319
+ <${FileReader}
320
+ path=${storeState.fileViewer.path}
321
+ title=${storeState.fileViewer.title}
322
+ onClose=${closeFileViewer}
323
+ />
324
+ `}
325
+ <${DecisionDrawer} decision=${storeState.decisionViewer} onClose=${closeDecisionViewer} />
326
+ <${BlockerDrawer} blocker=${storeState.blockerViewer} onClose=${closeBlockerViewer} />
313
327
  </div>
314
328
  `;
315
329
  }
@@ -171,25 +171,33 @@ function Tooltip({ phase, nodePos, canvasW, canvasH }) {
171
171
  `;
172
172
  }
173
173
 
174
- /** Wrapped flow row of chips — the honest no-dependencies presentation. */
174
+ /**
175
+ * Sequence row — the honest no-dependencies presentation. There is nothing
176
+ * to lay out as a DAG (no edges), so this doesn't pretend to be a graph: a
177
+ * connected left-to-right chain of small status pills in roadmap order,
178
+ * joined by arrows. Reads as "the order things ship in", not "broken nodes".
179
+ */
175
180
  function FlowRow({ phases }) {
176
181
  return html`
177
- <div class="pg-flow">
178
- ${phases.map(p => {
182
+ <div class="pg-hint pg-hint-top">No cross-phase dependencies declared — phases shown in roadmap order.</div>
183
+ <div class="pg-seq">
184
+ ${phases.map((p, i) => {
179
185
  const kind = statusKind(p.status);
180
186
  const sprints = (p.sprints || []).length;
181
187
  return html`
182
- <button key=${p.id} type="button"
183
- class=${'pg-chip pg-' + kind}
184
- title=${(p.name || '') + ' — ' + sprints + (sprints === 1 ? ' sprint' : ' sprints')}
185
- onClick=${() => goToPhase(p.id)}>
186
- <span class="pg-chip-id">P${p.id}</span>
187
- <span class="pg-chip-name">${truncate(p.name, 24)}</span>
188
- </button>
188
+ <span key=${p.id} class="pg-seq-item">
189
+ <button type="button"
190
+ class=${'pg-seq-chip pg-' + kind}
191
+ title=${(p.name || '') + ' — ' + sprints + (sprints === 1 ? ' sprint' : ' sprints')}
192
+ onClick=${() => goToPhase(p.id)}>
193
+ <span class="pg-seq-id">P${p.id}</span>
194
+ <span class="pg-seq-name">${truncate(p.name, 22)}</span>
195
+ </button>
196
+ ${i < phases.length - 1 ? html`<span class="pg-seq-arrow" aria-hidden="true">›</span>` : null}
197
+ </span>
189
198
  `;
190
199
  })}
191
200
  </div>
192
- <div class="pg-hint">No cross-phase dependencies declared — phases shown in roadmap order.</div>
193
201
  `;
194
202
  }
195
203
 
@@ -280,7 +288,7 @@ export function PhaseGraph({ phases }) {
280
288
  return html`
281
289
  <details class="pg-panel" open>
282
290
  <summary>
283
- <${Icon} name="layers" size=${14}/> Dependency Graph
291
+ <${Icon} name="layers" size=${14}/> ${hasDeps ? 'Dependency Graph' : 'Phases'}
284
292
  <span class="pg-count">${list.length} ${list.length === 1 ? 'phase' : 'phases'}</span>
285
293
  </summary>
286
294
  <div class="pg-legend">
@@ -33,6 +33,7 @@ const NAV_LINKS = [
33
33
  { view: 'sprints', icon: 'zap', label: 'Sprints' },
34
34
  { view: 'tasks', icon: 'checkSquare', label: 'Tasks' },
35
35
  { view: 'kanban', icon: 'kanban', label: 'Kanban' },
36
+ { view: 'backlog', icon: 'hourglass', label: 'Backlog' },
36
37
  { view: 'decisions', icon: 'scale', label: 'Decisions' },
37
38
  { view: 'files', icon: 'file-text', label: 'Files' },
38
39
  { view: 'agents', icon: 'users', label: 'Agents' },
@@ -92,10 +92,10 @@ export function Topbar({ projectName, updatedAgo, refreshing, onRefresh, onToggl
92
92
  class="tb-btn tb-btn--icon"
93
93
  type="button"
94
94
  onClick=${onToggleTheme}
95
- title=${'More — switch to ' + (themeLabel === 'light' ? 'dark' : 'light') + ' theme'}
96
- aria-label="More options"
95
+ title=${'Switch to ' + (themeLabel === 'light' ? 'dark' : 'light') + ' theme'}
96
+ aria-label="Toggle theme"
97
97
  >
98
- <span class="tb-kebab">⋯</span>
98
+ <${Icon} name=${themeLabel === 'light' ? 'moon' : 'sun'} size=${15} />
99
99
  </button>
100
100
  </div>
101
101
  </header>
@@ -11,8 +11,8 @@
11
11
  */
12
12
 
13
13
  import { html } from '../../preact.js';
14
- import { useStore } from '../../store.js';
15
- import { rowLink } from '../../util.js';
14
+ import { useStore, openBlockerViewer } from '../../store.js';
15
+ import { pressable } from '../shared.js';
16
16
 
17
17
  // Severity → pill label (lowercase enum to human-facing label).
18
18
  const SEV_LABEL = { high: 'High', medium: 'Medium', low: 'Low' };
@@ -40,7 +40,7 @@ export function Blockers() {
40
40
  ${blockers.map((b) => {
41
41
  const sev = SEV_LABEL[b.severity] ? b.severity : 'low';
42
42
  return html`
43
- <li class="bk-row ovr-link" key=${b.title} ...${rowLink('tasks')}>
43
+ <li class="bk-row ovr-link" key=${b.title} ...${pressable(() => openBlockerViewer(b))}>
44
44
  <span class=${'bk-icon bk-sev-' + sev} aria-hidden="true">⚠</span>
45
45
  <div class="bk-body">
46
46
  <p class="bk-title">${b.title}</p>
@@ -10,8 +10,9 @@
10
10
  */
11
11
 
12
12
  import { html } from '../../preact.js';
13
- import { useStore } from '../../store.js';
13
+ import { useStore, openFileViewer } from '../../store.js';
14
14
  import { humanDate } from '../../util.js';
15
+ import { pressable } from '../shared.js';
15
16
 
16
17
  export function CompletedTasks() {
17
18
  const S = useStore();
@@ -30,7 +31,8 @@ export function CompletedTasks() {
30
31
  : html`
31
32
  <ul class="ct-list">
32
33
  ${items.map((t, i) => html`
33
- <li class="ct-row" key=${t.title + i}>
34
+ <li class=${'ct-row' + (t.file ? ' ovr-link' : '')} key=${t.title + i}
35
+ ...${t.file ? pressable(() => openFileViewer(t.file, t.title)) : {}}>
34
36
  <svg class="ct-check" width="16" height="16" viewBox="0 0 24 24"
35
37
  fill="none" stroke="currentColor" stroke-width="2.5"
36
38
  stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -18,8 +18,8 @@
18
18
  */
19
19
 
20
20
  import { html } from '../../preact.js';
21
- import { useStore } from '../../store.js';
22
- import { orchElapsed, rowLink } from '../../util.js';
21
+ import { useStore, openFileViewer } from '../../store.js';
22
+ import { orchElapsed } from '../../util.js';
23
23
  import { openTermPanel } from '../../orchestrator.js';
24
24
  import { pressable } from '../shared.js';
25
25
  import { TaskPipeline } from '../TaskPipeline.js';
@@ -60,7 +60,8 @@ export function InProgress() {
60
60
  <ul class="ip-list">
61
61
  ${live.map(s => html`<${LiveRow} key=${'live-' + s.storyId} session=${s}/>`)}
62
62
  ${items.map((t, i) => html`
63
- <li class="ip-row ovr-link" key=${t.title + i} ...${rowLink('tasks')}>
63
+ <li class=${'ip-row' + (t.file ? ' ovr-link' : '')} key=${t.title + i}
64
+ ...${t.file ? pressable(() => openFileViewer(t.file, t.title)) : {}}>
64
65
  <span class="ip-title">${t.title}</span>
65
66
  <${TaskPipeline} task=${t} mini=${true}/>
66
67
  ${Number.isFinite(t.pct) ? html`<span class="ip-badge">${t.pct}%</span>` : null}
@@ -12,8 +12,8 @@
12
12
  */
13
13
 
14
14
  import { html } from '../../preact.js';
15
- import { useStore } from '../../store.js';
16
- import { rowLink } from '../../util.js';
15
+ import { useStore, openFileViewer } from '../../store.js';
16
+ import { pressable } from '../shared.js';
17
17
 
18
18
  // Map phase state → label + badge/segment modifier.
19
19
  function stateMeta(state) {
@@ -84,11 +84,16 @@ export function ProgressTimeline() {
84
84
  <div class="pt-track">
85
85
  ${phases.map((p, i) => {
86
86
  const m = stateMeta(p.state);
87
- // Segment deep-links to its phase detail; fall back to the list
88
- // when the phase has no id.
89
- const target = p.id != null ? 'phases/' + p.id : 'phases';
87
+ // Same "first sprint with a resolved file" lookup PhasesView uses
88
+ // for its "View plan file" button. Falls back to the phase detail
89
+ // page when nothing resolved (e.g. phase has no SPRINT.md yet).
90
+ const sps = Array.isArray(p.sprints) ? p.sprints : [];
91
+ const planFile = (sps.find(s => s.file) || {}).file || null;
92
+ const onActivate = planFile
93
+ ? () => openFileViewer(planFile, 'Phase ' + (p.id != null ? p.id : '') + ' plan')
94
+ : () => { location.hash = p.id != null ? 'phases/' + p.id : 'phases'; };
90
95
  return html`
91
- <div class=${'pt-seg ovr-link ' + m.mod} key=${p.name + i} ...${rowLink(target)}>
96
+ <div class=${'pt-seg ovr-link ' + m.mod} key=${p.name + i} ...${pressable(onActivate)}>
92
97
  <span class="pt-seg-name">${p.name}</span>
93
98
  <span class="pt-seg-range">${p.range || ''}</span>
94
99
  <span class="pt-seg-badge">${m.label}</span>
@@ -9,8 +9,9 @@
9
9
  */
10
10
 
11
11
  import { html } from '../../preact.js';
12
- import { useStore } from '../../store.js';
13
- import { humanDate, rowLink } from '../../util.js';
12
+ import { useStore, openDecisionViewer } from '../../store.js';
13
+ import { humanDate } from '../../util.js';
14
+ import { pressable } from '../shared.js';
14
15
 
15
16
  // Map a free-form status string to a badge modifier class.
16
17
  function statusClass(status) {
@@ -42,7 +43,7 @@ export function RecentDecisions() {
42
43
  : html`
43
44
  <ul class="rd-list">
44
45
  ${decisions.map((d, i) => html`
45
- <li class="rd-row ovr-link" key=${d.title + i} ...${rowLink('decisions')}>
46
+ <li class="rd-row ovr-link" key=${d.title + i} ...${pressable(() => openDecisionViewer(d))}>
46
47
  <span class="rd-title">${d.title}</span>
47
48
  ${d.status
48
49
  ? html`<span class=${'rd-badge ' + statusClass(d.status)}>${d.status}</span>`
@@ -7,12 +7,12 @@
7
7
  * Import from here; do NOT inline these in view modules.
8
8
  */
9
9
 
10
- import { html, useState } from '../preact.js';
10
+ import { html, useState, useEffect } from '../preact.js';
11
11
  import { pctNum, chip as chipDesc, humanDate, pct, currentPhaseId } from '../util.js';
12
12
  import {
13
13
  isSessionRunning, runningInSprint, runningInPhase,
14
14
  } from '../orchestrator.js';
15
- import { getState } from '../store.js';
15
+ import { getState, openFileViewer } from '../store.js';
16
16
  import { Icon } from '../icons-client.js';
17
17
  import { TaskPipeline } from './TaskPipeline.js';
18
18
  import { openRunnerPicker } from './RunnerPicker.js';
@@ -264,11 +264,20 @@ export function SprintCard({ sprint: s, S }) {
264
264
  const borderStyle = isCur
265
265
  ? 'border-left-color:var(--accent-amber);background:rgba(245,158,11,0.04)'
266
266
  : '';
267
+ function handleViewFile(e) {
268
+ e.stopPropagation();
269
+ openFileViewer(s.file, 'Sprint ' + s.id);
270
+ }
267
271
  return html`
268
272
  <div class=${'item item-clickable' + (isCur ? ' sprint-current' : '')} style=${borderStyle}
269
273
  ...${pressable(() => { location.hash = 'sprints/' + s.id; })}>
270
274
  <div class="item-title">
271
275
  <${RunBtn} storyId=${'sprint-' + s.id} cmd=${'/rcode-execute-sprint ' + s.id} label=${'Sprint ' + s.id}/>
276
+ ${s.file ? html`
277
+ <button class="card-file-btn" title=${'View ' + s.file} onClick=${handleViewFile}>
278
+ <${Icon} name="file-text" size=${11}/> File
279
+ </button>
280
+ ` : null}
272
281
  Sprint ${s.id} — ${s.goal || 'No goal'}
273
282
  ${isCur ? html`<${Tag}>current</${Tag}>` : null}
274
283
  <${Chip} status=${s.status}/>
@@ -321,6 +330,10 @@ export function TaskCard({ task: t }) {
321
330
  }
322
331
  }
323
332
 
333
+ function handleViewFile(e) {
334
+ e.stopPropagation();
335
+ openFileViewer(t.file, t.title);
336
+ }
324
337
  return html`
325
338
  <div class="item item-clickable" data-status=${t.status || ''}
326
339
  style=${done ? 'opacity:.65' : ''}
@@ -328,6 +341,11 @@ export function TaskCard({ task: t }) {
328
341
  ...${pressable(() => setExpanded(e => !e))}>
329
342
  <div class="item-title" style=${done ? 'text-decoration:line-through' : ''}>
330
343
  ${t.id && !done ? html`<${RunBtn} storyId=${t.id} cmd=${'/rcode-dev-story ' + t.id} label=${'Story ' + t.id}/>` : null}
344
+ ${t.file ? html`
345
+ <button class="card-file-btn" title=${'View ' + t.file} onClick=${handleViewFile}>
346
+ <${Icon} name="file-text" size=${11}/> File
347
+ </button>
348
+ ` : null}
331
349
  ${done ? '✓ ' : ''}${t.title}
332
350
  <${Chip} status=${t.status}/>
333
351
  <span class="task-expand-icon">${expanded ? '▼' : '▶'}</span>
@@ -378,3 +396,94 @@ export function TaskCard({ task: t }) {
378
396
  </div>
379
397
  `;
380
398
  }
399
+
400
+ // ---- DecisionDrawer ----
401
+ /**
402
+ * Same slide-over shell as FileReader (.reader-*) but shows a decision
403
+ * record's own fields instead of fetching a file — decisions have no
404
+ * backing markdown file, only whatever state.json recorded for them.
405
+ * @param {{ decision: object|null, onClose: function }} props
406
+ */
407
+ export function DecisionDrawer({ decision: d, onClose }) {
408
+ // Hooks run unconditionally (Rules of Hooks) even though the component
409
+ // renders null below when there's nothing open.
410
+ useEffect(() => {
411
+ function onKey(e) { if (e.key === 'Escape' && onClose) onClose(); }
412
+ document.addEventListener('keydown', onKey);
413
+ return () => document.removeEventListener('keydown', onKey);
414
+ }, [onClose]);
415
+
416
+ if (!d) return null;
417
+ const title = d.title || d.summary || d.decision || 'Decision';
418
+
419
+ // state.json only records a short summary + a few fields for each entry —
420
+ // the full write-up (rationale, alternatives considered) lives in the
421
+ // separate, manually-curated decision log. There's no reliable way to
422
+ // match this specific record to one entry in that file, so this opens the
423
+ // whole log rather than pretending to jump to the right spot.
424
+ function handleOpenLog(e) {
425
+ e.stopPropagation();
426
+ onClose();
427
+ openFileViewer('.rcode/memory/project/decisions.md', 'Decision Log');
428
+ }
429
+
430
+ return html`
431
+ <div class="reader-backdrop" onClick=${onClose}></div>
432
+ <div class="reader-panel" role="dialog" aria-label=${title}>
433
+ <div class="reader-header">
434
+ <div class="reader-heading">
435
+ <div class="reader-title">${title}</div>
436
+ ${d.phase ? html`<div class="reader-path">Phase ${d.phase}</div>` : null}
437
+ </div>
438
+ <div class="reader-actions">
439
+ <button class="reader-copy" onClick=${handleOpenLog}>View decision log →</button>
440
+ <button class="reader-close" aria-label="Close" onClick=${onClose}>×</button>
441
+ </div>
442
+ </div>
443
+ <div class="reader-body">
444
+ ${d.status ? html`<div class="task-detail-row"><strong>Status:</strong> <${Chip} status=${d.status}/></div>` : null}
445
+ ${d.date ? html`<div class="task-detail-row"><strong>Date:</strong> ${humanDate(d.date)}</div>` : null}
446
+ ${d.plan != null ? html`<div class="task-detail-row"><strong>Plan:</strong> ${d.plan}</div>` : null}
447
+ ${d.rationale ? html`<div class="task-detail-row"><strong>Rationale:</strong> ${d.rationale}</div>` : null}
448
+ <div class="dash-empty">
449
+ <span>This is everything state.json recorded for this entry. Full rationale and alternatives (when written up) live in the decision log.</span>
450
+ </div>
451
+ </div>
452
+ </div>
453
+ `;
454
+ }
455
+
456
+ // ---- BlockerDrawer ----
457
+ /**
458
+ * Same slide-over shell as DecisionDrawer — blockers are plain
459
+ * { title, desc, severity } records in state.json, no backing file.
460
+ * @param {{ blocker: object|null, onClose: function }} props
461
+ */
462
+ export function BlockerDrawer({ blocker: b, onClose }) {
463
+ useEffect(() => {
464
+ function onKey(e) { if (e.key === 'Escape' && onClose) onClose(); }
465
+ document.addEventListener('keydown', onKey);
466
+ return () => document.removeEventListener('keydown', onKey);
467
+ }, [onClose]);
468
+
469
+ if (!b) return null;
470
+ const sev = ['high', 'medium', 'low'].includes(b.severity) ? b.severity : 'low';
471
+
472
+ return html`
473
+ <div class="reader-backdrop" onClick=${onClose}></div>
474
+ <div class="reader-panel" role="dialog" aria-label=${b.title}>
475
+ <div class="reader-header">
476
+ <div class="reader-heading">
477
+ <div class="reader-title">${b.title}</div>
478
+ </div>
479
+ <div class="reader-actions">
480
+ <button class="reader-close" aria-label="Close" onClick=${onClose}>×</button>
481
+ </div>
482
+ </div>
483
+ <div class="reader-body">
484
+ <div class="task-detail-row"><strong>Severity:</strong> <span class=${'bk-pill bk-sev-' + sev}>${sev}</span></div>
485
+ ${b.desc ? html`<div class="task-detail-row"><strong>Details:</strong> ${b.desc}</div>` : null}
486
+ </div>
487
+ </div>
488
+ `;
489
+ }
@@ -23,6 +23,7 @@ let _state = {
23
23
  timeline: _seed.timeline || null,
24
24
  tasks: _seed.tasks || null,
25
25
  health: _seed.health || null,
26
+ backlog: _seed.backlog || [],
26
27
  // Fields injected by client.js / window.__S__
27
28
  phases: _seed.phases || [],
28
29
  milestone: _seed.milestone || '',
@@ -81,11 +82,57 @@ let _state = {
81
82
  // renders and the spawn waits for explicit user approval.
82
83
  // { kind: 'story'|'command', storyId?, cmd, title, opts }
83
84
  runConfirm: null,
85
+ // Global file-viewer drawer state — opens FileReader over whatever view is
86
+ // currently active (Overview, Phases, Sprints, Tasks, Roadmap, ...) without
87
+ // navigating away, unlike the older requestedFile→Files-view bridge above.
88
+ // { path, title } | null. Driven by openFileViewer()/closeFileViewer() below.
89
+ fileViewer: null,
90
+ // Global decision-detail drawer state. Decisions have no backing file (they
91
+ // live as plain records in state.json), so this shows the record's own
92
+ // fields rather than fetching anything. The raw decision object | null.
93
+ decisionViewer: null,
94
+ // Global blocker-detail drawer state — same reasoning as decisionViewer:
95
+ // blockers are plain { title, desc, severity } records with no backing
96
+ // file. The raw blocker object | null.
97
+ blockerViewer: null,
84
98
  };
85
99
 
86
100
  /** Registered subscriber functions. */
87
101
  const _subscribers = new Set();
88
102
 
103
+ /** Open the global file-viewer drawer over the current view. No-op when path is falsy. */
104
+ export function openFileViewer(path, title) {
105
+ if (!path) return;
106
+ setState({ fileViewer: { path, title: title || path.split('/').pop() } });
107
+ }
108
+
109
+ /** Close the global file-viewer drawer. */
110
+ export function closeFileViewer() {
111
+ setState({ fileViewer: null });
112
+ }
113
+
114
+ /** Open the global decision-detail drawer. No-op when decision is falsy. */
115
+ export function openDecisionViewer(decision) {
116
+ if (!decision) return;
117
+ setState({ decisionViewer: decision });
118
+ }
119
+
120
+ /** Close the global decision-detail drawer. */
121
+ export function closeDecisionViewer() {
122
+ setState({ decisionViewer: null });
123
+ }
124
+
125
+ /** Open the global blocker-detail drawer. No-op when blocker is falsy. */
126
+ export function openBlockerViewer(blocker) {
127
+ if (!blocker) return;
128
+ setState({ blockerViewer: blocker });
129
+ }
130
+
131
+ /** Close the global blocker-detail drawer. */
132
+ export function closeBlockerViewer() {
133
+ setState({ blockerViewer: null });
134
+ }
135
+
89
136
  /** Return a shallow copy of the current state. */
90
137
  export function getState() {
91
138
  return { ..._state };
@@ -75,7 +75,7 @@ export function allSprints(phases) {
75
75
  export function allTasks(phases) {
76
76
  return (phases || []).flatMap(p =>
77
77
  (p.sprints || []).flatMap(s =>
78
- (s.stories || []).map(t => Object.assign({}, t, { sprintId: s.id, phaseId: p.id, phaseName: p.name }))
78
+ (s.stories || []).map(t => Object.assign({}, t, { sprintId: s.id, phaseId: p.id, phaseName: p.name, file: s.file || null }))
79
79
  )
80
80
  );
81
81
  }
@@ -139,27 +139,6 @@ export function sessionChip(status) {
139
139
  return { cls, label: status };
140
140
  }
141
141
 
142
- /**
143
- * Props for a clickable card row that navigates to a hash route.
144
- * Spread onto a list row (`<li ...${rowLink('tasks')}>`) to make it act like
145
- * a link: pointer + keyboard activation and an accessible role. Pair with the
146
- * `ovr-link` class for the hover/focus affordance.
147
- *
148
- * @param {string} hash — target hash route without the leading '#'.
149
- * @returns {object} Preact props (role, tabindex, onClick, onKeyDown).
150
- */
151
- export function rowLink(hash) {
152
- const go = () => { location.hash = hash; };
153
- return {
154
- role: 'link',
155
- tabindex: 0,
156
- onClick: go,
157
- onKeyDown: (e) => {
158
- if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); }
159
- },
160
- };
161
- }
162
-
163
142
  /**
164
143
  * Human-readable elapsed time since an ISO timestamp.
165
144
  * Ported from _orchElapsed() in client-main.js.
@@ -0,0 +1,44 @@
1
+ /**
2
+ * BacklogView — Preact component.
3
+ *
4
+ * Lists phases that haven't started yet (state.dashboard.backlog, derived
5
+ * server-side in scanner.js from phases with state === 'todo'). Each row
6
+ * links to that phase's detail page — same navigation PhaseCard/SprintCard
7
+ * already use elsewhere, no new routing concept.
8
+ */
9
+
10
+ import { html } from '../preact.js';
11
+ import { useStore } from '../store.js';
12
+ import { pressable } from '../components/shared.js';
13
+ import { Icon } from '../icons-client.js';
14
+
15
+ export function BacklogView() {
16
+ const S = useStore();
17
+ const backlog = S.backlog || [];
18
+
19
+ return html`
20
+ <div id="view-backlog" class="view active">
21
+ <div class="view-title">Backlog</div>
22
+ ${backlog.length === 0
23
+ ? html`
24
+ <div class="empty">
25
+ No phases waiting in the backlog.
26
+ <div class="empty-action">Run /rcode-add-phase to queue up new work</div>
27
+ </div>
28
+ `
29
+ : html`
30
+ <div class="phase-list">
31
+ ${backlog.map(p => html`
32
+ <div key=${p.id} class="item item-clickable"
33
+ ...${pressable(() => { location.hash = 'phases/' + p.id; })}>
34
+ <div class="item-title">
35
+ <${Icon} name="clipboard-list" size=${16}/> Phase ${p.id} — ${p.name}
36
+ </div>
37
+ ${p.range ? html`<div class="item-meta">${p.range}</div>` : null}
38
+ </div>
39
+ `)}
40
+ </div>
41
+ `}
42
+ </div>
43
+ `;
44
+ }
@@ -7,9 +7,9 @@
7
7
  */
8
8
 
9
9
  import { html, useState } from '../preact.js';
10
- import { useStore } from '../store.js';
10
+ import { useStore, openDecisionViewer } from '../store.js';
11
11
  import { humanDate } from '../util.js';
12
- import { CmdHint, CmdHints, showToast } from '../components/shared.js';
12
+ import { CmdHint, CmdHints, showToast, pressable } from '../components/shared.js';
13
13
 
14
14
  const CMD_HINTS = [
15
15
  ['/rcode-council', 'Convene the council for a new decision'],
@@ -83,8 +83,9 @@ export function DecisionsView() {
83
83
  const rationale = (typeof d === 'object' && d.rationale)
84
84
  ? html`<div style="color:var(--text-secondary);font-size:var(--text-sm);margin-top:4px;">${d.rationale}</div>`
85
85
  : null;
86
+ const decisionObj = typeof d === 'object' ? d : { title: d };
86
87
  return html`
87
- <div key=${i} class="item">
88
+ <div key=${i} class="item item-clickable" ...${pressable(() => openDecisionViewer(decisionObj))}>
88
89
  <div class="item-title">${title}${dateInfo}</div>
89
90
  <div class="item-meta">${phaseInfo}</div>
90
91
  ${rationale}
@@ -10,20 +10,27 @@
10
10
  * clears the field so subsequent renders don't re-trigger.
11
11
  */
12
12
 
13
- import { html, useState, useEffect } from '../preact.js';
13
+ import { html, useState, useEffect, useMemo } from '../preact.js';
14
14
  import { useStore, setState } from '../store.js';
15
15
  import { FileReader } from '../components/FileReader.js';
16
+ import { Icon } from '../icons-client.js';
17
+
18
+ // File-type icon — markdown gets its own glyph, everything else a plain file.
19
+ function fileIcon(label) {
20
+ return /\.md$/i.test(label || '') ? 'file-text' : 'file';
21
+ }
16
22
 
17
23
  // ---- File tree components ----
18
- function FileEntry({ file, extraText, onSelect, isSelected }) {
24
+ function FileEntry({ file, onSelect, isSelected }) {
19
25
  return html`
20
26
  <div
21
- class=${'item item-clickable inline-file-entry' + (isSelected ? ' selected' : '')}
27
+ class=${'inline-file-entry' + (isSelected ? ' selected' : '')}
22
28
  data-path=${file.path}
29
+ title=${file.path}
23
30
  onClick=${() => onSelect(file)}
24
- style="padding:var(--space-2) var(--space-3);font-family:'SF Mono',Monaco,Consolas,monospace;font-size:var(--text-xs);"
25
31
  >
26
- ${file.label}
32
+ <${Icon} name=${fileIcon(file.label)} size=${13}/>
33
+ <span class="inline-file-label">${file.label}</span>
27
34
  </div>
28
35
  `;
29
36
  }
@@ -36,31 +43,31 @@ function FileGroup({ group, onSelect, selectedPath, filter }) {
36
43
  }
37
44
 
38
45
  if (group.subGroups) {
46
+ const subGroupsVisible = group.subGroups
47
+ .map(sg => ({ sg, visible: sg.files.filter(f => matchesFilter(f, sg.subGroup)) }))
48
+ .filter(x => x.visible.length);
49
+ if (!subGroupsVisible.length) return null;
50
+ const total = subGroupsVisible.reduce((n, x) => n + x.visible.length, 0);
39
51
  return html`
40
- <div class="inline-file-group" style="margin-bottom:var(--space-3);">
41
- <div style="font-size:var(--text-xs);font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.07em;padding:var(--space-1) var(--space-3);">
42
- ${group.group}
52
+ <div class="inline-file-group">
53
+ <div class="inline-file-group-title">
54
+ ${group.group} <span class="inline-file-group-count">${total}</span>
43
55
  </div>
44
- ${group.subGroups.map(sg => {
45
- const visible = sg.files.filter(f => matchesFilter(f, sg.subGroup));
46
- if (!visible.length) return null;
47
- return html`
48
- <details class="inline-subgroup" open style="margin-left:var(--space-2);margin-bottom:var(--space-1);">
49
- <summary style="font-size:var(--text-xs);font-weight:500;color:var(--text-secondary);cursor:pointer;padding:var(--space-1) var(--space-3);user-select:none;">
50
- ${sg.subGroup} <span style="color:var(--text-muted);font-weight:400;">(${visible.length})</span>
51
- </summary>
52
- ${visible.map(f => html`
53
- <${FileEntry}
54
- key=${f.path}
55
- file=${f}
56
- extraText=${sg.subGroup}
57
- onSelect=${onSelect}
58
- isSelected=${selectedPath === f.path}
59
- />
60
- `)}
61
- </details>
62
- `;
63
- })}
56
+ ${subGroupsVisible.map(({ sg, visible }) => html`
57
+ <details class="inline-subgroup" open>
58
+ <summary class="inline-subgroup-summary">
59
+ ${sg.subGroup} <span class="inline-file-group-count">${visible.length}</span>
60
+ </summary>
61
+ ${visible.map(f => html`
62
+ <${FileEntry}
63
+ key=${f.path}
64
+ file=${f}
65
+ onSelect=${onSelect}
66
+ isSelected=${selectedPath === f.path}
67
+ />
68
+ `)}
69
+ </details>
70
+ `)}
64
71
  </div>
65
72
  `;
66
73
  }
@@ -69,9 +76,9 @@ function FileGroup({ group, onSelect, selectedPath, filter }) {
69
76
  const visible = group.files.filter(f => matchesFilter(f, ''));
70
77
  if (!visible.length) return null;
71
78
  return html`
72
- <div class="inline-file-group" style="margin-bottom:var(--space-3);">
73
- <div style="font-size:var(--text-xs);font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.07em;padding:var(--space-1) var(--space-3);">
74
- ${group.group}
79
+ <div class="inline-file-group">
80
+ <div class="inline-file-group-title">
81
+ ${group.group} <span class="inline-file-group-count">${visible.length}</span>
75
82
  </div>
76
83
  ${visible.map(f => html`
77
84
  <${FileEntry}
@@ -116,9 +123,15 @@ export function FilesView() {
116
123
  setState({ requestedFile: null });
117
124
  }, [requestedFile]);
118
125
 
126
+ const total = useMemo(() => groups.reduce((n, g) =>
127
+ n + (g.files ? g.files.length : (g.subGroups || []).reduce((m, sg) => m + sg.files.length, 0)), 0),
128
+ [groups]);
129
+
119
130
  return html`
120
131
  <div class="view active" id="view-files">
121
- <div class="view-title">Files</div>
132
+ <div class="view-title">
133
+ Files ${total > 0 ? html`<span class="inline-file-total">${total}</span>` : null}
134
+ </div>
122
135
  <div id="file-list-inline">
123
136
  <div class="filter-bar">
124
137
  <input
@@ -131,9 +144,9 @@ export function FilesView() {
131
144
  </div>
132
145
  <div id="inline-file-items" class="phase-list">
133
146
  ${loading
134
- ? html`<div class="empty" style="margin:16px;">Loading…</div>`
147
+ ? html`<div class="empty">Loading…</div>`
135
148
  : groups.length === 0
136
- ? html`<div class="empty" style="margin:16px;">No files found.</div>`
149
+ ? html`<div class="empty">No files found.</div>`
137
150
  : groups.map(g => html`
138
151
  <${FileGroup}
139
152
  key=${g.group}
@@ -11,6 +11,7 @@ import { html, useState, useCallback } from '../preact.js';
11
11
  import { useStore, refresh } from '../store.js';
12
12
  import { allTasks, currentPhaseName } from '../util.js';
13
13
  import { stopStory, openOrchPanel, openTermPanel, setTaskStatus } from '../orchestrator.js';
14
+ import { openFileViewer } from '../store.js';
14
15
  import { openRunnerPicker } from '../components/RunnerPicker.js';
15
16
  import { showToast } from '../components/shared.js';
16
17
 
@@ -66,6 +67,10 @@ function KanbanCard({ task, col, live, orchDown, onDragStart, onDragEnd }) {
66
67
  e.stopPropagation();
67
68
  openOrchPanel(sid);
68
69
  }
70
+ function handleViewFile(e) {
71
+ e.stopPropagation();
72
+ openFileViewer(task.file, task.title || sid);
73
+ }
69
74
 
70
75
  return html`
71
76
  <div
@@ -92,6 +97,9 @@ function KanbanCard({ task, col, live, orchDown, onDragStart, onDragEnd }) {
92
97
  ` : null}
93
98
  ${sid ? html`
94
99
  <div class="kanban-card-actions">
100
+ ${task.file ? html`
101
+ <button class="kanban-view-btn" title=${'View ' + task.file} onClick=${handleViewFile}>📄 File</button>
102
+ ` : null}
95
103
  ${canRun ? html`
96
104
  <button class="kanban-run-btn" disabled=${orchDown}
97
105
  title=${orchDown ? 'Orchestrator unreachable' : 'Run ' + sid}
@@ -14,6 +14,7 @@ import {
14
14
  Chip, ProgressBar, Breadcrumb, CmdHints, RunningBadge, SprintCard, PhaseCard,
15
15
  } from '../components/shared.js';
16
16
  import { openTermPanel, runningInPhase } from '../orchestrator.js';
17
+ import { openFileViewer } from '../store.js';
17
18
  import { openRunnerPicker } from '../components/RunnerPicker.js';
18
19
  import { Icon } from '../icons-client.js';
19
20
  import { StatusSummaryBar } from '../components/StatusSummaryBar.js';
@@ -71,10 +72,12 @@ function PhaseDetail({ phase: p, S }) {
71
72
  e.stopPropagation();
72
73
  openTermPanel('phase-' + p.id, 'Phase ' + p.id);
73
74
  }
75
+ // First sprint that has a resolved SPRINT.md path — the phase-level "view
76
+ // plan" entry point; each SprintCard below also gets its own precise button.
77
+ const planFile = (sps.find(s => s.file) || {}).file || null;
74
78
  function handleViewPlan(e) {
75
79
  e.stopPropagation();
76
- // Navigate to files view viewPlanFile was a legacy DOM function
77
- window.location.hash = 'files';
80
+ openFileViewer(planFile, 'Phase ' + p.id + ' plan');
78
81
  }
79
82
 
80
83
  return html`
@@ -99,7 +102,9 @@ function PhaseDetail({ phase: p, S }) {
99
102
  <div class="term-action-bar">
100
103
  <button class="term-run-btn" onClick=${handleRun}>▶ Run Phase</button>
101
104
  <button class="term-run-btn outline" onClick=${handleTerm}><${Icon} name="monitor" size=${14}/> Terminal</button>
102
- <button class="back-btn" onClick=${handleViewPlan}><${Icon} name="file-text" size=${14}/> View plan file →</button>
105
+ ${planFile ? html`
106
+ <button class="back-btn" onClick=${handleViewPlan}><${Icon} name="file-text" size=${14}/> View plan file →</button>
107
+ ` : null}
103
108
  </div>
104
109
  <${VelocityBars} sprints=${sps}/>
105
110
  <div class="view-title" style="margin-top:var(--space-6)">Sprints</div>
@@ -15,6 +15,7 @@ import {
15
15
  Chip, ProgressBar, Breadcrumb, CmdHints, RunningBadge, SprintCard, TaskCard,
16
16
  } from '../components/shared.js';
17
17
  import { openTermPanel, runningInSprint } from '../orchestrator.js';
18
+ import { openFileViewer } from '../store.js';
18
19
  import { openRunnerPicker } from '../components/RunnerPicker.js';
19
20
  import { Icon } from '../icons-client.js';
20
21
  import { StatusSummaryBar } from '../components/StatusSummaryBar.js';
@@ -37,6 +38,7 @@ function SprintDetail({ sprint: s, S }) {
37
38
  sprintGoal: s.goal || '',
38
39
  phaseId: s.phaseId,
39
40
  phaseName: s.phaseName,
41
+ file: s.file || null,
40
42
  }),
41
43
  );
42
44
  const done = stories.filter(t => t.status === 'done' || t.status === 'completed').length;
@@ -60,6 +62,10 @@ function SprintDetail({ sprint: s, S }) {
60
62
  e.stopPropagation();
61
63
  openTermPanel('sprint-' + s.id, 'Sprint ' + s.id);
62
64
  }
65
+ function handleViewPlan(e) {
66
+ e.stopPropagation();
67
+ openFileViewer(s.file, 'Sprint ' + s.id);
68
+ }
63
69
 
64
70
  return html`
65
71
  <div>
@@ -88,6 +94,9 @@ function SprintDetail({ sprint: s, S }) {
88
94
  <div class="term-action-bar">
89
95
  <button class="term-run-btn" onClick=${handleRun}>▶ Run Sprint</button>
90
96
  <button class="term-run-btn outline" onClick=${handleTerm}><${Icon} name="monitor" size=${14}/> Terminal</button>
97
+ ${s.file ? html`
98
+ <button class="back-btn" onClick=${handleViewPlan}><${Icon} name="file-text" size=${14}/> View plan file →</button>
99
+ ` : null}
91
100
  </div>
92
101
  <div class="view-title" style="margin-top:var(--space-4)">Tasks</div>
93
102
  <div class="phase-list">
@@ -34,6 +34,7 @@ function clientState(state) {
34
34
  timeline: d.timeline || null,
35
35
  tasks: d.tasks || null,
36
36
  health: d.health || null,
37
+ backlog: d.backlog || [],
37
38
  // Colliding keys — the contract shapes are supersets, so legacy views and
38
39
  // the redesign read the same field. currentPhase becomes the contract object.
39
40
  phases: d.phases || state.phaseTree || state.raw?.phases || [],
@@ -997,6 +997,7 @@ section .body {
997
997
  .inline-file-entry {
998
998
  display: flex;
999
999
  align-items: center;
1000
+ gap: var(--space-2);
1000
1001
  padding: var(--space-2) var(--space-4);
1001
1002
  font-family: var(--font-mono);
1002
1003
  font-size: var(--text-xs);
@@ -1009,8 +1010,49 @@ section .body {
1009
1010
  .inline-file-entry:last-child { border-bottom: none; }
1010
1011
  .inline-file-entry:hover { background: var(--bg-hover); color: var(--text-primary); }
1011
1012
  .inline-file-entry.selected { background: rgba(34,182,207,0.1); color: var(--accent-primary); }
1013
+ .inline-file-entry svg { flex-shrink: 0; opacity: 0.7; }
1014
+ .inline-file-label {
1015
+ overflow: hidden;
1016
+ text-overflow: ellipsis;
1017
+ white-space: nowrap;
1018
+ }
1012
1019
  .inline-file-group { margin-bottom: var(--space-3); }
1013
- .inline-subgroup { padding-left: var(--space-4); }
1020
+ .inline-file-group-title {
1021
+ display: flex;
1022
+ align-items: center;
1023
+ gap: var(--space-2);
1024
+ font-size: var(--text-xs);
1025
+ font-weight: 600;
1026
+ color: var(--text-muted);
1027
+ text-transform: uppercase;
1028
+ letter-spacing: 0.07em;
1029
+ padding: var(--space-1) var(--space-3);
1030
+ }
1031
+ .inline-file-group-count {
1032
+ font-size: var(--text-2xs);
1033
+ font-weight: 500;
1034
+ color: var(--text-muted);
1035
+ text-transform: none;
1036
+ letter-spacing: 0;
1037
+ }
1038
+ .inline-subgroup { padding-left: var(--space-4); margin-bottom: var(--space-1); }
1039
+ .inline-subgroup-summary {
1040
+ display: flex;
1041
+ align-items: center;
1042
+ gap: var(--space-2);
1043
+ font-size: var(--text-xs);
1044
+ font-weight: 500;
1045
+ color: var(--text-secondary);
1046
+ cursor: pointer;
1047
+ padding: var(--space-1) var(--space-3);
1048
+ user-select: none;
1049
+ }
1050
+ .inline-file-total {
1051
+ margin-left: var(--space-2);
1052
+ font-size: var(--text-sm);
1053
+ font-weight: 400;
1054
+ color: var(--text-muted);
1055
+ }
1014
1056
  .file-path-header {
1015
1057
  font-size: var(--text-2xs);
1016
1058
  color: var(--text-muted);
@@ -2254,6 +2296,27 @@ footer {
2254
2296
  color var(--t-fast) var(--ease),
2255
2297
  border-color var(--t-fast) var(--ease);
2256
2298
  }
2299
+ .card-file-btn {
2300
+ float: right;
2301
+ margin-left: var(--space-3);
2302
+ height: 20px;
2303
+ padding: 0 var(--space-3);
2304
+ background: transparent;
2305
+ border: 1px solid var(--border-default);
2306
+ border-radius: var(--radius-2);
2307
+ color: var(--text-tertiary);
2308
+ font-size: 10px;
2309
+ font-weight: 500;
2310
+ cursor: pointer;
2311
+ transition: background var(--t-fast) var(--ease),
2312
+ color var(--t-fast) var(--ease),
2313
+ border-color var(--t-fast) var(--ease);
2314
+ }
2315
+ .card-file-btn:hover {
2316
+ background: var(--accent-blue);
2317
+ border-color: var(--accent-blue);
2318
+ color: #fff;
2319
+ }
2257
2320
  .card-run-btn:hover {
2258
2321
  background: var(--accent-green);
2259
2322
  border-color: var(--accent-green);
@@ -2879,7 +2942,6 @@ footer {
2879
2942
  }
2880
2943
  .tb-btn--primary:hover { filter: brightness(1.05); border-color: transparent; }
2881
2944
  .tb-btn--icon { padding: 8px 11px; }
2882
- .tb-kebab { font-size: 16px; line-height: 1; letter-spacing: 1px; }
2883
2945
 
2884
2946
  @media (max-width: 760px) {
2885
2947
  .tb-sub { display: none; }
@@ -3718,7 +3780,6 @@ footer {
3718
3780
  }
3719
3781
  .tb-btn--primary:hover { filter: brightness(1.05); border-color: transparent; }
3720
3782
  .tb-btn--icon { padding: 8px 11px; }
3721
- .tb-kebab { font-size: 16px; line-height: 1; letter-spacing: 1px; }
3722
3783
 
3723
3784
  @media (max-width: 760px) {
3724
3785
  .tb-sub { display: none; }
@@ -5436,21 +5497,32 @@ summary:focus-visible,
5436
5497
  .pg-tip text { font-size: var(--text-2xs); fill: var(--text-secondary); pointer-events: none; }
5437
5498
  .pg-tip .pg-tip-title { fill: var(--text-primary); font-weight: 600; }
5438
5499
 
5439
- /* Flow-row mode — no cross-phase dependencies: wrapped chip sequence. */
5440
- .pg-flow {
5500
+ /* Sequence mode — no cross-phase dependencies: a connected chain of small
5501
+ pills in roadmap order (arrows imply order, not a graph edge). */
5502
+ .pg-hint-top {
5503
+ padding-top: var(--space-2);
5504
+ padding-bottom: var(--space-3);
5505
+ }
5506
+ .pg-seq {
5441
5507
  display: flex;
5442
5508
  flex-wrap: wrap;
5443
- gap: var(--space-3);
5444
- padding: 0 var(--space-5) var(--space-2);
5509
+ align-items: center;
5510
+ row-gap: var(--space-2);
5511
+ padding: 0 var(--space-5) var(--space-4);
5445
5512
  }
5446
- .pg-chip {
5513
+ .pg-seq-item {
5447
5514
  display: inline-flex;
5448
5515
  align-items: center;
5449
- gap: var(--space-2);
5450
- max-width: 220px;
5451
- padding: var(--space-2) var(--space-3);
5452
- border: 1.5px solid var(--pg-todo);
5453
- border-radius: var(--radius-3);
5516
+ }
5517
+ .pg-seq-chip {
5518
+ display: inline-flex;
5519
+ align-items: center;
5520
+ gap: 6px;
5521
+ max-width: 190px;
5522
+ padding: 3px var(--space-3);
5523
+ border: 1px solid var(--border-default);
5524
+ border-left: 3px solid var(--pg-todo);
5525
+ border-radius: var(--radius-full, 999px);
5454
5526
  background: var(--bg-elev-2);
5455
5527
  color: var(--text-secondary);
5456
5528
  font-size: var(--text-2xs);
@@ -5458,20 +5530,25 @@ summary:focus-visible,
5458
5530
  cursor: pointer;
5459
5531
  transition: background var(--t-fast) var(--ease);
5460
5532
  }
5461
- .pg-chip:hover { background: var(--bg-hover); }
5462
- .pg-chip.pg-done { border-color: var(--pg-done); }
5463
- .pg-chip.pg-active { border-color: var(--pg-active); animation: pg-chip-pulse 2s var(--ease) infinite; }
5464
- .pg-chip.pg-blocked { border-color: var(--pg-blocked); }
5533
+ .pg-seq-chip:hover { background: var(--bg-hover); }
5534
+ .pg-seq-chip.pg-done { border-left-color: var(--pg-done); }
5535
+ .pg-seq-chip.pg-active { border-left-color: var(--pg-active); animation: pg-chip-pulse 2s var(--ease) infinite; }
5536
+ .pg-seq-chip.pg-blocked { border-left-color: var(--pg-blocked); }
5465
5537
  @keyframes pg-chip-pulse {
5466
5538
  0%, 100% { box-shadow: 0 0 0 0 transparent; }
5467
5539
  50% { box-shadow: 0 0 6px 0 var(--pg-active); }
5468
5540
  }
5469
- .pg-chip-id { font-weight: 600; color: var(--text-primary); }
5470
- .pg-chip-name {
5541
+ .pg-seq-id { font-weight: 600; color: var(--text-primary); }
5542
+ .pg-seq-name {
5471
5543
  overflow: hidden;
5472
5544
  text-overflow: ellipsis;
5473
5545
  white-space: nowrap;
5474
5546
  }
5547
+ .pg-seq-arrow {
5548
+ padding: 0 var(--space-2);
5549
+ color: var(--text-muted);
5550
+ font-size: var(--text-sm);
5551
+ }
5475
5552
  .pg-hint, .pg-empty {
5476
5553
  padding: 0 var(--space-5) var(--space-3);
5477
5554
  font-size: var(--text-2xs);
@@ -195,7 +195,13 @@ function buildPhaseTree(projectDir, rawPhases, listCached, overrides) {
195
195
  : (p.status === 'active' || p.status === 'in_progress') ? 'in_progress'
196
196
  : 'planned';
197
197
 
198
- return { id: sid, number: num, goal: goal || `Sprint ${num}`, status, stories, dependsOn };
198
+ // Project-relative path to this sprint's SPRINT.md same shape as the
199
+ // sprintFile computed below for roadmap cards, so /api/file?path=...
200
+ // resolves it identically. Lets task rows (which point at a specific
201
+ // sprint, not just "latest file in the phase") open the right file.
202
+ const file = path.join('.planning', 'phases', dir.name, f).split(path.sep).join('/');
203
+
204
+ return { id: sid, number: num, goal: goal || `Sprint ${num}`, status, stories, dependsOn, file };
199
205
  });
200
206
 
201
207
  // Derive phase-level depends_on by aggregating sprint-level depends_on entries.
@@ -366,11 +372,15 @@ function buildDashboard(state) {
366
372
  const stories = Array.isArray(s.stories) ? s.stories : [];
367
373
  for (const st of stories) {
368
374
  if (/done|complete/i.test(st.status || '')) {
369
- completedTasks.push({ title: st.title || st.id, date: fmtISODate(p.completed || s.completed_at || p.created) });
375
+ completedTasks.push({
376
+ title: st.title || st.id,
377
+ date: fmtISODate(p.completed || s.completed_at || p.created),
378
+ file: s.file || null,
379
+ });
370
380
  } else if (p.state === 'active') {
371
381
  // No per-task progress tracking exists — pct stays null and the UI
372
382
  // omits the percent pill rather than inventing a number.
373
- inProgressTasks.push({ title: st.title || st.id, pct: null });
383
+ inProgressTasks.push({ title: st.title || st.id, pct: null, file: s.file || null });
374
384
  }
375
385
  }
376
386
  }
@@ -378,10 +388,10 @@ function buildDashboard(state) {
378
388
  // Fallbacks so the cards are never empty when stories are unregistered in state.json.
379
389
  if (!completedTasks.length) {
380
390
  phases.filter(p => p.state === 'done').slice(-6).forEach(p =>
381
- completedTasks.push({ title: p.name, date: fmtISODate(p.completed || p.created) }));
391
+ completedTasks.push({ title: p.name, date: fmtISODate(p.completed || p.created), file: null }));
382
392
  }
383
393
  if (!inProgressTasks.length && activePhase && activePhase.state === 'active') {
384
- inProgressTasks.push({ title: activePhase.name, pct: null });
394
+ inProgressTasks.push({ title: activePhase.name, pct: null, file: null });
385
395
  }
386
396
  const tasks = {
387
397
  completed: completedTasks.slice(-8).reverse(),
@@ -419,6 +429,11 @@ function buildDashboard(state) {
419
429
  .sort((a, b) => String(b.date).localeCompare(String(a.date)))
420
430
  .slice(0, 8);
421
431
 
432
+ // ---- backlog (phases not yet started — real state.json data, no invented rows) ----
433
+ const backlog = phases
434
+ .filter(p => p.state === 'todo')
435
+ .map(p => ({ id: p.id, name: p.name, range: p.range }));
436
+
422
437
  // ---- project (identity + current user) ----
423
438
  // User comes from config, else the real OS account running the server;
424
439
  // null when neither exists (UI greets generically, hides the profile row).
@@ -430,7 +445,7 @@ function buildDashboard(state) {
430
445
  user: { name: userName, email: cfg.user_email || '' },
431
446
  };
432
447
 
433
- return { project, progress, currentPhase, timeline: { launchDate, onTrack: blockers.length === 0, points }, tasks, blockers, health, decisions, phases };
448
+ return { project, progress, currentPhase, timeline: { launchDate, onTrack: blockers.length === 0, points }, tasks, blockers, health, decisions, phases, backlog };
434
449
  }
435
450
 
436
451
  function scanStateUncached(rcodeDir) {