claude-multi-session 2.7.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,6 +121,9 @@ 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.
@@ -134,17 +137,17 @@ Do NOT verify worker output by reading files directly — check artifacts instea
134
137
 
135
138
  ### Rule 5: Always tell workers to publish artifacts
136
139
  Every worker prompt should include instructions to:
137
- 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
138
141
  2. Use \`team_ask\` to communicate with teammates (NOT you)
139
- 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
140
143
  4. Broadcast completion (\`team_broadcast\`)
141
- 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".
142
145
  6. Follow shared conventions defined in Rule 0 (include them in the prompt or reference the conventions artifact)
143
146
 
144
147
  ### Rule 6: Don't fix worker code yourself (pragmatic exception for trivial fixes)
145
148
 
146
149
  === FIX PROTOCOL (when you must fix worker code directly) ===
147
- 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:
148
151
 
149
152
  1. Is this fix ≤ 3 lines?
150
153
  NO → \`send_message\` to worker or spawn fix-worker. Do NOT fix yourself.
@@ -175,19 +178,30 @@ This shows which workers actually read the conventions. If a worker is missing,
175
178
 
176
179
  NEVER trust a worker's self-reported completion — verify the artifact exists yourself.
177
180
 
178
- ## 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)
179
191
 
180
192
  | You want to... | Use this tool |
181
193
  |----------------|---------------|
182
194
  | Verify tools before starting | \`server_version\` |
183
195
  | Build a multi-person project | \`team_spawn\` (multiple in parallel) |
184
196
  | Run a single isolated task | \`delegate_task\` |
185
- | 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) |
186
198
  | See published outputs | \`artifact_list\` |
187
199
  | See task completion status | \`contract_list\` |
200
+ | Verify phase completion | \`phase_gate\` |
188
201
  | Send a correction to a worker | \`send_message\` to that session |
189
202
  | Check who read an artifact | \`artifact_readers\` |
190
- | Verify phase completion | \`phase_gate\` |
203
+ | Check file ownership (Rule 6) | \`check_file_owner\` |
204
+ | Register file ownership | \`register_files\` |
191
205
  | Clean up between runs | \`team_reset\` |
192
206
 
193
207
  ### When NOT to Delegate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multi-session",
3
- "version": "2.7.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,10 +76,89 @@ let pipelineEngine = null;
76
76
  let snapshotEngine = null;
77
77
  let currentTeamName = null;
78
78
 
79
- // Phase gate enforcement state — tracks gate calls vs spawn batches per team
80
- // team -> { gateCount: number, spawnBatchCount: number, lastSpawnTime: number }
79
+ // Phase gate enforcement state — deterministic open/close gate per team
80
+ // team -> { gateOpen: boolean, phase: string|null, spawnCount: number, firstBatchFree: boolean }
81
81
  const _gateState = new Map();
82
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
+
83
162
  /**
84
163
  * Get or create Team Hub instances for a given team
85
164
  * This function lazily creates the instances on first use
@@ -999,6 +1078,38 @@ const TOOLS = [
999
1078
  },
1000
1079
  },
1001
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
+
1002
1113
  // ── Session Continuity (Layer 0) ──────────────────────────────────────
1003
1114
  {
1004
1115
  name: 'continuity_snapshot',
@@ -1337,6 +1448,12 @@ async function executeTool(toolName, args) {
1337
1448
  case 'team_reset':
1338
1449
  return handleTeamReset(args);
1339
1450
 
1451
+ // ── File Ownership handlers ──
1452
+ case 'register_files':
1453
+ return handleRegisterFiles(args);
1454
+ case 'check_file_owner':
1455
+ return handleCheckFileOwner(args);
1456
+
1340
1457
  // ── Session Continuity (Layer 0) handlers ──
1341
1458
  case 'continuity_snapshot': {
1342
1459
  const snap = new SessionSnapshot(args.projectPath);
@@ -1464,28 +1581,46 @@ async function handleSpawn(args) {
1464
1581
  }
1465
1582
 
1466
1583
  async function handleSend(args) {
1467
- // Auto-resume if session is not alive
1468
- if (!manager.sessions.has(args.name)) {
1469
- log(`Session "${args.name}" not alive. Auto-resuming...`);
1470
- 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);
1471
1604
  return textResult(JSON.stringify({
1472
1605
  session_name: args.name,
1473
- auto_resumed: true,
1474
- response: response?.text || '',
1475
- cost: response?.cost || 0,
1476
- turns: response?.turns || 0,
1477
- duration_ms: response?.duration || 0,
1478
- }, 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);
1479
1623
  }
1480
-
1481
- const response = await manager.send(args.name, args.message);
1482
- return textResult(JSON.stringify({
1483
- session_name: args.name,
1484
- response: response.text,
1485
- cost: response.cost,
1486
- turns: response.turns,
1487
- duration_ms: response.duration,
1488
- }, null, 2));
1489
1624
  }
1490
1625
 
1491
1626
  async function handleResume(args) {
@@ -1525,6 +1660,10 @@ async function handleFork(args) {
1525
1660
 
1526
1661
  function handleStop(args) {
1527
1662
  manager.stop(args.name);
1663
+
1664
+ // Auto-set team worker status to idle when stopped
1665
+ autoSetWorkerIdle(args.name);
1666
+
1528
1667
  return textResult(JSON.stringify({
1529
1668
  session_name: args.name,
1530
1669
  status: 'stopped',
@@ -1534,6 +1673,10 @@ function handleStop(args) {
1534
1673
 
1535
1674
  function handleKill(args) {
1536
1675
  manager.kill(args.name);
1676
+
1677
+ // Auto-set team worker status to idle when killed
1678
+ autoSetWorkerIdle(args.name);
1679
+
1537
1680
  return textResult(JSON.stringify({
1538
1681
  session_name: args.name,
1539
1682
  status: 'killed',
@@ -1752,27 +1895,25 @@ async function handleTeamSpawn(args) {
1752
1895
  const teamName = args.team || 'default';
1753
1896
  const { teamHub } = getTeamInstances(teamName);
1754
1897
 
1755
- // Gate enforcement: require phase_gate between spawn batches
1756
- const state = _gateState.get(teamName) || { gateCount: 0, spawnBatchCount: 0, lastSpawnTime: 0 };
1757
- const now = Date.now();
1758
-
1759
- // Detect new spawn batch (>5s since last spawn = new batch)
1760
- const isNewBatch = (now - state.lastSpawnTime) > 5000;
1761
- if (isNewBatch) {
1762
- state.spawnBatchCount++;
1763
- }
1764
- state.lastSpawnTime = now;
1765
- _gateState.set(teamName, state);
1766
-
1767
- // Rule: spawnBatchCount must be <= gateCount + 1
1768
- // (first batch is free, every subsequent batch needs a gate)
1769
- if (state.spawnBatchCount > state.gateCount + 1) {
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
1770
1911
  return errorResult(
1771
- `PHASE GATE REQUIRED: You have spawned ${state.spawnBatchCount - 1} batch(es) of workers ` +
1772
- `but only called phase_gate ${state.gateCount} time(s). ` +
1773
- `Call phase_gate() to verify the previous phase completed before spawning more workers.\n\n` +
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` +
1774
1915
  `Example:\n` +
