brainclaw 0.29.2 → 1.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/README.md +193 -170
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +673 -24
  4. package/dist/commands/accept.js +3 -0
  5. package/dist/commands/add-step.js +11 -26
  6. package/dist/commands/agent-board.js +70 -3
  7. package/dist/commands/audit.js +19 -0
  8. package/dist/commands/check-policy.js +54 -0
  9. package/dist/commands/check-security-mcp.js +145 -0
  10. package/dist/commands/check-security.js +106 -0
  11. package/dist/commands/claim-resource.js +1 -0
  12. package/dist/commands/codev.js +672 -0
  13. package/dist/commands/compact.js +74 -0
  14. package/dist/commands/complete-step.js +16 -26
  15. package/dist/commands/constraint.js +8 -20
  16. package/dist/commands/decision.js +9 -20
  17. package/dist/commands/delete-plan.js +10 -12
  18. package/dist/commands/delete-step.js +16 -0
  19. package/dist/commands/dispatch.js +163 -0
  20. package/dist/commands/doctor.js +1122 -49
  21. package/dist/commands/enable-agent.js +1 -0
  22. package/dist/commands/export.js +280 -22
  23. package/dist/commands/handoff.js +33 -0
  24. package/dist/commands/harvest.js +189 -0
  25. package/dist/commands/hooks.js +82 -25
  26. package/dist/commands/inbox.js +169 -0
  27. package/dist/commands/init.js +38 -31
  28. package/dist/commands/install-hooks.js +71 -44
  29. package/dist/commands/link.js +89 -0
  30. package/dist/commands/list-claims.js +48 -3
  31. package/dist/commands/list-plans.js +129 -25
  32. package/dist/commands/loops-handlers.js +409 -0
  33. package/dist/commands/mcp-read-handlers.js +1628 -0
  34. package/dist/commands/mcp-schemas.generated.js +74 -0
  35. package/dist/commands/mcp.js +4221 -1501
  36. package/dist/commands/plan-resource.js +64 -0
  37. package/dist/commands/plan.js +12 -26
  38. package/dist/commands/prune.js +37 -2
  39. package/dist/commands/reflect.js +20 -7
  40. package/dist/commands/release-claim.js +11 -6
  41. package/dist/commands/release-notes.js +170 -0
  42. package/dist/commands/repair.js +210 -0
  43. package/dist/commands/run-profile.js +57 -0
  44. package/dist/commands/sequence.js +113 -0
  45. package/dist/commands/session-end.js +423 -14
  46. package/dist/commands/session-start.js +214 -41
  47. package/dist/commands/setup-security.js +103 -0
  48. package/dist/commands/setup.js +42 -4
  49. package/dist/commands/stale.js +109 -0
  50. package/dist/commands/switch.js +100 -2
  51. package/dist/commands/trap.js +14 -31
  52. package/dist/commands/update-handoff.js +63 -4
  53. package/dist/commands/update-plan.js +21 -28
  54. package/dist/commands/update-step.js +37 -0
  55. package/dist/commands/upgrade.js +313 -6
  56. package/dist/commands/usage.js +102 -0
  57. package/dist/commands/version.js +20 -0
  58. package/dist/commands/who.js +33 -5
  59. package/dist/commands/worktree.js +105 -0
  60. package/dist/core/actions.js +315 -0
  61. package/dist/core/agent-capability.js +610 -17
  62. package/dist/core/agent-context.js +7 -1
  63. package/dist/core/agent-files.js +1169 -85
  64. package/dist/core/agent-integrations.js +160 -5
  65. package/dist/core/agent-inventory.js +2 -0
  66. package/dist/core/agent-profiles.js +93 -0
  67. package/dist/core/agent-registry.js +162 -30
  68. package/dist/core/agentrun-reconciler.js +345 -0
  69. package/dist/core/agentruns.js +424 -0
  70. package/dist/core/ai-agent-detection.js +31 -10
  71. package/dist/core/archival.js +77 -0
  72. package/dist/core/assignment-sweeper.js +82 -0
  73. package/dist/core/assignments.js +367 -0
  74. package/dist/core/audit.js +30 -0
  75. package/dist/core/brainclaw-version.js +94 -2
  76. package/dist/core/candidates.js +93 -2
  77. package/dist/core/claims.js +419 -0
  78. package/dist/core/codev-metrics.js +77 -0
  79. package/dist/core/codev-personas.js +31 -0
  80. package/dist/core/codev-plan-gen.js +35 -0
  81. package/dist/core/codev-prompts.js +74 -0
  82. package/dist/core/codev-responses.js +62 -0
  83. package/dist/core/codev-rounds.js +218 -0
  84. package/dist/core/config.js +4 -0
  85. package/dist/core/context.js +381 -34
  86. package/dist/core/coordination.js +201 -6
  87. package/dist/core/cross-project.js +230 -16
  88. package/dist/core/default-profiles/doctor.yaml +11 -0
  89. package/dist/core/default-profiles/janitor.yaml +11 -0
  90. package/dist/core/default-profiles/onboarder.yaml +11 -0
  91. package/dist/core/default-profiles/reviewer.yaml +13 -0
  92. package/dist/core/dispatcher.js +1189 -0
  93. package/dist/core/duplicates.js +2 -2
  94. package/dist/core/entity-operations.js +450 -0
  95. package/dist/core/entity-registry.js +344 -0
  96. package/dist/core/events.js +106 -2
  97. package/dist/core/execution-adapters.js +154 -0
  98. package/dist/core/execution-context.js +63 -0
  99. package/dist/core/execution-profile.js +270 -0
  100. package/dist/core/execution.js +255 -0
  101. package/dist/core/facade-schema.js +81 -0
  102. package/dist/core/federation-cloud.js +99 -0
  103. package/dist/core/federation-message.js +52 -0
  104. package/dist/core/federation-transport.js +65 -0
  105. package/dist/core/gc-semantic.js +482 -0
  106. package/dist/core/governance.js +247 -0
  107. package/dist/core/guards.js +19 -0
  108. package/dist/core/ideation.js +72 -0
  109. package/dist/core/identity.js +110 -25
  110. package/dist/core/ids.js +6 -0
  111. package/dist/core/input-validation.js +2 -2
  112. package/dist/core/instruction-templates.js +344 -136
  113. package/dist/core/io.js +90 -11
  114. package/dist/core/lock.js +6 -2
  115. package/dist/core/loops/brief-assembly.js +213 -0
  116. package/dist/core/loops/facade-schema.js +148 -0
  117. package/dist/core/loops/index.js +7 -0
  118. package/dist/core/loops/iteration-engine.js +139 -0
  119. package/dist/core/loops/lock.js +385 -0
  120. package/dist/core/loops/store.js +201 -0
  121. package/dist/core/loops/types.js +403 -0
  122. package/dist/core/loops/verbs.js +534 -0
  123. package/dist/core/markdown.js +15 -3
  124. package/dist/core/memory-compactor.js +432 -0
  125. package/dist/core/memory-git.js +152 -8
  126. package/dist/core/messaging.js +278 -0
  127. package/dist/core/migration.js +32 -1
  128. package/dist/core/mutation-pipeline.js +4 -2
  129. package/dist/core/operations/memory-mutation.js +129 -0
  130. package/dist/core/operations/memory-write.js +78 -0
  131. package/dist/core/operations/plan.js +190 -0
  132. package/dist/core/policy.js +169 -0
  133. package/dist/core/reputation.js +9 -3
  134. package/dist/core/schema.js +491 -6
  135. package/dist/core/search.js +21 -2
  136. package/dist/core/security-cache.js +71 -0
  137. package/dist/core/security-guard.js +152 -0
  138. package/dist/core/security-scoring.js +86 -0
  139. package/dist/core/sequence.js +130 -0
  140. package/dist/core/socket-client.js +113 -0
  141. package/dist/core/staleness.js +246 -0
  142. package/dist/core/state.js +98 -22
  143. package/dist/core/store-resolution.js +43 -11
  144. package/dist/core/toml-writer.js +76 -0
  145. package/dist/core/upgrades/backup.js +232 -0
  146. package/dist/core/upgrades/health-check.js +169 -0
  147. package/dist/core/upgrades/patches/candidate-archive.js +145 -0
  148. package/dist/core/upgrades/patches/handoff-review-strip.js +128 -0
  149. package/dist/core/upgrades/patches/provenance-rollout.js +136 -0
  150. package/dist/core/upgrades/schema-version.js +97 -0
  151. package/dist/core/worktree.js +606 -0
  152. package/dist/facts.js +114 -0
  153. package/dist/facts.json +111 -0
  154. package/docs/architecture/project-refs.md +5 -1
  155. package/docs/cli.md +690 -43
  156. package/docs/concepts/ideation-loop.md +317 -0
  157. package/docs/concepts/loop-engine.md +456 -0
  158. package/docs/concepts/mcp-governance.md +268 -0
  159. package/docs/concepts/memory-staleness.md +122 -0
  160. package/docs/concepts/multi-agent-workflows.md +166 -0
  161. package/docs/concepts/plans-and-claims.md +31 -6
  162. package/docs/concepts/project-md-convention.md +35 -0
  163. package/docs/concepts/troubleshooting.md +220 -0
  164. package/docs/concepts/upgrade-cli.md +202 -0
  165. package/docs/concepts/upgrade-dogfood-procedure.md +114 -0
  166. package/docs/context-format-changelog.md +2 -2
  167. package/docs/context-format.md +2 -2
  168. package/docs/index.md +68 -0
  169. package/docs/integrations/agents.md +15 -16
  170. package/docs/integrations/cline.md +88 -0
  171. package/docs/integrations/codex.md +75 -23
  172. package/docs/integrations/continue.md +60 -0
  173. package/docs/integrations/copilot.md +67 -9
  174. package/docs/integrations/kilocode.md +72 -0
  175. package/docs/integrations/mcp.md +304 -21
  176. package/docs/integrations/mistral-vibe.md +122 -0
  177. package/docs/integrations/opencode.md +84 -0
  178. package/docs/integrations/overview.md +23 -8
  179. package/docs/integrations/roo.md +74 -0
  180. package/docs/integrations/windsurf.md +83 -0
  181. package/docs/mcp-schema-changelog.md +191 -1
  182. package/docs/playbooks/integration/index.md +121 -0
  183. package/docs/playbooks/productivity/index.md +102 -0
  184. package/docs/playbooks/team/index.md +122 -0
  185. package/docs/product/agent-first-model.md +184 -0
  186. package/docs/product/entity-model-audit.md +462 -0
  187. package/docs/quickstart-existing-project.md +135 -0
  188. package/docs/quickstart.md +124 -37
  189. package/docs/release-maintenance.md +79 -0
  190. package/docs/review.md +2 -0
  191. package/docs/server-operations.md +118 -0
  192. package/package.json +20 -12
  193. package/dist/commands/claude-desktop-extension.js +0 -18
  194. package/dist/commands/diff.js +0 -99
  195. package/dist/core/claude-desktop-extension.js +0 -224
