brainclaw 1.17.0 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/commands/code-map.js +4 -1
- package/dist/commands/codev.js +61 -30
- package/dist/commands/doctor.js +14 -1
- package/dist/commands/harvest.js +223 -43
- package/dist/commands/inbox.js +10 -4
- package/dist/commands/install-hooks.js +184 -27
- package/dist/commands/loop.js +2 -2
- package/dist/commands/loops-handlers.js +82 -1
- package/dist/commands/mcp-catalog.js +12 -4
- package/dist/commands/mcp-read-handlers.js +90 -7
- package/dist/commands/mcp-schemas.generated.js +3 -0
- package/dist/commands/mcp-write-claims.js +57 -0
- package/dist/commands/mcp-write-coordination.js +216 -57
- package/dist/commands/mcp-write-entities.js +11 -0
- package/dist/commands/mcp.js +29 -2
- package/dist/commands/session-end.js +15 -0
- package/dist/commands/session-start.js +19 -0
- package/dist/core/agentrun-reconciler.js +171 -7
- package/dist/core/agentruns.js +6 -1
- package/dist/core/claim-conformity.js +193 -0
- package/dist/core/claim-scope.js +155 -0
- package/dist/core/claims.js +127 -2
- package/dist/core/code-map/aggregate.js +473 -0
- package/dist/core/code-map/backend.js +36 -10
- package/dist/core/code-map/freshness.js +36 -1
- package/dist/core/code-map/lang/c/imports.scm +12 -0
- package/dist/core/code-map/lang/c/index.js +150 -0
- package/dist/core/code-map/lang/c/tags.scm +68 -0
- package/dist/core/code-map/lang/cpp/imports.scm +14 -0
- package/dist/core/code-map/lang/cpp/index.js +149 -0
- package/dist/core/code-map/lang/cpp/tags.scm +87 -0
- package/dist/core/code-map/lang/csharp/imports.scm +20 -0
- package/dist/core/code-map/lang/csharp/index.js +224 -0
- package/dist/core/code-map/lang/csharp/tags.scm +63 -0
- package/dist/core/code-map/lang/go/imports.scm +13 -0
- package/dist/core/code-map/lang/go/index.js +139 -0
- package/dist/core/code-map/lang/go/tags.scm +36 -0
- package/dist/core/code-map/lang/providers.js +12 -1
- package/dist/core/code-map/lang/ruby/imports.scm +24 -0
- package/dist/core/code-map/lang/ruby/index.js +198 -0
- package/dist/core/code-map/lang/ruby/tags.scm +49 -0
- package/dist/core/code-map/lang/rust/imports.scm +44 -0
- package/dist/core/code-map/lang/rust/index.js +136 -0
- package/dist/core/code-map/lang/rust/tags.scm +47 -0
- package/dist/core/code-map/query.js +229 -80
- package/dist/core/code-map/types.js +18 -0
- package/dist/core/code-map/work-section.js +8 -7
- package/dist/core/codev-responses.js +16 -0
- package/dist/core/dispatcher.js +176 -22
- package/dist/core/execution-adapters.js +29 -3
- package/dist/core/facade-schema.js +32 -0
- package/dist/core/guidance-telemetry.js +197 -0
- package/dist/core/ideation-loop-close.js +152 -0
- package/dist/core/instruction-templates.js +11 -3
- package/dist/core/loops/artifact-resolver.js +197 -0
- package/dist/core/loops/attempt-reservation.js +576 -0
- package/dist/core/loops/commit-intent.js +494 -0
- package/dist/core/loops/facade-schema.js +48 -0
- package/dist/core/loops/impl-bind.js +144 -0
- package/dist/core/loops/index.js +1 -1
- package/dist/core/loops/iteration-engine.js +29 -0
- package/dist/core/loops/lock.js +14 -0
- package/dist/core/loops/project-resolution.js +157 -0
- package/dist/core/loops/reconcile-turn.js +369 -0
- package/dist/core/loops/result-reducers.js +88 -0
- package/dist/core/loops/store.js +46 -7
- package/dist/core/loops/types.js +139 -11
- package/dist/core/loops/verbs.js +49 -4
- package/dist/core/loops/verify-command.js +209 -0
- package/dist/core/messaging.js +58 -5
- package/dist/core/next-actions.js +157 -0
- package/dist/core/review-loop-close.js +27 -6
- package/dist/core/review-loop-turn-dispatch.js +290 -28
- package/dist/core/runtime-signals.js +68 -0
- package/dist/core/schema.js +64 -0
- package/dist/core/surface-freshness.js +150 -0
- package/dist/core/warnings.js +98 -0
- package/dist/core/worktree.js +24 -0
- package/dist/facts.js +9 -9
- package/dist/facts.json +8 -8
- package/dist/wasm/tree-sitter-c.wasm +0 -0
- package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
- package/dist/wasm/tree-sitter-cpp.wasm +0 -0
- package/dist/wasm/tree-sitter-go.wasm +0 -0
- package/dist/wasm/tree-sitter-ruby.wasm +0 -0
- package/dist/wasm/tree-sitter-rust.wasm +0 -0
- package/docs/cli.md +1 -1
- package/docs/code-map.md +22 -6
- package/docs/concepts/loop-engine.md +24 -0
- package/docs/concepts/observer-protocol.md +22 -0
- package/docs/concepts/plans-and-claims.md +57 -0
- package/docs/integrations/claude-code.md +53 -0
- package/docs/integrations/mcp.md +45 -0
- package/docs/mcp-schema-changelog.md +118 -2
- package/package.json +1 -1
package/dist/core/dispatcher.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
635
|
-
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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: `
|
|
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
|
-
|
|
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
|
-
|
|
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} && ${
|
|
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,
|
|
@@ -167,6 +167,26 @@ export const NextActionSchema = z.object({
|
|
|
167
167
|
/** When this action applies, e.g. "when implementation is complete". */
|
|
168
168
|
when: z.string().optional(),
|
|
169
169
|
});
|
|
170
|
+
/**
|
|
171
|
+
* pln#635 — structured warning. ADDITIVE sibling of `warnings: string[]`, which
|
|
172
|
+
* keeps its type and its exact historical contents (the legacy string is
|
|
173
|
+
* derived from this record — see core/warnings.ts). Five handler sites were
|
|
174
|
+
* already encoding structure into a string via JSON.stringify because there was
|
|
175
|
+
* nowhere else to put it; this is that nowhere.
|
|
176
|
+
*
|
|
177
|
+
* `next_actions` is what the string channel could never carry: the recovery
|
|
178
|
+
* path. A warning an agent cannot act on is just noise it learns to skip.
|
|
179
|
+
*/
|
|
180
|
+
export const WarningDetailSchema = z.object({
|
|
181
|
+
/** Stable machine-readable identifier, e.g. "scope_already_claimed". */
|
|
182
|
+
code: z.string(),
|
|
183
|
+
/** Human-readable prose. Also the legacy string for non-JSON codes. */
|
|
184
|
+
message: z.string(),
|
|
185
|
+
/** Structured payload (ids, agents, scopes) the prose mentions. */
|
|
186
|
+
data: z.record(z.string(), z.unknown()).optional(),
|
|
187
|
+
/** How to resolve it — same contract as the response-level next_actions. */
|
|
188
|
+
next_actions: z.array(NextActionSchema).optional(),
|
|
189
|
+
});
|
|
170
190
|
export const FacadeResponseSchema = z.object({
|
|
171
191
|
status: z.enum(['ok', 'error', 'partial']),
|
|
172
192
|
intent: z.string(),
|
|
@@ -222,6 +242,18 @@ export const FacadeResponseSchema = z.object({
|
|
|
222
242
|
* remains for the bootstrap hint; new consumers should read this array.
|
|
223
243
|
*/
|
|
224
244
|
next_actions: z.array(NextActionSchema).optional(),
|
|
245
|
+
/**
|
|
246
|
+
* pln#635 — structured warnings carrying a stable `code`, the `data` the prose
|
|
247
|
+
* refers to, and the recovery `next_actions`. Optional and additive:
|
|
248
|
+
* `warnings` keeps byte-identical contents, so a consumer ignoring this field
|
|
249
|
+
* is unaffected.
|
|
250
|
+
*
|
|
251
|
+
* This is a structured **SUBSET**, not a mirror — `warnings` remains the
|
|
252
|
+
* complete channel (see core/warnings.ts for why: handlers thread the string
|
|
253
|
+
* array into helpers by reference). Read `warnings` for completeness; read
|
|
254
|
+
* `warning_details` for the codes that carry a recovery path.
|
|
255
|
+
*/
|
|
256
|
+
warning_details: z.array(WarningDetailSchema).optional(),
|
|
225
257
|
/**
|
|
226
258
|
* Code Map P0 (spec §10): opt-in, present ONLY when the project's Code Map
|
|
227
259
|
* manifest carries `code_map_enabled: true`. Absent for every project that
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pln#634 PR2 — guidance adherence telemetry.
|
|
3
|
+
*
|
|
4
|
+
* brainclaw emits `next_actions` and `warning_details[].next_actions` on more and
|
|
5
|
+
* more surfaces (PR1, pln#635) but has never measured whether an agent's NEXT
|
|
6
|
+
* call follows the suggestion. Without that number the whole guidance backlog
|
|
7
|
+
* (pln#636/#637/#638) is prioritised on opinion: we cannot tell "the signal is
|
|
8
|
+
* missing" from "the signal is ignored", and those two diagnoses have opposite
|
|
9
|
+
* remedies — add more channels vs. stop adding channels and converge state
|
|
10
|
+
* server-side instead.
|
|
11
|
+
*
|
|
12
|
+
* MECHANISM. `executeMcpToolCall` is the single seam every MCP call passes
|
|
13
|
+
* through. After a response is built we remember which tools it suggested; on
|
|
14
|
+
* the next call in the same session we compare. One observation per
|
|
15
|
+
* suggestion→call pair.
|
|
16
|
+
*
|
|
17
|
+
* WHAT IS RECORDED: tool NAMES and a timestamp. Never arguments, never content,
|
|
18
|
+
* never file paths — the adherence question needs no payload, and a telemetry
|
|
19
|
+
* file that accumulated payloads would become a redaction problem
|
|
20
|
+
* (trp_0d79711e). This is also why it is safe to keep on by default.
|
|
21
|
+
*
|
|
22
|
+
* COST. Observations accumulate in memory and flush in batches, so a session of
|
|
23
|
+
* N calls costs ~N/BATCH writes rather than N. No daemon, no store mutation, no
|
|
24
|
+
* journal noise: the file lives beside the other machine-local runtime
|
|
25
|
+
* artifacts (ack/log sentinels).
|
|
26
|
+
*
|
|
27
|
+
* Opt out with `BRAINCLAW_GUIDANCE_TELEMETRY=0` (also false/off/no).
|
|
28
|
+
*
|
|
29
|
+
* @module
|
|
30
|
+
*/
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import { MEMORY_DIR } from './io.js';
|
|
34
|
+
const TELEMETRY_FILE = 'guidance-adherence.jsonl';
|
|
35
|
+
/** Flush every N observations — bounds writes without risking much on a crash. */
|
|
36
|
+
const FLUSH_EVERY = 20;
|
|
37
|
+
/** Rotate past this size so the file cannot grow without bound. */
|
|
38
|
+
const MAX_BYTES = 512 * 1024;
|
|
39
|
+
/** Per-session pending suggestion. Process-scoped: one MCP server per connection. */
|
|
40
|
+
const pending = new Map();
|
|
41
|
+
/** Buffered observations awaiting flush, keyed by target store cwd. */
|
|
42
|
+
const buffered = new Map();
|
|
43
|
+
function enabled() {
|
|
44
|
+
const raw = process.env.BRAINCLAW_GUIDANCE_TELEMETRY?.trim().toLowerCase();
|
|
45
|
+
return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no');
|
|
46
|
+
}
|
|
47
|
+
function sessionKey(sessionId) {
|
|
48
|
+
return sessionId?.trim() || 'no-session';
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Pull suggested tool names out of a built response.
|
|
52
|
+
*
|
|
53
|
+
* Deliberately a SHALLOW scan of the two places affordances actually live —
|
|
54
|
+
* top level (handlers that spread fields into `toolResponse`) and
|
|
55
|
+
* `structuredContent` (facade responses) — plus the per-warning nests. A deep
|
|
56
|
+
* recursive walk would cost more than the signal is worth and would pick up
|
|
57
|
+
* unrelated `next_actions` echoed inside payload data.
|
|
58
|
+
*/
|
|
59
|
+
export function extractSuggestedTools(response) {
|
|
60
|
+
if (!response || typeof response !== 'object')
|
|
61
|
+
return [];
|
|
62
|
+
const tools = [];
|
|
63
|
+
const collect = (value) => {
|
|
64
|
+
if (!Array.isArray(value))
|
|
65
|
+
return;
|
|
66
|
+
for (const entry of value) {
|
|
67
|
+
if (entry && typeof entry === 'object' && typeof entry.tool === 'string') {
|
|
68
|
+
tools.push(entry.tool);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const collectWarningNests = (value) => {
|
|
73
|
+
if (!Array.isArray(value))
|
|
74
|
+
return;
|
|
75
|
+
for (const entry of value) {
|
|
76
|
+
if (entry && typeof entry === 'object')
|
|
77
|
+
collect(entry.next_actions);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const top = response;
|
|
81
|
+
collect(top.next_actions);
|
|
82
|
+
collectWarningNests(top.warning_details);
|
|
83
|
+
const structured = top.structuredContent;
|
|
84
|
+
if (structured && typeof structured === 'object') {
|
|
85
|
+
const inner = structured;
|
|
86
|
+
collect(inner.next_actions);
|
|
87
|
+
collectWarningNests(inner.warning_details);
|
|
88
|
+
}
|
|
89
|
+
return [...new Set(tools)];
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Observe a tool call against the suggestion left by the previous call.
|
|
93
|
+
*
|
|
94
|
+
* Returns the observation (for tests) or undefined when there was nothing
|
|
95
|
+
* pending. Consuming the pending entry is intentional: one suggestion set is
|
|
96
|
+
* judged exactly once, by the call that immediately follows it.
|
|
97
|
+
*/
|
|
98
|
+
export function observeToolCall(input) {
|
|
99
|
+
if (!enabled())
|
|
100
|
+
return undefined;
|
|
101
|
+
const key = sessionKey(input.sessionId);
|
|
102
|
+
const prior = pending.get(key);
|
|
103
|
+
if (!prior)
|
|
104
|
+
return undefined;
|
|
105
|
+
pending.delete(key);
|
|
106
|
+
const observation = {
|
|
107
|
+
at: input.now ?? new Date().toISOString(),
|
|
108
|
+
suggested_by: prior.suggestedBy,
|
|
109
|
+
suggested: prior.suggested,
|
|
110
|
+
called: input.tool,
|
|
111
|
+
followed: prior.suggested.includes(input.tool),
|
|
112
|
+
};
|
|
113
|
+
const list = buffered.get(input.cwd) ?? [];
|
|
114
|
+
list.push(observation);
|
|
115
|
+
buffered.set(input.cwd, list);
|
|
116
|
+
if (list.length >= FLUSH_EVERY)
|
|
117
|
+
flushAdherence(input.cwd);
|
|
118
|
+
return observation;
|
|
119
|
+
}
|
|
120
|
+
/** Remember what a response suggested, so the next call can be judged. */
|
|
121
|
+
export function recordSuggestion(input) {
|
|
122
|
+
if (!enabled())
|
|
123
|
+
return;
|
|
124
|
+
const key = sessionKey(input.sessionId);
|
|
125
|
+
if (input.suggested.length === 0) {
|
|
126
|
+
// No suggestion means nothing to judge — clear rather than leave a stale
|
|
127
|
+
// set that a later call would be measured against unfairly.
|
|
128
|
+
pending.delete(key);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
pending.set(key, { suggestedBy: input.tool, suggested: input.suggested });
|
|
132
|
+
}
|
|
133
|
+
function telemetryPath(cwd) {
|
|
134
|
+
return path.join(cwd, MEMORY_DIR, 'coordination', 'runtime', TELEMETRY_FILE);
|
|
135
|
+
}
|
|
136
|
+
/** Write buffered observations. Best-effort by construction: never throws. */
|
|
137
|
+
export function flushAdherence(cwd) {
|
|
138
|
+
const list = buffered.get(cwd);
|
|
139
|
+
if (!list || list.length === 0)
|
|
140
|
+
return;
|
|
141
|
+
buffered.set(cwd, []);
|
|
142
|
+
try {
|
|
143
|
+
const file = telemetryPath(cwd);
|
|
144
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
145
|
+
try {
|
|
146
|
+
if (fs.statSync(file).size > MAX_BYTES) {
|
|
147
|
+
// Keep the newest half; adherence is a trend, not an archive.
|
|
148
|
+
const kept = fs.readFileSync(file, 'utf-8').split('\n').filter(Boolean);
|
|
149
|
+
fs.writeFileSync(file, kept.slice(Math.floor(kept.length / 2)).join('\n') + '\n', 'utf-8');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch { /* absent file — nothing to rotate */ }
|
|
153
|
+
fs.appendFileSync(file, list.map((o) => JSON.stringify(o)).join('\n') + '\n', 'utf-8');
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
/* telemetry must never break a tool call */
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** Read the recorded observations and summarise. Never throws. */
|
|
160
|
+
export function readAdherence(cwd) {
|
|
161
|
+
let persisted = [];
|
|
162
|
+
try {
|
|
163
|
+
persisted = fs.readFileSync(telemetryPath(cwd), 'utf-8')
|
|
164
|
+
.split('\n')
|
|
165
|
+
.filter(Boolean)
|
|
166
|
+
.map((line) => JSON.parse(line));
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
/* absent or unreadable — an empty history, not an error */
|
|
170
|
+
}
|
|
171
|
+
// Include anything still buffered so a read right after a call is not stale.
|
|
172
|
+
const observations = [...persisted, ...(buffered.get(cwd) ?? [])];
|
|
173
|
+
const followed = observations.filter((o) => o.followed).length;
|
|
174
|
+
const perTool = new Map();
|
|
175
|
+
for (const o of observations) {
|
|
176
|
+
const entry = perTool.get(o.suggested_by) ?? { total: 0, followed: 0 };
|
|
177
|
+
entry.total += 1;
|
|
178
|
+
if (o.followed)
|
|
179
|
+
entry.followed += 1;
|
|
180
|
+
perTool.set(o.suggested_by, entry);
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
total: observations.length,
|
|
184
|
+
followed,
|
|
185
|
+
ignored: observations.length - followed,
|
|
186
|
+
...(observations.length > 0 ? { rate: followed / observations.length } : {}),
|
|
187
|
+
by_tool: [...perTool.entries()]
|
|
188
|
+
.map(([tool, v]) => ({ tool, total: v.total, followed: v.followed, rate: v.followed / v.total }))
|
|
189
|
+
.sort((a, b) => a.rate - b.rate),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/** Test hook — the maps are process-scoped by design. */
|
|
193
|
+
export function __resetAdherenceForTests() {
|
|
194
|
+
pending.clear();
|
|
195
|
+
buffered.clear();
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=guidance-telemetry.js.map
|