@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
@@ -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 recordTrivialEdit, n as SessionTrack, o as recordBrainstormRequired, r as agentsFresh, s as recordDoc, t as AgentQuality, u as trivialCount } from "../session-state-V8Jp-KE5.mjs";
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 };
@@ -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 recordTrivialEdit } from "../store-ARx-GQSQ.mjs";
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;
@@ -1,9 +1,9 @@
1
1
  import { r as resolveMaxLines } from "./limits-CHn8AIL1.mjs";
2
- import { c as ttlLabel, t as HOME_DIR } from "./dotenv-B9nM4cuQ.mjs";
3
- import { t as detectHarness } from "./harness-C8Nxxyn_.mjs";
4
- import { F as PLUGINS_DIR, I as SOLID_REF, L as countFrameworkCodeLines } from "./evaluate-qYC5xClb.mjs";
2
+ import { c as ttlLabel } from "./dotenv-Jj8aL1FL.mjs";
3
+ import { d as countFrameworkCodeLines, l as PLUGINS_DIR, u as SOLID_REF } from "./home-state-D0RLWP8J.mjs";
5
4
  import { r as isDocConsulted, t as formatDocDeny } from "./doc-helpers-CWZegVdR.mjs";
6
5
  import { t as routeReferences } from "./router-PKVNBHge.mjs";
6
+ import { n as findMarketplacePlugins, r as readPluginMeta, t as resolveSkillPath } from "./skill-path-DhItkBzk.mjs";
7
7
  import { extname, join } from "node:path";
8
8
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
9
9
  import { homedir } from "node:os";
@@ -182,13 +182,17 @@ const brainstormGate = (ctx) => ctx.brainstormRequired && ctx.brainstormFresh ==
182
182
  reason: `Creation intent detected — brainstorm before creating new ${ctx.framework} files.`,
183
183
  actions: ["Launch the brainstorming agent"]
184
184
  } : null;
