brainclaw 1.26.2 → 1.28.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 (88) 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 +143 -15
  8. package/dist/commands/mcp-catalog.js +52 -18
  9. package/dist/commands/mcp-schemas.generated.js +64 -0
  10. package/dist/commands/mcp-write-claims.js +128 -1
  11. package/dist/commands/mcp-write-coordination.js +149 -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 +189 -14
  25. package/dist/core/execution-contract.js +345 -0
  26. package/dist/core/execution.js +130 -16
  27. package/dist/core/facade-schema.js +3 -0
  28. package/dist/core/harness-adapters/base.js +150 -0
  29. package/dist/core/harness-adapters/claude.js +39 -0
  30. package/dist/core/harness-adapters/codex.js +57 -0
  31. package/dist/core/harness-adapters/harvest.js +109 -0
  32. package/dist/core/harness-adapters/index.js +8 -0
  33. package/dist/core/harness-adapters/prompt-only.js +13 -0
  34. package/dist/core/harness-adapters/registry.js +48 -0
  35. package/dist/core/harness-adapters/result.js +33 -0
  36. package/dist/core/harness-adapters/types.js +2 -0
  37. package/dist/core/ideation-loop-close.js +25 -2
  38. package/dist/core/instruction-templates.js +3 -2
  39. package/dist/core/loop-turn-dispatch.js +235 -0
  40. package/dist/core/loops/artifact-contract.js +11 -0
  41. package/dist/core/loops/attempt-authority.js +496 -0
  42. package/dist/core/loops/attempt-generations.js +509 -0
  43. package/dist/core/loops/attempt-reservation.js +197 -35
  44. package/dist/core/loops/attempt-rollout.js +404 -0
  45. package/dist/core/loops/attempt-takeover.js +155 -0
  46. package/dist/core/loops/bootstrap-acquire.js +7 -3
  47. package/dist/core/loops/brief-assembly.js +21 -4
  48. package/dist/core/loops/evidence.js +188 -0
  49. package/dist/core/loops/facade-schema.js +75 -11
  50. package/dist/core/loops/gate-policy.js +533 -0
  51. package/dist/core/loops/impl-bind.js +91 -81
  52. package/dist/core/loops/index.js +9 -0
  53. package/dist/core/loops/iteration-engine.js +31 -19
  54. package/dist/core/loops/kind-policies.js +90 -0
  55. package/dist/core/loops/lock.js +71 -13
  56. package/dist/core/loops/reconcile-turn.js +237 -18
  57. package/dist/core/loops/result-reducers.js +113 -10
  58. package/dist/core/loops/store.js +34 -3
  59. package/dist/core/loops/turn-execution.js +480 -0
  60. package/dist/core/loops/types.js +127 -3
  61. package/dist/core/loops/verbs.js +335 -99
  62. package/dist/core/loops/verify-command.js +105 -20
  63. package/dist/core/loops/workspace-digest.js +54 -0
  64. package/dist/core/review-loop-close.js +25 -3
  65. package/dist/core/review-loop-turn-dispatch.js +210 -161
  66. package/dist/core/runtime-signals.js +62 -25
  67. package/dist/core/schema.js +40 -0
  68. package/dist/core/spawn-check.js +3 -2
  69. package/dist/core/upgrades/backup.js +27 -4
  70. package/dist/facts.js +9 -8
  71. package/dist/facts.json +8 -7
  72. package/docs/cli.md +49 -1
  73. package/docs/concepts/attempt-authority.md +407 -0
  74. package/docs/concepts/evidence-attestations.md +135 -0
  75. package/docs/concepts/execution-contract.md +166 -0
  76. package/docs/concepts/harness-adapters.md +166 -0
  77. package/docs/concepts/ideation-loop.md +5 -4
  78. package/docs/concepts/loop-engine.md +302 -113
  79. package/docs/index.md +4 -1
  80. package/docs/integrations/codex.md +3 -3
  81. package/docs/integrations/mcp.md +59 -5
  82. package/docs/loops/debug.md +144 -0
  83. package/docs/loops/ideation.md +158 -0
  84. package/docs/loops/implementation.md +174 -0
  85. package/docs/loops/research.md +136 -0
  86. package/docs/loops/review.md +200 -0
  87. package/docs/mcp-schema-changelog.md +18 -5
  88. package/package.json +1 -1
