brainclaw 1.17.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 (80) hide show
  1. package/README.md +5 -5
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/commands/code-map.js +4 -1
  4. package/dist/commands/codev.js +61 -30
  5. package/dist/commands/doctor.js +14 -1
  6. package/dist/commands/harvest.js +196 -42
  7. package/dist/commands/inbox.js +10 -4
  8. package/dist/commands/loop.js +2 -2
  9. package/dist/commands/loops-handlers.js +82 -1
  10. package/dist/commands/mcp-catalog.js +12 -4
  11. package/dist/commands/mcp-read-handlers.js +90 -7
  12. package/dist/commands/mcp-schemas.generated.js +3 -0
  13. package/dist/commands/mcp-write-coordination.js +159 -40
  14. package/dist/commands/mcp.js +11 -2
  15. package/dist/core/agentrun-reconciler.js +171 -7
  16. package/dist/core/agentruns.js +6 -1
  17. package/dist/core/code-map/aggregate.js +473 -0
  18. package/dist/core/code-map/backend.js +36 -10
  19. package/dist/core/code-map/freshness.js +36 -1
  20. package/dist/core/code-map/lang/c/imports.scm +12 -0
  21. package/dist/core/code-map/lang/c/index.js +150 -0
  22. package/dist/core/code-map/lang/c/tags.scm +68 -0
  23. package/dist/core/code-map/lang/cpp/imports.scm +14 -0
  24. package/dist/core/code-map/lang/cpp/index.js +149 -0
  25. package/dist/core/code-map/lang/cpp/tags.scm +87 -0
  26. package/dist/core/code-map/lang/csharp/imports.scm +20 -0
  27. package/dist/core/code-map/lang/csharp/index.js +224 -0
  28. package/dist/core/code-map/lang/csharp/tags.scm +63 -0
  29. package/dist/core/code-map/lang/go/imports.scm +13 -0
  30. package/dist/core/code-map/lang/go/index.js +139 -0
  31. package/dist/core/code-map/lang/go/tags.scm +36 -0
  32. package/dist/core/code-map/lang/providers.js +12 -1
  33. package/dist/core/code-map/lang/ruby/imports.scm +24 -0
  34. package/dist/core/code-map/lang/ruby/index.js +198 -0
  35. package/dist/core/code-map/lang/ruby/tags.scm +49 -0
  36. package/dist/core/code-map/lang/rust/imports.scm +44 -0
  37. package/dist/core/code-map/lang/rust/index.js +136 -0
  38. package/dist/core/code-map/lang/rust/tags.scm +47 -0
  39. package/dist/core/code-map/query.js +229 -80
  40. package/dist/core/code-map/types.js +18 -0
  41. package/dist/core/code-map/work-section.js +8 -7
  42. package/dist/core/codev-responses.js +16 -0
  43. package/dist/core/dispatcher.js +176 -22
  44. package/dist/core/execution-adapters.js +29 -3
  45. package/dist/core/ideation-loop-close.js +124 -0
  46. package/dist/core/loops/artifact-resolver.js +197 -0
  47. package/dist/core/loops/attempt-reservation.js +576 -0
  48. package/dist/core/loops/commit-intent.js +494 -0
  49. package/dist/core/loops/facade-schema.js +48 -0
  50. package/dist/core/loops/impl-bind.js +144 -0
  51. package/dist/core/loops/index.js +1 -1
  52. package/dist/core/loops/iteration-engine.js +29 -0
  53. package/dist/core/loops/lock.js +14 -0
  54. package/dist/core/loops/project-resolution.js +157 -0
  55. package/dist/core/loops/reconcile-turn.js +369 -0
  56. package/dist/core/loops/result-reducers.js +88 -0
  57. package/dist/core/loops/store.js +46 -7
  58. package/dist/core/loops/types.js +139 -11
  59. package/dist/core/loops/verbs.js +9 -3
  60. package/dist/core/loops/verify-command.js +209 -0
  61. package/dist/core/messaging.js +58 -5
  62. package/dist/core/review-loop-close.js +5 -2
  63. package/dist/core/review-loop-turn-dispatch.js +290 -28
  64. package/dist/core/runtime-signals.js +68 -0
  65. package/dist/core/schema.js +24 -0
  66. package/dist/core/worktree.js +24 -0
  67. package/dist/facts.js +9 -9
  68. package/dist/facts.json +8 -8
  69. package/dist/wasm/tree-sitter-c.wasm +0 -0
  70. package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
  71. package/dist/wasm/tree-sitter-cpp.wasm +0 -0
  72. package/dist/wasm/tree-sitter-go.wasm +0 -0
  73. package/dist/wasm/tree-sitter-ruby.wasm +0 -0
  74. package/dist/wasm/tree-sitter-rust.wasm +0 -0
  75. package/docs/cli.md +1 -1
  76. package/docs/code-map.md +22 -6
  77. package/docs/concepts/loop-engine.md +24 -0
  78. package/docs/concepts/observer-protocol.md +22 -0
  79. package/docs/mcp-schema-changelog.md +43 -1
  80. package/package.json +1 -1
@@ -34,9 +34,10 @@
34
34
  * @module
35
35
  */
36
36
  import { buildClaimEnvPrefix } from './execution-profile.js';
37
- import { getActiveSequence } from './sequence.js';
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
41
  import { listAgentIdentities, ensureAgentRegisteredForDispatch } from './agent-registry.js';
41
42
  import { sendMessage, hasActiveAssignment } from './messaging.js';
42
43
  import { memoryDir } from './io.js';