185
- /** Default APEX gate chain (brainstorm, freshness, docs, SOLID refs). */
186
- const APEX_GATES = [
187
- brainstormGate,
188
- freshnessGate,
189
- docConsultedGate,
190
- solidReadGate
191
- ];
185
+ /** Gates that run BEFORE Check 1 (harness-specific brainstorm + agent freshness, pre-dating enforce-apex-phases). */
186
+ const PRE_AUTH_GATES = [brainstormGate, freshnessGate];
187
+ /** Gates that run AFTER Check 1: Check 2 (doc consulted once per session) then the SOLID refs. */
188
+ const POST_AUTH_GATES = [docConsultedGate, solidReadGate];
189
+ /**
190
+ * Default APEX gate chain (brainstorm, freshness, docs, SOLID refs). Check 1
191
+ * (`apexAuthorizationGate`, policy/apex-authorization.ts) is wired BETWEEN the
192
+ * PRE and POST sub-chains by the runtime (gate-apex.ts), which owns its
193
+ * target-write side effect — parity enforce-apex-phases.ts Check1 -> Check2.
194
+ */
195
+ const APEX_GATES = [...PRE_AUTH_GATES, ...POST_AUTH_GATES];
192
196
  /**
193
197
  * Run the APEX gates (chain-of-responsibility): the first failing gate's prompt
194
198
  * wins; null means every gate passed (allow).
@@ -819,94 +823,6 @@ function isShadcnProject(cwd) {
819
823
  return UI_DIRS.some((dir) => existsSync(join(cwd, dir)));
820
824
  }
821
825
  //#endregion
822
- //#region src/runtime/lifecycle/cartographer/detect.ts
823
- /**
824
- * Plugin discovery (fs). Ports `detect_plugins.py`: marketplace `plugins` dir
825
- * resolution + `plugin.json` meta reading.
826
- */
827
- /** Sorted entry names of `dir` (alpha, byte-order), or `[]` on error. */
828
- function sortedNames$1(dir) {
829
- try {
830
- return readdirSync(dir).sort((a, b) => a.localeCompare(b, "en"));
831
- } catch {
832
- return [];
833
- }
834
- }
835
- /**
836
- * Read `[version, name]` from `<pluginPath>/.claude-plugin/plugin.json`.
837
- * @param pluginPath - Absolute plugin directory.
838
- * @returns The `[version, name]` pair (both "" when absent/unreadable).
839
- */
840
- function readPluginMeta(pluginPath) {
841
- const pj = join(pluginPath, ".claude-plugin", "plugin.json");
842
- if (!existsSync(pj)) return ["", ""];
843
- try {
844
- const meta = JSON.parse(readFileSync(pj, "utf-8"));
845
- return [meta.version ?? "", meta.name ?? ""];
846
- } catch {
847
- return ["", ""];
848
- }
849
- }
850
- /**
851
- * Auto-detect the marketplace `plugins` dir that contains `cartographer`,
852
- * falling back to the first marketplace with a `plugins` dir, else `cwd`.
853
- * Ports `find_marketplace_plugins`, but harness-agnostic: the config dir is
854
- * derived from the detected harness (`.claude`, `.codex`, `.cursor`, …) via the
855
- * shared `HOME_DIR` mapping instead of a hardcoded `.claude`.
856
- * @param home - Home directory (defaults to `~`).
857
- * @param id - Detected harness id (defaults to runtime detection).
858
- * @returns The resolved plugins directory.
859
- */
860
- function findMarketplacePlugins(home = homedir(), id = detectHarness().id) {
861
- const mp = join(home, HOME_DIR[id] ?? ".claude", "plugins", "marketplaces");
862
- const markets = sortedNames$1(mp);
863
- for (const m of markets) if (existsSync(join(mp, m, "plugins", "cartographer"))) return join(mp, m, "plugins");
864
- for (const m of markets) if (existsSync(join(mp, m, "plugins"))) return join(mp, m, "plugins");
865
- return process.cwd();
866
- }
867
- //#endregion
868
- //#region src/policy/skill-path.ts
869
- /**
870
- * Dynamic skill-path resolution (fs). Replaces a hardcoded `PLUGIN_DIR`
871
- * table (framework -> plugin dir name), which can drift from what's actually
872
- * installed: scans installed marketplace plugins for a `skills/<name>/SKILL.md`
873
- * that exists ON DISK, returning the first real absolute path found — never a
874
- * guessed one.
875
- *
876
- * Deliberately lighter than the cartographer's own `scanSkills` (which
877
- * frontmatter-parses every skill in a plugin): this runs once per missing
878
- * sub-skill on every gated Write/Edit, so it does a single flat `readdirSync`
879
- * of the plugins dir + one `existsSync` per candidate — no file reads, no
880
- * recursion, no `statSync`.
881
- */
882
- /** Sorted entry names of `dir` (alpha, byte-order), or `[]` on error. */
883
- function pluginDirNames(dir) {
884
- try {
885
- return readdirSync(dir).sort((a, b) => a.localeCompare(b, "en"));
886
- } catch {
887
- return [];
888
- }
889
- }
890
- /**
891
- * Resolve the real, installed `SKILL.md` path for `skillName`.
892
- * @param skillName - the sub-skill directory name (e.g. "solid-react",
893
- * "shadcn-detection", "react-shadcn").
894
- * @param pluginsDirOverride - override for the marketplace plugins dir
895
- * (tests); defaults to the auto-detected marketplace root.
896
- * @returns the absolute `<pluginDir>/skills/<skillName>/SKILL.md` of the
897
- * first installed plugin that actually has it, or the generic
898
- * `${PLUGINS_DIR}/<skillName>` fallback when none is found (CI/test
899
- * environments without a marketplace).
900
- */
901
- function resolveSkillPath(skillName, pluginsDirOverride) {
902
- const pluginsDir = pluginsDirOverride ?? findMarketplacePlugins();
903
- for (const dir of pluginDirNames(pluginsDir)) {
904
- const candidate = join(pluginsDir, dir, "skills", skillName, "SKILL.md");
905
- if (existsSync(candidate)) return candidate;
906
- }
907
- return `${PLUGINS_DIR}/${skillName}`;
908
- }
909
- //#endregion
910
826
  //#region src/policy/skill-triggers.ts