@@ -1,32 +1,68 @@
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
- import { listSequences } from '../sequence.js';
1
+ import { loadSequence } from '../sequence.js';
2
+ import { loadState } from '../state.js';
3
+ import { withLoopLock } from './lock.js';
19
4
  import { getLoop } from './store.js';
20
5
  import { advance } from './verbs.js';
21
- import { withLoopLock } from './lock.js';
6
+ function deriveBindings(loop, sequenceId, cwd) {
7
+ if (!loop)
8
+ throw new Error('implementation loop disappeared during bind');
9
+ const sequence = loadSequence(sequenceId, cwd);
10
+ if (sequence.items.length === 0)
11
+ throw new Error(`linked sequence ${sequenceId} has no items`);
12
+ const linkedPlans = new Set(loop.linked?.plan_ids ?? []);
13
+ if (linkedPlans.size === 0) {
14
+ throw new Error(`impl-bind requires linked.plan_ids in addition to linked.sequence_ids`);
15
+ }
16
+ const plans = new Map(loadState(cwd).plan_items.map((plan) => [plan.id, plan]));
17
+ for (const item of sequence.items) {
18
+ if (!linkedPlans.has(item.planId)) {
19
+ throw new Error(`sequence item rank ${item.rank} references unlinked plan ${item.planId}`);
20
+ }
21
+ const plan = plans.get(item.planId);
22
+ if (!plan)
23
+ throw new Error(`linked sequence ${sequenceId} references missing plan ${item.planId}`);
24
+ if (item.stepId && !(plan.steps ?? []).some((step) => step.id === item.stepId)) {
25
+ throw new Error(`sequence item rank ${item.rank} references missing step ${item.stepId} on plan ${item.planId}`);
26
+ }
27
+ }
28
+ const grouped = new Map();
29
+ for (const item of sequence.items) {
30
+ const lane = item.lane?.trim() || 'default';
31
+ grouped.set(lane, [...(grouped.get(lane) ?? []), item]);
32
+ }
33
+ const lanes = [...grouped.keys()].sort();
34
+ if (loop.slots.length !== lanes.length) {
35
+ throw new Error(`impl-bind lane/slot mismatch: sequence ${sequenceId} has ${lanes.length} lane(s) (${lanes.join(', ')}) but loop has ${loop.slots.length} slot(s); open one worker slot per lane`);
36
+ }
37
+ const bindings = {};
38
+ loop.slots.forEach((slot, index) => {
39
+ const lane = lanes[index];
40
+ const items = grouped.get(lane);
41
+ const scopes = [...new Set(items.map((item) => item.scope_hint?.trim()).filter((value) => Boolean(value)))];
42
+ bindings[slot.slot_id] = {
43
+ lane,
44
+ scope_hint: scopes.length > 0 ? scopes.join(', ') : undefined,
45
+ plan_ids: [...new Set(items.map((item) => item.planId))],
46
+ step_ids: [...new Set(items.flatMap((item) => item.stepId ? [item.stepId] : []))],
47
+ };
48
+ });
49
+ return bindings;
50
+ }
51
+ 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';
52
+ function compatibilityWarnings(input) {
53
+ const usedLaunchOption = input.lanes !== undefined
54
+ || input.autoExecute !== undefined
55
+ || input.model !== undefined
56
+ || input.maxAssignments !== undefined;
57
+ return usedLaunchOption
58
+ ? [`${ENGINE_ONLY_WARNING}; bind launch options are retained but ignored`]
59
+ : [ENGINE_ONLY_WARNING];
60
+ }
22
61
  /**
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).
62
+ * Validate an implementation loop's linked sequence and advance to execute.
25
63
  *
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.
64
+ * Idempotent: a loop already past `bind` returns `noop`. The phase re-check and
65
+ * advance happen under the loop lock, so racing bind calls cannot advance twice.
30
66
  */
