brainclaw 1.25.0 → 1.26.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/code-map.js +1 -4
- package/dist/commands/mcp.js +7 -7
- package/dist/commands/session-start.js +137 -15
- package/dist/core/bootstrap.js +28 -4
- package/dist/core/code-map/aggregate.js +36 -31
- package/dist/core/code-map/backend.js +4 -4
- package/dist/core/code-map/core.js +1 -0
- package/dist/core/code-map/export.js +4 -4
- package/dist/core/code-map/finalizer.js +57 -2
- package/dist/core/code-map/freshness.js +78 -13
- package/dist/core/code-map/impact.js +36 -4
- package/dist/core/code-map/indexes.js +37 -0
- package/dist/core/code-map/lang/python/index.js +4 -2
- package/dist/core/code-map/lang/query-runtime.js +2 -0
- package/dist/core/code-map/lang/typescript/index.js +4 -2
- package/dist/core/code-map/lang/usages.js +333 -0
- package/dist/core/code-map/memory-reader.js +15 -0
- package/dist/core/code-map/query.js +209 -58
- package/dist/core/code-map/refresh.js +0 -0
- package/dist/core/code-map/resolve.js +27 -2
- package/dist/core/code-map/store.js +1 -0
- package/dist/core/code-map/types.js +55 -9
- package/dist/core/code-map/vocabulary.js +6 -0
- package/dist/core/code-map/work-section.js +12 -14
- package/dist/core/context-diff.js +17 -3
- package/dist/core/entity-operations.js +14 -2
- package/dist/core/hint-aging.js +4 -1
- package/dist/core/identity.js +284 -91
- package/dist/core/io.js +192 -0
- package/dist/core/project-discovery.js +7 -1
- package/dist/core/runtime.js +99 -11
- package/dist/core/store-resolution.js +5 -21
- package/dist/facts.js +12 -12
- package/dist/facts.json +11 -11
- package/docs/code-map.md +36 -27
- package/package.json +1 -1
|
Binary file
|
|
@@ -15,10 +15,7 @@ function badgeLine(badge) {
|
|
|
15
15
|
const detail = detailKeys.length
|
|
16
16
|
? ` (${detailKeys.map((k) => `${k}=${JSON.stringify(badge.details[k])}`).join(', ')})`
|
|
17
17
|
: '';
|
|
18
|
-
|
|
19
|
-
// the precise status + details. `coarse` may be absent on legacy/hand-built badges.
|
|
20
|
-
const coarse = badge.coarse ? `${badge.coarse} · ` : '';
|
|
21
|
-
return `Freshness: ${coarse}${badge.status}${detail}`;
|
|
18
|
+
return `Freshness: ${badge.freshness}${detail}`;
|
|
22
19
|
}
|
|
23
20
|
export async function runCodeMap(subcommand, args, options = {}) {
|
|
24
21
|
const normalized = (subcommand ?? '').trim().toLowerCase();
|
package/dist/commands/mcp.js
CHANGED
|
@@ -1097,7 +1097,7 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
1097
1097
|
const status = await be.status({ cwd, cascade: args.cascade === true });
|
|
1098
1098
|
return {
|
|
1099
1099
|
response: toolResponse({
|
|
1100
|
-
content: [{ type: 'text', text: `Code Map: ${status.store_exists ? 'store present' : 'no store'} — freshness=${status.freshness_badge.
|
|
1100
|
+
content: [{ type: 'text', text: `Code Map: ${status.store_exists ? 'store present' : 'no store'} — freshness=${status.freshness_badge.freshness}` }],
|
|
1101
1101
|
structuredContent: { ...status, freshness_badge: status.freshness_badge },
|
|
1102
1102
|
}),
|
|
1103
1103
|
};
|
|
@@ -1108,7 +1108,7 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
1108
1108
|
const cascadeNote = result.cascade ? ` cascade=${result.cascade.children_refreshed} child(ren)+root` : '';
|
|
1109
1109
|
return {
|
|
1110
1110
|
response: toolResponse({
|
|
1111
|
-
content: [{ type: 'text', text: `Code Map refresh [${result.scope}]: ran=${result.ran} freshness=${result.freshness_badge.
|
|
1111
|
+
content: [{ type: 'text', text: `Code Map refresh [${result.scope}]: ran=${result.ran} freshness=${result.freshness_badge.freshness}${cascadeNote}${result.lock_status ? ` (${result.lock_status})` : ''}` }],
|
|
1112
1112
|
structuredContent: { ...result, freshness_badge: result.freshness_badge },
|
|
1113
1113
|
}),
|
|
1114
1114
|
};
|
|
@@ -1122,7 +1122,7 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
1122
1122
|
const result = await be.find({ query, limit, cwd });
|
|
1123
1123
|
return {
|
|
1124
1124
|
response: toolResponse({
|
|
1125
|
-
content: [{ type: 'text', text: `Code Map find "${result.query}": ${result.matches.length} match(es), freshness=${result.freshness_badge.
|
|
1125
|
+
content: [{ type: 'text', text: `Code Map find "${result.query}": ${result.matches.length} match(es), freshness=${result.freshness_badge.freshness}` }],
|
|
1126
1126
|
structuredContent: { ...result, freshness_badge: result.freshness_badge },
|
|
1127
1127
|
}),
|
|
1128
1128
|
};
|
|
@@ -1143,7 +1143,7 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
1143
1143
|
const result = await be.exportGraph({ target, targetKind, direction, depth, maxNodes, maxEdges, minConfidence, format, cwd });
|
|
1144
1144
|
return {
|
|
1145
1145
|
response: toolResponse({
|
|
1146
|
-
content: [{ type: 'text', text: `Code Map export "${result.target}": ${result.nodes.length} node(s), ${result.edges.length} edge(s), depth=${result.limits.max_depth}, freshness=${result.freshness_badge.
|
|
1146
|
+
content: [{ type: 'text', text: `Code Map export "${result.target}": ${result.nodes.length} node(s), ${result.edges.length} edge(s), depth=${result.limits.max_depth}, freshness=${result.freshness_badge.freshness}` }],
|
|
1147
1147
|
structuredContent: { ...result, freshness_badge: result.freshness_badge },
|
|
1148
1148
|
}),
|
|
1149
1149
|
};
|
|
@@ -1159,7 +1159,7 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
1159
1159
|
const result = await be.impact({ target, depth, limit, cwd });
|
|
1160
1160
|
return {
|
|
1161
1161
|
response: toolResponse({
|
|
1162
|
-
content: [{ type: 'text', text: `Code Map impact "${result.target}": ${result.risk.counters.direct_dependents} direct, ${result.risk.counters.transitive_dependents} transitive dependent(s), risk=${result.risk.score}, freshness=${result.freshness_badge.
|
|
1162
|
+
content: [{ type: 'text', text: `Code Map impact "${result.target}": ${result.risk.counters.direct_dependents} direct, ${result.risk.counters.transitive_dependents} transitive dependent(s), risk=${result.risk.score}, freshness=${result.freshness_badge.freshness}` }],
|
|
1163
1163
|
structuredContent: { ...result, freshness_badge: result.freshness_badge },
|
|
1164
1164
|
}),
|
|
1165
1165
|
};
|
|
@@ -1174,7 +1174,7 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
1174
1174
|
const result = await be.outline({ path: outlinePath, limit, cwd });
|
|
1175
1175
|
return {
|
|
1176
1176
|
response: toolResponse({
|
|
1177
|
-
content: [{ type: 'text', text: `Code Map outline "${result.path}": ${result.symbols.length}/${result.symbol_count} symbol(s), index=${result.index_status}, freshness=${result.freshness_badge.
|
|
1177
|
+
content: [{ type: 'text', text: `Code Map outline "${result.path}": ${result.symbols.length}/${result.symbol_count} symbol(s), index=${result.index_status}, freshness=${result.freshness_badge.freshness}` }],
|
|
1178
1178
|
structuredContent: { ...result, freshness_badge: result.freshness_badge },
|
|
1179
1179
|
}),
|
|
1180
1180
|
};
|
|
@@ -1188,7 +1188,7 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
1188
1188
|
const result = await be.brief({ target, limit, cwd });
|
|
1189
1189
|
return {
|
|
1190
1190
|
response: toolResponse({
|
|
1191
|
-
content: [{ type: 'text', text: `Code Map brief "${result.target}": ${result.suggested_files_to_read.length} file(s) to read, freshness=${result.freshness_badge.
|
|
1191
|
+
content: [{ type: 'text', text: `Code Map brief "${result.target}": ${result.suggested_files_to_read.length} file(s) to read, freshness=${result.freshness_badge.freshness}` }],
|
|
1192
1192
|
structuredContent: { ...result, freshness_badge: result.freshness_badge },
|
|
1193
1193
|
}),
|
|
1194
1194
|
};
|
|
@@ -2,9 +2,9 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { execSync } from 'node:child_process';
|
|
5
|
-
import { memoryExists, resolveEntityDir } from '../core/io.js';
|
|
5
|
+
import { assertSafeSessionId, isSessionSnapshotRecordFilename, memoryExists, resolveEntityDir, sessionSnapshotRecordPaths } from '../core/io.js';
|
|
6
6
|
import { loadVersionedJsonFile, saveVersionedJsonFile } from '../core/migration.js';
|
|
7
|
-
import { buildOperationalIdentity, loadAllSessions, saveCurrentSession } from '../core/identity.js';
|
|
7
|
+
import { buildOperationalIdentity, describeIgnoredSessionIdEnv, loadAllSessions, saveCurrentSession } from '../core/identity.js';
|
|
8
8
|
import { requireMinimumTrustLevel, resolveCurrentModel, resolveOrAutoRegisterAgentIdentity } from '../core/agent-registry.js';
|
|
9
9
|
import { buildContext, renderContextPromptTemplate } from '../core/context.js';
|
|
10
10
|
import { writeContextMarker } from '../core/freshness.js';
|
|
@@ -25,11 +25,86 @@ import { loadHygienePolicy } from '../core/hygiene-policy.js';
|
|
|
25
25
|
import { maybeCreateCheckpoint } from '../core/events/checkpoint.js';
|
|
26
26
|
import { pullSignalsFromLinkedProjects, markSignalProcessed } from '../core/federation-transport.js';
|
|
27
27
|
import { materializeFederationSignal } from '../core/federation-materialize.js';
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
/**
|
|
29
|
+
* pln#670 — snapshot writes always target the canonical directory ('write' mode).
|
|
30
|
+
* The previous 'read'-mode resolution meant a fresh store (no coordination/sessions
|
|
31
|
+
* yet) landed the snapshot in the legacy dir — the current_session home — where
|
|
32
|
+
* saveCurrentSession then clobbered it (same `<session_id>.json` name, same id).
|
|
33
|
+
*/
|
|
34
|
+
function sessionSnapshotWriteDir(cwd) {
|
|
35
|
+
return resolveEntityDir('sessions', cwd ?? process.cwd(), 'write');
|
|
30
36
|
}
|
|
31
37
|
function sessionSnapshotPath(sessionId, cwd) {
|
|
32
|
-
|
|
38
|
+
// pln#672 review P1 — THE SECOND WRITER. This builder was unguarded while
|
|
39
|
+
// sessionFilePathIn / sessionSnapshotRecordPaths were hardened, so an
|
|
40
|
+
// env-controlled traversal still escaped the store HERE, before the
|
|
41
|
+
// current_session write refused it (reproduced by the reviewer:
|
|
42
|
+
// `ESCAPED.snapshot.json` landed outside, and the later throw did not undo
|
|
43
|
+
// the escaped write). A guard on some builders is not a guard.
|
|
44
|
+
assertSafeSessionId(sessionId);
|
|
45
|
+
return path.join(sessionSnapshotWriteDir(cwd), `${sessionId}.snapshot.json`);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* pln#672 review P2 — the snapshot write needs the same positive proof the
|
|
49
|
+
* current_session write already has. On a case-insensitive filesystem
|
|
50
|
+
* `CaseSnapshot` and `casesnapshot` name the SAME file, so a second session
|
|
51
|
+
* silently overwrote the first one's snapshot. Refuse to replace a record
|
|
52
|
+
* that names a different session; identical-id rewrites (heartbeat, restart)
|
|
53
|
+
* stay allowed.
|
|
54
|
+
*/
|
|
55
|
+
function assertSnapshotSlotFree(filepath, sessionId) {
|
|
56
|
+
if (!fs.existsSync(filepath))
|
|
57
|
+
return;
|
|
58
|
+
try {
|
|
59
|
+
const raw = JSON.parse(fs.readFileSync(filepath, 'utf-8'));
|
|
60
|
+
if (typeof raw.session_id === 'string' && raw.session_id !== sessionId) {
|
|
61
|
+
throw new Error(`Refusing to overwrite the session_snapshot of '${raw.session_id}' at '${filepath}' with '${sessionId}' — the two ids resolve to the same filename on this filesystem`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
// A deliberate refusal propagates; an unreadable record does not block a
|
|
66
|
+
// fresh write (it is not proof that another session owns the slot).
|
|
67
|
+
if (err instanceof Error && err.message.startsWith('Refusing to overwrite'))
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* pln#670 — lazy migration of pre-split snapshot records: rename `<id>.json` to
|
|
73
|
+
* `<id>.snapshot.json` in the CANONICAL sessions directory only. The legacy
|
|
74
|
+
* directory is never scanned — it is the current_session home. Only files that
|
|
75
|
+
* validate as session_snapshot are touched; anything else is left in place.
|
|
76
|
+
*/
|
|
77
|
+
export function migrateLegacySnapshotNames(cwd) {
|
|
78
|
+
const dir = sessionSnapshotWriteDir(cwd);
|
|
79
|
+
if (!fs.existsSync(dir))
|
|
80
|
+
return 0;
|
|
81
|
+
let renamed = 0;
|
|
82
|
+
for (const file of fs.readdirSync(dir)) {
|
|
83
|
+
// Case-insensitive suffix checks (codex review P1): Windows filesystems
|
|
84
|
+
// match names case-insensitively — an upper-cased `.SNAPSHOT.json` is the
|
|
85
|
+
// same record a lower-case probe reads, and must not be re-suffixed.
|
|
86
|
+
if (!file.toLowerCase().endsWith('.json') || isSessionSnapshotRecordFilename(file))
|
|
87
|
+
continue;
|
|
88
|
+
const from = path.join(dir, file);
|
|
89
|
+
try {
|
|
90
|
+
// Discriminate on the RAW file — the migration loader zod-strips unknown
|
|
91
|
+
// keys, so a current_session record parses as a clean snapshot after it.
|
|
92
|
+
// Key PRESENCE, not value type (codex review P1): the invariant is
|
|
93
|
+
// "never touch a record CARRYING last_seen_at", and `last_seen_at: null`
|
|
94
|
+
// must be rejected too.
|
|
95
|
+
const raw = JSON.parse(fs.readFileSync(from, 'utf-8'));
|
|
96
|
+
if (Object.hasOwn(raw, 'last_seen_at'))
|
|
97
|
+
continue;
|
|
98
|
+
SessionSnapshotSchema.parse(loadVersionedJsonFile('session_snapshot', from).document);
|
|
99
|
+
const to = path.join(dir, `${file.slice(0, -'.json'.length)}.snapshot.json`);
|
|
100
|
+
if (!fs.existsSync(to)) {
|
|
101
|
+
fs.renameSync(from, to);
|
|
102
|
+
renamed++;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch { /* not a session_snapshot — leave it alone */ }
|
|
106
|
+
}
|
|
107
|
+
return renamed;
|
|
33
108
|
}
|
|
34
109
|
export async function runSessionStart(options = {}) {
|
|
35
110
|
try {
|
|
@@ -101,6 +176,11 @@ export async function runSessionStart(options = {}) {
|
|
|
101
176
|
if (snapshot.stale_surfaces) {
|
|
102
177
|
console.warn(`⚠ ${snapshot.stale_surfaces.message}`);
|
|
103
178
|
}
|
|
179
|
+
// pln#672 — a dropped env session id must reach the human too: the whole
|
|
180
|
+
// point of the warning is that the session identity is NOT the one asked for.
|
|
181
|
+
if (snapshot.invalid_session_id_ignored) {
|
|
182
|
+
console.warn(`⚠ ${snapshot.invalid_session_id_ignored.message}`);
|
|
183
|
+
}
|
|
104
184
|
// Fifth instance of the computed-then-dropped class, caught by the new seam
|
|
105
185
|
// guard on its first run: built since the shared-checkout detection landed,
|
|
106
186
|
// read by nothing. Two agents editing one checkout is precisely what a human
|
|
@@ -155,10 +235,12 @@ export async function startSession(options = {}) {
|
|
|
155
235
|
...(model ? { model } : {}),
|
|
156
236
|
};
|
|
157
237
|
// Persist snapshot
|
|
158
|
-
const dir =
|
|
238
|
+
const dir = sessionSnapshotWriteDir(options.cwd);
|
|
159
239
|
if (!fs.existsSync(dir))
|
|
160
240
|
fs.mkdirSync(dir, { recursive: true });
|
|
161
|
-
|
|
241
|
+
const snapshotPath = sessionSnapshotPath(snapshot.session_id, options.cwd);
|
|
242
|
+
assertSnapshotSlotFree(snapshotPath, snapshot.session_id);
|
|
243
|
+
saveVersionedJsonFile('session_snapshot', snapshotPath, SessionSnapshotSchema.parse(snapshot));
|
|
162
244
|
// Resolve git branch and worktree for session tracking
|
|
163
245
|
let currentBranch;
|
|
164
246
|
let currentWorktreePath;
|
|
@@ -218,6 +300,14 @@ export async function startSession(options = {}) {
|
|
|
218
300
|
inventoryAdvisory = lines;
|
|
219
301
|
}
|
|
220
302
|
catch { /* non-fatal — inventory scan failure should not block session start */ }
|
|
303
|
+
// pln#670 — lazy rename of pre-split snapshot records to the type-suffixed
|
|
304
|
+
// name. Session-start full maintenance is the natural sweep point (no daemon,
|
|
305
|
+
// feedback_lazy_reconcile_pattern); dual-read keeps unrenamed records readable
|
|
306
|
+
// in the meantime.
|
|
307
|
+
try {
|
|
308
|
+
migrateLegacySnapshotNames(options.cwd);
|
|
309
|
+
}
|
|
310
|
+
catch { /* non-fatal — name migration must never block session start */ }
|
|
221
311
|
// pln#564 step B — cap the runtime-note tree on session start (no LLM gate,
|
|
222
312
|
// unlike the compaction-phase archiveSessionNotes). Keeps the newest N
|
|
223
313
|
// session/lifecycle notes per agent + all genuine observations, parks the
|
|
@@ -319,6 +409,18 @@ export async function startSession(options = {}) {
|
|
|
319
409
|
staleSurfaces = staleSurfaceWarning(freshness, currentVersion);
|
|
320
410
|
}
|
|
321
411
|
catch { /* non-fatal */ }
|
|
412
|
+
// pln#672 — report a DROPPED env session id. Never echo the raw value: it is
|
|
413
|
+
// attacker-influenced and would land straight in logs; the variable name and
|
|
414
|
+
// its length are enough to diagnose.
|
|
415
|
+
let invalidSessionIdIgnored;
|
|
416
|
+
const droppedSessionEnv = describeIgnoredSessionIdEnv();
|
|
417
|
+
if (droppedSessionEnv) {
|
|
418
|
+
invalidSessionIdIgnored = {
|
|
419
|
+
code: 'invalid_session_id_ignored',
|
|
420
|
+
message: `${droppedSessionEnv.variable} carries a session id that cannot be a record filename (${droppedSessionEnv.length} chars) — it was ignored and this session runs under '${snapshot.session_id}'. Unset or fix the variable to resume the intended session.`,
|
|
421
|
+
data: { variable: droppedSessionEnv.variable, length: droppedSessionEnv.length, effective_session_id: snapshot.session_id },
|
|
422
|
+
};
|
|
423
|
+
}
|
|
322
424
|
// Materialize incoming federation signals from linked projects (Phase 0 — local)
|
|
323
425
|
if (maintenanceMode === 'full') {
|
|
324
426
|
try {
|
|
@@ -354,6 +456,7 @@ export async function startSession(options = {}) {
|
|
|
354
456
|
...(staleClaimsReleased ? { stale_claims_released: staleClaimsReleased } : {}),
|
|
355
457
|
...(memoryPressure ? { memory_pressure: memoryPressure } : {}),
|
|
356
458
|
...(staleSurfaces ? { stale_surfaces: toWarningDetail(staleSurfaces) } : {}),
|
|
459
|
+
...(invalidSessionIdIgnored ? { invalid_session_id_ignored: toWarningDetail(invalidSessionIdIgnored) } : {}),
|
|
357
460
|
...(autoRegistered ? { auto_registered: true } : {}),
|
|
358
461
|
};
|
|
359
462
|
}
|
|
@@ -367,14 +470,33 @@ function isPidAlive(pid) {
|
|
|
367
470
|
}
|
|
368
471
|
}
|
|
369
472
|
export function loadSessionSnapshot(sessionId, cwd) {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
473
|
+
// pln#670 — probe the type-suffixed name first, then the pre-split `<id>.json`
|
|
474
|
+
// layouts. SessionSnapshotSchema is non-strict, so a current_session record for
|
|
475
|
+
// the same id would parse too (zod strips unknown keys) — the negative
|
|
476
|
+
// discriminant is `last_seen_at`, which only current_session carries.
|
|
477
|
+
for (const p of sessionSnapshotRecordPaths(sessionId, cwd)) {
|
|
478
|
+
if (!fs.existsSync(p))
|
|
479
|
+
continue;
|
|
480
|
+
try {
|
|
481
|
+
// Discriminate on the RAW file: the migration loader zod-strips unknown
|
|
482
|
+
// keys, so a current_session record would come back looking like a clean
|
|
483
|
+
// snapshot. Key PRESENCE, not value type (codex review P1) — a record
|
|
484
|
+
// carrying `last_seen_at: null` must be rejected too.
|
|
485
|
+
const raw = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
486
|
+
if (Object.hasOwn(raw, 'last_seen_at'))
|
|
487
|
+
continue;
|
|
488
|
+
const snapshot = SessionSnapshotSchema.parse(loadVersionedJsonFile('session_snapshot', p).document);
|
|
489
|
+
// The filename is not a type-safe identity boundary by itself (codex
|
|
490
|
+
// review): querying `sess_x.snapshot` also constructs `sess_x.snapshot.json`
|
|
491
|
+
// — the snapshot of sess_x. Only return a payload that names the caller's id.
|
|
492
|
+
if (snapshot.session_id !== sessionId)
|
|
493
|
+
continue;
|
|
494
|
+
return snapshot;
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
378
499
|
}
|
|
500
|
+
return undefined;
|
|
379
501
|
}
|
|
380
502
|
//# sourceMappingURL=session-start.js.map
|
package/dist/core/bootstrap.js
CHANGED
|
@@ -9,6 +9,7 @@ import { mutate } from './mutation-pipeline.js';
|
|
|
9
9
|
import { BootstrapApplicationReceiptSchema, BootstrapInterviewAnswerSchema, BootstrapInterviewPlanSchema, BootstrapInterviewQuestionSchema, BootstrapImportPlanDocumentSchema, BootstrapProfileDocumentSchema, BootstrapSuggestionDocumentSchema, MemorySeedDocumentSchema, } from './schema.js';
|
|
10
10
|
import { loadVersionedJsonFile, saveVersionedJsonFile } from './migration.js';
|
|
11
11
|
import { analyzeRepository, findNestedAgentsFiles } from './repo-analysis.js';
|
|
12
|
+
import { isManagedByBrainclaw } from './project-discovery.js';
|
|
12
13
|
import { buildExecutionContext, compactExecutionContext } from './execution-context.js';
|
|
13
14
|
import { buildAgentToolingContext } from './agent-context.js';
|
|
14
15
|
import { createInstruction, loadInstructions, saveInstruction } from './instructions.js';
|
|
@@ -256,15 +257,38 @@ function buildBootstrapArtifacts(input) {
|
|
|
256
257
|
sourcesScanned.push('README');
|
|
257
258
|
seeds.push(...extractReadmeSeeds(readmePath, input.target));
|
|
258
259
|
}
|
|
260
|
+
// pln#671 — instruction files generated by `brainclaw export` derive FROM
|
|
261
|
+
// brainclaw memory: deriving seeds from them feeds the store its own output
|
|
262
|
+
// back as "new" knowledge. Seed extraction skips managed files; DETECTION
|
|
263
|
+
// (agentsPresent, native_instruction_files, source fingerprint) stays
|
|
264
|
+
// complete — that is environment inventory, not knowledge to import. The
|
|
265
|
+
// skip is traced in sources_scanned so the exclusion is never silent.
|
|
259
266
|
const agentsPath = path.join(scanRoot, 'AGENTS.md');
|
|
260
267
|
const agentsPresent = fs.existsSync(agentsPath);
|
|
261
268
|
if (agentsPresent) {
|
|
262
|
-
|
|
263
|
-
|
|
269
|
+
if (isManagedByBrainclaw(agentsPath)) {
|
|
270
|
+
sourcesScanned.push('AGENTS.md (brainclaw-managed — skipped)');
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
sourcesScanned.push('AGENTS.md');
|
|
274
|
+
seeds.push(...extractAgentsSeeds(agentsPath, input.target));
|
|
275
|
+
}
|
|
264
276
|
}
|
|
265
277
|
if (nativeInstructionFiles.length > 0) {
|
|
266
|
-
|
|
267
|
-
|
|
278
|
+
// AGENTS.md is handled by its own extractor above — keep it out of the
|
|
279
|
+
// native accounting so a managed AGENTS.md is not counted as skipped twice.
|
|
280
|
+
const nativeCandidates = nativeInstructionFiles
|
|
281
|
+
.filter((relativePath) => path.basename(relativePath) !== 'AGENTS.md');
|
|
282
|
+
const humanAuthored = nativeCandidates
|
|
283
|
+
.filter((relativePath) => !isManagedByBrainclaw(path.join(scanRoot, relativePath)));
|
|
284
|
+
const skippedManaged = nativeCandidates.length - humanAuthored.length;
|
|
285
|
+
if (humanAuthored.length > 0) {
|
|
286
|
+
sourcesScanned.push('native_instructions');
|
|
287
|
+
seeds.push(...extractNativeInstructionSeeds(humanAuthored.map((relativePath) => path.join(scanRoot, relativePath)), scanRoot, input.target));
|
|
288
|
+
}
|
|
289
|
+
if (skippedManaged > 0) {
|
|
290
|
+
sourcesScanned.push(`native_instructions (${skippedManaged} brainclaw-managed — skipped)`);
|
|
291
|
+
}
|
|
268
292
|
}
|
|
269
293
|
const manifestResult = extractManifestSeeds(scanRoot, input.target);
|
|
270
294
|
if (manifestResult.seeds.length > 0) {
|
|
@@ -26,7 +26,7 @@ import path from 'node:path';
|
|
|
26
26
|
import { loadConfig } from '../config.js';
|
|
27
27
|
import { listNestedProjects } from './cascade.js';
|
|
28
28
|
import { readManifest, readImportsIndex } from './store.js';
|
|
29
|
-
import {
|
|
29
|
+
import { makeFreshnessBadge, applyGitHeadDrift } from './freshness.js';
|
|
30
30
|
import { findInStore, briefInStore, makeLazyChecker, newAccumulator, deriveBadge, reserveSourceSlots, attachRelatedMemory, attachMemoryIds, validateStoreEntry, BRIEF_FILE_CAP, LAZY_BUDGET, } from './query.js';
|
|
31
31
|
/** Same default cap as the single-store find (query.ts DEFAULT_FIND_LIMIT). */
|
|
32
32
|
const DEFAULT_FIND_LIMIT = 20;
|
|
@@ -191,22 +191,11 @@ function statusRank(s) {
|
|
|
191
191
|
function mergeBadges(perStore) {
|
|
192
192
|
const total = perStore.length;
|
|
193
193
|
const indexed = perStore.filter((p) => p.hasIndex);
|
|
194
|
-
const unindexed = perStore
|
|
195
|
-
.filter((p) => !p.hasIndex)
|
|
196
|
-
.map((p) => p.ref.relPath || '.')
|
|
197
|
-
.sort();
|
|
194
|
+
const unindexed = perStore.filter((p) => !p.hasIndex).map((p) => p.ref.relPath || '.').sort();
|
|
198
195
|
if (indexed.length === 0) {
|
|
199
|
-
return {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
details: {
|
|
203
|
-
traversal: 'workspace',
|
|
204
|
-
projects_indexed: 0,
|
|
205
|
-
projects_total: total,
|
|
206
|
-
unindexed_projects: unindexed,
|
|
207
|
-
hint: 'run refresh --cascade',
|
|
208
|
-
},
|
|
209
|
-
};
|
|
196
|
+
return makeFreshnessBadge('missing_index', {
|
|
197
|
+
extra: { traversal: 'workspace', projects_indexed: 0, projects_total: total, unindexed_projects: unindexed, hint: 'run refresh --cascade' },
|
|
198
|
+
});
|
|
210
199
|
}
|
|
211
200
|
let worst = indexed[0].badge.status;
|
|
212
201
|
for (const p of indexed) {
|
|
@@ -221,12 +210,11 @@ function mergeBadges(perStore) {
|
|
|
221
210
|
};
|
|
222
211
|
if (unindexed.length)
|
|
223
212
|
details.unindexed_projects = unindexed;
|
|
224
|
-
// Merge the per-store detail path-sets, prefixing each with its store's
|
|
225
|
-
// workspace-relative dir so a bare `src/index.ts` isn't ambiguous across packages.
|
|
226
213
|
const prefixMerge = (key) => {
|
|
227
214
|
const out = [];
|
|
228
215
|
for (const p of indexed) {
|
|
229
|
-
const
|
|
216
|
+
const spot = p.badge.details.spot_check;
|
|
217
|
+
const arr = spot?.[key];
|
|
230
218
|
if (Array.isArray(arr)) {
|
|
231
219
|
for (const f of arr)
|
|
232
220
|
out.push(p.ref.relPath ? `${p.ref.relPath}/${String(f)}` : String(f));
|
|
@@ -234,16 +222,25 @@ function mergeBadges(perStore) {
|
|
|
234
222
|
}
|
|
235
223
|
return out.sort();
|
|
236
224
|
};
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
225
|
+
const spotChecks = indexed.map((p) => p.badge.details.spot_check);
|
|
226
|
+
const spotStatus = spotChecks.some((spot) => spot?.status === 'partial')
|
|
227
|
+
? 'partial'
|
|
228
|
+
: spotChecks.some((spot) => spot?.status === 'stale')
|
|
229
|
+
? 'stale'
|
|
230
|
+
: spotChecks.some((spot) => spot?.status === 'fresh') ? 'fresh' : 'not_run';
|
|
231
|
+
const partialSpot = spotChecks.find((spot) => spot?.status === 'partial');
|
|
232
|
+
return makeFreshnessBadge(worst, {
|
|
233
|
+
spotCheck: {
|
|
234
|
+
status: spotStatus,
|
|
235
|
+
checked_files: spotChecks.reduce((sum, spot) => sum + (spot?.checked_files ?? 0), 0),
|
|
236
|
+
stale_changed_files: prefixMerge('stale_changed_files'),
|
|
237
|
+
deleted_files: prefixMerge('deleted_files'),
|
|
238
|
+
unchecked_files: prefixMerge('unchecked_files'),
|
|
239
|
+
budget_exhausted: spotChecks.some((spot) => spot?.budget_exhausted === true),
|
|
240
|
+
partial_reason: partialSpot?.partial_reason ?? null,
|
|
241
|
+
},
|
|
242
|
+
extra: details,
|
|
243
|
+
});
|
|
247
244
|
}
|
|
248
245
|
/**
|
|
249
246
|
* Aggregated find across a resolved multi-project workspace. Shares ONE lazy budget
|
|
@@ -426,9 +423,15 @@ export function aggregateBrief(target, limit, resolved, currentHead, memoryReade
|
|
|
426
423
|
const seen = new Set();
|
|
427
424
|
const mergedDefiningPaths = new Set();
|
|
428
425
|
const symbolNames = new Set();
|
|
426
|
+
const memorySymbolNames = new Set();
|
|
427
|
+
const memoryImportNames = new Set();
|
|
429
428
|
for (const p of contributing) {
|
|
430
429
|
for (const e of p.r.defining)
|
|
431
430
|
symbolNames.add(e.name);
|
|
431
|
+
for (const name of p.r.memorySymbolNames)
|
|
432
|
+
memorySymbolNames.add(name);
|
|
433
|
+
for (const name of p.r.memoryImportNames)
|
|
434
|
+
memoryImportNames.add(name);
|
|
432
435
|
for (const dp of p.r.definingPaths)
|
|
433
436
|
mergedDefiningPaths.add(p.ref.relPath ? `${p.ref.relPath}/${dp}` : dp);
|
|
434
437
|
for (const rf of p.r.confident) {
|
|
@@ -460,8 +463,10 @@ export function aggregateBrief(target, limit, resolved, currentHead, memoryReade
|
|
|
460
463
|
const capped = reserveSourceSlots(merged, cap, mergedDefiningPaths);
|
|
461
464
|
if (symbolNames.size === 0)
|
|
462
465
|
symbolNames.add(target);
|
|
463
|
-
|
|
464
|
-
|
|
466
|
+
if (memorySymbolNames.size === 0)
|
|
467
|
+
memorySymbolNames.add(target);
|
|
468
|
+
const related = attachRelatedMemory(memoryReader({ cwd: resolved.root }), capped.map((f) => f.path), [...memorySymbolNames], [...memoryImportNames]);
|
|
469
|
+
const baseEntries = attachMemoryIds(capped, related, mergedDefiningPaths);
|
|
465
470
|
const suggested = baseEntries.map((s, i) => ({
|
|
466
471
|
...s,
|
|
467
472
|
project: capped[i].project,
|
|
@@ -12,7 +12,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { readManifest, readShard, storeExists } from './store.js';
|
|
14
14
|
import { refresh as runRefresh } from './refresh.js';
|
|
15
|
-
import { applyGitHeadDrift,
|
|
15
|
+
import { applyGitHeadDrift, withFreshness } from './freshness.js';
|
|
16
16
|
import { brief as runBrief, find as runFind } from './query.js';
|
|
17
17
|
import { impact as runImpact } from './impact.js';
|
|
18
18
|
import { exportSubgraph } from './export.js';
|
|
@@ -32,9 +32,9 @@ export const OUTLINE_SYMBOL_CAP = 200;
|
|
|
32
32
|
/** Diagnostics are useful context, but unbounded provider facts are not. */
|
|
33
33
|
export const OUTLINE_DIAGNOSTIC_CAP = 20;
|
|
34
34
|
function badge(status, details = {}) {
|
|
35
|
-
// pln#601 —
|
|
36
|
-
//
|
|
37
|
-
return
|
|
35
|
+
// pln#601 — build the uniform freshness envelope for every backend surface
|
|
36
|
+
// It always includes index and spot-check details.
|
|
37
|
+
return withFreshness({ status, details });
|
|
38
38
|
}
|
|
39
39
|
/**
|
|
40
40
|
* Convert a user path into the POSIX project-relative identity used by shards.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* model; it never takes a second, potentially different graph traversal.
|
|
4
4
|
*/
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import {
|
|
6
|
+
import { withFreshness } from './freshness.js';
|
|
7
7
|
import { listShards, readManifest } from './store.js';
|
|
8
8
|
/** Absolute traversal and response ceilings: a whole-graph export is impossible. */
|
|
9
9
|
export const CODE_EXPORT_MAX_DEPTH = 4;
|
|
@@ -113,12 +113,12 @@ export function exportSubgraph(targetInput, options, ctx) {
|
|
|
113
113
|
min_confidence: clampConfidence(options?.minConfidence),
|
|
114
114
|
};
|
|
115
115
|
const manifest = readManifest(ctx.cwd, ctx.preferredDirName);
|
|
116
|
-
const missing =
|
|
116
|
+
const missing = withFreshness({ status: 'missing_index', details: { hint: 'run refresh' } });
|
|
117
117
|
if (!manifest || manifest.freshness.status === 'missing_index' || !target)
|
|
118
118
|
return emptyOutput(target, targetKind, limits, missing, format);
|
|
119
119
|
const targetPath = targetKind === 'file' ? normalizeFileTarget(target, manifest.project_root) : null;
|
|
120
120
|
if (targetKind === 'file' && !targetPath) {
|
|
121
|
-
return emptyOutput(target, targetKind, limits,
|
|
121
|
+
return emptyOutput(target, targetKind, limits, withFreshness({
|
|
122
122
|
status: manifest.freshness.status, details: { invalid_target: 'file path must be inside the indexed project' },
|
|
123
123
|
}), format);
|
|
124
124
|
}
|
|
@@ -187,7 +187,7 @@ export function exportSubgraph(targetInput, options, ctx) {
|
|
|
187
187
|
nodes: [...selected].map((id) => nodes.get(id)).filter((node) => node !== undefined).sort(compareNodes).map(graphNode),
|
|
188
188
|
edges: [...selectedEdges.values()].sort(compareEdges).map(graphEdge),
|
|
189
189
|
limits, truncated: { roots: rootsTruncated, nodes: nodesTruncated, edges: edgesTruncated, depth: depthTruncated },
|
|
190
|
-
freshness_badge:
|
|
190
|
+
freshness_badge: withFreshness({ status: manifest.freshness.status,
|
|
191
191
|
details: { stale_file_count: manifest.freshness.stale_file_count, partial_reason: manifest.freshness.partial_reason } }),
|
|
192
192
|
};
|
|
193
193
|
return format === 'mermaid' ? { ...graph, format, mermaid: toMermaid(graph) } : { ...graph, format };
|
|
@@ -82,6 +82,9 @@ export function finalize(draft, input) {
|
|
|
82
82
|
const byName = new Map();
|
|
83
83
|
// node id -> index in `nodes`, so an export clause can flip `exported` in place.
|
|
84
84
|
const nodeIndexById = new Map();
|
|
85
|
+
// P4 resolves provider draft ordinals to final symbol ids only here, after the
|
|
86
|
+
// identity authority has minted them. This keeps providers id-free.
|
|
87
|
+
const definitionIdsByOrdinal = new Map();
|
|
85
88
|
const pushSymbol = (subtype, name, span, exported, confidence) => {
|
|
86
89
|
const id = symNodeId(projectId, path, lang, subtype, name, span);
|
|
87
90
|
nodeIndexById.set(id, nodes.length);
|
|
@@ -127,7 +130,8 @@ export function finalize(draft, input) {
|
|
|
127
130
|
for (const item of items) {
|
|
128
131
|
if (item.kind === 'def') {
|
|
129
132
|
const d = item.ref;
|
|
130
|
-
pushSymbol(d.subtype, d.name, d.span, d.exported === true, d.confidence ?? 1.0);
|
|
133
|
+
const id = pushSymbol(d.subtype, d.name, d.span, d.exported === true, d.confidence ?? 1.0);
|
|
134
|
+
definitionIdsByOrdinal.set(d.ordinal, id);
|
|
131
135
|
}
|
|
132
136
|
else if (item.kind === 'import') {
|
|
133
137
|
const im = item.ref;
|
|
@@ -179,6 +183,46 @@ export function finalize(draft, input) {
|
|
|
179
183
|
});
|
|
180
184
|
}
|
|
181
185
|
}
|
|
186
|
+
// P4 lexical usages. Local targets are already proven by the provider's tree
|
|
187
|
+
// walk; imported bindings become candidates and are materialized only by the
|
|
188
|
+
// whole-project resolver when the target symbol is unique and importable.
|
|
189
|
+
const referenceCandidates = [];
|
|
190
|
+
for (const usage of draft.usages ?? []) {
|
|
191
|
+
const from = usage.fromDefinitionOrdinal === undefined
|
|
192
|
+
? fileNode
|
|
193
|
+
: definitionIdsByOrdinal.get(usage.fromDefinitionOrdinal);
|
|
194
|
+
if (!from)
|
|
195
|
+
continue;
|
|
196
|
+
const confidence = usage.confidence ?? 1.0;
|
|
197
|
+
const source = { path, line: usage.span.start_line };
|
|
198
|
+
if (usage.target.kind === 'import') {
|
|
199
|
+
// Textual hints are deliberately local-only. An imported binding gets no
|
|
200
|
+
// graph edge until `resolveProjectImports` proves its target symbol.
|
|
201
|
+
if (usage.kind === 'calls' || usage.kind === 'references') {
|
|
202
|
+
referenceCandidates.push({
|
|
203
|
+
from,
|
|
204
|
+
kind: usage.kind,
|
|
205
|
+
module: usage.target.module,
|
|
206
|
+
imported_name: usage.target.importedName,
|
|
207
|
+
confidence,
|
|
208
|
+
source,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const to = definitionIdsByOrdinal.get(usage.target.definitionOrdinal);
|
|
214
|
+
if (!to)
|
|
215
|
+
continue;
|
|
216
|
+
edges.push({
|
|
217
|
+
id: edgeId({ projectId, from, to, kind: usage.kind }),
|
|
218
|
+
from,
|
|
219
|
+
to,
|
|
220
|
+
kind: usage.kind,
|
|
221
|
+
confidence,
|
|
222
|
+
source,
|
|
223
|
+
origin: usage.kind === 'possible_textual_match' ? 'usage_textual' : 'usage_local',
|
|
224
|
+
});
|
|
225
|
+
}
|
|
182
226
|
const parseStatus = draft.attributes?.parseStatus ?? 'parsed';
|
|
183
227
|
const diagnostics = draft.facts.map((f) => ({ ...f }));
|
|
184
228
|
// Validate the finalized output against the durable schemas (spec §6).
|
|
@@ -186,6 +230,17 @@ export function finalize(draft, input) {
|
|
|
186
230
|
NodeSchema.parse(n);
|
|
187
231
|
for (const e of edges)
|
|
188
232
|
EdgeSchema.parse(e);
|
|
189
|
-
|
|
233
|
+
const result = { parseStatus, nodes, edges, diagnostics };
|
|
234
|
+
// P1's oracle intentionally compares the enumerable JSON result. Keep the
|
|
235
|
+
// resolver hand-off available to refresh without changing that stable shape.
|
|
236
|
+
if (referenceCandidates.length > 0) {
|
|
237
|
+
Object.defineProperty(result, 'referenceCandidates', {
|
|
238
|
+
value: referenceCandidates,
|
|
239
|
+
enumerable: false,
|
|
240
|
+
configurable: false,
|
|
241
|
+
writable: false,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return result;
|
|
190
245
|
}
|
|
191
246
|
//# sourceMappingURL=finalizer.js.map
|