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/commands/mcp.js
CHANGED
|
@@ -13,7 +13,7 @@ import { collectLoadValidationWarnings, findLoadValidationWarning, loadState } f
|
|
|
13
13
|
import { memoryExists, MEMORY_DIR } from '../core/io.js';
|
|
14
14
|
import { getEntity, listEntities, boundListResult, DEFAULT_FIND_CHAR_BUDGET, GRAMMAR_FILTER_CONTRACT, } from '../core/entity-operations.js';
|
|
15
15
|
import { handoffDiffPreviewNote } from '../core/handoff-snapshot.js';
|
|
16
|
-
import { generateClaimId, listClaims, loadClaim, saveClaim, adoptClaimSession } from '../core/claims.js';
|
|
16
|
+
import { generateClaimId, listClaims, loadClaim, saveClaim, adoptClaimSession, claimBaselineFields } from '../core/claims.js';
|
|
17
17
|
import { assertCrossProjectBoundary, checkPolicy } from '../core/policy.js';
|
|
18
18
|
import { startSession } from './session-start.js';
|
|
19
19
|
import { AgentIdentityResolutionError, AgentTrustError, resolveCurrentModel, } from '../core/agent-registry.js';
|
|
@@ -28,6 +28,7 @@ import { assessBootstrapNeed, resolveEmptyMemoryRecommendation } from '../core/s
|
|
|
28
28
|
import { WorkRequestSchema } from '../core/facade-schema.js';
|
|
29
29
|
import { codeMapWorkSection, codeMapRefreshNextActions } from '../core/code-map/work-section.js';
|
|
30
30
|
import { sweepDeadPidRunningAgentRunsAtRead, sweepTurnOwnedPreRunLeaseAtRead } from '../core/agentrun-reconciler.js';
|
|
31
|
+
import { extractSuggestedTools, observeToolCall, recordSuggestion } from '../core/guidance-telemetry.js';
|
|
31
32
|
import { bumpActiveAssignmentHeartbeat } from '../core/assignments.js';
|
|
32
33
|
import { handleBclawAckMessage, handleBclawCoordinate, handleBclawDispatch, handleBclawLoop, handleBclawSendMessage, } from './mcp-write-coordination.js';
|
|
33
34
|
import { ensureTrust, resolveMutationIdentity, explicitSessionIdFromEnv, projectInfoForCwd, scopeMetadataForTarget, } from './mcp-write-support.js';
|
|
@@ -1301,6 +1302,11 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
1301
1302
|
status: 'active',
|
|
1302
1303
|
plan_id: workReq.planId,
|
|
1303
1304
|
model: currentModel,
|
|
1305
|
+
// pln#636 C0-b / trp#1292 — bclaw_work(execute) is the entry point the
|
|
1306
|
+
// session protocol tells every agent to start with, so a missing
|
|
1307
|
+
// baseline here made the conformity reconcile inert for nearly all
|
|
1308
|
+
// real claims.
|
|
1309
|
+
...claimBaselineFields(targetCwd),
|
|
1304
1310
|
}, targetCwd);
|
|
1305
1311
|
appendAuditEntry({ actor: sessionResult.agent, actor_id: sessionResult.agent_id, action: 'claim', item_id: claimId, item_type: 'claim', scope: workReq.scope, session_id: sessionResult.session_id }, targetCwd);
|
|
1306
1312
|
claimStatus = 'created';
|
|
@@ -1881,6 +1887,14 @@ export async function executeMcpToolCall(payload) {
|
|
|
1881
1887
|
}
|
|
1882
1888
|
catch { /* best-effort */ }
|
|
1883
1889
|
}
|
|
1890
|
+
// pln#634 PR2 — guidance adherence. Judge the PREVIOUS response's suggestion
|
|
1891
|
+
// against the call actually being made now, before delegating. Tool names
|
|
1892
|
+
// only, never arguments or content. Best-effort: telemetry may not break a
|
|
1893
|
+
// tool call.
|
|
1894
|
+
try {
|
|
1895
|
+
observeToolCall({ sessionId: effectiveConnectionSessionId, tool: payload.name, cwd });
|
|
1896
|
+
}
|
|
1897
|
+
catch { /* never break the tool path */ }
|
|
1884
1898
|
// ── Delegate to inner handler ───────────────────────────────────────────────
|
|
1885
1899
|
const outcome = await _executeMcpToolCallInner({
|
|
1886
1900
|
...payload,
|
|
@@ -1888,6 +1902,15 @@ export async function executeMcpToolCall(payload) {
|
|
|
1888
1902
|
connectionSessionId: effectiveConnectionSessionId,
|
|
1889
1903
|
effectiveScope: effective,
|
|
1890
1904
|
});
|
|
1905
|
+
// Remember what THIS response suggested, so the next call can be judged.
|
|
1906
|
+
try {
|
|
1907
|
+
recordSuggestion({
|
|
1908
|
+
sessionId: effectiveConnectionSessionId,
|
|
1909
|
+
tool: payload.name,
|
|
1910
|
+
suggested: extractSuggestedTools(outcome.response),
|
|
1911
|
+
});
|
|
1912
|
+
}
|
|
1913
|
+
catch { /* never break the tool path */ }
|
|
1891
1914
|
// Apply legacy deprecation warning uniformly (Phase 3 slice 3g). Read tools
|
|
1892
1915
|
// already get it at line 2560; write tools historically did not. This
|
|
1893
1916
|
// wrapper ensures every call through a deprecated name surfaces the
|
|
@@ -21,6 +21,8 @@ import { memoryExists } from '../core/io.js';
|
|
|
21
21
|
import { buildOperationalIdentity, clearCurrentSession } from '../core/identity.js';
|
|
22
22
|
import { buildContextDiff } from '../core/context-diff.js';
|
|
23
23
|
import { listClaims, releaseClaim } from '../core/claims.js';
|
|
24
|
+
import { reconcileClaimConformity } from '../core/claim-conformity.js';
|
|
25
|
+
import { toWarningDetail } from '../core/warnings.js';
|
|
24
26
|
import { listRuntimeNotes, saveRuntimeNote, generateRuntimeNoteId } from '../core/runtime.js';
|
|
25
27
|
import { loadState, persistState } from '../core/state.js';
|
|
26
28
|
import { listArchivedCandidates, listCandidates } from '../core/candidates.js';
|
|
@@ -147,6 +149,18 @@ export async function endSession(options = {}) {
|
|
|
147
149
|
const state = loadState(options.cwd);
|
|
148
150
|
const claimPlanIds = new Set(activeClaims.map((c) => c.plan_id).filter(Boolean));
|
|
149
151
|
const inProgressPlans = state.plan_items.filter((p) => p.status === 'in_progress' && (p.assignee === registered.agent_name || claimPlanIds.has(p.id)));
|
|
152
|
+
// pln#636 C2 — sweep this session's own claims before auto-release closes
|
|
153
|
+
// them. session-end is the backstop trigger: it catches a claim whose worker
|
|
154
|
+
// neither released it via MCP nor reported through a LANE-RESULT.
|
|
155
|
+
const conformityWarnings = [];
|
|
156
|
+
for (const c of activeClaims) {
|
|
157
|
+
try {
|
|
158
|
+
const conformity = reconcileClaimConformity(c, options.cwd ?? process.cwd());
|
|
159
|
+
if (conformity.warning)
|
|
160
|
+
conformityWarnings.push(toWarningDetail(conformity.warning));
|
|
161
|
+
}
|
|
162
|
+
catch { /* advisory only — a session must always be able to end */ }
|
|
163
|
+
}
|
|
150
164
|
let openWorkWarning;
|
|
151
165
|
if (activeClaims.length > 0 || inProgressPlans.length > 0) {
|
|
152
166
|
if (options.autoRelease) {
|
|
@@ -369,6 +383,7 @@ export async function endSession(options = {}) {
|
|
|
369
383
|
open_work_warning: openWorkWarning,
|
|
370
384
|
session_stats: sessionStats,
|
|
371
385
|
compaction_hint: compactionHint,
|
|
386
|
+
...(conformityWarnings.length ? { scope_warnings: conformityWarnings } : {}),
|
|
372
387
|
...(reflectedHandoff ? { handoff: reflectedHandoff } : {}),
|
|
373
388
|
};
|
|
374
389
|
// pln#564 — session_end pushes the agent into a short dogfooding reflection
|
|
@@ -18,6 +18,9 @@ import { auditLocalAgentWorkspaceFiles } from '../core/agent-files.js';
|
|
|
18
18
|
import { buildAgentInventory, loadAgentInventory, saveAgentInventory, diffInventory } from '../core/agent-inventory.js';
|
|
19
19
|
import { checkMemoryPressure, enforceRuntimeNoteRetention, parkClosedAutoHandoffs } from '../core/gc-semantic.js';
|
|
20
20
|
import { sweepAssignments } from '../core/assignment-sweeper.js';
|
|
21
|
+
import { getInstalledBrainclawVersion } from '../core/brainclaw-version.js';
|
|
22
|
+
import { reconcileSurfaceFreshness, staleSurfaceWarning } from '../core/surface-freshness.js';
|
|
23
|
+
import { toWarningDetail } from '../core/warnings.js';
|
|
21
24
|
import { loadHygienePolicy } from '../core/hygiene-policy.js';
|
|
22
25
|
import { maybeCreateCheckpoint } from '../core/events/checkpoint.js';
|
|
23
26
|
import { pullSignalsFromLinkedProjects, markSignalProcessed } from '../core/federation-transport.js';
|
|
@@ -282,6 +285,21 @@ export async function startSession(options = {}) {
|
|
|
282
285
|
}
|
|
283
286
|
catch { /* non-fatal */ }
|
|
284
287
|
}
|
|
288
|
+
// pln#638 volet 2b — LAZY freshness reconcile of the generated guidance
|
|
289
|
+
// surfaces. session-start is the right trigger because it is the moment the
|
|
290
|
+
// agent is about to READ that guidance, and it is a path we already visit — no
|
|
291
|
+
// daemon, no watcher (feedback_lazy_reconcile_pattern). Advisory only: nothing
|
|
292
|
+
// is regenerated here, because regeneration is an explicit act and silently
|
|
293
|
+
// rewriting a file the operator may have edited would be worse than a warning.
|
|
294
|
+
let staleSurfaces;
|
|
295
|
+
if (maintenanceMode === 'full') {
|
|
296
|
+
try {
|
|
297
|
+
const currentVersion = getInstalledBrainclawVersion();
|
|
298
|
+
const freshness = reconcileSurfaceFreshness(options.cwd ?? process.cwd(), currentVersion);
|
|
299
|
+
staleSurfaces = staleSurfaceWarning(freshness, currentVersion);
|
|
300
|
+
}
|
|
301
|
+
catch { /* non-fatal */ }
|
|
302
|
+
}
|
|
285
303
|
// Materialize incoming federation signals from linked projects (Phase 0 — local)
|
|
286
304
|
if (maintenanceMode === 'full') {
|
|
287
305
|
try {
|
|
@@ -338,6 +356,7 @@ export async function startSession(options = {}) {
|
|
|
338
356
|
...(sharedCheckoutWarning ? { shared_checkout_warning: sharedCheckoutWarning } : {}),
|
|
339
357
|
...(staleClaimsReleased ? { stale_claims_released: staleClaimsReleased } : {}),
|
|
340
358
|
...(memoryPressure ? { memory_pressure: memoryPressure } : {}),
|
|
359
|
+
...(staleSurfaces ? { stale_surfaces: toWarningDetail(staleSurfaces) } : {}),
|
|
341
360
|
...(autoRegistered ? { auto_registered: true } : {}),
|
|
342
361
|
};
|
|
343
362
|
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pln#636 C2 — server-side lazy conformity reconcile.
|
|
3
|
+
*
|
|
4
|
+
* WHY SERVER-SIDE AT ALL. C1's PreToolUse hook only reaches hook-capable hosts.
|
|
5
|
+
* The workers that most need a scope signal are the ones that reach nothing: a
|
|
6
|
+
* spawned sandboxed lane never sees MCP, never loads a hook, and reports through
|
|
7
|
+
* a file. So the universal net has to live where the *outcome* is ingested, not
|
|
8
|
+
* where the write happens. Reconcile at the lifecycle boundaries every tier
|
|
9
|
+
* eventually crosses — release, assignment completion, harvest ingestion,
|
|
10
|
+
* session end — per the validated lazy-reconcile pattern. No daemon, no watcher.
|
|
11
|
+
*
|
|
12
|
+
* WHY POST-HOC IS THE HONEST SHAPE. By the time any of these fire the write has
|
|
13
|
+
* already landed. The only truthful output is an advisory that names the strays
|
|
14
|
+
* and the two calls that resolve them — never an error, never a block
|
|
15
|
+
* (trp_5f342186 is the scar tissue).
|
|
16
|
+
*
|
|
17
|
+
* THE BASELINE PROBLEM, and why `base_sha` exists (C0-b, review F3). Neither
|
|
18
|
+
* `git diff HEAD` nor the worktree's dirty set is authoritative: a lane that
|
|
19
|
+
* commits mid-work moves the ground under both, so the same claim would read
|
|
20
|
+
* "touched nothing" the moment it committed. The comparison runs against the
|
|
21
|
+
* commit recorded at claim creation — a fixed point — and unions in the dirty
|
|
22
|
+
* set so uncommitted work counts too.
|
|
23
|
+
*
|
|
24
|
+
* SILENT ON DOUBT. Every degradation path (no baseline, no git, detached
|
|
25
|
+
* worktree, unreadable repo) yields `unverifiable`, which emits NOTHING. The
|
|
26
|
+
* acceptance bar for this whole design is a zero false-positive rate on the real
|
|
27
|
+
* 613-claim corpus, and 42.4% of that corpus is not path-resolvable at all.
|
|
28
|
+
*
|
|
29
|
+
* @module
|
|
30
|
+
*/
|
|
31
|
+
import { spawnSync } from 'node:child_process';
|
|
32
|
+
import fs from 'node:fs';
|
|
33
|
+
import { assessScopeConformity } from './claim-scope.js';
|
|
34
|
+
/** Cap on how many stray paths ride along in a warning payload. */
|
|
35
|
+
const MAX_REPORTED_PATHS = 10;
|
|
36
|
+
/**
|
|
37
|
+
* Run git and return stdout, or undefined on ANY failure.
|
|
38
|
+
*
|
|
39
|
+
* Never throws and never inspects stderr: a conformity nicety may not degrade
|
|
40
|
+
* the workflow it observes, so an unavailable git, a detached worktree or a
|
|
41
|
+
* garbage-collected branch all read as "cannot tell".
|
|
42
|
+
*/
|
|
43
|
+
function git(cwd, args) {
|
|
44
|
+
try {
|
|
45
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf-8', windowsHide: true });
|
|
46
|
+
if (r.status !== 0 || typeof r.stdout !== 'string')
|
|
47
|
+
return undefined;
|
|
48
|
+
return r.stdout;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function splitPaths(out) {
|
|
55
|
+
if (!out)
|
|
56
|
+
return [];
|
|
57
|
+
return out.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Split WITHOUT trimming, for `--porcelain` output.
|
|
61
|
+
*
|
|
62
|
+
* The porcelain prefix is fixed-width — status codes in columns 1-2, a space in
|
|
63
|
+
* column 3, path from index 3 — so a leading space is DATA. Trimming ` M
|
|
64
|
+
* src/x.ts` first turns the subsequent `slice(3)` into `rc/x.ts`: a path that
|
|
65
|
+
* matches no pathspec and reads as a stray, i.e. a false accusation on every
|
|
66
|
+
* unstaged edit.
|
|
67
|
+
*/
|
|
68
|
+
function splitLinesRaw(out) {
|
|
69
|
+
if (!out)
|
|
70
|
+
return [];
|
|
71
|
+
return out.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Where a claim's work physically happened: its own worktree when it has one,
|
|
75
|
+
* otherwise the project root. A lane claim's diff is meaningless read from the
|
|
76
|
+
* coordinator's checkout.
|
|
77
|
+
*/
|
|
78
|
+
function claimWorkdir(claim, cwd) {
|
|
79
|
+
const dir = claim.worktree_path ?? cwd;
|
|
80
|
+
try {
|
|
81
|
+
return fs.existsSync(dir) ? dir : undefined;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Files this claim's worker touched since the claim was created.
|
|
89
|
+
*
|
|
90
|
+
* Union of two sources, because either alone lies:
|
|
91
|
+
* - `git diff --name-only <base_sha>` — everything committed since the
|
|
92
|
+
* baseline, which the dirty set loses the instant a lane commits.
|
|
93
|
+
* - `git status --porcelain` — uncommitted work, which the diff cannot see.
|
|
94
|
+
*
|
|
95
|
+
* A claim with no `base_sha` (created outside a repo, or before C0-b shipped) is
|
|
96
|
+
* unverifiable rather than compared against a guessed baseline.
|
|
97
|
+
*/
|
|
98
|
+
export function collectTouchedPaths(claim, cwd) {
|
|
99
|
+
const workdir = claimWorkdir(claim, cwd);
|
|
100
|
+
if (!workdir)
|
|
101
|
+
return { paths: [], unverifiableReason: 'claim worktree no longer exists' };
|
|
102
|
+
if (!claim.base_sha)
|
|
103
|
+
return { paths: [], unverifiableReason: 'claim has no recorded base_sha baseline' };
|
|
104
|
+
// Confirm the baseline is still reachable before trusting a diff against it —
|
|
105
|
+
// a pruned worktree branch would otherwise make git fail and read as "clean".
|
|
106
|
+
if (git(workdir, ['cat-file', '-e', `${claim.base_sha}^{commit}`]) === undefined) {
|
|
107
|
+
return { paths: [], unverifiableReason: 'recorded base_sha is no longer reachable in this worktree' };
|
|
108
|
+
}
|
|
109
|
+
const committed = splitPaths(git(workdir, ['diff', '--name-only', claim.base_sha]));
|
|
110
|
+
// -uall so a whole new untracked directory is reported file-by-file rather
|
|
111
|
+
// than collapsed to its directory name, which no pathspec would match.
|
|
112
|
+
const dirty = splitLinesRaw(git(workdir, ['status', '--porcelain', '-uall']))
|
|
113
|
+
.map((line) => line.slice(3).trim())
|
|
114
|
+
// A rename reads `R old -> new`; the destination is what was written.
|
|
115
|
+
.map((p) => (p.includes(' -> ') ? p.split(' -> ')[1] : p))
|
|
116
|
+
.map((p) => p.replace(/^"|"$/g, ''));
|
|
117
|
+
const paths = [...new Set([...committed, ...dirty])].filter((p) => p.length > 0);
|
|
118
|
+
return { paths };
|
|
119
|
+
}
|
|
120
|
+
function widenNextActions(claim, unexpected) {
|
|
121
|
+
return [
|
|
122
|
+
{
|
|
123
|
+
tool: 'bclaw_update',
|
|
124
|
+
args: {
|
|
125
|
+
entity: 'claim',
|
|
126
|
+
id: claim.id,
|
|
127
|
+
// Widening means declaring the footprint, not rewriting the prose scope:
|
|
128
|
+
// `paths[]` is the machine-readable half (C0-b) and is additive.
|
|
129
|
+
paths: unexpected.slice(0, MAX_REPORTED_PATHS),
|
|
130
|
+
},
|
|
131
|
+
when: 'the work legitimately spans these paths — declare them so the next reconcile is silent',
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
tool: 'bclaw_create',
|
|
135
|
+
args: {
|
|
136
|
+
entity: 'trap',
|
|
137
|
+
title: `Work on ${claim.scope} pulls in ${unexpected[0]}`,
|
|
138
|
+
body: 'Recurring coupling found by a claim-scope reconcile. Record why these move together.',
|
|
139
|
+
},
|
|
140
|
+
when: 'the strays reveal a real coupling worth warning the next agent about',
|
|
141
|
+
},
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
export function reconcileClaimConformity(claim, cwd, options = {}) {
|
|
145
|
+
const touched = options.touchedPaths
|
|
146
|
+
? { paths: [...options.touchedPaths].filter((p) => p.trim().length > 0) }
|
|
147
|
+
: collectTouchedPaths(claim, cwd);
|
|
148
|
+
if (touched.unverifiableReason) {
|
|
149
|
+
return {
|
|
150
|
+
verdict: { kind: 'unverifiable', reason: touched.unverifiableReason },
|
|
151
|
+
touchedPaths: [],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
// A declared `paths[]` footprint is the claim's own machine-readable statement
|
|
155
|
+
// of intent, so it outranks the prose scope when present — that is the entire
|
|
156
|
+
// reason C0-b made it optional-but-additive.
|
|
157
|
+
//
|
|
158
|
+
// Comma, not space: `resolveScopeToPathspecs` splits on ',' and treats any
|
|
159
|
+
// whitespace inside a token as proof of prose (dirty-scope.ts:143-154), so a
|
|
160
|
+
// space-joined list would silently classify as unverifiable.
|
|
161
|
+
const declared = claim.paths?.length ? claim.paths.join(',') : claim.scope;
|
|
162
|
+
const verdict = assessScopeConformity({
|
|
163
|
+
scope: declared,
|
|
164
|
+
cwd: claimWorkdir(claim, cwd) ?? cwd,
|
|
165
|
+
touchedPaths: touched.paths,
|
|
166
|
+
});
|
|
167
|
+
if (verdict.kind !== 'out_of_scope') {
|
|
168
|
+
return { verdict, touchedPaths: touched.paths };
|
|
169
|
+
}
|
|
170
|
+
const shown = verdict.unexpected.slice(0, MAX_REPORTED_PATHS);
|
|
171
|
+
const overflow = verdict.unexpected.length - shown.length;
|
|
172
|
+
return {
|
|
173
|
+
verdict,
|
|
174
|
+
touchedPaths: touched.paths,
|
|
175
|
+
warning: {
|
|
176
|
+
code: 'wrote_outside_claim_scope',
|
|
177
|
+
message: `Claim ${claim.id} declared '${claim.scope}' but ${verdict.unexpected.length} touched `
|
|
178
|
+
+ `file(s) sit outside it: ${shown.join(', ')}`
|
|
179
|
+
+ (overflow > 0 ? ` (+${overflow} more)` : '')
|
|
180
|
+
+ '. Advisory only — the work is already written.',
|
|
181
|
+
data: {
|
|
182
|
+
claim_id: claim.id,
|
|
183
|
+
scope: claim.scope,
|
|
184
|
+
declared_pathspecs: verdict.pathspecs,
|
|
185
|
+
unexpected_paths: shown,
|
|
186
|
+
...(overflow > 0 ? { unexpected_paths_omitted: overflow } : {}),
|
|
187
|
+
base_sha: claim.base_sha,
|
|
188
|
+
},
|
|
189
|
+
next_actions: widenNextActions(claim, verdict.unexpected),
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
//# sourceMappingURL=claim-conformity.js.map
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pln#636 C0-a — claim scope grammar + conformity verdict.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS SMALL. The design originally called for a fresh classifier. It is
|
|
5
|
+
* not needed: `resolveScopeToPathspecs` (core/dirty-scope.ts) already resolves a
|
|
6
|
+
* free-string scope to git pathspecs or `unknown`, and its own header has
|
|
7
|
+
* documented the bifurcation since pln#520 ("~60% are not resolvable to paths at
|
|
8
|
+
* all"). Writing a second classifier would have been duplicated truth. This
|
|
9
|
+
* module adds only the two things that were genuinely missing.
|
|
10
|
+
*
|
|
11
|
+
* MISSING PIECE 1 — a DECLARED grammar for the reserved semantic prefixes.
|
|
12
|
+
* dirty-scope hardcodes `review-loop:` alone (line ~149), but production carries
|
|
13
|
+
* three variants. Census over the 613 live claims in this store:
|
|
14
|
+
*
|
|
15
|
+
* review-loop 133
|
|
16
|
+
* ideate-loop 5
|
|
17
|
+
* ideation-loop 2
|
|
18
|
+
* C 1 ← a WINDOWS DRIVE LETTER, not a prefix
|
|
19
|
+
* project-resolution 1 ← prose that happens to contain a colon
|
|
20
|
+
* worktree-as-contract 1 ← ditto
|
|
21
|
+
*
|
|
22
|
+
* Two traps fall straight out of that data. A naive `/^[a-z-]+:/i` would read
|
|
23
|
+
* `C:/Users/...` as a semantic scope and stop treating an absolute Windows path
|
|
24
|
+
* as a path. And an unknown `word:` prefix is prose, not a loop reference — so
|
|
25
|
+
* the reserved set is ENUMERATED, never inferred from shape.
|
|
26
|
+
*
|
|
27
|
+
* MISSING PIECE 2 — the verdict's default on `unknown` must be INVERTED relative
|
|
28
|
+
* to the dirty guard, and this is the load-bearing insight of C0:
|
|
29
|
+
*
|
|
30
|
+
* - The dirty guard BLOCKS on unknown. Its cardinal rule is that a noisy,
|
|
31
|
+
* visible false-positive beats a silent false-negative, because letting a
|
|
32
|
+
* worker edit stale code is worse than refusing a legitimate dispatch.
|
|
33
|
+
* - A conformity advisory must be SILENT on unknown. Accusing an agent of
|
|
34
|
+
* writing outside its scope when we cannot tell teaches it to ignore the
|
|
35
|
+
* channel (the pln#634 failure mode) — and a channel an agent has learned to
|
|
36
|
+
* skip is worse than no channel. Saying nothing costs nothing.
|
|
37
|
+
*
|
|
38
|
+
* Same classification, opposite correct default, because the cost of being wrong
|
|
39
|
+
* points the other way. Hence `unverifiable` is a first-class verdict every
|
|
40
|
+
* consumer must render as silence.
|
|
41
|
+
*
|
|
42
|
+
* @module
|
|
43
|
+
*/
|
|
44
|
+
import path from 'node:path';
|
|
45
|
+
import { resolveScopeToPathspecs } from './dirty-scope.js';
|
|
46
|
+
/**
|
|
47
|
+
* Reserved semantic prefixes, enumerated from production usage. A scope starting
|
|
48
|
+
* with one of these refers to a loop lane, never to files.
|
|
49
|
+
*
|
|
50
|
+
* `ideate-loop` and `ideation-loop` BOTH appear in the live store — an
|
|
51
|
+
* inconsistency in the emitting code, not here. Both are accepted so
|
|
52
|
+
* classification is correct today; unifying the emitters is a separate cleanup.
|
|
53
|
+
*/
|
|
54
|
+
export const RESERVED_SCOPE_PREFIXES = ['review-loop', 'ideate-loop', 'ideation-loop'];
|
|
55
|
+
/** True when the token is an absolute Windows path (`C:/…`), not a prefixed scope. */
|
|
56
|
+
function looksLikeWindowsDrive(scope) {
|
|
57
|
+
return /^[A-Za-z]:[\\/]/.test(scope);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Classify a claim scope.
|
|
61
|
+
*
|
|
62
|
+
* Order matters: the drive-letter check runs BEFORE the prefix check, because
|
|
63
|
+
* `C:` satisfies a naive prefix pattern while being a path.
|
|
64
|
+
*/
|
|
65
|
+
export function classifyClaimScope(scope, cwd) {
|
|
66
|
+
const trimmed = scope?.trim();
|
|
67
|
+
if (!trimmed)
|
|
68
|
+
return { kind: 'empty', reason: 'no scope recorded on the claim' };
|
|
69
|
+
if (!looksLikeWindowsDrive(trimmed)) {
|
|
70
|
+
for (const prefix of RESERVED_SCOPE_PREFIXES) {
|
|
71
|
+
if (!trimmed.toLowerCase().startsWith(`${prefix}:`))
|
|
72
|
+
continue;
|
|
73
|
+
const rest = trimmed.slice(prefix.length + 1);
|
|
74
|
+
const [loopId, slotId] = rest.split(':');
|
|
75
|
+
return {
|
|
76
|
+
kind: 'loop_ref',
|
|
77
|
+
loopRef: {
|
|
78
|
+
prefix,
|
|
79
|
+
loopId: (loopId ?? '').trim(),
|
|
80
|
+
...(slotId?.trim() ? { slotId: slotId.trim() } : {}),
|
|
81
|
+
},
|
|
82
|
+
reason: `scope refers to a ${prefix} lane, not to files`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const resolved = resolveScopeToPathspecs(trimmed, cwd);
|
|
87
|
+
if (resolved.kind === 'pathspecs')
|
|
88
|
+
return { kind: 'paths', pathspecs: resolved.pathspecs };
|
|
89
|
+
return { kind: 'prose', reason: resolved.reason };
|
|
90
|
+
}
|
|
91
|
+
function normalise(p) {
|
|
92
|
+
return p.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
93
|
+
}
|
|
94
|
+
/** A touched file is in scope when it equals or sits under one declared pathspec. */
|
|
95
|
+
function matchesPathspec(file, pathspec) {
|
|
96
|
+
const f = normalise(file);
|
|
97
|
+
const spec = normalise(pathspec.replace(/^:\(glob\)/, '')).replace(/\/$/, '');
|
|
98
|
+
if (spec.includes('*') || spec.includes('?')) {
|
|
99
|
+
// Delegating real globs to git is the resolver's job; here a glob scope is
|
|
100
|
+
// deliberately unverifiable rather than approximated with a hand-rolled matcher.
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
return f === spec || f.startsWith(`${spec}/`);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Compare the files a claim actually touched against the scope it declared.
|
|
107
|
+
*
|
|
108
|
+
* SILENT ON DOUBT, by construction. A loop-ref, prose, empty or glob scope
|
|
109
|
+
* yields `unverifiable`, and so does an empty touched-file list — there is
|
|
110
|
+
* nothing to accuse anyone of. Only a path-resolvable scope with concrete
|
|
111
|
+
* touched files can ever produce `out_of_scope`.
|
|
112
|
+
*/
|
|
113
|
+
export function assessScopeConformity(input) {
|
|
114
|
+
const classified = classifyClaimScope(input.scope, input.cwd);
|
|
115
|
+
if (classified.kind !== 'paths' || !classified.pathspecs?.length) {
|
|
116
|
+
return { kind: 'unverifiable', reason: classified.reason ?? 'scope is not path-resolvable' };
|
|
117
|
+
}
|
|
118
|
+
if (input.touchedPaths.length === 0) {
|
|
119
|
+
return { kind: 'unverifiable', reason: 'no touched files to compare' };
|
|
120
|
+
}
|
|
121
|
+
if (classified.pathspecs.some((spec) => spec.includes('*') || spec.includes('?'))) {
|
|
122
|
+
return { kind: 'unverifiable', reason: 'glob scope — left to git rather than approximated here' };
|
|
123
|
+
}
|
|
124
|
+
const specs = classified.pathspecs;
|
|
125
|
+
const matched = [];
|
|
126
|
+
const unexpected = [];
|
|
127
|
+
for (const file of input.touchedPaths) {
|
|
128
|
+
// The coordination store and git internals are never "outside scope": every
|
|
129
|
+
// brainclaw call rewrites them, so counting them would accuse every agent.
|
|
130
|
+
const n = normalise(file);
|
|
131
|
+
if (n.startsWith('.brainclaw/') || n.startsWith('.git/'))
|
|
132
|
+
continue;
|
|
133
|
+
if (specs.some((spec) => matchesPathspec(file, spec)))
|
|
134
|
+
matched.push(n);
|
|
135
|
+
else
|
|
136
|
+
unexpected.push(n);
|
|
137
|
+
}
|
|
138
|
+
if (unexpected.length === 0) {
|
|
139
|
+
return matched.length > 0
|
|
140
|
+
? { kind: 'in_scope', matched }
|
|
141
|
+
: { kind: 'unverifiable', reason: 'every touched file was a system path' };
|
|
142
|
+
}
|
|
143
|
+
return { kind: 'out_of_scope', unexpected, pathspecs: specs };
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Absolute→relative helper for callers holding worktree-absolute paths (a git
|
|
147
|
+
* diff run inside a lane worktree returns repo-relative already, but a hook sees
|
|
148
|
+
* absolute `tool_input.file_path`).
|
|
149
|
+
*/
|
|
150
|
+
export function toRepoRelative(absoluteOrRelative, repoRoot) {
|
|
151
|
+
if (!path.isAbsolute(absoluteOrRelative))
|
|
152
|
+
return normalise(absoluteOrRelative);
|
|
153
|
+
return normalise(path.relative(repoRoot, absoluteOrRelative));
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=claim-scope.js.map
|