brainclaw 1.28.2 → 1.28.4

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 (49) hide show
  1. package/README.md +6 -0
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/commands/harvest.js +67 -25
  4. package/dist/commands/loops-handlers.js +28 -1
  5. package/dist/commands/mcp-catalog.js +25 -4
  6. package/dist/commands/mcp-read-handlers.js +15 -1
  7. package/dist/commands/mcp-schemas.generated.js +13 -0
  8. package/dist/commands/mcp-write-coordination.js +97 -26
  9. package/dist/commands/mcp-write-entities.js +5 -2
  10. package/dist/commands/mcp-write-memory.js +87 -1
  11. package/dist/commands/mcp.js +41 -9
  12. package/dist/core/code-map/aggregate.js +20 -7
  13. package/dist/core/code-map/backend.js +17 -7
  14. package/dist/core/code-map/cascade-jobs.js +174 -0
  15. package/dist/core/code-map/cascade-worker.js +15 -0
  16. package/dist/core/code-map/cascade.js +63 -26
  17. package/dist/core/code-map/query.js +6 -3
  18. package/dist/core/context.js +16 -3
  19. package/dist/core/dispatch-status.js +36 -14
  20. package/dist/core/dispatcher.js +28 -20
  21. package/dist/core/entity-operations.js +80 -8
  22. package/dist/core/entity-registry.js +3 -3
  23. package/dist/core/execution-adapters.js +18 -1
  24. package/dist/core/facade-schema.js +10 -0
  25. package/dist/core/ideation-loop-close.js +3 -1
  26. package/dist/core/lane-result-file.js +72 -0
  27. package/dist/core/loop-turn-dispatch.js +2 -0
  28. package/dist/core/loops/brief-assembly.js +19 -11
  29. package/dist/core/loops/next-expected.js +56 -1
  30. package/dist/core/loops/reconcile-turn.js +8 -0
  31. package/dist/core/loops/result-reducers.js +14 -12
  32. package/dist/core/loops/store.js +4 -0
  33. package/dist/core/loops/types.js +14 -2
  34. package/dist/core/loops/verbs.js +8 -1
  35. package/dist/core/loops/worker-reply-contract.js +1 -1
  36. package/dist/core/protocol-tool-policy.js +1 -0
  37. package/dist/core/review-loop-turn-dispatch.js +1 -0
  38. package/dist/core/schema.js +24 -1
  39. package/dist/core/search.js +3 -2
  40. package/dist/core/spawn-check.js +9 -1
  41. package/dist/core/worktree.js +14 -7
  42. package/dist/facts.js +10 -9
  43. package/dist/facts.json +9 -8
  44. package/docs/cli.md +35 -2
  45. package/docs/code-map.md +20 -9
  46. package/docs/concepts/ideation-loop.md +35 -14
  47. package/docs/integrations/mcp.md +15 -5
  48. package/docs/mcp-schema-changelog.md +72 -6
  49. package/package.json +1 -1
@@ -37,7 +37,7 @@ import { buildClaimEnvPrefix } from './execution-profile.js';
37
37
  import { getActiveSequence, listSequences } from './sequence.js';
38
38
  import { loadState, persistState } from './state.js';
39
39
  import { listClaims, createCoordinatorClaim, attachAssignmentMessageToClaim, linkClaimToAssignment, assessClaimLiveness } from './claims.js';
40
- import { sanitizeBranchComponent, isBranchMergedByContent, probeLocalBranch, isGitRepo } from './worktree.js';
40
+ import { sanitizeBranchComponent, isBranchMergedByContent, probeLocalBranch, isGitRepo, detectWorkspaceNodeModules } from './worktree.js';
41
41
  import { listAgentIdentities, ensureAgentRegisteredForDispatch } from './agent-registry.js';
42
42
  import { sendMessage, hasActiveAssignment } from './messaging.js';
43
43
  import { memoryDir } from './io.js';
@@ -328,7 +328,7 @@ export function buildWorkingDefaultsSection(opts) {
328
328
  '',
329
329
  ].join('\n');
330
330
  }
