@fusengine/harness 0.1.48 → 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.
Files changed (49) hide show
  1. package/dist/adapters/claude/index.d.mts +2 -2
  2. package/dist/adapters/claude/index.mjs +2 -2
  3. package/dist/adapters/cline/index.mjs +1 -1
  4. package/dist/adapters/codex/index.d.mts +1 -1
  5. package/dist/adapters/codex/index.mjs +1 -1
  6. package/dist/adapters/cursor/index.d.mts +10 -3
  7. package/dist/adapters/cursor/index.mjs +3 -3
  8. package/dist/adapters/gemini/index.mjs +1 -1
  9. package/dist/adapters/hermes/index.d.mts +54 -0
  10. package/dist/adapters/hermes/index.mjs +2 -0
  11. package/dist/apex-Wdi1nq_w.d.mts +73 -0
  12. package/dist/{claude-U1KTaRG4.mjs → claude-BxC9semG.mjs} +30 -9
  13. package/dist/cli/bin.mjs +5 -5
  14. package/dist/cli/index.mjs +1 -1
  15. package/dist/config/index.d.mts +1 -1
  16. package/dist/config/index.mjs +1 -1
  17. package/dist/detect/index.d.mts +1 -1
  18. package/dist/detect/index.mjs +1 -1
  19. package/dist/{doc-helpers-BNfYWvYv.d.mts → doc-helpers-CEKzGg2u.d.mts} +2 -0
  20. package/dist/{dotenv-B9nM4cuQ.mjs → dotenv-Jj8aL1FL.mjs} +2 -1
  21. package/dist/{evaluate-qYC5xClb.mjs → evaluate-CNAzgxnN.mjs} +3 -85
  22. package/dist/freshness/index.d.mts +1 -1
  23. package/dist/{handle-CCV0ycdr.mjs → handle-D1hHqHvh.mjs} +404 -52
  24. package/dist/{harness-C8Nxxyn_.mjs → harness-Cb9xR8dC.mjs} +6 -3
  25. package/dist/{harness-BPPu5CrN.d.mts → harness-s1Fce_dL.d.mts} +1 -1
  26. package/dist/hermes-DWXCRFZU.mjs +54 -0
  27. package/dist/home-state-D0RLWP8J.mjs +134 -0
  28. package/dist/{index-BqfOFKm7.d.mts → index-BfMsILmg.d.mts} +2 -63
  29. package/dist/{index-BxjzFraL.d.mts → index-DZCLmSoO.d.mts} +11 -9
  30. package/dist/{index-BXPySPxE.d.mts → index-jj_EzZRv.d.mts} +1 -1
  31. package/dist/index.d.mts +7 -6
  32. package/dist/index.mjs +6 -5
  33. package/dist/init/index.d.mts +1 -1
  34. package/dist/policy/index.d.mts +3 -2
  35. package/dist/policy/index.mjs +4 -3
  36. package/dist/prompt/index.d.mts +1 -1
  37. package/dist/{run-CaqeQFYp.mjs → run-DZvP_9xB.mjs} +1 -1
  38. package/dist/runtime/index.d.mts +22 -8
  39. package/dist/runtime/index.mjs +2 -2
  40. package/dist/{session-state-V8Jp-KE5.d.mts → session-state-DMpotbRz.d.mts} +26 -4
  41. package/dist/skill-path-DhItkBzk.mjs +95 -0
  42. package/dist/store-QSSTO3lY.mjs +349 -0
  43. package/dist/tracking/index.d.mts +2 -2
  44. package/dist/tracking/index.mjs +2 -2
  45. package/dist/{types-D56jSgD9.d.mts → types-DVbIl9md.d.mts} +6 -0
  46. package/dist/{validate-ebDjIyOa.mjs → validate-DCQ8dkdL.mjs} +16 -100
  47. package/package.json +7 -2
  48. package/dist/home-state-mKZxP4oZ.mjs +0 -52
  49. 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 { B as detectFramework, C as resolveSkillPath, D as frameworkSolidGate, G as requiredArchSkill, H as detectModularArchitecture, I as docConsultedGate, L as evaluateApex, M as capVerbosity, N as detectCreationIntent, O as isExcludedJsPath, S as usesTailwindUtilities, T as readPluginMeta, U as detectProjectType$1, _ as scanPlugin, c as EXCLUDE_DIRS$1, d as buildApexTaskInjection, h as buildClaudeMdContext, k as isExcludedSwiftPath, l as PROJECT_INDICATORS, n as missingSeoElements, o as parseEnrichment, r as descFromText, s as parseEntry, t as isHtmlLike, w as findMarketplacePlugins, x as skillTriggerGate, y as parseField } from "./validate-ebDjIyOa.mjs";
