claude-multi-session 2.6.0 → 2.9.1

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 ADDED
@@ -0,0 +1,64 @@
1
+ # Changelog
2
+
3
+ All notable changes to claude-multi-session will be documented in this file.
4
+
5
+ ## [2.9.1] - 2026-02-14
6
+ ### Fixed
7
+ - Gate enforcement bug: gate stayed open forever after phase_gate passed, allowing spawns without verification
8
+ - Gate now auto-closes when ALL workers from the current batch go idle
9
+ - Added spawnedWorkers tracking to gate state
10
+ - Test count: 48 → 49 (+1 gate auto-close test)
11
+
12
+ ## [2.9.0] - 2026-02-14
13
+ ### Changed
14
+ - Replaced 120-second batch window timing hack with deterministic gate logic
15
+ - phase_gate now explicitly opens/closes the spawn gate (no timing involved)
16
+ - Gate error message changed from "PHASE GATE REQUIRED" to "GATE CLOSED"
17
+ - phase_gate response now includes `_gate_phase` field instead of `_gate_generation`
18
+ - Test count increased from 46 to 48 (2 new deterministic gate tests)
19
+
20
+ ### Removed
21
+ - 120-second batch window constant
22
+ - Time-based spawn batch detection (lastSpawnTime, spawnBatchCount fields)
23
+
24
+ ## [2.8.0] - 2026-02-13
25
+ ### Added
26
+ - Inbox enforcement — workers MUST call team_check_inbox before artifact/contract tools
27
+ - Auto-status — server auto-sets workers to "active" on spawn, "idle" on stop/kill
28
+ - Convention completeness check in phase_gate (advisory, check 5 of 5)
29
+ - File ownership tracking — 2 new tools: register_files, check_file_owner
30
+ - Roster injection in team_spawn, phase_gate, send_message responses
31
+ - 46 automated enforcement tests in test-enforcement.js
32
+
33
+ ### Fixed
34
+ - roster.filter crash when roster.json contains object instead of array
35
+ - Defensive Array.isArray() guards on all roster operations
36
+
37
+ ## [2.7.0] - 2026-02
38
+ ### Added
39
+ - Gate-aware team_spawn — structural enforcement of phase_gate between spawn batches
40
+ - phase_gate tool with 5 verification checks
41
+ - team_reset tool for clearing state between runs
42
+ - server_version tool for staleness detection
43
+
44
+ ## [2.6.0] - 2026-02
45
+ ### Added
46
+ - Staleness warnings appended to all tool responses when version mismatch detected
47
+
48
+ ## [2.5.0] - 2026-01
49
+ ### Added
50
+ - server_version tool
51
+ - Improved orchestrator prompts
52
+
53
+ ## [2.4.0] - 2026-01
54
+ ### Added
55
+ - Version detection feature
56
+ - artifact_readers tool
57
+
58
+ ## [2.3.0] - 2026-01
59
+ ### Added
60
+ - Initial release with convention system
61
+ - Team coordination tools (team_spawn, team_ask, team_broadcast, etc.)
62
+ - Artifact store with versioning and read tracking
63
+ - Contract store with dependency resolution
64
+ - Session management (spawn, pause, resume, fork, kill)
package/README.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # claude-multi-session
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/claude-multi-session.svg)](https://www.npmjs.com/package/claude-multi-session)
4
+ [![license](https://img.shields.io/npm/l/claude-multi-session.svg)](https://github.com/)
5
+ [![node](https://img.shields.io/node/v/claude-multi-session.svg)](https://nodejs.org)
6
+
7
+ > Multi-session orchestration system for Claude Code CLI. Spawn parallel workers that coordinate via artifacts and phase gates. 68 MCP tools. v2.9.0.
8
+
9
+ ---
10
+
3
11
  **Multi-session orchestrator for Claude Code CLI** — spawn, control, pause, resume, and send multiple inputs to Claude Code sessions programmatically.
4
12
 
5
13
  Built on Claude CLI's `stream-json` protocol for efficient multi-turn conversations using a single long-lived process per session.
package/bin/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  /**
4
4
  * ╔══════════════════════════════════════════════════════════════╗
package/bin/setup.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  /**
4
4
  * ╔══════════════════════════════════════════════════════════════╗
@@ -121,10 +121,15 @@ The tool automatically:
121
121
  2. Validates artifact content and tracks the read as "orchestrator"
122
122
  3. Verifies all previous-phase workers are idle
123
123
  4. Confirms expected consumers actually read the artifacts
124
+ 5. Convention completeness advisory — warns if \`shared-conventions\` artifact is missing or incomplete (checks all 10 checklist keys: responseFormat, errorFormat, statusCodes, namingConventions, filePaths, enumValues, booleanHandling, dateFormat, auditActions, sharedColumnNames)
125
+
126
+ Check 5 is advisory (WARNING, not a gate failure) — the gate still passes/fails based on checks 1-4. But convention warnings are highly visible and should be addressed.
124
127
 
125
128
  Returns a structured pass/fail report with recommendation.
126
129
  PROCEED ONLY IF the report says ALL CHECKS PASSED.
127
130
 
131
+ **ENFORCED:** \`team_spawn\` will return an error if \`phase_gate\` was not called between spawn batches. The first batch (Phase 0) is free; every subsequent batch requires a passing \`phase_gate\` call first.
132
+
128
133
  Count your phases upfront. If you have N phases, fill in this checkpoint exactly N-1 times (between every adjacent pair of phases). Skipping verification for later phases is the #1 cause of test failures.
129
134
 
130
135
  Only intervene in workers when a session is BLOCKED or FAILED.
@@ -132,17 +137,17 @@ Do NOT verify worker output by reading files directly — check artifacts instea
132
137
 
133
138
  ### Rule 5: Always tell workers to publish artifacts
134
139
  Every worker prompt should include instructions to:
135
- 1. Check inbox before starting (\`team_check_inbox\`)
140
+ 1. Check inbox before starting (\`team_check_inbox\`) — **ENFORCED**: artifact and contract tools are blocked until workers call this
136
141
  2. Use \`team_ask\` to communicate with teammates (NOT you)
137
- 3. Publish output as artifacts (\`artifact_publish\`)
142
+ 3. Publish output as artifacts (\`artifact_publish\`) — auto-registers file ownership from \`files\`/\`filesCreated\`/\`filesModified\` in data
138
143
  4. Broadcast completion (\`team_broadcast\`)
139
- 5. Update status to idle when done (\`team_update_status\`)
144
+ 5. ~~Update status to idle when done~~ — **AUTO-MANAGED**: status is automatically set to "active" on spawn and "idle" on stop/kill. Workers only need \`team_update_status\` for custom statuses like "blocked".
140
145
  6. Follow shared conventions defined in Rule 0 (include them in the prompt or reference the conventions artifact)
141
146
 
142
147
  ### Rule 6: Don't fix worker code yourself (pragmatic exception for trivial fixes)
143
148
 
144
149
  === FIX PROTOCOL (when you must fix worker code directly) ===
145
- STOP. Before editing any file a worker created, answer these questions:
150
+ STOP. Before editing any file a worker created, call \`check_file_owner({ file: "path/to/file" })\` to verify ownership, then answer these questions:
146
151
 
147
152
  1. Is this fix ≤ 3 lines?
148
153
  NO → \`send_message\` to worker or spawn fix-worker. Do NOT fix yourself.
@@ -173,19 +178,30 @@ This shows which workers actually read the conventions. If a worker is missing,
173
178
 
174
179
  NEVER trust a worker's self-reported completion — verify the artifact exists yourself.
175
180
 
176
- ## Quick Reference
181
+ ## Auto-Behaviors (v2.7.0)
182
+
183
+ These happen automatically — no action needed from you or the workers:
184
+ - **Roster summary injection**: \`team_spawn\`, \`phase_gate\`, and \`send_message\` (to team workers) responses include a compact roster line: \`[Team: worker-a=active, worker-b=idle]\`
185
+ - **Auto-status management**: Workers are set to "active" on spawn and "idle" when their session stops/is killed
186
+ - **Inbox enforcement**: Workers are blocked from using artifact/contract tools until they call \`team_check_inbox\`
187
+ - **File ownership tracking**: \`artifact_publish\` auto-registers file ownership from \`files\`/\`filesCreated\`/\`filesModified\` arrays in artifact data
188
+ - **Convention completeness check**: \`phase_gate\` warns if \`shared-conventions\` artifact is missing or has incomplete fields
189
+
190
+ ## Quick Reference (68 tools)
177
191
 
178
192
  | You want to... | Use this tool |
179
193
  |----------------|---------------|
180
194
  | Verify tools before starting | \`server_version\` |
181
195
  | Build a multi-person project | \`team_spawn\` (multiple in parallel) |
182
196
  | Run a single isolated task | \`delegate_task\` |
183
- | Check who's working on what | \`team_roster\` |
197
+ | Check who's working on what | \`team_roster\` (also auto-injected in spawn/gate/message responses) |
184
198
  | See published outputs | \`artifact_list\` |
185
199
  | See task completion status | \`contract_list\` |
200
+ | Verify phase completion | \`phase_gate\` |
186
201
  | Send a correction to a worker | \`send_message\` to that session |
187
202
  | Check who read an artifact | \`artifact_readers\` |
188
- | Verify phase completion | \`phase_gate\` |
203
+ | Check file ownership (Rule 6) | \`check_file_owner\` |
204
+ | Register file ownership | \`register_files\` |
189
205
  | Clean up between runs | \`team_reset\` |
190
206
 
191
207
  ### When NOT to Delegate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multi-session",
3
- "version": "2.6.0",
3
+ "version": "2.9.1",
4
4
  "description": "Multi-session orchestrator for Claude Code CLI — spawn, control, pause, resume, and send multiple inputs to Claude Code sessions programmatically",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -17,12 +17,20 @@
17
17
  "keywords": [
18
18
  "claude",
19
19
  "claude-code",
20
+ "anthropic",
20
21
  "session-manager",
21
22
  "multi-session",
22
23
  "orchestrator",
24
+ "ai-agents",
25
+ "multi-agent",
26
+ "agent-coordination",
27
+ "mcp",
28
+ "model-context-protocol",
23
29
  "ai",
24
30
  "automation",
25
- "cli"
31
+ "cli",
32
+ "parallel-tasks",
33
+ "workflow-automation"
26
34
  ],
27
35
  "author": "",
28
36
  "license": "MIT",
@@ -33,6 +41,7 @@
33
41
  "src/",
34
42
  "bin/",
35
43
  "README.md",
44
+ "CHANGELOG.md",
36
45
  "STRATEGY.md",
37
46
  "LICENSE"
38
47
  ],
@@ -111,7 +111,24 @@ class ArtifactStore {
111
111
  if (schema.required && Array.isArray(schema.required)) {
112
112
  for (const field of schema.required) {
113
113
  if (!(field in data)) {
114
- errors.push(`field '${field}' is required`);
114
+ // Build helpful error message with type-specific suggestions
115
+ let errorMsg = `field '${field}' is required`;
116
+
117
+ // Add helpful suggestions for well-known types
118
+ const typeHints = {
119
+ 'api-contract': `type 'api-contract' requires an 'endpoints' field. If this artifact isn't an API contract, use type 'custom' instead.`,
120
+ 'schema-change': `type 'schema-change' requires a 'models' field. If this artifact isn't a database schema change, use type 'custom' instead.`,
121
+ 'test-results': `type 'test-results' requires 'total', 'passed', and 'failed' fields. If this artifact isn't test results, use type 'custom' instead.`,
122
+ 'component-spec': `type 'component-spec' requires a 'componentName' field. If this artifact isn't a component specification, use type 'custom' instead.`,
123
+ 'file-manifest': `type 'file-manifest' requires a 'files' field. If this artifact isn't a file manifest, use type 'custom' instead.`,
124
+ 'config-change': `type 'config-change' requires a 'changes' field. If this artifact isn't a configuration change, use type 'custom' instead.`
125
+ };
126
+
127
+ if (typeHints[type]) {
128
+ errorMsg = `Schema validation failed: ${typeHints[type]}`;
129
+ }
130
+
131
+ errors.push(errorMsg);
115
132
  }
116
133
  }
117
134
  }
@@ -399,10 +416,14 @@ class ArtifactStore {
399
416
  } catch (err) {
400
417
  // Handle race condition - file already exists
401
418
  if (err.code === 'EEXIST') {
402
- // Retry with incremented version
403
- version += 1;
419
+ // Re-read index to get actual latest version
420
+ const freshIndex = readJsonSafe(this.indexPath, {});
421
+ const freshLatest = freshIndex[artifactId]?.latestVersion || version;
422
+ version = freshLatest + 1;
423
+ const updatedData = { ...versionData, version, publishedAt: new Date().toISOString() };
404
424
  const retryPath = path.join(artifactDir, `v${version}.json`);
405
- writeImmutable(retryPath, { ...versionData, version });
425
+ writeImmutable(retryPath, updatedData);
426
+ versionData = updatedData; // Update for index write below
406
427
  } else {
407
428
  throw err;
408
429
  }
package/src/mcp-server.js CHANGED
@@ -76,6 +76,89 @@ let pipelineEngine = null;
76
76
  let snapshotEngine = null;
77
77
  let currentTeamName = null;
78
78
 
79
+ // Phase gate enforcement state — deterministic open/close gate per team
80
+ // team -> { gateOpen: boolean, phase: string|null, spawnCount: number, firstBatchFree: boolean }
81
+ const _gateState = new Map();
82
+
83
+ function getGateState(team) {
84
+ if (!_gateState.has(team)) {
85
+ _gateState.set(team, { gateOpen: false, phase: null, spawnCount: 0, firstBatchFree: true, spawnedWorkers: new Set() });
86
+ }
87
+ return _gateState.get(team);
88
+ }
89
+
90
+ // Inbox check enforcement state — tracks which workers have called team_check_inbox
91
+ // "teamName:workerName" -> boolean (true = checked inbox)
92
+ const _inboxCheckState = new Map();
93
+
94
+ // Worker-to-team mapping — used to auto-update roster status on stop/kill
95
+ // workerName -> teamName
96
+ const _workerTeamMap = new Map();
97
+
98
+ // File ownership tracking — maps filePath → { worker, team } for Rule 6 violation detection
99
+ const _fileOwnership = new Map();
100
+
101
+ /**
102
+ * Check if a caller is a team worker who hasn't checked their inbox yet.
103
+ * Returns an error string if blocked, or null if allowed to proceed.
104
+ */
105
+ function checkInboxEnforcement(teamName, callerName) {
106
+ if (!callerName) return null;
107
+ const key = teamName + ':' + callerName;
108
+ if (_inboxCheckState.has(key) && !_inboxCheckState.get(key)) {
109
+ return 'BLOCKED: You must call team_check_inbox before using other team tools. This is mandatory step 1 of your workflow.';
110
+ }
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * Auto-set a team worker's roster status to idle when their session stops or is killed.
116
+ * Only acts if the worker is a known team worker (exists in _workerTeamMap).
117
+ */
118
+ function autoSetWorkerIdle(workerName) {
119
+ const teamName = _workerTeamMap.get(workerName);
120
+ if (!teamName) return; // Not a team worker
121
+ try {
122
+ const { teamHub } = getTeamInstances(teamName);
123
+ teamHub.updateMember(workerName, { status: 'idle', task: 'Completed' });
124
+
125
+ // Auto-close gate when all spawned workers from current batch are idle
126
+ const gate = getGateState(teamName);
127
+ if (gate.spawnedWorkers.size > 0 && gate.spawnedWorkers.has(workerName)) {
128
+ const roster = teamHub.getRoster();
129
+ const safeRoster = Array.isArray(roster) ? roster : [];
130
+ const allIdle = [...gate.spawnedWorkers].every(name => {
131
+ const member = safeRoster.find(m => m.name === name);
132
+ return member && member.status === 'idle';
133
+ });
134
+ if (allIdle) {
135
+ gate.gateOpen = false;
136
+ gate.firstBatchFree = false;
137
+ gate.spawnedWorkers = new Set();
138
+ }
139
+ }
140
+ } catch (_) {
141
+ // Best-effort — don't fail the stop/kill if roster update fails
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Build a compact one-line roster summary for a team.
147
+ * Returns e.g. "[Team: worker-a=active, worker-b=idle]" or "" if empty/error.
148
+ */
149
+ function getRosterSummary(teamName) {
150
+ try {
151
+ const { teamHub } = getTeamInstances(teamName);
152
+ const roster = teamHub.getRoster();
153
+ // Defensive: ensure roster is an array
154
+ if (!Array.isArray(roster) || roster.length === 0) return '';
155
+ const pairs = roster.map(m => m.name + '=' + (m.status || 'unknown'));
156
+ return '\n[Team: ' + pairs.join(', ') + ']';
157
+ } catch (_) {
158
+ return '';
159
+ }
160
+ }
161
+
79
162
  /**
80
163
  * Get or create Team Hub instances for a given team
81
164
  * This function lazily creates the instances on first use
@@ -995,6 +1078,38 @@ const TOOLS = [
995
1078
  },
996
1079
  },
997
1080
 
1081
+ // ── File Ownership (Rule 6 enforcement) ────────────────────────────────
1082
+ {
1083
+ name: 'register_files',
1084
+ description:
1085
+ 'Register file ownership for a worker. Call this after creating or modifying files ' +
1086
+ 'so the system can track who owns which files and warn about Rule 6 violations.',
1087
+ inputSchema: {
1088
+ type: 'object',
1089
+ properties: {
1090
+ worker: { type: 'string', description: 'Worker session name that owns these files' },
1091
+ files: { type: 'array', items: { type: 'string' }, description: 'Array of file paths to register' },
1092
+ team: { type: 'string', description: 'Team name (default: "default")' },
1093
+ },
1094
+ required: ['worker', 'files'],
1095
+ },
1096
+ },
1097
+
1098
+ {
1099
+ name: 'check_file_owner',
1100
+ description:
1101
+ 'Check who owns a file. Use this before directly editing a file to verify ' +
1102
+ 'whether it belongs to a worker (Rule 6: don\'t fix worker code yourself).',
1103
+ inputSchema: {
1104
+ type: 'object',
1105
+ properties: {
1106
+ file: { type: 'string', description: 'File path to check ownership of' },
1107
+ team: { type: 'string', description: 'Team name (default: "default")' },
1108
+ },
1109
+ required: ['file'],
1110
+ },
1111
+ },
1112
+
998
1113
  // ── Session Continuity (Layer 0) ──────────────────────────────────────
999
1114
  {
1000
1115
  name: 'continuity_snapshot',
@@ -1333,6 +1448,12 @@ async function executeTool(toolName, args) {
1333
1448
  case 'team_reset':
1334
1449
  return handleTeamReset(args);
1335
1450
 
1451
+ // ── File Ownership handlers ──
1452
+ case 'register_files':
1453
+ return handleRegisterFiles(args);
1454
+ case 'check_file_owner':
1455
+ return handleCheckFileOwner(args);
1456
+
1336
1457
  // ── Session Continuity (Layer 0) handlers ──
1337
1458
  case 'continuity_snapshot': {
1338
1459
  const snap = new SessionSnapshot(args.projectPath);
@@ -1460,28 +1581,46 @@ async function handleSpawn(args) {
1460
1581
  }
1461
1582
 
1462
1583
  async function handleSend(args) {
1463
- // Auto-resume if session is not alive
1464
- if (!manager.sessions.has(args.name)) {
1465
- log(`Session "${args.name}" not alive. Auto-resuming...`);
1466
- const response = await manager.resume(args.name, args.message);
1584
+ try {
1585
+ // Check if target is a team worker for roster summary injection
1586
+ const workerTeam = _workerTeamMap.get(args.name);
1587
+ const rosterSuffix = workerTeam ? getRosterSummary(workerTeam) : '';
1588
+
1589
+ // Auto-resume if session is not alive
1590
+ if (!manager.sessions.has(args.name)) {
1591
+ log(`Session "${args.name}" not alive. Auto-resuming...`);
1592
+ const response = await manager.resume(args.name, args.message);
1593
+ return textResult(JSON.stringify({
1594
+ session_name: args.name,
1595
+ auto_resumed: true,
1596
+ response: response?.text || '',
1597
+ cost: response?.cost || 0,
1598
+ turns: response?.turns || 0,
1599
+ duration_ms: response?.duration || 0,
1600
+ }, null, 2) + rosterSuffix);
1601
+ }
1602
+
1603
+ const response = await manager.send(args.name, args.message);
1467
1604
  return textResult(JSON.stringify({
1468
1605
  session_name: args.name,
1469
- auto_resumed: true,
1470
- response: response?.text || '',
1471
- cost: response?.cost || 0,
1472
- turns: response?.turns || 0,
1473
- duration_ms: response?.duration || 0,
1474
- }, null, 2));
1606
+ response: response.text,
1607
+ cost: response.cost,
1608
+ turns: response.turns,
1609
+ duration_ms: response.duration,
1610
+ }, null, 2) + rosterSuffix);
1611
+ } catch (err) {
1612
+ // Try to set worker status to error if this is a team worker
1613
+ try {
1614
+ const workerTeam = _workerTeamMap.get(args.name);
1615
+ if (workerTeam) {
1616
+ const { teamHub } = getTeamInstances(workerTeam);
1617
+ teamHub.updateMember(args.name, { status: 'error', task: `Failed: ${err.message}` });
1618
+ }
1619
+ } catch (e) {
1620
+ // Ignore cleanup errors
1621
+ }
1622
+ return errorResult(err.message);
1475
1623
  }
1476
-
1477
- const response = await manager.send(args.name, args.message);
1478
- return textResult(JSON.stringify({
1479
- session_name: args.name,
1480
- response: response.text,
1481
- cost: response.cost,
1482
- turns: response.turns,
1483
- duration_ms: response.duration,
1484
- }, null, 2));
1485
1624
  }
1486
1625
 
1487
1626
  async function handleResume(args) {
@@ -1521,6 +1660,10 @@ async function handleFork(args) {
1521
1660
 
1522
1661
  function handleStop(args) {
1523
1662
  manager.stop(args.name);
1663
+
1664
+ // Auto-set team worker status to idle when stopped
1665
+ autoSetWorkerIdle(args.name);
1666
+
1524
1667
  return textResult(JSON.stringify({
1525
1668
  session_name: args.name,
1526
1669
  status: 'stopped',
@@ -1530,6 +1673,10 @@ function handleStop(args) {
1530
1673
 
1531
1674
  function handleKill(args) {
1532
1675
  manager.kill(args.name);
1676
+
1677
+ // Auto-set team worker status to idle when killed
1678
+ autoSetWorkerIdle(args.name);
1679
+
1533
1680
  return textResult(JSON.stringify({
1534
1681
  session_name: args.name,
1535
1682
  status: 'killed',
@@ -1748,6 +1895,29 @@ async function handleTeamSpawn(args) {
1748
1895
  const teamName = args.team || 'default';
1749
1896
  const { teamHub } = getTeamInstances(teamName);
1750
1897
 
1898
+ // Deterministic gate enforcement: phase_gate opens gate, spawns go through, next phase_gate closes and reopens
1899
+ const gate = getGateState(teamName);
1900
+
1901
+ if (gate.firstBatchFree) {
1902
+ // First batch before any phase_gate call — always allowed
1903
+ gate.spawnCount++;
1904
+ gate.spawnedWorkers.add(args.name);
1905
+ } else if (gate.gateOpen) {
1906
+ // Gate was opened by a passing phase_gate — allowed
1907
+ gate.spawnCount++;
1908
+ gate.spawnedWorkers.add(args.name);
1909
+ } else {
1910
+ // Gate is closed — block the spawn
1911
+ return errorResult(
1912
+ `GATE CLOSED: Cannot spawn workers. ` +
1913
+ `Call phase_gate to verify phase "${gate.phase || 'previous'}" is complete before spawning the next batch.\n\n` +
1914
+ `This is structural enforcement of Rule 4 (Phase Gate Verification).\n\n` +
1915
+ `Example:\n` +
1916
+ `phase_gate({ phase_completing: "${gate.phase || 'Phase N'}", phase_starting: "Phase N+1", ` +
1917
+ `expected_artifacts: ["..."] })`
1918
+ );
1919
+ }
1920
+
1751
1921
  // Join the team roster
1752
1922
  teamHub.joinTeam(args.name, {
1753
1923
  role: args.role || 'team member',
@@ -1755,6 +1925,12 @@ async function handleTeamSpawn(args) {
1755
1925
  model: args.model || 'sonnet',
1756
1926
  });
1757
1927
 
1928
+ // Track inbox check enforcement — worker must call team_check_inbox before using artifact/contract tools
1929
+ _inboxCheckState.set(teamName + ':' + args.name, false);
1930
+
1931
+ // Map worker name to team for auto-status updates on stop/kill
1932
+ _workerTeamMap.set(args.name, teamName);
1933
+
1758
1934
  // Get roster to build system prompt
1759
1935
  const roster = teamHub.getRoster();
1760
1936
 
@@ -1771,6 +1947,13 @@ async function handleTeamSpawn(args) {
1771
1947
  workDir: args.work_dir || process.cwd(),
1772
1948
  });
1773
1949
 
1950
+ // Auto-set worker status to active with their task description
1951
+ const taskDesc = args.task || args.prompt || 'Starting up';
1952
+ teamHub.updateMember(args.name, {
1953
+ status: 'active',
1954
+ task: taskDesc.length > 100 ? taskDesc.slice(0, 100) + '...' : taskDesc,
1955
+ });
1956
+
1774
1957
  const result = {
1775
1958
  session_name: session.name,
1776
1959
  status: session.status,
@@ -1792,8 +1975,16 @@ async function handleTeamSpawn(args) {
1792
1975
  result._staleness_warning = `Server is stale! ${staleness.trim()}`;
1793
1976
  }
1794
1977
 
1795
- return textResult(JSON.stringify(result, null, 2));
1978
+ return textResult(JSON.stringify(result, null, 2) + getRosterSummary(teamName));
1796
1979
  } catch (err) {
1980
+ // Try to set worker status to error so phase_gate doesn't hang
1981
+ try {
1982
+ const teamName = args.team || 'default';
1983
+ const { teamHub } = getTeamInstances(teamName);
1984
+ teamHub.updateMember(args.name, { status: 'error', task: `Failed: ${err.message}` });
1985
+ } catch (e) {
1986
+ // Ignore cleanup errors
1987
+ }
1797
1988
  return errorResult(err.message);
1798
1989
  }
1799
1990
  }
@@ -1843,6 +2034,12 @@ function handleTeamCheckInbox(args) {
1843
2034
  const teamName = args.team || 'default';
1844
2035
  const { teamHub } = getTeamInstances(teamName);
1845
2036
 
2037
+ // Mark this worker as having checked their inbox
2038
+ const inboxKey = teamName + ':' + args.name;
2039
+ if (_inboxCheckState.has(inboxKey)) {
2040
+ _inboxCheckState.set(inboxKey, true);
2041
+ }
2042
+
1846
2043
  const messages = teamHub.getInbox(args.name, {
1847
2044
  markRead: args.mark_read !== false,
1848
2045
  limit: args.limit || 20,
@@ -1929,11 +2126,13 @@ function handleTeamRoster(args) {
1929
2126
  const { teamHub } = getTeamInstances(teamName);
1930
2127
 
1931
2128
  const roster = teamHub.getRoster();
2129
+ // Defensive: ensure roster is an array
2130
+ const safeRoster = Array.isArray(roster) ? roster : [];
1932
2131
 
1933
2132
  return textResult(JSON.stringify({
1934
2133
  team: teamName,
1935
- count: roster.length,
1936
- members: roster.map(m => ({
2134
+ count: safeRoster.length,
2135
+ members: safeRoster.map(m => ({
1937
2136
  name: m.name,
1938
2137
  role: m.role,
1939
2138
  status: m.status,
@@ -1976,6 +2175,11 @@ function handleTeamUpdateStatus(args) {
1976
2175
  async function handleArtifactPublish(args) {
1977
2176
  try {
1978
2177
  const teamName = args.team || 'default';
2178
+
2179
+ // Inbox check enforcement — workers must call team_check_inbox first
2180
+ const inboxBlock = checkInboxEnforcement(teamName, args.publisher);
2181
+ if (inboxBlock) return errorResult(inboxBlock);
2182
+
1979
2183
  const { artifactStore, resolver } = getTeamInstances(teamName);
1980
2184
 
1981
2185
  // Publish the artifact
@@ -1998,13 +2202,32 @@ async function handleArtifactPublish(args) {
1998
2202
  data: args.data,
1999
2203
  });
2000
2204
 
2001
- return textResult(JSON.stringify({
2205
+ // Auto-register file ownership from artifact data
2206
+ let filesRegistered = 0;
2207
+ if (args.data && args.publisher) {
2208
+ const fileLists = [args.data.files, args.data.filesCreated, args.data.filesModified].filter(Array.isArray);
2209
+ for (const list of fileLists) {
2210
+ for (const filePath of list) {
2211
+ if (typeof filePath === 'string') {
2212
+ _fileOwnership.set(filePath, { worker: args.publisher, team: teamName });
2213
+ filesRegistered++;
2214
+ }
2215
+ }
2216
+ }
2217
+ }
2218
+
2219
+ const publishResult = {
2002
2220
  status: 'published',
2003
2221
  artifactId: result.artifactId,
2004
2222
  version: result.version,
2005
2223
  type: result.type,
2006
2224
  team: teamName,
2007
- }, null, 2));
2225
+ };
2226
+ if (filesRegistered > 0) {
2227
+ publishResult.filesRegistered = filesRegistered;
2228
+ }
2229
+
2230
+ return textResult(JSON.stringify(publishResult, null, 2));
2008
2231
  } catch (err) {
2009
2232
  return errorResult(err.message);
2010
2233
  }
@@ -2013,6 +2236,11 @@ async function handleArtifactPublish(args) {
2013
2236
  function handleArtifactGet(args) {
2014
2237
  try {
2015
2238
  const teamName = args.team || 'default';
2239
+
2240
+ // Inbox check enforcement — workers must call team_check_inbox first
2241
+ const inboxBlock = checkInboxEnforcement(teamName, args.reader);
2242
+ if (inboxBlock) return errorResult(inboxBlock);
2243
+
2016
2244
  const { artifactStore } = getTeamInstances(teamName);
2017
2245
 
2018
2246
  const artifact = artifactStore.get(args.artifactId, args.version);
@@ -2137,6 +2365,11 @@ function handleArtifactHistory(args) {
2137
2365
  async function handleContractCreate(args) {
2138
2366
  try {
2139
2367
  const teamName = args.team || 'default';
2368
+
2369
+ // Inbox check enforcement — workers must call team_check_inbox first
2370
+ const inboxBlock = checkInboxEnforcement(teamName, args.assigner);
2371
+ if (inboxBlock) return errorResult(inboxBlock);
2372
+
2140
2373
  const { contractStore, resolver, teamHub } = getTeamInstances(teamName);
2141
2374
 
2142
2375
  // Create the contract
@@ -2189,6 +2422,13 @@ function handleContractStart(args) {
2189
2422
  const teamName = args.team || 'default';
2190
2423
  const { contractStore } = getTeamInstances(teamName);
2191
2424
 
2425
+ // Inbox check enforcement — look up the contract's assignee to check
2426
+ const contractData = contractStore.get(args.contractId);
2427
+ if (contractData) {
2428
+ const inboxBlock = checkInboxEnforcement(teamName, contractData.assignee);
2429
+ if (inboxBlock) return errorResult(inboxBlock);
2430
+ }
2431
+
2192
2432
  const contract = contractStore.start(args.contractId);
2193
2433
 
2194
2434
  return textResult(JSON.stringify({
@@ -2703,6 +2943,8 @@ function handlePhaseGate(args) {
2703
2943
 
2704
2944
  // Check 3: Workers idle
2705
2945
  const roster = teamHub.getRoster();
2946
+ // Defensive: ensure roster is an array
2947
+ const safeRoster = Array.isArray(roster) ? roster : [];
2706
2948
  const idleCheck = {
2707
2949
  check: 'workers_idle',
2708
2950
  results: [],
@@ -2710,8 +2952,8 @@ function handlePhaseGate(args) {
2710
2952
  };
2711
2953
 
2712
2954
  const workersToCheck = args.expected_idle
2713
- ? roster.filter(m => args.expected_idle.includes(m.name))
2714
- : roster;
2955
+ ? safeRoster.filter(m => args.expected_idle.includes(m.name))
2956
+ : safeRoster;
2715
2957
 
2716
2958
  for (const member of workersToCheck) {
2717
2959
  const isIdle = member.status === 'idle';
@@ -2766,8 +3008,58 @@ function handlePhaseGate(args) {
2766
3008
  }
2767
3009
  report.checks.push(readerCheck);
2768
3010
 
2769
- // Overall pass/fail
2770
- report.passed = report.checks.every(c => c.passed);
3011
+ // Check 5: Convention completeness (advisory — does NOT affect pass/fail)
3012
+ const requiredConventionFields = [
3013
+ 'responseFormat', 'errorFormat', 'statusCodes', 'namingConventions',
3014
+ 'filePaths', 'enumValues', 'booleanHandling', 'dateFormat',
3015
+ 'auditActions', 'sharedColumnNames',
3016
+ ];
3017
+ const conventionCheck = {
3018
+ check: 'convention_completeness',
3019
+ advisory: true,
3020
+ };
3021
+
3022
+ const conventionArtifact = artifactStore.get('shared-conventions');
3023
+ if (!conventionArtifact) {
3024
+ conventionCheck.status = 'WARNING';
3025
+ conventionCheck.message = "No 'shared-conventions' artifact found. Recommend publishing conventions before spawning workers.";
3026
+ } else if (!conventionArtifact.data) {
3027
+ conventionCheck.status = 'WARNING';
3028
+ conventionCheck.message = "Convention artifact exists but has no data. Recommend re-publishing with all 10 fields.";
3029
+ } else {
3030
+ // Check that all required fields exist and are non-empty
3031
+ const missing = requiredConventionFields.filter(f => {
3032
+ if (!(f in conventionArtifact.data)) return true;
3033
+ const value = conventionArtifact.data[f];
3034
+ if (typeof value === 'string' && value.trim() === '') return true;
3035
+ return false;
3036
+ });
3037
+
3038
+ if (missing.length > 0) {
3039
+ conventionCheck.status = 'WARNING';
3040
+ conventionCheck.missingConventions = missing;
3041
+ conventionCheck.message = `Convention artifact missing ${missing.length}/10 fields: ${missing.join(', ')}. Incomplete conventions cause format mismatches between workers.`;
3042
+ } else {
3043
+ conventionCheck.status = 'PASS';
3044
+ conventionCheck.message = 'All 10 conventions defined.';
3045
+ }
3046
+ }
3047
+ report.checks.push(conventionCheck);
3048
+
3049
+ // Overall pass/fail (convention check is advisory, excluded from gate decision)
3050
+ report.passed = report.checks.filter(c => !c.advisory).every(c => c.passed);
3051
+
3052
+ // Deterministic gate: on pass, close current gate and open for next phase
3053
+ if (report.passed) {
3054
+ const gate = getGateState(teamName);
3055
+ gate.firstBatchFree = false;
3056
+ gate.gateOpen = true;
3057
+ gate.phase = args.phase_starting || null;
3058
+ gate.spawnCount = 0;
3059
+ gate.spawnedWorkers = new Set();
3060
+ _gateState.set(teamName, gate);
3061
+ report._gate_phase = gate.phase;
3062
+ }
2771
3063
 
2772
3064
  // Action recommendation
2773
3065
  if (report.passed) {
@@ -2777,7 +3069,56 @@ function handlePhaseGate(args) {
2777
3069
  report.recommendation = `BLOCKED: ${failures.join(', ')} failed. Fix these before proceeding to ${args.phase_starting}.`;
2778
3070
  }
2779
3071
 
2780
- return textResult(JSON.stringify(report, null, 2));
3072
+ return textResult(JSON.stringify(report, null, 2) + getRosterSummary(teamName));
3073
+ } catch (err) {
3074
+ return errorResult(err.message);
3075
+ }
3076
+ }
3077
+
3078
+ // ── File Ownership Handlers ────────────────────────────────────────────────
3079
+
3080
+ function handleRegisterFiles(args) {
3081
+ try {
3082
+ const teamName = args.team || 'default';
3083
+ let registered = 0;
3084
+
3085
+ for (const filePath of args.files) {
3086
+ if (typeof filePath === 'string') {
3087
+ _fileOwnership.set(filePath, { worker: args.worker, team: teamName });
3088
+ registered++;
3089
+ }
3090
+ }
3091
+
3092
+ return textResult(JSON.stringify({
3093
+ status: 'registered',
3094
+ worker: args.worker,
3095
+ filesRegistered: registered,
3096
+ team: teamName,
3097
+ }, null, 2));
3098
+ } catch (err) {
3099
+ return errorResult(err.message);
3100
+ }
3101
+ }
3102
+
3103
+ function handleCheckFileOwner(args) {
3104
+ try {
3105
+ const owner = _fileOwnership.get(args.file);
3106
+
3107
+ if (!owner) {
3108
+ return textResult(JSON.stringify({
3109
+ file: args.file,
3110
+ owned: false,
3111
+ message: 'No owner registered',
3112
+ }, null, 2));
3113
+ }
3114
+
3115
+ return textResult(JSON.stringify({
3116
+ file: args.file,
3117
+ owned: true,
3118
+ worker: owner.worker,
3119
+ team: owner.team,
3120
+ warning: `This file belongs to worker "${owner.worker}". Rule 6: Do not edit worker files directly unless the fix is ≤3 lines and the worker is idle.`,
3121
+ }, null, 2));
2781
3122
  } catch (err) {
2782
3123
  return errorResult(err.message);
2783
3124
  }
@@ -2854,7 +3195,7 @@ function handleTeamReset(args) {
2854
3195
  // Clear roster
2855
3196
  const rosterPath = path.join(baseDir, 'roster.json');
2856
3197
  if (fs.existsSync(rosterPath)) {
2857
- fs.writeFileSync(rosterPath, '{}');
3198
+ fs.writeFileSync(rosterPath, '[]');
2858
3199
  summary.cleared.push('roster');
2859
3200
  }
2860
3201
 
@@ -2895,6 +3236,30 @@ function handleTeamReset(args) {
2895
3236
  snapshotEngine = null;
2896
3237
  currentTeamName = null;
2897
3238
 
3239
+ // Clear gate enforcement state
3240
+ _gateState.delete(teamName);
3241
+
3242
+ // Clear inbox check enforcement state for this team
3243
+ for (const key of _inboxCheckState.keys()) {
3244
+ if (key.startsWith(teamName + ':')) {
3245
+ _inboxCheckState.delete(key);
3246
+ }
3247
+ }
3248
+
3249
+ // Clear worker-to-team mapping for this team
3250
+ for (const [worker, team] of _workerTeamMap.entries()) {
3251
+ if (team === teamName) {
3252
+ _workerTeamMap.delete(worker);
3253
+ }
3254
+ }
3255
+
3256
+ // Clear file ownership entries for this team
3257
+ for (const [filePath, owner] of _fileOwnership.entries()) {
3258
+ if (owner.team === teamName) {
3259
+ _fileOwnership.delete(filePath);
3260
+ }
3261
+ }
3262
+
2898
3263
  summary.message = `Team "${teamName}" has been reset. ${summary.cleared.length} stores cleared.`;
2899
3264
 
2900
3265
  return textResult(JSON.stringify(summary, null, 2));
@@ -3119,3 +3484,18 @@ function startServer() {
3119
3484
  }
3120
3485
 
3121
3486
  module.exports = { startServer, TOOLS, executeTool };
3487
+
3488
+ // Test-only exports — expose internal state for unit testing
3489
+ if (process.env.CMS_TEST_MODE) {
3490
+ Object.assign(module.exports, {
3491
+ _inboxCheckState,
3492
+ _gateState,
3493
+ _fileOwnership,
3494
+ _workerTeamMap,
3495
+ checkInboxEnforcement,
3496
+ autoSetWorkerIdle,
3497
+ getRosterSummary,
3498
+ getTeamInstances,
3499
+ getGateState,
3500
+ });
3501
+ }
package/src/prompts.js CHANGED
@@ -58,8 +58,9 @@ You have access to MCP tools that start with \`mcp__multi-session__team_*\` and
58
58
 
59
59
  === CRITICAL: MANDATORY WORKFLOW ===
60
60
 
61
- ### Step 1: CHECK INBOX (Before Starting ANY Work)
62
- ALWAYS call \`mcp__multi-session__team_check_inbox\` FIRST, before doing anything else.
61
+ ### Step 1: CHECK INBOX (Before Starting ANY Work) — ENFORCED
62
+ You MUST call \`mcp__multi-session__team_check_inbox\` FIRST artifact and contract tools are blocked until you do.
63
+ Calling \`artifact_publish\`, \`artifact_get\`, \`contract_create\`, or \`contract_start\` without checking inbox first will return a BLOCKED error.
63
64
  Your inbox may contain:
64
65
  - Task assignments or contracts from teammates
65
66
  - Questions that need IMMEDIATE replies
@@ -85,8 +86,8 @@ Follow the conventions STRICTLY. If NO convention artifact exists AND your work
85
86
 
86
87
  Note: team_ask is a **fallback mechanism** for when information wasn't available upfront. If your orchestrator provided thorough prompts with all needed context and conventions, you may never need team_ask — this is the ideal case.
87
88
 
88
- ### Step 2: UPDATE YOUR STATUS
89
- Call \`mcp__multi-session__team_update_status\` to set yourself as "active" with your current task.
89
+ ### Step 2: STATUS (Auto-Managed)
90
+ Your status is automatically set to "active" when spawned and "idle" when your session stops. You do NOT need to call \`team_update_status\` for these transitions. Only call it if you need a custom status like "blocked" or to update your task description mid-work.
90
91
 
91
92
  ### Step 3: DO YOUR WORK
92
93
  Complete your assigned task using the standard coding tools (Read, Write, Edit, Bash, etc.).
@@ -99,18 +100,22 @@ mcp__multi-session__artifact_publish({
99
100
  type: "api-contract" | "schema-change" | "test-results" | "implementation" | "custom",
100
101
  name: "Human readable name",
101
102
  publisher: "${name}",
102
- data: { /* your structured output */ },
103
+ data: {
104
+ /* your structured output */
105
+ filesCreated: ["src/routes/users.js", "src/models/user.js"], // auto-registers file ownership
106
+ filesModified: ["src/app.js"] // auto-registers file ownership
107
+ },
103
108
  summary: "Brief description of what this contains"
104
109
  })
105
110
  \`\`\`
111
+ **File ownership tracking:** If your \`data\` object includes \`files\`, \`filesCreated\`, or \`filesModified\` arrays, the system automatically registers you as the owner of those files. This helps the orchestrator respect Rule 6 (don't fix worker code directly). You can also call \`register_files\` explicitly to register ownership of additional files.
106
112
 
107
113
  ### Step 5: CHECK INBOX AGAIN (After Major Steps)
108
114
  After completing significant work or before starting a new sub-task, check inbox again for messages from teammates.
109
115
 
110
116
  ### Step 6: SIGNAL COMPLETION
111
- When fully done, update your status to "idle" and broadcast a completion message:
117
+ When fully done, broadcast a completion message. Your status will be automatically set to "idle" when your session ends, but you should still broadcast so teammates know:
112
118
  \`\`\`
113
- mcp__multi-session__team_update_status({ name: "${name}", status: "idle", task: "Completed: <summary>" })
114
119
  mcp__multi-session__team_broadcast({ from: "${name}", content: "Completed: <what you did>. Published artifact: <artifact-id>" })
115
120
  \`\`\`
116
121
 
@@ -140,7 +145,7 @@ mcp__multi-session__team_broadcast({ from: "${name}", content: "Completed: <what
140
145
 
141
146
  IMPORTANT: Follow these rules strictly. Violating them causes coordination failures.
142
147
 
143
- 1. **ALWAYS check inbox before starting work.** Your teammates may have sent you critical information.
148
+ 1. **ALWAYS check inbox before starting work (ENFORCED).** Artifact and contract tools are blocked until you call \`team_check_inbox\`. Your teammates may have sent you critical information.
144
149
 
145
150
  2. **NEVER ask the orchestrator to relay messages.** Talk to teammates DIRECTLY using \`team_send_message\` or \`team_ask\`. The orchestrator should NOT be a message router.
146
151
 
@@ -608,6 +613,8 @@ The tool automatically:
608
613
  Returns a structured pass/fail report with recommendation.
609
614
  PROCEED ONLY IF the report says ALL CHECKS PASSED.
610
615
 
616
+ **ENFORCED:** \`team_spawn\` will return an error if \`phase_gate\` was not called between spawn batches. The first batch (Phase 0) is free; every subsequent batch requires a passing \`phase_gate\` call first.
617
+
611
618
  === PHASE COUNTING RULE ===
612
619
  At the start of planning, count and list your phases explicitly:
613
620
  "Phase 0: [foundation], Phase 1: [routes], Phase 2: [tests], ..."
@@ -788,6 +795,58 @@ When done:
788
795
  // =============================================================================
789
796
 
790
797
  const ROLE_PROMPTS = {
798
+ 'setup': `
799
+ ### Role-Specific: Setup / Foundation Worker
800
+
801
+ Your job is to create the COMPLETE project foundation. Do not leave placeholders or TODOs.
802
+
803
+ You MUST create ALL of these:
804
+ 1. **package.json** with all dependencies AND scripts (start, test, dev)
805
+ 2. **Database setup file** (schema, connection, initialization)
806
+ 3. **Main app file** (Express app with middleware — CORS, JSON parsing, error handling)
807
+ 4. **Server entry point** (server.js that imports app and calls app.listen on a port)
808
+ 5. **.gitignore**
809
+ 6. **Route mounting** in the main app file — use placeholder requires that Phase 1 workers will create:
810
+ \`\`\`javascript
811
+ const booksRoutes = require('./routes/books');
812
+ app.use('/books', booksRoutes);
813
+ \`\`\`
814
+ Create the routes/ directory and empty placeholder files so requires don't crash.
815
+
816
+ CRITICAL: The project must be runnable with "npm start" after your phase completes, even if routes return empty responses. Do NOT leave comments like "// mount routes here" — actually mount them with placeholder requires.
817
+
818
+ When done:
819
+ - Publish a "project-foundation" artifact (type "custom") with file paths and connection details
820
+ - Include: server port, database file path, all created files
821
+ - Broadcast completion so Phase 1 workers can start
822
+ `,
823
+
824
+ 'foundation': `
825
+ ### Role-Specific: Setup / Foundation Worker
826
+
827
+ Your job is to create the COMPLETE project foundation. Do not leave placeholders or TODOs.
828
+
829
+ You MUST create ALL of these:
830
+ 1. **package.json** with all dependencies AND scripts (start, test, dev)
831
+ 2. **Database setup file** (schema, connection, initialization)
832
+ 3. **Main app file** (Express app with middleware — CORS, JSON parsing, error handling)
833
+ 4. **Server entry point** (server.js that imports app and calls app.listen on a port)
834
+ 5. **.gitignore**
835
+ 6. **Route mounting** in the main app file — use placeholder requires that Phase 1 workers will create:
836
+ \`\`\`javascript
837
+ const booksRoutes = require('./routes/books');
838
+ app.use('/books', booksRoutes);
839
+ \`\`\`
840
+ Create the routes/ directory and empty placeholder files so requires don't crash.
841
+
842
+ CRITICAL: The project must be runnable with "npm start" after your phase completes, even if routes return empty responses. Do NOT leave comments like "// mount routes here" — actually mount them with placeholder requires.
843
+
844
+ When done:
845
+ - Publish a "project-foundation" artifact (type "custom") with file paths and connection details
846
+ - Include: server port, database file path, all created files
847
+ - Broadcast completion so Phase 1 workers can start
848
+ `,
849
+
791
850
  'backend': `
792
851
  ### Role-Specific: Backend Developer
793
852
  - Publish API contracts as artifacts with type "api-contract" including: endpoints, methods, request/response schemas
@@ -919,6 +978,8 @@ IMPORTANT: You are the ORCHESTRATOR. Your job is to PLAN, SPAWN, and MONITOR —
919
978
 
920
979
  5. **Post-Phase Verification** — After each phase completes, call \`phase_gate()\` which runs ALL verification checks in one call: confirms artifacts exist, validates content, checks workers are idle, and verifies artifact readers. Only proceed when it reports ALL CHECKS PASSED. If you have N phases, verify N-1 times.
921
980
 
981
+ **Note:** \`team_spawn\` will return an error if \`phase_gate\` was not called between spawn batches. The first batch (Phase 0) is free; every subsequent batch requires a passing \`phase_gate\` call first.
982
+
922
983
  6. **Collect** — When all workers are idle, check \`artifact_list\` for published outputs and summarize results for the user.
923
984
 
924
985
  ### Critical Rule: Never Assign the Same File to Two Workers
package/src/store.js CHANGED
@@ -16,6 +16,7 @@
16
16
  const fs = require('fs');
17
17
  const path = require('path');
18
18
  const os = require('os');
19
+ const { atomicWriteJson } = require('./atomic-io');
19
20
 
20
21
  // Default data directory in user's home
21
22
  const DEFAULT_DATA_DIR = path.join(os.homedir(), '.claude-multi-session');
@@ -54,7 +55,7 @@ class Store {
54
55
  * @param {Object<string, object>} sessions
55
56
  */
56
57
  saveAll(sessions) {
57
- fs.writeFileSync(this.sessionsFile, JSON.stringify(sessions, null, 2), 'utf-8');
58
+ atomicWriteJson(this.sessionsFile, sessions);
58
59
  }
59
60
 
60
61
  /**
package/src/team-hub.js CHANGED
@@ -19,6 +19,7 @@ const crypto = require('crypto');
19
19
 
20
20
  // Import our atomic file operations
21
21
  const { atomicWriteJson, readJsonSafe, appendJsonl } = require('./atomic-io');
22
+ const { acquireLock, releaseLock } = require('./file-lock');
22
23
 
23
24
  /**
24
25
  * TeamHub class - manages team communication and roster
@@ -43,9 +44,13 @@ class TeamHub {
43
44
  const inboxDir = path.join(this.teamDir, 'inbox');
44
45
  const asksDir = path.join(this.teamDir, 'asks');
45
46
 
47
+ // Create locks directory for roster locking
48
+ this.locksDir = path.join(baseDir, 'team', teamName, 'locks');
49
+
46
50
  // Make sure all directories exist
47
51
  fs.mkdirSync(inboxDir, { recursive: true });
48
52
  fs.mkdirSync(asksDir, { recursive: true });
53
+ fs.mkdirSync(this.locksDir, { recursive: true });
49
54
 
50
55
  // Rate limiting: track message counts per sender
51
56
  // Map structure: { senderName: { count: number, windowStart: timestamp } }
@@ -66,7 +71,9 @@ class TeamHub {
66
71
  getRoster() {
67
72
  const rosterPath = path.join(this.teamDir, 'roster.json');
68
73
  // readJsonSafe returns empty array if file doesn't exist
69
- return readJsonSafe(rosterPath, []);
74
+ const data = readJsonSafe(rosterPath, []);
75
+ // Defensive: ensure we always return an array, even if file contains {}
76
+ return Array.isArray(data) ? data : [];
70
77
  }
71
78
 
72
79
  /**
@@ -79,29 +86,37 @@ class TeamHub {
79
86
  * @param {string} options.model - AI model being used (e.g., 'sonnet', 'opus')
80
87
  */
81
88
  joinTeam(name, { role, task, model }) {
82
- // Get current roster
83
- const roster = this.getRoster();
84
-
85
- // Create new member entry with all required fields
86
- const newMember = {
87
- name,
88
- role,
89
- task,
90
- model,
91
- status: 'active',
92
- joinedAt: new Date().toISOString(),
93
- lastSeen: new Date().toISOString()
94
- };
89
+ // Acquire lock for roster modification
90
+ acquireLock(this.locksDir, 'roster');
91
+
92
+ try {
93
+ // Get current roster
94
+ const roster = this.getRoster();
95
+
96
+ // Create new member entry with all required fields
97
+ const newMember = {
98
+ name,
99
+ role,
100
+ task,
101
+ model,
102
+ status: 'active',
103
+ joinedAt: new Date().toISOString(),
104
+ lastSeen: new Date().toISOString()
105
+ };
95
106
 
96
- // Remove existing entry if member is rejoining
97
- const filtered = roster.filter(m => m.name !== name);
107
+ // Remove existing entry if member is rejoining
108
+ const filtered = roster.filter(m => m.name !== name);
98
109
 
99
- // Add the new member
100
- filtered.push(newMember);
110
+ // Add the new member
111
+ filtered.push(newMember);
101
112
 
102
- // Save the updated roster
103
- const rosterPath = path.join(this.teamDir, 'roster.json');
104
- atomicWriteJson(rosterPath, filtered);
113
+ // Save the updated roster
114
+ const rosterPath = path.join(this.teamDir, 'roster.json');
115
+ atomicWriteJson(rosterPath, filtered);
116
+ } finally {
117
+ // Always release the lock
118
+ releaseLock(this.locksDir, 'roster');
119
+ }
105
120
  }
106
121
 
107
122
  /**
@@ -110,15 +125,23 @@ class TeamHub {
110
125
  * @param {string} name - Name of member to remove
111
126
  */
112
127
  leaveTeam(name) {
113
- // Get current roster
114
- const roster = this.getRoster();
115
-
116
- // Filter out the member
117
- const filtered = roster.filter(m => m.name !== name);
118
-
119
- // Save the updated roster
120
- const rosterPath = path.join(this.teamDir, 'roster.json');
121
- atomicWriteJson(rosterPath, filtered);
128
+ // Acquire lock for roster modification
129
+ acquireLock(this.locksDir, 'roster');
130
+
131
+ try {
132
+ // Get current roster
133
+ const roster = this.getRoster();
134
+
135
+ // Filter out the member
136
+ const filtered = roster.filter(m => m.name !== name);
137
+
138
+ // Save the updated roster
139
+ const rosterPath = path.join(this.teamDir, 'roster.json');
140
+ atomicWriteJson(rosterPath, filtered);
141
+ } finally {
142
+ // Always release the lock
143
+ releaseLock(this.locksDir, 'roster');
144
+ }
122
145
  }
123
146
 
124
147
  /**
@@ -128,21 +151,29 @@ class TeamHub {
128
151
  * @param {Object} updates - Fields to update (status, task, role, etc.)
129
152
  */
130
153
  updateMember(name, updates) {
131
- // Get current roster
132
- const roster = this.getRoster();
133
-
134
- // Find and update the member
135
- const updated = roster.map(member => {
136
- if (member.name === name) {
137
- // Merge updates into existing member object
138
- return { ...member, ...updates };
139
- }
140
- return member;
141
- });
154
+ // Acquire lock for roster modification
155
+ acquireLock(this.locksDir, 'roster');
156
+
157
+ try {
158
+ // Get current roster
159
+ const roster = this.getRoster();
160
+
161
+ // Find and update the member
162
+ const updated = roster.map(member => {
163
+ if (member.name === name) {
164
+ // Merge updates into existing member object
165
+ return { ...member, ...updates };
166
+ }
167
+ return member;
168
+ });
142
169
 
143
- // Save the updated roster
144
- const rosterPath = path.join(this.teamDir, 'roster.json');
145
- atomicWriteJson(rosterPath, updated);
170
+ // Save the updated roster
171
+ const rosterPath = path.join(this.teamDir, 'roster.json');
172
+ atomicWriteJson(rosterPath, updated);
173
+ } finally {
174
+ // Always release the lock
175
+ releaseLock(this.locksDir, 'roster');
176
+ }
146
177
  }
147
178
 
148
179
  /**