331
- export function laneResultShape(assignmentId, contractRef, fence) {
331
+ export function laneResultShape(assignmentId, contractRef, fence, artifactType) {
332
332
  const asgn = assignmentId ?? '<assignment_id>';
333
333
  const generation = fence
334
334
  ? `,"turn_id":"${fence.turn_id}","run_id":"${fence.run_id}","nonce":"${fence.nonce}","attempt_epoch":${fence.attempt_epoch},"workspace_digest":"${fence.workspace_digest}"`
@@ -336,10 +336,11 @@ export function laneResultShape(assignmentId, contractRef, fence) {
336
336
  const contract = contractRef
337
337
  ? `,"execution_contract_hash":"${contractRef.hash}","capability_snapshot_hash":"${contractRef.snapshot_hash}"`
338
338
  : '';
339
- return `{"assignment_id":"${asgn}"${generation}${contract},"status":"completed|blocked|failed","summary":"<one line>","body":"<your full output — the reasoning, not just a label>","files_changed":["..."],"artifacts":["..."]}`;
339
+ const artifact = artifactType ? `,"artifact_type":"${artifactType}"` : '';
340
+ return `{"assignment_id":"${asgn}"${generation}${contract},"status":"completed|blocked|failed","summary":"<one line>"${artifact},"body":"<your full output — the reasoning, not just a label>","files_changed":["..."],"artifacts":["<ref>",{"type":"file|commit|artifact","ref":"<path-or-id>","description":"<optional>"}]}`;
340
341
  }
341
342
  export function buildTransportSection(opts) {
342
- const laneResult = `write LANE-RESULT.json at the worktree ROOT: ${laneResultShape(opts.assignmentId, opts.executionContractRef, opts.attemptFence)}`;
343
+ const laneResult = `write the terminal envelope at the worktree ROOT with the exact filename LANE-RESULT.json: ${laneResultShape(opts.assignmentId, opts.executionContractRef, opts.attemptFence, opts.artifactType)}. The filename is protocol-significant: do not translate it, replace the hyphen, add a suffix, or place it in a subdirectory. The artifacts array accepts either string refs or {type,ref,description} objects; for a loop turn, artifact_type is required exactly as shown`;
343
344
  const acceptance = opts.executionContractRef
344
345
  ? [
345
346
  `Execution contract: ${opts.executionContractRef.hash}`,
@@ -408,8 +409,8 @@ export function buildProtocolSection(options) {
408
409
  if (options?.worktreePath) {
409
410
  parts.push(`Worktree: ${options.worktreePath}`);
410
411
  // pln#523 / trp_37b05a15: tell the worker how dependencies are provisioned so
411
- // it does not stall trying to (re)install them. The authoritative record is
412
- // the worktree's `.brainclaw-worktree.json` `deps_mode` (absent `link`).
412
+ // it does not stall trying to (re)install them. The sidecar records intent,
413
+ // but the brief only claims availability after checking the worktree itself.
413
414
  // - link (default): node_modules (incl. monorepo per-package) is
414
415
  // junction-linked from the main repo — build/typecheck directly; do NOT
415
416
  // `npm install`. An out-of-root symlink, so `next dev`/Turbopack rejects
@@ -417,28 +418,34 @@ export function buildProtocolSection(options) {
417
418
  // - install/copy: node_modules is a REAL in-root directory — everything,
418
419
  // including a dev server, works directly; no reinstall needed.
419
420
  // - none: no deps provisioned — run the project's install first.
420
- let depsMode = 'link';
421
+ let depsMode;
421
422
  let depsProvisioned;
423
+ let recordedPaths = [];
422
424
  try {
423
425
  const sidecar = JSON.parse(fs.readFileSync(path.join(options.worktreePath, '.brainclaw-worktree.json'), 'utf-8'));
424
- if (sidecar.deps_mode)
425
- depsMode = sidecar.deps_mode;
426
+ depsMode = sidecar.deps_mode;
426
427
  depsProvisioned = sidecar.deps_provisioned;
428
+ recordedPaths = Array.isArray(sidecar.deps_paths) ? sidecar.deps_paths : [];
427
429
  }
428
- catch { /* sidecar absent/unreadable — assume the default `link` */ }
429
- if ((depsMode === 'install' || depsMode === 'copy') && depsProvisioned === false) {
430
- // Codex review P1: provisioning was ATTEMPTED but FAILED (best-effort, non-fatal).
431
- // Do not claim node_modules is usable — tell the worker to install it.
432
- parts.push(`Dependencies: in-root provisioning was attempted (deps_mode=${depsMode}) but FAILED — node_modules may be missing or incomplete. Run the project's install (npm/pnpm/yarn/bun) in the worktree before building; see .brainclaw-worktree.json symlink_warnings for the failure.`);
430
+ catch { /* sidecar absent/unreadable — do not infer provisioning */ }
431
+ const detectedPaths = detectWorkspaceNodeModules(options.worktreePath)
432
+ .filter((relativePath) => fs.existsSync(path.join(options.worktreePath, relativePath)));
433
+ const verifiedPaths = [...new Set([...recordedPaths, ...detectedPaths])]
434
+ .filter((relativePath) => fs.existsSync(path.join(options.worktreePath, relativePath)));
435
+ if (verifiedPaths.length > 0 && (depsMode === 'install' || depsMode === 'copy')) {
436
+ parts.push(`Dependencies: verified in-root node_modules at ${verifiedPaths.join(', ')} (deps_mode=${depsMode}). Use the repository's local scripts/binaries; do NOT reinstall.`);
433
437
  }
434
- else if (depsMode === 'install' || depsMode === 'copy') {
435
- parts.push(`Dependencies: node_modules is a real in-root directory (deps_mode=${depsMode}) build, typecheck, and dev server all work directly; do NOT reinstall. If anything is missing, see .brainclaw-worktree.json symlink_warnings.`);
438
+ else if (verifiedPaths.length > 0 && depsMode === 'link') {
439
+ parts.push(`Dependencies: verified node_modules at ${verifiedPaths.join(', ')} (deps_mode=link). Use the repository's local scripts/binaries; do NOT reinstall. These may be out-of-root links, so next dev/Turbopack can require deps_mode=install.`);
436
440
  }
437
441
  else if (depsMode === 'none') {
438
- parts.push('Dependencies: none were provisioned (deps_mode=none) run the project\'s install (npm/pnpm/yarn/bun) in the worktree before building.');
442
+ parts.push('Dependencies: none were provisioned (deps_mode=none). Do not invoke npx or any network-backed install unless the task explicitly authorizes it; report local validation as blocked when required tools are unavailable.');
439
443
  }
440
444
  else {
441
- parts.push('Dependencies: node_modules is linked from the main repo (incl. monorepo per-package). Build/typecheck directly; if deps are missing, do NOT npm install here — see .brainclaw-worktree.json symlink_warnings and validate centrally. (Out-of-root symlink: next dev/Turbopack needs deps_mode=install.)');
445
+ const attempted = depsProvisioned === false && depsMode
446
+ ? ` Provisioning was attempted with deps_mode=${depsMode} but did not produce a usable node_modules tree.`
447
+ : '';
448
+ parts.push(`Dependencies: no node_modules directory was verified in this worktree.${attempted} Do not invoke npx or any network-backed install unless the task explicitly authorizes it; report local validation as blocked when required tools are unavailable. See .brainclaw-worktree.json symlink_warnings when present.`);
442
449
  }
443
450
  }
444
451
  parts.push('');
@@ -711,8 +718,8 @@ export function buildContextEnvelopeSection(cwd) {
711
718
  }
712
719
  const newestFirst = (items) => [...items].sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? '') || (a.id ?? '').localeCompare(b.id ?? ''));
713
720
  const clip = (text) => text.length > CONTEXT_ENVELOPE_ITEM_MAX_CHARS ? `${text.slice(0, CONTEXT_ENVELOPE_ITEM_MAX_CHARS - 1)}…` : text;
714
- const constraints = newestFirst(state.active_constraints ?? []);
715
- const traps = newestFirst(state.known_traps ?? []).slice(0, CONTEXT_ENVELOPE_TOP_K);
721
+ const constraints = newestFirst((state.active_constraints ?? []).filter((item) => item.status === 'active'));
722
+ const traps = newestFirst((state.known_traps ?? []).filter((item) => item.status === 'active')).slice(0, CONTEXT_ENVELOPE_TOP_K);
716
723
  const decisions = newestFirst(state.recent_decisions ?? []).slice(0, CONTEXT_ENVELOPE_TOP_K);
717
724
  if (constraints.length === 0 && traps.length === 0 && decisions.length === 0)
718
725
  return '';
@@ -777,6 +784,7 @@ export function generateDispatchBrief(options) {
777
784
  worktreePath: options.worktreePath,
778
785
  assignmentId: options.assignmentId,
779
786
  attemptFence: options.attemptFence,
787
+ artifactType: options.artifactType,
780
788
  }));
781
789
  }
782
790
  // pln#628 Focus 4A — transport addendum keyed to the ACTUAL missing capability
@@ -15,12 +15,13 @@
15
15
  */
16
16
  import path from 'node:path';
17
17
  import { loadState, mutateState } from './state.js';
18
+ import { detectDuplicates } from './duplicates.js';
18
19
  import { archiveCandidate, listCandidates, loadCandidate, saveCandidate, } from './candidates.js';
19
20
  import { addCrossProjectLink, removeCrossProjectLink, resolveCrossProjectLinks, } from './cross-project.js';
20
21
  import { findActiveClaimsForPlan, listClaims, loadClaim, logCascadeReleaseResult, markClaimStale, releaseClaimsCascade, releaseClaimWithCascade, saveClaim, } from './claims.js';
21
22
  import { listActionRequired } from './actions.js';
22
23
  import { listAgentIdentities } from './agent-registry.js';
23
- import { getCapabilityProfile, getSpawnableAgents } from './agent-capability.js';
24
+ import { getCapabilityProfile, getSpawnableAgents, validateAgentForDispatch } from './agent-capability.js';
24
25
  import { buildReputationSnapshot, toPublicReputationSummary } from './reputation.js';
25
26
  import { loadAllSessions } from './identity.js';
26
27
  import { loadInstructions } from './instructions.js';
@@ -39,7 +40,7 @@ import { createPlan, deletePlan, updatePlan, } from './operations/plan.js';
39
40
  import { ENTITY_NAMES, ENTITY_REGISTRY, isValidTransition, } from './entity-registry.js';
40
41
  import { generateId } from './ids.js';
41
42
  import { mergeHandoffReview } from './handoff-review.js';
42
- import { CandidateTypeSchema, ConstraintCategorySchema, DecisionOutcomeSchema, HandoffContractSchema, HandoffReviewSchema, MemoryVisibilitySchema, PlanTypeEnumSchema, PrioritySchema, RuntimeNoteTypeSchema, SequenceStatusSchema, SeveritySchema, } from './schema.js';
43
+ import { CandidateTypeSchema, ConstraintCategorySchema, DecisionOutcomeSchema, HandoffContractSchema, HandoffReviewSchema, MemoryVisibilitySchema, PlanTypeEnumSchema, PrioritySchema, RuntimeNoteTypeSchema, SequenceStatusSchema, SeveritySchema, MemoryVerificationSchema, } from './schema.js';
43
44
  /**
44
45
  * Default provenance stamp applied on create when the caller does not
45
46
  * supply one. `user` kind with whatever author is in the payload; the
@@ -271,7 +272,14 @@ export function listEntities(name, cwd, filter = {}) {
271
272
  : fieldFiltered.filter((item) => isLegacyProvenance(item)).length;
272
273
  const excludedLowConfidenceAutoReflect = fieldFiltered.filter((item) => isLowConfidenceAutoReflect(item, filter)).length;
273
274
  const filtered = fieldFiltered.filter((item) => passesProvenanceFilter(item, filter));
274
- const paged = applyPaging(filtered, filter);
275
+ const newestFirst = [...filtered].sort((a, b) => {
276
+ const left = a;
277
+ const right = b;
278
+ const leftDate = String(left.updated_at ?? left.created_at ?? '');
279
+ const rightDate = String(right.updated_at ?? right.created_at ?? '');
280
+ return rightDate.localeCompare(leftDate) || String(left.id ?? '').localeCompare(String(right.id ?? ''));
281
+ });
282
+ const paged = applyPaging(newestFirst, filter);
275
283
  return {
276
284
  entity: name,
277
285
  total: filtered.length,
@@ -300,6 +308,16 @@ export function boundListResult(result, offset, charBudget = DEFAULT_FIND_CHAR_B
300
308
  items = items.slice(0, items.length - drop);
301
309
  omittedForSize = result.items.length - items.length;
302
310
  }
311
+ let oversizedItemProjected = false;
312
+ if (items.length === 1 && JSON.stringify(items).length > charBudget) {
313
+ const item = items[0];
314
+ if (item && typeof item === 'object') {
315
+ const row = item;
316
+ const compactKeys = ['id', 'short_label', 'status', 'created_at', 'updated_at', 'agent', 'scope', 'plan_id'];
317
+ items = [Object.fromEntries(compactKeys.filter((key) => row[key] !== undefined).map((key) => [key, row[key]]))];
318
+ oversizedItemProjected = true;
319
+ }
320
+ }
303
321
  const returned = items.length;
304
322
  const hasMore = offset + returned < result.total;
305
323
  const bounded = {
@@ -308,6 +326,7 @@ export function boundListResult(result, offset, charBudget = DEFAULT_FIND_CHAR_B
308
326
  returned,
309
327
  has_more: hasMore,
310
328
  ...(omittedForSize > 0 ? { omitted_for_size: omittedForSize } : {}),
329
+ ...(oversizedItemProjected ? { oversized_item_projected: true } : {}),
311
330
  };
312
331
  if (hasMore) {
313
332
  bounded.next_offset = offset + returned;
@@ -315,6 +334,9 @@ export function boundListResult(result, offset, charBudget = DEFAULT_FIND_CHAR_B
315
334
  ? `Payload size-bounded: returned ${returned} of ${result.total} ${result.entity} item(s). Fetch more with filter.offset=${bounded.next_offset}, or narrow the filter (status/tag/author).`
316
335
  : `Returned ${returned} of ${result.total} ${result.entity} item(s). Page with filter.offset=${bounded.next_offset}, or narrow the filter.`;
317
336
  }
337
+ else if (oversizedItemProjected) {
338
+ bounded.hint = 'The matching item exceeded budget_tokens and was projected to identity/status fields. Use bclaw_get for the full item or request explicit bclaw_find fields.';
339
+ }
318
340
  return bounded;
319
341
  }
320
342
  /**
@@ -387,6 +409,11 @@ function loadAgentsForRead(cwd, filter) {
387
409
  : undefined;
388
410
  const project = (doc) => {
389
411
  const row = projectAgentForRead(doc);
412
+ const availability = validateAgentForDispatch(doc.agent_name, { requireSpawnable: true });
413
+ row.declared_spawnable = getCapabilityProfile(doc.agent_name)?.runtime.canBeSpawnedCli ?? false;
414
+ row.executable_now = availability.valid;
415
+ row.availability_code = availability.code;
416
+ row.availability_reason = availability.reason;
390
417
  if (reputationById)
391
418
  row.reputation = reputationById.get(String(row.id));
392
419
  return row;
@@ -407,7 +434,13 @@ function loadAgentsForRead(cwd, filter) {
407
434
  existing.dispatchable = true;
408
435
  continue;
409
436
  }
410
- byName.set(name, projectCatalogAgentForRead(name));
437
+ const row = projectCatalogAgentForRead(name);
438
+ const availability = validateAgentForDispatch(name, { requireSpawnable: true });
439
+ row.declared_spawnable = true;
440
+ row.executable_now = availability.valid;
441
+ row.availability_code = availability.code;
442
+ row.availability_reason = availability.reason;
443
+ byName.set(name, row);
411
444
  }
412
445
  return [...byName.values()];
413
446
  }
@@ -511,6 +544,19 @@ export function getEntity(name, idOrShortLabel, cwd) {
511
544
  // ─── CREATE ────────────────────────────────────────────────────────────
512
545
  export function createEntity(name, data, cwd) {
513
546
  assertKnownEntity(name, 'create');
547
+ const proximityKinds = new Set(['decision', 'constraint', 'trap']);
548
+ const nearby = proximityKinds.has(name) && typeof data.text === 'string'
549
+ ? detectDuplicates(data.text, name, loadState(cwd), listCandidates(undefined, cwd).filter((candidate) => candidate.status === 'pending')).slice(0, 3).map((match) => ({
550
+ id: match.id,
551
+ source: match.source,
552
+ reason: match.reason,
553
+ preview: match.text.length > 160 ? `${match.text.slice(0, 157)}…` : match.text,
554
+ }))
555
+ : [];
556
+ const result = (base) => ({
557
+ ...base,
558
+ ...(nearby.length ? { nearby_items: nearby } : {}),
559
+ });
514
560
  switch (name) {
515
561
  case 'plan': {
516
562
  // Explicit field whitelist + required-author check brings plan create in line
@@ -530,7 +576,7 @@ export function createEntity(name, data, cwd) {
530
576
  estimatedEffort: data.estimated_effort,
531
577
  }, cwd);
532
578
  stampProvenanceOnStateItem('plan', res.id, defaultProvenance(data), cwd);
533
- return { entity: name, id: res.id, short_label: res.shortLabel };
579
+ return result({ entity: name, id: res.id, short_label: res.shortLabel });
534
580
  }
535
581
  case 'decision': {
536
582
  const res = createDecision({
@@ -542,7 +588,8 @@ export function createEntity(name, data, cwd) {
542
588
  planId: data.plan_id,
543
589
  }, cwd);
544
590
  stampProvenanceOnStateItem('decision', res.id, defaultProvenance(data), cwd);
545
- return { entity: name, id: res.id, short_label: res.shortLabel };
591
+ stampMemoryVerification('decision', res.id, data, cwd);
592
+ return result({ entity: name, id: res.id, short_label: res.shortLabel });
546
593
  }
547
594
  case 'constraint': {
548
595
  const res = createConstraint({
@@ -553,7 +600,8 @@ export function createEntity(name, data, cwd) {
553
600
  relatedPaths: data.related_paths,
554
601
  }, cwd);
555
602
  stampProvenanceOnStateItem('constraint', res.id, defaultProvenance(data), cwd);
556
- return { entity: name, id: res.id, short_label: res.shortLabel };
603
+ stampMemoryVerification('constraint', res.id, data, cwd);
604
+ return result({ entity: name, id: res.id, short_label: res.shortLabel });
557
605
  }
558
606
  case 'trap': {
559
607
  const res = createTrap({
@@ -564,7 +612,8 @@ export function createEntity(name, data, cwd) {
564
612
  relatedPaths: data.related_paths,
565
613
  }, cwd);
566
614
  stampProvenanceOnStateItem('trap', res.id, defaultProvenance(data), cwd);
567
- return { entity: name, id: res.id, short_label: res.shortLabel };
615
+ stampMemoryVerification('trap', res.id, data, cwd);
616
+ return result({ entity: name, id: res.id, short_label: res.shortLabel });
568
617
  }
569
618
  case 'runtime_note': {
570
619
  const id = generateId('runtime_note');
@@ -1090,6 +1139,29 @@ function stampProvenanceOnStateItem(name, id, provenance, cwd) {
1090
1139
  item.provenance = provenance;
1091
1140
  }, cwd);
1092
1141
  }
1142
+ function stampMemoryVerification(name, id, data, cwd) {
1143
+ const verification = data.verification === undefined
1144
+ ? undefined
1145
+ : MemoryVerificationSchema.parse(data.verification);
1146
+ const verifiedAt = typeof data.verified_at === 'string' ? data.verified_at : verification?.verified_at;
1147
+ const verifyCmd = typeof data.verify_cmd === 'string' ? data.verify_cmd : undefined;
1148
+ if (!verification && !verifiedAt && !verifyCmd)
1149
+ return;
1150
+ mutateState((state) => {
1151
+ const bucket = name === 'decision' ? state.recent_decisions
1152
+ : name === 'constraint' ? state.active_constraints
1153
+ : state.known_traps;
1154
+ const item = bucket.find((entry) => entry.id === id);
1155
+ if (!item)
1156
+ return;
1157
+ if (verification)
1158
+ item.verification = verification;
1159
+ if (verifiedAt)
1160
+ item.verified_at = verifiedAt;
1161
+ if (verifyCmd)
1162
+ item.verify_cmd = verifyCmd;
1163
+ }, cwd);
1164
+ }
1093
1165
  function requireString(data, field) {
1094
1166
  const value = data[field];
1095
1167
  if (typeof value !== 'string' || !value) {
@@ -117,7 +117,7 @@ const decision = {
117
117
  shortLabelPrefix: 'dec',
118
118
  schema: DecisionSchema,
119
119
  updatable: [
120
- 'text', 'tags', 'outcome', 'scope', 'related_paths', 'verified_at', 'verify_cmd',
120
+ 'text', 'tags', 'outcome', 'scope', 'related_paths', 'verified_at', 'verify_cmd', 'verification',
121
121
  // pln#544 lifecycle (touched via memory-lifecycle.ts recordMemoryEvent;
122
122
  // exposing them here keeps bclaw_update straight-through for tests and
123
123
  // operator backfills).
@@ -140,7 +140,7 @@ const constraint = {
140
140
  shortLabelPrefix: 'cst',
141
141
  schema: ConstraintSchema,
142
142
  updatable: [
143
- 'text', 'tags', 'category', 'scope', 'related_paths', 'expires_at',
143
+ 'text', 'tags', 'category', 'scope', 'related_paths', 'expires_at', 'verified_at', 'verify_cmd', 'verification',
144
144
  // pln#544 lifecycle — see decision.updatable.
145
145
  'last_confirmed_at', 'last_infirmed_at',
146
146
  'confirmation_count', 'infirmation_count',
@@ -164,7 +164,7 @@ const trap = {
164
164
  schema: TrapSchema,
165
165
  updatable: [
166
166
  'text', 'tags', 'severity', 'scope', 'related_paths', 'expires_at', 'platform_scope',
167
- 'verified_at', 'verify_cmd',
167
+ 'verified_at', 'verify_cmd', 'verification',
168
168
  // pln#544 lifecycle — see decision.updatable.
169
169
  'last_confirmed_at', 'last_infirmed_at',
170
170
  'confirmation_count', 'infirmation_count',
@@ -213,6 +213,12 @@ export function withCodexWorkspaceRoot(invoke, agent, worktreePath, isWin32 = pr
213
213
  }
214
214
  return { ...invoke, args, bashCommand };
215
215
  }
216
+ /** Refuse a stdin-delivery invoke that would otherwise inherit `/dev/null`. */
217
+ export function assertPromptDelivery(invoke) {
218
+ if (invoke.promptDelivery === 'stdin_pipe' && !invoke.promptText?.trim()) {
219
+ throw new Error('Invalid stdin_pipe invocation: promptText is empty; refusing to spawn an agent with ignored stdin.');
220
+ }
221
+ }
216
222
  export class CliExecutionAdapter {
217
223
  id = 'cli';
218
224
  canSpawn(agentName) {
@@ -281,6 +287,7 @@ export class CliExecutionAdapter {
281
287
  };
282
288
  }
283
289
  start(invoke, options) {
290
+ assertPromptDelivery(invoke);
284
291
  const isWin32 = process.platform === 'win32';
285
292
  invoke = withCodexWorkspaceRoot(invoke, options.agent, options.worktreePath, isWin32);
286
293
  // F7 (trp_0e5150d3): route worker env through buildWorkerIdentityEnv so the
@@ -317,7 +324,7 @@ export class CliExecutionAdapter {
317
324
  }
318
325
  const spawnExecutable = resolvedExecutable ?? invoke.executable;
319
326
  const useShell = isWin32 && /\.(cmd|bat)$/i.test(spawnExecutable);
320
- const needsStdin = invoke.promptDelivery === 'stdin_pipe' && invoke.promptText;
327
+ const needsStdin = invoke.promptDelivery === 'stdin_pipe';
321
328
  // pln#520 step 4: when we ack-wrap, the SHELL redirects stdout/stderr to the
322
329
  // per-assignment log files (fds passed via stdio are NOT inherited through
323
330
  // the cmd.exe → .cmd → node shim — the empty-logs bug of can_f792cacd), and
@@ -358,6 +365,13 @@ export class CliExecutionAdapter {
358
365
  contractBootstrapPath,
359
366
  expectedWorkspacePath: options.worktreePath,
360
367
  }, isWin32, options.turnEcho);
368
+ // Materialize advertised log paths before spawn. A shell/bootstrap error
369
+ // can otherwise happen before redirection creates either file.
370
+ for (const stream of ['stdout', 'stderr']) {
371
+ const logPath = getRuntimeLogPath(signalRoot, options.assignmentId, stream, runtimeRunId);
372
+ if (!fs.existsSync(logPath))
373
+ fs.writeFileSync(logPath, '', { encoding: 'utf8', mode: 0o600 });
374
+ }
361
375
  child = spawn(wrappedCmd, [], {
362
376
  detached: !isWin32,
363
377
  shell: true,
@@ -388,6 +402,9 @@ export class CliExecutionAdapter {
388
402
  child.stdin.write(invoke.promptText);
389
403
  child.stdin.end();
390
404
  }
405
+ // Preserve fire-and-forget semantics while keeping an exit observer so
406
+ // libuv reaps the direct wrapper child on long-lived POSIX coordinators.
407
+ child.once('exit', () => { });
391
408
  child.unref();
392
409
  const pid = child.pid;
393
410
  if (!pid) {
@@ -36,6 +36,16 @@ export const CoordinateRequestSchema = z.object({
36
36
  task: z.string(),
37
37
  scope: z.string().optional(),
38
38
  targetAgents: z.array(z.string()).optional(),
39
+ /**
40
+ * Ideation critic scheduling. The default is deliberately sequential: the
41
+ * loop still requires every critic artifact, but only the first open critic
42
+ * is dispatched initially and the driver takes the next real turn after
43
+ * harvest. Parallel fan-out remains available as an explicit throughput
44
+ * trade-off.
45
+ */
46
+ ideation_schedule: z.enum(['sequential', 'parallel']).optional().default('sequential'),
47
+ /** Optional per-slot lenses, positionally aligned with targetAgents. */
48
+ criticPerspectives: z.array(z.string().min(1).max(1000)).optional(),
39
49
  constraints: z.record(z.string(), z.unknown()).optional(),
40
50
  threadId: z.string().optional(),
41
51
  /** Optional pipeline provenance persisted when open_loop creates a loop. */
@@ -107,7 +107,9 @@ export function closeIdeationLoopFromLaneResult(assignment, lane, actor, cwd) {
107
107
  // Prefer the typed envelope, but honor the legacy artifacts labels too:
108
108
  // coverage_gap used to be silently invisible to a critique gate.
109
109
  const reportedArtifactType = lane.artifact_type?.trim()
110
- ?? lane.artifacts?.find((label) => /^[a-z][a-z0-9_]*$/.test(label) && label !== expectedArtifactType);
110
+ ?? lane.artifacts
111
+ ?.map((item) => typeof item === 'string' ? item : item.type)
112
+ .find((label) => /^[a-z][a-z0-9_]*$/.test(label) && label !== expectedArtifactType);
111
113
  const body = lane.body?.trim();
112
114
  const critique = body || [lane.summary, lane.notes]
113
115
  .map((s) => (s ?? '').trim())
@@ -0,0 +1,72 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { LANE_RESULT_BODY_MAX_BYTES, LaneResultSchema, } from './schema.js';
4
+ export const LANE_RESULT_FILENAME = 'LANE-RESULT.json';
5
+ const MAX_RESULT_FILE_BYTES = LANE_RESULT_BODY_MAX_BYTES + 16 * 1024;
6
+ function parseLaneResultFile(file) {
7
+ const stat = fs.statSync(file);
8
+ if (!stat.isFile() || stat.size > MAX_RESULT_FILE_BYTES)
9
+ return undefined;
10
+ return LaneResultSchema.parse(JSON.parse(fs.readFileSync(file, 'utf-8')));
11
+ }
12
+ /**
13
+ * Resolve a worker's terminal result without trusting arbitrary paths.
14
+ *
15
+ * The protocol filename is exact and remains authoritative. When an agent
16
+ * nevertheless renames it, recover only a UNIQUE schema-valid JSON file at the
17
+ * worktree root (never recursively, never through a symlink), optionally bound
18
+ * to the requested assignment id. Ambiguity is surfaced instead of guessed.
19
+ */
20
+ export function resolveLaneResultFile(worktreePath, assignmentId) {
21
+ const canonicalPath = path.join(worktreePath, LANE_RESULT_FILENAME);
22
+ let foreignCanonical;
23
+ if (fs.existsSync(canonicalPath)) {
24
+ try {
25
+ const lane = parseLaneResultFile(canonicalPath);
26
+ if (!lane) {
27
+ return { kind: 'invalid', path: canonicalPath, error: 'file is not a regular bounded lane-result file' };
28
+ }
29
+ const found = { kind: 'found', path: canonicalPath, lane, canonical: true };
30
+ if (!assignmentId || lane.assignment_id === assignmentId)
31
+ return found;
32
+ foreignCanonical = found;
33
+ }
34
+ catch (err) {
35
+ return {
36
+ kind: 'invalid',
37
+ path: canonicalPath,
38
+ error: err instanceof Error ? err.message : String(err),
39
+ };
40
+ }
41
+ }
42
+ let entries;
43
+ try {
44
+ entries = fs.readdirSync(worktreePath, { withFileTypes: true });
45
+ }
46
+ catch {
47
+ return foreignCanonical ?? { kind: 'absent' };
48
+ }
49
+ const recovered = [];
50
+ for (const entry of entries) {
51
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.json') || entry.name === LANE_RESULT_FILENAME)
52
+ continue;
53
+ const candidatePath = path.join(worktreePath, entry.name);
54
+ try {
55
+ const lane = parseLaneResultFile(candidatePath);
56
+ if (lane && (!assignmentId || lane.assignment_id === assignmentId)) {
57
+ recovered.push({ path: candidatePath, lane });
58
+ }
59
+ }
60
+ catch {
61
+ // Ordinary project JSON and malformed non-canonical files are not lane results.
62
+ }
63
+ }
64
+ if (recovered.length === 1) {
65
+ return { kind: 'found', ...recovered[0], canonical: false };
66
+ }
67
+ if (recovered.length > 1) {
68
+ return { kind: 'ambiguous', paths: recovered.map((item) => item.path).sort() };
69
+ }
70
+ return foreignCanonical ?? { kind: 'absent' };
71
+ }
72
+ //# sourceMappingURL=lane-result-file.js.map
@@ -82,6 +82,7 @@ export async function dispatchLoopTurn(input) {
82
82
  const phaseBrief = buildIdeationBrief({
83
83
  thread: loop,
84
84
  slotRole: slot.role,
85
+ slotPerspective: slot.perspective,
85
86
  memoryProvider: provider,
86
87
  seedText: input.task,
87
88
  scopeHints: slot.scope_hint ? slot.scope_hint.split(',').map((value) => value.trim()) : [],
@@ -180,6 +181,7 @@ export async function dispatchLoopTurn(input) {
180
181
  attempt_epoch: prepared.attempt_epoch,
181
182
  workspace_digest: prepared.workspace_digest,
182
183
  } : undefined,
184
+ artifactType: policy.expected_artifacts?.[0]?.loop_artifact_type,
183
185
  cwd: input.cwd,
184
186
  });
185
187
  const message = sendMessage({
@@ -45,7 +45,7 @@ const LOOP_INTERNAL_CATEGORIES = new Set([
45
45
  'synthesis_artifact',
46
46
  ]);
47
47
  export function buildIdeationBrief(input) {
48
- const { thread, slotRole, memoryProvider, maxChars = DEFAULT_MAX_CHARS, topKPerCategory = DEFAULT_TOP_K_PER_CATEGORY, seedText, scopeHints = [], } = input;
48
+ const { thread, slotRole, slotPerspective, memoryProvider, maxChars = DEFAULT_MAX_CHARS, topKPerCategory = DEFAULT_TOP_K_PER_CATEGORY, seedText, scopeHints = [], } = input;
49
49
  const proposal = findProposalArtifact(thread);
50
50
  const proposalText = seedText?.trim() || proposal?.body?.trim() || '(no proposal seed found)';
51
51
  // Resolve which memory categories the current phase wants. If the
@@ -64,13 +64,15 @@ export function buildIdeationBrief(input) {
64
64
  }
65
65
  }
66
66
  // Loop-internal categories: pulled from thread.artifacts directly.
67
- // critique_history all critique artifacts in iterations < current.
68
- // revision_history all revision artifacts in iterations < current.
67
+ // Critique history includes contributions already made in the current
68
+ // round. Sequential ideation depends on this: participant B challenges A,
69
+ // then C sees both, instead of producing isolated first impressions.
70
+ // Revision history similarly includes the latest available revision.
69
71
  // synthesis_artifact → the most recent synthesis output (if any).
70
72
  const priorArtifactsBlock = includesLoopInternal
71
73
  ? renderPriorArtifactsBlock(thread, requestedCategories)
72
74
  : '';
73
- const header = renderHeader(thread, slotRole, currentPhaseDef?.name ?? thread.current_phase);
75
+ const header = renderHeader(thread, slotRole, currentPhaseDef?.name ?? thread.current_phase, slotPerspective);
74
76
  const proposalBlock = renderProposalBlock(proposalText);
75
77
  const memoryBlock = renderMemoryBlock(fetchedItemsByCategory);
76
78
  const closing = renderClosingInstructions(slotRole, thread.current_phase);
@@ -114,7 +116,7 @@ function expandUserFacingCategories(requested) {
114
116
  // Drop loop-internal categories — they're handled separately.
115
117
  return requested.filter((c) => !LOOP_INTERNAL_CATEGORIES.has(c) && c !== '*');
116
118
  }
117
- function renderHeader(thread, slotRole, phase) {
119
+ function renderHeader(thread, slotRole, phase, perspective) {
118
120
  const lines = [
119
121
  `# ${thread.kind}_loop brief`,
120
122
  `loop: ${thread.id}`,
@@ -125,6 +127,8 @@ function renderHeader(thread, slotRole, phase) {
125
127
  ];
126
128
  if (thread.goal)
127
129
  lines.push(`goal: ${thread.goal}`);
130
+ if (perspective)
131
+ lines.push(`perspective: ${perspective}`);
128
132
  return lines.join('\n');
129
133
  }
130
134
  function normalizeScope(value) {
@@ -168,9 +172,9 @@ function renderPriorArtifactsBlock(thread, requested) {
168
172
  const wantsSynthesis = requested.includes('*') || requested.includes('synthesis_artifact');
169
173
  const sections = [];
170
174
  if (wantsCritique) {
171
- const priorCritique = thread.artifacts.filter((a) => a.type === 'critique' && (a.iteration ?? 0) < thread.iteration_count);
175
+ const priorCritique = thread.artifacts.filter((a) => a.type === 'critique' && (a.iteration ?? 0) <= thread.iteration_count);
172
176
  if (priorCritique.length > 0) {
173
- const lines = ['### critique_history (prior iterations)'];
177
+ const lines = ['### critique_history (conversation so far)'];
174
178
  for (const a of priorCritique) {
175
179
  lines.push(`- [${a.artifact_id}] (iter ${a.iteration ?? 0}) ${truncateLine(a.body)}`);
176
180
  }
@@ -178,7 +182,7 @@ function renderPriorArtifactsBlock(thread, requested) {
178
182
  }
179
183
  }
180
184
  if (wantsRevision) {
181
- const priorRevision = thread.artifacts.filter((a) => a.phase === 'revision' && (a.iteration ?? 0) < thread.iteration_count);
185
+ const priorRevision = thread.artifacts.filter((a) => a.phase === 'revision' && (a.iteration ?? 0) <= thread.iteration_count);
182
186
  if (priorRevision.length > 0) {
183
187
  const lines = ['### revision_history (prior iterations)'];
184
188
  for (const a of priorRevision) {
@@ -200,12 +204,16 @@ function renderPriorArtifactsBlock(thread, requested) {
200
204
  return ['## prior loop artifacts', ...sections].join('\n\n');
201
205
  }
202
206
  function renderClosingInstructions(slotRole, phase) {
203
- return [
207
+ const lines = [
204
208
  `## what to produce`,
205
209
  `- Phase "${phase}" expects you to act in role "${slotRole}".`,
206
210
  `- Emit findings as LoopArtifacts via bclaw_loop intent='complete_turn' or 'add_artifact'.`,
207
- `- Cite the memory ids you relied on so the synthesis can audit coverage.`,
208
- ].join('\n');
211
+ ];
212
+ if (phase === 'critique') {
213
+ lines.push(`- Treat memory items as investigation leads, never as proof that the current worktree still behaves that way.`, `- Verify every finding about the current implementation against the worktree. Cite at least one concrete file path plus a line, symbol, assertion, or test/command result.`, `- If you cannot verify a memory-backed concern in the worktree, label it as an unverified question instead of reporting it as a finding.`);
214
+ }
215
+ lines.push(`- Cite the memory ids you relied on so the synthesis can audit coverage.`);
216
+ return lines.join('\n');
209
217
  }
210
218
  function truncateLine(s, maxLen = 200) {
211
219
  if (!s)