5
- import { F as PLUGINS_DIR, L as countFrameworkCodeLines, R as countLines, n as FAIL_CLOSED, t as evaluate } from "./evaluate-qYC5xClb.mjs";
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 recordTrivialEdit } from "./store-ARx-GQSQ.mjs";
14
- import { d as readText, f as sleep, l as collectFiles, m as writeText, n as contextResponse, p as spawnCapture, r as denyResponse, s as systemMessage, t as blockResponse, u as pathExists } from "./claude-U1KTaRG4.mjs";
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
- /** Research tools — MCP docs + web (parity `apex_constants.RESEARCH_TOOLS`). */
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([
@@ -4269,25 +4326,120 @@ function agentsRanFromTranscript(transcriptPath, names, windowMs, now) {
4269
4326
  return names.every((n) => found.has(n));
4270
4327
  }
4271
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
4272
4419
  //#region src/runtime/gate-apex.ts
4273
4420
  /**
4274
- * The APEX-scoped portion of {@link gate}: a trivial-edit fast path, then the
4275
- * freshness/doc/SOLID gates fed from the session track. Freshness prefers
4276
- * platform-authored transcript evidence (an agent cannot forge a `Task` in the
4277
- * runtime-owned transcript); the self-recorded track is only a fallback when no
4278
- * transcript path is available (tests / other harnesses).
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.
4279
4429
  * @param input - The tool-use to gate.
4280
4430
  * @param track - The loaded session track.
4281
4431
  * @param window - Freshness window in ms.
4282
4432
  * @returns The first blocking prompt, or null to fall through to the DRY check.
4283
4433
  */
4284
4434
  async function apexScopedGate(input, track, window) {
4285
- const lineCount = input.content === void 0 ? Number.POSITIVE_INFINITY : input.content.split("\n").length;
4286
- if (input.tool === "Edit" && !input.isReplaceAll && lineCount < 5 && trivialCount(track, window, input.now) < 4) {
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) {
4287
4439
  await saveTrack(input.trackFile, recordTrivialEdit(track, input.now, window, input.now));
4288
4440
  return null;
4289
4441
  }
4290
- 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));
4291
4443
  const ctx = {
4292
4444
  sessionId: input.sessionId,
4293
4445
  framework: input.framework,
@@ -4305,7 +4457,20 @@ async function apexScopedGate(input, track, window) {
4305
4457
  brainstormFresh: freshnessFor(["brainstorming"], Number.MAX_SAFE_INTEGER)
4306
4458
  };
4307
4459
  try {
4308
- return evaluateApex(ctx);
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);
4309
4474
  } catch {
4310
4475
  return FAIL_CLOSED;
4311
4476
  }
@@ -4331,6 +4496,8 @@ const TRIVIAL_BUDGET = 4;
4331
4496
  async function gate(input) {
4332
4497
  const precommit = preCommitGate(input.tool, input.command, input.cwd);
4333
4498
  if (precommit) return precommit;
4499
+ const protectedDeny = protectedPathGate(input.tool, input.filePath);
4500
+ if (protectedDeny) return protectedDeny;
4334
4501
  const { raw: existingLines, code: existingCodeLines } = existingLineCounts(input.filePath);
4335
4502
  let quick;
4336
4503
  try {
@@ -4493,7 +4660,7 @@ function mcpPostStore(tool, input, response, dir, now = Date.now()) {
4493
4660
  /** Apply an activity to a session's track and persist it (PostToolUse path). */
4494
4661
  async function recordActivity(file, activity) {
4495
4662
  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));
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));
4497
4664
  }
