brainclaw 1.8.0 → 1.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.
Files changed (178) hide show
  1. package/README.md +592 -505
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +138 -13
  4. package/dist/commands/add-step.js +1 -1
  5. package/dist/commands/bootstrap.js +2 -26
  6. package/dist/commands/check-security-mcp.js +50 -33
  7. package/dist/commands/check-security.js +86 -43
  8. package/dist/commands/claim.js +22 -21
  9. package/dist/commands/confirm.js +26 -0
  10. package/dist/commands/context-diff.js +1 -1
  11. package/dist/commands/dispatch-watch.js +142 -0
  12. package/dist/commands/doctor.js +113 -2
  13. package/dist/commands/estimation-report.js +115 -16
  14. package/dist/commands/harvest.js +286 -23
  15. package/dist/commands/hooks.js +73 -73
  16. package/dist/commands/init.js +124 -22
  17. package/dist/commands/install-hooks.js +78 -78
  18. package/dist/commands/loops-handlers.js +4 -0
  19. package/dist/commands/mcp-read-handlers.js +253 -41
  20. package/dist/commands/mcp.js +664 -102
  21. package/dist/commands/memory.js +21 -17
  22. package/dist/commands/migrate.js +81 -17
  23. package/dist/commands/prune.js +78 -4
  24. package/dist/commands/reflect.js +26 -20
  25. package/dist/commands/register-agent.js +57 -1
  26. package/dist/commands/repair.js +20 -0
  27. package/dist/commands/session-end.js +15 -6
  28. package/dist/commands/session-start.js +18 -1
  29. package/dist/commands/setup-security.js +39 -18
  30. package/dist/commands/setup.js +26 -27
  31. package/dist/commands/stale.js +16 -2
  32. package/dist/commands/switch.js +26 -5
  33. package/dist/commands/uninstall.js +126 -34
  34. package/dist/commands/update-step.js +6 -0
  35. package/dist/commands/version.js +1 -1
  36. package/dist/commands/worktree.js +60 -0
  37. package/dist/core/actions.js +12 -3
  38. package/dist/core/agent-capability.js +30 -17
  39. package/dist/core/agent-files.js +963 -666
  40. package/dist/core/agent-integrations.js +0 -3
  41. package/dist/core/agent-inventory.js +67 -0
  42. package/dist/core/agent-registry.js +163 -29
  43. package/dist/core/agentrun-reconciler.js +33 -2
  44. package/dist/core/agentruns.js +7 -1
  45. package/dist/core/ai-agent-detection.js +31 -44
  46. package/dist/core/archival.js +15 -9
  47. package/dist/core/assignment-reconciler.js +56 -0
  48. package/dist/core/assignment-sweeper.js +127 -4
  49. package/dist/core/assignments.js +69 -11
  50. package/dist/core/bootstrap.js +233 -67
  51. package/dist/core/brainclaw-version.js +22 -0
  52. package/dist/core/candidates.js +21 -1
  53. package/dist/core/claims.js +313 -150
  54. package/dist/core/codev-prompts.js +38 -38
  55. package/dist/core/config.js +6 -1
  56. package/dist/core/context-diff.js +148 -20
  57. package/dist/core/context.js +129 -8
  58. package/dist/core/coordination.js +22 -3
  59. package/dist/core/default-profiles/doctor.yaml +11 -11
  60. package/dist/core/default-profiles/janitor.yaml +11 -11
  61. package/dist/core/default-profiles/onboarder.yaml +11 -11
  62. package/dist/core/default-profiles/reviewer.yaml +13 -13
  63. package/dist/core/dispatch-status.js +79 -5
  64. package/dist/core/dispatcher.js +65 -12
  65. package/dist/core/entity-operations.js +74 -27
  66. package/dist/core/entity-registry.js +31 -5
  67. package/dist/core/event-log.js +138 -21
  68. package/dist/core/events/checkpoint.js +258 -0
  69. package/dist/core/events/genesis.js +220 -0
  70. package/dist/core/events/journal.js +507 -0
  71. package/dist/core/events/materialize.js +126 -0
  72. package/dist/core/events/registry-post-image.js +110 -0
  73. package/dist/core/events/verify.js +109 -0
  74. package/dist/core/execution-adapters.js +23 -0
  75. package/dist/core/execution.js +1 -1
  76. package/dist/core/facade-schema.js +38 -0
  77. package/dist/core/gc-semantic.js +130 -5
  78. package/dist/core/handoff-snapshot.js +68 -0
  79. package/dist/core/ids.js +19 -8
  80. package/dist/core/instruction-templates.js +34 -115
  81. package/dist/core/io.js +39 -3
  82. package/dist/core/json-store.js +10 -1
  83. package/dist/core/lock.js +153 -28
  84. package/dist/core/loops/bootstrap-acquire.js +25 -1
  85. package/dist/core/loops/facade-schema.js +2 -0
  86. package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
  87. package/dist/core/loops/index.js +1 -0
  88. package/dist/core/loops/presets/bootstrap.js +7 -0
  89. package/dist/core/loops/store.js +17 -0
  90. package/dist/core/loops/verbs.js +24 -2
  91. package/dist/core/markdown.js +8 -76
  92. package/dist/core/mcp-command-resolution.js +245 -0
  93. package/dist/core/memory-compactor.js +5 -3
  94. package/dist/core/memory-lifecycle.js +282 -0
  95. package/dist/core/merge-risk.js +150 -0
  96. package/dist/core/messaging.js +10 -3
  97. package/dist/core/migration.js +11 -1
  98. package/dist/core/observer-mode.js +26 -0
  99. package/dist/core/operations/memory-mutation.js +90 -65
  100. package/dist/core/operations/plan.js +27 -1
  101. package/dist/core/protocol-skills.js +210 -0
  102. package/dist/core/reflection-safety.js +6 -7
  103. package/dist/core/reputation.js +84 -2
  104. package/dist/core/runtime-signals.js +72 -10
  105. package/dist/core/runtime.js +84 -1
  106. package/dist/core/schema.js +114 -0
  107. package/dist/core/search.js +19 -2
  108. package/dist/core/security-detectors.js +125 -0
  109. package/dist/core/security-extract.js +189 -0
  110. package/dist/core/security-guard.js +217 -139
  111. package/dist/core/security-packages.js +121 -0
  112. package/dist/core/security-scoring.js +76 -9
  113. package/dist/core/security.js +34 -2
  114. package/dist/core/sequence.js +11 -2
  115. package/dist/core/setup-flow.js +141 -13
  116. package/dist/core/spawn-check.js +16 -2
  117. package/dist/core/staleness.js +73 -2
  118. package/dist/core/state.js +250 -54
  119. package/dist/core/store-resolution.js +45 -12
  120. package/dist/core/worktree.js +90 -26
  121. package/dist/facts.js +8 -8
  122. package/dist/facts.json +7 -7
  123. package/docs/PROTOCOL.md +223 -0
  124. package/docs/adapters/openclaw.md +43 -43
  125. package/docs/architecture/project-refs.md +328 -328
  126. package/docs/cli.md +2097 -2096
  127. package/docs/concepts/coordination.md +52 -52
  128. package/docs/concepts/coordinator-runbook.md +129 -0
  129. package/docs/concepts/dispatch-lifecycle.md +245 -245
  130. package/docs/concepts/event-log-store.md +928 -0
  131. package/docs/concepts/ideation-loop.md +317 -317
  132. package/docs/concepts/loop-engine.md +520 -511
  133. package/docs/concepts/mcp-governance.md +268 -268
  134. package/docs/concepts/memory.md +89 -88
  135. package/docs/concepts/multi-agent-workflows.md +167 -167
  136. package/docs/concepts/observer-protocol.md +361 -0
  137. package/docs/concepts/parallel-merge-protocol.md +71 -0
  138. package/docs/concepts/plans-and-claims.md +217 -174
  139. package/docs/concepts/project-md-convention.md +35 -35
  140. package/docs/concepts/runtime-notes.md +38 -38
  141. package/docs/concepts/skills.md +78 -0
  142. package/docs/concepts/troubleshooting.md +254 -254
  143. package/docs/concepts/workspace-bootstrapping.md +142 -81
  144. package/docs/context-format-changelog.md +35 -35
  145. package/docs/context-format.md +48 -48
  146. package/docs/index.md +65 -65
  147. package/docs/integrations/agents.md +162 -162
  148. package/docs/integrations/claude-code.md +23 -23
  149. package/docs/integrations/cline.md +87 -88
  150. package/docs/integrations/codex.md +2 -2
  151. package/docs/integrations/continue.md +60 -60
  152. package/docs/integrations/copilot.md +82 -80
  153. package/docs/integrations/cursor.md +23 -23
  154. package/docs/integrations/kilocode.md +72 -72
  155. package/docs/integrations/mcp.md +377 -377
  156. package/docs/integrations/mistral-vibe.md +122 -122
  157. package/docs/integrations/openclaw.md +99 -98
  158. package/docs/integrations/opencode.md +84 -84
  159. package/docs/integrations/overview.md +122 -122
  160. package/docs/integrations/roo.md +74 -74
  161. package/docs/integrations/windsurf.md +83 -83
  162. package/docs/mcp-schema-changelog.md +360 -329
  163. package/docs/playbooks/integration/index.md +121 -121
  164. package/docs/playbooks/orchestration.md +37 -0
  165. package/docs/playbooks/productivity/index.md +99 -99
  166. package/docs/playbooks/team/index.md +117 -117
  167. package/docs/product/agent-first-model.md +184 -184
  168. package/docs/product/entity-model-audit.md +462 -462
  169. package/docs/product/positioning.md +86 -86
  170. package/docs/quickstart-existing-project.md +107 -107
  171. package/docs/quickstart.md +148 -147
  172. package/docs/release-maintenance.md +79 -79
  173. package/docs/reputation.md +52 -52
  174. package/docs/review.md +45 -45
  175. package/docs/security.md +212 -53
  176. package/docs/server-operations.md +118 -118
  177. package/docs/storage.md +110 -108
  178. package/package.json +86 -69
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { resolveEntityDir } from './io.js';
4
4
  import { logger } from './logger.js';
