@fusengine/harness 0.1.47 → 0.1.49
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-DUlUbhyF.mjs → handle-D1hHqHvh.mjs} +422 -62
- 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-l9Xvxrx7.d.mts → index-BfMsILmg.d.mts} +2 -52
- 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 +23 -8
- package/dist/runtime/index.mjs +2 -2
- package/dist/session-state-DMpotbRz.d.mts +73 -0
- 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-DVV4nRfr.mjs → validate-DCQ8dkdL.mjs} +34 -103
- package/package.json +7 -2
- package/dist/home-state-mKZxP4oZ.mjs +0 -52
- package/dist/session-state-D4F_Dub6.d.mts +0 -35
- package/dist/store-CNjFenWe.mjs +0 -184
|
@@ -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";
|
|
@@ -3529,6 +3531,50 @@ async function postEditContext(scope, event, now) {
|
|
|
3529
3531
|
return contextResponse("PostToolUse", `${additionalContextOf(sniper)}\n\n${additionalContextOf(lint)}`);
|
|
3530
3532
|
}
|
|
3531
3533
|
//#endregion
|
|
3534
|
+
//#region src/policy/trivial-edits.ts
|
|
3535
|
+
/**
|
|
3536
|
+
* Pre-filters of the ported enforce-apex-phases.ts hook, in Python order
|
|
3537
|
+
* (lines 48-65): protected-path guard first, then the dependency/build-dir
|
|
3538
|
+
* skip, then the trivial-edit fast path. The trivial COUNTER itself lives in
|
|
3539
|
+
* the SessionTrack (`trivialEdits`, see recordTrivialEdit/trivialCount) — no
|
|
3540
|
+
* separate state file.
|
|
3541
|
+
*/
|
|
3542
|
+
/** Hook-managed state paths — Write/Edit always denied (parity enforce-apex-phases.ts PROTECTED_PATHS). */
|
|
3543
|
+
const PROTECTED_PATHS = /\.claude\/(plugins\/(marketplaces|cache)|logs\/00-apex|fusengine-cache\/skill-tracking)/;
|
|
3544
|
+
/** Dependency/build dirs the APEX gates skip entirely (parity enforce-apex-phases.ts SKIP_DIRS). */
|
|
3545
|
+
const SKIP_DIRS = /(node_modules|vendor|dist|build|\.next|DerivedData)/;
|
|
3546
|
+
/**
|
|
3547
|
+
* Absolute deny on Write/Edit of hook-managed state paths, with the Python
|
|
3548
|
+
* wording byte-for-byte. Runs BEFORE the code-ext/skip-dirs filters (parity
|
|
3549
|
+
* enforce-apex-phases.ts:48-54), so non-code files under the protected roots
|
|
3550
|
+
* are covered too.
|
|
3551
|
+
* @param tool - The harness tool name (only Write/Edit are policed).
|
|
3552
|
+
* @param filePath - Target path of the edit (or undefined).
|
|
3553
|
+
* @returns A blocking prompt, or null to fall through.
|
|
3554
|
+
*/
|
|
3555
|
+
function protectedPathGate(tool, filePath) {
|
|
3556
|
+
if (tool !== "Write" && tool !== "Edit" || !filePath || !PROTECTED_PATHS.test(filePath)) return null;
|
|
3557
|
+
return {
|
|
3558
|
+
kind: "block",
|
|
3559
|
+
title: "APEX Hook Guard: protected path",
|
|
3560
|
+
reason: "[APEX Hook Guard] Write blocked — this path is managed automatically by APEX hooks. Manual edits are forbidden and would corrupt tracked state."
|
|
3561
|
+
};
|
|
3562
|
+
}
|
|
3563
|
+
/**
|
|
3564
|
+
* True when this tool-use qualifies for the trivial fast path (parity
|
|
3565
|
+
* enforce-apex-phases.ts:58-65): only an Edit (a Write always creates or
|
|
3566
|
+
* replaces a file wholesale), NEVER `replace_all`, and a new_string under
|
|
3567
|
+
* {@link TRIVIAL_MAX_LINES} lines. Absent content is NOT trivial (fail closed
|
|
3568
|
+
* — the full gates run).
|
|
3569
|
+
* @param tool - The harness tool name.
|
|
3570
|
+
* @param content - The Edit's new_string (adapter-normalized).
|
|
3571
|
+
* @param isReplaceAll - The Edit's replace_all flag.
|
|
3572
|
+
*/
|
|
3573
|
+
function isTrivialEdit(tool, content, isReplaceAll) {
|
|
3574
|
+
if (tool !== "Edit" || isReplaceAll === true || content === void 0) return false;
|
|
3575
|
+
return content.split("\n").length < 5;
|
|
3576
|
+
}
|
|
3577
|
+
//#endregion
|
|
3532
3578
|
//#region src/runtime/gate-helpers.ts
|
|
3533
3579
|
/** Source extensions the APEX gates apply to (parity: require-apex-agents.py CODE_EXT). */
|
|
3534
3580
|
const CODE_EXT = /\.(ts|tsx|js|jsx|py|go|rs|java|php|cpp|c|rb|swift|kt|dart|vue|svelte|astro)$/;
|
|
@@ -4113,7 +4159,14 @@ function geminiMcpGate(tool, filePath, content, ev) {
|
|
|
4113
4159
|
//#region src/freshness/explore-tools.ts
|
|
4114
4160
|
/** Native exploration tools (parity `apex_constants.EXPLORE_TOOLS`). */
|
|
4115
4161
|
const EXPLORE_TOOLS = /* @__PURE__ */ new Set(["Glob", "Grep"]);
|
|
4116
|
-
/**
|
|
4162
|
+
/**
|
|
4163
|
+
* Research tools — MCP docs + web (parity `apex_constants.RESEARCH_TOOLS`).
|
|
4164
|
+
* The fuse-browser fast-path entries are a deliberate TS addition: CLAUDE.md
|
|
4165
|
+
* mandates fuse-browser FIRST for web research, and `docSourceOf` (activity.ts)
|
|
4166
|
+
* already credits it for doc consultation — without them here, an agent
|
|
4167
|
+
* following the rules would never satisfy the freshness gate (same
|
|
4168
|
+
* cross-consumer inconsistency class as the solidReadGate lesson).
|
|
4169
|
+
*/
|
|
4117
4170
|
const RESEARCH_TOOLS = /* @__PURE__ */ new Set([
|
|
4118
4171
|
"mcp__context7__query-docs",
|
|
4119
4172
|
"mcp__context7__resolve-library-id",
|
|
@@ -4121,7 +4174,11 @@ const RESEARCH_TOOLS = /* @__PURE__ */ new Set([
|
|
|
4121
4174
|
"mcp__exa__get_code_context_exa",
|
|
4122
4175
|
"mcp__exa__deep_researcher_start",
|
|
4123
4176
|
"WebSearch",
|
|
4124
|
-
"WebFetch"
|
|
4177
|
+
"WebFetch",
|
|
4178
|
+
"mcp__fuse-browser__browser_fetch",
|
|
4179
|
+
"mcp__fuse-browser__browser_fetch_batch",
|
|
4180
|
+
"mcp__fuse-browser__browser_crawl",
|
|
4181
|
+
"mcp__fuse-browser__browser_serp_batch"
|
|
4125
4182
|
]);
|
|
4126
4183
|
/** Bash executables that count as exploration (parity `EXPLORE_BASH_CMDS`). */
|
|
4127
4184
|
const EXPLORE_BASH_CMDS = /* @__PURE__ */ new Set([
|
|
@@ -4136,9 +4193,17 @@ const EXPLORE_BASH_CMDS = /* @__PURE__ */ new Set([
|
|
|
4136
4193
|
"head",
|
|
4137
4194
|
"tail"
|
|
4138
4195
|
]);
|
|
4139
|
-
/**
|
|
4196
|
+
/** Legacy Python cache names still credited (parity `CACHE_READ_RE` + doc-helpers). */
|
|
4140
4197
|
const CACHE_READ_RE = /\/context\/mcp\/(exa-search|exa-code-context|context7)-/;
|
|
4141
4198
|
/**
|
|
4199
|
+
* Real TS cache stores: `<root>/.harness/cache/<fnv16>.md` (core MCP store,
|
|
4200
|
+
* `projectLayout().cacheDir`) and `~/.fuse-harness/cache/**` (ai-pilot doc
|
|
4201
|
+
* caches, `cache-base.cacheBaseDir`). Segment-matched — not resolved against
|
|
4202
|
+
* `homedir()` — so tilde-prefixed and absolute paths both hit; the `.md`
|
|
4203
|
+
* suffix keeps session-state JSON reads from counting as research.
|
|
4204
|
+
*/
|
|
4205
|
+
const TS_CACHE_READ_RE = /\.(?:fuse-)?harness[\\/]cache[\\/].*\.md$/;
|
|
4206
|
+
/**
|
|
4142
4207
|
* First non-assignment shell token's basename, or "" — mirrors Python
|
|
4143
4208
|
* `_bash_executable` (skips leading `VAR=value` env prefixes, e.g. `FOO=1 grep`).
|
|
4144
4209
|
* @param cmd - Raw Bash `command` string.
|
|
@@ -4170,7 +4235,7 @@ function classifyExplore(tool, input) {
|
|
|
4170
4235
|
};
|
|
4171
4236
|
if (tool === "Read") {
|
|
4172
4237
|
const path = String(input?.file_path ?? input?.path ?? "");
|
|
4173
|
-
if (path && CACHE_READ_RE.test(path)) return {
|
|
4238
|
+
if (path && (CACHE_READ_RE.test(path) || TS_CACHE_READ_RE.test(path))) return {
|
|
4174
4239
|
phase: "research-expert",
|
|
4175
4240
|
cacheHit: true
|
|
4176
4241
|
};
|
|
@@ -4261,25 +4326,120 @@ function agentsRanFromTranscript(transcriptPath, names, windowMs, now) {
|
|
|
4261
4326
|
return names.every((n) => found.has(n));
|
|
4262
4327
|
}
|
|
4263
4328
|
//#endregion
|
|
4329
|
+
//#region src/freshness/agent-evidence-record.ts
|
|
4330
|
+
/**
|
|
4331
|
+
* Session-track agent evidence — writer (parity track-subagent-research.py) +
|
|
4332
|
+
* reader scan (parity apex_agent_helpers._scan_agents). Sub-agent hooks fire
|
|
4333
|
+
* with the LEAD's `session_id`, so evidence recorded here lands in the ONE
|
|
4334
|
+
* session track the freshness gate scans FIRST — sidechain research and
|
|
4335
|
+
* Workflow-spawned agents count, unlike the lead-transcript scan.
|
|
4336
|
+
*/
|
|
4337
|
+
/** Tools the existing Task tracking already credits as agent LAUNCHES. */
|
|
4338
|
+
const AGENT_LAUNCH_TOOLS = /* @__PURE__ */ new Set(["Task", "Agent"]);
|
|
4339
|
+
/**
|
|
4340
|
+
* `JSON.stringify` length of the raw `tool_response` OBJECT (parity Python
|
|
4341
|
+
* `len(str(tool_response))`); 0 when absent or unserializable (circular).
|
|
4342
|
+
*/
|
|
4343
|
+
function responseJsonLength(toolResponse) {
|
|
4344
|
+
if (toolResponse === void 0 || toolResponse === null) return 0;
|
|
4345
|
+
try {
|
|
4346
|
+
return (JSON.stringify(toolResponse) ?? "").length;
|
|
4347
|
+
} catch {
|
|
4348
|
+
return 0;
|
|
4349
|
+
}
|
|
4350
|
+
}
|
|
4351
|
+
/**
|
|
4352
|
+
* Classify one PostToolUse call into session evidence, or null when it is
|
|
4353
|
+
* neither exploration nor research (parity track-subagent-research._classify,
|
|
4354
|
+
* reusing the shared {@link classifyExplore} tables — research/explore/Bash/
|
|
4355
|
+
* cache-read). ANTI-DOUBLE-COUNT: calls classified as agent launches
|
|
4356
|
+
* (`Task`/`Agent`) are skipped — the existing Task tracking credits those; the
|
|
4357
|
+
* criterion is the CALL's classification, never its provenance (lead vs sub).
|
|
4358
|
+
* @param tool - Harness tool name (e.g. "Glob", "Bash", "Read").
|
|
4359
|
+
* @param input - Raw tool input payload.
|
|
4360
|
+
* @param toolResponse - Raw `tool_response` OBJECT from the hook payload.
|
|
4361
|
+
* @returns The evidence to record, or null to skip.
|
|
4362
|
+
*/
|
|
4363
|
+
function classifyAgentEvidence(tool, input, toolResponse) {
|
|
4364
|
+
if (AGENT_LAUNCH_TOOLS.has(tool)) return null;
|
|
4365
|
+
const hit = classifyExplore(tool, input);
|
|
4366
|
+
if (!hit) return null;
|
|
4367
|
+
return {
|
|
4368
|
+
name: hit.phase === "explore-codebase" ? "subagent-explore-codebase" : "subagent-research-expert",
|
|
4369
|
+
quality: hit.cacheHit || responseJsonLength(toolResponse) > 50 ? "sufficient" : "insufficient"
|
|
4370
|
+
};
|
|
4371
|
+
}
|
|
4372
|
+
/**
|
|
4373
|
+
* Persist evidence into the SESSION track via {@link recordAgent} — keyed by
|
|
4374
|
+
* `session_id` alone (sub-agent hooks carry the lead's `session_id`); the TTL
|
|
4375
|
+
* anchors on `ts`, the tool call's own timestamp. `agentId` is tagged as
|
|
4376
|
+
* metadata when present, NEVER used as a condition (unreliable field —
|
|
4377
|
+
* anthropics/claude-code#22348).
|
|
4378
|
+
* @param file - Session track file path.
|
|
4379
|
+
* @param evidence - Classified evidence from {@link classifyAgentEvidence}.
|
|
4380
|
+
* @param ts - Epoch-ms timestamp of the tool call (the hook event's `now`).
|
|
4381
|
+
* @param agentId - Optional Claude `agent_id` — metadata tag only.
|
|
4382
|
+
*/
|
|
4383
|
+
async function recordAgentEvidence(file, evidence, ts, agentId) {
|
|
4384
|
+
const next = recordAgent(await loadTrack(file), evidence.name, ts, evidence.quality);
|
|
4385
|
+
const last = next.agents[next.agents.length - 1];
|
|
4386
|
+
if (agentId && last) {
|
|
4387
|
+
const tagged = {
|
|
4388
|
+
...last,
|
|
4389
|
+
agentId
|
|
4390
|
+
};
|
|
4391
|
+
next.agents[next.agents.length - 1] = tagged;
|
|
4392
|
+
}
|
|
4393
|
+
await saveTrack(file, next);
|
|
4394
|
+
}
|
|
4395
|
+
/**
|
|
4396
|
+
* Parity `_scan_agents` (apex_agent_helpers.py): reverse-scan `track.agents`,
|
|
4397
|
+
* STOPPING at the first entry older than `windowMs` (entries append in time
|
|
4398
|
+
* order), matching each required name by SUBSTRING (`research-expert` matches
|
|
4399
|
+
* `subagent-research-expert`) and counting ONLY `quality === "sufficient"`.
|
|
4400
|
+
* @param track - The loaded session track.
|
|
4401
|
+
* @param names - Required agent names — ALL must match to return true.
|
|
4402
|
+
* @param windowMs - Freshness window (ms), anchored on each entry's `ts`.
|
|
4403
|
+
* @param now - Current epoch ms.
|
|
4404
|
+
* @returns True when every required name has fresh, sufficient evidence.
|
|
4405
|
+
*/
|
|
4406
|
+
function agentsFreshInTrack(track, names, windowMs, now) {
|
|
4407
|
+
const found = /* @__PURE__ */ new Set();
|
|
4408
|
+
for (let i = track.agents.length - 1; i >= 0; i--) {
|
|
4409
|
+
const entry = track.agents[i];
|
|
4410
|
+
if (!entry) continue;
|
|
4411
|
+
if (now - entry.ts > windowMs) break;
|
|
4412
|
+
if (entry.quality !== "sufficient") continue;
|
|
4413
|
+
for (const req of names) if (entry.name.includes(req)) found.add(req);
|
|
4414
|
+
if (found.size === names.length) return true;
|
|
4415
|
+
}
|
|
4416
|
+
return names.every((n) => found.has(n));
|
|
4417
|
+
}
|
|
4418
|
+
//#endregion
|
|
4264
4419
|
//#region src/runtime/gate-apex.ts
|
|
4265
4420
|
/**
|
|
4266
|
-
* The APEX-scoped portion of {@link gate}
|
|
4267
|
-
*
|
|
4268
|
-
*
|
|
4269
|
-
*
|
|
4270
|
-
*
|
|
4421
|
+
* The APEX-scoped portion of {@link gate}, in the Python enforce-apex-phases.ts
|
|
4422
|
+
* order: protected-paths guard, skip-dirs allow, trivial-edit fast path, then
|
|
4423
|
+
* brainstorm/freshness, Check 1 (per-framework doc authorization — a deny
|
|
4424
|
+
* persists `track.target` so recordDoc can cross-credit the framework) and
|
|
4425
|
+
* Check 2 + SOLID refs. Freshness scans the SESSION track first (parity
|
|
4426
|
+
* _scan_agents: sub-agent hooks share the lead's session_id, so sidechain/
|
|
4427
|
+
* Workflow research is visible); the platform-authored transcript scan and the
|
|
4428
|
+
* legacy exact-name track check are fallbacks.
|
|
4271
4429
|
* @param input - The tool-use to gate.
|
|
4272
4430
|
* @param track - The loaded session track.
|
|
4273
4431
|
* @param window - Freshness window in ms.
|
|
4274
4432
|
* @returns The first blocking prompt, or null to fall through to the DRY check.
|
|
4275
4433
|
*/
|
|
4276
4434
|
async function apexScopedGate(input, track, window) {
|
|
4277
|
-
const
|
|
4278
|
-
if (
|
|
4435
|
+
const protectedDeny = protectedPathGate(input.tool, input.filePath);
|
|
4436
|
+
if (protectedDeny) return protectedDeny;
|
|
4437
|
+
if (SKIP_DIRS.test(input.filePath ?? "")) return null;
|
|
4438
|
+
if (isTrivialEdit(input.tool, input.content, input.isReplaceAll) && trivialCount(track, window, input.now) < 4) {
|
|
4279
4439
|
await saveTrack(input.trackFile, recordTrivialEdit(track, input.now, window, input.now));
|
|
4280
4440
|
return null;
|
|
4281
4441
|
}
|
|
4282
|
-
const freshnessFor = (names, windowMs = window) => input.transcriptPath ? agentsRanFromTranscript(input.transcriptPath, names, windowMs, input.now) : agentsFresh(track, names, windowMs, input.now);
|
|
4442
|
+
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));
|
|
4283
4443
|
const ctx = {
|
|
4284
4444
|
sessionId: input.sessionId,
|
|
4285
4445
|
framework: input.framework,
|
|
@@ -4288,6 +4448,8 @@ async function apexScopedGate(input, track, window) {
|
|
|
4288
4448
|
authorizations: track.authorizations,
|
|
4289
4449
|
refs: input.refs,
|
|
4290
4450
|
refsRead: track.refsRead,
|
|
4451
|
+
refsReadAt: track.refsReadAt,
|
|
4452
|
+
now: input.now,
|
|
4291
4453
|
agentsFresh: freshnessFor([...REQUIRED_AGENTS]),
|
|
4292
4454
|
missingAgents: REQUIRED_AGENTS.filter((name) => !freshnessFor([name])),
|
|
4293
4455
|
windowMs: window,
|
|
@@ -4295,7 +4457,20 @@ async function apexScopedGate(input, track, window) {
|
|
|
4295
4457
|
brainstormFresh: freshnessFor(["brainstorming"], Number.MAX_SAFE_INTEGER)
|
|
4296
4458
|
};
|
|
4297
4459
|
try {
|
|
4298
|
-
|
|
4460
|
+
const pre = evaluateApex(ctx, PRE_AUTH_GATES);
|
|
4461
|
+
if (pre) return pre;
|
|
4462
|
+
const authDeny = apexAuthorizationGate(ctx);
|
|
4463
|
+
if (authDeny) {
|
|
4464
|
+
const set_at = new Date(input.now).toISOString();
|
|
4465
|
+
await saveTrack(input.trackFile, recordTarget(track, {
|
|
4466
|
+
project: input.cwd ?? "",
|
|
4467
|
+
framework: input.framework,
|
|
4468
|
+
set_by: "apex-authorization",
|
|
4469
|
+
set_at
|
|
4470
|
+
}));
|
|
4471
|
+
return authDeny;
|
|
4472
|
+
}
|
|
4473
|
+
return evaluateApex(ctx, POST_AUTH_GATES);
|
|
4299
4474
|
} catch {
|
|
4300
4475
|
return FAIL_CLOSED;
|
|
4301
4476
|
}
|
|
@@ -4321,6 +4496,8 @@ const TRIVIAL_BUDGET = 4;
|
|
|
4321
4496
|
async function gate(input) {
|
|
4322
4497
|
const precommit = preCommitGate(input.tool, input.command, input.cwd);
|
|
4323
4498
|
if (precommit) return precommit;
|
|
4499
|
+
const protectedDeny = protectedPathGate(input.tool, input.filePath);
|
|
4500
|
+
if (protectedDeny) return protectedDeny;
|
|
4324
4501
|
const { raw: existingLines, code: existingCodeLines } = existingLineCounts(input.filePath);
|
|
4325
4502
|
let quick;
|
|
4326
4503
|
try {
|
|
@@ -4483,7 +4660,7 @@ function mcpPostStore(tool, input, response, dir, now = Date.now()) {
|
|
|
4483
4660
|
/** Apply an activity to a session's track and persist it (PostToolUse path). */
|
|
4484
4661
|
async function recordActivity(file, activity) {
|
|
4485
4662
|
const track = await loadTrack(file);
|
|
4486
|
-
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));
|
|
4663
|
+
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));
|
|
4487
4664
|
}
|
|
4488
4665
|
//#endregion
|
|
4489
4666
|
//#region src/runtime/respond.ts
|
|
@@ -4491,51 +4668,81 @@ async function recordActivity(file, activity) {
|
|
|
4491
4668
|
* Map a portable {@link Prompt} to a harness's native hook response, honoring
|
|
4492
4669
|
* all 3 prompt kinds (`block`/`ask`/`inform`) instead of collapsing `ask` and
|
|
4493
4670
|
* `inform` together. Reuses the Claude adapter's `denyResponse`/
|
|
4494
|
-
* `contextResponse` builders for claude-code/codex (same hook
|
|
4495
|
-
* duplicating the JSON.
|
|
4671
|
+
* `contextResponse`/`informResponse` builders for claude-code/codex (same hook
|
|
4672
|
+
* shape) to avoid duplicating the JSON.
|
|
4496
4673
|
* - claude-code/codex: `block` -> deny, `ask` -> interactive
|
|
4497
4674
|
* `permissionDecision:"ask"`, `inform` -> non-blocking `additionalContext`.
|
|
4498
4675
|
* - gemini-cli/cline: their real hook schemas have no interactive "ask"
|
|
4499
4676
|
* state (deny is the only blocking outcome), so `ask` and `inform` already
|
|
4500
4677
|
* both resolve to non-blocking context injection — unchanged.
|
|
4501
|
-
* - cursor: `ask` keeps its current best-effort `permission:"ask"` shape
|
|
4502
|
-
*
|
|
4503
|
-
*
|
|
4504
|
-
*
|
|
4678
|
+
* - cursor: `ask` keeps its current best-effort `permission:"ask"` shape;
|
|
4679
|
+
* only `inform` is fixed to a non-blocking `permission:"allow"` note.
|
|
4680
|
+
* - hermes: delegated to the adapter's `toHermesResponse` — `block` ->
|
|
4681
|
+
* `{decision:"block",reason}`; `ask`/`inform` degrade to non-blocking
|
|
4682
|
+
* `{context}` (Hermes has no interactive "ask" state).
|
|
4683
|
+
*
|
|
4684
|
+
* User-visible pass notices (`prompt.userMessage`, Python `hook_output.allow_pass`
|
|
4685
|
+
* / `post_pass` parity), by harness:
|
|
4686
|
+
* - claude-code/codex: top-level `systemMessage` ("warning message shown to the
|
|
4687
|
+
* user"); a pure notice (empty `reason`) emits `{systemMessage}` alone. Codex
|
|
4688
|
+
* consumes the Claude hook schema, so it rides the same field.
|
|
4689
|
+
* - gemini-cli: common `systemMessage` output field ("displayed immediately to
|
|
4690
|
+
* the user in the terminal") — the notice rides there.
|
|
4691
|
+
* - cursor: `user_message` is its user-visible channel (see case comment).
|
|
4692
|
+
* - cline: NO user-visible channel (`cancel`/`contextModification`/
|
|
4693
|
+
* `errorMessage`-on-cancel only) — the notice is dropped silently (a pure
|
|
4694
|
+
* notice returns "" = plain allow).
|
|
4505
4695
|
*/
|
|
4506
4696
|
function respond(id, prompt) {
|
|
4507
4697
|
const message = formatPrompt(prompt);
|
|
4508
|
-
const { kind } = prompt;
|
|
4698
|
+
const { kind, userMessage, reason } = prompt;
|
|
4509
4699
|
switch (id) {
|
|
4510
4700
|
case "claude-code":
|
|
4511
4701
|
case "codex":
|
|
4512
4702
|
if (kind === "block") return denyResponse("PreToolUse", message);
|
|
4513
|
-
if (kind === "inform") return contextResponse("PreToolUse", message);
|
|
4703
|
+
if (kind === "inform") return userMessage ? informResponse("PreToolUse", userMessage, reason ? message : "") : contextResponse("PreToolUse", message);
|
|
4514
4704
|
return JSON.stringify({ hookSpecificOutput: {
|
|
4515
4705
|
hookEventName: "PreToolUse",
|
|
4516
4706
|
permissionDecision: "ask",
|
|
4517
4707
|
permissionDecisionReason: message
|
|
4518
4708
|
} });
|
|
4519
|
-
case "gemini-cli":
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
case "cursor":
|
|
4524
|
-
if (kind === "inform") return JSON.stringify({
|
|
4525
|
-
permission: "allow",
|
|
4526
|
-
userMessage: message,
|
|
4527
|
-
agentMessage: message
|
|
4709
|
+
case "gemini-cli":
|
|
4710
|
+
if (kind === "block") return JSON.stringify({
|
|
4711
|
+
decision: "deny",
|
|
4712
|
+
reason: message
|
|
4528
4713
|
});
|
|
4714
|
+
if (userMessage) return JSON.stringify({
|
|
4715
|
+
...reason ? { hookSpecificOutput: { additionalContext: message } } : {},
|
|
4716
|
+
systemMessage: userMessage
|
|
4717
|
+
});
|
|
4718
|
+
return JSON.stringify({ hookSpecificOutput: { additionalContext: message } });
|
|
4719
|
+
case "cursor":
|
|
4720
|
+
if (kind === "inform") {
|
|
4721
|
+
if (userMessage) return JSON.stringify({
|
|
4722
|
+
permission: "allow",
|
|
4723
|
+
user_message: userMessage,
|
|
4724
|
+
...reason ? { agent_message: message } : {}
|
|
4725
|
+
});
|
|
4726
|
+
return JSON.stringify({
|
|
4727
|
+
permission: "allow",
|
|
4728
|
+
user_message: message,
|
|
4729
|
+
agent_message: message
|
|
4730
|
+
});
|
|
4731
|
+
}
|
|
4529
4732
|
return JSON.stringify({
|
|
4530
4733
|
permission: kind === "block" ? "deny" : "ask",
|
|
4531
4734
|
continue: false,
|
|
4532
|
-
|
|
4533
|
-
|
|
4735
|
+
user_message: message,
|
|
4736
|
+
agent_message: message
|
|
4534
4737
|
});
|
|
4535
|
-
case "
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4738
|
+
case "hermes": return toHermesResponse(prompt);
|
|
4739
|
+
case "cline":
|
|
4740
|
+
if (kind === "block") return JSON.stringify({
|
|
4741
|
+
cancel: true,
|
|
4742
|
+
errorMessage: message
|
|
4743
|
+
});
|
|
4744
|
+
if (userMessage && !reason) return "";
|
|
4745
|
+
return JSON.stringify({ contextModification: message });
|
|
4539
4746
|
default: return "";
|
|
4540
4747
|
}
|
|
4541
4748
|
}
|
|
@@ -4723,10 +4930,10 @@ function collectDesignEvidence(sessionId, cwd, baseDir = defaultStateDir(cwd)) {
|
|
|
4723
4930
|
* `design.ts` to keep that file within the SOLID size budget (SRP).
|
|
4724
4931
|
* @packageDocumentation
|
|
4725
4932
|
*/
|
|
4726
|
-
const NAV$
|
|
4727
|
-
const SHOT$
|
|
4933
|
+
const NAV$2 = "mcp__fuse-browser__browser_navigate";
|
|
4934
|
+
const SHOT$2 = "mcp__fuse-browser__browser_screenshot";
|
|
4728
4935
|
const SCROLL = "mcp__fuse-browser__browser_scroll";
|
|
4729
|
-
const GEMINI$
|
|
4936
|
+
const GEMINI$2 = "mcp__gemini-design__create_frontend";
|
|
4730
4937
|
/** Read design-system.md walking up to 6 parents from `cwd` ("" if absent/unreadable). */
|
|
4731
4938
|
function findDesignSystem(cwd) {
|
|
4732
4939
|
let dir = cwd;
|
|
@@ -4745,10 +4952,10 @@ function findDesignSystem(cwd) {
|
|
|
4745
4952
|
}
|
|
4746
4953
|
/** Apply a PostToolUse fuse-browser transition to the design state. */
|
|
4747
4954
|
function recordPost(event, cacheDir, state) {
|
|
4748
|
-
if (event.tool === SHOT$
|
|
4749
|
-
else if (event.tool === NAV$
|
|
4955
|
+
if (event.tool === SHOT$2) saveDesignState(cacheDir, recordScreenshot(state, MIN_SCREENSHOTS[state.mode]));
|
|
4956
|
+
else if (event.tool === NAV$2) saveDesignState(cacheDir, recordNavigate(state));
|
|
4750
4957
|
else if (event.tool === SCROLL) saveDesignState(cacheDir, recordScroll(state));
|
|
4751
|
-
else if (event.tool === GEMINI$
|
|
4958
|
+
else if (event.tool === GEMINI$2) saveDesignState(cacheDir, {
|
|
4752
4959
|
...state,
|
|
4753
4960
|
geminiCalls: state.geminiCalls + 1
|
|
4754
4961
|
});
|
|
@@ -4771,6 +4978,9 @@ const FORBIDDEN_FONTS = [
|
|
|
4771
4978
|
"Open Sans"
|
|
4772
4979
|
];
|
|
4773
4980
|
const OKLCH_RE = /oklch\(\s*[\d.]+%?\s+0\.0*[1-9]/;
|
|
4981
|
+
const NAV$1 = "mcp__fuse-browser__browser_navigate";
|
|
4982
|
+
const SHOT$1 = "mcp__fuse-browser__browser_screenshot";
|
|
4983
|
+
const GEMINI$1 = "mcp__gemini-design__create_frontend";
|
|
4774
4984
|
const deny = (reason) => ({
|
|
4775
4985
|
kind: "block",
|
|
4776
4986
|
title: "Design pipeline",
|
|
@@ -4804,6 +5014,40 @@ function geminiEnabled() {
|
|
|
4804
5014
|
const v = process.env.FUSE_DESIGN_GEMINI;
|
|
4805
5015
|
return v === "1" || v === "true";
|
|
4806
5016
|
}
|
|
5017
|
+
/**
|
|
5018
|
+
* Python-parity pass notices (`hook_output.allow_pass`/`post_pass`): the
|
|
5019
|
+
* user-visible `script: detail` lines the old plugins printed on EVERY passing
|
|
5020
|
+
* design hook. Call AFTER the gates allowed the event; returns an `inform`
|
|
5021
|
+
* Prompt carrying only `userMessage` (for the human, never agent context), or
|
|
5022
|
+
* null when nothing applies. The check-design-skill line fires for ANY agent
|
|
5023
|
+
* on a UI write; the rest only inside the active design-agent context.
|
|
5024
|
+
*/
|
|
5025
|
+
function designPassNotice(i, cacheDir) {
|
|
5026
|
+
const isWrite = i.tool === "Write" || i.tool === "Edit";
|
|
5027
|
+
const fp = i.filePath;
|
|
5028
|
+
const lines = [];
|
|
5029
|
+
if (i.phase === "pre" && isUiWrite(i.tool, fp, i.content)) {
|
|
5030
|
+
const domains = SKILL_TRIGGERS.filter((t) => t.patterns.some((re) => re.test(i.content))).map((t) => t.skill).join(", ");
|
|
5031
|
+
lines.push(`check-design-skill: pass (domain: ${domains || "base"})`);
|
|
5032
|
+
}
|
|
5033
|
+
const state = i.agentId && activeDesignAgent(cacheDir) === i.agentId ? loadDesignState(cacheDir, i.agentId) : null;
|
|
5034
|
+
if (state && i.phase === "pre") {
|
|
5035
|
+
if (isWrite && fp && !EXEMPT_DIRS.some((d) => fp.includes(d)) && ALLOWED_WRITE.test(fp)) lines.push(`enforce-html-css-only: allowed: ${basename(fp)}`);
|
|
5036
|
+
if (i.tool === NAV$1) lines.push(`check-inspiration-read: pass (${i.url})`);
|
|
5037
|
+
if (i.tool === GEMINI$1 && geminiEnabled()) lines.push("validate-design-system: design-system.md ok");
|
|
5038
|
+
if (isWrite || i.tool === NAV$1 || i.tool === SHOT$1 || i.tool === GEMINI$1) lines.push(`pipeline-gate: phase ${state.currentPhase} ok`);
|
|
5039
|
+
}
|
|
5040
|
+
if (state && i.phase === "post" && isWrite) {
|
|
5041
|
+
if (basename(fp) === "design-system.md") lines.push("validate-design: design-system.md → phase 3");
|
|
5042
|
+
else if (/\.(tsx|jsx|css)$/.test(fp)) lines.push("validate-design: design ok");
|
|
5043
|
+
}
|
|
5044
|
+
return lines.length ? {
|
|
5045
|
+
kind: "inform",
|
|
5046
|
+
title: "Design pipeline",
|
|
5047
|
+
reason: "",
|
|
5048
|
+
userMessage: lines.join("\n")
|
|
5049
|
+
} : null;
|
|
5050
|
+
}
|
|
4807
5051
|
//#endregion
|
|
4808
5052
|
//#region src/policy/design/gates-pipeline.ts
|
|
4809
5053
|
const SKILLS = `${PLUGINS_DIR}/design-expert/skills`;
|
|
@@ -4947,13 +5191,10 @@ async function handlePre(ctx) {
|
|
|
4947
5191
|
stdout: securityAdvisory(event.tool, event.filePath ?? "", opts.now),
|
|
4948
5192
|
exit: 0
|
|
4949
5193
|
};
|
|
4950
|
-
if (opts.scope === "solid"
|
|
4951
|
-
|
|
4952
|
-
|
|
4953
|
-
|
|
4954
|
-
exit: 0
|
|
4955
|
-
};
|
|
4956
|
-
}
|
|
5194
|
+
if (opts.scope === "solid") return {
|
|
5195
|
+
stdout: validateSolidGate(event.tool, event.filePath ?? "", event.content ?? ""),
|
|
5196
|
+
exit: 0
|
|
5197
|
+
};
|
|
4957
5198
|
if (event.tool === "Task") {
|
|
4958
5199
|
const taskCtx = taskContext(opts.cwd);
|
|
4959
5200
|
if (taskCtx) return {
|
|
@@ -4978,15 +5219,103 @@ async function handlePre(ctx) {
|
|
|
4978
5219
|
trackFile: file,
|
|
4979
5220
|
transcriptPath: typeof payload.transcript_path === "string" ? payload.transcript_path : void 0
|
|
4980
5221
|
});
|
|
4981
|
-
|
|
5222
|
+
if (prompt) return {
|
|
4982
5223
|
stdout: respond(id, prompt),
|
|
4983
5224
|
exit: 0
|
|
4984
|
-
}
|
|
4985
|
-
|
|
5225
|
+
};
|
|
5226
|
+
const notice = designPassNotice({
|
|
5227
|
+
agentId: typeof payload.agent_id === "string" ? payload.agent_id : "",
|
|
5228
|
+
tool: event.tool,
|
|
5229
|
+
filePath: event.filePath ?? "",
|
|
5230
|
+
content: event.content ?? "",
|
|
5231
|
+
url: typeof event.input.url === "string" ? event.input.url : "",
|
|
5232
|
+
phase: "pre"
|
|
5233
|
+
}, mcpDir);
|
|
5234
|
+
return {
|
|
5235
|
+
stdout: notice ? respond(id, notice) : "",
|
|
4986
5236
|
exit: 0
|
|
4987
5237
|
};
|
|
4988
5238
|
}
|
|
4989
5239
|
//#endregion
|
|
5240
|
+
//#region src/freshness/query-framework.ts
|
|
5241
|
+
/**
|
|
5242
|
+
* Framework classification for doc consultations, keyed on the QUERY text
|
|
5243
|
+
* (parity ai-pilot/scripts/track_doc_helpers.py::detect_framework): an MCP
|
|
5244
|
+
* doc call (context7/exa/web) has no filePath, so the file-based
|
|
5245
|
+
* `detectFramework()` always yields "generic" — the query keywords are the
|
|
5246
|
+
* only framework signal a doc consultation carries.
|
|
5247
|
+
*/
|
|
5248
|
+
/**
|
|
5249
|
+
* Ordered patterns, EXACT parity with the Python list (same order, same
|
|
5250
|
+
* case-sensitive alternations, substring search — only `go` is word-bounded).
|
|
5251
|
+
* Quirks are inherited deliberately: "javascript" hits `java` (but only after
|
|
5252
|
+
* nextjs/react/laravel/swift/tailwind all missed) and all-caps "NEXT" misses.
|
|
5253
|
+
* The output domain matches `detectFramework()` exactly, so every credit
|
|
5254
|
+
* lands on a key the apex authorization gate actually reads.
|
|
5255
|
+
*/
|
|
5256
|
+
const QUERY_FRAMEWORKS = [
|
|
5257
|
+
[/(next|nextjs|Next)/, "nextjs"],
|
|
5258
|
+
[/(react|React)/, "react"],
|
|
5259
|
+
[/(laravel|Laravel|php|PHP)/, "laravel"],
|
|
5260
|
+
[/(swift|Swift|swiftui|SwiftUI)/, "swift"],
|
|
5261
|
+
[/(tailwind|Tailwind)/, "tailwind"],
|
|
5262
|
+
[/(java|Java|spring|Spring)/, "java"],
|
|
5263
|
+
[/\b(go|Go|golang)\b/, "go"],
|
|
5264
|
+
[/(ruby|Ruby|rails|Rails)/, "ruby"],
|
|
5265
|
+
[/(rust|Rust|cargo|Cargo)/, "rust"]
|
|
5266
|
+
];
|
|
5267
|
+
/**
|
|
5268
|
+
* The framework a doc query targets, or null when no keyword matches (the
|
|
5269
|
+
* Python returns "generic"; null here lets the caller fall back to the
|
|
5270
|
+
* event's file-detected framework instead of hardcoding generic).
|
|
5271
|
+
* @param query - The query/url/prompt text of the doc tool call.
|
|
5272
|
+
*/
|
|
5273
|
+
function frameworkFromQuery(query) {
|
|
5274
|
+
for (const [re, fw] of QUERY_FRAMEWORKS) if (re.test(query)) return fw;
|
|
5275
|
+
return null;
|
|
5276
|
+
}
|
|
5277
|
+
/**
|
|
5278
|
+
* tool_input fields carrying query text across doc tools: context7
|
|
5279
|
+
* (`libraryId`/`libraryName`, parity extract_tool_info), exa/WebSearch
|
|
5280
|
+
* (`query`), fuse-browser serp (`queries[]`), WebFetch (`url` + `prompt`).
|
|
5281
|
+
*/
|
|
5282
|
+
const QUERY_FIELDS = [
|
|
5283
|
+
"query",
|
|
5284
|
+
"queries",
|
|
5285
|
+
"libraryId",
|
|
5286
|
+
"libraryName",
|
|
5287
|
+
"prompt",
|
|
5288
|
+
"url"
|
|
5289
|
+
];
|
|
5290
|
+
/**
|
|
5291
|
+
* Fold every query-ish tool_input field into one searchable string. Distinct
|
|
5292
|
+
* from mcp-key.ts `queryOf`, which picks a SINGLE field to key the response
|
|
5293
|
+
* cache — classification wants ALL the text (e.g. WebFetch url + prompt).
|
|
5294
|
+
* @param input - The normalized tool_input (absent on some harnesses).
|
|
5295
|
+
*/
|
|
5296
|
+
function docQueryOf(input) {
|
|
5297
|
+
if (!input) return "";
|
|
5298
|
+
const parts = [];
|
|
5299
|
+
for (const key of QUERY_FIELDS) {
|
|
5300
|
+
const v = input[key];
|
|
5301
|
+
if (typeof v === "string") parts.push(v);
|
|
5302
|
+
else if (Array.isArray(v)) {
|
|
5303
|
+
for (const s of v) if (typeof s === "string") parts.push(s);
|
|
5304
|
+
}
|
|
5305
|
+
}
|
|
5306
|
+
return parts.join(" ");
|
|
5307
|
+
}
|
|
5308
|
+
/**
|
|
5309
|
+
* The framework to credit for a doc consultation: the query's framework when
|
|
5310
|
+
* a keyword matches (query wins over file-based detection), else `fallback`
|
|
5311
|
+
* (the event's framework — "generic" for pathless MCP calls).
|
|
5312
|
+
* @param input - The doc tool's tool_input.
|
|
5313
|
+
* @param fallback - The event's file-detected framework.
|
|
5314
|
+
*/
|
|
5315
|
+
function docFramework(input, fallback) {
|
|
5316
|
+
return frameworkFromQuery(docQueryOf(input)) ?? fallback;
|
|
5317
|
+
}
|
|
5318
|
+
//#endregion
|
|
4990
5319
|
//#region src/runtime/activity.ts
|
|
4991
5320
|
/** Min response length (chars) for a lead agent call to count as `sufficient`. */
|
|
4992
5321
|
const AGENT_QUALITY_MIN = 500;
|
|
@@ -5045,9 +5374,10 @@ function activityFor(event) {
|
|
|
5045
5374
|
const docSource = docSourceOf(event.tool);
|
|
5046
5375
|
if (docSource) out.push({
|
|
5047
5376
|
kind: "doc",
|
|
5048
|
-
framework: event.framework,
|
|
5377
|
+
framework: docFramework(event.input, event.framework),
|
|
5049
5378
|
sessionId: event.sessionId,
|
|
5050
|
-
source: docSource
|
|
5379
|
+
source: docSource,
|
|
5380
|
+
ts: event.now
|
|
5051
5381
|
});
|
|
5052
5382
|
if (event.tool === "Task" || event.tool === "Agent") {
|
|
5053
5383
|
const name = String(event.input?.subagent_type ?? event.input?.name ?? "").split(":").pop() ?? "";
|
|
@@ -5062,7 +5392,8 @@ function activityFor(event) {
|
|
|
5062
5392
|
const path = String(event.input?.file_path ?? event.input?.path ?? "");
|
|
5063
5393
|
if (path.endsWith(".md")) out.push({
|
|
5064
5394
|
kind: "ref",
|
|
5065
|
-
path
|
|
5395
|
+
path,
|
|
5396
|
+
ts: event.now
|
|
5066
5397
|
});
|
|
5067
5398
|
}
|
|
5068
5399
|
return out;
|
|
@@ -5090,6 +5421,8 @@ async function handlePost(ctx) {
|
|
|
5090
5421
|
responseLength: extractText(response).length
|
|
5091
5422
|
});
|
|
5092
5423
|
for (const activity of activities) await recordActivity(file, activity);
|
|
5424
|
+
const evidence = classifyAgentEvidence(event.tool, event.input, response);
|
|
5425
|
+
if (evidence) await recordAgentEvidence(file, evidence, opts.now, typeof payload.agent_id === "string" ? payload.agent_id : void 0);
|
|
5093
5426
|
postTrackingSideEffects(opts.scope ?? "core", event, event.input, opts.now, payload, opts.cwd);
|
|
5094
5427
|
const seoDeny = opts.scope === "seo" ? seoPostToolUseResponse(payload) : null;
|
|
5095
5428
|
if (seoDeny) return {
|
|
@@ -5118,8 +5451,35 @@ async function handlePost(ctx) {
|
|
|
5118
5451
|
};
|
|
5119
5452
|
}
|
|
5120
5453
|
const extra = await postEditContext(opts.scope ?? "core", event, opts.now);
|
|
5454
|
+
const notice = designPassNotice({
|
|
5455
|
+
agentId: typeof payload.agent_id === "string" ? payload.agent_id : "",
|
|
5456
|
+
tool: event.tool,
|
|
5457
|
+
filePath: event.filePath ?? "",
|
|
5458
|
+
content: event.content ?? "",
|
|
5459
|
+
url: "",
|
|
5460
|
+
phase: "post"
|
|
5461
|
+
}, mcpDir);
|
|
5462
|
+
if (designWarn) return {
|
|
5463
|
+
stdout: respond(id, notice?.userMessage ? {
|
|
5464
|
+
...designWarn,
|
|
5465
|
+
userMessage: notice.userMessage
|
|
5466
|
+
} : designWarn),
|
|
5467
|
+
exit: 0
|
|
5468
|
+
};
|
|
5469
|
+
if (!notice?.userMessage) return {
|
|
5470
|
+
stdout: extra,
|
|
5471
|
+
exit: 0
|
|
5472
|
+
};
|
|
5473
|
+
if (!extra) return {
|
|
5474
|
+
stdout: respond(id, notice),
|
|
5475
|
+
exit: 0
|
|
5476
|
+
};
|
|
5477
|
+
if (id === "claude-code" || id === "codex") return {
|
|
5478
|
+
stdout: attachSystemMessage(extra, notice.userMessage),
|
|
5479
|
+
exit: 0
|
|
5480
|
+
};
|
|
5121
5481
|
return {
|
|
5122
|
-
stdout:
|
|
5482
|
+
stdout: respond(id, notice) || extra,
|
|
5123
5483
|
exit: 0
|
|
5124
5484
|
};
|
|
5125
5485
|
}
|