tycono 0.3.45-beta.2 → 0.3.45-beta.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 (113) hide show
  1. package/README.md +191 -162
  2. package/bin/tycono.ts +42 -10
  3. package/package.json +21 -15
  4. package/packages/server/bin/cli.js +35 -0
  5. package/packages/server/bin/server.ts +183 -0
  6. package/{src → packages/server/src}/api/src/create-server.ts +11 -3
  7. package/{src → packages/server/src}/api/src/engine/agent-loop.ts +30 -7
  8. package/{src → packages/server/src}/api/src/engine/context-assembler.ts +122 -57
  9. package/{src → packages/server/src}/api/src/engine/llm-adapter.ts +10 -7
  10. package/{src → packages/server/src}/api/src/engine/org-tree.ts +43 -3
  11. package/{src → packages/server/src}/api/src/engine/runners/claude-cli.ts +37 -15
  12. package/{src → packages/server/src}/api/src/engine/runners/types.ts +6 -0
  13. package/{src → packages/server/src}/api/src/engine/tools/executor.ts +65 -9
  14. package/{src → packages/server/src}/api/src/routes/execute.ts +221 -17
  15. package/packages/server/src/api/src/services/claude-md-manager.ts +190 -0
  16. package/{src → packages/server/src}/api/src/services/company-config.ts +1 -0
  17. package/{src → packages/server/src}/api/src/services/digest-engine.ts +4 -1
  18. package/packages/server/src/api/src/services/dispatch-classifier.ts +179 -0
  19. package/{src → packages/server/src}/api/src/services/execution-manager.ts +227 -21
  20. package/{src → packages/server/src}/api/src/services/file-reader.ts +4 -1
  21. package/packages/server/src/api/src/services/preset-loader.ts +310 -0
  22. package/{src → packages/server/src}/api/src/services/supervisor-heartbeat.ts +89 -9
  23. package/{src → packages/server/src}/api/src/services/wave-multiplexer.ts +18 -8
  24. package/{src → packages/server/src}/api/src/services/wave-tracker.ts +25 -0
  25. package/packages/server/src/core/scaffolder.ts +620 -0
  26. package/{src → packages/server/src}/shared/types.ts +3 -1
  27. package/packages/server/templates/CLAUDE.md.tmpl +152 -0
  28. package/packages/server/templates/agentic-knowledge-base.md +355 -0
  29. package/src/api/src/services/claude-md-manager.ts +0 -94
  30. package/src/api/src/services/preset-loader.ts +0 -149
  31. package/templates/CLAUDE.md.tmpl +0 -239
  32. /package/{src/web → packages/pixel}/dist/assets/index-BJyiMGkM.js +0 -0
  33. /package/{src/web → packages/pixel}/dist/assets/index-BOuHc64o.css +0 -0
  34. /package/{src/web → packages/pixel}/dist/assets/index-DDPzbp9E.js +0 -0
  35. /package/{src/web → packages/pixel}/dist/assets/index-DVKWFwwK.css +0 -0
  36. /package/{src/web → packages/pixel}/dist/assets/preview-app-DZ6WxhDc.js +0 -0
  37. /package/{src/web → packages/pixel}/dist/index.html +0 -0
  38. /package/{src/web → packages/pixel}/dist/tyconoforge.js +0 -0
  39. /package/{src → packages/server/src}/api/package.json +0 -0
  40. /package/{src → packages/server/src}/api/src/create-app.ts +0 -0
  41. /package/{src → packages/server/src}/api/src/engine/authority-validator.ts +0 -0
  42. /package/{src → packages/server/src}/api/src/engine/index.ts +0 -0
  43. /package/{src → packages/server/src}/api/src/engine/knowledge-gate.ts +0 -0
  44. /package/{src → packages/server/src}/api/src/engine/role-lifecycle.ts +0 -0
  45. /package/{src → packages/server/src}/api/src/engine/runners/direct-api.ts +0 -0
  46. /package/{src → packages/server/src}/api/src/engine/runners/index.ts +0 -0
  47. /package/{src → packages/server/src}/api/src/engine/skill-template.ts +0 -0
  48. /package/{src → packages/server/src}/api/src/engine/tools/definitions.ts +0 -0
  49. /package/{src → packages/server/src}/api/src/routes/active-sessions.ts +0 -0
  50. /package/{src → packages/server/src}/api/src/routes/coins.ts +0 -0
  51. /package/{src → packages/server/src}/api/src/routes/company.ts +0 -0
  52. /package/{src → packages/server/src}/api/src/routes/cost.ts +0 -0
  53. /package/{src → packages/server/src}/api/src/routes/engine.ts +0 -0
  54. /package/{src → packages/server/src}/api/src/routes/git.ts +0 -0
  55. /package/{src → packages/server/src}/api/src/routes/knowledge.ts +0 -0
  56. /package/{src → packages/server/src}/api/src/routes/operations.ts +0 -0
  57. /package/{src → packages/server/src}/api/src/routes/preferences.ts +0 -0
  58. /package/{src → packages/server/src}/api/src/routes/presets.ts +0 -0
  59. /package/{src → packages/server/src}/api/src/routes/projects.ts +0 -0
  60. /package/{src → packages/server/src}/api/src/routes/quests.ts +0 -0
  61. /package/{src → packages/server/src}/api/src/routes/roles.ts +0 -0
  62. /package/{src → packages/server/src}/api/src/routes/save.ts +0 -0
  63. /package/{src → packages/server/src}/api/src/routes/sessions.ts +0 -0
  64. /package/{src → packages/server/src}/api/src/routes/setup.ts +0 -0
  65. /package/{src → packages/server/src}/api/src/routes/skills.ts +0 -0
  66. /package/{src → packages/server/src}/api/src/routes/speech.ts +0 -0
  67. /package/{src → packages/server/src}/api/src/routes/supervision.ts +0 -0
  68. /package/{src → packages/server/src}/api/src/routes/sync.ts +0 -0
  69. /package/{src → packages/server/src}/api/src/server.ts +0 -0
  70. /package/{src → packages/server/src}/api/src/services/activity-stream.ts +0 -0
  71. /package/{src → packages/server/src}/api/src/services/activity-tracker.ts +0 -0
  72. /package/{src → packages/server/src}/api/src/services/database.ts +0 -0
  73. /package/{src → packages/server/src}/api/src/services/git-save.ts +0 -0
  74. /package/{src → packages/server/src}/api/src/services/job-manager.ts +0 -0
  75. /package/{src → packages/server/src}/api/src/services/knowledge-importer.ts +0 -0
  76. /package/{src → packages/server/src}/api/src/services/markdown-parser.ts +0 -0
  77. /package/{src → packages/server/src}/api/src/services/port-registry.ts +0 -0
  78. /package/{src → packages/server/src}/api/src/services/preferences.ts +0 -0
  79. /package/{src → packages/server/src}/api/src/services/pricing.ts +0 -0
  80. /package/{src → packages/server/src}/api/src/services/scaffold.ts +0 -0
  81. /package/{src → packages/server/src}/api/src/services/session-store.ts +0 -0
  82. /package/{src → packages/server/src}/api/src/services/team-recommender.ts +0 -0
  83. /package/{src → packages/server/src}/api/src/services/token-ledger.ts +0 -0
  84. /package/{src → packages/server/src}/api/src/services/wave-messages.ts +0 -0
  85. /package/{src → packages/server/src}/api/src/utils/role-level.ts +0 -0
  86. /package/{templates → packages/server/templates}/company.md.tmpl +0 -0
  87. /package/{templates → packages/server/templates}/gitignore.tmpl +0 -0
  88. /package/{templates → packages/server/templates}/roles.md.tmpl +0 -0
  89. /package/{templates → packages/server/templates}/skills/_manifest.json +0 -0
  90. /package/{templates → packages/server/templates}/skills/agent-browser/SKILL.md +0 -0
  91. /package/{templates → packages/server/templates}/skills/agent-browser/meta.json +0 -0
  92. /package/{templates → packages/server/templates}/skills/akb-linter/SKILL.md +0 -0
  93. /package/{templates → packages/server/templates}/skills/akb-linter/meta.json +0 -0
  94. /package/{templates → packages/server/templates}/skills/knowledge-gate/SKILL.md +0 -0
  95. /package/{templates → packages/server/templates}/skills/knowledge-gate/meta.json +0 -0
  96. /package/{templates → packages/server/templates}/teams/agency.json +0 -0
  97. /package/{templates → packages/server/templates}/teams/research.json +0 -0
  98. /package/{templates → packages/server/templates}/teams/startup.json +0 -0
  99. /package/{src/tui → packages/tui/src}/api.ts +0 -0
  100. /package/{src/tui → packages/tui/src}/app.tsx +0 -0
  101. /package/{src/tui → packages/tui/src}/components/CommandMode.tsx +0 -0
  102. /package/{src/tui → packages/tui/src}/components/OrgTree.tsx +0 -0
  103. /package/{src/tui → packages/tui/src}/components/PanelMode.tsx +0 -0
  104. /package/{src/tui → packages/tui/src}/components/SetupWizard.tsx +0 -0
  105. /package/{src/tui → packages/tui/src}/components/StatusBar.tsx +0 -0
  106. /package/{src/tui → packages/tui/src}/components/StreamView.tsx +0 -0
  107. /package/{src/tui → packages/tui/src}/hooks/useApi.ts +0 -0
  108. /package/{src/tui → packages/tui/src}/hooks/useCommand.ts +0 -0
  109. /package/{src/tui → packages/tui/src}/hooks/useSSE.ts +0 -0
  110. /package/{src/tui → packages/tui/src}/index.tsx +0 -0
  111. /package/{src/tui → packages/tui/src}/store.ts +0 -0
  112. /package/{src/tui → packages/tui/src}/theme.ts +0 -0
  113. /package/{src/tui → packages/tui/src}/utils/markdown.tsx +0 -0
