brainclaw 1.16.0 → 1.18.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 +22 -8
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-review.js +2 -2
- package/dist/commands/code-map.js +4 -1
- package/dist/commands/codev.js +61 -30
- package/dist/commands/doctor.js +14 -1
- package/dist/commands/harvest.js +241 -25
- package/dist/commands/inbox.js +10 -4
- package/dist/commands/loop.js +2 -2
- package/dist/commands/loops-handlers.js +82 -1
- package/dist/commands/mcp-catalog.js +12 -4
- package/dist/commands/mcp-read-handlers.js +90 -7
- package/dist/commands/mcp-schemas.generated.js +3 -0
- package/dist/commands/mcp-write-coordination.js +159 -40
- package/dist/commands/mcp.js +11 -2
- package/dist/core/agent-capability.js +7 -2
- package/dist/core/agent-files.js +53 -2
- package/dist/core/agent-integrations.js +1 -0
- package/dist/core/agentrun-reconciler.js +171 -7
- package/dist/core/agentruns.js +6 -1
- package/dist/core/code-map/aggregate.js +473 -0
- package/dist/core/code-map/backend.js +36 -10
- package/dist/core/code-map/freshness.js +36 -1
- package/dist/core/code-map/lang/c/imports.scm +12 -0
- package/dist/core/code-map/lang/c/index.js +150 -0
- package/dist/core/code-map/lang/c/tags.scm +68 -0
- package/dist/core/code-map/lang/cpp/imports.scm +14 -0
- package/dist/core/code-map/lang/cpp/index.js +149 -0
- package/dist/core/code-map/lang/cpp/tags.scm +87 -0
- package/dist/core/code-map/lang/csharp/imports.scm +20 -0
- package/dist/core/code-map/lang/csharp/index.js +224 -0
- package/dist/core/code-map/lang/csharp/tags.scm +63 -0
- package/dist/core/code-map/lang/go/imports.scm +13 -0
- package/dist/core/code-map/lang/go/index.js +139 -0
- package/dist/core/code-map/lang/go/tags.scm +36 -0
- package/dist/core/code-map/lang/providers.js +12 -1
- package/dist/core/code-map/lang/ruby/imports.scm +24 -0
- package/dist/core/code-map/lang/ruby/index.js +198 -0
- package/dist/core/code-map/lang/ruby/tags.scm +49 -0
- package/dist/core/code-map/lang/rust/imports.scm +44 -0
- package/dist/core/code-map/lang/rust/index.js +136 -0
- package/dist/core/code-map/lang/rust/tags.scm +47 -0
- package/dist/core/code-map/query.js +229 -80
- package/dist/core/code-map/types.js +18 -0
- package/dist/core/code-map/work-section.js +8 -7
- package/dist/core/codev-responses.js +16 -0
- package/dist/core/dispatcher.js +209 -29
- package/dist/core/execution-adapters.js +29 -3
- package/dist/core/ideation-loop-close.js +124 -0
- package/dist/core/loops/artifact-resolver.js +197 -0
- package/dist/core/loops/attempt-reservation.js +576 -0
- package/dist/core/loops/commit-intent.js +494 -0
- package/dist/core/loops/facade-schema.js +48 -0
- package/dist/core/loops/impl-bind.js +144 -0
- package/dist/core/loops/index.js +1 -1
- package/dist/core/loops/iteration-engine.js +29 -0
- package/dist/core/loops/lock.js +14 -0
- package/dist/core/loops/project-resolution.js +157 -0
- package/dist/core/loops/reconcile-turn.js +369 -0
- package/dist/core/loops/result-reducers.js +88 -0
- package/dist/core/loops/store.js +46 -7
- package/dist/core/loops/types.js +139 -11
- package/dist/core/loops/verbs.js +9 -3
- package/dist/core/loops/verify-command.js +209 -0
- package/dist/core/messaging.js +58 -5
- package/dist/core/review-loop-close.js +106 -34
- package/dist/core/review-loop-turn-dispatch.js +445 -0
- package/dist/core/runtime-signals.js +68 -0
- package/dist/core/schema.js +34 -0
- package/dist/core/worktree.js +240 -22
- package/dist/facts.js +10 -10
- package/dist/facts.json +9 -9
- package/dist/wasm/tree-sitter-c.wasm +0 -0
- package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
- package/dist/wasm/tree-sitter-cpp.wasm +0 -0
- package/dist/wasm/tree-sitter-go.wasm +0 -0
- package/dist/wasm/tree-sitter-ruby.wasm +0 -0
- package/dist/wasm/tree-sitter-rust.wasm +0 -0
- package/docs/cli.md +1 -1
- package/docs/code-map.md +22 -6
- package/docs/concepts/loop-engine.md +28 -2
- package/docs/concepts/observer-protocol.md +22 -0
- package/docs/integrations/codex.md +19 -3
- package/docs/mcp-schema-changelog.md +43 -1
- package/package.json +1 -1
package/dist/core/loops/types.js
CHANGED
|
@@ -103,11 +103,28 @@ export const LoopPhaseSchema = z.object({
|
|
|
103
103
|
export const LoopIterationSchema = z.object({
|
|
104
104
|
cycle: z.array(z.string().min(1)).min(1),
|
|
105
105
|
max_iterations: z.number().int().positive(),
|
|
106
|
-
exit_when: z.enum(['critic_signal', 'no_new_critique_artifacts']),
|
|
106
|
+
exit_when: z.enum(['critic_signal', 'no_new_critique_artifacts', 'command_green']),
|
|
107
|
+
});
|
|
108
|
+
/** pln#632 — hard ceiling + default for an engine-run verify command's wall clock. */
|
|
109
|
+
export const VERIFY_TIMEOUT_HARD_CAP_MS = 15 * 60 * 1000;
|
|
110
|
+
export const VERIFY_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
111
|
+
/**
|
|
112
|
+
* pln#632 — engine-run verify command config, set by the loop OPENER at open (never by
|
|
113
|
+
* the agent under test — that is the determinism guarantee). The command is an argv
|
|
114
|
+
* ARRAY run with `shell:false` (no injection surface); an operator who needs a pipeline
|
|
115
|
+
* passes it explicitly as `['bash','-lc','npm test && npm run lint']` and owns that.
|
|
116
|
+
* When present, `bclaw_loop(intent='verify')` runs it and records a deterministic
|
|
117
|
+
* `verify_report`; absent, the loop falls back to the agent-narrated report unchanged.
|
|
118
|
+
*/
|
|
119
|
+
export const LoopVerifyConfigSchema = z.object({
|
|
120
|
+
command: z.array(z.string().min(1)).min(1),
|
|
121
|
+
timeout_ms: z.number().int().positive().max(VERIFY_TIMEOUT_HARD_CAP_MS).optional(),
|
|
107
122
|
});
|
|
108
123
|
export const LoopProtocolConfigSchema = z.object({
|
|
109
124
|
review_mode: z.enum(REVIEW_MODES).optional(),
|
|
110
125
|
iteration: LoopIterationSchema.optional(),
|
|
126
|
+
/** pln#632 — engine-run verify command (opener-provided; makes command_green real). */
|
|
127
|
+
verify: LoopVerifyConfigSchema.optional(),
|
|
111
128
|
/**
|
|
112
129
|
* pln#508 step 1 — protocol preset selector. When set (e.g. `'bootstrap'`),
|
|
113
130
|
* the coordinate facade routes preset-specific behaviors (close hook,
|
|
@@ -139,6 +156,13 @@ export const LoopSlotSchema = z.object({
|
|
|
139
156
|
claim_id: z.string().optional(),
|
|
140
157
|
phase: z.string().optional(),
|
|
141
158
|
status: z.enum(SLOT_STATUSES),
|
|
159
|
+
/**
|
|
160
|
+
* pln#630 PR2b-a (§13 R1) — pointer to the immutable turn-attempt record for
|
|
161
|
+
* the slot's CURRENT dispatch. Identity/evidence live on the attempt
|
|
162
|
+
* (`.brainclaw/loops/reservations/<turn_id>.json`), never overwritten on the
|
|
163
|
+
* reusable slot. Additive; wired onto the dispatch path in a later PR.
|
|
164
|
+
*/
|
|
165
|
+
current_turn_id: z.string().optional(),
|
|
142
166
|
});
|
|
143
167
|
// ───────────────────────────────────────────────────────────────────────
|
|
144
168
|
// pln#508 step 1 — bootstrap loop foundation: operator-interaction schemas
|
|
@@ -305,9 +329,29 @@ export const REF_BASED_ARTIFACT_TYPES = new Set([
|
|
|
305
329
|
* is enforced. This preserves backward compatibility with proposal / critique
|
|
306
330
|
* / revision / plan_draft / change_summary artifacts produced before pln#508.
|
|
307
331
|
*/
|
|
332
|
+
/**
|
|
333
|
+
* pln#609 — implementation loop `verify_report` artifact body. Records the
|
|
334
|
+
* outcome of a verify command (tests/build/lint) for one execute↔verify
|
|
335
|
+
* iteration. `passed` is the single field the `command_green` iteration exit
|
|
336
|
+
* reads. Kept small (tails only) to fit LOOP_ARTIFACT_BODY_MAX_BYTES. In
|
|
337
|
+
* Increment 1 the report is produced by the verify turn (add_artifact); the
|
|
338
|
+
* engine-run execution seam (spawnSync, out of the loop lock) is Increment 2.
|
|
339
|
+
*/
|
|
340
|
+
export const VerifyReportBodySchema = z.object({
|
|
341
|
+
command: z.string().min(1),
|
|
342
|
+
exit_code: z.number().int().nullable(),
|
|
343
|
+
passed: z.boolean(),
|
|
344
|
+
duration_ms: z.number().int().nonnegative().optional(),
|
|
345
|
+
cwd: z.string().optional(),
|
|
346
|
+
timed_out: z.boolean().optional(),
|
|
347
|
+
stdout_tail: z.string().max(1024).optional(),
|
|
348
|
+
stderr_tail: z.string().max(1024).optional(),
|
|
349
|
+
});
|
|
308
350
|
export const KNOWN_ARTIFACT_BODY_SCHEMAS = {
|
|
309
351
|
// inline JSON body: body = JSON.stringify({ ...fields per OperatorQuestionBodySchema })
|
|
310
352
|
operator_question: OperatorQuestionBodySchema,
|
|
353
|
+
// inline JSON body: body = JSON.stringify({ ...fields per VerifyReportBodySchema })
|
|
354
|
+
verify_report: VerifyReportBodySchema,
|
|
311
355
|
// inline JSON body: body = JSON.stringify({ ...fields per OperatorAnswerBodySchema })
|
|
312
356
|
operator_answer: OperatorAnswerBodySchema,
|
|
313
357
|
// ref-based: body = JSON.stringify({ ref, byte_count, sha256 })
|
|
@@ -560,6 +604,17 @@ export const LoopEventSchema = z.discriminatedUnion('kind', [
|
|
|
560
604
|
input: z.string().optional(),
|
|
561
605
|
retry_of: z.string().optional(),
|
|
562
606
|
}),
|
|
607
|
+
// pln#630 PR2b-a (§13 R1/Q1) — journal breadcrumb that an immutable
|
|
608
|
+
// turn-attempt record was reserved for this dispatch. The authoritative
|
|
609
|
+
// record lives in `loops/reservations/<turn_id>.json`; this event lets a
|
|
610
|
+
// thread reader see the attempt existed without scanning that dir.
|
|
611
|
+
z.object({
|
|
612
|
+
...LoopEventBaseShape,
|
|
613
|
+
kind: z.literal('turn_reserved'),
|
|
614
|
+
slot_id: z.string().min(1),
|
|
615
|
+
phase: z.string().min(1),
|
|
616
|
+
turn_id: z.string().min(1),
|
|
617
|
+
}),
|
|
563
618
|
z.object({
|
|
564
619
|
...LoopEventBaseShape,
|
|
565
620
|
kind: z.literal('turn_completed'),
|
|
@@ -726,23 +781,96 @@ export const DEFAULT_PROTOCOLS = {
|
|
|
726
781
|
},
|
|
727
782
|
stop_condition: { kind: 'artifact_produced', phase: 'synthesis', type: 'plan_draft' },
|
|
728
783
|
},
|
|
784
|
+
// pln#609 — implementation loop v2. The loop ADDS to the dispatch pipeline
|
|
785
|
+
// what it lacked: a deterministic command_green gate + a bounded fix↔verify
|
|
786
|
+
// cycle + per-phase context sculpting. `bind` is an ENGINE action (bind
|
|
787
|
+
// plan+sequence and dispatch) not narration; execute↔verify iterates until
|
|
788
|
+
// the verify command is green (a passing verify_report this iteration) or
|
|
789
|
+
// the cycle cap is hit (→ handoff_ready with the red report → blocked).
|
|
729
790
|
implementation: {
|
|
730
791
|
phases: [
|
|
731
|
-
{ name: '
|
|
732
|
-
{ name: '
|
|
733
|
-
{
|
|
734
|
-
|
|
735
|
-
|
|
792
|
+
{ name: 'bind', context_filter: ['plans', 'decisions', 'constraints', 'project_vision'] },
|
|
793
|
+
{ name: 'execute', context_filter: ['decisions', 'constraints', 'traps', 'runtime_notes'] },
|
|
794
|
+
{
|
|
795
|
+
name: 'verify',
|
|
796
|
+
context_filter: ['traps', 'runtime_notes'],
|
|
797
|
+
// Cannot leave verify without having produced a verify_report THIS
|
|
798
|
+
// iteration — guards "narrated verify, didn't run it". Reuses the
|
|
799
|
+
// iteration-aware min_artifacts_by_type evaluator.
|
|
800
|
+
advance_gate: { kind: 'min_artifacts_by_type', type: 'verify_report', n: 1, scope: 'phase' },
|
|
801
|
+
},
|
|
802
|
+
{ name: 'handoff_ready', context_filter: ['handoffs', 'plans'] },
|
|
736
803
|
],
|
|
737
|
-
|
|
804
|
+
// execute ↔ verify bounded cycle; exit early when a passing verify_report
|
|
805
|
+
// exists in the current iteration (command_green).
|
|
806
|
+
iteration: {
|
|
807
|
+
cycle: ['execute', 'verify'],
|
|
808
|
+
max_iterations: 3,
|
|
809
|
+
exit_when: 'command_green',
|
|
810
|
+
},
|
|
811
|
+
// Mirrors review: handoff within budget → completed; cap exhausted without
|
|
812
|
+
// green → blocked (stopHitsMaxIterations).
|
|
813
|
+
stop_condition: {
|
|
814
|
+
kind: 'any',
|
|
815
|
+
conditions: [
|
|
816
|
+
{ kind: 'artifact_produced', phase: 'handoff_ready', type: 'handoff' },
|
|
817
|
+
{ kind: 'max_iterations', n: 3 },
|
|
818
|
+
],
|
|
819
|
+
},
|
|
738
820
|
},
|
|
821
|
+
// pln#628 PART 3 — research loop, ideation-shaped: investigate ↔ synthesize
|
|
822
|
+
// converges on a deliverable. NO max_iterations in the stop → research ALWAYS
|
|
823
|
+
// lands in `conclude` and completes with a synthesis (there is no "blocked"
|
|
824
|
+
// research outcome). exit_when=critic_signal: `synthesize` emits it when the
|
|
825
|
+
// question is judged answered (explicit sufficiency beats saturation-by-absence
|
|
826
|
+
// for open-ended research). Reuses existing machinery only.
|
|
739
827
|
research: {
|
|
740
|
-
phases: [
|
|
741
|
-
|
|
828
|
+
phases: [
|
|
829
|
+
{
|
|
830
|
+
name: 'investigate',
|
|
831
|
+
context_filter: ['plans', 'decisions', 'constraints', 'project_vision', 'candidates', 'runtime_notes', 'traps'],
|
|
832
|
+
// Don't synthesize an empty round: ≥1 finding gathered THIS iteration
|
|
833
|
+
// (iteration-aware phase scope) before advancing.
|
|
834
|
+
advance_gate: { kind: 'min_artifacts_by_type', type: 'finding', n: 1, scope: 'phase' },
|
|
835
|
+
},
|
|
836
|
+
{ name: 'synthesize', context_filter: ['*'] },
|
|
837
|
+
{ name: 'conclude', context_filter: ['*'] },
|
|
838
|
+
],
|
|
839
|
+
iteration: { cycle: ['investigate', 'synthesize'], max_iterations: 3, exit_when: 'critic_signal' },
|
|
840
|
+
stop_condition: { kind: 'artifact_produced', phase: 'conclude', type: 'synthesis' },
|
|
742
841
|
},
|
|
842
|
+
// pln#628 PART 3 — debug loop, implementation-shaped: "bug fixed" ⟺ "the
|
|
843
|
+
// reproducing command is now green" ⟺ a passing verify_report. hypothesize →
|
|
844
|
+
// isolate → fix repeats until the repro no longer reproduces (command_green)
|
|
845
|
+
// or the cycle cap is hit (→ handoff with the red report → blocked). Reuses
|
|
846
|
+
// command_green + verify_report; no new engine machinery.
|
|
743
847
|
debug: {
|
|
744
|
-
phases: [
|
|
745
|
-
|
|
848
|
+
phases: [
|
|
849
|
+
{
|
|
850
|
+
name: 'reproduce',
|
|
851
|
+
context_filter: ['traps', 'runtime_notes', 'handoffs', 'plans'],
|
|
852
|
+
// Cannot start hypothesizing until a reliable repro exists.
|
|
853
|
+
advance_gate: { kind: 'artifact_produced', phase: 'reproduce', type: 'repro' },
|
|
854
|
+
},
|
|
855
|
+
{ name: 'hypothesize', context_filter: ['decisions', 'constraints', 'traps', 'runtime_notes'] },
|
|
856
|
+
{ name: 'isolate', context_filter: ['decisions', 'constraints', 'traps', 'runtime_notes'] },
|
|
857
|
+
{
|
|
858
|
+
name: 'fix',
|
|
859
|
+
context_filter: ['traps', 'runtime_notes', 'constraints'],
|
|
860
|
+
// Mirrors implementation's verify gate: cannot leave fix without having
|
|
861
|
+
// re-run the repro THIS iteration (iteration-aware phase scope).
|
|
862
|
+
advance_gate: { kind: 'min_artifacts_by_type', type: 'verify_report', n: 1, scope: 'phase' },
|
|
863
|
+
},
|
|
864
|
+
{ name: 'handoff', context_filter: ['handoffs', 'plans'] },
|
|
865
|
+
],
|
|
866
|
+
iteration: { cycle: ['hypothesize', 'isolate', 'fix'], max_iterations: 3, exit_when: 'command_green' },
|
|
867
|
+
stop_condition: {
|
|
868
|
+
kind: 'any',
|
|
869
|
+
conditions: [
|
|
870
|
+
{ kind: 'artifact_produced', phase: 'handoff', type: 'handoff' },
|
|
871
|
+
{ kind: 'max_iterations', n: 3 },
|
|
872
|
+
],
|
|
873
|
+
},
|
|
746
874
|
},
|
|
747
875
|
};
|
|
748
876
|
//# sourceMappingURL=types.js.map
|
package/dist/core/loops/verbs.js
CHANGED
|
@@ -3,6 +3,7 @@ import { nowISO } from '../ids.js';
|
|
|
3
3
|
import { convergeAssignmentToTerminal } from '../assignments.js';
|
|
4
4
|
import { writeProjectMdSafe } from './hooks/bootstrap-write.js';
|
|
5
5
|
import { appendEvent, closeLoop, generateMutationId, getLoop, listLoopEvents, writeThreadFile, } from './store.js';
|
|
6
|
+
import { commitViaIntent } from './commit-intent.js';
|
|
6
7
|
import { LoopArtifactSchema, PAUSE_REASONS, } from './types.js';
|
|
7
8
|
import { decideNextPhase, } from './iteration-engine.js';
|
|
8
9
|
function nextSeq(loopId, cwd) {
|
|
@@ -416,6 +417,7 @@ export function turn(input, cwd) {
|
|
|
416
417
|
phase: current.current_phase,
|
|
417
418
|
assignment_id: input.assignment_id ?? slot.assignment_id,
|
|
418
419
|
claim_id: input.claim_id ?? slot.claim_id,
|
|
420
|
+
current_turn_id: input.turn_id ?? slot.current_turn_id,
|
|
419
421
|
}
|
|
420
422
|
: slot);
|
|
421
423
|
const next = {
|
|
@@ -519,9 +521,13 @@ export function complete_turn(input, cwd) {
|
|
|
519
521
|
outcome,
|
|
520
522
|
failure_reason: input.failure_reason,
|
|
521
523
|
});
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
524
|
+
// pln#630 PR1b (dec#137) — stage the completion as ONE durable WAL intent,
|
|
525
|
+
// then apply it (journal fsync -> thread projection -> .applied marker).
|
|
526
|
+
// Crash-atomic + idempotent: a crash mid-write re-converges at the next
|
|
527
|
+
// lock-entry recovery. Replaces the former two-step appendEvent+writeThreadFile
|
|
528
|
+
// crash window that could lose the verdict or expose a projection ahead of the
|
|
529
|
+
// journal (codex r4). The events already carry their frozen seqs + event_ids.
|
|
530
|
+
commitViaIntent({ loop_id: current.id, base_version: current.version, events, thread_snapshot: next }, cwd);
|
|
525
531
|
return next;
|
|
526
532
|
}
|
|
527
533
|
export function add_artifact(input, cwd) {
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pln#632 — engine-run verify-command runner (pln#609 Increment 2).
|
|
3
|
+
*
|
|
4
|
+
* Makes the loop `command_green` gate DETERMINISTIC: brainclaw itself runs the
|
|
5
|
+
* configured verify command (tests/build/lint) and records a `verify_report`, instead
|
|
6
|
+
* of trusting an agent-narrated one. The iteration engine already READS a passing
|
|
7
|
+
* verify_report (iteration-engine.ts hasPassingVerifyReportInIteration); this module is
|
|
8
|
+
* the execution seam it referenced.
|
|
9
|
+
*
|
|
10
|
+
* SECURITY (the whole point of the runner):
|
|
11
|
+
* - PROVENANCE: the command comes only from `thread.protocol.verify` (set by the loop
|
|
12
|
+
* OPENER at open), never from the worker under test — that is the determinism
|
|
13
|
+
* guarantee (a tested agent cannot fabricate green by supplying `['true']`).
|
|
14
|
+
* - argv ARRAY, `shell:false` — no `;`/`&&`/`$()` injection surface. A pipeline is an
|
|
15
|
+
* explicit `['bash','-lc','npm test && npm run lint']` the operator owns.
|
|
16
|
+
* - ENV SANITIZATION: every `BRAINCLAW_*` (+ `BCLAW_PROMPT_FILE`) is stripped from the
|
|
17
|
+
* child env so the spawned suite can't hit the REAL brainclaw store
|
|
18
|
+
* (trap_agent_shell_env_contaminates_tests).
|
|
19
|
+
* - BOUNDED: a timeout (→ passed:false, timed_out:true) + a maxBuffer cap.
|
|
20
|
+
*
|
|
21
|
+
* The long spawn runs OUT of the loop lock (two lock scopes with the spawn between),
|
|
22
|
+
* so a multi-minute test run never holds the lock past its deadline. Opt-out: no
|
|
23
|
+
* `protocol.verify` → a typed `unconfigured` result; the agent-narrated path is unchanged.
|
|
24
|
+
*/
|
|
25
|
+
import { spawnSync } from 'node:child_process';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { getLoop } from './store.js';
|
|
28
|
+
import { withLoopLock } from './lock.js';
|
|
29
|
+
import { add_artifact } from './verbs.js';
|
|
30
|
+
import { artifactsInIteration } from './iteration-engine.js';
|
|
31
|
+
import { VERIFY_DEFAULT_TIMEOUT_MS, LOOP_ARTIFACT_BODY_MAX_BYTES, } from './types.js';
|
|
32
|
+
/** VerifyReportBodySchema caps stdout_tail/stderr_tail at 1024. */
|
|
33
|
+
const TAIL_MAX = 1024;
|
|
34
|
+
function tail(s) {
|
|
35
|
+
return s.length <= TAIL_MAX ? s : s.slice(s.length - TAIL_MAX);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The one security-critical function: spawnSync the verify command with `shell:false`,
|
|
39
|
+
* a SANITIZED env (all BRAINCLAW_* + BCLAW_PROMPT_FILE removed), a bounded timeout and
|
|
40
|
+
* maxBuffer. Timeout → passed:false, timed_out:true, exit_code:null. A spawn error
|
|
41
|
+
* (ENOENT — misconfigured command) → passed:false with the message in stderr_tail
|
|
42
|
+
* (stays RED, so the loop hits max_iterations → blocked, never a false green).
|
|
43
|
+
*/
|
|
44
|
+
export const defaultVerifyRunner = (config) => {
|
|
45
|
+
const env = { ...process.env };
|
|
46
|
+
for (const k of Object.keys(env)) {
|
|
47
|
+
if (k.startsWith('BRAINCLAW_') || k === 'BCLAW_PROMPT_FILE')
|
|
48
|
+
delete env[k];
|
|
49
|
+
}
|
|
50
|
+
const started = Date.now();
|
|
51
|
+
const r = spawnSync(config.command[0], config.command.slice(1), {
|
|
52
|
+
cwd: config.cwd,
|
|
53
|
+
env,
|
|
54
|
+
shell: false,
|
|
55
|
+
encoding: 'utf-8',
|
|
56
|
+
timeout: config.timeout_ms,
|
|
57
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
58
|
+
});
|
|
59
|
+
const duration_ms = Date.now() - started;
|
|
60
|
+
const err = r.error;
|
|
61
|
+
const stdout_tail = tail(r.stdout ?? '');
|
|
62
|
+
if (err?.code === 'ETIMEDOUT') {
|
|
63
|
+
return { exit_code: null, passed: false, timed_out: true, duration_ms, stdout_tail, stderr_tail: tail(r.stderr ?? '') };
|
|
64
|
+
}
|
|
65
|
+
if (err) {
|
|
66
|
+
// Could not RUN the command (ENOENT, EACCES, …) — red, with the reason.
|
|
67
|
+
return { exit_code: null, passed: false, timed_out: false, duration_ms, stdout_tail, stderr_tail: tail(String(err.message ?? err)) };
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
exit_code: r.status,
|
|
71
|
+
passed: r.status === 0,
|
|
72
|
+
timed_out: false,
|
|
73
|
+
duration_ms,
|
|
74
|
+
stdout_tail,
|
|
75
|
+
stderr_tail: tail(r.stderr ?? ''),
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Resolve the verify command for a loop. PR1: the loop-PROJECT cwd only (the
|
|
80
|
+
* lane-worktree cwd for a sequenced impl loop is a follow-up). Returns `unconfigured`
|
|
81
|
+
* when the loop opted out (no `protocol.verify`).
|
|
82
|
+
*/
|
|
83
|
+
export function resolveVerifyCommand(thread, cwd) {
|
|
84
|
+
const cfg = thread.protocol?.verify;
|
|
85
|
+
if (!cfg)
|
|
86
|
+
return { kind: 'unconfigured' };
|
|
87
|
+
return {
|
|
88
|
+
kind: 'ok',
|
|
89
|
+
config: {
|
|
90
|
+
command: cfg.command,
|
|
91
|
+
cwd: path.resolve(cwd ?? process.cwd()),
|
|
92
|
+
timeout_ms: cfg.timeout_ms ?? VERIFY_DEFAULT_TIMEOUT_MS,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* pln#632 (review F2) — ensure the SERIALIZED body fits add_artifact's 4 KiB byte limit.
|
|
98
|
+
* Each tail is ≤1024 CHARS (schema-valid), but multibyte / ANSI output can make the
|
|
99
|
+
* JSON body exceed LOOP_ARTIFACT_BODY_MAX_BYTES *bytes* (a control char JSON-escapes to
|
|
100
|
+
* 6 bytes), which would make add_artifact throw and drop a green suite's report. Shrink
|
|
101
|
+
* the tails (halving) until the serialized body fits; last resort drops the tails.
|
|
102
|
+
*/
|
|
103
|
+
function fitBody(report) {
|
|
104
|
+
const size = (r) => Buffer.byteLength(JSON.stringify(r), 'utf8');
|
|
105
|
+
if (size(report) <= LOOP_ARTIFACT_BODY_MAX_BYTES)
|
|
106
|
+
return report;
|
|
107
|
+
for (let keep = 512; keep >= 1; keep = Math.floor(keep / 2)) {
|
|
108
|
+
const r = {
|
|
109
|
+
...report,
|
|
110
|
+
stdout_tail: report.stdout_tail ? report.stdout_tail.slice(-keep) : undefined,
|
|
111
|
+
stderr_tail: report.stderr_tail ? report.stderr_tail.slice(-keep) : undefined,
|
|
112
|
+
};
|
|
113
|
+
if (size(r) <= LOOP_ARTIFACT_BODY_MAX_BYTES)
|
|
114
|
+
return r;
|
|
115
|
+
}
|
|
116
|
+
return { ...report, stdout_tail: undefined, stderr_tail: undefined };
|
|
117
|
+
}
|
|
118
|
+
export function buildVerifyReportBody(config, result) {
|
|
119
|
+
return fitBody({
|
|
120
|
+
command: config.command.join(' '),
|
|
121
|
+
exit_code: result.exit_code,
|
|
122
|
+
passed: result.passed,
|
|
123
|
+
duration_ms: result.duration_ms,
|
|
124
|
+
cwd: config.cwd,
|
|
125
|
+
timed_out: result.timed_out,
|
|
126
|
+
stdout_tail: result.stdout_tail || undefined,
|
|
127
|
+
stderr_tail: result.stderr_tail || undefined,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/** True when a verify_report already exists in this iteration (idempotency key = loop+iteration). */
|
|
131
|
+
function hasVerifyReportForIteration(thread, iteration) {
|
|
132
|
+
return artifactsInIteration(thread, iteration).some((a) => a.type === 'verify_report');
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Run the configured verify command and record a deterministic `verify_report` for the
|
|
136
|
+
* loop's CURRENT iteration/phase. Two lock scopes with the spawn BETWEEN them: scope 1
|
|
137
|
+
* reads the command + iteration + idempotency pre-check; the command runs OUT of the
|
|
138
|
+
* lock; scope 2 re-checks idempotency (a concurrent verify may have appended while we
|
|
139
|
+
* spawned) and appends via the existing `add_artifact` verb (which auto-stamps the
|
|
140
|
+
* iteration). Does NOT advance — the report is a fact; exiting the cycle stays a
|
|
141
|
+
* separate `advance` call by the driver.
|
|
142
|
+
*/
|
|
143
|
+
export function runVerify(input, cwd) {
|
|
144
|
+
const runner = input.runner ?? defaultVerifyRunner;
|
|
145
|
+
// --- Lock scope 1: read config + iteration; idempotency pre-check; snapshot. ---
|
|
146
|
+
const snapshot = withLoopLock({
|
|
147
|
+
cwd,
|
|
148
|
+
intent: 'verify',
|
|
149
|
+
agentId: input.actor,
|
|
150
|
+
scope: { kind: 'loop', loopId: input.loop_id },
|
|
151
|
+
work: () => {
|
|
152
|
+
const thread = getLoop(input.loop_id, cwd);
|
|
153
|
+
if (!thread)
|
|
154
|
+
throw new Error(`loop ${input.loop_id} not found`);
|
|
155
|
+
const resolved = resolveVerifyCommand(thread, cwd);
|
|
156
|
+
if (resolved.kind === 'unconfigured')
|
|
157
|
+
return { state: 'unconfigured', thread };
|
|
158
|
+
const iteration = thread.iteration_count;
|
|
159
|
+
if (hasVerifyReportForIteration(thread, iteration))
|
|
160
|
+
return { state: 'deduped', thread };
|
|
161
|
+
// Snapshot the iteration + phase we are about to verify. The command tests THIS
|
|
162
|
+
// iteration's working tree; the report must be attributed to it even if a
|
|
163
|
+
// concurrent advance bumps the loop's iteration while we spawn (review F1).
|
|
164
|
+
return { state: 'run', thread, config: resolved.config, iteration, phase: thread.current_phase };
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
if (snapshot.state === 'unconfigured')
|
|
168
|
+
return { thread: snapshot.thread, deduped: false, unconfigured: true };
|
|
169
|
+
if (snapshot.state === 'deduped')
|
|
170
|
+
return { thread: snapshot.thread, deduped: true };
|
|
171
|
+
// --- OUT OF LOCK: run the command (may take minutes). ---
|
|
172
|
+
const { config, iteration, phase } = snapshot;
|
|
173
|
+
const report = buildVerifyReportBody(config, runner(config));
|
|
174
|
+
// --- Lock scope 2: re-check idempotency (by SNAPSHOT iteration), then append. ---
|
|
175
|
+
return withLoopLock({
|
|
176
|
+
cwd,
|
|
177
|
+
intent: 'verify',
|
|
178
|
+
agentId: input.actor,
|
|
179
|
+
scope: { kind: 'loop', loopId: input.loop_id },
|
|
180
|
+
work: () => {
|
|
181
|
+
const thread = getLoop(input.loop_id, cwd);
|
|
182
|
+
if (!thread)
|
|
183
|
+
throw new Error(`loop ${input.loop_id} not found`);
|
|
184
|
+
// Dedup on the SNAPSHOT iteration — a report for the iteration we verified already
|
|
185
|
+
// landed (a concurrent verify won). Checking the snapshot (not the current)
|
|
186
|
+
// iteration is what makes this correct after a concurrent advance (review F1).
|
|
187
|
+
if (hasVerifyReportForIteration(thread, iteration)) {
|
|
188
|
+
return { thread, report, deduped: true };
|
|
189
|
+
}
|
|
190
|
+
const updated = add_artifact({
|
|
191
|
+
id: input.loop_id,
|
|
192
|
+
actor: input.actor,
|
|
193
|
+
artifact: {
|
|
194
|
+
// Stamp the SNAPSHOT phase + iteration so the report is attributed to the
|
|
195
|
+
// iteration whose code it actually tested — never a later iteration a
|
|
196
|
+
// concurrent advance moved the loop to (which would be a FALSE green).
|
|
197
|
+
phase,
|
|
198
|
+
iteration,
|
|
199
|
+
type: 'verify_report',
|
|
200
|
+
body: JSON.stringify(report),
|
|
201
|
+
produced_by: 'engine',
|
|
202
|
+
},
|
|
203
|
+
}, cwd);
|
|
204
|
+
const art = updated.artifacts[updated.artifacts.length - 1];
|
|
205
|
+
return { thread: updated, report, deduped: false, report_artifact_id: art?.artifact_id };
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
//# sourceMappingURL=verify-command.js.map
|
package/dist/core/messaging.js
CHANGED
|
@@ -50,18 +50,44 @@ function loadMessagesFromDir(dirPath) {
|
|
|
50
50
|
}
|
|
51
51
|
return items.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Hard cap on the inline body persisted per inbox message (pln#627 Phase B).
|
|
55
|
+
* Bodies above this are truncated at write time and flagged — the inbox must
|
|
56
|
+
* never again store a multi-hundred-KB persona/CoDev dump (root cause: one rfc
|
|
57
|
+
* message reached 960 KB). Large content belongs in a dedicated artifact store
|
|
58
|
+
* (Phase C), with the message carrying only a summary + pointer.
|
|
59
|
+
*
|
|
60
|
+
* Set ABOVE the largest *legitimate* message so real traffic is never
|
|
61
|
+
* corrupted: the loop brief assembler already bounds its memory bundle to
|
|
62
|
+
* DEFAULT_MAX_CHARS = 48 000 (brief-assembly.ts), and the coordinate dispatch
|
|
63
|
+
* envelope wraps that up to ~54 KB. 128 KB leaves ~2.4× headroom over that
|
|
64
|
+
* while still catching the ~960 KB dump class an order of magnitude below it.
|
|
65
|
+
*/
|
|
66
|
+
export const MAX_INLINE_MESSAGE_CHARS = 131_072;
|
|
67
|
+
/** Truncate an over-cap body, appending a marker that names the original size. */
|
|
68
|
+
function capMessageBody(text) {
|
|
69
|
+
const originalLength = text.length;
|
|
70
|
+
if (originalLength <= MAX_INLINE_MESSAGE_CHARS) {
|
|
71
|
+
return { text, truncated: false, originalLength };
|
|
72
|
+
}
|
|
73
|
+
const marker = `\n\n[truncated at write: ${originalLength} chars exceeded the ${MAX_INLINE_MESSAGE_CHARS}-char inbox cap; full body not stored inline — persist large content in an artifact store and reference it here]`;
|
|
74
|
+
const keep = Math.max(0, MAX_INLINE_MESSAGE_CHARS - marker.length);
|
|
75
|
+
return { text: text.slice(0, keep) + marker, truncated: true, originalLength };
|
|
76
|
+
}
|
|
53
77
|
export function sendMessage(input, cwd) {
|
|
54
78
|
return mutate({ cwd }, () => {
|
|
55
79
|
const { id, short_label } = generateIdWithLabel('inbox_messages', cwd);
|
|
56
80
|
const timestamp = nowISO();
|
|
57
81
|
const resolvedTo = resolveAgentAlias(input.to);
|
|
82
|
+
const capped = capMessageBody(input.text);
|
|
58
83
|
const message = {
|
|
59
84
|
id,
|
|
60
85
|
short_label,
|
|
61
86
|
from: input.from,
|
|
62
87
|
to: resolvedTo,
|
|
63
88
|
type: input.type,
|
|
64
|
-
text:
|
|
89
|
+
text: capped.text,
|
|
90
|
+
...(capped.truncated ? { truncated_at_write: true, original_text_length: capped.originalLength } : {}),
|
|
65
91
|
ref: input.ref,
|
|
66
92
|
payload: input.payload,
|
|
67
93
|
scope: input.scope,
|
|
@@ -83,14 +109,30 @@ export function sendMessage(input, cwd) {
|
|
|
83
109
|
const dir = ensureAgentInboxDir(input.to, cwd);
|
|
84
110
|
saveVersionedJsonFile('message', path.join(dir, `${id}.json`), message);
|
|
85
111
|
commitMemoryChange(`message ${id} sent to ${resolvedTo}`, cwd);
|
|
86
|
-
return {
|
|
112
|
+
return {
|
|
113
|
+
id,
|
|
114
|
+
shortLabel: short_label,
|
|
115
|
+
to: resolvedTo,
|
|
116
|
+
type: input.type,
|
|
117
|
+
...(capped.truncated
|
|
118
|
+
? { warning: `Message body truncated at write: ${capped.originalLength} chars exceeded the ${MAX_INLINE_MESSAGE_CHARS}-char inbox cap. Store large content in an artifact store and send a pointer instead.` }
|
|
119
|
+
: {}),
|
|
120
|
+
};
|
|
87
121
|
});
|
|
88
122
|
}
|
|
89
123
|
/** Apply all inbox filters (status, type, thread_id, claim_id) to a message list. */
|
|
90
124
|
function applyInboxFilters(messages, input) {
|
|
91
125
|
let filtered = messages;
|
|
92
|
-
if (input.status)
|
|
126
|
+
if (input.status) {
|
|
93
127
|
filtered = filtered.filter(m => m.status === input.status);
|
|
128
|
+
}
|
|
129
|
+
else if (!input.includeAll) {
|
|
130
|
+
// Default = actionable only (pln#627 Phase A): hide acknowledged + archived
|
|
131
|
+
// so a long tail of processed messages can't bury the live ones. Callers
|
|
132
|
+
// opt back into the full set with includeAll, or target a done status
|
|
133
|
+
// explicitly with `status`.
|
|
134
|
+
filtered = filtered.filter(m => m.status === 'pending' || m.status === 'read');
|
|
135
|
+
}
|
|
94
136
|
if (input.type)
|
|
95
137
|
filtered = filtered.filter(m => m.type === input.type);
|
|
96
138
|
if (input.thread_id)
|
|
@@ -101,6 +143,17 @@ function applyInboxFilters(messages, input) {
|
|
|
101
143
|
}
|
|
102
144
|
return filtered;
|
|
103
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Filter + order a directory's messages for a read (pln#627 Phase A).
|
|
148
|
+
* Ordered newest-first by created_at so a bounded page always serves the most
|
|
149
|
+
* recent messages, not the oldest debris — loadMessagesFromDir returns disk
|
|
150
|
+
* order (oldest-first, trp#291), which would otherwise make slice(0, limit)
|
|
151
|
+
* page through ancient processed messages first.
|
|
152
|
+
*/
|
|
153
|
+
function loadMessagesForRead(dir, input) {
|
|
154
|
+
const filtered = applyInboxFilters(loadMessagesFromDir(dir), input);
|
|
155
|
+
return filtered.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
156
|
+
}
|
|
104
157
|
export function readInbox(input, cwd) {
|
|
105
158
|
const dir = agentInboxDir(input.agent, cwd);
|
|
106
159
|
// If markAsRead, do everything under a single lock to avoid race conditions.
|
|
@@ -108,7 +161,7 @@ export function readInbox(input, cwd) {
|
|
|
108
161
|
if (input.markAsRead) {
|
|
109
162
|
return mutate({ cwd }, () => {
|
|
110
163
|
// Fresh read inside lock
|
|
111
|
-
const messages =
|
|
164
|
+
const messages = loadMessagesForRead(dir, input);
|
|
112
165
|
const total = messages.length;
|
|
113
166
|
const offset = input.offset ?? 0;
|
|
114
167
|
const limit = input.limit ?? 20;
|
|
@@ -127,7 +180,7 @@ export function readInbox(input, cwd) {
|
|
|
127
180
|
});
|
|
128
181
|
}
|
|
129
182
|
// Read-only path: no lock needed
|
|
130
|
-
const messages =
|
|
183
|
+
const messages = loadMessagesForRead(dir, input);
|
|
131
184
|
const total = messages.length;
|
|
132
185
|
const offset = input.offset ?? 0;
|
|
133
186
|
const limit = input.limit ?? 20;
|