@hanzlaa/rcode 4.12.1 → 4.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/AGENTS.md +1 -1
  2. package/CLAUDE.md +1 -1
  3. package/CONTRIBUTING.md +1 -0
  4. package/cli/doctor.js +40 -5
  5. package/cli/install.js +6 -1
  6. package/dist/rcode.js +87 -87
  7. package/package.json +1 -1
  8. package/rcode/agents/rcode-hussain-pm.md +37 -3
  9. package/rcode/agents/rcode-orchestrator.md +91 -0
  10. package/rcode/agents/rules/executor/correctness-hazard-scan.md +98 -0
  11. package/rcode/agents/rules/executor/execution-flow.md +8 -0
  12. package/rcode/agents/rules/executor/self-check.md +8 -0
  13. package/rcode/agents/rules/orchestrator/contract.md +76 -0
  14. package/rcode/agents/rules/sprint-checker/dimensions.md +38 -0
  15. package/rcode/agents/rules/verifier/reachability-check.md +45 -2
  16. package/rcode/bin/lib/progress.cjs +41 -13
  17. package/rcode/bin/lib/state-digest.cjs +88 -0
  18. package/rcode/bin/rcode-hooks.cjs +192 -23
  19. package/rcode/bin/rcode-tools.cjs +94 -4
  20. package/rcode/references/REFERENCES_INDEX.md +3 -1
  21. package/rcode/references/agent-shared-rules.md +87 -0
  22. package/rcode/references/code-reviewer-playbook.md +5 -0
  23. package/rcode/references/executor-playbook.md +2 -0
  24. package/rcode/references/github-comment-style.md +57 -0
  25. package/rcode/references/persona-executor-mode.md +61 -0
  26. package/rcode/references/response-style.md +21 -4
  27. package/rcode/references/verifier-playbook.md +14 -0
  28. package/rcode/skills/SKILLS_INDEX.md +1 -1
  29. package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/references.md +7 -0
  30. package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/rules/merge-strategy.md +19 -3
  31. package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/templates/wave-prompt.md +3 -1
  32. package/rcode/skills/agents/{raees-orchestrator → orchestrator}/SKILL.md +1 -1
  33. package/rcode/team.yaml +20 -1
  34. package/rcode/workflows/audit-worktrees.md +15 -1
  35. package/rcode/workflows/execute-verify-phase-goal.md +58 -2
  36. package/rcode/workflows/execute.md +37 -8
  37. package/rcode/workflows/plan-research-validation.md +8 -2
  38. package/rcode/workflows/plan-spawn-planner.md +32 -4
  39. package/rcode/workflows/plan.md +138 -10
  40. package/rcode/workflows/pr-branch.md +2 -0
  41. package/rcode/workflows/research-phase.md +12 -4
  42. package/rcode/workflows/ship.md +4 -0
  43. package/rcode/workflows/verify-phase.md +40 -0
  44. package/server/dashboard.js +57 -17
  45. package/server/lib/html/client/components/OrchPanel.js +6 -2
  46. package/server/lib/html/client/components/XtermPanel.js +7 -2
  47. package/server/lib/html/client/orchestrator.js +58 -21
  48. package/server/lib/html/client/views/MemoryView.js +59 -3
  49. package/server/lib/html/css.js +40 -0
  50. package/server/lib/html/shell.js +10 -4
  51. package/server/lib/scanner.js +150 -3
  52. package/server/lib/view-only.js +32 -0
  53. package/server/orchestrator.js +63 -4
  54. /package/rcode/skills/agents/{raees-orchestrator → orchestrator}/references.md +0 -0
@@ -72,7 +72,35 @@ When `--from-stub` is passed:
72
72
 
73
73
  This mode exists to skip expensive re-derivation when a human or prior agent has already produced a planning skeleton.
74
74
 
