brainclaw 1.26.2 → 1.27.0

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 (86) hide show
  1. package/README.md +13 -0
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli/register-coordination.js +65 -1
  4. package/dist/commands/attempt-authority.js +80 -0
  5. package/dist/commands/harvest.js +140 -61
  6. package/dist/commands/loop.js +34 -0
  7. package/dist/commands/loops-handlers.js +87 -14
  8. package/dist/commands/mcp-catalog.js +42 -18
  9. package/dist/commands/mcp-schemas.generated.js +44 -0
  10. package/dist/commands/mcp-write-claims.js +128 -1
  11. package/dist/commands/mcp-write-coordination.js +146 -76
  12. package/dist/core/agent-capability.js +1 -1
  13. package/dist/core/agentrun-reconciler.js +148 -22
  14. package/dist/core/agentruns.js +254 -29
  15. package/dist/core/assignment-request-schema.js +7 -0
  16. package/dist/core/assignment-sweeper.js +5 -3
  17. package/dist/core/assignments.js +131 -33
  18. package/dist/core/claim-request-schema.js +7 -0
  19. package/dist/core/claims.js +53 -2
  20. package/dist/core/dispatch-status.js +16 -6
  21. package/dist/core/dispatcher.js +51 -51
  22. package/dist/core/entity-operations.js +20 -0
  23. package/dist/core/events.js +4 -0
  24. package/dist/core/execution-adapters.js +160 -14
  25. package/dist/core/execution-contract.js +345 -0
  26. package/dist/core/execution.js +130 -16
  27. package/dist/core/harness-adapters/base.js +150 -0
  28. package/dist/core/harness-adapters/claude.js +39 -0
  29. package/dist/core/harness-adapters/codex.js +57 -0
  30. package/dist/core/harness-adapters/harvest.js +109 -0
  31. package/dist/core/harness-adapters/index.js +8 -0
  32. package/dist/core/harness-adapters/prompt-only.js +13 -0
  33. package/dist/core/harness-adapters/registry.js +48 -0
  34. package/dist/core/harness-adapters/result.js +33 -0
  35. package/dist/core/harness-adapters/types.js +2 -0
  36. package/dist/core/ideation-loop-close.js +25 -2
  37. package/dist/core/instruction-templates.js +3 -2
  38. package/dist/core/loop-turn-dispatch.js +207 -0
  39. package/dist/core/loops/artifact-contract.js +11 -0
  40. package/dist/core/loops/attempt-authority.js +476 -0
  41. package/dist/core/loops/attempt-generations.js +509 -0
  42. package/dist/core/loops/attempt-reservation.js +197 -35
  43. package/dist/core/loops/attempt-rollout.js +404 -0
  44. package/dist/core/loops/attempt-takeover.js +155 -0
  45. package/dist/core/loops/bootstrap-acquire.js +7 -3
  46. package/dist/core/loops/evidence.js +187 -0
  47. package/dist/core/loops/facade-schema.js +41 -10
  48. package/dist/core/loops/gate-policy.js +485 -0
  49. package/dist/core/loops/impl-bind.js +37 -79
  50. package/dist/core/loops/index.js +9 -0
  51. package/dist/core/loops/iteration-engine.js +31 -19
  52. package/dist/core/loops/kind-policies.js +90 -0
  53. package/dist/core/loops/lock.js +71 -13
  54. package/dist/core/loops/reconcile-turn.js +235 -18
  55. package/dist/core/loops/result-reducers.js +99 -10
  56. package/dist/core/loops/store.js +30 -3
  57. package/dist/core/loops/turn-execution.js +480 -0
  58. package/dist/core/loops/types.js +113 -2
  59. package/dist/core/loops/verbs.js +332 -99
  60. package/dist/core/loops/verify-command.js +31 -8
  61. package/dist/core/loops/workspace-digest.js +54 -0
  62. package/dist/core/review-loop-close.js +25 -3
  63. package/dist/core/review-loop-turn-dispatch.js +210 -161
  64. package/dist/core/runtime-signals.js +62 -25
  65. package/dist/core/schema.js +35 -0
  66. package/dist/core/spawn-check.js +3 -2
  67. package/dist/core/upgrades/backup.js +27 -4
  68. package/dist/facts.js +7 -6
  69. package/dist/facts.json +6 -5
  70. package/docs/cli.md +49 -1
  71. package/docs/concepts/attempt-authority.md +407 -0
  72. package/docs/concepts/evidence-attestations.md +135 -0
  73. package/docs/concepts/execution-contract.md +166 -0
  74. package/docs/concepts/harness-adapters.md +166 -0
  75. package/docs/concepts/ideation-loop.md +5 -4
  76. package/docs/concepts/loop-engine.md +302 -113
  77. package/docs/index.md +4 -1
  78. package/docs/integrations/codex.md +3 -3
  79. package/docs/integrations/mcp.md +59 -5
  80. package/docs/loops/debug.md +144 -0
  81. package/docs/loops/ideation.md +158 -0
  82. package/docs/loops/implementation.md +154 -0
  83. package/docs/loops/research.md +136 -0
  84. package/docs/loops/review.md +200 -0
  85. package/docs/mcp-schema-changelog.md +14 -5
  86. package/package.json +1 -1
