@fusengine/harness 0.1.43 → 0.1.44

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 (43) 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.mjs +1 -1
  7. package/dist/adapters/gemini/index.mjs +1 -1
  8. package/dist/{claude-CeRYMOaG.mjs → claude-DLh0fHWM.mjs} +6 -2
  9. package/dist/cli/bin.mjs +227 -7
  10. package/dist/cli/index.mjs +1 -1
  11. package/dist/config/index.d.mts +1 -1
  12. package/dist/config/index.mjs +1 -2
  13. package/dist/{doc-helpers-D14nkD5D.d.mts → doc-helpers-BNfYWvYv.d.mts} +9 -1
  14. package/dist/{doc-helpers-BhzDmJ18.mjs → doc-helpers-CWZegVdR.mjs} +14 -5
  15. package/dist/{dotenv-DGyLln7U.mjs → dotenv-B9nM4cuQ.mjs} +26 -1
  16. package/dist/evaluate-d7Pp8XJH.mjs +784 -0
  17. package/dist/freshness/index.d.mts +1 -1
  18. package/dist/freshness/index.mjs +1 -1
  19. package/dist/{handle-BTHcKWQ5.mjs → handle-CtMMVoxT.mjs} +789 -357
  20. package/dist/home-state-mKZxP4oZ.mjs +52 -0
  21. package/dist/{index-D7GpOmkl.d.mts → index-BA-SqNR7.d.mts} +1 -1
  22. package/dist/{index-DXQfL1u8.d.mts → index-BXPySPxE.d.mts} +7 -1
  23. package/dist/{index-CwOdFBOr.d.mts → index-BxjzFraL.d.mts} +3 -1
  24. package/dist/{index-DN4cZDbU.d.mts → index-CVhw7eA0.d.mts} +111 -23
  25. package/dist/index.d.mts +5 -5
  26. package/dist/index.mjs +7 -8
  27. package/dist/{loader-Bn-DbZmt.mjs → loader-AGz4nK7d.mjs} +1 -1
  28. package/dist/policy/index.d.mts +2 -2
  29. package/dist/policy/index.mjs +3 -3
  30. package/dist/refs/index.mjs +2 -2
  31. package/dist/{router-BfX0hJg8.mjs → router-PKVNBHge.mjs} +11 -1
  32. package/dist/{run-jgivVDv6.mjs → run-DLXtA5DH.mjs} +1 -1
  33. package/dist/runtime/index.d.mts +71 -14
  34. package/dist/runtime/index.mjs +3 -3
  35. package/dist/{session-state-Dzq6yrw7.d.mts → session-state-D4F_Dub6.d.mts} +1 -1
  36. package/dist/state/index.d.mts +1 -1
  37. package/dist/{store-CdWOQ9zD.mjs → store-CNjFenWe.mjs} +3 -50
  38. package/dist/tracking/index.d.mts +1 -1
  39. package/dist/tracking/index.mjs +1 -1
  40. package/dist/{validate-xi-zc-22.mjs → validate-Ca7NSp-r.mjs} +495 -83
  41. package/package.json +1 -1
  42. package/dist/evaluate-CeivW6G0.mjs +0 -477
  43. package/dist/ttl-BG55s6HZ.mjs +0 -20
