mstro-app 0.3.8 → 0.3.9

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 (105) hide show
  1. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  2. package/dist/server/cli/headless/claude-invoker.js +18 -9
  3. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  4. package/dist/server/cli/headless/headless-logger.d.ts +10 -0
  5. package/dist/server/cli/headless/headless-logger.d.ts.map +1 -0
  6. package/dist/server/cli/headless/headless-logger.js +66 -0
  7. package/dist/server/cli/headless/headless-logger.js.map +1 -0
  8. package/dist/server/cli/headless/mcp-config.d.ts.map +1 -1
  9. package/dist/server/cli/headless/mcp-config.js +6 -5
  10. package/dist/server/cli/headless/mcp-config.js.map +1 -1
  11. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  12. package/dist/server/cli/headless/runner.js +4 -0
  13. package/dist/server/cli/headless/runner.js.map +1 -1
  14. package/dist/server/cli/headless/stall-assessor.d.ts +21 -0
  15. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  16. package/dist/server/cli/headless/stall-assessor.js +70 -19
  17. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  18. package/dist/server/cli/headless/tool-watchdog.d.ts +0 -12
  19. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  20. package/dist/server/cli/headless/tool-watchdog.js +22 -9
  21. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  22. package/dist/server/cli/headless/types.d.ts +8 -1
  23. package/dist/server/cli/headless/types.d.ts.map +1 -1
  24. package/dist/server/cli/improvisation-session-manager.d.ts +16 -0
  25. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  26. package/dist/server/cli/improvisation-session-manager.js +94 -11
  27. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  28. package/dist/server/mcp/bouncer-cli.d.ts +3 -0
  29. package/dist/server/mcp/bouncer-cli.d.ts.map +1 -0
  30. package/dist/server/mcp/bouncer-cli.js +54 -0
  31. package/dist/server/mcp/bouncer-cli.js.map +1 -0
  32. package/dist/server/services/plan/composer.d.ts +4 -0
  33. package/dist/server/services/plan/composer.d.ts.map +1 -0
  34. package/dist/server/services/plan/composer.js +181 -0
  35. package/dist/server/services/plan/composer.js.map +1 -0
  36. package/dist/server/services/plan/dependency-resolver.d.ts +28 -0
  37. package/dist/server/services/plan/dependency-resolver.d.ts.map +1 -0
  38. package/dist/server/services/plan/dependency-resolver.js +152 -0
  39. package/dist/server/services/plan/dependency-resolver.js.map +1 -0
  40. package/dist/server/services/plan/executor.d.ts +91 -0
  41. package/dist/server/services/plan/executor.d.ts.map +1 -0
  42. package/dist/server/services/plan/executor.js +545 -0
  43. package/dist/server/services/plan/executor.js.map +1 -0
  44. package/dist/server/services/plan/parser.d.ts +11 -0
  45. package/dist/server/services/plan/parser.d.ts.map +1 -0
  46. package/dist/server/services/plan/parser.js +415 -0
  47. package/dist/server/services/plan/parser.js.map +1 -0
  48. package/dist/server/services/plan/state-reconciler.d.ts +2 -0
  49. package/dist/server/services/plan/state-reconciler.d.ts.map +1 -0
  50. package/dist/server/services/plan/state-reconciler.js +105 -0
  51. package/dist/server/services/plan/state-reconciler.js.map +1 -0
  52. package/dist/server/services/plan/types.d.ts +120 -0
  53. package/dist/server/services/plan/types.d.ts.map +1 -0
  54. package/dist/server/services/plan/types.js +4 -0
  55. package/dist/server/services/plan/types.js.map +1 -0
  56. package/dist/server/services/plan/watcher.d.ts +14 -0
  57. package/dist/server/services/plan/watcher.d.ts.map +1 -0
  58. package/dist/server/services/plan/watcher.js +69 -0
  59. package/dist/server/services/plan/watcher.js.map +1 -0
  60. package/dist/server/services/websocket/file-explorer-handlers.js +20 -0
  61. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
  62. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  63. package/dist/server/services/websocket/handler.js +21 -0
  64. package/dist/server/services/websocket/handler.js.map +1 -1
  65. package/dist/server/services/websocket/plan-handlers.d.ts +6 -0
  66. package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -0
  67. package/dist/server/services/websocket/plan-handlers.js +494 -0
  68. package/dist/server/services/websocket/plan-handlers.js.map +1 -0
  69. package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
  70. package/dist/server/services/websocket/quality-handlers.js +375 -11
  71. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  72. package/dist/server/services/websocket/quality-persistence.d.ts +45 -0
  73. package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -0
  74. package/dist/server/services/websocket/quality-persistence.js +187 -0
  75. package/dist/server/services/websocket/quality-persistence.js.map +1 -0
  76. package/dist/server/services/websocket/quality-service.d.ts +2 -2
  77. package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
  78. package/dist/server/services/websocket/quality-service.js +62 -12
  79. package/dist/server/services/websocket/quality-service.js.map +1 -1
  80. package/dist/server/services/websocket/types.d.ts +2 -2
  81. package/dist/server/services/websocket/types.d.ts.map +1 -1
  82. package/package.json +2 -2
  83. package/server/cli/headless/claude-invoker.ts +21 -9
  84. package/server/cli/headless/headless-logger.ts +78 -0
  85. package/server/cli/headless/mcp-config.ts +6 -5
  86. package/server/cli/headless/runner.ts +4 -0
  87. package/server/cli/headless/stall-assessor.ts +97 -19
  88. package/server/cli/headless/tool-watchdog.ts +10 -9
  89. package/server/cli/headless/types.ts +10 -1
  90. package/server/cli/improvisation-session-manager.ts +118 -11
  91. package/server/mcp/bouncer-cli.ts +73 -0
  92. package/server/services/plan/composer.ts +199 -0
  93. package/server/services/plan/dependency-resolver.ts +179 -0
  94. package/server/services/plan/executor.ts +604 -0
  95. package/server/services/plan/parser.ts +459 -0
  96. package/server/services/plan/state-reconciler.ts +132 -0
  97. package/server/services/plan/types.ts +164 -0
  98. package/server/services/plan/watcher.ts +73 -0
  99. package/server/services/websocket/file-explorer-handlers.ts +20 -0
  100. package/server/services/websocket/handler.ts +21 -0
  101. package/server/services/websocket/plan-handlers.ts +592 -0
  102. package/server/services/websocket/quality-handlers.ts +441 -11
  103. package/server/services/websocket/quality-persistence.ts +250 -0
  104. package/server/services/websocket/quality-service.ts +65 -12
  105. package/server/services/websocket/types.ts +48 -2
