@hanzlaa/rcode 4.3.1 → 4.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/AGENTS.md +1 -1
  2. package/CONTRIBUTING.md +1 -0
  3. package/README.md +38 -9
  4. package/cli/install.js +14 -0
  5. package/dist/rcode.js +48 -48
  6. package/package.json +1 -1
  7. package/rcode/bin/rcode-hooks.cjs +347 -9
  8. package/rcode/commands/lens-audit.md +8 -6
  9. package/rcode/skills/SKILLS_INDEX.md +3 -2
  10. package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/SKILL.md +12 -0
  11. package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/rules/backlog-building.md +13 -0
  12. package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/rules/merge-strategy.md +17 -0
  13. package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/rules/orchestrator-rhythm.md +9 -0
  14. package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/rules/wave-design.md +21 -0
  15. package/rcode/skills/core/rcode-lazy/SKILL.md +149 -0
  16. package/rcode/templates/settings-hooks.json +12 -1
  17. package/rcode/workflows/do.md +1 -0
  18. package/rcode/workflows/enable-hooks.md +3 -2
  19. package/rcode/workflows/lens-audit.md +49 -8
  20. package/server/dashboard.js +6 -2
  21. package/server/lib/api.js +35 -20
  22. package/server/lib/html/client/components/OrchPanel.js +43 -2
  23. package/server/lib/html/client/components/StatusSummaryBar.js +20 -2
  24. package/server/lib/html/client/components/dashboard/InProgress.js +5 -4
  25. package/server/lib/html/client/components/shared.js +14 -0
  26. package/server/lib/html/client/orchestrator.js +13 -0
  27. package/server/lib/html/client/util.js +41 -0
  28. package/server/lib/html/client/views/KanbanView.js +33 -4
  29. package/server/lib/html/client/views/PhasesView.js +1 -12
  30. package/server/lib/html/client/views/SprintsView.js +1 -12
  31. package/server/lib/html/css.js +62 -3
  32. package/server/lib/scanner.js +40 -4
  33. package/server/orchestrator.js +42 -0
  34. package/rcode/skills/core/rcode-init/scripts/__pycache__/rcode_init.cpython-38.pyc +0 -0
  35. package/rcode/skills/core/rcode-init/scripts/tests/__pycache__/test_rcode_init.cpython-38.pyc +0 -0
@@ -80,6 +80,22 @@ export function allTasks(phases) {
80
80
  );
81
81
  }
82
82
 
