@fusengine/harness 0.1.30 → 0.1.32
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/adapters/claude/index.mjs +1 -1
- package/dist/adapters/cline/index.mjs +1 -1
- package/dist/adapters/codex/index.mjs +1 -1
- package/dist/adapters/cursor/index.mjs +1 -1
- package/dist/adapters/gemini/index.mjs +1 -1
- package/dist/{claude-B9FYp0Yw.mjs → claude-BWZcrZbS.mjs} +1 -1
- package/dist/cli/bin.mjs +6 -3
- package/dist/cli/index.mjs +1 -1
- package/dist/{evaluate-j3gRJ_ng.mjs → evaluate-9ch1K2kt.mjs} +1 -1
- package/dist/{handle-USWK4NSE.mjs → handle-DJqHZcml.mjs} +1528 -22
- package/dist/{index-C1vLIMwN.d.mts → index-BEMumjOw.d.mts} +8 -1
- package/dist/{index-mISsk0ff.d.mts → index-DmbOUJK8.d.mts} +12 -1
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +5 -5
- package/dist/memory/index.mjs +2 -1
- package/dist/memory-la_KkjCS.mjs +1 -0
- package/dist/policy/index.d.mts +2 -2
- package/dist/policy/index.mjs +3 -3
- package/dist/{project-root-ff0_poWU.mjs → project-root-3kk7gCOp.mjs} +7 -1
- package/dist/{run-CXsV-wIJ.mjs → run-CQbtlAKa.mjs} +2 -2
- package/dist/runtime/index.d.mts +86 -8
- package/dist/runtime/index.mjs +2 -2
- package/dist/util/index.d.mts +2 -2
- package/dist/util/index.mjs +2 -2
- package/dist/{describe-CPtgUzFS.mjs → validate-CccewDwk.mjs} +50 -6
- package/package.json +1 -1
- /package/dist/{memory-BkoEbdec.mjs → registry-BkoEbdec.mjs} +0 -0
|
@@ -1,16 +1,22 @@
|
|
|
1
|
+
import { r as resolveMaxLines } from "./limits-CHn8AIL1.mjs";
|
|
1
2
|
import { r as projectLayout } from "./layout-C0jaaCQC.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
3
|
+
import { i as walkUpFor, n as projectRoot, r as projectRootOrNull, t as isCodeFile } from "./project-root-3kk7gCOp.mjs";
|
|
4
|
+
import { A as evaluateApex, E as detectCreationIntent, L as requiredArchSkill, P as detectModularArchitecture, S as frameworkSolidGate, T as capVerbosity, _ as buildClaudeMdContext, b as skillTriggerGate, c as parseBodyDesc, d as PROJECT_INDICATORS, l as parseField, n as missingSeoElements, o as parseEnrichment, p as buildApexTaskInjection, r as descFromText, s as parseEntry, t as isHtmlLike, u as EXCLUDE_DIRS$1 } from "./validate-CccewDwk.mjs";
|
|
5
|
+
import { j as detectFramework, k as countLines, n as FAIL_CLOSED, t as evaluate } from "./evaluate-9ch1K2kt.mjs";
|
|
4
6
|
import { t as formatPrompt } from "./types-ernB1Dy3.mjs";
|
|
7
|
+
import { a as nowStamp, l as throttleMs, n as readRoots, o as readState, s as setStateField, t as addRoot } from "./registry-BkoEbdec.mjs";
|
|
5
8
|
import { a as extractText, o as loadIndex, r as cacheStore, t as cacheLookup } from "./store-PrNPm6So.mjs";
|
|
6
|
-
import { t as atomicWrite } from "./json-io-CAn72gI4.mjs";
|
|
9
|
+
import { i as writeJsonFile, r as readJsonFile, t as atomicWrite } from "./json-io-CAn72gI4.mjs";
|
|
7
10
|
import { t as loadRefs } from "./loader-CyAoJv2W.mjs";
|
|
8
11
|
import { a as recordAgent, c as recordRefRead, l as recordTrivialEdit, n as saveTrack, o as recordBrainstormRequired, r as agentsFresh, s as recordDoc, t as loadTrack, u as trivialCount } from "./store-D-ge2ZPI.mjs";
|
|
9
|
-
import { t as contextResponse } from "./claude-
|
|
12
|
+
import { n as denyResponse, t as contextResponse } from "./claude-BWZcrZbS.mjs";
|
|
10
13
|
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
11
|
-
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
12
15
|
import { homedir, tmpdir } from "node:os";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { mkdir, rmdir } from "node:fs/promises";
|
|
13
18
|
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { Glob } from "bun";
|
|
14
20
|
//#region src/runtime/activity.ts
|
|
15
21
|
/** Min response length (chars) for a lead agent call to count as `sufficient`. */
|
|
16
22
|
const AGENT_QUALITY_MIN = 500;
|
|
@@ -1455,30 +1461,1460 @@ function generateProjectMap(cwd, outputDir) {
|
|
|
1455
1461
|
return "";
|
|
1456
1462
|
}
|
|
1457
1463
|
//#endregion
|
|
1464
|
+
//#region src/runtime/lifecycle/cartographer/detect.ts
|
|
1465
|
+
/**
|
|
1466
|
+
* Plugin discovery (fs). Ports `detect_plugins.py`: marketplace `plugins` dir
|
|
1467
|
+
* resolution + `plugin.json` meta reading.
|
|
1468
|
+
*/
|
|
1469
|
+
/** Sorted entry names of `dir` (alpha, byte-order), or `[]` on error. */
|
|
1470
|
+
function sortedNames$1(dir) {
|
|
1471
|
+
try {
|
|
1472
|
+
return readdirSync(dir).sort((a, b) => a.localeCompare(b, "en"));
|
|
1473
|
+
} catch {
|
|
1474
|
+
return [];
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* Read `[version, name]` from `<pluginPath>/.claude-plugin/plugin.json`.
|
|
1479
|
+
* @param pluginPath - Absolute plugin directory.
|
|
1480
|
+
* @returns The `[version, name]` pair (both "" when absent/unreadable).
|
|
1481
|
+
*/
|
|
1482
|
+
function readPluginMeta(pluginPath) {
|
|
1483
|
+
const pj = join(pluginPath, ".claude-plugin", "plugin.json");
|
|
1484
|
+
if (!existsSync(pj)) return ["", ""];
|
|
1485
|
+
try {
|
|
1486
|
+
const meta = JSON.parse(readFileSync(pj, "utf-8"));
|
|
1487
|
+
return [meta.version ?? "", meta.name ?? ""];
|
|
1488
|
+
} catch {
|
|
1489
|
+
return ["", ""];
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Auto-detect the marketplace `plugins` dir that contains `cartographer`,
|
|
1494
|
+
* falling back to the first marketplace with a `plugins` dir, else `cwd`.
|
|
1495
|
+
* Ports `find_marketplace_plugins`.
|
|
1496
|
+
* @param home - Home directory (defaults to `~`).
|
|
1497
|
+
* @returns The resolved plugins directory.
|
|
1498
|
+
*/
|
|
1499
|
+
function findMarketplacePlugins(home = homedir()) {
|
|
1500
|
+
const mp = join(home, ".claude", "plugins", "marketplaces");
|
|
1501
|
+
const markets = sortedNames$1(mp);
|
|
1502
|
+
for (const m of markets) if (existsSync(join(mp, m, "plugins", "cartographer"))) return join(mp, m, "plugins");
|
|
1503
|
+
for (const m of markets) if (existsSync(join(mp, m, "plugins"))) return join(mp, m, "plugins");
|
|
1504
|
+
return process.cwd();
|
|
1505
|
+
}
|
|
1506
|
+
//#endregion
|
|
1507
|
+
//#region src/runtime/lifecycle/cartographer/scan.ts
|
|
1508
|
+
/**
|
|
1509
|
+
* Plugin scanning (fs). Ports `scan_plugins.py`: turns a plugin's
|
|
1510
|
+
* agents/skills/commands/hooks into ordered `[type, name, desc]` rows.
|
|
1511
|
+
*/
|
|
1512
|
+
/** Sorted entry names of `dir` (alpha, byte-order), or `[]` on error. */
|
|
1513
|
+
function sortedNames(dir) {
|
|
1514
|
+
try {
|
|
1515
|
+
return readdirSync(dir).sort((a, b) => a.localeCompare(b, "en"));
|
|
1516
|
+
} catch {
|
|
1517
|
+
return [];
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
/** Read a `.md` frontmatter field from a file path, "" when missing/unreadable. */
|
|
1521
|
+
function fileField(path, field) {
|
|
1522
|
+
try {
|
|
1523
|
+
return parseField(readFileSync(path, "utf-8"), field);
|
|
1524
|
+
} catch {
|
|
1525
|
+
return "";
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
/** Scan `agents/*.md` → `("agent", name, desc[:50])` rows. */
|
|
1529
|
+
function scanAgents(root) {
|
|
1530
|
+
const dir = join(root, "agents");
|
|
1531
|
+
return sortedNames(dir).filter((n) => extname(n) === ".md").map((n) => {
|
|
1532
|
+
const f = join(dir, n);
|
|
1533
|
+
return [
|
|
1534
|
+
"agent",
|
|
1535
|
+
fileField(f, "name") || n.replace(/\.md$/, ""),
|
|
1536
|
+
fileField(f, "description").slice(0, 50)
|
|
1537
|
+
];
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
/** Scan `skills/<dir>/SKILL.md` → `("skill", dir, desc)` rows. */
|
|
1541
|
+
function scanSkills(root) {
|
|
1542
|
+
const dir = join(root, "skills");
|
|
1543
|
+
const rows = [];
|
|
1544
|
+
for (const name of sortedNames(dir)) {
|
|
1545
|
+
try {
|
|
1546
|
+
if (!statSync(join(dir, name)).isDirectory()) continue;
|
|
1547
|
+
} catch {
|
|
1548
|
+
continue;
|
|
1549
|
+
}
|
|
1550
|
+
const skillMd = join(dir, name, "SKILL.md");
|
|
1551
|
+
let desc = "";
|
|
1552
|
+
if (existsSync(skillMd)) {
|
|
1553
|
+
desc = fileField(skillMd, "description");
|
|
1554
|
+
if (!desc) try {
|
|
1555
|
+
desc = parseBodyDesc(readFileSync(skillMd, "utf-8"));
|
|
1556
|
+
} catch {}
|
|
1557
|
+
}
|
|
1558
|
+
rows.push([
|
|
1559
|
+
"skill",
|
|
1560
|
+
name,
|
|
1561
|
+
desc || "(no description)"
|
|
1562
|
+
]);
|
|
1563
|
+
}
|
|
1564
|
+
return rows;
|
|
1565
|
+
}
|
|
1566
|
+
/** Scan `commands/*.md` → `("command", "/name", desc[:50])` rows. */
|
|
1567
|
+
function scanCommands(root) {
|
|
1568
|
+
const dir = join(root, "commands");
|
|
1569
|
+
return sortedNames(dir).filter((n) => extname(n) === ".md").map((n) => [
|
|
1570
|
+
"command",
|
|
1571
|
+
`/${n.replace(/\.md$/, "")}`,
|
|
1572
|
+
fileField(join(dir, n), "description").slice(0, 50)
|
|
1573
|
+
]);
|
|
1574
|
+
}
|
|
1575
|
+
/** Scan `hooks/hooks.json` → a single `("hooks", "<events>", "")` row, or none. */
|
|
1576
|
+
function scanHooks(root) {
|
|
1577
|
+
const file = join(root, "hooks", "hooks.json");
|
|
1578
|
+
if (!existsSync(file)) return [];
|
|
1579
|
+
try {
|
|
1580
|
+
const raw = JSON.parse(readFileSync(file, "utf-8"));
|
|
1581
|
+
const data = raw && typeof raw === "object" ? raw : {};
|
|
1582
|
+
const hooks = data.hooks && typeof data.hooks === "object" ? data.hooks : data;
|
|
1583
|
+
const events = Object.keys(hooks).filter((k) => !k.startsWith("_")).sort((a, b) => a.localeCompare(b, "en"));
|
|
1584
|
+
return events.length ? [[
|
|
1585
|
+
"hooks",
|
|
1586
|
+
events.join(", "),
|
|
1587
|
+
""
|
|
1588
|
+
]] : [];
|
|
1589
|
+
} catch {
|
|
1590
|
+
return [];
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
/**
|
|
1594
|
+
* Scan a single plugin directory into ordered `[type, name, desc]` rows.
|
|
1595
|
+
* @param pluginDir - Absolute plugin directory.
|
|
1596
|
+
* @returns The agents + skills + commands + hooks rows.
|
|
1597
|
+
*/
|
|
1598
|
+
function scanPlugin(pluginDir) {
|
|
1599
|
+
return [
|
|
1600
|
+
...scanAgents(pluginDir),
|
|
1601
|
+
...scanSkills(pluginDir),
|
|
1602
|
+
...scanCommands(pluginDir),
|
|
1603
|
+
...scanHooks(pluginDir)
|
|
1604
|
+
];
|
|
1605
|
+
}
|
|
1606
|
+
//#endregion
|
|
1607
|
+
//#region src/policy/cartographer/build-tree.ts
|
|
1608
|
+
const SECTION_ORDER = [
|
|
1609
|
+
"agent",
|
|
1610
|
+
"skill",
|
|
1611
|
+
"command"
|
|
1612
|
+
];
|
|
1613
|
+
/**
|
|
1614
|
+
* Format grouped items with tree connectors and optional markdown links.
|
|
1615
|
+
* Skill sections link to `./skills/<name>/index.md`; other sections to
|
|
1616
|
+
* `./<folder>/<name>.md`; unlinked sections render the bare name.
|
|
1617
|
+
* @param prefix - The line prefix (indent + branch glyphs).
|
|
1618
|
+
* @param items - The `[name, desc]` pairs to render.
|
|
1619
|
+
* @param folder - The link folder ("" disables linking).
|
|
1620
|
+
* @param asDirs - Whether items link to a subdirectory `index.md`.
|
|
1621
|
+
* @returns The rendered lines.
|
|
1622
|
+
*/
|
|
1623
|
+
function printItems(prefix, items, folder, asDirs) {
|
|
1624
|
+
return items.map(([name, desc], i) => {
|
|
1625
|
+
const connector = i === items.length - 1 ? "└──" : "├──";
|
|
1626
|
+
const safe = name.replace(/^\/+/, "");
|
|
1627
|
+
let label = name;
|
|
1628
|
+
if (folder && asDirs) label = `[${name}](./${folder}/${safe}/index.md)`;
|
|
1629
|
+
else if (folder) label = `[${name}](./${folder}/${safe}.md)`;
|
|
1630
|
+
const short = desc && desc !== "(no description)" ? ` — ${desc.slice(0, 80)}` : "";
|
|
1631
|
+
return `${prefix}${connector} ${label}${short}`;
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* Build an indented tree from scanned items. The `hooks` row renders as a single
|
|
1636
|
+
* trailing `└── hooks: …` line; agents/skills/commands render as folder sections.
|
|
1637
|
+
* @param items - The scanned `[type, name, desc]` rows.
|
|
1638
|
+
* @param linked - When true, leaf names become markdown links.
|
|
1639
|
+
* @returns The joined tree text.
|
|
1640
|
+
*/
|
|
1641
|
+
function buildTree(items, linked = false) {
|
|
1642
|
+
const groups = {};
|
|
1643
|
+
let hooksLine = "";
|
|
1644
|
+
for (const [typ, name, desc] of items) if (typ === "hooks") hooksLine = name;
|
|
1645
|
+
else (groups[typ] ??= []).push([name, desc]);
|
|
1646
|
+
const sections = SECTION_ORDER.filter((s) => s in groups);
|
|
1647
|
+
if (hooksLine) sections.push("hooks");
|
|
1648
|
+
const lines = [];
|
|
1649
|
+
const total = sections.length;
|
|
1650
|
+
for (let idx = 0; idx < total; idx++) {
|
|
1651
|
+
const section = sections[idx] ?? "";
|
|
1652
|
+
if (section === "hooks") {
|
|
1653
|
+
lines.push(`└── hooks: ${hooksLine}`);
|
|
1654
|
+
continue;
|
|
1655
|
+
}
|
|
1656
|
+
const isLast = idx === total - 1;
|
|
1657
|
+
const folder = `${section}s`;
|
|
1658
|
+
const prefix = isLast ? "└──" : "├──";
|
|
1659
|
+
const subPrefix = isLast ? " " : "│ ";
|
|
1660
|
+
lines.push(`${prefix} ${folder}/`);
|
|
1661
|
+
const linkFolder = linked ? folder : "";
|
|
1662
|
+
const isDirSection = section === "skill";
|
|
1663
|
+
lines.push(...printItems(subPrefix, groups[section] ?? [], linkFolder, linked && isDirSection));
|
|
1664
|
+
}
|
|
1665
|
+
return lines.join("\n");
|
|
1666
|
+
}
|
|
1667
|
+
//#endregion
|
|
1668
|
+
//#region src/runtime/lifecycle/cartographer/write-plugin-map.ts
|
|
1669
|
+
/**
|
|
1670
|
+
* Per-plugin map writer (fs). Ports `write_plugin_map.py`: writes a level-2
|
|
1671
|
+
* `<plugin>/index.md` (indented linked tree) then recurses agents/skills/
|
|
1672
|
+
* commands into deeper index trees. Reuses `buildTree`, `mergeLines`, `writeTree`.
|
|
1673
|
+
*/
|
|
1674
|
+
/** True when `dir` is a real directory. */
|
|
1675
|
+
function isDir(dir) {
|
|
1676
|
+
try {
|
|
1677
|
+
return statSync(dir).isDirectory();
|
|
1678
|
+
} catch {
|
|
1679
|
+
return false;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
/**
|
|
1683
|
+
* Write `<outputDir>/<pluginName>/index.md` (indented linked tree) and recurse
|
|
1684
|
+
* agents/skills/commands into their own index trees rooted there.
|
|
1685
|
+
* @param outputDir - The map root directory.
|
|
1686
|
+
* @param pluginName - Display name of the plugin (the index subfolder).
|
|
1687
|
+
* @param version - Plugin version ("" to omit).
|
|
1688
|
+
* @param items - The scanned `[type, name, desc]` rows.
|
|
1689
|
+
* @param pluginPath - Absolute source plugin directory (for recursion).
|
|
1690
|
+
*/
|
|
1691
|
+
function writePluginMap(outputDir, pluginName, version, items, pluginPath) {
|
|
1692
|
+
const pluginDir = join(outputDir, pluginName);
|
|
1693
|
+
mkdirSync(pluginDir, { recursive: true });
|
|
1694
|
+
const newLines = `# ${pluginName}${version ? ` (v${version})` : ""}\n\n${items.length ? buildTree(items, true) : "└── (empty)"}`.split("\n");
|
|
1695
|
+
const indexPath = join(pluginDir, "index.md");
|
|
1696
|
+
writeFileSync(indexPath, mergeLines(newLines, indexPath).join("\n") + "\n", "utf-8");
|
|
1697
|
+
for (const section of [
|
|
1698
|
+
"agents",
|
|
1699
|
+
"skills",
|
|
1700
|
+
"commands"
|
|
1701
|
+
]) {
|
|
1702
|
+
const src = join(pluginPath, section);
|
|
1703
|
+
if (isDir(src)) writeTree(src, join(pluginDir, section), "../index.md");
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
//#endregion
|
|
1707
|
+
//#region src/runtime/lifecycle/cartographer/ecosystem-map.ts
|
|
1708
|
+
/**
|
|
1709
|
+
* Ecosystem (plugin) map generation (fs). Ports `generate_map.py`: scans every
|
|
1710
|
+
* installed plugin into a level-1 `.cartographer/index.md` + per-plugin level-2+
|
|
1711
|
+
* trees, preserving enriched descriptions. Reuses `findMarketplacePlugins`,
|
|
1712
|
+
* `readPluginMeta`, `scanPlugin`, `mergeLines`, `writePluginMap`.
|
|
1713
|
+
*/
|
|
1714
|
+
function pluginDirs(dir) {
|
|
1715
|
+
let entries = [];
|
|
1716
|
+
try {
|
|
1717
|
+
entries = readdirSync(dir);
|
|
1718
|
+
} catch {
|
|
1719
|
+
return [];
|
|
1720
|
+
}
|
|
1721
|
+
return entries.filter((n) => !n.startsWith("_") && !n.startsWith(".")).filter((n) => {
|
|
1722
|
+
try {
|
|
1723
|
+
return statSync(join(dir, n)).isDirectory();
|
|
1724
|
+
} catch {
|
|
1725
|
+
return false;
|
|
1726
|
+
}
|
|
1727
|
+
}).sort((a, b) => a.localeCompare(b, "en"));
|
|
1728
|
+
}
|
|
1729
|
+
function utcStamp(now) {
|
|
1730
|
+
return new Date(now).toISOString().slice(0, 16).replace("T", " ");
|
|
1731
|
+
}
|
|
1732
|
+
/**
|
|
1733
|
+
* Generate the plugin ecosystem map under `<pluginsDir>/.cartographer`.
|
|
1734
|
+
* @param now - Clock for the banner timestamp.
|
|
1735
|
+
* @param pluginsDirOverride - Override for the marketplace plugins directory.
|
|
1736
|
+
* @returns The map navigation context, or "".
|
|
1737
|
+
*/
|
|
1738
|
+
function generateEcosystemMap(now, pluginsDirOverride) {
|
|
1739
|
+
const pluginsDir = resolve(pluginsDirOverride ?? findMarketplacePlugins());
|
|
1740
|
+
try {
|
|
1741
|
+
if (!statSync(pluginsDir).isDirectory()) return "";
|
|
1742
|
+
} catch {
|
|
1743
|
+
return "";
|
|
1744
|
+
}
|
|
1745
|
+
const outputDir = join(pluginsDir, ".cartographer");
|
|
1746
|
+
mkdirSync(outputDir, { recursive: true });
|
|
1747
|
+
const dirs = pluginDirs(pluginsDir);
|
|
1748
|
+
const lines = [`# Ecosystem Map (${dirs.length} plugins)\n`, `> Auto-generated by cartographer — ${utcStamp(now)}\n`];
|
|
1749
|
+
for (const name of dirs) {
|
|
1750
|
+
const pluginPath = join(pluginsDir, name);
|
|
1751
|
+
const [version, pkgName] = readPluginMeta(pluginPath);
|
|
1752
|
+
const display = pkgName || name;
|
|
1753
|
+
const items = scanPlugin(pluginPath);
|
|
1754
|
+
const agents = items.filter(([t]) => t === "agent").map(([, n]) => n);
|
|
1755
|
+
const ver = version ? ` (v${version})` : "";
|
|
1756
|
+
lines.push(`- [${display}](./${display}/index.md)${ver} → ${agents.length ? agents.join(", ") : "(no agents)"}`);
|
|
1757
|
+
writePluginMap(outputDir, display, version, items, pluginPath);
|
|
1758
|
+
writePluginMap(pluginPath, ".cartographer", version, items, pluginPath);
|
|
1759
|
+
}
|
|
1760
|
+
const indexPath = join(outputDir, "index.md");
|
|
1761
|
+
writeFileSync(indexPath, mergeLines(lines, indexPath).join("\n") + "\n", "utf-8");
|
|
1762
|
+
return `Project map: .cartographer/project/index.md — navigate project files. Plugin skills map: ${outputDir}/index.md — navigate agent skills. Branches link to deeper index.md, leaves link to real files.`;
|
|
1763
|
+
}
|
|
1764
|
+
//#endregion
|
|
1458
1765
|
//#region src/runtime/lifecycle/cartographer/session-start.ts
|
|
1459
1766
|
/**
|
|
1460
|
-
* Cartographer SessionStart handler. Ports the
|
|
1461
|
-
* `generate_project_map.py
|
|
1462
|
-
*
|
|
1463
|
-
*
|
|
1767
|
+
* Cartographer SessionStart handler. Ports BOTH halves of the Python maps:
|
|
1768
|
+
* `generate_project_map.py` (regenerate `.cartographer/project`) and
|
|
1769
|
+
* `generate_map.py` (regenerate the plugin ecosystem map), emitting the
|
|
1770
|
+
* navigation context from the latter as additionalContext.
|
|
1464
1771
|
*/
|
|
1465
1772
|
/**
|
|
1466
|
-
* Regenerate the project map for `cwd` on SessionStart.
|
|
1467
|
-
*
|
|
1773
|
+
* Regenerate the project map + plugin ecosystem map for `cwd` on SessionStart.
|
|
1774
|
+
* Emits the ecosystem navigation context as additionalContext (or "").
|
|
1468
1775
|
* @param cwd - The working directory.
|
|
1469
|
-
* @
|
|
1776
|
+
* @param now - Clock for the ecosystem map banner timestamp.
|
|
1777
|
+
* @returns The SessionStart additionalContext response, or "".
|
|
1470
1778
|
*/
|
|
1471
|
-
function cartoSessionStart(cwd) {
|
|
1779
|
+
function cartoSessionStart(cwd, now = Date.now()) {
|
|
1472
1780
|
generateProjectMap(cwd);
|
|
1781
|
+
const ctx = generateEcosystemMap(now);
|
|
1782
|
+
return ctx ? contextResponse("SessionStart", ctx) : "";
|
|
1783
|
+
}
|
|
1784
|
+
//#endregion
|
|
1785
|
+
//#region src/runtime/lifecycle/lessons/state.ts
|
|
1786
|
+
/**
|
|
1787
|
+
* Per-project lessons paths. The `fuse-lessons` plugin stores its lessons under
|
|
1788
|
+
* `<root>/MEMORY/` (NOT the harness `.harness/memory/`), so these two path
|
|
1789
|
+
* helpers override the layout while ALL state/gitignore/throttle logic is
|
|
1790
|
+
* reused from `src/memory` (`setStateField`, `ensureMemoryGitignore`,
|
|
1791
|
+
* `readState`, `nowStamp`, `throttleMs`).
|
|
1792
|
+
*/
|
|
1793
|
+
/** Absolute `<root>/MEMORY/LESSON.md` — the curated, committable lessons file. */
|
|
1794
|
+
function lessonsFileFor(root) {
|
|
1795
|
+
return join(root, "MEMORY", "LESSON.md");
|
|
1796
|
+
}
|
|
1797
|
+
/** Absolute `<root>/MEMORY/state.json` — machine-local throttle counter. */
|
|
1798
|
+
function lessonsStateFileFor(root) {
|
|
1799
|
+
return join(root, "MEMORY", "state.json");
|
|
1800
|
+
}
|
|
1801
|
+
//#endregion
|
|
1802
|
+
//#region src/runtime/lifecycle/lessons/dispatch.ts
|
|
1803
|
+
/**
|
|
1804
|
+
* fuse-lessons scope dispatch (TS port of the 4 handler scripts). Routes by
|
|
1805
|
+
* event: SessionStart/SubagentStart inject `MEMORY/LESSON.md`; Stop reminds
|
|
1806
|
+
* across every project with unsaved code edits; PostToolUse marks the write to
|
|
1807
|
+
* arm/silence the per-project throttle. Non-fatal by design.
|
|
1808
|
+
*/
|
|
1809
|
+
/** Inject `<root>/MEMORY/LESSON.md` as additionalContext for `event`. */
|
|
1810
|
+
function injectMemory(cwd, event) {
|
|
1811
|
+
const file = lessonsFileFor(projectRoot(cwd));
|
|
1812
|
+
if (!existsSync(file)) return "";
|
|
1813
|
+
let content = "";
|
|
1814
|
+
try {
|
|
1815
|
+
content = readFileSync(file, "utf-8").trim();
|
|
1816
|
+
} catch {
|
|
1817
|
+
return "";
|
|
1818
|
+
}
|
|
1819
|
+
if (!content) return "";
|
|
1820
|
+
return contextResponse(event, `Project lessons — never reproduce these:\n${content}\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.`);
|
|
1821
|
+
}
|
|
1822
|
+
/** Select roots with unsaved code edits past the throttle, bumping their state. */
|
|
1823
|
+
function collectPending(now, window) {
|
|
1824
|
+
const pending = [];
|
|
1825
|
+
for (const root of readRoots()) {
|
|
1826
|
+
const stateFile = lessonsStateFileFor(root);
|
|
1827
|
+
const { lastRemindedAt, lastCodeEditAt } = readState(stateFile);
|
|
1828
|
+
if (lastCodeEditAt <= lastRemindedAt) continue;
|
|
1829
|
+
if (now - lastRemindedAt < window) continue;
|
|
1830
|
+
pending.push(root);
|
|
1831
|
+
setStateField(stateFile, "lastRemindedAt", now);
|
|
1832
|
+
}
|
|
1833
|
+
return pending;
|
|
1834
|
+
}
|
|
1835
|
+
/** Stop: emit one reminder covering every project with pending lessons. */
|
|
1836
|
+
function remindWrite(now) {
|
|
1837
|
+
const pending = collectPending(now, throttleMs());
|
|
1838
|
+
if (pending.length === 0) return "";
|
|
1839
|
+
return contextResponse("Stop", `Before ending: if this session hit a mistake/blocker worth never reproducing, append 1-3 COMPACT bullets OR sharpen/merge existing ones (format \`- [${nowStamp()}] what went wrong → do instead\`, use exactly this timestamp) in each project's lessons file below. Skip if nothing notable.\n${pending.map((r) => `- ${r}/MEMORY/LESSON.md`).join("\n")}`);
|
|
1840
|
+
}
|
|
1841
|
+
/** PostToolUse: record the relevant throttle timestamp for the edited file. */
|
|
1842
|
+
function markWrite(payload, now) {
|
|
1843
|
+
const input = payload.tool_input;
|
|
1844
|
+
if (!input?.file_path) return;
|
|
1845
|
+
const abs = resolve(input.file_path);
|
|
1846
|
+
const root = projectRootOrNull(dirname(abs));
|
|
1847
|
+
if (!root) return;
|
|
1848
|
+
const stateFile = lessonsStateFileFor(root);
|
|
1849
|
+
if (abs === resolve(root, "MEMORY", "LESSON.md")) setStateField(stateFile, "lastRemindedAt", now);
|
|
1850
|
+
else if (isCodeFile(abs)) {
|
|
1851
|
+
setStateField(stateFile, "lastCodeEditAt", now);
|
|
1852
|
+
addRoot(root);
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
/**
|
|
1856
|
+
* Route a fuse-lessons event to its handler. Returns the native stdout for
|
|
1857
|
+
* context-injecting events (SessionStart/SubagentStart/Stop) or "" for the
|
|
1858
|
+
* side-effect-only PostToolUse mark.
|
|
1859
|
+
* @param event - The raw hook event name.
|
|
1860
|
+
* @param payload - The raw hook payload.
|
|
1861
|
+
* @param cwd - Project root for memory injection.
|
|
1862
|
+
* @param now - Clock.
|
|
1863
|
+
* @returns The native stdout (possibly empty).
|
|
1864
|
+
*/
|
|
1865
|
+
function dispatchLessons(event, payload, cwd, now) {
|
|
1866
|
+
switch (event) {
|
|
1867
|
+
case "SessionStart":
|
|
1868
|
+
case "SubagentStart": return injectMemory(cwd, event);
|
|
1869
|
+
case "Stop": return remindWrite(now);
|
|
1870
|
+
case "PostToolUse":
|
|
1871
|
+
markWrite(payload, now);
|
|
1872
|
+
return "";
|
|
1873
|
+
default: return "";
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
//#endregion
|
|
1877
|
+
//#region src/runtime/lifecycle/aipilot/inject-apex.ts
|
|
1878
|
+
/**
|
|
1879
|
+
* SubagentStart (matcher "") for the ai-pilot scope: inject APEX AGENTS.md +
|
|
1880
|
+
* task context + cartographer paths into every sub-agent. Ports
|
|
1881
|
+
* `inject-subagent-context.ts`. The core-scope SubagentStart already surfaces
|
|
1882
|
+
* the MCP cache table (`subagent-cache.ts`) in a separate hook entry — this
|
|
1883
|
+
* handler only emits the APEX/cartographer block, so there is no double-emit.
|
|
1884
|
+
*/
|
|
1885
|
+
/** Last 3 completed task subjects (newest first), or "none". */
|
|
1886
|
+
function completedTasks(tasks) {
|
|
1887
|
+
const done = Object.entries(tasks).filter(([, t]) => t.status === "completed").sort((a, b) => (b[1].completed_at ?? "").localeCompare(a[1].completed_at ?? "")).slice(0, 3).map(([id, t]) => `#${id}: ${t.subject}`);
|
|
1888
|
+
return done.length > 0 ? done.join(", ") : "none";
|
|
1889
|
+
}
|
|
1890
|
+
/** Pending task subjects, or "none". */
|
|
1891
|
+
function pendingTasks(tasks) {
|
|
1892
|
+
const pending = Object.entries(tasks).filter(([, t]) => t.status === "pending").map(([id, t]) => `#${id}: ${t.subject}`);
|
|
1893
|
+
return pending.length > 0 ? pending.join(", ") : "none";
|
|
1894
|
+
}
|
|
1895
|
+
/** Cartographer navigation block, or "" when no plugin map is present. */
|
|
1896
|
+
function cartographerContext() {
|
|
1897
|
+
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
|
1898
|
+
if (!pluginRoot) return "";
|
|
1899
|
+
const pluginsMap = resolve(pluginRoot, "..", ".cartographer", "index.md");
|
|
1900
|
+
if (!existsSync(pluginsMap)) return "";
|
|
1901
|
+
return `\n### 7. Cartographer Maps\nNavigate branches (index.md) -> leaves link to real files:\n- Plugin skills: ${pluginsMap}\n- Project files: .cartographer/project/index.md`;
|
|
1902
|
+
}
|
|
1903
|
+
/**
|
|
1904
|
+
* Build the APEX sub-agent injection for SubagentStart, or "" when the project
|
|
1905
|
+
* has no `.claude/apex/` dir. Reads AGENTS.md (first 4KB) + task.json.
|
|
1906
|
+
* @param cwd - Fallback project root when `CLAUDE_PROJECT_DIR` is unset.
|
|
1907
|
+
* @param home - Home dir (unused placeholder; kept for symmetry/testing).
|
|
1908
|
+
* @returns The native hook stdout (possibly empty).
|
|
1909
|
+
*/
|
|
1910
|
+
async function injectApexSubagentContext(cwd, home = homedir()) {
|
|
1911
|
+
const apexDir = join(process.env.CLAUDE_PROJECT_DIR ?? cwd, ".claude", "apex");
|
|
1912
|
+
if (!existsSync(apexDir)) return "";
|
|
1913
|
+
const agentsFile = Bun.file(join(apexDir, "AGENTS.md"));
|
|
1914
|
+
const agents = await agentsFile.exists() ? (await agentsFile.text()).slice(0, 4e3) : "";
|
|
1915
|
+
const taskData = await readJsonFile(join(apexDir, "task.json"));
|
|
1916
|
+
return contextResponse("SubagentStart", `## APEX Sub-Agent Instructions
|
|
1917
|
+
|
|
1918
|
+
You are a sub-agent in APEX workflow. Follow these rules:
|
|
1919
|
+
|
|
1920
|
+
### 1. AGENTS.md Rules
|
|
1921
|
+
${agents}
|
|
1922
|
+
|
|
1923
|
+
### 2. Task Context
|
|
1924
|
+
- Last completed: ${taskData ? completedTasks(taskData.tasks) : "none"}
|
|
1925
|
+
- Pending: ${taskData ? pendingTasks(taskData.tasks) : "none"}
|
|
1926
|
+
|
|
1927
|
+
### 3. Before Starting Work
|
|
1928
|
+
- Use TaskUpdate(taskId, status: in_progress) before starting
|
|
1929
|
+
|
|
1930
|
+
### 4. SOLID Rules
|
|
1931
|
+
- Files < ${resolveMaxLines()} lines | Interfaces in src/interfaces/ | JSDoc/PHPDoc required
|
|
1932
|
+
|
|
1933
|
+
### 5. Research Before Code
|
|
1934
|
+
- Use Context7/Exa for docs | Write notes to .claude/apex/docs/
|
|
1935
|
+
|
|
1936
|
+
### 6. When Done
|
|
1937
|
+
- TaskUpdate(taskId, status: completed) triggers auto-commit${cartographerContext()}`);
|
|
1938
|
+
}
|
|
1939
|
+
//#endregion
|
|
1940
|
+
//#region src/runtime/lifecycle/aipilot/cache-base.ts
|
|
1941
|
+
/**
|
|
1942
|
+
* Shared cache-path + age helpers for the ai-pilot scope.
|
|
1943
|
+
* Reuses the harness home-state (`fusengineCache`) so the ai-pilot caches live
|
|
1944
|
+
* under the same `~/.claude/fusengine-cache` tree as the core MCP cache — no
|
|
1945
|
+
* second cache layer.
|
|
1946
|
+
*/
|
|
1947
|
+
/** 16-char hex SHA-256 of `text` (project hash / doc topic key). */
|
|
1948
|
+
function hashText16(text) {
|
|
1949
|
+
return createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
1950
|
+
}
|
|
1951
|
+
/** 16-char project hash from its absolute path. */
|
|
1952
|
+
function projectHash(projectPath) {
|
|
1953
|
+
return hashText16(projectPath);
|
|
1954
|
+
}
|
|
1955
|
+
/** `~/.claude/fusengine-cache` base dir (shared with the core MCP cache). */
|
|
1956
|
+
function cacheBaseDir(home = homedir()) {
|
|
1957
|
+
return fusengineCache(home);
|
|
1958
|
+
}
|
|
1959
|
+
/** Per-type, per-project cache dir: `fusengine-cache/<type>/<projectHash>`. */
|
|
1960
|
+
function cacheDirFor(type, projectPath, home = homedir()) {
|
|
1961
|
+
return join(cacheBaseDir(home), type, projectHash(projectPath));
|
|
1962
|
+
}
|
|
1963
|
+
/** Age in seconds from an ISO timestamp (now - ts). */
|
|
1964
|
+
function cacheAge(ts, now = Date.now()) {
|
|
1965
|
+
return Math.floor((now - new Date(ts).getTime()) / 1e3);
|
|
1966
|
+
}
|
|
1967
|
+
/** Full SHA-256 hex checksum of a file's text; "" when unreadable. */
|
|
1968
|
+
async function fileChecksum(path) {
|
|
1969
|
+
try {
|
|
1970
|
+
return createHash("sha256").update(await Bun.file(path).text()).digest("hex");
|
|
1971
|
+
} catch {
|
|
1972
|
+
return "";
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
//#endregion
|
|
1976
|
+
//#region src/runtime/lifecycle/aipilot/analytics.ts
|
|
1977
|
+
/**
|
|
1978
|
+
* Cache analytics for the ai-pilot scope: append hit/miss events to
|
|
1979
|
+
* `sessions.jsonl` and aggregate them into `summary.json` on SessionEnd.
|
|
1980
|
+
* Ports `cache-analytics-save.ts` + the `logCacheEvent` helper.
|
|
1981
|
+
*/
|
|
1982
|
+
const TOKEN_WEIGHTS = {
|
|
1983
|
+
explore: 15e3,
|
|
1984
|
+
doc: 1e4,
|
|
1985
|
+
lessons: 3e3,
|
|
1986
|
+
tests: 5e3
|
|
1987
|
+
};
|
|
1988
|
+
const CATEGORIES = [
|
|
1989
|
+
"explore",
|
|
1990
|
+
"doc",
|
|
1991
|
+
"lessons",
|
|
1992
|
+
"tests"
|
|
1993
|
+
];
|
|
1994
|
+
/** Append a single cache event to `analytics/sessions.jsonl` (best effort). */
|
|
1995
|
+
function logCacheEvent(type, action, projHash, extra = {}, home = homedir()) {
|
|
1996
|
+
try {
|
|
1997
|
+
const dir = join(cacheBaseDir(home), "analytics");
|
|
1998
|
+
mkdirSync(dir, { recursive: true });
|
|
1999
|
+
const entry = {
|
|
2000
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2001
|
+
session: String(Math.floor(Date.now() / 1e3)),
|
|
2002
|
+
type,
|
|
2003
|
+
action,
|
|
2004
|
+
project_hash: projHash,
|
|
2005
|
+
...extra
|
|
2006
|
+
};
|
|
2007
|
+
appendFileSync(join(dir, "sessions.jsonl"), JSON.stringify(entry) + "\n");
|
|
2008
|
+
} catch {}
|
|
2009
|
+
}
|
|
2010
|
+
/** Count entries matching a `type`+`action`. */
|
|
2011
|
+
function countBy(entries, type, action) {
|
|
2012
|
+
return entries.filter((e) => e.type === type && e.action === action).length;
|
|
2013
|
+
}
|
|
2014
|
+
/** Format a hit-rate percentage string. */
|
|
2015
|
+
function hitRate(hits, misses) {
|
|
2016
|
+
const total = hits + misses;
|
|
2017
|
+
return total === 0 ? "0%" : `${Math.floor(hits * 100 / total)}%`;
|
|
2018
|
+
}
|
|
2019
|
+
/** Parse the JSONL session log into typed entries. */
|
|
2020
|
+
function parseEntries(raw) {
|
|
2021
|
+
return raw.split("\n").filter(Boolean).map((line) => {
|
|
2022
|
+
try {
|
|
2023
|
+
return JSON.parse(line);
|
|
2024
|
+
} catch {
|
|
2025
|
+
return null;
|
|
2026
|
+
}
|
|
2027
|
+
}).filter((e) => e !== null);
|
|
2028
|
+
}
|
|
2029
|
+
/**
|
|
2030
|
+
* Aggregate `sessions.jsonl` into `summary.json` and prune entries > 30 days.
|
|
2031
|
+
* Ports `cache-analytics-save.ts`. SessionEnd hook output is ignored by Claude,
|
|
2032
|
+
* so this returns nothing — it is a pure side-effect.
|
|
2033
|
+
* @param home - Home dir (defaults to `~`).
|
|
2034
|
+
* @param now - Clock (defaults to `Date.now()`).
|
|
2035
|
+
*/
|
|
2036
|
+
async function cacheAnalyticsSave(home = homedir(), now = Date.now()) {
|
|
2037
|
+
const dir = join(cacheBaseDir(home), "analytics");
|
|
2038
|
+
const sessionsFile = join(dir, "sessions.jsonl");
|
|
2039
|
+
const file = Bun.file(sessionsFile);
|
|
2040
|
+
if (!await file.exists()) return;
|
|
2041
|
+
const raw = await file.text();
|
|
2042
|
+
if (!raw.trim()) return;
|
|
2043
|
+
const entries = parseEntries(raw);
|
|
2044
|
+
if (entries.length === 0) return;
|
|
2045
|
+
const hits = {};
|
|
2046
|
+
const misses = {};
|
|
2047
|
+
for (const cat of CATEGORIES) {
|
|
2048
|
+
hits[cat] = countBy(entries, cat, "hit") + (cat === "doc" ? countBy(entries, cat, "blocked") : 0);
|
|
2049
|
+
misses[cat] = countBy(entries, cat, "miss");
|
|
2050
|
+
}
|
|
2051
|
+
const tokensSaved = CATEGORIES.reduce((sum, c) => sum + (hits[c] ?? 0) * (TOKEN_WEIGHTS[c] ?? 0), 0);
|
|
2052
|
+
const sessionCount = new Set(entries.map((e) => e.session).filter(Boolean)).size;
|
|
2053
|
+
const old = await readJsonFile(join(dir, "summary.json"));
|
|
2054
|
+
const merged = {
|
|
2055
|
+
updated: new Date(now).toISOString(),
|
|
2056
|
+
total_sessions: (old?.total_sessions ?? 0) + sessionCount,
|
|
2057
|
+
cache_hits: {},
|
|
2058
|
+
cache_misses: {},
|
|
2059
|
+
hit_rates: {},
|
|
2060
|
+
estimated_tokens_saved: (old?.estimated_tokens_saved ?? 0) + tokensSaved
|
|
2061
|
+
};
|
|
2062
|
+
for (const cat of CATEGORIES) {
|
|
2063
|
+
merged.cache_hits[cat] = (old?.cache_hits?.[cat] ?? 0) + (hits[cat] ?? 0);
|
|
2064
|
+
merged.cache_misses[cat] = (old?.cache_misses?.[cat] ?? 0) + (misses[cat] ?? 0);
|
|
2065
|
+
merged.hit_rates[cat] = hitRate(merged.cache_hits[cat] ?? 0, merged.cache_misses[cat] ?? 0);
|
|
2066
|
+
}
|
|
2067
|
+
await writeJsonFile(join(dir, "summary.json"), merged, true);
|
|
2068
|
+
const cutoff = (/* @__PURE__ */ new Date(now - 30 * 864e5)).toISOString();
|
|
2069
|
+
const kept = entries.filter((e) => e.ts >= cutoff);
|
|
2070
|
+
await Bun.write(sessionsFile, kept.map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
2071
|
+
}
|
|
2072
|
+
//#endregion
|
|
2073
|
+
//#region src/runtime/lifecycle/aipilot/inject-explore.ts
|
|
2074
|
+
/**
|
|
2075
|
+
* SubagentStart (matcher "explore-codebase") for the ai-pilot scope: serve a
|
|
2076
|
+
* cached architecture report when fresh + config-matching, else inject save
|
|
2077
|
+
* instructions. Ports `explore-cache-check.ts`.
|
|
2078
|
+
*/
|
|
2079
|
+
const TTL_SECONDS$2 = 86400;
|
|
2080
|
+
const CONFIG_FILES = [
|
|
2081
|
+
"package.json",
|
|
2082
|
+
"tsconfig.json",
|
|
2083
|
+
"composer.json",
|
|
2084
|
+
"go.mod",
|
|
2085
|
+
"Cargo.toml",
|
|
2086
|
+
"Package.swift",
|
|
2087
|
+
"biome.json",
|
|
2088
|
+
".eslintrc.js",
|
|
2089
|
+
".eslintrc.json"
|
|
2090
|
+
];
|
|
2091
|
+
/** Compute a config hash from git-tracked config files; "noconfig" on failure. */
|
|
2092
|
+
async function configHash(cwd) {
|
|
2093
|
+
try {
|
|
2094
|
+
const proc = Bun.spawn([
|
|
2095
|
+
"git",
|
|
2096
|
+
"ls-tree",
|
|
2097
|
+
"HEAD",
|
|
2098
|
+
...CONFIG_FILES
|
|
2099
|
+
], {
|
|
2100
|
+
cwd,
|
|
2101
|
+
stdout: "pipe",
|
|
2102
|
+
stderr: "ignore"
|
|
2103
|
+
});
|
|
2104
|
+
const output = await new Response(proc.stdout).text();
|
|
2105
|
+
return output.trim() ? hashText16(output) : "noconfig";
|
|
2106
|
+
} catch {
|
|
2107
|
+
return "noconfig";
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
/** Build the cache-miss save-instructions block. */
|
|
2111
|
+
function missBlock(cacheDir, metaFile, snapFile, ts, cfgHash, projPath) {
|
|
2112
|
+
return `## EXPLORATION CACHE INSTRUCTIONS\nAfter completing your exploration, save the report for future runs:\n\`\`\`bash\nmkdir -p ${cacheDir}\ncat > ${metaFile} << 'METAEOF'\n{"timestamp":"${ts}","config_hash":"${cfgHash}","project":"${projPath}"}\nMETAEOF\n\`\`\`\nThen write your full exploration report (markdown) to: ${snapFile}`;
|
|
2113
|
+
}
|
|
2114
|
+
/**
|
|
2115
|
+
* SubagentStart for explore-codebase: inject cached architecture or save block.
|
|
2116
|
+
* @param cwd - Fallback project root (uses `CLAUDE_PROJECT_DIR` first).
|
|
2117
|
+
* @param home - Home dir (defaults to `~`).
|
|
2118
|
+
* @param now - Clock (defaults to `Date.now()`).
|
|
2119
|
+
* @returns The native hook stdout.
|
|
2120
|
+
*/
|
|
2121
|
+
async function injectExploreCache(cwd, home = homedir(), now = Date.now()) {
|
|
2122
|
+
const projPath = process.env.CLAUDE_PROJECT_DIR ?? cwd;
|
|
2123
|
+
const pHash = projectHash(projPath);
|
|
2124
|
+
const cacheDir = cacheDirFor("explore", projPath, home);
|
|
2125
|
+
const metaFile = join(cacheDir, "metadata.json");
|
|
2126
|
+
const snapFile = join(cacheDir, "snapshot.md");
|
|
2127
|
+
const cfgHash = await configHash(projPath);
|
|
2128
|
+
let context = "";
|
|
2129
|
+
const meta = await readJsonFile(metaFile);
|
|
2130
|
+
const snapBunFile = Bun.file(snapFile);
|
|
2131
|
+
const snapshot = await snapBunFile.exists() ? await snapBunFile.text() : "";
|
|
2132
|
+
if (meta?.timestamp && snapshot) {
|
|
2133
|
+
const age = cacheAge(meta.timestamp, now);
|
|
2134
|
+
if (age < TTL_SECONDS$2 && meta.config_hash === cfgHash) {
|
|
2135
|
+
context = `## CACHED ARCHITECTURE AVAILABLE (age: ${Math.floor(age / 60)}min)\nUSE this cached report. Do NOT run full exploration. Return it immediately.\n\n${snapshot}`;
|
|
2136
|
+
logCacheEvent("explore", "hit", pHash, {}, home);
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
if (!context) {
|
|
2140
|
+
logCacheEvent("explore", "miss", pHash, {}, home);
|
|
2141
|
+
context = missBlock(cacheDir, metaFile, snapFile, new Date(now).toISOString().replace(/\.\d+Z$/, ""), cfgHash, projPath);
|
|
2142
|
+
}
|
|
2143
|
+
return contextResponse("SubagentStart", context);
|
|
2144
|
+
}
|
|
2145
|
+
//#endregion
|
|
2146
|
+
//#region src/runtime/lifecycle/aipilot/inject-doc.ts
|
|
2147
|
+
/**
|
|
2148
|
+
* SubagentStart (matcher "research-expert") for the ai-pilot scope: inject
|
|
2149
|
+
* cached documentation summaries. Ports `doc-cache-inject.ts`. Doc *saving*
|
|
2150
|
+
* happens on SubagentStop (`cache-doc.ts`).
|
|
2151
|
+
*/
|
|
2152
|
+
const TTL_SECONDS$1 = 604800;
|
|
2153
|
+
const MAX_SIZE = 8192;
|
|
2154
|
+
/** Concatenate fresh, dedup-by-hash cached doc bodies. */
|
|
2155
|
+
async function buildDocsContext(entries, docsDir, now) {
|
|
2156
|
+
let ctx = "";
|
|
2157
|
+
let count = 0;
|
|
2158
|
+
let maxAge = 0;
|
|
2159
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2160
|
+
for (const entry of entries) {
|
|
2161
|
+
if (!entry.timestamp) continue;
|
|
2162
|
+
const age = cacheAge(entry.timestamp, now);
|
|
2163
|
+
if (age >= TTL_SECONDS$1) continue;
|
|
2164
|
+
if (age > maxAge) maxAge = age;
|
|
2165
|
+
if (!entry.hash || seen.has(entry.hash)) continue;
|
|
2166
|
+
seen.add(entry.hash);
|
|
2167
|
+
const file = Bun.file(join(docsDir, `${entry.hash}.md`));
|
|
2168
|
+
if (!await file.exists()) continue;
|
|
2169
|
+
const content = await file.text();
|
|
2170
|
+
if (!content) continue;
|
|
2171
|
+
ctx += `\n${content}\n`;
|
|
2172
|
+
count++;
|
|
2173
|
+
}
|
|
2174
|
+
return {
|
|
2175
|
+
ctx,
|
|
2176
|
+
count,
|
|
2177
|
+
maxAge
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
2180
|
+
/**
|
|
2181
|
+
* SubagentStart for research-expert: inject cached doc summaries, or "".
|
|
2182
|
+
* @param cwd - Fallback project root (uses `CLAUDE_PROJECT_DIR` first).
|
|
2183
|
+
* @param home - Home dir (defaults to `~`).
|
|
2184
|
+
* @param now - Clock (defaults to `Date.now()`).
|
|
2185
|
+
* @returns The native hook stdout (possibly empty).
|
|
2186
|
+
*/
|
|
2187
|
+
async function injectDocCache(cwd, home = homedir(), now = Date.now()) {
|
|
2188
|
+
const projPath = process.env.CLAUDE_PROJECT_DIR ?? cwd;
|
|
2189
|
+
const pHash = projectHash(projPath);
|
|
2190
|
+
const cacheDir = cacheDirFor("doc", projPath, home);
|
|
2191
|
+
const index = await readJsonFile(join(cacheDir, "index.json"));
|
|
2192
|
+
if (!index?.docs?.length) return "";
|
|
2193
|
+
const { ctx, count, maxAge } = await buildDocsContext(index.docs, join(cacheDir, "docs"), now);
|
|
2194
|
+
if (count === 0) return "";
|
|
2195
|
+
logCacheEvent("doc", "hit", pHash, { docs_injected: count }, home);
|
|
2196
|
+
return contextResponse("SubagentStart", `${`## CACHED DOCUMENTATION (${count} docs, ${Math.ceil(maxAge / 3600)}h ago)\nUse this knowledge. Only query Context7 for topics NOT covered below.\n`}${ctx}Full docs: ${join(cacheDir, "docs")}/`.slice(0, MAX_SIZE));
|
|
2197
|
+
}
|
|
2198
|
+
//#endregion
|
|
2199
|
+
//#region src/runtime/lifecycle/aipilot/source-scan.ts
|
|
2200
|
+
/**
|
|
2201
|
+
* Source-file scanning + project-stack detection for the ai-pilot scope.
|
|
2202
|
+
* Ported from the ai-pilot plugin's `cache/source-collector.ts` +
|
|
2203
|
+
* the stack detection in `cache/lesson-helpers.ts` (now removed).
|
|
2204
|
+
*/
|
|
2205
|
+
/** Source file glob patterns (monorepo-aware; separate to avoid brace-wildcards). */
|
|
2206
|
+
const SRC_PATTERNS = [
|
|
2207
|
+
"src/**/*.{ts,tsx,js,jsx}",
|
|
2208
|
+
"app/**/*.{ts,tsx,js,jsx}",
|
|
2209
|
+
"apps/*/src/**/*.{ts,tsx,js,jsx}",
|
|
2210
|
+
"packages/*/src/**/*.{ts,tsx,js,jsx}"
|
|
2211
|
+
];
|
|
2212
|
+
/**
|
|
2213
|
+
* Scan source files in `projectPath` (monorepo-aware), capped at `maxFiles`.
|
|
2214
|
+
* @param projectPath - Absolute project root.
|
|
2215
|
+
* @param maxFiles - Max files to collect (default 200).
|
|
2216
|
+
* @returns Absolute paths matching the source patterns.
|
|
2217
|
+
*/
|
|
2218
|
+
async function scanSourceFiles(projectPath, maxFiles = 200) {
|
|
2219
|
+
const files = [];
|
|
2220
|
+
for (const pattern of SRC_PATTERNS) {
|
|
2221
|
+
try {
|
|
2222
|
+
for await (const p of new Glob(pattern).scan({
|
|
2223
|
+
cwd: projectPath,
|
|
2224
|
+
absolute: true
|
|
2225
|
+
})) {
|
|
2226
|
+
if (p.includes("node_modules")) continue;
|
|
2227
|
+
files.push(p);
|
|
2228
|
+
if (files.length >= maxFiles) break;
|
|
2229
|
+
}
|
|
2230
|
+
} catch {}
|
|
2231
|
+
if (files.length >= maxFiles) break;
|
|
2232
|
+
}
|
|
2233
|
+
return files;
|
|
2234
|
+
}
|
|
2235
|
+
/** Detect the project stack from config files in the project root. */
|
|
2236
|
+
function detectStack(projectPath) {
|
|
2237
|
+
try {
|
|
2238
|
+
const entries = readdirSync(projectPath);
|
|
2239
|
+
if (entries.some((f) => f.startsWith("next.config"))) return "nextjs";
|
|
2240
|
+
if (entries.includes("composer.json")) return "laravel";
|
|
2241
|
+
if (entries.some((f) => f.endsWith(".xcodeproj")) || entries.includes("Package.swift")) return "swift";
|
|
2242
|
+
if (entries.some((f) => f.startsWith("tailwind.config"))) return "tailwindcss";
|
|
2243
|
+
} catch {}
|
|
2244
|
+
return "universal";
|
|
2245
|
+
}
|
|
2246
|
+
//#endregion
|
|
2247
|
+
//#region src/runtime/lifecycle/aipilot/lessons.ts
|
|
2248
|
+
/**
|
|
2249
|
+
* Lesson aggregation, dedup, merge, and edit categorization for the ai-pilot
|
|
2250
|
+
* scope. Ported from the ai-pilot plugin's `cache/lesson-aggregator.ts` +
|
|
2251
|
+
* `cache/lesson-helpers.ts` (now removed).
|
|
2252
|
+
*/
|
|
2253
|
+
/** Deduplicate lessons by `error_type:pattern`, summing counts. */
|
|
2254
|
+
function dedupLessons(lessons) {
|
|
2255
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2256
|
+
for (const l of lessons) {
|
|
2257
|
+
const key = `${l.error_type}:${l.pattern}`;
|
|
2258
|
+
const group = groups.get(key);
|
|
2259
|
+
if (group) group.push(l);
|
|
2260
|
+
else groups.set(key, [l]);
|
|
2261
|
+
}
|
|
2262
|
+
return [...groups.values()].map((entries) => {
|
|
2263
|
+
const first = entries[0];
|
|
2264
|
+
return {
|
|
2265
|
+
error_type: first.error_type,
|
|
2266
|
+
pattern: first.pattern,
|
|
2267
|
+
fix: first.fix,
|
|
2268
|
+
last_seen: first.last_seen,
|
|
2269
|
+
count: entries.reduce((s, e) => s + e.count, 0),
|
|
2270
|
+
files: [...new Set(entries.flatMap((e) => e.files))],
|
|
2271
|
+
code: { line: [...new Set(entries.flatMap((e) => e.code?.line ?? []))].slice(0, 5) }
|
|
2272
|
+
};
|
|
2273
|
+
}).sort((a, b) => b.count - a.count);
|
|
2274
|
+
}
|
|
2275
|
+
/** Aggregate all local lesson JSON files into a flat deduplicated list. */
|
|
2276
|
+
async function aggregateLocalLessons(cacheDir) {
|
|
2277
|
+
if (!existsSync(cacheDir)) return [];
|
|
2278
|
+
const files = readdirSync(cacheDir).filter((f) => f.endsWith(".json"));
|
|
2279
|
+
const all = [];
|
|
2280
|
+
for (const f of files) {
|
|
2281
|
+
const data = await readJsonFile(join(cacheDir, f));
|
|
2282
|
+
if (data?.errors) all.push(...data.errors);
|
|
2283
|
+
}
|
|
2284
|
+
return dedupLessons(all);
|
|
2285
|
+
}
|
|
2286
|
+
/** Load global lessons for a stack (stack-specific + universal). */
|
|
2287
|
+
async function loadGlobalLessons(stack, home = homedir()) {
|
|
2288
|
+
const globalDir = join(cacheBaseDir(home), "lessons", "_global");
|
|
2289
|
+
const result = [];
|
|
2290
|
+
for (const name of [`${stack}.json`, "universal.json"]) {
|
|
2291
|
+
const data = await readJsonFile(join(globalDir, name));
|
|
2292
|
+
if (data) result.push(...data);
|
|
2293
|
+
}
|
|
2294
|
+
return result;
|
|
2295
|
+
}
|
|
2296
|
+
/** Merge local + global lessons, dedup by type+pattern, sort by count desc. */
|
|
2297
|
+
function mergeLessons(local, global) {
|
|
2298
|
+
return dedupLessons([...local, ...global]);
|
|
2299
|
+
}
|
|
2300
|
+
/** Categorize an edit entry by analyzing the new code content. */
|
|
2301
|
+
function categorizeEdit(edit) {
|
|
2302
|
+
const n = edit.newStr.toLowerCase();
|
|
2303
|
+
if (n.includes("use client")) return "missing_directive";
|
|
2304
|
+
if (n.includes("displayname")) return "missing_display_name";
|
|
2305
|
+
if (/onkeydown|tabindex|role=/.test(n)) return "missing_a11y";
|
|
2306
|
+
if (/try|catch/.test(n)) return "missing_error_handling";
|
|
2307
|
+
if (/\?\?|if.*null/.test(n)) return "null_safety";
|
|
2308
|
+
return "code_fix";
|
|
2309
|
+
}
|
|
2310
|
+
//#endregion
|
|
2311
|
+
//#region src/runtime/lifecycle/aipilot/inject-lessons.ts
|
|
2312
|
+
/**
|
|
2313
|
+
* SubagentStart (matcher "") for the ai-pilot scope: inject cached lessons
|
|
2314
|
+
* (known project issues) into every agent. Ports `lessons-cache-inject.ts`.
|
|
2315
|
+
*/
|
|
2316
|
+
const MAX_AGE_MS = 30 * 864e5;
|
|
2317
|
+
const MAX_LESSONS = 10;
|
|
2318
|
+
/** Remove JSON files older than 30 days from `dir` (best effort). */
|
|
2319
|
+
function pruneOldFiles(dir, now) {
|
|
2320
|
+
try {
|
|
2321
|
+
const cutoff = now - MAX_AGE_MS;
|
|
2322
|
+
for (const f of readdirSync(dir).filter((n) => n.endsWith(".json"))) {
|
|
2323
|
+
const path = `${dir}/${f}`;
|
|
2324
|
+
try {
|
|
2325
|
+
if (statSync(path).mtimeMs < cutoff) unlinkSync(path);
|
|
2326
|
+
} catch {}
|
|
2327
|
+
}
|
|
2328
|
+
} catch {}
|
|
2329
|
+
}
|
|
2330
|
+
/** Render lessons as a numbered known-issues list. */
|
|
2331
|
+
function formatLessonList(lessons) {
|
|
2332
|
+
return lessons.map((l, i) => {
|
|
2333
|
+
const code = l.code?.line?.length ? `\n Code: ${l.code.line.join(" | ").slice(0, 200)}` : "";
|
|
2334
|
+
return `${i + 1}. [${l.count}x] | ${l.pattern ?? "unknown"} -> ${l.fix ?? "see docs"}${code}`;
|
|
2335
|
+
}).join("\n");
|
|
2336
|
+
}
|
|
2337
|
+
/**
|
|
2338
|
+
* SubagentStart lessons injection: aggregate local + global lessons, or "".
|
|
2339
|
+
* @param cwd - Fallback project root (uses `CLAUDE_PROJECT_DIR` first).
|
|
2340
|
+
* @param home - Home dir (defaults to `~`).
|
|
2341
|
+
* @param now - Clock (defaults to `Date.now()`).
|
|
2342
|
+
* @returns The native hook stdout (possibly empty).
|
|
2343
|
+
*/
|
|
2344
|
+
async function injectLessonsCache(cwd, home = homedir(), now = Date.now()) {
|
|
2345
|
+
const projectPath = process.env.CLAUDE_PROJECT_DIR ?? cwd;
|
|
2346
|
+
const pHash = projectHash(projectPath);
|
|
2347
|
+
const cacheDir = cacheDirFor("lessons", projectPath, home);
|
|
2348
|
+
const stack = detectStack(projectPath);
|
|
2349
|
+
pruneOldFiles(cacheDir, now);
|
|
2350
|
+
let localLessons = [];
|
|
2351
|
+
try {
|
|
2352
|
+
localLessons = await aggregateLocalLessons(cacheDir);
|
|
2353
|
+
} catch {}
|
|
2354
|
+
const globalLessons = await loadGlobalLessons(stack, home);
|
|
2355
|
+
if (localLessons.length === 0 && globalLessons.length === 0) return "";
|
|
2356
|
+
const merged = mergeLessons(localLessons, globalLessons).slice(0, MAX_LESSONS);
|
|
2357
|
+
if (merged.length === 0) return "";
|
|
2358
|
+
logCacheEvent("lessons", "hit", pHash, {
|
|
2359
|
+
count: merged.length,
|
|
2360
|
+
stack
|
|
2361
|
+
}, home);
|
|
2362
|
+
return contextResponse("SubagentStart", `## KNOWN PROJECT ISSUES (from previous sniper validations)\nThese errors have been found and fixed before. AVOID them:\n${formatLessonList(merged)}\n\nINSTRUCTION: Check your code against these known issues BEFORE submitting.`);
|
|
2363
|
+
}
|
|
2364
|
+
//#endregion
|
|
2365
|
+
//#region src/runtime/lifecycle/aipilot/inject-test.ts
|
|
2366
|
+
/**
|
|
2367
|
+
* SubagentStart (matcher "sniper") for the ai-pilot scope: tell sniper which
|
|
2368
|
+
* source files changed vs. already-validated. Ports `test-cache-inject.ts`.
|
|
2369
|
+
*/
|
|
2370
|
+
const TTL_SECONDS = 172800;
|
|
2371
|
+
/**
|
|
2372
|
+
* SubagentStart for sniper: inject the changed-file list, or "" when nothing
|
|
2373
|
+
* useful to say (no cache / no unchanged files).
|
|
2374
|
+
* @param cwd - Fallback project root (uses `CLAUDE_PROJECT_DIR` first).
|
|
2375
|
+
* @param home - Home dir (defaults to `~`).
|
|
2376
|
+
* @param now - Clock (defaults to `Date.now()`).
|
|
2377
|
+
* @returns The native hook stdout (possibly empty).
|
|
2378
|
+
*/
|
|
2379
|
+
async function injectTestCache(cwd, home = homedir(), now = Date.now()) {
|
|
2380
|
+
const projectPath = process.env.CLAUDE_PROJECT_DIR ?? cwd;
|
|
2381
|
+
const cache = await readJsonFile(join(cacheDirFor("tests", projectPath, home), "results.json"));
|
|
2382
|
+
if (!cache?.files || Object.keys(cache.files).length === 0) return "";
|
|
2383
|
+
const srcFiles = await scanSourceFiles(projectPath);
|
|
2384
|
+
if (srcFiles.length === 0) return "";
|
|
2385
|
+
const changed = [];
|
|
2386
|
+
let unchanged = 0;
|
|
2387
|
+
for (const filepath of srcFiles) {
|
|
2388
|
+
const relPath = filepath.replace(`${projectPath}/`, "");
|
|
2389
|
+
const cached = cache.files[relPath];
|
|
2390
|
+
if (!cached) {
|
|
2391
|
+
changed.push(relPath);
|
|
2392
|
+
continue;
|
|
2393
|
+
}
|
|
2394
|
+
if ((await fileChecksum(filepath)).slice(0, 16) !== cached.checksum) {
|
|
2395
|
+
changed.push(relPath);
|
|
2396
|
+
continue;
|
|
2397
|
+
}
|
|
2398
|
+
if (cached.last_tested && cacheAge(cached.last_tested, now) > TTL_SECONDS) {
|
|
2399
|
+
changed.push(relPath);
|
|
2400
|
+
continue;
|
|
2401
|
+
}
|
|
2402
|
+
unchanged++;
|
|
2403
|
+
}
|
|
2404
|
+
if (unchanged === 0) return "";
|
|
2405
|
+
const changedList = changed.map((f) => `- ${f}`).join("\n");
|
|
2406
|
+
return contextResponse("SubagentStart", `## TEST CACHE (${unchanged}/${srcFiles.length} files already validated)\nOnly run linters on these CHANGED files:\n${changedList}\nSKIP linting on ${unchanged} unchanged files - already PASS.`);
|
|
2407
|
+
}
|
|
2408
|
+
//#endregion
|
|
2409
|
+
//#region src/runtime/lifecycle/aipilot/transcript.ts
|
|
2410
|
+
/**
|
|
2411
|
+
* JSONL transcript parsing helpers for the ai-pilot scope: project-root
|
|
2412
|
+
* detection from file paths, file-path extraction, Edit extraction, and the
|
|
2413
|
+
* assistant report extraction. Ported into the harness from the ai-pilot
|
|
2414
|
+
* plugin's `cache/project-detect.ts` + `cache/lesson-helpers.ts` (now removed).
|
|
2415
|
+
*/
|
|
2416
|
+
const ROOT_MARKERS = [
|
|
2417
|
+
".git",
|
|
2418
|
+
".hg",
|
|
2419
|
+
"turbo.json",
|
|
2420
|
+
"nx.json",
|
|
2421
|
+
"lerna.json",
|
|
2422
|
+
"pnpm-workspace.yaml"
|
|
2423
|
+
];
|
|
2424
|
+
const PKG_MARKERS = [
|
|
2425
|
+
"package.json",
|
|
2426
|
+
"composer.json",
|
|
2427
|
+
"Package.swift",
|
|
2428
|
+
"Cargo.toml",
|
|
2429
|
+
"go.mod",
|
|
2430
|
+
"pyproject.toml",
|
|
2431
|
+
"Gemfile",
|
|
2432
|
+
"pom.xml"
|
|
2433
|
+
];
|
|
2434
|
+
/** Detect the project root by walking up from the first file path to a marker. */
|
|
2435
|
+
function projectRootFromPaths(filePaths) {
|
|
2436
|
+
const firstPath = filePaths[0];
|
|
2437
|
+
if (!firstPath) return null;
|
|
2438
|
+
const lastSlash = firstPath.lastIndexOf("/");
|
|
2439
|
+
if (lastSlash <= 0) return null;
|
|
2440
|
+
let dir = firstPath.substring(0, lastSlash);
|
|
2441
|
+
let bestRoot = null;
|
|
2442
|
+
while (dir && dir !== "/" && dir.length > 1) {
|
|
2443
|
+
try {
|
|
2444
|
+
const entries = readdirSync(dir);
|
|
2445
|
+
if (ROOT_MARKERS.some((m) => entries.includes(m))) return dir;
|
|
2446
|
+
if (PKG_MARKERS.some((m) => entries.includes(m))) bestRoot = dir;
|
|
2447
|
+
} catch {
|
|
2448
|
+
break;
|
|
2449
|
+
}
|
|
2450
|
+
const parentSlash = dir.lastIndexOf("/");
|
|
2451
|
+
if (parentSlash <= 0) break;
|
|
2452
|
+
dir = dir.substring(0, parentSlash);
|
|
2453
|
+
}
|
|
2454
|
+
return bestRoot;
|
|
2455
|
+
}
|
|
2456
|
+
/** Extract all absolute file paths from tool_use entries in a JSONL transcript. */
|
|
2457
|
+
async function transcriptFilePaths(transcriptPath) {
|
|
2458
|
+
const text = await Bun.file(transcriptPath).text();
|
|
2459
|
+
const paths = /* @__PURE__ */ new Set();
|
|
2460
|
+
for (const line of text.split("\n").filter(Boolean)) try {
|
|
2461
|
+
const content = JSON.parse(line)?.message?.content;
|
|
2462
|
+
if (!Array.isArray(content)) continue;
|
|
2463
|
+
for (const block of content) {
|
|
2464
|
+
if (block?.type !== "tool_use") continue;
|
|
2465
|
+
const fp = block.input?.file_path ?? block.input?.path ?? "";
|
|
2466
|
+
if (typeof fp === "string" && fp.startsWith("/")) paths.add(fp);
|
|
2467
|
+
}
|
|
2468
|
+
} catch {}
|
|
2469
|
+
return [...paths];
|
|
2470
|
+
}
|
|
2471
|
+
/** Extract deduplicated Edit tool_use entries (keyed by basename) from a transcript. */
|
|
2472
|
+
async function transcriptEdits(transcriptPath) {
|
|
2473
|
+
const text = await Bun.file(transcriptPath).text();
|
|
2474
|
+
const edits = [];
|
|
2475
|
+
for (const line of text.split("\n").filter(Boolean)) try {
|
|
2476
|
+
const content = JSON.parse(line)?.message?.content;
|
|
2477
|
+
if (!Array.isArray(content)) continue;
|
|
2478
|
+
for (const block of content) if (block?.type === "tool_use" && block.name === "Edit" && block.input?.file_path) edits.push({
|
|
2479
|
+
file: block.input.file_path,
|
|
2480
|
+
oldStr: block.input.old_string ?? "",
|
|
2481
|
+
newStr: block.input.new_string ?? ""
|
|
2482
|
+
});
|
|
2483
|
+
} catch {}
|
|
2484
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2485
|
+
for (const e of edits) seen.set(e.file.split("/").pop() ?? e.file, e);
|
|
2486
|
+
return [...seen.values()];
|
|
2487
|
+
}
|
|
2488
|
+
/** Extract the last assistant text report (first 500 lines) from a transcript. */
|
|
2489
|
+
async function transcriptReport(transcriptPath) {
|
|
2490
|
+
const text = await Bun.file(transcriptPath).text();
|
|
2491
|
+
let lastReport = "";
|
|
2492
|
+
for (const line of text.split("\n").filter(Boolean)) try {
|
|
2493
|
+
const entry = JSON.parse(line);
|
|
2494
|
+
if (entry?.message?.role !== "assistant") continue;
|
|
2495
|
+
for (const block of entry.message.content ?? []) if (block.type === "text" && block.text) lastReport = block.text;
|
|
2496
|
+
} catch {}
|
|
2497
|
+
return lastReport.split("\n").slice(0, 500).join("\n");
|
|
2498
|
+
}
|
|
2499
|
+
//#endregion
|
|
2500
|
+
//#region src/runtime/lifecycle/aipilot/cache-doc.ts
|
|
2501
|
+
/**
|
|
2502
|
+
* SubagentStop (matcher "research-expert") for the ai-pilot scope: extract the
|
|
2503
|
+
* agent's synthesis text from the transcript and cache it as documentation.
|
|
2504
|
+
* Ports `cache-doc-from-transcript.ts`. SubagentStop output here is a pure
|
|
2505
|
+
* side-effect (no stdout needed).
|
|
2506
|
+
*/
|
|
2507
|
+
const TOOL_PATTERN = /context7__query-docs|exa__get_code_context|exa__web_search/;
|
|
2508
|
+
const MAX_DOC_SIZE = 20480;
|
|
2509
|
+
const MIN_TEXT_SIZE = 200;
|
|
2510
|
+
const MAX_DOCS = 15;
|
|
2511
|
+
const RETRY_DELAYS = [
|
|
2512
|
+
500,
|
|
2513
|
+
1e3,
|
|
2514
|
+
2e3
|
|
2515
|
+
];
|
|
2516
|
+
/** Extract the longest assistant synthesis + queried library ids from a transcript. */
|
|
2517
|
+
async function extractSynthesis(path) {
|
|
2518
|
+
const lines = (await Bun.file(path).text()).split("\n").filter(Boolean);
|
|
2519
|
+
const libraries = [];
|
|
2520
|
+
let synthesis = "";
|
|
2521
|
+
for (const line of lines) try {
|
|
2522
|
+
const entry = JSON.parse(line);
|
|
2523
|
+
const role = entry?.type ?? entry?.role;
|
|
2524
|
+
const contents = entry?.message?.content;
|
|
2525
|
+
if (!Array.isArray(contents)) continue;
|
|
2526
|
+
for (const block of contents) {
|
|
2527
|
+
if (block.type === "tool_use" && TOOL_PATTERN.test(block.name ?? "")) {
|
|
2528
|
+
const lib = block.input?.libraryId ?? block.input?.query ?? "";
|
|
2529
|
+
if (lib && !libraries.includes(lib)) libraries.push(lib);
|
|
2530
|
+
}
|
|
2531
|
+
if (role === "assistant" && block.type === "text" && typeof block.text === "string" && block.text.length > synthesis.length) synthesis = block.text;
|
|
2532
|
+
}
|
|
2533
|
+
} catch {}
|
|
2534
|
+
return {
|
|
2535
|
+
text: synthesis,
|
|
2536
|
+
libraries
|
|
2537
|
+
};
|
|
2538
|
+
}
|
|
2539
|
+
/**
|
|
2540
|
+
* SubagentStop research-expert: cache the synthesis text from the transcript.
|
|
2541
|
+
* @param transcript - Path to the agent JSONL transcript.
|
|
2542
|
+
* @param cwd - Fallback project root.
|
|
2543
|
+
* @param home - Home dir (defaults to `~`).
|
|
2544
|
+
*/
|
|
2545
|
+
async function cacheDocFromTranscript(transcript, cwd, home = homedir()) {
|
|
2546
|
+
if (!transcript || !await Bun.file(transcript).exists()) return;
|
|
2547
|
+
const projPath = projectRootFromPaths(await transcriptFilePaths(transcript)) ?? process.env.CLAUDE_PROJECT_DIR ?? cwd;
|
|
2548
|
+
const cacheDir = cacheDirFor("doc", projPath, home);
|
|
2549
|
+
const docsDir = join(cacheDir, "docs");
|
|
2550
|
+
let result = await extractSynthesis(transcript);
|
|
2551
|
+
for (const delay of RETRY_DELAYS) {
|
|
2552
|
+
if (result.text.length >= MIN_TEXT_SIZE && result.libraries.length > 0) break;
|
|
2553
|
+
await Bun.sleep(delay);
|
|
2554
|
+
result = await extractSynthesis(transcript);
|
|
2555
|
+
}
|
|
2556
|
+
const { text, libraries } = result;
|
|
2557
|
+
if (text.length < MIN_TEXT_SIZE || libraries.length === 0) return;
|
|
2558
|
+
const indexFile = join(cacheDir, "index.json");
|
|
2559
|
+
const index = await readJsonFile(indexFile) ?? {
|
|
2560
|
+
project: projPath,
|
|
2561
|
+
docs: []
|
|
2562
|
+
};
|
|
2563
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
2564
|
+
const content = text.slice(0, MAX_DOC_SIZE);
|
|
2565
|
+
const topic = libraries.join(", ");
|
|
2566
|
+
const docHash = hashText16(topic);
|
|
2567
|
+
await Bun.write(join(docsDir, `${docHash}.md`), content);
|
|
2568
|
+
const sizeKb = Math.floor(content.length / 1024);
|
|
2569
|
+
for (const lib of libraries) {
|
|
2570
|
+
index.docs = index.docs.filter((d) => d.library !== lib);
|
|
2571
|
+
index.docs.push({
|
|
2572
|
+
hash: docHash,
|
|
2573
|
+
library: lib,
|
|
2574
|
+
topic,
|
|
2575
|
+
timestamp,
|
|
2576
|
+
size_kb: sizeKb
|
|
2577
|
+
});
|
|
2578
|
+
}
|
|
2579
|
+
if (index.docs.length > MAX_DOCS) index.docs = index.docs.slice(-15);
|
|
2580
|
+
await writeJsonFile(indexFile, index, true);
|
|
2581
|
+
}
|
|
2582
|
+
//#endregion
|
|
2583
|
+
//#region src/runtime/lifecycle/aipilot/cache-lessons.ts
|
|
2584
|
+
/**
|
|
2585
|
+
* SubagentStop (matcher "sniper") for the ai-pilot scope: capture error
|
|
2586
|
+
* patterns + corrected code from the sniper transcript as lessons. Ports
|
|
2587
|
+
* `cache-sniper-lessons.ts` (the dead `promote-global-lessons.ts` spawn is
|
|
2588
|
+
* dropped — that helper no longer exists). Pure side-effect.
|
|
2589
|
+
*/
|
|
2590
|
+
/**
|
|
2591
|
+
* SubagentStop sniper: extract lessons from the transcript and persist them.
|
|
2592
|
+
* @param transcript - Path to the agent JSONL transcript.
|
|
2593
|
+
* @param cwd - Fallback project root.
|
|
2594
|
+
* @param home - Home dir (defaults to `~`).
|
|
2595
|
+
*/
|
|
2596
|
+
async function cacheSniperLessons(transcript, cwd, home = homedir()) {
|
|
2597
|
+
if (!transcript || !await Bun.file(transcript).exists()) return;
|
|
2598
|
+
const edits = await transcriptEdits(transcript);
|
|
2599
|
+
if (edits.length === 0) return;
|
|
2600
|
+
const projectPath = projectRootFromPaths(edits.map((e) => e.file)) ?? process.env.CLAUDE_PROJECT_DIR ?? cwd;
|
|
2601
|
+
const pHash = projectHash(projectPath);
|
|
2602
|
+
const cacheDir = cacheDirFor("lessons", projectPath, home);
|
|
2603
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
2604
|
+
const report = await transcriptReport(transcript);
|
|
2605
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
2606
|
+
const errors = edits.map((edit) => {
|
|
2607
|
+
const basename = edit.file.split("/").pop() ?? edit.file;
|
|
2608
|
+
const descLine = report.split("\n").find((l) => l.toLowerCase().includes(basename.toLowerCase()));
|
|
2609
|
+
const errorType = categorizeEdit(edit);
|
|
2610
|
+
return {
|
|
2611
|
+
error_type: errorType,
|
|
2612
|
+
pattern: descLine ?? `Code correction in ${basename}`,
|
|
2613
|
+
fix: `Fix ${errorType} in ${basename}`,
|
|
2614
|
+
count: 1,
|
|
2615
|
+
last_seen: timestamp,
|
|
2616
|
+
files: [edit.file],
|
|
2617
|
+
code: { line: (edit.newStr ?? "").split("\n").filter(Boolean).slice(0, 10) }
|
|
2618
|
+
};
|
|
2619
|
+
});
|
|
2620
|
+
await writeJsonFile(join(cacheDir, `${timestamp.replace(/:/g, "-")}.json`), {
|
|
2621
|
+
project: projectPath,
|
|
2622
|
+
timestamp,
|
|
2623
|
+
errors
|
|
2624
|
+
}, true);
|
|
2625
|
+
logCacheEvent("lessons", "hit", pHash, { count: edits.length }, home);
|
|
2626
|
+
}
|
|
2627
|
+
//#endregion
|
|
2628
|
+
//#region src/runtime/lifecycle/aipilot/cache-test.ts
|
|
2629
|
+
/**
|
|
2630
|
+
* SubagentStop (matcher "sniper") for the ai-pilot scope: extract linter
|
|
2631
|
+
* results from the transcript and cache per-file checksums. Ports
|
|
2632
|
+
* `cache-test-results.ts`. Pure side-effect.
|
|
2633
|
+
*/
|
|
2634
|
+
/** Extract linter-related command/output text from a JSONL transcript. */
|
|
2635
|
+
async function extractLinterOutput(path) {
|
|
2636
|
+
const text = await Bun.file(path).text();
|
|
2637
|
+
const outputs = [];
|
|
2638
|
+
for (const line of text.split("\n").filter(Boolean)) try {
|
|
2639
|
+
const content = JSON.parse(line)?.message?.content;
|
|
2640
|
+
if (!Array.isArray(content)) continue;
|
|
2641
|
+
for (const block of content) {
|
|
2642
|
+
if (block.type === "tool_use" && block.name === "Bash") {
|
|
2643
|
+
const cmd = block.input?.command ?? "";
|
|
2644
|
+
if (/eslint|tsc|biome|npx.*lint/i.test(cmd)) outputs.push(cmd);
|
|
2645
|
+
}
|
|
2646
|
+
if (block.type === "tool_result" || block.type === "text") outputs.push(block.text ?? block.content ?? "");
|
|
2647
|
+
}
|
|
2648
|
+
} catch {}
|
|
2649
|
+
return outputs.join("\n");
|
|
2650
|
+
}
|
|
2651
|
+
/**
|
|
2652
|
+
* SubagentStop sniper: cache linter results per-file with checksums.
|
|
2653
|
+
* @param transcript - Path to the agent JSONL transcript.
|
|
2654
|
+
* @param cwd - Fallback project root.
|
|
2655
|
+
* @param home - Home dir (defaults to `~`).
|
|
2656
|
+
*/
|
|
2657
|
+
async function cacheTestResults(transcript, cwd, home = homedir()) {
|
|
2658
|
+
if (!transcript || !await Bun.file(transcript).exists()) return;
|
|
2659
|
+
const projectPath = projectRootFromPaths(await transcriptFilePaths(transcript)) ?? process.env.CLAUDE_PROJECT_DIR ?? cwd;
|
|
2660
|
+
const pHash = projectHash(projectPath);
|
|
2661
|
+
const cacheDir = cacheDirFor("tests", projectPath, home);
|
|
2662
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
2663
|
+
const resultsPath = join(cacheDir, "results.json");
|
|
2664
|
+
const linterOutput = await extractLinterOutput(transcript);
|
|
2665
|
+
if (!linterOutput) return;
|
|
2666
|
+
const srcFiles = await scanSourceFiles(projectPath);
|
|
2667
|
+
if (srcFiles.length === 0) return;
|
|
2668
|
+
const existing = await readJsonFile(resultsPath) ?? {
|
|
2669
|
+
timestamp: "",
|
|
2670
|
+
files: {}
|
|
2671
|
+
};
|
|
2672
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
2673
|
+
const newFiles = {};
|
|
2674
|
+
for (const filepath of srcFiles) {
|
|
2675
|
+
const relPath = filepath.replace(`${projectPath}/`, "");
|
|
2676
|
+
const checksum = (await fileChecksum(filepath)).slice(0, 16);
|
|
2677
|
+
if (!checksum) continue;
|
|
2678
|
+
const basename = filepath.split("/").pop() ?? "";
|
|
2679
|
+
newFiles[relPath] = {
|
|
2680
|
+
checksum,
|
|
2681
|
+
eslint: linterOutput.includes(basename) && linterOutput.includes("error") ? "fail" : "pass",
|
|
2682
|
+
tsc: "pass",
|
|
2683
|
+
last_tested: timestamp
|
|
2684
|
+
};
|
|
2685
|
+
}
|
|
2686
|
+
await writeJsonFile(resultsPath, {
|
|
2687
|
+
timestamp,
|
|
2688
|
+
files: {
|
|
2689
|
+
...existing.files,
|
|
2690
|
+
...newFiles
|
|
2691
|
+
}
|
|
2692
|
+
}, true);
|
|
2693
|
+
logCacheEvent("tests", "hit", pHash, { count: Object.keys(newFiles).length }, home);
|
|
2694
|
+
}
|
|
2695
|
+
//#endregion
|
|
2696
|
+
//#region src/runtime/lifecycle/aipilot/apex-task-store.ts
|
|
2697
|
+
/**
|
|
2698
|
+
* APEX `task.json` mutation helpers + a directory-based lock for the ai-pilot
|
|
2699
|
+
* scope. Ported from the ai-pilot plugin's `apex/state.ts` + `apex/task-helpers.ts`
|
|
2700
|
+
* (now removed).
|
|
2701
|
+
*/
|
|
2702
|
+
/**
|
|
2703
|
+
* Acquire a directory-based lock with a timeout.
|
|
2704
|
+
* @param lockDir - Path to the lock directory.
|
|
2705
|
+
* @param timeoutMs - Max wait in ms (default 5000).
|
|
2706
|
+
* @returns A release function, or null if acquisition timed out.
|
|
2707
|
+
*/
|
|
2708
|
+
async function acquireLock(lockDir, timeoutMs = 5e3) {
|
|
2709
|
+
const start = Date.now();
|
|
2710
|
+
while (Date.now() - start < timeoutMs) try {
|
|
2711
|
+
await mkdir(lockDir, { recursive: false });
|
|
2712
|
+
return async () => {
|
|
2713
|
+
try {
|
|
2714
|
+
await rmdir(lockDir);
|
|
2715
|
+
} catch {}
|
|
2716
|
+
};
|
|
2717
|
+
} catch {
|
|
2718
|
+
await Bun.sleep(100);
|
|
2719
|
+
}
|
|
2720
|
+
return null;
|
|
2721
|
+
}
|
|
2722
|
+
/** Add a new task to `task.json`. */
|
|
2723
|
+
async function taskCreate(file, id, subject, desc) {
|
|
2724
|
+
const data = await readJsonFile(file);
|
|
2725
|
+
if (!data) return;
|
|
2726
|
+
data.tasks[id] = {
|
|
2727
|
+
subject,
|
|
2728
|
+
description: desc,
|
|
2729
|
+
status: "pending",
|
|
2730
|
+
phase: "pending",
|
|
2731
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2732
|
+
doc_consulted: {},
|
|
2733
|
+
files_modified: [],
|
|
2734
|
+
blockedBy: []
|
|
2735
|
+
};
|
|
2736
|
+
await writeJsonFile(file, data);
|
|
2737
|
+
}
|
|
2738
|
+
/** Mark a task as in_progress in `task.json`. */
|
|
2739
|
+
async function taskStart(file, id, subject, desc, blocked) {
|
|
2740
|
+
const data = await readJsonFile(file);
|
|
2741
|
+
if (!data) return;
|
|
2742
|
+
if (!data.tasks[id]) data.tasks[id] = {
|
|
2743
|
+
subject: "",
|
|
2744
|
+
description: "",
|
|
2745
|
+
status: "in_progress",
|
|
2746
|
+
phase: "analyze",
|
|
2747
|
+
doc_consulted: {},
|
|
2748
|
+
files_modified: []
|
|
2749
|
+
};
|
|
2750
|
+
data.current_task = id;
|
|
2751
|
+
const task = data.tasks[id];
|
|
2752
|
+
Object.assign(task, {
|
|
2753
|
+
status: "in_progress",
|
|
2754
|
+
phase: "analyze",
|
|
2755
|
+
started_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2756
|
+
});
|
|
2757
|
+
if (subject) task.subject = subject;
|
|
2758
|
+
if (desc) task.description = desc;
|
|
2759
|
+
if (blocked) task.blockedBy = blocked.split(",");
|
|
2760
|
+
await writeJsonFile(file, data);
|
|
2761
|
+
}
|
|
2762
|
+
/** Mark a task as completed in `task.json`. */
|
|
2763
|
+
async function taskComplete(file, id) {
|
|
2764
|
+
const data = await readJsonFile(file);
|
|
2765
|
+
if (!data?.tasks[id]) return;
|
|
2766
|
+
Object.assign(data.tasks[id], {
|
|
2767
|
+
status: "completed",
|
|
2768
|
+
phase: "completed",
|
|
2769
|
+
completed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2770
|
+
});
|
|
2771
|
+
await writeJsonFile(file, data);
|
|
2772
|
+
}
|
|
2773
|
+
//#endregion
|
|
2774
|
+
//#region src/runtime/lifecycle/aipilot/sync-task.ts
|
|
2775
|
+
/**
|
|
2776
|
+
* PostToolUse (matcher "TaskCreate|TaskUpdate") for the ai-pilot scope:
|
|
2777
|
+
* synchronize Claude task tools with `.claude/apex/task.json` and prompt an
|
|
2778
|
+
* auto-commit on completion when git changes are detected. Ports
|
|
2779
|
+
* `sync-task-tracking.ts`.
|
|
2780
|
+
*/
|
|
2781
|
+
/** True when the project has uncommitted git changes. */
|
|
2782
|
+
async function hasGitChanges(cwd) {
|
|
2783
|
+
try {
|
|
2784
|
+
const proc = Bun.spawn([
|
|
2785
|
+
"git",
|
|
2786
|
+
"status",
|
|
2787
|
+
"--porcelain"
|
|
2788
|
+
], {
|
|
2789
|
+
cwd,
|
|
2790
|
+
stdout: "pipe",
|
|
2791
|
+
stderr: "ignore"
|
|
2792
|
+
});
|
|
2793
|
+
return (await new Response(proc.stdout).text()).trim().length > 0;
|
|
2794
|
+
} catch {
|
|
2795
|
+
return false;
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
/** Handle a completed task: emit the commit reminder (or the no-change note). */
|
|
2799
|
+
async function onComplete(taskFile, taskId, projectRoot) {
|
|
2800
|
+
await taskComplete(taskFile, taskId);
|
|
2801
|
+
if (!await hasGitChanges(projectRoot)) return contextResponse("PostToolUse", "Task completed. No changes to commit.");
|
|
2802
|
+
return contextResponse("PostToolUse", `Task #${taskId} completed: ${(await readJsonFile(taskFile))?.tasks[taskId]?.subject ?? "Task"}\n\nChanges detected. MANDATORY: Run /fuse-commit-pro:commit to commit with smart detection.`);
|
|
2803
|
+
}
|
|
2804
|
+
/**
|
|
2805
|
+
* PostToolUse TaskCreate/TaskUpdate handler.
|
|
2806
|
+
* @param payload - The raw hook payload (`tool_name`, `tool_input`, `tool_response`).
|
|
2807
|
+
* @param cwd - Fallback project root (uses `CLAUDE_PROJECT_DIR` first).
|
|
2808
|
+
* @returns The native hook stdout (possibly empty).
|
|
2809
|
+
*/
|
|
2810
|
+
async function syncTaskTracking(payload, cwd) {
|
|
2811
|
+
const toolName = String(payload.tool_name ?? "");
|
|
2812
|
+
if (toolName !== "TaskCreate" && toolName !== "TaskUpdate") return "";
|
|
2813
|
+
const projectRoot = process.env.CLAUDE_PROJECT_DIR ?? cwd;
|
|
2814
|
+
const taskFile = join(projectRoot, ".claude", "apex", "task.json");
|
|
2815
|
+
if (!existsSync(taskFile)) return "";
|
|
2816
|
+
const unlock = await acquireLock(join(projectRoot, ".claude", "apex", ".task.lock"), 1e4);
|
|
2817
|
+
if (!unlock) return "";
|
|
2818
|
+
try {
|
|
2819
|
+
const ti = payload.tool_input ?? {};
|
|
2820
|
+
if (toolName === "TaskCreate") {
|
|
2821
|
+
const existing = (await readJsonFile(taskFile))?.tasks ?? {};
|
|
2822
|
+
await taskCreate(taskFile, payload.tool_response?.id ?? String(Math.max(0, ...Object.keys(existing).map(Number)) + 1), ti.subject ?? "", ti.description ?? "");
|
|
2823
|
+
return "";
|
|
2824
|
+
}
|
|
2825
|
+
const taskId = ti.taskId ?? "";
|
|
2826
|
+
if (!taskId) return "";
|
|
2827
|
+
const newStatus = ti.status ?? "";
|
|
2828
|
+
if (newStatus === "in_progress") {
|
|
2829
|
+
const blocked = ti.addBlockedBy?.join(",") ?? "";
|
|
2830
|
+
await taskStart(taskFile, taskId, ti.subject || void 0, ti.description || void 0, blocked || void 0);
|
|
2831
|
+
}
|
|
2832
|
+
if (newStatus === "completed") return onComplete(taskFile, taskId, projectRoot);
|
|
2833
|
+
return "";
|
|
2834
|
+
} finally {
|
|
2835
|
+
await unlock();
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
//#endregion
|
|
2839
|
+
//#region src/runtime/lifecycle/aipilot/dispatch-aipilot.ts
|
|
2840
|
+
/**
|
|
2841
|
+
* ai-pilot scope dispatcher: routes Claude lifecycle events to the ported
|
|
2842
|
+
* cache/injection handlers by (event, agent_type matcher).
|
|
2843
|
+
*/
|
|
2844
|
+
/** The agent_type a SubagentStart/Stop payload reports. */
|
|
2845
|
+
function agentTypeOf(payload) {
|
|
2846
|
+
return String(payload.agent_type ?? payload.subagent_type ?? "");
|
|
2847
|
+
}
|
|
2848
|
+
/** Extract the `additionalContext` text from a SubagentStart response, or "". */
|
|
2849
|
+
function contextTextOf(response) {
|
|
2850
|
+
if (!response) return "";
|
|
2851
|
+
try {
|
|
2852
|
+
return JSON.parse(response).hookSpecificOutput?.additionalContext ?? "";
|
|
2853
|
+
} catch {
|
|
2854
|
+
return "";
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
/**
|
|
2858
|
+
* Combine two SubagentStart responses into one (Claude concatenates multiple
|
|
2859
|
+
* hooks' additionalContext; collapsing two matcher-"" scripts into one dispatch
|
|
2860
|
+
* call must do that join itself). Returns "" when both are empty.
|
|
2861
|
+
*/
|
|
2862
|
+
function combineContext(a, b) {
|
|
2863
|
+
const parts = [contextTextOf(a), contextTextOf(b)].filter(Boolean);
|
|
2864
|
+
return parts.length ? contextResponse("SubagentStart", parts.join("\n\n")) : "";
|
|
2865
|
+
}
|
|
2866
|
+
/** The transcript path a SubagentStop payload reports (Stop-only field). */
|
|
2867
|
+
function transcriptOf(payload) {
|
|
2868
|
+
const t = payload.agent_transcript_path;
|
|
2869
|
+
return typeof t === "string" ? t : void 0;
|
|
2870
|
+
}
|
|
2871
|
+
/** SubagentStart routing: cache injectors keyed on agent_type. */
|
|
2872
|
+
async function onSubagentStart(payload, cwd, now) {
|
|
2873
|
+
const agent = agentTypeOf(payload);
|
|
2874
|
+
if (agent.includes("explore-codebase")) return injectExploreCache(cwd, void 0, now);
|
|
2875
|
+
if (agent.includes("research-expert")) return injectDocCache(cwd, void 0, now);
|
|
2876
|
+
if (agent.includes("sniper")) return injectTestCache(cwd, void 0, now);
|
|
2877
|
+
return combineContext(await injectApexSubagentContext(cwd), await injectLessonsCache(cwd, void 0, now));
|
|
2878
|
+
}
|
|
2879
|
+
/** SubagentStop routing: transcript-driven cache writers (side-effects). */
|
|
2880
|
+
async function onSubagentStop(payload, cwd) {
|
|
2881
|
+
const agent = agentTypeOf(payload);
|
|
2882
|
+
const transcript = transcriptOf(payload);
|
|
2883
|
+
if (agent.includes("research-expert")) {
|
|
2884
|
+
await cacheDocFromTranscript(transcript, cwd);
|
|
2885
|
+
return "";
|
|
2886
|
+
}
|
|
2887
|
+
if (agent.includes("sniper")) {
|
|
2888
|
+
await cacheSniperLessons(transcript, cwd);
|
|
2889
|
+
await cacheTestResults(transcript, cwd);
|
|
2890
|
+
}
|
|
1473
2891
|
return "";
|
|
1474
2892
|
}
|
|
2893
|
+
/**
|
|
2894
|
+
* Dispatch an ai-pilot-scope lifecycle event. Returns the native stdout, or
|
|
2895
|
+
* `null` when unhandled (caller falls through to the default pipeline).
|
|
2896
|
+
*/
|
|
2897
|
+
async function dispatchAipilot(event, payload, cwd, now) {
|
|
2898
|
+
if (event === "SubagentStart") return onSubagentStart(payload, cwd, now);
|
|
2899
|
+
if (event === "SubagentStop") return onSubagentStop(payload, cwd);
|
|
2900
|
+
if (event === "SessionEnd") {
|
|
2901
|
+
await cacheAnalyticsSave(void 0, now);
|
|
2902
|
+
return "";
|
|
2903
|
+
}
|
|
2904
|
+
return null;
|
|
2905
|
+
}
|
|
2906
|
+
/** PostToolUse (TaskCreate/TaskUpdate) sync for the ai-pilot scope. */
|
|
2907
|
+
async function aipilotPostToolUse(payload, cwd) {
|
|
2908
|
+
return syncTaskTracking(payload, cwd);
|
|
2909
|
+
}
|
|
1475
2910
|
//#endregion
|
|
1476
2911
|
//#region src/runtime/lifecycle/dispatch.ts
|
|
1477
2912
|
/** SessionStart handler keyed on plugin scope. */
|
|
1478
2913
|
function sessionStart(input) {
|
|
1479
2914
|
if (input.scope === "solid") return solidDetectStart();
|
|
1480
2915
|
if (input.scope === "rules") return injectRules(process.env.CLAUDE_PLUGIN_ROOT ?? input.cwd);
|
|
1481
|
-
if (input.scope === "carto") return cartoSessionStart(input.cwd);
|
|
2916
|
+
if (input.scope === "carto") return cartoSessionStart(input.cwd, input.now);
|
|
2917
|
+
if (input.scope === "lessons") return dispatchLessons("SessionStart", input.payload, input.cwd, input.now);
|
|
1482
2918
|
return sessionStartCore(input.cwd, void 0, input.now);
|
|
1483
2919
|
}
|
|
1484
2920
|
/**
|
|
@@ -1492,15 +2928,19 @@ function dispatchLifecycle(input) {
|
|
|
1492
2928
|
switch (input.event) {
|
|
1493
2929
|
case "SessionStart": return sessionStart(input);
|
|
1494
2930
|
case "UserPromptSubmit": return input.scope === "rules" ? injectRules(process.env.CLAUDE_PLUGIN_ROOT ?? input.cwd) : null;
|
|
1495
|
-
case "SubagentStart":
|
|
1496
|
-
|
|
2931
|
+
case "SubagentStart":
|
|
2932
|
+
if (input.scope === "aipilot") return "";
|
|
2933
|
+
if (input.scope === "lessons") return dispatchLessons("SubagentStart", input.payload, input.cwd, input.now);
|
|
2934
|
+
return subagentCacheContext(input.payload.session_id);
|
|
2935
|
+
case "Stop": return input.scope === "lessons" ? dispatchLessons("Stop", input.payload, input.cwd, input.now) : null;
|
|
2936
|
+
case "SubagentStop": return input.scope === "aipilot" ? "" : trackAgentMemory(input.payload, void 0, input.now);
|
|
1497
2937
|
case "TeammateIdle": return validateTeammateOutput(input.payload);
|
|
1498
2938
|
case "PostToolUseFailure":
|
|
1499
2939
|
logToolFailure(input.payload, void 0, input.now);
|
|
1500
2940
|
return "";
|
|
1501
2941
|
case "PreCompact": return saveApexState(input.cwd, input.now);
|
|
1502
2942
|
case "SessionEnd":
|
|
1503
|
-
cleanupSession(void 0, input.now);
|
|
2943
|
+
if (input.scope !== "aipilot") cleanupSession(void 0, input.now);
|
|
1504
2944
|
return "";
|
|
1505
2945
|
case "InstructionsLoaded":
|
|
1506
2946
|
validateRulesLoaded(input.payload);
|
|
@@ -1677,13 +3117,16 @@ function trackWatchResearch(tool, input, now = Date.now(), home = homedir()) {
|
|
|
1677
3117
|
/**
|
|
1678
3118
|
* Dispatch the appropriate PostToolUse tracker for the invoking scope. Carto
|
|
1679
3119
|
* persists manual enrichments; security records skill reads + MCP research;
|
|
1680
|
-
* changelog records watch research
|
|
3120
|
+
* changelog records watch research; lessons arms the per-project throttle.
|
|
3121
|
+
* Side-effect only.
|
|
1681
3122
|
* @param scope - The invoking plugin scope.
|
|
1682
3123
|
* @param event - The normalized event.
|
|
1683
3124
|
* @param input - The raw tool input.
|
|
1684
3125
|
* @param now - Clock.
|
|
3126
|
+
* @param payload - The raw hook payload (for the lessons mark).
|
|
3127
|
+
* @param cwd - The project root (for the lessons mark).
|
|
1685
3128
|
*/
|
|
1686
|
-
function postTrackingSideEffects(scope, event, input, now) {
|
|
3129
|
+
function postTrackingSideEffects(scope, event, input, now, payload = {}, cwd = process.cwd()) {
|
|
1687
3130
|
if (scope === "carto" && (event.tool === "Edit" || event.tool === "Write") && event.filePath) {
|
|
1688
3131
|
trackEnrichment(event.filePath);
|
|
1689
3132
|
return;
|
|
@@ -1693,7 +3136,11 @@ function postTrackingSideEffects(scope, event, input, now) {
|
|
|
1693
3136
|
trackMcpResearch(event.tool, input, now);
|
|
1694
3137
|
return;
|
|
1695
3138
|
}
|
|
1696
|
-
if (scope === "changelog")
|
|
3139
|
+
if (scope === "changelog") {
|
|
3140
|
+
trackWatchResearch(event.tool, input, now);
|
|
3141
|
+
return;
|
|
3142
|
+
}
|
|
3143
|
+
if (scope === "lessons") dispatchLessons("PostToolUse", payload, cwd, now);
|
|
1697
3144
|
}
|
|
1698
3145
|
//#endregion
|
|
1699
3146
|
//#region src/runtime/lifecycle/security/check-skill.ts
|
|
@@ -1727,6 +3174,42 @@ function securityAdvisory(tool, filePath, now = Date.now(), home = homedir()) {
|
|
|
1727
3174
|
} });
|
|
1728
3175
|
}
|
|
1729
3176
|
//#endregion
|
|
3177
|
+
//#region src/runtime/lifecycle/seo/post-tool-use.ts
|
|
3178
|
+
/**
|
|
3179
|
+
* SEO PostToolUse handler (fs effects). Ports `seo/hooks/validate-seo.ts`: on an
|
|
3180
|
+
* edited HTML-like file under a `.fuse-seo` marker, deny when SEO elements are
|
|
3181
|
+
* missing. Opt-in only — silent when no marker / non-HTML / file unreadable.
|
|
3182
|
+
*/
|
|
3183
|
+
/**
|
|
3184
|
+
* Validate the edited file's SEO completeness. Returns a deny message (for a
|
|
3185
|
+
* `permissionDecision: deny` response) when HTML-like, opted-in, and missing
|
|
3186
|
+
* elements; otherwise `null` (allow).
|
|
3187
|
+
* @param payload - The raw PostToolUse payload.
|
|
3188
|
+
* @returns The deny reason string, or `null` to allow.
|
|
3189
|
+
*/
|
|
3190
|
+
function seoPostToolUse(payload) {
|
|
3191
|
+
const path = payload.tool_input?.file_path;
|
|
3192
|
+
if (!path || !isHtmlLike(path)) return null;
|
|
3193
|
+
if (!walkUpFor(typeof payload.cwd === "string" ? payload.cwd : dirname(path), ".fuse-seo")) return null;
|
|
3194
|
+
try {
|
|
3195
|
+
const missing = missingSeoElements(readFileSync(path, "utf-8"));
|
|
3196
|
+
if (missing.length === 0) return null;
|
|
3197
|
+
return `fuse-seo: missing SEO elements in ${path}:\n - ${missing.join("\n - ")}`;
|
|
3198
|
+
} catch {
|
|
3199
|
+
return null;
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
/**
|
|
3203
|
+
* SEO PostToolUse as a ready native response: a `permissionDecision: deny`
|
|
3204
|
+
* string when the edited file is missing SEO elements, else `null` (allow).
|
|
3205
|
+
* @param payload - The raw PostToolUse payload.
|
|
3206
|
+
* @returns The deny response string, or `null` to allow.
|
|
3207
|
+
*/
|
|
3208
|
+
function seoPostToolUseResponse(payload) {
|
|
3209
|
+
const deny = seoPostToolUse(payload);
|
|
3210
|
+
return deny ? denyResponse("PostToolUse", deny) : null;
|
|
3211
|
+
}
|
|
3212
|
+
//#endregion
|
|
1730
3213
|
//#region src/runtime/lifecycle-bridge.ts
|
|
1731
3214
|
/** Raw event name from a payload (Cline lacks one; lifecycle is Claude-only). */
|
|
1732
3215
|
function rawEvent(payload) {
|
|
@@ -2237,6 +3720,10 @@ async function handlePre(ctx) {
|
|
|
2237
3720
|
}
|
|
2238
3721
|
//#endregion
|
|
2239
3722
|
//#region src/runtime/handle.ts
|
|
3723
|
+
/** Raw Claude hook event name from a payload (empty when absent). */
|
|
3724
|
+
function rawEventName(payload) {
|
|
3725
|
+
return typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
|
|
3726
|
+
}
|
|
2240
3727
|
/**
|
|
2241
3728
|
* The full hook handler: on a PRE event it gates the tool-use (stateless guards
|
|
2242
3729
|
* then APEX gates from the session track) and returns the native response; on a
|
|
@@ -2253,6 +3740,13 @@ async function handleHook(id, payload, opts) {
|
|
|
2253
3740
|
stdout: "",
|
|
2254
3741
|
exit: 0
|
|
2255
3742
|
};
|
|
3743
|
+
if (opts.scope === "aipilot") {
|
|
3744
|
+
const ai = await dispatchAipilot(rawEventName(payload), payload, opts.cwd, opts.now);
|
|
3745
|
+
if (ai !== null) return {
|
|
3746
|
+
stdout: ai,
|
|
3747
|
+
exit: 0
|
|
3748
|
+
};
|
|
3749
|
+
}
|
|
2256
3750
|
const life = lifecycleStdout(payload, opts.cwd, opts.scope ?? "core", opts.now);
|
|
2257
3751
|
if (life !== null) return {
|
|
2258
3752
|
stdout: life,
|
|
@@ -2279,7 +3773,19 @@ async function handleHook(id, payload, opts) {
|
|
|
2279
3773
|
responseLength: extractText(response).length
|
|
2280
3774
|
});
|
|
2281
3775
|
if (activity) await recordActivity(file, activity);
|
|
2282
|
-
postTrackingSideEffects(opts.scope ?? "core", event, event.input, opts.now);
|
|
3776
|
+
postTrackingSideEffects(opts.scope ?? "core", event, event.input, opts.now, payload, opts.cwd);
|
|
3777
|
+
const seoDeny = opts.scope === "seo" ? seoPostToolUseResponse(payload) : null;
|
|
3778
|
+
if (seoDeny) return {
|
|
3779
|
+
stdout: seoDeny,
|
|
3780
|
+
exit: 0
|
|
3781
|
+
};
|
|
3782
|
+
if (opts.scope === "aipilot" && (event.tool === "TaskCreate" || event.tool === "TaskUpdate")) {
|
|
3783
|
+
const out = await aipilotPostToolUse(payload, opts.cwd);
|
|
3784
|
+
if (out) return {
|
|
3785
|
+
stdout: out,
|
|
3786
|
+
exit: 0
|
|
3787
|
+
};
|
|
3788
|
+
}
|
|
2283
3789
|
const extra = postEditContext(opts.scope ?? "core", event, opts.now);
|
|
2284
3790
|
return {
|
|
2285
3791
|
stdout: designWarn ? respond(id, designWarn) : extra,
|
|
@@ -2297,4 +3803,4 @@ async function handleHook(id, payload, opts) {
|
|
|
2297
3803
|
});
|
|
2298
3804
|
}
|
|
2299
3805
|
//#endregion
|
|
2300
|
-
export {
|
|
3806
|
+
export { detectSolidProfile as $, dispatchLessons as A, activityFor as At, mergeLines as B, securityStateDir as C, trackFile as Ct, dispatchLifecycle as D, mcpPostStore as Dt, trackEnrichment as E, isMcpTool as Et, writePluginMap as F, trackSessionChanges as G, getFileDesc as H, generateProjectMap as I, saveApexState as J, validateRulesLoaded as K, isProject as L, lessonsStateFileFor as M, cartoSessionStart as N, aipilotPostToolUse as O, mcpPreIntercept as Ot, generateEcosystemMap as P, subagentCacheContext as Q, writeTree as R, saveSecurityState as S, recordActivity as St, todayUtc as T, MCP_TTL_MS as Tt, listChildren as U, countFiles as V, postEditTypescript as W, validateTeammateOutput as X, logToolFailure as Y, trackAgentMemory as Z, trackWatchResearch as _, sessionStatePath as _t, TRIVIAL_BUDGET as a, pruneEmptyDirs as at, isoUtc as b, taskContext as bt, detectDuplication as c, trimLogFile as ct, lifecycleStdout as d, projectContext as dt, solidDetectStart as et, postEditContext as f, claudeHome as ft, postTrackingSideEffects as g, saveSessionState as gt, securityAdvisory as h, sanitizeSessionId as ht, REQUIRED_AGENTS as i, sessionStartCore as it, lessonsFileFor as j, dispatchAipilot as k, queryOf as kt, dryGate as l, devContext as lt, seoPostToolUseResponse as m, loadSessionState as mt, handlePre as n, readRules as nt, gate as o, purgeTtlTree as ot, seoPostToolUse as p, fusengineCache as pt, cleanupSession as q, DEFAULT_WINDOW_MS as r, runSessionStartCleanups as rt, preCommitGate as s, removeOldFiles as st, handleHook as t, injectRules as tt, extractSymbols as u, gitContext as ut, trackMcpResearch as v, sessionsDir as vt, securityStatePath as w, normalizeEvent as wt, loadSecurityState as x, respond as xt, trackSkillRead as y, promptSubmitContext as yt, loadEnriched as z };
|