claude-comms 1.0.44 → 1.0.46

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 (49) hide show
  1. package/.agents/tools/agent-job/__pycache__/agent_monitor.cpython-312.pyc +0 -0
  2. package/.agents/tools/agent-job/agent_job.py +71 -1
  3. package/.agents/tools/workflow/CONSUMER_SETUP.md +114 -0
  4. package/.agents/tools/workflow/ONBOARD.md +167 -0
  5. package/.agents/tools/workflow/README.md +169 -0
  6. package/.agents/tools/workflow/cli.ts +726 -0
  7. package/.agents/tools/workflow/config.example.json +16 -0
  8. package/.agents/tools/workflow/init.ts +76 -0
  9. package/.agents/tools/workflow/lib/file-tracker.test.ts +81 -0
  10. package/.agents/tools/workflow/lib/file-tracker.ts +387 -0
  11. package/.agents/tools/workflow/lib/harness-executor.ts +649 -0
  12. package/.agents/tools/workflow/lib/prompts.scenario.test.ts +262 -0
  13. package/.agents/tools/workflow/lib/prompts.ts +548 -0
  14. package/.agents/tools/workflow/lib/streams.test.ts +140 -0
  15. package/.agents/tools/workflow/lib/streams.ts +254 -0
  16. package/.agents/tools/workflow/runner.ts +994 -0
  17. package/.agents/tools/workflow/templates/document.md +42 -0
  18. package/.agents/tools/workflow/templates/implement.md +117 -0
  19. package/.agents/tools/workflow/templates/plan.md +54 -0
  20. package/.agents/tools/workflow/templates/pm.md +84 -0
  21. package/.agents/tools/workflow/templates/product-owner.md +211 -0
  22. package/.agents/tools/workflow/templates/review.md +56 -0
  23. package/.agents/tools/workflow/templates/uat.md +71 -0
  24. package/.claude/agents/core/engineer.md +2 -13
  25. package/.claude/commands/action-feedback-item.md +14 -9
  26. package/.claude/hooks/comms/__pycache__/register_subagent.cpython-312.pyc +0 -0
  27. package/.claude/hooks/comms/__pycache__/update_subagent_completion.cpython-312.pyc +0 -0
  28. package/.claude/hooks/utils/__pycache__/server_config.cpython-312.pyc +0 -0
  29. package/.claude/settings.local.json +46 -0
  30. package/CLAUDE.md +1 -1
  31. package/bin/claude-setup.js +3 -4
  32. package/package.json +1 -1
  33. package/src/orchestrator/installer.js +5 -63
  34. package/.claude/agents/core/architect.md +0 -316
  35. package/.claude/agents/core/consultants/codex.md +0 -39
  36. package/.claude/agents/core/consultants/gemini.md +0 -39
  37. package/.claude/agents/core/designer.md +0 -181
  38. package/.claude/agents/core/planner.md +0 -251
  39. package/.claude/agents/core/uat.md +0 -147
  40. package/.claude/agents/util/feedback-executor.md +0 -29
  41. package/.claude/agents/util/meta-agent-builder.md +0 -309
  42. package/.claude/agents/util/session-review.md +0 -228
  43. package/.claude/commands/analyse-document.md +0 -10
  44. package/.claude/commands/await-feedback-orchestrator.md +0 -12
  45. package/.claude/commands/await-feedback.md +0 -21
  46. package/.claude/commands/cook.md +0 -119
  47. package/.claude/commands/feedback.md +0 -140
  48. package/.claude/commands/hello-world.md +0 -1
  49. package/.claude/commands/uat-refine.md +0 -121