@@ -12,8 +12,9 @@ import { EventEmitter } from 'node:events';
12
12
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  import { AnalyticsEvents, trackEvent } from '../services/analytics.js';
15
+ import { herror, hlog } from './headless/headless-logger.js';
15
16
  import { HeadlessRunner } from './headless/index.js';
16
- import { assessBestResult, assessContextLoss, type ContextLossContext } from './headless/stall-assessor.js';
17
+ import { assessBestResult, assessContextLoss, assessPrematureCompletion, type ContextLossContext } from './headless/stall-assessor.js';
17
18
  import type { ExecutionCheckpoint } from './headless/types.js';
18
19
 
19
20
  export interface ImprovisationOptions {
@@ -302,7 +303,7 @@ export class ImprovisationSessionManager extends EventEmitter {
302
303
  writeFileSync(filePath, Buffer.from(attachment.content, 'base64'));
303
304
  paths.push(filePath);
304
305
  } catch (err) {
305
- console.error(`Failed to persist attachment ${attachment.fileName}:`, err);
306
+ herror(`Failed to persist attachment ${attachment.fileName}:`, err);
306
307
  }
307
308
  }
308
309
 
@@ -503,6 +504,8 @@ export class ImprovisationSessionManager extends EventEmitter {
503
504
  if (this.shouldRetrySignalCrash(result, state, maxRetries, promptWithAttachments)) continue;
504
505
  if (this.shouldRetryContextLoss(result, state, useResume, nativeTimeouts, maxRetries, promptWithAttachments)) continue;
505
506
  if (this.applyToolTimeoutRetry(state, maxRetries, promptWithAttachments)) continue;
507
+ // Premature completion: model exited normally but task appears incomplete
508
+ if (await this.shouldRetryPrematureCompletion(result, state, maxRetries)) continue;
506
509
  break;
507
510
  }
508
511
  return result;
@@ -522,7 +525,7 @@ export class ImprovisationSessionManager extends EventEmitter {
522
525
  try {
523
526
  attachment.content = readFileSync(attachment.filePath).toString('base64');
524
527
  } catch (err) {
525
- console.error(`Failed to read pre-uploaded image ${attachment.filePath}:`, err);
528
+ herror(`Failed to read pre-uploaded image ${attachment.filePath}:`, err);
526
529
  attachment.isImage = false;
527
530
  }
528
531
  }
@@ -662,17 +665,17 @@ export class ImprovisationSessionManager extends EventEmitter {
662
665
  }
