@kage-core/kage-graph-mcp 1.1.37 → 1.2.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/cli.js +99 -4
- package/dist/daemon.js +31 -1
- package/dist/index.js +116 -5
- package/dist/kernel.js +896 -18
- package/package.json +1 -1
- package/viewer/app.js +216 -63
- package/viewer/data.html +6 -6
- package/viewer/graph.html +6 -6
- package/viewer/index.html +18 -7
- package/viewer/intel.html +6 -6
- package/viewer/memory.html +6 -6
- package/viewer/owners.html +6 -6
- package/viewer/review.html +7 -7
- package/viewer/styles.css +149 -1
package/dist/kernel.js
CHANGED
|
@@ -83,12 +83,15 @@ exports.buildIndexes = buildIndexes;
|
|
|
83
83
|
exports.indexProject = indexProject;
|
|
84
84
|
exports.refreshProject = refreshProject;
|
|
85
85
|
exports.gcProject = gcProject;
|
|
86
|
+
exports.verifyCitations = verifyCitations;
|
|
87
|
+
exports.compactProject = compactProject;
|
|
86
88
|
exports.installAgentPolicy = installAgentPolicy;
|
|
87
89
|
exports.createDenseEmbeddingProvider = createDenseEmbeddingProvider;
|
|
88
90
|
exports.buildEmbeddingIndex = buildEmbeddingIndex;
|
|
89
91
|
exports.recall = recall;
|
|
90
92
|
exports.recallWithEmbeddings = recallWithEmbeddings;
|
|
91
93
|
exports.queryCodeGraph = queryCodeGraph;
|
|
94
|
+
exports.kageTeammateBrief = kageTeammateBrief;
|
|
92
95
|
exports.kageRisk = kageRisk;
|
|
93
96
|
exports.kageDependencyPath = kageDependencyPath;
|
|
94
97
|
exports.kageCleanupCandidates = kageCleanupCandidates;
|
|
@@ -102,6 +105,7 @@ exports.kageCapabilityAudit = kageCapabilityAudit;
|
|
|
102
105
|
exports.kageDecisionIntelligence = kageDecisionIntelligence;
|
|
103
106
|
exports.kageModuleHealth = kageModuleHealth;
|
|
104
107
|
exports.kageGraphInsights = kageGraphInsights;
|
|
108
|
+
exports.kageRepoXray = kageRepoXray;
|
|
105
109
|
exports.kageWorkspace = kageWorkspace;
|
|
106
110
|
exports.kageWorkspaceRecall = kageWorkspaceRecall;
|
|
107
111
|
exports.queryGraph = queryGraph;
|
|
@@ -124,6 +128,7 @@ exports.verifyAgentActivation = verifyAgentActivation;
|
|
|
124
128
|
exports.observe = observe;
|
|
125
129
|
exports.kageSessionCaptureReport = kageSessionCaptureReport;
|
|
126
130
|
exports.kageSessionReplay = kageSessionReplay;
|
|
131
|
+
exports.kageSessionLearningLedger = kageSessionLearningLedger;
|
|
127
132
|
exports.distillSession = distillSession;
|
|
128
133
|
exports.proposeFromDiff = proposeFromDiff;
|
|
129
134
|
exports.buildBranchOverlay = buildBranchOverlay;
|
|
@@ -177,6 +182,27 @@ exports.MEMORY_TYPES = [
|
|
|
177
182
|
"negative_result",
|
|
178
183
|
"constraint",
|
|
179
184
|
];
|
|
185
|
+
// Bounded context assembly (PRD Feature 2: "inject only the relevant rule + structural
|
|
186
|
+
// map, dropping the rest"). Opt-in: when a token budget is set, keep the highest-priority
|
|
187
|
+
// sections (preamble + code graph + memory come first in the block) and drop trailing
|
|
188
|
+
// lower-priority sections until the estimate fits. Default (no budget) is unchanged.
|
|
189
|
+
function boundContextBlock(block, budget) {
|
|
190
|
+
if (!Number.isFinite(budget) || budget <= 0 || estimateTokens(block) <= budget)
|
|
191
|
+
return block;
|
|
192
|
+
const parts = block.split(/\n(?=## )/);
|
|
193
|
+
const kept = [parts[0]];
|
|
194
|
+
let dropped = 0;
|
|
195
|
+
for (const section of parts.slice(1)) {
|
|
196
|
+
if (estimateTokens([...kept, section].join("\n")) <= budget)
|
|
197
|
+
kept.push(section);
|
|
198
|
+
else
|
|
199
|
+
dropped += 1;
|
|
200
|
+
}
|
|
201
|
+
const result = kept.join("\n");
|
|
202
|
+
return dropped
|
|
203
|
+
? `${result}\n\n_Context trimmed to ~${budget} tokens; ${dropped} lower-priority section(s) dropped._`
|
|
204
|
+
: result;
|
|
205
|
+
}
|
|
180
206
|
const graphMemoryCache = new Map();
|
|
181
207
|
exports.SETUP_AGENTS = [
|
|
182
208
|
"codex",
|
|
@@ -1116,6 +1142,52 @@ function staleMemoryReasons(projectDir, packet, fingerprintCache) {
|
|
|
1116
1142
|
}
|
|
1117
1143
|
return unique(reasons);
|
|
1118
1144
|
}
|
|
1145
|
+
// Classifies stale reasons into severity. "hard" reasons (deprecated status, user
|
|
1146
|
+
// reported stale, ttl expired, all citations deleted) mean the memory should be
|
|
1147
|
+
// excluded from recall; "soft" reasons (some citations missing, a linked file
|
|
1148
|
+
// changed) mean keep-but-flag — the memory may just need review, not suppression.
|
|
1149
|
+
function staleSeverity(reasons) {
|
|
1150
|
+
if (!reasons.length)
|
|
1151
|
+
return "none";
|
|
1152
|
+
const hard = reasons.some((reason) => reason.startsWith("packet status is") ||
|
|
1153
|
+
reason.startsWith("user or agent reported") ||
|
|
1154
|
+
reason.startsWith("freshness ttl expired") ||
|
|
1155
|
+
reason.startsWith("all referenced paths are missing"));
|
|
1156
|
+
return hard ? "hard" : "soft";
|
|
1157
|
+
}
|
|
1158
|
+
// Decides whether a packet should be excluded from the recall payload (PRD Feature 3:
|
|
1159
|
+
// "deleted or heavily refactored since the timestamp"). Distinct from staleMemoryReasons:
|
|
1160
|
+
// a citation that NEVER existed (no stored fingerprint) is an ungrounded write — guarded
|
|
1161
|
+
// at capture time — not recall-time staleness, so it does NOT trigger exclusion here.
|
|
1162
|
+
// Returns a reason string when the memory is hard-stale, otherwise null.
|
|
1163
|
+
function recallHardStaleReason(projectDir, packet, cache) {
|
|
1164
|
+
if (packet.status === "deprecated" || packet.status === "superseded")
|
|
1165
|
+
return `packet status is ${packet.status}`;
|
|
1166
|
+
const quality = (packet.quality ?? {});
|
|
1167
|
+
if (Number(quality.reports_stale ?? 0) > 0)
|
|
1168
|
+
return "user or agent reported this memory stale";
|
|
1169
|
+
const freshness = (packet.freshness ?? {});
|
|
1170
|
+
const ttlDays = Number(freshness.ttl_days ?? freshness.ttlDays ?? 0);
|
|
1171
|
+
const verifiedAt = Date.parse(String(freshness.last_verified_at ?? packet.updated_at ?? packet.created_at));
|
|
1172
|
+
if (Number.isFinite(ttlDays) && ttlDays > 0 && Number.isFinite(verifiedAt)) {
|
|
1173
|
+
const ageDays = (Date.now() - verifiedAt) / (1000 * 60 * 60 * 24);
|
|
1174
|
+
if (ageDays > ttlDays)
|
|
1175
|
+
return `freshness ttl expired (${Math.floor(ageDays)}d old, ttl ${ttlDays}d)`;
|
|
1176
|
+
}
|
|
1177
|
+
// Only paths that existed at capture get a stored fingerprint; if every one of them is
|
|
1178
|
+
// now gone, the memory's evidence was deleted out from under it.
|
|
1179
|
+
const stored = packetStoredPathFingerprints(packet);
|
|
1180
|
+
if (stored.length) {
|
|
1181
|
+
const deleted = stored.filter((fingerprint) => {
|
|
1182
|
+
const current = memoryPathFingerprint(projectDir, fingerprint.path, cache);
|
|
1183
|
+
return current === null;
|
|
1184
|
+
});
|
|
1185
|
+
if (deleted.length === stored.length) {
|
|
1186
|
+
return `all cited files deleted since capture: ${deleted.slice(0, 4).map((fingerprint) => fingerprint.path).join(", ")}`;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
return null;
|
|
1190
|
+
}
|
|
1119
1191
|
function changedPathsFromStaleReasons(reasons) {
|
|
1120
1192
|
return unique(reasons.flatMap((reason) => {
|
|
1121
1193
|
const match = reason.match(/^linked path changed since memory was verified: (.+)$/);
|
|
@@ -1520,13 +1592,28 @@ function walkFiles(root, predicate) {
|
|
|
1520
1592
|
}
|
|
1521
1593
|
return out.sort();
|
|
1522
1594
|
}
|
|
1595
|
+
// Tolerant packet read: a single corrupt or merge-conflicted packet (e.g. an
|
|
1596
|
+
// unresolved `<<<<<<<` from a teammate's git merge) must not take down all of
|
|
1597
|
+
// recall/verify/compact. Skip the bad file with a warning and keep going.
|
|
1598
|
+
function tryReadPacket(path) {
|
|
1599
|
+
try {
|
|
1600
|
+
return readJson(path);
|
|
1601
|
+
}
|
|
1602
|
+
catch (error) {
|
|
1603
|
+
process.stderr.write(`kage: skipping unreadable memory packet ${path}: ${error.message}\n`);
|
|
1604
|
+
return null;
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1523
1607
|
function loadPacketsFromDir(dir) {
|
|
1524
1608
|
if (!(0, node_fs_1.existsSync)(dir))
|
|
1525
1609
|
return [];
|
|
1526
1610
|
return (0, node_fs_1.readdirSync)(dir)
|
|
1527
1611
|
.filter((name) => name.endsWith(".json"))
|
|
1528
1612
|
.sort()
|
|
1529
|
-
.
|
|
1613
|
+
.flatMap((name) => {
|
|
1614
|
+
const packet = tryReadPacket((0, node_path_1.join)(dir, name));
|
|
1615
|
+
return packet ? [packet] : [];
|
|
1616
|
+
});
|
|
1530
1617
|
}
|
|
1531
1618
|
function loadPacketEntriesFromDir(dir) {
|
|
1532
1619
|
if (!(0, node_fs_1.existsSync)(dir))
|
|
@@ -1534,9 +1621,10 @@ function loadPacketEntriesFromDir(dir) {
|
|
|
1534
1621
|
return (0, node_fs_1.readdirSync)(dir)
|
|
1535
1622
|
.filter((name) => name.endsWith(".json"))
|
|
1536
1623
|
.sort()
|
|
1537
|
-
.
|
|
1624
|
+
.flatMap((name) => {
|
|
1538
1625
|
const path = (0, node_path_1.join)(dir, name);
|
|
1539
|
-
|
|
1626
|
+
const packet = tryReadPacket(path);
|
|
1627
|
+
return packet ? [{ path, packet }] : [];
|
|
1540
1628
|
});
|
|
1541
1629
|
}
|
|
1542
1630
|
function loadApprovedPackets(projectDir) {
|
|
@@ -5507,6 +5595,115 @@ function gcProject(projectDir, options = {}) {
|
|
|
5507
5595
|
total_scanned: packetEntries.length,
|
|
5508
5596
|
};
|
|
5509
5597
|
}
|
|
5598
|
+
// On-demand citation/freshness check the agent can call before trusting a memory.
|
|
5599
|
+
// Pass an id to verify one packet, or omit to audit all approved memory.
|
|
5600
|
+
function verifyCitations(projectDir, options = {}) {
|
|
5601
|
+
ensureMemoryDirs(projectDir);
|
|
5602
|
+
const approved = loadApprovedPackets(projectDir);
|
|
5603
|
+
const targets = options.id ? approved.filter((packet) => packet.id === options.id) : approved;
|
|
5604
|
+
if (options.id && !targets.length) {
|
|
5605
|
+
return { ok: false, project_dir: projectDir, checked: 0, valid: 0, stale: 0, ungrounded: 0, packets: [], errors: [`Approved packet not found: ${options.id}`] };
|
|
5606
|
+
}
|
|
5607
|
+
const cache = new Map();
|
|
5608
|
+
const packets = targets.map((packet) => {
|
|
5609
|
+
const meaningful = packet.paths.filter((path) => meaningfulMemoryPath(path) && !shouldSkipRepoMemoryPath(path));
|
|
5610
|
+
const missing = meaningful.filter((path) => !pathExistsInRepo(projectDir, path));
|
|
5611
|
+
const reasons = staleMemoryReasons(projectDir, packet, cache);
|
|
5612
|
+
const severity = staleSeverity(reasons);
|
|
5613
|
+
const grounded = packetGroundingWarnings(projectDir, packet, "packet").length === 0;
|
|
5614
|
+
return {
|
|
5615
|
+
id: packet.id,
|
|
5616
|
+
title: packet.title,
|
|
5617
|
+
status: packet.status,
|
|
5618
|
+
paths: packet.paths,
|
|
5619
|
+
missing_paths: missing,
|
|
5620
|
+
grounded,
|
|
5621
|
+
stale: severity !== "none",
|
|
5622
|
+
stale_severity: severity,
|
|
5623
|
+
stale_reasons: reasons,
|
|
5624
|
+
};
|
|
5625
|
+
});
|
|
5626
|
+
return {
|
|
5627
|
+
ok: true,
|
|
5628
|
+
project_dir: projectDir,
|
|
5629
|
+
checked: packets.length,
|
|
5630
|
+
valid: packets.filter((entry) => !entry.stale && entry.grounded).length,
|
|
5631
|
+
stale: packets.filter((entry) => entry.stale).length,
|
|
5632
|
+
ungrounded: packets.filter((entry) => !entry.grounded).length,
|
|
5633
|
+
packets,
|
|
5634
|
+
errors: [],
|
|
5635
|
+
};
|
|
5636
|
+
}
|
|
5637
|
+
// Deterministic memory consolidation (no hosted LLM — preserves the no-API-key promise):
|
|
5638
|
+
// 1. prune dead citations from packets and refresh their path fingerprints,
|
|
5639
|
+
// 2. deprecate hard-stale packets (delegating to the same severity rules as recall/gc),
|
|
5640
|
+
// 3. surface near-duplicate clusters for an agent to merge via kage_supersede.
|
|
5641
|
+
function compactProject(projectDir, options = {}) {
|
|
5642
|
+
ensureMemoryDirs(projectDir);
|
|
5643
|
+
const dryRun = options.dryRun === true;
|
|
5644
|
+
const entries = loadPacketEntriesFromDir(packetsDir(projectDir));
|
|
5645
|
+
const prunedCitations = [];
|
|
5646
|
+
const deprecated = [];
|
|
5647
|
+
const cache = new Map();
|
|
5648
|
+
for (const { path, packet } of entries) {
|
|
5649
|
+
if (packet.status === "deprecated" || packet.status === "superseded")
|
|
5650
|
+
continue;
|
|
5651
|
+
const hardReason = recallHardStaleReason(projectDir, packet, cache);
|
|
5652
|
+
if (hardReason) {
|
|
5653
|
+
deprecated.push({ id: packet.id, title: packet.title, reason: hardReason });
|
|
5654
|
+
if (!dryRun)
|
|
5655
|
+
writeJson(path, { ...packet, status: "deprecated", updated_at: nowIso() });
|
|
5656
|
+
continue;
|
|
5657
|
+
}
|
|
5658
|
+
const meaningful = packet.paths.filter((p) => meaningfulMemoryPath(p) && !shouldSkipRepoMemoryPath(p));
|
|
5659
|
+
const missing = meaningful.filter((p) => !pathExistsInRepo(projectDir, p));
|
|
5660
|
+
if (missing.length) {
|
|
5661
|
+
const keptPaths = packet.paths.filter((p) => !missing.includes(p));
|
|
5662
|
+
prunedCitations.push({ id: packet.id, title: packet.title, removed_paths: missing });
|
|
5663
|
+
if (!dryRun) {
|
|
5664
|
+
writeJson(path, {
|
|
5665
|
+
...packet,
|
|
5666
|
+
paths: keptPaths,
|
|
5667
|
+
freshness: {
|
|
5668
|
+
...(packet.freshness ?? {}),
|
|
5669
|
+
path_fingerprints: memoryPathFingerprints(projectDir, keptPaths),
|
|
5670
|
+
last_verified_at: nowIso(),
|
|
5671
|
+
},
|
|
5672
|
+
updated_at: nowIso(),
|
|
5673
|
+
});
|
|
5674
|
+
}
|
|
5675
|
+
}
|
|
5676
|
+
}
|
|
5677
|
+
// Cluster near-duplicate approved packets (report only — merging is an agent decision).
|
|
5678
|
+
const context = memoryQualityContext(projectDir);
|
|
5679
|
+
const approved = context.packets.filter((packet) => packet.status === "approved");
|
|
5680
|
+
const seen = new Set();
|
|
5681
|
+
const clusters = [];
|
|
5682
|
+
for (const packet of approved) {
|
|
5683
|
+
if (seen.has(packet.id))
|
|
5684
|
+
continue;
|
|
5685
|
+
const dupes = duplicateCandidatesWithContext(packet, context, 0.6).filter((dupe) => dupe.status === "approved");
|
|
5686
|
+
if (!dupes.length)
|
|
5687
|
+
continue;
|
|
5688
|
+
const members = [{ id: packet.id, title: packet.title }, ...dupes.map((dupe) => ({ id: dupe.id, title: dupe.title }))];
|
|
5689
|
+
members.forEach((member) => seen.add(member.id));
|
|
5690
|
+
clusters.push({ score: Math.max(...dupes.map((dupe) => dupe.score)), packets: members });
|
|
5691
|
+
}
|
|
5692
|
+
if (!dryRun && (prunedCitations.length || deprecated.length)) {
|
|
5693
|
+
const rebuilt = buildGraphIndexes(projectDir);
|
|
5694
|
+
writeJson((0, node_path_1.join)(memoryRoot(projectDir), "metrics.json"), kageMetricsShallow(projectDir, rebuilt));
|
|
5695
|
+
}
|
|
5696
|
+
return {
|
|
5697
|
+
ok: true,
|
|
5698
|
+
project_dir: projectDir,
|
|
5699
|
+
dry_run: dryRun,
|
|
5700
|
+
pruned_citations: prunedCitations,
|
|
5701
|
+
deprecated,
|
|
5702
|
+
duplicate_clusters: clusters,
|
|
5703
|
+
total_scanned: entries.length,
|
|
5704
|
+
errors: [],
|
|
5705
|
+
};
|
|
5706
|
+
}
|
|
5510
5707
|
function installAgentPolicy(projectDir) {
|
|
5511
5708
|
const agentsPath = (0, node_path_1.join)(projectDir, "AGENTS.md");
|
|
5512
5709
|
const claudePath = (0, node_path_1.join)(projectDir, "CLAUDE.md");
|
|
@@ -6274,7 +6471,23 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
6274
6471
|
})()
|
|
6275
6472
|
: recallQueryExpansion(query);
|
|
6276
6473
|
const terms = expansion.terms;
|
|
6277
|
-
const
|
|
6474
|
+
const allApprovedPackets = loadApprovedPackets(projectDir);
|
|
6475
|
+
const includeStale = inputs.includeStale === true;
|
|
6476
|
+
const staleFingerprintCache = new Map();
|
|
6477
|
+
const suppressed = [];
|
|
6478
|
+
// Just-in-time staleness gate: hard-stale memory (deleted citations, expired ttl,
|
|
6479
|
+
// reported stale) is excluded from the recall payload so the agent never sees it
|
|
6480
|
+
// as valid. Suppression is recorded (not silent) so `kage verify` can explain it.
|
|
6481
|
+
const approvedPackets = includeStale
|
|
6482
|
+
? allApprovedPackets
|
|
6483
|
+
: allApprovedPackets.filter((packet) => {
|
|
6484
|
+
const reason = recallHardStaleReason(projectDir, packet, staleFingerprintCache);
|
|
6485
|
+
if (reason) {
|
|
6486
|
+
suppressed.push({ id: packet.id, title: packet.title, reason });
|
|
6487
|
+
return false;
|
|
6488
|
+
}
|
|
6489
|
+
return true;
|
|
6490
|
+
});
|
|
6278
6491
|
const baseScores = scorePacketsBm25(expansion.baseTerms, approvedPackets);
|
|
6279
6492
|
const temporalScores = scorePacketsBm25(expansion.temporalTerms, approvedPackets);
|
|
6280
6493
|
const semanticScores = scorePacketsBm25(expansion.semanticTerms, approvedPackets);
|
|
@@ -6332,6 +6545,13 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
6332
6545
|
const graphContext = queryGraph(projectDir, query, 5, knowledgeGraph);
|
|
6333
6546
|
const codeContext = queryCodeGraph(projectDir, query, 5, codeGraph);
|
|
6334
6547
|
const pinnedContext = renderPinnedRepoContext(readContextSlots(projectDir));
|
|
6548
|
+
// PRD Feature 2: traverse the code graph outward from the recalled memory's files
|
|
6549
|
+
// (the semantic entry point) to assemble a bounded structural blast radius. Opt-in
|
|
6550
|
+
// via inputs.structuralHops so default recall output is unchanged.
|
|
6551
|
+
const structuralHops = inputs.structuralHops ?? 0;
|
|
6552
|
+
const blastRadius = structuralHops > 0
|
|
6553
|
+
? structuralBlastRadius(codeGraph, unique(scored.flatMap((entry) => entry.packet.paths).filter((path) => meaningfulMemoryPath(path))), structuralHops)
|
|
6554
|
+
: [];
|
|
6335
6555
|
const lines = [
|
|
6336
6556
|
`# Kage Context`,
|
|
6337
6557
|
"",
|
|
@@ -6344,6 +6564,9 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
6344
6564
|
...codeContext.tests.slice(0, 3).map((test, index) => `${index + 1}. [test] ${test.title} in ${test.test_path}:${test.line}${test.covers_symbol ? ` covers ${test.covers_symbol}` : ""}`),
|
|
6345
6565
|
...(!codeContext.symbols.length && !codeContext.routes.length && !codeContext.tests.length ? codeContext.files.slice(0, 3).map((file, index) => `${index + 1}. [file] ${file.path} (${file.kind})`) : []),
|
|
6346
6566
|
"",
|
|
6567
|
+
...(blastRadius.length
|
|
6568
|
+
? [`## Structural Blast Radius (${structuralHops}-hop)`, ...blastRadius.map((path, index) => `${index + 1}. ${path}`), ""]
|
|
6569
|
+
: []),
|
|
6347
6570
|
scored.length ? "## Relevant Memory" : "No relevant repo memory found.",
|
|
6348
6571
|
...scored.flatMap((entry, index) => [
|
|
6349
6572
|
"",
|
|
@@ -6364,11 +6587,16 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
6364
6587
|
"",
|
|
6365
6588
|
graphContext.edges.length ? "## Related Graph Facts" : "",
|
|
6366
6589
|
...graphContext.edges.slice(0, 5).map((edge, index) => `${index + 1}. ${edge.fact} (evidence: ${edge.evidence.join(", ")})`),
|
|
6590
|
+
...(suppressed.length
|
|
6591
|
+
? ["", `_${suppressed.length} stale memory packet(s) excluded from recall. Run kage verify for details._`]
|
|
6592
|
+
: []),
|
|
6367
6593
|
];
|
|
6594
|
+
const assembledBlock = lines.join("\n");
|
|
6368
6595
|
const result = {
|
|
6369
6596
|
query,
|
|
6370
|
-
context_block:
|
|
6597
|
+
context_block: inputs.maxContextTokens ? boundContextBlock(assembledBlock, inputs.maxContextTokens) : assembledBlock,
|
|
6371
6598
|
results: scored,
|
|
6599
|
+
suppressed: suppressed.length ? suppressed : undefined,
|
|
6372
6600
|
explanations: explain
|
|
6373
6601
|
? scored.map((entry) => ({
|
|
6374
6602
|
packet_id: entry.packet.id,
|
|
@@ -6555,6 +6783,112 @@ function queryCodeGraph(projectDir, query, limit = 10, graph) {
|
|
|
6555
6783
|
structural_edges: structuralEdges,
|
|
6556
6784
|
};
|
|
6557
6785
|
}
|
|
6786
|
+
function fileHintsFromText(text) {
|
|
6787
|
+
const matches = text.match(/[A-Za-z0-9_./@-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|kts|rb|php|cs|c|h|cc|cpp|hpp|swift|json|md)\b/g) ?? [];
|
|
6788
|
+
return [...new Set(matches.map((match) => match.replace(/^\.\//, "")).filter((match) => !/^https?:\/\//.test(match)))];
|
|
6789
|
+
}
|
|
6790
|
+
function dedupeStrings(values) {
|
|
6791
|
+
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
|
6792
|
+
}
|
|
6793
|
+
function teammateBriefLines(brief) {
|
|
6794
|
+
const verification = brief.verification_contract;
|
|
6795
|
+
const lines = [
|
|
6796
|
+
"\n## Teammate Brief",
|
|
6797
|
+
"Purpose: reduce verification debt and context loss for this task.",
|
|
6798
|
+
"",
|
|
6799
|
+
"### Verification Contract",
|
|
6800
|
+
];
|
|
6801
|
+
if (verification.focus_files.length) {
|
|
6802
|
+
lines.push(`Focus files: ${verification.focus_files.join(", ")}`);
|
|
6803
|
+
}
|
|
6804
|
+
if (verification.related_tests.length) {
|
|
6805
|
+
lines.push("Related tests:");
|
|
6806
|
+
for (const test of verification.related_tests.slice(0, 5)) {
|
|
6807
|
+
lines.push(`- ${test.test_path}${test.title ? ` - ${test.title}` : ""}${test.covers ? ` (covers ${test.covers})` : ""}`);
|
|
6808
|
+
}
|
|
6809
|
+
}
|
|
6810
|
+
else if (verification.focus_files.length) {
|
|
6811
|
+
lines.push("Related tests: none found in the current code graph.");
|
|
6812
|
+
}
|
|
6813
|
+
if (verification.test_gap_files.length) {
|
|
6814
|
+
lines.push(`Test gaps: ${verification.test_gap_files.join(", ")}`);
|
|
6815
|
+
}
|
|
6816
|
+
if (brief.memory_warnings.length) {
|
|
6817
|
+
lines.push("", "### Memory Warnings", ...brief.memory_warnings.slice(0, 5).map((warning) => `- ${warning}`));
|
|
6818
|
+
}
|
|
6819
|
+
lines.push("", "### Next Actions");
|
|
6820
|
+
for (const action of brief.next_actions.slice(0, 6)) {
|
|
6821
|
+
lines.push(`- ${action}`);
|
|
6822
|
+
}
|
|
6823
|
+
return lines;
|
|
6824
|
+
}
|
|
6825
|
+
function kageTeammateBrief(projectDir, options) {
|
|
6826
|
+
const query = options.query;
|
|
6827
|
+
const focusFiles = dedupeStrings([
|
|
6828
|
+
...(options.targets ?? []),
|
|
6829
|
+
...(options.changedFiles ?? []),
|
|
6830
|
+
...fileHintsFromText(query),
|
|
6831
|
+
]);
|
|
6832
|
+
const codeQuery = dedupeStrings([query, ...focusFiles]).join(" ");
|
|
6833
|
+
const code = queryCodeGraph(projectDir, codeQuery || query, 12);
|
|
6834
|
+
const relatedTests = code.tests
|
|
6835
|
+
.map((test) => ({
|
|
6836
|
+
test_path: test.test_path,
|
|
6837
|
+
title: test.title,
|
|
6838
|
+
covers: test.covers_path ?? test.covers_symbol ?? null,
|
|
6839
|
+
}))
|
|
6840
|
+
.filter((test, index, all) => all.findIndex((item) => item.test_path === test.test_path && item.title === test.title) === index);
|
|
6841
|
+
const riskTargets = options.riskResult ? Object.values(options.riskResult.targets) : [];
|
|
6842
|
+
const testGapFiles = dedupeStrings([
|
|
6843
|
+
...riskTargets.filter((target) => target.test_gap).map((target) => target.target),
|
|
6844
|
+
...(focusFiles.length && !relatedTests.length ? focusFiles : []),
|
|
6845
|
+
]);
|
|
6846
|
+
const memoryWarnings = [
|
|
6847
|
+
...((options.recallResult?.results ?? [])
|
|
6848
|
+
.filter((entry) => Boolean((entry.packet.quality ?? {}).stale))
|
|
6849
|
+
.map((entry) => `Recalled memory may be stale: ${entry.packet.title}.`)),
|
|
6850
|
+
...(options.reconciliation?.unresolved_count
|
|
6851
|
+
? [`${options.reconciliation.unresolved_count} linked memory item(s) need update, supersede, or stale marking before handoff.`]
|
|
6852
|
+
: []),
|
|
6853
|
+
];
|
|
6854
|
+
const requiredActions = [
|
|
6855
|
+
...(relatedTests.length
|
|
6856
|
+
? [`Run or account for related test coverage: ${relatedTests.slice(0, 3).map((test) => test.test_path).join(", ")}.`]
|
|
6857
|
+
: focusFiles.length
|
|
6858
|
+
? ["No related tests were found; identify the correct verification before claiming completion."]
|
|
6859
|
+
: ["Identify task-specific verification before claiming completion."]),
|
|
6860
|
+
...testGapFiles.map((file) => `Resolve test-gap risk for ${file} or explain why existing verification is sufficient.`),
|
|
6861
|
+
...(memoryWarnings.length ? ["Resolve memory warnings before final handoff."] : []),
|
|
6862
|
+
];
|
|
6863
|
+
const nextActions = dedupeStrings([
|
|
6864
|
+
...requiredActions,
|
|
6865
|
+
...(riskTargets.length
|
|
6866
|
+
? riskTargets
|
|
6867
|
+
.filter((target) => target.co_change_warnings.length)
|
|
6868
|
+
.slice(0, 2)
|
|
6869
|
+
.map((target) => `Review co-change partners for ${target.target}: ${target.co_change_warnings.slice(0, 3).map((item) => item.file_path).join(", ")}.`)
|
|
6870
|
+
: []),
|
|
6871
|
+
"Keep any durable lesson evidence-backed; future agents should inherit only verified repo knowledge.",
|
|
6872
|
+
]);
|
|
6873
|
+
const briefWithoutBlock = {
|
|
6874
|
+
schema_version: 1,
|
|
6875
|
+
project_dir: projectDir,
|
|
6876
|
+
generated_at: nowIso(),
|
|
6877
|
+
query,
|
|
6878
|
+
verification_contract: {
|
|
6879
|
+
focus_files: focusFiles,
|
|
6880
|
+
related_tests: relatedTests,
|
|
6881
|
+
test_gap_files: testGapFiles,
|
|
6882
|
+
required_actions: requiredActions,
|
|
6883
|
+
},
|
|
6884
|
+
memory_warnings: memoryWarnings,
|
|
6885
|
+
next_actions: nextActions,
|
|
6886
|
+
};
|
|
6887
|
+
return {
|
|
6888
|
+
...briefWithoutBlock,
|
|
6889
|
+
context_block: teammateBriefLines(briefWithoutBlock).join("\n"),
|
|
6890
|
+
};
|
|
6891
|
+
}
|
|
6558
6892
|
function gitLines(projectDir, args) {
|
|
6559
6893
|
return (readGit(projectDir, args) ?? "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
6560
6894
|
}
|
|
@@ -6662,7 +6996,7 @@ function gitFileSignal(projectDir, path, graphPaths) {
|
|
|
6662
6996
|
}
|
|
6663
6997
|
function gitChangedFiles(projectDir) {
|
|
6664
6998
|
return gitLines(projectDir, ["status", "--porcelain", "-uall"])
|
|
6665
|
-
.map((line) => line
|
|
6999
|
+
.map((line) => parsePorcelainPath(line).split(" -> ").at(-1) ?? "")
|
|
6666
7000
|
.filter(Boolean)
|
|
6667
7001
|
.map((path) => gitPathToProjectRelative(projectDir, path) ?? path)
|
|
6668
7002
|
.filter((path) => !isNoisePath(path));
|
|
@@ -6721,6 +7055,45 @@ function codeDependents(graph) {
|
|
|
6721
7055
|
}
|
|
6722
7056
|
return dependents;
|
|
6723
7057
|
}
|
|
7058
|
+
// Bounded N-hop structural traversal from a set of seed files (the files the recalled
|
|
7059
|
+
// memory is about) — the PRD's "structural blast radius". Walks both import directions
|
|
7060
|
+
// (who-depends-on and what-it-depends-on), excludes the seeds, and ranks by how many
|
|
7061
|
+
// files depend on each node. Pure graph traversal; no full-repo scan.
|
|
7062
|
+
function structuralBlastRadius(graph, seedPaths, hops, limit = 8) {
|
|
7063
|
+
const seeds = seedPaths.filter(Boolean);
|
|
7064
|
+
if (!seeds.length || hops <= 0)
|
|
7065
|
+
return [];
|
|
7066
|
+
const dependents = codeDependents(graph);
|
|
7067
|
+
const dependencies = new Map();
|
|
7068
|
+
for (const edge of graph.imports) {
|
|
7069
|
+
if (!edge.from_path || !edge.to_path)
|
|
7070
|
+
continue;
|
|
7071
|
+
const list = dependencies.get(edge.from_path) ?? new Set();
|
|
7072
|
+
list.add(edge.to_path);
|
|
7073
|
+
dependencies.set(edge.from_path, list);
|
|
7074
|
+
}
|
|
7075
|
+
const seedSet = new Set(seeds);
|
|
7076
|
+
const visited = new Set();
|
|
7077
|
+
let frontier = new Set(seeds);
|
|
7078
|
+
for (let depth = 0; depth < hops; depth += 1) {
|
|
7079
|
+
const next = new Set();
|
|
7080
|
+
for (const node of frontier) {
|
|
7081
|
+
for (const neighbor of [...(dependents.get(node) ?? []), ...(dependencies.get(node) ?? [])]) {
|
|
7082
|
+
if (seedSet.has(neighbor) || visited.has(neighbor))
|
|
7083
|
+
continue;
|
|
7084
|
+
visited.add(neighbor);
|
|
7085
|
+
next.add(neighbor);
|
|
7086
|
+
}
|
|
7087
|
+
}
|
|
7088
|
+
frontier = next;
|
|
7089
|
+
}
|
|
7090
|
+
const score = new Map();
|
|
7091
|
+
for (const [path, incoming] of dependents.entries())
|
|
7092
|
+
score.set(path, incoming.size);
|
|
7093
|
+
return [...visited]
|
|
7094
|
+
.sort((a, b) => (score.get(b) ?? 0) - (score.get(a) ?? 0) || a.localeCompare(b))
|
|
7095
|
+
.slice(0, limit);
|
|
7096
|
+
}
|
|
6724
7097
|
function impactSurface(target, dependents, graph) {
|
|
6725
7098
|
const visited = new Set();
|
|
6726
7099
|
let frontier = new Set([target]);
|
|
@@ -7562,7 +7935,7 @@ function kageProjectProfile(projectDir) {
|
|
|
7562
7935
|
}
|
|
7563
7936
|
const memoryConceptCounts = new Map();
|
|
7564
7937
|
for (const packet of approved) {
|
|
7565
|
-
for (const tag of packet.tags.filter((item) => item && !["session-learning", "
|
|
7938
|
+
for (const tag of packet.tags.filter((item) => item && !["session-learning", "external-comparison"].includes(item))) {
|
|
7566
7939
|
memoryConceptCounts.set(tag, (memoryConceptCounts.get(tag) ?? 0) + 1);
|
|
7567
7940
|
}
|
|
7568
7941
|
}
|
|
@@ -8307,13 +8680,261 @@ function kageGraphInsights(projectDir) {
|
|
|
8307
8680
|
summary: `${centralFiles.length} central file(s), ${cycles.length} dependency cycle(s), ${communities.length} communit${communities.length === 1 ? "y" : "ies"}, ${flows.length} entry flow(s).`,
|
|
8308
8681
|
};
|
|
8309
8682
|
}
|
|
8683
|
+
function xrayItem(input) {
|
|
8684
|
+
return {
|
|
8685
|
+
...input,
|
|
8686
|
+
strength: Math.max(1, Math.min(100, Math.round(input.strength ?? 50))),
|
|
8687
|
+
status: input.status ?? "ok",
|
|
8688
|
+
};
|
|
8689
|
+
}
|
|
8690
|
+
function uniqueXrayItems(items) {
|
|
8691
|
+
const byPath = new Map();
|
|
8692
|
+
for (const item of items) {
|
|
8693
|
+
const existing = byPath.get(item.path);
|
|
8694
|
+
if (!existing || item.strength > existing.strength || (item.status === "risk" && existing.status !== "risk")) {
|
|
8695
|
+
byPath.set(item.path, item);
|
|
8696
|
+
}
|
|
8697
|
+
}
|
|
8698
|
+
return [...byPath.values()].sort((a, b) => b.strength - a.strength || a.path.localeCompare(b.path));
|
|
8699
|
+
}
|
|
8700
|
+
function isXrayCodePath(path, graphPaths) {
|
|
8701
|
+
const normalized = path.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
8702
|
+
return graphPaths.has(normalized) && !normalized.startsWith(".agent_memory/") && !normalized.startsWith("agent_memory/");
|
|
8703
|
+
}
|
|
8704
|
+
function kageRepoXray(projectDir) {
|
|
8705
|
+
const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
8706
|
+
const profile = kageProjectProfile(projectDir);
|
|
8707
|
+
const risk = kageRisk(projectDir);
|
|
8708
|
+
const health = kageModuleHealth(projectDir);
|
|
8709
|
+
const insights = kageGraphInsights(projectDir);
|
|
8710
|
+
const decisions = kageDecisionIntelligence(projectDir);
|
|
8711
|
+
const approved = loadApprovedPackets(projectDir);
|
|
8712
|
+
const graphPaths = new Set(graph.files.map((file) => file.path));
|
|
8713
|
+
const routeCounts = countBy(graph.routes, (route) => route.file_path);
|
|
8714
|
+
const testsBySource = new Map();
|
|
8715
|
+
for (const test of graph.tests) {
|
|
8716
|
+
const key = test.covers_path ?? test.covers_symbol ?? "";
|
|
8717
|
+
if (!key)
|
|
8718
|
+
continue;
|
|
8719
|
+
const list = testsBySource.get(key) ?? [];
|
|
8720
|
+
list.push(test);
|
|
8721
|
+
testsBySource.set(key, list);
|
|
8722
|
+
}
|
|
8723
|
+
const entryItems = uniqueXrayItems([
|
|
8724
|
+
...graph.routes.map((route) => xrayItem({
|
|
8725
|
+
label: `${route.method} ${route.path}`,
|
|
8726
|
+
path: route.file_path,
|
|
8727
|
+
kind: "route",
|
|
8728
|
+
strength: 90,
|
|
8729
|
+
status: "ok",
|
|
8730
|
+
evidence: [`Route handler in ${route.file_path}`, `${route.method} ${route.path}`],
|
|
8731
|
+
action: "Start here to understand request flow before changing runtime behavior.",
|
|
8732
|
+
})),
|
|
8733
|
+
...insights.entry_flows.map((flow) => xrayItem({
|
|
8734
|
+
label: flow.entry,
|
|
8735
|
+
path: flow.entry,
|
|
8736
|
+
kind: "entry_flow",
|
|
8737
|
+
strength: Math.min(96, 64 + flow.path.length * 6),
|
|
8738
|
+
status: "ok",
|
|
8739
|
+
evidence: [`Entry flow: ${flow.path.slice(0, 5).join(" -> ")}`],
|
|
8740
|
+
action: "Trace this entry flow before editing shared dependencies.",
|
|
8741
|
+
})),
|
|
8742
|
+
...profile.run_commands.slice(0, 4).map((command) => xrayItem({
|
|
8743
|
+
label: command.name,
|
|
8744
|
+
path: "package.json",
|
|
8745
|
+
kind: "script",
|
|
8746
|
+
strength: 58,
|
|
8747
|
+
status: "ok",
|
|
8748
|
+
evidence: [`package script: ${command.name} = ${command.command}`],
|
|
8749
|
+
action: "Use this command evidence when verifying changes.",
|
|
8750
|
+
})),
|
|
8751
|
+
]).slice(0, 8);
|
|
8752
|
+
const centralByPath = new Map(insights.central_files.map((file) => [file.path, file]));
|
|
8753
|
+
const coreItems = uniqueXrayItems([
|
|
8754
|
+
...profile.key_files.map((file) => {
|
|
8755
|
+
const central = centralByPath.get(file.path);
|
|
8756
|
+
return xrayItem({
|
|
8757
|
+
label: file.path,
|
|
8758
|
+
path: file.path,
|
|
8759
|
+
kind: file.kind,
|
|
8760
|
+
strength: Math.min(100, file.score + (central ? central.dependents * 6 : 0)),
|
|
8761
|
+
status: "ok",
|
|
8762
|
+
evidence: unique([
|
|
8763
|
+
...file.why,
|
|
8764
|
+
...(central ? [`centrality ${central.pagerank}, ${central.dependents} dependent(s)`] : []),
|
|
8765
|
+
]).slice(0, 4),
|
|
8766
|
+
action: "Inspect this file early; Kage sees it as a central part of the repo.",
|
|
8767
|
+
});
|
|
8768
|
+
}),
|
|
8769
|
+
...insights.central_files.slice(0, 8).map((file) => xrayItem({
|
|
8770
|
+
label: file.path,
|
|
8771
|
+
path: file.path,
|
|
8772
|
+
kind: file.kind,
|
|
8773
|
+
strength: Math.min(100, 45 + file.dependents * 8 + file.imports * 3),
|
|
8774
|
+
status: "ok",
|
|
8775
|
+
evidence: [`${file.dependents} dependent(s)`, `${file.imports} outgoing import(s)`],
|
|
8776
|
+
action: "Use this as a structural orientation point before following dependencies.",
|
|
8777
|
+
})),
|
|
8778
|
+
]).slice(0, 10);
|
|
8779
|
+
const riskTargets = Object.values(risk.targets).filter((target) => isXrayCodePath(target.target, graphPaths));
|
|
8780
|
+
const riskHotspots = risk.global_hotspots.filter((hotspot) => isXrayCodePath(hotspot.file_path, graphPaths));
|
|
8781
|
+
const riskItems = uniqueXrayItems([
|
|
8782
|
+
...riskTargets.map((target) => xrayItem({
|
|
8783
|
+
label: target.target,
|
|
8784
|
+
path: target.target,
|
|
8785
|
+
kind: target.risk_type,
|
|
8786
|
+
strength: Math.max(30, Math.round(target.hotspot_score * 100), target.dependents_count * 12, target.test_gap ? 70 : 0),
|
|
8787
|
+
status: target.test_gap || target.risk_type === "single-owner" || target.risk_type === "churn-heavy" ? "risk" : "watch",
|
|
8788
|
+
evidence: [
|
|
8789
|
+
`${target.dependents_count} direct dependent(s)`,
|
|
8790
|
+
`${target.git.commit_count_90d} commit(s) in 90d`,
|
|
8791
|
+
target.test_gap ? "test gap" : "test signal found",
|
|
8792
|
+
],
|
|
8793
|
+
action: "Review dependents, tests, and owners before editing this path.",
|
|
8794
|
+
})),
|
|
8795
|
+
...riskHotspots.slice(0, 8).map((hotspot) => xrayItem({
|
|
8796
|
+
label: hotspot.file_path,
|
|
8797
|
+
path: hotspot.file_path,
|
|
8798
|
+
kind: "hotspot",
|
|
8799
|
+
strength: Math.round(hotspot.hotspot_score * 100),
|
|
8800
|
+
status: "risk",
|
|
8801
|
+
evidence: [`${hotspot.commit_count_90d} commit(s) in 90d`, `primary owner ${hotspot.primary_owner ?? "unknown"}`],
|
|
8802
|
+
action: "Treat this as a change hotspot; ask Kage for risk before editing.",
|
|
8803
|
+
})),
|
|
8804
|
+
...health.modules.filter((module) => module.grade === "C" || module.grade === "D").slice(0, 5).map((module) => xrayItem({
|
|
8805
|
+
label: module.module,
|
|
8806
|
+
path: module.module === "(root)" ? "." : module.module,
|
|
8807
|
+
kind: "module",
|
|
8808
|
+
strength: 100 - module.score,
|
|
8809
|
+
status: module.grade === "D" ? "risk" : "watch",
|
|
8810
|
+
evidence: module.reasons.slice(0, 3),
|
|
8811
|
+
action: "Use module health reasons to decide tests and review scope.",
|
|
8812
|
+
})),
|
|
8813
|
+
]).slice(0, 10);
|
|
8814
|
+
const testItems = uniqueXrayItems([
|
|
8815
|
+
...graph.tests.map((test) => xrayItem({
|
|
8816
|
+
label: test.title || test.test_path,
|
|
8817
|
+
path: test.test_path,
|
|
8818
|
+
kind: "test",
|
|
8819
|
+
strength: 78,
|
|
8820
|
+
status: "ok",
|
|
8821
|
+
evidence: [`covers ${test.covers_path ?? test.covers_symbol ?? "repo behavior"}`],
|
|
8822
|
+
action: "Run or account for this test when changing the covered code.",
|
|
8823
|
+
})),
|
|
8824
|
+
...graph.files
|
|
8825
|
+
.filter((file) => file.kind === "source" && !hasTestCoverage(file.path, graph))
|
|
8826
|
+
.slice(0, 8)
|
|
8827
|
+
.map((file) => xrayItem({
|
|
8828
|
+
label: file.path,
|
|
8829
|
+
path: file.path,
|
|
8830
|
+
kind: "test_gap",
|
|
8831
|
+
strength: routeCounts[file.path] ? 82 : 58,
|
|
8832
|
+
status: "watch",
|
|
8833
|
+
evidence: routeCounts[file.path] ? [`${routeCounts[file.path]} route(s), no direct test signal`] : ["no direct test signal"],
|
|
8834
|
+
action: "Identify the right verification path before claiming a change here is safe.",
|
|
8835
|
+
})),
|
|
8836
|
+
]).slice(0, 12);
|
|
8837
|
+
const memoryByPath = new Map();
|
|
8838
|
+
for (const packet of approved) {
|
|
8839
|
+
for (const path of packet.paths.filter((item) => graphPaths.has(item))) {
|
|
8840
|
+
const list = memoryByPath.get(path) ?? [];
|
|
8841
|
+
list.push(packet);
|
|
8842
|
+
memoryByPath.set(path, list);
|
|
8843
|
+
}
|
|
8844
|
+
}
|
|
8845
|
+
const memoryItems = uniqueXrayItems([...memoryByPath.entries()].map(([path, packets]) => xrayItem({
|
|
8846
|
+
label: path,
|
|
8847
|
+
path,
|
|
8848
|
+
kind: "memory_overlay",
|
|
8849
|
+
strength: Math.min(100, packets.length * 22 + (testsBySource.get(path)?.length ?? 0) * 8 + (routeCounts[path] ?? 0) * 8),
|
|
8850
|
+
status: "ok",
|
|
8851
|
+
evidence: packets.slice(0, 3).map((packet) => `${packet.type}: ${packet.title}`),
|
|
8852
|
+
action: "Read linked memory before editing; this is repo lore attached to code.",
|
|
8853
|
+
}))).slice(0, 10);
|
|
8854
|
+
const gapItems = uniqueXrayItems(decisions.coverage_gaps.slice(0, 10).map((gap) => xrayItem({
|
|
8855
|
+
label: gap.path,
|
|
8856
|
+
path: gap.path,
|
|
8857
|
+
kind: "knowledge_gap",
|
|
8858
|
+
strength: Math.min(100, gap.dependents * 16 + gap.churn_90d * 8 + 24),
|
|
8859
|
+
status: "watch",
|
|
8860
|
+
evidence: [gap.reason, `${gap.dependents} dependent(s)`, `${gap.churn_90d} commit(s) in 90d`],
|
|
8861
|
+
action: "Capture why-memory here when the next session learns reusable context.",
|
|
8862
|
+
})));
|
|
8863
|
+
const layers = [
|
|
8864
|
+
{
|
|
8865
|
+
id: "entry_points",
|
|
8866
|
+
title: "Entry Points",
|
|
8867
|
+
summary: entryItems.length ? "Where runtime behavior appears to start." : "No route, script, or entry-flow signals found yet.",
|
|
8868
|
+
items: entryItems,
|
|
8869
|
+
},
|
|
8870
|
+
{
|
|
8871
|
+
id: "core_modules",
|
|
8872
|
+
title: "Core Modules",
|
|
8873
|
+
summary: coreItems.length ? "Files Kage would inspect first to understand this repo." : "No central code files found yet.",
|
|
8874
|
+
items: coreItems,
|
|
8875
|
+
},
|
|
8876
|
+
{
|
|
8877
|
+
id: "change_risk",
|
|
8878
|
+
title: "Change Risk",
|
|
8879
|
+
summary: riskItems.length ? "Hotspots, low-health modules, and risky change targets." : "No local risk signals found yet.",
|
|
8880
|
+
items: riskItems,
|
|
8881
|
+
},
|
|
8882
|
+
{
|
|
8883
|
+
id: "test_map",
|
|
8884
|
+
title: "Test Map",
|
|
8885
|
+
summary: testItems.length ? "Verification paths and code with missing direct test signals." : "No tests found in the code graph.",
|
|
8886
|
+
items: testItems,
|
|
8887
|
+
},
|
|
8888
|
+
{
|
|
8889
|
+
id: "memory_overlay",
|
|
8890
|
+
title: "Memory Overlay",
|
|
8891
|
+
summary: memoryItems.length ? "Repo knowledge already attached to code." : "No code-linked memory yet.",
|
|
8892
|
+
items: memoryItems,
|
|
8893
|
+
},
|
|
8894
|
+
{
|
|
8895
|
+
id: "knowledge_gaps",
|
|
8896
|
+
title: "Knowledge Gaps",
|
|
8897
|
+
summary: gapItems.length ? "High-signal code paths that need why-memory." : "No decision-memory coverage gaps detected.",
|
|
8898
|
+
items: gapItems,
|
|
8899
|
+
},
|
|
8900
|
+
];
|
|
8901
|
+
const script = [
|
|
8902
|
+
"I mapped your repo.",
|
|
8903
|
+
`I found ${entryItems.length} entry point(s), ${coreItems.length} core code signal(s), ${riskItems.length} risk signal(s), and ${testItems.length} verification signal(s).`,
|
|
8904
|
+
memoryItems.length
|
|
8905
|
+
? `${memoryItems.length} code area(s) already have attached repo memory.`
|
|
8906
|
+
: "I do not see much code-linked repo memory yet, so I will learn carefully during the session.",
|
|
8907
|
+
"Click any X-Ray item to focus the graph and see the evidence.",
|
|
8908
|
+
];
|
|
8909
|
+
const nextActions = [
|
|
8910
|
+
...(entryItems.length ? [`Start orientation from ${entryItems[0].path}.`] : ["Run kage refresh so entry points can be indexed."]),
|
|
8911
|
+
...(riskItems.length ? [`Review highest-risk area ${riskItems[0].path} before making edits.`] : []),
|
|
8912
|
+
...(testItems.some((item) => item.kind === "test_gap") ? ["Resolve test-map gaps by identifying task-specific verification before handoff."] : []),
|
|
8913
|
+
...(gapItems.length ? ["Capture why-memory for knowledge gaps when the session uncovers durable context."] : []),
|
|
8914
|
+
];
|
|
8915
|
+
const warnings = unique([
|
|
8916
|
+
...profile.warnings,
|
|
8917
|
+
...risk.warnings,
|
|
8918
|
+
...health.warnings,
|
|
8919
|
+
...insights.warnings,
|
|
8920
|
+
...decisions.warnings,
|
|
8921
|
+
]);
|
|
8922
|
+
return {
|
|
8923
|
+
schema_version: 1,
|
|
8924
|
+
project_dir: projectDir,
|
|
8925
|
+
generated_at: nowIso(),
|
|
8926
|
+
summary: `Repo X-Ray mapped ${graph.files.length} file(s), ${graph.symbols.length} symbol(s), ${graph.routes.length} route(s), ${graph.tests.length} test signal(s), and ${approved.length} memory packet(s).`,
|
|
8927
|
+
first_use_script: script,
|
|
8928
|
+
layers,
|
|
8929
|
+
next_actions: unique(nextActions),
|
|
8930
|
+
warnings,
|
|
8931
|
+
};
|
|
8932
|
+
}
|
|
8310
8933
|
const WORKSPACE_SKIP_DIRS = new Set([
|
|
8311
8934
|
".agent_memory",
|
|
8312
8935
|
".git",
|
|
8313
8936
|
".hg",
|
|
8314
8937
|
".next",
|
|
8315
|
-
".repowise",
|
|
8316
|
-
".repowise-workspace",
|
|
8317
8938
|
"coverage",
|
|
8318
8939
|
"dist",
|
|
8319
8940
|
"node_modules",
|
|
@@ -10096,6 +10717,9 @@ function learn(input) {
|
|
|
10096
10717
|
paths: input.paths,
|
|
10097
10718
|
stack: input.stack,
|
|
10098
10719
|
context: input.context,
|
|
10720
|
+
allowMissingPaths: input.allowMissingPaths,
|
|
10721
|
+
strictCitations: input.strictCitations,
|
|
10722
|
+
graphNodes: input.graphNodes,
|
|
10099
10723
|
});
|
|
10100
10724
|
}
|
|
10101
10725
|
function capture(input) {
|
|
@@ -10111,7 +10735,34 @@ function capture(input) {
|
|
|
10111
10735
|
errors: [`Sensitive content blocked: ${unique(scanFindings).join(", ")}`],
|
|
10112
10736
|
};
|
|
10113
10737
|
}
|
|
10738
|
+
const warnings = [];
|
|
10739
|
+
const meaningfulPaths = (input.paths ?? [])
|
|
10740
|
+
.filter((path) => path && meaningfulMemoryPath(path) && !shouldSkipRepoMemoryPath(path));
|
|
10741
|
+
const missingPaths = meaningfulPaths.filter((path) => !pathExistsInRepo(input.projectDir, path));
|
|
10742
|
+
// Citation validation. Strict mode (agent-facing record_memory tools / CLI) rejects a
|
|
10743
|
+
// write whose every cited path is missing — the PRD's "reject if citations don't exist".
|
|
10744
|
+
// The core library stays permissive (warn-only) for programmatic callers and migrations.
|
|
10745
|
+
if (input.strictCitations && meaningfulPaths.length && missingPaths.length === meaningfulPaths.length && !input.allowMissingPaths) {
|
|
10746
|
+
return {
|
|
10747
|
+
ok: false,
|
|
10748
|
+
errors: [
|
|
10749
|
+
`Citation validation failed: none of the referenced paths exist in this repo: ${missingPaths.join(", ")}. ` +
|
|
10750
|
+
`Fix the paths, or pass allow_missing_paths to record anyway (e.g. for a file you are about to create).`,
|
|
10751
|
+
],
|
|
10752
|
+
warnings: [],
|
|
10753
|
+
};
|
|
10754
|
+
}
|
|
10755
|
+
if (missingPaths.length) {
|
|
10756
|
+
warnings.push(`Some referenced paths do not exist in this repo: ${missingPaths.join(", ")}`);
|
|
10757
|
+
}
|
|
10114
10758
|
const createdAt = nowIso();
|
|
10759
|
+
// Agent-asserted links to code-graph nodes (PRD `graph_nodes`): the agent recording the
|
|
10760
|
+
// memory knows which symbol/route/file the rule is about, so let it declare them instead
|
|
10761
|
+
// of relying solely on background derivation.
|
|
10762
|
+
const graphEdges = unique(input.graphNodes ?? [])
|
|
10763
|
+
.map((node) => node.trim())
|
|
10764
|
+
.filter(Boolean)
|
|
10765
|
+
.map((node) => ({ relation: "references_code", to: node, evidence: "agent_capture", created_at: createdAt }));
|
|
10115
10766
|
const packet = {
|
|
10116
10767
|
schema_version: exports.PACKET_SCHEMA_VERSION,
|
|
10117
10768
|
id: makePacketId(input.projectDir, type, input.title, String(Date.now())),
|
|
@@ -10153,10 +10804,12 @@ function capture(input) {
|
|
|
10153
10804
|
},
|
|
10154
10805
|
created_at: createdAt,
|
|
10155
10806
|
updated_at: createdAt,
|
|
10807
|
+
author_branch: gitBranch(input.projectDir),
|
|
10156
10808
|
};
|
|
10809
|
+
packet.edges = graphEdges;
|
|
10157
10810
|
const validation = validatePacket(packet);
|
|
10158
10811
|
if (!validation.ok)
|
|
10159
|
-
return { ok: false, errors: validation.errors };
|
|
10812
|
+
return { ok: false, errors: validation.errors, warnings };
|
|
10160
10813
|
packet.quality = {
|
|
10161
10814
|
...packet.quality,
|
|
10162
10815
|
...evaluateMemoryQuality(input.projectDir, packet),
|
|
@@ -10168,7 +10821,7 @@ function capture(input) {
|
|
|
10168
10821
|
path: (0, node_path_1.relative)(input.projectDir, path),
|
|
10169
10822
|
source_kind: packet.source_refs[0]?.kind ?? "explicit_capture",
|
|
10170
10823
|
});
|
|
10171
|
-
return { ok: true, packet, path, errors: [] };
|
|
10824
|
+
return { ok: true, packet, path, errors: [], warnings };
|
|
10172
10825
|
}
|
|
10173
10826
|
function createPublicCandidate(projectDir, id) {
|
|
10174
10827
|
ensureMemoryDirs(projectDir);
|
|
@@ -11156,6 +11809,171 @@ function kageSessionReplay(projectDir, options = {}) {
|
|
|
11156
11809
|
: "No durable candidates in this digest yet; keep observing or capture reusable learnings with kage learn.",
|
|
11157
11810
|
};
|
|
11158
11811
|
}
|
|
11812
|
+
function distilledObservationSessions(projectDir) {
|
|
11813
|
+
const ids = new Set();
|
|
11814
|
+
for (const packet of [...loadApprovedPackets(projectDir), ...loadPendingPackets(projectDir)]) {
|
|
11815
|
+
for (const ref of packet.source_refs) {
|
|
11816
|
+
if (ref.kind === "observation_session" && typeof ref.session_id === "string" && ref.session_id.trim()) {
|
|
11817
|
+
ids.add(ref.session_id.trim());
|
|
11818
|
+
}
|
|
11819
|
+
}
|
|
11820
|
+
}
|
|
11821
|
+
return ids;
|
|
11822
|
+
}
|
|
11823
|
+
function eventLearningCandidate(event, knownCommands) {
|
|
11824
|
+
if (event.type === "command_result") {
|
|
11825
|
+
if (typeof event.exit_code === "number" && event.exit_code !== 0 && !`${event.summary ?? ""}\n${event.text ?? ""}`.trim()) {
|
|
11826
|
+
return null;
|
|
11827
|
+
}
|
|
11828
|
+
const reusable = reusableCommandObservation(event, knownCommands);
|
|
11829
|
+
if (reusable)
|
|
11830
|
+
return { memory_type: "runbook", reason: reusable.learning };
|
|
11831
|
+
}
|
|
11832
|
+
if (event.type === "file_change") {
|
|
11833
|
+
const learning = reusableFileObservation(event);
|
|
11834
|
+
if (learning)
|
|
11835
|
+
return { memory_type: "workflow", reason: learning };
|
|
11836
|
+
}
|
|
11837
|
+
if (event.type === "user_prompt") {
|
|
11838
|
+
const learning = reusablePromptObservation(event);
|
|
11839
|
+
if (learning)
|
|
11840
|
+
return { memory_type: "decision", reason: learning };
|
|
11841
|
+
}
|
|
11842
|
+
return null;
|
|
11843
|
+
}
|
|
11844
|
+
function ignoredObservationReason(event) {
|
|
11845
|
+
if (event.type === "tool_use" || event.type === "tool_result")
|
|
11846
|
+
return "Tool telemetry helps replay the session but is not durable repo knowledge by itself.";
|
|
11847
|
+
if (event.type === "command_result" || event.type === "test_result")
|
|
11848
|
+
return "Verification evidence is useful for this session but needs a reusable cause, fix, or runbook before saving.";
|
|
11849
|
+
if (event.type === "file_change")
|
|
11850
|
+
return "The file touch is generic; save only if it explains a convention, workflow, bug, or invariant.";
|
|
11851
|
+
if (event.type === "user_prompt")
|
|
11852
|
+
return "The prompt is episodic; save only decisions, policies, gotchas, or reusable context.";
|
|
11853
|
+
return "Session bookkeeping is not durable repo memory.";
|
|
11854
|
+
}
|
|
11855
|
+
function learningLedgerContextBlock(report) {
|
|
11856
|
+
const lines = ["\n## Session Learning Ledger"];
|
|
11857
|
+
if (!report.sessions.length) {
|
|
11858
|
+
lines.push("No observed session events found.");
|
|
11859
|
+
return lines.join("\n");
|
|
11860
|
+
}
|
|
11861
|
+
lines.push(`Save candidates: ${report.totals.save_candidates}`);
|
|
11862
|
+
lines.push(`Needs evidence: ${report.totals.needs_evidence}`);
|
|
11863
|
+
if (report.totals.already_distilled)
|
|
11864
|
+
lines.push(`Already distilled: ${report.totals.already_distilled}`);
|
|
11865
|
+
lines.push("");
|
|
11866
|
+
lines.push("### Memory Decisions");
|
|
11867
|
+
for (const session of report.sessions.slice(0, 3)) {
|
|
11868
|
+
lines.push(`Session ${session.session_id}: ${session.save_candidates} save, ${session.needs_evidence} needs evidence, ${session.ignore_items} ignore.`);
|
|
11869
|
+
for (const decision of session.decisions.filter((item) => item.disposition !== "ignore").slice(0, 4)) {
|
|
11870
|
+
lines.push(`- ${decision.disposition}: ${decision.memory_type ?? decision.event_type} - ${decision.evidence}`);
|
|
11871
|
+
}
|
|
11872
|
+
}
|
|
11873
|
+
lines.push("", "### Next Actions");
|
|
11874
|
+
for (const action of unique(report.sessions.map((session) => session.next_action)).slice(0, 4)) {
|
|
11875
|
+
lines.push(`- ${action}`);
|
|
11876
|
+
}
|
|
11877
|
+
return lines.join("\n");
|
|
11878
|
+
}
|
|
11879
|
+
function kageSessionLearningLedger(projectDir, options = {}) {
|
|
11880
|
+
ensureMemoryDirs(projectDir);
|
|
11881
|
+
const limit = Math.max(1, Math.min(200, Math.floor(options.limit ?? 50)));
|
|
11882
|
+
const observations = loadObservations(projectDir, options.sessionId);
|
|
11883
|
+
const knownCommands = knownRepoCommands(projectDir);
|
|
11884
|
+
const distilledSessions = distilledObservationSessions(projectDir);
|
|
11885
|
+
const bySession = new Map();
|
|
11886
|
+
for (const observation of observations) {
|
|
11887
|
+
const rows = bySession.get(observation.session_id) ?? [];
|
|
11888
|
+
rows.push(observation);
|
|
11889
|
+
bySession.set(observation.session_id, rows);
|
|
11890
|
+
}
|
|
11891
|
+
const sessions = Array.from(bySession.entries()).map(([sessionId, rows]) => {
|
|
11892
|
+
const sorted = rows.slice().sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
11893
|
+
const alreadyDistilled = distilledSessions.has(sessionId);
|
|
11894
|
+
const distillCommand = `kage distill --project . --session ${sessionId}`;
|
|
11895
|
+
const decisions = sorted.map((event) => {
|
|
11896
|
+
const candidate = eventLearningCandidate(event, knownCommands);
|
|
11897
|
+
const failingEvidence = (event.type === "command_result" || event.type === "test_result") && typeof event.exit_code === "number" && event.exit_code !== 0;
|
|
11898
|
+
const evidence = summarize(observationDigestSummary(event)).slice(0, 220);
|
|
11899
|
+
if (candidate) {
|
|
11900
|
+
return {
|
|
11901
|
+
observation_id: event.id,
|
|
11902
|
+
timestamp: event.timestamp,
|
|
11903
|
+
session_id: event.session_id,
|
|
11904
|
+
event_type: event.type,
|
|
11905
|
+
disposition: alreadyDistilled ? "already_distilled" : "save",
|
|
11906
|
+
memory_type: candidate.memory_type,
|
|
11907
|
+
reason: alreadyDistilled ? "A memory packet already references this observed session." : candidate.reason,
|
|
11908
|
+
evidence,
|
|
11909
|
+
...(event.path ? { path: event.path } : {}),
|
|
11910
|
+
...(event.command ? { command: normalizeCommandText(event.command) } : {}),
|
|
11911
|
+
...(typeof event.exit_code === "number" ? { exit_code: event.exit_code } : {}),
|
|
11912
|
+
distill_command: distillCommand,
|
|
11913
|
+
};
|
|
11914
|
+
}
|
|
11915
|
+
return {
|
|
11916
|
+
observation_id: event.id,
|
|
11917
|
+
timestamp: event.timestamp,
|
|
11918
|
+
session_id: event.session_id,
|
|
11919
|
+
event_type: event.type,
|
|
11920
|
+
disposition: failingEvidence ? "needs_evidence" : "ignore",
|
|
11921
|
+
reason: failingEvidence ? "A failure happened, but the observation does not yet explain a reusable cause, fix, workaround, or runbook." : ignoredObservationReason(event),
|
|
11922
|
+
evidence,
|
|
11923
|
+
...(event.path ? { path: event.path } : {}),
|
|
11924
|
+
...(event.command ? { command: normalizeCommandText(event.command) } : {}),
|
|
11925
|
+
...(typeof event.exit_code === "number" ? { exit_code: event.exit_code } : {}),
|
|
11926
|
+
...(failingEvidence ? { distill_command: distillCommand } : {}),
|
|
11927
|
+
};
|
|
11928
|
+
});
|
|
11929
|
+
const saveCandidates = decisions.filter((decision) => decision.disposition === "save").length;
|
|
11930
|
+
const needsEvidence = decisions.filter((decision) => decision.disposition === "needs_evidence").length;
|
|
11931
|
+
const ignoreItems = decisions.filter((decision) => decision.disposition === "ignore").length;
|
|
11932
|
+
const alreadyDistilledCount = decisions.filter((decision) => decision.disposition === "already_distilled").length;
|
|
11933
|
+
const nextAction = saveCandidates > 0
|
|
11934
|
+
? `${distillCommand} and review save candidates before handoff.`
|
|
11935
|
+
: needsEvidence > 0
|
|
11936
|
+
? "Add a concise cause/fix summary for failing observations before deciding whether to save them."
|
|
11937
|
+
: alreadyDistilledCount > 0
|
|
11938
|
+
? "Session learning already has memory packets; update or supersede them only if the facts changed."
|
|
11939
|
+
: "No save-worthy session fact yet; keep observing without creating memory noise.";
|
|
11940
|
+
return {
|
|
11941
|
+
session_id: sessionId,
|
|
11942
|
+
first_at: sorted[0]?.timestamp ?? "",
|
|
11943
|
+
last_at: sorted.at(-1)?.timestamp ?? "",
|
|
11944
|
+
observations: sorted.length,
|
|
11945
|
+
save_candidates: saveCandidates,
|
|
11946
|
+
ignore_items: ignoreItems,
|
|
11947
|
+
needs_evidence: needsEvidence,
|
|
11948
|
+
already_distilled: alreadyDistilledCount,
|
|
11949
|
+
commands: unique(sorted.map((event) => event.command).filter(Boolean)).slice(0, 8),
|
|
11950
|
+
paths: unique(sorted.map((event) => event.path).filter(Boolean)).slice(0, 12),
|
|
11951
|
+
decisions: decisions.slice(0, limit),
|
|
11952
|
+
next_action: nextAction,
|
|
11953
|
+
};
|
|
11954
|
+
}).sort((a, b) => b.last_at.localeCompare(a.last_at));
|
|
11955
|
+
const totals = {
|
|
11956
|
+
sessions: sessions.length,
|
|
11957
|
+
observations: observations.length,
|
|
11958
|
+
save_candidates: sessions.reduce((sum, session) => sum + session.save_candidates, 0),
|
|
11959
|
+
ignore_items: sessions.reduce((sum, session) => sum + session.ignore_items, 0),
|
|
11960
|
+
needs_evidence: sessions.reduce((sum, session) => sum + session.needs_evidence, 0),
|
|
11961
|
+
already_distilled: sessions.reduce((sum, session) => sum + session.already_distilled, 0),
|
|
11962
|
+
};
|
|
11963
|
+
const reportWithoutBlock = {
|
|
11964
|
+
schema_version: 1,
|
|
11965
|
+
project_dir: projectDir,
|
|
11966
|
+
generated_at: nowIso(),
|
|
11967
|
+
...(options.sessionId ? { selected_session_id: options.sessionId } : {}),
|
|
11968
|
+
totals,
|
|
11969
|
+
sessions,
|
|
11970
|
+
privacy_model: "The ledger classifies privacy-scanned observation metadata into save, ignore, needs-evidence, and already-distilled decisions; raw transcript text is not the product surface.",
|
|
11971
|
+
};
|
|
11972
|
+
return {
|
|
11973
|
+
...reportWithoutBlock,
|
|
11974
|
+
context_block: learningLedgerContextBlock(reportWithoutBlock),
|
|
11975
|
+
};
|
|
11976
|
+
}
|
|
11159
11977
|
function distillSession(projectDir, sessionId) {
|
|
11160
11978
|
const observations = loadObservations(projectDir, sessionId);
|
|
11161
11979
|
const candidates = [];
|
|
@@ -11525,6 +12343,7 @@ function prCheck(projectDir) {
|
|
|
11525
12343
|
const codeInputHash = currentCodeGraphInputHash(projectDir);
|
|
11526
12344
|
const memoryInputHash = knowledgeGraphInputHash(projectDir, codeInputHash);
|
|
11527
12345
|
const stalePackets = loadPacketsFromDir(packetsDir(projectDir))
|
|
12346
|
+
.filter((packet) => packet.status === "approved" || packet.status === "pending")
|
|
11528
12347
|
.map((packet) => ({ packet, reasons: staleMemoryReasons(projectDir, packet) }))
|
|
11529
12348
|
.filter((entry) => entry.reasons.length)
|
|
11530
12349
|
.map((entry) => staleFinding(entry.packet, entry.reasons));
|
|
@@ -11587,12 +12406,65 @@ function regexpEscape(value) {
|
|
|
11587
12406
|
function shellQuote(value) {
|
|
11588
12407
|
return `'${value.replace(/'/g, "'\"'\"'")}'`;
|
|
11589
12408
|
}
|
|
11590
|
-
function gitHookPath(projectDir) {
|
|
11591
|
-
const raw = readGit(projectDir, ["rev-parse", "--git-path",
|
|
12409
|
+
function gitHookPath(projectDir, hookName = "post-commit") {
|
|
12410
|
+
const raw = readGit(projectDir, ["rev-parse", "--git-path", `hooks/${hookName}`]);
|
|
11592
12411
|
if (!raw)
|
|
11593
12412
|
return null;
|
|
11594
12413
|
return (0, node_path_1.resolve)(projectDir, raw);
|
|
11595
12414
|
}
|
|
12415
|
+
// Hooks that fire after history changes underfoot (git pull / merge / checkout):
|
|
12416
|
+
// re-index repo memory so newly pulled teammate packets are immediately recallable.
|
|
12417
|
+
const KAGE_SYNC_HOOKS = ["post-merge", "post-checkout"];
|
|
12418
|
+
function kageSyncHookBlock(projectDir) {
|
|
12419
|
+
const project = shellQuote((0, node_path_1.resolve)(projectDir));
|
|
12420
|
+
return [
|
|
12421
|
+
KAGE_POST_COMMIT_HOOK_START,
|
|
12422
|
+
"# Kage sync hook: re-index repo memory after pull/merge/checkout.",
|
|
12423
|
+
"# Set KAGE_SKIP_HOOK=1 to bypass, or KAGE_BIN=/path/to/kage to override.",
|
|
12424
|
+
"if [ \"${KAGE_SKIP_HOOK:-0}\" != \"1\" ]; then",
|
|
12425
|
+
" KAGE_BIN=\"${KAGE_BIN:-kage}\"",
|
|
12426
|
+
" if command -v \"$KAGE_BIN\" >/dev/null 2>&1; then",
|
|
12427
|
+
" (",
|
|
12428
|
+
` "$KAGE_BIN" index --project ${project} --json >/dev/null 2>&1 || true`,
|
|
12429
|
+
" ) &",
|
|
12430
|
+
" fi",
|
|
12431
|
+
"fi",
|
|
12432
|
+
KAGE_POST_COMMIT_HOOK_END,
|
|
12433
|
+
].join("\n");
|
|
12434
|
+
}
|
|
12435
|
+
function installSyncHooks(projectDir) {
|
|
12436
|
+
const installed = [];
|
|
12437
|
+
for (const hookName of KAGE_SYNC_HOOKS) {
|
|
12438
|
+
const hookPath = gitHookPath(projectDir, hookName);
|
|
12439
|
+
if (!hookPath)
|
|
12440
|
+
continue;
|
|
12441
|
+
ensureDir((0, node_path_1.dirname)(hookPath));
|
|
12442
|
+
const existing = safeReadText(hookPath) ?? "";
|
|
12443
|
+
const base = stripKageHookBlock(existing);
|
|
12444
|
+
const prefix = base.trim() ? base.trimEnd() : "#!/bin/sh";
|
|
12445
|
+
const next = `${prefix}\n\n${kageSyncHookBlock(projectDir)}\n`;
|
|
12446
|
+
if (existing !== next)
|
|
12447
|
+
(0, node_fs_1.writeFileSync)(hookPath, next, "utf8");
|
|
12448
|
+
(0, node_fs_1.chmodSync)(hookPath, 0o755);
|
|
12449
|
+
installed.push(hookPath);
|
|
12450
|
+
}
|
|
12451
|
+
return installed;
|
|
12452
|
+
}
|
|
12453
|
+
function uninstallSyncHooks(projectDir) {
|
|
12454
|
+
const removed = [];
|
|
12455
|
+
for (const hookName of KAGE_SYNC_HOOKS) {
|
|
12456
|
+
const hookPath = gitHookPath(projectDir, hookName);
|
|
12457
|
+
if (!hookPath)
|
|
12458
|
+
continue;
|
|
12459
|
+
const existing = safeReadText(hookPath) ?? "";
|
|
12460
|
+
if (!hasKageHookBlock(existing))
|
|
12461
|
+
continue;
|
|
12462
|
+
(0, node_fs_1.writeFileSync)(hookPath, stripKageHookBlock(existing), "utf8");
|
|
12463
|
+
(0, node_fs_1.chmodSync)(hookPath, 0o755);
|
|
12464
|
+
removed.push(hookPath);
|
|
12465
|
+
}
|
|
12466
|
+
return removed;
|
|
12467
|
+
}
|
|
11596
12468
|
function hasKageHookBlock(content) {
|
|
11597
12469
|
return content.includes(KAGE_POST_COMMIT_HOOK_START) && content.includes(KAGE_POST_COMMIT_HOOK_END);
|
|
11598
12470
|
}
|
|
@@ -11668,20 +12540,24 @@ function kageHookInstall(projectDir) {
|
|
|
11668
12540
|
const base = stripKageHookBlock(existing);
|
|
11669
12541
|
const prefix = base.trim() ? base.trimEnd() : "#!/bin/sh";
|
|
11670
12542
|
const next = `${prefix}\n\n${kagePostCommitHookBlock(projectDir)}\n`;
|
|
11671
|
-
const
|
|
11672
|
-
if (
|
|
12543
|
+
const commitChanged = existing !== next;
|
|
12544
|
+
if (commitChanged)
|
|
11673
12545
|
(0, node_fs_1.writeFileSync)(hookPath, next, "utf8");
|
|
11674
12546
|
(0, node_fs_1.chmodSync)(hookPath, 0o755);
|
|
12547
|
+
const syncHooks = installSyncHooks(projectDir);
|
|
11675
12548
|
return {
|
|
11676
12549
|
ok: true,
|
|
11677
12550
|
action: "install",
|
|
11678
12551
|
project_dir: projectDir,
|
|
11679
12552
|
hook_path: hookPath,
|
|
11680
12553
|
installed: true,
|
|
11681
|
-
changed,
|
|
11682
|
-
message:
|
|
12554
|
+
changed: commitChanged,
|
|
12555
|
+
message: commitChanged
|
|
12556
|
+
? "Installed Kage post-commit hook and pull/merge sync hooks."
|
|
12557
|
+
: "Kage post-commit and sync hooks are already current.",
|
|
11683
12558
|
errors: [],
|
|
11684
12559
|
warnings: [],
|
|
12560
|
+
additional_hooks: syncHooks,
|
|
11685
12561
|
};
|
|
11686
12562
|
}
|
|
11687
12563
|
function kageHookUninstall(projectDir) {
|
|
@@ -11717,6 +12593,7 @@ function kageHookUninstall(projectDir) {
|
|
|
11717
12593
|
const next = stripKageHookBlock(existing);
|
|
11718
12594
|
(0, node_fs_1.writeFileSync)(hookPath, next, "utf8");
|
|
11719
12595
|
(0, node_fs_1.chmodSync)(hookPath, 0o755);
|
|
12596
|
+
const removedSyncHooks = uninstallSyncHooks(projectDir);
|
|
11720
12597
|
return {
|
|
11721
12598
|
ok: true,
|
|
11722
12599
|
action: "uninstall",
|
|
@@ -11724,9 +12601,10 @@ function kageHookUninstall(projectDir) {
|
|
|
11724
12601
|
hook_path: hookPath,
|
|
11725
12602
|
installed: false,
|
|
11726
12603
|
changed: true,
|
|
11727
|
-
message: "Removed Kage post-commit
|
|
12604
|
+
message: "Removed Kage post-commit and sync hooks.",
|
|
11728
12605
|
errors: [],
|
|
11729
12606
|
warnings: [],
|
|
12607
|
+
additional_hooks: removedSyncHooks,
|
|
11730
12608
|
};
|
|
11731
12609
|
}
|
|
11732
12610
|
function exportPublicBundle(projectDir) {
|