5
+ import { mutate } from './mutation-pipeline.js';
5
6
  /** Default age threshold: items older than 30 days are eligible for archival. */
6
7
  const DEFAULT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
7
8
  /**
@@ -11,15 +12,20 @@ const DEFAULT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
11
12
  * This is lossless — all data is preserved in the archive.
12
13
  */
13
14
  export function archiveStalePlansAndHandoffs(cwd, maxAgeMs = DEFAULT_MAX_AGE_MS) {
14
- const results = [];
15
- const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
16
- results.push(archiveEntity('plans', cutoff, (item) => {
17
- return item.status === 'done' || item.status === 'dropped';
18
- }, cwd));
19
- results.push(archiveEntity('handoffs', cutoff, (item) => {
20
- return item.status === 'closed';
21
- }, cwd));
22
- return results.filter(r => r.archived > 0);
15
+ // Review follow-up O4 (lop_e2d566765b8b4ce3): the append+unlink pairs must
16
+ // run inside the store mutation lock — outside it, a concurrent stale-snapshot
17
+ // persistState could RECREATE the just-archived files (resurrection).
18
+ return mutate({ cwd }, () => {
19
+ const results = [];
20
+ const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
21
+ results.push(archiveEntity('plans', cutoff, (item) => {
22
+ return item.status === 'done' || item.status === 'dropped';
23
+ }, cwd));
24
+ results.push(archiveEntity('handoffs', cutoff, (item) => {
25
+ return item.status === 'closed';
26
+ }, cwd));
27
+ return results.filter(r => r.archived > 0);
28
+ });
23
29
  }