663
666
  if (!result.assistantResponse || result.assistantResponse.trim().length === 0) {
664
667
  state.contextLost = true;
665
- if (this.options.verbose) console.log('[CONTEXT-RECOVERY] Resume context loss: null/empty response');
668
+ if (this.options.verbose) hlog('[CONTEXT-RECOVERY] Resume context loss: null/empty response');
666
669
  } else if (result.resumeBufferedOutput !== undefined) {
667
670
  state.contextLost = true;
668
- if (this.options.verbose) console.log('[CONTEXT-RECOVERY] Resume context loss: buffer never flushed (no thinking/tools)');
671
+ if (this.options.verbose) hlog('[CONTEXT-RECOVERY] Resume context loss: buffer never flushed (no thinking/tools)');
669
672
  } else if (
670
673
  (!result.toolUseHistory || result.toolUseHistory.length === 0) &&
671
674
  !result.thinkingOutput &&
672
675
  result.assistantResponse.length < 500
673
676
  ) {
674
677
  state.contextLost = true;
675
- if (this.options.verbose) console.log('[CONTEXT-RECOVERY] Resume context loss: no tools, no thinking, short response');
678
+ if (this.options.verbose) hlog('[CONTEXT-RECOVERY] Resume context loss: no tools, no thinking, short response');
676
679
  }
677
680
  }
678
681
 
@@ -716,7 +719,7 @@ export class ImprovisationSessionManager extends EventEmitter {
716
719
  const verdict = await assessContextLoss(contextLossCtx, claudeCmd, this.options.verbose);
717
720
  state.contextLost = verdict.contextLost;
718
721
  if (this.options.verbose) {
719
- console.log(`[CONTEXT-RECOVERY] Haiku verdict: ${state.contextLost ? 'LOST' : 'OK'} — ${verdict.reason}`);
722
+ hlog(`[CONTEXT-RECOVERY] Haiku verdict: ${state.contextLost ? 'LOST' : 'OK'} — ${verdict.reason}`);
720
723
  }
721
724
  }
722
725
 
@@ -1015,6 +1018,110 @@ export class ImprovisationSessionManager extends EventEmitter {
1015
1018
  return parts.join('\n');
1016
1019
  }
1017
1020
 
1021
+ /**
1022
+ * Detect premature completion: Claude exited normally (exit code 0, end_turn) but the
1023
+ * response indicates more work was planned. This happens when the model "context-fatigues"
1024
+ * during long multi-step tasks and produces end_turn after completing a subset of the work.
1025
+ *
1026
+ * Two paths:
1027
+ * - max_tokens: always retry (model was forcibly stopped mid-generation)
1028
+ * - end_turn: Haiku assessment determines if the response looks incomplete
1029
+ */
1030
+ private async shouldRetryPrematureCompletion(
1031
+ result: HeadlessRunResult,
1032
+ state: RetryLoopState,
1033
+ maxRetries: number,
1034
+ ): Promise<boolean> {
1035
+ if (!this.isPrematureCompletionCandidate(result, state, maxRetries)) {
1036
+ return false;
1037
+ }
1038
+
1039
+ const stopReason = result.stopReason!;
1040
+ const isMaxTokens = stopReason === 'max_tokens';
1041
+ const isIncomplete = isMaxTokens || await this.assessEndTurnCompletion(result);
1042
+
1043
+ if (!isIncomplete) return false;
1044
+
1045
+ this.applyPrematureCompletionRetry(result, state, maxRetries, stopReason, isMaxTokens);
1046
+ return true;
1047
+ }
1048
+
1049
+ /** Guard checks for premature completion — must pass all to proceed with assessment */
1050
+ private isPrematureCompletionCandidate(
1051
+ result: HeadlessRunResult,
1052
+ state: RetryLoopState,
1053
+ maxRetries: number,
1054
+ ): boolean {
1055
+ // Only trigger for clean exits with a known stop reason
1056
+ if (!result.completed || result.signalName || state.retryNumber >= maxRetries) return false;
1057
+ // Don't re-trigger if other recovery paths already handled this iteration
1058
+ if (state.checkpointRef.value || state.contextLost) return false;
1059
+ // Must have a session ID to resume, and a stop reason to classify
1060
+ if (!result.claudeSessionId || !result.stopReason) return false;
1061
+ // Only act on max_tokens or end_turn
1062
+ return result.stopReason === 'max_tokens' || result.stopReason === 'end_turn';
1063
+ }
1064
+
1065
+ /** Use Haiku to assess whether an end_turn response is genuinely complete */
1066
+ private async assessEndTurnCompletion(result: HeadlessRunResult): Promise<boolean> {
1067
+ if (!result.assistantResponse) return false;
1068
+
1069
+ const claudeCmd = process.env.CLAUDE_COMMAND || 'claude';
1070
+ const verdict = await assessPrematureCompletion({
1071
+ responseTail: result.assistantResponse.slice(-800),
1072
+ successfulToolCalls: result.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0,
1073
+ hasThinking: !!result.thinkingOutput,
1074
+ responseLength: result.assistantResponse.length,
1075
+ }, claudeCmd, this.options.verbose);
1076
+
1077
+ if (this.options.verbose) {
1078
+ hlog(`[PREMATURE-COMPLETION] Haiku verdict: ${verdict.isIncomplete ? 'INCOMPLETE' : 'COMPLETE'} — ${verdict.reason}`);
1079
+ }
1080
+ return verdict.isIncomplete;
1081
+ }
1082
+
1083
+ /** Apply the retry: emit events, update state, set continuation prompt */
1084
+ private applyPrematureCompletionRetry(
1085
+ result: HeadlessRunResult,
1086
+ state: RetryLoopState,
1087
+ maxRetries: number,
1088
+ stopReason: string,
1089
+ isMaxTokens: boolean,
1090
+ ): void {
1091
+ state.retryNumber++;
1092
+ const reason = isMaxTokens ? 'max_tokens hit' : 'incomplete end_turn (Haiku assessment)';
1093
+
1094
+ state.retryLog.push({
1095
+ retryNumber: state.retryNumber,
1096
+ path: 'PrematureCompletion',
1097
+ reason,
1098
+ timestamp: Date.now(),
1099
+ });
1100
+
1101
+ this.emit('onAutoRetry', {
1102
+ retryNumber: state.retryNumber,
1103
+ maxRetries,
1104
+ toolName: `PrematureCompletion(${stopReason})`,
1105
+ completedCount: result.toolUseHistory?.length ?? 0,
1106
+ });
1107
+
1108
+ trackEvent(AnalyticsEvents.IMPROVISE_AUTO_RETRY, {
1109
+ retry_number: state.retryNumber,
1110
+ hung_tool: `premature_completion:${stopReason}`,
1111
+ completed_tools: result.toolUseHistory?.length ?? 0,
1112
+ resume_attempted: true,
1113
+ });
1114
+
1115
+ this.queueOutput(
1116
+ `\n[[MSTRO_AUTO_CONTINUE]] Task incomplete (${reason}) — resuming session (retry ${state.retryNumber}/${maxRetries}).\n`
1117
+ );
1118
+ this.flushOutputQueue();
1119
+
1120
+ state.contextRecoverySessionId = result.claudeSessionId;
1121
+ this.claudeSessionId = result.claudeSessionId;
1122
+ state.currentPrompt = 'continue';
1123
+ }
1124
+
1018
1125
  /** Select the best result across retries using Haiku assessment */