@@ -1,2 +1,2 @@
1
- import { a as fileSizeGuard, c as toClaudeResponse, i as denyResponse, n as blockResponse, o as guard, r as contextResponse, s as readClaudeInput, t as ClaudeHookInput } from "../../index-CwOdFBOr.mjs";
2
- export { ClaudeHookInput, blockResponse, contextResponse, denyResponse, fileSizeGuard, guard, readClaudeInput, toClaudeResponse };
1
+ import { a as fileSizeGuard, c as systemMessage, i as denyResponse, l as toClaudeResponse, n as blockResponse, o as guard, r as contextResponse, s as readClaudeInput, t as ClaudeHookInput } from "../../index-BxjzFraL.mjs";
2
+ export { ClaudeHookInput, blockResponse, contextResponse, denyResponse, fileSizeGuard, guard, readClaudeInput, systemMessage, toClaudeResponse };
@@ -1,2 +1,2 @@
1
- import { a as guard, i as fileSizeGuard, n as contextResponse, o as readClaudeInput, r as denyResponse, s as toClaudeResponse, t as blockResponse } from "../../claude-CeRYMOaG.mjs";
2
- export { blockResponse, contextResponse, denyResponse, fileSizeGuard, guard, readClaudeInput, toClaudeResponse };
1
+ import { a as guard, c as toClaudeResponse, i as fileSizeGuard, n as contextResponse, o as readClaudeInput, r as denyResponse, s as systemMessage, t as blockResponse } from "../../claude-DLh0fHWM.mjs";
2
+ export { blockResponse, contextResponse, denyResponse, fileSizeGuard, guard, readClaudeInput, systemMessage, toClaudeResponse };
@@ -1,4 +1,4 @@
1
- import { t as evaluate } from "../../evaluate-CeivW6G0.mjs";
1
+ import { t as evaluate } from "../../evaluate-d7Pp8XJH.mjs";
2
2
  import { t as formatPrompt } from "../../types-ernB1Dy3.mjs";
3
3
  //#region src/adapters/cline/index.ts
4
4
  /**
@@ -1,2 +1,2 @@
1
- import { i as denyResponse, o as guard, r as contextResponse, s as readClaudeInput, t as ClaudeHookInput } from "../../index-CwOdFBOr.mjs";
1
+ import { i as denyResponse, o as guard, r as contextResponse, s as readClaudeInput, t as ClaudeHookInput } from "../../index-BxjzFraL.mjs";
2
2
  export { type ClaudeHookInput as CodexHookInput, contextResponse, denyResponse, guard, readClaudeInput as readCodexInput };
@@ -1,2 +1,2 @@
1
- import { a as guard, n as contextResponse, o as readClaudeInput, r as denyResponse } from "../../claude-CeRYMOaG.mjs";
1
+ import { a as guard, n as contextResponse, o as readClaudeInput, r as denyResponse } from "../../claude-DLh0fHWM.mjs";
2
2
  export { contextResponse, denyResponse, guard, readClaudeInput as readCodexInput };
@@ -1,4 +1,4 @@
1
- import { t as evaluate } from "../../evaluate-CeivW6G0.mjs";
1
+ import { t as evaluate } from "../../evaluate-d7Pp8XJH.mjs";
2
2
  import { t as formatPrompt } from "../../types-ernB1Dy3.mjs";
3
3
  //#region src/adapters/cursor/index.ts
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { t as evaluate } from "../../evaluate-CeivW6G0.mjs";
1
+ import { t as evaluate } from "../../evaluate-d7Pp8XJH.mjs";
2
2
  import { t as formatPrompt } from "../../types-ernB1Dy3.mjs";
3
3
  //#region src/adapters/gemini/index.ts
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { t as evaluate } from "./evaluate-CeivW6G0.mjs";
1
+ import { t as evaluate } from "./evaluate-d7Pp8XJH.mjs";
2
2
  import { t as formatPrompt } from "./types-ernB1Dy3.mjs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
@@ -116,6 +116,10 @@ function contextResponse(event, text) {
116
116
  additionalContext: text
117
117
  } });
118
118
  }
119
+ /** A raw `systemMessage` notice — shown to the user without blocking the tool. Mirrors the shared Python `hook_output.allow_pass`/`post_pass` convention. */
120
+ function systemMessage(text) {
121
+ return JSON.stringify({ systemMessage: text });
122
+ }
119
123
  /**
120
124
  * Render a portable {@link Prompt} as a Claude Code hook response:
121
125
  * `block` → `permissionDecision: deny`, `ask` → `permissionDecision: ask`
@@ -148,4 +152,4 @@ function guard(input) {
148
152
  /** @deprecated use {@link guard}. Kept for back-compat. */
149
153
  const fileSizeGuard = guard;
150
154
  //#endregion
