@orchestrator-claude/cli 3.25.1 → 3.26.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/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/templates/base/claude/agents/debug-sidecar.md +133 -0
- package/dist/templates/base/claude/agents/doc-sidecar.md +60 -0
- package/dist/templates/base/claude/agents/implementer.md +162 -31
- package/dist/templates/base/claude/agents/test-sidecar.md +106 -0
- package/dist/templates/base/claude/hooks/mailbox-listener.ts +246 -0
- package/dist/templates/base/claude/hooks/sprint-registry.ts +85 -0
- package/dist/templates/base/claude/settings.json +19 -0
- package/dist/templates/base/claude/skills/sprint-launch/SKILL.md +176 -0
- package/dist/templates/base/claude/skills/sprint-teammate/sprint-teammate.md +79 -0
- package/dist/templates/base/scripts/lib/SprintLauncher.ts +325 -0
- package/dist/templates/base/scripts/lib/TmuxManager.ts +296 -0
- package/dist/templates/base/scripts/lib/WorktreeIsolator.ts +165 -0
- package/dist/templates/base/scripts/lib/WorktreeManager.ts +106 -0
- package/dist/templates/base/scripts/lib/mailbox/types.ts +175 -0
- package/dist/templates/base/scripts/lib/sidecar/SidecarWatcher.ts +249 -0
- package/dist/templates/base/scripts/lib/sidecar/run.ts +90 -0
- package/dist/templates/base/scripts/sprint-launch.ts +285 -0
- package/package.json +1 -1
- package/templates/base/claude/agents/debug-sidecar.md +133 -0
- package/templates/base/claude/agents/doc-sidecar.md +60 -0
- package/templates/base/claude/agents/implementer.md +162 -31
- package/templates/base/claude/agents/test-sidecar.md +106 -0
- package/templates/base/claude/hooks/mailbox-listener.ts +246 -0
- package/templates/base/claude/hooks/sprint-registry.ts +85 -0
- package/templates/base/claude/settings.json +19 -0
- package/templates/base/claude/skills/sprint-launch/SKILL.md +176 -0
- package/templates/base/claude/skills/sprint-teammate/sprint-teammate.md +79 -0
- package/templates/base/scripts/lib/SprintLauncher.ts +325 -0
- package/templates/base/scripts/lib/TmuxManager.ts +296 -0
- package/templates/base/scripts/lib/WorktreeIsolator.ts +165 -0
- package/templates/base/scripts/lib/WorktreeManager.ts +106 -0
- package/templates/base/scripts/lib/mailbox/types.ts +175 -0
- package/templates/base/scripts/lib/sidecar/SidecarWatcher.ts +249 -0
- package/templates/base/scripts/lib/sidecar/run.ts +90 -0
- package/templates/base/scripts/sprint-launch.ts +285 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorktreeIsolator.ts — Isolates worktree Claude environments (RFC-025)
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* 1. generateClaudeMd(role, worktreePath): Reads .claude/agents/<role>.md and
|
|
6
|
+
* writes a role-specific CLAUDE.md to the worktree with a header wrapper.
|
|
7
|
+
* 2. generateSettingsJson(role, worktreePath): Strips orchestrator hooks, overrides
|
|
8
|
+
* agent field, preserves all other settings, writes to worktree .claude/settings.json.
|
|
9
|
+
* 3. isolateWorktree(role, worktreePath): Convenience method calling both above.
|
|
10
|
+
*
|
|
11
|
+
* DI via factory function pattern (same pattern as gate-guardian.ts in ADR-017 Phase 4).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from 'node:fs';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
|
|
17
|
+
// ─────────────────────────────────────────────────────────────
|
|
18
|
+
// Types
|
|
19
|
+
// ─────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export interface WorktreeIsolatorDeps {
|
|
22
|
+
readFileSync: typeof fs.readFileSync;
|
|
23
|
+
writeFileSync: typeof fs.writeFileSync;
|
|
24
|
+
mkdirSync: typeof fs.mkdirSync;
|
|
25
|
+
existsSync: typeof fs.existsSync;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SprintContext {
|
|
29
|
+
sessionName: string;
|
|
30
|
+
workflowId: string;
|
|
31
|
+
mailboxRole: string;
|
|
32
|
+
task?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface WorktreeIsolator {
|
|
36
|
+
generateClaudeMd(role: string, worktreePath: string, sprintContext?: SprintContext): void;
|
|
37
|
+
generateSettingsJson(role: string, worktreePath: string, sprintContext?: SprintContext): void;
|
|
38
|
+
isolateWorktree(role: string, worktreePath: string, sprintContext?: SprintContext): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Shape of .claude/settings.json (only fields we care about)
|
|
42
|
+
interface ClaudeSettings {
|
|
43
|
+
env?: Record<string, string>;
|
|
44
|
+
permissions?: unknown;
|
|
45
|
+
hooks?: unknown;
|
|
46
|
+
enableAllProjectMcpServers?: boolean;
|
|
47
|
+
enabledPlugins?: unknown;
|
|
48
|
+
alwaysThinkingEnabled?: boolean;
|
|
49
|
+
agent?: string;
|
|
50
|
+
[key: string]: unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─────────────────────────────────────────────────────────────
|
|
54
|
+
// Factory
|
|
55
|
+
// ─────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
const defaultDeps: WorktreeIsolatorDeps = {
|
|
58
|
+
readFileSync: fs.readFileSync,
|
|
59
|
+
writeFileSync: fs.writeFileSync,
|
|
60
|
+
mkdirSync: fs.mkdirSync,
|
|
61
|
+
existsSync: fs.existsSync,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function createWorktreeIsolator(
|
|
65
|
+
projectRoot: string,
|
|
66
|
+
deps: Partial<WorktreeIsolatorDeps> = {},
|
|
67
|
+
): WorktreeIsolator {
|
|
68
|
+
const { readFileSync, writeFileSync, mkdirSync, existsSync } = {
|
|
69
|
+
...defaultDeps,
|
|
70
|
+
...deps,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function generateClaudeMd(role: string, worktreePath: string, sprintContext?: SprintContext): void {
|
|
74
|
+
const capitalised = role.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('-');
|
|
75
|
+
const lines: string[] = [`# ${capitalised} Agent`];
|
|
76
|
+
|
|
77
|
+
if (sprintContext) {
|
|
78
|
+
const taskHint = sprintContext.task
|
|
79
|
+
? `Task hint: ${sprintContext.task} (full task via mailbox)`
|
|
80
|
+
: 'Check mailbox for task assignments';
|
|
81
|
+
lines.push(
|
|
82
|
+
`Sprint: ${sprintContext.sessionName} | Mailbox: ${sprintContext.mailboxRole}`,
|
|
83
|
+
taskHint,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const output = lines.join('\n') + '\n';
|
|
88
|
+
writeFileSync(path.join(worktreePath, 'CLAUDE.md'), output, 'utf-8');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function generateSettingsJson(
|
|
92
|
+
role: string,
|
|
93
|
+
worktreePath: string,
|
|
94
|
+
sprintContext?: SprintContext,
|
|
95
|
+
): void {
|
|
96
|
+
const settingsPath = path.join(projectRoot, '.claude', 'settings.json');
|
|
97
|
+
const raw = readFileSync(settingsPath, 'utf-8') as string;
|
|
98
|
+
const original: ClaudeSettings = JSON.parse(raw);
|
|
99
|
+
|
|
100
|
+
// Build hooks: inject sprint-specific hooks when sprint context is available
|
|
101
|
+
const hooks: Record<string, unknown> = sprintContext
|
|
102
|
+
? {
|
|
103
|
+
SessionStart: [
|
|
104
|
+
{
|
|
105
|
+
matcher: '',
|
|
106
|
+
hooks: [
|
|
107
|
+
{ type: 'command', command: 'npx tsx .claude/hooks/sprint-registry.ts' },
|
|
108
|
+
{
|
|
109
|
+
type: 'command',
|
|
110
|
+
command: 'npx tsx .claude/hooks/mailbox-listener.ts',
|
|
111
|
+
timeout: 3000,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
PostToolUse: [
|
|
117
|
+
{
|
|
118
|
+
matcher: '',
|
|
119
|
+
hooks: [
|
|
120
|
+
{
|
|
121
|
+
type: 'command',
|
|
122
|
+
command: 'npx tsx .claude/hooks/mailbox-listener.ts',
|
|
123
|
+
timeout: 3000,
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
}
|
|
129
|
+
: {};
|
|
130
|
+
|
|
131
|
+
// Merge sprint env vars into existing env when sprint context is present
|
|
132
|
+
const env = sprintContext
|
|
133
|
+
? {
|
|
134
|
+
...(original.env ?? {}),
|
|
135
|
+
SPRINT_ID: sprintContext.sessionName,
|
|
136
|
+
PANE_ROLE: sprintContext.mailboxRole,
|
|
137
|
+
}
|
|
138
|
+
: original.env;
|
|
139
|
+
|
|
140
|
+
const isolated: ClaudeSettings = {
|
|
141
|
+
...original,
|
|
142
|
+
env,
|
|
143
|
+
hooks,
|
|
144
|
+
agent: role,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const claudeDir = path.join(worktreePath, '.claude');
|
|
148
|
+
if (!existsSync(claudeDir)) {
|
|
149
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
writeFileSync(
|
|
153
|
+
path.join(claudeDir, 'settings.json'),
|
|
154
|
+
JSON.stringify(isolated, null, 2),
|
|
155
|
+
'utf-8',
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isolateWorktree(role: string, worktreePath: string, sprintContext?: SprintContext): void {
|
|
160
|
+
generateClaudeMd(role, worktreePath, sprintContext);
|
|
161
|
+
generateSettingsJson(role, worktreePath, sprintContext);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { generateClaudeMd, generateSettingsJson, isolateWorktree };
|
|
165
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import * as childProcess from 'child_process';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export interface WorktreeManagerDeps {
|
|
5
|
+
execSync: typeof childProcess.execSync;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface WorktreeEntry {
|
|
9
|
+
worktreePath: string;
|
|
10
|
+
branchName: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface WorktreeManager {
|
|
14
|
+
prune(): void;
|
|
15
|
+
getCurrentBranch(): string;
|
|
16
|
+
addWorktree(index: number, sessionName: string): string;
|
|
17
|
+
removeWorktree(worktreePath: string): void;
|
|
18
|
+
removeAll(sessionName: string): void;
|
|
19
|
+
getCreatedWorktrees(): string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createWorktreeManager(
|
|
23
|
+
projectRoot: string,
|
|
24
|
+
deps?: Partial<WorktreeManagerDeps>,
|
|
25
|
+
): WorktreeManager {
|
|
26
|
+
const exec = deps?.execSync ?? childProcess.execSync;
|
|
27
|
+
const opts = { encoding: 'utf-8' as const };
|
|
28
|
+
const tracked: WorktreeEntry[] = [];
|
|
29
|
+
|
|
30
|
+
function prune(): void {
|
|
31
|
+
exec(`git -C "${projectRoot}" worktree prune`, opts);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getCurrentBranch(): string {
|
|
35
|
+
const result = exec(`git -C "${projectRoot}" rev-parse --abbrev-ref HEAD`, opts);
|
|
36
|
+
return (result as string).trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function addWorktree(index: number, sessionName: string): string {
|
|
40
|
+
const worktreePath = path.resolve(projectRoot, '..', `wt-${sessionName}-${index}`);
|
|
41
|
+
const branchName = `${sessionName}-impl-${index}`;
|
|
42
|
+
|
|
43
|
+
exec(`git -C "${projectRoot}" worktree add -b "${branchName}" "${worktreePath}" HEAD`, opts);
|
|
44
|
+
tracked.push({ worktreePath, branchName });
|
|
45
|
+
|
|
46
|
+
return worktreePath;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function removeWorktree(worktreePath: string): void {
|
|
50
|
+
try {
|
|
51
|
+
exec(`git -C "${projectRoot}" worktree remove --force "${worktreePath}"`, opts);
|
|
52
|
+
} catch {
|
|
53
|
+
// Worktree may not exist — not an error
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const entryIndex = tracked.findIndex((e) => e.worktreePath === worktreePath);
|
|
57
|
+
if (entryIndex !== -1) {
|
|
58
|
+
const { branchName } = tracked[entryIndex];
|
|
59
|
+
tracked.splice(entryIndex, 1);
|
|
60
|
+
try {
|
|
61
|
+
exec(`git -C "${projectRoot}" branch -d "${branchName}"`, opts);
|
|
62
|
+
} catch {
|
|
63
|
+
// Branch delete failure is non-fatal (e.g. unmerged changes) — warn but do not crash
|
|
64
|
+
console.warn(
|
|
65
|
+
`[WorktreeManager] Could not delete branch "${branchName}" — it may not have been fully merged.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function removeAll(_sessionName: string): void {
|
|
72
|
+
// Snapshot so iteration is stable even if tracked mutates
|
|
73
|
+
const entries = [...tracked];
|
|
74
|
+
|
|
75
|
+
for (const { worktreePath } of entries) {
|
|
76
|
+
try {
|
|
77
|
+
exec(`git -C "${projectRoot}" worktree remove --force "${worktreePath}"`, opts);
|
|
78
|
+
} catch {
|
|
79
|
+
// Continue removing remaining worktrees on partial failure
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const { branchName } of entries) {
|
|
84
|
+
try {
|
|
85
|
+
exec(`git -C "${projectRoot}" branch -D "${branchName}"`, opts);
|
|
86
|
+
} catch {
|
|
87
|
+
// Branch may already be deleted — not an error
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
tracked.splice(0);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getCreatedWorktrees(): string[] {
|
|
95
|
+
return tracked.map((e) => e.worktreePath);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
prune,
|
|
100
|
+
getCurrentBranch,
|
|
101
|
+
addWorktree,
|
|
102
|
+
removeWorktree,
|
|
103
|
+
removeAll,
|
|
104
|
+
getCreatedWorktrees,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* types.ts — Mailbox protocol types and Zod schemas (RFC-025 M1)
|
|
3
|
+
*
|
|
4
|
+
* Tasks:
|
|
5
|
+
* 1.1 — MessageEnvelope + Zod schema
|
|
6
|
+
* 1.2 — MailboxAddress / Role value types
|
|
7
|
+
* 1.3 — Body variant types + Zod sub-schemas
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
|
|
12
|
+
// ─────────────────────────────────────────────────────────────
|
|
13
|
+
// Task 1.2 — Role value type
|
|
14
|
+
// ─────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Role identifies the agent type in the mailbox system.
|
|
18
|
+
* Each role may have multiple instances (differentiated by index in MailboxAddress).
|
|
19
|
+
*/
|
|
20
|
+
export const RoleSchema = z.enum([
|
|
21
|
+
'orchestrator',
|
|
22
|
+
'implementer',
|
|
23
|
+
'planner',
|
|
24
|
+
'reviewer',
|
|
25
|
+
'debug-sidecar',
|
|
26
|
+
'test-sidecar',
|
|
27
|
+
'doc-sidecar',
|
|
28
|
+
]);
|
|
29
|
+
export type Role = z.infer<typeof RoleSchema>;
|
|
30
|
+
|
|
31
|
+
// ─────────────────────────────────────────────────────────────
|
|
32
|
+
// Task 1.2 — MailboxAddress value type
|
|
33
|
+
// ─────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* MailboxAddress uniquely identifies an agent mailbox.
|
|
37
|
+
* Multiple instances of the same role are distinguished by index.
|
|
38
|
+
*
|
|
39
|
+
* @example { role: 'implementer', index: 0 } → "implementer-0"
|
|
40
|
+
*/
|
|
41
|
+
export const MailboxAddressSchema = z.object({
|
|
42
|
+
role: RoleSchema,
|
|
43
|
+
index: z.number().int().nonnegative(),
|
|
44
|
+
});
|
|
45
|
+
export type MailboxAddress = z.infer<typeof MailboxAddressSchema>;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Converts a MailboxAddress to its canonical directory-safe string form.
|
|
49
|
+
* Example: { role: 'implementer', index: 0 } → "implementer-0"
|
|
50
|
+
*/
|
|
51
|
+
export function addressToString(address: MailboxAddress): string {
|
|
52
|
+
return `${address.role}-${address.index}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─────────────────────────────────────────────────────────────
|
|
56
|
+
// Task 1.3 — Body variant schemas
|
|
57
|
+
// ─────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* TaskAssignmentBody — orchestrator → implementer/planner
|
|
61
|
+
* Assigns a specific task with acceptance criteria.
|
|
62
|
+
*/
|
|
63
|
+
export const TaskAssignmentBodySchema = z.object({
|
|
64
|
+
type: z.literal('task_assignment'),
|
|
65
|
+
taskId: z.string().min(1),
|
|
66
|
+
title: z.string().min(1),
|
|
67
|
+
description: z.string(),
|
|
68
|
+
acceptanceCriteria: z.array(z.string()),
|
|
69
|
+
});
|
|
70
|
+
export type TaskAssignmentBody = z.infer<typeof TaskAssignmentBodySchema>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* TaskCompleteBody — implementer/planner → orchestrator
|
|
74
|
+
* Signals that a task has been completed successfully.
|
|
75
|
+
*/
|
|
76
|
+
export const TaskCompleteBodySchema = z.object({
|
|
77
|
+
type: z.literal('task_complete'),
|
|
78
|
+
taskId: z.string().min(1),
|
|
79
|
+
summary: z.string(),
|
|
80
|
+
});
|
|
81
|
+
export type TaskCompleteBody = z.infer<typeof TaskCompleteBodySchema>;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* StatusUpdateBody — implementer/planner → orchestrator
|
|
85
|
+
* Reports incremental progress on a task.
|
|
86
|
+
*/
|
|
87
|
+
export const StatusUpdateBodySchema = z.object({
|
|
88
|
+
type: z.literal('status_update'),
|
|
89
|
+
taskId: z.string().min(1),
|
|
90
|
+
progress: z.number().int().min(0).max(100),
|
|
91
|
+
message: z.string(),
|
|
92
|
+
});
|
|
93
|
+
export type StatusUpdateBody = z.infer<typeof StatusUpdateBodySchema>;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* ErrorReportBody — any → any
|
|
97
|
+
* Reports an error. taskId is optional (error may not be task-scoped).
|
|
98
|
+
*/
|
|
99
|
+
export const ErrorReportBodySchema = z.object({
|
|
100
|
+
type: z.literal('error_report'),
|
|
101
|
+
errorCode: z.string().min(1),
|
|
102
|
+
message: z.string(),
|
|
103
|
+
taskId: z.string().optional(),
|
|
104
|
+
});
|
|
105
|
+
export type ErrorReportBody = z.infer<typeof ErrorReportBodySchema>;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* PingBody — any → any
|
|
109
|
+
* Heartbeat request.
|
|
110
|
+
*/
|
|
111
|
+
export const PingBodySchema = z.object({
|
|
112
|
+
type: z.literal('ping'),
|
|
113
|
+
});
|
|
114
|
+
export type PingBody = z.infer<typeof PingBodySchema>;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* PongBody — any → any
|
|
118
|
+
* Heartbeat response.
|
|
119
|
+
*/
|
|
120
|
+
export const PongBodySchema = z.object({
|
|
121
|
+
type: z.literal('pong'),
|
|
122
|
+
});
|
|
123
|
+
export type PongBody = z.infer<typeof PongBodySchema>;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* MessageBodySchema — discriminated union over all body variants.
|
|
127
|
+
* Discriminant key: `type`.
|
|
128
|
+
*/
|
|
129
|
+
export const MessageBodySchema = z.discriminatedUnion('type', [
|
|
130
|
+
TaskAssignmentBodySchema,
|
|
131
|
+
TaskCompleteBodySchema,
|
|
132
|
+
StatusUpdateBodySchema,
|
|
133
|
+
ErrorReportBodySchema,
|
|
134
|
+
PingBodySchema,
|
|
135
|
+
PongBodySchema,
|
|
136
|
+
]);
|
|
137
|
+
export type MessageBody = z.infer<typeof MessageBodySchema>;
|
|
138
|
+
|
|
139
|
+
// ─────────────────────────────────────────────────────────────
|
|
140
|
+
// Task 1.1 — MessageEnvelope + Zod schema
|
|
141
|
+
// ─────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* MessageEnvelope is the top-level protocol unit exchanged between agents.
|
|
145
|
+
* All messages on the filesystem are JSON-serialized MessageEnvelope objects.
|
|
146
|
+
*/
|
|
147
|
+
export const MessageEnvelopeSchema = z.object({
|
|
148
|
+
/** UUID v4 — unique message identifier */
|
|
149
|
+
id: z.string().uuid(),
|
|
150
|
+
/** Sender address */
|
|
151
|
+
from: MailboxAddressSchema,
|
|
152
|
+
/** Recipient address */
|
|
153
|
+
to: MailboxAddressSchema,
|
|
154
|
+
/** ISO 8601 UTC timestamp of creation */
|
|
155
|
+
timestamp: z.string().datetime(),
|
|
156
|
+
/** Typed message payload */
|
|
157
|
+
body: MessageBodySchema,
|
|
158
|
+
});
|
|
159
|
+
export type MessageEnvelope = z.infer<typeof MessageEnvelopeSchema>;
|
|
160
|
+
|
|
161
|
+
// ─────────────────────────────────────────────────────────────
|
|
162
|
+
// RegistryEntry — sprint agent registration record (RFC-025 M4)
|
|
163
|
+
// ─────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
export const RegistryEntrySchema = z.object({
|
|
166
|
+
role: RoleSchema,
|
|
167
|
+
sprintId: z.string(),
|
|
168
|
+
sessionId: z.string().optional(),
|
|
169
|
+
pid: z.number(),
|
|
170
|
+
registeredAt: z.string().datetime(),
|
|
171
|
+
worktreePath: z.string().optional(),
|
|
172
|
+
workflowId: z.string().optional(),
|
|
173
|
+
resumedAt: z.string().datetime().optional(),
|
|
174
|
+
});
|
|
175
|
+
export type RegistryEntry = z.infer<typeof RegistryEntrySchema>;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SidecarWatcher.ts — Reactive sidecar process for sprint mailbox events (RFC-025 v3.3)
|
|
3
|
+
*
|
|
4
|
+
* A Node.js watcher that listens for incoming mailbox messages and spawns
|
|
5
|
+
* Agent SDK `query()` calls on demand — zero idle resource usage.
|
|
6
|
+
*
|
|
7
|
+
* Architecture:
|
|
8
|
+
* 1. chokidar watches the inbox directory for new .json files
|
|
9
|
+
* 2. On "add" event: read + validate envelope via Zod, then unlink (consume)
|
|
10
|
+
* 3. Build a prompt from the envelope body and call SDK query()
|
|
11
|
+
* 4. Stream query output to stdout (visible in the tmux pane)
|
|
12
|
+
*
|
|
13
|
+
* DI via factory function — same pattern as MailboxWatcher.ts and gate-guardian.ts.
|
|
14
|
+
* All external dependencies (chokidar, SDK, fs) are injected for testability.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import { watch as chokidarWatchFn } from 'chokidar';
|
|
20
|
+
import type { FSWatcher, WatchOptions } from 'chokidar';
|
|
21
|
+
import { query as sdkQuery } from '@anthropic-ai/claude-agent-sdk';
|
|
22
|
+
import type { Options } from '@anthropic-ai/claude-agent-sdk';
|
|
23
|
+
import { MessageEnvelopeSchema } from '../mailbox/types.js';
|
|
24
|
+
import type { MessageEnvelope } from '../mailbox/types.js';
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Types
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Minimal query function signature — accepts prompt + options, returns async iterable.
|
|
32
|
+
* Matches @anthropic-ai/claude-agent-sdk's query() signature.
|
|
33
|
+
*/
|
|
34
|
+
export type QueryFn = (params: {
|
|
35
|
+
prompt: string;
|
|
36
|
+
options?: Options;
|
|
37
|
+
}) => AsyncIterable<unknown>;
|
|
38
|
+
|
|
39
|
+
export interface SidecarWatcherDeps {
|
|
40
|
+
/** Absolute path to the inbox directory to watch. */
|
|
41
|
+
inboxPath: string;
|
|
42
|
+
/** Absolute path to the worktree — passed as cwd to SDK query(). */
|
|
43
|
+
worktreePath: string;
|
|
44
|
+
/** Agent slug passed to SDK query() options.agent. */
|
|
45
|
+
agentSlug: string;
|
|
46
|
+
/** Chokidar watch function (injected for testability). */
|
|
47
|
+
watch: (inboxPath: string, options?: WatchOptions) => FSWatcher;
|
|
48
|
+
/** Agent SDK query function (injected for testability). */
|
|
49
|
+
query: QueryFn;
|
|
50
|
+
/** fs.readFileSync (injected for testability). */
|
|
51
|
+
readFileSync: (filePath: string, encoding: BufferEncoding) => string;
|
|
52
|
+
/** fs.unlinkSync (injected for testability). */
|
|
53
|
+
unlinkSync: (filePath: string) => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SidecarWatcher {
|
|
57
|
+
/** Start watching the inbox directory. Idempotent — calling twice is safe. */
|
|
58
|
+
start(): void;
|
|
59
|
+
/** Stop watching and release resources. Resolves when the watcher is closed. */
|
|
60
|
+
stop(): Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Prompt builder
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Builds the prompt string sent to the Agent SDK from a validated envelope.
|
|
69
|
+
* For task_assignment: includes taskId, title, description, and acceptance criteria.
|
|
70
|
+
* For other body types: includes the serialised body as JSON context.
|
|
71
|
+
*/
|
|
72
|
+
function buildPrompt(envelope: MessageEnvelope): string {
|
|
73
|
+
const { body } = envelope;
|
|
74
|
+
|
|
75
|
+
if (body.type === 'task_assignment') {
|
|
76
|
+
const criteria = body.acceptanceCriteria.length > 0
|
|
77
|
+
? `\n\nAcceptance criteria:\n${body.acceptanceCriteria.map(c => `- ${c}`).join('\n')}`
|
|
78
|
+
: '';
|
|
79
|
+
return [
|
|
80
|
+
`Task: ${body.taskId} — ${body.title}`,
|
|
81
|
+
body.description ? `\n${body.description}` : '',
|
|
82
|
+
criteria,
|
|
83
|
+
].join('');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (body.type === 'status_update') {
|
|
87
|
+
return `Status update for ${body.taskId}: [${body.progress}%] ${body.message}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (body.type === 'task_complete') {
|
|
91
|
+
return `Task ${body.taskId} is complete. Summary: ${body.summary}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (body.type === 'error_report') {
|
|
95
|
+
const taskSuffix = body.taskId ? ` (task: ${body.taskId})` : '';
|
|
96
|
+
return `Error [${body.errorCode}]${taskSuffix}: ${body.message}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ping / pong / unknown — send minimal context
|
|
100
|
+
return `Received message of type "${body.type}" from ${envelope.from.role}-${envelope.from.index}. Acknowledge and stand by.`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// File guard
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
function isMessageFile(filePath: string): boolean {
|
|
108
|
+
const basename = path.basename(filePath);
|
|
109
|
+
return basename.endsWith('.json') && !basename.startsWith('.tmp-');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Factory
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
const defaultDeps: Pick<SidecarWatcherDeps, 'watch' | 'query' | 'readFileSync' | 'unlinkSync'> = {
|
|
117
|
+
watch: chokidarWatchFn,
|
|
118
|
+
query: sdkQuery,
|
|
119
|
+
readFileSync: (p, enc) => fs.readFileSync(p, enc),
|
|
120
|
+
unlinkSync: fs.unlinkSync,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export function createSidecarWatcher(deps: SidecarWatcherDeps): SidecarWatcher {
|
|
124
|
+
const {
|
|
125
|
+
inboxPath,
|
|
126
|
+
worktreePath,
|
|
127
|
+
agentSlug,
|
|
128
|
+
watch,
|
|
129
|
+
query,
|
|
130
|
+
readFileSync,
|
|
131
|
+
unlinkSync,
|
|
132
|
+
} = { ...defaultDeps, ...deps };
|
|
133
|
+
|
|
134
|
+
let fsWatcher: FSWatcher | null = null;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Handles a newly-added file in the inbox.
|
|
138
|
+
* Steps:
|
|
139
|
+
* 1. Guard: skip non-message files
|
|
140
|
+
* 2. Read + parse the envelope JSON
|
|
141
|
+
* 3. Validate against Zod schema
|
|
142
|
+
* 4. Unlink (consume) the file
|
|
143
|
+
* 5. Call SDK query() and drain the stream to stdout
|
|
144
|
+
*/
|
|
145
|
+
async function handleAddedFile(filePath: string): Promise<void> {
|
|
146
|
+
if (!isMessageFile(filePath)) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let envelope: MessageEnvelope;
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
154
|
+
const parsed: unknown = JSON.parse(raw);
|
|
155
|
+
envelope = MessageEnvelopeSchema.parse(parsed);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
// eslint-disable-next-line no-console
|
|
158
|
+
console.error(
|
|
159
|
+
`[sidecar:${agentSlug}] Failed to parse envelope at ${filePath}:`,
|
|
160
|
+
err instanceof Error ? err.message : String(err),
|
|
161
|
+
);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Consume the file — done before query() so re-entry is safe
|
|
166
|
+
try {
|
|
167
|
+
unlinkSync(filePath);
|
|
168
|
+
} catch {
|
|
169
|
+
// Best-effort — continue even if unlink fails (e.g. concurrent consumer)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const prompt = buildPrompt(envelope);
|
|
173
|
+
|
|
174
|
+
// eslint-disable-next-line no-console
|
|
175
|
+
console.log(`[sidecar:${agentSlug}] Processing message ${envelope.id} — calling query()...`);
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
// Defense-in-depth: strip API keys from env passed to query().
|
|
179
|
+
// Primary stripping happens in run.ts (process.env level), but this
|
|
180
|
+
// ensures keys are never passed even if run.ts is bypassed.
|
|
181
|
+
const sidecarEnv: Record<string, string | undefined> = { ...process.env };
|
|
182
|
+
delete sidecarEnv['ANTHROPIC_API_KEY'];
|
|
183
|
+
delete sidecarEnv['CLAUDE_API_KEY'];
|
|
184
|
+
|
|
185
|
+
const stream = query({
|
|
186
|
+
prompt,
|
|
187
|
+
options: {
|
|
188
|
+
agent: agentSlug,
|
|
189
|
+
cwd: worktreePath,
|
|
190
|
+
// Sidecar runs non-interactively — must bypass all permission prompts.
|
|
191
|
+
permissionMode: 'bypassPermissions',
|
|
192
|
+
allowDangerouslySkipPermissions: true,
|
|
193
|
+
// Ephemeral — no need to persist session history for reactive sidecars.
|
|
194
|
+
persistSession: false,
|
|
195
|
+
// Force OAuth — same as `env -u ANTHROPIC_API_KEY` in TmuxManager.
|
|
196
|
+
env: sidecarEnv,
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Drain the stream — log result messages for observability
|
|
201
|
+
for await (const msg of stream) {
|
|
202
|
+
const m = msg as Record<string, unknown>;
|
|
203
|
+
if (m.type === 'result') {
|
|
204
|
+
// eslint-disable-next-line no-console
|
|
205
|
+
console.log(
|
|
206
|
+
`[sidecar:${agentSlug}] query() completed — result: ${m.subtype}`,
|
|
207
|
+
typeof m.result === 'string' ? m.result.slice(0, 200) : '',
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} catch (err) {
|
|
212
|
+
// eslint-disable-next-line no-console
|
|
213
|
+
console.error(
|
|
214
|
+
`[sidecar:${agentSlug}] query() error for message ${envelope.id}:`,
|
|
215
|
+
err instanceof Error ? err.message : String(err),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function start(): void {
|
|
221
|
+
if (fsWatcher !== null) {
|
|
222
|
+
// Already started — idempotent
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
fsWatcher = watch(inboxPath, {
|
|
227
|
+
ignoreInitial: true,
|
|
228
|
+
persistent: true,
|
|
229
|
+
// usePolling fallback: when inotify watchers are exhausted (ENOSPC),
|
|
230
|
+
// chokidar falls back to polling. 1s interval is fine for mailbox events.
|
|
231
|
+
usePolling: true,
|
|
232
|
+
interval: 1000,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
fsWatcher.on('add', (filePath: string) => {
|
|
236
|
+
// Fire-and-forget — errors are caught inside handleAddedFile
|
|
237
|
+
void handleAddedFile(filePath);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function stop(): Promise<void> {
|
|
242
|
+
if (fsWatcher !== null) {
|
|
243
|
+
await fsWatcher.close();
|
|
244
|
+
fsWatcher = null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return { start, stop };
|
|
249
|
+
}
|