911
827
  /**
912
828
  * Detect which sub-skills the written `content` requires for a `framework`.
@@ -1242,7 +1158,7 @@ function buildApexInstruction(projectType, maxLines) {
1242
1158
  const expertAgent = getExpertAgent(projectType);
1243
1159
  return `INSTRUCTION: This is a development task. Use APEX methodology:
1244
1160
 
1245
- **TRACKING FILE**: [project]/.claude/apex/task.json (auto-created on first Write/Edit)
1161
+ **TRACKING FILE**: [project]/.claude/apex/task.json (created by the /apex command)
1246
1162
 
1247
1163
  1. **ANALYZE** (MANDATORY - 3 AGENTS IN PARALLEL):
1248
1164
  - explore-codebase + research-expert + ${expertAgent} (framework expertise)\n - Project type detected: ${projectType}\n\n2. **PLAN**: Use TaskCreate to break down tasks (<${maxLines} lines per file)\n\n3. **EXECUTE**: ${expertAgent}, follow SOLID principles, split at ${maxLines - 10} lines\n\n4. **EXAMINE**: Run sniper agent after ANY modification\n\n**IMPORTANT**: Read .claude/apex/task.json to check documentation status before writing code.`;
@@ -1560,4 +1476,4 @@ function missingSeoElements(html) {
1560
1476
  return missing;
1561
1477
  }
1562
1478
  //#endregion
1563
- export { MAX_EXA_RESULTS as A, detectFramework as B, resolveSkillPath as C, frameworkSolidGate as D, SKILL_TRIGGERS as E, brainstormGate as F, requiredArchSkill as G, detectModularArchitecture as H, docConsultedGate as I, evaluateApex as L, capVerbosity as M, detectCreationIntent as N, isExcludedJsPath as O, APEX_GATES as P, freshnessGate as R, usesTailwindUtilities as S, readPluginMeta as T, detectProjectType as U, DEV_KEYWORDS as V, isApexCommand as W, scanPlugin as _, firstHeading as a, detectRequiredSkills as b, EXCLUDE_DIRS as c, buildApexTaskInjection as d, loadApexTaskState as f, detectClaudeMdProjectType as g, buildClaudeMdContext as h, firstComment as i, MAX_TOKENS as j, isExcludedSwiftPath as k, PROJECT_INDICATORS as l, buildApexInstruction as m, missingSeoElements as n, parseEnrichment as o, DEV_VERBS as p, descFromText as r, parseEntry as s, isHtmlLike as t, buildApexTaskContext as u, parseBodyDesc as v, findMarketplacePlugins as w, skillTriggerGate as x, parseField as y, solidReadGate as z };
1479
+ export { detectCreationIntent as A, DEV_KEYWORDS as B, SKILL_TRIGGERS as C, MAX_EXA_RESULTS as D, isExcludedSwiftPath as E, docConsultedGate as F, detectProjectType as H, evaluateApex as I, freshnessGate as L, POST_AUTH_GATES as M, PRE_AUTH_GATES as N, MAX_TOKENS as O, brainstormGate as P, solidReadGate as R, usesTailwindUtilities as S, isExcludedJsPath as T, isApexCommand as U, detectModularArchitecture as V, requiredArchSkill as W, scanPlugin as _, firstHeading as a, detectRequiredSkills as b, EXCLUDE_DIRS as c, buildApexTaskInjection as d, loadApexTaskState as f, detectClaudeMdProjectType as g, buildClaudeMdContext as h, firstComment as i, APEX_GATES as j, capVerbosity as k, PROJECT_INDICATORS as l, buildApexInstruction as m, missingSeoElements as n, parseEnrichment as o, DEV_VERBS as p, descFromText as r, parseEntry as s, isHtmlLike as t, buildApexTaskContext as u, parseBodyDesc as v, frameworkSolidGate as w, skillTriggerGate as x, parseField as y, detectFramework as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fusengine/harness",
3
- "version": "0.1.48",
3
+ "version": "0.1.49",
4
4
  "description": "Harness-agnostic toolkit for AI coding agents: runtime harness detection (Claude Code, Codex, Cursor, Cline, Gemini, Aider...), pure policy core (env config, project/framework detection, SOLID/file-size limits, APEX freshness, guard patterns, portable prompts), cache, project memory, ref routing, state/locks, statusline, per-harness adapters (Claude/Cursor/Cline/Gemini) and a cli-mode harness-check binary. Bun-native, with a built dist for Node + bundlers.",
5
5
  "type": "module",
6
6
  "module": "src/index.ts",
@@ -108,6 +108,11 @@
108
108
  "bun": "./src/adapters/gemini/index.ts",
109
109
  "types": "./dist/adapters/gemini/index.d.mts",
110
110
  "import": "./dist/adapters/gemini/index.mjs"
111
+ },
112
+ "./adapters/hermes": {
113
+ "bun": "./src/adapters/hermes/index.ts",
114
+ "types": "./dist/adapters/hermes/index.d.mts",
115
+ "import": "./dist/adapters/hermes/index.mjs"
111
116
  }
112
117
  },
113
118
  "files": [
@@ -127,7 +132,7 @@
127
132
  "test": "bun test",
128
133
  "typecheck": "tsc --noEmit",
129
134
  "docs:api": "typedoc",
130
- "build": "tsdown src/index.ts src/config/index.ts src/util/index.ts src/detect/index.ts src/policy/index.ts src/prompt/index.ts src/memory/index.ts src/cache/index.ts src/freshness/index.ts src/refs/index.ts src/statusline/index.ts src/cli/index.ts src/cli/bin.ts src/init/index.ts src/tracking/index.ts src/runtime/index.ts src/adapters/claude/index.ts src/adapters/codex/index.ts src/adapters/cursor/index.ts src/adapters/cline/index.ts src/adapters/gemini/index.ts --dts --format esm --clean --out-dir dist",
135
+ "build": "tsdown src/index.ts src/config/index.ts src/util/index.ts src/detect/index.ts src/policy/index.ts src/prompt/index.ts src/memory/index.ts src/cache/index.ts src/freshness/index.ts src/refs/index.ts src/statusline/index.ts src/cli/index.ts src/cli/bin.ts src/init/index.ts src/tracking/index.ts src/runtime/index.ts src/adapters/claude/index.ts src/adapters/codex/index.ts src/adapters/cursor/index.ts src/adapters/cline/index.ts src/adapters/gemini/index.ts src/adapters/hermes/index.ts --dts --format esm --clean --out-dir dist",
131
136
  "prepublishOnly": "bun test && tsc --noEmit && bun run build"
132
137
  },
133
138
  "publishConfig": {