151
- export { guard as a, collectFiles as c, sleep as d, spawnCapture as f, fileSizeGuard as i, pathExists as l, contextResponse as n, readClaudeInput as o, writeText as p, denyResponse as r, toClaudeResponse as s, blockResponse as t, readText as u };
155
+ export { guard as a, toClaudeResponse as c, readText as d, sleep as f, fileSizeGuard as i, collectFiles as l, writeText as m, contextResponse as n, readClaudeInput as o, spawnCapture as p, denyResponse as r, systemMessage as s, blockResponse as t, pathExists as u };
package/dist/cli/bin.mjs CHANGED
@@ -1,11 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { r as resolveTtlSec } from "../ttl-BG55s6HZ.mjs";
3
- import { r as loadDotenv } from "../dotenv-DGyLln7U.mjs";
2
+ import { r as loadDotenv, s as resolveTtlSec } from "../dotenv-B9nM4cuQ.mjs";
4
3
  import { t as detectHarness } from "../harness-C8Nxxyn_.mjs";
5
- import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-jgivVDv6.mjs";
4
+ import { t as claudeHome } from "../home-state-mKZxP4oZ.mjs";
5
+ import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-DLXtA5DH.mjs";
6
6
  import { n as writeInitFile, t as initFor } from "../run-Do2JltgU.mjs";
7
- import { f as claudeHome } from "../store-CdWOQ9zD.mjs";
8
- import { Ot as todayUtc, t as handleHook } from "../handle-BTHcKWQ5.mjs";
7
+ import { Mt as todayUtc, t as handleHook } from "../handle-CtMMVoxT.mjs";
9
8
  import { delimiter, dirname, join } from "node:path";
10
9
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
11
10
  import { homedir } from "node:os";
@@ -78,6 +77,222 @@ async function scanChangelog(now = Date.now(), home = homedir()) {
78
77
  };
79
78
  }
80
79
  //#endregion