31
67
  export async function runImplBind(input, cwd) {
32
68
  const { loop_id, dispatcherAgent } = input;
@@ -36,16 +72,10 @@ export async function runImplBind(input, cwd) {
36
72
  if (loop.kind !== 'implementation') {
37
73
  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
74
  }
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
75
  if (loop.status !== 'open') {
45
76
  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
77
  }
47
78
  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
79
  if (loop.current_phase !== 'bind') {
50
80
  return {
51
81
  loop_id,
@@ -53,58 +83,32 @@ export async function runImplBind(input, cwd) {
53
83
  action: 'noop',
54
84
  dispatch: null,
55
85
  messages_sent: 0,
56
- reason: `loop is in phase '${loop.current_phase}', not 'bind' — already bound (idempotent)`,
86
+ warnings: compatibilityWarnings(input),
87
+ reason: `loop is in phase '${loop.current_phase}', not 'bind' - already bound (idempotent)`,
57
88
  };
58
89
  }
59
90
  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}.`);
91
+ 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
92
  }
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)
66
- 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
- };
93
+ let bindings;
94
+ try {
95
+ bindings = deriveBindings(loop, sequenceId, cwd);
96
+ }
97
+ catch (error) {
98
+ throw new Error(`impl-bind validation failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
90
99
  }
91
100
  if (input.dryRun) {
92
101
  return {
93
102
  loop_id,
94
103
  sequence_id: sequenceId,
95
104
  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)`,
105
+ dispatch: null,
106
+ messages_sent: 0,
107
+ warnings: compatibilityWarnings(input),
108
+ reason: `dry run: linked sequence ${sequenceId} is valid; loop stays in 'bind' and no worker is dispatched`,
109
+ lanes: Object.entries(bindings).map(([slot_id, binding]) => ({ slot_id, lane: binding.lane, scope_hint: binding.scope_hint })),
99
110
  };
100
111
  }
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
112
  const advanced = withLoopLock({
109
113
  cwd,
110
114
  intent: 'impl-bind-advance',
@@ -112,12 +116,15 @@ export async function runImplBind(input, cwd) {
112
116
  scope: { kind: 'loop', loopId: loop_id },
113
117
  work: () => {
114
118
  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
119
  if (!fresh || fresh.status !== 'open' || fresh.current_phase !== 'bind')
118
120
  return null;
119
- const a = advance({ id: loop_id, actor: dispatcherAgent }, cwd);
120
- return { phase: a.loop.current_phase, auto_closed: a.auto_closed };
121
+ const freshSequenceId = fresh.linked?.sequence_ids?.[0];
122
+ if (freshSequenceId !== sequenceId) {
123
+ throw new Error(`linked sequence ${sequenceId} changed or disappeared before bind could advance`);
124
+ }
125
+ const freshBindings = deriveBindings(fresh, sequenceId, cwd);
126
+ const result = advance({ id: loop_id, actor: dispatcherAgent, slot_bindings: freshBindings }, cwd);
127
+ return { phase: result.loop.current_phase, auto_closed: result.auto_closed };
121
128
  },
122
129
  });
123
130
  if (!advanced) {
@@ -125,9 +132,10 @@ export async function runImplBind(input, cwd) {
125
132
  loop_id,
126
133
  sequence_id: sequenceId,
127
134
  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)`,
135
+ dispatch: null,
136
+ messages_sent: 0,
137
+ warnings: compatibilityWarnings(input),
138
+ reason: `phase already advanced out of 'bind' under a concurrent bind (idempotent - not re-advanced)`,
131
139
  };
132
140
  }
