@fusengine/harness 0.1.48 → 0.1.50
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.d.mts +2 -2
- package/dist/adapters/claude/index.mjs +2 -2
- package/dist/adapters/cline/index.mjs +1 -1
- package/dist/adapters/codex/index.d.mts +1 -1
- package/dist/adapters/codex/index.mjs +1 -1
- package/dist/adapters/cursor/index.d.mts +10 -3
- package/dist/adapters/cursor/index.mjs +3 -3
- package/dist/adapters/gemini/index.mjs +1 -1
- package/dist/adapters/hermes/index.d.mts +54 -0
- package/dist/adapters/hermes/index.mjs +2 -0
- package/dist/apex-Wdi1nq_w.d.mts +73 -0
- package/dist/{claude-U1KTaRG4.mjs → claude-BxC9semG.mjs} +30 -9
- package/dist/cli/bin.mjs +5 -5
- package/dist/cli/index.mjs +1 -1
- package/dist/config/index.d.mts +1 -1
- package/dist/config/index.mjs +1 -1
- package/dist/detect/index.d.mts +1 -1
- package/dist/detect/index.mjs +1 -1
- package/dist/{doc-helpers-BNfYWvYv.d.mts → doc-helpers-CEKzGg2u.d.mts} +2 -0
- package/dist/{dotenv-B9nM4cuQ.mjs → dotenv-Jj8aL1FL.mjs} +2 -1
- package/dist/{evaluate-qYC5xClb.mjs → evaluate-CNAzgxnN.mjs} +3 -85
- package/dist/freshness/index.d.mts +1 -1
- package/dist/{handle-CCV0ycdr.mjs → handle-DzPjaPS-.mjs} +868 -71
- package/dist/{harness-C8Nxxyn_.mjs → harness-Cb9xR8dC.mjs} +6 -3
- package/dist/{harness-BPPu5CrN.d.mts → harness-s1Fce_dL.d.mts} +1 -1
- package/dist/hermes-DWXCRFZU.mjs +54 -0
- package/dist/home-state-D0RLWP8J.mjs +134 -0
- package/dist/{index-BqfOFKm7.d.mts → index-BfMsILmg.d.mts} +2 -63
- package/dist/{index-BxjzFraL.d.mts → index-DZCLmSoO.d.mts} +11 -9
- package/dist/{index-BXPySPxE.d.mts → index-jj_EzZRv.d.mts} +1 -1
- package/dist/index.d.mts +7 -6
- package/dist/index.mjs +6 -5
- package/dist/init/index.d.mts +1 -1
- package/dist/policy/index.d.mts +3 -2
- package/dist/policy/index.mjs +4 -3
- package/dist/prompt/index.d.mts +1 -1
- package/dist/{run-CaqeQFYp.mjs → run-DZvP_9xB.mjs} +1 -1
- package/dist/runtime/index.d.mts +39 -9
- package/dist/runtime/index.mjs +3 -3
- package/dist/{session-state-V8Jp-KE5.d.mts → session-state-DMpotbRz.d.mts} +26 -4
- package/dist/skill-path-DhItkBzk.mjs +95 -0
- package/dist/store-QSSTO3lY.mjs +349 -0
- package/dist/tracking/index.d.mts +2 -2
- package/dist/tracking/index.mjs +2 -2
- package/dist/{types-D56jSgD9.d.mts → types-DVbIl9md.d.mts} +6 -0
- package/dist/{validate-ebDjIyOa.mjs → validate-DCQ8dkdL.mjs} +16 -100
- package/package.json +7 -2
- package/dist/home-state-mKZxP4oZ.mjs +0 -52
- package/dist/store-ARx-GQSQ.mjs +0 -200
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { a as parseEnvInt, i as splitTarget, r as resolveMaxLines } from "./limits-CHn8AIL1.mjs";
|
|
2
2
|
import { r as projectLayout } from "./layout-C0jaaCQC.mjs";
|
|
3
3
|
import { i as walkUpFor, n as projectRoot, r as projectRootOrNull, t as isCodeFile } from "./project-root-3kk7gCOp.mjs";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { A as detectCreationIntent, E as isExcludedSwiftPath, F as docConsultedGate, H as detectProjectType$1, I as evaluateApex, M as POST_AUTH_GATES, N as PRE_AUTH_GATES, S as usesTailwindUtilities, T as isExcludedJsPath, V as detectModularArchitecture, W as requiredArchSkill, _ as scanPlugin, c as EXCLUDE_DIRS$1, d as buildApexTaskInjection, h as buildClaudeMdContext, k as capVerbosity, l as PROJECT_INDICATORS, n as missingSeoElements, o as parseEnrichment, r as descFromText, s as parseEntry, t as isHtmlLike, w as frameworkSolidGate, x as skillTriggerGate, y as parseField, z as detectFramework } from "./validate-DCQ8dkdL.mjs";
|
|
5
|
+
import { a as sanitizeSessionId, c as sessionsDir, d as countFrameworkCodeLines, f as countLines, i as loadSessionState, l as PLUGINS_DIR, n as fuseHarnessHome, o as saveSessionState, r as fusengineCache, t as claudeHome } from "./home-state-D0RLWP8J.mjs";
|
|
6
|
+
import { n as FAIL_CLOSED, t as evaluate } from "./evaluate-CNAzgxnN.mjs";
|
|
6
7
|
import { a as writeJsonFile, i as readJsonFile, r as hashText, t as atomicWrite } from "./json-io-DisYd2fb.mjs";
|
|
7
|
-
import { a as sanitizeSessionId, c as sessionsDir, i as loadSessionState, n as fuseHarnessHome, o as saveSessionState, r as fusengineCache, t as claudeHome } from "./home-state-mKZxP4oZ.mjs";
|
|
8
8
|
import { r as isDocConsulted } from "./doc-helpers-CWZegVdR.mjs";
|
|
9
|
+
import { n as findMarketplacePlugins, r as readPluginMeta, t as resolveSkillPath } from "./skill-path-DhItkBzk.mjs";
|
|
9
10
|
import { t as formatPrompt } from "./types-ernB1Dy3.mjs";
|
|
10
11
|
import { a as nowStamp, l as throttleMs, n as readRoots, o as readState, s as setStateField, t as addRoot } from "./registry-CymilZiZ.mjs";
|
|
11
12
|
import { d as loadIndex, i as cacheLookupMeta, n as webfetchCacheWrite, o as cacheLookupSubstringMeta, t as mcpCacheWrite, u as extractText } from "./mcp-store-BkBDmuxN.mjs";
|
|
12
13
|
import { t as loadRefs } from "./loader-AGz4nK7d.mjs";
|
|
13
|
-
import { c as recordDoc, d as trivialCount, i as agentsFresh, l as recordRefRead, n as saveTrack, o as recordAgent, r as verifyTrack, s as recordBrainstormRequired, t as loadTrack, u as
|
|
14
|
-
import { d as
|
|
14
|
+
import { c as recordDoc, d as recordTrivialEdit, f as trivialCount, i as agentsFresh, l as recordRefRead, n as saveTrack, o as recordAgent, p as apexAuthorizationGate, r as verifyTrack, s as recordBrainstormRequired, t as loadTrack, u as recordTarget } from "./store-QSSTO3lY.mjs";
|
|
15
|
+
import { d as collectFiles, f as pathExists, g as writeText, h as spawnCapture, i as denyResponse, l as systemMessage, m as sleep, n as blockResponse, p as readText, r as contextResponse, s as informResponse, t as attachSystemMessage } from "./claude-BxC9semG.mjs";
|
|
16
|
+
import { r as toHermesResponse } from "./hermes-DWXCRFZU.mjs";
|
|
15
17
|
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
16
18
|
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
17
19
|
import { homedir } from "node:os";
|
|
@@ -325,18 +327,81 @@ function designLifecycle(payload, cacheDir, cwd, stamp, now) {
|
|
|
325
327
|
}
|
|
326
328
|
return false;
|
|
327
329
|
}
|
|
330
|
+
/** Sidecar basename under the per-project state dir. */
|
|
331
|
+
const SIDECAR = "inject-dedup.json";
|
|
332
|
+
/** Load the `{ key -> epochMs }` map, or `{}` when missing/corrupt. */
|
|
333
|
+
function loadMap(path) {
|
|
334
|
+
try {
|
|
335
|
+
if (!existsSync(path)) return {};
|
|
336
|
+
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
337
|
+
return data && typeof data === "object" && !Array.isArray(data) ? data : {};
|
|
338
|
+
} catch {
|
|
339
|
+
return {};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/** Keep only entries newer than `windowMs` before `now` (bounds sidecar size). */
|
|
343
|
+
function prune(map, now, windowMs) {
|
|
344
|
+
const out = {};
|
|
345
|
+
for (const [k, t] of Object.entries(map)) if (typeof t === "number" && now - t < windowMs) out[k] = t;
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Cooldown gate. Returns `true` when `key` has NOT been recorded within the last
|
|
350
|
+
* `windowMs` (the caller MAY emit — and the emission is recorded now), or `false`
|
|
351
|
+
* when it was (the caller SHOULD suppress). The first call in a window wins;
|
|
352
|
+
* subsequent identical keys are throttled until the window elapses.
|
|
353
|
+
*
|
|
354
|
+
* Fails open: if the sidecar is unwritable, the emission is allowed rather than
|
|
355
|
+
* silently dropping context.
|
|
356
|
+
* @param key - Stable identity of the block (e.g. a content hash, or `lesson:<id>`).
|
|
357
|
+
* @param windowMs - Suppression window in ms.
|
|
358
|
+
* @param opts - Optional clock + state-dir overrides (for tests).
|
|
359
|
+
* @returns `true` to proceed/emit, `false` to suppress.
|
|
360
|
+
*/
|
|
361
|
+
function oncePerWindow(key, windowMs, opts = {}) {
|
|
362
|
+
const now = opts.now ?? Date.now();
|
|
363
|
+
const path = join(opts.dir ?? defaultStateDir(), SIDECAR);
|
|
364
|
+
const map = prune(loadMap(path), now, windowMs);
|
|
365
|
+
const last = map[key];
|
|
366
|
+
if (typeof last === "number" && now - last < windowMs) return false;
|
|
367
|
+
map[key] = now;
|
|
368
|
+
try {
|
|
369
|
+
atomicWrite(path, JSON.stringify(map));
|
|
370
|
+
} catch {}
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
328
373
|
//#endregion
|
|
329
374
|
//#region src/runtime/inject-context.ts
|
|
330
375
|
/**
|
|
376
|
+
* Build the {@link oncePerWindow} key for the CLAUDE.md preamble gate. The
|
|
377
|
+
* prompt hash keeps two distinct legitimate turns from colliding — even non-dev
|
|
378
|
+
* prompts, whose block is prompt-independent (just CLAUDE.md) and would
|
|
379
|
+
* otherwise hash-collide within the window — while the content hash still lets a
|
|
380
|
+
* same-turn double-fire of an identical block be suppressed. Single source of
|
|
381
|
+
* truth so the owner invariant test guards the real production key.
|
|
382
|
+
* @param prompt - The raw user prompt.
|
|
383
|
+
* @param ctx - The rendered CLAUDE.md (+ optional APEX) block.
|
|
384
|
+
* @returns The namespaced dedup key.
|
|
385
|
+
*/
|
|
386
|
+
function claudeMdKey(prompt, ctx) {
|
|
387
|
+
return `claude-md:${hashText(prompt)}:${hashText(ctx)}`;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
331
390
|
* UserPromptSubmit context injection: render the CLAUDE.md (+ optional APEX)
|
|
332
391
|
* preamble as a Claude `additionalContext` response, or "" when nothing to emit.
|
|
392
|
+
* Guarded by {@link oncePerWindow} via {@link claudeMdKey}: only a
|
|
393
|
+
* near-simultaneous double-fire of the SAME turn (identical prompt AND identical
|
|
394
|
+
* block, within {@link DEDUP_WINDOW_MS}) is suppressed. The invariant "CLAUDE.md
|
|
395
|
+
* is emitted on EVERY message" is thus preserved.
|
|
333
396
|
* @param prompt - The raw user prompt.
|
|
334
397
|
* @param cwd - Project root (for project-type detection).
|
|
335
398
|
* @returns The native hook stdout (possibly empty).
|
|
336
399
|
*/
|
|
337
400
|
function promptSubmitContext(prompt, cwd) {
|
|
338
401
|
const ctx = buildClaudeMdContext(prompt, cwd);
|
|
339
|
-
|
|
402
|
+
if (!ctx) return "";
|
|
403
|
+
if (!oncePerWindow(claudeMdKey(prompt, ctx), 3e3)) return "";
|
|
404
|
+
return contextResponse("UserPromptSubmit", ctx);
|
|
340
405
|
}
|
|
341
406
|
/**
|
|
342
407
|
* PreToolUse Task context injection: render the APEX sub-agent context as a
|
|
@@ -865,6 +930,87 @@ function subagentCacheContext(sessionIdRaw, home = homedir(), env = process.env,
|
|
|
865
930
|
return fresh.length ? contextResponse("SubagentStart", render(fresh)) : "";
|
|
866
931
|
}
|
|
867
932
|
//#endregion
|
|
933
|
+
//#region src/runtime/lifecycle/agent-files.ts
|
|
934
|
+
/**
|
|
935
|
+
* Per-agent file attribution for SubagentStop. Parses a sub-agent's OWN
|
|
936
|
+
* transcript (`agent_transcript_path` — the clean JSONL the Claude Code platform
|
|
937
|
+
* writes for that specific sub-agent, added in CLI v2.0.42) to recover the exact
|
|
938
|
+
* set of files it wrote via Write/Edit/MultiEdit/NotebookEdit. This lets the
|
|
939
|
+
* sniper reminder attribute only the files THIS agent touched, instead of every
|
|
940
|
+
* file changed in the whole session (which cross-attributes other teammates'
|
|
941
|
+
* work — the bug this fixes). Per LESSON.md, the SubagentStop transcript is the
|
|
942
|
+
* reliable anchor; sidechain PostToolUse hooks are not (issues #43612/#34692).
|
|
943
|
+
*/
|
|
944
|
+
/** Tools whose `input.file_path`/`notebook_path` names a file the agent authored. */
|
|
945
|
+
const WRITE_TOOLS = /* @__PURE__ */ new Set([
|
|
946
|
+
"Write",
|
|
947
|
+
"Edit",
|
|
948
|
+
"MultiEdit",
|
|
949
|
+
"NotebookEdit"
|
|
950
|
+
]);
|
|
951
|
+
/**
|
|
952
|
+
* Return the distinct list of file paths written by the sub-agent whose own
|
|
953
|
+
* transcript is at `transcriptPath`, by scanning its Write/Edit/MultiEdit/
|
|
954
|
+
* NotebookEdit `tool_use` blocks. Distinguishes "no writes" from "cannot read":
|
|
955
|
+
*
|
|
956
|
+
* - `null` — the path is absent, or the transcript is unreadable. The caller
|
|
957
|
+
* MUST fall back to the session-wide list (fail-open: never drop the sniper
|
|
958
|
+
* reminder just because a transcript could not be parsed — no regression vs.
|
|
959
|
+
* the pre-fix behavior).
|
|
960
|
+
* - `string[]` (possibly empty) — the transcript was read; every write was
|
|
961
|
+
* collected. An empty array means this agent authored no files, so it should
|
|
962
|
+
* NOT be told to validate files other agents changed.
|
|
963
|
+
*
|
|
964
|
+
* @param transcriptPath - Absolute path to the sub-agent's own `.jsonl`
|
|
965
|
+
* transcript (SubagentStop payload field `agent_transcript_path`).
|
|
966
|
+
* @returns Distinct written paths (order-preserving), or `null` when unreadable.
|
|
967
|
+
*/
|
|
968
|
+
function filesWrittenByAgent(transcriptPath) {
|
|
969
|
+
if (!transcriptPath) return null;
|
|
970
|
+
let text;
|
|
971
|
+
try {
|
|
972
|
+
text = readText(transcriptPath);
|
|
973
|
+
} catch {
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
const out = [];
|
|
977
|
+
const seen = /* @__PURE__ */ new Set();
|
|
978
|
+
for (const line of text.split("\n")) {
|
|
979
|
+
if (!line.trim()) continue;
|
|
980
|
+
let entry;
|
|
981
|
+
try {
|
|
982
|
+
entry = JSON.parse(line);
|
|
983
|
+
} catch {
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
const content = entry.message?.content;
|
|
987
|
+
if (!Array.isArray(content)) continue;
|
|
988
|
+
for (const block of content) {
|
|
989
|
+
if (block?.type !== "tool_use" || !block.name || !WRITE_TOOLS.has(block.name)) continue;
|
|
990
|
+
const fp = block.input?.file_path ?? block.input?.notebook_path;
|
|
991
|
+
if (typeof fp === "string" && fp && !seen.has(fp)) {
|
|
992
|
+
seen.add(fp);
|
|
993
|
+
out.push(fp);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
return out;
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Filter `sessionFiles` (from unified state `.changes.modifiedFiles`) down to the
|
|
1001
|
+
* ones this agent actually wrote. Matches on the exact recorded string first,
|
|
1002
|
+
* then tolerantly on `basename` so a relative-vs-absolute path drift between the
|
|
1003
|
+
* PostToolUse record and the transcript input never loses an owned file.
|
|
1004
|
+
* @param sessionFiles - Session-wide modified files.
|
|
1005
|
+
* @param written - Paths the agent wrote (from {@link filesWrittenByAgent}).
|
|
1006
|
+
* @returns The subset of `sessionFiles` attributable to this agent.
|
|
1007
|
+
*/
|
|
1008
|
+
function attributeFiles(sessionFiles, written) {
|
|
1009
|
+
const exact = new Set(written);
|
|
1010
|
+
const bases = new Set(written.map((f) => basename(f)));
|
|
1011
|
+
return sessionFiles.filter((f) => exact.has(f) || bases.has(basename(f)));
|
|
1012
|
+
}
|
|
1013
|
+
//#endregion
|
|
868
1014
|
//#region src/runtime/lifecycle/agent-memory.ts
|
|
869
1015
|
/** `~/.claude/memory/agents` — agent completion history dir. */
|
|
870
1016
|
function memoryDir(home) {
|
|
@@ -901,17 +1047,19 @@ function trackAgentMemory(data, home = homedir(), now = Date.now()) {
|
|
|
901
1047
|
if (SKIP_AGENTS.test(agentType)) return JSON.stringify({ message: `Agent ${agentType} completed` });
|
|
902
1048
|
const state = loadSessionState(sessionId, home);
|
|
903
1049
|
const changes = state.changes;
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
const
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1050
|
+
if ((changes?.cumulativeCodeFiles ?? 0) > 0) {
|
|
1051
|
+
const written = filesWrittenByAgent(typeof data.agent_transcript_path === "string" ? data.agent_transcript_path : void 0);
|
|
1052
|
+
const owned = written === null ? changes?.modifiedFiles ?? [] : attributeFiles(changes?.modifiedFiles ?? [], written);
|
|
1053
|
+
if (owned.length > 0) {
|
|
1054
|
+
saveSessionState(sessionId, {
|
|
1055
|
+
...state,
|
|
1056
|
+
changes: {
|
|
1057
|
+
...changes,
|
|
1058
|
+
cumulativeCodeFiles: 0
|
|
1059
|
+
}
|
|
1060
|
+
}, home);
|
|
1061
|
+
return contextResponse("SubagentStop", `SNIPER VALIDATION REQUIRED: Agent '${agentType}' modified ${owned.length} code file(s): ${owned.join(", ")}. Run sniper agent now.`);
|
|
1062
|
+
}
|
|
915
1063
|
}
|
|
916
1064
|
return JSON.stringify({ message: `Agent ${agentType} completed (no code changes)` });
|
|
917
1065
|
}
|
|
@@ -1553,6 +1701,109 @@ function cartoSessionStart(cwd, now = Date.now()) {
|
|
|
1553
1701
|
return ctx ? contextResponse("SessionStart", ctx) : "";
|
|
1554
1702
|
}
|
|
1555
1703
|
//#endregion
|
|
1704
|
+
//#region src/runtime/lifecycle/aipilot/curate-lessons.ts
|
|
1705
|
+
/**
|
|
1706
|
+
* Mechanical, LLM-free curation of `MEMORY/LESSON.md` bullets (anti-obesity):
|
|
1707
|
+
* strict-dedup near-identical bullets (keep newest, `[TRIGGERS …]` preserved),
|
|
1708
|
+
* flag over-cap + stale (>90d, cited path gone) in a report. Only dedup writes.
|
|
1709
|
+
*/
|
|
1710
|
+
const CAP = 50;
|
|
1711
|
+
const STALE_DAYS = 90;
|
|
1712
|
+
const SIM_THRESHOLD = .8;
|
|
1713
|
+
const MIN_TOKENS = 4;
|
|
1714
|
+
const TRIG = /^\[TRIGGERS\s+.+\]$/;
|
|
1715
|
+
/** Epoch ms for a `[YYYY-MM-DD HH:MM]` stamp; `NaN` if absent or out of range. */
|
|
1716
|
+
function parseTs$1(line) {
|
|
1717
|
+
const m = line.match(/\[(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?/);
|
|
1718
|
+
if (!m) return NaN;
|
|
1719
|
+
const mo = +(m[2] ?? 0), d = +(m[3] ?? 0);
|
|
1720
|
+
if (mo < 1 || mo > 12 || d < 1 || d > 31) return NaN;
|
|
1721
|
+
return Date.UTC(+(m[1] ?? 0), mo - 1, d, +(m[4] ?? 0), +(m[5] ?? 0));
|
|
1722
|
+
}
|
|
1723
|
+
/** Content words (>=4 chars), timestamp & TRIGGERS marker stripped. */
|
|
1724
|
+
function tokenize(text) {
|
|
1725
|
+
return new Set(text.toLowerCase().replace(/\[triggers[^\]]*\]/g, " ").replace(/\[\d{4}-\d{2}-\d{2}[^\]]*\]/g, " ").replace(/[^a-z0-9àâäéèêëîïôöùûüç/._-]+/gi, " ").split(/\s+/).filter((t) => t.length >= 4));
|
|
1726
|
+
}
|
|
1727
|
+
/** Jaccard overlap of two token sets (0 when both empty). */
|
|
1728
|
+
function jaccard(a, b) {
|
|
1729
|
+
if (a.size === 0 && b.size === 0) return 0;
|
|
1730
|
+
const inter = [...a].filter((t) => b.has(t)).length;
|
|
1731
|
+
return inter / (a.size + b.size - inter);
|
|
1732
|
+
}
|
|
1733
|
+
/** Repo-relative cited paths (slash + extension) referenced in a block. */
|
|
1734
|
+
function citedPaths(text) {
|
|
1735
|
+
const out = /* @__PURE__ */ new Set();
|
|
1736
|
+
for (const m of text.matchAll(/`([^`]+)`/g)) if (m[1]) out.add(m[1]);
|
|
1737
|
+
for (const m of text.matchAll(/[\w./@-]+\.\w{1,5}/g)) if (m[0]) out.add(m[0]);
|
|
1738
|
+
return [...out].filter((p) => p.includes("/") && /\.\w{1,5}$/.test(p));
|
|
1739
|
+
}
|
|
1740
|
+
/** Split into a verbatim preamble and one Block per `- ` bullet. */
|
|
1741
|
+
function parse(content) {
|
|
1742
|
+
const lines = content.split("\n");
|
|
1743
|
+
const blocks = [];
|
|
1744
|
+
let i = 0;
|
|
1745
|
+
while (i < lines.length && !/^-\s/.test(lines[i] ?? "")) i++;
|
|
1746
|
+
const preamble = lines.slice(0, i).join("\n");
|
|
1747
|
+
for (; i < lines.length; i++) {
|
|
1748
|
+
const l = lines[i] ?? "", last = blocks[blocks.length - 1];
|
|
1749
|
+
if (/^-\s/.test(l)) blocks.push({
|
|
1750
|
+
raw: [l],
|
|
1751
|
+
ts: parseTs$1(l),
|
|
1752
|
+
tokens: tokenize(l)
|
|
1753
|
+
});
|
|
1754
|
+
else if (l.trim() && last) last.raw.push(l);
|
|
1755
|
+
}
|
|
1756
|
+
return {
|
|
1757
|
+
preamble,
|
|
1758
|
+
blocks
|
|
1759
|
+
};
|
|
1760
|
+
}
|
|
1761
|
+
/** Report lines for bullets older than STALE_DAYS whose only cited path is gone. */
|
|
1762
|
+
function staleReport(blocks, now, root) {
|
|
1763
|
+
const cutoff = now - STALE_DAYS * 864e5;
|
|
1764
|
+
return blocks.flatMap((b) => {
|
|
1765
|
+
if (!(b.ts <= cutoff)) return [];
|
|
1766
|
+
const paths = citedPaths(b.raw.join(" "));
|
|
1767
|
+
if (paths.length === 0 || paths.some((p) => existsSync(join(root, p)))) return [];
|
|
1768
|
+
return [`[STALE?] ${(b.raw[0] ?? "").slice(0, 90)} — chemin(s) disparu(s): ${paths.join(", ")}`];
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
/**
|
|
1772
|
+
* Strict-dedup LESSON.md bullets (keep newest, its `[TRIGGERS …]` line preserved —
|
|
1773
|
+
* or carried over from the dropped twin if the kept one lacks it) and report
|
|
1774
|
+
* cap/stale. `content` unchanged unless a dedup occurred. Returns content + report.
|
|
1775
|
+
*/
|
|
1776
|
+
function curateLessons(content, now, root = process.cwd()) {
|
|
1777
|
+
const { preamble, blocks } = parse(content);
|
|
1778
|
+
const kept = [];
|
|
1779
|
+
const fused = [];
|
|
1780
|
+
for (const b of blocks) {
|
|
1781
|
+
const hit = b.tokens.size >= MIN_TOKENS ? kept.find((k) => k.tokens.size >= MIN_TOKENS && jaccard(k.tokens, b.tokens) >= SIM_THRESHOLD) : void 0;
|
|
1782
|
+
if (!hit) {
|
|
1783
|
+
kept.push(b);
|
|
1784
|
+
continue;
|
|
1785
|
+
}
|
|
1786
|
+
const [win, drop] = b.ts > hit.ts || Number.isNaN(hit.ts) ? [b, hit] : [hit, b];
|
|
1787
|
+
if (win !== hit) kept[kept.indexOf(hit)] = win;
|
|
1788
|
+
if (!win.raw.some((l) => TRIG.test(l.trim()))) {
|
|
1789
|
+
const t = drop.raw.find((l) => TRIG.test(l.trim()));
|
|
1790
|
+
if (t) win.raw.push(t);
|
|
1791
|
+
}
|
|
1792
|
+
fused.push(`fusion: gardé ${(win.raw[0] ?? "").slice(0, 60)} · retiré ${(drop.raw[0] ?? "").slice(0, 60)}`);
|
|
1793
|
+
}
|
|
1794
|
+
const old = [...kept].sort((a, b) => a.ts - b.ts).slice(0, Math.max(0, kept.length - CAP));
|
|
1795
|
+
const cap = old.length ? [`${kept.length} bullets (> ${CAP}) — plus anciens candidats à l'archivage:`, ...old.map((b) => ` ${(b.raw[0] ?? "").slice(0, 80)}`)] : [];
|
|
1796
|
+
const report = [
|
|
1797
|
+
...fused,
|
|
1798
|
+
...cap,
|
|
1799
|
+
...staleReport(blocks, now, root)
|
|
1800
|
+
].join("\n");
|
|
1801
|
+
return {
|
|
1802
|
+
content: fused.length ? `${preamble}\n${kept.map((b) => b.raw.join("\n")).join("\n\n")}\n` : content,
|
|
1803
|
+
report
|
|
1804
|
+
};
|
|
1805
|
+
}
|
|
1806
|
+
//#endregion
|
|
1556
1807
|
//#region src/runtime/lifecycle/lessons/state.ts
|
|
1557
1808
|
/**
|
|
1558
1809
|
* Per-project lessons paths. The `fuse-lessons` plugin stores its lessons under
|
|
@@ -1577,9 +1828,10 @@ function lessonsStateFileFor(root) {
|
|
|
1577
1828
|
* across every project with unsaved code edits; PostToolUse marks the write to
|
|
1578
1829
|
* arm/silence the per-project throttle. Non-fatal by design.
|
|
1579
1830
|
*/
|
|
1580
|
-
/** Inject
|
|
1581
|
-
function injectMemory(cwd, event) {
|
|
1582
|
-
const
|
|
1831
|
+
/** Inject `MEMORY/LESSON.md` for `event`, after mechanical curation (a strict dedup rewrites the file in place; any report surfaces to the user via systemMessage). */
|
|
1832
|
+
function injectMemory(cwd, event, now) {
|
|
1833
|
+
const root = projectRoot(cwd);
|
|
1834
|
+
const file = lessonsFileFor(root);
|
|
1583
1835
|
if (!existsSync(file)) return "";
|
|
1584
1836
|
let content = "";
|
|
1585
1837
|
try {
|
|
@@ -1588,7 +1840,13 @@ function injectMemory(cwd, event) {
|
|
|
1588
1840
|
return "";
|
|
1589
1841
|
}
|
|
1590
1842
|
if (!content) return "";
|
|
1591
|
-
|
|
1843
|
+
const { content: curated, report } = curateLessons(content, now, root);
|
|
1844
|
+
if (curated !== content) try {
|
|
1845
|
+
atomicWrite(file, curated);
|
|
1846
|
+
content = curated;
|
|
1847
|
+
} catch {}
|
|
1848
|
+
const ctx = `Project lessons — never reproduce these:\n${content}\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.`;
|
|
1849
|
+
return report ? attachSystemMessage(contextResponse(event, ctx), `LESSON.md curation:\n${report}`) : contextResponse(event, ctx);
|
|
1592
1850
|
}
|
|
1593
1851
|
/** Select roots with unsaved code edits past the throttle, bumping their state. */
|
|
1594
1852
|
function collectPending(now, window) {
|
|
@@ -1636,7 +1894,7 @@ function markWrite(payload, now) {
|
|
|
1636
1894
|
function dispatchLessons(event, payload, cwd, now) {
|
|
1637
1895
|
switch (event) {
|
|
1638
1896
|
case "SessionStart":
|
|
1639
|
-
case "SubagentStart": return injectMemory(cwd, event);
|
|
1897
|
+
case "SubagentStart": return injectMemory(cwd, event, now);
|
|
1640
1898
|
case "Stop": return remindWrite(now);
|
|
1641
1899
|
case "PostToolUse":
|
|
1642
1900
|
markWrite(payload, now);
|
|
@@ -3529,6 +3787,50 @@ async function postEditContext(scope, event, now) {
|
|
|
3529
3787
|
return contextResponse("PostToolUse", `${additionalContextOf(sniper)}\n\n${additionalContextOf(lint)}`);
|
|
3530
3788
|
}
|
|
3531
3789
|
//#endregion
|
|
3790
|
+
//#region src/policy/trivial-edits.ts
|
|
3791
|
+
/**
|
|
3792
|
+
* Pre-filters of the ported enforce-apex-phases.ts hook, in Python order
|
|
3793
|
+
* (lines 48-65): protected-path guard first, then the dependency/build-dir
|
|
3794
|
+
* skip, then the trivial-edit fast path. The trivial COUNTER itself lives in
|
|
3795
|
+
* the SessionTrack (`trivialEdits`, see recordTrivialEdit/trivialCount) — no
|
|
3796
|
+
* separate state file.
|
|
3797
|
+
*/
|
|
3798
|
+
/** Hook-managed state paths — Write/Edit always denied (parity enforce-apex-phases.ts PROTECTED_PATHS). */
|
|
3799
|
+
const PROTECTED_PATHS = /\.claude\/(plugins\/(marketplaces|cache)|logs\/00-apex|fusengine-cache\/skill-tracking)/;
|
|
3800
|
+
/** Dependency/build dirs the APEX gates skip entirely (parity enforce-apex-phases.ts SKIP_DIRS). */
|
|
3801
|
+
const SKIP_DIRS = /(node_modules|vendor|dist|build|\.next|DerivedData)/;
|
|
3802
|
+
/**
|
|
3803
|
+
* Absolute deny on Write/Edit of hook-managed state paths, with the Python
|
|
3804
|
+
* wording byte-for-byte. Runs BEFORE the code-ext/skip-dirs filters (parity
|
|
3805
|
+
* enforce-apex-phases.ts:48-54), so non-code files under the protected roots
|
|
3806
|
+
* are covered too.
|
|
3807
|
+
* @param tool - The harness tool name (only Write/Edit are policed).
|
|
3808
|
+
* @param filePath - Target path of the edit (or undefined).
|
|
3809
|
+
* @returns A blocking prompt, or null to fall through.
|
|
3810
|
+
*/
|
|
3811
|
+
function protectedPathGate(tool, filePath) {
|
|
3812
|
+
if (tool !== "Write" && tool !== "Edit" || !filePath || !PROTECTED_PATHS.test(filePath)) return null;
|
|
3813
|
+
return {
|
|
3814
|
+
kind: "block",
|
|
3815
|
+
title: "APEX Hook Guard: protected path",
|
|
3816
|
+
reason: "[APEX Hook Guard] Write blocked — this path is managed automatically by APEX hooks. Manual edits are forbidden and would corrupt tracked state."
|
|
3817
|
+
};
|
|
3818
|
+
}
|
|
3819
|
+
/**
|
|
3820
|
+
* True when this tool-use qualifies for the trivial fast path (parity
|
|
3821
|
+
* enforce-apex-phases.ts:58-65): only an Edit (a Write always creates or
|
|
3822
|
+
* replaces a file wholesale), NEVER `replace_all`, and a new_string under
|
|
3823
|
+
* {@link TRIVIAL_MAX_LINES} lines. Absent content is NOT trivial (fail closed
|
|
3824
|
+
* — the full gates run).
|
|
3825
|
+
* @param tool - The harness tool name.
|
|
3826
|
+
* @param content - The Edit's new_string (adapter-normalized).
|
|
3827
|
+
* @param isReplaceAll - The Edit's replace_all flag.
|
|
3828
|
+
*/
|
|
3829
|
+
function isTrivialEdit(tool, content, isReplaceAll) {
|
|
3830
|
+
if (tool !== "Edit" || isReplaceAll === true || content === void 0) return false;
|
|
3831
|
+
return content.split("\n").length < 5;
|
|
3832
|
+
}
|
|
3833
|
+
//#endregion
|
|
3532
3834
|
//#region src/runtime/gate-helpers.ts
|
|
3533
3835
|
/** Source extensions the APEX gates apply to (parity: require-apex-agents.py CODE_EXT). */
|
|
3534
3836
|
const CODE_EXT = /\.(ts|tsx|js|jsx|py|go|rs|java|php|cpp|c|rb|swift|kt|dart|vue|svelte|astro)$/;
|
|
@@ -4113,7 +4415,14 @@ function geminiMcpGate(tool, filePath, content, ev) {
|
|
|
4113
4415
|
//#region src/freshness/explore-tools.ts
|
|
4114
4416
|
/** Native exploration tools (parity `apex_constants.EXPLORE_TOOLS`). */
|
|
4115
4417
|
const EXPLORE_TOOLS = /* @__PURE__ */ new Set(["Glob", "Grep"]);
|
|
4116
|
-
/**
|
|
4418
|
+
/**
|
|
4419
|
+
* Research tools — MCP docs + web (parity `apex_constants.RESEARCH_TOOLS`).
|
|
4420
|
+
* The fuse-browser fast-path entries are a deliberate TS addition: CLAUDE.md
|
|
4421
|
+
* mandates fuse-browser FIRST for web research, and `docSourceOf` (activity.ts)
|
|
4422
|
+
* already credits it for doc consultation — without them here, an agent
|
|
4423
|
+
* following the rules would never satisfy the freshness gate (same
|
|
4424
|
+
* cross-consumer inconsistency class as the solidReadGate lesson).
|
|
4425
|
+
*/
|
|
4117
4426
|
const RESEARCH_TOOLS = /* @__PURE__ */ new Set([
|
|
4118
4427
|
"mcp__context7__query-docs",
|
|
4119
4428
|
"mcp__context7__resolve-library-id",
|
|
@@ -4121,7 +4430,11 @@ const RESEARCH_TOOLS = /* @__PURE__ */ new Set([
|
|
|
4121
4430
|
"mcp__exa__get_code_context_exa",
|
|
4122
4431
|
"mcp__exa__deep_researcher_start",
|
|
4123
4432
|
"WebSearch",
|
|
4124
|
-
"WebFetch"
|
|
4433
|
+
"WebFetch",
|
|
4434
|
+
"mcp__fuse-browser__browser_fetch",
|
|
4435
|
+
"mcp__fuse-browser__browser_fetch_batch",
|
|
4436
|
+
"mcp__fuse-browser__browser_crawl",
|
|
4437
|
+
"mcp__fuse-browser__browser_serp_batch"
|
|
4125
4438
|
]);
|
|
4126
4439
|
/** Bash executables that count as exploration (parity `EXPLORE_BASH_CMDS`). */
|
|
4127
4440
|
const EXPLORE_BASH_CMDS = /* @__PURE__ */ new Set([
|
|
@@ -4269,25 +4582,120 @@ function agentsRanFromTranscript(transcriptPath, names, windowMs, now) {
|
|
|
4269
4582
|
return names.every((n) => found.has(n));
|
|
4270
4583
|
}
|
|
4271
4584
|
//#endregion
|
|
4585
|
+
//#region src/freshness/agent-evidence-record.ts
|
|
4586
|
+
/**
|
|
4587
|
+
* Session-track agent evidence — writer (parity track-subagent-research.py) +
|
|
4588
|
+
* reader scan (parity apex_agent_helpers._scan_agents). Sub-agent hooks fire
|
|
4589
|
+
* with the LEAD's `session_id`, so evidence recorded here lands in the ONE
|
|
4590
|
+
* session track the freshness gate scans FIRST — sidechain research and
|
|
4591
|
+
* Workflow-spawned agents count, unlike the lead-transcript scan.
|
|
4592
|
+
*/
|
|
4593
|
+
/** Tools the existing Task tracking already credits as agent LAUNCHES. */
|
|
4594
|
+
const AGENT_LAUNCH_TOOLS = /* @__PURE__ */ new Set(["Task", "Agent"]);
|
|
4595
|
+
/**
|
|
4596
|
+
* `JSON.stringify` length of the raw `tool_response` OBJECT (parity Python
|
|
4597
|
+
* `len(str(tool_response))`); 0 when absent or unserializable (circular).
|
|
4598
|
+
*/
|
|
4599
|
+
function responseJsonLength(toolResponse) {
|
|
4600
|
+
if (toolResponse === void 0 || toolResponse === null) return 0;
|
|
4601
|
+
try {
|
|
4602
|
+
return (JSON.stringify(toolResponse) ?? "").length;
|
|
4603
|
+
} catch {
|
|
4604
|
+
return 0;
|
|
4605
|
+
}
|
|
4606
|
+
}
|
|
4607
|
+
/**
|
|
4608
|
+
* Classify one PostToolUse call into session evidence, or null when it is
|
|
4609
|
+
* neither exploration nor research (parity track-subagent-research._classify,
|
|
4610
|
+
* reusing the shared {@link classifyExplore} tables — research/explore/Bash/
|
|
4611
|
+
* cache-read). ANTI-DOUBLE-COUNT: calls classified as agent launches
|
|
4612
|
+
* (`Task`/`Agent`) are skipped — the existing Task tracking credits those; the
|
|
4613
|
+
* criterion is the CALL's classification, never its provenance (lead vs sub).
|
|
4614
|
+
* @param tool - Harness tool name (e.g. "Glob", "Bash", "Read").
|
|
4615
|
+
* @param input - Raw tool input payload.
|
|
4616
|
+
* @param toolResponse - Raw `tool_response` OBJECT from the hook payload.
|
|
4617
|
+
* @returns The evidence to record, or null to skip.
|
|
4618
|
+
*/
|
|
4619
|
+
function classifyAgentEvidence(tool, input, toolResponse) {
|
|
4620
|
+
if (AGENT_LAUNCH_TOOLS.has(tool)) return null;
|
|
4621
|
+
const hit = classifyExplore(tool, input);
|
|
4622
|
+
if (!hit) return null;
|
|
4623
|
+
return {
|
|
4624
|
+
name: hit.phase === "explore-codebase" ? "subagent-explore-codebase" : "subagent-research-expert",
|
|
4625
|
+
quality: hit.cacheHit || responseJsonLength(toolResponse) > 50 ? "sufficient" : "insufficient"
|
|
4626
|
+
};
|
|
4627
|
+
}
|
|
4628
|
+
/**
|
|
4629
|
+
* Persist evidence into the SESSION track via {@link recordAgent} — keyed by
|
|
4630
|
+
* `session_id` alone (sub-agent hooks carry the lead's `session_id`); the TTL
|
|
4631
|
+
* anchors on `ts`, the tool call's own timestamp. `agentId` is tagged as
|
|
4632
|
+
* metadata when present, NEVER used as a condition (unreliable field —
|
|
4633
|
+
* anthropics/claude-code#22348).
|
|
4634
|
+
* @param file - Session track file path.
|
|
4635
|
+
* @param evidence - Classified evidence from {@link classifyAgentEvidence}.
|
|
4636
|
+
* @param ts - Epoch-ms timestamp of the tool call (the hook event's `now`).
|
|
4637
|
+
* @param agentId - Optional Claude `agent_id` — metadata tag only.
|
|
4638
|
+
*/
|
|
4639
|
+
async function recordAgentEvidence(file, evidence, ts, agentId) {
|
|
4640
|
+
const next = recordAgent(await loadTrack(file), evidence.name, ts, evidence.quality);
|
|
4641
|
+
const last = next.agents[next.agents.length - 1];
|
|
4642
|
+
if (agentId && last) {
|
|
4643
|
+
const tagged = {
|
|
4644
|
+
...last,
|
|
4645
|
+
agentId
|
|
4646
|
+
};
|
|
4647
|
+
next.agents[next.agents.length - 1] = tagged;
|
|
4648
|
+
}
|
|
4649
|
+
await saveTrack(file, next);
|
|
4650
|
+
}
|
|
4651
|
+
/**
|
|
4652
|
+
* Parity `_scan_agents` (apex_agent_helpers.py): reverse-scan `track.agents`,
|
|
4653
|
+
* STOPPING at the first entry older than `windowMs` (entries append in time
|
|
4654
|
+
* order), matching each required name by SUBSTRING (`research-expert` matches
|
|
4655
|
+
* `subagent-research-expert`) and counting ONLY `quality === "sufficient"`.
|
|
4656
|
+
* @param track - The loaded session track.
|
|
4657
|
+
* @param names - Required agent names — ALL must match to return true.
|
|
4658
|
+
* @param windowMs - Freshness window (ms), anchored on each entry's `ts`.
|
|
4659
|
+
* @param now - Current epoch ms.
|
|
4660
|
+
* @returns True when every required name has fresh, sufficient evidence.
|
|
4661
|
+
*/
|
|
4662
|
+
function agentsFreshInTrack(track, names, windowMs, now) {
|
|
4663
|
+
const found = /* @__PURE__ */ new Set();
|
|
4664
|
+
for (let i = track.agents.length - 1; i >= 0; i--) {
|
|
4665
|
+
const entry = track.agents[i];
|
|
4666
|
+
if (!entry) continue;
|
|
4667
|
+
if (now - entry.ts > windowMs) break;
|
|
4668
|
+
if (entry.quality !== "sufficient") continue;
|
|
4669
|
+
for (const req of names) if (entry.name.includes(req)) found.add(req);
|
|
4670
|
+
if (found.size === names.length) return true;
|
|
4671
|
+
}
|
|
4672
|
+
return names.every((n) => found.has(n));
|
|
4673
|
+
}
|
|
4674
|
+
//#endregion
|
|
4272
4675
|
//#region src/runtime/gate-apex.ts
|
|
4273
4676
|
/**
|
|
4274
|
-
* The APEX-scoped portion of {@link gate}
|
|
4275
|
-
*
|
|
4276
|
-
*
|
|
4277
|
-
*
|
|
4278
|
-
*
|
|
4677
|
+
* The APEX-scoped portion of {@link gate}, in the Python enforce-apex-phases.ts
|
|
4678
|
+
* order: protected-paths guard, skip-dirs allow, trivial-edit fast path, then
|
|
4679
|
+
* brainstorm/freshness, Check 1 (per-framework doc authorization — a deny
|
|
4680
|
+
* persists `track.target` so recordDoc can cross-credit the framework) and
|
|
4681
|
+
* Check 2 + SOLID refs. Freshness scans the SESSION track first (parity
|
|
4682
|
+
* _scan_agents: sub-agent hooks share the lead's session_id, so sidechain/
|
|
4683
|
+
* Workflow research is visible); the platform-authored transcript scan and the
|
|
4684
|
+
* legacy exact-name track check are fallbacks.
|
|
4279
4685
|
* @param input - The tool-use to gate.
|
|
4280
4686
|
* @param track - The loaded session track.
|
|
4281
4687
|
* @param window - Freshness window in ms.
|
|
4282
4688
|
* @returns The first blocking prompt, or null to fall through to the DRY check.
|
|
4283
4689
|
*/
|
|
4284
4690
|
async function apexScopedGate(input, track, window) {
|
|
4285
|
-
const
|
|
4286
|
-
if (
|
|
4691
|
+
const protectedDeny = protectedPathGate(input.tool, input.filePath);
|
|
4692
|
+
if (protectedDeny) return protectedDeny;
|
|
4693
|
+
if (SKIP_DIRS.test(input.filePath ?? "")) return null;
|
|
4694
|
+
if (isTrivialEdit(input.tool, input.content, input.isReplaceAll) && trivialCount(track, window, input.now) < 4) {
|
|
4287
4695
|
await saveTrack(input.trackFile, recordTrivialEdit(track, input.now, window, input.now));
|
|
4288
4696
|
return null;
|
|
4289
4697
|
}
|
|
4290
|
-
const freshnessFor = (names, windowMs = window) => input.transcriptPath ? agentsRanFromTranscript(input.transcriptPath, names, windowMs, input.now) : agentsFresh(track, names, windowMs, input.now);
|
|
4698
|
+
const freshnessFor = (names, windowMs = window) => agentsFreshInTrack(track, names, windowMs, input.now) || (input.transcriptPath ? agentsRanFromTranscript(input.transcriptPath, names, windowMs, input.now) : agentsFresh(track, names, windowMs, input.now));
|
|
4291
4699
|
const ctx = {
|
|
4292
4700
|
sessionId: input.sessionId,
|
|
4293
4701
|
framework: input.framework,
|
|
@@ -4305,7 +4713,20 @@ async function apexScopedGate(input, track, window) {
|
|
|
4305
4713
|
brainstormFresh: freshnessFor(["brainstorming"], Number.MAX_SAFE_INTEGER)
|
|
4306
4714
|
};
|
|
4307
4715
|
try {
|
|
4308
|
-
|
|
4716
|
+
const pre = evaluateApex(ctx, PRE_AUTH_GATES);
|
|
4717
|
+
if (pre) return pre;
|
|
4718
|
+
const authDeny = apexAuthorizationGate(ctx);
|
|
4719
|
+
if (authDeny) {
|
|
4720
|
+
const set_at = new Date(input.now).toISOString();
|
|
4721
|
+
await saveTrack(input.trackFile, recordTarget(track, {
|
|
4722
|
+
project: input.cwd ?? "",
|
|
4723
|
+
framework: input.framework,
|
|
4724
|
+
set_by: "apex-authorization",
|
|
4725
|
+
set_at
|
|
4726
|
+
}));
|
|
4727
|
+
return authDeny;
|
|
4728
|
+
}
|
|
4729
|
+
return evaluateApex(ctx, POST_AUTH_GATES);
|
|
4309
4730
|
} catch {
|
|
4310
4731
|
return FAIL_CLOSED;
|
|
4311
4732
|
}
|
|
@@ -4331,6 +4752,8 @@ const TRIVIAL_BUDGET = 4;
|
|
|
4331
4752
|
async function gate(input) {
|
|
4332
4753
|
const precommit = preCommitGate(input.tool, input.command, input.cwd);
|
|
4333
4754
|
if (precommit) return precommit;
|
|
4755
|
+
const protectedDeny = protectedPathGate(input.tool, input.filePath);
|
|
4756
|
+
if (protectedDeny) return protectedDeny;
|
|
4334
4757
|
const { raw: existingLines, code: existingCodeLines } = existingLineCounts(input.filePath);
|
|
4335
4758
|
let quick;
|
|
4336
4759
|
try {
|
|
@@ -4493,7 +4916,7 @@ function mcpPostStore(tool, input, response, dir, now = Date.now()) {
|
|
|
4493
4916
|
/** Apply an activity to a session's track and persist it (PostToolUse path). */
|
|
4494
4917
|
async function recordActivity(file, activity) {
|
|
4495
4918
|
const track = await loadTrack(file);
|
|
4496
|
-
await saveTrack(file, activity.kind === "agent" ? recordAgent(track, activity.name, activity.ts, activity.quality) : activity.kind === "doc" ? recordDoc(track, activity.framework, activity.sessionId, activity.source) : recordRefRead(track, activity.path, activity.ts));
|
|
4919
|
+
await saveTrack(file, activity.kind === "agent" ? recordAgent(track, activity.name, activity.ts, activity.quality) : activity.kind === "doc" ? recordDoc(track, activity.framework, activity.sessionId, activity.source, activity.ts) : recordRefRead(track, activity.path, activity.ts));
|
|
4497
4920
|
}
|
|
4498
4921
|
//#endregion
|
|
4499
4922
|
//#region src/runtime/respond.ts
|
|
@@ -4501,51 +4924,81 @@ async function recordActivity(file, activity) {
|
|
|
4501
4924
|
* Map a portable {@link Prompt} to a harness's native hook response, honoring
|
|
4502
4925
|
* all 3 prompt kinds (`block`/`ask`/`inform`) instead of collapsing `ask` and
|
|
4503
4926
|
* `inform` together. Reuses the Claude adapter's `denyResponse`/
|
|
4504
|
-
* `contextResponse` builders for claude-code/codex (same hook
|
|
4505
|
-
* duplicating the JSON.
|
|
4927
|
+
* `contextResponse`/`informResponse` builders for claude-code/codex (same hook
|
|
4928
|
+
* shape) to avoid duplicating the JSON.
|
|
4506
4929
|
* - claude-code/codex: `block` -> deny, `ask` -> interactive
|
|
4507
4930
|
* `permissionDecision:"ask"`, `inform` -> non-blocking `additionalContext`.
|
|
4508
4931
|
* - gemini-cli/cline: their real hook schemas have no interactive "ask"
|
|
4509
4932
|
* state (deny is the only blocking outcome), so `ask` and `inform` already
|
|
4510
4933
|
* both resolve to non-blocking context injection — unchanged.
|
|
4511
|
-
* - cursor: `ask` keeps its current best-effort `permission:"ask"` shape
|
|
4512
|
-
*
|
|
4513
|
-
*
|
|
4514
|
-
*
|
|
4934
|
+
* - cursor: `ask` keeps its current best-effort `permission:"ask"` shape;
|
|
4935
|
+
* only `inform` is fixed to a non-blocking `permission:"allow"` note.
|
|
4936
|
+
* - hermes: delegated to the adapter's `toHermesResponse` — `block` ->
|
|
4937
|
+
* `{decision:"block",reason}`; `ask`/`inform` degrade to non-blocking
|
|
4938
|
+
* `{context}` (Hermes has no interactive "ask" state).
|
|
4939
|
+
*
|
|
4940
|
+
* User-visible pass notices (`prompt.userMessage`, Python `hook_output.allow_pass`
|
|
4941
|
+
* / `post_pass` parity), by harness:
|
|
4942
|
+
* - claude-code/codex: top-level `systemMessage` ("warning message shown to the
|
|
4943
|
+
* user"); a pure notice (empty `reason`) emits `{systemMessage}` alone. Codex
|
|
4944
|
+
* consumes the Claude hook schema, so it rides the same field.
|
|
4945
|
+
* - gemini-cli: common `systemMessage` output field ("displayed immediately to
|
|
4946
|
+
* the user in the terminal") — the notice rides there.
|
|
4947
|
+
* - cursor: `user_message` is its user-visible channel (see case comment).
|
|
4948
|
+
* - cline: NO user-visible channel (`cancel`/`contextModification`/
|
|
4949
|
+
* `errorMessage`-on-cancel only) — the notice is dropped silently (a pure
|
|
4950
|
+
* notice returns "" = plain allow).
|
|
4515
4951
|
*/
|
|
4516
4952
|
function respond(id, prompt) {
|
|
4517
4953
|
const message = formatPrompt(prompt);
|
|
4518
|
-
const { kind } = prompt;
|
|
4954
|
+
const { kind, userMessage, reason } = prompt;
|
|
4519
4955
|
switch (id) {
|
|
4520
4956
|
case "claude-code":
|
|
4521
4957
|
case "codex":
|
|
4522
4958
|
if (kind === "block") return denyResponse("PreToolUse", message);
|
|
4523
|
-
if (kind === "inform") return contextResponse("PreToolUse", message);
|
|
4959
|
+
if (kind === "inform") return userMessage ? informResponse("PreToolUse", userMessage, reason ? message : "") : contextResponse("PreToolUse", message);
|
|
4524
4960
|
return JSON.stringify({ hookSpecificOutput: {
|
|
4525
4961
|
hookEventName: "PreToolUse",
|
|
4526
4962
|
permissionDecision: "ask",
|
|
4527
4963
|
permissionDecisionReason: message
|
|
4528
4964
|
} });
|
|
4529
|
-
case "gemini-cli":
|
|
4530
|
-
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
if (
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
agentMessage: message
|
|
4965
|
+
case "gemini-cli":
|
|
4966
|
+
if (kind === "block") return JSON.stringify({
|
|
4967
|
+
decision: "deny",
|
|
4968
|
+
reason: message
|
|
4969
|
+
});
|
|
4970
|
+
if (userMessage) return JSON.stringify({
|
|
4971
|
+
...reason ? { hookSpecificOutput: { additionalContext: message } } : {},
|
|
4972
|
+
systemMessage: userMessage
|
|
4538
4973
|
});
|
|
4974
|
+
return JSON.stringify({ hookSpecificOutput: { additionalContext: message } });
|
|
4975
|
+
case "cursor":
|
|
4976
|
+
if (kind === "inform") {
|
|
4977
|
+
if (userMessage) return JSON.stringify({
|
|
4978
|
+
permission: "allow",
|
|
4979
|
+
user_message: userMessage,
|
|
4980
|
+
...reason ? { agent_message: message } : {}
|
|
4981
|
+
});
|
|
4982
|
+
return JSON.stringify({
|
|
4983
|
+
permission: "allow",
|
|
4984
|
+
user_message: message,
|
|
4985
|
+
agent_message: message
|
|
4986
|
+
});
|
|
4987
|
+
}
|
|
4539
4988
|
return JSON.stringify({
|
|
4540
4989
|
permission: kind === "block" ? "deny" : "ask",
|
|
4541
4990
|
continue: false,
|
|
4542
|
-
|
|
4543
|
-
|
|
4991
|
+
user_message: message,
|
|
4992
|
+
agent_message: message
|
|
4993
|
+
});
|
|
4994
|
+
case "hermes": return toHermesResponse(prompt);
|
|
4995
|
+
case "cline":
|
|
4996
|
+
if (kind === "block") return JSON.stringify({
|
|
4997
|
+
cancel: true,
|
|
4998
|
+
errorMessage: message
|
|
4544
4999
|
});
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
errorMessage: message
|
|
4548
|
-
} : { contextModification: message });
|
|
5000
|
+
if (userMessage && !reason) return "";
|
|
5001
|
+
return JSON.stringify({ contextModification: message });
|
|
4549
5002
|
default: return "";
|
|
4550
5003
|
}
|
|
4551
5004
|
}
|
|
@@ -4733,10 +5186,10 @@ function collectDesignEvidence(sessionId, cwd, baseDir = defaultStateDir(cwd)) {
|
|
|
4733
5186
|
* `design.ts` to keep that file within the SOLID size budget (SRP).
|
|
4734
5187
|
* @packageDocumentation
|
|
4735
5188
|
*/
|
|
4736
|
-
const NAV$
|
|
4737
|
-
const SHOT$
|
|
5189
|
+
const NAV$2 = "mcp__fuse-browser__browser_navigate";
|
|
5190
|
+
const SHOT$2 = "mcp__fuse-browser__browser_screenshot";
|
|
4738
5191
|
const SCROLL = "mcp__fuse-browser__browser_scroll";
|
|
4739
|
-
const GEMINI$
|
|
5192
|
+
const GEMINI$2 = "mcp__gemini-design__create_frontend";
|
|
4740
5193
|
/** Read design-system.md walking up to 6 parents from `cwd` ("" if absent/unreadable). */
|
|
4741
5194
|
function findDesignSystem(cwd) {
|
|
4742
5195
|
let dir = cwd;
|
|
@@ -4755,10 +5208,10 @@ function findDesignSystem(cwd) {
|
|
|
4755
5208
|
}
|
|
4756
5209
|
/** Apply a PostToolUse fuse-browser transition to the design state. */
|
|
4757
5210
|
function recordPost(event, cacheDir, state) {
|
|
4758
|
-
if (event.tool === SHOT$
|
|
4759
|
-
else if (event.tool === NAV$
|
|
5211
|
+
if (event.tool === SHOT$2) saveDesignState(cacheDir, recordScreenshot(state, MIN_SCREENSHOTS[state.mode]));
|
|
5212
|
+
else if (event.tool === NAV$2) saveDesignState(cacheDir, recordNavigate(state));
|
|
4760
5213
|
else if (event.tool === SCROLL) saveDesignState(cacheDir, recordScroll(state));
|
|
4761
|
-
else if (event.tool === GEMINI$
|
|
5214
|
+
else if (event.tool === GEMINI$2) saveDesignState(cacheDir, {
|
|
4762
5215
|
...state,
|
|
4763
5216
|
geminiCalls: state.geminiCalls + 1
|
|
4764
5217
|
});
|
|
@@ -4781,6 +5234,9 @@ const FORBIDDEN_FONTS = [
|
|
|
4781
5234
|
"Open Sans"
|
|
4782
5235
|
];
|
|
4783
5236
|
const OKLCH_RE = /oklch\(\s*[\d.]+%?\s+0\.0*[1-9]/;
|
|
5237
|
+
const NAV$1 = "mcp__fuse-browser__browser_navigate";
|
|
5238
|
+
const SHOT$1 = "mcp__fuse-browser__browser_screenshot";
|
|
5239
|
+
const GEMINI$1 = "mcp__gemini-design__create_frontend";
|
|
4784
5240
|
const deny = (reason) => ({
|
|
4785
5241
|
kind: "block",
|
|
4786
5242
|
title: "Design pipeline",
|
|
@@ -4814,6 +5270,40 @@ function geminiEnabled() {
|
|
|
4814
5270
|
const v = process.env.FUSE_DESIGN_GEMINI;
|
|
4815
5271
|
return v === "1" || v === "true";
|
|
4816
5272
|
}
|
|
5273
|
+
/**
|
|
5274
|
+
* Python-parity pass notices (`hook_output.allow_pass`/`post_pass`): the
|
|
5275
|
+
* user-visible `script: detail` lines the old plugins printed on EVERY passing
|
|
5276
|
+
* design hook. Call AFTER the gates allowed the event; returns an `inform`
|
|
5277
|
+
* Prompt carrying only `userMessage` (for the human, never agent context), or
|
|
5278
|
+
* null when nothing applies. The check-design-skill line fires for ANY agent
|
|
5279
|
+
* on a UI write; the rest only inside the active design-agent context.
|
|
5280
|
+
*/
|
|
5281
|
+
function designPassNotice(i, cacheDir) {
|
|
5282
|
+
const isWrite = i.tool === "Write" || i.tool === "Edit";
|
|
5283
|
+
const fp = i.filePath;
|
|
5284
|
+
const lines = [];
|
|
5285
|
+
if (i.phase === "pre" && isUiWrite(i.tool, fp, i.content)) {
|
|
5286
|
+
const domains = SKILL_TRIGGERS.filter((t) => t.patterns.some((re) => re.test(i.content))).map((t) => t.skill).join(", ");
|
|
5287
|
+
lines.push(`check-design-skill: pass (domain: ${domains || "base"})`);
|
|
5288
|
+
}
|
|
5289
|
+
const state = i.agentId && activeDesignAgent(cacheDir) === i.agentId ? loadDesignState(cacheDir, i.agentId) : null;
|
|
5290
|
+
if (state && i.phase === "pre") {
|
|
5291
|
+
if (isWrite && fp && !EXEMPT_DIRS.some((d) => fp.includes(d)) && ALLOWED_WRITE.test(fp)) lines.push(`enforce-html-css-only: allowed: ${basename(fp)}`);
|
|
5292
|
+
if (i.tool === NAV$1) lines.push(`check-inspiration-read: pass (${i.url})`);
|
|
5293
|
+
if (i.tool === GEMINI$1 && geminiEnabled()) lines.push("validate-design-system: design-system.md ok");
|
|
5294
|
+
if (isWrite || i.tool === NAV$1 || i.tool === SHOT$1 || i.tool === GEMINI$1) lines.push(`pipeline-gate: phase ${state.currentPhase} ok`);
|
|
5295
|
+
}
|
|
5296
|
+
if (state && i.phase === "post" && isWrite) {
|
|
5297
|
+
if (basename(fp) === "design-system.md") lines.push("validate-design: design-system.md → phase 3");
|
|
5298
|
+
else if (/\.(tsx|jsx|css)$/.test(fp)) lines.push("validate-design: design ok");
|
|
5299
|
+
}
|
|
5300
|
+
return lines.length ? {
|
|
5301
|
+
kind: "inform",
|
|
5302
|
+
title: "Design pipeline",
|
|
5303
|
+
reason: "",
|
|
5304
|
+
userMessage: lines.join("\n")
|
|
5305
|
+
} : null;
|
|
5306
|
+
}
|
|
4817
5307
|
//#endregion
|
|
4818
5308
|
//#region src/policy/design/gates-pipeline.ts
|
|
4819
5309
|
const SKILLS = `${PLUGINS_DIR}/design-expert/skills`;
|
|
@@ -4925,6 +5415,206 @@ function designGate(payload, event, cacheDir, cwd) {
|
|
|
4925
5415
|
return null;
|
|
4926
5416
|
}
|
|
4927
5417
|
//#endregion
|
|
5418
|
+
//#region src/policy/lessons/trigger-index.ts
|
|
5419
|
+
/**
|
|
5420
|
+
* Compile the triggered-lesson index from `MEMORY/LESSON.md`. A lesson is a
|
|
5421
|
+
* bullet (`- [YYYY-MM-DD HH:MM] ...`); it opts into decision-time injection by
|
|
5422
|
+
* ending with a `[TRIGGERS tool:.. path:.. error:.. keyword:..]` line. Lessons
|
|
5423
|
+
* WITHOUT that tag are skipped here (they keep the SessionStart block behavior —
|
|
5424
|
+
* zero regression). Parsed once per file version (mtime-memoized).
|
|
5425
|
+
*/
|
|
5426
|
+
/** Matches a trailing `[TRIGGERS ...]` line (its body captured). */
|
|
5427
|
+
const TRIGGER_RE = /^\[TRIGGERS\s+(.+?)\]$/;
|
|
5428
|
+
/** Comma list for `key:` in a trigger body (values are space-delimited). */
|
|
5429
|
+
function list(body, key) {
|
|
5430
|
+
const val = body.match(new RegExp(`\\b${key}:([^\\s\\]]+)`))?.[1];
|
|
5431
|
+
return val ? val.split(",").filter(Boolean) : [];
|
|
5432
|
+
}
|
|
5433
|
+
/** Parse a `[TRIGGERS ...]` body into predicates (error is a single regex). */
|
|
5434
|
+
function parseTriggers(body) {
|
|
5435
|
+
const err = body.match(/\berror:([^\s\]]+)/);
|
|
5436
|
+
return {
|
|
5437
|
+
tools: list(body, "tool"),
|
|
5438
|
+
paths: list(body, "path"),
|
|
5439
|
+
error: err?.[1],
|
|
5440
|
+
keywords: list(body, "keyword")
|
|
5441
|
+
};
|
|
5442
|
+
}
|
|
5443
|
+
/** Collapse to a single ≤3-line compact string (cap length). */
|
|
5444
|
+
function compact(text) {
|
|
5445
|
+
const one = text.replace(/\s+/g, " ").trim();
|
|
5446
|
+
return one.length > 280 ? `${one.slice(0, 277)}…` : one;
|
|
5447
|
+
}
|
|
5448
|
+
/**
|
|
5449
|
+
* Parse LESSON.md content into triggered entries. A bullet's text spans its
|
|
5450
|
+
* `- ` line plus any following non-blank continuation lines up to the next
|
|
5451
|
+
* bullet; a `[TRIGGERS ...]` continuation line arms it.
|
|
5452
|
+
* @param content - Raw LESSON.md text.
|
|
5453
|
+
* @returns Entries that declared triggers (others skipped).
|
|
5454
|
+
*/
|
|
5455
|
+
function parseLessons(content) {
|
|
5456
|
+
const lines = content.split("\n");
|
|
5457
|
+
const out = [];
|
|
5458
|
+
for (let i = 0; i < lines.length; i++) {
|
|
5459
|
+
const line = lines[i];
|
|
5460
|
+
if (line === void 0 || !line.startsWith("- ")) continue;
|
|
5461
|
+
let text = line.slice(2);
|
|
5462
|
+
let triggers = null;
|
|
5463
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
5464
|
+
const cont = lines[j];
|
|
5465
|
+
if (cont === void 0 || cont.trim() === "" || cont.startsWith("- ")) break;
|
|
5466
|
+
const body = cont.trim().match(TRIGGER_RE)?.[1];
|
|
5467
|
+
if (body !== void 0) triggers = parseTriggers(body);
|
|
5468
|
+
else text += ` ${cont.trim()}`;
|
|
5469
|
+
}
|
|
5470
|
+
if (triggers) out.push({
|
|
5471
|
+
text: compact(text),
|
|
5472
|
+
triggers
|
|
5473
|
+
});
|
|
5474
|
+
}
|
|
5475
|
+
return out;
|
|
5476
|
+
}
|
|
5477
|
+
let memo = null;
|
|
5478
|
+
/**
|
|
5479
|
+
* Compile (once per file version) the triggered-lesson index from `file`.
|
|
5480
|
+
* Memoized by path+mtime: re-parses only when LESSON.md changes.
|
|
5481
|
+
* @param file - Absolute path to MEMORY/LESSON.md.
|
|
5482
|
+
* @returns The compiled entries (missing/unreadable file → empty).
|
|
5483
|
+
*/
|
|
5484
|
+
function lessonIndex(file) {
|
|
5485
|
+
let key;
|
|
5486
|
+
try {
|
|
5487
|
+
key = `${file}:${statSync(file).mtimeMs}`;
|
|
5488
|
+
} catch {
|
|
5489
|
+
return [];
|
|
5490
|
+
}
|
|
5491
|
+
if (memo?.key === key) return memo.entries;
|
|
5492
|
+
let entries = [];
|
|
5493
|
+
try {
|
|
5494
|
+
entries = parseLessons(readFileSync(file, "utf-8"));
|
|
5495
|
+
} catch {
|
|
5496
|
+
entries = [];
|
|
5497
|
+
}
|
|
5498
|
+
memo = {
|
|
5499
|
+
key,
|
|
5500
|
+
entries
|
|
5501
|
+
};
|
|
5502
|
+
return entries;
|
|
5503
|
+
}
|
|
5504
|
+
/** Glob (`*`/`**`) → RegExp, matching a path segment/tail. */
|
|
5505
|
+
function globToRe(glob) {
|
|
5506
|
+
const esc = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(//g, ".*");
|
|
5507
|
+
return new RegExp(`(^|/)${esc}$`);
|
|
5508
|
+
}
|
|
5509
|
+
/** Safe case-insensitive regex test (absent source or invalid → false). */
|
|
5510
|
+
function safeTest(src, s) {
|
|
5511
|
+
if (!src) return false;
|
|
5512
|
+
try {
|
|
5513
|
+
return new RegExp(src, "i").test(s);
|
|
5514
|
+
} catch {
|
|
5515
|
+
return false;
|
|
5516
|
+
}
|
|
5517
|
+
}
|
|
5518
|
+
/** Score one entry against the call; null = no predicate matched. */
|
|
5519
|
+
function scoreEntry(e, tool, filePath, inputJson, prevError) {
|
|
5520
|
+
const tr = e.triggers;
|
|
5521
|
+
if (tr.tools.includes(tool)) return {
|
|
5522
|
+
entry: e,
|
|
5523
|
+
rank: 3
|
|
5524
|
+
};
|
|
5525
|
+
if (filePath && tr.paths.some((g) => globToRe(g).test(filePath))) return {
|
|
5526
|
+
entry: e,
|
|
5527
|
+
rank: 2
|
|
5528
|
+
};
|
|
5529
|
+
if (prevError && safeTest(tr.error, prevError)) return {
|
|
5530
|
+
entry: e,
|
|
5531
|
+
rank: 1
|
|
5532
|
+
};
|
|
5533
|
+
if (tr.keywords.some((k) => inputJson.includes(k))) return {
|
|
5534
|
+
entry: e,
|
|
5535
|
+
rank: 0
|
|
5536
|
+
};
|
|
5537
|
+
return null;
|
|
5538
|
+
}
|
|
5539
|
+
/** Stable, filesystem-safe cooldown key from a lesson's compact text (djb2). */
|
|
5540
|
+
function cooldownKey(text) {
|
|
5541
|
+
let h = 5381;
|
|
5542
|
+
for (let i = 0; i < text.length; i++) h = (h << 5) + h + text.charCodeAt(i) | 0;
|
|
5543
|
+
return `lesson:${(h >>> 0).toString(36)}`;
|
|
5544
|
+
}
|
|
5545
|
+
/**
|
|
5546
|
+
* The single most-specific lesson for this PreToolUse call, or null. Matching
|
|
5547
|
+
* priority: exact tool > path glob > error regex > input-JSON keyword. Cooldown
|
|
5548
|
+
* suppresses a lesson already injected within the window.
|
|
5549
|
+
* @param tool - The tool being called (e.g. `Write`).
|
|
5550
|
+
* @param toolInput - The raw `tool_input`.
|
|
5551
|
+
* @param opts - Index file, cooldown gate, and optional prior error.
|
|
5552
|
+
* @returns An `inform` prompt, or null when nothing matches / in cooldown.
|
|
5553
|
+
*/
|
|
5554
|
+
function lessonFor(tool, toolInput, opts) {
|
|
5555
|
+
const entries = lessonIndex(opts.file);
|
|
5556
|
+
if (entries.length === 0) return null;
|
|
5557
|
+
const filePath = typeof toolInput?.file_path === "string" ? toolInput.file_path : "";
|
|
5558
|
+
const inputJson = JSON.stringify(toolInput ?? {});
|
|
5559
|
+
let best = null;
|
|
5560
|
+
for (const e of entries) {
|
|
5561
|
+
const m = scoreEntry(e, tool, filePath, inputJson, opts.prevError);
|
|
5562
|
+
if (m && (!best || m.rank > best.rank)) best = m;
|
|
5563
|
+
}
|
|
5564
|
+
if (!best) return null;
|
|
5565
|
+
if (!opts.once(cooldownKey(best.entry.text), opts.cooldownMs ?? 18e5)) return null;
|
|
5566
|
+
return {
|
|
5567
|
+
kind: "inform",
|
|
5568
|
+
title: `Project lesson${filePath ? ` (${basename(filePath)})` : ""}`,
|
|
5569
|
+
reason: best.entry.text
|
|
5570
|
+
};
|
|
5571
|
+
}
|
|
5572
|
+
//#endregion
|
|
5573
|
+
//#region src/runtime/pre-allow.ts
|
|
5574
|
+
/**
|
|
5575
|
+
* PreToolUse ALLOW-path response assembly. Reached only after every gate
|
|
5576
|
+
* allowed (a deny/ask already returned upstream), so nothing here can block nor
|
|
5577
|
+
* override a decision. Combines the Python-parity pass notice (systemMessage)
|
|
5578
|
+
* with the single most-specific decision-time lesson (additionalContext).
|
|
5579
|
+
*/
|
|
5580
|
+
/**
|
|
5581
|
+
* Build the native outcome for a PreToolUse call that passed every gate: emit a
|
|
5582
|
+
* user-visible pass notice (once per allowed call) and, when its TRIGGERS match
|
|
5583
|
+
* this call, the one cooldown-guarded decision-time lesson. Both channels ride a
|
|
5584
|
+
* single response (lesson → additionalContext, notice → systemMessage).
|
|
5585
|
+
* @param id - Harness id for {@link respond}.
|
|
5586
|
+
* @param event - The normalized PreToolUse event.
|
|
5587
|
+
* @param payload - The raw hook payload (for `agent_id`).
|
|
5588
|
+
* @param mcpDir - MCP state dir backing the pass-notice throttle.
|
|
5589
|
+
* @param cwd - Project root (lesson file + notice scope).
|
|
5590
|
+
* @returns The native hook outcome (empty stdout when nothing to emit).
|
|
5591
|
+
*/
|
|
5592
|
+
function allowOutcome(id, event, payload, mcpDir, cwd) {
|
|
5593
|
+
const notice = designPassNotice({
|
|
5594
|
+
agentId: typeof payload.agent_id === "string" ? payload.agent_id : "",
|
|
5595
|
+
tool: event.tool,
|
|
5596
|
+
filePath: event.filePath ?? "",
|
|
5597
|
+
content: event.content ?? "",
|
|
5598
|
+
url: typeof event.input.url === "string" ? event.input.url : "",
|
|
5599
|
+
phase: "pre"
|
|
5600
|
+
}, mcpDir);
|
|
5601
|
+
const lesson = lessonFor(event.tool, event.input, {
|
|
5602
|
+
file: lessonsFileFor(projectRoot(cwd)),
|
|
5603
|
+
once: oncePerWindow
|
|
5604
|
+
});
|
|
5605
|
+
if (lesson) return {
|
|
5606
|
+
stdout: respond(id, notice?.userMessage ? {
|
|
5607
|
+
...lesson,
|
|
5608
|
+
userMessage: notice.userMessage
|
|
5609
|
+
} : lesson),
|
|
5610
|
+
exit: 0
|
|
5611
|
+
};
|
|
5612
|
+
return {
|
|
5613
|
+
stdout: notice ? respond(id, notice) : "",
|
|
5614
|
+
exit: 0
|
|
5615
|
+
};
|
|
5616
|
+
}
|
|
5617
|
+
//#endregion
|
|
4928
5618
|
//#region src/runtime/handle-pre.ts
|
|
4929
5619
|
/**
|
|
4930
5620
|
* Run the PreToolUse pipeline: MCP/WebFetch cache intercept, design gate, APEX
|
|
@@ -4985,13 +5675,90 @@ async function handlePre(ctx) {
|
|
|
4985
5675
|
trackFile: file,
|
|
4986
5676
|
transcriptPath: typeof payload.transcript_path === "string" ? payload.transcript_path : void 0
|
|
4987
5677
|
});
|
|
4988
|
-
|
|
5678
|
+
if (prompt) return {
|
|
4989
5679
|
stdout: respond(id, prompt),
|
|
4990
5680
|
exit: 0
|
|
4991
|
-
} : {
|
|
4992
|
-
stdout: "",
|
|
4993
|
-
exit: 0
|
|
4994
5681
|
};
|
|
5682
|
+
return allowOutcome(id, event, payload, mcpDir, opts.cwd);
|
|
5683
|
+
}
|
|
5684
|
+
//#endregion
|
|
5685
|
+
//#region src/freshness/query-framework.ts
|
|
5686
|
+
/**
|
|
5687
|
+
* Framework classification for doc consultations, keyed on the QUERY text
|
|
5688
|
+
* (parity ai-pilot/scripts/track_doc_helpers.py::detect_framework): an MCP
|
|
5689
|
+
* doc call (context7/exa/web) has no filePath, so the file-based
|
|
5690
|
+
* `detectFramework()` always yields "generic" — the query keywords are the
|
|
5691
|
+
* only framework signal a doc consultation carries.
|
|
5692
|
+
*/
|
|
5693
|
+
/**
|
|
5694
|
+
* Ordered patterns, EXACT parity with the Python list (same order, same
|
|
5695
|
+
* case-sensitive alternations, substring search — only `go` is word-bounded).
|
|
5696
|
+
* Quirks are inherited deliberately: "javascript" hits `java` (but only after
|
|
5697
|
+
* nextjs/react/laravel/swift/tailwind all missed) and all-caps "NEXT" misses.
|
|
5698
|
+
* The output domain matches `detectFramework()` exactly, so every credit
|
|
5699
|
+
* lands on a key the apex authorization gate actually reads.
|
|
5700
|
+
*/
|
|
5701
|
+
const QUERY_FRAMEWORKS = [
|
|
5702
|
+
[/(next|nextjs|Next)/, "nextjs"],
|
|
5703
|
+
[/(react|React)/, "react"],
|
|
5704
|
+
[/(laravel|Laravel|php|PHP)/, "laravel"],
|
|
5705
|
+
[/(swift|Swift|swiftui|SwiftUI)/, "swift"],
|
|
5706
|
+
[/(tailwind|Tailwind)/, "tailwind"],
|
|
5707
|
+
[/(java|Java|spring|Spring)/, "java"],
|
|
5708
|
+
[/\b(go|Go|golang)\b/, "go"],
|
|
5709
|
+
[/(ruby|Ruby|rails|Rails)/, "ruby"],
|
|
5710
|
+
[/(rust|Rust|cargo|Cargo)/, "rust"]
|
|
5711
|
+
];
|
|
5712
|
+
/**
|
|
5713
|
+
* The framework a doc query targets, or null when no keyword matches (the
|
|
5714
|
+
* Python returns "generic"; null here lets the caller fall back to the
|
|
5715
|
+
* event's file-detected framework instead of hardcoding generic).
|
|
5716
|
+
* @param query - The query/url/prompt text of the doc tool call.
|
|
5717
|
+
*/
|
|
5718
|
+
function frameworkFromQuery(query) {
|
|
5719
|
+
for (const [re, fw] of QUERY_FRAMEWORKS) if (re.test(query)) return fw;
|
|
5720
|
+
return null;
|
|
5721
|
+
}
|
|
5722
|
+
/**
|
|
5723
|
+
* tool_input fields carrying query text across doc tools: context7
|
|
5724
|
+
* (`libraryId`/`libraryName`, parity extract_tool_info), exa/WebSearch
|
|
5725
|
+
* (`query`), fuse-browser serp (`queries[]`), WebFetch (`url` + `prompt`).
|
|
5726
|
+
*/
|
|
5727
|
+
const QUERY_FIELDS = [
|
|
5728
|
+
"query",
|
|
5729
|
+
"queries",
|
|
5730
|
+
"libraryId",
|
|
5731
|
+
"libraryName",
|
|
5732
|
+
"prompt",
|
|
5733
|
+
"url"
|
|
5734
|
+
];
|
|
5735
|
+
/**
|
|
5736
|
+
* Fold every query-ish tool_input field into one searchable string. Distinct
|
|
5737
|
+
* from mcp-key.ts `queryOf`, which picks a SINGLE field to key the response
|
|
5738
|
+
* cache — classification wants ALL the text (e.g. WebFetch url + prompt).
|
|
5739
|
+
* @param input - The normalized tool_input (absent on some harnesses).
|
|
5740
|
+
*/
|
|
5741
|
+
function docQueryOf(input) {
|
|
5742
|
+
if (!input) return "";
|
|
5743
|
+
const parts = [];
|
|
5744
|
+
for (const key of QUERY_FIELDS) {
|
|
5745
|
+
const v = input[key];
|
|
5746
|
+
if (typeof v === "string") parts.push(v);
|
|
5747
|
+
else if (Array.isArray(v)) {
|
|
5748
|
+
for (const s of v) if (typeof s === "string") parts.push(s);
|
|
5749
|
+
}
|
|
5750
|
+
}
|
|
5751
|
+
return parts.join(" ");
|
|
5752
|
+
}
|
|
5753
|
+
/**
|
|
5754
|
+
* The framework to credit for a doc consultation: the query's framework when
|
|
5755
|
+
* a keyword matches (query wins over file-based detection), else `fallback`
|
|
5756
|
+
* (the event's framework — "generic" for pathless MCP calls).
|
|
5757
|
+
* @param input - The doc tool's tool_input.
|
|
5758
|
+
* @param fallback - The event's file-detected framework.
|
|
5759
|
+
*/
|
|
5760
|
+
function docFramework(input, fallback) {
|
|
5761
|
+
return frameworkFromQuery(docQueryOf(input)) ?? fallback;
|
|
4995
5762
|
}
|
|
4996
5763
|
//#endregion
|
|
4997
5764
|
//#region src/runtime/activity.ts
|
|
@@ -5052,9 +5819,10 @@ function activityFor(event) {
|
|
|
5052
5819
|
const docSource = docSourceOf(event.tool);
|
|
5053
5820
|
if (docSource) out.push({
|
|
5054
5821
|
kind: "doc",
|
|
5055
|
-
framework: event.framework,
|
|
5822
|
+
framework: docFramework(event.input, event.framework),
|
|
5056
5823
|
sessionId: event.sessionId,
|
|
5057
|
-
source: docSource
|
|
5824
|
+
source: docSource,
|
|
5825
|
+
ts: event.now
|
|
5058
5826
|
});
|
|
5059
5827
|
if (event.tool === "Task" || event.tool === "Agent") {
|
|
5060
5828
|
const name = String(event.input?.subagent_type ?? event.input?.name ?? "").split(":").pop() ?? "";
|
|
@@ -5098,6 +5866,8 @@ async function handlePost(ctx) {
|
|
|
5098
5866
|
responseLength: extractText(response).length
|
|
5099
5867
|
});
|
|
5100
5868
|
for (const activity of activities) await recordActivity(file, activity);
|
|
5869
|
+
const evidence = classifyAgentEvidence(event.tool, event.input, response);
|
|
5870
|
+
if (evidence) await recordAgentEvidence(file, evidence, opts.now, typeof payload.agent_id === "string" ? payload.agent_id : void 0);
|
|
5101
5871
|
postTrackingSideEffects(opts.scope ?? "core", event, event.input, opts.now, payload, opts.cwd);
|
|
5102
5872
|
const seoDeny = opts.scope === "seo" ? seoPostToolUseResponse(payload) : null;
|
|
5103
5873
|
if (seoDeny) return {
|
|
@@ -5126,8 +5896,35 @@ async function handlePost(ctx) {
|
|
|
5126
5896
|
};
|
|
5127
5897
|
}
|
|
5128
5898
|
const extra = await postEditContext(opts.scope ?? "core", event, opts.now);
|
|
5899
|
+
const notice = designPassNotice({
|
|
5900
|
+
agentId: typeof payload.agent_id === "string" ? payload.agent_id : "",
|
|
5901
|
+
tool: event.tool,
|
|
5902
|
+
filePath: event.filePath ?? "",
|
|
5903
|
+
content: event.content ?? "",
|
|
5904
|
+
url: "",
|
|
5905
|
+
phase: "post"
|
|
5906
|
+
}, mcpDir);
|
|
5907
|
+
if (designWarn) return {
|
|
5908
|
+
stdout: respond(id, notice?.userMessage ? {
|
|
5909
|
+
...designWarn,
|
|
5910
|
+
userMessage: notice.userMessage
|
|
5911
|
+
} : designWarn),
|
|
5912
|
+
exit: 0
|
|
5913
|
+
};
|
|
5914
|
+
if (!notice?.userMessage) return {
|
|
5915
|
+
stdout: extra,
|
|
5916
|
+
exit: 0
|
|
5917
|
+
};
|
|
5918
|
+
if (!extra) return {
|
|
5919
|
+
stdout: respond(id, notice),
|
|
5920
|
+
exit: 0
|
|
5921
|
+
};
|
|
5922
|
+
if (id === "claude-code" || id === "codex") return {
|
|
5923
|
+
stdout: attachSystemMessage(extra, notice.userMessage),
|
|
5924
|
+
exit: 0
|
|
5925
|
+
};
|
|
5129
5926
|
return {
|
|
5130
|
-
stdout:
|
|
5927
|
+
stdout: respond(id, notice) || extra,
|
|
5131
5928
|
exit: 0
|
|
5132
5929
|
};
|
|
5133
5930
|
}
|
|
@@ -5212,4 +6009,4 @@ async function handleHook(id, payload, opts) {
|
|
|
5212
6009
|
});
|
|
5213
6010
|
}
|
|
5214
6011
|
//#endregion
|
|
5215
|
-
export { saveApexState as $, trackSkillRead as A,
|
|
6012
|
+
export { saveApexState as $, trackSkillRead as A, saveSecurityState as At, writePluginMap as B, seoPostToolUse as C, taskContext as Ct, postTrackingSideEffects as D, normalizeEvent as Dt, securityAdvisory as E, trackFile as Et, dispatchLessons as F, mergeLines as G, isProject as H, lessonsFileFor as I, listChildren as J, countFiles as K, lessonsStateFileFor as L, dispatchLifecycle as M, securityStatePath as Mt, aipilotPostToolUse as N, todayUtc as Nt, trackWatchResearch as O, isoUtc as Ot, dispatchAipilot as P, cleanupSession as Q, cartoSessionStart as R, postEditContext as S, promptSubmitContext as St, dispatchMemory as T, projectHash$1 as Tt, writeTree as U, generateProjectMap as V, loadEnriched as W, trackSessionChanges as X, postEditTypescript as Y, validateRulesLoaded as Z, preCommitGate as _, trimLogFile as _t, recordActivity as a, validateSolidGate as at, extractSymbols as b, projectContext as bt, MCP_TTL_MS as c, detectSolidProfile as ct, isMcpTool as d, readRules as dt, logToolFailure as et, queryOf as f, runSessionStartCleanups as ft, gate as g, removeOldFiles as gt, TRIVIAL_BUDGET as h, purgeTtlTree as ht, respond as i, validateTailwind as it, trackEnrichment as j, securityStateDir as jt, trackMcpResearch as k, loadSecurityState as kt, WEBFETCH_TTL_MS as l, solidDetectStart as lt, REQUIRED_AGENTS as m, pruneEmptyDirs as mt, activityFor as n, trackAgentMemory as nt, mcpPostStore as o, checkFileSize as ot, DEFAULT_WINDOW_MS as p, sessionStartCore as pt, getFileDesc as q, handlePre as r, subagentCacheContext as rt, mcpPreIntercept as s, countLoc as st, handleHook as t, validateTeammateOutput as tt, cacheQueryOf as u, injectRules as ut, detectDuplication as v, devContext as vt, seoPostToolUseResponse as w, defaultStateDir as wt, lifecycleStdout as x, claudeMdKey as xt, dryGate as y, gitContext as yt, generateEcosystemMap as z };
|