brainclaw 1.18.0 → 1.19.1
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/dist/brainclaw-vscode.vsix +0 -0
- package/dist/commands/claim.js +5 -1
- package/dist/commands/harvest.js +28 -2
- package/dist/commands/install-hooks.js +184 -27
- package/dist/commands/mcp-write-claims.js +63 -1
- package/dist/commands/mcp-write-coordination.js +57 -17
- package/dist/commands/mcp-write-entities.js +11 -0
- package/dist/commands/mcp.js +24 -1
- package/dist/commands/session-end.js +15 -0
- package/dist/commands/session-start.js +19 -0
- package/dist/core/claim-conformity.js +193 -0
- package/dist/core/claim-scope.js +155 -0
- package/dist/core/claims.js +160 -2
- package/dist/core/facade-schema.js +32 -0
- package/dist/core/guidance-telemetry.js +197 -0
- package/dist/core/ideation-loop-close.js +32 -4
- package/dist/core/instruction-templates.js +11 -3
- package/dist/core/loops/verbs.js +40 -1
- package/dist/core/next-actions.js +157 -0
- package/dist/core/review-loop-close.js +22 -4
- package/dist/core/schema.js +40 -0
- package/dist/core/surface-freshness.js +150 -0
- package/dist/core/warnings.js +98 -0
- package/dist/facts.js +5 -5
- package/dist/facts.json +4 -4
- 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 +75 -1
- package/package.json +1 -1
package/dist/core/claims.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
1
2
|
import crypto from 'node:crypto';
|
|
2
3
|
import fs from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
@@ -13,6 +14,7 @@ import { refreshLiveCompanions } from '../commands/export.js';
|
|
|
13
14
|
import { loadSessionById } from './identity.js';
|
|
14
15
|
import { loadState, persistState } from './state.js';
|
|
15
16
|
import { createRuntimeEvent } from './events.js';
|
|
17
|
+
import { latestActivityMs, readHeartbeat } from './runtime-signals.js';
|
|
16
18
|
import { emitRegistryPostImage, registryFaultPoint } from './events/registry-post-image.js';
|
|
17
19
|
import { maybeEnqueueClaimTransition, isFederationEnqueueActive } from './federation-outbox.js';
|
|
18
20
|
/** Parse duration string like '4h', '30m' to ms. */
|
|
@@ -119,6 +121,63 @@ export function saveClaim(claim, cwd) {
|
|
|
119
121
|
saveClaimUnlocked(claim, cwd);
|
|
120
122
|
});
|
|
121
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* pln#636 C0-b — resolve the commit a claim starts from.
|
|
126
|
+
*
|
|
127
|
+
* Recorded once, at creation, and never updated: it is the fixed point a later
|
|
128
|
+
* "what did this claim actually touch?" comparison needs. The design review
|
|
129
|
+
* rejected both alternatives — neither HEAD-at-read-time nor the worktree dirty
|
|
130
|
+
* set is authoritative once a lane commits mid-work.
|
|
131
|
+
*
|
|
132
|
+
* BEST-EFFORT BY CONSTRUCTION. A non-git project, a detached state, or a missing
|
|
133
|
+
* git binary yields `undefined`, and a claim without a baseline is simply
|
|
134
|
+
* `unverifiable` downstream. Claim acquisition must never fail because a
|
|
135
|
+
* conformity nicety could not be computed.
|
|
136
|
+
*/
|
|
137
|
+
export function resolveClaimBaseSha(cwd) {
|
|
138
|
+
try {
|
|
139
|
+
const result = spawnSync('git', ['rev-parse', 'HEAD'], {
|
|
140
|
+
cwd: cwd ?? process.cwd(),
|
|
141
|
+
encoding: 'utf-8',
|
|
142
|
+
windowsHide: true,
|
|
143
|
+
});
|
|
144
|
+
if (result.status !== 0)
|
|
145
|
+
return undefined;
|
|
146
|
+
const sha = result.stdout.trim();
|
|
147
|
+
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha : undefined;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The baseline fields every NEW claim must carry, ready to spread into a claim
|
|
155
|
+
* literal: `{ ...claimBaselineFields(cwd) }`.
|
|
156
|
+
*
|
|
157
|
+
* WHY A HELPER AND NOT AN INLINE CALL. `base_sha` shipped in 1.19.0 stamped from
|
|
158
|
+
* exactly ONE place — inside `acquireClaimScope` — and it turned out nothing
|
|
159
|
+
* user-facing calls that function. All four real creation paths
|
|
160
|
+
* (`bclaw_work(execute)`, `bclaw_claim`, the CLI `claim create`, and
|
|
161
|
+
* `createCoordinatorClaim`) build their claim literal inline and call `saveClaim`
|
|
162
|
+
* directly, so NO real claim ever got a baseline and the whole conformity
|
|
163
|
+
* reconcile that depends on it was inert (trp#1292). A named, greppable helper is
|
|
164
|
+
* what makes "did this creation path stamp the baseline?" answerable, and it is
|
|
165
|
+
* asserted per surface rather than only on the core function — testing
|
|
166
|
+
* `acquireClaimScope` directly is exactly what hid the gap.
|
|
167
|
+
*
|
|
168
|
+
* Returns an EMPTY object rather than `{ base_sha: undefined }` so a claim created
|
|
169
|
+
* outside a git repo has no such key at all, matching the "optional, never
|
|
170
|
+
* backfilled" contract the schema documents.
|
|
171
|
+
*
|
|
172
|
+
* Deliberately NOT applied inside `saveClaim`: that function also persists
|
|
173
|
+
* UPDATES (release, patch, adoption), and the baseline must be a fixed point —
|
|
174
|
+
* re-stamping it on every save is precisely the moving-ground failure that made
|
|
175
|
+
* `git diff HEAD` unusable in the first place.
|
|
176
|
+
*/
|
|
177
|
+
export function claimBaselineFields(cwd) {
|
|
178
|
+
const base_sha = resolveClaimBaseSha(cwd);
|
|
179
|
+
return base_sha ? { base_sha } : {};
|
|
180
|
+
}
|
|
122
181
|
/**
|
|
123
182
|
* Atomically check for an active claim on `scope` and save a new one if absent.
|
|
124
183
|
*
|
|
@@ -126,6 +185,10 @@ export function saveClaim(claim, cwd) {
|
|
|
126
185
|
* the mutation-pipeline mutex serializes filesystem writes on the claims store.
|
|
127
186
|
*/
|
|
128
187
|
export function acquireClaimScope(input, cwd) {
|
|
188
|
+
// Resolved OUTSIDE the mutate callback: one git call per acquisition, and it
|
|
189
|
+
// stays off the critical section (mutate serializes filesystem writes on the
|
|
190
|
+
// claims store, so a subprocess spawn inside it would widen the lock window).
|
|
191
|
+
const baseline = claimBaselineFields(cwd);
|
|
129
192
|
return mutate({ cwd }, () => {
|
|
130
193
|
const conflictingClaim = listClaims(cwd).find((claim) => claim.status === 'active' && claim.scope === input.scope);
|
|
131
194
|
if (conflictingClaim) {
|
|
@@ -143,6 +206,10 @@ export function acquireClaimScope(input, cwd) {
|
|
|
143
206
|
status: 'active',
|
|
144
207
|
plan_id: input.plan_id,
|
|
145
208
|
model: input.model,
|
|
209
|
+
// pln#636 C0-b — capture the baseline while we know it. Absent when the
|
|
210
|
+
// project is not a git repo; downstream treats that as unverifiable.
|
|
211
|
+
...baseline,
|
|
212
|
+
...(input.paths?.length ? { paths: input.paths } : {}),
|
|
146
213
|
};
|
|
147
214
|
saveClaimUnlocked(claim, cwd);
|
|
148
215
|
return { acquired: true, claim };
|
|
@@ -502,11 +569,82 @@ const DEFAULT_STALE_HOURS = 24;
|
|
|
502
569
|
* even if it has no session yet (coordinator claims are created before the worker session starts).
|
|
503
570
|
*/
|
|
504
571
|
const YOUNG_CLAIM_THRESHOLD_MS = 30 * 60_000; // 30 minutes
|
|
572
|
+
/** Default freshness window for file evidence — matches the default `heartbeat_ttl_ms`. */
|
|
573
|
+
const DEFAULT_EVIDENCE_TTL_MS = 30 * 60_000;
|
|
505
574
|
/**
|
|
506
|
-
*
|
|
575
|
+
* How far into the future a file timestamp may sit before we stop trusting it.
|
|
576
|
+
*
|
|
577
|
+
* WHY THIS TOLERANCE EXISTS, and why the naive `age < 0 → ignore` was wrong.
|
|
578
|
+
* `fs.stat().mtimeMs` is sub-millisecond on NTFS while `Date.now()` is coarser,
|
|
579
|
+
* so a heartbeat written microseconds ago routinely stats as *newer than now* —
|
|
580
|
+
* i.e. the freshest evidence possible was the evidence most likely to be thrown
|
|
581
|
+
* away. That reproduced as a nondeterministic liveness verdict: the same claim
|
|
582
|
+
* read `live` on an idle machine and `never-adopted` under load.
|
|
583
|
+
*
|
|
584
|
+
* A file dated slightly ahead is therefore clamped to age 0 (maximally fresh),
|
|
585
|
+
* while one dated grossly ahead is discarded — that is a genuinely wrong clock or
|
|
586
|
+
* a hand-forged timestamp, and inventing liveness from it would let a dead
|
|
587
|
+
* worker hold a claim forever.
|
|
588
|
+
*/
|
|
589
|
+
const FUTURE_EVIDENCE_TOLERANCE_MS = 5 * 60_000;
|
|
590
|
+
/**
|
|
591
|
+
* pln#636 — age of the freshest FILE evidence that this claim's worker is alive.
|
|
592
|
+
*
|
|
593
|
+
* WHY FILE EVIDENCE AND NOT A SESSION. A sandboxed spawned worker cannot reach
|
|
594
|
+
* MCP, so it cannot maintain any server-side liveness record — which is exactly
|
|
595
|
+
* why the project moved proof-of-life to filesystem sentinels: the dispatcher
|
|
596
|
+
* injects a "Liveness — DO THIS FIRST" step into every brief, and the worker
|
|
597
|
+
* writes/refreshes a heartbeat in the ONE location a sandbox can write (its own
|
|
598
|
+
* worktree). `assignment-sweeper` already honours that evidence; claims did not,
|
|
599
|
+
* which meant a demonstrably-alive sandboxed worker kept its assignment but had
|
|
600
|
+
* its CLAIM aged out on wall-clock alone (trp_4d0fc2ef). This closes that
|
|
601
|
+
* asymmetry by reading the same signals.
|
|
602
|
+
*
|
|
603
|
+
* Deliberately reads the leaf `runtime-signals` module rather than
|
|
604
|
+
* `collectEvidence`: agentrun-reconciler imports `loadClaim` from here, so
|
|
605
|
+
* importing it back would create a cycle.
|
|
606
|
+
*
|
|
607
|
+
* Returns undefined when there is nothing to read — no assignment, no signals —
|
|
608
|
+
* and never throws.
|
|
609
|
+
*/
|
|
610
|
+
function freshestEvidenceAgeMs(claim, nowMs, cwd) {
|
|
611
|
+
if (!claim.assignment_id)
|
|
612
|
+
return undefined;
|
|
613
|
+
const root = cwd ?? process.cwd();
|
|
614
|
+
let freshest;
|
|
615
|
+
const consider = (ms) => {
|
|
616
|
+
if (ms === undefined)
|
|
617
|
+
return;
|
|
618
|
+
const age = nowMs - ms;
|
|
619
|
+
// Slightly-future timestamps are a clock-granularity artefact, not skew —
|
|
620
|
+
// clamp them to "just now". Grossly-future ones are untrustworthy: ignore
|
|
621
|
+
// rather than invent liveness. See FUTURE_EVIDENCE_TOLERANCE_MS.
|
|
622
|
+
if (age < -FUTURE_EVIDENCE_TOLERANCE_MS)
|
|
623
|
+
return;
|
|
624
|
+
const normalised = age < 0 ? 0 : age;
|
|
625
|
+
if (freshest === undefined || normalised < freshest)
|
|
626
|
+
freshest = normalised;
|
|
627
|
+
};
|
|
628
|
+
try {
|
|
629
|
+
const hb = readHeartbeat(root, claim.assignment_id, claim.worktree_path);
|
|
630
|
+
if (hb.exists)
|
|
631
|
+
consider(hb.mtimeMs);
|
|
632
|
+
}
|
|
633
|
+
catch { /* evidence is best-effort */ }
|
|
634
|
+
try {
|
|
635
|
+
consider(latestActivityMs(root, claim.assignment_id, claim.worktree_path));
|
|
636
|
+
}
|
|
637
|
+
catch { /* evidence is best-effort */ }
|
|
638
|
+
return freshest;
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Assess the liveness of an active claim.
|
|
507
642
|
*
|
|
508
643
|
* Decision tree:
|
|
509
|
-
*
|
|
644
|
+
* 0. Young (< 30 min) → never auto-release — dispatcher may not have sent the worker yet.
|
|
645
|
+
* 1. FRESH FILE EVIDENCE → 'live', whatever the session says. This branch comes
|
|
646
|
+
* first because it is the only proof a sandboxed, MCP-less worker can
|
|
647
|
+
* produce, and it is the same evidence the assignment sweeper trusts.
|
|
510
648
|
* 2. Has session_id + session alive → 'live' — long-running work; do NOT release.
|
|
511
649
|
* 3. Has session_id + adopted_at + session dead → 'orphaned' — crash recovery scenario.
|
|
512
650
|
* 4. Has session_id + no adopted_at + session dead → 'stale' — direct agent claim, session ended.
|
|
@@ -525,6 +663,21 @@ export function assessClaimLiveness(claim, options = {}) {
|
|
|
525
663
|
ageMs,
|
|
526
664
|
};
|
|
527
665
|
}
|
|
666
|
+
// 1. FILE EVIDENCE FIRST (pln#636, trp_4d0fc2ef). A sandboxed worker proves
|
|
667
|
+
// life by writing a heartbeat into its worktree — the only place it can
|
|
668
|
+
// write — and by touching files there. That evidence outranks any session
|
|
669
|
+
// reasoning: a worker actively committing is alive whether or not a session
|
|
670
|
+
// record exists, and a coordinator-created claim has no session_id at all.
|
|
671
|
+
const evidenceAgeMs = freshestEvidenceAgeMs(claim, nowMs, options.cwd);
|
|
672
|
+
const evidenceTtlMs = options.evidenceTtlMs ?? DEFAULT_EVIDENCE_TTL_MS;
|
|
673
|
+
if (evidenceAgeMs !== undefined && evidenceAgeMs < evidenceTtlMs) {
|
|
674
|
+
return {
|
|
675
|
+
status: 'live',
|
|
676
|
+
reason: `File evidence is fresh (${Math.round(evidenceAgeMs / 60_000)}min ago) — the worker is demonstrably active`,
|
|
677
|
+
ageMs,
|
|
678
|
+
evidenceAgeMs,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
528
681
|
// 2–4. Has a session_id — check session liveness
|
|
529
682
|
if (claim.session_id) {
|
|
530
683
|
let sessionAgeMs;
|
|
@@ -729,6 +882,10 @@ export function createCoordinatorClaim(options) {
|
|
|
729
882
|
};
|
|
730
883
|
}
|
|
731
884
|
const claimId = generateClaimId();
|
|
885
|
+
// Resolved OUTSIDE the lock below, for the same reason acquireClaimScope does
|
|
886
|
+
// it: a subprocess spawn inside the mutate callback would widen the critical
|
|
887
|
+
// section that serializes writes on the claims store.
|
|
888
|
+
const baseline = claimBaselineFields(options.cwd);
|
|
732
889
|
let worktreePath;
|
|
733
890
|
let worktreeWarning;
|
|
734
891
|
// Create isolated worktree (matching bclaw_claim MCP handler behavior).
|
|
@@ -780,6 +937,7 @@ export function createCoordinatorClaim(options) {
|
|
|
780
937
|
created_at: nowISO(),
|
|
781
938
|
status: 'active',
|
|
782
939
|
worktree_path: worktreePath,
|
|
940
|
+
...baseline, // pln#636 C0-b / trp#1292 — dispatched lanes need it most
|
|
783
941
|
}, options.cwd);
|
|
784
942
|
return { claimId, worktreePath, worktreeWarning, reusedExisting: false };
|
|
785
943
|
});
|
|
@@ -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
|
|
@@ -103,17 +103,45 @@ export function closeIdeationLoopFromLaneResult(assignment, lane, actor, cwd) {
|
|
|
103
103
|
// A critic's LANE-RESULT carries free-form summary/notes (no structured
|
|
104
104
|
// critiques[] field) → ONE critique artifact. A bare lane with no critique
|
|
105
105
|
// content FAILS the slot (mirror ideationReducer: no fake gate progress).
|
|
106
|
-
const
|
|
106
|
+
const expectedArtifactType = 'critique';
|
|
107
|
+
// Prefer the typed envelope, but honor the legacy artifacts labels too:
|
|
108
|
+
// coverage_gap used to be silently invisible to a critique gate.
|
|
109
|
+
const reportedArtifactType = lane.artifact_type?.trim()
|
|
110
|
+
?? lane.artifacts?.find((label) => /^[a-z][a-z0-9_]*$/.test(label) && label !== expectedArtifactType);
|
|
111
|
+
const body = lane.body?.trim();
|
|
112
|
+
const critique = body || [lane.summary, lane.notes]
|
|
107
113
|
.map((s) => (s ?? '').trim())
|
|
108
114
|
.filter(Boolean)
|
|
109
115
|
.join('\n\n')
|
|
110
116
|
.trim();
|
|
111
117
|
if (!critique) {
|
|
112
118
|
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:
|
|
119
|
+
return { loop_id: loopId, action: 'failed', reason: reportedArtifactType && reportedArtifactType !== expectedArtifactType
|
|
120
|
+
? `reported artifact type "${reportedArtifactType}" has no usable body; expected "${expectedArtifactType}"`
|
|
121
|
+
: 'bare critic lane → slot failed; critique gate unchanged',
|
|
122
|
+
loop_status: getLoop(loopId, cwd)?.status };
|
|
114
123
|
}
|
|
115
|
-
|
|
116
|
-
|
|
124
|
+
// pln#639 BUG-2 — attribute the artifact to the phase the slot was
|
|
125
|
+
// DISPATCHED in, not the loop's phase at close time.
|
|
126
|
+
//
|
|
127
|
+
// `turn()` stamps `slot.phase = current_phase` when the slot is handed
|
|
128
|
+
// out (loops/verbs.ts). Using `loop.current_phase` here instead means a
|
|
129
|
+
// lane that returns AFTER a phase advance has its work filed under the
|
|
130
|
+
// new phase: a critique landing 90 seconds late is recorded in
|
|
131
|
+
// `revision`, where the critique gate cannot see it and where it
|
|
132
|
+
// misrepresents what the agent was asked to do. Reproduced in the
|
|
133
|
+
// pln#638 1a/1b ideation, which advanced ~90s after its last critic.
|
|
134
|
+
//
|
|
135
|
+
// Truthful attribution is also the fix for "don't count it": the gate
|
|
136
|
+
// filters on `artifact.phase === current_phase`, so an out-of-phase
|
|
137
|
+
// artifact stops satisfying the current gate by construction — no
|
|
138
|
+
// separate refusal path, and the content is preserved rather than lost.
|
|
139
|
+
const dispatchPhase = slot.phase ?? loop.current_phase;
|
|
140
|
+
complete_turn({ id: loopId, slot_id: slot.slot_id, actor, outcome: 'done', artifact: { phase: dispatchPhase, type: 'critique', body: capCritique(critique) } }, cwd);
|
|
141
|
+
const advanced = tryAdvance(true);
|
|
142
|
+
return reportedArtifactType && reportedArtifactType !== expectedArtifactType
|
|
143
|
+
? { ...advanced, reason: `reconciled reported artifact type "${reportedArtifactType}" to expected "${expectedArtifactType}"; ${advanced.reason}` }
|
|
144
|
+
: advanced;
|
|
117
145
|
},
|
|
118
146
|
});
|
|
119
147
|
}
|
|
@@ -217,10 +217,18 @@ function renderHeader(input) {
|
|
|
217
217
|
`> Regenerate: brainclaw export --format ${formatForAgent(input.profile.name)} --write`,
|
|
218
218
|
].join('\n');
|
|
219
219
|
}
|
|
220
|
-
function renderLiveHeader(
|
|
220
|
+
function renderLiveHeader(input) {
|
|
221
|
+
// pln#638 volet 2a — HONESTY FIX. This header used to say "auto-refreshed",
|
|
222
|
+
// but regeneration is EXPLICIT: it happens on session-end, handoff, and
|
|
223
|
+
// `export --write`. An agent tier that never fires those events (no hooks, no
|
|
224
|
+
// MCP) read a file claiming to be fresh while being arbitrarily stale. A claim
|
|
225
|
+
// that is false for half the tiers is worse than no claim, so the header now
|
|
226
|
+
// names the actual triggers and tells the reader how to force a refresh.
|
|
227
|
+
// Guarded by tests/unit/guidance-engine-consistency.test.ts.
|
|
221
228
|
return [
|
|
222
|
-
`> Brainclaw live state —
|
|
223
|
-
`>
|
|
229
|
+
`> Brainclaw live state — do not edit. Regenerated on: session-end, handoff, \`brainclaw export --write\`.`,
|
|
230
|
+
`> Written by brainclaw v${input.brainclawVersion} at ${new Date().toISOString().slice(0, 19)}`,
|
|
231
|
+
`> Older than your last session? It is stale — run \`brainclaw export --write\` to refresh.`,
|
|
224
232
|
].join('\n');
|
|
225
233
|
}
|
|
226
234
|
// Kept deliberately small (pln#542): entry point + grammar + escalation
|
package/dist/core/loops/verbs.js
CHANGED
|
@@ -39,6 +39,31 @@ function isVerdictAccepted(artifact) {
|
|
|
39
39
|
const body = (artifact.body ?? '').trim().toLowerCase();
|
|
40
40
|
return /^accepted(?:\b|[:\s])/.test(body);
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* pln#639 BUG-1 — does this artifact carry anything a reader could USE?
|
|
44
|
+
*
|
|
45
|
+
* `body` is optional in both the input schema (loops/facade-schema.ts) and
|
|
46
|
+
* `LoopArtifactSchema`, so `{phase, type}` alone is schema-valid. Without this
|
|
47
|
+
* predicate such an artifact counted toward `min_artifacts_by_type`, which means
|
|
48
|
+
* a phase gate — the mechanism whose entire job is to prove the phase produced
|
|
49
|
+
* real work — could be opened by producing nothing at all.
|
|
50
|
+
*
|
|
51
|
+
* THE INVARIANT ALREADY EXISTED, one layer too low. `ideationReducer` states it
|
|
52
|
+
* verbatim: "a bare summary with no critique body → slot failed, gate stays shut
|
|
53
|
+
* (correct: no fake progress from a lane that produced no critiques)". That guard
|
|
54
|
+
* only covers the LANE-RESULT reducer path; a direct `add_artifact` /
|
|
55
|
+
* `complete_turn` MCP call bypassed it entirely. Enforcing it in the evaluator
|
|
56
|
+
* makes it hold for every entry path.
|
|
57
|
+
*
|
|
58
|
+
* A `ref` counts as content: ref-based artifacts legitimately carry no inline
|
|
59
|
+
* body (the payload lives in the referenced entity), so the rule is "no usable
|
|
60
|
+
* content" — NOT "body required", which would break them.
|
|
61
|
+
*/
|
|
62
|
+
function hasUsableContent(artifact) {
|
|
63
|
+
if ((artifact.body ?? '').trim().length > 0)
|
|
64
|
+
return true;
|
|
65
|
+
return artifact.ref !== undefined;
|
|
66
|
+
}
|
|
42
67
|
export function evaluateStopCondition(thread, condition) {
|
|
43
68
|
if (!condition)
|
|
44
69
|
return false;
|
|
@@ -65,6 +90,9 @@ export function evaluateStopCondition(thread, condition) {
|
|
|
65
90
|
const matches = thread.artifacts.filter((artifact) => {
|
|
66
91
|
if (artifact.type !== condition.type)
|
|
67
92
|
return false;
|
|
93
|
+
// pln#639 BUG-1 — an artifact with no usable content never counts.
|
|
94
|
+
if (!hasUsableContent(artifact))
|
|
95
|
+
return false;
|
|
68
96
|
if (condition.scope === 'phase') {
|
|
69
97
|
if (artifact.phase !== thread.current_phase)
|
|
70
98
|
return false;
|
|
@@ -125,6 +153,11 @@ function describeUnmetGate(thread, gate) {
|
|
|
125
153
|
const matches = thread.artifacts.filter((artifact) => {
|
|
126
154
|
if (artifact.type !== gate.type)
|
|
127
155
|
return false;
|
|
156
|
+
// pln#639 BUG-1 — same content filter as the evaluator, for the same
|
|
157
|
+
// reason the iteration filter is mirrored here: a message reporting a
|
|
158
|
+
// count the evaluator never saw sends the operator hunting a phantom.
|
|
159
|
+
if (!hasUsableContent(artifact))
|
|
160
|
+
return false;
|
|
128
161
|
if (gate.scope === 'phase') {
|
|
129
162
|
if (artifact.phase !== thread.current_phase)
|
|
130
163
|
return false;
|
|
@@ -137,7 +170,13 @@ function describeUnmetGate(thread, gate) {
|
|
|
137
170
|
}
|
|
138
171
|
return true;
|
|
139
172
|
});
|
|
140
|
-
|
|
173
|
+
// Name the empty-artifact case explicitly: "count = 2 < n = 3" is baffling
|
|
174
|
+
// when the operator can see three artifacts of the right type in the thread.
|
|
175
|
+
const emptyOfType = thread.artifacts.filter((a) => a.type === gate.type && !hasUsableContent(a)).length;
|
|
176
|
+
const emptyNote = emptyOfType > 0
|
|
177
|
+
? ` (${emptyOfType} artifact(s) of this type carry no usable content and do not count)`
|
|
178
|
+
: '';
|
|
179
|
+
return `min_artifacts_by_type unmet: ${gate.scope}-scope count of type "${gate.type}" = ${matches.length} < n=${gate.n}${emptyNote}`;
|
|
141
180
|
}
|
|
142
181
|
case 'phase_reached':
|
|
143
182
|
return `phase_reached unmet: current_phase="${thread.current_phase}" expected="${gate.phase}"`;
|