brainclaw 1.26.1 → 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.
- package/README.md +41 -12
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-coordination.js +65 -1
- package/dist/commands/attempt-authority.js +80 -0
- package/dist/commands/harvest.js +140 -61
- package/dist/commands/loop.js +34 -0
- package/dist/commands/loops-handlers.js +87 -14
- package/dist/commands/mcp-catalog.js +42 -18
- package/dist/commands/mcp-schemas.generated.js +44 -0
- package/dist/commands/mcp-write-claims.js +128 -1
- package/dist/commands/mcp-write-coordination.js +146 -76
- package/dist/core/agent-capability.js +1 -1
- package/dist/core/agent-files.js +21 -21
- package/dist/core/agentrun-reconciler.js +148 -22
- package/dist/core/agentruns.js +254 -29
- package/dist/core/assignment-request-schema.js +7 -0
- package/dist/core/assignment-sweeper.js +5 -3
- package/dist/core/assignments.js +131 -33
- package/dist/core/claim-request-schema.js +7 -0
- package/dist/core/claims.js +53 -2
- package/dist/core/dispatch-status.js +16 -6
- package/dist/core/dispatcher.js +51 -51
- package/dist/core/entity-operations.js +20 -0
- package/dist/core/events.js +4 -0
- package/dist/core/execution-adapters.js +160 -14
- package/dist/core/execution-contract.js +345 -0
- package/dist/core/execution.js +130 -16
- package/dist/core/harness-adapters/base.js +150 -0
- package/dist/core/harness-adapters/claude.js +39 -0
- package/dist/core/harness-adapters/codex.js +57 -0
- package/dist/core/harness-adapters/harvest.js +109 -0
- package/dist/core/harness-adapters/index.js +8 -0
- package/dist/core/harness-adapters/prompt-only.js +13 -0
- package/dist/core/harness-adapters/registry.js +48 -0
- package/dist/core/harness-adapters/result.js +33 -0
- package/dist/core/harness-adapters/types.js +2 -0
- package/dist/core/ideation-loop-close.js +25 -2
- package/dist/core/instruction-templates.js +3 -2
- package/dist/core/loop-turn-dispatch.js +207 -0
- package/dist/core/loops/artifact-contract.js +11 -0
- package/dist/core/loops/attempt-authority.js +476 -0
- package/dist/core/loops/attempt-generations.js +509 -0
- package/dist/core/loops/attempt-reservation.js +197 -35
- package/dist/core/loops/attempt-rollout.js +404 -0
- package/dist/core/loops/attempt-takeover.js +155 -0
- package/dist/core/loops/bootstrap-acquire.js +7 -3
- package/dist/core/loops/evidence.js +187 -0
- package/dist/core/loops/facade-schema.js +41 -10
- package/dist/core/loops/gate-policy.js +485 -0
- package/dist/core/loops/impl-bind.js +37 -79
- package/dist/core/loops/index.js +9 -0
- package/dist/core/loops/iteration-engine.js +31 -19
- package/dist/core/loops/kind-policies.js +90 -0
- package/dist/core/loops/lock.js +71 -13
- package/dist/core/loops/reconcile-turn.js +235 -18
- package/dist/core/loops/result-reducers.js +99 -10
- package/dist/core/loops/store.js +30 -3
- package/dist/core/loops/turn-execution.js +480 -0
- package/dist/core/loops/types.js +113 -2
- package/dist/core/loops/verbs.js +332 -99
- package/dist/core/loops/verify-command.js +31 -8
- package/dist/core/loops/workspace-digest.js +54 -0
- package/dist/core/protocol-tool-policy.js +44 -0
- package/dist/core/review-loop-close.js +25 -3
- package/dist/core/review-loop-turn-dispatch.js +210 -161
- package/dist/core/runtime-signals.js +62 -25
- package/dist/core/schema.js +35 -0
- package/dist/core/spawn-check.js +3 -2
- package/dist/core/upgrades/backup.js +27 -4
- package/dist/facts.js +9 -8
- package/dist/facts.json +8 -7
- package/docs/PROTOCOL.md +6 -4
- package/docs/cli.md +49 -1
- package/docs/concepts/attempt-authority.md +407 -0
- package/docs/concepts/evidence-attestations.md +135 -0
- package/docs/concepts/execution-contract.md +166 -0
- package/docs/concepts/harness-adapters.md +166 -0
- package/docs/concepts/ideation-loop.md +5 -4
- package/docs/concepts/loop-engine.md +348 -133
- package/docs/index.md +4 -1
- package/docs/integrations/codex.md +3 -3
- package/docs/integrations/hermes.md +42 -3
- package/docs/integrations/mcp.md +75 -9
- package/docs/loops/debug.md +144 -0
- package/docs/loops/ideation.md +158 -0
- package/docs/loops/implementation.md +154 -0
- package/docs/loops/research.md +136 -0
- package/docs/loops/review.md +200 -0
- package/docs/mcp-schema-changelog.md +14 -5
- package/docs/product/agent-first-model.md +33 -33
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,7 +46,7 @@ It sits alongside your coding agents and gives them a shared state layer they ca
|
|
|
46
46
|
| **Agent-ready context** | compact, prompt-sized context built from real workspace state instead of stale instructions |
|
|
47
47
|
| **Code Map** | a Tree-sitter symbol + import index (11 languages — JS/TS, Python, PHP, Java, Go, Rust, C#, Ruby, C, C++) so agents ask "where is X / what should I read first" before editing, with related decisions/traps attached — `bclaw_code_find` / `bclaw_code_brief`, see [code map](docs/code-map.md) |
|
|
48
48
|
| **Native agent files** | auto-writes `CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `.cursor/rules/`, `.windsurfrules`, and similar local guidance |
|
|
49
|
-
| **Multi-turn loops** | review and
|
|
49
|
+
| **Multi-turn loops** | review, ideation, implementation, research, and debug workflows with structured phases, iteration semantics, verification gates, and per-phase memory filters — see [loop engine](docs/concepts/loop-engine.md) |
|
|
50
50
|
| **Machine AI surface discovery** | detects local coding agents plus desktop AI work surfaces such as ChatGPT Desktop and Gemini CLI |
|
|
51
51
|
| **Queued surface tasks** | stores project-scoped requests for other local AI surfaces, such as visual generation, drafting, summaries, or research |
|
|
52
52
|
| **Local-first storage** | plain text + JSON, Git-friendly, no mandatory cloud, no telemetry by default |
|
|
@@ -263,14 +263,42 @@ bclaw_loop({ intent: "get", loop_id: "lop_abc" }); // inspect status any time
|
|
|
263
263
|
|
|
264
264
|
## The Loop Engine (Multi-Turn Workflows)
|
|
265
265
|
|
|
266
|
-
Brainclaw's Loop Engine
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
266
|
+
Brainclaw's Loop Engine formalizes repeated multi-turn work so agents can
|
|
267
|
+
resume, automate, and audit it rather than relying on manual ping-pong. It is
|
|
268
|
+
one engine with five shipped default workflows: **review, ideation,
|
|
269
|
+
implementation, research, and debug**.
|
|
270
|
+
|
|
271
|
+
| Workflow | Typical outcome | Normal entry point |
|
|
272
|
+
| --- | --- | --- |
|
|
273
|
+
| Review | accepted verdict or bounded fix cycle | `bclaw_coordinate(intent="review", open_loop=true)` |
|
|
274
|
+
| Ideation | memory-confronted plan draft or synthesis | `bclaw_coordinate(intent="ideate")` |
|
|
275
|
+
| Implementation | green verification and handoff | `bclaw_loop(intent="open", kind="implementation", allow_orphan=true)`, then `bind` |
|
|
276
|
+
| Research | evidence-backed synthesis | `bclaw_loop(intent="open", kind="research", allow_orphan=true)` |
|
|
277
|
+
| Debug | reproduced, verified fix and handoff | `bclaw_loop(intent="open", kind="debug", allow_orphan=true)` |
|
|
278
|
+
|
|
279
|
+
Every loop has structured phases, bounded iteration, explicit artifacts, and
|
|
280
|
+
per-phase memory filters. The shared controls are `open`, `turn`,
|
|
281
|
+
`complete_turn`, `advance`, `add_artifact`, `pause`, `resume`, and `close`;
|
|
282
|
+
implementation also adds `bind` and `verify`. `request_input` /
|
|
283
|
+
`provide_input` are cross-cutting clarification primitives for any workflow.
|
|
284
|
+
|
|
285
|
+
Every worker-backed phase, across all five workflows, is launched from one
|
|
286
|
+
immutable [execution contract](docs/concepts/execution-contract.md): exact
|
|
287
|
+
identity, artifact expectations, workspace policy, capability snapshot and
|
|
288
|
+
evidence rules are persisted before the launch fence crosses. This reuses the
|
|
289
|
+
existing TurnReservation, Assignment and AgentRun records; it does not add a
|
|
290
|
+
second event journal.
|
|
291
|
+
|
|
292
|
+
Gate-driving artifacts are also bound to a server-sealed
|
|
293
|
+
[evidence envelope](docs/concepts/evidence-attestations.md). Review approval,
|
|
294
|
+
command verification, claim binding, and ordinary observations are separate
|
|
295
|
+
attestations rather than one confidence score; the same policy mechanism
|
|
296
|
+
protects every workflow above.
|
|
297
|
+
|
|
298
|
+
Review is a useful specialized path, not the definition of the engine. It has
|
|
299
|
+
asymmetric and symmetric modes and can auto-close on an approved verdict; the
|
|
300
|
+
other workflows use the same lifecycle to converge on a plan, synthesis,
|
|
301
|
+
handoff, or verified fix. See the [Loop Engine guide](docs/concepts/loop-engine.md).
|
|
274
302
|
|
|
275
303
|
## Enterprise Ready: Mono-repo & Micro-services
|
|
276
304
|
|
|
@@ -310,7 +338,7 @@ Recent releases have moved a lot of multi-agent parallel work from "risky" to "s
|
|
|
310
338
|
|
|
311
339
|
- **Per-claim auto-worktree** — each dispatched lane gets its own isolated git worktree; the coordinator integrates with an octopus merge.
|
|
312
340
|
- **Sequenced parallel execute** — `bclaw_dispatch(intent="execute")` fans out independent lanes across several agent instances and integrates the result.
|
|
313
|
-
- **
|
|
341
|
+
- **Loop Engine protocols** — review, ideation, implementation, research, and debug workflows share a persisted lifecycle. Review offers a symmetric auto-fix shortcut; implementation and debug bind verification to the work; ideation and research converge on durable syntheses.
|
|
314
342
|
- **Cross-platform spawn** — OS-aware prompt delivery (stdin pipe / inline arg) plus a brief-ack file handshake, so spawned workers can be detected and timed out reliably on Windows and Unix.
|
|
315
343
|
- **Worktree GC is scope-bounded** — symlinks and junctions are no longer followed during cleanup, so post-merge sweeps can't wipe `node_modules` or other neighboring directories.
|
|
316
344
|
- **MCP runtime self-heal** — when the runtime is corrupted, the server logs an actionable repair pointer; `brainclaw doctor --repair` rebuilds dist in one step.
|
|
@@ -327,8 +355,9 @@ Recommended use today:
|
|
|
327
355
|
|
|
328
356
|
1. for parallel work, dispatch a sequence with `bclaw_dispatch(intent="execute")` — each lane gets its own worktree
|
|
329
357
|
2. for sequential work in the same project, let one agent claim at a time and rely on handoffs
|
|
330
|
-
3.
|
|
331
|
-
4.
|
|
358
|
+
3. choose the loop by outcome: ideation for a plan, implementation or debug for a verified handoff, research for a synthesis, and review for a verdict
|
|
359
|
+
4. when reviewing or fixing across agents, prefer symmetric review loops over manual ping-pong
|
|
360
|
+
5. keep multi-machine workflows on a single source of truth until federation lands
|
|
332
361
|
|
|
333
362
|
---
|
|
334
363
|
|
|
Binary file
|
|
@@ -388,7 +388,7 @@ export function registerCoordinationCommands(program) {
|
|
|
388
388
|
// --- loop (drive loop turn verbs; pln#517 step 2) ---
|
|
389
389
|
const loopCmd = program
|
|
390
390
|
.command('loop')
|
|
391
|
-
.description('Drive
|
|
391
|
+
.description('Drive loop turns, fenced takeovers, phase advances, and artifacts');
|
|
392
392
|
loopCmd
|
|
393
393
|
.command('turn <loop_id>')
|
|
394
394
|
.description('Issue a turn assignment on a slot')
|
|
@@ -407,6 +407,13 @@ export function registerCoordinationCommands(program) {
|
|
|
407
407
|
.description('Complete a slot turn')
|
|
408
408
|
.requiredOption('--slot <slot_id>', 'Target slot id (lsl_...)')
|
|
409
409
|
.requiredOption('--outcome <outcome>', 'Turn outcome: done, failed, or cancelled')
|
|
410
|
+
.option('--assignment-id <id>', 'Attempt fence: stable assignment id')
|
|
411
|
+
.option('--turn-id <id>', 'Attempt fence: stable logical turn id')
|
|
412
|
+
.option('--run-id <id>', 'Attempt fence: physical run id')
|
|
413
|
+
.option('--nonce <value>', 'Attempt fence: generation launch nonce')
|
|
414
|
+
.option('--attempt-epoch <n>', 'Attempt fence: non-negative generation epoch')
|
|
415
|
+
.option('--execution-contract-hash <sha256>', 'Attempt fence: execution contract hash')
|
|
416
|
+
.option('--workspace-digest <sha256>', 'Attempt fence: canonical workspace digest')
|
|
410
417
|
.option('--failure-reason <text>', 'Reason when outcome is failed')
|
|
411
418
|
.option('--artifact <json>', 'JSON object payload for an artifact to attach')
|
|
412
419
|
.option('--json', 'Machine-readable output')
|
|
@@ -415,6 +422,24 @@ export function registerCoordinationCommands(program) {
|
|
|
415
422
|
const { runLoopCommand } = await import('../commands/loop.js');
|
|
416
423
|
await runLoopCommand('complete-turn', { loop_id }, options, globalOpts.cwd);
|
|
417
424
|
});
|
|
425
|
+
loopCmd
|
|
426
|
+
.command('takeover <loop_id>')
|
|
427
|
+
.description('Fence the active physical run and arm a fresh generation')
|
|
428
|
+
.requiredOption('--slot <slot_id>', 'Target slot id (lsl_...)')
|
|
429
|
+
.requiredOption('--turn-id <turn_id>', 'Stable logical turn id')
|
|
430
|
+
.requiredOption('--expected-epoch <n>', 'Currently active generation epoch')
|
|
431
|
+
.requiredOption('--cause <text>', 'Audited takeover cause')
|
|
432
|
+
.requiredOption('--liveness-evidence <text>', 'Evidence that the prior producer cannot safely continue')
|
|
433
|
+
.requiredOption('--external-effect-policy <policy>', 'none, idempotent, or externally_fenced')
|
|
434
|
+
.requiredOption('--next-workspace-path <path>', 'Existing isolated workspace for the new generation')
|
|
435
|
+
.requiredOption('--agent <agent>', 'Loop coordinator identity')
|
|
436
|
+
.option('--mode <mode>', 'takeover or retry', 'takeover')
|
|
437
|
+
.option('--json', 'Machine-readable output')
|
|
438
|
+
.action(async (loop_id, options) => {
|
|
439
|
+
const globalOpts = program.opts();
|
|
440
|
+
const { runLoopCommand } = await import('../commands/loop.js');
|
|
441
|
+
await runLoopCommand('takeover', { loop_id }, options, globalOpts.cwd);
|
|
442
|
+
});
|
|
418
443
|
loopCmd
|
|
419
444
|
.command('advance <loop_id>')
|
|
420
445
|
.description('Advance a loop to its next phase')
|
|
@@ -441,6 +466,45 @@ export function registerCoordinationCommands(program) {
|
|
|
441
466
|
const { runLoopCommand } = await import('../commands/loop.js');
|
|
442
467
|
await runLoopCommand('add-artifact', { loop_id }, options, globalOpts.cwd);
|
|
443
468
|
});
|
|
469
|
+
// --- attempt-authority (two-release writer guard; P4) ---
|
|
470
|
+
const attemptAuthorityCmd = program
|
|
471
|
+
.command('attempt-authority')
|
|
472
|
+
.description('Prepare, acknowledge and activate AttemptAuthority v2 writer compatibility');
|
|
473
|
+
attemptAuthorityCmd
|
|
474
|
+
.command('status')
|
|
475
|
+
.option('--json', 'Machine-readable output')
|
|
476
|
+
.action((options) => {
|
|
477
|
+
const globalOpts = program.opts();
|
|
478
|
+
return import('../commands/attempt-authority.js').then(({ runAttemptAuthorityCommand }) => runAttemptAuthorityCommand('status', { ...options, cwd: globalOpts.cwd }));
|
|
479
|
+
});
|
|
480
|
+
attemptAuthorityCmd
|
|
481
|
+
.command('prepare')
|
|
482
|
+
.requiredOption('--writers <agent_ids...>', 'Complete Release-A writer membership (registered agent ids)')
|
|
483
|
+
.option('--membership-epoch <n>', 'Membership epoch; defaults to active+1')
|
|
484
|
+
.option('--prepared-by <actor>', 'Audited operator/coordinator identity', 'operator')
|
|
485
|
+
.option('--json', 'Machine-readable output')
|
|
486
|
+
.action((options) => {
|
|
487
|
+
const globalOpts = program.opts();
|
|
488
|
+
return import('../commands/attempt-authority.js').then(({ runAttemptAuthorityCommand }) => runAttemptAuthorityCommand('prepare', { ...options, cwd: globalOpts.cwd }));
|
|
489
|
+
});
|
|
490
|
+
attemptAuthorityCmd
|
|
491
|
+
.command('ack')
|
|
492
|
+
.requiredOption('--membership-epoch <n>', 'Prepared membership epoch')
|
|
493
|
+
.requiredOption('--agent-id <agent_id>', 'Writer signing this ACK')
|
|
494
|
+
.option('--json', 'Machine-readable output')
|
|
495
|
+
.action((options) => {
|
|
496
|
+
const globalOpts = program.opts();
|
|
497
|
+
return import('../commands/attempt-authority.js').then(({ runAttemptAuthorityCommand }) => runAttemptAuthorityCommand('ack', { ...options, cwd: globalOpts.cwd }));
|
|
498
|
+
});
|
|
499
|
+
attemptAuthorityCmd
|
|
500
|
+
.command('activate')
|
|
501
|
+
.requiredOption('--membership-epoch <n>', 'Fully acknowledged membership epoch')
|
|
502
|
+
.option('--activated-by <actor>', 'Audited operator/coordinator identity', 'operator')
|
|
503
|
+
.option('--json', 'Machine-readable output')
|
|
504
|
+
.action((options) => {
|
|
505
|
+
const globalOpts = program.opts();
|
|
506
|
+
return import('../commands/attempt-authority.js').then(({ runAttemptAuthorityCommand }) => runAttemptAuthorityCommand('activate', { ...options, cwd: globalOpts.cwd }));
|
|
507
|
+
});
|
|
444
508
|
// --- reply (provide_input to an operator_question; pln#508 step 4) ---
|
|
445
509
|
program
|
|
446
510
|
.command('reply <qst_id>')
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { loadAgentIdentity, loadAgentSigningKey } from '../core/agent-registry.js';
|
|
2
|
+
import { loadConnectionState } from '../core/federation-state.js';
|
|
3
|
+
import { ATTEMPT_AUTHORITY_WRITER_VERSION, activateAttemptAuthorityV2, attemptRolloutActivationDigest, ensureLocalAuthorityHome, prepareAttemptAuthorityRollout, publishAttemptRolloutAck, readLocalAuthorityHome, resolveActiveAttemptRollout, } from '../core/loops/attempt-rollout.js';
|
|
4
|
+
function epoch(value, fallback = 1) {
|
|
5
|
+
const parsed = value === undefined ? fallback : Number(value);
|
|
6
|
+
if (!Number.isInteger(parsed) || parsed <= 0)
|
|
7
|
+
throw new Error('membership epoch must be a positive integer');
|
|
8
|
+
return parsed;
|
|
9
|
+
}
|
|
10
|
+
function print(value, json) {
|
|
11
|
+
if (json)
|
|
12
|
+
console.log(JSON.stringify(value, null, 2));
|
|
13
|
+
else
|
|
14
|
+
console.log(typeof value === 'string' ? value : JSON.stringify(value, null, 2));
|
|
15
|
+
}
|
|
16
|
+
export function runAttemptAuthorityCommand(subcommand, options = {}) {
|
|
17
|
+
const cwd = options.cwd ?? process.cwd();
|
|
18
|
+
if (subcommand === 'status') {
|
|
19
|
+
const active = resolveActiveAttemptRollout(cwd);
|
|
20
|
+
print({
|
|
21
|
+
writer_version: ATTEMPT_AUTHORITY_WRITER_VERSION,
|
|
22
|
+
local_authority_home: readLocalAuthorityHome(cwd),
|
|
23
|
+
active,
|
|
24
|
+
}, options.json);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (subcommand === 'prepare') {
|
|
28
|
+
const writerIds = options.writers ?? [];
|
|
29
|
+
if (writerIds.length === 0)
|
|
30
|
+
throw new Error('prepare requires at least one --writers <agent_id>');
|
|
31
|
+
const participants = writerIds.map((writerId) => {
|
|
32
|
+
const identity = loadAgentIdentity(writerId, cwd);
|
|
33
|
+
if (!identity.identity_key) {
|
|
34
|
+
throw new Error(`agent ${writerId} has no Ed25519 identity key; re-register it before preparing rollout`);
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
writer_id: writerId,
|
|
38
|
+
public_key_pem: identity.identity_key.public_key,
|
|
39
|
+
key_fingerprint: identity.identity_key.fingerprint,
|
|
40
|
+
status: 'active',
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
const active = resolveActiveAttemptRollout(cwd);
|
|
44
|
+
const membershipEpoch = epoch(options.membershipEpoch, (active?.guard.membership_epoch ?? 0) + 1);
|
|
45
|
+
const connection = loadConnectionState(cwd);
|
|
46
|
+
const home = ensureLocalAuthorityHome(cwd, { device_id: connection?.device.device_id });
|
|
47
|
+
const guard = prepareAttemptAuthorityRollout(cwd, {
|
|
48
|
+
membership_epoch: membershipEpoch,
|
|
49
|
+
authority_home: home,
|
|
50
|
+
participants,
|
|
51
|
+
previous_activation_digest: active ? attemptRolloutActivationDigest(active.activation) : undefined,
|
|
52
|
+
prepared_by: options.preparedBy ?? 'operator',
|
|
53
|
+
});
|
|
54
|
+
print(guard, options.json);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (subcommand === 'ack') {
|
|
58
|
+
const writerId = options.agentId;
|
|
59
|
+
if (!writerId)
|
|
60
|
+
throw new Error('ack requires --agent-id <agent_id>');
|
|
61
|
+
const signing = loadAgentSigningKey(writerId);
|
|
62
|
+
if (!signing)
|
|
63
|
+
throw new Error(`no local Ed25519 signing key for ${writerId}`);
|
|
64
|
+
const ack = publishAttemptRolloutAck(cwd, {
|
|
65
|
+
membership_epoch: epoch(options.membershipEpoch),
|
|
66
|
+
writer_id: writerId,
|
|
67
|
+
writer_version: ATTEMPT_AUTHORITY_WRITER_VERSION,
|
|
68
|
+
private_key_pem: signing.privateKeyPem,
|
|
69
|
+
});
|
|
70
|
+
print(ack, options.json);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (subcommand === 'activate') {
|
|
74
|
+
const activation = activateAttemptAuthorityV2(cwd, epoch(options.membershipEpoch), options.activatedBy ?? 'operator');
|
|
75
|
+
print(activation, options.json);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
throw new Error(`unknown attempt-authority subcommand: ${subcommand}`);
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=attempt-authority.js.map
|
package/dist/commands/harvest.js
CHANGED
|
@@ -24,54 +24,33 @@ import { getCapabilityProfile, dispatchCanCommit } from '../core/agent-capabilit
|
|
|
24
24
|
import { commitWorktreeOnBehalf, worktreesBaseDir, resolveGitToplevel } from '../core/worktree.js';
|
|
25
25
|
import { closeReviewLoopFromLaneResult } from '../core/review-loop-close.js';
|
|
26
26
|
import { closeIdeationLoopFromLaneResult } from '../core/ideation-loop-close.js';
|
|
27
|
-
import { dispatchReviewLoopTurn,
|
|
27
|
+
import { dispatchReviewLoopTurn, turnOwnedLoopEnabled } from '../core/review-loop-turn-dispatch.js';
|
|
28
28
|
import { reconcileTurn } from '../core/loops/reconcile-turn.js';
|
|
29
29
|
import { findReservationByAssignmentId } from '../core/loops/attempt-reservation.js';
|
|
30
|
+
import { resolveTurnGenerationChain } from '../core/loops/attempt-generations.js';
|
|
30
31
|
import { getLoop } from '../core/loops/store.js';
|
|
32
|
+
import { phasePolicy } from '../core/loops/kind-policies.js';
|
|
31
33
|
import { readCompletionSignals } from '../core/runtime-signals.js';
|
|
32
34
|
import { reconcileClaimConformity } from '../core/claim-conformity.js';
|
|
33
35
|
import { toWarningDetail } from '../core/warnings.js';
|
|
34
|
-
|
|
35
|
-
* pln#630 PR3a — finalize a TURN-OWNED review lane via the exactly-once `reconcileTurn`
|
|
36
|
-
* instead of the legacy `closeReviewLoopFromLaneResult`. Returns `undefined` for a legacy
|
|
37
|
-
* (non-reserved) lane so the caller runs the unchanged legacy path — this is the
|
|
38
|
-
* exactly-one-finalizer discriminator: a lane is turn-owned iff a reservation OWNS its
|
|
39
|
-
* assignment_id (only the turn-owned dispatch writes a reservation file).
|
|
40
|
-
*
|
|
41
|
-
* Evidence sourcing (the load-bearing subtlety): a real reviewer's LANE-RESULT.json is
|
|
42
|
-
* KEYLESS — the review brief never asks the worker to echo turn_id/run_id/nonce — so
|
|
43
|
-
* read-strict `reconcileTurn` (which matches lane.{turn_id,run_id,nonce} against the
|
|
44
|
-
* attempt) would REJECT it. We source the keys authoritatively: turn_id + run_id are
|
|
45
|
-
* deterministic from the reservation, and the NONCE — the non-derivable proof that THIS
|
|
46
|
-
* launch generation actually ran — comes from the coordinator's completion SENTINEL
|
|
47
|
-
* (written mechanically by the ack-wrapper with the launch-grant token). A caller/test
|
|
48
|
-
* that already supplies keyed lanes is honored (lane.* wins); a stale generation's
|
|
49
|
-
* sentinel carries the old token → still rejected, preserving the anti-stale guarantee.
|
|
50
|
-
*/
|
|
51
|
-
/**
|
|
52
|
-
* The turn-owned FINALIZATION discriminator (pln#630, review Finding 1). A lane finalizes via
|
|
53
|
-
* the exactly-once reconcileTurn ONLY if a committed reservation OWNS it AND turn-keyed evidence
|
|
54
|
-
* (the nonce) is available — from the lane or the coordinator's completion SENTINEL. Without the
|
|
55
|
-
* nonce, reconcileTurn's read-strict gate can NEVER converge: this is reachable in production
|
|
56
|
-
* when a turn-owned dispatch WON the fence but did not ack-wrap-spawn (inbox_only / IDE-only
|
|
57
|
-
* reviewer, command_ready_manual, capacity cap, BRAINCLAW_NO_SPAWN, worktree-creation failure) —
|
|
58
|
-
* it minted a reservation but no sentinel will ever be written. Returning undefined there routes
|
|
59
|
-
* the lane to the LEGACY presence-based closer so the loop still converges instead of stalling
|
|
60
|
-
* forever. This is SAFE: the exactly-once SPAWN guarantee is enforced at DISPATCH by the launch
|
|
61
|
-
* fence (already run), so using legacy FINALIZATION for a sentinel-less lane reintroduces no
|
|
62
|
-
* double-spawn; and a sentinel that lands after a legacy close makes a later reconcile a
|
|
63
|
-
* terminal-loop idempotent no-op.
|
|
64
|
-
*/
|
|
36
|
+
import { harvestHarnessObservation } from '../core/harness-adapters/index.js';
|
|
65
37
|
function turnOwnedLaneEvidence(lane, cwd) {
|
|
66
38
|
const reservation = findReservationByAssignmentId(lane.assignment_id, cwd);
|
|
67
39
|
if (!reservation)
|
|
68
40
|
return undefined; // legacy lane (no reservation)
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
41
|
+
const chain = resolveTurnGenerationChain(cwd, reservation.turn_id);
|
|
42
|
+
const completion = readCompletionSignals(cwd, reservation.child_ids.assignment_id, chain?.latest_generation.run_id).completed;
|
|
43
|
+
const nonce = lane.nonce ?? completion?.nonce;
|
|
44
|
+
if (!nonce && !reservation.execution_contract_ref)
|
|
45
|
+
return undefined;
|
|
46
|
+
return {
|
|
47
|
+
reservation,
|
|
48
|
+
nonce,
|
|
49
|
+
contract_hash: lane.execution_contract_hash ?? completion?.contract_hash,
|
|
50
|
+
capability_snapshot_hash: lane.capability_snapshot_hash ?? completion?.capability_snapshot_hash,
|
|
51
|
+
};
|
|
73
52
|
}
|
|
74
|
-
function
|
|
53
|
+
function reconcileTurnOwnedLane(lane, cwd, evidence) {
|
|
75
54
|
const ev = evidence ?? turnOwnedLaneEvidence(lane, cwd);
|
|
76
55
|
if (!ev)
|
|
77
56
|
return undefined; // legacy lane OR no turn-keyed evidence — caller runs the legacy path
|
|
@@ -81,8 +60,17 @@ function reconcileTurnOwnedReviewLane(lane, cwd, evidence) {
|
|
|
81
60
|
turn_id: lane.turn_id ?? reservation.turn_id,
|
|
82
61
|
run_id: lane.run_id ?? reservation.child_ids.run_id,
|
|
83
62
|
nonce,
|
|
63
|
+
execution_contract_hash: lane.execution_contract_hash ?? ev.contract_hash,
|
|
64
|
+
capability_snapshot_hash: lane.capability_snapshot_hash ?? ev.capability_snapshot_hash,
|
|
84
65
|
};
|
|
85
|
-
const
|
|
66
|
+
const loop = getLoop(reservation.loop_id, cwd);
|
|
67
|
+
const critiques = loop?.kind === 'ideation'
|
|
68
|
+
&& reservation.phase === 'critique'
|
|
69
|
+
&& lane.artifact_type === 'critique'
|
|
70
|
+
&& (lane.body ?? '').trim().length > 0
|
|
71
|
+
? [{ body: lane.body.trim() }]
|
|
72
|
+
: undefined;
|
|
73
|
+
const result = reconcileTurn({ turn_id: reservation.turn_id, lane: enrichedLane, cwd, critiques });
|
|
86
74
|
return { reservation, result };
|
|
87
75
|
}
|
|
88
76
|
/**
|
|
@@ -392,15 +380,31 @@ export function harvestLaneResults(options = {}) {
|
|
|
392
380
|
const worktreePaths = resolveLaneScanPaths(options, cwd);
|
|
393
381
|
for (const worktreePath of worktreePaths) {
|
|
394
382
|
const file = getLaneResultPath(worktreePath);
|
|
395
|
-
|
|
383
|
+
const fileExists = fs.existsSync(file);
|
|
384
|
+
let nativeObservation;
|
|
385
|
+
if (options.assignmentId) {
|
|
386
|
+
try {
|
|
387
|
+
nativeObservation = harvestHarnessObservation(options.assignmentId, cwd, !options.dryRun);
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
result.errors.push(`Failed to harvest native harness output for ${options.assignmentId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (!fileExists && !nativeObservation)
|
|
396
395
|
continue;
|
|
397
396
|
let lane;
|
|
398
|
-
|
|
399
|
-
|
|
397
|
+
if (fileExists) {
|
|
398
|
+
try {
|
|
399
|
+
lane = LaneResultSchema.parse(JSON.parse(fs.readFileSync(file, 'utf-8')));
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
result.errors.push(`Failed to parse ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
400
405
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
continue;
|
|
406
|
+
else {
|
|
407
|
+
lane = nativeObservation.lane;
|
|
404
408
|
}
|
|
405
409
|
// Assignment filter (when harvesting a specific lane).
|
|
406
410
|
if (options.assignmentId && lane.assignment_id !== options.assignmentId)
|
|
@@ -438,14 +442,66 @@ export function harvestLaneResults(options = {}) {
|
|
|
438
442
|
// Kill-switch (=0), a legacy lane (no reservation), OR a reservation WITHOUT evidence
|
|
439
443
|
// (review Finding 1: an inbox_only/non-ack-wrapped dispatch that never wrote a
|
|
440
444
|
// sentinel) → the lane takes the unchanged legacy close so it still converges.
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
|
|
445
|
+
// P0C: the same evidence gate now serves every LoopKind. Review keeps
|
|
446
|
+
// its report-only request_changes deferral because that path cannot
|
|
447
|
+
// re-dispatch a fix cycle; other kinds can safely record their result.
|
|
448
|
+
const candidateEvidence = turnOwnedLaneEvidence(lane, cwd);
|
|
449
|
+
// Set before reading the loop: a corrupt loop store may throw below,
|
|
450
|
+
// and the catch must still know this was a turn-owned lane to warn.
|
|
451
|
+
turnEvidenceForCatch = candidateEvidence;
|
|
452
|
+
const candidateLoop = candidateEvidence ? getLoop(candidateEvidence.reservation.loop_id, cwd) : undefined;
|
|
453
|
+
const laneTurnEvidence = candidateEvidence && candidateLoop && turnOwnedLoopEnabled(candidateLoop.kind)
|
|
454
|
+
? candidateEvidence
|
|
455
|
+
: undefined;
|
|
456
|
+
turnEvidenceForCatch = laneTurnEvidence ?? candidateEvidence;
|
|
457
|
+
const ownedLoop = laneTurnEvidence ? candidateLoop : undefined;
|
|
458
|
+
const ownedPhasePolicy = ownedLoop
|
|
459
|
+
? phasePolicy(ownedLoop.kind, laneTurnEvidence.reservation.phase)
|
|
460
|
+
: undefined;
|
|
444
461
|
if (!laneTurnEvidence) {
|
|
445
462
|
closeReviewLoopFromLaneResult(laneAssignment, lane, agent, cwd, { cycleOnRequestChanges: false });
|
|
446
463
|
}
|
|
464
|
+
else if (ownedPhasePolicy?.finalization === 'integrate') {
|
|
465
|
+
result.warnings.push(toWarningDetail({
|
|
466
|
+
code: 'loop_turn_not_converged',
|
|
467
|
+
message: `Turn-owned ${ownedLoop?.kind ?? 'loop'} lane ${lane.assignment_id} requires harvest --integrate before convergence; claim retained.`,
|
|
468
|
+
data: {
|
|
469
|
+
assignment_id: lane.assignment_id,
|
|
470
|
+
loop_id: laneTurnEvidence.reservation.loop_id,
|
|
471
|
+
turn_id: laneTurnEvidence.reservation.turn_id,
|
|
472
|
+
phase: laneTurnEvidence.reservation.phase,
|
|
473
|
+
},
|
|
474
|
+
}));
|
|
475
|
+
}
|
|
476
|
+
else if (ownedLoop?.kind !== 'review') {
|
|
477
|
+
const rr = reconcileTurnOwnedLane(lane, cwd, laneTurnEvidence);
|
|
478
|
+
if (ownedLoop?.kind === 'ideation'
|
|
479
|
+
&& laneTurnEvidence.reservation.phase === 'critique'
|
|
480
|
+
&& (lane.artifact_type !== 'critique' || !(lane.body ?? '').trim())) {
|
|
481
|
+
result.warnings.push(toWarningDetail({
|
|
482
|
+
code: 'loop_turn_not_converged',
|
|
483
|
+
message: `Ideation critique lane ${lane.assignment_id} did not provide artifact_type='critique' with a non-empty body; no critique artifact was accepted.`,
|
|
484
|
+
data: { assignment_id: lane.assignment_id, loop_id: laneTurnEvidence.reservation.loop_id, turn_id: laneTurnEvidence.reservation.turn_id },
|
|
485
|
+
}));
|
|
486
|
+
}
|
|
487
|
+
if (rr && !rr.result.reconciled && !/superseded/.test(rr.result.reason ?? '')) {
|
|
488
|
+
result.warnings.push(toWarningDetail({
|
|
489
|
+
code: 'loop_turn_not_converged',
|
|
490
|
+
message: `Turn-owned ${ownedLoop?.kind ?? 'loop'} lane ${lane.assignment_id} did not converge: ${rr.result.reason}.`,
|
|
491
|
+
data: { assignment_id: lane.assignment_id, loop_id: laneTurnEvidence.reservation.loop_id, turn_id: laneTurnEvidence.reservation.turn_id },
|
|
492
|
+
}));
|
|
493
|
+
}
|
|
494
|
+
if (ownedLoop?.kind === 'ideation' && rr) {
|
|
495
|
+
ideationLoop = {
|
|
496
|
+
loop_id: laneTurnEvidence.reservation.loop_id,
|
|
497
|
+
action: rr.result.auto_closed ? 'closed' : rr.result.reconciled ? 'advanced' : 'noop',
|
|
498
|
+
reason: rr.result.reason,
|
|
499
|
+
loop_status: rr.result.loop_status,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
}
|
|
447
503
|
else if (lane.review_verdict === 'approve') {
|
|
448
|
-
const rr =
|
|
504
|
+
const rr = reconcileTurnOwnedLane(lane, cwd, laneTurnEvidence);
|
|
449
505
|
// Reason-based quietness (PR #171 review P2-1 refinement): a terminal loop
|
|
450
506
|
// returns reconciled:true (idempotent no-op) and a superseded turn is the one
|
|
451
507
|
// healthy decline (`harvest --all` over a prior round's lane) — everything
|
|
@@ -465,7 +521,9 @@ export function harvestLaneResults(options = {}) {
|
|
|
465
521
|
}
|
|
466
522
|
// pln#521 P2-bis — the ideation analog: a critic lane records its critique +
|
|
467
523
|
// advances the ideation loop. Returns undefined for non-ideate scopes (no-op here).
|
|
468
|
-
|
|
524
|
+
if (!laneTurnEvidence) {
|
|
525
|
+
ideationLoop = closeIdeationLoopFromLaneResult(laneAssignment, lane, agent, cwd);
|
|
526
|
+
}
|
|
469
527
|
// pln#636 C2 (review F3) — the universal net's most important trigger.
|
|
470
528
|
// A file-fallback worker declares its own footprint in `files_changed`,
|
|
471
529
|
// which is BOTH cheaper and more reliable than a git diff here: by
|
|
@@ -522,6 +580,8 @@ export function harvestLaneResults(options = {}) {
|
|
|
522
580
|
ideation_loop: ideationLoop ?? null,
|
|
523
581
|
files_changed: lane.files_changed ?? [],
|
|
524
582
|
source_worktree: worktreePath,
|
|
583
|
+
harness_stdout_log: nativeObservation?.stdout_log ?? null,
|
|
584
|
+
harness_stderr_log: nativeObservation?.stderr_log ?? null,
|
|
525
585
|
},
|
|
526
586
|
}, cwd);
|
|
527
587
|
fs.mkdirSync(path.dirname(marker), { recursive: true });
|
|
@@ -684,27 +744,46 @@ export function integrateLaneResults(options = {}) {
|
|
|
684
744
|
// next_turn) unless the iteration cap is hit. This is the --integrate
|
|
685
745
|
// path, so it MAY cycle (it can re-dispatch AND retain the claim). No-op
|
|
686
746
|
// for non-review lanes / lanes without a verdict; never throws.
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
const
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
747
|
+
// Legacy ideation lanes still use the historical closer. A turn-owned
|
|
748
|
+
// lane of any kind is finalized exactly once by reconcileTurn below.
|
|
749
|
+
const candidateEvidence = turnOwnedLaneEvidence(lane, cwd);
|
|
750
|
+
const ownedLoop = candidateEvidence ? getLoop(candidateEvidence.reservation.loop_id, cwd) : undefined;
|
|
751
|
+
const turnOwnedEvidence = candidateEvidence && ownedLoop && turnOwnedLoopEnabled(ownedLoop.kind)
|
|
752
|
+
? candidateEvidence
|
|
753
|
+
: undefined;
|
|
754
|
+
if (!turnOwnedEvidence) {
|
|
755
|
+
const ideationClose = closeIdeationLoopFromLaneResult(assignment, lane, actor, cwd);
|
|
756
|
+
if (ideationClose) {
|
|
757
|
+
reasons.push(`ideate-loop ${ideationClose.loop_id}: ${ideationClose.action} — ${ideationClose.reason}`);
|
|
758
|
+
entry.ideation_loop = ideationClose;
|
|
759
|
+
}
|
|
695
760
|
}
|
|
696
761
|
// pln#630 PR3a — a TURN-OWNED review lane finalizes via the exactly-once
|
|
697
762
|
// reconcileTurn, which REPLACES the legacy closer + teardown gate for this lane
|
|
698
763
|
// (exactly-one finalizer per lane). Kill-switch (=0), a legacy (non-reserved) lane, OR a
|
|
699
764
|
// reservation with NO turn-keyed evidence (review Finding 1) → `turnOwned` is undefined
|
|
700
765
|
// and the unchanged legacy `else` block runs so the loop still converges.
|
|
701
|
-
const turnOwned =
|
|
702
|
-
?
|
|
766
|
+
const turnOwned = turnOwnedEvidence
|
|
767
|
+
? reconcileTurnOwnedLane(lane, cwd, turnOwnedEvidence)
|
|
703
768
|
: undefined;
|
|
704
769
|
if (turnOwned) {
|
|
705
770
|
const { reservation, result: rr } = turnOwned;
|
|
706
|
-
|
|
707
|
-
|
|
771
|
+
if (ownedLoop?.kind === 'review') {
|
|
772
|
+
entry.review_loop = reconcileToReviewLoopResult(reservation, rr, lane);
|
|
773
|
+
reasons.push(`turn-owned reconcile ${reservation.loop_id}: ${entry.review_loop.action} — ${rr.reason}${rr.conflict ? ' [CONFLICT — held]' : ''}`);
|
|
774
|
+
}
|
|
775
|
+
else {
|
|
776
|
+
const action = rr.auto_closed ? 'closed' : rr.reconciled ? 'advanced' : 'noop';
|
|
777
|
+
reasons.push(`turn-owned ${ownedLoop?.kind ?? 'loop'} reconcile ${reservation.loop_id}: ${action} — ${rr.reason}${rr.conflict ? ' [CONFLICT — held]' : ''}`);
|
|
778
|
+
if (ownedLoop?.kind === 'ideation') {
|
|
779
|
+
entry.ideation_loop = {
|
|
780
|
+
loop_id: reservation.loop_id,
|
|
781
|
+
action,
|
|
782
|
+
reason: rr.reason,
|
|
783
|
+
loop_status: rr.loop_status,
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
}
|
|
708
787
|
// pln#630 PR3b — a symmetric request_changes bumped the round + retained the claim
|
|
709
788
|
// and handed back the next fix-cycle turn. Push it exactly like the legacy path so
|
|
710
789
|
// the existing async re-dispatch loop spawns round N+1 into the reused worktree. The
|
package/dist/commands/loop.js
CHANGED
|
@@ -60,6 +60,14 @@ function parseOutcome(opts) {
|
|
|
60
60
|
}
|
|
61
61
|
return outcome;
|
|
62
62
|
}
|
|
63
|
+
function parseOptionalEpoch(value, opts) {
|
|
64
|
+
if (value === undefined)
|
|
65
|
+
return undefined;
|
|
66
|
+
const epoch = Number(value);
|
|
67
|
+
if (!Number.isInteger(epoch) || epoch < 0)
|
|
68
|
+
fail('--attempt-epoch must be a non-negative integer', 1, opts);
|
|
69
|
+
return epoch;
|
|
70
|
+
}
|
|
63
71
|
function formatNextExpected(hint) {
|
|
64
72
|
if (!hint)
|
|
65
73
|
return ' (loop has no further expected action)';
|
|
@@ -95,11 +103,37 @@ function buildRequest(subcommand, loopId, opts) {
|
|
|
95
103
|
intent: 'complete_turn',
|
|
96
104
|
loop_id: loopId,
|
|
97
105
|
slot_id: requireOption(opts.slot, '--slot <slot_id>', opts),
|
|
106
|
+
assignment_id: opts.assignmentId,
|
|
107
|
+
turn_id: opts.turnId,
|
|
108
|
+
run_id: opts.runId,
|
|
109
|
+
nonce: opts.nonce,
|
|
110
|
+
attempt_epoch: parseOptionalEpoch(opts.attemptEpoch, opts),
|
|
111
|
+
execution_contract_hash: opts.executionContractHash,
|
|
112
|
+
workspace_digest: opts.workspaceDigest,
|
|
98
113
|
outcome: parseOutcome(opts),
|
|
99
114
|
failure_reason: opts.failureReason,
|
|
100
115
|
artifact,
|
|
101
116
|
};
|
|
102
117
|
}
|
|
118
|
+
case 'takeover': {
|
|
119
|
+
const epoch = Number(opts.expectedEpoch);
|
|
120
|
+
if (!Number.isInteger(epoch) || epoch < 0)
|
|
121
|
+
fail('--expected-epoch must be a non-negative integer', 1, opts);
|
|
122
|
+
return {
|
|
123
|
+
intent: 'takeover',
|
|
124
|
+
loop_id: loopId,
|
|
125
|
+
slot_id: requireOption(opts.slot, '--slot <slot_id>', opts),
|
|
126
|
+
turn_id: requireOption(opts.turnId, '--turn-id <turn_id>', opts),
|
|
127
|
+
expected_epoch: epoch,
|
|
128
|
+
cause: requireOption(opts.cause, '--cause <text>', opts),
|
|
129
|
+
liveness_evidence: requireOption(opts.livenessEvidence, '--liveness-evidence <text>', opts),
|
|
130
|
+
external_effect_policy: requireOption(opts.externalEffectPolicy, '--external-effect-policy <policy>', opts),
|
|
131
|
+
next_workspace_path: requireOption(opts.nextWorkspacePath, '--next-workspace-path <path>', opts),
|
|
132
|
+
takeover_mode: opts.mode,
|
|
133
|
+
agent: opts.agent,
|
|
134
|
+
agentId: opts.agent,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
103
137
|
case 'advance':
|
|
104
138
|
return {
|
|
105
139
|
intent: 'advance',
|