24
30
  function archiveEntity(entity, cutoffDate, isEligible, cwd) {
25
31
  const dir = resolveEntityDir(entity, cwd ?? process.cwd(), 'read');
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Lazy reconciler for orphaned review-loop assignments (pln#563, layer B).
3
+ *
4
+ * A review-loop assignment whose loop already reached a terminal status but
5
+ * which is itself still stuck in `offered`/`accepted`/`started` (the file-based
6
+ * worker never reported terminal; the coordinator couldn't cross-update it —
7
+ * trp#547) is converged on read. This is the house lazy-reconcile-at-read
8
+ * pattern (feedback_lazy_reconcile_pattern / pln#496): no daemon, convergence
9
+ * happens the next time open_work is computed.
10
+ *
11
+ * Layer A (the closeLoop cascade) handles the common path going forward; this
12
+ * backstop cleans the existing backlog and any future close that didn't cascade
13
+ * (e.g. a loop force-closed out of band). Kept in its own module to avoid an
14
+ * import cycle (loops/store → assignments for the cascade; this → both).
15
+ */
16
+ import { listAssignments, convergeAssignmentToTerminal } from './assignments.js';
17
+ import { getLoop } from './loops/store.js';
18
+ /** review-loop:lop_xxx → the loop id. */
19
+ const LOOP_SCOPE_RE = /^review-loop:(lop_[0-9a-z]+)/;
20
+ const LOOP_TERMINAL = new Set(['completed', 'cancelled', 'blocked']);
21
+ /**
22
+ * Converge any review-loop assignment whose loop is terminal. Returns the
23
+ * count converged. Pure best-effort and cheap: it only does a loop lookup for
24
+ * assignments whose scope is a review-loop, and only writes when a stuck one is
25
+ * found (the steady state is zero writes).
26
+ */
27
+ export function reconcileOrphanedLoopAssignments(cwd) {
28
+ return reconcileOrphanedLoopAssignmentsFromList(listAssignments(cwd), cwd).length;
29
+ }
30
+ /**
31
+ * Same reconciliation as reconcileOrphanedLoopAssignments, but reuses an
32
+ * already-loaded assignment list. This keeps open_work context building from
33
+ * doing a second full assignment directory scan just to clean loop orphans.
34
+ */
35
+ export function reconcileOrphanedLoopAssignmentsFromList(assignments, cwd) {
36
+ const convergedIds = [];
37
+ const loopCache = new Map();
38
+ for (const a of assignments) {
39
+ const match = a.scope?.match(LOOP_SCOPE_RE);
40
+ if (!match)
41
+ continue;
42
+ let loop = loopCache.get(match[1]);
43
+ if (!loopCache.has(match[1])) {
44
+ loop = getLoop(match[1], cwd);
45
+ loopCache.set(match[1], loop);
46
+ }
47
+ if (!loop || !LOOP_TERMINAL.has(loop.status))
48
+ continue;
49
+ const terminal = loop.status === 'completed' ? 'completed' : 'cancelled';
50
+ if (convergeAssignmentToTerminal(a.id, terminal, `loop ${match[1]} ${loop.status} (lazy reconcile)`, cwd)) {
51
+ convergedIds.push(a.id);
52
+ }
53
+ }
54
+ return convergedIds;
55
+ }
56
+ //# sourceMappingURL=assignment-reconciler.js.map
@@ -4,15 +4,100 @@
4
4
  * Runs opportunistically (no daemon): integrated into dispatch().
5
5
  * Future: integrate into session_start() and expose as CLI `brainclaw sweep`.
6
6
  *
7
+ * can_948acfd6 (sprint 1.5): the sweep consults IMPLICIT worker evidence
8
+ * before declaring an administrative death. Three live workers were expired
9
+ * by the acceptance-TTL in a single sprint because they could not call
10
+ * bclaw_assignment_update (sandboxed / no MCP) — yet their ack sentinel,
11
+ * heartbeat, filesystem activity and commits were all observable. This is the
12
+ * acceptance-sweep counterpart of the pln#527 no-heartbeat veto.
13
+ *
7
14
  * @module
8
15
  */
16
+ import { spawnSync } from 'node:child_process';
9
17
  import { listAssignments, transitionAssignment } from './assignments.js';
18
+ import { signalExists, readHeartbeat, latestActivityMs } from './runtime-signals.js';
19
+ function lastCommitAgeMs(worktreePath, nowMs) {
20
+ if (!worktreePath)
21
+ return undefined;
22
+ try {
23
+ const res = spawnSync('git', ['log', '-1', '--format=%ct'], {
24
+ cwd: worktreePath, encoding: 'utf-8', windowsHide: true, timeout: 10_000,
25
+ });
26
+ if (res.status !== 0)
27
+ return undefined;
28
+ const epochSec = parseInt((res.stdout ?? '').trim(), 10);
29
+ if (!Number.isFinite(epochSec))
30
+ return undefined;
31
+ return nowMs - epochSec * 1000;
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ }
37
+ /**
38
+ * Collect implicit life-signs for an assignment: ack sentinel, heartbeat
39
+ * (project-root OR worktree-local), filesystem activity (logs + worktree),
40
+ * and a post-dispatch commit on the worktree branch.
41
+ *
42
+ * `sinceMs` anchors the commit check (a commit older than the offer is not
43
+ * evidence of THIS assignment's worker). `freshTtlMs` bounds what counts as
44
+ * "currently active" for the accepted/started branches.
45
+ */
46
+ function collectImplicitEvidence(assignment, cwd, nowMs, sinceMs, freshTtlMs) {
47
+ const root = cwd ?? process.cwd();
48
+ const parts = [];
49
+ let freshest;
50
+ const bump = (ageMs) => {
51
+ if (ageMs === undefined)
52
+ return;
53
+ if (freshest === undefined || ageMs < freshest)
54
+ freshest = ageMs;
55
+ };
56
+ try {
57
+ if (signalExists(root, assignment.id, 'ack'))
58
+ parts.push('ack sentinel');
59
+ }
60
+ catch { /* defensive */ }
61
+ try {
62
+ const hb = readHeartbeat(root, assignment.id, assignment.worktree_path);
63
+ if (hb.exists && hb.mtimeMs !== undefined) {
64
+ const age = nowMs - hb.mtimeMs;
65
+ parts.push(`heartbeat ${Math.round(age / 1000)}s old`);
66
+ bump(age);
67
+ }
68
+ }
69
+ catch { /* defensive */ }
70
+ try {
71
+ const lastFs = latestActivityMs(root, assignment.id, assignment.worktree_path);
72
+ if (lastFs !== undefined) {
73
+ const age = nowMs - lastFs;
74
+ parts.push(`fs activity ${Math.round(age / 1000)}s old`);
75
+ bump(age);
76
+ }
77
+ }
78
+ catch { /* defensive */ }
79
+ const commitAge = lastCommitAgeMs(assignment.worktree_path, nowMs);
80
+ if (commitAge !== undefined && nowMs - commitAge >= sinceMs) {
81
+ parts.push(`post-dispatch commit ${Math.round(commitAge / 1000)}s old`);
82
+ bump(commitAge);
83
+ }
84
+ return {
85
+ any: parts.length > 0,
86
+ fresh: freshest !== undefined && freshest <= freshTtlMs,
87
+ description: parts.join(' + ') || 'none',
88
+ };
89
+ }
10
90
  // ── Sweeper ──────────────────────────────────────────────────
11
91
  /**
12
92
  * Scan all active assignments and timeout those past their TTL.
13
93
  *
14
94
  * - `started` assignments with no heartbeat within `heartbeat_ttl_ms` → `timed_out`
95
+ * UNLESS file evidence (heartbeat sentinel / fs activity / commit) is fresh.
96
+ * - `accepted` assignments not started within `acceptance_ttl_ms` → `timed_out`
97
+ * UNLESS fresh evidence ⇒ implicit `started`.
15
98
  * - `offered` assignments not accepted within `acceptance_ttl_ms` → `expired`
99
+ * UNLESS any evidence ⇒ implicit `accepted` (ack/heartbeat/fs/commit are
100
+ * acceptance, just delivered by a worker that cannot reach MCP).
16
101
  *
17
102
  * @param cwd - Project root
18
103
  * @param options.nowMs - Override current time for testing
@@ -21,7 +106,7 @@ import { listAssignments, transitionAssignment } from './assignments.js';
21
106
  export function sweepAssignments(cwd, options) {
22
107
  const now = options?.nowMs ?? Date.now();
23
108
  const actor = options?.actor ?? 'sweeper';
24
- const result = { timed_out: [], expired: [] };
109
+ const result = { timed_out: [], expired: [], implicitly_advanced: [] };
25
110
  const all = listAssignments(cwd);
26
111
  for (const assignment of all) {
27
112
  // Check started assignments for heartbeat timeout
@@ -31,9 +116,16 @@ export function sweepAssignments(cwd, options) {
31
116
  continue;
32
117
  const ageMs = now - new Date(lastBeat).getTime();
33
118
  if (ageMs > assignment.heartbeat_ttl_ms) {
119
+ // can_948acfd6: a worker without MCP cannot bump last_heartbeat_at —
120
+ // its file evidence is the heartbeat. Fresh file activity vetoes the
121
+ // administrative timeout.
122
+ const sinceMs = new Date(assignment.started_at ?? assignment.created_at).getTime();
123
+ const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs, assignment.heartbeat_ttl_ms);
124
+ if (evidence.fresh)
125
+ continue;
34
126
  try {
35
127
  transitionAssignment(assignment.id, 'timed_out', {
36
- status_reason: `No heartbeat for ${Math.round(ageMs / 60_000)} minutes (TTL: ${Math.round(assignment.heartbeat_ttl_ms / 60_000)}min)`,
128
+ status_reason: `No heartbeat for ${Math.round(ageMs / 60_000)} minutes (TTL: ${Math.round(assignment.heartbeat_ttl_ms / 60_000)}min); implicit evidence: ${evidence.description}`,
37
129
  actor,
38
130
  }, cwd);
39
131
  result.timed_out.push({ assignment_id: assignment.id, agent: assignment.agent, age_ms: ageMs });
@@ -49,9 +141,23 @@ export function sweepAssignments(cwd, options) {
49
141
  const ageMs = now - new Date(acceptedAt).getTime();
50
142
  // Use acceptance_ttl for accepted→timed_out (same window: agent should start quickly after accepting)
51
143
  if (ageMs > assignment.acceptance_ttl_ms) {
144
+ const sinceMs = new Date(acceptedAt).getTime();
145
+ const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs, assignment.acceptance_ttl_ms);
146
+ if (evidence.fresh) {
147
+ // Working without MCP — record the implicit start so the FSM matches reality.
148
+ try {
149
+ transitionAssignment(assignment.id, 'started', {
150
+ status_reason: `Implicit start inferred by sweeper: ${evidence.description}`,
151
+ actor,
152
+ }, cwd);
153
+ result.implicitly_advanced.push({ assignment_id: assignment.id, agent: assignment.agent, to: 'started', evidence: evidence.description });
154
+ }
155
+ catch { /* skip */ }
156
+ continue;
157
+ }
52
158
  try {
53
159
  transitionAssignment(assignment.id, 'timed_out', {
54
- status_reason: `Accepted but not started within ${Math.round(ageMs / 60_000)} minutes`,
160
+ status_reason: `Accepted but not started within ${Math.round(ageMs / 60_000)} minutes; implicit evidence: ${evidence.description}`,
55
161
  actor,
56
162
  }, cwd);
57
163
  result.timed_out.push({ assignment_id: assignment.id, agent: assignment.agent, age_ms: ageMs });
@@ -66,9 +172,26 @@ export function sweepAssignments(cwd, options) {
66
172
  continue;
67
173
  const ageMs = now - new Date(offeredAt).getTime();
68
174
  if (ageMs > assignment.acceptance_ttl_ms) {
175
+ // can_948acfd6: ANY worker evidence (ack sentinel touched pre-exec,
176
+ // heartbeat written, files edited, commit landed) is an implicit
177
+ // acceptance — the worker just couldn't say so via MCP. Expiring it
178
+ // is the false-administrative-death observed three times in sprint 1.
179
+ const sinceMs = new Date(offeredAt).getTime();
180
+ const evidence = collectImplicitEvidence(assignment, cwd, now, sinceMs, assignment.acceptance_ttl_ms);
181
+ if (evidence.any) {
182
+ try {
183
+ transitionAssignment(assignment.id, 'accepted', {
184
+ status_reason: `Implicit acceptance inferred by sweeper: ${evidence.description}`,
185
+ actor,
186
+ }, cwd);
187
+ result.implicitly_advanced.push({ assignment_id: assignment.id, agent: assignment.agent, to: 'accepted', evidence: evidence.description });
188
+ }
189
+ catch { /* skip */ }
190
+ continue;
191
+ }
69
192
  try {
70
193
  transitionAssignment(assignment.id, 'expired', {
71
- status_reason: `Not accepted within ${Math.round(ageMs / 60_000)} minutes (TTL: ${Math.round(assignment.acceptance_ttl_ms / 60_000)}min)`,
194
+ status_reason: `Not accepted within ${Math.round(ageMs / 60_000)} minutes (TTL: ${Math.round(assignment.acceptance_ttl_ms / 60_000)}min); no implicit evidence`,
72
195
  actor,
73
196
  }, cwd);
74
197
  result.expired.push({ assignment_id: assignment.id, agent: assignment.agent, age_ms: ageMs });
@@ -7,6 +7,7 @@ import { JsonStore } from './json-store.js';
7
7
  import { appendAuditEntry } from './audit.js';
8
8
  import { appendEvent } from './event-log.js';
9
9
  import { createRuntimeEvent } from './events.js';
10
+ import { emitRegistryPostImage, emitRegistryTombstone, registryFaultPoint } from './events/registry-post-image.js';
10
11
  import { findLatestAgentRunForAssignment, recordAgentRunProgress, syncAgentRunFromAssignmentTransition } from './agentruns.js';
11
12
  // ── Directory / Store ────────────────────────────────────────
12
13
  function assignmentsDir(cwd, mode = 'read') {
@@ -36,7 +37,12 @@ export function saveAssignment(assignment, cwd) {
36
37
  getId: (a) => a.id,
37
38
  sort: (a, b) => a.created_at.localeCompare(b.created_at),
38
39
  });
39
- store.save(AssignmentSchema.parse(assignment));
40
+ const parsed = AssignmentSchema.parse(assignment);
41
+ // pln#568 (I2): journal the post-image BEFORE the projection write.
42
+ const created = !store.exists(parsed.id);
43
+ emitRegistryPostImage('assignment', parsed, { created, agent: parsed.agent, agent_id: parsed.agent_id, session_id: parsed.session_id, cwd });
44
+ registryFaultPoint('after_registry_journal');
45
+ store.save(parsed);
40
46
  });
41
47
  }
42
48
  export function loadAssignment(id, cwd) {
@@ -65,20 +71,22 @@ export function listAssignments(cwd, filter) {
65
71
  return items;
66
72
  }
67
73
  export function deleteAssignment(id, cwd) {
68
- const store = assignmentStore(cwd);
69
- if (!store.exists(id)) {
70
- return false;
71
- }
72
- mutate({ cwd }, () => {
74
+ return mutate({ cwd }, () => {
73
75
  const writableStore = new JsonStore({
74
76
  dirPath: assignmentsDir(cwd, 'write'),
75
77
  documentType: 'assignment',
76
78
  getId: (a) => a.id,
77
79
  sort: (a, b) => a.created_at.localeCompare(b.created_at),
78
80
  });
81
+ if (!writableStore.exists(id)) {
82
+ return false;
83
+ }
84
+ const assignment = writableStore.load(id);
85
+ emitRegistryTombstone('assignment', assignment.id, { agent: assignment.agent, agent_id: assignment.agent_id, session_id: assignment.session_id, cwd });
86
+ registryFaultPoint('after_registry_journal');
79
87
  writableStore.delete(id);
88
+ return true;
80
89
  });
81
- return true;
82
90
  }
83
91
  // ── ID Generation ────────────────────────────────────────────
84
92
  export function generateAssignmentId(cwd) {
@@ -102,7 +110,12 @@ const VALID_TRANSITIONS = new Map([
102
110
  ['timed_out', new Set(['retrying', 'rerouted', 'cancelled'])],
103
111
  ['retrying', new Set(['offered', 'rerouted', 'cancelled'])],
104
112
  ['blocked', new Set(['rerouted', 'started', 'failed', 'cancelled'])],
105
- // Terminal: completed, cancelled, expired, rerouted (no outgoing transitions)
113
+ // can_948acfd6: evidence can arrive AFTER an administrative expiry — the
114
+ // worker was alive all along but never acked (sandboxed, no MCP), and its
115
+ // commit / LANE-RESULT surfaced later. Allow the late convergence so
116
+ // harvest/reconcile can record the truth instead of being FSM-blocked.
117
+ ['expired', new Set(['completed'])],
118
+ // Terminal: completed, cancelled, rerouted (no outgoing transitions)
106
119
  ]);
107
120
  export function validateTransition(from, to) {
108
121
  const allowed = VALID_TRANSITIONS.get(from);
@@ -119,6 +132,9 @@ export function validateTransition(from, to) {
119
132
  * Updates relevant timestamps, emits event and audit entry.
120
133
  */
121
134
  export function transitionAssignment(id, newStatus, options, cwd) {
135
+ return transitionAssignmentInternal(id, newStatus, options, cwd);
136
+ }
137
+ function transitionAssignmentInternal(id, newStatus, options, cwd) {
122
138
  const assignment = loadAssignment(id, cwd);
123
139
  if (!assignment) {
124
140
  throw new Error(`Assignment not found: ${id}`);
@@ -131,9 +147,17 @@ export function transitionAssignment(id, newStatus, options, cwd) {
131
147
  saveAssignment(assignment, cwd);
132
148
  return { assignment, previous_status: newStatus, idempotent: true };
133
149
  }
134
- const validation = validateTransition(assignment.status, newStatus);
135
- if (!validation.valid) {
136
- throw new Error(validation.reason);
150
+ if (options.systemConvergence) {
151
+ const validation = validateSystemConvergence(assignment.status, newStatus, options.actor);
152
+ if (!validation.valid) {
153
+ throw new Error(validation.reason);
154
+ }
155
+ }
156
+ else {
157
+ const validation = validateTransition(assignment.status, newStatus);
158
+ if (!validation.valid) {
159
+ throw new Error(validation.reason);
160
+ }
137
161
  }
138
162
  const previous_status = assignment.status;
139
163
  const now = nowISO();
@@ -300,6 +324,40 @@ export function createAssignment(options, cwd) {
300
324
  // ── Active Assignment Lookup ─────────────────────────────────
301
325
  /** Statuses that indicate a finished assignment (no longer active). */
302
326
  const TERMINAL_STATUSES = new Set(['completed', 'cancelled', 'expired', 'rerouted']);
327
+ /**
328
+ * Statuses a file-based worker leaves an assignment stuck in — it never calls
329
+ * bclaw_assignment_update, so the assignment never advances past these (pln#563).
330
+ * These are the only states a system convergence (loop-close cascade / lazy
331
+ * reconciler) fast-forwards; failed/blocked/timed_out carry real signal and are
332
+ * left alone.
333
+ */
334
+ const CONVERGEABLE_STATUSES = new Set(['offered', 'accepted', 'started']);
335
+ function validateSystemConvergence(from, to, actor) {
336
+ if (actor !== 'system') {
337
+ return { valid: false, reason: 'System convergence must be performed by actor=system' };
338
+ }
339
+ if (!CONVERGEABLE_STATUSES.has(from) || (to !== 'completed' && to !== 'cancelled')) {
340
+ return { valid: false, reason: `Invalid system convergence: ${from} → ${to}` };
341
+ }
342
+ return { valid: true };
343
+ }
344
+ /**
345
+ * Force a stuck assignment to a terminal status as a SYSTEM convergence
346
+ * (pln#563). No-op (returns false) if the assignment is missing or not in a
347
+ * convergeable state, so callers can fire it best-effort. Used by the loop-close
348
+ * cascade and the lazy orphan reconciler.
349
+ */
350
+ export function convergeAssignmentToTerminal(id, terminal, reason, cwd) {
351
+ const assignment = loadAssignment(id, cwd);
352
+ if (!assignment || !CONVERGEABLE_STATUSES.has(assignment.status))
353
+ return false;
354
+ transitionAssignmentInternal(id, terminal, {
355
+ actor: 'system',
356
+ status_reason: reason,
357
+ systemConvergence: true,
358
+ }, cwd);
359
+ return true;
360
+ }
303
361
  /**
304
362
  * Return the most recently created non-terminal assignment for the given agent.
305
363
  * When `claimId` is provided, it is used as a fast-path lookup before falling