@@ -64,14 +65,23 @@ function buildEnvPrefix(claimId) {
64
65
  }
65
66
  // ── Lane Analysis ───────────────────────────────────────────
66
67
  /**
67
- * Analyze the active sequence and categorize each item as ready, active, blocked, or done.
68
+ * Analyze a sequence and categorize each item as ready, active, blocked, or done.
69
+ *
70
+ * `sequenceId` (pln#632 impl-loop bind) targets a SPECIFIC sequence by id instead of
71
+ * the project's active one — so an implementation loop can dispatch its own linked
72
+ * sequence without hijacking the global active-sequence pointer. Omitted → the active
73
+ * sequence (byte-identical to the historical behaviour; the resolver is non-throwing,
74
+ * so an unknown id yields `null` exactly like "no active sequence").
68
75
  */
69
- export function analyzeSequence(cwd) {
70
- const sequence = getActiveSequence(cwd);
76
+ export function analyzeSequence(cwd, sequenceId) {
77
+ const sequence = sequenceId
78
+ ? listSequences(cwd).find((s) => s.id === sequenceId)
79
+ : getActiveSequence(cwd);
71
80
  if (!sequence)
72
81
  return null;
73
82
  const state = loadState(cwd);
74
- const claims = listClaims(cwd).filter(c => c.status === 'active');
83
+ const allClaimsSnapshot = listClaims(cwd);
84
+ const claims = allClaimsSnapshot.filter(c => c.status === 'active');
75
85
  const agents = listAgentIdentities(cwd);
76
86
  // Index plans by ID for fast lookup
77
87
  const planIndex = new Map();
@@ -80,12 +90,36 @@ export function analyzeSequence(cwd) {
80
90
  if (p.short_label)
81
91
  planIndex.set(p.short_label, p);
82
92
  }
83
- // Collect plan IDs that are done or dropped (terminal states)
93
+ // pln#529 index sequence items by planId (scope_hint fallback for branch
94
+ // derivation).
95
+ const itemByPlanId = new Map();
96
+ for (const it of sequence.items)
97
+ itemByPlanId.set(it.planId, it);
98
+ // pln#529 (review Finding 1) — GROUND-TRUTH predecessor branch resolution: a
99
+ // predecessor lane's branch was created by createCoordinatorClaim from its
100
+ // CLAIM scope (which is stable across the coordinate/assign paths + survives a
101
+ // later scope_hint edit + persists on release). Re-deriving from live sequence
102
+ // metadata probes the wrong branch and silently defaults to HEAD. So resolve
103
+ // the predecessor's scope from its persisted claim (any claim for the plan;
104
+ // retries reuse the scope), falling back to the sequence item only when no
105
+ // claim exists.
106
+ const claimByPlanId = new Map();
107
+ for (const c of allClaimsSnapshot) {
108
+ if (c.plan_id)
109
+ claimByPlanId.set(c.plan_id, c);
110
+ }
111
+ const canonicalPlanId = (id) => planIndex.get(id)?.id ?? id;
112
+ const scopeForPred = (predId) => claimByPlanId.get(canonicalPlanId(predId))?.scope ?? itemByPlanId.get(predId)?.scope_hint ?? predId;
113
+ // Collect plan IDs that are done or dropped (terminal → gate-open) and the
114
+ // dropped subset (excluded from socle-fork: never propagate abandoned code —
115
+ // review Finding 6).
84
116
  const terminalPlanIds = new Set();
117
+ const droppedPlanIds = new Set();
85
118
  for (const p of state.plan_items) {
86
- if (p.status === 'done' || p.status === 'dropped') {
119
+ if (p.status === 'done' || p.status === 'dropped')
87
120
  terminalPlanIds.add(p.id);
88
- }
121
+ if (p.status === 'dropped')
122
+ droppedPlanIds.add(p.id);
89
123
  }
90
124
  // Collect plan IDs with active claims
91
125
  const claimedPlanIds = new Map();
@@ -150,16 +184,40 @@ export function analyzeSequence(cwd) {
150
184
  });
151
185
  continue;
152
186
  }
187
+ // pln#529 (dec#122 B+A) — for a gated lane, readiness ≠ code-availability:
188
+ // resolve the fork base by CONTENT. A ≥2-unintegrated diamond keeps the gate
189
+ // CLOSED (A); otherwise the lane is ready with its resolved base (HEAD, or a
190
+ // predecessor branch when the socle isn't on HEAD yet — B).
191
+ if (item.hard_after.length > 0) {
192
+ // Socle-fork considers DONE predecessors only — a dropped predecessor still
193
+ // satisfies the gate but its abandoned code must not be propagated (#6).
194
+ const socleDeps = item.hard_after.filter((id) => !droppedPlanIds.has(canonicalPlanId(id)));
195
+ const base = resolveGatedLaneBase(socleDeps, scopeForPred, cwd);
196
+ if (base.gateBlocked) {
197
+ blocked.push({
198
+ item,
199
+ plan,
200
+ lane: item.lane,
201
+ reason: base.gateBlocked.reason,
202
+ blocked_by: base.gateBlocked.unintegrated,
203
+ });
204
+ continue;
205
+ }
206
+ ready.push({
207
+ item,
208
+ plan,
209
+ lane: item.lane,
210
+ reason: `All hard dependencies met${softNote}`,
211
+ worktreeBase: base,
212
+ code_propagation_note: base.reason,
213
+ });
214
+ continue;
215
+ }
153
216
  ready.push({
154
217
  item,
155
218
  plan,
156
219
  lane: item.lane,
157
220
  reason: `All hard dependencies met${softNote}`,
158
- // pln#529 — readiness ≠ code-availability for gated lanes.
159
- ...(item.hard_after.length > 0 ? {
160
- code_propagation_note: `Unblocked by hard_after [${item.hard_after.join(', ')}]. Ensure that work is committed AND on the dispatch base (HEAD), ` +
161
- `or dispatch this lane with ref=<predecessor branch> — otherwise the worker spawns from HEAD without it.`,
162
- } : {}),
163
221
  });