83
+ /**
84
+ * Map a numeric phase id to its milestone bucket.
85
+ * Single source of truth — imported by PhasesView and SprintsView so that
86
+ * milestone boundaries (19, 33) never diverge between the two views.
87
+ * M1 = phases 1–19, M2 = 20–33, M3 = 34+.
88
+ *
89
+ * @param {number|string} id — phase id
90
+ * @returns {'M1'|'M2'|'M3'}
91
+ */
92
+ export function phaseMilestone(id) {
93
+ const n = Number(id);
94
+ if (n <= 19) return 'M1';
95
+ if (n <= 33) return 'M2';
96
+ return 'M3';
97
+ }
98
+
83
99
  /**
84
100
  * Return a status chip descriptor — NOT an HTML string.
85
101
  * Components decide how to render the CSS class and label.
@@ -98,6 +114,31 @@ export function chip(status) {
98
114
  return { cls, label: status };
99
115
  }
100
116
 
117
+ /**
118
+ * Return a status chip descriptor for orchestrator session statuses.
119
+ * Session objects use a different vocabulary than phases/sprints
120
+ * ('running', 'stopped', 'starting', 'error'), so a separate normaliser
121
+ * keeps the two status domains from coupling inside chip().
122
+ *
123
+ * Mapping:
124
+ * running → 'sess-running' (accent-blue — live activity)
125
+ * starting → 'sess-starting' (amber — transient / pending)
126
+ * stopped → 'sess-stopped' (text-secondary — idle / muted)
127
+ * error → 'sess-error' (accent-red — needs attention)
128
+ *
129
+ * @param {string} status
130
+ * @returns {{ cls: string, label: string }}
131
+ */
132
+ export function sessionChip(status) {
133
+ const s = String(status || '').toLowerCase();
134
+ const cls =
135
+ s === 'running' ? 'sess-running' :
136
+ s === 'starting' ? 'sess-starting' :
137
+ s === 'error' ? 'sess-error' :
138
+ s === 'stopped' ? 'sess-stopped' : 'sess-stopped';
139
+ return { cls, label: status };
140
+ }
141
+
101
142
  /**
102
143
  * Props for a clickable card row that navigates to a hash route.
103
144
  * Spread onto a list row (`<li ...${rowLink('tasks')}>`) to make it act like
@@ -10,7 +10,7 @@
10
10
  import { html, useState, useCallback } from '../preact.js';
11
11
  import { useStore, refresh } from '../store.js';
12
12
  import { allTasks, currentPhaseName } from '../util.js';
13
- import { stopStory, openOrchPanel } from '../orchestrator.js';
13
+ import { stopStory, openOrchPanel, openTermPanel, setTaskStatus } from '../orchestrator.js';
14
14
  import { openRunnerPicker } from '../components/RunnerPicker.js';
15
15
  import { showToast } from '../components/shared.js';
16
16
 
@@ -56,6 +56,12 @@ function KanbanCard({ task, col, live, orchDown, onDragStart, onDragEnd }) {
56
56
  e.stopPropagation();
57
57
  stopStory(sid);
58
58
  }
59
+ // Live session → full interactive xterm terminal (you can type to the agent).
60
+ function handleTerm(e) {
61
+ e.stopPropagation();
62
+ openTermPanel(sid, sid);
63
+ }
64
+ // Ended session → lightweight read-only log view (no live TUI to mangle).
59
65
  function handleView(e) {
60
66
  e.stopPropagation();
61
67
  openOrchPanel(sid);
@@ -92,7 +98,7 @@ function KanbanCard({ task, col, live, orchDown, onDragStart, onDragEnd }) {
92
98
  onClick=${handleRun}>▶ Run</button>
93
99
  ` : isRunning ? html`
94
100
  <button class="kanban-stop-btn" onClick=${handleStop}>■ Stop</button>
95
- <button class="kanban-view-btn" onClick=${handleView}>↗ View</button>
101
+ <button class="kanban-view-btn" onClick=${handleTerm}>↗ Terminal</button>
96
102
  ` : html`
97
103
  <button class="kanban-view-btn" onClick=${handleView}>↗ Logs</button>
98
104
  `}
@@ -188,9 +194,32 @@ export function KanbanView() {
188
194
 
189
195
  function handleDrop(e, colId) {
190
196
  if (!dragging || !dragging.id) return;
191
- setVisualMoves(prev => ({ ...prev, [dragging.id]: colId }));
197
+ const taskId = dragging.id;
198
+ const prevCol = effCol(dragging, runningByStory);
199
+ const isRunning = !!(runningByStory && runningByStory[taskId]);
200
+
201
+ // Optimistic visual move
202
+ setVisualMoves(prev => ({ ...prev, [taskId]: colId }));
203
+
204
+ const anchor = document.querySelector('[data-story-id="' + taskId + '"]') || e.currentTarget;
205
+
206
+ if (colId === 'in_progress' && (prevCol === 'todo' || prevCol === 'blocked') && !isRunning) {
207
+ openRunnerPicker(anchor, {
208
+ kind: 'session', storyId: taskId, cmd: '/rcode-dev-story ' + taskId, title: taskId,
209
+ });
210
+ setTaskStatus(taskId, 'in_progress').then(() => {
211
+ refresh();
212
+ setVisualMoves(prev => { const n = { ...prev }; delete n[taskId]; return n; });
213
+ });
214
+ } else {
215
+ setTaskStatus(taskId, colId).then(() => {
216
+ refresh();
217
+ setVisualMoves(prev => { const n = { ...prev }; delete n[taskId]; return n; });
218
+ });
219
+ showToast('Moved to ' + (COLS.find(c => c.id === colId)?.label || colId));
220
+ }
221
+
192
222
  setDragging(null);
193
- showToast('Moved (visual only — not persisted)'); // visual only — not persisted
194
223
  }
195
224
 
196
225
  // ---- Manual refresh ----
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { html, useState } from '../preact.js';
11
11
  import { useStore } from '../store.js';
12
- import { pct, humanDate, phaseHints, chip } from '../util.js';
12
+ import { pct, humanDate, phaseHints, chip, phaseMilestone } from '../util.js';
13
13
  import {
14
14
  Chip, ProgressBar, Breadcrumb, CmdHints, RunningBadge, SprintCard, PhaseCard,
15
15
  } from '../components/shared.js';
@@ -122,17 +122,6 @@ function PhaseDetail({ phase: p, S }) {
122
122
  `;
123
123
  }
124
124
 
125
- /**
126
- * Map a numeric phase id to its milestone bucket.
127
- * M1 = phases 1–19, M2 = 20–33, M3 = 34+.
128
- */
129
- function phaseMilestone(id) {
130
- const n = Number(id);
131
- if (n <= 19) return 'M1';
132
- if (n <= 33) return 'M2';
133
- return 'M3';
134
- }
135
-
136
125
  export function PhasesView({ subId, filters }) {
137
126
  const S = useStore();
138
127
  const phases = S.phases || [];
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { html, useState } from '../preact.js';
12
12
  import { useStore } from '../store.js';
13
- import { pct, humanDate, allSprints, sprintHints, chip } from '../util.js';
13
+ import { pct, humanDate, allSprints, sprintHints, chip, phaseMilestone } from '../util.js';
14
14
  import {
15
15
  Chip, ProgressBar, Breadcrumb, CmdHints, RunningBadge, SprintCard, TaskCard,
16
16
  } from '../components/shared.js';
@@ -118,17 +118,6 @@ function SprintDetail({ sprint: s, S }) {
118
118
  `;
119
119
  }
120
120
 
121
- /**
122
- * Map a numeric phase id to its milestone bucket.
123
- * M1 = phases 1–19, M2 = 20–33, M3 = 34+.
124
- */
125
- function phaseMilestone(id) {
126
- const n = Number(id);
127
- if (n <= 19) return 'M1';
128
- if (n <= 33) return 'M2';
129
- return 'M3';
130
- }
131
-
132
121
  export function SprintsView({ subId, filters }) {
133
122
  const S = useStore();
134
123
  const sprints = allSprints(S.phases || []);
@@ -770,6 +770,46 @@ section .body {
770
770
  }
771
771
  .task-detail-row strong { color: var(--text-muted); font-weight: 500; min-width: 64px; flex-shrink: 0; }
772
772
  .task-detail-cmds { margin-top: var(--space-3); }
773
+ /* ── Per-task actions + result (#905) ───────────────────────────── */
774
+ .task-actions {
775
+ margin-top: var(--space-3);
776
+ padding-top: var(--space-3);
777
+ border-top: 1px solid var(--border-subtle);
778
+ }
779
+ .task-actions-title {
780
+ color: var(--text-muted);
781
+ font-weight: 500;
782
+ margin-bottom: var(--space-2);
783
+ }
784
+ .task-actions-list {
785
+ margin: 0;
786
+ padding-left: var(--space-5);
787
+ display: flex;
788
+ flex-direction: column;
789
+ gap: var(--space-1);
790
+ }
791
+ .task-action-step {
792
+ color: var(--text-secondary);
793
+ line-height: 1.45;
794
+ }
795
+ .task-outcome {
796
+ display: flex;
797
+ align-items: baseline;
798
+ gap: var(--space-2);
799
+ margin-top: var(--space-3);
800
+ padding: var(--space-2) var(--space-3);
801
+ background: var(--bg-elev-2);
802
+ border-left: 2px solid var(--accent-green);
803
+ border-radius: var(--radius-4);
804
+ }
805
+ .task-outcome-label {
806
+ color: var(--accent-green);
807
+ font-weight: 600;
808
+ text-transform: uppercase;
809
+ letter-spacing: 0.04em;
810
+ flex-shrink: 0;
811
+ }
812
+ .task-outcome-text { color: var(--text-secondary); line-height: 1.45; }
773
813
  .task-expand-icon {
774
814
  font-size: 8px;
775
815
  color: var(--text-muted);
@@ -1451,9 +1491,9 @@ footer {
1451
1491
  top: 0;
1452
1492
  right: 0;
1453
1493
  bottom: 0;
1454
- width: 440px;
1455
- max-width: 42vw;
1456
- min-width: 320px;
1494
+ width: var(--orch-w, 720px);
1495
+ max-width: 70vw;
1496
+ min-width: 360px;
1457
1497
  background: var(--bg-elev-1);
1458
1498
  border-left: 1px solid var(--border-subtle);
1459
1499
  display: flex;
@@ -1463,6 +1503,20 @@ footer {
1463
1503
  transition: transform var(--t-menu) var(--ease);
1464
1504
  box-shadow: -8px 0 32px rgba(0,0,0,0.4);
1465
1505
  }
1506
+ .orch-panel-resize {
1507
+ position: absolute;
1508
+ left: 0;
1509
+ top: 0;
1510
+ bottom: 0;
1511
+ width: 6px;
1512
+ cursor: ew-resize;
1513
+ z-index: 10;
1514
+ }
1515
+ .orch-panel-resize:hover,
1516
+ .orch-panel-resize.dragging {
1517
+ background: var(--accent, #5865f2);
1518
+ opacity: 0.35;
1519
+ }
1466
1520
  .orch-panel.open {
1467
1521
  transform: translateX(0);
1468
1522
  }
@@ -4390,6 +4444,11 @@ summary:focus-visible,
4390
4444
  .summary-count-chip.blocked { color: var(--accent-red); }
4391
4445
  .summary-count-chip.planned,
4392
4446
  .summary-count-chip.todo { color: var(--text-secondary); }
4447
+ /* Session status chips — use sessionChip() vocabulary, not chip() */
4448
+ .summary-count-chip.sess-running { color: var(--accent-blue); }
4449
+ .summary-count-chip.sess-starting { color: var(--amber); }
4450
+ .summary-count-chip.sess-stopped { color: var(--text-secondary); }
4451
+ .summary-count-chip.sess-error { color: var(--accent-red); }
4393
4452
 
4394
4453
  /* ── Filter chips ────────────────────────────────────────────────── */
4395
4454
  .filter-chips {
@@ -80,6 +80,23 @@ function parseYamlList(text, key) {
80
80
  return [];
81
81
  }
82
82
 
83
+ /**
84
+ * Parse a SPRINT.md <action> block into an ordered list of action steps.
85
+ * Action blocks are numbered ("1. …\n2. …"); each step may span several lines.
86
+ * Whitespace within a step is collapsed so each renders as one readable line.
87
+ * Falls back to non-empty lines when the block is not numbered. Returns [].
88
+ */
89
+ function parseActionSteps(raw) {
90
+ if (!raw) return [];
91
+ const t = raw.trim();
92
+ if (!t) return [];
93
+ const matches = [...t.matchAll(/(?:^|\n)\s*\d+\.\s+([\s\S]*?)(?=\n\s*\d+\.\s|$)/g)];
94
+ if (matches.length) {
95
+ return matches.map(m => m[1].replace(/\s+/g, ' ').trim()).filter(Boolean);
96
+ }
97
+ return t.split('\n').map(l => l.replace(/^[-*]\s+/, '').trim()).filter(Boolean);
98
+ }
99
+
83
100
  /**
84
101
  * Derive the phase → sprint → story tree from the .planning/phases/ filesystem,
85
102
  * which is the committed source of truth. state.json sprint/story records are
@@ -93,9 +110,10 @@ function parseYamlList(text, key) {
93
110
  * @param {function} [listCached] per-scan dir lister from makeDirLister()
94
111
  * @returns {Array|null} phases with a populated `sprints` array each
95
112
  */
96
- function buildPhaseTree(projectDir, rawPhases, listCached) {
113
+ function buildPhaseTree(projectDir, rawPhases, listCached, overrides) {
97
114
  if (!Array.isArray(rawPhases)) return null;
98
115
  const list = listCached || makeDirLister();
116
+ const ov = (overrides && typeof overrides === 'object' && !Array.isArray(overrides)) ? overrides : {};
99
117
  const phasesDir = path.join(projectDir, '.planning', 'phases');
100
118
  const allEntries = list(phasesDir);
101
119
  if (allEntries === null) return rawPhases;
@@ -144,7 +162,16 @@ function buildPhaseTree(projectDir, rawPhases, listCached) {
144
162
  title: titleM ? titleM[1].trim() : `Task ${stories.length + 1}`,
145
163
  status: phaseComplete ? 'done' : 'todo',
146
164
  };
165
+ if (ov[story.id]) story.status = ov[story.id].status;
147
166
  if (acM && acM[1].trim()) story.acceptance = acM[1].trim();
167
+ // Per-task execution transparency (#905): the ordered <action> steps
168
+ // (what the task does) + the <done> outcome (the result). Read-only —
169
+ // parsed from the same SPRINT.md text already loaded above.
170
+ const actionM = tm[2].match(/<action>\s*([\s\S]*?)\s*<\/action>/);
171
+ const doneM = tm[2].match(/<done>\s*([\s\S]*?)\s*<\/done>/);
172
+ const steps = actionM ? parseActionSteps(actionM[1]) : [];
173
+ if (steps.length) story.actions = steps;
174
+ if (doneM && doneM[1].trim()) story.outcome = doneM[1].replace(/\s+/g, ' ').trim();
148
175
  stories.push(story);
149
176
  }
150
177
  // Fallback for pre-<task> SPRINT.md format (phases 20-30 era):
@@ -153,10 +180,11 @@ function buildPhaseTree(projectDir, rawPhases, listCached) {
153
180
  const headRe = /^#{2,4}\s+(?:Story|Task)\s+([^\s—–-]+)\s*[—–-]\s*(.+?)\s*$/gm;
154
181
  let hm;
155
182
  while ((hm = headRe.exec(text))) {
183
+ const hId = hm[1].trim();
156
184
  stories.push({
157
- id: hm[1].trim(),
185
+ id: hId,
158
186
  title: hm[2].trim(),
159
- status: phaseComplete ? 'done' : 'todo',
187
+ status: ov[hId] ? ov[hId].status : (phaseComplete ? 'done' : 'todo'),
160
188
  });
161
189
  }
162
190
  }
@@ -566,7 +594,14 @@ function scanStateUncached(rcodeDir) {
566
594
  } catch { /* ignore */ }
567
595
  }
568
596
 
569
- state.phaseTree = buildPhaseTree(projectDir, state.raw && state.raw.phases, listCached);
597
+ let boardOverrides = {};
598
+ try {
599
+ const ovRaw = fs.readFileSync(path.join(rcodeDir, 'board-overrides.json'), 'utf8');
600
+ const ovParsed = JSON.parse(ovRaw);
601
+ if (ovParsed && typeof ovParsed === 'object' && !Array.isArray(ovParsed)) boardOverrides = ovParsed;
602
+ } catch { boardOverrides = {}; }
603
+
604
+ state.phaseTree = buildPhaseTree(projectDir, state.raw && state.raw.phases, listCached, boardOverrides);
570
605
 
571
606
  // Derive the redesign dashboard contract (DATA-CONTRACT.md). Attached to the
572
607
  // scan so GET /api/state returns the exact shape and client.js seeds it into
@@ -607,6 +642,7 @@ function scanSignature(rcodeDir, projectDir) {
607
642
  statOne(path.join(rcodeDir, 'config.yaml'));
608
643
  statOne(path.join(rcodeDir, 'HANDOFF.json'));
609
644
  statOne(path.join(rcodeDir, 'context', 'active.md'));
645
+ statOne(path.join(rcodeDir, 'board-overrides.json'));
610
646
  (function walk(dir, depth) {
611
647
  if (depth > SIG_WALK_MAX_DEPTH) return;
612
648
  for (const e of listDir(dir)) {
@@ -280,6 +280,22 @@ function appendRejection(entry) {
280
280
  }
281
281
  }
282
282
 
283
+ // ── Board overlay ─────────────────────────────────────────────────────────────
284
+ // Writes a single entry to .rcode/board-overrides.json. The scanner reads this
285
+ // file on every scan and applies it on top of derived story statuses.
286
+ function setTaskOverride(storyId, status, runner) {
287
+ const overridesPath = path.join(PROJECT_ROOT, '.rcode', 'board-overrides.json');
288
+ let overrides = {};
289
+ try {
290
+ const raw = fs.readFileSync(overridesPath, 'utf8');
291
+ const parsed = JSON.parse(raw);
292
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) overrides = parsed;
293
+ } catch { overrides = {}; }
294
+ overrides[storyId] = { status, runner: runner || null, updatedAt: new Date().toISOString() };
295
+ fs.mkdirSync(path.dirname(overridesPath), { recursive: true });
296
+ fs.writeFileSync(overridesPath, JSON.stringify(overrides, null, 2));
297
+ }
298
+
283
299
  // ── helpers ──────────────────────────────────────────────────────────────────
284
300
 
285
301
  function json(res, code, body) {
@@ -557,6 +573,14 @@ async function handleRun(req, res) {
557
573
  const status = signal ? 'stopped' : (exitCode === 0 ? 'done' : 'exited');
558
574
  setStatus(s, status);
559
575
  persistRun(storyId, s, status);
576
+ if (status === 'done'
577
+ && !storyId.startsWith('cmd-')
578
+ && !storyId.startsWith('sprint-')
579
+ && !storyId.startsWith('phase-')) {
580
+ try { setTaskOverride(storyId, 'done', null); } catch (err) {
581
+ console.error('[orchestrator] board-overrides write failed:', err.message);
582
+ }
583
+ }
560
584
  });
561
585
 
562
586
  // CLIs with no interactive initial-prompt flag (see registry) get the
@@ -622,6 +646,23 @@ function handleRejections(res) {
622
646
  json(res, 200, { rejections: readRejections() });
623
647
  }
624
648
 
649
+ const TASK_STATUS_ENUM = new Set(['todo', 'in_progress', 'blocked', 'done']);
650
+
651
+ async function handleTaskStatus(req, res) {
652
+ const body = await parseBody(req);
653
+ const storyId = String(body.storyId || '').trim();
654
+ if (!validStoryId(storyId)) { json(res, 400, { error: 'invalid storyId' }); return; }
655
+ const status = String(body.status || '').trim();
656
+ if (!TASK_STATUS_ENUM.has(status)) { json(res, 400, { error: 'invalid status — must be one of todo,in_progress,blocked,done' }); return; }
657
+ try {
658
+ setTaskOverride(storyId, status, null);
659
+ } catch (err) {
660
+ console.error('[orchestrator] handleTaskStatus write failed:', err.message);
661
+ json(res, 500, { error: 'could not write board-overrides' }); return;
662
+ }
663
+ json(res, 200, { ok: true });
664
+ }
665
+
625
666
  // ── WebSocket data plane ───────────────────────────────────────────────────────
626
667
 
627
668
  function attachWebSocket(ws, storyId) {
@@ -704,6 +745,7 @@ const server = http.createServer(async (req, res) => {
704
745
  if (method === 'POST' && pathOnly === '/api/clean-sessions') { await handleCleanSessions(req, res); return; }
705
746
  if (method === 'POST' && pathOnly === '/api/reject') { await handleReject(req, res); return; }
706
747
  if (method === 'GET' && pathOnly === '/api/rejections') { handleRejections(res); return; }
748
+ if (method === 'POST' && pathOnly === '/api/task-status') { await handleTaskStatus(req, res); return; }
707
749
 
708
750
  res.writeHead(404); res.end('Not found');
709
751
  });