1019
1126
  private async selectBestResult(
1020
1127
  state: RetryLoopState,
@@ -1047,10 +1154,10 @@ export class ImprovisationSessionManager extends EventEmitter {
1047
1154
  }, claudeCmd, this.options.verbose);
1048
1155
 
1049
1156
  if (verdict.winner === 'A') {
1050
- if (this.options.verbose) console.log(`[BEST-RESULT] Haiku picked earlier attempt: ${verdict.reason}`);
1157
+ if (this.options.verbose) hlog(`[BEST-RESULT] Haiku picked earlier attempt: ${verdict.reason}`);
1051
1158
  return this.mergeResultSessionId(state.bestResult, result.claudeSessionId);
1052
1159
  }
1053
- if (this.options.verbose) console.log(`[BEST-RESULT] Haiku picked final attempt: ${verdict.reason}`);
1160
+ if (this.options.verbose) hlog(`[BEST-RESULT] Haiku picked final attempt: ${verdict.reason}`);
1054
1161
  return result;
1055
1162
  } catch {
1056
1163
  return this.fallbackBestResult(state.bestResult, result);
@@ -1061,7 +1168,7 @@ export class ImprovisationSessionManager extends EventEmitter {
1061
1168
  private fallbackBestResult(bestResult: HeadlessRunResult, result: HeadlessRunResult): HeadlessRunResult {
1062
1169
  if (scoreRunResult(bestResult) > scoreRunResult(result)) {
1063
1170
  if (this.options.verbose) {
1064
- console.log(`[BEST-RESULT] Haiku unavailable, numeric fallback: earlier attempt (score ${scoreRunResult(bestResult)} vs ${scoreRunResult(result)})`);
1171
+ hlog(`[BEST-RESULT] Haiku unavailable, numeric fallback: earlier attempt (score ${scoreRunResult(bestResult)} vs ${scoreRunResult(result)})`);
1065
1172
  }
1066
1173
  return this.mergeResultSessionId(bestResult, result.claudeSessionId);
1067
1174
  }
@@ -1497,7 +1604,7 @@ export class ImprovisationSessionManager extends EventEmitter {
1497
1604
  const data = readFileSync(this.historyPath, 'utf-8');
1498
1605
  return JSON.parse(data);
1499
1606
  } catch (error) {
1500
- console.error('Failed to load history:', error);
1607
+ herror('Failed to load history:', error);
1501
1608
  }
1502
1609
  }
1503
1610
 
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
3
+ // Licensed under the MIT License. See LICENSE file for details.
4
+
5
+ /**
6
+ * Bouncer CLI — stdin/stdout wrapper for Claude Code PreToolUse hooks.
7
+ *
8
+ * Reads a tool use request from stdin (JSON), runs it through the full
9
+ * 2-layer bouncer (pattern matching + Haiku AI), and writes the decision
10
+ * to stdout in the format Claude Code hooks expect.
11
+ *
12
+ * Input format (from Claude Code hook):
13
+ * { "tool_name": "Bash", "input": { "command": "rm -rf /" } }
14
+ *
15
+ * Output format (to Claude Code hook):
16
+ * { "decision": "allow"|"deny", "reason": "..." }
17
+ */
18
+
19
+ import type { BouncerReviewRequest } from './bouncer-integration.js';
20
+ import { reviewOperation } from './bouncer-integration.js';
21
+
22
+ function buildOperation(toolName: string, toolInput: Record<string, unknown>): string {
23
+ const prefix = `${toolName}: `;
24
+ if (toolName === 'Bash' && toolInput.command) return prefix + String(toolInput.command);
25
+ if (toolName === 'Edit' && toolInput.file_path) return prefix + String(toolInput.file_path);
26
+ if (toolName === 'Write' && toolInput.file_path) return prefix + String(toolInput.file_path);
27
+ return prefix + JSON.stringify(toolInput).slice(0, 500);
28
+ }
29
+
30
+ async function evaluate(rawInput: string): Promise<{ decision: string; reason: string }> {
31
+ if (!rawInput.trim()) {
32
+ return { decision: 'allow', reason: 'Empty input' };
33
+ }
34
+
35
+ let parsed: { tool_name?: string; toolName?: string; input?: Record<string, unknown>; toolInput?: Record<string, unknown> };
36
+ try {
37
+ parsed = JSON.parse(rawInput);
38
+ } catch {
39
+ return { decision: 'allow', reason: 'Invalid JSON input' };
40
+ }
41
+
42
+ const toolName = parsed.tool_name || parsed.toolName || 'unknown';
43
+ const toolInput = parsed.input || parsed.toolInput || {};
44
+
45
+ const request: BouncerReviewRequest = {
46
+ operation: buildOperation(toolName, toolInput),
47
+ context: {
48
+ purpose: 'Tool use request from Claude Code hook',
49
+ workingDirectory: process.cwd(),
50
+ toolName,
51
+ toolInput,
52
+ },
53
+ };
54
+
55
+ const result = await reviewOperation(request);
56
+ return {
57
+ decision: result.decision === 'deny' ? 'deny' : 'allow',
58
+ reason: result.reasoning,
59
+ };
60
+ }
61
+
62
+ async function main(): Promise<void> {
63
+ let rawInput = '';
64
+ for await (const chunk of process.stdin) {
65
+ rawInput += chunk;
66
+ }
67
+ const result = await evaluate(rawInput);
68
+ console.log(JSON.stringify(result));
69
+ }
70
+
71
+ main().catch(() => {
72
+ console.log(JSON.stringify({ decision: 'allow', reason: 'Bouncer crash' }));
73
+ });
@@ -0,0 +1,199 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Plan Composer — Handles natural language prompts for PPS creation/editing.
6
+ *
7
+ * When a planPrompt message arrives, this builds a context-enriched prompt
8
+ * against the .pm/ (or legacy .plan/) directory and spawns a scoped
9
+ * HeadlessRunner session to execute it.
10
+ */
11
+
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { runWithFileLogger } from '../../cli/headless/headless-logger.js';
15
+ import { HeadlessRunner, type ToolUseEvent } from '../../cli/headless/index.js';
16
+ import type { HandlerContext } from '../websocket/handler-context.js';
17
+ import type { WSContext } from '../websocket/types.js';
18
+ import { getNextId, parsePlanDirectory, resolvePmDir } from './parser.js';
19
+
20
+ const PROMPT_TOOL_MESSAGES: Record<string, string> = {
21
+ Glob: 'Discovering project files...',
22
+ Read: 'Reading project structure...',
23
+ Grep: 'Searching codebase...',
24
+ Write: 'Creating project files...',
25
+ Edit: 'Updating project files...',
26
+ Bash: 'Running commands...',
27
+ };
28
+
29
+ function getPromptToolCompleteMessage(event: ToolUseEvent): string | null {
30
+ const input = event.completeInput;
31
+ if (!input) return null;
32
+ if (event.toolName === 'Write' && input.file_path) {
33
+ const filename = String(input.file_path).split('/').pop() ?? '';
34
+ return `Created ${filename}`;
35
+ }
36
+ if (event.toolName === 'Edit' && input.file_path) {
37
+ const filename = String(input.file_path).split('/').pop() ?? '';
38
+ return `Updated ${filename}`;
39
+ }
40
+ if (event.toolName === 'Read' && input.file_path) {
41
+ return `Read ${String(input.file_path).split('/').slice(-2).join('/')}`;
42
+ }
43
+ return null;
44
+ }
45
+
46
+ function createPromptProgressTracker() {
47
+ const seenToolStarts = new Set<string>();
48
+
49
+ return (event: ToolUseEvent): string | null => {
50
+ if (event.type === 'tool_start' && event.toolName) {
51
+ if (seenToolStarts.has(event.toolName)) return null;
52
+ seenToolStarts.add(event.toolName);
53
+ return PROMPT_TOOL_MESSAGES[event.toolName] ?? null;
54
+ }
55
+ if (event.type === 'tool_complete') return getPromptToolCompleteMessage(event);
56
+ return null;
57
+ };
58
+ }
59
+
60
+ function readFileOrEmpty(path: string): string {
61
+ try {
62
+ if (existsSync(path)) return readFileSync(path, 'utf-8');
63
+ } catch { /* skip */ }
64
+ return '';
65
+ }
66
+
67
+ export async function handlePlanPrompt(
68
+ ctx: HandlerContext,
69
+ ws: WSContext,
70
+ userPrompt: string,
71
+ workingDir: string,
72
+ ): Promise<void> {
73
+ const pmDir = resolvePmDir(workingDir) ?? join(workingDir, '.pm');
74
+ const stateContent = readFileOrEmpty(join(pmDir, 'STATE.md'));
75
+ const projectContent = readFileOrEmpty(join(pmDir, 'project.md'));
76
+
77
+ // Compute next available IDs
78
+ const fullState = parsePlanDirectory(workingDir);
79
+ let idInfo = '';
80
+ if (fullState) {
81
+ const nextIS = getNextId(fullState.issues, 'IS');
82
+ const nextBG = getNextId(fullState.issues, 'BG');
83
+ const nextEP = getNextId(fullState.issues, 'EP');
84
+ idInfo = `Next available IDs: ${nextIS}, ${nextBG}, ${nextEP}`;
85
+ }
86
+
87
+ // Read existing epic files to provide context
88
+ let epicContext = '';
89
+ if (fullState) {
90
+ const existingEpics = fullState.issues.filter((i: { type: string }) => i.type === 'epic');
91
+ if (existingEpics.length > 0) {
92
+ epicContext = `\nExisting epics:\n${existingEpics.map((e: { id: string; title: string; path: string; children: string[] }) => `- ${e.id}: ${e.title} (${e.path}, children: ${e.children.length})`).join('\n')}\n`;
93
+ }
94
+ }
95
+
96
+ const enrichedPrompt = `You are managing a project in the .pm/ directory format (Project Plan Spec).
97
+ The project's current state is:
98
+
99
+ <state>
100
+ ${stateContent || 'No STATE.md exists yet'}
101
+ </state>
102
+
103
+ <project>
104
+ ${projectContent || 'No project.md yet'}
105
+ </project>
106
+
107
+ ${idInfo}
108
+ ${epicContext}
109
+
110
+ Follow these rules:
111
+ - When creating .pm/ files, use YAML front matter + markdown body
112
+ - When modifying issues, preserve all existing YAML fields you don't change
113
+ - After any state change, update STATE.md to reflect the new status
114
+ - Use the next available ID for new entities
115
+ - Respond briefly describing what you did
116
+
117
+ Issue scoping rules (critical for execution quality):
118
+ - Each issue is executed by a single AI agent with its own context window
119
+ - Issues estimated at 1-3 story points execute well (focused, single concern)
120
+ - Issues at 5 story points are viable if scoped to one subsystem
121
+ - Issues at 8+ story points MUST be decomposed into smaller sub-issues
122
+ - Issues at 13+ story points MUST become an epic with child issues
123
+ - Each issue should touch one logical concern (one component, one service, one data flow)
124
+ - If an issue requires work across multiple subsystems, split it into one issue per subsystem with blocked_by edges between them
125
+ - Research/investigation issues should be separate from implementation issues
126
+
127
+ Epic creation rules (when user asks for a feature with sub-tasks or an epic):
128
+ - Create an EP-*.md file in .pm/backlog/ with type: epic and a children: [] field in front matter
129
+ - Create individual IS-*.md (or BG-*.md) files for each child issue
130
+ - Each child issue must have epic: backlog/EP-XXX.md in its front matter
131
+ - The epic's children field must list all child paths: [backlog/IS-001.md, backlog/IS-002.md, ...]
132
+ - Set blocked_by between child issues where there are natural dependencies
133
+ - Give each child issue clear acceptance criteria and files to modify when possible
134
+ - Set appropriate priorities (P0-P3) based on the issue's importance within the epic
135
+
136
+ User request: ${userPrompt}`;
137
+
138
+ try {
139
+ ctx.broadcastToAll({
140
+ type: 'planPromptProgress',
141
+ data: { message: 'Starting project planning...' },
142
+ });
143
+
144
+ const runner = new HeadlessRunner({
145
+ workingDir,
146
+ directPrompt: enrichedPrompt,
147
+ outputCallback: (text: string) => {
148
+ ctx.send(ws, {
149
+ type: 'planPromptStreaming',
150
+ data: { token: text },
151
+ });
152
+ },
153
+ toolUseCallback: (() => {
154
+ const getProgressMessage = createPromptProgressTracker();
155
+ return (event: ToolUseEvent) => {
156
+ const message = getProgressMessage(event);
157
+ if (message) {
158
+ ctx.broadcastToAll({
159
+ type: 'planPromptProgress',
160
+ data: { message },
161
+ });
162
+ }
163
+ };
164
+ })(),
165
+ });
166
+
167
+ ctx.broadcastToAll({
168
+ type: 'planPromptProgress',
169
+ data: { message: 'Claude is planning your project...' },
170
+ });
171
+
172
+ const result = await runWithFileLogger('pm-compose', () => runner.run());
173
+
174
+ ctx.broadcastToAll({
175
+ type: 'planPromptProgress',
176
+ data: { message: 'Finalizing project plan...' },
177
+ });
178
+
179
+ ctx.send(ws, {
180
+ type: 'planPromptResponse',
181
+ data: {
182
+ response: result.completed ? 'Prompt executed successfully.' : (result.error || 'Unknown error'),
183
+ success: result.completed,
184
+ error: result.error || null,
185
+ },
186
+ });
187
+
188
+ // Re-parse and broadcast updated state
189
+ const updatedState = parsePlanDirectory(workingDir);
190
+ if (updatedState) {
191
+ ctx.broadcastToAll({ type: 'planStateUpdated', data: updatedState });
192
+ }
193
+ } catch (error) {
194
+ ctx.send(ws, {
195
+ type: 'planError',
196
+ data: { error: error instanceof Error ? error.message : String(error) },
197
+ });
198
+ }
199
+ }
@@ -0,0 +1,179 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Dependency Resolver — Validates and computes the dependency DAG.
6
+ *
7
+ * Builds adjacency list from blocked_by/blocks fields, detects cycles,
8
+ * and computes the "ready to work" set.
9
+ */
10
+
11
+ import type { Issue } from './types.js';
12
+
13
+ /**
14
+ * Detect cycles in the dependency graph.
15
+ * Returns the first cycle found as an array of issue IDs, or null if acyclic.
16
+ */
17
+ export function detectCycles(issues: Issue[]): string[] | null {
18
+ const issueByPath = new Map<string, Issue>();
19
+ for (const issue of issues) {
20
+ issueByPath.set(issue.path, issue);
21
+ }
22
+
23
+ // DFS with coloring: 0=white, 1=gray, 2=black
24
+ const color = new Map<string, number>();
25
+ const parent = new Map<string, string>();
26
+
27
+ for (const issue of issues) {
28
+ color.set(issue.path, 0);
29
+ }
30
+
31
+ for (const issue of issues) {
32
+ if (color.get(issue.path) === 0) {
33
+ const cycle = dfs(issue.path, issueByPath, color, parent);
34
+ if (cycle) return cycle;
35
+ }
36
+ }
37
+
38
+ return null;
39
+ }
40
+
41
+ function dfs(
42
+ path: string,
43
+ issueByPath: Map<string, Issue>,
44
+ color: Map<string, number>,
45
+ parent: Map<string, string>,
46
+ ): string[] | null {
47
+ color.set(path, 1); // Gray
48
+ const issue = issueByPath.get(path);
49
+ if (!issue) {
50
+ color.set(path, 2);
51
+ return null;
52
+ }
53
+
54
+ for (const dep of issue.blocks) {
55
+ if (!issueByPath.has(dep)) continue;
56
+ const depColor = color.get(dep);
57
+ if (depColor === 1) {
58
+ // Found cycle — reconstruct
59
+ const cycle = [dep, path];
60
+ let cur = path;
61
+ while (parent.has(cur) && parent.get(cur) !== dep) {
62
+ cur = parent.get(cur)!;
63
+ cycle.push(cur);
64
+ }
65
+ return cycle.map(p => issueByPath.get(p)?.id || p);
66
+ }
67
+ if (depColor === 0) {
68
+ parent.set(dep, path);
69
+ const cycle = dfs(dep, issueByPath, color, parent);
70
+ if (cycle) return cycle;
71
+ }
72
+ }
73
+
74
+ color.set(path, 2); // Black
75
+ return null;
76
+ }
77
+
78
+ /**
79
+ * Compute the set of issues that are ready to work on.
80
+ * An issue is ready if:
81
+ * - It's not an epic
82
+ * - Its status is backlog or todo (not started, done, or cancelled)
83
+ * - All its blocked_by items are done or cancelled
84
+ *
85
+ * If epicScope is provided, only returns issues belonging to that epic.
86
+ */
87
+ export function resolveReadyToWork(issues: Issue[], epicScope?: string): Issue[] {
88
+ const issueByPath = new Map<string, Issue>();
89
+ for (const issue of issues) {
90
+ issueByPath.set(issue.path, issue);
91
+ }
92
+
93
+ const readyStatuses = new Set(['backlog', 'todo']);
94
+ const doneStatuses = new Set(['done', 'cancelled']);
95
+
96
+ const priorityOrder: Record<string, number> = { P0: 0, P1: 1, P2: 2, P3: 3 };
97
+
98
+ // Build set of child paths for epic scoping
99
+ let epicChildPaths: Set<string> | null = null;
100
+ if (epicScope) {
101
+ const epic = issueByPath.get(epicScope);
102
+ if (epic) {
103
+ epicChildPaths = new Set(epic.children);
104
+ // Also include issues that reference this epic via their epic field
105
+ for (const issue of issues) {
106
+ if (issue.epic === epicScope) epicChildPaths.add(issue.path);
107
+ }
108
+ }
109
+ }
110
+
111
+ return issues
112
+ .filter(issue => {
113
+ if (issue.type === 'epic') return false;
114
+ if (!readyStatuses.has(issue.status)) return false;
115
+
116
+ // If scoped to an epic, only include that epic's children
117
+ if (epicChildPaths && !epicChildPaths.has(issue.path)) return false;
118
+
119
+ // Check all blockers are resolved
120
+ if (issue.blockedBy.length > 0) {
121
+ const allResolved = issue.blockedBy.every(bp => {
122
+ const blocker = issueByPath.get(bp);
123
+ return blocker && doneStatuses.has(blocker.status);
124
+ });
125
+ if (!allResolved) return false;
126
+ }
127
+
128
+ return true;
129
+ })
130
+ .sort((a, b) => {
131
+ // Sort by priority (P0 first)
132
+ return (priorityOrder[a.priority] ?? 9) - (priorityOrder[b.priority] ?? 9);
133
+ });
134
+ }
135
+
136
+ /**
137
+ * Compute the critical path through incomplete issues.
138
+ * Returns the longest chain of dependent issues.
139
+ */
140
+ export function computeCriticalPath(issues: Issue[]): Issue[] {
141
+ const issueByPath = new Map<string, Issue>();
142
+ for (const issue of issues) {
143
+ issueByPath.set(issue.path, issue);
144
+ }
145
+
146
+ const doneStatuses = new Set(['done', 'cancelled']);
147
+ const incompleteIssues = issues.filter(i => !doneStatuses.has(i.status) && i.type !== 'epic');
148
+
149
+ // Compute longest path using DFS with memoization
150
+ const longestFrom = new Map<string, Issue[]>();
151
+
152
+ function getLongest(path: string): Issue[] {
153
+ if (longestFrom.has(path)) return longestFrom.get(path)!;
154
+
155
+ const issue = issueByPath.get(path);
156
+ if (!issue || doneStatuses.has(issue.status)) {
157
+ longestFrom.set(path, []);
158
+ return [];
159
+ }
160
+
161
+ let best: Issue[] = [];
162
+ for (const dep of issue.blocks) {
163
+ const sub = getLongest(dep);
164
+ if (sub.length > best.length) best = sub;
165
+ }
166
+
167
+ const result = [issue, ...best];
168
+ longestFrom.set(path, result);
169
+ return result;
170
+ }
171
+
172
+ let criticalPath: Issue[] = [];
173
+ for (const issue of incompleteIssues) {
174
+ const path = getLongest(issue.path);
175
+ if (path.length > criticalPath.length) criticalPath = path;
176
+ }
177
+
178
+ return criticalPath;
179
+ }