brainclaw 0.29.2 → 1.5.4

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 (197) hide show
  1. package/LICENSE +21 -74
  2. package/README.md +199 -176
  3. package/dist/brainclaw-vscode.vsix +0 -0
  4. package/dist/cli.js +710 -25
  5. package/dist/commands/accept.js +3 -0
  6. package/dist/commands/add-step.js +11 -26
  7. package/dist/commands/agent-board.js +70 -3
  8. package/dist/commands/audit.js +19 -0
  9. package/dist/commands/check-policy.js +54 -0
  10. package/dist/commands/check-security-mcp.js +145 -0
  11. package/dist/commands/check-security.js +106 -0
  12. package/dist/commands/claim-resource.js +1 -0
  13. package/dist/commands/codev.js +672 -0
  14. package/dist/commands/compact.js +74 -0
  15. package/dist/commands/complete-step.js +16 -26
  16. package/dist/commands/constraint.js +8 -20
  17. package/dist/commands/decision.js +9 -20
  18. package/dist/commands/delete-plan.js +10 -12
  19. package/dist/commands/delete-step.js +16 -0
  20. package/dist/commands/dispatch.js +163 -0
  21. package/dist/commands/doctor.js +1122 -49
  22. package/dist/commands/enable-agent.js +1 -0
  23. package/dist/commands/export.js +280 -22
  24. package/dist/commands/handoff.js +33 -0
  25. package/dist/commands/harvest.js +189 -0
  26. package/dist/commands/hooks.js +82 -25
  27. package/dist/commands/inbox.js +169 -0
  28. package/dist/commands/init.js +38 -31
  29. package/dist/commands/install-hooks.js +71 -44
  30. package/dist/commands/link.js +89 -0
  31. package/dist/commands/list-claims.js +48 -3
  32. package/dist/commands/list-plans.js +129 -25
  33. package/dist/commands/loops-handlers.js +409 -0
  34. package/dist/commands/mcp-read-handlers.js +1628 -0
  35. package/dist/commands/mcp-schemas.generated.js +269 -0
  36. package/dist/commands/mcp.js +4224 -1501
  37. package/dist/commands/plan-resource.js +64 -0
  38. package/dist/commands/plan.js +12 -26
  39. package/dist/commands/prune.js +37 -2
  40. package/dist/commands/reflect.js +20 -7
  41. package/dist/commands/release-claim.js +11 -6
  42. package/dist/commands/release-notes.js +170 -0
  43. package/dist/commands/repair.js +210 -0
  44. package/dist/commands/run-profile.js +57 -0
  45. package/dist/commands/sequence.js +113 -0
  46. package/dist/commands/session-end.js +423 -14
  47. package/dist/commands/session-start.js +214 -41
  48. package/dist/commands/setup-security.js +103 -0
  49. package/dist/commands/setup.js +42 -4
  50. package/dist/commands/stale.js +109 -0
  51. package/dist/commands/switch.js +100 -2
  52. package/dist/commands/trap.js +14 -31
  53. package/dist/commands/update-handoff.js +63 -4
  54. package/dist/commands/update-plan.js +21 -28
  55. package/dist/commands/update-step.js +37 -0
  56. package/dist/commands/upgrade.js +313 -6
  57. package/dist/commands/usage.js +102 -0
  58. package/dist/commands/version.js +20 -0
  59. package/dist/commands/who.js +33 -5
  60. package/dist/commands/worktree.js +105 -0
  61. package/dist/core/actions.js +315 -0
  62. package/dist/core/agent-capability.js +610 -17
  63. package/dist/core/agent-context.js +7 -1
  64. package/dist/core/agent-files.js +1169 -85
  65. package/dist/core/agent-integrations.js +160 -5
  66. package/dist/core/agent-inventory.js +2 -0
  67. package/dist/core/agent-profiles.js +93 -0
  68. package/dist/core/agent-registry.js +162 -30
  69. package/dist/core/agentrun-reconciler.js +345 -0
  70. package/dist/core/agentruns.js +424 -0
  71. package/dist/core/ai-agent-detection.js +31 -10
  72. package/dist/core/archival.js +77 -0
  73. package/dist/core/assignment-sweeper.js +82 -0
  74. package/dist/core/assignments.js +367 -0
  75. package/dist/core/audit.js +30 -0
  76. package/dist/core/brainclaw-version.js +94 -2
  77. package/dist/core/candidates.js +93 -2
  78. package/dist/core/claims.js +419 -0
  79. package/dist/core/codev-metrics.js +77 -0
  80. package/dist/core/codev-personas.js +31 -0
  81. package/dist/core/codev-plan-gen.js +35 -0
  82. package/dist/core/codev-prompts.js +74 -0
  83. package/dist/core/codev-responses.js +62 -0
  84. package/dist/core/codev-rounds.js +218 -0
  85. package/dist/core/config.js +4 -0
  86. package/dist/core/context.js +381 -34
  87. package/dist/core/coordination.js +201 -6
  88. package/dist/core/cross-project.js +230 -16
  89. package/dist/core/default-profiles/doctor.yaml +11 -0
  90. package/dist/core/default-profiles/janitor.yaml +11 -0
  91. package/dist/core/default-profiles/onboarder.yaml +11 -0
  92. package/dist/core/default-profiles/reviewer.yaml +13 -0
  93. package/dist/core/dispatcher.js +1189 -0
  94. package/dist/core/duplicates.js +2 -2
  95. package/dist/core/entity-operations.js +450 -0
  96. package/dist/core/entity-registry.js +344 -0
  97. package/dist/core/events.js +106 -2
  98. package/dist/core/execution-adapters.js +154 -0
  99. package/dist/core/execution-context.js +63 -0
  100. package/dist/core/execution-profile.js +270 -0
  101. package/dist/core/execution.js +255 -0
  102. package/dist/core/facade-schema.js +81 -0
  103. package/dist/core/federation-cloud.js +99 -0
  104. package/dist/core/federation-message.js +52 -0
  105. package/dist/core/federation-transport.js +65 -0
  106. package/dist/core/gc-semantic.js +482 -0
  107. package/dist/core/governance.js +247 -0
  108. package/dist/core/guards.js +19 -0
  109. package/dist/core/ideation.js +72 -0
  110. package/dist/core/identity.js +110 -25
  111. package/dist/core/ids.js +6 -0
  112. package/dist/core/input-validation.js +2 -2
  113. package/dist/core/instruction-templates.js +344 -136
  114. package/dist/core/io.js +90 -11
  115. package/dist/core/lock.js +6 -2
  116. package/dist/core/loops/brief-assembly.js +213 -0
  117. package/dist/core/loops/facade-schema.js +148 -0
  118. package/dist/core/loops/index.js +7 -0
  119. package/dist/core/loops/iteration-engine.js +139 -0
  120. package/dist/core/loops/lock.js +385 -0
  121. package/dist/core/loops/store.js +201 -0
  122. package/dist/core/loops/types.js +403 -0
  123. package/dist/core/loops/verbs.js +534 -0
  124. package/dist/core/markdown.js +15 -3
  125. package/dist/core/memory-compactor.js +432 -0
  126. package/dist/core/memory-git.js +152 -8
  127. package/dist/core/messaging.js +278 -0
  128. package/dist/core/migration.js +32 -1
  129. package/dist/core/mutation-pipeline.js +4 -2
  130. package/dist/core/operations/memory-mutation.js +129 -0
  131. package/dist/core/operations/memory-write.js +78 -0
  132. package/dist/core/operations/plan.js +190 -0
  133. package/dist/core/policy.js +169 -0
  134. package/dist/core/reputation.js +9 -3
  135. package/dist/core/schema.js +491 -6
  136. package/dist/core/search.js +21 -2
  137. package/dist/core/security-cache.js +71 -0
  138. package/dist/core/security-guard.js +152 -0
  139. package/dist/core/security-scoring.js +86 -0
  140. package/dist/core/sequence.js +130 -0
  141. package/dist/core/socket-client.js +113 -0
  142. package/dist/core/staleness.js +246 -0
  143. package/dist/core/state.js +98 -22
  144. package/dist/core/store-resolution.js +43 -11
  145. package/dist/core/toml-writer.js +76 -0
  146. package/dist/core/upgrades/backup.js +232 -0
  147. package/dist/core/upgrades/health-check.js +169 -0
  148. package/dist/core/upgrades/patches/candidate-archive.js +145 -0
  149. package/dist/core/upgrades/patches/handoff-review-strip.js +128 -0
  150. package/dist/core/upgrades/patches/provenance-rollout.js +136 -0
  151. package/dist/core/upgrades/schema-version.js +97 -0
  152. package/dist/core/worktree.js +606 -0
  153. package/dist/facts.js +114 -0
  154. package/dist/facts.json +111 -0
  155. package/docs/architecture/project-refs.md +5 -1
  156. package/docs/cli.md +690 -43
  157. package/docs/concepts/ideation-loop.md +317 -0
  158. package/docs/concepts/loop-engine.md +456 -0
  159. package/docs/concepts/mcp-governance.md +268 -0
  160. package/docs/concepts/memory-staleness.md +122 -0
  161. package/docs/concepts/multi-agent-workflows.md +166 -0
  162. package/docs/concepts/plans-and-claims.md +31 -6
  163. package/docs/concepts/project-md-convention.md +35 -0
  164. package/docs/concepts/troubleshooting.md +220 -0
  165. package/docs/concepts/upgrade-cli.md +202 -0
  166. package/docs/concepts/upgrade-dogfood-procedure.md +114 -0
  167. package/docs/context-format-changelog.md +2 -2
  168. package/docs/context-format.md +2 -2
  169. package/docs/index.md +68 -0
  170. package/docs/integrations/agents.md +15 -16
  171. package/docs/integrations/cline.md +88 -0
  172. package/docs/integrations/codex.md +75 -23
  173. package/docs/integrations/continue.md +60 -0
  174. package/docs/integrations/copilot.md +67 -9
  175. package/docs/integrations/kilocode.md +72 -0
  176. package/docs/integrations/mcp.md +304 -21
  177. package/docs/integrations/mistral-vibe.md +122 -0
  178. package/docs/integrations/opencode.md +84 -0
  179. package/docs/integrations/overview.md +23 -8
  180. package/docs/integrations/roo.md +74 -0
  181. package/docs/integrations/windsurf.md +83 -0
  182. package/docs/mcp-schema-changelog.md +191 -1
  183. package/docs/playbooks/integration/index.md +121 -0
  184. package/docs/playbooks/productivity/index.md +102 -0
  185. package/docs/playbooks/team/index.md +122 -0
  186. package/docs/product/agent-first-model.md +184 -0
  187. package/docs/product/entity-model-audit.md +462 -0
  188. package/docs/product/positioning.md +10 -10
  189. package/docs/quickstart-existing-project.md +135 -0
  190. package/docs/quickstart.md +124 -37
  191. package/docs/release-maintenance.md +79 -0
  192. package/docs/review.md +2 -0
  193. package/docs/server-operations.md +118 -0
  194. package/package.json +21 -13
  195. package/dist/commands/claude-desktop-extension.js +0 -18
  196. package/dist/commands/diff.js +0 -99
  197. package/dist/core/claude-desktop-extension.js +0 -224