@@ -0,0 +1,485 @@
1
+ import { evidenceDigest, validateArtifactEvidence } from './evidence.js';
2
+ import { captureWorkspaceDigest } from './workspace-digest.js';
3
+ import { currentNonce, findReservationByAssignmentId, getReservation, } from './attempt-reservation.js';
4
+ import { readLaunchDecision, resolveTurnGenerationChain, } from './attempt-generations.js';
5
+ const OBSERVATION = {
6
+ right: 'gate:artifact',
7
+ authorities: [
8
+ { channel: 'complete_turn', producer_kind: 'slot', attestation: 'observation', issuer: 'brainclaw:artifact-commit', required_subject_fields: ['slot_id'] },
9
+ { channel: 'reconcile_turn', producer_kind: 'slot', attestation: 'observation', issuer: 'brainclaw:artifact-commit', required_subject_fields: ['slot_id', 'turn_id', 'run_id', 'nonce_digest', 'attempt_epoch', 'execution_contract_hash', 'workspace_digest'] },
10
+ { channel: 'operator_input', producer_kind: 'slot', attestation: 'observation', issuer: 'brainclaw:artifact-commit', required_subject_fields: ['slot_id'] },
11
+ { channel: 'operator_input', producer_kind: 'operator', attestation: 'observation', issuer: 'brainclaw:artifact-commit' },
12
+ { channel: 'operator_input', producer_kind: 'engine', attestation: 'observation', issuer: 'brainclaw:artifact-commit' },
13
+ { channel: 'system_hook', producer_kind: 'engine', attestation: 'observation', issuer: 'brainclaw:artifact-commit' },
14
+ { channel: 'verify_command', producer_kind: 'engine', attestation: 'verification', issuer: 'brainclaw:verify-command', required_subject_fields: ['command_digest', 'workspace_digest'] },
15
+ ],
16
+ };
17
+ const APPROVAL = {
18
+ right: 'gate:reviewer_green',
19
+ authorities: [
20
+ { channel: 'complete_turn', producer_kind: 'slot', attestation: 'approval', issuer: 'brainclaw:review-slot', required_subject_fields: ['slot_id'] },
21
+ { channel: 'reconcile_turn', producer_kind: 'slot', attestation: 'approval', issuer: 'brainclaw:review-slot', required_subject_fields: ['slot_id', 'turn_id', 'run_id', 'nonce_digest', 'attempt_epoch', 'execution_contract_hash', 'workspace_digest'] },
22
+ ],
23
+ };
24
+ const VERIFICATION = {
25
+ right: 'gate:command_green',
26
+ authorities: [
27
+ { channel: 'verify_command', producer_kind: 'engine', attestation: 'verification', issuer: 'brainclaw:verify-command', required_subject_fields: ['command_digest', 'workspace_digest'] },
28
+ ],
29
+ };
30
+ const CRITIC_OBSERVATION = {
31
+ right: 'gate:artifact',
32
+ authorities: OBSERVATION.authorities.filter((authority) => authority.channel === 'complete_turn' || authority.channel === 'reconcile_turn'),
33
+ };
34
+ const NO_REVIEWER_GREEN = { right: 'gate:reviewer_green', authorities: [] };
35
+ const NO_COMMAND_GREEN = { right: 'gate:command_green', authorities: [] };
36
+ const NO_CRITIC_SIGNAL = { right: 'gate:artifact', authorities: [] };
37
+ /** Policies refine the five protocol graphs; they do not duplicate phase order. */
38
+ export const GATE_POLICIES = {
39
+ review: {
40
+ version: 'gate-policy-v1',
41
+ kind: 'review',
42
+ requirements: {
43
+ artifact: OBSERVATION,
44
+ reviewer_green: APPROVAL,
45
+ command_green: NO_COMMAND_GREEN,
46
+ critic_signal: NO_CRITIC_SIGNAL,
47
+ },
48
+ },
49
+ ideation: {
50
+ version: 'gate-policy-v1',
51
+ kind: 'ideation',
52
+ requirements: {
53
+ artifact: OBSERVATION,
54
+ reviewer_green: NO_REVIEWER_GREEN,
55
+ command_green: NO_COMMAND_GREEN,
56
+ critic_signal: CRITIC_OBSERVATION,
57
+ },
58
+ },
59
+ implementation: {
60
+ version: 'gate-policy-v1',
61
+ kind: 'implementation',
62
+ requirements: {
63
+ artifact: OBSERVATION,
64
+ reviewer_green: NO_REVIEWER_GREEN,
65
+ command_green: VERIFICATION,
66
+ critic_signal: NO_CRITIC_SIGNAL,
67
+ },
68
+ },
69
+ research: {
70
+ version: 'gate-policy-v1',
71
+ kind: 'research',
72
+ requirements: {
73
+ artifact: OBSERVATION,
74
+ reviewer_green: NO_REVIEWER_GREEN,
75
+ command_green: NO_COMMAND_GREEN,
76
+ // The shipped research FSM exits investigate↔synthesize on this signal.
77
+ critic_signal: CRITIC_OBSERVATION,
78
+ },
79
+ },
80
+ debug: {
81
+ version: 'gate-policy-v1',
82
+ kind: 'debug',
83
+ requirements: {
84
+ artifact: OBSERVATION,
85
+ reviewer_green: NO_REVIEWER_GREEN,
86
+ // The shipped debug FSM exits hypothesize↔isolate↔fix on command_green.
87
+ command_green: VERIFICATION,
88
+ critic_signal: NO_CRITIC_SIGNAL,
89
+ },
90
+ },
91
+ };
92
+ function hasUsableContent(artifact) {
93
+ return (artifact.body ?? '').trim().length > 0 || artifact.ref !== undefined;
94
+ }
95
+ function legacyEligibleCount(thread, artifacts, purpose, cwd) {
96
+ // Kind-specialized purposes stay fail-closed even for persisted legacy
97
+ // loops. Legacy relaxes envelope presence; it does not invent an authority
98
+ // that the kind's policy explicitly forbids.
99
+ if (GATE_POLICIES[thread.kind].requirements[purpose].authorities.length === 0)
100
+ return 0;
101
+ return artifacts.filter((artifact) => {
102
+ if (purpose !== 'critic_signal' && !hasUsableContent(artifact))
103
+ return false;
104
+ if (!artifact.evidence)
105
+ return true;
106
+ return validateArtifactEvidence(thread, artifact).valid
107
+ && reconciledV2AuthorityRejection(thread, artifact, cwd) === undefined;
108
+ }).length;
109
+ }
110
+ function payloadFingerprint(artifact) {
111
+ return evidenceDigest({
112
+ phase: artifact.phase,
113
+ type: artifact.type,
114
+ body: artifact.body,
115
+ ref: artifact.ref,
116
+ iteration: artifact.iteration ?? 0,
117
+ producer: artifact.evidence?.subject.slot_id ?? artifact.evidence?.producer.id ?? artifact.produced_by,
118
+ });
119
+ }
120
+ /**
121
+ * A reconciled v2 envelope is only a claim about an attempt until the gate
122
+ * compares it with immutable AttemptAuthority. The persisted nonce is a digest
123
+ * (never the bearer token), so compare it with a server-derived digest of the
124
+ * current generation nonce.
125
+ *
126
+ * No v2 chain enters an explicit restrictive legacy verifier backed by the
127
+ * committed reservation. Missing/unverifiable authority and every tuple
128
+ * mismatch are fail-closed in both modes.
129
+ */
130
+ function reconciledV2AuthorityRejection(thread, artifact, cwd) {
131
+ const envelope = artifact.evidence;
132
+ if (!envelope || envelope.producer.channel !== 'reconcile_turn')
133
+ return undefined;
134
+ const subject = envelope.subject;
135
+ const reservation = subject.assignment_id
136
+ ? findReservationByAssignmentId(subject.assignment_id, cwd)
137
+ : undefined;
138
+ const resolvedReservation = reservation ?? (subject.turn_id ? getReservation(subject.turn_id, cwd) : undefined);
139
+ if (!resolvedReservation)
140
+ return 'reconciled_authority_missing';
141
+ let chain;
142
+ try {
143
+ chain = resolveTurnGenerationChain(resolvedReservation.store_root, resolvedReservation.turn_id);
144
+ }
145
+ catch {
146
+ return 'reconciled_authority_unreadable';
147
+ }
148
+ if (!chain) {
149
+ // Legacy is deliberately narrow, not permissive: every binding is
150
+ // reconstructed from the committed, crossed reservation. Pre-P1 records
151
+ // have no stored ExecutionContract, so use the same deterministic
152
+ // compatibility identities the reconciler seals. This preserves dual-read
153
+ // without accepting a caller-chosen hash.
154
+ const nonce = currentNonce(resolvedReservation);
155
+ if (resolvedReservation.decision !== 'committed'
156
+ || resolvedReservation.launch?.status !== 'crossed'
157
+ || !nonce) {
158
+ return 'reconciled_legacy_authority_unverifiable';
159
+ }
160
+ const contractHash = resolvedReservation.execution_contract_ref?.hash ?? evidenceDigest({
161
+ version: 'legacy-uncontracted-reservation-v1',
162
+ turn_id: resolvedReservation.turn_id,
163
+ run_id: resolvedReservation.child_ids.run_id,
164
+ epoch: resolvedReservation.epoch,
165
+ phase: resolvedReservation.phase,
166
+ iteration: resolvedReservation.iteration,
167
+ cwd: resolvedReservation.cwd,
168
+ });
169
+ const workspaceDigest = evidenceDigest({
170
+ workspace_policy: resolvedReservation.execution_contract?.workspace_policy,
171
+ cwd: resolvedReservation.cwd,
172
+ store_root: resolvedReservation.store_root,
173
+ });
174
+ const expectedLegacy = {
175
+ loop_id: resolvedReservation.loop_id,
176
+ slot_id: resolvedReservation.slot_id,
177
+ turn_id: resolvedReservation.turn_id,
178
+ assignment_id: resolvedReservation.child_ids.assignment_id,
179
+ run_id: resolvedReservation.child_ids.run_id,
180
+ nonce_digest: evidenceDigest({ launch_nonce: nonce }),
181
+ attempt_epoch: resolvedReservation.epoch,
182
+ execution_contract_hash: contractHash,
183
+ workspace_digest: workspaceDigest,
184
+ };
185
+ const actualLegacy = {
186
+ loop_id: thread.id,
187
+ slot_id: subject.slot_id,
188
+ turn_id: subject.turn_id,
189
+ assignment_id: subject.assignment_id,
190
+ run_id: subject.run_id,
191
+ nonce_digest: subject.nonce_digest,
192
+ attempt_epoch: subject.attempt_epoch,
193
+ execution_contract_hash: subject.execution_contract_hash,
194
+ workspace_digest: subject.workspace_digest,
195
+ };
196
+ const mismatch = Object.keys(expectedLegacy)
197
+ .find((field) => actualLegacy[field] !== expectedLegacy[field]);
198
+ return mismatch ? `reconciled_legacy_authority_mismatch:${mismatch}` : undefined;
199
+ }
200
+ if (chain.status !== 'active' && chain.status !== 'settled') {
201
+ return `reconciled_authority_not_usable:${chain.status}`;
202
+ }
203
+ const generation = chain.latest_generation;
204
+ let launch;
205
+ try {
206
+ launch = readLaunchDecision(resolvedReservation.store_root, generation.turn_id, generation.attempt_epoch);
207
+ }
208
+ catch {
209
+ return 'reconciled_authority_unreadable';
210
+ }
211
+ if (launch?.decision !== 'crossed')
212
+ return 'reconciled_authority_launch_not_crossed';
213
+ const expected = {
214
+ loop_id: resolvedReservation.loop_id,
215
+ slot_id: resolvedReservation.slot_id,
216
+ turn_id: generation.turn_id,
217
+ assignment_id: generation.assignment_id,
218
+ run_id: generation.run_id,
219
+ nonce_digest: evidenceDigest({ launch_nonce: generation.launch_nonce }),
220
+ attempt_epoch: generation.attempt_epoch,
221
+ execution_contract_hash: generation.contract_hash,
222
+ workspace_digest: generation.workspace_digest,
223
+ };
224
+ const actual = {
225
+ loop_id: thread.id,
226
+ slot_id: subject.slot_id,
227
+ turn_id: subject.turn_id,
228
+ assignment_id: subject.assignment_id,
229
+ run_id: subject.run_id,
230
+ nonce_digest: subject.nonce_digest,
231
+ attempt_epoch: subject.attempt_epoch,
232
+ execution_contract_hash: subject.execution_contract_hash,
233
+ workspace_digest: subject.workspace_digest,
234
+ };
235
+ const mismatch = Object.keys(expected)
236
+ .find((field) => actual[field] !== expected[field]);
237
+ return mismatch ? `reconciled_authority_mismatch:${mismatch}` : undefined;
238
+ }
239
+ function selectEligible(thread, artifacts, purpose, cwd) {
240
+ const mode = thread.evidence_policy?.mode ?? 'legacy';
241
+ const requirement = GATE_POLICIES[thread.kind].requirements[purpose];
242
+ const eligible = [];
243
+ const accepted_evidence_ids = [];
244
+ const rejected = [];
245
+ const fingerprints = new Set();
246
+ for (const artifact of artifacts) {
247
+ // critic_signal is intentionally a typed marker; unlike deliverable
248
+ // artifacts it does not require an inline/ref payload.
249
+ if (purpose !== 'critic_signal' && !hasUsableContent(artifact)) {
250
+ rejected.push({ artifact_id: artifact.artifact_id, reason: 'no_usable_content' });
251
+ continue;
252
+ }
253
+ // A present envelope is always validated, including on legacy threads.
254
+ if (artifact.evidence) {
255
+ const validity = validateArtifactEvidence(thread, artifact);
256
+ if (!validity.valid) {
257
+ rejected.push({ artifact_id: artifact.artifact_id, reason: validity.reasons.join(',') });
258
+ continue;
259
+ }
260
+ }
261
+ else if (mode === 'strict' || mode === 'shadow') {
262
+ rejected.push({ artifact_id: artifact.artifact_id, reason: 'missing_evidence' });
263
+ continue;
264
+ }
265
+ if (mode !== 'legacy') {
266
+ const envelope = artifact.evidence;
267
+ if (purpose === 'reviewer_green' && (artifact.iteration ?? 0) !== thread.iteration_count) {
268
+ rejected.push({ artifact_id: artifact.artifact_id, reason: 'stale_subject_iteration' });
269
+ continue;
270
+ }
271
+ const channelAuthorities = requirement.authorities.filter((authority) => authority.channel === envelope.producer.channel);
272
+ if (channelAuthorities.length === 0) {
273
+ rejected.push({ artifact_id: artifact.artifact_id, reason: `channel_not_allowed:${envelope.producer.channel}` });
274
+ continue;
275
+ }
276
+ const producerAuthorities = channelAuthorities.filter((authority) => authority.producer_kind === envelope.producer.kind);
277
+ if (producerAuthorities.length === 0) {
278
+ rejected.push({ artifact_id: artifact.artifact_id, reason: `producer_channel_mismatch:${envelope.producer.kind}:${envelope.producer.channel}` });
279
+ continue;
280
+ }
281
+ const authority = producerAuthorities.find((candidate) => envelope.attestations.some((item) => item.kind === candidate.attestation
282
+ && item.issuer === candidate.issuer
283
+ && item.rights.includes(requirement.right)));
284
+ if (!authority) {
285
+ rejected.push({ artifact_id: artifact.artifact_id, reason: `missing_authorized_attestation:${requirement.right}` });
286
+ continue;
287
+ }
288
+ const missingSubject = authority.required_subject_fields?.find((field) => envelope.subject[field] === undefined || envelope.subject[field] === '');
289
+ if (missingSubject) {
290
+ rejected.push({ artifact_id: artifact.artifact_id, reason: `missing_subject_binding:${missingSubject}` });
291
+ continue;
292
+ }
293
+ if (envelope.subject.slot_id) {
294
+ const slot = thread.slots.find((candidate) => candidate.slot_id === envelope.subject.slot_id);
295
+ if (!slot) {
296
+ rejected.push({ artifact_id: artifact.artifact_id, reason: 'unknown_subject_slot' });
297
+ continue;
298
+ }
299
+ if (slot.current_turn_id && envelope.subject.turn_id !== slot.current_turn_id) {
300
+ rejected.push({ artifact_id: artifact.artifact_id, reason: 'wrong_subject_turn' });
301
+ continue;
302
+ }
303
+ if (slot.assignment_id && envelope.subject.assignment_id !== slot.assignment_id) {
304
+ rejected.push({ artifact_id: artifact.artifact_id, reason: 'wrong_subject_assignment' });
305
+ continue;
306
+ }
307
+ if (slot.claim_id && envelope.subject.claim_id !== slot.claim_id) {
308
+ rejected.push({ artifact_id: artifact.artifact_id, reason: 'wrong_subject_claim' });
309
+ continue;
310
+ }
311
+ }
312
+ if (envelope.producer.channel === 'verify_command') {
313
+ let report;
314
+ try {
315
+ report = JSON.parse(artifact.body ?? '{}');
316
+ }
317
+ catch {
318
+ report = {};
319
+ }
320
+ if (report.workspace_stable !== true
321
+ || !report.command_argv
322
+ || evidenceDigest({ command: report.command_argv }) !== report.command_digest
323
+ || (thread.protocol?.verify
324
+ && evidenceDigest({ command: thread.protocol.verify.command }) !== report.command_digest)
325
+ || report.command_digest !== envelope.subject.command_digest
326
+ || report.workspace_digest !== envelope.subject.workspace_digest) {
327
+ rejected.push({ artifact_id: artifact.artifact_id, reason: 'verification_subject_mismatch' });
328
+ continue;
329
+ }
330
+ try {
331
+ if (!report.cwd || captureWorkspaceDigest(report.cwd) !== envelope.subject.workspace_digest) {
332
+ rejected.push({ artifact_id: artifact.artifact_id, reason: 'workspace_changed_after_verification' });
333
+ continue;
334
+ }
335
+ }
336
+ catch {
337
+ rejected.push({ artifact_id: artifact.artifact_id, reason: 'workspace_freshness_unavailable' });
338
+ continue;
339
+ }
340
+ }
341
+ if (envelope.producer.channel === 'reconcile_turn' && envelope.subject.execution_contract_hash === undefined) {
342
+ rejected.push({ artifact_id: artifact.artifact_id, reason: 'missing_subject_binding:execution_contract_hash' });
343
+ continue;
344
+ }
345
+ const authorityRejection = reconciledV2AuthorityRejection(thread, artifact, cwd);
346
+ if (authorityRejection) {
347
+ rejected.push({ artifact_id: artifact.artifact_id, reason: authorityRejection });
348
+ continue;
349
+ }
350
+ }
351
+ if (mode !== 'legacy') {
352
+ const fingerprint = payloadFingerprint(artifact);
353
+ if (fingerprints.has(fingerprint)) {
354
+ rejected.push({ artifact_id: artifact.artifact_id, reason: 'duplicate_payload' });
355
+ continue;
356
+ }
357
+ fingerprints.add(fingerprint);
358
+ }
359
+ eligible.push(artifact);
360
+ if (artifact.evidence)
361
+ accepted_evidence_ids.push(artifact.evidence.evidence_id);
362
+ }
363
+ return { eligible, accepted_evidence_ids, rejected };
364
+ }
365
+ function modeFor(thread) {
366
+ return thread.evidence_policy?.mode ?? 'legacy';
367
+ }
368
+ function decision(thread, condition, strictPassed, legacyPassed, set = { eligible: [], accepted_evidence_ids: [], rejected: [] }) {
369
+ const mode = modeFor(thread);
370
+ return {
371
+ passed: mode === 'legacy' || mode === 'shadow' ? legacyPassed : strictPassed,
372
+ strict_passed: strictPassed,
373
+ legacy_passed: legacyPassed,
374
+ policy_version: mode === 'legacy' ? 'legacy' : 'gate-policy-v1',
375
+ mode,
376
+ condition_digest: evidenceDigest(condition),
377
+ accepted_evidence_ids: [...new Set(set.accepted_evidence_ids)],
378
+ rejected: set.rejected,
379
+ };
380
+ }
381
+ function artifactCandidates(thread, condition) {
382
+ return thread.artifacts.filter((artifact) => {
383
+ if (artifact.type !== condition.type)
384
+ return false;
385
+ if (condition.kind === 'artifact_produced')
386
+ return artifact.phase === condition.phase;
387
+ if (condition.scope !== 'phase')
388
+ return true;
389
+ if (artifact.phase !== thread.current_phase)
390
+ return false;
391
+ const iterationAware = thread.iteration_count > 0 || thread.artifacts.some((item) => item.iteration !== undefined);
392
+ return !iterationAware || (artifact.iteration ?? 0) === thread.iteration_count;
393
+ });
394
+ }
395
+ /** One evaluator owns both terminal stop conditions and phase gates. */
396
+ export function evaluateGateCondition(thread, condition, cwd) {
397
+ if (!condition)
398
+ return decision(thread, { kind: 'absent' }, false, false);
399
+ switch (condition.kind) {
400
+ case 'phase_reached': {
401
+ const passed = thread.current_phase === condition.phase;
402
+ return decision(thread, condition, passed, passed);
403
+ }
404
+ case 'max_iterations':
405
+ case 'min_iterations': {
406
+ const passed = thread.iteration_count >= condition.n;
407
+ return decision(thread, condition, passed, passed);
408
+ }
409
+ case 'no_open_questions': {
410
+ const passed = thread.open_questions.length === 0;
411
+ return decision(thread, condition, passed, passed);
412
+ }
413
+ case 'manual':
414
+ return decision(thread, condition, false, false);
415
+ case 'reviewer_green': {
416
+ const candidates = thread.artifacts.filter((artifact) => artifact.type === 'verdict' && /^accepted(?:\b|[:\s])/.test((artifact.body ?? '').trim().toLowerCase()));
417
+ const set = selectEligible(thread, candidates, 'reviewer_green', cwd);
418
+ return decision(thread, condition, set.eligible.length > 0, legacyEligibleCount(thread, candidates, 'reviewer_green', cwd) > 0, set);
419
+ }
420
+ case 'artifact_produced': {
421
+ const candidates = artifactCandidates(thread, condition);
422
+ const set = selectEligible(thread, candidates, 'artifact', cwd);
423
+ return decision(thread, condition, set.eligible.length > 0, legacyEligibleCount(thread, candidates, 'artifact', cwd) > 0, set);
424
+ }
425
+ case 'min_artifacts_by_type': {
426
+ const candidates = artifactCandidates(thread, condition);
427
+ const set = selectEligible(thread, candidates, 'artifact', cwd);
428
+ return decision(thread, condition, set.eligible.length >= condition.n, legacyEligibleCount(thread, candidates, 'artifact', cwd) >= condition.n, set);
429
+ }
430
+ case 'any': {
431
+ const children = condition.conditions.map((child) => evaluateGateCondition(thread, child, cwd));
432
+ return decision(thread, condition, children.some((child) => child.strict_passed ?? child.passed), children.some((child) => child.legacy_passed ?? child.passed), {
433
+ eligible: [],
434
+ accepted_evidence_ids: children.flatMap((child) => child.accepted_evidence_ids),
435
+ rejected: children.flatMap((child) => child.rejected),
436
+ });
437
+ }
438
+ case 'all': {
439
+ const children = condition.conditions.map((child) => evaluateGateCondition(thread, child, cwd));
440
+ return decision(thread, condition, children.every((child) => child.strict_passed ?? child.passed), children.every((child) => child.legacy_passed ?? child.passed), {
441
+ eligible: [],
442
+ accepted_evidence_ids: children.flatMap((child) => child.accepted_evidence_ids),
443
+ rejected: children.flatMap((child) => child.rejected),
444
+ });
445
+ }
446
+ }
447
+ }
448
+ export function eligibleArtifactsForPurpose(thread, artifacts, purpose, cwd) {
449
+ return selectEligible(thread, artifacts, purpose, cwd);
450
+ }
451
+ export function evaluateCommandGreen(thread, iteration, cwd) {
452
+ const candidates = thread.artifacts.filter((artifact) => {
453
+ if (artifact.type !== 'verify_report' || (artifact.iteration ?? 0) !== iteration)
454
+ return false;
455
+ try {
456
+ return JSON.parse(artifact.body ?? '{}').passed === true;
457
+ }
458
+ catch {
459
+ return false;
460
+ }
461
+ });
462
+ const set = selectEligible(thread, candidates, 'command_green', cwd);
463
+ return decision(thread, { kind: 'command_green', iteration }, set.eligible.length > 0, legacyEligibleCount(thread, candidates, 'command_green', cwd) > 0, set);
464
+ }
465
+ export function evaluateCriticSignal(thread, iteration, cwd) {
466
+ const candidates = thread.artifacts.filter((artifact) => artifact.type === 'critic_signal' && (artifact.iteration ?? 0) === iteration);
467
+ const set = selectEligible(thread, candidates, 'critic_signal', cwd);
468
+ return decision(thread, { kind: 'critic_signal', iteration }, set.eligible.length > 0, legacyEligibleCount(thread, candidates, 'critic_signal', cwd) > 0, set);
469
+ }
470
+ export function evaluateNoNewCritique(thread, iteration, cwd) {
471
+ const candidates = thread.artifacts.filter((artifact) => artifact.type === 'critique' && (artifact.iteration ?? 0) === iteration);
472
+ const set = selectEligible(thread, candidates, 'artifact', cwd);
473
+ const closureCandidates = thread.artifacts.filter((artifact) => artifact.type === 'critique_window_closed' && (artifact.iteration ?? 0) === iteration);
474
+ const closure = selectEligible(thread, closureCandidates, 'artifact', cwd);
475
+ // Invalid/replayed critiques cannot be used to manufacture a negative convergence signal.
476
+ const strictPassed = set.eligible.length === 0
477
+ && set.rejected.length === 0
478
+ && closure.eligible.length > 0;
479
+ return decision(thread, { kind: 'no_new_critique_artifacts', iteration }, strictPassed, candidates.length === 0, {
480
+ eligible: closure.eligible,
481
+ accepted_evidence_ids: [...set.accepted_evidence_ids, ...closure.accepted_evidence_ids],
482
+ rejected: [...set.rejected, ...closure.rejected],
483
+ });
484
+ }
485
+ //# sourceMappingURL=gate-policy.js.map
@@ -1,32 +1,22 @@
1
- /**
2
- * pln#632 impl-loop bind — the ENGINE action for an implementation loop's `bind` phase.
3
- *
4
- * The implementation protocol declares `bind` as "bind plan+sequence and dispatch" (see
5
- * LOOP_PROTOCOLS.implementation in types.ts). This is that action: read the loop's linked
6
- * sequence, dispatch its ready lanes via the EXISTING sequence spawner, then advance
7
- * `bind → execute` so the loop enters its execute↔verify cycle.
8
- *
9
- * Reuses `dispatch()` ADDITIVELY via its `sequenceId` option (pln#632) so the loop drives
10
- * its OWN linked sequence WITHOUT touching the project's global active-sequence pointer —
11
- * no hijack of whatever sequence other bclaw_dispatch work is using. Mirrors the
12
- * async-handler-spawns pattern already used by bclaw_coordinate(open_loop=true) for review
13
- * loops; touches NO review/ideation dispatch. The live spawn happens only when this runs
14
- * on a real loop (exactly like bclaw_dispatch) — `dryRun` previews with no spawn and no
15
- * phase mutation, so the flow is unit-testable.
16
- */
17
- import { dispatch } from '../dispatcher.js';
18
1
  import { listSequences } from '../sequence.js';