4498
4665
  //#endregion
4499
4666
  //#region src/runtime/respond.ts
@@ -4501,51 +4668,81 @@ async function recordActivity(file, activity) {
4501
4668
  * Map a portable {@link Prompt} to a harness's native hook response, honoring
4502
4669
  * all 3 prompt kinds (`block`/`ask`/`inform`) instead of collapsing `ask` and
4503
4670
  * `inform` together. Reuses the Claude adapter's `denyResponse`/
4504
- * `contextResponse` builders for claude-code/codex (same hook shape) to avoid
4505
- * duplicating the JSON.
4671
+ * `contextResponse`/`informResponse` builders for claude-code/codex (same hook
4672
+ * shape) to avoid duplicating the JSON.
4506
4673
  * - claude-code/codex: `block` -> deny, `ask` -> interactive
4507
4674
  * `permissionDecision:"ask"`, `inform` -> non-blocking `additionalContext`.
4508
4675
  * - gemini-cli/cline: their real hook schemas have no interactive "ask"
4509
4676
  * state (deny is the only blocking outcome), so `ask` and `inform` already
4510
4677
  * both resolve to non-blocking context injection — unchanged.
4511
- * - cursor: `ask` keeps its current best-effort `permission:"ask"` shape
4512
- * (unverified against Cursor's exact hook docs); only `inform` is fixed to
4513
- * a non-blocking `permission:"allow"` note, since it was wrongly conflated
4514
- * with `ask` before.
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).
4515
4695
  */
4516
4696
  function respond(id, prompt) {
4517
4697
  const message = formatPrompt(prompt);
4518
- const { kind } = prompt;
4698
+ const { kind, userMessage, reason } = prompt;
4519
4699
  switch (id) {
4520
4700
  case "claude-code":
4521
4701
  case "codex":
4522
4702
  if (kind === "block") return denyResponse("PreToolUse", message);
4523
- if (kind === "inform") return contextResponse("PreToolUse", message);
4703
+ if (kind === "inform") return userMessage ? informResponse("PreToolUse", userMessage, reason ? message : "") : contextResponse("PreToolUse", message);
4524
4704
  return JSON.stringify({ hookSpecificOutput: {
4525
4705
  hookEventName: "PreToolUse",
4526
4706
  permissionDecision: "ask",
4527
4707
  permissionDecisionReason: message
4528
4708
  } });
4529
- case "gemini-cli": return JSON.stringify(kind === "block" ? {
4530
- decision: "deny",
4531
- reason: message
4532
- } : { hookSpecificOutput: { additionalContext: message } });
4533
- case "cursor":
4534
- if (kind === "inform") return JSON.stringify({
4535
- permission: "allow",
4536
- userMessage: message,
4537
- agentMessage: message
4709
+ case "gemini-cli":
4710
+ if (kind === "block") return JSON.stringify({
4711
+ decision: "deny",
4712
+ reason: message
4713
+ });
4714
+ if (userMessage) return JSON.stringify({
4715
+ ...reason ? { hookSpecificOutput: { additionalContext: message } } : {},
4716
+ systemMessage: userMessage
4538
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
+ }
4539
4732
  return JSON.stringify({
4540
4733
  permission: kind === "block" ? "deny" : "ask",
4541
4734
  continue: false,
4542
- userMessage: message,
4543
- agentMessage: message
4735
+ user_message: message,
4736
+ agent_message: message
4737
+ });
4738
+ case "hermes": return toHermesResponse(prompt);
4739
+ case "cline":
4740
+ if (kind === "block") return JSON.stringify({
4741
+ cancel: true,
4742
+ errorMessage: message
4544
4743
  });
4545
- case "cline": return JSON.stringify(kind === "block" ? {
4546
- cancel: true,
4547
- errorMessage: message
4548
- } : { contextModification: message });
4744
+ if (userMessage && !reason) return "";
4745
+ return JSON.stringify({ contextModification: message });
4549
4746
  default: return "";
4550
4747
  }
4551
4748
  }
@@ -4733,10 +4930,10 @@ function collectDesignEvidence(sessionId, cwd, baseDir = defaultStateDir(cwd)) {
4733
4930
  * `design.ts` to keep that file within the SOLID size budget (SRP).
4734
4931
  * @packageDocumentation
4735
4932
  */
4736
- const NAV$1 = "mcp__fuse-browser__browser_navigate";
4737
- const SHOT$1 = "mcp__fuse-browser__browser_screenshot";
4933
+ const NAV$2 = "mcp__fuse-browser__browser_navigate";
4934
+ const SHOT$2 = "mcp__fuse-browser__browser_screenshot";
4738
4935
  const SCROLL = "mcp__fuse-browser__browser_scroll";
4739
- const GEMINI$1 = "mcp__gemini-design__create_frontend";
4936
+ const GEMINI$2 = "mcp__gemini-design__create_frontend";
4740
4937
  /** Read design-system.md walking up to 6 parents from `cwd` ("" if absent/unreadable). */
4741
4938
  function findDesignSystem(cwd) {
4742
4939
  let dir = cwd;
@@ -4755,10 +4952,10 @@ function findDesignSystem(cwd) {
4755
4952
  }
4756
4953
  /** Apply a PostToolUse fuse-browser transition to the design state. */
4757
4954
  function recordPost(event, cacheDir, state) {
4758
- if (event.tool === SHOT$1) saveDesignState(cacheDir, recordScreenshot(state, MIN_SCREENSHOTS[state.mode]));
4759
- else if (event.tool === NAV$1) saveDesignState(cacheDir, recordNavigate(state));
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));
4760
4957
  else if (event.tool === SCROLL) saveDesignState(cacheDir, recordScroll(state));
4761
- else if (event.tool === GEMINI$1) saveDesignState(cacheDir, {
4958
+ else if (event.tool === GEMINI$2) saveDesignState(cacheDir, {
4762
4959
  ...state,
4763
4960
  geminiCalls: state.geminiCalls + 1
4764
4961
  });
@@ -4781,6 +4978,9 @@ const FORBIDDEN_FONTS = [
4781
4978
  "Open Sans"
4782
4979
  ];
4783
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";
4784
4984
  const deny = (reason) => ({
4785
4985
  kind: "block",
4786
4986
  title: "Design pipeline",
@@ -4814,6 +5014,40 @@ function geminiEnabled() {
4814
5014
  const v = process.env.FUSE_DESIGN_GEMINI;
4815
5015
  return v === "1" || v === "true";
4816
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
+ }
4817
5051
  //#endregion
4818
5052
  //#region src/policy/design/gates-pipeline.ts
4819
5053
  const SKILLS = `${PLUGINS_DIR}/design-expert/skills`;
@@ -4985,15 +5219,103 @@ async function handlePre(ctx) {
4985
5219
  trackFile: file,
4986
5220
  transcriptPath: typeof payload.transcript_path === "string" ? payload.transcript_path : void 0
4987
5221
  });
4988
- return prompt ? {
5222
+ if (prompt) return {
4989
5223
  stdout: respond(id, prompt),
4990
5224
  exit: 0
4991
- } : {
4992
- stdout: "",
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) : "",
4993
5236
  exit: 0
4994
5237
  };
4995
5238
  }
4996
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
4997
5319
  //#region src/runtime/activity.ts
4998
5320
  /** Min response length (chars) for a lead agent call to count as `sufficient`. */
4999
5321
  const AGENT_QUALITY_MIN = 500;
@@ -5052,9 +5374,10 @@ function activityFor(event) {
5052
5374
  const docSource = docSourceOf(event.tool);
5053
5375
  if (docSource) out.push({
5054
5376
  kind: "doc",
5055
- framework: event.framework,
5377
+ framework: docFramework(event.input, event.framework),
5056
5378
  sessionId: event.sessionId,
5057
- source: docSource
5379
+ source: docSource,
5380
+ ts: event.now
5058
5381
  });
5059
5382
  if (event.tool === "Task" || event.tool === "Agent") {
5060
5383
  const name = String(event.input?.subagent_type ?? event.input?.name ?? "").split(":").pop() ?? "";
@@ -5098,6 +5421,8 @@ async function handlePost(ctx) {
5098
5421
  responseLength: extractText(response).length
5099
5422
  });
5100
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);
5101
5426
  postTrackingSideEffects(opts.scope ?? "core", event, event.input, opts.now, payload, opts.cwd);
5102
5427
  const seoDeny = opts.scope === "seo" ? seoPostToolUseResponse(payload) : null;
5103
5428
  if (seoDeny) return {
@@ -5126,8 +5451,35 @@ async function handlePost(ctx) {
5126
5451
  };
5127
5452
  }
5128
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
+ };
5129
5481
  return {
5130
- stdout: designWarn ? respond(id, designWarn) : extra,
5482
+ stdout: respond(id, notice) || extra,
5131
5483
  exit: 0
5132
5484
  };
5133
5485
  }
@@ -14,7 +14,8 @@ const ENV_SIGNALS = [
14
14
  ["AIDER", "aider"],
15
15
  ["KIRO", "kiro"],
16
16
  ["GOOSE", "goose"],
17
- ["AMP", "amp"]
17
+ ["AMP", "amp"],
18
+ ["HERMES_SESSION_ID", "hermes"]
18
19
  ];
19
20
  /** `AGENT=<name>` / `AI_AGENT=<name>` standard value -> harness id. */
20
21
  const STD_NAMES = {
@@ -29,7 +30,8 @@ const STD_NAMES = {
29
30
  opencode: "opencode",
30
31
  gemini: "gemini-cli",
31
32
  copilot: "copilot",
32
- kiro: "kiro"
33
+ kiro: "kiro",
34
+ hermes: "hermes"
33
35
  };
34
36
  /** Harnesses exposing a native hook system (vs CLI-only integration). */
35
37
  const HOOK_CAPABLE = /* @__PURE__ */ new Set([
@@ -38,7 +40,8 @@ const HOOK_CAPABLE = /* @__PURE__ */ new Set([
38
40
  "cursor",
39
41
  "cline",
40
42
  "gemini-cli",
41
- "opencode"
43
+ "opencode",
44
+ "hermes"
42
45
  ]);
43
46
  /** Integration mode for a harness id. */
44
47
  function modeFor(id) {
@@ -1,6 +1,6 @@
1
1
  //#region src/detect/interfaces/types.d.ts
2
2
  /** Known AI coding harnesses detectable at runtime. */
3
- type HarnessId = "claude-code" | "codex" | "cursor" | "cline" | "gemini-cli" | "opencode" | "windsurf" | "copilot" | "aider" | "kiro" | "goose" | "amp" | "unknown";
3
+ type HarnessId = "claude-code" | "codex" | "cursor" | "cline" | "gemini-cli" | "opencode" | "windsurf" | "copilot" | "aider" | "kiro" | "goose" | "amp" | "hermes" | "unknown";
4
4
  /** Integration mode: `hook` = native lifecycle hooks; `cli` = run as an external step. */
5
5
  type HarnessMode = "hook" | "cli";
6
6
  /** How the harness was identified. */