brainclaw 1.25.0 → 1.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +84 -13
- 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 +69 -17
- package/dist/core/io.js +27 -0
- package/dist/core/project-discovery.js +7 -1
- package/dist/core/runtime.js +23 -0
- 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,7 +2,7 @@ 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 { isSessionSnapshotRecordFilename, memoryExists, resolveEntityDir, sessionSnapshotRecordPaths } from '../core/io.js';
|
|
6
6
|
import { loadVersionedJsonFile, saveVersionedJsonFile } from '../core/migration.js';
|
|
7
7
|
import { buildOperationalIdentity, loadAllSessions, saveCurrentSession } from '../core/identity.js';
|
|
8
8
|
import { requireMinimumTrustLevel, resolveCurrentModel, resolveOrAutoRegisterAgentIdentity } from '../core/agent-registry.js';
|
|
@@ -25,11 +25,55 @@ 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
|
-
return path.join(
|
|
38
|
+
return path.join(sessionSnapshotWriteDir(cwd), `${sessionId}.snapshot.json`);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* pln#670 — lazy migration of pre-split snapshot records: rename `<id>.json` to
|
|
42
|
+
* `<id>.snapshot.json` in the CANONICAL sessions directory only. The legacy
|
|
43
|
+
* directory is never scanned — it is the current_session home. Only files that
|
|
44
|
+
* validate as session_snapshot are touched; anything else is left in place.
|
|
45
|
+
*/
|
|
46
|
+
export function migrateLegacySnapshotNames(cwd) {
|
|
47
|
+
const dir = sessionSnapshotWriteDir(cwd);
|
|
48
|
+
if (!fs.existsSync(dir))
|
|
49
|
+
return 0;
|
|
50
|
+
let renamed = 0;
|
|
51
|
+
for (const file of fs.readdirSync(dir)) {
|
|
52
|
+
// Case-insensitive suffix checks (codex review P1): Windows filesystems
|
|
53
|
+
// match names case-insensitively — an upper-cased `.SNAPSHOT.json` is the
|
|
54
|
+
// same record a lower-case probe reads, and must not be re-suffixed.
|
|
55
|
+
if (!file.toLowerCase().endsWith('.json') || isSessionSnapshotRecordFilename(file))
|
|
56
|
+
continue;
|
|
57
|
+
const from = path.join(dir, file);
|
|
58
|
+
try {
|
|
59
|
+
// Discriminate on the RAW file — the migration loader zod-strips unknown
|
|
60
|
+
// keys, so a current_session record parses as a clean snapshot after it.
|
|
61
|
+
// Key PRESENCE, not value type (codex review P1): the invariant is
|
|
62
|
+
// "never touch a record CARRYING last_seen_at", and `last_seen_at: null`
|
|
63
|
+
// must be rejected too.
|
|
64
|
+
const raw = JSON.parse(fs.readFileSync(from, 'utf-8'));
|
|
65
|
+
if (Object.hasOwn(raw, 'last_seen_at'))
|
|
66
|
+
continue;
|
|
67
|
+
SessionSnapshotSchema.parse(loadVersionedJsonFile('session_snapshot', from).document);
|
|
68
|
+
const to = path.join(dir, `${file.slice(0, -'.json'.length)}.snapshot.json`);
|
|
69
|
+
if (!fs.existsSync(to)) {
|
|
70
|
+
fs.renameSync(from, to);
|
|
71
|
+
renamed++;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch { /* not a session_snapshot — leave it alone */ }
|
|
75
|
+
}
|
|
76
|
+
return renamed;
|
|
33
77
|
}
|
|
34
78
|
export async function runSessionStart(options = {}) {
|
|
35
79
|
try {
|
|
@@ -155,7 +199,7 @@ export async function startSession(options = {}) {
|
|
|
155
199
|
...(model ? { model } : {}),
|
|
156
200
|
};
|
|
157
201
|
// Persist snapshot
|
|
158
|
-
const dir =
|
|
202
|
+
const dir = sessionSnapshotWriteDir(options.cwd);
|
|
159
203
|
if (!fs.existsSync(dir))
|
|
160
204
|
fs.mkdirSync(dir, { recursive: true });
|
|
161
205
|
saveVersionedJsonFile('session_snapshot', sessionSnapshotPath(snapshot.session_id, options.cwd), SessionSnapshotSchema.parse(snapshot));
|
|
@@ -218,6 +262,14 @@ export async function startSession(options = {}) {
|
|
|
218
262
|
inventoryAdvisory = lines;
|
|
219
263
|
}
|
|
220
264
|
catch { /* non-fatal — inventory scan failure should not block session start */ }
|
|
265
|
+
// pln#670 — lazy rename of pre-split snapshot records to the type-suffixed
|
|
266
|
+
// name. Session-start full maintenance is the natural sweep point (no daemon,
|
|
267
|
+
// feedback_lazy_reconcile_pattern); dual-read keeps unrenamed records readable
|
|
268
|
+
// in the meantime.
|
|
269
|
+
try {
|
|
270
|
+
migrateLegacySnapshotNames(options.cwd);
|
|
271
|
+
}
|
|
272
|
+
catch { /* non-fatal — name migration must never block session start */ }
|
|
221
273
|
// pln#564 step B — cap the runtime-note tree on session start (no LLM gate,
|
|
222
274
|
// unlike the compaction-phase archiveSessionNotes). Keeps the newest N
|
|
223
275
|
// session/lifecycle notes per agent + all genuine observations, parks the
|
|
@@ -367,14 +419,33 @@ function isPidAlive(pid) {
|
|
|
367
419
|
}
|
|
368
420
|
}
|
|
369
421
|
export function loadSessionSnapshot(sessionId, cwd) {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
422
|
+
// pln#670 — probe the type-suffixed name first, then the pre-split `<id>.json`
|
|
423
|
+
// layouts. SessionSnapshotSchema is non-strict, so a current_session record for
|
|
424
|
+
// the same id would parse too (zod strips unknown keys) — the negative
|
|
425
|
+
// discriminant is `last_seen_at`, which only current_session carries.
|
|
426
|
+
for (const p of sessionSnapshotRecordPaths(sessionId, cwd)) {
|
|
427
|
+
if (!fs.existsSync(p))
|
|
428
|
+
continue;
|
|
429
|
+
try {
|
|
430
|
+
// Discriminate on the RAW file: the migration loader zod-strips unknown
|
|
431
|
+
// keys, so a current_session record would come back looking like a clean
|
|
432
|
+
// snapshot. Key PRESENCE, not value type (codex review P1) — a record
|
|
433
|
+
// carrying `last_seen_at: null` must be rejected too.
|
|
434
|
+
const raw = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
435
|
+
if (Object.hasOwn(raw, 'last_seen_at'))
|
|
436
|
+
continue;
|
|
437
|
+
const snapshot = SessionSnapshotSchema.parse(loadVersionedJsonFile('session_snapshot', p).document);
|
|
438
|
+
// The filename is not a type-safe identity boundary by itself (codex
|
|
439
|
+
// review): querying `sess_x.snapshot` also constructs `sess_x.snapshot.json`
|
|
440
|
+
// — the snapshot of sess_x. Only return a payload that names the caller's id.
|
|
441
|
+
if (snapshot.session_id !== sessionId)
|
|
442
|
+
continue;
|
|
443
|
+
return snapshot;
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
378
448
|
}
|
|
449
|
+
return undefined;
|
|
379
450
|
}
|
|
380
451
|
//# 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
|
|
@@ -45,9 +45,72 @@ export function coarseFreshness(status) {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Build the canonical, surface-uniform badge. `freshness` is derived solely from
|
|
50
|
+
* the index state supplied as `status`; a query's bounded spot-check is diagnostic
|
|
51
|
+
* evidence under `details.spot_check`, never a competing top-level badge.
|
|
52
|
+
*/
|
|
53
|
+
export function makeFreshnessBadge(status, options = {}) {
|
|
54
|
+
const spot = options.spotCheck ?? {};
|
|
55
|
+
return {
|
|
56
|
+
freshness: coarseFreshness(status),
|
|
57
|
+
status,
|
|
58
|
+
details: {
|
|
59
|
+
...(options.extra ?? {}),
|
|
60
|
+
index: {
|
|
61
|
+
status,
|
|
62
|
+
stale_file_count: options.staleFileCount ?? 0,
|
|
63
|
+
partial_reason: options.partialReason ?? null,
|
|
64
|
+
git_head_changed: options.gitHeadChanged ?? null,
|
|
65
|
+
},
|
|
66
|
+
spot_check: {
|
|
67
|
+
status: spot.status ?? 'not_run',
|
|
68
|
+
checked_files: spot.checked_files ?? 0,
|
|
69
|
+
stale_changed_files: spot.stale_changed_files ?? [],
|
|
70
|
+
deleted_files: spot.deleted_files ?? [],
|
|
71
|
+
unchecked_files: spot.unchecked_files ?? [],
|
|
72
|
+
budget_exhausted: spot.budget_exhausted ?? false,
|
|
73
|
+
partial_reason: spot.partial_reason ?? null,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Compatibility normalizer for internal callers that previously constructed a
|
|
80
|
+
* `{ status, details }` badge. It preserves non-freshness metadata while always
|
|
81
|
+
* adding the two canonical detail sections.
|
|
82
|
+
*/
|
|
83
|
+
export function withFreshness(b) {
|
|
84
|
+
const raw = b.details ?? {};
|
|
85
|
+
const index = raw.index;
|
|
86
|
+
const spot = raw.spot_check;
|
|
87
|
+
const known = new Set([
|
|
88
|
+
'index', 'spot_check', 'stale_file_count', 'partial_reason', 'git_head_changed',
|
|
89
|
+
'stale_changed_files', 'deleted_files', 'unchecked_files', 'budget',
|
|
90
|
+
]);
|
|
91
|
+
const extra = Object.fromEntries(Object.entries(raw).filter(([key]) => !known.has(key)));
|
|
92
|
+
const stringArray = (value) => Array.isArray(value) ? value.map(String).sort() : [];
|
|
93
|
+
const numberValue = (value) => typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
94
|
+
const nullableString = (value) => typeof value === 'string' ? value : value === null ? null : undefined;
|
|
95
|
+
const git = (index?.git_head_changed ?? raw.git_head_changed);
|
|
96
|
+
const gitHeadChanged = git && typeof git.index_head === 'string' && typeof git.current_head === 'string'
|
|
97
|
+
? { index_head: git.index_head, current_head: git.current_head }
|
|
98
|
+
: null;
|
|
99
|
+
return makeFreshnessBadge(b.status, {
|
|
100
|
+
staleFileCount: numberValue(index?.stale_file_count ?? raw.stale_file_count),
|
|
101
|
+
partialReason: nullableString(index?.partial_reason ?? raw.partial_reason),
|
|
102
|
+
gitHeadChanged,
|
|
103
|
+
spotCheck: {
|
|
104
|
+
status: spot?.status,
|
|
105
|
+
checked_files: numberValue(spot?.checked_files),
|
|
106
|
+
stale_changed_files: stringArray(spot?.stale_changed_files ?? raw.stale_changed_files),
|
|
107
|
+
deleted_files: stringArray(spot?.deleted_files ?? raw.deleted_files),
|
|
108
|
+
unchecked_files: stringArray(spot?.unchecked_files ?? raw.unchecked_files),
|
|
109
|
+
budget_exhausted: spot?.budget_exhausted === true,
|
|
110
|
+
partial_reason: nullableString(spot?.partial_reason),
|
|
111
|
+
},
|
|
112
|
+
extra,
|
|
113
|
+
});
|
|
51
114
|
}
|
|
52
115
|
/** Stable serialization: sort object keys recursively so hashing is order-independent. */
|
|
53
116
|
function stableStringify(value) {
|
|
@@ -165,16 +228,18 @@ export function summarizeFreshness(shards) {
|
|
|
165
228
|
* actionable status; only the cause detail is added.
|
|
166
229
|
*/
|
|
167
230
|
export function applyGitHeadDrift(badge, indexHead, currentHead) {
|
|
231
|
+
const normalized = withFreshness(badge);
|
|
232
|
+
const currentIndex = normalized.details.index;
|
|
168
233
|
if (!indexHead || !currentHead || indexHead === currentHead)
|
|
169
|
-
return
|
|
170
|
-
const status =
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
};
|
|
234
|
+
return normalized;
|
|
235
|
+
const status = normalized.status === 'fresh' ? 'stale_git_head' : normalized.status;
|
|
236
|
+
const extra = Object.fromEntries(Object.entries(normalized.details).filter(([key]) => key !== 'index' && key !== 'spot_check'));
|
|
237
|
+
return makeFreshnessBadge(status, {
|
|
238
|
+
staleFileCount: currentIndex.stale_file_count,
|
|
239
|
+
partialReason: currentIndex.partial_reason,
|
|
240
|
+
gitHeadChanged: { index_head: indexHead, current_head: currentHead },
|
|
241
|
+
spotCheck: normalized.details.spot_check,
|
|
242
|
+
extra,
|
|
243
|
+
});
|
|
179
244
|
}
|
|
180
245
|
//# sourceMappingURL=freshness.js.map
|