@@ -6,6 +6,39 @@ import { resolveEntityDir } from './io.js';
6
6
  import { mutate } from './mutation-pipeline.js';
7
7
  import { nowISO, getNextShortLabel } from './ids.js';
8
8
  import { JsonStore } from './json-store.js';
9
+ import { refreshLiveCompanions } from '../commands/export.js';
10
+ /**
11
+ * Return the effective source for a candidate.
12
+ *
13
+ * Resolution order:
14
+ * 1. Explicit `source` field if set to a valid enum value ('auto'|'agent'|'human').
15
+ * 2. Inferred from `origin` free-text pattern (e.g. 'runtime-note:...' → 'agent',
16
+ * 'session-end:...' → 'auto', 'mcp:...' / 'cross-project:...' → 'agent').
17
+ * 3. Default 'human' for legacy items without any provenance.
18
+ *
19
+ * This default is only applied in memory — no files are rewritten.
20
+ */
21
+ export function resolvedSource(candidate) {
22
+ if (candidate.source)
23
+ return candidate.source;
24
+ return inferSourceFromOrigin(candidate.origin);
25
+ }
26
+ /** Infer the enum `source` from a free-text `origin` pattern. */
27
+ export function inferSourceFromOrigin(origin) {
28
+ if (!origin)
29
+ return 'human';
30
+ if (origin.startsWith('session-end:'))
31
+ return 'auto';
32
+ if (origin.startsWith('runtime-note:'))
33
+ return 'agent';
34
+ if (origin.startsWith('mcp:'))
35
+ return 'agent';
36
+ if (origin.startsWith('cross-project:'))
37
+ return 'agent';
38
+ // Unknown origin pattern — treat as agent since something explicit was set,
39
+ // but not matching an auto pattern. Human sources typically have no origin.
40
+ return 'agent';
41
+ }
9
42
  function inboxDir(cwd, mode = 'read') {
10
43
  return resolveEntityDir('inbox', cwd ?? process.cwd(), mode);
11
44
  }