75
- ## 0. Project-Status Preflight
75
+ ## 0. You are Raees for this run (orchestrator contract)
76
+
77
+ @.rcode/agents-rules/orchestrator/contract.md
78
+
79
+ Planning is orchestration too: it spawns a researcher, a planner, a specialist
80
+ panel, and a checker, and it decides what each one gets. Adopt the contract and
81
+ hold it for the whole run.
82
+
83
+ **Open with the orientation banner before the first subagent is spawned**, filled
84
+ from the INIT JSON and the phase's artifacts — not from memory:
85
+
86
+ ```
87
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
88
+ rcode ► RAEES — {project}
89
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
90
+
91
+ Where you are Phase {N} — {name} · {status} · {existing plans, if any}
92
+ What I read {ROADMAP, CONTEXT.md, RESEARCH.md — whichever you actually opened}
93
+ What I'll do {the agents this run will spawn, in order, each named}
94
+ What I need {CONTEXT.md gaps or decisions blocked on the user, or "nothing — starting now"}
95
+ ```
96
+
97
+ **Raees does not write the plan.** `rcode-planner` writes SPRINT.md files; the
98
+ panel reviews them; `rcode-sprint-checker` grades them. If you find yourself
99
+ drafting tasks inline, the run has lost its orchestrator — spawn the planner
100
+ instead. A SPRINT.md with no planner `Task()` behind it is the failure this rule
101
+ exists to prevent (see step 8).
102
+
103
+ ## 0.5. Project-Status Preflight
76
104
 
77
105
  ```bash
78
106
  PROJECT_STATUS=$(node .rcode/bin/rcode-tools.cjs project-status 2>/dev/null || echo uninitialized)
@@ -124,11 +152,6 @@ Load all context in one call (paths only to minimize orchestrator context):
124
152
  ```bash
125
153
  INIT=$(node ".rcode/bin/rcode-tools.cjs" init sprint-plan "$PHASE" 2>/dev/null)
126
154
  if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
127
- AGENT_SKILLS_RESEARCHER=$(node ".rcode/bin/rcode-tools.cjs" agent-skills rcode-phase-researcher 2>/dev/null || echo "")
128
- AGENT_SKILLS_PLANNER=$(node ".rcode/bin/rcode-tools.cjs" agent-skills rcode-planner 2>/dev/null || echo "")
129
- AGENT_SKILLS_CHECKER=$(node ".rcode/bin/rcode-tools.cjs" agent-skills rcode-sprint-checker 2>/dev/null || echo "")
130
- CONTEXT_WINDOW=$(node ".rcode/bin/rcode-tools.cjs" config-get context_window 2>/dev/null)
131
- CONTEXT_WINDOW=${CONTEXT_WINDOW:-200000} # config-get exits 0 with empty output when key absent; || fallback won't fire
132
155
 
133
156
  # Detect UI signals in phase goal + CONTEXT.md to decide whether to load ui-brand.md (254 lines)