1775
- `phase_gate({ phase_completing: "Phase N", phase_starting: "Phase N+1", ` +
1916
+ `phase_gate({ phase_completing: "${gate.phase || 'Phase N'}", phase_starting: "Phase N+1", ` +
1776
1917
  `expected_artifacts: ["..."] })`
1777
1918
  );
1778
1919
  }
@@ -1784,6 +1925,12 @@ async function handleTeamSpawn(args) {
1784
1925
  model: args.model || 'sonnet',
1785
1926
  });
1786
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
+
1787
1934
  // Get roster to build system prompt
1788
1935
  const roster = teamHub.getRoster();
1789
1936
 
@@ -1800,6 +1947,13 @@ async function handleTeamSpawn(args) {
1800
1947
  workDir: args.work_dir || process.cwd(),
1801
1948
  });
1802
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
+
1803
1957
  const result = {
1804
1958
  session_name: session.name,
1805
1959
  status: session.status,
@@ -1821,8 +1975,16 @@ async function handleTeamSpawn(args) {
1821
1975
  result._staleness_warning = `Server is stale! ${staleness.trim()}`;
1822
1976
  }
1823
1977
 
1824
- return textResult(JSON.stringify(result, null, 2));
1978
+ return textResult(JSON.stringify(result, null, 2) + getRosterSummary(teamName));
1825
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
+ }
1826
1988
  return errorResult(err.message);
1827
1989
  }
1828
1990
  }
