@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 +47 -14
- package/package.json +1 -1
- package/server/lib/html/client/components/App.js +15 -1
- package/server/lib/html/client/components/PhaseGraph.js +20 -12
- package/server/lib/html/client/components/Sidebar.js +1 -0
- package/server/lib/html/client/components/Topbar.js +3 -3
- package/server/lib/html/client/components/dashboard/Blockers.js +3 -3
- package/server/lib/html/client/components/dashboard/CompletedTasks.js +4 -2
- package/server/lib/html/client/components/dashboard/InProgress.js +4 -3
- package/server/lib/html/client/components/dashboard/ProgressTimeline.js +11 -6
- package/server/lib/html/client/components/dashboard/RecentDecisions.js +4 -3
- package/server/lib/html/client/components/shared.js +111 -2
- package/server/lib/html/client/store.js +47 -0
- package/server/lib/html/client/util.js +1 -22
- package/server/lib/html/client/views/BacklogView.js +44 -0
- package/server/lib/html/client/views/DecisionsView.js +4 -3
- package/server/lib/html/client/views/FilesView.js +47 -34
- package/server/lib/html/client/views/KanbanView.js +8 -0
- package/server/lib/html/client/views/PhasesView.js +8 -3
- package/server/lib/html/client/views/SprintsView.js +9 -0
- package/server/lib/html/client.js +1 -0
- package/server/lib/html/css.js +96 -19
- package/server/lib/scanner.js +21 -6
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ pnpm dlx @hanzlaa/rcode install
|
|
|
23
23
|
[](https://github.com/hanzlahabib/rcode/actions/workflows/test.yml)
|
|
24
24
|
[](LICENSE)
|
|
25
25
|
|
|
26
|
-
Status: `@hanzlaa/rcode` v4.
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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-
|
|
178
|
-
|
|
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
|
-
<
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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=${'
|
|
96
|
-
aria-label="
|
|
95
|
+
title=${'Switch to ' + (themeLabel === 'light' ? 'dark' : 'light') + ' theme'}
|
|
96
|
+
aria-label="Toggle theme"
|
|
97
97
|
>
|
|
98
|
-
|
|
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 {
|
|
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} ...${
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
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} ...${
|
|
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
|
|
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} ...${
|
|
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,
|
|
24
|
+
function FileEntry({ file, onSelect, isSelected }) {
|
|
19
25
|
return html`
|
|
20
26
|
<div
|
|
21
|
-
class=${'
|
|
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
|
-
|
|
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"
|
|
41
|
-
<div
|
|
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
|
-
${
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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"
|
|
73
|
-
<div
|
|
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">
|
|
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"
|
|
147
|
+
? html`<div class="empty">Loading…</div>`
|
|
135
148
|
: groups.length === 0
|
|
136
|
-
? html`<div class="empty"
|
|
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
|
-
|
|
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
|
-
|
|
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 || [],
|
package/server/lib/html/css.js
CHANGED
|
@@ -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-
|
|
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
|
-
/*
|
|
5440
|
-
.
|
|
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
|
-
|
|
5444
|
-
|
|
5509
|
+
align-items: center;
|
|
5510
|
+
row-gap: var(--space-2);
|
|
5511
|
+
padding: 0 var(--space-5) var(--space-4);
|
|
5445
5512
|
}
|
|
5446
|
-
.pg-
|
|
5513
|
+
.pg-seq-item {
|
|
5447
5514
|
display: inline-flex;
|
|
5448
5515
|
align-items: center;
|
|
5449
|
-
|
|
5450
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
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-
|
|
5470
|
-
.pg-
|
|
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);
|
package/server/lib/scanner.js
CHANGED
|
@@ -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
|
-
|
|
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({
|
|
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) {
|