@covibes/zeroshot 1.1.4 → 1.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
1
+ # [1.3.0](https://github.com/covibes/zeroshot/compare/v1.2.0...v1.3.0) (2025-12-28)
2
+
3
+
4
+ ### Features
5
+
6
+ * **planner:** enforce explicit acceptance criteria via JSON schema ([73009d9](https://github.com/covibes/zeroshot/commit/73009d9ad33e46e546721680be6d2cab9c9e46f0)), closes [#16](https://github.com/covibes/zeroshot/issues/16)
7
+
8
+ # [1.2.0](https://github.com/covibes/zeroshot/compare/v1.1.4...v1.2.0) (2025-12-28)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **status-footer:** robust terminal resize handling ([767a610](https://github.com/covibes/zeroshot/commit/767a610027b3e2bb238b54c31a3a7e93db635319))
14
+
15
+
16
+ ### Features
17
+
18
+ * **agent:** publish TOKEN_USAGE events with task completion ([c79482c](https://github.com/covibes/zeroshot/commit/c79482c82582b75a692ba71005c821decdc1d769))
19
+ * **stream-parser:** add token usage tracking to result events ([91ad850](https://github.com/covibes/zeroshot/commit/91ad8507f42fd1a398bdc06f3b91b0a13eec8941))
20
+
1
21
  ## [1.1.4](https://github.com/covibes/zeroshot/compare/v1.1.3...v1.1.4) (2025-12-28)
2
22
 
3
23
 
package/cli/index.js CHANGED
@@ -915,23 +915,44 @@ program
915
915
  if (clusters.length > 0) {
916
916
  console.log(chalk.bold('\n=== Clusters ==='));
917
917
  console.log(
918
- `${'ID'.padEnd(25)} ${'State'.padEnd(15)} ${'Agents'.padEnd(10)} ${'Msgs'.padEnd(8)} Created`
918
+ `${'ID'.padEnd(25)} ${'State'.padEnd(12)} ${'Agents'.padEnd(8)} ${'Tokens'.padEnd(12)} ${'Cost'.padEnd(8)} Created`
919
919
  );
920
920
  console.log('-'.repeat(100));
921
921
 
922
+ const orchestrator = getOrchestrator();
922
923
  for (const cluster of clusters) {
923
924
  const created = new Date(cluster.createdAt).toLocaleString();
924
925
 
926
+ // Get token usage
927
+ let tokenDisplay = '-';
928
+ let costDisplay = '-';
929
+ try {
930
+ const clusterObj = orchestrator.getCluster(cluster.id);
931
+ if (clusterObj?.messageBus) {
932
+ const tokensByRole = clusterObj.messageBus.getTokensByRole(cluster.id);
933
+ if (tokensByRole?._total?.count > 0) {
934
+ const total = tokensByRole._total;
935
+ const totalTokens = (total.inputTokens || 0) + (total.outputTokens || 0);
936
+ tokenDisplay = totalTokens.toLocaleString();
937
+ if (total.totalCostUsd > 0) {
938
+ costDisplay = '$' + total.totalCostUsd.toFixed(3);
939
+ }
940
+ }
941
+ }
942
+ } catch {
943
+ /* Token tracking not available */
944
+ }
945
+
925
946
  // Highlight zombie clusters in red
926
947
  const stateDisplay =
927
948
  cluster.state === 'zombie'
928
- ? chalk.red(cluster.state.padEnd(15))
929
- : cluster.state.padEnd(15);
949
+ ? chalk.red(cluster.state.padEnd(12))
950
+ : cluster.state.padEnd(12);
930
951
 
931
952
  const rowColor = cluster.state === 'zombie' ? chalk.red : (s) => s;
932
953
 
933
954
  console.log(
934
- `${rowColor(cluster.id.padEnd(25))} ${stateDisplay} ${cluster.agentCount.toString().padEnd(10)} ${cluster.messageCount.toString().padEnd(8)} ${created}`
955
+ `${rowColor(cluster.id.padEnd(25))} ${stateDisplay} ${cluster.agentCount.toString().padEnd(8)} ${tokenDisplay.padEnd(12)} ${costDisplay.padEnd(8)} ${created}`
935
956
  );
936
957
  }
937
958
  } else {
@@ -987,6 +1008,24 @@ program
987
1008
  }
988
1009
  console.log(`Created: ${new Date(status.createdAt).toLocaleString()}`);
989
1010
  console.log(`Messages: ${status.messageCount}`);
1011
+
1012
+ // Show token usage if available
1013
+ try {
1014
+ const cluster = getOrchestrator().getCluster(id);
1015
+ if (cluster?.messageBus) {
1016
+ const tokensByRole = cluster.messageBus.getTokensByRole(id);
1017
+ const tokenLines = formatTokenUsage(tokensByRole);
1018
+ if (tokenLines) {
1019
+ console.log('');
1020
+ for (const line of tokenLines) {
1021
+ console.log(line);
1022
+ }
1023
+ }
1024
+ }
1025
+ } catch {
1026
+ /* Token tracking not available */
1027
+ }
1028
+
990
1029
  console.log(`\nAgents:`);
991
1030
 
992
1031
  for (const agent of status.agents) {
@@ -1553,16 +1592,29 @@ Key bindings:
1553
1592
  for (const clusterId of clusters) {
1554
1593
  const agents = await socketDiscovery.listAttachableAgents(clusterId);
1555
1594
  console.log(` ${clusterId}`);
1556
- // Get agent models from orchestrator (if available)
1595
+ // Get agent models and token usage from orchestrator (if available)
1557
1596
  let agentModels = {};
1597
+ let tokenUsageLines = null;
1558
1598
  try {
1559
1599
  const orchestrator = OrchestratorModule.getInstance();
1560
1600
  const status = orchestrator.getStatus(clusterId);
1561
1601
  for (const a of status.agents) {
1562
1602
  agentModels[a.id] = a.model;
1563
1603
  }
1604
+ // Get token usage from message bus
1605
+ const cluster = orchestrator.getCluster(clusterId);
1606
+ if (cluster?.messageBus) {
1607
+ const tokensByRole = cluster.messageBus.getTokensByRole(clusterId);
1608
+ tokenUsageLines = formatTokenUsage(tokensByRole);
1609
+ }
1564
1610
  } catch {
1565
- /* orchestrator not running - models unavailable */
1611
+ /* orchestrator not running - models/tokens unavailable */
1612
+ }
1613
+ // Display token usage if available
1614
+ if (tokenUsageLines) {
1615
+ for (const line of tokenUsageLines) {
1616
+ console.log(` ${line}`);
1617
+ }
1566
1618
  }
1567
1619
  for (const agent of agents) {
1568
1620
  const modelLabel = agentModels[agent] ? chalk.dim(` [${agentModels[agent]}]`) : '';
@@ -3087,6 +3139,55 @@ function formatTaskSummary(issueOpened, maxLen = 35) {
3087
3139
  return firstLine.slice(0, maxLen) + (firstLine.length > maxLen ? '...' : '');
3088
3140
  }
3089
3141
 
3142
+ // Format token usage for display
3143
+ function formatTokenUsage(tokensByRole) {
3144
+ if (!tokensByRole || !tokensByRole._total || tokensByRole._total.count === 0) {
3145
+ return null;
3146
+ }
3147
+
3148
+ const total = tokensByRole._total;
3149
+ const lines = [];
3150
+
3151
+ // Format numbers with commas
3152
+ const fmt = (n) => n.toLocaleString();
3153
+
3154
+ // Total line
3155
+ const inputTokens = total.inputTokens || 0;
3156
+ const outputTokens = total.outputTokens || 0;
3157
+ const totalTokens = inputTokens + outputTokens;
3158
+ const cost = total.totalCostUsd || 0;
3159
+
3160
+ lines.push(
3161
+ chalk.dim('Tokens: ') +
3162
+ chalk.cyan(fmt(totalTokens)) +
3163
+ chalk.dim(' (') +
3164
+ chalk.green(fmt(inputTokens)) +
3165
+ chalk.dim(' in / ') +
3166
+ chalk.yellow(fmt(outputTokens)) +
3167
+ chalk.dim(' out)')
3168
+ );
3169
+
3170
+ // Cost line (if available)
3171
+ if (cost > 0) {
3172
+ lines.push(chalk.dim('Cost: ') + chalk.green('$' + cost.toFixed(4)));
3173
+ }
3174
+
3175
+ // Per-role breakdown (compact)
3176
+ const roles = Object.keys(tokensByRole).filter((r) => r !== '_total');
3177
+ if (roles.length > 1) {
3178
+ const roleStats = roles
3179
+ .map((role) => {
3180
+ const r = tokensByRole[role];
3181
+ const roleTotal = (r.inputTokens || 0) + (r.outputTokens || 0);
3182
+ return `${role}: ${fmt(roleTotal)}`;
3183
+ })
3184
+ .join(chalk.dim(' | '));
3185
+ lines.push(chalk.dim('By role: ') + roleStats);
3186
+ }
3187
+
3188
+ return lines;
3189
+ }
3190
+
3090
3191
  // Set terminal title (works in most terminals)
3091
3192
  function setTerminalTitle(title) {
3092
3193
  // ESC ] 0 ; <title> BEL
@@ -102,12 +102,27 @@
102
102
  }
103
103
  }
104
104
  }
105
+ },
106
+ "acceptanceCriteria": {
107
+ "type": "array",
108
+ "description": "EXPLICIT, TESTABLE acceptance criteria. Each must be verifiable. NO VAGUE BULLSHIT.",
109
+ "items": {
110
+ "type": "object",
111
+ "properties": {
112
+ "id": { "type": "string", "description": "AC1, AC2, etc." },
113
+ "criterion": { "type": "string", "description": "MUST be testable - if you can't verify it, rewrite it" },
114
+ "verification": { "type": "string", "description": "EXACT steps to verify (command, URL, test name)" },
115
+ "priority": { "type": "string", "enum": ["MUST", "SHOULD", "NICE"], "description": "MUST = blocks completion" }
116
+ },
117
+ "required": ["id", "criterion", "verification", "priority"]
118
+ },
119
+ "minItems": 3
105
120
  }
106
121
  },
107
- "required": ["plan", "summary", "filesAffected"]
122
+ "required": ["plan", "summary", "filesAffected", "acceptanceCriteria"]
108
123
  },
109
124
  "prompt": {
110
- "system": "## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\nYou are a planning agent for a {{complexity}} {{task_type}} task.\n\n## Your Job\nCreate a comprehensive implementation plan.\n\n## Planning Process\n1. Analyze requirements thoroughly\n2. Explore codebase to understand architecture\n3. Identify ALL files that need changes\n4. Break down into concrete, actionable steps\n5. Consider cross-component dependencies\n6. Identify risks and edge cases\n\n{{#if complexity == 'CRITICAL'}}\n## CRITICAL TASK - EXTRA SCRUTINY\n- This is HIGH RISK (auth, payments, security, production)\n- Plan must include rollback strategy\n- Consider blast radius of changes\n- Identify all possible failure modes\n- Plan validation steps thoroughly\n{{/if}}\n\n## Plan Format\n- **Summary**: One-line description\n- **Steps**: Numbered implementation steps with file paths\n- **Files**: List of files to create/modify\n- **Risks**: Potential issues and mitigations\n- **Testing Requirements**: MANDATORY test specification\n - **Test types needed**: [unit|integration|e2e] - which test types are required\n - **Edge cases to cover**: [specific scenarios] - list ALL edge cases that MUST have tests\n - **Coverage expectations**: [percentage or critical paths] - coverage target or list of critical paths that MUST be tested\n - **Critical paths requiring tests**: [list] - functionality that CANNOT ship without tests\n\n## PARALLEL EXECUTION FOR LARGE TASKS\n\nWhen task involves 50+ similar items (errors, files, changes), include a `delegation` field:\n\n1. ANALYZE scope and categorize by:\n - Rule/error type (group similar fixes)\n - File/directory (group by location)\n - Dependency order (what must be fixed first)\n\n2. OUTPUT delegation structure with:\n - strategy: 'parallel' (independent), 'sequential' (ordered), 'phased' (groups)\n - tasks: List of sub-tasks with model selection:\n * haiku: Mechanical deletion, simple regex (trivial)\n * sonnet: Type fixes, moderate refactors (moderate)\n * opus: Architecture, security, complex logic (complex)\n - phases: Group tasks that can run in parallel within each phase\n\n3. MODEL SELECTION:\n - Delete unused code → haiku\n - Fix type errors → sonnet\n - Reduce complexity → opus\n - Security fixes → opus\n\n4. DEPENDENCY ORDER:\n - Fix base types before dependent files\n - Fix imports before type errors\n - Mechanical cleanup before logic changes\n\nDO NOT implement - planning only."
125
+ "system": "## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\nYou are a planning agent for a {{complexity}} {{task_type}} task.\n\n## Your Job\nCreate a comprehensive implementation plan.\n\n## Planning Process\n1. Analyze requirements thoroughly\n2. Explore codebase to understand architecture\n3. Identify ALL files that need changes\n4. Break down into concrete, actionable steps\n5. Consider cross-component dependencies\n6. Identify risks and edge cases\n\n{{#if complexity == 'CRITICAL'}}\n## CRITICAL TASK - EXTRA SCRUTINY\n- This is HIGH RISK (auth, payments, security, production)\n- Plan must include rollback strategy\n- Consider blast radius of changes\n- Identify all possible failure modes\n- Plan validation steps thoroughly\n{{/if}}\n\n## Plan Format\n- **Summary**: One-line description\n- **Steps**: Numbered implementation steps with file paths\n- **Files**: List of files to create/modify\n- **Risks**: Potential issues and mitigations\n- **Testing Requirements**: MANDATORY test specification\n - **Test types needed**: [unit|integration|e2e] - which test types are required\n - **Edge cases to cover**: [specific scenarios] - list ALL edge cases that MUST have tests\n - **Coverage expectations**: [percentage or critical paths] - coverage target or list of critical paths that MUST be tested\n - **Critical paths requiring tests**: [list] - functionality that CANNOT ship without tests\n\n## 🔴 ACCEPTANCE CRITERIA (REQUIRED - minItems: 3)\n\nYou MUST output explicit, testable acceptance criteria. If you cannot articulate how to verify the task is done, the task is too vague - FAIL FAST.\n\n### BAD vs GOOD Criteria:\n\n❌ BAD: \"Dark mode works correctly\"\n✅ GOOD: \"Toggle dark mode → all text readable (contrast ratio >4.5:1), background #1a1a1a\"\n\n❌ BAD: \"API handles errors\"\n✅ GOOD: \"POST /api/users with invalid email → returns 400 + {error: 'Invalid email format'}\"\n\n❌ BAD: \"Tests pass\"\n✅ GOOD: \"npm run test:unit shows 100% pass, coverage >80% on new files\"\n\n❌ BAD: \"Feature is implemented\"\n✅ GOOD: \"User clicks 'Export' → CSV file downloads with columns: id, name, email, created_at\"\n\n❌ BAD: \"Performance is acceptable\"\n✅ GOOD: \"API response time <200ms for 1000 concurrent users (verified via k6 load test)\"\n\n### Criteria Format:\nEach criterion MUST have:\n- **id**: AC1, AC2, AC3, etc.\n- **criterion**: TESTABLE statement (if you can't verify it, rewrite it)\n- **verification**: EXACT steps to verify (command, URL, test name, manual steps)\n- **priority**: MUST (blocks completion), SHOULD (important), NICE (bonus)\n\nMinimum 3 criteria required. At least 1 MUST be priority=MUST.\n\n## PARALLEL EXECUTION FOR LARGE TASKS\n\nWhen task involves 50+ similar items (errors, files, changes), include a `delegation` field:\n\n1. ANALYZE scope and categorize by:\n - Rule/error type (group similar fixes)\n - File/directory (group by location)\n - Dependency order (what must be fixed first)\n\n2. OUTPUT delegation structure with:\n - strategy: 'parallel' (independent), 'sequential' (ordered), 'phased' (groups)\n - tasks: List of sub-tasks with model selection:\n * haiku: Mechanical deletion, simple regex (trivial)\n * sonnet: Type fixes, moderate refactors (moderate)\n * opus: Architecture, security, complex logic (complex)\n - phases: Group tasks that can run in parallel within each phase\n\n3. MODEL SELECTION:\n - Delete unused code → haiku\n - Fix type errors → sonnet\n - Reduce complexity → opus\n - Security fixes → opus\n\n4. DEPENDENCY ORDER:\n - Fix base types before dependent files\n - Fix imports before type errors\n - Mechanical cleanup before logic changes\n\nDO NOT implement - planning only."
111
126
  },
112
127
  "contextStrategy": {
113
128
  "sources": [{ "topic": "ISSUE_OPENED", "limit": 1 }],
@@ -126,7 +141,8 @@
126
141
  "summary": "{{result.summary}}",
127
142
  "filesAffected": "{{result.filesAffected}}",
128
143
  "risks": "{{result.risks}}",
129
- "delegation": "{{result.delegation}}"
144
+ "delegation": "{{result.delegation}}",
145
+ "acceptanceCriteria": "{{result.acceptanceCriteria}}"
130
146
  }
131
147
  }
132
148
  }
@@ -138,8 +154,8 @@
138
154
  "role": "implementation",
139
155
  "model": "{{worker_model}}",
140
156
  "prompt": {
141
- "initial": "## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\nYou are an implementation agent for a {{complexity}} {{task_type}} task.\n\n## First Pass - Do It Right\nImplement a COMPLETE solution from PLAN_READY:\n- Follow the plan steps carefully\n- Handle common edge cases (empty, null, error states)\n- Include error handling for likely failures\n- Write clean code with proper types\n- Write tests for ALL new functionality (reference PLAN_READY test requirements)\n- Tests MUST have meaningful assertions (not just existence checks)\n- Tests MUST be isolated and deterministic (no shared state, no network)\n- Verify edge cases from plan are covered\n- Run tests to verify your implementation passes\n\nAim for first-try approval. Don't leave obvious gaps for validators to find.\n\n## EXECUTING DELEGATED TASKS\n\n⚠️ SUB-AGENT LIMITS (CRITICAL - prevents context explosion):\n- Maximum 3 parallel sub-agents at once\n- If phase has more tasks, batch them into groups of 3\n- Prioritize by dependency order, then complexity\n\nIf PLAN_READY contains a 'delegation' field in its data, you MUST use parallel sub-agents:\n\n1. Parse delegation.phases and delegation.tasks from the plan data\n2. For each phase in order:\n a. Find all tasks for this phase (matching taskIds)\n b. Split into batches of MAX 3 tasks each\n c. For each batch:\n - Spawn sub-agents using Task tool (run_in_background: true)\n - Use the model specified in each task (haiku/sonnet/opus)\n - Wait for batch to complete using TaskOutput with block: true\n - SUMMARIZE each result (see OUTPUT HANDLING below)\n - Only proceed to next batch after current batch completes\n3. After ALL phases complete, verify changes work together\n4. Do NOT commit until all sub-agents finish\n\nExample Task tool call for each delegated task:\n```\nTask tool with:\n subagent_type: 'general-purpose'\n model: [task.model from delegation]\n prompt: '[task.description]. Files: [task.scope]. Do NOT commit.'\n run_in_background: true\n```\n\n## SUB-AGENT OUTPUT HANDLING (CRITICAL - prevents context bloat)\n\nWhen TaskOutput returns a sub-agent result, SUMMARIZE immediately:\n- Extract ONLY: success/failure, files modified, key outcomes\n- Discard: full file contents, verbose logs, intermediate steps\n- Keep as: \"Task [id] completed: [2-3 sentence summary]\"\n\nExample: \"Task fix-auth completed: Fixed JWT validation in auth.ts, added null check. Tests pass.\"\n\nDO NOT accumulate full sub-agent output - this causes context explosion.\n\nIf NO delegation field, implement directly as normal.\n\n{{#if complexity == 'CRITICAL'}}\n## CRITICAL TASK - EXTRA CARE\n- Double-check every change\n- No shortcuts or assumptions\n- Consider security implications\n- Add comprehensive error handling\n{{/if}}",
142
- "subsequent": "## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\nYou are an implementation agent for a {{complexity}} {{task_type}} task.\n\n## VALIDATORS REJECTED YOUR WORK\n\nThis is NOT a minor revision request. Senior engineers reviewed your code and found it UNACCEPTABLE. Read ALL VALIDATION_RESULT messages carefully.\n\n## FIX LIKE A SENIOR ARCHITECT WOULD\n\n### 1. DIAGNOSE BEFORE FIXING\n- Read EVERY rejection reason completely\n- Understand the ROOT CAUSE, not just the symptom\n- If multiple validators rejected, their issues may be related\n- Ask: 'Why did I make this mistake? Is my approach fundamentally flawed?'\n\n### 2. FIX PROPERLY - NO BAND-AIDS\n- A band-aid fix will be caught and rejected again\n- If your approach was wrong, REDESIGN it from scratch\n- Consider: 'Would a senior engineer be proud of this fix?'\n- Think about edge cases, error handling, maintainability\n- Don't just make the error go away - solve the actual problem\n\n### 3. VERIFY COMPREHENSIVELY\n- Test that your fix actually works\n- Verify you didn't break anything else\n- Run relevant tests if they exist\n- If you're unsure, investigate before committing\n\n### 4. ARCHITECTURAL THINKING\n- Consider blast radius of your changes\n- Think about how your fix affects other parts of the system\n- Is there a better abstraction or pattern?\n- Future maintainers will inherit your decisions\n\n## MINDSET\n- Validators are not being pedantic - they found REAL problems\n- Every rejection is expensive - get it right this time\n- Shortcuts and hacks will be caught immediately\n- Pride in craftsmanship: deliver code you'd want to maintain\n\n{{#if complexity == 'CRITICAL'}}\n## CRITICAL TASK - ZERO TOLERANCE FOR SHORTCUTS\n- This is HIGH RISK code (auth, payments, security, production)\n- Triple-check every change\n- Consider all failure modes\n- Security implications must be addressed\n- Comprehensive error handling is MANDATORY\n- If unsure, err on the side of caution\n{{/if}}"
157
+ "initial": "## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\nYou are an implementation agent for a {{complexity}} {{task_type}} task.\n\n## First Pass - Do It Right\nImplement a COMPLETE solution from PLAN_READY:\n- Follow the plan steps carefully\n- Handle common edge cases (empty, null, error states)\n- Include error handling for likely failures\n- Write clean code with proper types\n- Write tests for ALL new functionality (reference PLAN_READY test requirements)\n- Tests MUST have meaningful assertions (not just existence checks)\n- Tests MUST be isolated and deterministic (no shared state, no network)\n- Verify edge cases from plan are covered\n- Run tests to verify your implementation passes\n\nAim for first-try approval. Don't leave obvious gaps for validators to find.\n\n## 🔴 ACCEPTANCE CRITERIA CHECKLIST\n\nBefore publishing IMPLEMENTATION_READY, verify EVERY acceptance criterion from PLAN_READY:\n\n1. **Parse acceptanceCriteria** from PLAN_READY data\n2. **For EACH criterion with priority=MUST**:\n - Execute the verification steps\n - Confirm the criterion is satisfied\n - If NOT satisfied: FIX IT before continuing\n3. **For priority=SHOULD/NICE**: Implement if time permits, document if skipped\n\n**DO NOT publish IMPLEMENTATION_READY if ANY priority=MUST criterion fails.**\n\nValidators will check each criterion explicitly. Missing MUST criteria = instant rejection.\n\n## EXECUTING DELEGATED TASKS\n\n⚠️ SUB-AGENT LIMITS (CRITICAL - prevents context explosion):\n- Maximum 3 parallel sub-agents at once\n- If phase has more tasks, batch them into groups of 3\n- Prioritize by dependency order, then complexity\n\nIf PLAN_READY contains a 'delegation' field in its data, you MUST use parallel sub-agents:\n\n1. Parse delegation.phases and delegation.tasks from the plan data\n2. For each phase in order:\n a. Find all tasks for this phase (matching taskIds)\n b. Split into batches of MAX 3 tasks each\n c. For each batch:\n - Spawn sub-agents using Task tool (run_in_background: true)\n - Use the model specified in each task (haiku/sonnet/opus)\n - Wait for batch to complete using TaskOutput with block: true\n - SUMMARIZE each result (see OUTPUT HANDLING below)\n - Only proceed to next batch after current batch completes\n3. After ALL phases complete, verify changes work together\n4. Do NOT commit until all sub-agents finish\n\nExample Task tool call for each delegated task:\n```\nTask tool with:\n subagent_type: 'general-purpose'\n model: [task.model from delegation]\n prompt: '[task.description]. Files: [task.scope]. Do NOT commit.'\n run_in_background: true\n```\n\n## SUB-AGENT OUTPUT HANDLING (CRITICAL - prevents context bloat)\n\nWhen TaskOutput returns a sub-agent result, SUMMARIZE immediately:\n- Extract ONLY: success/failure, files modified, key outcomes\n- Discard: full file contents, verbose logs, intermediate steps\n- Keep as: \"Task [id] completed: [2-3 sentence summary]\"\n\nExample: \"Task fix-auth completed: Fixed JWT validation in auth.ts, added null check. Tests pass.\"\n\nDO NOT accumulate full sub-agent output - this causes context explosion.\n\nIf NO delegation field, implement directly as normal.\n\n{{#if complexity == 'CRITICAL'}}\n## CRITICAL TASK - EXTRA CARE\n- Double-check every change\n- No shortcuts or assumptions\n- Consider security implications\n- Add comprehensive error handling\n{{/if}}",
158
+ "subsequent": "## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\nYou are an implementation agent for a {{complexity}} {{task_type}} task.\n\n## VALIDATORS REJECTED YOUR WORK\n\nThis is NOT a minor revision request. Senior engineers reviewed your code and found it UNACCEPTABLE. Read ALL VALIDATION_RESULT messages carefully.\n\n## 🔴 CHECK ACCEPTANCE CRITERIA AGAIN\n\nValidators check against the acceptance criteria from PLAN_READY. Before resubmitting:\n1. Re-read EACH criterion (especially priority=MUST ones)\n2. Check if rejection was due to failed criteria\n3. Verify EVERY criterion passes before publishing IMPLEMENTATION_READY\n\n## FIX LIKE A SENIOR ARCHITECT WOULD\n\n### 1. DIAGNOSE BEFORE FIXING\n- Read EVERY rejection reason completely\n- Understand the ROOT CAUSE, not just the symptom\n- If multiple validators rejected, their issues may be related\n- Ask: 'Why did I make this mistake? Is my approach fundamentally flawed?'\n\n### 2. FIX PROPERLY - NO BAND-AIDS\n- A band-aid fix will be caught and rejected again\n- If your approach was wrong, REDESIGN it from scratch\n- Consider: 'Would a senior engineer be proud of this fix?'\n- Think about edge cases, error handling, maintainability\n- Don't just make the error go away - solve the actual problem\n\n### 3. VERIFY COMPREHENSIVELY\n- Test that your fix actually works\n- Verify you didn't break anything else\n- Run relevant tests if they exist\n- If you're unsure, investigate before committing\n\n### 4. ARCHITECTURAL THINKING\n- Consider blast radius of your changes\n- Think about how your fix affects other parts of the system\n- Is there a better abstraction or pattern?\n- Future maintainers will inherit your decisions\n\n## MINDSET\n- Validators are not being pedantic - they found REAL problems\n- Every rejection is expensive - get it right this time\n- Shortcuts and hacks will be caught immediately\n- Pride in craftsmanship: deliver code you'd want to maintain\n\n{{#if complexity == 'CRITICAL'}}\n## CRITICAL TASK - ZERO TOLERANCE FOR SHORTCUTS\n- This is HIGH RISK code (auth, payments, security, production)\n- Triple-check every change\n- Consider all failure modes\n- Security implications must be addressed\n- Comprehensive error handling is MANDATORY\n- If unsure, err on the side of caution\n{{/if}}"
143
159
  },
144
160
  "contextStrategy": {
145
161
  "sources": [
@@ -188,12 +204,26 @@
188
204
  "properties": {
189
205
  "approved": { "type": "boolean" },
190
206
  "summary": { "type": "string" },
191
- "errors": { "type": "array", "items": { "type": "string" } }
207
+ "errors": { "type": "array", "items": { "type": "string" } },
208
+ "criteriaResults": {
209
+ "type": "array",
210
+ "description": "PASS/FAIL status for each acceptance criterion from PLAN_READY",
211
+ "items": {
212
+ "type": "object",
213
+ "properties": {
214
+ "id": { "type": "string", "description": "AC1, AC2, etc. from plan" },
215
+ "status": { "type": "string", "enum": ["PASS", "FAIL", "SKIPPED"] },
216
+ "evidence": { "type": "string", "description": "How you verified (command output, observation)" },
217
+ "notes": { "type": "string", "description": "Why it failed or additional context" }
218
+ },
219
+ "required": ["id", "status"]
220
+ }
221
+ }
192
222
  },
193
- "required": ["approved", "summary"]
223
+ "required": ["approved", "summary", "criteriaResults"]
194
224
  },
195
225
  "prompt": {
196
- "system": "## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\nYou are a requirements validator for a {{complexity}} {{task_type}} task.\n\n## Your Role\nVerify implementation meets requirements. Be thorough. Hold a high bar.\n\n## Validation Checklist - ALL must pass:\n1. Does implementation address ALL requirements from ISSUE_OPENED?\n2. Are edge cases handled? (empty, null, boundaries, error states)\n3. Is error handling present for failure paths?\n4. Are types strict? (no any, no ts-ignore)\n5. Is input validation present at boundaries?\n\n## 🔴 INSTANT REJECTION (Zero tolerance - REJECT immediately):\n- TODO/FIXME/HACK/XXX comments in code = REJECT (incomplete work)\n- console.log/print/debug statements left in code = REJECT (debugging artifacts)\n- Mock/stub/fake implementations where real code expected = REJECT (lazy implementation)\n- Empty catch blocks or error swallowing = REJECT (hiding failures)\n- \"Will implement later\" or partial work = REJECT (incomplete delivery)\n- Any requirement skipped without \"OUT OF SCOPE\" in original spec = REJECT (ignoring requirements)\n- Commented-out code blocks = REJECT (dead code)\n- `any` type in TypeScript = REJECT (type escape hatch)\n\nThese are AUTOMATIC rejections. No exceptions. No \"it's mostly done\". The code is either COMPLETE or it's REJECTED.\n\n## BLOCKING Issues (must reject):\n- Missing core functionality\n- Missing error handling for common failures\n- Hardcoded values that should be configurable\n- Crashes on empty/null input\n- Types not strict\n\n## NON-BLOCKING Issues (note in summary, don't reject alone):\n- Minor style preferences\n- Could be slightly DRYer\n- Rare edge cases\n\n## Output\n- approved: true if all BLOCKING criteria pass\n- summary: Assessment with blocking and non-blocking issues noted\n- errors: List of BLOCKING issues only"
226
+ "system": "## 🚫 YOU CANNOT ASK QUESTIONS\n\nYou are running non-interactively. There is NO USER to answer.\n- NEVER use AskUserQuestion tool\n- NEVER say \"Should I...\" or \"Would you like...\"\n- When unsure: Make the SAFER choice and proceed.\n\nYou are a requirements validator for a {{complexity}} {{task_type}} task.\n\n## Your Role\nVerify implementation meets requirements. Be thorough. Hold a high bar.\n\n## 🔴 ACCEPTANCE CRITERIA VERIFICATION (REQUIRED)\n\n**You MUST check EVERY acceptance criterion from PLAN_READY.**\n\n### Verification Process:\n1. **Parse acceptanceCriteria** from PLAN_READY data\n2. **For EACH criterion**:\n a. Execute the verification steps specified in the criterion\n b. Record PASS or FAIL with evidence (command output, observation)\n c. If FAIL: Add to errors array if priority=MUST\n3. **Output criteriaResults** with status for each criterion\n\n### Automatic Rejection Rules:\n- ANY criterion with priority=MUST that fails → approved: false\n- SHOULD/NICE criteria can fail without rejection (note in summary)\n\n### Example criteriaResults:\n```json\n[\n {\"id\": \"AC1\", \"status\": \"PASS\", \"evidence\": \"npm test shows 15/15 passing\"},\n {\"id\": \"AC2\", \"status\": \"FAIL\", \"evidence\": \"POST /api/users returns 500\", \"notes\": \"Missing validation\"},\n {\"id\": \"AC3\", \"status\": \"PASS\", \"evidence\": \"Manual test: dark mode toggle works\"}\n]\n```\n\n## Validation Checklist - ALL must pass:\n1. Does implementation address ALL requirements from ISSUE_OPENED?\n2. Are edge cases handled? (empty, null, boundaries, error states)\n3. Is error handling present for failure paths?\n4. Are types strict? (no any, no ts-ignore)\n5. Is input validation present at boundaries?\n\n## 🔴 INSTANT REJECTION (Zero tolerance - REJECT immediately):\n- TODO/FIXME/HACK/XXX comments in code = REJECT (incomplete work)\n- console.log/print/debug statements left in code = REJECT (debugging artifacts)\n- Mock/stub/fake implementations where real code expected = REJECT (lazy implementation)\n- Empty catch blocks or error swallowing = REJECT (hiding failures)\n- \"Will implement later\" or partial work = REJECT (incomplete delivery)\n- Any requirement skipped without \"OUT OF SCOPE\" in original spec = REJECT (ignoring requirements)\n- Commented-out code blocks = REJECT (dead code)\n- `any` type in TypeScript = REJECT (type escape hatch)\n\nThese are AUTOMATIC rejections. No exceptions. No \"it's mostly done\". The code is either COMPLETE or it's REJECTED.\n\n## BLOCKING Issues (must reject):\n- Missing core functionality\n- Missing error handling for common failures\n- Hardcoded values that should be configurable\n- Crashes on empty/null input\n- Types not strict\n- **ANY priority=MUST criterion that fails**\n\n## NON-BLOCKING Issues (note in summary, don't reject alone):\n- Minor style preferences\n- Could be slightly DRYer\n- Rare edge cases\n- priority=SHOULD/NICE criteria that fail\n\n## Output\n- approved: true if all BLOCKING criteria pass AND all priority=MUST acceptance criteria pass\n- summary: Assessment with blocking and non-blocking issues noted\n- errors: List of BLOCKING issues only\n- criteriaResults: PASS/FAIL for EACH acceptance criterion"
197
227
  },
198
228
  "contextStrategy": {
199
229
  "sources": [
@@ -218,7 +248,8 @@
218
248
  "text": "{{result.summary}}",
219
249
  "data": {
220
250
  "approved": "{{result.approved}}",
221
- "errors": "{{result.errors}}"
251
+ "errors": "{{result.errors}}",
252
+ "criteriaResults": "{{result.criteriaResults}}"
222
253
  }
223
254
  }
224
255
  }
@@ -45,8 +45,9 @@ function parseStreamLine(line) {
45
45
  return parseUserMessage(event.message);
46
46
  }
47
47
 
48
- // result - final task result
48
+ // result - final task result (includes token usage and cost)
49
49
  if (event.type === 'result') {
50
+ const usage = event.usage || {};
50
51
  return {
51
52
  type: 'result',
52
53
  success: event.subtype === 'success',
@@ -54,6 +55,13 @@ function parseStreamLine(line) {
54
55
  error: event.is_error ? event.result : null,
55
56
  cost: event.total_cost_usd,
56
57
  duration: event.duration_ms,
58
+ // Token usage from Claude API
59
+ inputTokens: usage.input_tokens || 0,
60
+ outputTokens: usage.output_tokens || 0,
61
+ cacheReadInputTokens: usage.cache_read_input_tokens || 0,
62
+ cacheCreationInputTokens: usage.cache_creation_input_tokens || 0,
63
+ // Per-model breakdown (for multi-model tasks)
64
+ modelUsage: event.modelUsage || null,
57
65
  };
58
66
  }
59
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@covibes/zeroshot",
3
- "version": "1.1.4",
3
+ "version": "1.3.0",
4
4
  "description": "Multi-agent orchestration engine for Claude - cluster coordinator and CLI",
5
5
  "main": "src/orchestrator.js",
6
6
  "bin": {
@@ -278,8 +278,31 @@ async function executeTask(agent, triggeringMessage) {
278
278
  iteration: agent.iteration,
279
279
  success: true,
280
280
  taskId: agent.currentTaskId,
281
+ tokenUsage: result.tokenUsage || null,
281
282
  });
282
283
 
284
+ // Publish TOKEN_USAGE event for aggregation and tracking
285
+ // CRITICAL: Include taskId for causal linking - allows consumers to group
286
+ // messages by task regardless of interleaved timing from async hooks
287
+ if (result.tokenUsage) {
288
+ agent.messageBus.publish({
289
+ cluster_id: agent.cluster.id,
290
+ topic: 'TOKEN_USAGE',
291
+ sender: agent.id,
292
+ content: {
293
+ text: `${agent.id} used ${result.tokenUsage.inputTokens} input + ${result.tokenUsage.outputTokens} output tokens`,
294
+ data: {
295
+ agentId: agent.id,
296
+ role: agent.role,
297
+ model: agent._selectModel(),
298
+ iteration: agent.iteration,
299
+ taskId: agent.currentTaskId, // Causal linking for message ordering
300
+ ...result.tokenUsage,
301
+ },
302
+ },
303
+ });
304
+ }
305
+
283
306
  // Execute onComplete hook
284
307
  await executeHook({
285
308
  hook: agent.config.hooks?.onComplete,
@@ -54,6 +54,44 @@ function sanitizeErrorMessage(error) {
54
54
  // Track if we've already ensured the AskUserQuestion hook is installed
55
55
  let askUserQuestionHookInstalled = false;
56
56
 
57
+ /**
58
+ * Extract token usage from NDJSON output.
59
+ * Looks for the 'result' event line which contains usage data.
60
+ *
61
+ * @param {string} output - Full NDJSON output from Claude CLI
62
+ * @returns {Object|null} Token usage data or null if not found
63
+ */
64
+ function extractTokenUsage(output) {
65
+ if (!output) return null;
66
+
67
+ const lines = output.split('\n');
68
+
69
+ // Find the result line containing usage data
70
+ for (const line of lines) {
71
+ if (!line.trim()) continue;
72
+
73
+ try {
74
+ const event = JSON.parse(line.trim());
75
+ if (event.type === 'result') {
76
+ const usage = event.usage || {};
77
+ return {
78
+ inputTokens: usage.input_tokens || 0,
79
+ outputTokens: usage.output_tokens || 0,
80
+ cacheReadInputTokens: usage.cache_read_input_tokens || 0,
81
+ cacheCreationInputTokens: usage.cache_creation_input_tokens || 0,
82
+ totalCostUsd: event.total_cost_usd || null,
83
+ durationMs: event.duration_ms || null,
84
+ modelUsage: event.modelUsage || null,
85
+ };
86
+ }
87
+ } catch {
88
+ // Not valid JSON, continue
89
+ }
90
+ }
91
+
92
+ return null;
93
+ }
94
+
57
95
  /**
58
96
  * Ensure the AskUserQuestion blocking hook is installed in user's Claude config.
59
97
  * This adds defense-in-depth by blocking the tool at the Claude CLI level.
@@ -569,6 +607,7 @@ function followClaudeTaskLogs(agent, taskId) {
569
607
  success,
570
608
  output,
571
609
  error: sanitizeErrorMessage(errorContext),
610
+ tokenUsage: extractTokenUsage(output),
572
611
  });
573
612
  }, 500);
574
613
  }
@@ -590,6 +629,7 @@ function followClaudeTaskLogs(agent, taskId) {
590
629
  success: false,
591
630
  output,
592
631
  error: reason,
632
+ tokenUsage: extractTokenUsage(output),
593
633
  });
594
634
  },
595
635
  };
@@ -907,6 +947,7 @@ function followClaudeTaskLogsIsolated(agent, taskId) {
907
947
  output: fullOutput,
908
948
  taskId,
909
949
  result: parsedResult,
950
+ tokenUsage: extractTokenUsage(fullOutput),
910
951
  });
911
952
  }
912
953
  } catch (pollErr) {
package/src/ledger.js CHANGED
@@ -116,6 +116,84 @@ class Ledger extends EventEmitter {
116
116
  }
117
117
  }
118
118
 
119
+ /**
120
+ * Append multiple messages atomically using a transaction
121
+ * All messages get contiguous timestamps and are committed together.
122
+ * If any insert fails, the entire batch is rolled back.
123
+ *
124
+ * Use this for task completion messages to prevent interleaving:
125
+ * - TOKEN_USAGE, TASK_COMPLETED, and hook messages published atomically
126
+ * - Other agents' messages cannot appear between them
127
+ *
128
+ * @param {Array<Object>} messages - Array of message objects
129
+ * @returns {Array<Object>} Array of appended messages with generated IDs
130
+ */
131
+ batchAppend(messages) {
132
+ if (!Array.isArray(messages) || messages.length === 0) {
133
+ return [];
134
+ }
135
+
136
+ // Create transaction function - all inserts happen atomically
137
+ const insertMany = this.db.transaction((msgs) => {
138
+ const results = [];
139
+ const baseTimestamp = Date.now();
140
+
141
+ for (let i = 0; i < msgs.length; i++) {
142
+ const message = msgs[i];
143
+ const id = message.id || `msg_${crypto.randomBytes(16).toString('hex')}`;
144
+ // Use incrementing timestamps to preserve order within batch
145
+ const timestamp = message.timestamp || (baseTimestamp + i);
146
+
147
+ const record = {
148
+ id,
149
+ timestamp,
150
+ topic: message.topic,
151
+ sender: message.sender,
152
+ receiver: message.receiver || 'broadcast',
153
+ content_text: message.content?.text || null,
154
+ content_data: message.content?.data ? JSON.stringify(message.content.data) : null,
155
+ metadata: message.metadata ? JSON.stringify(message.metadata) : null,
156
+ cluster_id: message.cluster_id,
157
+ };
158
+
159
+ this.stmts.insert.run(
160
+ record.id,
161
+ record.timestamp,
162
+ record.topic,
163
+ record.sender,
164
+ record.receiver,
165
+ record.content_text,
166
+ record.content_data,
167
+ record.metadata,
168
+ record.cluster_id
169
+ );
170
+
171
+ results.push(this._deserializeMessage(record));
172
+ }
173
+
174
+ return results;
175
+ });
176
+
177
+ try {
178
+ // Execute transaction (atomic - all or nothing)
179
+ const appendedMessages = insertMany(messages);
180
+
181
+ // Invalidate cache
182
+ this.cache.clear();
183
+
184
+ // Emit events for subscriptions AFTER transaction commits
185
+ // This ensures listeners see consistent state
186
+ for (const fullMessage of appendedMessages) {
187
+ this.emit('message', fullMessage);
188
+ this.emit(`topic:${fullMessage.topic}`, fullMessage);
189
+ }
190
+
191
+ return appendedMessages;
192
+ } catch (error) {
193
+ throw new Error(`Failed to batch append messages: ${error.message}`);
194
+ }
195
+ }
196
+
119
197
  /**
120
198
  * Query messages with filters
121
199
  * @param {Object} criteria - Query criteria
@@ -269,6 +347,77 @@ class Ledger extends EventEmitter {
269
347
  return rows.map((row) => this._deserializeMessage(row));
270
348
  }
271
349
 
350
+ /**
351
+ * Get aggregated token usage by agent role
352
+ * Queries TOKEN_USAGE messages and sums tokens per role
353
+ * @param {String} cluster_id - Cluster ID
354
+ * @returns {Object} Token usage aggregated by role
355
+ * Example: {
356
+ * implementation: { inputTokens: 5000, outputTokens: 2000, totalCostUsd: 0.05, count: 3 },
357
+ * validator: { inputTokens: 3000, outputTokens: 1500, totalCostUsd: 0.03, count: 2 },
358
+ * _total: { inputTokens: 8000, outputTokens: 3500, totalCostUsd: 0.08, count: 5 }
359
+ * }
360
+ */
361
+ getTokensByRole(cluster_id) {
362
+ if (!cluster_id) {
363
+ throw new Error('cluster_id is required for getTokensByRole');
364
+ }
365
+
366
+ // Query all TOKEN_USAGE messages for this cluster
367
+ const sql = `SELECT * FROM messages WHERE cluster_id = ? AND topic = 'TOKEN_USAGE' ORDER BY timestamp ASC`;
368
+ const stmt = this.db.prepare(sql);
369
+ const rows = stmt.all(cluster_id);
370
+
371
+ const byRole = {};
372
+ const total = {
373
+ inputTokens: 0,
374
+ outputTokens: 0,
375
+ cacheReadInputTokens: 0,
376
+ cacheCreationInputTokens: 0,
377
+ totalCostUsd: 0,
378
+ count: 0,
379
+ };
380
+
381
+ for (const row of rows) {
382
+ const message = this._deserializeMessage(row);
383
+ const data = message.content?.data || {};
384
+ const role = data.role || 'unknown';
385
+
386
+ // Initialize role bucket if needed
387
+ if (!byRole[role]) {
388
+ byRole[role] = {
389
+ inputTokens: 0,
390
+ outputTokens: 0,
391
+ cacheReadInputTokens: 0,
392
+ cacheCreationInputTokens: 0,
393
+ totalCostUsd: 0,
394
+ count: 0,
395
+ };
396
+ }
397
+
398
+ // Aggregate tokens for this role
399
+ byRole[role].inputTokens += data.inputTokens || 0;
400
+ byRole[role].outputTokens += data.outputTokens || 0;
401
+ byRole[role].cacheReadInputTokens += data.cacheReadInputTokens || 0;
402
+ byRole[role].cacheCreationInputTokens += data.cacheCreationInputTokens || 0;
403
+ byRole[role].totalCostUsd += data.totalCostUsd || 0;
404
+ byRole[role].count += 1;
405
+
406
+ // Aggregate totals
407
+ total.inputTokens += data.inputTokens || 0;
408
+ total.outputTokens += data.outputTokens || 0;
409
+ total.cacheReadInputTokens += data.cacheReadInputTokens || 0;
410
+ total.cacheCreationInputTokens += data.cacheCreationInputTokens || 0;
411
+ total.totalCostUsd += data.totalCostUsd || 0;
412
+ total.count += 1;
413
+ }
414
+
415
+ // Add total as special _total key
416
+ byRole._total = total;
417
+
418
+ return byRole;
419
+ }
420
+
272
421
  /**
273
422
  * Subscribe to new messages
274
423
  * @param {Function} callback - Called with each new message
@@ -126,6 +126,48 @@ class MessageBus extends EventEmitter {
126
126
  return this.ledger.getAll(cluster_id);
127
127
  }
128
128
 
129
+ /**
130
+ * Publish multiple messages atomically
131
+ * All messages are committed in a single transaction with contiguous timestamps.
132
+ * Use this for task completion to prevent message interleaving between agents.
133
+ *
134
+ * @param {Array<Object>} messages - Array of messages to publish
135
+ * @returns {Array<Object>} Published messages with IDs
136
+ */
137
+ batchPublish(messages) {
138
+ // Validate all messages
139
+ for (const message of messages) {
140
+ if (!message.cluster_id) {
141
+ throw new Error('cluster_id is required for all messages');
142
+ }
143
+ if (!message.topic) {
144
+ throw new Error('topic is required for all messages');
145
+ }
146
+ if (!message.sender) {
147
+ throw new Error('sender is required for all messages');
148
+ }
149
+ }
150
+
151
+ // Delegate to ledger's atomic batchAppend
152
+ const published = this.ledger.batchAppend(messages);
153
+
154
+ // Emit to topic-specific listeners for each message
155
+ for (const msg of published) {
156
+ this.emit(`topic:${msg.topic}`, msg);
157
+ }
158
+
159
+ return published;
160
+ }
161
+
162
+ /**
163
+ * Get aggregated token usage by role (passthrough to ledger)
164
+ * @param {String} cluster_id - Cluster ID
165
+ * @returns {Object} Token usage aggregated by role with _total key
166
+ */
167
+ getTokensByRole(cluster_id) {
168
+ return this.ledger.getTokensByRole(cluster_id);
169
+ }
170
+
129
171
  /**
130
172
  * Register a WebSocket client for broadcasts
131
173
  * @param {WebSocket} ws - WebSocket connection
@@ -8,6 +8,13 @@
8
8
  *
9
9
  * Uses ANSI escape sequences to maintain a fixed footer while
10
10
  * allowing normal terminal output to scroll above it.
11
+ *
12
+ * ROBUST RESIZE HANDLING:
13
+ * - Debounced resize events (100ms) prevent rapid-fire redraws
14
+ * - Render lock prevents concurrent renders from corrupting state
15
+ * - Full footer clear before scroll region reset prevents artifacts
16
+ * - Dimension checkpointing skips unnecessary redraws
17
+ * - Graceful degradation for terminals < 8 rows
11
18
  */
12
19
 
13
20
  const { getProcessMetrics } = require('./process-metrics');
@@ -37,6 +44,20 @@ const COLORS = {
37
44
  bgBlack: `${CSI}40m`,
38
45
  };
39
46
 
47
+ /**
48
+ * Debounce function - prevents rapid-fire calls during resize
49
+ * @param {Function} fn - Function to debounce
50
+ * @param {number} ms - Debounce delay in milliseconds
51
+ * @returns {Function} Debounced function
52
+ */
53
+ function debounce(fn, ms) {
54
+ let timeout;
55
+ return (...args) => {
56
+ clearTimeout(timeout);
57
+ timeout = setTimeout(() => fn(...args), ms);
58
+ };
59
+ }
60
+
40
61
  /**
41
62
  * @typedef {Object} AgentState
42
63
  * @property {string} id - Agent ID
@@ -65,6 +86,17 @@ class StatusFooter {
65
86
  this.clusterId = null;
66
87
  this.clusterState = 'initializing';
67
88
  this.startTime = Date.now();
89
+
90
+ // Robust resize handling state
91
+ this.isRendering = false; // Render lock - prevents concurrent renders
92
+ this.pendingResize = false; // Queue resize if render in progress
93
+ this.lastKnownRows = 0; // Track terminal dimensions for change detection
94
+ this.lastKnownCols = 0;
95
+ this.minRows = 8; // Minimum rows for footer display (graceful degradation)
96
+ this.hidden = false; // True when terminal too small for footer
97
+
98
+ // Debounced resize handler (100ms) - prevents rapid-fire redraws
99
+ this._debouncedResize = debounce(() => this._handleResize(), 100);
68
100
  }
69
101
 
70
102
  /**
@@ -95,22 +127,80 @@ class StatusFooter {
95
127
  process.stdout.write(`${CSI}${row};${col}H`);
96
128
  }
97
129
 
130
+ /**
131
+ * Clear a specific line completely
132
+ * @param {number} row - 1-based row number
133
+ * @private
134
+ */
135
+ _clearLine(row) {
136
+ process.stdout.write(`${CSI}${row};1H${CLEAR_LINE}`);
137
+ }
138
+
139
+ /**
140
+ * Clear all footer lines (uses last known height for safety)
141
+ * @private
142
+ */
143
+ _clearFooterArea() {
144
+ const { rows } = this.getTerminalSize();
145
+ // Use max of current and last footer height to ensure full cleanup
146
+ const heightToClear = Math.max(this.footerHeight, this.lastFooterHeight, 3);
147
+ const startRow = Math.max(1, rows - heightToClear + 1);
148
+
149
+ for (let row = startRow; row <= rows; row++) {
150
+ this._clearLine(row);
151
+ }
152
+ }
153
+
98
154
  /**
99
155
  * Set up scroll region to reserve space for footer
156
+ * ROBUST: Clears footer area first, resets to full screen, then sets new region
100
157
  */
101
158
  setupScrollRegion() {
102
159
  if (!this.isTTY()) return;
103
160
 
104
- const { rows } = this.getTerminalSize();
161
+ const { rows, cols } = this.getTerminalSize();
162
+
163
+ // Graceful degradation: hide footer if terminal too small
164
+ if (rows < this.minRows) {
165
+ if (!this.hidden) {
166
+ this.hidden = true;
167
+ // Reset to full screen scroll
168
+ process.stdout.write(`${CSI}1;${rows}r`);
169
+ this.scrollRegionSet = false;
170
+ }
171
+ return;
172
+ }
173
+
174
+ // Restore footer if terminal grew large enough
175
+ if (this.hidden) {
176
+ this.hidden = false;
177
+ }
178
+
105
179
  const scrollEnd = rows - this.footerHeight;
106
180
 
107
- // Set scroll region (lines 1 to scrollEnd)
181
+ // CRITICAL: Save cursor before any manipulation
182
+ process.stdout.write(SAVE_CURSOR);
183
+ process.stdout.write(HIDE_CURSOR);
184
+
185
+ // Step 1: Reset scroll region to full screen first (prevents artifacts)
186
+ process.stdout.write(`${CSI}1;${rows}r`);
187
+
188
+ // Step 2: Clear footer area completely (prevents ghosting)
189
+ this._clearFooterArea();
190
+
191
+ // Step 3: Set new scroll region (lines 1 to scrollEnd)
108
192
  process.stdout.write(`${CSI}1;${scrollEnd}r`);
109
193
 
110
- // Move cursor to top of scroll region
111
- process.stdout.write(`${CSI}1;1H`);
194
+ // Step 4: Move cursor to bottom of scroll region (safe position)
195
+ process.stdout.write(`${CSI}${scrollEnd};1H`);
196
+
197
+ // Restore cursor and show it
198
+ process.stdout.write(RESTORE_CURSOR);
199
+ process.stdout.write(SHOW_CURSOR);
112
200
 
113
201
  this.scrollRegionSet = true;
202
+ this.lastKnownRows = rows;
203
+ this.lastKnownCols = cols;
114
204
  }
115
205
 
116
206
  /**
@@ -124,6 +214,35 @@ class StatusFooter {
124
214
  this.scrollRegionSet = false;
125
215
  }
126
216
 
217
+ /**
218
+ * Handle terminal resize event
219
+ * Called via debounced wrapper to prevent rapid-fire redraws
220
+ * @private
221
+ */
222
+ _handleResize() {
223
+ if (!this.isTTY()) return;
224
+
225
+ const { rows, cols } = this.getTerminalSize();
226
+
227
+ // Skip if dimensions haven't actually changed (debounce may still fire)
228
+ if (rows === this.lastKnownRows && cols === this.lastKnownCols) {
229
+ return;
230
+ }
231
+
232
+ // If render in progress, queue resize for after
233
+ if (this.isRendering) {
234
+ this.pendingResize = true;
235
+ return;
236
+ }
237
+
238
+ // Update dimensions and reconfigure
239
+ this.lastKnownRows = rows;
240
+ this.lastKnownCols = cols;
241
+
242
+ this.setupScrollRegion();
243
+ this.render();
244
+ }
245
+
127
246
  /**
128
247
  * Register cluster for monitoring
129
248
  * @param {string} clusterId
@@ -223,72 +342,101 @@ class StatusFooter {
223
342
 
224
343
  /**
225
344
  * Render the footer
345
+ * ROBUST: Uses render lock to prevent concurrent renders from corrupting state
226
346
  */
227
347
  async render() {
228
348
  if (!this.enabled || !this.isTTY()) return;
229
349
 
230
- const { rows, cols } = this.getTerminalSize();
350
+ // Graceful degradation: don't render if hidden
351
+ if (this.hidden) return;
231
352
 
232
- // Collect metrics for all agents with PIDs
233
- for (const [agentId, agent] of this.agents) {
234
- if (agent.pid) {
235
- try {
236
- const metrics = await getProcessMetrics(agent.pid, { samplePeriodMs: 500 });
237
- this.metricsCache.set(agentId, metrics);
238
- } catch {
239
- // Process may have exited
240
- this.metricsCache.delete(agentId);
241
- }
242
- }
353
+ // Render lock: prevent concurrent renders
354
+ if (this.isRendering) {
355
+ return;
243
356
  }
357
+ this.isRendering = true;
244
358
 
245
- // Get executing agents for display
246
- const executingAgents = Array.from(this.agents.entries())
247
- .filter(([, agent]) => agent.state === 'executing')
248
- .slice(0, this.maxAgentRows);
359
+ try {
360
+ const { rows, cols } = this.getTerminalSize();
249
361
 
250
- // Calculate dynamic footer height: header + agent rows + separator + summary
251
- // Minimum 3 lines (header + "no agents" message + summary)
252
- const agentRowCount = Math.max(1, executingAgents.length);
253
- const newHeight = 2 + agentRowCount + 1; // header + agents + summary (separator merged with summary)
362
+ // Double-check terminal size (may have changed since last check)
363
+ if (rows < this.minRows) {
364
+ this.hidden = true;
365
+ this.resetScrollRegion();
366
+ return;
367
+ }
254
368
 
255
- // Update scroll region if height changed
256
- if (newHeight !== this.footerHeight) {
257
- this.footerHeight = newHeight;
258
- this.setupScrollRegion();
259
- }
369
+ // Collect metrics for all agents with PIDs
370
+ for (const [agentId, agent] of this.agents) {
371
+ if (agent.pid) {
372
+ try {
373
+ const metrics = await getProcessMetrics(agent.pid, { samplePeriodMs: 500 });
374
+ this.metricsCache.set(agentId, metrics);
375
+ } catch {
376
+ // Process may have exited
377
+ this.metricsCache.delete(agentId);
378
+ }
379
+ }
380
+ }
260
381
 
261
- // Build footer lines
262
- const headerLine = this.buildHeaderLine(cols);
263
- const agentRows = this.buildAgentRows(executingAgents, cols);
264
- const summaryLine = this.buildSummaryLine(cols);
382
+ // Get executing agents for display
383
+ const executingAgents = Array.from(this.agents.entries())
384
+ .filter(([, agent]) => agent.state === 'executing')
385
+ .slice(0, this.maxAgentRows);
386
+
387
+ // Calculate dynamic footer height: header + agent rows + summary
388
+ // Minimum 3 lines (header + "no agents" message + summary)
389
+ const agentRowCount = Math.max(1, executingAgents.length);
390
+ const newHeight = 2 + agentRowCount + 1; // header + agents + summary
391
+
392
+ // Update scroll region if height changed
393
+ if (newHeight !== this.footerHeight) {
394
+ this.lastFooterHeight = this.footerHeight;
395
+ this.footerHeight = newHeight;
396
+ this.setupScrollRegion();
397
+ }
265
398
 
266
- // Save cursor, render footer, restore cursor
267
- process.stdout.write(SAVE_CURSOR);
268
- process.stdout.write(HIDE_CURSOR);
399
+ // Build footer lines
400
+ const headerLine = this.buildHeaderLine(cols);
401
+ const agentRows = this.buildAgentRows(executingAgents, cols);
402
+ const summaryLine = this.buildSummaryLine(cols);
269
403
 
270
- // Render from top of footer area
271
- let currentRow = rows - this.footerHeight + 1;
404
+ // Save cursor, render footer, restore cursor
405
+ process.stdout.write(SAVE_CURSOR);
406
+ process.stdout.write(HIDE_CURSOR);
272
407
 
273
- // Header line
274
- this.moveTo(currentRow++, 1);
275
- process.stdout.write(CLEAR_LINE);
276
- process.stdout.write(`${COLORS.bgBlack}${headerLine}${COLORS.reset}`);
408
+ // Render from top of footer area
409
+ let currentRow = rows - this.footerHeight + 1;
277
410
 
278
- // Agent rows
279
- for (const agentRow of agentRows) {
411
+ // Header line
280
412
  this.moveTo(currentRow++, 1);
281
413
  process.stdout.write(CLEAR_LINE);
282
- process.stdout.write(`${COLORS.bgBlack}${agentRow}${COLORS.reset}`);
283
- }
414
+ process.stdout.write(`${COLORS.bgBlack}${headerLine}${COLORS.reset}`);
415
+
416
+ // Agent rows
417
+ for (const agentRow of agentRows) {
418
+ this.moveTo(currentRow++, 1);
419
+ process.stdout.write(CLEAR_LINE);
420
+ process.stdout.write(`${COLORS.bgBlack}${agentRow}${COLORS.reset}`);
421
+ }
284
422
 
285
- // Summary line (with bottom border)
286
- this.moveTo(currentRow, 1);
287
- process.stdout.write(CLEAR_LINE);
288
- process.stdout.write(`${COLORS.bgBlack}${summaryLine}${COLORS.reset}`);
423
+ // Summary line (with bottom border)
424
+ this.moveTo(currentRow, 1);
425
+ process.stdout.write(CLEAR_LINE);
426
+ process.stdout.write(`${COLORS.bgBlack}${summaryLine}${COLORS.reset}`);
289
427
 
290
- process.stdout.write(RESTORE_CURSOR);
291
- process.stdout.write(SHOW_CURSOR);
428
+ process.stdout.write(RESTORE_CURSOR);
429
+ process.stdout.write(SHOW_CURSOR);
430
+ } finally {
431
+ this.isRendering = false;
432
+
433
+ // Process pending resize if one was queued during render
434
+ if (this.pendingResize) {
435
+ this.pendingResize = false;
436
+ // Use setImmediate to avoid deep recursion
437
+ setImmediate(() => this._handleResize());
438
+ }
439
+ }
292
440
  }
293
441
 
294
442
  /**
@@ -402,7 +550,7 @@ class StatusFooter {
402
550
  parts.push(` ${COLORS.gray}│${COLORS.reset} ${COLORS.dim}${duration}${COLORS.reset}`);
403
551
 
404
552
  // Agent counts
405
- const executing = Array.from(this.agents.values()).filter(a => a.state === 'executing').length;
553
+ const executing = Array.from(this.agents.values()).filter((a) => a.state === 'executing').length;
406
554
  const total = this.agents.size;
407
555
  parts.push(` ${COLORS.gray}│${COLORS.reset} ${COLORS.green}${executing}/${total}${COLORS.reset} active`);
408
556
 
@@ -456,13 +604,21 @@ class StatusFooter {
456
604
  return;
457
605
  }
458
606
 
607
+ // Initialize dimension tracking
608
+ const { rows, cols } = this.getTerminalSize();
609
+ this.lastKnownRows = rows;
610
+ this.lastKnownCols = cols;
611
+
612
+ // Check for graceful degradation at startup
613
+ if (rows < this.minRows) {
614
+ this.hidden = true;
615
+ return; // Don't set up scroll region for tiny terminals
616
+ }
617
+
459
618
  this.setupScrollRegion();
460
619
 
461
- // Handle terminal resize
462
- process.stdout.on('resize', () => {
463
- this.setupScrollRegion();
464
- this.render();
465
- });
620
+ // Handle terminal resize with debounced handler
621
+ process.stdout.on('resize', this._debouncedResize);
466
622
 
467
623
  // Start refresh interval
468
624
  this.intervalId = setInterval(() => {
@@ -482,19 +638,19 @@ class StatusFooter {
482
638
  this.intervalId = null;
483
639
  }
484
640
 
485
- if (this.isTTY()) {
641
+ // Remove resize listener
642
+ process.stdout.removeListener('resize', this._debouncedResize);
643
+
644
+ if (this.isTTY() && !this.hidden) {
486
645
  // Reset scroll region
487
646
  this.resetScrollRegion();
488
647
 
489
- // Clear all footer lines (dynamic height)
490
- const { rows } = this.getTerminalSize();
491
- const startRow = rows - this.footerHeight + 1;
492
- for (let row = startRow; row <= rows; row++) {
493
- this.moveTo(row, 1);
494
- process.stdout.write(CLEAR_LINE);
495
- }
648
+ // Clear all footer lines
649
+ this._clearFooterArea();
496
650
 
497
651
  // Move cursor to safe position
652
+ const { rows } = this.getTerminalSize();
653
+ const startRow = rows - this.footerHeight + 1;
498
654
  this.moveTo(startRow, 1);
499
655
  process.stdout.write(SHOW_CURSOR);
500
656
  }
@@ -507,14 +663,7 @@ class StatusFooter {
507
663
  if (!this.isTTY()) return;
508
664
 
509
665
  this.resetScrollRegion();
510
-
511
- // Clear all footer lines (dynamic height)
512
- const { rows } = this.getTerminalSize();
513
- const startRow = rows - this.footerHeight + 1;
514
- for (let row = startRow; row <= rows; row++) {
515
- this.moveTo(row, 1);
516
- process.stdout.write(CLEAR_LINE);
517
- }
666
+ this._clearFooterArea();
518
667
  }
519
668
 
520
669
  /**
@@ -523,6 +672,14 @@ class StatusFooter {
523
672
  show() {
524
673
  if (!this.isTTY()) return;
525
674
 
675
+ // Reset hidden state and check terminal size
676
+ const { rows } = this.getTerminalSize();
677
+ if (rows < this.minRows) {
678
+ this.hidden = true;
679
+ return;
680
+ }
681
+
682
+ this.hidden = false;
526
683
  this.setupScrollRegion();
527
684
  this.render();
528
685
  }