133
141
  return {
@@ -136,9 +144,11 @@ export async function runImplBind(input, cwd) {
136
144
  action: 'bound',
137
145
  advanced_to: advanced.phase,
138
146
  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}`,
147
+ dispatch: null,
148
+ messages_sent: 0,
149
+ warnings: compatibilityWarnings(input),
150
+ reason: `validated linked sequence ${sequenceId}; advanced bind -> ${advanced.phase}; dispatch worker slots with turn(dispatch=true)`,
151
+ lanes: Object.entries(bindings).map(([slot_id, binding]) => ({ slot_id, lane: binding.lane, scope_hint: binding.scope_hint })),
142
152
  };
143
153
  }
144
154
  //# sourceMappingURL=impl-bind.js.map
@@ -1,4 +1,10 @@
1
1
  export * from './types.js';
2
+ export * from './attempt-generations.js';
3
+ export * from './attempt-rollout.js';
4
+ export * from './attempt-takeover.js';
5
+ export { GATE_POLICY_VERSION, artifactEvidenceProvenance, artifactEvidenceDigest, evidenceDigest, evidencePolicyForNewLoop, evidenceWriterEnabled, validateArtifactEvidence, validateThreadEvidence, } from './evidence.js';
6
+ export { GATE_POLICIES, eligibleArtifactsForPurpose, evaluateCommandGreen, evaluateCriticSignal, evaluateGateCondition, evaluateNoNewCritique, } from './gate-policy.js';
7
+ export { EXECUTION_CONTRACT_PROTOCOL_VERSION, EXECUTION_CONTRACT_VERSION, CapabilityRequirementSchema, CapabilityResolutionReasonSchema, CapabilitySnapshotSchema, HarnessCapabilityBindingSchema, ExecutionContractRefSchema, ExecutionContractSchema, RuntimeCapabilityObservationSchema, assertExecutionContractIntegrity, capabilitySnapshotHash, canonicalExecutionContract, executionContractHash, executionContractRef, resolveCapabilitySnapshot, validateWorkerContractAcceptance, } from '../execution-contract.js';
2
8
  export { AwaitingFileApplyApprovalError, closeLoop, ensureLoopsDir, generateLoopId, generateMutationId, generateSlotId, getLoop, listLoopEvents, listLoops, openLoop, writeThreadFile, } from './store.js';
3
9
  export { add_artifact, advance, complete_turn, evaluatePhaseAdvanceGate, evaluateStopCondition, pause, provideInput, reconcileOpenQuestions, requestInput, resume, sweepPauseTimeouts, turn, } from './verbs.js';
4
10
  export { decideNextPhase, artifactsInIteration, noNewCritiqueInIteration, hasCriticSignalInIteration, hasPassingVerifyReportInIteration, } from './iteration-engine.js';
@@ -12,4 +18,7 @@ export { buildSurveySignalsBaseline, } from './hooks/survey-signals-baseline.js'
12
18
  export { acquireLock, hashRequest, recordConflict, withLoopLock, DEFAULT_MAX_MUTATION_DURATION_MS, IDEMPOTENCY_TTL_MS, LEASE_GRACE_MS, LEASE_WINDOW_MS, IdempotencyKeyReusedError, IdempotencyOwnerMismatchError, LockLostError, LockTimeoutError, VersionConflictError, } from './lock.js';
13
19
  export { acquireBootstrapLoop, findExistingBootstrapLoop, BootstrapCoordinationInProgressError, } from './bootstrap-acquire.js';
14
20
  export { deriveWorkerReplyContract, renderWorkerReplyProse, workerReplyNextAction, } from './worker-reply-contract.js';
21
+ export { abortAttempt, inspectAttempt, matchEvidence, prepareAttempt, projectAndCross, revokeAttempt, } from './attempt-authority.js';
22
+ export { LOOP_KIND_POLICIES, assertLoopKindPoliciesComplete, isWorkerPhase, phasePolicy, policyForKind, } from './kind-policies.js';
23
+ export { ensureTurnExecutionProjections, prepareTurnExecution, } from './turn-execution.js';
15
24
  //# sourceMappingURL=index.js.map
@@ -12,6 +12,7 @@
12
12
  * transitions = exit_when conditions + cycle membership, guards = phase
13
13
  * advance_gate, actions = system event emissions.
14
14
  */
15
+ import { evaluateCommandGreen, evaluateCriticSignal, evaluateNoNewCritique, } from './gate-policy.js';
15
16
  /**
16
17
  * Decide the next phase given the current thread state and the protocol.
17
18
  *
@@ -19,7 +20,7 @@
19
20
  * is no successor (last phase + no iteration block). Callers that want
20
21
  * to handle "already at end" should check beforehand.
21
22
  */
22
- export function decideNextPhase(thread, protocol) {
23
+ export function decideNextPhase(thread, protocol, cwd) {
23
24
  const phaseNames = protocol.phases.map((p) => p.name);
24
25
  const currentIndex = phaseNames.indexOf(thread.current_phase);
25
26
  if (currentIndex < 0) {
@@ -44,6 +45,26 @@ export function decideNextPhase(thread, protocol) {
44
45
  // exit by max_iterations.
45
46
  const cycleIndex = cycle.indexOf(thread.current_phase);
46
47
  const atCycleEnd = cycleIndex === cycle.length - 1;
48
+ // A saturation exit is a negative observation: after at least one full
49
+ // cycle, a settled critique phase with no eligible new critique exits
50
+ // directly. Waiting until the revision boundary made this condition
51
+ // unreachable because the critique phase's quantitative gate refused the
52
+ // very zero-artifact round the exit condition needs to observe.
53
+ if (cycleIndex === 0 &&
54
+ thread.iteration_count > 0 &&
55
+ protocol.iteration?.exit_when === 'no_new_critique_artifacts' &&
56
+ noNewCritiqueInIteration(thread, thread.iteration_count, cwd)) {
57
+ const lastCyclePhaseIndex = phaseNames.indexOf(cycle[cycle.length - 1]);
58
+ if (lastCyclePhaseIndex < 0 || lastCyclePhaseIndex + 1 >= phaseNames.length) {
59
+ throw new Error(`decideNextPhase: cycle's last phase "${cycle[cycle.length - 1]}" has no post-cycle successor`);
60
+ }
61
+ return {
62
+ kind: 'exit_cycle',
63
+ target: phaseNames[lastCyclePhaseIndex + 1],
64
+ iteration: thread.iteration_count,
65
+ reason: 'no_new_critique_artifacts',
66
+ };
67
+ }
47
68
  if (!atCycleEnd) {
48
69
  return {
49
70
  kind: 'advance_to',
@@ -75,7 +96,7 @@ export function decideNextPhase(thread, protocol) {
75
96
  // iteration_count is the one that just completed (the engine has not
76
97
  // yet incremented).
77
98
  if (iterationBlock.exit_when === 'critic_signal' &&
78
- hasCriticSignalInIteration(thread, thread.iteration_count)) {
99
+ hasCriticSignalInIteration(thread, thread.iteration_count, cwd)) {
79
100
  return {
80
101
  kind: 'exit_cycle',
81
102
  target: postCycleTarget,
@@ -84,7 +105,7 @@ export function decideNextPhase(thread, protocol) {
84
105
  };
85
106
  }
86
107
  if (iterationBlock.exit_when === 'no_new_critique_artifacts' &&
87
- noNewCritiqueInIteration(thread, thread.iteration_count)) {
108
+ noNewCritiqueInIteration(thread, thread.iteration_count, cwd)) {
88
109
  return {
89
110
  kind: 'exit_cycle',
90
111
  target: postCycleTarget,
@@ -93,7 +114,7 @@ export function decideNextPhase(thread, protocol) {
93
114
  };
94
115
  }
95
116
  if (iterationBlock.exit_when === 'command_green' &&
96
- hasPassingVerifyReportInIteration(thread, thread.iteration_count)) {
117
+ hasPassingVerifyReportInIteration(thread, thread.iteration_count, cwd)) {
97
118
  return {
98
119
  kind: 'exit_cycle',
99
120
  target: postCycleTarget,
@@ -134,16 +155,16 @@ export function artifactsInIteration(thread, iteration) {
134
155
  * just-completed iteration produced no critique-typed artifacts. Used
135
156
  * by `decideNextPhase` at the cycle boundary.
136
157
  */
137
- export function noNewCritiqueInIteration(thread, iteration) {
138
- return !thread.artifacts.some((a) => (a.iteration ?? 0) === iteration && a.type === 'critique');
158
+ export function noNewCritiqueInIteration(thread, iteration, cwd) {
159
+ return evaluateNoNewCritique(thread, iteration, cwd).passed;
139
160
  }
140
161
  /**
141
162
  * `exit_when='critic_signal'` predicate: true when the iteration
142
163
  * contains a `type='critic_signal'` artifact (any subtype/body). The
143
164
  * critic emits this when it judges the proposal sufficient.
144
165
  */
145
- export function hasCriticSignalInIteration(thread, iteration) {
146
- return thread.artifacts.some((a) => (a.iteration ?? 0) === iteration && a.type === 'critic_signal');
166
+ export function hasCriticSignalInIteration(thread, iteration, cwd) {
167
+ return evaluateCriticSignal(thread, iteration, cwd).passed;
147
168
  }
148
169
  /**
149
170
  * `exit_when='command_green'` predicate (pln#609): true when the just-finished
@@ -153,16 +174,7 @@ export function hasCriticSignalInIteration(thread, iteration) {
153
174
  * Increment 2). Iteration-window scoped so a green from a prior iteration can
154
175
  * never satisfy the current one; absence reads as false.
155
176
  */
156
- export function hasPassingVerifyReportInIteration(thread, iteration) {
157
- return artifactsInIteration(thread, iteration).some((a) => {
158
- if (a.type !== 'verify_report')
159
- return false;
160
- try {
161
- return JSON.parse(a.body ?? '{}').passed === true;
162
- }
163
- catch {
164
- return false;
165
- }
166
- });
177
+ export function hasPassingVerifyReportInIteration(thread, iteration, cwd) {
178
+ return evaluateCommandGreen(thread, iteration, cwd).passed;
167
179
  }
168
180
  //# sourceMappingURL=iteration-engine.js.map
@@ -0,0 +1,90 @@
1
+ import { DEFAULT_PROTOCOLS, LOOP_KINDS } from './types.js';
2
+ const expected = (logicalName, loopArtifactType = logicalName) => ({
3
+ logical_name: logicalName,
4
+ worker_path: 'LANE-RESULT.json',
5
+ loop_artifact_type: loopArtifactType,
6
+ completion_policy: 'required',
7
+ });
8
+ /**
9
+ * Execution metadata only. DEFAULT_PROTOCOLS remains canonical for phase
10
+ * graphs, advance gates, iteration and stop conditions.
11
+ */
12
+ export const LOOP_KIND_POLICIES = {
13
+ review: {
14
+ phases: {
15
+ change_summary: { execution: 'manual' },
16
+ findings: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('review_verdict', 'verdict')], finalization: 'report' },
17
+ author_response: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('author_response')], finalization: 'integrate' },
18
+ followup_review: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('review_verdict', 'verdict')], finalization: 'report' },
19
+ verdict: { execution: 'engine' },
20
+ },
21
+ },
22
+ ideation: {
23
+ phases: {
24
+ proposal: { execution: 'manual' },
25
+ critique: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('critique')], finalization: 'report' },
26
+ // Critic and champion work use the same generic production driver. The
27
+ // coordinator still chooses when to cross each phase; the worker only
28
+ // produces the declared artifact and never advances the loop.
29
+ revision: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('revision')], finalization: 'report' },
30
+ synthesis: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('plan_draft')], finalization: 'report' },
31
+ },
32
+ },
33
+ implementation: {
34
+ phases: {
35
+ bind: { execution: 'engine' },
36
+ execute: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('execute_report')], finalization: 'integrate' },
37
+ verify: { execution: 'engine' },
38
+ handoff_ready: { execution: 'manual' },
39
+ },
40
+ },
41
+ research: {
42
+ phases: {
43
+ investigate: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('finding')], finalization: 'report' },
44
+ synthesize: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('synthesis')], finalization: 'report' },
45
+ conclude: { execution: 'engine' },
46
+ },
47
+ },
48
+ debug: {
49
+ phases: {
50
+ reproduce: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('repro')], finalization: 'report' },
51
+ hypothesize: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('hypothesis')], finalization: 'report' },
52
+ isolate: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('isolation_report')], finalization: 'report' },
53
+ fix: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('verify_report')], finalization: 'integrate' },
54
+ handoff: { execution: 'manual' },
55
+ },
56
+ },
57
+ };
58
+ export function policyForKind(kind) {
59
+ return LOOP_KIND_POLICIES[kind];
60
+ }
61
+ export function phasePolicy(kind, phase) {
62
+ return LOOP_KIND_POLICIES[kind].phases[phase];
63
+ }
64
+ export function isWorkerPhase(kind, phase) {
65
+ return phasePolicy(kind, phase)?.execution === 'worker';
66
+ }
67
+ /** Runtime assertion used by conformance tests and startup diagnostics. */
68
+ export function assertLoopKindPoliciesComplete() {
69
+ for (const kind of LOOP_KINDS) {
70
+ const policy = LOOP_KIND_POLICIES[kind];
71
+ if (!policy)
72
+ throw new Error(`missing LoopKindPolicy for ${kind}`);
73
+ const protocolPhases = DEFAULT_PROTOCOLS[kind].phases.map((phase) => phase.name).sort();
74
+ const policyPhases = Object.keys(policy.phases).sort();
75
+ if (JSON.stringify(protocolPhases) !== JSON.stringify(policyPhases)) {
76
+ throw new Error(`${kind} policy phases diverge from DEFAULT_PROTOCOLS: policy=${policyPhases.join(',')} protocol=${protocolPhases.join(',')}`);
77
+ }
78
+ for (const [phase, phaseExecution] of Object.entries(policy.phases)) {
79
+ if (phaseExecution.execution === 'worker') {
80
+ if (!phaseExecution.completion_mode || !phaseExecution.expected_artifacts?.length || !phaseExecution.finalization) {
81
+ throw new Error(`${kind}.${phase} worker policy is incomplete`);
82
+ }
83
+ }
84
+ else if (phaseExecution.completion_mode || phaseExecution.expected_artifacts?.length || phaseExecution.finalization) {
85
+ throw new Error(`${kind}.${phase} ${phaseExecution.execution} policy must not declare worker execution metadata`);
86
+ }
87
+ }
88
+ }
89
+ }
90
+ //# sourceMappingURL=kind-policies.js.map
@@ -9,9 +9,11 @@ import { recoverPendingIntents } from './commit-intent.js';
9
9
  * Per-loop exclusive lock + idempotency + fencing helpers.