@@ -39,6 +72,11 @@ export function saveCandidate(candidate, cwd) {
39
72
  mutate({ cwd }, () => {
40
73
  ensureInboxDirs(cwd);
41
74
  candidateStore('pending', cwd).save(CandidateSchema.parse(candidate));
75
+ // Auto-refresh live companions after candidate changes (non-fatal)
76
+ try {
77
+ refreshLiveCompanions(cwd);
78
+ }
79
+ catch { /* best-effort */ }
42
80
  });
43
81
  }
44
82
  export function loadCandidate(id, cwd) {
@@ -47,15 +85,36 @@ export function loadCandidate(id, cwd) {
47
85
  export function updateCandidate(candidate, cwd) {
48
86
  saveCandidate(candidate, cwd);
49
87
  }
50
- export function listCandidates(status, cwd) {
88
+ export function listCandidates(status, cwd, filter) {
51
89
  const candidates = candidateStore('pending', cwd).list();
52
- return status ? candidates.filter((candidate) => candidate.status === status) : candidates;
90
+ const byStatus = status ? candidates.filter((candidate) => candidate.status === status) : candidates;
91
+ return applySourceFilter(byStatus, filter);
92
+ }
93
+ function applySourceFilter(candidates, filter) {
94
+ if (!filter)
95
+ return candidates;
96
+ let result = candidates;
97
+ if (filter.source !== undefined) {
98
+ result = result.filter((c) => resolvedSource(c) === filter.source);
99
+ }
100
+ if (filter.auto_generated === false) {
101
+ result = result.filter((c) => resolvedSource(c) !== 'auto');
102
+ }
103
+ else if (filter.auto_generated === true) {
104
+ result = result.filter((c) => resolvedSource(c) === 'auto');
105
+ }
106
+ return result;
53
107
  }
54
108
  export function archiveCandidate(candidate, dest, cwd) {
55
109
  mutate({ cwd }, () => {
56
110
  ensureInboxDirs(cwd);
57
111
  candidateStore(dest, cwd).save(CandidateSchema.parse(candidate));
58
112
  candidateStore('pending', cwd).delete(candidate.id);
113
+ // Auto-refresh live companions after candidate archive (non-fatal)
114
+ try {
115
+ refreshLiveCompanions(cwd);
116
+ }
117
+ catch { /* best-effort */ }
59
118
  });
60
119
  }
61
120
  export function listArchivedCandidates(dest, cwd) {
@@ -70,6 +129,38 @@ export function deleteArchivedCandidate(id, dest, cwd) {
70
129
  }
71
130
  return false;
72
131
  }
132
+ export function cleanupStaleCandidates(options = {}) {
133
+ const maxAgeDays = options.maxAgeDays ?? 30;
134
+ const source = options.source ?? 'auto';
135
+ const cutoffMs = Date.now() - maxAgeDays * 86_400_000;
136
+ const candidates = listCandidates('pending', options.cwd).filter((candidate) => {
137
+ if (resolvedSource(candidate) !== source)
138
+ return false;
139
+ return Date.parse(candidate.created_at) <= cutoffMs;
140
+ });
141
+ if (options.dryRun || candidates.length === 0) {
142
+ return {
143
+ matched: candidates.length,
144
+ deleted: 0,
145
+ candidates,
146
+ };
147
+ }
148
+ mutate({ cwd: options.cwd }, () => {
149
+ const store = candidateStore('pending', options.cwd);
150
+ for (const candidate of candidates) {
151
+ store.delete(candidate.id);
152
+ }
153
+ try {
154
+ refreshLiveCompanions(options.cwd);
155
+ }
156
+ catch { /* best-effort */ }
157
+ });
158
+ return {
159
+ matched: candidates.length,
160
+ deleted: candidates.length,
161
+ candidates,
162
+ };
163
+ }
73
164
  export function addCandidateStar(id, by, cwd) {
74
165
  const candidate = loadCandidate(id, cwd);
75
166
  if (candidate.status !== 'pending') {
@@ -5,6 +5,26 @@ import { resolveEntityDir } from './io.js';
5
5
  import { mutate } from './mutation-pipeline.js';
6
6
  import { nowISO } from './ids.js';
7
7
  import { JsonStore } from './json-store.js';
8
+ import { loadConfig } from './config.js';
9
+ import { createWorktree } from './worktree.js';
10
+ import { appendAuditEntry } from './audit.js';
11
+ import { refreshLiveCompanions } from '../commands/export.js';
12
+ import { loadSessionById } from './identity.js';
13
+ import { loadState, persistState } from './state.js';
14
+ import { createRuntimeEvent } from './events.js';
15
+ /** Parse duration string like '4h', '30m' to ms. */
16
+ function parseTtl(value) {
17
+ const match = /^(\d+)([mhd])$/i.exec(value.trim());
18
+ if (!match)
19
+ return 4 * 3_600_000;
20
+ const amount = parseInt(match[1], 10);
21
+ const unit = match[2].toLowerCase();
22
+ if (unit === 'm')
23
+ return amount * 60_000;
24
+ if (unit === 'h')
25
+ return amount * 3_600_000;
26
+ return amount * 86_400_000;
27
+ }
8
28
  function claimsDir(cwd, mode = 'read') {
9
29
  return resolveEntityDir('claims', cwd ?? process.cwd(), mode);
10
30
  }
@@ -32,6 +52,11 @@ export function saveClaim(claim, cwd) {
32
52
  sort: (a, b) => a.created_at.localeCompare(b.created_at),
33
53
  });
34
54
  writeStore.save(ClaimSchema.parse(claim));
55
+ // Auto-refresh live companions after claim changes (non-fatal)
56
+ try {
57
+ refreshLiveCompanions(cwd);
58
+ }
59
+ catch { /* best-effort */ }
35
60
  });
36
61
  }
37
62
  export function loadClaim(id, cwd) {
@@ -47,6 +72,96 @@ export function releaseClaim(id, cwd) {
47
72
  saveClaim(claim, cwd);
48
73
  return claim;
49
74
  }
75
+ /**
76
+ * Release a claim and optionally cascade the status to its linked plan.
77
+ *
78
+ * Rules:
79
+ * - planStatus='done' → only transition plan if NO OTHER active claims on same plan (last-claim rule).
80
+ * If other active claims exist, warn and leave plan in_progress.
81
+ * - planStatus='blocked' → always propagate to plan.
82
+ * - planStatus='todo'/'in_progress' or undefined → no cascade.
83
+ *
84
+ * Emits `plan_cascade_to_done` runtime event when auto-transitioning to done.
85
+ */
86
+ export function releaseClaimWithCascade(id, options = {}) {
87
+ const { planStatus, cwd } = options;
88
+ // Release the claim (idempotent: already-released claims are returned as-is)
89
+ const claim = loadClaim(id, cwd);
90
+ if (claim.status === 'released') {
91
+ return { claim, planTransitioned: false };
92
+ }
93
+ claim.status = 'released';
94
+ claim.released_at = nowISO();
95
+ saveClaim(claim, cwd);
96
+ appendAuditEntry({
97
+ actor: claim.agent,
98
+ actor_id: claim.agent_id,
99
+ action: 'release_claim',
100
+ item_id: id,
101
+ item_type: 'claim',
102
+ scope: claim.scope,
103
+ session_id: claim.session_id,
104
+ host_id: claim.host_id,
105
+ }, cwd);
106
+ // No cascade requested or no linked plan
107
+ if (!planStatus || !claim.plan_id) {
108
+ return { claim, planTransitioned: false };
109
+ }
110
+ const state = loadState(cwd);
111
+ const plan = state.plan_items.find((item) => item.id === claim.plan_id);
112
+ if (!plan) {
113
+ return { claim, planTransitioned: false };
114
+ }
115
+ const ts = nowISO();
116
+ if (planStatus === 'blocked') {
117
+ // Always propagate blocked status to plan
118
+ plan.status = 'blocked';
119
+ plan.updated_at = ts;
120
+ persistState(state, cwd);
121
+ return { claim, planTransitioned: true, planId: plan.id, newPlanStatus: 'blocked' };
122
+ }
123
+ if (planStatus === 'done') {
124
+ // Count OTHER active claims on the same plan (current claim already released above)
125
+ const otherActive = listClaims(cwd).filter((c) => c.status === 'active' && c.plan_id === claim.plan_id && c.id !== id);
126
+ if (otherActive.length > 0) {
127
+ const planWarning = `Plan has ${otherActive.length} other active claim(s); staying in_progress`;
128
+ appendAuditEntry({
129
+ actor: claim.agent,
130
+ action: 'update',
131
+ item_id: plan.id,
132
+ item_type: 'plan',
133
+ after: { cascade_blocked: true, reason: planWarning },
134
+ }, cwd);
135
+ return {
136
+ claim,
137
+ planTransitioned: false,
138
+ planWarning,
139
+ planId: plan.id,
140
+ newPlanStatus: plan.status,
141
+ otherActiveClaimsCount: otherActive.length,
142
+ };
143
+ }
144
+ // Last active claim released → auto-transition plan to done
145
+ plan.status = 'done';
146
+ if (!plan.completed_at)
147
+ plan.completed_at = ts;
148
+ plan.updated_at = ts;
149
+ persistState(state, cwd);
150
+ createRuntimeEvent({
151
+ agent: claim.agent,
152
+ agent_id: claim.agent_id,
153
+ event_type: 'plan_cascade_to_done',
154
+ claim_id: id,
155
+ plan_id: plan.id,
156
+ session_id: claim.session_id,
157
+ host_id: claim.host_id,
158
+ text: `Plan ${plan.id} auto-transitioned to done — last active claim released by ${claim.agent}`,
159
+ }, cwd);
160
+ return { claim, planTransitioned: true, planId: plan.id, newPlanStatus: 'done' };
161
+ }
162
+ // planStatus='todo', 'in_progress', or other — no cascade
163
+ return { claim, planTransitioned: false };
164
+ }
50
165
  export function generateClaimId() {
51
166
  const rand = crypto.randomBytes(4).toString('hex');
52
167
  return `clm_${rand}`;
@@ -72,4 +187,308 @@ export function expireStaleActiveClaims(cwd) {
72
187
  }
73
188
  return count;
74
189
  }
190
+ /** Default stale threshold: 24 hours. */
191
+ const DEFAULT_STALE_HOURS = 24;
192
+ /**
193
+ * Threshold below which a newly created claim is considered "young" and must not be auto-released,
194
+ * even if it has no session yet (coordinator claims are created before the worker session starts).
195
+ */
196
+ const YOUNG_CLAIM_THRESHOLD_MS = 30 * 60_000; // 30 minutes
197
+ /**
198
+ * Assess the liveness of an active claim against session state.
199
+ *
200
+ * Decision tree:
201
+ * 1. Young (< 30 min) → never auto-release — dispatcher may not have sent the worker yet.
202
+ * 2. Has session_id + session alive → 'live' — long-running work; do NOT release.
203
+ * 3. Has session_id + adopted_at + session dead → 'orphaned' — crash recovery scenario.
204
+ * 4. Has session_id + no adopted_at + session dead → 'stale' — direct agent claim, session ended.
205
+ * 5. No session_id + old → 'never-adopted' — coordinator claim never dispatched.
206
+ * 6. No session_id + within threshold → 'young'.
207
+ */
208
+ export function assessClaimLiveness(claim, options = {}) {
209
+ const nowMs = options.nowMs ?? Date.now();
210
+ const thresholdMs = (options.thresholdHours ?? DEFAULT_STALE_HOURS) * 3_600_000;
211
+ const ageMs = nowMs - new Date(claim.created_at).getTime();
212
+ // 1. Too young to classify — don't release (worker may not have started yet)
213
+ if (ageMs < YOUNG_CLAIM_THRESHOLD_MS) {
214
+ return {
215
+ status: 'young',
216
+ reason: 'Claim is less than 30 minutes old — too new to classify',
217
+ ageMs,
218
+ };
219
+ }
220
+ // 2–4. Has a session_id — check session liveness
221
+ if (claim.session_id) {
222
+ let sessionAgeMs;
223
+ let sessionAlive = false;
224
+ let sessionMissing = false;
225
+ const sessionTtlMs = options.sessionTtlMs ?? resolveSessionTtlMs(options.cwd);
226
+ try {
227
+ const session = loadSessionById(claim.session_id, options.cwd);
228
+ if (session) {
229
+ sessionAgeMs = nowMs - new Date(session.last_seen_at).getTime();
230
+ sessionAlive = sessionAgeMs < sessionTtlMs;
231
+ }
232
+ else {
233
+ // File not found — session either ended cleanly (session-end deletes it)
234
+ // or the record was deleted externally. Either way, session cannot return.
235
+ sessionMissing = true;
236
+ }
237
+ }
238
+ catch {
239
+ // Session file error — treat as dead to avoid hanging claims forever.
240
+ sessionMissing = true;
241
+ }
242
+ if (sessionAlive) {
243
+ return {
244
+ status: 'live',
245
+ reason: `Session ${claim.session_id} is active (last_seen_at within TTL)`,
246
+ ageMs,
247
+ sessionAgeMs,
248
+ };
249
+ }
250
+ // Session is dead or not found.
251
+ if (claim.adopted_at) {
252
+ // Worker was dispatched and formally adopted the claim — this is a crash.
253
+ const sessionDesc = sessionMissing
254
+ ? 'session record cannot be found'
255
+ : `last seen ${Math.round((sessionAgeMs ?? 0) / 3_600_000)}h ago`;
256
+ return {
257
+ status: 'orphaned',
258
+ reason: `Session ${claim.session_id} was adopted at ${claim.adopted_at} but is now dead (${sessionDesc})`,
259
+ ageMs,
260
+ sessionAgeMs,
261
+ };
262
+ }
263
+ // Direct agent claim, no adopted_at, session dead (Phase 4 slice pln#388):
264
+ // once we have confirmed the session cannot return — either the file is
265
+ // missing (clean end) or last_seen_at is well past the TTL — mark stale
266
+ // immediately. The old 24h age threshold forced orphaned claims to hang
267
+ // around purely because of wall-clock age, even though we already knew
268
+ // they belonged to a dead session.
269
+ const sessionConfirmedDead = sessionMissing
270
+ || (sessionAgeMs !== undefined && sessionAgeMs >= sessionTtlMs + YOUNG_CLAIM_THRESHOLD_MS);
271
+ if (sessionConfirmedDead) {
272
+ const reason = sessionMissing
273
+ ? `Session ${claim.session_id} record is gone (ended cleanly or deleted externally)`
274
+ : `Session ${claim.session_id} last_seen_at is ${Math.round((sessionAgeMs ?? 0) / 3_600_000)}h past TTL`;
275
+ return {
276
+ status: 'stale',
277
+ reason,
278
+ ageMs,
279
+ sessionAgeMs,
280
+ };
281
+ }
282
+ // Session last_seen_at just slipped past TTL — allow a short grace window
283
+ // in case the session heartbeat catches up (brief disconnect).
284
+ return {
285
+ status: 'young',
286
+ reason: 'Session is briefly past TTL — waiting for heartbeat or confirmed end',
287
+ ageMs,
288
+ sessionAgeMs,
289
+ };
290
+ }
291
+ // 5–6. No session_id — coordinator claim that was never dispatched
292
+ if (ageMs >= thresholdMs) {
293
+ return {
294
+ status: 'never-adopted',
295
+ reason: `No session ever adopted this claim and it is ${Math.round(ageMs / 3_600_000)}h old (threshold: ${options.thresholdHours ?? DEFAULT_STALE_HOURS}h)`,
296
+ ageMs,
297
+ };
298
+ }
299
+ return {
300
+ status: 'young',
301
+ reason: 'Claim has not been adopted by a session yet but is still within the stale threshold',
302
+ ageMs,
303
+ };
304
+ }
305
+ /** Resolve session TTL from config, falling back to 4 hours. */
306
+ function resolveSessionTtlMs(cwd) {
307
+ try {
308
+ return parseTtl(loadConfig(cwd).implicit_session_ttl ?? '4h');
309
+ }
310
+ catch {
311
+ return 4 * 3_600_000;
312
+ }
313
+ }
314
+ /**
315
+ * Check if a claim is stale based on session-aware liveness.
316
+ * Returns true for 'stale', 'orphaned', and 'never-adopted' statuses.
317
+ * A claim with a live session is never considered stale regardless of age.
318
+ */
319
+ export function isClaimStale(claim, thresholdHours, cwd) {
320
+ if (claim.status !== 'active')
321
+ return false;
322
+ const { status } = assessClaimLiveness(claim, { thresholdHours, cwd });
323
+ return status === 'stale' || status === 'orphaned' || status === 'never-adopted';
324
+ }
325
+ /**
326
+ * Detect and auto-release stale claims across the store.
327
+ *
328
+ * Phase 4 slice pln#388 stp_e2b10ab4: session-aware safety. When a
329
+ * `currentSessionId` is provided, the sweep skips only claims that
330
+ * belong to THAT session — prior-session claims from the same agent
331
+ * are legitimately reclaimable (crash recovery for the same agent on
332
+ * reconnect). Without `currentSessionId` we fall back to the old
333
+ * "skip same agent" rule to stay source-compatible.
334
+ *
335
+ * Uses claims.auto_release_after from config (default 24h).
336
+ * Skips claims whose session is still live ('live') or too young to
337
+ * classify ('young'). Releases 'stale', 'orphaned' (crash recovery),
338
+ * and 'never-adopted' claims.
339
+ */
340
+ export function releaseStaleClaimsFromOtherAgents(currentAgent, cwd, currentSessionId) {
341
+ const config = loadConfig(cwd);
342
+ const thresholdHours = config.claims?.auto_release_after_hours ?? DEFAULT_STALE_HOURS;
343
+ const store = claimStore(cwd);
344
+ const all = store.list();
345
+ const now = nowISO();
346
+ const released = [];
347
+ const warned = [];
348
+ for (const claim of all) {
349
+ if (claim.status !== 'active')
350
+ continue;
351
+ // Session-aware skip: if the caller names its current session, only that
352
+ // session's claims are off-limits. Otherwise fall back to the legacy
353
+ // "skip same agent" rule.
354
+ if (currentSessionId) {
355
+ if (claim.session_id === currentSessionId)
356
+ continue;
357
+ }
358
+ else if (claim.agent === currentAgent) {
359
+ continue;
360
+ }
361
+ const { status } = assessClaimLiveness(claim, { thresholdHours, cwd });
362
+ if (status === 'live' || status === 'young')
363
+ continue;
364
+ claim.status = 'released';
365
+ claim.released_at = now;
366
+ store.save(claim);
367
+ released.push(claim);
368
+ }
369
+ return { released, warned };
370
+ }
371
+ /**
372
+ * Create a coordinator-owned claim with worktree isolation.
373
+ * Encapsulates: generateClaimId + createWorktree + saveClaim + audit.
374
+ * Used by both bclaw_dispatch and bclaw_coordinate assign/reroute.
375
+ */
376
+ export function createCoordinatorClaim(options) {
377
+ // Scope lock is GLOBAL: any active claim on the same scope blocks, regardless of agent.
378
+ const existingScopeClaim = listClaims(options.cwd).find((claim) => claim.status === 'active' && claim.scope === options.scope);
379
+ if (existingScopeClaim) {
380
+ if (existingScopeClaim.agent === options.agent) {
381
+ // Same agent already has this scope — reuse the claim (backward compat, same-agent multi-call).
382
+ return {
383
+ claimId: existingScopeClaim.id,
384
+ worktreePath: existingScopeClaim.worktree_path,
385
+ reusedExisting: true,
386
+ };
387
+ }
388
+ // DIFFERENT agent has an active claim on this scope — scope is locked.
389
+ // Return the existing claim info + a conflict flag so the dispatcher can skip.
390
+ return {
391
+ claimId: existingScopeClaim.id,
392
+ worktreePath: existingScopeClaim.worktree_path,
393
+ reusedExisting: true,
394
+ scopeConflict: true,
395
+ conflictAgent: existingScopeClaim.agent,
396
+ };
397
+ }
398
+ const claimId = generateClaimId();
399
+ let worktreePath;
400
+ let worktreeWarning;
401
+ // Create isolated worktree (matching bclaw_claim MCP handler behavior)
402
+ const branchSlug = options.scope.replace(/[^a-zA-Z0-9._-]/g, '-').replace(/-+/g, '-').slice(0, 48);
403
+ const worktreeBranch = `feat/${branchSlug}`;
404
+ try {
405
+ worktreePath = createWorktree(options.cwd, worktreeBranch, {
406
+ sessionId: options.sessionId,
407
+ agent: options.agent,
408
+ baseRef: options.worktreeBaseRef,
409
+ resetExistingBranch: options.resetExistingWorktreeBranch,
410
+ });
411
+ }
412
+ catch (err) {
413
+ worktreeWarning = `Worktree creation failed: ${err instanceof Error ? err.message : String(err)}`;
414
+ }
415
+ saveClaim({
416
+ id: claimId,
417
+ agent: options.agent,
418
+ scope: options.scope,
419
+ description: options.description,
420
+ plan_id: options.planId,
421
+ created_at: nowISO(),
422
+ status: 'active',
423
+ worktree_path: worktreePath,
424
+ }, options.cwd);
425
+ appendAuditEntry({
426
+ actor: options.dispatcherAgent,
427
+ action: 'claim',
428
+ item_id: claimId,
429
+ item_type: 'claim',
430
+ scope: options.scope,
431
+ }, options.cwd);
432
+ return { claimId, worktreePath, worktreeWarning, reusedExisting: false };
433
+ }
434
+ // ── Claim lifecycle helpers for multi-instance dispatch ────
435
+ /**
436
+ * Attach the assignment message ID to a claim (for tracing claim→message→instance).
437
+ * Called by the dispatcher after sending the inbox message.
438
+ */
439
+ export function attachAssignmentMessageToClaim(claimId, messageId, cwd) {
440
+ const claim = loadClaim(claimId, cwd);
441
+ claim.assignment_message_id = messageId;
442
+ saveClaim(claim, cwd);
443
+ }
444
+ /** Link a claim to its Assignment entity (Agent SDK runtime protocol). */
445
+ export function linkClaimToAssignment(claimId, assignmentId, cwd) {
446
+ const claim = loadClaim(claimId, cwd);
447
+ claim.assignment_id = assignmentId;
448
+ saveClaim(claim, cwd);
449
+ }
450
+ /**
451
+ * Adopt a claim from a spawned instance's session.
452
+ * Sets session_id + adopted_at on the claim. Refuses if the claim is already
453
+ * adopted by a different live session (prevents race conditions).
454
+ *
455
+ * Codex r1 finding (pln#388 stp_aa095668 review): the whole load/decide/save
456
+ * sequence runs inside a single `mutate` critical section so two reconnecting
457
+ * workers racing on a dead-session claim cannot both succeed — the second
458
+ * adopter re-reads the claim under the lock and observes the first
459
+ * adopter's session_id as live.
460
+ */
461
+ export function adoptClaimSession(claimId, sessionId, cwd) {
462
+ return mutate({ cwd }, () => {
463
+ const claim = loadClaim(claimId, cwd);
464
+ if (claim.status !== 'active') {
465
+ return { adopted: false, reason: `Claim ${claimId} is not active (status: ${claim.status})` };
466
+ }
467
+ if (claim.session_id && claim.session_id !== sessionId) {
468
+ // Check if the existing session is still alive — allow re-adoption if dead/stale
469
+ let isAlive = false;
470
+ try {
471
+ const existingSession = loadSessionById(claim.session_id, cwd);
472
+ if (existingSession) {
473
+ let ttlMs = 4 * 3_600_000; // default 4h
474
+ try {
475
+ ttlMs = parseTtl(loadConfig(cwd).implicit_session_ttl ?? '4h');
476
+ }
477
+ catch { /* use default */ }
478
+ const lastSeen = new Date(existingSession.last_seen_at).getTime();
479
+ isAlive = !isNaN(lastSeen) && (Date.now() - lastSeen) < ttlMs;
480
+ }
481
+ }
482
+ catch { /* session file error — treat as dead */ }
483
+ if (isAlive) {
484
+ return { adopted: false, reason: `Claim ${claimId} already adopted by live session ${claim.session_id}` };
485
+ }
486
+ // Existing session is dead/stale — allow re-adoption
487
+ }
488
+ claim.session_id = sessionId;
489
+ claim.adopted_at = nowISO();
490
+ saveClaim(claim, cwd);
491
+ return { adopted: true, reason: 'ok' };
492
+ });
493
+ }
75
494
  //# sourceMappingURL=claims.js.map
@@ -0,0 +1,77 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { memoryDir } from './io.js';
4
+ function sanitizeForPath(slug) {
5
+ return slug.replace(/[<>:"/\\|?*]/g, '_');
6
+ }
7
+ export function metricsPath(threadSlug, cwd) {
8
+ return path.join(memoryDir(cwd), 'coordination', 'ideation', sanitizeForPath(threadSlug), 'metrics.jsonl');
9
+ }
10
+ export function recordResponse(threadSlug, metric, cwd) {
11
+ const file = metricsPath(threadSlug, cwd);
12
+ fs.mkdirSync(path.dirname(file), { recursive: true });
13
+ fs.appendFileSync(file, `${JSON.stringify(metric)}\n`, 'utf8');
14
+ }
15
+ function isMetric(v) {
16
+ const m = v;
17
+ return !!m && typeof m.thread_id === 'string' && typeof m.round === 'number' &&
18
+ typeof m.persona === 'string' && typeof m.agent_name === 'string' &&
19
+ typeof m.dispatched_at === 'string' && typeof m.responded_at === 'string' &&
20
+ typeof m.duration_ms === 'number';
21
+ }
22
+ export function loadMetrics(threadSlug, cwd) {
23
+ try {
24
+ const data = fs.readFileSync(metricsPath(threadSlug, cwd), 'utf8');
25
+ const out = [];
26
+ for (const line of data.split(/\r?\n/)) {
27
+ if (!line.trim())
28
+ continue;
29
+ try {
30
+ const parsed = JSON.parse(line);
31
+ if (isMetric(parsed))
32
+ out.push(parsed);
33
+ }
34
+ catch { }
35
+ }
36
+ return out;
37
+ }
38
+ catch {
39
+ return [];
40
+ }
41
+ }
42
+ function computeSummary(entries) {
43
+ if (!entries.length)
44
+ return { avg_ms: 0, p95_ms: 0, by_agent: {} };
45
+ const durations = entries.map((m) => m.duration_ms).sort((a, b) => a - b);
46
+ const avg_ms = durations.reduce((s, d) => s + d, 0) / durations.length;
47
+ const p95_ms = durations[Math.ceil(0.95 * durations.length) - 1];
48
+ const sums = {};
49
+ for (const m of entries) {
50
+ const entry = sums[m.agent_name] ?? { sum: 0, count: 0 };
51
+ entry.sum += m.duration_ms;
52
+ entry.count += 1;
53
+ sums[m.agent_name] = entry;
54
+ }
55
+ const by_agent = {};
56
+ for (const [agent, v] of Object.entries(sums))
57
+ by_agent[agent] = { avg_ms: v.sum / v.count, count: v.count };
58
+ return { avg_ms, p95_ms, by_agent };
59
+ }
60
+ export function summarizeMetrics(threadSlug, cwd) {
61
+ return computeSummary(loadMetrics(threadSlug, cwd));
62
+ }
63
+ export function summarizeMetricsByRound(threadSlug, cwd) {
64
+ const metrics = loadMetrics(threadSlug, cwd);
65
+ const byRound = {};
66
+ for (const m of metrics) {
67
+ (byRound[m.round] ??= []).push(m);
68
+ }
69
+ return Object.entries(byRound)
70
+ .sort(([a], [b]) => Number(a) - Number(b))
71
+ .map(([round, entries]) => ({
72
+ round: Number(round),
73
+ ...computeSummary(entries),
74
+ count: entries.length,
75
+ }));
76
+ }
77
+ //# sourceMappingURL=codev-metrics.js.map
@@ -0,0 +1,31 @@
1
+ /**
2
+ * CoDev persona definitions for multi-perspective ideation.
3
+ * @module
4
+ */
5
+ export const CODEV_PERSONAS = {
6
+ tier1: [
7
+ { name: 'visionnaire', focus: 'radical possibilities, unconventional approaches, what becomes possible if we remove all constraints' },
8
+ { name: 'conservateur', focus: 'risks, technical debt, maintainability, what could go wrong and what we will regret' },
9
+ { name: 'produit', focus: 'user impact, adoption friction, who benefits and how this changes their workflow' },
10
+ { name: 'avocat_du_diable', focus: 'challenge every assumption, find the weakest argument, stress-test the premise' },
11
+ { name: 'simplificateur', focus: 'cut complexity ruthlessly, find the 80/20, what is the smallest thing that works' },
12
+ ],
13
+ tier2: [
14
+ { name: 'stratege', focus: 'competitive moat, long-term positioning, what makes this defensible' },
15
+ { name: 'scenariste', focus: 'what-if scenarios, edge cases, how this plays out under different futures' },
16
+ { name: 'systemicien', focus: 'second-order effects, feedback loops, unintended consequences across the system' },
17
+ { name: 'opportuniste', focus: 'quick wins, low-hanging fruit, what can we ship this week that moves the needle' },
18
+ { name: 'temporaliste', focus: 'past lessons, present constraints, future trajectory — what does the timeline tell us' },
19
+ ],
20
+ };
21
+ export function listPersonas() {
22
+ const lines = [];
23
+ for (const [tier, personas] of Object.entries(CODEV_PERSONAS)) {
24
+ lines.push(`${tier}:`);
25
+ for (const p of personas) {
26
+ lines.push(` ${p.name} — ${p.focus}`);
27
+ }
28
+ }
29
+ return lines.join('\n');
30
+ }
31
+ //# sourceMappingURL=codev-personas.js.map