@@ -0,0 +1,548 @@
1
+ /**
2
+ * Prompt Building Module
3
+ *
4
+ * Handles template loading and prompt assembly for all job types.
5
+ * Extracted from runner.ts for modularity.
6
+ */
7
+
8
+ import { readFileSync } from "fs";
9
+ import { dirname, join } from "path";
10
+ import { fileURLToPath } from "url";
11
+
12
+ // Types needed for prompt building
13
+ export interface Assignment {
14
+ _id: string;
15
+ _creationTime: number;
16
+ namespaceId: string;
17
+ northStar: string;
18
+ status: "pending" | "active" | "blocked" | "complete";
19
+ blockedReason?: string;
20
+ independent: boolean;
21
+ priority: number;
22
+ artifacts: string;
23
+ decisions: string;
24
+ headGroupId?: string;
25
+ createdAt: number;
26
+ updatedAt: number;
27
+ }
28
+
29
+ export interface JobGroup {
30
+ _id: string;
31
+ _creationTime: number;
32
+ assignmentId: string;
33
+ nextGroupId?: string;
34
+ status: "pending" | "running" | "complete" | "failed";
35
+ aggregatedResult?: string;
36
+ createdAt: number;
37
+ }
38
+
39
+ export interface Job {
40
+ _id: string;
41
+ _creationTime: number;
42
+ groupId: string;
43
+ jobType: string;
44
+ harness: "claude" | "codex" | "gemini";
45
+ context?: string;
46
+ status: "pending" | "running" | "complete" | "failed";
47
+ result?: string;
48
+ prompt?: string;
49
+ startedAt?: number;
50
+ completedAt?: number;
51
+ createdAt: number;
52
+ }
53
+
54
+ export interface ChatMessage {
55
+ _id: string;
56
+ threadId: string;
57
+ role: "user" | "assistant" | "pm";
58
+ content: string;
59
+ createdAt: number;
60
+ }
61
+
62
+ export interface ChatJobContext {
63
+ threadId: string;
64
+ namespaceId: string;
65
+ mode: "jam" | "cook" | "guardian";
66
+ // For differential prompting
67
+ effectivePromptMode: "jam" | "cook";
68
+ lastPromptMode?: "jam" | "cook";
69
+ messages: ChatMessage[];
70
+ latestUserMessage: string;
71
+ claudeSessionId?: string;
72
+ // Guardian mode context
73
+ assignmentId?: string;
74
+ isGuardianEvaluation?: boolean; // True when PO is evaluating PM response
75
+ }
76
+
77
+ // Prompt types for differential prompting
78
+ export type PromptType = "full" | "mode_activation" | "minimal" | "guardian_eval";
79
+
80
+ // Resolve templates directory relative to this module
81
+ const __dirname = dirname(fileURLToPath(import.meta.url));
82
+ const TEMPLATES_DIR = join(__dirname, "..", "templates");
83
+
84
+ /**
85
+ * Load a template file by job type
86
+ */
87
+ export function loadTemplate(jobType: string): string {
88
+ const templatePath = join(TEMPLATES_DIR, `${jobType}.md`);
89
+ try {
90
+ return readFileSync(templatePath, "utf-8");
91
+ } catch {
92
+ console.error(`Template not found: ${jobType}.md`);
93
+ return "Execute the task as described.\n\n{{CONTEXT}}";
94
+ }
95
+ }
96
+
97
+ // Accumulated job result for PM jobs
98
+ export interface AccumulatedJobResult {
99
+ jobType: string;
100
+ harness: string; // Kept for internal tracking, not shown to PM
101
+ result: string;
102
+ groupId?: string;
103
+ groupIndex?: number;
104
+ }
105
+
106
+ /**
107
+ * Format accumulated results for PM prompt
108
+ * Uses jobType with A/B/C suffixes for multiple jobs of same type
109
+ * Does NOT expose harness names - PM sees only jobType labels
110
+ */
111
+ function formatAccumulatedResults(
112
+ accumulatedResults: AccumulatedJobResult[]
113
+ ): string {
114
+ if (!accumulatedResults || accumulatedResults.length === 0) {
115
+ return "(no previous results)";
116
+ }
117
+
118
+ // Group by jobType to determine if we need A/B/C suffixes
119
+ const byJobType = new Map<string, AccumulatedJobResult[]>();
120
+ for (const r of accumulatedResults) {
121
+ const list = byJobType.get(r.jobType) || [];
122
+ list.push(r);
123
+ byJobType.set(r.jobType, list);
124
+ }
125
+
126
+ // Build sections with A/B/C suffixes for duplicates
127
+ const sections: string[] = [];
128
+ const suffixes = ["A", "B", "C", "D", "E", "F", "G", "H"];
129
+
130
+ for (const [jobType, results] of byJobType) {
131
+ if (results.length === 1) {
132
+ // Single job of this type - no suffix
133
+ sections.push(`## ${jobType}\n\n${results[0].result}`);
134
+ } else {
135
+ // Multiple jobs of same type - add A/B/C suffixes
136
+ for (let i = 0; i < results.length; i++) {
137
+ const suffix = suffixes[i] || `${i + 1}`;
138
+ sections.push(`## ${jobType} ${suffix}\n\n${results[i].result}`);
139
+ }
140
+ }
141
+ }
142
+
143
+ return sections.join("\n\n---\n\n");
144
+ }
145
+
146
+ interface GroupedResults {
147
+ groupId: string;
148
+ groupIndex: number;
149
+ results: AccumulatedJobResult[];
150
+ }
151
+
152
+ function groupAccumulatedResults(
153
+ accumulatedResults: AccumulatedJobResult[]
154
+ ): GroupedResults[] {
155
+ if (!accumulatedResults || accumulatedResults.length === 0) {
156
+ return [];
157
+ }
158
+
159
+ const groups = new Map<string, GroupedResults>();
160
+ let fallbackIndex = 0;
161
+
162
+ for (const result of accumulatedResults) {
163
+ const groupId = result.groupId || `unknown-${fallbackIndex}`;
164
+ const groupIndex =
165
+ typeof result.groupIndex === "number" ? result.groupIndex : fallbackIndex;
166
+
167
+ if (!groups.has(groupId)) {
168
+ groups.set(groupId, {
169
+ groupId,
170
+ groupIndex,
171
+ results: [],
172
+ });
173
+ }
174
+
175
+ groups.get(groupId)!.results.push(result);
176
+ fallbackIndex += 1;
177
+ }
178
+
179
+ return Array.from(groups.values()).sort((a, b) => a.groupIndex - b.groupIndex);
180
+ }
181
+
182
+ function isReviewJobType(jobType: string): boolean {
183
+ return jobType === "review" || jobType.endsWith("review");
184
+ }
185
+
186
+ function includesJobType(results: AccumulatedJobResult[], jobType: string): boolean {
187
+ return results.some((r) => r.jobType === jobType);
188
+ }
189
+
190
+ function inferPrimaryJobType(results: AccumulatedJobResult[]): string {
191
+ if (includesJobType(results, "implement")) return "implement";
192
+ if (includesJobType(results, "plan")) return "plan";
193
+ if (includesJobType(results, "document")) return "document";
194
+ if (includesJobType(results, "uat")) return "uat";
195
+ return results[0]?.jobType || "unknown";
196
+ }
197
+
198
+ function buildPmModules(
199
+ accumulatedResults: AccumulatedJobResult[],
200
+ r1GroupResults?: AccumulatedJobResult[]
201
+ ): string {
202
+ const grouped = groupAccumulatedResults(accumulatedResults);
203
+ if (grouped.length === 0) {
204
+ return "No prior job results found. Ask clarifying questions if needed and decide the first actionable job.";
205
+ }
206
+
207
+ const latestGroup = grouped[grouped.length - 1];
208
+ const latestHasReview = latestGroup.results.some((r) => isReviewJobType(r.jobType));
209
+ const latestHasImplement = latestGroup.results.some((r) => r.jobType === "implement");
210
+ const latestHasPlan = latestGroup.results.some((r) => r.jobType === "plan");
211
+
212
+ if (latestHasReview) {
213
+ let reviewGroupIndex = -1;
214
+ for (let i = grouped.length - 1; i >= 0; i--) {
215
+ if (grouped[i].results.some((r) => isReviewJobType(r.jobType))) {
216
+ reviewGroupIndex = i;
217
+ break;
218
+ }
219
+ }
220
+
221
+ const hasR1Override = r1GroupResults && r1GroupResults.length > 0;
222
+ const priorGroup =
223
+ reviewGroupIndex > 0 ? grouped[reviewGroupIndex - 1] : undefined;
224
+ const p1JobType = hasR1Override
225
+ ? inferPrimaryJobType(r1GroupResults!)
226
+ : priorGroup
227
+ ? inferPrimaryJobType(priorGroup.results)
228
+ : "unknown";
229
+ const priorResults = hasR1Override
230
+ ? formatAccumulatedResults(r1GroupResults!)
231
+ : priorGroup
232
+ ? formatAccumulatedResults(priorGroup.results)
233
+ : "(no prior group found)";
234
+
235
+ return `### Post-Review Decision\n\n#### R-1 Context (Group Before Review)\n${priorResults}\n\n#### Decision Logic (P-1 = ${p1JobType})\n1. **If fundamental decisions are required** (cannot be inferred from mental-model + north star with high confidence):\n - Ask clarifying questions\n - Block the assignment until resolved\n2. **If high-severity issues have a clear optimal solution and reviewers concur**:\n - Append a new **${p1JobType}** job to address/refine\n - Include the issues raised and rationale in your PM response\n3. **If only medium/low/no issues and reviewers (and UAT, if present) approve**:\n - Filter issues for alignment with mental model and real product value\n - If **P-1 = plan**: update the plan doc yourself, then append **implement** (or complete if planning-only)\n - If **P-1 = implement**: append **implement** to address items, or append **document** if no further changes are warranted\n\nAlways include the issues raised and your decision rationale.\n`;
236
+ }
237
+
238
+ if (latestHasImplement) {
239
+ return `### Post-Implement Decision\n\n- Append a **review** job to assess the implementation quality and alignment.\n- If the implementation impacts UX, include **uat** in the same job group.\n- Provide clear context for reviewers/UAT (what changed, success criteria, files/flows).\n`;
240
+ }
241
+
242
+ if (latestHasPlan) {
243
+ return `### Post-Plan Decision\n\n- If the plan/spec represents **non-trivial change** (5+ files, backend + frontend, foundational schema, or core system building blocks), append a **review** job.\n- If the plan is **trivial** or already reviewed and approved, append **implement** to execute the full plan.\n - Note: the implement job is powerful and can candle multiple WP and sequence accordingly. Assign suffiecient work to the implement job so it can deliver a full vertical slice (ready for review).\n- Ensure the plan doc path is recorded in artifacts.\n`;
244
+ }
245
+
246
+ return "No matching PM module found. Decide next steps based on the latest results and north star alignment.";
247
+ }
248
+
249
+ /**
250
+ * Build prompt for assignment-based jobs
251
+ * Each job has its own jobType and context
252
+ */
253
+ export function buildPrompt(
254
+ group: JobGroup,
255
+ assignment: Assignment,
256
+ job: Job,
257
+ accumulatedResults: AccumulatedJobResult[],
258
+ previousNonPmGroupResults: AccumulatedJobResult[],
259
+ r1GroupResults: AccumulatedJobResult[]
260
+ ): string {
261
+ const template = loadTemplate(job.jobType);
262
+
263
+ // Format accumulated results for PM/retrospect jobs
264
+ const previousResults =
265
+ job.jobType === "pm" ? accumulatedResults : previousNonPmGroupResults;
266
+ const resultText = formatAccumulatedResults(previousResults);
267
+ const pmModules =
268
+ job.jobType === "pm" ? buildPmModules(accumulatedResults, r1GroupResults) : "";
269
+
270
+ return template
271
+ .replace(/\{\{NORTH_STAR\}\}/g, assignment.northStar)
272
+ .replace(/\{\{ARTIFACTS\}\}/g, assignment.artifacts || "(none)")
273
+ .replace(/\{\{DECISIONS\}\}/g, assignment.decisions || "(none)")
274
+ .replace(/\{\{CONTEXT\}\}/g, job.context || "(no specific context)")
275
+ .replace(/\{\{PREVIOUS_RESULT\}\}/g, resultText)
276
+ .replace(/\{\{ASSIGNMENT_ID\}\}/g, assignment._id)
277
+ .replace(/\{\{GROUP_ID\}\}/g, group._id)
278
+ .replace(/\{\{CURRENT_JOB_ID\}\}/g, job._id)
279
+ .replace(/\{\{HARNESS\}\}/g, job.harness)
280
+ .replace(/\{\{PM_MODULES\}\}/g, pmModules);
281
+ }
282
+
283
+ /**
284
+ * Determine which prompt type to use based on context
285
+ */
286
+ export function determinePromptType(chatContext: ChatJobContext): PromptType {
287
+ const isNewSession = !chatContext.claudeSessionId;
288
+ const isGuardianEval = chatContext.isGuardianEvaluation === true;
289
+
290
+ // Guardian evaluation always gets its special prompt
291
+ if (isGuardianEval) {
292
+ return "guardian_eval";
293
+ }
294
+
295
+ // New session (no claudeSessionId) - send full prompt
296
+ if (isNewSession) {
297
+ return "full";
298
+ }
299
+
300
+ // Check if mode changed since last prompt
301
+ const modeChanged = chatContext.lastPromptMode !== chatContext.effectivePromptMode;
302
+
303
+ if (modeChanged) {
304
+ return "mode_activation";
305
+ }
306
+
307
+ // Same mode, resuming session - minimal prompt
308
+ return "minimal";
309
+ }
310
+
311
+ /**
312
+ * Build prompt for chat jobs with differential prompting
313
+ * - Full prompt for new sessions
314
+ * - Mode activation snippet when mode changes
315
+ * - Minimal prompt for same-mode resume
316
+ * - Guardian eval injection for PM report evaluation
317
+ */
318
+ export function buildChatPrompt(chatContext: ChatJobContext, namespace: string): string {
319
+ const promptType = determinePromptType(chatContext);
320
+ const template = loadTemplate("product-owner");
321
+
322
+ // Variable replacements used across all prompt types
323
+ const replaceVars = (text: string): string => {
324
+ return text
325
+ .replace(/\{\{THREAD_ID\}\}/g, chatContext.threadId)
326
+ .replace(/\{\{NAMESPACE\}\}/g, namespace)
327
+ .replace(/\{\{MODE\}\}/g, chatContext.effectivePromptMode)
328
+ .replace(/\{\{LATEST_MESSAGE\}\}/g, chatContext.latestUserMessage)
329
+ .replace(/\{\{ASSIGNMENT_ID\}\}/g, chatContext.assignmentId || "(no assignment linked)");
330
+ };
331
+
332
+ switch (promptType) {
333
+ case "full":
334
+ return buildFullPrompt(template, chatContext, replaceVars);
335
+
336
+ case "mode_activation":
337
+ return buildModeActivationPrompt(chatContext, replaceVars);
338
+
339
+ case "minimal":
340
+ return buildMinimalPrompt(chatContext, replaceVars);
341
+
342
+ case "guardian_eval":
343
+ return buildGuardianEvalPrompt(template, chatContext, replaceVars);
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Build full prompt for new sessions
349
+ */
350
+ function buildFullPrompt(
351
+ template: string,
352
+ chatContext: ChatJobContext,
353
+ replaceVars: (text: string) => string
354
+ ): string {
355
+ let processed = template;
356
+
357
+ // Remove GUARDIAN_MODE blocks (not applicable for full prompt)
358
+ const guardianModeRegex = /\{\{#if GUARDIAN_MODE\}\}[\s\S]*?\{\{\/if\}\}/g;
359
+ processed = processed.replace(guardianModeRegex, "");
360
+
361
+ // Handle {{#if COOK_MODE}}...{{else}}...{{/if}}
362
+ const cookModeRegex = /\{\{#if COOK_MODE\}\}([\s\S]*?)\{\{else\}\}([\s\S]*?)\{\{\/if\}\}/g;
363
+ processed = processed.replace(cookModeRegex, (_match, cookContent, jamContent) => {
364
+ return chatContext.effectivePromptMode === "cook" ? cookContent : jamContent;
365
+ });
366
+
367
+ // Handle simple {{#if COOK_MODE}}...{{/if}} without else
368
+ const simpleCookModeRegex = /\{\{#if COOK_MODE\}\}([\s\S]*?)\{\{\/if\}\}/g;
369
+ processed = processed.replace(simpleCookModeRegex, (_match, content) => {
370
+ return chatContext.effectivePromptMode === "cook" ? content : "";
371
+ });
372
+
373
+ // Handle {{#if NEW_SESSION}}...{{/if}} - include for new sessions
374
+ const newSessionRegex = /\{\{#if NEW_SESSION\}\}([\s\S]*?)\{\{\/if\}\}/g;
375
+ processed = processed.replace(newSessionRegex, (_match, content) => content);
376
+
377
+ return replaceVars(processed);
378
+ }
379
+
380
+ /**
381
+ * Build mode activation prompt when mode changes during session
382
+ */
383
+ function buildModeActivationPrompt(
384
+ chatContext: ChatJobContext,
385
+ replaceVars: (text: string) => string
386
+ ): string {
387
+ const mode = chatContext.effectivePromptMode.toUpperCase();
388
+
389
+ let modeContent: string;
390
+
391
+ if (chatContext.effectivePromptMode === "cook") {
392
+ modeContent = `## COOK MODE ACTIVATED
393
+
394
+ You now have **FULL AUTONOMY** to take action. Adopt COOK mode behavior until explicitly changed.
395
+
396
+ ### Your Powers in Cook Mode
397
+
398
+ - CREATE new assignments via CLI
399
+ - INSERT jobs into the workflow queue
400
+ - Make product decisions and execute them
401
+
402
+ When the user wants work to be done:
403
+ 1. **Confirm** your understanding of requirements
404
+ 2. **Create** an assignment with a **verbose north star** (include user perspective + success criteria)
405
+ 3. **Insert** an initial job to begin work (usually \`plan\` type)
406
+ 4. **Immediately update** \`docs/project/spec/mental-model.md\` with new insights from the conversation
407
+ 5. **Inform** the user what you've initiated
408
+
409
+ ### CLI Commands Available
410
+
411
+ \`\`\`bash
412
+ # Create a new assignment (auto-linked to this thread)
413
+ npx tsx .agents/tools/workflow/cli.ts create "<north-star-description>" --priority <N>
414
+
415
+ # Insert job(s) - jobs in the same array run in parallel
416
+ npx tsx .agents/tools/workflow/cli.ts insert-job <assignmentId> \\
417
+ --jobs '[{"jobType":"plan","context":"<context>"}]'
418
+
419
+ # Append to existing chain (use --append to link after current tail)
420
+ npx tsx .agents/tools/workflow/cli.ts insert-job <assignmentId> --append \\
421
+ --jobs '[{"jobType":"implement","context":"Build auth"},{"jobType":"uat","context":"Test login"}]'
422
+
423
+ # View assignments and queue
424
+ npx tsx .agents/tools/workflow/cli.ts assignments
425
+ npx tsx .agents/tools/workflow/cli.ts queue
426
+
427
+ # Delete assignment
428
+ npx tsx .agents/tools/workflow/cli.ts delete-assignment <assignmentId>
429
+ \`\`\`
430
+
431
+ ### Job Types You Can Create
432
+
433
+ | Type | Use When |
434
+ |------|----------|
435
+ | \`plan\` | Need a spec doc and work-package breakdown |
436
+ | \`implement\` | Clear requirements ready for implementation |
437
+ | \`review\` | Engineering quality review of plan/spec or implementation |
438
+ | \`uat\` | Need user-perspective testing |
439
+ | \`document\` | Update docs and finalize assignment |`;
440
+ } else {
441
+ modeContent = `## JAM MODE ACTIVATED
442
+
443
+ You are now in **READ-ONLY** ideation mode. Adopt JAM mode behavior until explicitly changed.
444
+
445
+ ### Your Role in Jam Mode
446
+
447
+ - You CANNOT create assignments or jobs
448
+ - You CAN help spec out ideas
449
+ - You CAN ask clarifying questions
450
+ - You CAN suggest approaches and trade-offs
451
+ - You CAN help refine requirements
452
+ - You CAN explore the codebase and existing work
453
+
454
+ Help the user think through their ideas:
455
+ - Ask probing questions to clarify requirements
456
+ - Identify potential challenges and edge cases
457
+ - Suggest technical approaches
458
+ - Help prioritize features
459
+ - Draft acceptance criteria
460
+ - Explore trade-offs between options
461
+
462
+ ### When to Suggest Cook Mode
463
+
464
+ If the user says things like "Let's do it", "Make it happen", "Start working on this" - suggest they switch to **Cook mode** to take action.`;
465
+ }
466
+
467
+ const prompt = `${modeContent}
468
+
469
+ ---
470
+
471
+ ## Current Message
472
+
473
+ **User says:**
474
+ ${chatContext.latestUserMessage}`;
475
+
476
+ return replaceVars(prompt);
477
+ }
478
+
479
+ /**
480
+ * Build minimal prompt for same-mode resume
481
+ */
482
+ function buildMinimalPrompt(
483
+ chatContext: ChatJobContext,
484
+ replaceVars: (text: string) => string
485
+ ): string {
486
+ const prompt = `## Current Message
487
+
488
+ **User says:**
489
+ ${chatContext.latestUserMessage}`;
490
+
491
+ return replaceVars(prompt);
492
+ }
493
+
494
+ /**
495
+ * Build guardian evaluation prompt for PM report evaluation
496
+ */
497
+ function buildGuardianEvalPrompt(
498
+ template: string,
499
+ chatContext: ChatJobContext,
500
+ replaceVars: (text: string) => string
501
+ ): string {
502
+ // Extract the GUARDIAN_MODE block from template
503
+ const guardianModeRegex = /\{\{#if GUARDIAN_MODE\}\}([\s\S]*?)\{\{\/if\}\}/;
504
+ const match = template.match(guardianModeRegex);
505
+
506
+ if (match && match[1]) {
507
+ return replaceVars(match[1].trim());
508
+ }
509
+
510
+ // Fallback if template doesn't have guardian block
511
+ return replaceVars(`## GUARDIAN MODE - ALIGNMENT EVALUATION
512
+
513
+ You are monitoring assignment alignment. A PM has reported on work progress.
514
+
515
+ **Assignment ID:** ${chatContext.assignmentId || "(unknown)"}
516
+
517
+ ### PM Progress Report
518
+ ${chatContext.latestUserMessage}
519
+
520
+ ### Alignment Response
521
+
522
+ Respond with **ONE** of:
523
+
524
+ **🟢** - Trajectory aligned with intent. Just the emoji, nothing else.
525
+
526
+ **🟠** - Uncertain. Include 2-3 sentence rationale explaining the concern.
527
+
528
+ **🔴** - Misaligned. Include rationale and block the assignment.`);
529
+ }
530
+
531
+ /**
532
+ * Parse chat context from job context JSON field
533
+ */
534
+ export function parseChatContext(contextStr: string): ChatJobContext | null {
535
+ try {
536
+ return JSON.parse(contextStr) as ChatJobContext;
537
+ } catch {
538
+ console.error("Failed to parse chat context:", contextStr);
539
+ return null;
540
+ }
541
+ }
542
+
543
+ /**
544
+ * Check if a job is a chat job based on its type
545
+ */
546
+ export function isChatJob(job: Job): boolean {
547
+ return job.jobType === "chat" || job.jobType === "product-owner";
548
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Tests for stream handlers module
3
+ *
4
+ * Run with: npx tsx --test lib/streams.test.ts
5
+ */
6
+
7
+ import { describe, it } from "node:test";
8
+ import assert from "node:assert";
9
+ import {
10
+ ClaudeStreamHandler,
11
+ CodexStreamHandler,
12
+ GeminiStreamHandler,
13
+ buildCommand,
14
+ } from "./streams.js";
15
+
16
+ // ============================================================================
17
+ // ClaudeStreamHandler tests
18
+ // ============================================================================
19
+
20
+ describe("ClaudeStreamHandler", () => {
21
+ it("extracts text and captures session_id on success", () => {
22
+ const handler = new ClaudeStreamHandler();
23
+
24
+ // Simulate event stream
25
+ handler.onEvent({
26
+ type: "assistant",
27
+ message: { content: [{ type: "text", text: "Hello" }] },
28
+ });
29
+ handler.onEvent({
30
+ type: "result",
31
+ subtype: "success",
32
+ result: "Final result",
33
+ session_id: "session_123",
34
+ });
35
+
36
+ assert.strictEqual(handler.getResult(), "Final result");
37
+ assert.strictEqual(handler.isComplete(), true);
38
+ assert.strictEqual(handler.getSessionId(), "session_123");
39
+ });
40
+
41
+ it("does not mark complete on failure", () => {
42
+ const handler = new ClaudeStreamHandler();
43
+
44
+ handler.onEvent({ type: "result", subtype: "error" });
45
+
46
+ assert.strictEqual(handler.isComplete(), false);
47
+ });
48
+ });
49
+
50
+ // ============================================================================
51
+ // CodexStreamHandler tests
52
+ // ============================================================================
53
+
54
+ describe("CodexStreamHandler", () => {
55
+ it("extracts agent_message text and marks complete on turn.completed", () => {
56
+ const handler = new CodexStreamHandler();
57
+
58
+ handler.onEvent({
59
+ type: "item.completed",
60
+ item: { type: "agent_message", text: "First message" },
61
+ });
62
+ handler.onEvent({
63
+ type: "item.completed",
64
+ item: { type: "agent_message", text: "Second message" },
65
+ });
66
+ handler.onEvent({ type: "turn.completed" });
67
+
68
+ assert.ok(handler.getResult().includes("First message"));
69
+ assert.ok(handler.getResult().includes("Second message"));
70
+ assert.strictEqual(handler.isComplete(), true);
71
+ });
72
+
73
+ it("ignores non-agent_message items", () => {
74
+ const handler = new CodexStreamHandler();
75
+
76
+ handler.onEvent({
77
+ type: "item.completed",
78
+ item: { type: "reasoning", text: "Thinking..." },
79
+ });
80
+
81
+ assert.strictEqual(handler.getResult(), "");
82
+ });
83
+ });
84
+
85
+ // ============================================================================
86
+ // GeminiStreamHandler tests
87
+ // ============================================================================
88
+
89
+ describe("GeminiStreamHandler", () => {
90
+ it("accumulates assistant content and marks complete on result", () => {
91
+ const handler = new GeminiStreamHandler();
92
+
93
+ handler.onEvent({ type: "message", role: "assistant", content: "Hello " });
94
+ handler.onEvent({ type: "message", role: "assistant", content: "world" });
95
+ handler.onEvent({ type: "result" });
96
+
97
+ assert.strictEqual(handler.getResult(), "Hello world");
98
+ assert.strictEqual(handler.isComplete(), true);
99
+ });
100
+
101
+ it("ignores non-assistant messages", () => {
102
+ const handler = new GeminiStreamHandler();
103
+
104
+ handler.onEvent({ type: "message", role: "user", content: "User input" });
105
+ handler.onEvent({ type: "tool_use", tool_name: "shell" });
106
+
107
+ assert.strictEqual(handler.getResult(), "");
108
+ });
109
+ });
110
+
111
+ // ============================================================================
112
+ // buildCommand tests
113
+ // ============================================================================
114
+
115
+ describe("buildCommand", () => {
116
+ it("builds claude command with optional session resume", () => {
117
+ const basic = buildCommand("claude", "test");
118
+ assert.strictEqual(basic.cmd, "claude");
119
+ assert.ok(basic.args.includes("--output-format"));
120
+ assert.ok(!basic.args.includes("--resume"));
121
+
122
+ const withSession = buildCommand("claude", "test", { sessionId: "s123" });
123
+ assert.ok(withSession.args.includes("--resume"));
124
+ assert.ok(withSession.args.includes("s123"));
125
+ });
126
+
127
+ it("builds codex and gemini commands", () => {
128
+ const codex = buildCommand("codex", "test");
129
+ assert.strictEqual(codex.cmd, "codex");
130
+ assert.ok(codex.args.includes("--json"));
131
+
132
+ const gemini = buildCommand("gemini", "test");
133
+ assert.strictEqual(gemini.cmd, "gemini");
134
+ assert.ok(gemini.args.includes("stream-json"));
135
+ });
136
+
137
+ it("throws for unknown harness", () => {
138
+ assert.throws(() => buildCommand("unknown", "test"), /Unknown harness/);
139
+ });
140
+ });