@fusengine/harness 0.1.0
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/LICENSE +21 -0
- package/README.md +68 -0
- package/dist/adapters/claude/index.d.mts +36 -0
- package/dist/adapters/claude/index.mjs +66 -0
- package/dist/cache/index.d.mts +30 -0
- package/dist/cache/index.mjs +110 -0
- package/dist/compact-json-DK2nX-MK.mjs +21 -0
- package/dist/config/index.d.mts +37 -0
- package/dist/config/index.mjs +20 -0
- package/dist/detect/index.d.mts +31 -0
- package/dist/detect/index.mjs +82 -0
- package/dist/doc-helpers-CG1nuf-c.d.mts +30 -0
- package/dist/evaluate-CsYyUucy.mjs +152 -0
- package/dist/freshness/index.d.mts +14 -0
- package/dist/freshness/index.mjs +64 -0
- package/dist/index.d.mts +13 -0
- package/dist/index.mjs +17 -0
- package/dist/json-io-RH82El2J.mjs +40 -0
- package/dist/limits-CHn8AIL1.mjs +31 -0
- package/dist/memory/index.d.mts +30 -0
- package/dist/memory/index.mjs +94 -0
- package/dist/policy/index.d.mts +80 -0
- package/dist/policy/index.mjs +34 -0
- package/dist/project-root-C4ks_q1G.mjs +32 -0
- package/dist/prompt/index.d.mts +2 -0
- package/dist/prompt/index.mjs +2 -0
- package/dist/refs/index.d.mts +44 -0
- package/dist/refs/index.mjs +70 -0
- package/dist/state/index.d.mts +59 -0
- package/dist/state/index.mjs +104 -0
- package/dist/statusline/index.d.mts +84 -0
- package/dist/statusline/index.mjs +148 -0
- package/dist/types-D56jSgD9.d.mts +21 -0
- package/dist/types-ernB1Dy3.mjs +12 -0
- package/dist/util/index.d.mts +20 -0
- package/dist/util/index.mjs +3 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fusengine
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# @fusengine/harness
|
|
2
|
+
|
|
3
|
+
Harness-agnostic toolkit for AI coding agents. One package, modular subpaths,
|
|
4
|
+
**Bun-native** (the `exports` map points at the TypeScript source — no build step).
|
|
5
|
+
|
|
6
|
+
It splits cleanly into a **pure policy core** (no harness coupling) and **thin
|
|
7
|
+
adapters** that wire it into a specific harness's hook system.
|
|
8
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
The same guard logic (file-size limits, APEX freshness, framework detection,
|
|
12
|
+
git guards, project memory) was duplicated across Python + TypeScript hooks and
|
|
13
|
+
bound to one harness. This package is the single, tested source of truth — and
|
|
14
|
+
it knows which harness it's running in.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
bun add @fusengine/harness
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Modules
|
|
23
|
+
|
|
24
|
+
| Subpath | What |
|
|
25
|
+
|---------|------|
|
|
26
|
+
| `@fusengine/harness/detect` | `detectHarness()` / `detectMode()` — Claude Code, Codex, Cursor, Cline, Gemini, opencode, Windsurf, Copilot, Aider, Kiro, Goose, Amp (env signals + `AGENT`/`AI_AGENT` standards). `mode` is `hook` or `cli`. |
|
|
27
|
+
| `@fusengine/harness/policy` | `evaluate(ctx)` → `{ decision, message }`; `evaluateFileSize`, `detectProjectType`, `detectFramework`, git/install guard patterns. |
|
|
28
|
+
| `@fusengine/harness/config` | `resolveTtlSec` / `resolveMaxLines` (env-driven, robust parse), `ttlLabel`, `splitTarget`. |
|
|
29
|
+
| `@fusengine/harness/memory` | Per-project "never reproduce" lessons: throttle state, multi-project registry by git root. |
|
|
30
|
+
| `@fusengine/harness/cache` | `compactMarkdown`, `queryHash`, `jaccardSimilar`, atomic JSON I/O, MCP response extraction. |
|
|
31
|
+
| `@fusengine/harness/freshness` | `isDocConsulted` (Context7 + Exa), trivial-edit counter. |
|
|
32
|
+
| `@fusengine/harness/refs` | Frontmatter parsing, glob→regex, SOLID reference scoring/routing. |
|
|
33
|
+
| `@fusengine/harness/state` | Directory locks, daily APEX state, task.json helpers. |
|
|
34
|
+
| `@fusengine/harness/statusline` | Formatters, ANSI colors, progress/gradient bars. |
|
|
35
|
+
| `@fusengine/harness/adapters/claude` | Claude Code adapter: read stdin → policy → `hookSpecificOutput`. |
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { detectHarness, detectMode } from "@fusengine/harness/detect";
|
|
41
|
+
import { evaluate } from "@fusengine/harness/policy";
|
|
42
|
+
|
|
43
|
+
const { id, mode } = detectHarness(); // e.g. { id: "cursor", mode: "hook" }
|
|
44
|
+
|
|
45
|
+
const verdict = evaluate({ tool: "Write", filePath: "src/big.ts", content });
|
|
46
|
+
if (verdict.decision === "deny") console.error(verdict.message);
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Claude Code hook (thin adapter):
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { readClaudeInput, fileSizeGuard } from "@fusengine/harness/adapters/claude";
|
|
53
|
+
|
|
54
|
+
const deny = fileSizeGuard(await readClaudeInput());
|
|
55
|
+
if (deny) { console.log(deny); process.exit(2); }
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Harness without hooks (Aider/Windsurf/OpenHands) → `cli` mode: run the same
|
|
59
|
+
`evaluate()` from a pre-commit step instead.
|
|
60
|
+
|
|
61
|
+
## Develop
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
bun test # test suite
|
|
65
|
+
bunx tsc --noEmit # typecheck
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
CI runs both on every PR. MIT licensed.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { t as Prompt } from "../../types-D56jSgD9.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/adapters/claude/index.d.ts
|
|
4
|
+
/** Subset of the Claude Code hook stdin payload we consume. */
|
|
5
|
+
interface ClaudeHookInput {
|
|
6
|
+
hook_event_name?: string;
|
|
7
|
+
tool_name?: string;
|
|
8
|
+
tool_input?: {
|
|
9
|
+
file_path?: string;
|
|
10
|
+
content?: string;
|
|
11
|
+
new_string?: string;
|
|
12
|
+
command?: string;
|
|
13
|
+
};
|
|
14
|
+
cwd?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Read & parse the Claude hook payload from stdin (empty object on bad input). */
|
|
17
|
+
declare function readClaudeInput(): Promise<ClaudeHookInput>;
|
|
18
|
+
/** A `deny` hook response for a given event. */
|
|
19
|
+
declare function denyResponse(event: string, reason: string): string;
|
|
20
|
+
/** An `additionalContext` injection response. */
|
|
21
|
+
declare function contextResponse(event: string, text: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Render a portable {@link Prompt} as a Claude Code hook response:
|
|
24
|
+
* `block` → `permissionDecision: deny`, `ask` → `permissionDecision: ask`
|
|
25
|
+
* (interactive confirm), `inform` → `additionalContext`.
|
|
26
|
+
*/
|
|
27
|
+
declare function toClaudeResponse(event: string, prompt: Prompt): string;
|
|
28
|
+
/**
|
|
29
|
+
* Run the bundled policy over a Claude payload and return the native response
|
|
30
|
+
* string (deny/ask/additionalContext), or null to allow.
|
|
31
|
+
*/
|
|
32
|
+
declare function guard(input: ClaudeHookInput): string | null;
|
|
33
|
+
/** @deprecated use {@link guard}. Kept for back-compat. */
|
|
34
|
+
declare const fileSizeGuard: typeof guard;
|
|
35
|
+
//#endregion
|
|
36
|
+
export { ClaudeHookInput, contextResponse, denyResponse, fileSizeGuard, guard, readClaudeInput, toClaudeResponse };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { t as evaluate } from "../../evaluate-CsYyUucy.mjs";
|
|
2
|
+
import { t as formatPrompt } from "../../types-ernB1Dy3.mjs";
|
|
3
|
+
//#region src/adapters/claude/index.ts
|
|
4
|
+
/**
|
|
5
|
+
* Claude Code adapter — the thin Claude-only shim over the portable policy core.
|
|
6
|
+
* Reads the hook stdin payload and emits hookSpecificOutput responses.
|
|
7
|
+
*/
|
|
8
|
+
/** Read & parse the Claude hook payload from stdin (empty object on bad input). */
|
|
9
|
+
async function readClaudeInput() {
|
|
10
|
+
const text = await Bun.stdin.text();
|
|
11
|
+
if (!text.trim()) return {};
|
|
12
|
+
try {
|
|
13
|
+
const parsed = JSON.parse(text);
|
|
14
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
15
|
+
} catch {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** A `deny` hook response for a given event. */
|
|
20
|
+
function denyResponse(event, reason) {
|
|
21
|
+
return JSON.stringify({ hookSpecificOutput: {
|
|
22
|
+
hookEventName: event,
|
|
23
|
+
permissionDecision: "deny",
|
|
24
|
+
permissionDecisionReason: reason
|
|
25
|
+
} });
|
|
26
|
+
}
|
|
27
|
+
/** An `additionalContext` injection response. */
|
|
28
|
+
function contextResponse(event, text) {
|
|
29
|
+
return JSON.stringify({ hookSpecificOutput: {
|
|
30
|
+
hookEventName: event,
|
|
31
|
+
additionalContext: text
|
|
32
|
+
} });
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Render a portable {@link Prompt} as a Claude Code hook response:
|
|
36
|
+
* `block` → `permissionDecision: deny`, `ask` → `permissionDecision: ask`
|
|
37
|
+
* (interactive confirm), `inform` → `additionalContext`.
|
|
38
|
+
*/
|
|
39
|
+
function toClaudeResponse(event, prompt) {
|
|
40
|
+
const reason = formatPrompt(prompt);
|
|
41
|
+
if (prompt.kind === "block") return denyResponse(event, reason);
|
|
42
|
+
if (prompt.kind === "ask") return JSON.stringify({ hookSpecificOutput: {
|
|
43
|
+
hookEventName: event,
|
|
44
|
+
permissionDecision: "ask",
|
|
45
|
+
permissionDecisionReason: reason
|
|
46
|
+
} });
|
|
47
|
+
return contextResponse(event, reason);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Run the bundled policy over a Claude payload and return the native response
|
|
51
|
+
* string (deny/ask/additionalContext), or null to allow.
|
|
52
|
+
*/
|
|
53
|
+
function guard(input) {
|
|
54
|
+
const result = evaluate({
|
|
55
|
+
tool: input.tool_name ?? "Write",
|
|
56
|
+
filePath: input.tool_input?.file_path,
|
|
57
|
+
content: input.tool_input?.content ?? input.tool_input?.new_string,
|
|
58
|
+
command: input.tool_input?.command
|
|
59
|
+
});
|
|
60
|
+
if (result.decision === "allow" || !result.prompt) return null;
|
|
61
|
+
return toClaudeResponse(input.hook_event_name ?? "PreToolUse", result.prompt);
|
|
62
|
+
}
|
|
63
|
+
/** @deprecated use {@link guard}. Kept for back-compat. */
|
|
64
|
+
const fileSizeGuard = guard;
|
|
65
|
+
//#endregion
|
|
66
|
+
export { contextResponse, denyResponse, fileSizeGuard, guard, readClaudeInput, toClaudeResponse };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//#region src/cache/compact.d.ts
|
|
2
|
+
/** Strip HTML entities + boilerplate, normalize blank lines, truncate to ~5KB. */
|
|
3
|
+
declare function compactMarkdown(content: string): string;
|
|
4
|
+
/** 8-char MD5 of `${toolName}::${query}`. */
|
|
5
|
+
declare function queryHash(toolName: string, query: string): string;
|
|
6
|
+
/** Bag-of-words Jaccard similarity strictly greater than `threshold`. */
|
|
7
|
+
declare function jaccardSimilar(a: string, b: string, threshold?: number): boolean;
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region src/cache/io.d.ts
|
|
10
|
+
/** Read a JSON array from `path`; [] on missing/corrupt/non-array. */
|
|
11
|
+
declare function loadIndex(path: string): unknown[];
|
|
12
|
+
/** Summary of a cache index. */
|
|
13
|
+
interface IndexSummary {
|
|
14
|
+
total: number;
|
|
15
|
+
byTool: Record<string, number>;
|
|
16
|
+
oldestTs: string | null;
|
|
17
|
+
newestTs: string | null;
|
|
18
|
+
}
|
|
19
|
+
/** Summarize an index of `{ tool?, ts? }` entries. */
|
|
20
|
+
declare function summarizeIndex(index: unknown[]): IndexSummary;
|
|
21
|
+
//#endregion
|
|
22
|
+
//#region src/cache/mcp-response.d.ts
|
|
23
|
+
/**
|
|
24
|
+
* Extract usable markdown from an MCP `tool_response`: a string, a list of
|
|
25
|
+
* content blocks (non-text blocks skipped), or any JSON structure (fallback).
|
|
26
|
+
* Recurses up to depth 5 to guard against pathological/cyclic structures.
|
|
27
|
+
*/
|
|
28
|
+
declare function extractText(resp: unknown, depth?: number): string;
|
|
29
|
+
//#endregion
|
|
30
|
+
export { IndexSummary, compactMarkdown, extractText, jaccardSimilar, loadIndex, queryHash, summarizeIndex };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
//#region src/cache/compact.ts
|
|
4
|
+
const HTML_ENTITIES = {
|
|
5
|
+
"&": "&",
|
|
6
|
+
"<": "<",
|
|
7
|
+
">": ">",
|
|
8
|
+
""": "\"",
|
|
9
|
+
"'": "'",
|
|
10
|
+
"'": "'",
|
|
11
|
+
" ": " ",
|
|
12
|
+
"'": "'"
|
|
13
|
+
};
|
|
14
|
+
const BOILERPLATE = [
|
|
15
|
+
/^.*cookie.*(accept|consent|banner).*$/gim,
|
|
16
|
+
/^.*was this (helpful|page helpful|article helpful).*$/gim,
|
|
17
|
+
/^.*©\s*\d{4}.*all rights reserved.*$/gim,
|
|
18
|
+
/^\s*(home|about|contact|privacy|terms)\s*\|\s*.*$/gim,
|
|
19
|
+
/^.*subscribe to (our )?newsletter.*$/gim,
|
|
20
|
+
/^.*follow us on (twitter|facebook|linkedin).*$/gim
|
|
21
|
+
];
|
|
22
|
+
const MAX_BYTES = 5 * 1024;
|
|
23
|
+
function decodeEntities(text) {
|
|
24
|
+
let out = text;
|
|
25
|
+
for (const [ent, ch] of Object.entries(HTML_ENTITIES)) out = out.split(ent).join(ch);
|
|
26
|
+
out = out.replace(/&#x([0-9a-fA-F]+);/g, (_m, h) => String.fromCodePoint(parseInt(h, 16)));
|
|
27
|
+
out = out.replace(/&#(\d+);/g, (_m, d) => String.fromCodePoint(parseInt(d, 10)));
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
/** Strip HTML entities + boilerplate, normalize blank lines, truncate to ~5KB. */
|
|
31
|
+
function compactMarkdown(content) {
|
|
32
|
+
let text = decodeEntities(content);
|
|
33
|
+
for (const re of BOILERPLATE) text = text.replace(re, "");
|
|
34
|
+
text = text.replace(/\n{3,}/g, "\n\n").trim();
|
|
35
|
+
const enc = new TextEncoder().encode(text);
|
|
36
|
+
if (enc.length > MAX_BYTES) {
|
|
37
|
+
const truncated = new TextDecoder().decode(enc.slice(0, MAX_BYTES));
|
|
38
|
+
text = `${truncated}\n\n[... truncated, ${text.slice(truncated.length).split("\n").length - 1} lines]`;
|
|
39
|
+
}
|
|
40
|
+
return text;
|
|
41
|
+
}
|
|
42
|
+
/** 8-char MD5 of `${toolName}::${query}`. */
|
|
43
|
+
function queryHash(toolName, query) {
|
|
44
|
+
return createHash("md5").update(`${toolName}::${query}`).digest("hex").slice(0, 8);
|
|
45
|
+
}
|
|
46
|
+
/** Bag-of-words Jaccard similarity strictly greater than `threshold`. */
|
|
47
|
+
function jaccardSimilar(a, b, threshold = .8) {
|
|
48
|
+
const ta = new Set(a.toLowerCase().split(/\s+/).filter(Boolean));
|
|
49
|
+
const tb = new Set(b.toLowerCase().split(/\s+/).filter(Boolean));
|
|
50
|
+
if (ta.size === 0 || tb.size === 0) return false;
|
|
51
|
+
let inter = 0;
|
|
52
|
+
for (const t of ta) if (tb.has(t)) inter++;
|
|
53
|
+
const union = ta.size + tb.size - inter;
|
|
54
|
+
return inter / union > threshold;
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/cache/io.ts
|
|
58
|
+
/** Read a JSON array from `path`; [] on missing/corrupt/non-array. */
|
|
59
|
+
function loadIndex(path) {
|
|
60
|
+
try {
|
|
61
|
+
if (!existsSync(path)) return [];
|
|
62
|
+
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
63
|
+
return Array.isArray(data) ? data : [];
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Summarize an index of `{ tool?, ts? }` entries. */
|
|
69
|
+
function summarizeIndex(index) {
|
|
70
|
+
const byTool = {};
|
|
71
|
+
const timestamps = [];
|
|
72
|
+
for (const entry of index) {
|
|
73
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
74
|
+
const e = entry;
|
|
75
|
+
if (typeof e.tool === "string") byTool[e.tool] = (byTool[e.tool] ?? 0) + 1;
|
|
76
|
+
if (typeof e.ts === "string") timestamps.push(e.ts);
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
total: index.length,
|
|
80
|
+
byTool,
|
|
81
|
+
oldestTs: timestamps.length ? timestamps.reduce((a, b) => a < b ? a : b) : null,
|
|
82
|
+
newestTs: timestamps.length ? timestamps.reduce((a, b) => a > b ? a : b) : null
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/cache/mcp-response.ts
|
|
87
|
+
const MAX_DEPTH = 5;
|
|
88
|
+
/**
|
|
89
|
+
* Extract usable markdown from an MCP `tool_response`: a string, a list of
|
|
90
|
+
* content blocks (non-text blocks skipped), or any JSON structure (fallback).
|
|
91
|
+
* Recurses up to depth 5 to guard against pathological/cyclic structures.
|
|
92
|
+
*/
|
|
93
|
+
function extractText(resp, depth = 0) {
|
|
94
|
+
if (depth >= MAX_DEPTH) return "";
|
|
95
|
+
if (typeof resp === "string") return resp;
|
|
96
|
+
if (Array.isArray(resp)) {
|
|
97
|
+
const parts = resp.filter((b) => typeof b === "object" && b !== null).filter((b) => b.type === "text").map((b) => b.text ?? "");
|
|
98
|
+
if (parts.length) return parts.join("\n\n");
|
|
99
|
+
const joined = resp.filter((b) => Array.isArray(b) || typeof b === "object" && b !== null).map((b) => extractText(b, depth + 1)).filter(Boolean).join("\n\n");
|
|
100
|
+
if (joined) return joined;
|
|
101
|
+
}
|
|
102
|
+
if (!resp) return "";
|
|
103
|
+
try {
|
|
104
|
+
return JSON.stringify(resp);
|
|
105
|
+
} catch {
|
|
106
|
+
return "";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
//#endregion
|
|
110
|
+
export { compactMarkdown, extractText, jaccardSimilar, loadIndex, queryHash, summarizeIndex };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
//#region src/util/compact-json.ts
|
|
2
|
+
/**
|
|
3
|
+
* Compact JSON serializer for cache/state files.
|
|
4
|
+
* Top-level keys are indented one per line; nested objects/arrays render inline.
|
|
5
|
+
* Behaviour preserved from the fusengine hooks.
|
|
6
|
+
*/
|
|
7
|
+
function compactJson(data) {
|
|
8
|
+
if (typeof data !== "object" || data === null) return `${JSON.stringify(data)}\n`;
|
|
9
|
+
if (Array.isArray(data)) {
|
|
10
|
+
if (data.length === 0) return "[]\n";
|
|
11
|
+
return `[\n${data.map((item) => ` ${JSON.stringify(item)}`).join(",\n")}\n]\n`;
|
|
12
|
+
}
|
|
13
|
+
const lines = [];
|
|
14
|
+
for (const [key, val] of Object.entries(data)) if (Array.isArray(val) && val.length > 0) {
|
|
15
|
+
const items = val.map((item) => ` ${JSON.stringify(item)}`);
|
|
16
|
+
lines.push(` "${key}": [\n${items.join(",\n")}\n ]`);
|
|
17
|
+
} else lines.push(` "${key}": ${JSON.stringify(val)}`);
|
|
18
|
+
return `{\n${lines.join(",\n")}\n}\n`;
|
|
19
|
+
}
|
|
20
|
+
//#endregion
|
|
21
|
+
export { compactJson as t };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//#region src/config/env.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Robust integer-from-env parser.
|
|
4
|
+
* undefined / empty / whitespace / NaN / float / <= 0 all fall back to `fallback`.
|
|
5
|
+
* `Number("")` is 0, so the empty guard is required before `Number()`.
|
|
6
|
+
*/
|
|
7
|
+
declare function parseEnvInt(raw: string | undefined, fallback: number): number;
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region src/config/ttl.d.ts
|
|
10
|
+
/** Default enforcement-freshness window, in seconds (2 minutes). */
|
|
11
|
+
declare const DEFAULT_TTL_SEC = 120;
|
|
12
|
+
/** Default env var name carrying the TTL override. */
|
|
13
|
+
declare const TTL_ENV_KEY = "FUSE_ENFORCE_TTL_SEC";
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the enforcement TTL (seconds) from an env map.
|
|
16
|
+
* @param env - environment map (defaults to `process.env`)
|
|
17
|
+
* @param key - env var name (defaults to `FUSE_ENFORCE_TTL_SEC`)
|
|
18
|
+
*/
|
|
19
|
+
declare function resolveTtlSec(env?: Record<string, string | undefined>, key?: string): number;
|
|
20
|
+
/** Human label for a TTL: 120 -> "2min", 240 -> "4min", 90 -> "90s". */
|
|
21
|
+
declare function ttlLabel(sec: number): string;
|
|
22
|
+
//#endregion
|
|
23
|
+
//#region src/config/limits.d.ts
|
|
24
|
+
/** Default SOLID max lines per file. */
|
|
25
|
+
declare const DEFAULT_MAX_LINES = 100;
|
|
26
|
+
/** Default env var name carrying the max-lines override. */
|
|
27
|
+
declare const MAX_LINES_ENV_KEY = "FUSE_SOLID_MAX_LINES";
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the SOLID max-lines limit from an env map.
|
|
30
|
+
* @param env - environment map (defaults to `process.env`)
|
|
31
|
+
* @param key - env var name (defaults to `FUSE_SOLID_MAX_LINES`)
|
|
32
|
+
*/
|
|
33
|
+
declare function resolveMaxLines(env?: Record<string, string | undefined>, key?: string): number;
|
|
34
|
+
/** Advisory module-split headroom = `maxLines - 10` (never below 1). */
|
|
35
|
+
declare function splitTarget(maxLines: number): number;
|
|
36
|
+
//#endregion
|
|
37
|
+
export { DEFAULT_MAX_LINES, DEFAULT_TTL_SEC, MAX_LINES_ENV_KEY, TTL_ENV_KEY, parseEnvInt, resolveMaxLines, resolveTtlSec, splitTarget, ttlLabel };
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
//#region src/config/ttl.ts
|
|
3
|
+
/** Default enforcement-freshness window, in seconds (2 minutes). */
|
|
4
|
+
const DEFAULT_TTL_SEC = 120;
|
|
5
|
+
/** Default env var name carrying the TTL override. */
|
|
6
|
+
const TTL_ENV_KEY = "FUSE_ENFORCE_TTL_SEC";
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the enforcement TTL (seconds) from an env map.
|
|
9
|
+
* @param env - environment map (defaults to `process.env`)
|
|
10
|
+
* @param key - env var name (defaults to `FUSE_ENFORCE_TTL_SEC`)
|
|
11
|
+
*/
|
|
12
|
+
function resolveTtlSec(env = process.env, key = TTL_ENV_KEY) {
|
|
13
|
+
return parseEnvInt(env[key], 120);
|
|
14
|
+
}
|
|
15
|
+
/** Human label for a TTL: 120 -> "2min", 240 -> "4min", 90 -> "90s". */
|
|
16
|
+
function ttlLabel(sec) {
|
|
17
|
+
return sec % 60 === 0 ? `${sec / 60}min` : `${sec}s`;
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
export { DEFAULT_MAX_LINES, DEFAULT_TTL_SEC, MAX_LINES_ENV_KEY, TTL_ENV_KEY, parseEnvInt, resolveMaxLines, resolveTtlSec, splitTarget, ttlLabel };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//#region src/detect/harness.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Runtime detection of the AI coding harness, and its integration mode.
|
|
4
|
+
* Env-signal names verified 2026 (agentx, agents.md#136, @vercel/detect-agent,
|
|
5
|
+
* official Claude Code / Cursor / Gemini / Codex docs). Presence-based: the
|
|
6
|
+
* value is ignored except for the `AGENT` / `AI_AGENT` standards.
|
|
7
|
+
*/
|
|
8
|
+
/** Known AI coding harnesses detectable at runtime. */
|
|
9
|
+
type HarnessId = "claude-code" | "codex" | "cursor" | "cline" | "gemini-cli" | "opencode" | "windsurf" | "copilot" | "aider" | "kiro" | "goose" | "amp" | "unknown";
|
|
10
|
+
/** Integration mode: `hook` = native lifecycle hooks; `cli` = run as an external step. */
|
|
11
|
+
type HarnessMode = "hook" | "cli";
|
|
12
|
+
/** How the harness was identified. */
|
|
13
|
+
type HarnessVia = "agent-std" | "ai-agent-std" | "env" | "fallback";
|
|
14
|
+
/** Result of {@link detectHarness}. */
|
|
15
|
+
interface HarnessInfo {
|
|
16
|
+
id: HarnessId;
|
|
17
|
+
mode: HarnessMode;
|
|
18
|
+
via: HarnessVia;
|
|
19
|
+
}
|
|
20
|
+
/** Integration mode for a harness id. */
|
|
21
|
+
declare function modeFor(id: HarnessId): HarnessMode;
|
|
22
|
+
/**
|
|
23
|
+
* Detect the current AI coding harness from environment signals.
|
|
24
|
+
* Priority: `AGENT` standard -> `AI_AGENT` standard -> tool-specific vars -> unknown.
|
|
25
|
+
* @param env - environment map (defaults to `process.env`)
|
|
26
|
+
*/
|
|
27
|
+
declare function detectHarness(env?: Record<string, string | undefined>): HarnessInfo;
|
|
28
|
+
/** Convenience: the integration mode of the current harness. */
|
|
29
|
+
declare function detectMode(env?: Record<string, string | undefined>): HarnessMode;
|
|
30
|
+
//#endregion
|
|
31
|
+
export { HarnessId, HarnessInfo, HarnessMode, HarnessVia, detectHarness, detectMode, modeFor };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
//#region src/detect/harness.ts
|
|
2
|
+
/** Tool-specific env var (presence) -> harness id. Order = detection priority. */
|
|
3
|
+
const ENV_SIGNALS = [
|
|
4
|
+
["CLAUDECODE", "claude-code"],
|
|
5
|
+
["CODEX_SANDBOX", "codex"],
|
|
6
|
+
["CURSOR_AGENT", "cursor"],
|
|
7
|
+
["CLINE", "cline"],
|
|
8
|
+
["CLINE_AGENT", "cline"],
|
|
9
|
+
["GEMINI_CLI", "gemini-cli"],
|
|
10
|
+
["OPENCODE", "opencode"],
|
|
11
|
+
["WINDSURF_AGENT", "windsurf"],
|
|
12
|
+
["CODEIUM_AGENT", "windsurf"],
|
|
13
|
+
["COPILOT_AGENT", "copilot"],
|
|
14
|
+
["AIDER", "aider"],
|
|
15
|
+
["KIRO", "kiro"],
|
|
16
|
+
["GOOSE", "goose"],
|
|
17
|
+
["AMP", "amp"]
|
|
18
|
+
];
|
|
19
|
+
/** `AGENT=<name>` / `AI_AGENT=<name>` standard value -> harness id. */
|
|
20
|
+
const STD_NAMES = {
|
|
21
|
+
goose: "goose",
|
|
22
|
+
amp: "amp",
|
|
23
|
+
claude: "claude-code",
|
|
24
|
+
"claude-code": "claude-code",
|
|
25
|
+
cursor: "cursor",
|
|
26
|
+
codex: "codex",
|
|
27
|
+
cline: "cline",
|
|
28
|
+
aider: "aider",
|
|
29
|
+
opencode: "opencode",
|
|
30
|
+
gemini: "gemini-cli",
|
|
31
|
+
copilot: "copilot",
|
|
32
|
+
kiro: "kiro"
|
|
33
|
+
};
|
|
34
|
+
/** Harnesses exposing a native hook system (vs CLI-only integration). */
|
|
35
|
+
const HOOK_CAPABLE = /* @__PURE__ */ new Set([
|
|
36
|
+
"claude-code",
|
|
37
|
+
"cursor",
|
|
38
|
+
"cline",
|
|
39
|
+
"gemini-cli",
|
|
40
|
+
"opencode"
|
|
41
|
+
]);
|
|
42
|
+
/** Integration mode for a harness id. */
|
|
43
|
+
function modeFor(id) {
|
|
44
|
+
return HOOK_CAPABLE.has(id) ? "hook" : "cli";
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Detect the current AI coding harness from environment signals.
|
|
48
|
+
* Priority: `AGENT` standard -> `AI_AGENT` standard -> tool-specific vars -> unknown.
|
|
49
|
+
* @param env - environment map (defaults to `process.env`)
|
|
50
|
+
*/
|
|
51
|
+
function detectHarness(env = process.env) {
|
|
52
|
+
const agent = env.AGENT?.trim().toLowerCase();
|
|
53
|
+
const agentId = agent ? STD_NAMES[agent] : void 0;
|
|
54
|
+
if (agentId) return {
|
|
55
|
+
id: agentId,
|
|
56
|
+
mode: modeFor(agentId),
|
|
57
|
+
via: "agent-std"
|
|
58
|
+
};
|
|
59
|
+
const ai = env.AI_AGENT?.trim().toLowerCase();
|
|
60
|
+
const aiId = ai ? STD_NAMES[ai] : void 0;
|
|
61
|
+
if (aiId) return {
|
|
62
|
+
id: aiId,
|
|
63
|
+
mode: modeFor(aiId),
|
|
64
|
+
via: "ai-agent-std"
|
|
65
|
+
};
|
|
66
|
+
for (const [key, id] of ENV_SIGNALS) if (env[key]?.trim()) return {
|
|
67
|
+
id,
|
|
68
|
+
mode: modeFor(id),
|
|
69
|
+
via: "env"
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
id: "unknown",
|
|
73
|
+
mode: "cli",
|
|
74
|
+
via: "fallback"
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** Convenience: the integration mode of the current harness. */
|
|
78
|
+
function detectMode(env = process.env) {
|
|
79
|
+
return detectHarness(env).mode;
|
|
80
|
+
}
|
|
81
|
+
//#endregion
|
|
82
|
+
export { detectHarness, detectMode, modeFor };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//#region src/freshness/doc-helpers.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Documentation-consultation freshness: was Context7 AND Exa consulted this
|
|
4
|
+
* session (via live MCP query, or a Read on a cached MCP result file)?
|
|
5
|
+
*/
|
|
6
|
+
/** Authorization entry from APEX state (legacy session + new sessions[]). */
|
|
7
|
+
interface AuthEntry {
|
|
8
|
+
source?: string;
|
|
9
|
+
sources?: string[];
|
|
10
|
+
sessions?: string[];
|
|
11
|
+
session?: string;
|
|
12
|
+
doc_sessions?: string[];
|
|
13
|
+
read_paths?: string[];
|
|
14
|
+
}
|
|
15
|
+
/** Resolve the sessions array from an auth entry (legacy fallback). */
|
|
16
|
+
declare function resolveSessions(auth: AuthEntry | undefined): string[];
|
|
17
|
+
/** Per-source satisfaction status for a session. */
|
|
18
|
+
interface DocSatisfactionStatus {
|
|
19
|
+
context7: boolean;
|
|
20
|
+
exa: boolean;
|
|
21
|
+
viaCache: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** True when BOTH Context7 and Exa were satisfied for the session. */
|
|
24
|
+
declare function isDocConsulted(authorizations: Record<string, AuthEntry> | undefined, sessionId: string): boolean;
|
|
25
|
+
/** Report how each doc source was satisfied for a session. */
|
|
26
|
+
declare function formatDocSatisfactionStatus(authorizations: Record<string, AuthEntry> | undefined, sessionId: string): DocSatisfactionStatus;
|
|
27
|
+
/** Deny message when online documentation has not been consulted. */
|
|
28
|
+
declare function formatDocDeny(framework: string): string;
|
|
29
|
+
//#endregion
|
|
30
|
+
export { isDocConsulted as a, formatDocSatisfactionStatus as i, DocSatisfactionStatus as n, resolveSessions as o, formatDocDeny as r, AuthEntry as t };
|