2
+ import { withLoopLock } from './lock.js';
19
3
  import { getLoop } from './store.js';
20
4
  import { advance } from './verbs.js';
21
- import { withLoopLock } from './lock.js';
5
+ const ENGINE_ONLY_WARNING = 'implementation bind is engine-only and does not dispatch workers; use bclaw_loop(intent="turn", dispatch=true, slot_id=...) in execute';
6
+ function compatibilityWarnings(input) {
7
+ const usedLaunchOption = input.lanes !== undefined
8
+ || input.autoExecute !== undefined
9
+ || input.model !== undefined
10
+ || input.maxAssignments !== undefined;
11
+ return usedLaunchOption
12
+ ? [`${ENGINE_ONLY_WARNING}; bind launch options are retained but ignored`]
13
+ : [ENGINE_ONLY_WARNING];
14
+ }
22
15
  /**
23
- * Bind an implementation loop to its linked sequence and dispatch it. Async because the
24
- * underlying spawn is async (the only awaited work; `dryRun` resolves synchronously).
16
+ * Validate an implementation loop's linked sequence and advance to execute.
25
17
  *
26
- * Idempotent: a loop already past `bind` returns a `noop` (never re-dispatches on a second
27
- * bind after it advanced). A crash BETWEEN dispatch and advance leaves the loop in `bind`,
28
- * so a retry re-dispatches only the still-unassigned lanes (dispatch skips lanes with an
29
- * active assignment) and re-attempts the advance — safe crash-recovery.
18
+ * Idempotent: a loop already past `bind` returns `noop`. The phase re-check and
19
+ * advance happen under the loop lock, so racing bind calls cannot advance twice.
30
20
  */