@@ -0,0 +1,179 @@
1
+ /**
2
+ * dispatch-classifier.ts — Role당 1세션 Invariant
3
+ *
4
+ * Dispatch 시 같은 wave 내 같은 role의 기존 세션을 찾아:
5
+ * - active → amend (기존 세션에 추가 지시)
6
+ * - done → amend (이어서 작업)
7
+ * - error N회 → new (fresh start)
8
+ * - 없음 → new (첫 생성)
9
+ *
10
+ * Haiku classifier 제거 — deterministic 판단.
11
+ * BUG-FORKBOMB: CEO 무한 dispatch 루프 구조적 차단.
12
+ */
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { COMPANY_ROOT } from './file-reader.js';
16
+ import { listSessions } from './session-store.js';
17
+ import { executionManager } from './execution-manager.js';
18
+
19
+ /* ─── Types ──────────────────────────── */
20
+
21
+ export interface DispatchDecision {
22
+ ts: string;
23
+ waveId: string;
24
+ roleId: string;
25
+ sourceRole: string;
26
+ newTask: string;
27
+ prevSessionId: string;
28
+ decision: 'amend' | 'new';
29
+ reason: string;
30
+ }
31
+
32
+ /* ─── Constants ──────────────────────── */
33
+
34
+ const ERROR_THRESHOLD = 3; // error 3회 초과 시 fresh session 허용
35
+
36
+ /* ─── Session Finder ─────────────────── */
37
+
38
+ export interface PrevSessionInfo {
39
+ sessionId: string;
40
+ task: string;
41
+ status: string;
42
+ cliSessionId?: string;
43
+ }
44
+
45
+ /**
46
+ * 같은 wave 내에서 같은 role의 active 세션을 찾는다.
47
+ */
48
+ export function findActiveSession(waveId: string, roleId: string): PrevSessionInfo | null {
49
+ const session = listSessions().find(
50
+ s => s.waveId === waveId && s.roleId === roleId && (s.status === 'active' || s.status === 'awaiting_input'),
51
+ );
52
+ if (!session) return null;
53
+
54
+ const exec = executionManager.getActiveExecution(session.id);
55
+ return {
56
+ sessionId: session.id,
57
+ task: exec?.task ?? '',
58
+ status: session.status,
59
+ cliSessionId: exec?.cliSessionId,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * 같은 wave 내에서 같은 role의 가장 최근 done 세션을 찾는다.
65
+ */
66
+ export function findPrevDoneSession(waveId: string, roleId: string): PrevSessionInfo | null {
67
+ const sessions = listSessions().filter(
68
+ s => s.waveId === waveId && s.roleId === roleId && (s.status === 'done' || s.status === 'closed'),
69
+ );
70
+
71
+ if (sessions.length === 0) return null;
72
+
73
+ const latest = sessions[sessions.length - 1];
74
+ const exec = executionManager.getCompletedExecution(latest.id);
75
+
76
+ return {
77
+ sessionId: latest.id,
78
+ task: exec?.task ?? '',
79
+ status: latest.status,
80
+ cliSessionId: exec?.cliSessionId,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * 같은 wave 내에서 같은 role의 error 세션 수를 센다.
86
+ */
87
+ function countErrorSessions(waveId: string, roleId: string): number {
88
+ return listSessions().filter(
89
+ s => s.waveId === waveId && s.roleId === roleId && s.status === 'error',
90
+ ).length;
91
+ }
92
+
93
+ /* ─── Decision Logger ─────────────────── */
94
+
95
+ function logDecision(decision: DispatchDecision): void {
96
+ const logDir = path.join(COMPANY_ROOT, '.tycono');
97
+ const logPath = path.join(logDir, 'dispatch-decisions.jsonl');
98
+ try {
99
+ fs.mkdirSync(logDir, { recursive: true });
100
+ fs.appendFileSync(logPath, JSON.stringify(decision) + '\n');
101
+ } catch {
102
+ // Non-critical — don't crash on log failure
103
+ }
104
+ }
105
+
106
+ /* ─── Main Decision Function ──────────── */
107
+
108
+ export interface AutoAmendResult {
109
+ action: 'amend' | 'new';
110
+ prevSessionId?: string;
111
+ reason: string;
112
+ }
113
+
114
+ /**
115
+ * dispatch 요청 시 기존 세션 reuse 여부를 deterministic하게 판단.
116
+ *
117
+ * Role당 1세션 Invariant:
118
+ * 1. active 세션 있음 → amend (대기 후 추가 지시)
119
+ * 2. done 세션 있음 → amend (이어서)
120
+ * 3. error N회 초과 → new (fresh start)
121
+ * 4. 세션 없음 → new (첫 생성)
122
+ */
123
+ export async function decideDispatchOrAmend(
124
+ waveId: string | undefined,
125
+ roleId: string,
126
+ sourceRole: string,
127
+ newTask: string,
128
+ ): Promise<AutoAmendResult> {
129
+ // No wave context → always new dispatch
130
+ if (!waveId) {
131
+ return { action: 'new', reason: 'no-wave-context' };
132
+ }
133
+
134
+ // 1. Active session → amend (BUG-FORKBOMB fix: 기존에는 'new' 반환하여 fork bomb 유발)
135
+ const active = findActiveSession(waveId, roleId);
136
+ if (active) {
137
+ const decision: DispatchDecision = {
138
+ ts: new Date().toISOString(),
139
+ waveId, roleId, sourceRole, newTask: newTask.slice(0, 200),
140
+ prevSessionId: active.sessionId,
141
+ decision: 'amend', reason: 'role-already-active',
142
+ };
143
+ logDecision(decision);
144
+ console.log(`[Dispatch] ${roleId}: AMEND (active session ${active.sessionId})`);
145
+ return { action: 'amend', prevSessionId: active.sessionId, reason: 'role-already-active' };
146
+ }
147
+
148
+ // 2. Done session → amend (이어서 작업)
149
+ const prev = findPrevDoneSession(waveId, roleId);
150
+ if (prev) {
151
+ // 2a. Error threshold 체크: error가 많으면 fresh start
152
+ const errorCount = countErrorSessions(waveId, roleId);
153
+ if (errorCount >= ERROR_THRESHOLD) {
154
+ const decision: DispatchDecision = {
155
+ ts: new Date().toISOString(),
156
+ waveId, roleId, sourceRole, newTask: newTask.slice(0, 200),
157
+ prevSessionId: prev.sessionId,
158
+ decision: 'new', reason: `error-threshold-${errorCount}`,
159
+ };
160
+ logDecision(decision);
161
+ console.log(`[Dispatch] ${roleId}: NEW (${errorCount} errors >= ${ERROR_THRESHOLD} threshold)`);
162
+ return { action: 'new', reason: `error-threshold-${errorCount}` };
163
+ }
164
+
165
+ const decision: DispatchDecision = {
166
+ ts: new Date().toISOString(),
167
+ waveId, roleId, sourceRole, newTask: newTask.slice(0, 200),
168
+ prevSessionId: prev.sessionId,
169
+ decision: 'amend', reason: 'prev-session-done',
170
+ };
171
+ logDecision(decision);
172
+ console.log(`[Dispatch] ${roleId}: AMEND (done session ${prev.sessionId})`);
173
+ return { action: 'amend', prevSessionId: prev.sessionId, reason: 'prev-session-done' };
174
+ }
175
+
176
+ // 3. No session → new dispatch
177
+ console.log(`[Dispatch] ${roleId}: NEW (first dispatch in wave)`);
178
+ return { action: 'new', reason: 'no-prev-session' };
179
+ }
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { execSync } from 'node:child_process';
3
4
  import { COMPANY_ROOT } from './file-reader.js';
4
5
  import { ActivityStream, type ActivityEvent } from './activity-stream.js';
5
6
  import { buildOrgTree } from '../engine/org-tree.js';
@@ -42,6 +43,8 @@ export interface Execution {
42
43
  knowledgeDebt?: KnowledgeDebtItem[];
43
44
  ports?: PortAllocation;
44
45
  traceId?: string;
46
+ /** CLI session ID for --resume (captured from Claude CLI result event) */
47
+ cliSessionId?: string;
45
48
  }
46
49
 
47
50
  export interface StartExecutionParams {
@@ -56,6 +59,8 @@ export interface StartExecutionParams {
56
59
  targetRoles?: string[];
57
60
  sessionId: string;
58
61
  attachments?: ImageAttachment[];
62
+ /** CLI session ID for --resume (context continuity across turn limits) */
63
+ cliSessionId?: string;
59
64
  }
60
65
 
61
66
  /* ─── Helpers ────────────────────────────── */
@@ -77,6 +82,29 @@ function hasQuestion(output: string): boolean {
77
82
  return /\?\s*$/.test(lastBlock) || /할까요|해볼까요|어떨까요|확인.*필요/.test(lastBlock);
78
83
  }
79
84
 
85
+ /* ─── [APPROVAL_NEEDED] Detection ─── */
86
+
87
+ const APPROVAL_TAGS = /\[APPROVAL_NEEDED\]|\[CEO_DECISION\]|\[DECISION_REQUIRED\]/;
88
+
89
+ function extractApprovalQuestion(output: string): string | null {
90
+ const idx = output.search(APPROVAL_TAGS);
91
+ if (idx === -1) return null;
92
+ // Extract text after the tag until end or next section marker
93
+ const afterTag = output.slice(idx);
94
+ const lines = afterTag.split('\n');
95
+ // Take up to 10 lines after the tag line for context
96
+ const relevant = lines.slice(0, 10).join('\n').trim();
97
+ return relevant || null;
98
+ }
99
+
100
+ function sendApprovalNotification(roleId: string, question: string): void {
101
+ try {
102
+ const title = `Tycono: ${roleId} needs approval`;
103
+ const msg = question.replace(/["\\\n]/g, ' ').slice(0, 200);
104
+ execSync(`osascript -e 'display notification "${msg}" with title "${title}" sound name "Ping"'`);
105
+ } catch { /* ignore on non-macOS */ }
106
+ }
107
+
80
108
  function isExecActive(status: ExecStatus): boolean {
81
109
  return status === 'running' || status === 'awaiting_input';
82
110
  }
@@ -102,6 +130,7 @@ class ExecutionManager {
102
130
  private runner = createRunner();
103
131
  private nextId = 1;
104
132
  private executionCreatedListeners = new Set<(exec: Execution) => void>();
133
+ private pendingAmendments = new Map<string, string[]>(); // sessionId → queued tasks
105
134
 
106
135
  setRunner(newRunner: ExecutionRunner): void {
107
136
  this.runner = newRunner;
@@ -246,6 +275,24 @@ class ExecutionManager {
246
275
  ...(execution.ports.hmr && { VITE_HMR_PORT: String(execution.ports.hmr) }),
247
276
  } : {};
248
277
 
278
+ // Handoff summary: collect prior dispatch results for this wave
279
+ const priorDispatches: Array<{ roleId: string; task: string; result: string }> = [];
280
+ const execSession = getSession(params.sessionId);
281
+ if (execSession?.waveId) {
282
+ for (const [, exec] of this.executions) {
283
+ if (exec.status === 'done' && exec.result && exec.sessionId !== params.sessionId) {
284
+ const s = getSession(exec.sessionId);
285
+ if (s?.waveId === execSession.waveId) {
286
+ priorDispatches.push({
287
+ roleId: exec.roleId,
288
+ task: exec.task,
289
+ result: exec.result.output?.slice(0, 500) ?? '',
290
+ });
291
+ }
292
+ }
293
+ }
294
+ }
295
+
249
296
  const handle = this.runner.execute(
250
297
  {
251
298
  companyRoot: COMPANY_ROOT,
@@ -262,6 +309,8 @@ class ExecutionManager {
262
309
  presetId,
263
310
  codeRoot: resolveCodeRoot(COMPANY_ROOT),
264
311
  attachments: params.attachments,
312
+ cliSessionId: params.cliSessionId,
313
+ priorDispatches,
265
314
  env: {
266
315
  ...process.env,
267
316
  ...portEnv,
@@ -299,12 +348,16 @@ class ExecutionManager {
299
348
  });
300
349
  }
301
350
  },
302
- onDispatch: (subRoleId, subTask) => {
351
+ onDispatch: async (subRoleId, subTask) => {
303
352
  if (params.targetRoles && params.targetRoles.length > 0) {
304
353
  if (!params.targetRoles.includes(subRoleId)) {
354
+ const errorMsg = `Dispatch to ${subRoleId} blocked — not in active target scope for this wave.`;
305
355
  console.warn(`[ExecMgr] Dispatch blocked: ${params.roleId} → ${subRoleId} (not in targetRoles)`);
306
- execution.stream.emit('stderr', params.roleId, {
307
- message: `Dispatch to ${subRoleId} blocked — not in active target scope for this wave.`,
356
+ execution.stream.emit('dispatch:error', params.roleId, {
357
+ sourceRole: params.roleId,
358
+ targetRole: subRoleId,
359
+ error: errorMsg,
360
+ timestamp: Date.now(),
308
361
  });
309
362
  return;
310
363
  }
@@ -314,6 +367,32 @@ class ExecutionManager {
314
367
  const parentSession = getSession(execution.sessionId);
315
368
  const parentWaveId = parentSession?.waveId;
316
369
 
370
+ // BUG-FORKBOMB: Role당 1세션 invariant — active/done 세션 있으면 amend
371
+ if (parentWaveId) {
372
+ const { decideDispatchOrAmend } = await import('./dispatch-classifier.js');
373
+ const decision = await decideDispatchOrAmend(parentWaveId, subRoleId, params.roleId, subTask);
374
+
375
+ if (decision.action === 'amend' && decision.prevSessionId) {
376
+ console.log(`[ExecMgr] AMEND instead of dispatch: ${subRoleId} → ${decision.prevSessionId} (${decision.reason})`);
377
+
378
+ if (decision.reason === 'role-already-active') {
379
+ // Active session — queue amendment
380
+ this.queueAmendment(decision.prevSessionId, `[FOLLOW-UP from ${params.roleId}] ${subTask}`);
381
+ return;
382
+ }
383
+
384
+ // Done session — continue
385
+ const amended = this.continueSession(
386
+ decision.prevSessionId,
387
+ `[FOLLOW-UP from ${params.roleId}] ${subTask}`,
388
+ params.roleId,
389
+ );
390
+ if (amended) return;
391
+ // continueSession failed — fall through to new dispatch
392
+ console.warn(`[ExecMgr] continueSession failed for ${decision.prevSessionId}, creating new session`);
393
+ }
394
+ }
395
+
317
396
  const childSession = createSession(subRoleId, {
318
397
  mode: 'do',
319
398
  source: 'dispatch',
@@ -424,6 +503,7 @@ class ExecutionManager {
424
503
  handle.promise
425
504
  .then((result: RunnerResult) => {
426
505
  execution.result = result;
506
+ if (result.cliSessionId) execution.cliSessionId = result.cliSessionId;
427
507
 
428
508
  const costUsd = estimateCost(
429
509
  result.totalTokens.input,
@@ -449,6 +529,34 @@ class ExecutionManager {
449
529
 
450
530
  const targetRole = resolveTargetRole(params.sourceRole, params.parentSessionId, this.executions);
451
531
 
532
+ // ── [APPROVAL_NEEDED] detection — notify user when agent is blocked ──
533
+ const approvalQuestion = extractApprovalQuestion(result.output);
534
+ if (approvalQuestion) {
535
+ console.log(`[Approval] ${params.roleId} (${execution.sessionId}) output contains approval tag`);
536
+ execution.stream.emit('approval:needed', params.roleId, {
537
+ roleId: params.roleId,
538
+ sessionId: execution.sessionId,
539
+ question: approvalQuestion,
540
+ timestamp: Date.now(),
541
+ });
542
+ sendApprovalNotification(params.roleId, approvalQuestion);
543
+
544
+ // BUG-APPROVAL belt-and-suspenders: directly notify supervisor (don't rely solely on stream)
545
+ // This ensures approval state is set even if stream watcher was lost (e.g., stream closed by cleanup)
546
+ if (params.roleId === 'ceo') {
547
+ const session = getSession(execution.sessionId);
548
+ if (session?.waveId) {
549
+ import('./supervisor-heartbeat.js').then(({ supervisorHeartbeat }) => {
550
+ const state = supervisorHeartbeat.getState(session.waveId!);
551
+ if (state && state.status !== 'awaiting_approval') {
552
+ console.log(`[Approval] Direct supervisor notification: wave ${session.waveId} → awaiting_approval`);
553
+ state.status = 'awaiting_approval';
554
+ }
555
+ }).catch(() => { /* avoid circular import crash */ });
556
+ }
557
+ }
558
+ }
559
+
452
560
  if (hardLimitReached) {
453
561
  execution.status = 'awaiting_input';
454
562
  execution.targetRole = targetRole;
@@ -460,15 +568,32 @@ class ExecutionManager {
460
568
  targetRole,
461
569
  reason: 'turn_limit',
462
570
  });
571
+
572
+ // Auto-continue on turn limit: resume with --resume for context continuity
573
+ // Delay slightly to allow stream event to propagate
574
+ setTimeout(() => {
575
+ console.log(`[Harness] Auto-continuing ${params.roleId} (${execution.sessionId}) after turn limit`);
576
+ this.continueSession(execution.sessionId, '턴 한도에 도달했습니다. 이전 작업을 이어서 계속 진행하세요.');
577
+ }, 3_000);
463
578
  } else if (!params.isContinuation && hasQuestion(result.output)) {
464
- execution.status = 'awaiting_input';
465
- execution.targetRole = targetRole;
466
- execution.stream.emit('msg:awaiting_input', params.roleId, {
467
- ...doneData,
468
- question: result.output.trim().split('\n').slice(-5).join('\n'),
469
- awaitingInput: true,
470
- targetRole,
471
- });
579
+ // CEO supervisor should auto-continue instead of hanging on awaiting_input
580
+ // (subordinates may have completed while CEO was running — CEO needs to synthesize results)
581
+ const session = getSession(execution.sessionId);
582
+ if (session?.roleId === 'ceo' && session?.source === 'wave') {
583
+ console.log(`[Harness] CEO supervisor hasQuestion — auto-continuing to synthesize results`);
584
+ setTimeout(() => {
585
+ this.continueSession(execution.sessionId, 'All dispatched sessions have completed. Synthesize the results from your team and provide a final briefing.');
586
+ }, 3_000);
587
+ } else {
588
+ execution.status = 'awaiting_input';
589
+ execution.targetRole = targetRole;
590
+ execution.stream.emit('msg:awaiting_input', params.roleId, {
591
+ ...doneData,
592
+ question: result.output.trim().split('\n').slice(-5).join('\n'),
593
+ awaitingInput: true,
594
+ targetRole,
595
+ });
596
+ }
472
597
  } else {
473
598
  const changedMdFiles = result.toolCalls
474
599
  .filter(tc => (tc.name === 'write_file' || tc.name === 'edit_file') && tc.input && typeof tc.input.path === 'string')
@@ -503,6 +628,20 @@ class ExecutionManager {
503
628
  this.finalizeSessionMessage(execution, 'done', result);
504
629
  }
505
630
 
631
+ // Emit dispatch:done on parent's stream (monni VOC: parent needs completion signal)
632
+ if (params.parentSessionId) {
633
+ const parentExec = this.getActiveExecution(params.parentSessionId);
634
+ if (parentExec) {
635
+ parentExec.stream.emit('dispatch:done', parentExec.roleId, {
636
+ targetRoleId: params.roleId,
637
+ childSessionId: params.sessionId,
638
+ output: result.output.slice(-1000),
639
+ turns: result.turns,
640
+ tokens: result.totalTokens,
641
+ });
642
+ }
643
+ }
644
+
506
645
  if (!params.parentSessionId && result) {
507
646
  const totalTokens = (result.totalTokens?.input ?? 0) + (result.totalTokens?.output ?? 0);
508
647
  const bonus = Math.min(2000, Math.max(500, Math.round(totalTokens / 500)));
@@ -568,11 +707,18 @@ class ExecutionManager {
568
707
 
569
708
  // OOM prevention: remove completed execution from memory after delay
570
709
  // (delay allows getActiveExecution to find it briefly for multiplexer/recovery)
710
+ // BUG-APPROVAL fix: Don't close stream if a continuation is running on the same session
711
+ // (closing the stream kills watcher subscribers, breaking supervisor event delivery)
571
712
  setTimeout(() => {
572
713
  this.executions.delete(execution.id);
573
- // Also close the ActivityStream to free subscribers + file handles
574
- execution.stream.close();
575
- }, 30_000).unref();
714
+ // Only close stream if no other active execution shares this session
715
+ const hasActiveSibling = [...this.executions.values()].some(
716
+ e => e.sessionId === execution.sessionId && e.id !== execution.id && (e.status === 'running' || e.status === 'awaiting_input'),
717
+ );
718
+ if (!hasActiveSibling) {
719
+ execution.stream.close();
720
+ }
721
+ }, 300_000).unref(); // 5 min — prevents HTTP 410 on dispatch --check
576
722
  });
577
723
  }
578
724
 
@@ -656,6 +802,11 @@ class ExecutionManager {
656
802
  if (session.roleId !== 'ceo' || session.source !== 'wave') {
657
803
  updateSession(execution.sessionId, { status: 'done' });
658
804
  }
805
+
806
+ // Process queued amendments (BUG-FORKBOMB: role당 1세션 invariant)
807
+ if (status === 'done') {
808
+ this.processPendingAmendments(execution.sessionId);
809
+ }
659
810
  }
660
811
 
661
812
  private cleanupOrphanedChildren(parentSessionId: string): void {
@@ -779,6 +930,19 @@ Your job: monitor progress, course-correct if needed, wait for completion, then
779
930
  return this.recoverExecutionFromStream(sessionId);
780
931
  }
781
932
 
933
+ /** Find the latest completed execution for a session (for auto-amend lookup) */
934
+ getCompletedExecution(sessionId: string): { task: string; cliSessionId?: string } | undefined {
935
+ let latest: Execution | undefined;
936
+ for (const exec of this.executions.values()) {
937
+ if (exec.sessionId === sessionId && exec.status === 'done') {
938
+ if (!latest || exec.createdAt > latest.createdAt) {
939
+ latest = exec;
940
+ }
941
+ }
942
+ }
943
+ return latest ? { task: latest.task, cliSessionId: latest.cliSessionId } : undefined;
944
+ }
945
+
782
946
  listExecutions(filter?: { status?: ExecStatus; roleId?: string; active?: boolean }): Array<{
783
947
  id: string;
784
948
  type: ExecType;
@@ -856,14 +1020,23 @@ Your job: monitor progress, course-correct if needed, wait for completion, then
856
1020
  exec.stream.emit('msg:reply', exec.roleId, { response, responderRole: effectiveResponder, isFollowUp });
857
1021
 
858
1022
  const prevOutput = exec.result?.output ?? '';
859
- const contextSummary = prevOutput.length > 2000
860
- ? prevOutput.slice(-2000)
861
- : prevOutput;
1023
+ const hasCliSession = !!exec.cliSessionId;
862
1024
 
863
1025
  const responderLabel = effectiveResponder === 'ceo' ? 'CEO' : effectiveResponder.toUpperCase();
864
- const continuationTask = isFollowUp
865
- ? `[CEO Follow-up Directive]\n${response}\n\n[Previous context — your earlier report follows]\n${contextSummary}`
866
- : `[Continuationprevious output follows]\n${contextSummary}\n\n[${responderLabel} Response]\n${response}`;
1026
+ let continuationTask: string;
1027
+ if (hasCliSession) {
1028
+ // --resume preserves full conversation context no need to repeat output
1029
+ continuationTask = isFollowUp
1030
+ ? `[CEO Follow-up Directive]\n${response}`
1031
+ : `[${responderLabel} Response — continue where you left off]\n${response}`;
1032
+ } else {
1033
+ const contextSummary = prevOutput.length > 2000
1034
+ ? prevOutput.slice(-2000)
1035
+ : prevOutput;
1036
+ continuationTask = isFollowUp
1037
+ ? `[CEO Follow-up Directive]\n${response}\n\n[Previous context — your earlier report follows]\n${contextSummary}`
1038
+ : `[Continuation — previous output follows]\n${contextSummary}\n\n[${responderLabel} Response]\n${response}`;
1039
+ }
867
1040
 
868
1041
  const newExec = this.startExecution({
869
1042
  type: exec.type,
@@ -873,11 +1046,44 @@ Your job: monitor progress, course-correct if needed, wait for completion, then
873
1046
  parentSessionId: exec.parentSessionId,
874
1047
  isContinuation: !isFollowUp,
875
1048
  sessionId: exec.sessionId, // Same session → same stream
1049
+ // Pass CLI session ID for --resume (preserves Claude conversation context)
1050
+ cliSessionId: exec.cliSessionId,
876
1051
  });
877
1052
 
878
1053
  return newExec;
879
1054
  }
880
1055
 
1056
+ /**
1057
+ * Queue an amendment for a running session.
1058
+ * Will be processed when the current execution completes.
1059
+ */
1060
+ queueAmendment(sessionId: string, task: string): void {
1061
+ const queue = this.pendingAmendments.get(sessionId) ?? [];
1062
+ queue.push(task);
1063
+ this.pendingAmendments.set(sessionId, queue);
1064
+ console.log(`[Dispatch] Queued amendment for ${sessionId} (${queue.length} pending)`);
1065
+ }
1066
+
1067
+ /**
1068
+ * Process pending amendments after execution completes.
1069
+ * Called from finalization logic.
1070
+ */
1071
+ processPendingAmendments(sessionId: string): void {
1072
+ const queue = this.pendingAmendments.get(sessionId);
1073
+ if (!queue || queue.length === 0) return;
1074
+
1075
+ const task = queue.shift()!;
1076
+ if (queue.length === 0) {
1077
+ this.pendingAmendments.delete(sessionId);
1078
+ }
1079
+
1080
+ console.log(`[Dispatch] Processing queued amendment for ${sessionId} (${queue.length} remaining)`);
1081
+ // Use setTimeout to avoid recursive call stack during finalization
1082
+ setTimeout(() => {
1083
+ this.continueSession(sessionId, task);
1084
+ }, 100);
1085
+ }
1086
+
881
1087
  getActiveExecutionForRole(roleId: string): Execution | undefined {
882
1088
  for (const exec of this.executions.values()) {
883
1089
  if (exec.roleId === roleId && isExecActive(exec.status)) {
@@ -957,7 +1163,7 @@ Your job: monitor progress, course-correct if needed, wait for completion, then
957
1163
  setTimeout(() => {
958
1164
  this.executions.delete(execution.id);
959
1165
  execution.stream.close();
960
- }, 30_000).unref();
1166
+ }, 300_000).unref(); // 5 min — prevents HTTP 410 on dispatch --check
961
1167
  }
962
1168
 
963
1169
  return execution;
@@ -5,9 +5,12 @@ import { glob } from 'glob';
5
5
 
6
6
  function findCompanyRoot(): string {
7
7
  if (process.env.COMPANY_ROOT) return process.env.COMPANY_ROOT;
8
- // Walk up from cwd to find knowledge/CLAUDE.md (project root marker)
8
+ // Walk up from cwd to find CLAUDE.md (supports both layouts)
9
+ // - Claude Code standard: CLAUDE.md at project root
10
+ // - Tycono scaffold: knowledge/CLAUDE.md
9
11
  let dir = process.cwd();
10
12
  while (dir !== path.dirname(dir)) {
13
+ if (fs.existsSync(path.join(dir, 'CLAUDE.md'))) return dir;
11
14
  if (fs.existsSync(path.join(dir, 'knowledge', 'CLAUDE.md'))) return dir;
12
15
  dir = path.dirname(dir);
13
16
  }