80
+ //#region src/runtime/lifecycle/security/scan-patterns.ts
81
+ /** OWASP scan patterns keyed by detected language (ported 1:1 from Python). */
82
+ const PATTERNS = {
83
+ javascript: [
84
+ {
85
+ severity: "HIGH",
86
+ category: "XSS",
87
+ regex: /innerHTML\s*=/,
88
+ glob: "*.js"
89
+ },
90
+ {
91
+ severity: "HIGH",
92
+ category: "XSS",
93
+ regex: /dangerouslySetInnerHTML/,
94
+ glob: "*.js"
95
+ },
96
+ {
97
+ severity: "CRITICAL",
98
+ category: "CODE_EXEC",
99
+ regex: /eval\(/,
100
+ glob: "*.js"
101
+ },
102
+ {
103
+ severity: "CRITICAL",
104
+ category: "CODE_EXEC",
105
+ regex: /new Function\(/,
106
+ glob: "*.js"
107
+ },
108
+ {
109
+ severity: "CRITICAL",
110
+ category: "CMD_INJECTION",
111
+ regex: /child_process/,
112
+ glob: "*.js"
113
+ },
114
+ {
115
+ severity: "HIGH",
116
+ category: "CMD_INJECTION",
117
+ regex: /shell:\s*true/,
118
+ glob: "*.js"
119
+ },
120
+ {
121
+ severity: "MEDIUM",
122
+ category: "WEAK_CRYPTO",
123
+ regex: /Math\.random\(\)/,
124
+ glob: "*.js"
125
+ },
126
+ {
127
+ severity: "CRITICAL",
128
+ category: "SECRETS",
129
+ regex: /AKIA[0-9A-Z]{16}/,
130
+ glob: "*.js"
131
+ }
132
+ ],
133
+ php: [
134
+ {
135
+ severity: "CRITICAL",
136
+ category: "RCE",
137
+ regex: /shell_exec|system\(|passthru\(/,
138
+ glob: "*.php"
139
+ },
140
+ {
141
+ severity: "CRITICAL",
142
+ category: "CODE_EXEC",
143
+ regex: /eval\(|assert\(/,
144
+ glob: "*.php"
145
+ },
146
+ {
147
+ severity: "HIGH",
148
+ category: "SQL_INJECTION",
149
+ regex: /mysql_query\(/,
150
+ glob: "*.php"
151
+ }
152
+ ],
153
+ python: [
154
+ {
155
+ severity: "CRITICAL",
156
+ category: "CODE_EXEC",
157
+ regex: /eval\(|exec\(/,
158
+ glob: "*.py"
159
+ },
160
+ {
161
+ severity: "CRITICAL",
162
+ category: "CMD_INJECTION",
163
+ regex: /os\.system\(|subprocess.*shell=True/,
164
+ glob: "*.py"
165
+ },
166
+ {
167
+ severity: "HIGH",
168
+ category: "DESERIALIZATION",
169
+ regex: /pickle\.loads\(/,
170
+ glob: "*.py"
171
+ },
172
+ {
173
+ severity: "HIGH",
174
+ category: "TLS",
175
+ regex: /verify=False|ssl\.CERT_NONE/,
176
+ glob: "*.py"
177
+ }
178
+ ],
179
+ swift: [
180
+ {
181
+ severity: "HIGH",
182
+ category: "INSECURE_STORAGE",
183
+ regex: /UserDefaults.*password|token|secret/,
184
+ glob: "*.swift"
185
+ },
186
+ {
187
+ severity: "MEDIUM",
188
+ category: "INSECURE_HTTP",
189
+ regex: /"http:\/\//,
190
+ glob: "*.swift"
191
+ },
192
+ {
193
+ severity: "HIGH",
194
+ category: "WEAK_KEYCHAIN",
195
+ regex: /kSecAttrAccessibleAlways/,
196
+ glob: "*.swift"
197
+ }
198
+ ]
199
+ };
200
+ /** Return the scan patterns for a language ("" / go / rust / unknown → none). */
201
+ function getPatterns(lang) {
202
+ return PATTERNS[lang] ?? [];
203
+ }
204
+ //#endregion
205
+ //#region src/runtime/lifecycle/security/scan.ts
206
+ /**
207
+ * @module security/scan
208
+ * OWASP vulnerability scanner. Ports the Python `security-scan.py`: detect the
209
+ * project language, walk source files (excluding vendored/build dirs), match the
210
+ * per-language {@link getPatterns} rules, and aggregate a JSON report. Invoked by
211
+ * the `harness scan` CLI command (skill `/fuse-security:scan`), NOT a hook.
212
+ * @packageDocumentation
213
+ */
214
+ const EXCLUDE_RE = /(^|\/)(node_modules|vendor|\.git|dist|build)(\/|$)/;
215
+ const LANG_FILES = [
216
+ ["package.json", "javascript"],
217
+ ["composer.json", "php"],
218
+ ["requirements.txt", "python"],
219
+ ["pyproject.toml", "python"],
220
+ ["Package.swift", "swift"],
221
+ ["go.mod", "go"],
222
+ ["Cargo.toml", "rust"]
223
+ ];
224
+ /** Detect the project language from its config files (first match wins). */
225
+ function detectLanguage(dir) {
226
+ for (const [file, lang] of LANG_FILES) if (existsSync(join(dir, file))) return lang;
227
+ return "unknown";
228
+ }
229
+ /** Recursively collect files whose name ends with `ext`, skipping vendored dirs. */
230
+ function collectFiles(dir, ext, out = []) {
231
+ let entries;
232
+ try {
233
+ entries = readdirSync(dir, { withFileTypes: true });
234
+ } catch {
235
+ return out;
236
+ }
237
+ for (const e of entries) {
238
+ const full = join(dir, e.name);
239
+ if (EXCLUDE_RE.test(full)) continue;
240
+ if (e.isDirectory()) collectFiles(full, ext, out);
241
+ else if (e.name.endsWith(ext)) out.push(full);
242
+ }
243
+ return out;
244
+ }
245
+ /** Apply one pattern to every matching file and collect line-level findings. */
246
+ function scanPattern(dir, p) {
247
+ const ext = p.glob.replace(/^\*/, "");
248
+ const findings = [];
249
+ for (const file of collectFiles(dir, ext)) {
250
+ let text;
251
+ try {
252
+ text = readFileSync(file, "utf8");
253
+ } catch {
254
+ continue;
255
+ }
256
+ text.split("\n").forEach((line, i) => {
257
+ if (p.regex.test(line)) findings.push({
258
+ severity: p.severity,
259
+ category: p.category,
260
+ pattern: p.regex.source,
261
+ file,
262
+ line: i + 1
263
+ });
264
+ });
265
+ }
266
+ return findings;
267
+ }
268
+ /** Run the OWASP scan over `dir` and return the aggregated {@link ScanReport}. */
269
+ function runSecurityScan(dir) {
270
+ const language = detectLanguage(dir);
271
+ const counts = {
272
+ critical: 0,
273
+ high: 0,
274
+ medium: 0,
275
+ low: 0
276
+ };
277
+ const findings = [];
278
+ for (const p of getPatterns(language)) {
279
+ const hits = scanPattern(dir, p);
280
+ findings.push(...hits);
281
+ const key = p.severity.toLowerCase();
282
+ counts[key] = (counts[key] ?? 0) + hits.length;
283
+ }
284
+ const total = Object.values(counts).reduce((a, b) => a + b, 0);
285
+ return {
286
+ language,
287
+ directory: dir,
288
+ summary: {
289
+ ...counts,
290
+ total
291
+ },
292
+ findings
293
+ };
294
+ }
295
+ //#endregion
81
296
  //#region src/refs/discover.ts
82
297
  /** Immediate subdirectory names of `p`, or `[]` when it is missing/inaccessible. */
83
298
  function subdirs(p) {
@@ -264,7 +479,8 @@ if (cmd === "--version" || cmd === "-v") {
264
479
  "aipilot",
265
480
  "lessons",
266
481
  "seo",
267
- "memory"
482
+ "memory",
483
+ "tailwindcss"
268
484
  ])).has(scopeArg) ? scopeArg : "core";
269
485
  const marketplaces = (process.env.FUSE_HARNESS_MARKETPLACES ?? "fusengine-plugins").split(",").map((s) => s.trim()).filter(Boolean);
270
486
  const refsDir = process.env.FUSE_HARNESS_REFS || discoverRefs(homedir(), process.cwd(), marketplaces) || void 0;
@@ -297,7 +513,11 @@ if (cmd === "--version" || cmd === "-v") {
297
513
  }) + "\n");
298
514
  process.exit(1);
299
515
  }
300
- else {
516
+ else if (cmd === "scan") {
517
+ const dir = process.argv[3] ?? process.cwd();
518
+ process.stdout.write(JSON.stringify(runSecurityScan(dir), null, 2) + "\n");
519
+ process.exit(0);
520
+ } else {
301
521
  const files = stagedFiles();
302
522
  if (files.length === 0) process.exit(0);
303
523
  const violations = checkStaged(files, stagedContent);
@@ -1,2 +1,2 @@
1
- import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-jgivVDv6.mjs";
1
+ import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-DLXtA5DH.mjs";
2
2
  export { checkStaged, stagedContent, stagedFiles };
@@ -1,2 +1,2 @@
1
- import { _ as parseEnvInt, a as ProjectLayout, c as projectLayout, d as resolveMaxLines, f as splitTarget, g as ttlLabel, h as resolveTtlSec, i as parseEnvFile, l as DEFAULT_MAX_LINES, m as TTL_ENV_KEY, n as envCandidates, o as STATE_GITIGNORE, p as DEFAULT_TTL_SEC, r as loadDotenv, s as STATE_ROOT, t as HOME_DIR, u as MAX_LINES_ENV_KEY } from "../index-DXQfL1u8.mjs";
1
+ import { _ as parseEnvInt, a as ProjectLayout, c as projectLayout, d as resolveMaxLines, f as splitTarget, g as ttlLabel, h as resolveTtlSec, i as parseEnvFile, l as DEFAULT_MAX_LINES, m as TTL_ENV_KEY, n as envCandidates, o as STATE_GITIGNORE, p as DEFAULT_TTL_SEC, r as loadDotenv, s as STATE_ROOT, t as HOME_DIR, u as MAX_LINES_ENV_KEY } from "../index-BXPySPxE.mjs";
2
2
  export { DEFAULT_MAX_LINES, DEFAULT_TTL_SEC, HOME_DIR, MAX_LINES_ENV_KEY, ProjectLayout, STATE_GITIGNORE, STATE_ROOT, TTL_ENV_KEY, envCandidates, loadDotenv, parseEnvFile, parseEnvInt, projectLayout, resolveMaxLines, resolveTtlSec, splitTarget, ttlLabel };
@@ -1,6 +1,5 @@
1
1
  import { a as parseEnvInt, i as splitTarget, n as MAX_LINES_ENV_KEY, r as resolveMaxLines, t as DEFAULT_MAX_LINES } from "../limits-CHn8AIL1.mjs";
2
- import { i as ttlLabel, n as TTL_ENV_KEY, r as resolveTtlSec, t as DEFAULT_TTL_SEC } from "../ttl-BG55s6HZ.mjs";
2
+ import { a as DEFAULT_TTL_SEC, c as ttlLabel, i as parseEnvFile, n as envCandidates, o as TTL_ENV_KEY, r as loadDotenv, s as resolveTtlSec, t as HOME_DIR } from "../dotenv-B9nM4cuQ.mjs";
3
3
  import { n as STATE_ROOT, r as projectLayout, t as STATE_GITIGNORE } from "../layout-C0jaaCQC.mjs";
4
- import { i as parseEnvFile, n as envCandidates, r as loadDotenv, t as HOME_DIR } from "../dotenv-DGyLln7U.mjs";
5
4
  import "../config-la_KkjCS.mjs";
6
5
  export { DEFAULT_MAX_LINES, DEFAULT_TTL_SEC, HOME_DIR, MAX_LINES_ENV_KEY, STATE_GITIGNORE, STATE_ROOT, TTL_ENV_KEY, envCandidates, loadDotenv, parseEnvFile, parseEnvInt, projectLayout, resolveMaxLines, resolveTtlSec, splitTarget, ttlLabel };
@@ -22,7 +22,15 @@ interface DocSatisfactionStatus {
22
22
  web: boolean;
23
23
  viaCache: boolean;
24
24
  }
25
- /** True when ANY documentation source (Context7, Exa, or web) was satisfied for the session. */
25
+ /**
26
+ * True when documentation research is satisfied for the session. Primary path
27
+ * is parity with the Python `mcp_research_done`
28
+ * (`_shared/scripts/check_skill_common.py`): `"context7:" in content and
29
+ * "exa:" in content`, a strict AND (the Python source has no web-alone path).
30
+ * `web` (WebSearch/WebFetch/fuse-browser) is a deliberate TS-side addition —
31
+ * it alone satisfies the gate, since fuse-browser is this harness's preferred
32
+ * doc-research fast-path (see CLAUDE.md) and Context7/Exa can be unavailable.
33
+ */
26
34
  declare function isDocConsulted(authorizations: Record<string, AuthEntry> | undefined, sessionId: string): boolean;
27
35
  /** Report how each doc source was satisfied for a session. */
28
36
  declare function formatDocSatisfactionStatus(authorizations: Record<string, AuthEntry> | undefined, sessionId: string): DocSatisfactionStatus;
@@ -23,10 +23,18 @@ function evaluateDoc(auths) {
23
23
  viaCache: !liveC7 && cacheC7 || !liveExa && cacheExa
24
24
  };
25
25
  }
26
- /** True when ANY documentation source (Context7, Exa, or web) was satisfied for the session. */
26
+ /**
27
+ * True when documentation research is satisfied for the session. Primary path
28
+ * is parity with the Python `mcp_research_done`
29
+ * (`_shared/scripts/check_skill_common.py`): `"context7:" in content and
30
+ * "exa:" in content`, a strict AND (the Python source has no web-alone path).
31
+ * `web` (WebSearch/WebFetch/fuse-browser) is a deliberate TS-side addition —
32
+ * it alone satisfies the gate, since fuse-browser is this harness's preferred
33
+ * doc-research fast-path (see CLAUDE.md) and Context7/Exa can be unavailable.
34
+ */
27
35
  function isDocConsulted(authorizations, sessionId) {
28
36
  const s = evaluateDoc(sessionAuthsFor(authorizations, sessionId));
29
- return s.context7 || s.exa || s.web;
37
+ return s.context7 && s.exa || s.web;
30
38
  }
31
39
  /** Report how each doc source was satisfied for a session. */
32
40
  function formatDocSatisfactionStatus(authorizations, sessionId) {
@@ -35,9 +43,10 @@ function formatDocSatisfactionStatus(authorizations, sessionId) {
35
43
  /** Deny message when online documentation has not been consulted. */
36
44
  function formatDocDeny(framework) {
37
45
  return [
38
- `APEX: Online documentation not consulted for ${framework}!`,
39
- "Use ANY ONE of: mcp__context7__query-docs, mcp__exa__web_search_exa (or — if Exa is down — mcp__fuse-browser__browser_fetch / browser_crawl / browser_serp_batch), WebSearch, or WebFetch.",
40
- "This check is once per session after consulting one source, Write/Edit will be allowed."
46
+ `BLOCKED: No MCP research done for ${framework}. Use BOTH:`,
47
+ "1) mcp__context7__query-docs AND 2) mcp__exa__web_search_exa",
48
+ " OR a web fallback alone: mcp__fuse-browser__browser_fetch / browser_crawl / browser_serp_batch, WebSearch, or WebFetch.",
49
+ "This check is once per session — after consulting the required source(s), Write/Edit will be allowed."
41
50
  ].join("\n");
42
51
  }
43
52
  //#endregion
@@ -1,6 +1,31 @@
1
+ import { a as parseEnvInt } from "./limits-CHn8AIL1.mjs";
1
2
  import { join } from "node:path";
2
3
  import { existsSync, readFileSync } from "node:fs";
3
4
  import { homedir } from "node:os";
5
+ //#region src/config/ttl.ts
6
+ /**
7
+ * Default enforcement-freshness window, in seconds (2 minutes). Matches the
8
+ * plugin's original `FUSE_ENFORCE_TTL_SEC` default. This is the constant that
9
+ * actually reaches production (via `resolveTtlSec` -> `bin.ts`); `gate.ts`'s
10
+ * `DEFAULT_WINDOW_MS` is only a fallback for direct programmatic callers that
11
+ * omit `windowMs` (e.g. tests) and never applies on the real CLI path.
12
+ */
13
+ const DEFAULT_TTL_SEC = 120;
14
+ /** Default env var name carrying the TTL override. */
15
+ const TTL_ENV_KEY = "FUSE_ENFORCE_TTL_SEC";
16
+ /**
17
+ * Resolve the enforcement TTL (seconds) from an env map.
18
+ * @param env - environment map (defaults to `process.env`)
19
+ * @param key - env var name (defaults to `FUSE_ENFORCE_TTL_SEC`)
20
+ */
21
+ function resolveTtlSec(env = process.env, key = TTL_ENV_KEY) {
22
+ return parseEnvInt(env[key], 120);
23
+ }
24
+ /** Human label for a TTL: 120 -> "2min", 240 -> "4min", 90 -> "90s". */
25
+ function ttlLabel(sec) {
26
+ return sec % 60 === 0 ? `${sec / 60}min` : `${sec}s`;
27
+ }
28
+ //#endregion
4
29
  //#region src/config/dotenv.ts
5
30
  /**
6
31
  * Native .env loader. Ports the claude-plugins `services/env-file.ts`
@@ -52,4 +77,4 @@ function loadDotenv(id, env = process.env, home = homedir(), cwd = process.cwd()
52
77
  for (const path of envCandidates(id, home, cwd)) for (const [k, v] of Object.entries(parseEnvFile(path))) if (env[k] === void 0) env[k] = v;
53
78
  }
54
79
  //#endregion
55
- export { parseEnvFile as i, envCandidates as n, loadDotenv as r, HOME_DIR as t };
80
+ export { DEFAULT_TTL_SEC as a, ttlLabel as c, parseEnvFile as i, envCandidates as n, TTL_ENV_KEY as o, loadDotenv as r, resolveTtlSec as s, HOME_DIR as t };