31
21
  export async function runImplBind(input, cwd) {
32
22
  const { loop_id, dispatcherAgent } = input;
@@ -36,16 +26,10 @@ export async function runImplBind(input, cwd) {
36
26
  if (loop.kind !== 'implementation') {
37
27
  throw new Error(`bind is only valid for implementation loops (loop ${loop_id} is kind='${loop.kind}'); review/ideation loops dispatch via bclaw_coordinate`);
38
28
  }
39
- // bind SPAWNS real workers via dispatch() — never do that on a loop that is not open.
40
- // A paused loop keeps current_phase='bind' (pause only flips status), and open→close
41
- // leaves the phase at 'bind' too, so the phase-idempotency check below is NOT sufficient
42
- // to stop a spawn on a held/terminal loop (review Finding 1). Gate on status FIRST, so
43
- // no dispatch fires and we don't spawn then throw at the advance.
44
29
  if (loop.status !== 'open') {
45
30
  throw new Error(`bind requires an open loop; loop ${loop_id} is ${loop.status} (resume a paused loop, or reopen a terminal one, before binding)`);
46
31
  }
47
32
  const sequenceId = loop.linked?.sequence_ids?.[0];
48
- // Idempotency: `bind` is the loop's FIRST phase. A loop already past it was bound before.
49
33
  if (loop.current_phase !== 'bind') {
50
34
  return {
51
35
  loop_id,
@@ -53,58 +37,27 @@ export async function runImplBind(input, cwd) {
53
37
  action: 'noop',
54
38
  dispatch: null,
55
39
  messages_sent: 0,
56
- reason: `loop is in phase '${loop.current_phase}', not 'bind' — already bound (idempotent)`,
40
+ warnings: compatibilityWarnings(input),
41
+ reason: `loop is in phase '${loop.current_phase}', not 'bind' - already bound (idempotent)`,
57
42
  };
58
43
  }
59
44
  if (!sequenceId) {
60
- throw new Error(`impl-bind requires a linked sequence: open the implementation loop with linked.sequence_ids=[] (the sequence whose lanes it executes). None found on ${loop_id}.`);
45
+ throw new Error(`impl-bind requires a linked sequence: open the implementation loop with linked.sequence_ids=[...] (the sequence whose lanes it executes). None found on ${loop_id}.`);
61
46
  }
62
- // Validate up-front (non-throwing lookup) so a missing sequence fails with a clear
63
- // message rather than a silent null dispatch.
64
- const seq = listSequences(cwd).find((s) => s.id === sequenceId);
65
- if (!seq)
47
+ if (!listSequences(cwd).some((sequence) => sequence.id === sequenceId)) {
66
48
  throw new Error(`linked sequence ${sequenceId} not found for loop ${loop_id}`);
67
- const dispatched = await dispatch({
68
- sequenceId,
69
- dispatcherAgent,
70
- dispatcherAgentId: input.dispatcherAgentId,
71
- sessionId: input.sessionId,
72
- dryRun: input.dryRun,
73
- lanes: input.lanes,
74
- autoExecute: input.autoExecute,
75
- model: input.model,
76
- maxAssignments: input.maxAssignments,
77
- }, cwd ?? process.cwd());
78
- const result = dispatched?.result ?? null;
79
- const messages_sent = result?.messages_sent.length ?? 0;
80
- // Guard a race: the sequence disappeared between validation and dispatch → don't advance.
81
- if (!dispatched) {
82
- return {
83
- loop_id,
84
- sequence_id: sequenceId,
85
- action: 'noop',
86
- dispatch: null,
87
- messages_sent: 0,
88
- reason: `sequence ${sequenceId} yielded no dispatch analysis (unavailable); loop stays in 'bind'`,
89
- };
90
49
  }
91
50
  if (input.dryRun) {
92
51
  return {
93
52
  loop_id,
94
53
  sequence_id: sequenceId,
95
54
  action: 'preview',
96
- dispatch: result,
97
- messages_sent,
98
- reason: `dry run: ${result?.delivery_plan.length ?? 0} lane(s) would dispatch; loop stays in 'bind' (no spawn, no advance)`,
55
+ dispatch: null,
56
+ messages_sent: 0,
57
+ warnings: compatibilityWarnings(input),
58
+ reason: `dry run: linked sequence ${sequenceId} is valid; loop stays in 'bind' and no worker is dispatched`,
99
59
  };
100
60
  }
101
- // Real bind: advance bind → execute so the loop enters the execute↔verify cycle. The
102
- // implementation protocol's `bind` phase carries no advance_gate, so the advance is
103
- // unconditional FROM bind — but the phase-check and the advance must be ATOMIC or two
104
- // racing binds each advance once and push the loop bind→execute→verify with no execute
105
- // work done (review Finding 2). advance() does not self-lock (its callers — the facade's
106
- // withLockedLoopMutation and the ideation closer — provide the lock), so we take the loop
107
- // lock here and re-check the phase under it: only the bind that still sees 'bind' advances.
108
61
  const advanced = withLoopLock({
109
62
  cwd,
110
63
  intent: 'impl-bind-advance',
@@ -112,12 +65,15 @@ export async function runImplBind(input, cwd) {
112
65
  scope: { kind: 'loop', loopId: loop_id },
113
66
  work: () => {
114
67
  const fresh = getLoop(loop_id, cwd);
115
- // Raced: a concurrent bind (or a pause/close) moved the loop out of an open 'bind'
116
- // state → do NOT advance again. Idempotent by construction.
117
68
  if (!fresh || fresh.status !== 'open' || fresh.current_phase !== 'bind')
118
69
  return null;
119
- const a = advance({ id: loop_id, actor: dispatcherAgent }, cwd);
120
- return { phase: a.loop.current_phase, auto_closed: a.auto_closed };
70
+ const freshSequenceId = fresh.linked?.sequence_ids?.[0];
71
+ if (freshSequenceId !== sequenceId
72
+ || !listSequences(cwd).some((sequence) => sequence.id === sequenceId)) {
73
+ throw new Error(`linked sequence ${sequenceId} changed or disappeared before bind could advance`);
74
+ }
75
+ const result = advance({ id: loop_id, actor: dispatcherAgent }, cwd);
76
+ return { phase: result.loop.current_phase, auto_closed: result.auto_closed };
121
77
  },
122
78
  });
123
79
  if (!advanced) {
@@ -125,9 +81,10 @@ export async function runImplBind(input, cwd) {
125
81
  loop_id,
126
82
  sequence_id: sequenceId,
127
83
  action: 'noop',
128
- dispatch: result,
129
- messages_sent,
130
- reason: `dispatched ${messages_sent} assignment(s); phase already advanced out of 'bind' under a concurrent bind (idempotent — not re-advanced)`,
84
+ dispatch: null,
85
+ messages_sent: 0,
86
+ warnings: compatibilityWarnings(input),
87
+ reason: `phase already advanced out of 'bind' under a concurrent bind (idempotent - not re-advanced)`,
131
88
  };
132
89
  }
133
90
  return {
@@ -136,9 +93,10 @@ export async function runImplBind(input, cwd) {
136
93
  action: 'bound',
137
94
  advanced_to: advanced.phase,
138
95
  auto_closed: advanced.auto_closed,
139
- dispatch: result,
140
- messages_sent,
141
- reason: `dispatched ${messages_sent} assignment(s) on sequence ${sequenceId}; advanced bind → ${advanced.phase}`,
96
+ dispatch: null,
97
+ messages_sent: 0,
98
+ warnings: compatibilityWarnings(input),
99
+ reason: `validated linked sequence ${sequenceId}; advanced bind -> ${advanced.phase}; dispatch worker slots with turn(dispatch=true)`,
142
100
  };
143
101
  }
144
102
  //# sourceMappingURL=impl-bind.js.map