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.
Files changed (85) hide show
  1. package/README.md +22 -8
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli/register-review.js +2 -2
  4. package/dist/commands/code-map.js +4 -1
  5. package/dist/commands/codev.js +61 -30
  6. package/dist/commands/doctor.js +14 -1
  7. package/dist/commands/harvest.js +241 -25
  8. package/dist/commands/inbox.js +10 -4
  9. package/dist/commands/loop.js +2 -2
  10. package/dist/commands/loops-handlers.js +82 -1
  11. package/dist/commands/mcp-catalog.js +12 -4
  12. package/dist/commands/mcp-read-handlers.js +90 -7
  13. package/dist/commands/mcp-schemas.generated.js +3 -0
  14. package/dist/commands/mcp-write-coordination.js +159 -40
  15. package/dist/commands/mcp.js +11 -2
  16. package/dist/core/agent-capability.js +7 -2
  17. package/dist/core/agent-files.js +53 -2
  18. package/dist/core/agent-integrations.js +1 -0
  19. package/dist/core/agentrun-reconciler.js +171 -7
  20. package/dist/core/agentruns.js +6 -1
  21. package/dist/core/code-map/aggregate.js +473 -0
  22. package/dist/core/code-map/backend.js +36 -10
  23. package/dist/core/code-map/freshness.js +36 -1
  24. package/dist/core/code-map/lang/c/imports.scm +12 -0
  25. package/dist/core/code-map/lang/c/index.js +150 -0
  26. package/dist/core/code-map/lang/c/tags.scm +68 -0
  27. package/dist/core/code-map/lang/cpp/imports.scm +14 -0
  28. package/dist/core/code-map/lang/cpp/index.js +149 -0
  29. package/dist/core/code-map/lang/cpp/tags.scm +87 -0
  30. package/dist/core/code-map/lang/csharp/imports.scm +20 -0
  31. package/dist/core/code-map/lang/csharp/index.js +224 -0
  32. package/dist/core/code-map/lang/csharp/tags.scm +63 -0
  33. package/dist/core/code-map/lang/go/imports.scm +13 -0
  34. package/dist/core/code-map/lang/go/index.js +139 -0
  35. package/dist/core/code-map/lang/go/tags.scm +36 -0
  36. package/dist/core/code-map/lang/providers.js +12 -1
  37. package/dist/core/code-map/lang/ruby/imports.scm +24 -0
  38. package/dist/core/code-map/lang/ruby/index.js +198 -0
  39. package/dist/core/code-map/lang/ruby/tags.scm +49 -0
  40. package/dist/core/code-map/lang/rust/imports.scm +44 -0
  41. package/dist/core/code-map/lang/rust/index.js +136 -0
  42. package/dist/core/code-map/lang/rust/tags.scm +47 -0
  43. package/dist/core/code-map/query.js +229 -80
  44. package/dist/core/code-map/types.js +18 -0
  45. package/dist/core/code-map/work-section.js +8 -7
  46. package/dist/core/codev-responses.js +16 -0
  47. package/dist/core/dispatcher.js +209 -29
  48. package/dist/core/execution-adapters.js +29 -3
  49. package/dist/core/ideation-loop-close.js +124 -0
  50. package/dist/core/loops/artifact-resolver.js +197 -0
  51. package/dist/core/loops/attempt-reservation.js +576 -0
  52. package/dist/core/loops/commit-intent.js +494 -0
  53. package/dist/core/loops/facade-schema.js +48 -0
  54. package/dist/core/loops/impl-bind.js +144 -0
  55. package/dist/core/loops/index.js +1 -1
  56. package/dist/core/loops/iteration-engine.js +29 -0
  57. package/dist/core/loops/lock.js +14 -0
  58. package/dist/core/loops/project-resolution.js +157 -0
  59. package/dist/core/loops/reconcile-turn.js +369 -0
  60. package/dist/core/loops/result-reducers.js +88 -0
  61. package/dist/core/loops/store.js +46 -7
  62. package/dist/core/loops/types.js +139 -11
  63. package/dist/core/loops/verbs.js +9 -3
  64. package/dist/core/loops/verify-command.js +209 -0
  65. package/dist/core/messaging.js +58 -5
  66. package/dist/core/review-loop-close.js +106 -34
  67. package/dist/core/review-loop-turn-dispatch.js +445 -0
  68. package/dist/core/runtime-signals.js +68 -0
  69. package/dist/core/schema.js +34 -0
  70. package/dist/core/worktree.js +240 -22
  71. package/dist/facts.js +10 -10
  72. package/dist/facts.json +9 -9
  73. package/dist/wasm/tree-sitter-c.wasm +0 -0
  74. package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
  75. package/dist/wasm/tree-sitter-cpp.wasm +0 -0
  76. package/dist/wasm/tree-sitter-go.wasm +0 -0
  77. package/dist/wasm/tree-sitter-ruby.wasm +0 -0
  78. package/dist/wasm/tree-sitter-rust.wasm +0 -0
  79. package/docs/cli.md +1 -1
  80. package/docs/code-map.md +22 -6
  81. package/docs/concepts/loop-engine.md +28 -2
  82. package/docs/concepts/observer-protocol.md +22 -0
  83. package/docs/integrations/codex.md +19 -3
  84. package/docs/mcp-schema-changelog.md +43 -1
  85. package/package.json +1 -1
@@ -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: 'sequence_build' },
732
- { name: 'dispatch' },
733
- { name: 'execute' },
734
- { name: 'self_check' },
735
- { name: 'handoff_ready' },
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
- stop_condition: { kind: 'artifact_produced', phase: 'handoff_ready', type: 'handoff' },
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: [{ name: 'investigate' }, { name: 'synthesize' }],
741
- stop_condition: { kind: 'manual' },
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: [{ name: 'reproduce' }, { name: 'hypothesize' }, { name: 'isolate' }, { name: 'fix' }],
745
- stop_condition: { kind: 'manual' },
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
@@ -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
- for (const event of events)
523
- appendEvent(current.id, event, cwd);
524
- writeThreadFile(next, cwd);
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
@@ -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: input.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 { id, shortLabel: short_label, to: resolvedTo, type: input.type };
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 = applyInboxFilters(loadMessagesFromDir(dir), input);
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 = applyInboxFilters(loadMessagesFromDir(dir), input);
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;