@@ -1872,6 +2034,12 @@ function handleTeamCheckInbox(args) {
1872
2034
  const teamName = args.team || 'default';
1873
2035
  const { teamHub } = getTeamInstances(teamName);
1874
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
+
1875
2043
  const messages = teamHub.getInbox(args.name, {
1876
2044
  markRead: args.mark_read !== false,
1877
2045
  limit: args.limit || 20,
@@ -1958,11 +2126,13 @@ function handleTeamRoster(args) {
1958
2126
  const { teamHub } = getTeamInstances(teamName);
1959
2127
 
1960
2128
  const roster = teamHub.getRoster();
2129
+ // Defensive: ensure roster is an array
2130
+ const safeRoster = Array.isArray(roster) ? roster : [];
1961
2131
 
1962
2132
  return textResult(JSON.stringify({
1963
2133
  team: teamName,
1964
- count: roster.length,
1965
- members: roster.map(m => ({
2134
+ count: safeRoster.length,
2135
+ members: safeRoster.map(m => ({
1966
2136
  name: m.name,
1967
2137
  role: m.role,
1968
2138
  status: m.status,
@@ -2005,6 +2175,11 @@ function handleTeamUpdateStatus(args) {
2005
2175
  async function handleArtifactPublish(args) {
2006
2176
  try {
2007
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
+
2008
2183
  const { artifactStore, resolver } = getTeamInstances(teamName);
2009
2184
 
2010
2185
  // Publish the artifact
@@ -2027,13 +2202,32 @@ async function handleArtifactPublish(args) {
2027
2202
  data: args.data,
2028
2203
  });
2029
2204
 
2030
- 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 = {
2031
2220
  status: 'published',
2032
2221
  artifactId: result.artifactId,
2033
2222
  version: result.version,
2034
2223
  type: result.type,
2035
2224
  team: teamName,
2036
- }, null, 2));
2225
+ };
2226
+ if (filesRegistered > 0) {
2227
+ publishResult.filesRegistered = filesRegistered;
2228
+ }
2229
+
2230
+ return textResult(JSON.stringify(publishResult, null, 2));
2037
2231
  } catch (err) {
2038
2232
  return errorResult(err.message);
2039
2233
  }
@@ -2042,6 +2236,11 @@ async function handleArtifactPublish(args) {
2042
2236
  function handleArtifactGet(args) {
2043
2237
  try {
2044
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
+
2045
2244
  const { artifactStore } = getTeamInstances(teamName);
2046
2245
 
2047
2246
  const artifact = artifactStore.get(args.artifactId, args.version);
@@ -2166,6 +2365,11 @@ function handleArtifactHistory(args) {
2166
2365
  async function handleContractCreate(args) {
2167
2366
  try {
2168
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
+
2169
2373
  const { contractStore, resolver, teamHub } = getTeamInstances(teamName);
2170
2374
 
2171
2375
  // Create the contract
@@ -2218,6 +2422,13 @@ function handleContractStart(args) {
2218
2422
  const teamName = args.team || 'default';
2219
2423
  const { contractStore } = getTeamInstances(teamName);
2220
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
+
2221
2432
  const contract = contractStore.start(args.contractId);
2222
2433
 
2223
2434
  return textResult(JSON.stringify({
@@ -2732,6 +2943,8 @@ function handlePhaseGate(args) {
2732
2943
 
2733
2944
  // Check 3: Workers idle
2734
2945
  const roster = teamHub.getRoster();
2946
+ // Defensive: ensure roster is an array
2947
+ const safeRoster = Array.isArray(roster) ? roster : [];
2735
2948
  const idleCheck = {
2736
2949
  check: 'workers_idle',
2737
2950
  results: [],
@@ -2739,8 +2952,8 @@ function handlePhaseGate(args) {
2739
2952
  };
2740
2953
 
2741
2954
  const workersToCheck = args.expected_idle
2742
- ? roster.filter(m => args.expected_idle.includes(m.name))
2743
- : roster;
2955
+ ? safeRoster.filter(m => args.expected_idle.includes(m.name))
2956
+ : safeRoster;
2744
2957
 
2745
2958
  for (const member of workersToCheck) {
2746
2959
  const isIdle = member.status === 'idle';
@@ -2795,15 +3008,57 @@ function handlePhaseGate(args) {
2795
3008
  }
2796
3009
  report.checks.push(readerCheck);
2797
3010
 
2798
- // Overall pass/fail
2799
- 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);
2800
3048
 
2801
- // Track gate pass for spawn enforcement
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
2802
3053
  if (report.passed) {
2803
- const state = _gateState.get(teamName) || { gateCount: 0, spawnBatchCount: 0, lastSpawnTime: 0 };
2804
- state.gateCount++;
2805
- _gateState.set(teamName, state);
2806
- report._gate_generation = state.gateCount;
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;
2807
3062
  }
2808
3063
 
2809
3064
  // Action recommendation
@@ -2814,7 +3069,56 @@ function handlePhaseGate(args) {
2814
3069
  report.recommendation = `BLOCKED: ${failures.join(', ')} failed. Fix these before proceeding to ${args.phase_starting}.`;
2815
3070
  }
2816
3071
 
2817
- 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));
2818
3122
  } catch (err) {
2819
3123
  return errorResult(err.message);
2820
3124
  }
@@ -2891,7 +3195,7 @@ function handleTeamReset(args) {
2891
3195
  // Clear roster
2892
3196
  const rosterPath = path.join(baseDir, 'roster.json');
2893
3197
  if (fs.existsSync(rosterPath)) {
2894
- fs.writeFileSync(rosterPath, '{}');
3198
+ fs.writeFileSync(rosterPath, '[]');
2895
3199
  summary.cleared.push('roster');
2896
3200
  }
2897
3201
 
@@ -2935,6 +3239,27 @@ function handleTeamReset(args) {
2935
3239
  // Clear gate enforcement state
2936
3240
  _gateState.delete(teamName);
2937
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
+
2938
3263
  summary.message = `Team "${teamName}" has been reset. ${summary.cleared.length} stores cleared.`;
2939
3264
 
2940
3265
  return textResult(JSON.stringify(summary, null, 2));
@@ -3159,3 +3484,18 @@ function startServer() {
3159
3484
  }
3160
3485
 
3161
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
 
@@ -790,6 +795,58 @@ When done:
790
795
  // =============================================================================
791
796
 
792
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
+
793
850
  'backend': `
794
851
  ### Role-Specific: Backend Developer
795
852
  - Publish API contracts as artifacts with type "api-contract" including: endpoints, methods, request/response schemas
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
  /**