@@ -1,11 +1,40 @@
1
1
  import { memoryExists } from '../core/io.js';
2
- import { listClaims } from '../core/claims.js';
2
+ import { listClaims, expireStaleActiveClaims, isClaimExpired, assessClaimLiveness } from '../core/claims.js';
3
+ import { resolveStoreChain } from '../core/store-resolution.js';
3
4
  export function runListClaims(options = {}) {
4
5
  if (!memoryExists(options.cwd)) {
5
6
  console.error('Error: .brainclaw/ not found. Run `brainclaw init` first.');
6
7
  process.exit(1);
7
8
  }
8
- let claims = listClaims(options.cwd);
9
+ const effectiveCwd = options.cwd ?? process.cwd();
10
+ // Auto-expire claims whose TTL has passed before listing (local store only — writes are always local)
11
+ expireStaleActiveClaims(options.cwd);
12
+ let claims;
13
+ if (options.localOnly) {
14
+ claims = listClaims(options.cwd);
15
+ }
16
+ else {
17
+ const chain = resolveStoreChain(effectiveCwd);
18
+ const seenIds = new Set();
19
+ claims = [];
20
+ for (const store of chain) {
21
+ try {
22
+ // Expire stale claims in each parent store too
23
+ expireStaleActiveClaims(store.cwd);
24
+ for (const claim of listClaims(store.cwd)) {
25
+ if (!seenIds.has(claim.id)) {
26
+ seenIds.add(claim.id);
27
+ claims.push(claim);
28
+ }
29
+ }
30
+ }
31
+ catch { /* skip unreadable stores */ }
32
+ }
33
+ // Fallback when no chain found
34
+ if (claims.length === 0 && chain.length === 0) {
35
+ claims = listClaims(options.cwd);
36
+ }
37
+ }
9
38
  if (!options.all) {
10
39
  claims = claims.filter(c => c.status === 'active');
11
40
  }
@@ -30,6 +59,7 @@ export function runListClaims(options = {}) {
30
59
  console.log(`${claims.length} ${label}:`);
31
60
  console.log('');
32
61
  for (const c of claims) {
62
+ const expired = isClaimExpired(c) ? ' [EXPIRED]' : '';
33
63
  const status = c.status !== 'active' ? ` (${c.status})` : '';
34
64
  const extras = [];
35
65
  if (c.session_id)
@@ -38,8 +68,23 @@ export function runListClaims(options = {}) {
38
68
  extras.push(`plan ${c.plan_id}`);
39
69
  if (c.project)
40
70
  extras.push(`project ${c.project}`);
71
+ if (c.expires_at && !isClaimExpired(c))
72
+ extras.push(`expires ${c.expires_at.slice(0, 16).replace('T', ' ')}`);
41
73
  const suffix = extras.length ? ` [${extras.join(', ')}]` : '';
42
- console.log(` [${c.id}] ${c.agent} ${c.scope}: ${c.description}${suffix}${status}`);
74
+ // Liveness tag for active claims (omit for released/done)
75
+ let livenessTag = '';
76
+ if (c.status === 'active') {
77
+ const liveness = assessClaimLiveness(c, { cwd: options.cwd });
78
+ if (liveness.status === 'live')
79
+ livenessTag = ' [LIVE]';
80
+ else if (liveness.status === 'orphaned')
81
+ livenessTag = ' [ORPHANED]';
82
+ else if (liveness.status === 'never-adopted')
83
+ livenessTag = ' [NEVER-ADOPTED]';
84
+ else if (liveness.status === 'stale')
85
+ livenessTag = ' [STALE]';
86
+ }
87
+ console.log(` [${c.id}] ${c.agent} → ${c.scope}: ${c.description}${suffix}${livenessTag}${status}${expired}`);
43
88
  }
44
89
  }
45
90
  //# sourceMappingURL=list-claims.js.map
@@ -1,48 +1,152 @@
1
+ import path from 'node:path';
1
2
  import { loadState } from '../core/state.js';
2
3
  import { memoryExists } from '../core/io.js';
3
- export function runListPlans(options = {}) {
4
- if (!memoryExists(options.cwd)) {
5
- console.error('Error: .brainclaw/ not found. Run `brainclaw init` first.');
6
- process.exit(1);
7
- }
8
- let plans = loadState(options.cwd).plan_items;
4
+ import { resolveStoreChain } from '../core/store-resolution.js';
5
+ import { scanNestedBrainclawProjects } from '../core/workspace-projects.js';
6
+ function filterPlans(plans, options) {
7
+ let filtered = plans;
9
8
  if (!options.all) {
10
- plans = plans.filter((plan) => plan.status !== 'done' && plan.status !== 'dropped');
9
+ filtered = filtered.filter((plan) => plan.status !== 'done' && plan.status !== 'dropped');
11
10
  }
12
11
  if (options.status) {
13
- plans = plans.filter((plan) => plan.status === options.status);
12
+ filtered = filtered.filter((plan) => plan.status === options.status);
14
13
  }
15
14
  if (options.type) {
16
- plans = plans.filter((plan) => plan.type === options.type);
15
+ filtered = filtered.filter((plan) => plan.type === options.type);
17
16
  }
18
17
  if (options.assignee) {
19
18
  const target = options.assignee.toLowerCase();
20
- plans = plans.filter((plan) => plan.assignee?.toLowerCase() === target);
19
+ filtered = filtered.filter((plan) => plan.assignee?.toLowerCase() === target);
21
20
  }
22
21
  if (options.project) {
23
22
  const project = options.project.toLowerCase();
24
- plans = plans.filter((plan) => plan.project?.toLowerCase() === project);
23
+ filtered = filtered.filter((plan) => plan.project?.toLowerCase() === project);
24
+ }
25
+ return filtered;
26
+ }
27
+ function formatPlan(plan) {
28
+ const meta = [plan.type ?? 'feat', plan.status, plan.priority];
29
+ if (plan.assignee)
30
+ meta.push(`assignee ${plan.assignee}`);
31
+ if (plan.project)
32
+ meta.push(`project ${plan.project}`);
33
+ if (plan.depends_on.length > 0)
34
+ meta.push(`depends_on ${plan.depends_on.join(',')}`);
35
+ const tags = plan.tags.length ? ` [${plan.tags.join(', ')}]` : '';
36
+ return ` [${plan.id}] ${plan.text} (${meta.join(' · ')})${tags}`;
37
+ }
38
+ export function scanDescendantPlans(cwd, options) {
39
+ const resolvedCwd = path.resolve(cwd ?? process.cwd());
40
+ const descendants = scanNestedBrainclawProjects(resolvedCwd);
41
+ const groups = [];
42
+ // In recursive mode, --project filters by descendant project name/path, not plan.project field
43
+ const projectFilter = options.project?.toLowerCase();
44
+ const filterOpts = { ...options, project: undefined };
45
+ for (const project of descendants) {
46
+ const relativePath = path.relative(resolvedCwd, project.path);
47
+ const projectName = project.project_name?.toLowerCase();
48
+ const relLower = relativePath.toLowerCase();
49
+ const baseLower = path.basename(project.path).toLowerCase();
50
+ if (projectFilter && projectName !== projectFilter && relLower !== projectFilter && baseLower !== projectFilter) {
51
+ continue;
52
+ }
53
+ try {
54
+ const plans = filterPlans(loadState(project.path).plan_items, filterOpts);
55
+ if (plans.length > 0) {
56
+ groups.push({
57
+ path: project.path,
58
+ relative_path: relativePath,
59
+ project_name: project.project_name,
60
+ plans,
61
+ });
62
+ }
63
+ }
64
+ catch { /* skip unreadable project */ }
65
+ }
66
+ return groups;
67
+ }
68
+ export function runListPlans(options = {}) {
69
+ if (!memoryExists(options.cwd)) {
70
+ console.error('Error: .brainclaw/ not found. Run `brainclaw init` first.');
71
+ process.exit(1);
72
+ }
73
+ const effectiveCwd = options.cwd ?? process.cwd();
74
+ let rawPlans;
75
+ if (options.localOnly) {
76
+ rawPlans = loadState(options.cwd).plan_items;
77
+ }
78
+ else {
79
+ const chain = resolveStoreChain(effectiveCwd);
80
+ const seenIds = new Set();
81
+ rawPlans = [];
82
+ for (const store of chain) {
83
+ try {
84
+ for (const plan of loadState(store.cwd).plan_items) {
85
+ if (!seenIds.has(plan.id)) {
86
+ seenIds.add(plan.id);
87
+ rawPlans.push(plan);
88
+ }
89
+ }
90
+ }
91
+ catch { /* skip unreadable stores */ }
92
+ }
93
+ // Fallback when no chain found (should not happen if memoryExists passed)
94
+ if (rawPlans.length === 0 && chain.length === 0) {
95
+ rawPlans = loadState(options.cwd).plan_items;
96
+ }
97
+ }
98
+ const localPlans = filterPlans(rawPlans, options);
99
+ if (options.recursive) {
100
+ const cwd = options.cwd ?? process.cwd();
101
+ const descendantGroups = scanDescendantPlans(cwd, options);
102
+ const totalDescendantPlans = descendantGroups.reduce((sum, g) => sum + g.plans.length, 0);
103
+ if (options.json) {
104
+ console.log(JSON.stringify({
105
+ local: localPlans,
106
+ descendants: descendantGroups,
107
+ total: localPlans.length + totalDescendantPlans,
108
+ }, null, 2));
109
+ return;
110
+ }
111
+ // Local plans
112
+ console.log(`── local (${localPlans.length} plans) ──`);
113
+ if (localPlans.length === 0) {
114
+ console.log(' (none)');
115
+ }
116
+ else {
117
+ for (const plan of localPlans)
118
+ console.log(formatPlan(plan));
119
+ }
120
+ // Descendant plans
121
+ for (const group of descendantGroups) {
122
+ const label = group.project_name ?? group.relative_path;
123
+ console.log(`\n── ${label} (${group.plans.length} plans) ──`);
124
+ for (const plan of group.plans)
125
+ console.log(formatPlan(plan));
126
+ }
127
+ if (localPlans.length === 0 && totalDescendantPlans === 0) {
128
+ console.log('\nNo plan items found locally or in descendants.');
129
+ }
130
+ return;
25
131
  }
26
132
  if (options.json) {
27
- console.log(JSON.stringify(plans, null, 2));
133
+ console.log(JSON.stringify(localPlans, null, 2));
28
134
  return;
29
135
  }
30
- if (plans.length === 0) {
136
+ if (localPlans.length === 0) {
137
+ // Check descendants for signal
138
+ const cwd = options.cwd ?? process.cwd();
139
+ const descendantGroups = scanDescendantPlans(cwd, options);
140
+ const totalDescendantPlans = descendantGroups.reduce((sum, g) => sum + g.plans.length, 0);
31
141
  console.log('No plan items found.');
142
+ if (totalDescendantPlans > 0) {
143
+ console.log(`ℹ ${totalDescendantPlans} plan(s) found in ${descendantGroups.length} descendant project(s) (use --recursive to see all)`);
144
+ }
32
145
  return;
33
146
  }
34
- console.log(`${plans.length} plan item(s):`);
147
+ console.log(`${localPlans.length} plan item(s):`);
35
148
  console.log('');
36
- for (const plan of plans) {
37
- const meta = [plan.type ?? 'feat', plan.status, plan.priority];
38
- if (plan.assignee)
39
- meta.push(`assignee ${plan.assignee}`);
40
- if (plan.project)
41
- meta.push(`project ${plan.project}`);
42
- if (plan.depends_on.length > 0)
43
- meta.push(`depends_on ${plan.depends_on.join(',')}`);
44
- const tags = plan.tags.length ? ` [${plan.tags.join(', ')}]` : '';
45
- console.log(` [${plan.id}] ${plan.text} (${meta.join(' · ')})${tags}`);
46
- }
149
+ for (const plan of localPlans)
150
+ console.log(formatPlan(plan));
47
151
  }
48
152
  //# sourceMappingURL=list-plans.js.map
@@ -0,0 +1,409 @@
1
+ import { ZodError } from 'zod';
2
+ import { listAgentRuns } from '../core/agentruns.js';
3
+ import { reconcileAgentRun } from '../core/agentrun-reconciler.js';
4
+ import { add_artifact, advance, closeLoop, complete_turn, getLoop, IdempotencyKeyReusedError, IdempotencyOwnerMismatchError, listLoopEvents, listLoops, LockLostError, LockTimeoutError, openLoop, pause, resume, turn, VersionConflictError, withLoopLock, } from '../core/loops/index.js';
5
+ import { BclawLoopRequestSchema, BCLAW_LOOP_INTENTS, } from '../core/loops/facade-schema.js';
6
+ function resolveActor(req, defaultActor) {
7
+ const agentId = req.agentId?.trim() || defaultActor;
8
+ const actor = req.agent?.trim() || req.agentId?.trim() || defaultActor;
9
+ return { actor, agentId };
10
+ }
11
+ function successResponse(intent, result, artifacts, side_effects, warnings, durationMs, summary) {
12
+ return {
13
+ response: {
14
+ status: 'ok',
15
+ intent: `bclaw_loop.${intent}`,
16
+ result,
17
+ artifacts,
18
+ side_effects,
19
+ warnings,
20
+ duration_ms: durationMs,
21
+ },
22
+ summary,
23
+ };
24
+ }
25
+ function errorResponse(intent, code, message, durationMs, result = null) {
26
+ return {
27
+ response: {
28
+ status: 'error',
29
+ intent: `bclaw_loop.${intent}`,
30
+ result,
31
+ artifacts: [],
32
+ side_effects: [],
33
+ warnings: [],
34
+ error: `${code}: ${message}`,
35
+ duration_ms: durationMs,
36
+ },
37
+ summary: `✘ bclaw_loop[${intent}] ${code}: ${message}`,
38
+ };
39
+ }
40
+ function inferIntent(args) {
41
+ if (!args || typeof args !== 'object')
42
+ return 'unknown';
43
+ const candidate = args.intent;
44
+ if (typeof candidate !== 'string')
45
+ return 'unknown';
46
+ return BCLAW_LOOP_INTENTS.includes(candidate)
47
+ ? candidate
48
+ : 'unknown';
49
+ }
50
+ function loopArtifactEntry(id) {
51
+ return { type: 'loop', id };
52
+ }
53
+ function loopEventArtifactEntry(id) {
54
+ return { type: 'loop_event', id };
55
+ }
56
+ function sideEffectCreate(entity, id) {
57
+ return { action: 'create', entity, id };
58
+ }
59
+ function sideEffectUpdate(entity, id) {
60
+ return { action: 'update', entity, id };
61
+ }
62
+ function snapshotLoopEvents(loopId, cwd) {
63
+ return new Set(listLoopEvents(loopId, cwd).map((event) => event.event_id));
64
+ }
65
+ function findNewLoopEvents(loopId, before, cwd) {
66
+ const events = listLoopEvents(loopId, cwd);
67
+ if (!before)
68
+ return events;
69
+ return events.filter((event) => !before.has(event.event_id));
70
+ }
71
+ function loopEventArtifacts(events) {
72
+ return events.map((event) => loopEventArtifactEntry(event.event_id));
73
+ }
74
+ function loopEventSideEffects(events) {
75
+ return events.map((event) => sideEffectCreate('loop_event', event.event_id));
76
+ }
77
+ function validateSemanticRequest(req) {
78
+ if (req.intent === 'turn' && !req.slot_id && !req.role) {
79
+ return 'turn requires slot_id or role';
80
+ }
81
+ return null;
82
+ }
83
+ function requestPayload(req) {
84
+ const { agent, agentId, client_request_id, ...rest } = req;
85
+ return rest;
86
+ }
87
+ function currentLoopVersion(loopId, cwd) {
88
+ return getLoop(loopId, cwd)?.version ?? 0;
89
+ }
90
+ /**
91
+ * Slot-bound intents must not let a cached idempotent response leak to a
92
+ * different caller. `complete_turn` is the obvious case: it carries auth
93
+ * semantics (caller_agent_id must match the slot owner or loop.created_by).
94
+ * Without caller-match enforcement on the idempotency cache, a second caller
95
+ * who learned the client_request_id could replay it and receive the cached
96
+ * success payload — bypassing slot-bound auth from an information-disclosure
97
+ * perspective even though the state change already happened. Intents listed
98
+ * here get `requireCallerMatch: true` on their withLoopLock invocation.
99
+ */
100
+ const SLOT_BOUND_INTENTS = new Set(['complete_turn']);
101
+ /**
102
+ * Fence-check discipline for mutations.
103
+ *
104
+ * The `work` callback calls `fenceCheck()` at entry before invoking the verb.
105
+ * This closes the "lock acquired, then reaped mid-wait, then foreign writer
106
+ * took over" window — the verb will not proceed if the lock's mutation_id
107
+ * changed between `acquireLock` and `work` dispatch. It does NOT cover mid-verb
108
+ * fs operations: the verbs themselves (`openLoop`, `advance`, …) perform their
109
+ * atomic-rename + JSONL append without consulting the fence. That gap is
110
+ * intentional for the MVP because the verbs are synchronous and complete in
111
+ * single-digit milliseconds, much shorter than any reasonable hard_deadline
112
+ * (default 30_000 ms). If a future slice adds async dispatch inside a
113
+ * mutation, thread `fenceCheck` down into the verb and call it before each
114
+ * committing write.
115
+ */
116
+ function withLockedLoopMutation(req, agentId, cwd, work) {
117
+ return withLoopLock({
118
+ cwd,
119
+ intent: req.intent,
120
+ agentId,
121
+ scope: { kind: 'loop', loopId: req.loop_id },
122
+ expectedVersion: req.expected_version,
123
+ clientRequestId: req.client_request_id,
124
+ requestPayload: requestPayload(req),
125
+ currentVersion: () => currentLoopVersion(req.loop_id, cwd),
126
+ requireCallerMatch: SLOT_BOUND_INTENTS.has(req.intent),
127
+ work: ({ fenceCheck }) => {
128
+ // Best-effort fence at entry; see SLOT_BOUND_INTENTS / fence-check
129
+ // discipline comment above for why mid-verb re-checks are deferred.
130
+ fenceCheck();
131
+ return work();
132
+ },
133
+ });
134
+ }
135
+ /**
136
+ * `NextExpectedHint` — self-describing hint to the caller about the most
137
+ * natural next intent. Kept conservative for the MVP: we look at the loop's
138
+ * status + slot states and pick the smallest correct action.
139
+ */
140
+ function computeNextExpected(loop) {
141
+ if (loop.status === 'completed' || loop.status === 'cancelled' || loop.status === 'blocked') {
142
+ return null;
143
+ }
144
+ if (loop.status === 'paused') {
145
+ return null;
146
+ }
147
+ const currentPhaseSlots = loop.slots.filter((s) => (s.phase ?? loop.current_phase) === loop.current_phase);
148
+ const openSlots = currentPhaseSlots.filter((s) => s.status === 'open');
149
+ if (openSlots.length > 0) {
150
+ const first = openSlots[0];
151
+ return {
152
+ action: 'turn',
153
+ intent: 'bclaw_loop.turn',
154
+ phase: loop.current_phase,
155
+ slot_id: first.slot_id,
156
+ role: first.role,
157
+ blocking_on: openSlots.map((s) => s.slot_id),
158
+ };
159
+ }
160
+ const assignedOrWorking = currentPhaseSlots.filter((s) => s.status === 'assigned' || s.status === 'working');
161
+ if (assignedOrWorking.length > 0) {
162
+ return {
163
+ action: 'complete_turn',
164
+ intent: 'bclaw_loop.complete_turn',
165
+ phase: loop.current_phase,
166
+ slot_id: assignedOrWorking[0].slot_id,
167
+ role: assignedOrWorking[0].role,
168
+ blocking_on: assignedOrWorking.map((s) => s.slot_id),
169
+ };
170
+ }
171
+ const phaseNames = loop.phases.map((p) => p.name);
172
+ const currentIndex = phaseNames.indexOf(loop.current_phase);
173
+ if (currentIndex >= 0 && currentIndex + 1 < phaseNames.length) {
174
+ return {
175
+ action: 'advance',
176
+ intent: 'bclaw_loop.advance',
177
+ from_phase: loop.current_phase,
178
+ to_phase: phaseNames[currentIndex + 1],
179
+ blocking_on: [],
180
+ };
181
+ }
182
+ return {
183
+ action: 'close',
184
+ intent: 'bclaw_loop.close',
185
+ reason: 'terminal_phase_reached',
186
+ blocking_on: [],
187
+ };
188
+ }
189
+ function summarizeLoop(loop, autoClosed) {
190
+ const suffix = autoClosed ? ' (auto-closed)' : '';
191
+ return `✔ loop ${loop.id} [${loop.kind}] phase=${loop.current_phase} status=${loop.status}${suffix}`;
192
+ }
193
+ export function handleBclawLoop(options) {
194
+ const startMs = Date.now();
195
+ const defaultActor = options.defaultActor ?? 'bclaw_loop';
196
+ const inferredIntent = inferIntent(options.args);
197
+ const parseResult = BclawLoopRequestSchema.safeParse(options.args);
198
+ if (!parseResult.success) {
199
+ return errorResponse(inferredIntent, 'validation_error', parseResult.error.message, Date.now() - startMs);
200
+ }
201
+ const req = parseResult.data;
202
+ const semanticError = validateSemanticRequest(req);
203
+ if (semanticError) {
204
+ return errorResponse(req.intent, 'validation_error', semanticError, Date.now() - startMs);
205
+ }
206
+ const { actor, agentId } = resolveActor(req, defaultActor);
207
+ try {
208
+ switch (req.intent) {
209
+ case 'open': {
210
+ if (!req.allow_orphan) {
211
+ return errorResponse('open', 'validation_error', "Direct bclaw_loop(intent='open') creates a loop with no dispatch — no claim, no inbox message, no agent will pick up the work. Use bclaw_coordinate(intent='review', open_loop=true) for review loops (recommended), or pass allow_orphan: true to acknowledge that you will handle turn() + dispatch manually (advanced/test use only). See CLAUDE.md anti-pattern note and pln#461.", Date.now() - startMs);
212
+ }
213
+ const runOpen = () => {
214
+ const loop = openLoop({
215
+ kind: req.kind,
216
+ title: req.title,
217
+ goal: req.goal,
218
+ phases: req.phases,
219
+ slots: req.slots,
220
+ linked: req.linked,
221
+ stop_condition: req.stop_condition,
222
+ mode: req.mode,
223
+ created_by: agentId,
224
+ }, options.cwd);
225
+ const newEvents = findNewLoopEvents(loop.id, undefined, options.cwd);
226
+ return successResponse('open', { loop, next_expected: computeNextExpected(loop) }, [loopArtifactEntry(loop.id), ...loopEventArtifacts(newEvents)], [sideEffectCreate('loop', loop.id), ...loopEventSideEffects(newEvents)], [], Date.now() - startMs, `✔ opened ${loop.id} [${loop.kind}] phase=${loop.current_phase}`);
227
+ };
228
+ if (!req.client_request_id) {
229
+ return runOpen();
230
+ }
231
+ return withLoopLock({
232
+ cwd: options.cwd,
233
+ intent: req.intent,
234
+ agentId,
235
+ scope: { kind: 'open_idempotency', clientRequestId: req.client_request_id },
236
+ clientRequestId: req.client_request_id,
237
+ requestPayload: requestPayload(req),
238
+ loopIdForIdempotency: undefined,
239
+ work: () => runOpen(),
240
+ });
241
+ }
242
+ case 'get': {
243
+ const loop = getLoop(req.loop_id, options.cwd);
244
+ if (!loop) {
245
+ return errorResponse('get', 'not_found', `unknown loop_id ${req.loop_id}`, Date.now() - startMs);
246
+ }
247
+ // pln#496 Phase 2: reconcile each slot's assigned run before
248
+ // returning the loop. Catches the silent-completion case where a
249
+ // dispatched reviewer committed work / released their claim but
250
+ // never emitted run_completed — the loop appeared stuck on
251
+ // 'assigned' slots forever in May 2026 (lop_3b2068e25166e183 +
252
+ // lop_ea5852302acb8cbb). Targeted to slots with assignment_id so
253
+ // we never run a broad scan here.
254
+ try {
255
+ for (const slot of loop.slots ?? []) {
256
+ const slotStatus = slot.status;
257
+ const assignmentId = slot.assignment_id;
258
+ if (!assignmentId)
259
+ continue;
260
+ if (slotStatus === 'done' || slotStatus === 'failed' || slotStatus === 'cancelled')
261
+ continue;
262
+ for (const run of listAgentRuns(options.cwd, { assignment_id: assignmentId })) {
263
+ reconcileAgentRun(run.id, options.cwd);
264
+ }
265
+ }
266
+ }
267
+ catch { /* defensive: never block loop reads on reconcile errors */ }
268
+ // Re-load the loop after reconciliation so any slot transitions
269
+ // triggered by the reconciler (e.g. via assignment lifecycle hooks)
270
+ // are reflected in the response. Cheap (fs read).
271
+ const reconciledLoop = getLoop(req.loop_id, options.cwd) ?? loop;
272
+ const events = req.include_events ? listLoopEvents(req.loop_id, options.cwd) : undefined;
273
+ return successResponse('get', { loop: reconciledLoop, events, next_expected: computeNextExpected(reconciledLoop) }, [loopArtifactEntry(reconciledLoop.id)], [], [], Date.now() - startMs, summarizeLoop(reconciledLoop));
274
+ }
275
+ case 'list': {
276
+ const loops = listLoops({ kind: req.kind, status: req.status }, options.cwd);
277
+ const sliced = typeof req.offset === 'number' || typeof req.limit === 'number'
278
+ ? loops.slice(req.offset ?? 0, (req.offset ?? 0) + (req.limit ?? loops.length))
279
+ : loops;
280
+ return successResponse('list', { loops: sliced, total: loops.length }, sliced.map((l) => loopArtifactEntry(l.id)), [], [], Date.now() - startMs, `✔ list ${sliced.length}/${loops.length} loops`);
281
+ }
282
+ case 'turn': {
283
+ return withLockedLoopMutation(req, agentId, options.cwd, () => {
284
+ const beforeEvents = snapshotLoopEvents(req.loop_id, options.cwd);
285
+ const loop = turn({
286
+ id: req.loop_id,
287
+ slot_id: req.slot_id,
288
+ role: req.role,
289
+ input: req.input,
290
+ dispatch: req.dispatch,
291
+ assignment_id: req.assignment_id,
292
+ actor,
293
+ }, options.cwd);
294
+ const newEvents = findNewLoopEvents(loop.id, beforeEvents, options.cwd);
295
+ return successResponse('turn', { loop, next_expected: computeNextExpected(loop) }, [loopArtifactEntry(loop.id), ...loopEventArtifacts(newEvents)], [sideEffectUpdate('loop', loop.id), ...loopEventSideEffects(newEvents)], [], Date.now() - startMs, summarizeLoop(loop));
296
+ });
297
+ }
298
+ case 'complete_turn': {
299
+ return withLockedLoopMutation(req, agentId, options.cwd, () => {
300
+ const beforeEvents = snapshotLoopEvents(req.loop_id, options.cwd);
301
+ const loop = complete_turn({
302
+ id: req.loop_id,
303
+ slot_id: req.slot_id,
304
+ outcome: req.outcome,
305
+ failure_reason: req.failure_reason,
306
+ artifact: req.artifact
307
+ ? {
308
+ phase: req.artifact.phase,
309
+ type: req.artifact.type,
310
+ body: req.artifact.body,
311
+ ref: req.artifact.ref,
312
+ }
313
+ : undefined,
314
+ actor,
315
+ caller_agent_id: req.agentId,
316
+ }, options.cwd);
317
+ const newEvents = findNewLoopEvents(loop.id, beforeEvents, options.cwd);
318
+ return successResponse('complete_turn', { loop, next_expected: computeNextExpected(loop) }, [loopArtifactEntry(loop.id), ...loopEventArtifacts(newEvents)], [sideEffectUpdate('loop', loop.id), ...loopEventSideEffects(newEvents)], [], Date.now() - startMs, summarizeLoop(loop));
319
+ });
320
+ }
321
+ case 'advance': {
322
+ return withLockedLoopMutation(req, agentId, options.cwd, () => {
323
+ const beforeEvents = snapshotLoopEvents(req.loop_id, options.cwd);
324
+ const result = advance({
325
+ id: req.loop_id,
326
+ to_phase: req.to_phase,
327
+ reason: req.reason,
328
+ force: req.force,
329
+ actor,
330
+ }, options.cwd);
331
+ const newEvents = findNewLoopEvents(result.loop.id, beforeEvents, options.cwd);
332
+ return successResponse('advance', { loop: result.loop, auto_closed: result.auto_closed, next_expected: computeNextExpected(result.loop) }, [loopArtifactEntry(result.loop.id), ...loopEventArtifacts(newEvents)], [sideEffectUpdate('loop', result.loop.id), ...loopEventSideEffects(newEvents)], [], Date.now() - startMs, summarizeLoop(result.loop, result.auto_closed));
333
+ });
334
+ }
335
+ case 'add_artifact': {
336
+ return withLockedLoopMutation(req, agentId, options.cwd, () => {
337
+ const beforeEvents = snapshotLoopEvents(req.loop_id, options.cwd);
338
+ const loop = add_artifact({
339
+ id: req.loop_id,
340
+ artifact: {
341
+ phase: req.artifact.phase,
342
+ type: req.artifact.type,
343
+ body: req.artifact.body,
344
+ produced_by: req.artifact.produced_by,
345
+ ref: req.artifact.ref,
346
+ },
347
+ actor,
348
+ }, options.cwd);
349
+ const newEvents = findNewLoopEvents(loop.id, beforeEvents, options.cwd);
350
+ return successResponse('add_artifact', { loop, next_expected: computeNextExpected(loop) }, [loopArtifactEntry(loop.id), ...loopEventArtifacts(newEvents)], [sideEffectUpdate('loop', loop.id), ...loopEventSideEffects(newEvents)], [], Date.now() - startMs, summarizeLoop(loop));
351
+ });
352
+ }
353
+ case 'pause': {
354
+ return withLockedLoopMutation(req, agentId, options.cwd, () => {
355
+ const beforeEvents = snapshotLoopEvents(req.loop_id, options.cwd);
356
+ const loop = pause({ id: req.loop_id, reason: req.reason, actor }, options.cwd);
357
+ const newEvents = findNewLoopEvents(loop.id, beforeEvents, options.cwd);
358
+ return successResponse('pause', { loop, next_expected: null }, [loopArtifactEntry(loop.id), ...loopEventArtifacts(newEvents)], [sideEffectUpdate('loop', loop.id), ...loopEventSideEffects(newEvents)], [], Date.now() - startMs, summarizeLoop(loop));
359
+ });
360
+ }
361
+ case 'resume': {
362
+ return withLockedLoopMutation(req, agentId, options.cwd, () => {
363
+ const beforeEvents = snapshotLoopEvents(req.loop_id, options.cwd);
364
+ const loop = resume({ id: req.loop_id, actor }, options.cwd);
365
+ const newEvents = findNewLoopEvents(loop.id, beforeEvents, options.cwd);
366
+ return successResponse('resume', { loop, next_expected: computeNextExpected(loop) }, [loopArtifactEntry(loop.id), ...loopEventArtifacts(newEvents)], [sideEffectUpdate('loop', loop.id), ...loopEventSideEffects(newEvents)], [], Date.now() - startMs, summarizeLoop(loop));
367
+ });
368
+ }
369
+ case 'close': {
370
+ return withLockedLoopMutation(req, agentId, options.cwd, () => {
371
+ const beforeEvents = snapshotLoopEvents(req.loop_id, options.cwd);
372
+ const loop = closeLoop({ id: req.loop_id, final_status: req.status, reason: req.reason, actor }, options.cwd);
373
+ const newEvents = findNewLoopEvents(loop.id, beforeEvents, options.cwd);
374
+ return successResponse('close', { loop, next_expected: null }, [loopArtifactEntry(loop.id), ...loopEventArtifacts(newEvents)], [sideEffectUpdate('loop', loop.id), ...loopEventSideEffects(newEvents)], [], Date.now() - startMs, summarizeLoop(loop));
375
+ });
376
+ }
377
+ }
378
+ }
379
+ catch (err) {
380
+ if (err instanceof ZodError) {
381
+ return errorResponse(req.intent, 'validation_error', err.message, Date.now() - startMs);
382
+ }
383
+ if (err instanceof VersionConflictError) {
384
+ return errorResponse(req.intent, 'version_conflict', `expected=${err.expected} actual=${err.actual}`, Date.now() - startMs, { actual_version: err.actual });
385
+ }
386
+ if (err instanceof IdempotencyKeyReusedError) {
387
+ return errorResponse(req.intent, 'idempotency_key_reused_with_different_body', `stored_hash=${err.storedHash} submitted_hash=${err.submittedHash}`, Date.now() - startMs, { stored_hash: err.storedHash, submitted_hash: err.submittedHash });
388
+ }
389
+ if (err instanceof IdempotencyOwnerMismatchError) {
390
+ return errorResponse(req.intent, 'idempotency_owner_mismatch', `stored_owner=${err.storedOwner ?? 'unknown'} submitted_owner=${err.submittedOwner}`, Date.now() - startMs, { stored_owner: err.storedOwner, submitted_owner: err.submittedOwner });
391
+ }
392
+ if (err instanceof LockTimeoutError) {
393
+ return errorResponse(req.intent, 'lock_timeout', err.message, Date.now() - startMs);
394
+ }
395
+ if (err instanceof LockLostError) {
396
+ return errorResponse(req.intent, 'lock_lost', err.message, Date.now() - startMs);
397
+ }
398
+ const message = err instanceof Error ? err.message : String(err);
399
+ if (message.includes('unauthorized_slot_write')) {
400
+ return errorResponse(req.intent, 'unauthorized_slot_write', message, Date.now() - startMs);
401
+ }
402
+ if (message.startsWith('unknown loop_id')) {
403
+ return errorResponse(req.intent, 'not_found', message, Date.now() - startMs);
404
+ }
405
+ return errorResponse(req.intent, 'verb_error', message, Date.now() - startMs);
406
+ }
407
+ return errorResponse('unknown', 'unreachable', 'unexpected fallthrough', Date.now() - startMs);
408
+ }
409
+ //# sourceMappingURL=loops-handlers.js.map