@fusengine/harness 0.1.47 → 0.1.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/claude/index.d.mts +2 -2
- package/dist/adapters/claude/index.mjs +2 -2
- package/dist/adapters/cline/index.mjs +1 -1
- package/dist/adapters/codex/index.d.mts +1 -1
- package/dist/adapters/codex/index.mjs +1 -1
- package/dist/adapters/cursor/index.d.mts +10 -3
- package/dist/adapters/cursor/index.mjs +3 -3
- package/dist/adapters/gemini/index.mjs +1 -1
- package/dist/adapters/hermes/index.d.mts +54 -0
- package/dist/adapters/hermes/index.mjs +2 -0
- package/dist/apex-Wdi1nq_w.d.mts +73 -0
- package/dist/{claude-U1KTaRG4.mjs → claude-BxC9semG.mjs} +30 -9
- package/dist/cli/bin.mjs +5 -5
- package/dist/cli/index.mjs +1 -1
- package/dist/config/index.d.mts +1 -1
- package/dist/config/index.mjs +1 -1
- package/dist/detect/index.d.mts +1 -1
- package/dist/detect/index.mjs +1 -1
- package/dist/{doc-helpers-BNfYWvYv.d.mts → doc-helpers-CEKzGg2u.d.mts} +2 -0
- package/dist/{dotenv-B9nM4cuQ.mjs → dotenv-Jj8aL1FL.mjs} +2 -1
- package/dist/{evaluate-qYC5xClb.mjs → evaluate-CNAzgxnN.mjs} +3 -85
- package/dist/freshness/index.d.mts +1 -1
- package/dist/{handle-DUlUbhyF.mjs → handle-D1hHqHvh.mjs} +422 -62
- package/dist/{harness-C8Nxxyn_.mjs → harness-Cb9xR8dC.mjs} +6 -3
- package/dist/{harness-BPPu5CrN.d.mts → harness-s1Fce_dL.d.mts} +1 -1
- package/dist/hermes-DWXCRFZU.mjs +54 -0
- package/dist/home-state-D0RLWP8J.mjs +134 -0
- package/dist/{index-l9Xvxrx7.d.mts → index-BfMsILmg.d.mts} +2 -52
- package/dist/{index-BxjzFraL.d.mts → index-DZCLmSoO.d.mts} +11 -9
- package/dist/{index-BXPySPxE.d.mts → index-jj_EzZRv.d.mts} +1 -1
- package/dist/index.d.mts +7 -6
- package/dist/index.mjs +6 -5
- package/dist/init/index.d.mts +1 -1
- package/dist/policy/index.d.mts +3 -2
- package/dist/policy/index.mjs +4 -3
- package/dist/prompt/index.d.mts +1 -1
- package/dist/{run-CaqeQFYp.mjs → run-DZvP_9xB.mjs} +1 -1
- package/dist/runtime/index.d.mts +23 -8
- package/dist/runtime/index.mjs +2 -2
- package/dist/session-state-DMpotbRz.d.mts +73 -0
- package/dist/skill-path-DhItkBzk.mjs +95 -0
- package/dist/store-QSSTO3lY.mjs +349 -0
- package/dist/tracking/index.d.mts +2 -2
- package/dist/tracking/index.mjs +2 -2
- package/dist/{types-D56jSgD9.d.mts → types-DVbIl9md.d.mts} +6 -0
- package/dist/{validate-DVV4nRfr.mjs → validate-DCQ8dkdL.mjs} +34 -103
- package/package.json +7 -2
- package/dist/home-state-mKZxP4oZ.mjs +0 -52
- package/dist/session-state-D4F_Dub6.d.mts +0 -35
- package/dist/store-CNjFenWe.mjs +0 -184
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { t as AuthEntry } from "./doc-helpers-CEKzGg2u.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/policy/apex-authorization.d.ts
|
|
4
|
+
/** Pending doc-credit target written on a Check-1 deny (parity enforce-apex-phases.ts:80 `state.target`). */
|
|
5
|
+
interface SessionTarget {
|
|
6
|
+
project: string;
|
|
7
|
+
framework: string;
|
|
8
|
+
set_by: string;
|
|
9
|
+
set_at: string;
|
|
10
|
+
}
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region src/tracking/session-state.d.ts
|
|
13
|
+
/** Quality of a recorded agent call (the freshness gate ignores insufficient ones). */
|
|
14
|
+
type AgentQuality = "sufficient" | "insufficient";
|
|
15
|
+
/** Recorded session activity that feeds the APEX gates. */
|
|
16
|
+
interface SessionTrack {
|
|
17
|
+
authorizations: Record<string, AuthEntry>;
|
|
18
|
+
refsRead: string[];
|
|
19
|
+
/**
|
|
20
|
+
* Epoch-ms timestamp of each reference read, keyed by `refsRead` path (parity
|
|
21
|
+
* track-solid-reads.py `solid_reads[].timestamp`). Optional: tracks persisted
|
|
22
|
+
* before this field existed only carry `refsRead`, and such paths count as
|
|
23
|
+
* read (backward compat). PARITY: only the SOLID-read gate TTL-checks these —
|
|
24
|
+
* Python TTL-izes SOLID reads exclusively (require-solid-read.py); the other
|
|
25
|
+
* refsRead consumers (skill-trigger/shadcn/tailwind/design) stay session-scoped.
|
|
26
|
+
*/
|
|
27
|
+
refsReadAt?: Record<string, number>;
|
|
28
|
+
/** Framework awaiting doc credit after a Check-1 deny. PERSISTS — {@link recordDoc} cross-credits it on EVERY consultation, never clears it; only the next deny replaces it via {@link recordTarget} (parity track-doc-consultation.py:62). */
|
|
29
|
+
target?: SessionTarget;
|
|
30
|
+
agents: {
|
|
31
|
+
name: string;
|
|
32
|
+
ts: number;
|
|
33
|
+
quality?: AgentQuality;
|
|
34
|
+
}[];
|
|
35
|
+
trivialEdits: number[];
|
|
36
|
+
brainstormRequired?: boolean;
|
|
37
|
+
}
|
|
38
|
+
/** A fresh, empty track. */
|
|
39
|
+
declare function emptyTrack(): SessionTrack;
|
|
40
|
+
/**
|
|
41
|
+
* Record a doc consultation (Context7/Exa/web) for a framework in this session:
|
|
42
|
+
* the Check-2 credit (`doc_sessions` + `sources`) plus the Check-1 stamp
|
|
43
|
+
* (`sessions` + `doc_consulted` = ISO of `now`). PARITY track-doc-consultation.py
|
|
44
|
+
* :62-70 — a `target` left by a Check-1 deny (enforce-apex-phases.ts:80) is
|
|
45
|
+
* cross-credited on EVERY consultation, with NO TTL on `target.set_at`, and the
|
|
46
|
+
* target PERSISTS (only the next deny replaces it via {@link recordTarget});
|
|
47
|
+
* single-shot clearing or TTL-gating the target re-opened the deny loop when a
|
|
48
|
+
* consultation landed > TTL after the deny. Immutable.
|
|
49
|
+
*/
|
|
50
|
+
declare function recordDoc(track: SessionTrack, framework: string, sessionId: string, source: string, now?: number): SessionTrack;
|
|
51
|
+
/** Set the pending doc-credit target (written by the runtime on a Check-1 deny). Immutable. */
|
|
52
|
+
declare function recordTarget(track: SessionTrack, target: SessionTarget): SessionTrack;
|
|
53
|
+
/**
|
|
54
|
+
* Record that a SOLID reference file was read (deduped). Immutable. When `now`
|
|
55
|
+
* (epoch ms — the tool event's own timestamp, never a fresh `Date.now()`) is
|
|
56
|
+
* supplied, the read is stamped in `refsReadAt`, refreshed on re-reads so the
|
|
57
|
+
* LATEST read drives the SOLID TTL (parity track-solid-reads.py, which appends
|
|
58
|
+
* a timestamped entry per read; require-solid-read.py checks the most recent).
|
|
59
|
+
* Callers that omit `now` keep the legacy untimestamped behavior.
|
|
60
|
+
*/
|
|
61
|
+
declare function recordRefRead(track: SessionTrack, path: string, now?: number): SessionTrack;
|
|
62
|
+
/** Record an agent/tool call with a timestamp + optional quality. Immutable. */
|
|
63
|
+
declare function recordAgent(track: SessionTrack, name: string, ts: number, quality?: AgentQuality): SessionTrack;
|
|
64
|
+
/** True when ALL of `names` ran within `windowMs` with non-insufficient quality. */
|
|
65
|
+
declare function agentsFresh(track: SessionTrack, names: string[], windowMs: number, now: number): boolean;
|
|
66
|
+
/** Record a trivial edit timestamp (sliding window; old evicted). Immutable. */
|
|
67
|
+
declare function recordTrivialEdit(track: SessionTrack, ts: number, windowMs: number, now: number): SessionTrack;
|
|
68
|
+
/** Count trivial edits within the sliding window. */
|
|
69
|
+
declare function trivialCount(track: SessionTrack, windowMs: number, now: number): number;
|
|
70
|
+
/** Set the brainstorm-required flag (from creation-intent detection). Immutable. */
|
|
71
|
+
declare function recordBrainstormRequired(track: SessionTrack, required: boolean): SessionTrack;
|
|
72
|
+
//#endregion
|
|
73
|
+
export { recordAgent as a, recordRefRead as c, trivialCount as d, emptyTrack as i, recordTarget as l, SessionTrack as n, recordBrainstormRequired as o, agentsFresh as r, recordDoc as s, AgentQuality as t, recordTrivialEdit as u };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { t as HOME_DIR } from "./dotenv-Jj8aL1FL.mjs";
|
|
2
|
+
import { t as detectHarness } from "./harness-Cb9xR8dC.mjs";
|
|
3
|
+
import { l as PLUGINS_DIR } from "./home-state-D0RLWP8J.mjs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
//#region src/runtime/lifecycle/cartographer/detect.ts
|
|
8
|
+
/**
|
|
9
|
+
* Plugin discovery (fs). Ports `detect_plugins.py`: marketplace `plugins` dir
|
|
10
|
+
* resolution + `plugin.json` meta reading.
|
|
11
|
+
*/
|
|
12
|
+
/** Sorted entry names of `dir` (alpha, byte-order), or `[]` on error. */
|
|
13
|
+
function sortedNames(dir) {
|
|
14
|
+
try {
|
|
15
|
+
return readdirSync(dir).sort((a, b) => a.localeCompare(b, "en"));
|
|
16
|
+
} catch {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Read `[version, name]` from `<pluginPath>/.claude-plugin/plugin.json`.
|
|
22
|
+
* @param pluginPath - Absolute plugin directory.
|
|
23
|
+
* @returns The `[version, name]` pair (both "" when absent/unreadable).
|
|
24
|
+
*/
|
|
25
|
+
function readPluginMeta(pluginPath) {
|
|
26
|
+
const pj = join(pluginPath, ".claude-plugin", "plugin.json");
|
|
27
|
+
if (!existsSync(pj)) return ["", ""];
|
|
28
|
+
try {
|
|
29
|
+
const meta = JSON.parse(readFileSync(pj, "utf-8"));
|
|
30
|
+
return [meta.version ?? "", meta.name ?? ""];
|
|
31
|
+
} catch {
|
|
32
|
+
return ["", ""];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Auto-detect the marketplace `plugins` dir that contains `cartographer`,
|
|
37
|
+
* falling back to the first marketplace with a `plugins` dir, else `cwd`.
|
|
38
|
+
* Ports `find_marketplace_plugins`, but harness-agnostic: the config dir is
|
|
39
|
+
* derived from the detected harness (`.claude`, `.codex`, `.cursor`, …) via the
|
|
40
|
+
* shared `HOME_DIR` mapping instead of a hardcoded `.claude`.
|
|
41
|
+
* @param home - Home directory (defaults to `~`).
|
|
42
|
+
* @param id - Detected harness id (defaults to runtime detection).
|
|
43
|
+
* @returns The resolved plugins directory.
|
|
44
|
+
*/
|
|
45
|
+
function findMarketplacePlugins(home = homedir(), id = detectHarness().id) {
|
|
46
|
+
const mp = join(home, HOME_DIR[id] ?? ".claude", "plugins", "marketplaces");
|
|
47
|
+
const markets = sortedNames(mp);
|
|
48
|
+
for (const m of markets) if (existsSync(join(mp, m, "plugins", "cartographer"))) return join(mp, m, "plugins");
|
|
49
|
+
for (const m of markets) if (existsSync(join(mp, m, "plugins"))) return join(mp, m, "plugins");
|
|
50
|
+
return process.cwd();
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/policy/skill-path.ts
|
|
54
|
+
/**
|
|
55
|
+
* Dynamic skill-path resolution (fs). Replaces a hardcoded `PLUGIN_DIR`
|
|
56
|
+
* table (framework -> plugin dir name), which can drift from what's actually
|
|
57
|
+
* installed: scans installed marketplace plugins for a `skills/<name>/SKILL.md`
|
|
58
|
+
* that exists ON DISK, returning the first real absolute path found — never a
|
|
59
|
+
* guessed one.
|
|
60
|
+
*
|
|
61
|
+
* Deliberately lighter than the cartographer's own `scanSkills` (which
|
|
62
|
+
* frontmatter-parses every skill in a plugin): this runs once per missing
|
|
63
|
+
* sub-skill on every gated Write/Edit, so it does a single flat `readdirSync`
|
|
64
|
+
* of the plugins dir + one `existsSync` per candidate — no file reads, no
|
|
65
|
+
* recursion, no `statSync`.
|
|
66
|
+
*/
|
|
67
|
+
/** Sorted entry names of `dir` (alpha, byte-order), or `[]` on error. */
|
|
68
|
+
function pluginDirNames(dir) {
|
|
69
|
+
try {
|
|
70
|
+
return readdirSync(dir).sort((a, b) => a.localeCompare(b, "en"));
|
|
71
|
+
} catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Resolve the real, installed `SKILL.md` path for `skillName`.
|
|
77
|
+
* @param skillName - the sub-skill directory name (e.g. "solid-react",
|
|
78
|
+
* "shadcn-detection", "react-shadcn").
|
|
79
|
+
* @param pluginsDirOverride - override for the marketplace plugins dir
|
|
80
|
+
* (tests); defaults to the auto-detected marketplace root.
|
|
81
|
+
* @returns the absolute `<pluginDir>/skills/<skillName>/SKILL.md` of the
|
|
82
|
+
* first installed plugin that actually has it, or the generic
|
|
83
|
+
* `${PLUGINS_DIR}/<skillName>` fallback when none is found (CI/test
|
|
84
|
+
* environments without a marketplace).
|
|
85
|
+
*/
|
|
86
|
+
function resolveSkillPath(skillName, pluginsDirOverride) {
|
|
87
|
+
const pluginsDir = pluginsDirOverride ?? findMarketplacePlugins();
|
|
88
|
+
for (const dir of pluginDirNames(pluginsDir)) {
|
|
89
|
+
const candidate = join(pluginsDir, dir, "skills", skillName, "SKILL.md");
|
|
90
|
+
if (existsSync(candidate)) return candidate;
|
|
91
|
+
}
|
|
92
|
+
return `${PLUGINS_DIR}/${skillName}`;
|
|
93
|
+
}
|
|
94
|
+
//#endregion
|
|
95
|
+
export { findMarketplacePlugins as n, readPluginMeta as r, resolveSkillPath as t };
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { c as ttlLabel } from "./dotenv-Jj8aL1FL.mjs";
|
|
2
|
+
import { n as fuseHarnessHome } from "./home-state-D0RLWP8J.mjs";
|
|
3
|
+
import { a as writeJsonFile, i as readJsonFile } from "./json-io-DisYd2fb.mjs";
|
|
4
|
+
import { i as resolveSessions } from "./doc-helpers-CWZegVdR.mjs";
|
|
5
|
+
import { t as routeReferences } from "./router-PKVNBHge.mjs";
|
|
6
|
+
import { t as resolveSkillPath } from "./skill-path-DhItkBzk.mjs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
10
|
+
//#region src/policy/skill-source.ts
|
|
11
|
+
/**
|
|
12
|
+
* Framework -> SKILL.md doc source for the APEX Check-1 non-routed deny —
|
|
13
|
+
* parity with the Python plugin's enforce-helpers.ts `getSkillSource()`
|
|
14
|
+
* (10-entry map, fallback "mcp__context7__query-docs"). The map is ported
|
|
15
|
+
* faithfully as skill NAMES only: disk paths are resolved DYNAMICALLY via
|
|
16
|
+
* {@link resolveSkillPath} (proprietary rule — no static path table; installed
|
|
17
|
+
* plugin dir names differ from upstream's `react-expert/...`, and a hardcoded
|
|
18
|
+
* table drifts from what is actually installed).
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Fallback doc source when no SKILL.md can be promised: the consigne to query
|
|
22
|
+
* context7 (a TOOL name, not a path) — parity enforce-helpers.ts:51.
|
|
23
|
+
*/
|
|
24
|
+
const CONTEXT7_SOURCE = "mcp__context7__query-docs";
|
|
25
|
+
/**
|
|
26
|
+
* Framework -> sub-skill dir name, faithful to the Python
|
|
27
|
+
* `getSkillSource` map (enforce-helpers.ts:39-50, all 10 entries).
|
|
28
|
+
*/
|
|
29
|
+
const SKILL_NAMES = {
|
|
30
|
+
react: "react-19",
|
|
31
|
+
nextjs: "nextjs-16",
|
|
32
|
+
swift: "swiftui-components",
|
|
33
|
+
laravel: "laravel-eloquent",
|
|
34
|
+
tailwind: "tailwindcss-v4",
|
|
35
|
+
generic: "solid-generic",
|
|
36
|
+
java: "solid-java",
|
|
37
|
+
go: "solid-go",
|
|
38
|
+
ruby: "solid-ruby",
|
|
39
|
+
rust: "solid-rust"
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* The doc source a Check-1 deny may promise for `framework` (parity
|
|
43
|
+
* `getSkillSource`): the REAL installed SKILL.md absolute path when it exists
|
|
44
|
+
* on disk, else {@link CONTEXT7_SOURCE}. `resolveSkillPath` returns a
|
|
45
|
+
* `/SKILL.md`-suffixed path ONLY on an `existsSync` hit; its
|
|
46
|
+
* `${PLUGINS_DIR}/<name>` not-found fallback (no marketplace installed, or a
|
|
47
|
+
* mapped skill absent on disk — e.g. upstream's `swiftui-components`) is
|
|
48
|
+
* converted to the context7 consigne here, because a deny must never promise
|
|
49
|
+
* a nonexistent path (every option a deny offers must actually unblock).
|
|
50
|
+
* @param framework - Detected framework (the `detectFramework()` domain).
|
|
51
|
+
* @param pluginsDirOverride - Marketplace plugins dir override (tests).
|
|
52
|
+
*/
|
|
53
|
+
function getSkillSource(framework, pluginsDirOverride) {
|
|
54
|
+
const name = SKILL_NAMES[framework];
|
|
55
|
+
if (!name) return CONTEXT7_SOURCE;
|
|
56
|
+
const path = resolveSkillPath(name, pluginsDirOverride);
|
|
57
|
+
return path.endsWith("/SKILL.md") ? path : CONTEXT7_SOURCE;
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/policy/apex-authorization.ts
|
|
61
|
+
/**
|
|
62
|
+
* Credit a doc consultation onto one framework's auth entry (parity
|
|
63
|
+
* track-doc-consultation.py): the once-per-session Check-2 fields
|
|
64
|
+
* (`doc_sessions` + `sources`) plus the per-framework Check-1 stamp
|
|
65
|
+
* (`sessions` + `doc_consulted`). Pure.
|
|
66
|
+
*/
|
|
67
|
+
function creditDocConsultation(prev, sessionId, source, nowIso) {
|
|
68
|
+
const p = prev ?? {};
|
|
69
|
+
const add = (list, v) => v && !list.includes(v) ? [...list, v] : list;
|
|
70
|
+
return {
|
|
71
|
+
...p,
|
|
72
|
+
sessions: add(resolveSessions(p), sessionId),
|
|
73
|
+
doc_sessions: add(p.doc_sessions ?? [], sessionId),
|
|
74
|
+
sources: add(p.sources ?? (p.source ? [p.source] : []), source),
|
|
75
|
+
doc_consulted: nowIso
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Check 1 (parity enforce-apex-phases.ts isAuthorized): the framework's doc or
|
|
80
|
+
* skill was consulted in THIS session (resolveSessions) AND the stamp is
|
|
81
|
+
* younger than the TTL (FUSE_ENFORCE_TTL_SEC via src/config/ttl.ts).
|
|
82
|
+
*/
|
|
83
|
+
function isAuthorized(auth, sessionId, now, ttlMs) {
|
|
84
|
+
if (!auth?.doc_consulted || !resolveSessions(auth).includes(sessionId)) return false;
|
|
85
|
+
const readEpoch = Date.parse(auth.doc_consulted);
|
|
86
|
+
return !Number.isNaN(readEpoch) && now - readEpoch < ttlMs;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A fresh same-session `skills/**.md` read whose path names the framework also
|
|
90
|
+
* satisfies Check 1 (parity track-doc-consultation.py, which stamps
|
|
91
|
+
* `doc_consulted` on `skills/.*\.md` Reads; the harness records those Reads in
|
|
92
|
+
* `refsRead`/`refsReadAt` instead, and the track file is per-session).
|
|
93
|
+
*/
|
|
94
|
+
function skillReadAuthorized(ctx, now, ttlMs) {
|
|
95
|
+
const nameRe = new RegExp(`\\b(${{
|
|
96
|
+
laravel: "laravel|php",
|
|
97
|
+
tailwind: "tailwindcss?"
|
|
98
|
+
}[ctx.framework] ?? ctx.framework})\\b`, "i");
|
|
99
|
+
return (ctx.refsRead ?? []).some((p) => {
|
|
100
|
+
if (!/skills\/.*\.md$/i.test(p) || !nameRe.test(p)) return false;
|
|
101
|
+
const at = ctx.refsReadAt?.[p];
|
|
102
|
+
return at !== void 0 && now - at < ttlMs;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Gate (Check 1): deny until the framework's doc/skill is freshly consulted.
|
|
107
|
+
* The deny lists the routed SOLID refs when refs are loaded (parity
|
|
108
|
+
* formatRoutedDeny), else the framework's skill source (parity getSkillSource:
|
|
109
|
+
* a real installed SKILL.md path, or the context7 consigne). The RUNTIME
|
|
110
|
+
* caller persists `state.target` on deny (recordTarget, parity :80-81) so
|
|
111
|
+
* recordDoc can cross-credit this framework on the next doc consultation.
|
|
112
|
+
*/
|
|
113
|
+
const apexAuthorizationGate = (ctx) => {
|
|
114
|
+
const windowMs = ctx.windowMs ?? 120 * 1e3;
|
|
115
|
+
const now = ctx.now ?? Date.now();
|
|
116
|
+
if (isAuthorized(ctx.authorizations?.[ctx.framework], ctx.sessionId, now, windowMs)) return null;
|
|
117
|
+
if (skillReadAuthorized(ctx, now, windowMs)) return null;
|
|
118
|
+
const ttl = ttlLabel(Math.round(windowMs / 1e3));
|
|
119
|
+
const title = `APEX: ${ctx.framework} documentation required`;
|
|
120
|
+
const routed = ctx.refs?.length ? routeReferences(ctx.refs, ctx.filePath, ctx.content) : null;
|
|
121
|
+
if (!routed) {
|
|
122
|
+
const src = getSkillSource(ctx.framework);
|
|
123
|
+
return {
|
|
124
|
+
kind: "block",
|
|
125
|
+
title,
|
|
126
|
+
reason: `APEX: Read doc first (expires every ${ttl}) for ${ctx.framework}! Source: ${src}`,
|
|
127
|
+
actions: [src === "mcp__context7__query-docs" ? `Query ${CONTEXT7_SOURCE} for ${ctx.framework} docs` : `Read ${src}`]
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const optional = routed.optional.map((r) => r.meta.filePath);
|
|
131
|
+
return {
|
|
132
|
+
kind: "block",
|
|
133
|
+
title,
|
|
134
|
+
reason: [
|
|
135
|
+
`APEX: Read specific SOLID references (expires every ${ttl}) for ${ctx.framework}.`,
|
|
136
|
+
`Editing: ${ctx.filePath}`,
|
|
137
|
+
...optional.length ? [`Optional: ${optional.join(", ")}`] : [],
|
|
138
|
+
`Full skill: ${routed.skillPath}`
|
|
139
|
+
].join("\n"),
|
|
140
|
+
actions: routed.required.map((r) => r.meta.filePath)
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/tracking/session-state.ts
|
|
145
|
+
/** A fresh, empty track. */
|
|
146
|
+
function emptyTrack() {
|
|
147
|
+
return {
|
|
148
|
+
authorizations: {},
|
|
149
|
+
refsRead: [],
|
|
150
|
+
agents: [],
|
|
151
|
+
trivialEdits: []
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Record a doc consultation (Context7/Exa/web) for a framework in this session:
|
|
156
|
+
* the Check-2 credit (`doc_sessions` + `sources`) plus the Check-1 stamp
|
|
157
|
+
* (`sessions` + `doc_consulted` = ISO of `now`). PARITY track-doc-consultation.py
|
|
158
|
+
* :62-70 — a `target` left by a Check-1 deny (enforce-apex-phases.ts:80) is
|
|
159
|
+
* cross-credited on EVERY consultation, with NO TTL on `target.set_at`, and the
|
|
160
|
+
* target PERSISTS (only the next deny replaces it via {@link recordTarget});
|
|
161
|
+
* single-shot clearing or TTL-gating the target re-opened the deny loop when a
|
|
162
|
+
* consultation landed > TTL after the deny. Immutable.
|
|
163
|
+
*/
|
|
164
|
+
function recordDoc(track, framework, sessionId, source, now = Date.now()) {
|
|
165
|
+
const nowIso = new Date(now).toISOString();
|
|
166
|
+
const authorizations = {
|
|
167
|
+
...track.authorizations,
|
|
168
|
+
[framework]: creditDocConsultation(track.authorizations[framework], sessionId, source, nowIso)
|
|
169
|
+
};
|
|
170
|
+
const t = track.target;
|
|
171
|
+
if (t && t.framework !== framework) authorizations[t.framework] = creditDocConsultation(track.authorizations[t.framework], sessionId, source, nowIso);
|
|
172
|
+
return {
|
|
173
|
+
...track,
|
|
174
|
+
authorizations
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/** Set the pending doc-credit target (written by the runtime on a Check-1 deny). Immutable. */
|
|
178
|
+
function recordTarget(track, target) {
|
|
179
|
+
return {
|
|
180
|
+
...track,
|
|
181
|
+
target
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Record that a SOLID reference file was read (deduped). Immutable. When `now`
|
|
186
|
+
* (epoch ms — the tool event's own timestamp, never a fresh `Date.now()`) is
|
|
187
|
+
* supplied, the read is stamped in `refsReadAt`, refreshed on re-reads so the
|
|
188
|
+
* LATEST read drives the SOLID TTL (parity track-solid-reads.py, which appends
|
|
189
|
+
* a timestamped entry per read; require-solid-read.py checks the most recent).
|
|
190
|
+
* Callers that omit `now` keep the legacy untimestamped behavior.
|
|
191
|
+
*/
|
|
192
|
+
function recordRefRead(track, path, now) {
|
|
193
|
+
const refsRead = track.refsRead.includes(path) ? track.refsRead : [...track.refsRead, path];
|
|
194
|
+
if (now === void 0) return refsRead === track.refsRead ? track : {
|
|
195
|
+
...track,
|
|
196
|
+
refsRead
|
|
197
|
+
};
|
|
198
|
+
return {
|
|
199
|
+
...track,
|
|
200
|
+
refsRead,
|
|
201
|
+
refsReadAt: {
|
|
202
|
+
...track.refsReadAt,
|
|
203
|
+
[path]: now
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/** Record an agent/tool call with a timestamp + optional quality. Immutable. */
|
|
208
|
+
function recordAgent(track, name, ts, quality) {
|
|
209
|
+
const entry = quality ? {
|
|
210
|
+
name,
|
|
211
|
+
ts,
|
|
212
|
+
quality
|
|
213
|
+
} : {
|
|
214
|
+
name,
|
|
215
|
+
ts
|
|
216
|
+
};
|
|
217
|
+
return {
|
|
218
|
+
...track,
|
|
219
|
+
agents: [...track.agents, entry]
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/** True when ALL of `names` ran within `windowMs` with non-insufficient quality. */
|
|
223
|
+
function agentsFresh(track, names, windowMs, now) {
|
|
224
|
+
const cutoff = now - windowMs;
|
|
225
|
+
return names.every((n) => track.agents.some((a) => a.name === n && a.ts > cutoff && a.quality !== "insufficient"));
|
|
226
|
+
}
|
|
227
|
+
/** Record a trivial edit timestamp (sliding window; old evicted). Immutable. */
|
|
228
|
+
function recordTrivialEdit(track, ts, windowMs, now) {
|
|
229
|
+
const cutoff = now - windowMs;
|
|
230
|
+
return {
|
|
231
|
+
...track,
|
|
232
|
+
trivialEdits: [...(track.trivialEdits ?? []).filter((t) => t > cutoff), ts]
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/** Count trivial edits within the sliding window. */
|
|
236
|
+
function trivialCount(track, windowMs, now) {
|
|
237
|
+
const cutoff = now - windowMs;
|
|
238
|
+
return (track.trivialEdits ?? []).filter((t) => t > cutoff).length;
|
|
239
|
+
}
|
|
240
|
+
/** Set the brainstorm-required flag (from creation-intent detection). Immutable. */
|
|
241
|
+
function recordBrainstormRequired(track, required) {
|
|
242
|
+
return {
|
|
243
|
+
...track,
|
|
244
|
+
brainstormRequired: required
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region src/tracking/integrity.ts
|
|
249
|
+
/**
|
|
250
|
+
* @module integrity
|
|
251
|
+
* HMAC-SHA256 tamper-evident wrapping for {@link SessionTrack}.
|
|
252
|
+
*
|
|
253
|
+
* Failure policy: ONLY a MAC mismatch causes fail-closed (returns null).
|
|
254
|
+
* The nonce is included in the signed payload and written to disk for advisory
|
|
255
|
+
* diagnostics, but is NOT checked during verification — concurrent hook
|
|
256
|
+
* invocations (PostToolUse, SubagentStop, …) legitimately load the same
|
|
257
|
+
* envelope after the nonce watermark advances; a monotonic check would trigger
|
|
258
|
+
* spurious fail-closed behaviour mid-session.
|
|
259
|
+
*
|
|
260
|
+
* The machine key is readable by the same agent process, so this deters naive
|
|
261
|
+
* out-of-band tampering — not a determined re-sign. Item B (transcript-grounded
|
|
262
|
+
* freshness) is the primary guarantee.
|
|
263
|
+
* @packageDocumentation
|
|
264
|
+
*/
|
|
265
|
+
const HARNESS_DIR = fuseHarnessHome();
|
|
266
|
+
const KEY_PATH = join(HARNESS_DIR, ".key");
|
|
267
|
+
const NONCE_PATH = join(HARNESS_DIR, ".nonce");
|
|
268
|
+
/** Load (or create on first use) the per-machine HMAC key stored at mode 0600. */
|
|
269
|
+
function loadOrCreateKey() {
|
|
270
|
+
mkdirSync(HARNESS_DIR, { recursive: true });
|
|
271
|
+
if (!existsSync(KEY_PATH)) {
|
|
272
|
+
const key = randomBytes(32).toString("hex");
|
|
273
|
+
writeFileSync(KEY_PATH, key, {
|
|
274
|
+
encoding: "utf8",
|
|
275
|
+
mode: 384
|
|
276
|
+
});
|
|
277
|
+
return key;
|
|
278
|
+
}
|
|
279
|
+
return readFileSync(KEY_PATH, "utf8").trim();
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Persist the last-seen nonce for advisory diagnostics (mode 0600).
|
|
283
|
+
* Never read during {@link verifyTrack} — see module JSDoc for rationale.
|
|
284
|
+
*/
|
|
285
|
+
function writeLastNonce(nonce) {
|
|
286
|
+
mkdirSync(HARNESS_DIR, { recursive: true });
|
|
287
|
+
writeFileSync(NONCE_PATH, String(nonce), {
|
|
288
|
+
encoding: "utf8",
|
|
289
|
+
mode: 384
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
/** Compute HMAC-SHA256 over the canonical message `"${nonce}:${data}"`. */
|
|
293
|
+
function computeMac(key, data, nonce) {
|
|
294
|
+
return createHmac("sha256", key).update(`${nonce}:${data}`).digest("hex");
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Sign a {@link SessionTrack} into a tamper-evident envelope. The nonce
|
|
298
|
+
* (`Date.now()`) is embedded in the MAC to bind the timestamp to the payload.
|
|
299
|
+
*/
|
|
300
|
+
function signTrack(track) {
|
|
301
|
+
const key = loadOrCreateKey();
|
|
302
|
+
const data = JSON.stringify(track);
|
|
303
|
+
const nonce = Date.now();
|
|
304
|
+
return {
|
|
305
|
+
data,
|
|
306
|
+
nonce,
|
|
307
|
+
mac: computeMac(key, data, nonce)
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Verify a {@link TrackEnvelope}. Returns the parsed {@link SessionTrack} on
|
|
312
|
+
* success, or `null` ONLY on MAC mismatch / parse failure (fail-closed on
|
|
313
|
+
* tampering). The nonce is NOT checked — see module JSDoc.
|
|
314
|
+
*/
|
|
315
|
+
function verifyTrack(envelope) {
|
|
316
|
+
try {
|
|
317
|
+
const key = loadOrCreateKey();
|
|
318
|
+
if (envelope.mac !== computeMac(key, envelope.data, envelope.nonce)) return null;
|
|
319
|
+
return JSON.parse(envelope.data);
|
|
320
|
+
} catch {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
//#endregion
|
|
325
|
+
//#region src/tracking/store.ts
|
|
326
|
+
/**
|
|
327
|
+
* Load and verify a session track from a signed envelope file.
|
|
328
|
+
*
|
|
329
|
+
* Returns {@link emptyTrack} (fail closed) when the file is absent, corrupt, or
|
|
330
|
+
* fails MAC validation — so the gates re-require real agents rather than trust a
|
|
331
|
+
* forged track. Only a MAC mismatch triggers fail-closed; the nonce is advisory
|
|
332
|
+
* and is never checked during load.
|
|
333
|
+
*/
|
|
334
|
+
async function loadTrack(file) {
|
|
335
|
+
const envelope = await readJsonFile(file);
|
|
336
|
+
if (!envelope) return emptyTrack();
|
|
337
|
+
return verifyTrack(envelope) ?? emptyTrack();
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Sign and persist a session track as a tamper-evident envelope, then write the
|
|
341
|
+
* advisory nonce watermark.
|
|
342
|
+
*/
|
|
343
|
+
async function saveTrack(file, track) {
|
|
344
|
+
const envelope = signTrack(track);
|
|
345
|
+
await writeJsonFile(file, envelope);
|
|
346
|
+
writeLastNonce(envelope.nonce);
|
|
347
|
+
}
|
|
348
|
+
//#endregion
|
|
349
|
+
export { emptyTrack as a, recordDoc as c, recordTrivialEdit as d, trivialCount as f, agentsFresh as i, recordRefRead as l, saveTrack as n, recordAgent as o, apexAuthorizationGate as p, verifyTrack as r, recordBrainstormRequired as s, loadTrack as t, recordTarget as u };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as recordAgent, c as recordRefRead, i as emptyTrack, l as
|
|
1
|
+
import { a as recordAgent, c as recordRefRead, d as trivialCount, i as emptyTrack, l as recordTarget, n as SessionTrack, o as recordBrainstormRequired, r as agentsFresh, s as recordDoc, t as AgentQuality, u as recordTrivialEdit } from "../session-state-DMpotbRz.mjs";
|
|
2
2
|
|
|
3
3
|
//#region src/tracking/store.d.ts
|
|
4
4
|
/**
|
|
@@ -16,4 +16,4 @@ declare function loadTrack(file: string): Promise<SessionTrack>;
|
|
|
16
16
|
*/
|
|
17
17
|
declare function saveTrack(file: string, track: SessionTrack): Promise<void>;
|
|
18
18
|
//#endregion
|
|
19
|
-
export { AgentQuality, SessionTrack, agentsFresh, emptyTrack, loadTrack, recordAgent, recordBrainstormRequired, recordDoc, recordRefRead, recordTrivialEdit, saveTrack, trivialCount };
|
|
19
|
+
export { AgentQuality, SessionTrack, agentsFresh, emptyTrack, loadTrack, recordAgent, recordBrainstormRequired, recordDoc, recordRefRead, recordTarget, recordTrivialEdit, saveTrack, trivialCount };
|
package/dist/tracking/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as emptyTrack, c as recordDoc, d as trivialCount, i as agentsFresh, l as recordRefRead, n as saveTrack, o as recordAgent, s as recordBrainstormRequired, t as loadTrack, u as
|
|
2
|
-
export { agentsFresh, emptyTrack, loadTrack, recordAgent, recordBrainstormRequired, recordDoc, recordRefRead, recordTrivialEdit, saveTrack, trivialCount };
|
|
1
|
+
import { a as emptyTrack, c as recordDoc, d as recordTrivialEdit, f as trivialCount, i as agentsFresh, l as recordRefRead, n as saveTrack, o as recordAgent, s as recordBrainstormRequired, t as loadTrack, u as recordTarget } from "../store-QSSTO3lY.mjs";
|
|
2
|
+
export { agentsFresh, emptyTrack, loadTrack, recordAgent, recordBrainstormRequired, recordDoc, recordRefRead, recordTarget, recordTrivialEdit, saveTrack, trivialCount };
|
|
@@ -14,6 +14,12 @@ interface Prompt {
|
|
|
14
14
|
reason: string;
|
|
15
15
|
/** Concrete next actions to proceed. */
|
|
16
16
|
actions?: string[];
|
|
17
|
+
/**
|
|
18
|
+
* User-facing notice (Python `hook_output.allow_pass`/`post_pass` parity):
|
|
19
|
+
* shown to the HUMAN — Claude Code's `systemMessage` — never to the agent,
|
|
20
|
+
* unlike `reason`/additionalContext. Excluded from {@link formatPrompt}.
|
|
21
|
+
*/
|
|
22
|
+
userMessage?: string;
|
|
17
23
|
}
|
|
18
24
|
/** Render a {@link Prompt} as a consistent, agent-readable memo block. */
|
|
19
25
|
declare function formatPrompt(p: Prompt): string;
|