134
157
  PHASE_GOAL_HAS_UI=$(grep -iEl "frontend|ui|component|design|style|brand" \
@@ -141,9 +164,11 @@ If `INIT` is empty, or `INIT.ok` is false or absent (null/undefined — `init sp
141
164
  Error: rcode-tools init failed. Verify .rcode/ is installed and state.json is valid.
142
165
  ```
143
166
 
167
+ **#949 — no separate `agent-skills` / `config-get context_window` calls.** `init sprint-plan` already returns `agent_skills.researcher`, `agent_skills.planner`, `agent_skills.checker` (the same manifest rows the standalone `agent-skills <id>` command returns) and `context_window`, folded in to avoid 4 extra cold Node starts per plan run. Read `agent_skills.researcher` / `.planner` / `.checker` directly from `$INIT` wherever this doc previously referenced `$AGENT_SKILLS_RESEARCHER` / `$AGENT_SKILLS_PLANNER` / `$AGENT_SKILLS_CHECKER`. `CONTEXT_WINDOW` defaults to `200000` when `context_window` is null (config key absent).
168
+
144
169
  When `CONTEXT_WINDOW >= 500000`, the planner prompt includes prior phase CONTEXT.md files so cross-phase decisions are consistent (e.g., "use library X for all data fetching" from Phase 2 is visible to Phase 5's planner).
145
170
 
146
- Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_enabled`, `plan_checker_enabled`, `nyquist_validation_enabled`, `commit_docs`, `text_mode`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`, `has_reviews`, `has_plans`, `plan_count`, `phase_status`, `planning_exists`, `roadmap_exists`, `phase_req_ids`, `response_language`.
171
+ Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_enabled`, `plan_checker_enabled`, `nyquist_validation_enabled`, `specialist_review_enabled`, `commit_docs`, `text_mode`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`, `has_reviews`, `has_plans`, `plan_count`, `phase_status`, `planning_exists`, `roadmap_exists`, `phase_req_ids`, `response_language`, `context_window`, `agent_skills`, `state_digest`.
147
172
 
148
173
  **If `response_language` is set:** Include `response_language: {value}` in all spawned subagent prompts so any user-facing output stays in the configured language.
149
174
 
@@ -153,7 +178,7 @@ Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_
153
178
 
154
179
  ## 2. Parse and Normalize Arguments
155
180
 
156
- Extract from $ARGUMENTS: phase number (integer or decimal like `2.1`), flags (`--research`, `--skip-research`, `--gaps`, `--skip-verify`, `--from-stub`, `--prd <filepath>`, `--reviews`, `--text`).
181
+ Extract from $ARGUMENTS: phase number (integer or decimal like `2.1`), flags (`--research`, `--skip-research`, `--gaps`, `--skip-verify`, `--from-stub`, `--prd <filepath>`, `--reviews`, `--text`, `--no-panel`).
157
182
 
158
183
  Set `TEXT_MODE=true` if `--text` is present in $ARGUMENTS OR `text_mode` from init JSON is `true`. When `TEXT_MODE` is active, replace every `AskUserQuestion` call with a plain-text numbered list and ask the user to type their choice number. This is required for Claude Code remote sessions (`/rc` mode) where TUI menus don't work through the Claude App.
159
184
 
@@ -571,6 +596,108 @@ Use AskUserQuestion with these 3 options.
571
596
  **If "Proceed":** Return to planner with instruction to attempt all decisions at full fidelity, accepting more plans/tasks.
572
597
  **If "Prioritize":** Use AskUserQuestion (multiSelect) to let user pick which D-XX are "now" vs "later". Create CONTEXT.md for each sub-phase with the selected decisions.
573
598
 
599
+ ## 9.5. Specialist Review Panel (domain-routed)
600
+
601
+ **Why this step exists.** Until now one generalist (`rcode-planner`) produced the
602
+ entire plan and one generalist (`rcode-sprint-checker`) graded it against the
603
+ phase goal. Nobody asked *"is this design wrong"* or *"what will this guard
604
+ miss"*. Confirmed live on a real project: nine phases planned and shipped this
605
+ way, and the first hour of specialist review found an inert RLS backstop, an
606
+ authorization mutation with no relationship check, a core feature whose only
607
+ importer was its own test, and real personal data committed to the repo. The
608
+ sprint-checker missed all four because none of them is a goal-coverage question.
609
+
610
+ **Skip only if:** `specialist_review_enabled` from the INIT JSON is `false`
611
+ (set `workflow.specialist_review: false` in `.rcode/config.yaml`; absent key =
612
+ enabled), or `--no-panel` was passed. In
613
+ autonomous/yolo mode this step is **NOT skippable** — yolo removes the human
614
+ mid-loop check, so plan-time review is the only review left.
615
+
616
+ ### 9.5a — Pick the panel
617
+
618
+ **You route this, from context — not from a keyword table.** Read the evidence
619
+ first, then decide:
620
+
621
+ 1. The plans' `<files>` fields — every path this phase will actually touch.
622
+ 2. Migrations, schema files, and config the plans create or alter.
623
+ 3. CONTEXT.md decisions (D-XX) and the phase goal.
624
+ 4. The installed roster and what each persona actually owns:
625
+ ```bash
626
+ node ".rcode/bin/rcode-tools.cjs" list-agents
627
+ ```
628
+
629
+ Then pick the domain seats by asking, per candidate: *given these files and
630
+ these decisions, does this persona's lens see something the others cannot?* If
631
+ the answer is no, do not seat them.
632
+
633
+ Run the keyword scorer as **one input, never the verdict**:
634
+
635
+ ```bash
636
+ node ".rcode/bin/rcode-tools.cjs" select-panel \
637
+ "${PHASE_GOAL}. ${CONTEXT_DECISIONS_SUMMARY}" --explain
638
+ ```
639
+
640
+ It is a weighted keyword table, so it routes on the words the phase text happens
641
+ to use, not on what the phase touches — a phase full of `drizzle/*.sql` RLS
642
+ policies that never writes the word "security" scores near zero for the security
643
+ lens. That is the same enumerate-a-location shape this panel exists to hunt for,
644
+ so treat a high score as corroboration and a zero score as no information.
645
+
646
+ **When your reading and the scorer disagree, your reading wins — and you must
647
+ say so in the panel report**: which persona you seated or dropped against the
648
+ score, and the file or decision that made you do it. A routing override with no
649
+ stated reason is indistinguishable from a coin flip.
650
+
651
+ Panel composition:
652
+
653
+ - **Always include `rcode-waleed`** (architecture lens) — the "is this design
654
+ wrong" seat. Every phase gets it.
655
+ - **Always include `rcode-fatima`** (quality lens) — the "what will this guard
656
+ miss" seat. Every phase gets it.
657
+ - **Plus 1-2 domain personas** chosen by the reading above. Seat a persona only
658
+ when you can name the file, migration, or decision that needs their lens. A
659
+ seat filled to reach a quota costs tokens and adds nothing.
660
+
661
+ Cap the panel at 4. If nothing in the plans needs a third lens, the two standing
662
+ seats ARE the panel — a correct outcome, not a failure to route.
663
+
664
+ ### 9.5b — Run the panel in parallel
665
+
666
+ Spawn all panel members in a single message so they run concurrently. Each gets
667
+ the same narrow contract:
668
+
669
+ ```
670
+ You are reviewing SPRINT plans BEFORE execution, through your lens only.
671
+
672
+ Read: {PHASE_DIR}/*-SPRINT.md, {PHASE_DIR}/CONTEXT.md, ROADMAP.md phase {N}.
673
+
674
+ Return ONLY blocking issues — things that would make the executed phase wrong,
675
+ unsafe, or unverifiable. Not style, not preferences, not "consider also".
676
+ For each issue: what is wrong, the file:line or task id it lives in, and the
677
+ specific change that fixes it.
678
+
679
+ Two questions you MUST answer explicitly, even if the answer is "none":
680
+ 1. Which task in this plan enumerates a LOCATION where it should derive from a
681
+ PROPERTY? (a glob, a single filename, one role, one directory) — that shape
682
+ is how a guard ends up green while pointing where the problem is not.
683
+ 2. Which delivered module would have no production importer after this phase
684
+ executes — reachable only from its own test?
685
+
686
+ If you have no blocking issues, return exactly: NO BLOCKING ISSUES.
687
+ Do not restate the plan. Do not summarize. {response_language pass-through}
688
+ ```
689
+
690
+ ### 9.5c — Handle panel return
691
+
692
+ - All members return `NO BLOCKING ISSUES` → proceed to step 10.
693
+ - Any blocking issue → feed it into the **existing revision loop (step 12)**
694
+ alongside the checker's issues. Do not build a second revision mechanism.
695
+ - Panel issues and checker issues are deduped by task id before re-spawning the
696
+ planner.
697
+
698
+ Report to the user which agents sat on the panel and what each blocked on — a
699
+ silent panel is indistinguishable from a skipped one.
700
+
574
701
  ## 10. Spawn rcode-sprint-checker Agent
575
702
 
576
703
  Display banner:
@@ -597,7 +724,7 @@ Checker prompt:
597
724
  - {research_path} (Technical Research — includes Validation Architecture)
598
725
  </files_to_read>
599
726
 
600
- ${AGENT_SKILLS_CHECKER}
727
+ {agent_skills.checker}
601
728
 
602
729
  **Phase requirement IDs (MUST ALL be covered):** {phase_req_ids}
603
730
 
@@ -721,7 +848,7 @@ Revision prompt:
721
848
  - {context_path} (USER DECISIONS from /rcode-discuss-phase)
722
849
  </files_to_read>
723
850
 
724
- ${AGENT_SKILLS_PLANNER}
851
+ {agent_skills.planner}
725
852
 
726
853
  **Checker issues:** {structured_issues_from_checker}
727
854
  </revision_context>
@@ -1029,6 +1156,7 @@ ${WINDOWS === 'true' ? '@.rcode/references/plan-windows-troubleshooting.md' : ''
1029
1156
  - [ ] Phase directory created if needed
1030
1157
  - [ ] CONTEXT.md loaded early (step 4) and passed to ALL agents
1031
1158
  - [ ] Research completed (unless --skip-research or --gaps or exists)
1159
+ - [ ] Specialist review panel spawned (Waleed + Fatima + domain agents) and its blocking issues fed into the revision loop, or `workflow.specialist_review: false` recorded
1032
1160
  - [ ] rcode-phase-researcher spawned with CONTEXT.md
1033
1161
  - [ ] Existing plans checked
1034
1162
  - [ ] rcode-planner spawned with CONTEXT.md + RESEARCH.md
@@ -114,6 +114,8 @@ Next steps:
114
114
  git push origin {PR_BRANCH}
115
115
  gh pr create --base {TARGET} --head {PR_BRANCH}
116
116
 
117
+ (PR title/body must follow @rcode/references/github-comment-style.md)
118
+
117
119
  Or use /rcode-ship to create the PR automatically.
118
120
  ```
119
121
  </step>
@@ -42,11 +42,13 @@ If exists: Offer update/view/skip options.
42
42
  INIT=$(node ".rcode/bin/rcode-tools.cjs" init phase-op "${PHASE}" 2>/dev/null)
43
43
  if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
44
44
  # If INIT is empty or INIT.ok is false: print "Error: rcode-tools init failed." and exit.
45
- # Extract: phase_dir, padded_phase, phase_number, state_path, requirements_path, context_path, response_language
45
+ # Extract: phase_dir, padded_phase, phase_number, requirements_path, context_path, response_language,
46
+ # state_digest, agent_skills.researcher
46
47
  # If response_language is set, include "Respond in {value}." in all spawned subagent prompts.
47
- AGENT_SKILLS_RESEARCHER=$(node ".rcode/bin/rcode-tools.cjs" agent-skills rcode-phase-researcher 2>/dev/null || echo "")
48
48
  ```
49
49
 
50
+ **#949 — no separate `agent-skills rcode-phase-researcher` call.** `init phase-op` already returns `agent_skills.researcher` (same manifest row), folded in to avoid a second cold Node start per research run.
51
+
50
52
  ## Step 4: Spawn Researcher
51
53
 
52
54
  ```
@@ -69,10 +71,16 @@ Downstream consumer: /rcode-plan reads {phase}-RESEARCH.md to structure sprints.
69
71
  <files_to_read>
70
72
  - {context_path} (USER DECISIONS from /rcode-discuss-phase)
71
73
  - {requirements_path} (Project requirements)
72
- - {state_path} (Project decisions and history)
73
74
  </files_to_read>
74
75
 
75
- ${AGENT_SKILLS_RESEARCHER}
76
+ <project_state_digest>
77
+ {state_digest as JSON — current phase, recent decisions, open blockers. Slim
78
+ extract of state.json (#948); do NOT separately Read .rcode/state.json — its
79
+ full history (all phases, all sprints) is not needed here and costs 10-20K+
80
+ tokens on a mature project.}
81
+ </project_state_digest>
82
+
83
+ {agent_skills.researcher}
76
84
 
77
85
  <additional_context>
78
86
  Phase description: {description}
@@ -246,6 +246,10 @@ Omit this section entirely when VERIFICATION.md status is a clean `passed` with
246
246
  </step>
247
247
 
248
248
  <step name="create_pr">
249
+ **Before creating the PR, apply `@rcode/references/github-comment-style.md` to `${PR_BODY}`** —
250
+ no em-dashes, no gates/CI block, no git-process talk, no AI attribution. Run the
251
+ self-check greps in that reference; they must print nothing.
252
+
249
253
  Create the PR using the generated body:
250
254
 
251
255
  ```bash
@@ -380,6 +380,43 @@ Fill template sections: frontmatter (phase/timestamp/status/score), goal achieve
380
380
  See .rcode/templates/verification-report.md for complete template.
381
381
  </step>
382
382
 
383
+ <step name="falsification_pass">
384
+ **MANDATORY when `$STATUS` is `passed`.** Run the falsification pass defined in
385
+ `@.rcode/workflows/execute-verify-phase-goal.md` — a second agent, given the goal
386
+ and the codebase but NOT the summaries or this report, whose only job is to
387
+ refute the pass. Attack order: production reachability, guard shape, runtime
388
+ truth, claim-without-evidence.
389
+
390
+ - REFUTED → set `$STATUS=gaps_found`, append the finding to VERIFICATION.md's
391
+ gaps section, and skip the write-back below.
392
+ - UPHELD → record `falsification: upheld` in VERIFICATION.md frontmatter.
393
+
394
+ A `passed` VERIFICATION.md with no `falsification:` key is self-certified and
395
+ must not be treated as verified by any downstream command.
396
+ </step>
397
+
398
+ <step name="write_back_state">
399
+ **MANDATORY when status is `passed`. Verification is the terminal step of the
400
+ plan → execute → verify loop, and a terminal step with no write-back leaves
401
+ state.json permanently stale.** A phase can be verified green on disk while
402
+ state.json still says `planned` — and because `state sync --from-disk` only
403
+ advances status, nothing downstream ever corrects it. `/rcode-status` then
404
+ reports 1/13 when the real number is 9/13.
405
+
406
+ ```bash
407
+ if [ "$STATUS" = "passed" ]; then # only reachable when falsification upheld
408
+ node ".rcode/bin/rcode-tools.cjs" phase complete "${PHASE_NUMBER}" 2>&1
409
+ fi
410
+ ```
411
+
412
+ Check the result. If it returns `ok: false` or a non-zero exit, say so in the
413
+ report to the orchestrator — do NOT report the phase as verified-and-recorded
414
+ when only the file was written. If the phase is not yet in `state.phases`, run
415
+ `node ".rcode/bin/rcode-tools.cjs" state sync --from-disk` first, then retry.
416
+
417
+ Do not run this for `gaps_found` or `human_needed` — those are not complete.
418
+ </step>
419
+
383
420
  <step name="return_to_orchestrator">
384
421
  Return status (`passed` | `gaps_found` | `human_needed`), score (N/M must-haves), report path.
385
422
 
@@ -404,7 +441,10 @@ Orchestrator routes: `passed` → update_roadmap | `gaps_found` → create/execu
404
441
  - [ ] Overall status determined
405
442
  - [ ] Deferred items filtered against later milestone phases (if gaps found)
406
443
  - [ ] Fix plans generated (if gaps_found after filtering)
444
+ - [ ] Falsification pass run and `falsification:` recorded whenever status is `passed`
445
+ - [ ] Production reachability checked for every delivered non-UI module (importers classified production vs test; inline re-implementations named)
407
446
  - [ ] VERIFICATION.md created with complete report
447
+ - [ ] Phase marked complete in state.json via `phase complete` when status is `passed` (write-back confirmed, not assumed)
408
448
  - [ ] Results returned to orchestrator
409
449
  </success_criteria>
410
450
 
@@ -25,7 +25,7 @@ const path = require('path');
25
25
  const fs = require('fs');
26
26
  const os = require('os');
27
27
  const crypto = require('crypto');
28
- const { spawn } = require('child_process');
28
+ const { fork } = require('child_process');
29
29
 
30
30
  // Client JS modules live here and are served verbatim at /js/<name>.js
31
31
  const CLIENT_DIR = path.join(__dirname, 'lib', 'html', 'client');
@@ -33,12 +33,17 @@ const CLIENT_DIR = path.join(__dirname, 'lib', 'html', 'client');
33
33
  const { scanState } = require('./lib/scanner');
34
34
  const { handleApiState, handleApiFiles, handleApiFile, handleApiHierarchy, handleApiMemory, handleApiAgents } = require('./lib/api');
35
35
  const { renderHtml } = require('./lib/html/shell');
36
+ const { isViewOnly } = require('./lib/view-only');
36
37
 
37
38
  // ---------- Configuration ----------
38
39
  let PORT = parseInt(process.env.PORT || '7717', 10);
39
- // #969 — the orchestrator's actual port, injected into the client so it never
40
- // has to hardcode 7718. Defaults match orchestrator.js's own default.
41
- const ORCH_PORT = parseInt(process.env.ORCH_PORT || '7718', 10);
40
+ // #1037 — the orchestrator's actual port is NOT known until the child process
41
+ // reports back over IPC that it bound successfully (see spawnOrchestrator()).
42
+ // null means "no orchestrator is currently running for this dashboard" — the
43
+ // client must render orchestration as disabled rather than guess a port.
44
+ // NEVER default this to a constant like 7718: that constant may be another
45
+ // project's dashboard's orchestrator.
46
+ let orchPort = null;
42
47
  const RCODE_DIR = process.env.RCODE_DIR || path.join(process.cwd(), '.rcode');
43
48
  const PROJECT_ROOT = path.dirname(RCODE_DIR);
44
49
  // Fallback root for agent prompts when rcode is installed as a package (not run
@@ -150,7 +155,7 @@ function handleRequest(req, res) {
150
155
  return;
151
156
  }
152
157
  res.writeHead(200, { 'Content-Type': 'application/json' });
153
- res.end(JSON.stringify({ token: ORCH_TOKEN, orchPort: ORCH_PORT }));
158
+ res.end(JSON.stringify({ token: ORCH_TOKEN, orchPort, projectRoot: PROJECT_ROOT }));
154
159
  return;
155
160
  }
156
161
 
@@ -181,7 +186,12 @@ function handleRequest(req, res) {
181
186
 
182
187
  if (url === '/' || url === '/index.html') {
183
188
  const state = scanState(RCODE_DIR);
184
- const html = renderHtml(state, ORCH_TOKEN, ORCH_PORT);
189
+ // #967 hide Run/Stop/Clean affordances in view-only mode. This is a UI
190
+ // convenience only; the actual refusal happens server-side in
191
+ // orchestrator.js's POST /api/run handler regardless of this flag.
192
+ // #1037 — orchPort (not ORCH_PORT) so we never advertise an orchestrator
193
+ // this process did not spawn; null renders orchestration as disabled.
194
+ const html = renderHtml(state, ORCH_TOKEN, orchPort, PROJECT_ROOT, isViewOnly(PROJECT_ROOT));
185
195
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
186
196
  res.end(html);
187
197
  return;
@@ -215,9 +225,14 @@ server.listen(PORT, '127.0.0.1', () => {
215
225
  console.log(` Mode: live (read + orchestration)`);
216
226
  console.log(` Scanning: ${RCODE_DIR}`);
217
227
  console.log(` Refresh: 30s soft poll`);
218
- console.log(` Note: port ${PORT + 1} is the internal orchestrator API not for the browser`);
228
+ console.log(` Note: the orchestrator (internal API, not for the browser) starts scanning near port ${PORT + 1}`);
219
229
  console.log(` Stop: kill $(ss -ltnp 'sport = :${PORT}' | awk 'NR>1{match($6,/pid=([0-9]+)/,m); print m[1]}')`);
220
230
  console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
231
+ // #1037 — spawn the orchestrator only now that PORT has SETTLED (any
232
+ // EADDRINUSE retries above have already run their course by the time this
233
+ // 'listening' callback fires). Spawning it earlier would derive its port
234
+ // from a PORT value that might still change underneath it.
235
+ ensurePty(spawnOrchestrator);
221
236
  });
222
237
 
223
238
  // ── Ensure interactive-terminal native module is present ─────────
@@ -242,16 +257,32 @@ function ensurePty(done) {
242
257
  done();
243
258
  }
244
259
 
245
- // ── Auto-spawn orchestrator (port 7718) ──────────────────────────
260
+ // ── Auto-spawn orchestrator ───────────────────────────────────────
261
+ // #1037 — the orchestrator's port is derived from THIS dashboard's own final
262
+ // bound PORT (never a hardcoded constant), and the orchestrator runs its own
263
+ // free-port scan rather than exiting on the first conflict (see
264
+ // orchestrator.js's listen/retry block). We fork() (not spawn()) so the child
265
+ // gets an IPC channel: it reports back the port it actually bound once
266
+ // listening succeeds, or an error if its scan is exhausted. Until that
267
+ // message arrives, `orchPort` stays null and the dashboard advertises
268
+ // orchestration as disabled — it must never guess a port it did not spawn.
246
269
  const ORCH_BIN = path.join(__dirname, 'orchestrator.js');
247
270
  let _orchProc = null;
248
271
 
249
272
  function spawnOrchestrator() {
273
+ orchPort = null;
250
274
  try {
251
- _orchProc = spawn(process.execPath, [ORCH_BIN], {
275
+ _orchProc = fork(ORCH_BIN, [], {
252
276
  cwd: path.join(__dirname, '..'),
253
- env: { ...process.env, ORCH_TOKEN, RCODE_DIR, PROJECT_ROOT, DASH_PORT: String(PORT) },
254
- stdio: 'pipe',
277
+ env: {
278
+ ...process.env,
279
+ ORCH_TOKEN, RCODE_DIR, PROJECT_ROOT,
280
+ DASH_PORT: String(PORT),
281
+ // Respect an explicit user override; otherwise start the orchestrator's
282
+ // own scan right after this dashboard's actual port.
283
+ ORCH_PORT: process.env.ORCH_PORT || String(PORT + 1),
284
+ },
285
+ silent: true,
255
286
  });
256
287
  _orchProc.stdout.on('data', chunk => {
257
288
  const msg = chunk.toString().trim();
@@ -261,12 +292,23 @@ function spawnOrchestrator() {
261
292
  const msg = chunk.toString().trim();
262
293
  if (msg && !msg.includes('no stdin')) console.error('[orch]', msg);
263
294
  });
295
+ _orchProc.on('message', msg => {
296
+ if (!msg || typeof msg !== 'object') return;
297
+ if (msg.type === 'orch-ready' && Number.isInteger(msg.port)) {
298
+ orchPort = msg.port;
299
+ console.log(`[orch] orchestrator ready (port ${orchPort})`);
300
+ } else if (msg.type === 'orch-error') {
301
+ orchPort = null;
302
+ console.log(`[orch] orchestrator failed to bind (${msg.reason}) — orchestration disabled for this dashboard.`);
303
+ }
304
+ });
264
305
  _orchProc.on('exit', (code, signal) => {
265
306
  _orchProc = null;
307
+ orchPort = null; // never keep advertising a port nothing is listening on
266
308
  if (signal !== 'SIGTERM' && signal !== 'SIGINT') {
267
309
  if (code === 2) {
268
- // Port-conflict exit — don't loop. Dashboard stays fully functional.
269
- console.log(`[orch] orchestrator port already in use — not restarting. Set ORCH_PORT=<N> env var to use a different port. Dashboard is still functional.`);
310
+ // Port-conflict exit (scan exhausted) — don't loop. Dashboard stays functional.
311
+ console.log(`[orch] orchestrator could not find a free port — not restarting. Set ORCH_PORT=<N> env var to use a different range. Dashboard is still functional.`);
270
312
  return;
271
313
  }
272
314
  console.log(`[orch] exited (${code}) — restarting in 3s…`);
@@ -276,16 +318,14 @@ function spawnOrchestrator() {
276
318
  _orchProc.on('error', err => {
277
319
  console.error('[orch] spawn error:', err.message);
278
320
  _orchProc = null;
321
+ orchPort = null;
279
322
  });
280
- console.log(`[orch] orchestrator started (port ${ORCH_PORT})`);
281
323
  } catch (err) {
282
324
  console.error('[orch] failed to start:', err.message);
325
+ orchPort = null;
283
326
  }
284
327
  }
285
328
 
286
- // Orchestrator spawns only once node-pty is settled (present or installed).
287
- ensurePty(spawnOrchestrator);
288
-
289
329
  // Graceful shutdown
290
330
  function shutdown() {
291
331
  if (_orchProc) { try { _orchProc.kill('SIGTERM'); } catch {} }
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { html, useState, useEffect, useRef, useCallback } from '../preact.js';
17
17
  import { useStore, setState } from '../store.js';
18
- import { orchToken, stopSession, cleanSessions, orchWs } from '../orchestrator.js';
18
+ import { orchToken, stopSession, cleanSessions, orchWs, isOrchAvailable, projectRoot } from '../orchestrator.js';
19
19
  import { showToast } from './shared.js';
20
20
  import { Icon } from '../icons-client.js';
21
21
 
@@ -105,9 +105,13 @@ export function OrchPanel() {
105
105
  showToast('No orchestrator token — restart the dashboard');
106
106
  return;
107
107
  }
108
+ if (!isOrchAvailable()) {
109
+ showToast('Orchestration is disabled — no orchestrator is running for this project');
110
+ return;
111
+ }
108
112
  const ws = new WebSocket(
109
113
  orchWs() + '/ws/' + encodeURIComponent(storyId) +
110
- '?token=' + encodeURIComponent(tok)
114
+ '?token=' + encodeURIComponent(tok) + '&root=' + encodeURIComponent(projectRoot())
111
115
  );
112
116
  _streams[storyId] = ws;
113
117
 
@@ -25,7 +25,7 @@
25
25
 
26
26
  import { html, useEffect, useRef, useCallback } from '../preact.js';
27
27
  import { useStore, setState } from '../store.js';
28
- import { orchToken, stopSession, orchWs } from '../orchestrator.js';
28
+ import { orchToken, stopSession, orchWs, isOrchAvailable, projectRoot } from '../orchestrator.js';
29
29
 
30
30
  // ── Internal state (module-scoped, one panel at a time) ──────────────────────
31
31
  // These are NOT component state because the xterm instance (and the story it
@@ -103,8 +103,13 @@ function connectWs(storyId) {
103
103
  if (_term) _term.writeln('\r\n\x1b[31m✗ No orchestrator token — restart the dashboard\x1b[0m');
104
104
  return;
105
105
  }
106
+ if (!isOrchAvailable()) {
107
+ if (_term) _term.writeln('\r\n\x1b[31m✗ Orchestration is disabled — no orchestrator is running for this project\x1b[0m');
108
+ return;
109
+ }
106
110
  setStatus('connecting');
107
- const url = orchWs() + '/ws/' + encodeURIComponent(storyId) + '?token=' + encodeURIComponent(tok);
111
+ const url = orchWs() + '/ws/' + encodeURIComponent(storyId) +
112
+ '?token=' + encodeURIComponent(tok) + '&root=' + encodeURIComponent(projectRoot());
108
113
  const ws = new WebSocket(url);
109
114
  _termWs = ws;
110
115