10
10
  *
11
11
  * Implements the commit protocol from docs/concepts/loop-engine.md §Persistence.
12
- * For synchronous MVP mutations, the lock window is short (< 100ms typically) so
13
- * lease renewal via an internal heartbeat is not yet wired up; the hard_deadline
14
- * is still recorded in the lock blob and used by the stale-lock recovery rules.
12
+ * For synchronous mutations, the lock window is short (< 100ms typically), so
13
+ * lease renewal via an internal heartbeat is not wired up. `lease_until` and
14
+ * `hard_deadline` remain durable diagnostics, but elapsed time alone never
15
+ * authorizes automatic takeover: without per-write fencing, a suspended live
16
+ * process could otherwise resume after takeover and commit as a second owner.
15
17
  */
16
18
  export const LOCK_BACKOFF_BASE_MS = 10;
17
19
  export const LOCK_BACKOFF_TOTAL_MS = 500;
@@ -179,14 +181,70 @@ function processIsAlive(pid) {
179
181
  return code === 'EPERM';
180
182
  }
181
183
  }
182
- function lockIsStale(blob, now) {
183
- if (now > Date.parse(blob.hard_deadline))
184
- return true;
185
- if (blob.host_id === os.hostname() && !processIsAlive(blob.pid))
186
- return true;
187
- if (now > Date.parse(blob.lease_until) + LEASE_GRACE_MS)
188
- return true;
189
- return false;
184
+ function lockIsStale(blob, _now) {
185
+ // Automatic reaping is deliberately proof-based. On the local host we can
186
+ // prove the owning process is gone. A deadline or lease expiry only proves
187
+ // that a process was delayed; on Windows a suspended live process can resume.
188
+ // Cross-host liveness is likewise unknowable from this file alone, so those
189
+ // locks fail closed and require explicit operator recovery.
190
+ return blob.host_id === os.hostname() && !processIsAlive(blob.pid);
191
+ }
192
+ function takeoverClaimPath(lockPath, observedMutationId) {
193
+ // Lock blobs are read from disk and may predate schema validation. Hash the
194
+ // token before using it as a filename so a corrupt value cannot escape the
195
+ // takeover directory or create a Windows-invalid path.
196
+ const generationKey = crypto.createHash('sha256').update(observedMutationId).digest('hex');
197
+ return path.join(`${lockPath}.takeovers`, `${generationKey}.lock`);
198
+ }
199
+ /**
200
+ * Remove exactly the stale generation that was observed.
201
+ *
202
+ * A plain `read stale -> unlink(path)` has an ABA race on Windows: another
203
+ * reaper can replace generation X with Y between the read and unlink, after
204
+ * which the late reaper deletes Y. The immutable, generation-keyed takeover
205
+ * claim elects one reaper for X. Losers never unlink the shared path.
206
+ *
207
+ * If the elected reaper crashes before unlinking, the generation fails closed
208
+ * (the tiny takeover claim is retained for operator recovery). If it crashes
209
+ * after unlinking, contenders can safely acquire the now-empty main path.
210
+ */
211
+ function reapObservedLock(lockPath, observed, agentId) {
212
+ const nowMs = Date.now();
213
+ const claimPath = takeoverClaimPath(lockPath, observed.mutation_id);
214
+ const claim = {
215
+ pid: process.pid,
216
+ host_id: os.hostname(),
217
+ agent_id: agentId,
218
+ acquired_at: new Date(nowMs).toISOString(),
219
+ lease_until: new Date(nowMs + LEASE_WINDOW_MS).toISOString(),
220
+ hard_deadline: new Date(nowMs + 5_000).toISOString(),
221
+ mutation_id: crypto.randomUUID().replace(/-/g, ''),
222
+ };
223
+ if (!acquireRaw(claimPath, claim))
224
+ return false;
225
+ let removed = false;
226
+ try {
227
+ const current = readLockBlob(lockPath);
228
+ if (current
229
+ && current.mutation_id === observed.mutation_id
230
+ && lockIsStale(current, Date.now())) {
231
+ fs.unlinkSync(lockPath);
232
+ removed = true;
233
+ }
234
+ return removed;
235
+ }
236
+ finally {
237
+ // Delete only our own claim. A crash before this point intentionally leaves
238
+ // a fail-closed marker instead of guessing that a different process is dead.
239
+ try {
240
+ const currentClaim = readLockBlob(claimPath);
241
+ if (currentClaim?.mutation_id === claim.mutation_id)
242
+ fs.unlinkSync(claimPath);
243
+ }
244
+ catch {
245
+ /* best-effort; a retained claim fails closed for this stale generation */
246
+ }
247
+ }
190
248
  }
191
249
  function acquireRaw(lockPath, blob) {
192
250
  ensureDirFor(lockPath);
@@ -221,10 +279,10 @@ export function acquireLock(options) {
221
279
  if (existing) {
222
280
  if (lockIsStale(existing, Date.now())) {
223
281
  try {
224
- fs.unlinkSync(options.lockPath);
282
+ reapObservedLock(options.lockPath, existing, options.agentId);
225
283
  }
226
284
  catch {
227
- /* race with another reaper; retry */
285
+ /* another reaper won or I/O failed; exclusive acquire below retries */
228
286
  }
229
287
  }
230
288
  }