164
222
  }
165
223
  // Build capacity summary per agent (multi-instance aware)
@@ -631,20 +689,113 @@ function countCycleByResource(cycleAssignments, resourceKey) {
631
689
  }
632
690
  return total;
633
691
  }
634
- export function selectWorktreeBaseForReadyLane(item, analysis) {
635
- const hardAfter = item.hard_after ?? [];
692
+ /**
693
+ * pln#529 (dec#122 B+A) — resolve the fork base for a gated lane whose hard_after
694
+ * predecessors are all DONE (dropped predecessors are excluded by the caller —
695
+ * their abandoned code must not be propagated). "Readiness ≠ code-availability":
696
+ * a done predecessor's code may be committed on its own branch but NOT integrated
697
+ * on HEAD (the standard squash-merge breaks ancestry — trp#926 — so integration
698
+ * is detected by CONTENT via `isBranchMergedByContent`, patch-id + file-content,
699
+ * not ancestry).
700
+ *
701
+ * `scopeFor(predId)` MUST return the GROUND-TRUTH scope the predecessor's branch
702
+ * was created from — its persisted claim scope (review Finding 1). Re-deriving
703
+ * the branch from live/mutable sequence metadata probes the wrong branch under
704
+ * the coordinate(assign) path or an edited scope_hint, and the miss silently
705
+ * defaults to HEAD — the very socle-drop this feature closes.
706
+ *
707
+ * `cwd` MUST be the project's MAIN git worktree (HEAD = the integration target);
708
+ * `analyzeSequence` is the sole production caller and passes the coordinator root.
709
+ *
710
+ * Per predecessor (branch = `feat/<sanitized scope>`), by tri-state probe:
711
+ * - present + content-merged → verified on HEAD;
712
+ * - present + NOT merged → committed-but-unintegrated (fork candidate);
713
+ * - absent (clean not-found) → ASSUMED on HEAD (merged + branch cleaned up) —
714
+ * honestly labelled "assumed", never claimed "verified";
715
+ * - unknown (git probe FAILED) → unverifiable → fail SAFE (gateBlocked), never
716
+ * silently "on HEAD" (review Finding 3).
717
+ * Then: any unverifiable, or ≥2 fork-candidates → gateBlocked (A); exactly 1
718
+ * fork-candidate → fork from it (B); else baseRef HEAD (A satisfied).
719
+ */
720
+ export function resolveGatedLaneBase(hardAfter, scopeFor, cwd) {
636
721
  if (hardAfter.length === 0)
637
722
  return {};
638
- const donePlanIds = new Set(analysis.done.map((entry) => entry.planId));
639
- const allHardDepsDone = hardAfter.every((planId) => donePlanIds.has(planId));
640
- if (!allHardDepsDone)
641
- return {};
723
+ // Non-git project branch/worktree socle propagation is inapplicable; keep the
724
+ // legacy HEAD base (the tri-state "unknown" fail-safe is ONLY for a git repo
725
+ // whose branch probe transiently failed, not for a project that has no git at
726
+ // all — otherwise every non-git gated lane would wrongly gate-block).
727
+ if (!isGitRepo(cwd)) {
728
+ return { baseRef: 'HEAD', resetExistingBranch: true, reason: 'non-git project — socle propagation not applicable; base = HEAD' };
729
+ }
730
+ const unintegrated = [];
731
+ const unverifiable = [];
732
+ const verifiedOnHead = [];
733
+ const assumedOnHead = [];
734
+ for (const predId of hardAfter) {
735
+ const branch = `feat/${sanitizeBranchComponent(scopeFor(predId))}`;
736
+ const probe = probeLocalBranch(cwd, branch);
737
+ if (probe === 'unknown') {
738
+ unverifiable.push({ planId: predId, branch });
739
+ continue;
740
+ }
741
+ if (probe === 'absent') {
742
+ assumedOnHead.push(predId);
743
+ continue;
744
+ } // merged + branch GC'd
745
+ if (isBranchMergedByContent(cwd, branch, 'HEAD')) {
746
+ verifiedOnHead.push(predId);
747
+ continue;
748
+ }
749
+ unintegrated.push({ planId: predId, branch });
750
+ }
751
+ // Fail SAFE: a git probe we could not complete must NOT open the gate on a
752
+ // "HEAD is fine" assumption. Combine with the ≥2-fork-candidate diamond.
753
+ if (unverifiable.length > 0 || unintegrated.length >= 2) {
754
+ const parts = [
755
+ ...unintegrated.map((u) => `${u.planId}→${u.branch} (committed, not on HEAD)`),
756
+ ...unverifiable.map((u) => `${u.planId}→${u.branch} (integration UNVERIFIABLE — git probe failed)`),
757
+ ];
758
+ return {
759
+ gateBlocked: {
760
+ reason: `pln#529(A): cannot safely resolve a single fork base for this gated lane — ${parts.join('; ')}. Integrate the un-integrated predecessors onto HEAD (merge/squash), or retry once git is reachable; a single worktree cannot fork from multiple bases without silently dropping a predecessor's code.`,
761
+ unintegrated: [...unintegrated, ...unverifiable].map((u) => u.planId),
762
+ },
763
+ };
764
+ }
765
+ const headNote = (verb) => `${verb}${verifiedOnHead.length ? ` content-verified on HEAD: ${verifiedOnHead.join(', ')}` : ''}` +
766
+ `${assumedOnHead.length ? `${verifiedOnHead.length ? '; ' : ' '}assumed on HEAD (branch absent — merged + cleaned, unverifiable): ${assumedOnHead.join(', ')}` : ''}`;
767
+ if (unintegrated.length === 1) {
768
+ const u = unintegrated[0];
769
+ return {
770
+ baseRef: u.branch,
771
+ resetExistingBranch: true,
772
+ reason: `pln#529(B): predecessor ${u.planId} is committed on ${u.branch} but not yet integrated on HEAD — the dependent lane forks from that branch so it carries the socle code. (${headNote('Other predecessors:')})`,
773
+ };
774
+ }
642
775
  return {
643
776
  baseRef: 'HEAD',
644
777
  resetExistingBranch: true,
645
- reason: `hard_after dependencies already integrated: ${hardAfter.join(', ')}`,
778
+ reason: `pln#529: ${headNote('hard_after predecessors —')}`,
646
779
  };
647
780
  }
781
+ /**
782
+ * @deprecated pln#529 — superseded by `resolveGatedLaneBase` (content + claim
783
+ * aware). Retained for callers that only have `(item, analysis)`; forwards using
784
+ * the analysis's done set for scope fallback (no claim access). Prefer the
785
+ * pre-computed `ReadyLane.worktreeBase`.
786
+ */
787
+ export function selectWorktreeBaseForReadyLane(item, analysis, cwd = process.cwd()) {
788
+ const hardAfter = item.hard_after ?? [];
789
+ if (hardAfter.length === 0)
790
+ return {};
791
+ const donePlanIds = new Set(analysis.done.map((entry) => entry.planId));
792
+ if (!hardAfter.every((planId) => donePlanIds.has(planId)))
793
+ return {};
794
+ const itemByPlanId = new Map();
795
+ for (const entry of analysis.done)
796
+ itemByPlanId.set(entry.planId, entry);
797
+ return resolveGatedLaneBase(hardAfter, (predId) => itemByPlanId.get(predId)?.scope_hint ?? predId, cwd);
798
+ }
648
799
  /**
649
800
  * Run a dispatch cycle: analyze the sequence, generate briefs, send assignments.
650
801
  */
@@ -654,7 +805,7 @@ export async function dispatch(options, cwd) {
654
805
  sweepAssignments(cwd, { actor: options.dispatcherAgent });
655
806
  }
656
807
  catch { /* best-effort */ }
657
- const analysis = analyzeSequence(cwd);
808
+ const analysis = analyzeSequence(cwd, options.sequenceId);
658
809
  if (!analysis)
659
810
  return null;
660
811
  const result = { delivery_plan: [], messages_sent: [], commands: [], skipped: [], warnings: [] };
@@ -728,7 +879,10 @@ export async function dispatch(options, cwd) {
728
879
  let claimId = '(dry-run)';
729
880
  let worktreePath;
730
881
  if (!options.dryRun) {
731
- const worktreeBase = selectWorktreeBaseForReadyLane(readyItem.item, analysis);
882
+ // pln#529 use the content-aware base resolved during analyzeSequence
883
+ // (HEAD when the socle is integrated, else the predecessor branch). Fall
884
+ // back to a fresh resolution for direct callers that bypassed analyze.
885
+ const worktreeBase = readyItem.worktreeBase ?? selectWorktreeBaseForReadyLane(readyItem.item, analysis, cwd);
732
886
  const claimResult = createCoordinatorClaim({
733
887
  agent: targetAgent,
734
888
  scope: claimScope,
@@ -5,13 +5,39 @@ import { buildClaimEnvPrefix, buildWorkerIdentityEnv } from './execution-profile
5
5
  import { getCapabilityProfile } from './agent-capability.js';
6
6
  import { nowISO } from './ids.js';
7
7
  import { ensureRuntimeDirs, getRuntimeLogPath, getRuntimeSignalPath, } from './runtime-signals.js';
8
- export function buildAckWrapCommand(bashCommand, paths, isWin32) {
8
+ // The turn-echo values are raw-embedded into a shell one-liner (see marker()),
9
+ // so the `[A-Za-z0-9_-]` safety invariant documented on TurnEcho is LOAD-BEARING,
10
+ // not cosmetic. A stray `"` desyncs cmd.exe quote-parity (no sentinel file is
11
+ // written → the turn-owned run never converges under read-strict acceptance —
12
+ // exactly the §13 D2 non-convergence this feature prevents); a `'` breaks out of
13
+ // the POSIX `printf '…'` wrapper. All real sources (deriveTurnId/deriveChildIds
14
+ // hex, crypto.randomUUID nonce) satisfy it, so this guard never fires in
15
+ // production — it exists to turn a future out-of-class caller's SILENT corruption
16
+ // into a loud, fast failure at the embed site.
17
+ const TURN_ECHO_SAFE = /^[A-Za-z0-9_-]+$/;
18
+ export function buildAckWrapCommand(bashCommand, paths, isWin32, turnEcho) {
19
+ if (turnEcho) {
20
+ for (const [field, value] of Object.entries(turnEcho)) {
21
+ if (!TURN_ECHO_SAFE.test(value)) {
22
+ throw new Error(`buildAckWrapCommand: turnEcho.${field} must match ${TURN_ECHO_SAFE} to be shell-safe for the completion sentinel (got ${JSON.stringify(value)})`);
23
+ }
24
+ }
25
+ }
9
26
  const touch = isWin32
10
27
  ? (p) => `type nul > "${p}"`
11
28
  : (p) => `touch "${p}"`;
29
+ // completed/failed marker: a turn-keyed JSON body when turnEcho is present,
30
+ // else the legacy empty touch (byte-for-byte unchanged for non-turn-owned
31
+ // spawns — full back-compat).
32
+ const marker = (p, status) => {
33
+ if (!turnEcho)
34
+ return touch(p);
35
+ const body = JSON.stringify({ turn_id: turnEcho.turn_id, run_id: turnEcho.run_id, nonce: turnEcho.nonce, status });
36
+ return isWin32 ? `echo ${body}>"${p}"` : `printf '%s' '${body}' > "${p}"`;
37
+ };
12
38
  const redirected = `${bashCommand} > "${paths.stdoutLog}" 2> "${paths.stderrLog}"`;
13
39
  return (`${touch(paths.ackPath)} && ` +
14
- `( ${redirected} && ${touch(paths.completedPath)} || ${touch(paths.failedPath)} )`);
40
+ `( ${redirected} && ${marker(paths.completedPath, 'completed')} || ${marker(paths.failedPath, 'failed')} )`);
15
41
  }
16
42
  /**
17
43
  * Check if a binary is resolvable on the system PATH.
@@ -148,7 +174,7 @@ export class CliExecutionAdapter {
148
174
  failedPath: getRuntimeSignalPath(signalRoot, options.assignmentId, 'failed'),
149
175
  stdoutLog: getRuntimeLogPath(signalRoot, options.assignmentId, 'stdout'),
150
176
  stderrLog: getRuntimeLogPath(signalRoot, options.assignmentId, 'stderr'),
151
- }, isWin32);
177
+ }, isWin32, options.turnEcho);
152
178
  child = spawn(wrappedCmd, [], {
153
179
  detached: !isWin32,
154
180
  shell: true,
@@ -0,0 +1,124 @@
1
+ import { getLoop } from './loops/store.js';
2
+ import { complete_turn, advance, evaluatePhaseAdvanceGate } from './loops/verbs.js';
3
+ import { withLoopLock } from './loops/lock.js';
4
+ import { LOOP_ARTIFACT_BODY_MAX_BYTES } from './loops/types.js';
5
+ /** ideate-loop:lop_xxx[:slot] → the loop id (dispatch sets `ideate-loop:${loopId}:${slotId}`). */
6
+ const IDEATE_LOOP_SCOPE_RE = /^ideate-loop:(lop_[0-9a-z]+)/;
7
+ const LOOP_TERMINAL = new Set(['completed', 'cancelled', 'blocked']);
8
+ /** Byte-cap a critique body (keep the head) so complete_turn's 4 KiB artifact-body limit
9
+ * can't reject a long critique. Leaves envelope headroom for the artifact JSON. */
10
+ function capCritique(body) {
11
+ const MAX = LOOP_ARTIFACT_BODY_MAX_BYTES - 512;
12
+ if (Buffer.byteLength(body, 'utf8') <= MAX)
13
+ return body;
14
+ let end = body.length;
15
+ while (end > 0 && Buffer.byteLength(body.slice(0, end), 'utf8') > MAX)
16
+ end -= 64;
17
+ return `${body.slice(0, Math.max(0, end))}…[truncated]`;
18
+ }
19
+ /**
20
+ * Resolve the critic slot to complete. STRICT by assignment_id (bound since pln#629), so
21
+ * multi-critic loops complete the right slot; never steal a slot bound to a DIFFERENT
22
+ * assignment. Legacy unbound slots fall back to agent / single-active.
23
+ */
24
+ function resolveCriticSlot(loop, assignment) {
25
+ // role === 'critic' is LOAD-BEARING (review F-A): a coordinate-opened ideation loop
26
+ // also has an unbound `champion` slot that lane-harvest never completes; without this
27
+ // filter the single-active fallback below would select the CHAMPION after the critics
28
+ // finish, corrupting the loop. Mirrors resolveReviewerSlot's role filter.
29
+ const active = loop.slots.filter((s) => s.role === 'critic' && s.status !== 'done' && s.status !== 'cancelled' && s.status !== 'failed');
30
+ if (active.length === 0)
31
+ return undefined;
32
+ if (assignment.id) {
33
+ const bound = active.find((s) => s.assignment_id === assignment.id);
34
+ if (bound)
35
+ return bound;
36
+ if (active.some((s) => s.assignment_id !== undefined))
37
+ return undefined; // bound elsewhere → don't steal
38
+ }
39
+ if (active.length === 1)
40
+ return active[0];
41
+ const byAgent = assignment.agent ? active.find((s) => s.agent === assignment.agent) : undefined;
42
+ return byAgent ?? active[0];
43
+ }
44
+ /**
45
+ * Map a harvested critic lane onto its ideation loop and converge it. Fires ONLY on an
46
+ * `ideate-loop:<lop>` scope + a completed lane; otherwise returns undefined and harvest
47
+ * proceeds unchanged. Idempotent (a terminal/absent slot → noop), defensive (any
48
+ * loop-verb / lock error is swallowed into a noop so a convergence failure never breaks
49
+ * harvest — mirrors closeReviewLoopFromLaneResult).
50
+ */
51
+ export function closeIdeationLoopFromLaneResult(assignment, lane, actor, cwd) {
52
+ const m = assignment.scope?.match(IDEATE_LOOP_SCOPE_RE);
53
+ if (!m)
54
+ return undefined;
55
+ if (lane.status !== 'completed')
56
+ return undefined; // only a completed critic converges
57
+ const loopId = m[1];
58
+ const noop = (reason, loop_status) => ({
59
+ loop_id: loopId, action: 'noop', reason, loop_status,
60
+ });
61
+ try {
62
+ return withLoopLock({
63
+ cwd, intent: 'ideate-harvest-close', agentId: actor, scope: { kind: 'loop', loopId },
64
+ work: () => {
65
+ const loop = getLoop(loopId, cwd);
66
+ if (!loop)
67
+ return noop('loop not found');
68
+ if (LOOP_TERMINAL.has(loop.status))
69
+ return noop(`loop already ${loop.status}`, loop.status);
70
+ // Advance, treating ONLY phase_advance_blocked as the expected gate-not-met case;
71
+ // re-throw any OTHER advance error to the outer catch so a real failure becomes a
72
+ // noop carrying the actual message, NEVER a misreported success (review F-C).
73
+ const tryAdvance = (recorded) => {
74
+ try {
75
+ const advanced = advance({ id: loopId, actor }, cwd);
76
+ return {
77
+ loop_id: loopId,
78
+ action: advanced.auto_closed ? 'closed' : 'advanced',
79
+ reason: `critique gate met → phase "${advanced.loop.current_phase}"`,
80
+ loop_status: advanced.loop.status,
81
+ };
82
+ }
83
+ catch (err) {
84
+ if (err instanceof Error && /phase_advance_blocked/.test(err.message)) {
85
+ return recorded
86
+ ? { loop_id: loopId, action: 'critique_recorded', reason: 'critique recorded; gate not yet met (more critics needed)', loop_status: getLoop(loopId, cwd)?.status }
87
+ : noop('no active critic slot; critique gate not yet met (idempotent)', getLoop(loopId, cwd)?.status);
88
+ }
89
+ throw err; // a REAL advance error → outer catch → noop with the message
90
+ }
91
+ };
92
+ const slot = resolveCriticSlot(loop, assignment);
93
+ if (!slot) {
94
+ // No active critic slot: either already processed, OR a prior pass recorded the
95
+ // critique(s) and crashed BEFORE advancing → the loop is stuck at a satisfied
96
+ // critique gate. RESUME only in that precise case (review F-B) — the current
97
+ // phase's gate must be a critique gate that now evaluates MET — so we never
98
+ // over-advance a loop that already moved on to revision/synthesis.
99
+ const gate = loop.phases.find((p) => p.name === loop.current_phase)?.advance_gate;
100
+ const stuckAtCritiqueGate = gate?.kind === 'min_artifacts_by_type' && gate.type === 'critique' && evaluatePhaseAdvanceGate(loop, gate).advance;
101
+ return stuckAtCritiqueGate ? tryAdvance(false) : noop('no active critic slot; nothing to resume', loop.status);
102
+ }
103
+ // A critic's LANE-RESULT carries free-form summary/notes (no structured
104
+ // critiques[] field) → ONE critique artifact. A bare lane with no critique
105
+ // content FAILS the slot (mirror ideationReducer: no fake gate progress).
106
+ const critique = [lane.summary, lane.notes]
107
+ .map((s) => (s ?? '').trim())
108
+ .filter(Boolean)
109
+ .join('\n\n')
110
+ .trim();
111
+ if (!critique) {
112
+ complete_turn({ id: loopId, slot_id: slot.slot_id, actor, outcome: 'failed', failure_reason: 'critic lane produced no critique content (bare summary)' }, cwd);
113
+ return { loop_id: loopId, action: 'failed', reason: 'bare critic lane → slot failed; critique gate unchanged', loop_status: getLoop(loopId, cwd)?.status };
114
+ }
115
+ complete_turn({ id: loopId, slot_id: slot.slot_id, actor, outcome: 'done', artifact: { phase: loop.current_phase, type: 'critique', body: capCritique(critique) } }, cwd);
116
+ return tryAdvance(true);
117
+ },
118
+ });
119
+ }
120
+ catch (err) {
121
+ return noop(`ideation close error (swallowed): ${err instanceof Error ? err.message : String(err)}`);
122
+ }
123
+ }
124
+ //# sourceMappingURL=ideation-loop-close.js.map
@@ -0,0 +1,197 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { memoryDir } from '../io.js';
5
+ /**
6
+ * Safe canonical artifact resolver (pln#630 §7).
7
+ *
8
+ * The single central resolver every loop-artifact reader/writer must use. It
9
+ * replaces the ad-hoc `path.join(dir, body.ref)` (hooks/bootstrap-write.ts) that
10
+ * joined a WORKER-CONTROLLED `ref` straight onto a store dir — a path-traversal
11
+ * hole (`ref: "../../../etc/passwd"` escaped the artifacts dir).
12
+ *
13
+ * The safety protocol, mandatory before any state mutation (§7):
14
+ * 1. Brainclaw-generated target basenames — `<artifact_id>.<ext>`, never a
15
+ * worker-supplied name.
16
+ * 2. Worker source paths validated by `realpath` CONTAINMENT (reject `../`
17
+ * escapes and symlink-out) before any read.
18
+ * 3. Atomic temp-copy + fsync + rename into the canonical store.
19
+ * 4. size + sha256 validation against the attempt's expected_artifacts.
20
+ * 5. Deterministic (artifact_id-keyed) target + hash check = per-turn
21
+ * idempotency: a crash between copy and the artifact/event write retries
22
+ * without duplicating (re-copy of an identical payload is a no-op).
23
+ *
24
+ * Canonical home (unifies the two conflicting doc paths §7):
25
+ * .brainclaw/loops/artifacts/<lop_id>/<artifact_id>.<ext>
26
+ * Migration is new-then-legacy on READ, reject-on-hash-mismatch; writes go to the
27
+ * new path only.
28
+ */
29
+ export class ArtifactResolverError extends Error {
30
+ code;
31
+ constructor(code, message) {
32
+ super(message);
33
+ this.code = code;
34
+ this.name = 'ArtifactResolverError';
35
+ }
36
+ }
37
+ /** Legacy on-disk home for ref-based payloads (pre-§7). Read fallback only. */
38
+ function legacyArtifactsDir(loopId, cwd) {
39
+ return path.join(memoryDir(cwd ?? process.cwd()), 'loops', 'threads', loopId, 'artifacts');
40
+ }
41
+ /** Canonical home for a loop's artifact payloads (§7). */
42
+ export function canonicalArtifactsDir(loopId, cwd) {
43
+ return path.join(memoryDir(cwd ?? process.cwd()), 'loops', 'artifacts', loopId);
44
+ }
45
+ /**
46
+ * The canonical absolute path for a brainclaw-owned artifact payload. The
47
+ * basename is derived ENTIRELY from brainclaw-generated ids (never a worker
48
+ * string), so it cannot traverse. `ext` is sanitized to a bare alnum extension.
49
+ */
50
+ export function canonicalArtifactPath(loopId, artifactId, ext, cwd) {
51
+ const safeExt = ext.replace(/^\.+/, '').replace(/[^A-Za-z0-9]/g, '') || 'txt';
52
+ return path.join(canonicalArtifactsDir(loopId, cwd), `${artifactId}.${safeExt}`);
53
+ }
54
+ /**
55
+ * Validate that a worker-relative path resolves to a real file CONTAINED within
56
+ * `workerRoot` (no `../` escape, no symlink pointing outside). Returns the
57
+ * validated absolute path; throws `containment_violation` / `source_missing`
58
+ * otherwise. This is the mandatory gate before ANY read of a worker-produced
59
+ * artifact (§7 / invariant #7).
60
+ */
61
+ export function resolveContainedWorkerPath(workerRoot, workerRelPath, _cwd) {
62
+ // realpath the containment ROOT first (it must exist and be a directory).
63
+ let rootReal;
64
+ try {
65
+ rootReal = fs.realpathSync(workerRoot);
66
+ }
67
+ catch {
68
+ throw new ArtifactResolverError('source_missing', `resolveContainedWorkerPath: worker root ${workerRoot} does not resolve`);
69
+ }
70
+ // Reject an absolute worker path outright — an expected artifact is always
71
+ // worker-RELATIVE; an absolute path is a red flag we never join.
72
+ if (path.isAbsolute(workerRelPath)) {
73
+ throw new ArtifactResolverError('containment_violation', `resolveContainedWorkerPath: absolute worker path "${workerRelPath}" rejected`);
74
+ }
75
+ const joined = path.resolve(rootReal, workerRelPath);
76
+ // Lexical containment check on the joined path BEFORE touching the FS (guards
77
+ // the case where the target itself does not exist yet).
78
+ const rootWithSep = rootReal.endsWith(path.sep) ? rootReal : rootReal + path.sep;
79
+ if (joined !== rootReal && !joined.startsWith(rootWithSep)) {
80
+ throw new ArtifactResolverError('containment_violation', `resolveContainedWorkerPath: "${workerRelPath}" escapes worker root`);
81
+ }
82
+ // realpath the target and re-check containment — defeats a symlink inside the
83
+ // root that points outside it (lexical check alone would pass).
84
+ let targetReal;
85
+ try {
86
+ targetReal = fs.realpathSync(joined);
87
+ }
88
+ catch {
89
+ throw new ArtifactResolverError('source_missing', `resolveContainedWorkerPath: "${workerRelPath}" does not resolve to a file under the worker root`);
90
+ }
91
+ if (targetReal !== rootReal && !targetReal.startsWith(rootWithSep)) {
92
+ throw new ArtifactResolverError('containment_violation', `resolveContainedWorkerPath: "${workerRelPath}" resolves (via symlink) outside the worker root`);
93
+ }
94
+ return targetReal;
95
+ }
96
+ function sha256OfFile(absPath) {
97
+ const buf = fs.readFileSync(absPath);
98
+ return { sha256: crypto.createHash('sha256').update(buf).digest('hex'), byte_count: buf.length };
99
+ }
100
+ /**
101
+ * Copy a containment-validated worker source into the canonical store — atomically
102
+ * (temp + fsync + rename), with size/sha256 validation and per-turn idempotency
103
+ * (§7). Idempotent: if the deterministic target already holds the same bytes, this
104
+ * is a no-op; if it holds DIFFERENT bytes, that is a hard `canonical_hash_conflict`
105
+ * (a deterministic-id collision or corruption — never silently overwrite).
106
+ */
107
+ export function copyArtifactToCanonicalStore(input) {
108
+ const { loopId, artifactId, ext, sourceAbsPath, expectedSha256, expectedByteCount, cwd } = input;
109
+ if (!fs.existsSync(sourceAbsPath)) {
110
+ throw new ArtifactResolverError('source_missing', `copyArtifactToCanonicalStore: source ${sourceAbsPath} missing`);
111
+ }
112
+ // Read the source EXACTLY ONCE (review Finding 4): hash + validate + write the
113
+ // SAME buffer, so a source mutation between a validate-read and a copy-read can
114
+ // never let bytes whose hash differs from the reported/validated sha256 become
115
+ // canonical state.
116
+ const buf = fs.readFileSync(sourceAbsPath);
117
+ const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
118
+ const byte_count = buf.length;
119
+ // Validate against the attempt's declared expectations BEFORE any write.
120
+ if (expectedSha256 !== undefined && expectedSha256 !== sha256) {
121
+ throw new ArtifactResolverError('sha256_mismatch', `copyArtifactToCanonicalStore: sha256 ${sha256} != expected ${expectedSha256}`);
122
+ }
123
+ if (expectedByteCount !== undefined && expectedByteCount !== byte_count) {
124
+ throw new ArtifactResolverError('byte_count_mismatch', `copyArtifactToCanonicalStore: byte_count ${byte_count} != expected ${expectedByteCount}`);
125
+ }
126
+ const canonicalPath = canonicalArtifactPath(loopId, artifactId, ext, cwd);
127
+ // Idempotency: a matching target is a no-op; a mismatching target is a conflict.
128
+ if (fs.existsSync(canonicalPath)) {
129
+ const existing = sha256OfFile(canonicalPath);
130
+ if (existing.sha256 === sha256) {
131
+ return { canonicalPath, sha256, byte_count, idempotent: true };
132
+ }
133
+ throw new ArtifactResolverError('canonical_hash_conflict', `copyArtifactToCanonicalStore: ${canonicalPath} already exists with a DIFFERENT hash (${existing.sha256} vs ${sha256}) — refusing to overwrite`);
134
+ }
135
+ const dir = path.dirname(canonicalPath);
136
+ fs.mkdirSync(dir, { recursive: true });
137
+ // Atomic temp-copy + fsync + rename of the ALREADY-HASHED buffer. The temp name
138
+ // is process/id-scoped so concurrent copies of distinct artifacts never collide.
139
+ const tmpPath = path.join(dir, `.${artifactId}.${process.pid}.tmp`);
140
+ const fd = fs.openSync(tmpPath, 'w');
141
+ try {
142
+ let off = 0;
143
+ while (off < buf.length)
144
+ off += fs.writeSync(fd, buf, off, buf.length - off);
145
+ fs.fsyncSync(fd);
146
+ }
147
+ finally {
148
+ fs.closeSync(fd);
149
+ }
150
+ try {
151
+ fs.renameSync(tmpPath, canonicalPath);
152
+ }
153
+ catch (err) {
154
+ // A racing writer may have created the target between our existence check and
155
+ // the rename. Re-check idempotency rather than clobbering.
156
+ fs.rmSync(tmpPath, { force: true });
157
+ if (fs.existsSync(canonicalPath) && sha256OfFile(canonicalPath).sha256 === sha256) {
158
+ return { canonicalPath, sha256, byte_count, idempotent: true };
159
+ }
160
+ throw err;
161
+ }
162
+ return { canonicalPath, sha256, byte_count, idempotent: false };
163
+ }
164
+ /**
165
+ * Read an artifact payload from the canonical store, falling back to the legacy
166
+ * `loops/threads/<loop_id>/artifacts/<ref>` path for pre-§7 artifacts. When BOTH
167
+ * exist, their hashes MUST match (reject-on-mismatch migration safety §7). An
168
+ * `expectedSha256` is validated against whichever copy is returned.
169
+ */
170
+ export function readCanonicalArtifact(loopId, artifactId, ext, opts = {}) {
171
+ const { legacyRef, expectedSha256, cwd } = opts;
172
+ const canonicalPath = canonicalArtifactPath(loopId, artifactId, ext, cwd);
173
+ const legacyPath = legacyRef ? path.join(legacyArtifactsDir(loopId, cwd), legacyRef) : undefined;
174
+ const canonicalExists = fs.existsSync(canonicalPath);
175
+ const legacyExists = legacyPath !== undefined && fs.existsSync(legacyPath);
176
+ if (!canonicalExists && !legacyExists) {
177
+ throw new ArtifactResolverError('artifact_missing', `readCanonicalArtifact: ${artifactId} not found (canonical nor legacy)`);
178
+ }
179
+ if (canonicalExists && legacyExists) {
180
+ // Migration overlap — both must agree, else refuse (never trust a divergent legacy copy).
181
+ const c = sha256OfFile(canonicalPath);
182
+ const l = sha256OfFile(legacyPath);
183
+ if (c.sha256 !== l.sha256) {
184
+ throw new ArtifactResolverError('canonical_hash_conflict', `readCanonicalArtifact: ${artifactId} canonical/legacy hash mismatch (${c.sha256} vs ${l.sha256})`);
185
+ }
186
+ }
187
+ const readPath = canonicalExists ? canonicalPath : legacyPath;
188
+ const buf = fs.readFileSync(readPath);
189
+ if (expectedSha256 !== undefined) {
190
+ const actual = crypto.createHash('sha256').update(buf).digest('hex');
191
+ if (actual !== expectedSha256) {
192
+ throw new ArtifactResolverError('sha256_mismatch', `readCanonicalArtifact: ${artifactId} sha256 ${actual} != expected ${expectedSha256}`);
193
+ }
194
+ }
195
+ return buf;
196
+ }
197
+ //# sourceMappingURL=artifact-resolver.js.map