orchestrix-yuri 4.2.4 → 4.3.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.
@@ -753,6 +753,186 @@ class PhaseOrchestrator {
753
753
  this.onComplete('develop', '🎉 Development complete! All stories finished.\n\nRun *test to start smoke testing.');
754
754
  }
755
755
 
756
+ // ── Change Management ───────────────────────────────────────────────────────
757
+
758
+ /**
759
+ * Execute a change request in background based on assessed scope.
760
+ *
761
+ * @param {string} projectRoot
762
+ * @param {'small'|'medium'|'large'} scope — assessed by Claude in step 1
763
+ * @param {string} description — the change description from user
764
+ * @returns {string} immediate status message
765
+ */
766
+ startChange(projectRoot, scope, description) {
767
+ if (this._phase) {
768
+ return `⚠️ Phase "${this._phase}" is already running. Finish it first or *cancel.`;
769
+ }
770
+
771
+ this._projectRoot = projectRoot;
772
+ this._phase = 'change';
773
+ this._lastHash = '';
774
+ this._stableCount = 0;
775
+
776
+ log.engine(`Change management: scope=${scope}, desc="${description.slice(0, 60)}..."`);
777
+
778
+ try {
779
+ if (scope === 'small') {
780
+ return this._executeSmallChange(projectRoot, description);
781
+ } else if (scope === 'medium' || scope === 'large') {
782
+ return this._executeMediumChange(projectRoot, scope, description);
783
+ } else {
784
+ this._phase = null;
785
+ return `❌ Unknown scope: ${scope}. Expected small/medium/large.`;
786
+ }
787
+ } catch (err) {
788
+ this._phase = null;
789
+ return `❌ Change failed: ${err.message}`;
790
+ }
791
+ }
792
+
793
+ /**
794
+ * Small change: send *solo to Dev window in existing dev session.
795
+ */
796
+ _executeSmallChange(projectRoot, description) {
797
+ const scriptPath = path.join(SKILL_DIR, 'scripts', 'ensure-session.sh');
798
+ const result = execSync(`bash "${scriptPath}" dev "${projectRoot}"`, { encoding: 'utf8', timeout: 60000 }).trim();
799
+ const lines = result.split('\n');
800
+ this._session = lines[lines.length - 1].trim();
801
+
802
+ // Send to Dev window (window 2)
803
+ tmx.sendKeysWithEnter(this._session, 2, '/clear');
804
+ execSync('sleep 2');
805
+ tmx.sendKeysWithEnter(this._session, 2, '/o dev');
806
+ execSync('sleep 12');
807
+ tmx.sendKeysWithEnter(this._session, 2, `*solo "${description}"`);
808
+
809
+ // Poll for completion
810
+ const pollInterval = this.config.phase_poll_interval || 30000;
811
+ this._step = 0; // track which step we're on
812
+ this._changeContext = { scope: 'small', description };
813
+ this._timer = setInterval(() => this._pollChange(), pollInterval);
814
+
815
+ return `🔧 Small change started → Dev *solo\n\n"${description.slice(0, 100)}"\n\nI'll notify you when it's done.`;
816
+ }
817
+
818
+ /**
819
+ * Medium/Large change: PO route-change in planning session, then apply in dev session.
820
+ */
821
+ _executeMediumChange(projectRoot, scope, description) {
822
+ const scriptPath = path.join(SKILL_DIR, 'scripts', 'ensure-session.sh');
823
+
824
+ // Step 1: Ensure planning session
825
+ const planResult = execSync(`bash "${scriptPath}" planning "${projectRoot}"`, { encoding: 'utf8', timeout: 60000 }).trim();
826
+ const planLines = planResult.split('\n');
827
+ const planSession = planLines[planLines.length - 1].trim();
828
+
829
+ // Step 2: Activate PO and route change
830
+ tmx.sendKeysWithEnter(planSession, 0, '/o po');
831
+ execSync('sleep 15');
832
+ tmx.sendKeysWithEnter(planSession, 0, `*route-change "${description}"`);
833
+
834
+ // Poll for PO completion, then chain to next agent
835
+ const pollInterval = this.config.phase_poll_interval || 30000;
836
+ this._step = 0;
837
+ this._changeContext = {
838
+ scope,
839
+ description,
840
+ planSession,
841
+ // Steps: 0=PO route-change → 1=Architect/PM (based on PO output) → 2=SM apply-proposal
842
+ };
843
+ this._timer = setInterval(() => this._pollChange(), pollInterval);
844
+
845
+ return `🔧 ${scope === 'large' ? 'Large' : 'Medium'} change started → PO *route-change\n\n"${description.slice(0, 100)}"\n\nPO will assess and route to the right agent. I'll keep you updated.`;
846
+ }
847
+
848
+ /**
849
+ * Poll change management progress.
850
+ */
851
+ _pollChange() {
852
+ if (this._phase !== 'change') return;
853
+ if (this._waitingForInput) return;
854
+
855
+ const ctx = this._changeContext;
856
+ if (!ctx) return;
857
+
858
+ if (ctx.scope === 'small') {
859
+ // Polling Dev window for completion
860
+ if (!tmx.hasSession(this._session)) {
861
+ this._handleError('change', 'Dev tmux session died');
862
+ return;
863
+ }
864
+ const result = tmx.checkCompletion(this._session, 2, this._lastHash);
865
+ if (result.status === 'complete' || (result.status === 'stable' && ++this._stableCount >= 3)) {
866
+ this._completeChange('Dev completed the change.');
867
+ return;
868
+ }
869
+ if (result.status !== 'stable') { this._stableCount = 0; this._lastHash = result.hash || ''; }
870
+ else { this._lastHash = result.hash; }
871
+ return;
872
+ }
873
+
874
+ // Medium/Large: multi-step
875
+ if (!tmx.hasSession(ctx.planSession)) {
876
+ this._handleError('change', 'Planning tmux session died');
877
+ return;
878
+ }
879
+
880
+ const result = tmx.checkCompletion(ctx.planSession, 0, this._lastHash);
881
+
882
+ if (result.status === 'complete' || (result.status === 'stable' && ++this._stableCount >= 3)) {
883
+ this._stableCount = 0;
884
+ this._lastHash = '';
885
+ this._step++;
886
+
887
+ if (this._step === 1) {
888
+ // PO finished routing. Now send to Architect for *resolve-change
889
+ this.onProgress('✅ PO routing complete. Sending to Architect...');
890
+ tmx.sendKeysWithEnter(ctx.planSession, 0, '/clear');
891
+ execSync('sleep 2');
892
+ tmx.sendKeysWithEnter(ctx.planSession, 0, '/o architect');
893
+ execSync('sleep 15');
894
+ tmx.sendKeysWithEnter(ctx.planSession, 0, '*resolve-change');
895
+ } else if (this._step === 2) {
896
+ // Architect finished. Apply in dev session via SM
897
+ this.onProgress('✅ Architect resolved. Applying change via SM...');
898
+ try {
899
+ const scriptPath = path.join(SKILL_DIR, 'scripts', 'ensure-session.sh');
900
+ const devResult = execSync(`bash "${scriptPath}" dev "${this._projectRoot}"`, { encoding: 'utf8', timeout: 60000 }).trim();
901
+ const devLines = devResult.split('\n');
902
+ this._session = devLines[devLines.length - 1].trim();
903
+
904
+ tmx.sendKeysWithEnter(this._session, 1, '/clear');
905
+ execSync('sleep 2');
906
+ tmx.sendKeysWithEnter(this._session, 1, '/o sm');
907
+ execSync('sleep 12');
908
+ tmx.sendKeysWithEnter(this._session, 1, '*draft');
909
+
910
+ // Now poll dev session SM window
911
+ this._changeContext._pollDevSM = true;
912
+ } catch (err) {
913
+ this._handleError('change', `Failed to apply in dev session: ${err.message}`);
914
+ }
915
+ } else if (this._step >= 3) {
916
+ this._completeChange('Change applied. SM started new stories from the change.');
917
+ }
918
+ return;
919
+ }
920
+
921
+ if (result.status !== 'stable') { this._stableCount = 0; this._lastHash = result.hash || ''; }
922
+ else { this._lastHash = result.hash; }
923
+ }
924
+
925
+ _completeChange(summary) {
926
+ if (this._timer) {
927
+ clearInterval(this._timer);
928
+ this._timer = null;
929
+ }
930
+ this._phase = null;
931
+ this._changeContext = null;
932
+ log.engine(`Change management complete: ${summary}`);
933
+ this.onComplete('change', `✅ Change management complete.\n\n${summary}`);
934
+ }
935
+
756
936
  // ── Shared ─────────────────────────────────────────────────────────────────
757
937
 
758
938
  _handleError(phase, message) {
@@ -19,6 +19,7 @@ const YURI_GLOBAL = path.join(os.homedir(), '.yuri');
19
19
  const PHASE_COMMANDS = {
20
20
  plan: /^\*plan\b/i,
21
21
  develop: /^\*develop\b/i,
22
+ change: /^\*change\s+(.+)/i,
22
23
  test: /^\*test\b/i,
23
24
  deploy: /^\*deploy\b/i,
24
25
  cancel: /^\*cancel\b/i,
@@ -266,7 +267,7 @@ class Router {
266
267
  return null;
267
268
  }
268
269
 
269
- _handlePhaseCommand(phase, msg) {
270
+ async _handlePhaseCommand(phase, msg) {
270
271
  const projectRoot = engine.resolveProjectRoot();
271
272
  if (!projectRoot) {
272
273
  return { text: '❌ No active project found. Create one first with *create.' };
@@ -280,16 +281,15 @@ class Router {
280
281
  case 'develop':
281
282
  response = this.orchestrator.startDevelop(projectRoot);
282
283
  break;
284
+ case 'change':
285
+ return this._handleChangeCommand(msg, projectRoot);
283
286
  case 'test':
284
287
  case 'deploy':
285
- // These phases are simpler — let Claude handle them normally
286
- // (they don't have the 30-minute orchestration problem)
287
288
  return this._processMessageDirect(msg);
288
289
  default:
289
290
  response = `Unknown phase: ${phase}`;
290
291
  }
291
292
 
292
- // Save to chat history
293
293
  this.history.append(msg.chatId, 'user', msg.text);
294
294
  this.history.append(msg.chatId, 'assistant', response.slice(0, 2000));
295
295
  this._updateGlobalFocus(msg, projectRoot);
@@ -297,6 +297,56 @@ class Router {
297
297
  return { text: response };
298
298
  }
299
299
 
300
+ /**
301
+ * Handle *change command in two steps:
302
+ * Step 1: Claude assesses the scope (small/medium/large) — quick claude -p call
303
+ * Step 2: Orchestrator executes the change in tmux background
304
+ */
305
+ async _handleChangeCommand(msg, projectRoot) {
306
+ // Extract description from "*change description here"
307
+ const match = msg.text.trim().match(/^\*change\s+(.+)/i);
308
+ const description = match ? match[1].replace(/^["']|["']$/g, '') : '';
309
+
310
+ if (!description) {
311
+ return { text: '❌ Usage: *change "description of the change"\n\nExample: *change "Add dark mode toggle to settings page"' };
312
+ }
313
+
314
+ // Step 1: Ask Claude to assess scope
315
+ this.history.append(msg.chatId, 'user', msg.text);
316
+ log.router(`Change request: "${description.slice(0, 80)}..." — assessing scope...`);
317
+
318
+ const scopePrompt = `You are assessing a change request for a software project.
319
+
320
+ Change description: "${description}"
321
+
322
+ Based on the description, classify the scope as one of:
323
+ - **small**: ≤5 files affected, no architectural changes, no new dependencies (e.g., UI tweak, bug fix, small feature)
324
+ - **medium**: Cross-component change, needs PO routing and possibly architect review (e.g., new API endpoint, refactoring a module)
325
+ - **large**: Cross-module/database/security change, needs full re-planning (e.g., auth system rewrite, new microservice)
326
+
327
+ Reply with ONLY one word: small, medium, or large. Nothing else.`;
328
+
329
+ const scopeResult = await engine.callClaude({
330
+ prompt: scopePrompt,
331
+ cwd: projectRoot,
332
+ engineConfig: this.config.engine,
333
+ timeout: 30000, // quick assessment
334
+ });
335
+
336
+ const scopeRaw = (scopeResult.reply || '').trim().toLowerCase();
337
+ const scope = ['small', 'medium', 'large'].find((s) => scopeRaw.includes(s)) || 'medium';
338
+
339
+ log.router(`Change scope assessed: ${scope}`);
340
+
341
+ // Step 2: Execute via orchestrator
342
+ const response = this.orchestrator.startChange(projectRoot, scope, description);
343
+
344
+ this.history.append(msg.chatId, 'assistant', response.slice(0, 2000));
345
+ this._updateGlobalFocus(msg, projectRoot);
346
+
347
+ return { text: `📋 Scope: **${scope}**\n\n${response}` };
348
+ }
349
+
300
350
  // ── Status Query ─────────────────────────────────────────────────────────────
301
351
 
302
352
  _isStatusQuery(text) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orchestrix-yuri",
3
- "version": "4.2.4",
3
+ "version": "4.3.0",
4
4
  "description": "Yuri — Meta-Orchestrator for Orchestrix. Drive your entire project lifecycle with natural language.",
5
5
  "main": "lib/installer.js",
6
6
  "bin": {
@@ -105,27 +105,63 @@ Read `{project}/.yuri/state/phase2.yaml`.
105
105
 
106
106
  ### IF Phase 3 (Develop) is in progress or complete:
107
107
 
108
- Read `{project}/.yuri/state/phase3.yaml`.
108
+ **Generate a progress card** using these data sources:
109
109
 
110
- Scan current story statuses:
110
+ **1. Scan story statuses:**
111
111
  ```bash
112
112
  SCRIPT_DIR="${CLAUDE_SKILL_DIR}/scripts"
113
113
  bash "$SCRIPT_DIR/scan-stories.sh" "$PROJECT_DIR"
114
114
  ```
115
115
 
116
- ```
117
- ### Development Progress
118
-
119
- | Metric | Value |
120
- |--------|-------|
121
- | Total Stories | {total} |
122
- | Done | {done count} |
123
- | In Progress | {in_progress count} |
124
- | Blocked | {blocked count} |
125
- | Remaining | {remaining count} |
116
+ The script outputs:
117
+ - `Total:{N}` — total planned stories from all `docs/prd/epic-*.yaml` definitions
118
+ - `Created:{N}` — story files in `docs/stories/`
119
+ - `StatusDone:{filename}` / `StatusInProgress:{filename}` / etc. — per-file status
120
+ - `Epics:{N}` — total epics (max N from `docs/prd/epic-N-*`)
121
+ - `CurrentEpic:{N}` epic of current story
122
+ - `CurrentStory:{filename}` InProgress or last non-Done story
126
123
 
127
- Progress: [{done}/{total}] {'█' * pct}{'░' * (100-pct)} {pct}%
128
- ```
124
+ **2. Detect active tmux agent:**
125
+ ```bash
126
+ # Check which orchestrix-* session exists
127
+ tmux list-sessions -F "#{session_name}" 2>/dev/null | grep "^orchestrix-"
128
+
129
+ # For each window (0=Architect, 1=SM, 2=Dev, 3=QA):
130
+ # - Window showing "Command | Description" table = IDLE
131
+ # - Window WITHOUT that table = actively executing
132
+ for w in 0 1 2 3; do
133
+ OUTPUT=$(tmux capture-pane -t "$DEV_SESSION:$w" -p -S -15)
134
+ if ! echo "$OUTPUT" | grep -q "Command.*Description"; then
135
+ echo "Window $w is ACTIVE"
136
+ fi
137
+ done
138
+ ```
139
+
140
+ **3. Format the progress card:**
141
+
142
+ ```
143
+ 📊 Dev Progress Report
144
+ ━━━━━━━━━━━━━━━━━━━━━
145
+ Epic: {CurrentEpic}/{Epics}
146
+ Story: {done}/{total} done ({pct}%) | {created} created
147
+ ▓▓▓▓▓▓░░░░░░░░░░░░░░ {pct}%
148
+ ━━━━━━━━━━━━━━━━━━━━━
149
+ ✅ Done: {N}
150
+ 🔄 InProgress: {N}
151
+ 📋 Approved: {N}
152
+ 📝 Draft: {N}
153
+ ━━━━━━━━━━━━━━━━━━━━━
154
+ 📝 Current: {CurrentStory}
155
+ 🤖 Agent: {active_agent} (window {N})
156
+ ⏱ Running for {elapsed}
157
+ ```
158
+
159
+ Where:
160
+ - `{pct}` = round(done / total * 100)
161
+ - Progress bar: `▓` for filled, `░` for empty (20 chars total)
162
+ - Only show status categories with count > 0
163
+ - `{elapsed}` = time since `focus.updated_at` or `phase3.started_at`
164
+ - If no agent is active (all windows show Command table), show "All agents idle (waiting for handoff)"
129
165
 
130
166
  ### IF Phase 4 (Test) is in progress or complete:
131
167