@fusengine/harness 0.1.80 → 0.1.81

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 (73) hide show
  1. package/dist/adapters/claude/index.d.mts +1 -1
  2. package/dist/adapters/claude/index.mjs +1 -1
  3. package/dist/adapters/cline/index.mjs +1 -1
  4. package/dist/adapters/codex/index.d.mts +11 -12
  5. package/dist/adapters/codex/index.mjs +3 -3
  6. package/dist/adapters/cursor/index.d.mts +29 -29
  7. package/dist/adapters/cursor/index.mjs +1 -1
  8. package/dist/adapters/gemini/index.mjs +1 -1
  9. package/dist/adapters/hermes/index.d.mts +22 -23
  10. package/dist/adapters/hermes/index.mjs +1 -1
  11. package/dist/adapters/kimi/index.d.mts +54 -46
  12. package/dist/adapters/kimi/index.mjs +2 -2
  13. package/dist/cache/index.d.mts +1 -1
  14. package/dist/{claude-51RSspJ1.mjs → claude-D93HY8dq.mjs} +2 -2
  15. package/dist/cli/bin.d.mts +1 -1
  16. package/dist/cli/bin.mjs +129 -38
  17. package/dist/cli/index.d.mts +3 -3
  18. package/dist/cli/index.mjs +1 -1
  19. package/dist/config/index.d.mts +2 -2
  20. package/dist/config/index.mjs +4 -4
  21. package/dist/detect/index.d.mts +2 -1
  22. package/dist/{doc-helpers-CEKzGg2u.d.mts → doc-helpers-BGCzP9VF.d.mts} +12 -12
  23. package/dist/{dotenv-B26N_uGr.mjs → dotenv-DoqNlALG.mjs} +29 -2
  24. package/dist/{evaluate-ClfbCDiY.mjs → evaluate-B4-MhOy_.mjs} +322 -32
  25. package/dist/freshness/index.d.mts +2 -2
  26. package/dist/{handle-B7YufFz9.mjs → handle-J5r2f_O_.mjs} +637 -159
  27. package/dist/{hermes-rTmVdhUL.mjs → hermes-1PUEoY8q.mjs} +2 -2
  28. package/dist/{home-state-BXf38Zi1.mjs → home-state-oUGFB4ds.mjs} +4 -2
  29. package/dist/index-84heAAm_.d.mts +12 -0
  30. package/dist/index-CaxrMD1A.d.mts +1 -0
  31. package/dist/index-CigBu4K9.d.mts +14 -0
  32. package/dist/{index-DGxNjXW7.d.mts → index-D4C2-IIc.d.mts} +6 -8
  33. package/dist/index-DBgnIZn9.d.mts +756 -0
  34. package/dist/{index-mmRF3KNp.d.mts → index-DEfz82BP.d.mts} +12 -13
  35. package/dist/{index-BUwEmIK-.d.mts → index-DhmV2MPh.d.mts} +31 -31
  36. package/dist/index-oXXPs2xl.d.mts +119 -0
  37. package/dist/index-p9fLrSOD.d.mts +27 -0
  38. package/dist/index.d.mts +11 -11
  39. package/dist/index.mjs +8 -8
  40. package/dist/init/index.d.mts +8 -9
  41. package/dist/init/index.mjs +1 -1
  42. package/dist/{kimi-C7FGprQz.mjs → kimi-ChbcU2uI.mjs} +17 -3
  43. package/dist/{layout-C0jaaCQC.mjs → layout-KWoE_Mqn.mjs} +1 -0
  44. package/dist/limits-DG5pl-5s.mjs +59 -0
  45. package/dist/memory/index.mjs +1 -1
  46. package/dist/policy/index.d.mts +2 -3
  47. package/dist/policy/index.mjs +4 -4
  48. package/dist/prompt/index.d.mts +2 -1
  49. package/dist/refs/index.d.mts +1 -1
  50. package/dist/{registry-CymilZiZ.mjs → registry-IhHk2KlT.mjs} +1 -1
  51. package/dist/{run-3tyTW9Ud.mjs → run-D_WchZ3R.mjs} +1 -1
  52. package/dist/{run-Do2JltgU.mjs → run-TAXNRSpD.mjs} +1 -1
  53. package/dist/runtime/index.d.mts +453 -451
  54. package/dist/runtime/index.mjs +3 -3
  55. package/dist/{session-state-CY4iohn_.d.mts → session-state-D5gLr66m.d.mts} +29 -30
  56. package/dist/{skill-path-CDvR4bbF.mjs → skill-path-DhBGlQe3.mjs} +2 -2
  57. package/dist/{store-nDZ3eLBQ.mjs → store-yr_QFsZY.mjs} +101 -13
  58. package/dist/tracking/index.d.mts +22 -13
  59. package/dist/tracking/index.mjs +2 -2
  60. package/dist/{types-DVbIl9md.d.mts → types-C0-igRKr.d.mts} +8 -8
  61. package/dist/types-DvXTI71X.d.mts +15 -0
  62. package/dist/util/index.d.mts +1 -1
  63. package/dist/{validate-CkmquRDV.mjs → validate-Bf-EsbDh.mjs} +279 -20
  64. package/package.json +163 -163
  65. package/dist/apex-Wdi1nq_w.d.mts +0 -73
  66. package/dist/harness-BQ5_Wnva.d.mts +0 -27
  67. package/dist/index-BEMumjOw.d.mts +0 -27
  68. package/dist/index-BIGVNQB8.d.mts +0 -576
  69. package/dist/index-BKZ67WMa.d.mts +0 -1
  70. package/dist/index-BOBXQ91y.d.mts +0 -12
  71. package/dist/index-BVVgDSdq.d.mts +0 -1
  72. package/dist/index-B_VHbsaJ.d.mts +0 -96
  73. package/dist/limits-CHn8AIL1.mjs +0 -31
@@ -1,24 +1,24 @@
1
- import { a as parseEnvInt, i as splitTarget, r as resolveMaxLines } from "./limits-CHn8AIL1.mjs";
2
- import { s as resolveTtlSec } from "./dotenv-B26N_uGr.mjs";
3
- import { r as projectLayout } from "./layout-C0jaaCQC.mjs";
1
+ import { a as resolveMaxLines, l as parseEnvInt, s as splitTarget } from "./limits-DG5pl-5s.mjs";
2
+ import { s as resolveTtlSec } from "./dotenv-DoqNlALG.mjs";
3
+ import { r as projectLayout } from "./layout-KWoE_Mqn.mjs";
4
4
  import { i as walkUpFor, n as projectRoot, r as projectRootOrNull, t as isCodeFile } from "./project-root-3kk7gCOp.mjs";
5
- import { B as detectProjectType$1, H as requiredArchSkill, L as detectFramework, n as FAIL_CLOSED, t as evaluate, z as detectModularArchitecture } from "./evaluate-ClfbCDiY.mjs";
6
- import { a as sanitizeSessionId, c as sessionsDir, d as countFrameworkCodeLines, f as countLines, i as loadSessionState, l as PLUGINS_DIR, n as fuseHarnessHome, o as saveSessionState, r as fusengineCache, t as claudeHome } from "./home-state-BXf38Zi1.mjs";
5
+ import { D as isVendorPath, G as isFileSizeScoped, K as resolveSolidRefFramework, W as computeEditResultLines, b as interfaceDeclLevel, et as detectFramework, g as conventionsMode, lt as requiredArchSkill, n as FAIL_CLOSED, ot as detectModularArchitecture, rt as maskCommentsAndStrings, st as detectProjectType$1, t as evaluate } from "./evaluate-B4-MhOy_.mjs";
6
+ import { a as sanitizeSessionId, c as sessionsDir, d as countFrameworkCodeLines, f as countLines, i as loadSessionState, l as PLUGINS_DIR, n as fuseHarnessHome, o as saveSessionState, p as evaluateFileSize, r as fusengineCache, t as claudeHome } from "./home-state-oUGFB4ds.mjs";
7
7
  import { a as writeJsonFile, i as readJsonFile, r as hashText, t as atomicWrite } from "./json-io-DisYd2fb.mjs";
8
8
  import { r as isDocConsulted } from "./doc-helpers-CWZegVdR.mjs";
9
- import { D as frameworkSolidGate, F as POST_AUTH_GATES, I as PRE_AUTH_GATES, M as capVerbosity, N as detectCreationIntent, O as isExcludedJsPath, R as docConsultedGate, S as parseField, T as usesTailwindUtilities, b as scanPlugin, c as EXCLUDE_DIRS$1, d as buildApexTaskInjection, g as buildClaudeMdContext, k as isExcludedSwiftPath, l as PROJECT_INDICATORS, n as missingSeoElements, o as parseEnrichment, p as apexAgentSteps, r as descFromText, s as parseEntry, t as isHtmlLike, v as apexDocName, w as skillTriggerGate, y as harnessHomeSegment, z as evaluateApex } from "./validate-CkmquRDV.mjs";
10
- import { n as findMarketplacePlugins, r as readPluginMeta, t as resolveSkillPath } from "./skill-path-CDvR4bbF.mjs";
9
+ import { D as frameworkSolidGate, F as isExcludedSwiftPath, G as evaluateApex, H as PRE_AUTH_GATES, P as isExcludedJsPath, R as capVerbosity, S as parseField, T as usesTailwindUtilities, V as POST_AUTH_GATES, W as docConsultedGate, b as scanPlugin, c as EXCLUDE_DIRS$1, d as buildApexTaskInjection, g as buildClaudeMdContext, l as PROJECT_INDICATORS, n as missingSeoElements, o as parseEnrichment, p as apexAgentSteps, r as descFromText, s as parseEntry, t as isHtmlLike, v as apexDocName, w as skillTriggerGate, y as harnessHomeSegment, z as detectCreationIntent } from "./validate-Bf-EsbDh.mjs";
10
+ import { n as findMarketplacePlugins, r as readPluginMeta, t as resolveSkillPath } from "./skill-path-DhBGlQe3.mjs";
11
11
  import { t as formatPrompt } from "./types-ernB1Dy3.mjs";
12
- import { a as nowStamp, l as throttleMs, n as readRoots, o as readState, s as setStateField, t as addRoot } from "./registry-CymilZiZ.mjs";
12
+ import { a as nowStamp, l as throttleMs, n as readRoots, o as readState, s as setStateField, t as addRoot } from "./registry-IhHk2KlT.mjs";
13
13
  import { d as loadIndex, i as cacheLookupMeta, n as webfetchCacheWrite, o as cacheLookupSubstringMeta, t as mcpCacheWrite, u as extractText } from "./mcp-store-BkBDmuxN.mjs";
14
14
  import { t as loadRefs } from "./loader-AGz4nK7d.mjs";
15
- import { a as writeLastNonce, c as recordAgent, d as recordRefRead, f as recordTarget, h as apexAuthorizationGate, i as verifyTrack, l as recordBrainstormRequired, m as trivialCount, n as saveTrack, o as agentsFresh, p as recordTrivialEdit, r as signTrack, s as emptyTrack, t as loadTrack, u as recordDoc } from "./store-nDZ3eLBQ.mjs";
15
+ import { _ as trivialCount, a as LOCK_TTL_MS, c as writeLastNonce, d as recordAgent, f as recordBrainstormRequired, g as recordTrivialEdit, h as recordTarget, i as LOCK_FAILED, l as agentsFresh, m as recordRefRead, o as signTrack, p as recordDoc, r as withTrack, s as verifyTrack, t as loadTrack, u as emptyTrack, v as apexAuthorizationGate } from "./store-yr_QFsZY.mjs";
16
16
  import { n as parseApplyPatch, t as isBypassPermissions } from "./permission-mode-BN3MNgbm.mjs";
17
- import { _ as writeText, d as collectFiles, f as pathExists, g as spawnCapture, h as sleep, i as denyResponse, l as systemMessage, m as readText, n as blockResponse, r as contextResponse, s as informResponse, t as attachSystemMessage, v as commandToString } from "./claude-51RSspJ1.mjs";
18
- import { r as toHermesResponse } from "./hermes-rTmVdhUL.mjs";
19
- import { r as toKimiResponse } from "./kimi-C7FGprQz.mjs";
17
+ import { _ as commandToString, d as collectFiles, f as pathExists, g as writeText, h as spawnCapture, i as denyResponse, l as systemMessage, m as sleep, n as blockResponse, p as readText$1, r as contextResponse, s as informResponse, t as attachSystemMessage } from "./claude-D93HY8dq.mjs";
18
+ import { i as toKimiResponse, n as kimiDenyResponse } from "./kimi-ChbcU2uI.mjs";
19
+ import { r as toHermesResponse } from "./hermes-1PUEoY8q.mjs";
20
20
  import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
21
- import { appendFileSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
21
+ import { appendFileSync, closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
22
22
  import { homedir } from "node:os";
23
23
  import { mkdir, rmdir } from "node:fs/promises";
24
24
  import { createHash } from "node:crypto";
@@ -351,6 +351,34 @@ function designLifecycle(payload, cacheDir, cwd, stamp, now) {
351
351
  return false;
352
352
  }
353
353
  //#endregion
354
+ //#region src/runtime/inform.ts
355
+ /**
356
+ * Lifecycle context-injection renderer. Every emitter that injects text into
357
+ * the model's context (rules, lessons, CLAUDE.md preamble, ...) goes through
358
+ * here instead of calling the Claude builders directly:
359
+ * - kimi: raw text on stdout — Kimi Code CLI appends stdout to the context on
360
+ * exit 0 (UserPromptSubmit is blockable; the Claude-shaped
361
+ * `additionalContext` envelope would be ignored or leak as raw JSON text).
362
+ * - every other harness: the historical Claude-shaped envelope, byte-identical
363
+ * to what the call sites produced before this module existed (zero-regression
364
+ * default — `attachSystemMessage` concatenates notices with "\n", so a single
365
+ * combined notice equals the previous chained calls).
366
+ */
367
+ /**
368
+ * Render a context injection for the firing harness.
369
+ * @param id - Harness id.
370
+ * @param event - The firing hook event name (envelope tag for Claude shapes).
371
+ * @param text - The context body to inject.
372
+ * @param notice - Optional human-facing notice (systemMessage on Claude
373
+ * shapes; appended as plain text for kimi).
374
+ * @returns The native hook stdout.
375
+ */
376
+ function renderInform(id, event, text, notice) {
377
+ if (id === "kimi") return notice ? `${text}\n\n${notice}` : text;
378
+ const base = contextResponse(event, text);
379
+ return notice ? attachSystemMessage(base, notice) : base;
380
+ }
381
+ //#endregion
354
382
  //#region src/runtime/inject-dedup-exclusive.ts
355
383
  /**
356
384
  * @module inject-dedup-exclusive
@@ -599,7 +627,7 @@ function promptSubmitContext(prompt, cwd, id = "claude-code") {
599
627
  const ctx = buildClaudeMdContext(prompt, cwd, id);
600
628
  if (!ctx) return "";
601
629
  if (!oncePerWindow(claudeMdKey(prompt, ctx), 3e3)) return "";
602
- return attachSystemMessage(contextResponse("UserPromptSubmit", ctx), `${apexDocName(id)} injected`);
630
+ return renderInform(id, "UserPromptSubmit", ctx, `${apexDocName(id)} injected`);
603
631
  }
604
632
  /**
605
633
  * PreToolUse Task context injection: render the APEX sub-agent context as a
@@ -801,17 +829,19 @@ function readRules(rulesDir) {
801
829
  }
802
830
  /**
803
831
  * Build the rules injection for claude-rules (SessionStart, UserPromptSubmit,
804
- * SubagentStart): read `<pluginRoot>/rules/*.md` and emit as `additionalContext`,
805
- * or "" when no rules. Tags the output with the *actual* `hookEventName` — the
806
- * spec requires it to match the firing event (a hardcoded "SessionStart" is
807
- * non-conforming and may be dropped on UserPromptSubmit/SubagentStart).
832
+ * SubagentStart): read `<pluginRoot>/rules/*.md` and emit via {@link renderInform}
833
+ * (Claude-shaped `additionalContext`, or raw text for kimi). Tags the output
834
+ * with the *actual* `hookEventName` — the spec requires it to match the firing
835
+ * event (a hardcoded "SessionStart" is non-conforming and may be dropped on
836
+ * UserPromptSubmit/SubagentStart).
808
837
  * @param pluginRoot - `CLAUDE_PLUGIN_ROOT` of the claude-rules plugin.
809
838
  * @param event - The firing hook event name (e.g. "SessionStart").
839
+ * @param id - Harness target id (defaults to "claude-code" — zero-regression default).
810
840
  * @returns The native hook stdout (possibly empty).
811
841
  */
812
- function injectRules(pluginRoot, event) {
842
+ function injectRules(pluginRoot, event, id = "claude-code") {
813
843
  const content = readRules(join(pluginRoot, "rules"));
814
- return content ? attachSystemMessage(contextResponse(event, content), "rules 00-08 injected") : "";
844
+ return content ? renderInform(id, event, content, "rules 00-08 injected") : "";
815
845
  }
816
846
  //#endregion
817
847
  //#region src/runtime/lifecycle/solid-detect.ts
@@ -960,7 +990,7 @@ function checkFileSize(tool, filePath, env = process.env) {
960
990
  if (!filePath || !pathExists(filePath)) return "";
961
991
  let content;
962
992
  try {
963
- content = readText(filePath);
993
+ content = readText$1(filePath);
964
994
  } catch {
965
995
  return "";
966
996
  }
@@ -982,20 +1012,32 @@ function checkFileSize(tool, filePath, env = process.env) {
982
1012
  * gated behind `SOLID_PROJECT_TYPE`. Inert when `SOLID_PROJECT_TYPE` is
983
1013
  * absent/"unknown"/nextjs/laravel/swift/rust (parity: the Python hook only
984
1014
  * has a `go`/`python` validator).
1015
+ *
1016
+ * Detection rides the conventions module (MASKED content, column-0 anchored
1017
+ * for Go): the old naive regexes matched inside comments/strings and
1018
+ * indented locals — all killed. LEGACY-level detections keep the exact hard
1019
+ * deny (byte-parity); EXTENDED-level ones (Go `interface{` without the
1020
+ * space, Python `Protocol`) follow `FUSE_CONVENTIONS_MODE` — advisory
1021
+ * (non-blocking additionalContext) by default, deny only on `"deny"`
1022
+ * (owner Amendment 5, external audit D0.2).
985
1023
  */
986
- /** Top-level Go interface declaration, e.g. `type Foo interface {`. */
987
- const GO_INTERFACE_RE = /^type.*interface \{/m;
988
- /** Python class whose header mentions `ABC` (naive, parity with the Python regex). */
989
- const PY_ABC_RE = /class.*ABC/;
990
1024
  /** Go: deny a top-level interface declared outside `/interfaces/`. */
991
- function checkGo(filePath, content) {
1025
+ function checkGo(filePath, content, env) {
992
1026
  if (!filePath.endsWith(".go") || filePath.includes("/interfaces/")) return null;
993
- return GO_INTERFACE_RE.test(content) ? "SOLID: Interfaces must be in internal/interfaces/" : null;
1027
+ const level = interfaceDeclLevel(filePath, content);
1028
+ if (level === null) return null;
1029
+ const msg = "SOLID: Interfaces must be in internal/interfaces/";
1030
+ if (level === "legacy" || conventionsMode(env) === "deny") return denyResponse("PreToolUse", msg);
1031
+ return contextResponse("PreToolUse", msg);
994
1032
  }
995
1033
  /** Python: deny an ABC subclass declared outside `/interfaces/`. */
996
- function checkPython(filePath, content) {
1034
+ function checkPython(filePath, content, env) {
997
1035
  if (!filePath.endsWith(".py") || filePath.includes("/interfaces/")) return null;
998
- return PY_ABC_RE.test(content) ? "SOLID: Abstract classes must be in src/interfaces/" : null;
1036
+ const level = interfaceDeclLevel(filePath, content);
1037
+ if (level === null) return null;
1038
+ const msg = "SOLID: Abstract classes must be in src/interfaces/";
1039
+ if (level === "legacy" || conventionsMode(env) === "deny") return denyResponse("PreToolUse", msg);
1040
+ return contextResponse("PreToolUse", msg);
999
1041
  }
1000
1042
  /** `SOLID_PROJECT_TYPE` values this gate validates (parity subset — see module doc). */
1001
1043
  const VALIDATORS = {
@@ -1003,20 +1045,19 @@ const VALIDATORS = {
1003
1045
  python: checkPython
1004
1046
  };
1005
1047
  /**
1006
- * Deny a Write/Edit that violates the Go/Python interface-location SOLID rule.
1048
+ * Deny (or advise on) a Write/Edit that violates the Go/Python interface-location SOLID rule.
1007
1049
  * @param tool - The tool name (only "Write"/"Edit" are checked).
1008
1050
  * @param filePath - The written file's absolute path.
1009
1051
  * @param content - The written/edited content (`new_string` snippet on Edit).
1010
1052
  * @param env - Environment (defaults to `process.env`).
1011
- * @returns The PreToolUse deny response, or `""` when clean/inert.
1053
+ * @returns The PreToolUse response, or `""` when clean/inert.
1012
1054
  */
1013
1055
  function validateSolidGate(tool, filePath, content, env = process.env) {
1014
1056
  const ptype = env.SOLID_PROJECT_TYPE ?? "";
1015
1057
  if (!ptype || ptype === "unknown") return "";
1016
1058
  if (tool !== "Write" && tool !== "Edit") return "";
1017
1059
  if (!filePath) return "";
1018
- const violation = VALIDATORS[ptype]?.(filePath, content) ?? null;
1019
- return violation ? denyResponse("PreToolUse", violation) : "";
1060
+ return VALIDATORS[ptype]?.(filePath, content, env) ?? "";
1020
1061
  }
1021
1062
  //#endregion
1022
1063
  //#region src/runtime/lifecycle/validate-tailwind.ts
@@ -1052,7 +1093,7 @@ function isTailwindProject(filePath) {
1052
1093
  const pkgPath = join(root, "package.json");
1053
1094
  if (!pathExists(pkgPath)) return false;
1054
1095
  try {
1055
- const pkg = JSON.parse(readText(pkgPath));
1096
+ const pkg = JSON.parse(readText$1(pkgPath));
1056
1097
  return Boolean(pkg.dependencies?.tailwindcss) || Boolean(pkg.devDependencies?.tailwindcss);
1057
1098
  } catch {
1058
1099
  return false;
@@ -1070,7 +1111,7 @@ function validateTailwind(tool, filePath) {
1070
1111
  if (!pathExists(filePath) || !isTailwindProject(filePath)) return "";
1071
1112
  let content;
1072
1113
  try {
1073
- content = readText(filePath);
1114
+ content = readText$1(filePath);
1074
1115
  } catch {
1075
1116
  return "";
1076
1117
  }
@@ -1227,7 +1268,7 @@ function freshReceiptFromFile(file, windowMs, now) {
1227
1268
  async function captureReceipt(file, command, output, exitCode, now) {
1228
1269
  const receipt = classifyReceipt(command, output, exitCode, now);
1229
1270
  if (!receipt) return;
1230
- await saveTrack(file, recordReceipt(await loadTrack(file), receipt));
1271
+ await withTrack(file, (track) => recordReceipt(track, receipt));
1231
1272
  }
1232
1273
  //#endregion
1233
1274
  //#region src/runtime/lifecycle/agent-transcript.ts
@@ -1259,7 +1300,7 @@ function readAgentToolUses(transcriptPath) {
1259
1300
  if (!transcriptPath) return null;
1260
1301
  let text;
1261
1302
  try {
1262
- text = readText(transcriptPath);
1303
+ text = readText$1(transcriptPath);
1263
1304
  } catch {
1264
1305
  return null;
1265
1306
  }
@@ -1713,6 +1754,102 @@ function postEditTypescript(filePath) {
1713
1754
  return contextResponse("PostToolUse", `Lint issues in ${basename(filePath)}: ${issues.join(" | ")}`);
1714
1755
  }
1715
1756
  //#endregion
1757
+ //#region src/util/semver.ts
1758
+ /**
1759
+ * Dependency-free semver ordering. `Array.prototype.sort()` compares strings
1760
+ * lexicographically — `"1.0.9" > "1.0.23"` — so versioned directories (plugin
1761
+ * caches) must be ordered with a numeric, segment-by-segment comparison.
1762
+ */
1763
+ /**
1764
+ * Compare two dotted version strings numerically, segment by segment
1765
+ * (shorter version wins when a prefix is equal: `1.0` < `1.0.1`). A segment
1766
+ * that is not a plain integer (prerelease tag like `0-beta`) falls back to
1767
+ * lexicographic comparison of that segment — never NaN, so `sort()` stays
1768
+ * defined; identical strings compare 0.
1769
+ * @returns Negative when `a < b`, positive when `a > b`, 0 when equal.
1770
+ */
1771
+ function compareSemver(a, b) {
1772
+ const pa = a.split("."), pb = b.split(".");
1773
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
1774
+ const sa = pa[i] ?? "0", sb = pb[i] ?? "0";
1775
+ if (sa === sb) continue;
1776
+ const na = Number(sa), nb = Number(sb);
1777
+ if (Number.isInteger(na) && Number.isInteger(nb) && na !== nb) return na - nb;
1778
+ return sa < sb ? -1 : 1;
1779
+ }
1780
+ return 0;
1781
+ }
1782
+ /** The highest version of the list, or null when empty. */
1783
+ function maxSemver(versions) {
1784
+ return versions.length === 0 ? null : versions.reduce((a, b) => compareSemver(a, b) >= 0 ? a : b);
1785
+ }
1786
+ //#endregion
1787
+ //#region src/runtime/lifecycle/rules-root.ts
1788
+ /**
1789
+ * Dynamic rules-plugin root resolution. The historical `CLAUDE_PLUGIN_ROOT ??
1790
+ * cwd` chain only worked when the harness exported the plugin root — Kimi
1791
+ * injects `KIMI_PLUGIN_ROOT` instead, and a bare cwd fallback never held a
1792
+ * `rules/` dir. Resolution order (first hit wins):
1793
+ * 1. `CLAUDE_PLUGIN_ROOT` (claude-code/codex plugin-declared hooks);
1794
+ * 2. `KIMI_PLUGIN_ROOT` (kimi plugin-declared hooks);
1795
+ * 3. Per-harness install probe (claude marketplace, codex versioned cache,
1796
+ * kimi managed plugins) — first `<plugin>/rules` dir whose plugin folder
1797
+ * name contains "rules";
1798
+ * 4. `cwd` (historical fallback, unchanged behavior).
1799
+ */
1800
+ /** Immediate child dir names of `dir`, or [] when unreadable. */
1801
+ function children$1(dir) {
1802
+ try {
1803
+ return readdirSync(dir);
1804
+ } catch {
1805
+ return [];
1806
+ }
1807
+ }
1808
+ /** First existing `<base>/<child>/rules` where `child` names a rules plugin. */
1809
+ function probe(base, childFilter) {
1810
+ for (const child of children$1(base).sort()) {
1811
+ if (!childFilter(child)) continue;
1812
+ if (existsSync(join(base, child, "rules"))) return join(base, child);
1813
+ }
1814
+ return null;
1815
+ }
1816
+ /** Claude: `~/.claude/plugins/marketplaces/<marketplace>/plugins/<rules-plugin>/`. */
1817
+ function probeClaude(home) {
1818
+ const markets = join(home, ".claude", "plugins", "marketplaces");
1819
+ for (const m of children$1(markets).sort()) {
1820
+ const hit = probe(join(markets, m, "plugins"), (n) => n.includes("rules"));
1821
+ if (hit) return hit;
1822
+ }
1823
+ return null;
1824
+ }
1825
+ /** Codex: `~/.codex/plugins/cache/<marketplace>/<rules-plugin>/<version>/` (highest semver). */
1826
+ function probeCodex(home) {
1827
+ const cache = join(home, ".codex", "plugins", "cache");
1828
+ for (const m of children$1(cache).sort()) for (const p of children$1(join(cache, m)).sort()) {
1829
+ if (!p.includes("rules")) continue;
1830
+ const latest = maxSemver(children$1(join(cache, m, p)).filter((v) => existsSync(join(cache, m, p, v, "rules"))));
1831
+ if (latest) return join(cache, m, p, latest);
1832
+ }
1833
+ return null;
1834
+ }
1835
+ /** Kimi: `<KIMI_CODE_HOME>/plugins/managed/<rules-plugin>/` (managed install copy). */
1836
+ function probeKimi(env, home) {
1837
+ return probe(join(env.KIMI_CODE_HOME ?? join(home, ".kimi-code"), "plugins", "managed"), (n) => n.includes("rules"));
1838
+ }
1839
+ /**
1840
+ * Resolve the directory whose `rules/` subtree holds the `*.md` rule files.
1841
+ * @param id - Harness id (selects the install-layout probe).
1842
+ * @param cwd - Historical fallback root.
1843
+ * @param env - Environment (defaults to `process.env`).
1844
+ * @returns The plugin root to read `rules/` from.
1845
+ */
1846
+ function resolveRulesRoot(id, cwd, env = process.env) {
1847
+ if (env.CLAUDE_PLUGIN_ROOT) return env.CLAUDE_PLUGIN_ROOT;
1848
+ if (env.KIMI_PLUGIN_ROOT) return env.KIMI_PLUGIN_ROOT;
1849
+ const home = env.HOME ?? homedir();
1850
+ return (id === "codex" ? probeCodex(home) : id === "kimi" ? probeKimi(env, home) : id === "claude-code" ? probeClaude(home) : null) ?? cwd;
1851
+ }
1852
+ //#endregion
1716
1853
  //#region src/freshness/explore-tools.ts
1717
1854
  /** Native exploration tools (parity `apex_constants.EXPLORE_TOOLS`). */
1718
1855
  const EXPLORE_TOOLS = /* @__PURE__ */ new Set(["Glob", "Grep"]);
@@ -1809,6 +1946,29 @@ function classifyExplore(tool, input) {
1809
1946
  return null;
1810
1947
  }
1811
1948
  //#endregion
1949
+ //#region src/runtime/is-agent-tool.ts
1950
+ /**
1951
+ * Sub-agent dispatch tool detection — the single source of truth for "this
1952
+ * tool call spawns a sub-agent". Claude Code and Codex dispatch via `Task`;
1953
+ * Kimi Code CLI dispatches via `Agent` (and `AgentSwarm` for parallel fan-out).
1954
+ * Centralized so the APEX task-context injection, the activity tracking and
1955
+ * the PostToolUse evidence credit stop drifting on inline `"Task" | "Agent"`
1956
+ * unions: every consumer imports from HERE, never re-declares the list.
1957
+ */
1958
+ /** Canonical set of sub-agent dispatch tool names (the ONLY definition). */
1959
+ const AGENT_TOOLS = /* @__PURE__ */ new Set([
1960
+ "Task",
1961
+ "Agent",
1962
+ "AgentSwarm"
1963
+ ]);
1964
+ /**
1965
+ * True when the tool is a sub-agent dispatch primitive on any harness.
1966
+ * @param tool - The normalized tool name from the hook payload.
1967
+ */
1968
+ function isAgentTool(tool) {
1969
+ return AGENT_TOOLS.has(tool);
1970
+ }
1971
+ //#endregion
1812
1972
  //#region src/freshness/agent-evidence-record.ts
1813
1973
  /**
1814
1974
  * Session-track agent evidence — writer (parity track-subagent-research.py) +
@@ -1818,17 +1978,6 @@ function classifyExplore(tool, input) {
1818
1978
  * Workflow-spawned agents count, unlike the lead-transcript scan.
1819
1979
  */
1820
1980
  /**
1821
- * Tools the existing Task tracking already credits as agent LAUNCHES.
1822
- * `AgentSwarm` is Kimi Code's batch launcher (`prompt_template` + `items`,
1823
- * one `subagent_type` applied to every spawned sub-agent) — same launch
1824
- * semantics as `Task`/`Agent`, so it is excluded here too (anti-double-count).
1825
- */
1826
- const AGENT_LAUNCH_TOOLS = /* @__PURE__ */ new Set([
1827
- "Task",
1828
- "Agent",
1829
- "AgentSwarm"
1830
- ]);
1831
- /**
1832
1981
  * `JSON.stringify` length of the raw `tool_response` OBJECT (parity Python
1833
1982
  * `len(str(tool_response))`); 0 when absent or unserializable (circular).
1834
1983
  */
@@ -1853,7 +2002,7 @@ function responseJsonLength(toolResponse) {
1853
2002
  * @returns The evidence to record, or null to skip.
1854
2003
  */
1855
2004
  function classifyAgentEvidence(tool, input, toolResponse) {
1856
- if (AGENT_LAUNCH_TOOLS.has(tool)) return null;
2005
+ if (isAgentTool(tool)) return null;
1857
2006
  const hit = classifyExplore(tool, input);
1858
2007
  if (!hit) return null;
1859
2008
  return {
@@ -1873,16 +2022,18 @@ function classifyAgentEvidence(tool, input, toolResponse) {
1873
2022
  * @param agentId - Optional Claude `agent_id` — metadata tag only.
1874
2023
  */
1875
2024
  async function recordAgentEvidence(file, evidence, ts, agentId) {
1876
- const next = recordAgent(await loadTrack(file), evidence.name, ts, evidence.quality);
1877
- const last = next.agents[next.agents.length - 1];
1878
- if (agentId && last) {
1879
- const tagged = {
1880
- ...last,
1881
- agentId
1882
- };
1883
- next.agents[next.agents.length - 1] = tagged;
1884
- }
1885
- await saveTrack(file, next);
2025
+ await withTrack(file, (track) => {
2026
+ const next = recordAgent(track, evidence.name, ts, evidence.quality);
2027
+ const last = next.agents[next.agents.length - 1];
2028
+ if (agentId && last) {
2029
+ const tagged = {
2030
+ ...last,
2031
+ agentId
2032
+ };
2033
+ next.agents[next.agents.length - 1] = tagged;
2034
+ }
2035
+ return next;
2036
+ });
1886
2037
  }
1887
2038
  /**
1888
2039
  * Parity `_scan_agents` (apex_agent_helpers.py): reverse-scan `track.agents`,
@@ -1964,6 +2115,62 @@ function harvestAgentEvidence(transcriptPath, track, now) {
1964
2115
  return uses.reduce((acc, u) => applyToolUse(acc, u, now), track);
1965
2116
  }
1966
2117
  //#endregion
2118
+ //#region src/tracking/track-lock-sync.ts
2119
+ /**
2120
+ * Synchronous twin of {@link withTrackLock} for lifecycle paths that CANNOT
2121
+ * float async work (the SubagentStop dispatch runs synchronously in a
2122
+ * short-lived hook process — an async write could be dropped before exit,
2123
+ * see `evidence-harvest-io.ts`). Same semantics: `wx` lockfile, bounded total
2124
+ * retry, named skipped write on contention, stale reclamation. The wait uses
2125
+ * `Atomics.wait` (allowed on the main thread in both Node and Bun).
2126
+ */
2127
+ /** Block the thread for `ms` (bounded by the caller's total budget). */
2128
+ function sleepSync(ms) {
2129
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
2130
+ }
2131
+ /** True when the lockfile is older than the TTL (orphaned by a dead process). */
2132
+ function isStale(lock) {
2133
+ try {
2134
+ return Date.now() - statSync(lock).mtimeMs > LOCK_TTL_MS;
2135
+ } catch {
2136
+ return false;
2137
+ }
2138
+ }
2139
+ /**
2140
+ * Run `fn` under the track lock of `dir`, synchronously.
2141
+ * @param dir - The track state directory (lockfile lives inside).
2142
+ * @param fn - The protected read-modify-write.
2143
+ * @returns `fn`'s result, or {@link LOCK_FAILED} when the lock stayed busy.
2144
+ */
2145
+ function withTrackLockSync(dir, fn) {
2146
+ mkdirSync(dir, { recursive: true });
2147
+ const lock = join(dir, "track.lock");
2148
+ const deadline = Date.now() + 400;
2149
+ for (;;) try {
2150
+ closeSync(openSync(lock, "wx"));
2151
+ break;
2152
+ } catch {
2153
+ if (isStale(lock)) {
2154
+ try {
2155
+ unlinkSync(lock);
2156
+ } catch {}
2157
+ continue;
2158
+ }
2159
+ if (Date.now() >= deadline) {
2160
+ process.stderr.write(`harness: track lock busy, write skipped (${lock})\n`);
2161
+ return LOCK_FAILED;
2162
+ }
2163
+ sleepSync(8);
2164
+ }
2165
+ try {
2166
+ return fn();
2167
+ } finally {
2168
+ try {
2169
+ unlinkSync(lock);
2170
+ } catch {}
2171
+ }
2172
+ }
2173
+ //#endregion
1967
2174
  //#region src/freshness/evidence-harvest-io.ts
1968
2175
  /**
1969
2176
  * SubagentStop wiring for {@link harvestAgentEvidence}. The core SubagentStop
@@ -1997,14 +2204,16 @@ function harvestSubagentTrack(payload, cwd, now, baseDir = defaultStateDir(cwd))
1997
2204
  const transcriptPath = typeof payload.agent_transcript_path === "string" ? payload.agent_transcript_path : void 0;
1998
2205
  if (!transcriptPath) return;
1999
2206
  const file = trackFile(typeof payload.session_id === "string" ? payload.session_id : "unknown", baseDir);
2000
- const track = loadTrackSync(file);
2001
- const next = harvestAgentEvidence(transcriptPath, track, now);
2002
- if (next === track) return;
2003
- try {
2004
- const envelope = signTrack(next);
2005
- atomicWrite(file, JSON.stringify(envelope, null, 2));
2006
- writeLastNonce(envelope.nonce);
2007
- } catch {}
2207
+ if (withTrackLockSync(dirname(file), () => {
2208
+ const track = loadTrackSync(file);
2209
+ const next = harvestAgentEvidence(transcriptPath, track, now);
2210
+ if (next === track) return;
2211
+ try {
2212
+ const envelope = signTrack(next);
2213
+ atomicWrite(file, JSON.stringify(envelope, null, 2));
2214
+ writeLastNonce(envelope.nonce);
2215
+ } catch {}
2216
+ }) === LOCK_FAILED) return;
2008
2217
  }
2009
2218
  //#endregion
2010
2219
  //#region src/cli/doctor.ts
@@ -2558,8 +2767,8 @@ function formatSummary(s) {
2558
2767
  * The proprietary rule "NEVER propose the same fix twice" is prose a model under
2559
2768
  * pressure ignores. This makes it machine-enforced: when a call whose
2560
2769
  * `(tool + normalized input)` hash was ALREADY denied in-window is retried, the
2561
- * harness keeps the deny but rewrites the message — `[REPEAT]` title, STOP
2562
- * prefix, forced `research-expert` action. State + wiring live in the sidecar
2770
+ * harness keeps the deny but rewrites the message — `[REPEAT]` title, a
2771
+ * mid-sentence STOP warning, forced `research-expert` action. State + wiring live in the sidecar
2563
2772
  * store ({@link module:deny-loop-store}); this file is IO-free and pure.
2564
2773
  * @packageDocumentation
2565
2774
  */
@@ -2624,7 +2833,7 @@ function denyLoopCheck(hash, priorDenies, opts) {
2624
2833
  * @returns A block prompt with `[REPEAT]` title, STOP-prefixed reason, forced research action.
2625
2834
  */
2626
2835
  function enrichRepeatDeny(prompt, count) {
2627
- const stop = `Identical attempt #${count} already denied for the same reason. STOP: do not retry this same call. `;
2836
+ const stop = `Identical attempt #${count} already denied for the same reason. Do not retry this same call UNCHANGED — if you have since completed the required reads/actions, retry now; otherwise STOP and pick a DIFFERENT approach. `;
2628
2837
  const action = "Launch fuse-ai-pilot:research-expert to find a DIFFERENT approach";
2629
2838
  return {
2630
2839
  ...prompt,
@@ -4002,7 +4211,7 @@ function persistCuration(file, root, curated, archive, original) {
4002
4211
  * was this exact block silently reaching ~44k tokens. Any curation report
4003
4212
  * surfaces via systemMessage.
4004
4213
  */
4005
- function injectMemory(cwd, event, now) {
4214
+ function injectMemory(cwd, event, now, id) {
4006
4215
  const root = projectRoot(cwd);
4007
4216
  const file = lessonsFileFor(root);
4008
4217
  if (!existsSync(file)) return "";
@@ -4015,8 +4224,7 @@ function injectMemory(cwd, event, now) {
4015
4224
  if (!content) return "";
4016
4225
  const { content: curated, archive, report } = curateLessons(content, now, root);
4017
4226
  if (curated !== content) content = persistCuration(file, root, curated, archive, content);
4018
- const withNotice = attachSystemMessage(contextResponse(event, `Project lessons — never reproduce these:\n${capFragment("lessons", compressInjection(content))}\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.`), "lessons injected");
4019
- return report ? attachSystemMessage(withNotice, `LESSON.md curation:\n${report}`) : withNotice;
4227
+ return renderInform(id, event, `Project lessons — never reproduce these:\n${capFragment("lessons", compressInjection(content))}\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.`, report ? `lessons injected\nLESSON.md curation:\n${report}` : "lessons injected");
4020
4228
  }
4021
4229
  /**
4022
4230
  * Route a fuse-lessons event to its handler. Returns the native stdout for
@@ -4026,13 +4234,14 @@ function injectMemory(cwd, event, now) {
4026
4234
  * @param payload - The raw hook payload.
4027
4235
  * @param cwd - Project root for memory injection.
4028
4236
  * @param now - Clock.
4237
+ * @param id - Harness target id (defaults to "claude-code" — zero-regression default).
4029
4238
  * @returns The native stdout (possibly empty).
4030
4239
  */
4031
- function dispatchLessons(event, payload, cwd, now) {
4240
+ function dispatchLessons(event, payload, cwd, now, id = "claude-code") {
4032
4241
  switch (event) {
4033
4242
  case "SessionStart":
4034
4243
  case "SubagentStart":
4035
- case "UserPromptSubmit": return injectMemory(cwd, event, now);
4244
+ case "UserPromptSubmit": return injectMemory(cwd, event, now, id);
4036
4245
  case "Stop": return remindWrite(payload, now);
4037
4246
  case "PostToolUse":
4038
4247
  markWrite(payload, now);
@@ -4109,7 +4318,7 @@ async function injectApexSubagentContext(cwd, home = homedir(), id = "claude-cod
4109
4318
  const apexDir = join(projectRoot, seg, "apex");
4110
4319
  if (!existsSync(apexDir)) return "";
4111
4320
  const agentsPath = join(apexDir, "AGENTS.md");
4112
- const agents = existsSync(agentsPath) ? readText(agentsPath).slice(0, 4e3) : "";
4321
+ const agents = existsSync(agentsPath) ? readText$1(agentsPath).slice(0, 4e3) : "";
4113
4322
  const taskData = await readJsonFile(join(apexDir, "task.json"));
4114
4323
  const completed = taskData ? completedTasks(taskData.tasks) : "none";
4115
4324
  const pending = taskData ? pendingTasks(taskData.tasks) : "none";
@@ -4164,7 +4373,7 @@ function cacheAge(ts, now = Date.now()) {
4164
4373
  /** Full SHA-256 hex checksum of a file's text; "" when unreadable. */
4165
4374
  async function fileChecksum(path) {
4166
4375
  try {
4167
- return createHash("sha256").update(readText(path)).digest("hex");
4376
+ return createHash("sha256").update(readText$1(path)).digest("hex");
4168
4377
  } catch {
4169
4378
  return "";
4170
4379
  }
@@ -4234,7 +4443,7 @@ async function cacheAnalyticsSave(home = homedir(), now = Date.now()) {
4234
4443
  const dir = join(cacheBaseDir(home), "analytics");
4235
4444
  const sessionsFile = join(dir, "sessions.jsonl");
4236
4445
  if (!pathExists(sessionsFile)) return;
4237
- const raw = readText(sessionsFile);
4446
+ const raw = readText$1(sessionsFile);
4238
4447
  if (!raw.trim()) return;
4239
4448
  const entries = parseEntries(raw);
4240
4449
  if (entries.length === 0) return;
@@ -4316,7 +4525,7 @@ async function injectExploreCache(cwd, home = homedir(), now = Date.now()) {
4316
4525
  const cfgHash = await configHash(projPath);
4317
4526
  let context = "";
4318
4527
  const meta = await readJsonFile(metaFile);
4319
- const snapshot = pathExists(snapFile) ? readText(snapFile) : "";
4528
+ const snapshot = pathExists(snapFile) ? readText$1(snapFile) : "";
4320
4529
  if (meta?.timestamp && snapshot) {
4321
4530
  const age = cacheAge(meta.timestamp, now);
4322
4531
  if (age < TTL_SECONDS$1 && meta.config_hash === cfgHash) {
@@ -4353,7 +4562,7 @@ async function buildDocsContext(entries, docsDir, now) {
4353
4562
  seen.add(entry.hash);
4354
4563
  const docPath = join(docsDir, `${entry.hash}.md`);
4355
4564
  if (!pathExists(docPath)) continue;
4356
- const content = readText(docPath);
4565
+ const content = readText$1(docPath);
4357
4566
  if (!content) continue;
4358
4567
  ctx += `\n${content}\n`;
4359
4568
  count++;
@@ -4646,7 +4855,7 @@ function projectRootFromPaths(filePaths) {
4646
4855
  }
4647
4856
  /** Extract absolute file paths from tool_use entries in a JSONL transcript, optionally restricted to `toolNames` (default: any tool). */
4648
4857
  async function transcriptFilePaths(transcriptPath, toolNames) {
4649
- const text = readText(transcriptPath);
4858
+ const text = readText$1(transcriptPath);
4650
4859
  const paths = /* @__PURE__ */ new Set();
4651
4860
  for (const line of text.split("\n").filter(Boolean)) try {
4652
4861
  const content = JSON.parse(line)?.message?.content;
@@ -4662,7 +4871,7 @@ async function transcriptFilePaths(transcriptPath, toolNames) {
4662
4871
  }
4663
4872
  /** Extract deduplicated Edit tool_use entries (keyed by basename) from a transcript. */
4664
4873
  async function transcriptEdits(transcriptPath) {
4665
- const text = readText(transcriptPath);
4874
+ const text = readText$1(transcriptPath);
4666
4875
  const edits = [];
4667
4876
  for (const line of text.split("\n").filter(Boolean)) try {
4668
4877
  const content = JSON.parse(line)?.message?.content;
@@ -4679,7 +4888,7 @@ async function transcriptEdits(transcriptPath) {
4679
4888
  }
4680
4889
  /** Extract the last assistant text report (first 500 lines) from a transcript. */
4681
4890
  async function transcriptReport(transcriptPath) {
4682
- const text = readText(transcriptPath);
4891
+ const text = readText$1(transcriptPath);
4683
4892
  let lastReport = "";
4684
4893
  for (const line of text.split("\n").filter(Boolean)) try {
4685
4894
  const entry = JSON.parse(line);
@@ -4707,7 +4916,7 @@ const RETRY_DELAYS = [
4707
4916
  ];
4708
4917
  /** Extract the longest assistant synthesis + queried library ids from a transcript. */
4709
4918
  async function extractSynthesis(path) {
4710
- const lines = readText(path).split("\n").filter(Boolean);
4919
+ const lines = readText$1(path).split("\n").filter(Boolean);
4711
4920
  const libraries = [];
4712
4921
  let synthesis = "";
4713
4922
  for (const line of lines) try {
@@ -4915,7 +5124,7 @@ async function cacheSniperLessons(transcript, cwd, home = homedir()) {
4915
5124
  */
4916
5125
  /** Extract linter-related command/output text from a JSONL transcript. */
4917
5126
  async function extractLinterOutput(path) {
4918
- const text = readText(path);
5127
+ const text = readText$1(path);
4919
5128
  const outputs = [];
4920
5129
  for (const line of text.split("\n").filter(Boolean)) try {
4921
5130
  const content = JSON.parse(line)?.message?.content;
@@ -5167,8 +5376,8 @@ async function docCacheGate(payload, cwd, now = Date.now(), home = homedir()) {
5167
5376
  * Ports `check-solid-compliance.py`.
5168
5377
  */
5169
5378
  const CODE_EXTENSIONS$1 = /\.(ts|tsx|js|jsx|py|php|swift|go|rs|rb|java|astro)$/;
5170
- const COMPONENT_DIR = /(components|pages|views)\//;
5171
- const BACKEND_DIR = /(Controllers|Models|Services)\//;
5379
+ const COMPONENT_DIR = /\/(components|pages|views)\//;
5380
+ const BACKEND_DIR = /\/(Controllers|Models|Services)\//;
5172
5381
  const TS_INTERFACE_RE = /^(export )?(interface|type) [A-Z]/m;
5173
5382
  const PHP_INTERFACE_RE = /^interface /m;
5174
5383
  /** Count non-empty, non-comment (`//`/`#`/`*`-prefixed) lines. Shared with {@link checkSolidFromTranscript}. */
@@ -5192,9 +5401,10 @@ function checkSolidCompliance(payload) {
5192
5401
  const input = payload.tool_input ?? {};
5193
5402
  const filePath = typeof input.file_path === "string" ? input.file_path : "";
5194
5403
  if (!filePath || !CODE_EXTENSIONS$1.test(filePath) || !pathExists(filePath)) return "";
5404
+ if (isVendorPath(filePath)) return "";
5195
5405
  let content;
5196
5406
  try {
5197
- content = readText(filePath);
5407
+ content = readText$1(filePath);
5198
5408
  } catch {
5199
5409
  return "";
5200
5410
  }
@@ -5204,15 +5414,64 @@ function checkSolidCompliance(payload) {
5204
5414
  const lc = countCodeLines(content);
5205
5415
  if (lc > max) violations.push(`FILE SIZE: ${lc} lines (max: ${max})`);
5206
5416
  else if (lc > split) violations.push(`FILE SIZE WARNING: ${lc} lines (split at ${split})`);
5207
- if (COMPONENT_DIR.test(filePath)) {
5208
- if (TS_INTERFACE_RE.test(content)) violations.push("INTERFACE LOCATION: Move to src/interfaces/");
5209
- } else if (BACKEND_DIR.test(filePath)) {
5210
- if (PHP_INTERFACE_RE.test(content)) violations.push("INTERFACE LOCATION: Move to app/Contracts/");
5417
+ const masked = maskCommentsAndStrings(content, filePath.endsWith(".php") ? "php" : filePath.endsWith(".py") ? "py" : "c");
5418
+ if (COMPONENT_DIR.test(filePath) && /\.(ts|tsx|js|jsx|astro)$/.test(filePath)) {
5419
+ if (TS_INTERFACE_RE.test(masked)) violations.push("INTERFACE LOCATION: Move to src/interfaces/");
5420
+ } else if (BACKEND_DIR.test(filePath) && filePath.endsWith(".php")) {
5421
+ if (PHP_INTERFACE_RE.test(masked)) violations.push("INTERFACE LOCATION: Move to app/Contracts/");
5211
5422
  }
5212
5423
  if (violations.length === 0) return "";
5213
5424
  return contextResponse("PostToolUse", `SOLID COMPLIANCE CHECK: ${filePath.split("/").pop() ?? filePath}\n\n${violations.join("\n")}\nINSTRUCTION: Fix violations before continuing.\nRun sniper agent for full validation.`);
5214
5425
  }
5215
5426
  //#endregion
5427
+ //#region src/runtime/lifecycle/aipilot/solid-notice.ts
5428
+ /**
5429
+ * SubagentStop SOLID-notice dedup (anti-loop): `checkSolidFromTranscript` is
5430
+ * stateless, so an unchanged violation set re-emitted the same
5431
+ * `additionalContext` on EVERY SubagentStop — re-invoking the agent in an
5432
+ * observed ~15-repeat loop. The dedup fingerprint is a SHA-256 hash of the
5433
+ * sorted `file:message` set, persisted per agent key in the canonical
5434
+ * `.harness/track/solid-notice.json` sidecar (`config/layout.ts`), written
5435
+ * under the synchronous track lock (this hook runs in an ephemeral sync
5436
+ * process). Semantics: identical fingerprint within the TTL → silence;
5437
+ * changed/absent/expired fingerprint → emit once and record (the TTL derives
5438
+ * from `FUSE_ENFORCE_TTL_SEC`, no new env var). On lock contention the notice
5439
+ * is emitted anyway (visibility beats a muted violation) but not recorded.
5440
+ */
5441
+ /** SHA-256 (truncated) of the sorted violation set — the dedup fingerprint. */
5442
+ function noticeFingerprint(violations) {
5443
+ return createHash("sha256").update([...violations].sort().join("\n")).digest("hex").slice(0, 16);
5444
+ }
5445
+ /**
5446
+ * Decide whether the notice for `key` may be emitted, recording it when so.
5447
+ * @param noticeFile - `projectLayout(cwd).solidNoticeFile`.
5448
+ * @param key - Agent/transcript identity (e.g. `agent_transcript_path`).
5449
+ * @param hash - The current set's fingerprint ({@link noticeFingerprint}).
5450
+ * @param now - Clock (epoch ms).
5451
+ * @param ttlMs - Reminder window (derived from `FUSE_ENFORCE_TTL_SEC`).
5452
+ */
5453
+ function shouldEmitNotice(noticeFile, key, hash, now, ttlMs) {
5454
+ let state = {};
5455
+ try {
5456
+ state = JSON.parse(readFileSync(noticeFile, "utf8"));
5457
+ } catch {}
5458
+ const prev = state[key];
5459
+ if (prev && prev.hash === hash && now - prev.at < ttlMs) return false;
5460
+ const next = {
5461
+ ...state,
5462
+ [key]: {
5463
+ hash,
5464
+ at: now
5465
+ }
5466
+ };
5467
+ withTrackLockSync(dirname(noticeFile), () => {
5468
+ try {
5469
+ atomicWrite(noticeFile, JSON.stringify(next));
5470
+ } catch {}
5471
+ });
5472
+ return true;
5473
+ }
5474
+ //#endregion
5216
5475
  //#region src/runtime/lifecycle/aipilot/solid-transcript.ts
5217
5476
  /**
5218
5477
  * SubagentStop (matcher "") for the ai-pilot scope: check SOLID file-size and
@@ -5222,19 +5481,22 @@ function checkSolidCompliance(payload) {
5222
5481
  */
5223
5482
  const CODE_EXTENSIONS = /\.(ts|tsx|js|jsx|py|go|rs|java|php|cpp|c|rb|swift|kt|dart|vue|svelte|astro)$/;
5224
5483
  const INTERFACE_PATTERN = /^(export )?(interface|type) [A-Z]/m;
5225
- const INTERFACE_DIRS = [
5226
- "components/",
5227
- "pages/",
5228
- "views/",
5229
- "app/"
5230
- ];
5484
+ /** Segment-anchored dirs (a substring like `app/` matched `livetest-app/`). */
5485
+ const INTERFACE_DIRS_RE = /\/(components|pages|views|app)\//;
5486
+ /** Canonical homes — never a violation there (owner decision: types/ is
5487
+ * canonical; Contracts/ is the Laravel home taught by solid-php/laravelGate). */
5488
+ const INTERFACE_EXEMPT_RE = /\/(interfaces|types|Contracts|contracts)\//;
5231
5489
  /**
5232
5490
  * Check every Write/Edit target in a subagent transcript against the SOLID
5233
- * line-count ceiling and interface-location convention.
5491
+ * line-count ceiling and interface-location convention. Deduped per
5492
+ * transcript (`solid-notice.ts`): an unchanged violation set is emitted once
5493
+ * per TTL window, never in a loop.
5234
5494
  * @param transcript - Path to the agent JSONL transcript (`agent_transcript_path`).
5235
- * @returns The SubagentStop `additionalContext` response, or `""` when clean.
5495
+ * @param cwd - Project root (locates the `.harness/track` dedup sidecar).
5496
+ * @param now - Clock (tests inject it).
5497
+ * @returns The SubagentStop `additionalContext` response, or `""` when clean/deduped.
5236
5498
  */
5237
- async function checkSolidFromTranscript(transcript) {
5499
+ async function checkSolidFromTranscript(transcript, cwd = process.cwd(), now = Date.now()) {
5238
5500
  if (!transcript || !pathExists(transcript)) return "";
5239
5501
  const files = await transcriptFilePaths(transcript, ["Write", "Edit"]);
5240
5502
  const max = resolveMaxLines();
@@ -5244,15 +5506,19 @@ async function checkSolidFromTranscript(transcript) {
5244
5506
  const name = fp.split("/").pop() ?? fp;
5245
5507
  let content;
5246
5508
  try {
5247
- content = readText(fp);
5509
+ content = readText$1(fp);
5248
5510
  } catch {
5249
5511
  continue;
5250
5512
  }
5251
5513
  const lc = countCodeLines(content);
5252
5514
  if (lc > max) violations.push(`SOLID: ${name} = ${lc} lines (max ${max})`);
5253
- if (INTERFACE_DIRS.some((prefix) => fp.includes(prefix)) && INTERFACE_PATTERN.test(content)) violations.push(`SOLID: ${name}: move interfaces to interfaces/`);
5515
+ if (INTERFACE_DIRS_RE.test(fp) && !INTERFACE_EXEMPT_RE.test(fp) && INTERFACE_PATTERN.test(content)) {
5516
+ const dest = fp.endsWith(".php") ? "app/Contracts/" : "interfaces/";
5517
+ violations.push(`SOLID: ${name}: move interfaces to ${dest}`);
5518
+ }
5254
5519
  }
5255
5520
  if (violations.length === 0) return "";
5521
+ if (!shouldEmitNotice(projectLayout(cwd).solidNoticeFile, transcript, noticeFingerprint(violations), now, resolveTtlSec(process.env) * 1e3)) return "";
5256
5522
  return contextResponse("SubagentStop", `## SOLID VIOLATIONS DETECTED (subagent output)\n${violations.join("\n")}\nRun sniper to fix these issues.`);
5257
5523
  }
5258
5524
  //#endregion
@@ -5312,7 +5578,7 @@ async function onSubagentStop(payload, cwd, home) {
5312
5578
  await cacheSniperLessons(transcript, cwd, home);
5313
5579
  await cacheTestResults(transcript, cwd, home);
5314
5580
  }
5315
- return checkSolidFromTranscript(transcript);
5581
+ return checkSolidFromTranscript(transcript, cwd);
5316
5582
  }
5317
5583
  /**
5318
5584
  * Dispatch an ai-pilot-scope lifecycle event. Returns the native stdout, or
@@ -5338,9 +5604,9 @@ async function aipilotPostToolUse(payload, cwd, id = "claude-code") {
5338
5604
  /** SessionStart handler keyed on plugin scope. */
5339
5605
  function sessionStart(input) {
5340
5606
  if (input.scope === "solid") return solidDetectStart();
5341
- if (input.scope === "rules") return injectRules(process.env.CLAUDE_PLUGIN_ROOT ?? input.cwd, input.event);
5607
+ if (input.scope === "rules") return injectRules(resolveRulesRoot(input.id ?? "claude-code", input.cwd), input.event, input.id ?? "claude-code");
5342
5608
  if (input.scope === "carto") return cartoSessionStart(input.cwd, input.now);
5343
- if (input.scope === "lessons") return dispatchLessons("SessionStart", input.payload, input.cwd, input.now);
5609
+ if (input.scope === "lessons") return dispatchLessons("SessionStart", input.payload, input.cwd, input.now, input.id ?? "claude-code");
5344
5610
  const core = sessionStartCore(input.cwd, void 0, input.now, input.id ?? "claude-code");
5345
5611
  return input.scope === "core" ? withSnapshot(core, input.cwd, import.meta.url) : core;
5346
5612
  }
@@ -5355,16 +5621,16 @@ function dispatchLifecycle(input) {
5355
5621
  switch (input.event) {
5356
5622
  case "SessionStart": return sessionStart(input);
5357
5623
  case "UserPromptSubmit":
5358
- if (input.scope === "rules") return injectRules(process.env.CLAUDE_PLUGIN_ROOT ?? input.cwd, input.event);
5359
- if (input.scope === "lessons") return dispatchLessons("UserPromptSubmit", input.payload, input.cwd, input.now);
5624
+ if (input.scope === "rules") return injectRules(resolveRulesRoot(input.id ?? "claude-code", input.cwd), input.event, input.id ?? "claude-code");
5625
+ if (input.scope === "lessons") return dispatchLessons("UserPromptSubmit", input.payload, input.cwd, input.now, input.id ?? "claude-code");
5360
5626
  return null;
5361
5627
  case "SubagentStart":
5362
- if (input.scope === "rules") return injectRules(process.env.CLAUDE_PLUGIN_ROOT ?? input.cwd, input.event);
5628
+ if (input.scope === "rules") return injectRules(resolveRulesRoot(input.id ?? "claude-code", input.cwd), input.event, input.id ?? "claude-code");
5363
5629
  if (input.scope === "aipilot") return "";
5364
- if (input.scope === "lessons") return dispatchLessons("SubagentStart", input.payload, input.cwd, input.now);
5630
+ if (input.scope === "lessons") return dispatchLessons("SubagentStart", input.payload, input.cwd, input.now, input.id ?? "claude-code");
5365
5631
  return subagentCacheContext(input.payload.session_id);
5366
5632
  case "Stop":
5367
- if (input.scope === "lessons") return dispatchLessons("Stop", input.payload, input.cwd, input.now);
5633
+ if (input.scope === "lessons") return dispatchLessons("Stop", input.payload, input.cwd, input.now, input.id ?? "claude-code");
5368
5634
  return input.scope === "core" ? stopCore(input.payload, input.cwd, input.now) : null;
5369
5635
  case "SubagentStop":
5370
5636
  if (input.scope === "aipilot") return "";
@@ -6370,7 +6636,11 @@ function nextModular(filePath, content, cwd) {
6370
6636
  }
6371
6637
  /** Laravel FuseCore architecture: `app/` domain ban + module.json + cross-module `use` rules. */
6372
6638
  function fusecore(filePath, content, cwd) {
6373
- for (const b of PHP_BLOCKED_IN_APP) if (filePath.includes(b)) return block$1(`BLOCKED: FuseCore project. Code in '${b.replace(/^\/|\/$/g, "")}' FORBIDDEN. Move to '${b.replaceAll("/app/", "/FuseCore/{Module}/App/").replace(/^\/|\/$/g, "")}'.`);
6639
+ for (const b of PHP_BLOCKED_IN_APP) if (filePath.includes(b)) {
6640
+ const from = b.replace(/^\/|\/$/g, "");
6641
+ const to = b.replaceAll("/app/", "/FuseCore/{Module}/App/").replace(/^\/|\/$/g, "");
6642
+ return block$1(`BLOCKED: FuseCore project. Code in '${from}' FORBIDDEN. Move to '${to}'.`);
6643
+ }
6374
6644
  const mod = filePath.match(/\/FuseCore\/([A-Za-z]+)\//);
6375
6645
  if (!mod) return null;
6376
6646
  const name = mod[1] ?? "";
@@ -6561,7 +6831,7 @@ function appendRefRead(dir, path, ts) {
6561
6831
  function reconcileRefReadsFromJournal(track, dir, now) {
6562
6832
  let text;
6563
6833
  try {
6564
- text = readText(join(dir, JOURNAL));
6834
+ text = readText$1(join(dir, JOURNAL));
6565
6835
  } catch {
6566
6836
  return track;
6567
6837
  }
@@ -6764,10 +7034,9 @@ function geminiMcpGate(tool, filePath, content, ev) {
6764
7034
  //#endregion
6765
7035
  //#region src/freshness/agent-evidence.ts
6766
7036
  /**
6767
- * Platform-authored transcript evidence for APEX agent freshness.
6768
- * Parses the Claude Code session JSONL transcript to find genuine `Task`/`Agent`
6769
- * tool_use entries — forging this requires writing into the transcript file
6770
- * which the Claude Code platform controls, unlike the self-recorded track.
7037
+ * Platform-authored transcript evidence for APEX agent freshness. Parses the
7038
+ * Claude Code session JSONL transcript for genuine dispatch tool_use entries
7039
+ * — forging requires writing into the platform-controlled transcript file.
6771
7040
  */
6772
7041
  /** Parse a raw `timestamp` field to epoch ms; `undefined` when absent or invalid. */
6773
7042
  function parseTs(raw) {
@@ -6777,23 +7046,14 @@ function parseTs(raw) {
6777
7046
  return Number.isFinite(ms) ? ms : void 0;
6778
7047
  }
6779
7048
  /**
6780
- * Return `true` ONLY when, for EVERY name in `names`, the Claude Code
6781
- * transcript at `transcriptPath` contains a genuine `tool_use` of the `Task`/
6782
- * `Agent` tool whose `subagent_type` (or `name`) matches — after stripping any
6783
- * plugin prefix — OR a direct exploration/research tool_use (Glob/Grep, an
6784
- * explore Bash command, mcp__context7/mcp__exa, WebSearch/WebFetch) classified
6785
- * via {@link classifyExplore} into that name, issued by ANY sub-agent or the
6786
- * lead within this same transcript with the entry timestamp within
6787
- * `windowMs` of `now`.
6788
- *
6789
- * **Timestamp note:** when a transcript entry carries no `timestamp` field it
6790
- * is counted as within-window (we cannot prove staleness). This is
6791
- * intentionally lenient to stay robust across transcript-format evolution; the
6792
- * tamper-resistance guarantee derives from the platform authoring the file —
6793
- * not from the timestamp alone.
6794
- *
6795
- * @param transcriptPath - Absolute path to the session `.jsonl` transcript
6796
- * (hook payload field: `transcript_path`). Returns `false` when `undefined`.
7049
+ * Return `true` ONLY when, for EVERY name in `names`, the transcript at
7050
+ * `transcriptPath` shows a within-window genuine `tool_use`: a dispatch tool
7051
+ * ({@link isAgentTool}) whose `subagent_type`/`name` (plugin prefix stripped)
7052
+ * matches, OR a direct exploration/research call classified into that name by
7053
+ * {@link classifyExplore} issued by any sub-agent or the lead (the transcript
7054
+ * carries no author field). Entries without `timestamp` count as within-window
7055
+ * (format-evolution leniency; tamper-resistance comes from platform authoring).
7056
+ * @param transcriptPath - Session `.jsonl` transcript (`transcript_path`); false when undefined.
6797
7057
  * @param names - Required agent `subagent_type` values — ALL must appear.
6798
7058
  * @param windowMs - Freshness window in milliseconds.
6799
7059
  * @param now - Current epoch ms (pass `Date.now()` at the call-site).
@@ -6803,7 +7063,7 @@ function agentsRanFromTranscript(transcriptPath, names, windowMs, now) {
6803
7063
  if (!transcriptPath || names.length === 0) return false;
6804
7064
  let text;
6805
7065
  try {
6806
- text = readText(transcriptPath);
7066
+ text = readText$1(transcriptPath);
6807
7067
  } catch {
6808
7068
  return false;
6809
7069
  }
@@ -6823,7 +7083,7 @@ function agentsRanFromTranscript(transcriptPath, names, windowMs, now) {
6823
7083
  if (!Array.isArray(content)) continue;
6824
7084
  for (const block of content) {
6825
7085
  if (block?.type !== "tool_use") continue;
6826
- if (block.name === "Task" || block.name === "Agent") {
7086
+ if (block.name !== void 0 && isAgentTool(block.name)) {
6827
7087
  const raw = block.input?.subagent_type ?? block.input?.name;
6828
7088
  const agent = typeof raw === "string" ? raw.split(":").pop() ?? raw : void 0;
6829
7089
  if (agent !== void 0 && names.includes(agent)) found.add(agent);
@@ -6857,7 +7117,7 @@ async function apexScopedGate(input, track, window) {
6857
7117
  if (protectedDeny) return protectedDeny;
6858
7118
  if (SKIP_DIRS.test(input.filePath ?? "")) return null;
6859
7119
  if (isTrivialEdit(input.tool, input.content, input.isReplaceAll) && trivialCount(track, window, input.now) < 4) {
6860
- await saveTrack(input.trackFile, recordTrivialEdit(track, input.now, window, input.now));
7120
+ await withTrack(input.trackFile, (fresh) => recordTrivialEdit(fresh, input.now, window, input.now));
6861
7121
  return null;
6862
7122
  }
6863
7123
  const freshnessFor = (names, windowMs = window) => agentsFreshInTrack(track, names, windowMs, input.now) || (input.transcriptPath ? agentsRanFromTranscript(input.transcriptPath, names, windowMs, input.now) : agentsFresh(track, names, windowMs, input.now));
@@ -6883,7 +7143,7 @@ async function apexScopedGate(input, track, window) {
6883
7143
  const authDeny = apexAuthorizationGate(ctx);
6884
7144
  if (authDeny) {
6885
7145
  const set_at = new Date(input.now).toISOString();
6886
- await saveTrack(input.trackFile, recordTarget(track, {
7146
+ await withTrack(input.trackFile, (fresh) => recordTarget(fresh, {
6887
7147
  project: input.cwd ?? "",
6888
7148
  framework: input.framework,
6889
7149
  set_by: "apex-authorization",
@@ -7121,6 +7381,7 @@ function denyWith(id, content) {
7121
7381
  decision: "deny",
7122
7382
  reason: content
7123
7383
  });
7384
+ if (id === "kimi") return kimiDenyResponse(content);
7124
7385
  return "";
7125
7386
  }
7126
7387
  function mutateWith(id, input) {
@@ -7189,8 +7450,7 @@ function mcpPostStore(tool, input, response, dir, now = Date.now()) {
7189
7450
  //#region src/runtime/record.ts
7190
7451
  /** Apply an activity to a session's track and persist it (PostToolUse path). */
7191
7452
  async function recordActivity(file, activity) {
7192
- const track = await loadTrack(file);
7193
- await saveTrack(file, activity.kind === "agent" ? recordAgent(track, activity.name, activity.ts, activity.quality) : activity.kind === "doc" ? recordDoc(track, activity.framework, activity.sessionId, activity.source, activity.ts) : recordRefRead(track, activity.path, activity.ts));
7453
+ await withTrack(file, (track) => activity.kind === "agent" ? recordAgent(track, activity.name, activity.ts, activity.quality) : activity.kind === "doc" ? recordDoc(track, activity.framework, activity.sessionId, activity.source, activity.ts) : recordRefRead(track, activity.path, activity.ts));
7194
7454
  if (activity.kind === "ref") appendRefRead(dirname(file), activity.path, activity.ts ?? Date.now());
7195
7455
  }
7196
7456
  //#endregion
@@ -7485,7 +7745,7 @@ function uiDesignSkillGate(tool, filePath, content, ev) {
7485
7745
  if (!isUiWrite(tool, filePath, content)) return null;
7486
7746
  if (!designSkillRead(ev.refsRead)) return block("BLOCKED: design skill not consulted before writing UI. Read a design SKILL.md (design-system, design-web, or design-motion), then retry.", "Read a design skill reference, then retry");
7487
7747
  const missing = missingDomainSkills(content, ev.refsRead);
7488
- if (missing.length) return block(`BLOCKED: code uses ${missing.join(", ")} but the matching design skill(s) were not consulted. Read the SKILL.md for: ${missing.join(", ")}, then retry.`, `Read design skill(s): ${missing.join(", ")}, then retry`);
7748
+ if (missing.length) return block(`BLOCKED: code uses ${missing.join(", ")} but the matching design skill(s) were not consulted. Read the SKILL.md FILE (with the Read tool) for: ${missing.join(", ")}, then retry.`, `Read design skill(s): ${missing.join(", ")}, then retry`);
7489
7749
  if (!ev.docConsulted) return block("BLOCKED: no documentation consulted. Use BOTH mcp__context7__query-docs AND mcp__exa__web_search_exa, or a web fallback alone (WebSearch/WebFetch). Gemini is NOT required.", "Consult Context7+Exa, or web docs alone (Gemini not required), then retry");
7490
7750
  return null;
7491
7751
  }
@@ -7755,6 +8015,201 @@ function designGate(payload, event, cacheDir, cwd) {
7755
8015
  return null;
7756
8016
  }
7757
8017
  //#endregion
8018
+ //#region src/runtime/solid-file-size-gate.ts
8019
+ /**
8020
+ * PreToolUse file-size deny for the solid scope — the same SOLID ceiling the
8021
+ * core gate chain enforces (`policy/evaluate.ts`), extracted so
8022
+ * `harness hook <id> solid` blocks oversized Write/Edit WITHOUT the core-only
8023
+ * APEX freshness gates (they require explore/research agents that not every
8024
+ * harness runs). Pure and read-only (on-disk read via {@link existingLineCounts},
8025
+ * no session state), so the sibling-plugin fan-out stays idempotent.
8026
+ */
8027
+ /**
8028
+ * Deny a Write/Edit whose resulting file would exceed the SOLID line ceiling.
8029
+ * Mirrors `policy/evaluate.ts` exactly: a computable Edit outcome passes when
8030
+ * the result is compliant OR strictly shrinks an already-oversized file; the
8031
+ * block message reports the pre-existing on-disk count for a Write.
8032
+ * @param tool - The tool name (only "Write"/"Edit" are checked).
8033
+ * @param filePath - The written file's absolute path.
8034
+ * @param content - Write: the full new content; Edit: the `new_string` snippet.
8035
+ * @param oldString - Edit only: the `old_string` being replaced.
8036
+ * @param isReplaceAll - Edit only: whether every occurrence is replaced.
8037
+ * @param agentType - Subagent type, when known (Explore/Plan are exempt).
8038
+ * @returns The blocking prompt, or null when compliant or out of scope.
8039
+ */
8040
+ function solidFileSizeGate(tool, filePath, content, oldString, isReplaceAll = false, agentType) {
8041
+ if (tool !== "Write" && tool !== "Edit") return null;
8042
+ if (!filePath || !isFileSizeScoped(filePath)) return null;
8043
+ if (agentType === "Explore" || agentType === "Plan") return null;
8044
+ const { raw: existingLines, content: existingContent } = existingLineCounts(filePath);
8045
+ const incoming = content !== void 0 ? countLines(content) : 0;
8046
+ const existing = existingLines ?? 0;
8047
+ const max = resolveMaxLines();
8048
+ const editResult = tool === "Edit" && existingContent !== void 0 && content !== void 0 ? computeEditResultLines(existingContent, oldString, content, isReplaceAll) : null;
8049
+ if (editResult !== null && (editResult <= max || editResult < existing)) return null;
8050
+ const lines = tool === "Edit" ? editResult ?? Math.max(incoming, existing) : incoming || existing;
8051
+ const displayLines = tool === "Write" ? existingLines ?? lines : lines;
8052
+ const verdict = evaluateFileSize(lines, max, filePath, resolveSolidRefFramework(filePath), displayLines);
8053
+ if (lines === 0 || verdict.ok) return null;
8054
+ return {
8055
+ kind: "block",
8056
+ title: "SOLID file-size limit",
8057
+ reason: verdict.message ?? "",
8058
+ actions: [`Split into modules under ${verdict.max} lines (Single Responsibility)`, "Then re-run the write"]
8059
+ };
8060
+ }
8061
+ //#endregion
8062
+ //#region src/runtime/core-guards-active.ts
8063
+ /**
8064
+ * Per-harness ACTIVATION probing for the core-guards plugin, behind
8065
+ * {@link coreGuardsWired}. The owner decision: the solid scope abstains from
8066
+ * its file-size deny ONLY when core-guards is installed AND active — an
8067
+ * installed-but-disabled plugin must leave the solid deny firing. Safe
8068
+ * default, per harness: anything undetectable (unreadable/missing state)
8069
+ * reads as NOT active, so the worst case is a duplicate deny — never zero.
8070
+ */
8071
+ /** File content, or null when unreadable (undetectable → caller's safe default). */
8072
+ function readText(path) {
8073
+ try {
8074
+ return readFileSync(path, "utf8");
8075
+ } catch {
8076
+ return null;
8077
+ }
8078
+ }
8079
+ /** First `core-guards@…` value in a name→bool map; undefined when absent. */
8080
+ function enabledEntry(map) {
8081
+ if (typeof map !== "object" || map === null || Array.isArray(map)) return void 0;
8082
+ for (const [k, v] of Object.entries(map)) if (k.startsWith("core-guards@")) return v !== false;
8083
+ }
8084
+ /**
8085
+ * Claude Code: `~/.claude/settings.json` → `enabledPlugins`. core-guards is a
8086
+ * REQUIRED marketplace plugin: no entry means active whenever the marketplace
8087
+ * copy exists (`installed`); an explicit `false` means disabled. Unreadable
8088
+ * settings → false (undetectable).
8089
+ * @param home - OS home.
8090
+ * @param installed - Whether the marketplace copy of core-guards exists.
8091
+ */
8092
+ function claudeCoreGuardsActive(home, installed) {
8093
+ const text = readText(join(home, ".claude", "settings.json"));
8094
+ if (text === null) return false;
8095
+ try {
8096
+ return enabledEntry(JSON.parse(text).enabledPlugins) ?? installed;
8097
+ } catch {
8098
+ return false;
8099
+ }
8100
+ }
8101
+ /**
8102
+ * Codex: `[plugins."core-guards@<mkt>"] enabled = …` in `~/.codex/config.toml`
8103
+ * (minimal section-scoped extraction — no TOML dependency). Missing section,
8104
+ * `enabled = false`, or unreadable file → false.
8105
+ * @param home - OS home.
8106
+ */
8107
+ function codexCoreGuardsActive(home) {
8108
+ const text = readText(join(home, ".codex", "config.toml"));
8109
+ if (text === null) return false;
8110
+ return /^\[plugins\."core-guards@[^"]+"\]\s*enabled\s*=\s*(true|false)/m.exec(text)?.[1] === "true";
8111
+ }
8112
+ /**
8113
+ * Kimi: `<KIMI_CODE_HOME>/plugins/installed.json` — map form (`id →
8114
+ * {enabled?}`) or array form (`[{id, enabled?}]`); an entry counts active
8115
+ * unless `enabled === false`. File absent here today → format read
8116
+ * tolerantly; missing/unreadable → false (undetectable).
8117
+ * @param root - Kimi data root (`KIMI_CODE_HOME` ?? `~/.kimi-code`).
8118
+ */
8119
+ function kimiCoreGuardsActive(root) {
8120
+ const text = readText(join(root, "plugins", "installed.json"));
8121
+ if (text === null) return false;
8122
+ try {
8123
+ const data = JSON.parse(text).plugins;
8124
+ if (Array.isArray(data)) {
8125
+ const hit = data.find((p) => typeof p === "object" && p !== null && p.id === "core-guards");
8126
+ return hit !== void 0 && hit.enabled !== false;
8127
+ }
8128
+ if (typeof data !== "object" || data === null) return false;
8129
+ for (const [k, v] of Object.entries(data)) if (k === "core-guards" || k.startsWith("core-guards@")) return typeof v === "object" && v !== null ? v.enabled !== false : v !== false;
8130
+ return false;
8131
+ } catch {
8132
+ return false;
8133
+ }
8134
+ }
8135
+ //#endregion
8136
+ //#region src/runtime/core-guards-wired.ts
8137
+ /**
8138
+ * Detect whether the core-guards plugin is installed AND ACTIVE for a harness.
8139
+ * When it is, its `core` scope already owns the PreToolUse file-size deny
8140
+ * (`policy/evaluate.ts`), so the `solid` scope MUST abstain on the same
8141
+ * tool-call — otherwise a user with BOTH plugins wired on PreToolUse
8142
+ * Write|Edit receives the identical deny twice. Activation probing is
8143
+ * per-harness (`core-guards-active.ts`, owner decision):
8144
+ * - claude-code: marketplace copy present + `settings.json` `enabledPlugins`
8145
+ * (required core: no entry = active; explicit `false` = disabled);
8146
+ * - codex: `[plugins."core-guards@…"] enabled = true` in `~/.codex/config.toml`;
8147
+ * - kimi: `<KIMI_CODE_HOME>/plugins/installed.json` entry not disabled.
8148
+ * SAFE DEFAULT: anything undetectable (unreadable/missing state) reads as
8149
+ * NOT active — the solid scope then fires its own deny, so the worst case is
8150
+ * a duplicate deny, NEVER zero deny.
8151
+ */
8152
+ /** Immediate child dir names of `dir`, or [] when unreadable. */
8153
+ function children(dir) {
8154
+ try {
8155
+ return readdirSync(dir);
8156
+ } catch {
8157
+ return [];
8158
+ }
8159
+ }
8160
+ /** Claude: marketplace copy `~/.claude/plugins/marketplaces/<m>/plugins/core-guards`. */
8161
+ function claudeInstalled(home) {
8162
+ const markets = join(home, ".claude", "plugins", "marketplaces");
8163
+ return children(markets).some((m) => existsSync(join(markets, m, "plugins", "core-guards")));
8164
+ }
8165
+ /**
8166
+ * True when core-guards is installed AND active for the harness.
8167
+ * @param id - Harness id.
8168
+ * @param env - Environment (defaults to `process.env`).
8169
+ * @param home - OS home (defaults to `env.HOME` / `os.homedir()`; injectable).
8170
+ */
8171
+ function coreGuardsWired(id, env = process.env, home = env.HOME ?? homedir()) {
8172
+ if (id === "claude-code") return claudeCoreGuardsActive(home, claudeInstalled(home));
8173
+ if (id === "codex") return codexCoreGuardsActive(home);
8174
+ if (id === "kimi") return kimiCoreGuardsActive(env.KIMI_CODE_HOME ?? join(home, ".kimi-code"));
8175
+ return false;
8176
+ }
8177
+ //#endregion
8178
+ //#region src/runtime/solid-pre.ts
8179
+ /**
8180
+ * Solid-scope PreToolUse outcome: the file-size deny ({@link solidFileSizeGate})
8181
+ * first — rendered via {@link respond} so every harness gets its native shape
8182
+ * (Kimi finally receives a real `hookSpecificOutput` deny instead of a leaked
8183
+ * Claude envelope) — then the ported Go/Python interface-location check
8184
+ * ({@link validateSolidGate}, kept byte-identical for claude-code/codex).
8185
+ * Owns the whole solid-scope branch so `handle-pre` stays a dispatcher and the
8186
+ * branch NEVER falls through to the core APEX/SOLID gate chain: core-guards
8187
+ * owns it, so falling through would run `gate()` twice per edit when both
8188
+ * plugins wire PreToolUse Write|Edit (duplicate denies + added latency). For
8189
+ * the same reason the file-size gate itself ABSTAINS when core-guards is
8190
+ * installed ({@link coreGuardsWired}): one tool-call, one deny, whoever fires.
8191
+ */
8192
+ /**
8193
+ * Run the solid-scope PreToolUse gates and render the native hook outcome.
8194
+ * @param id - Harness id (selects the response shape via {@link respond}).
8195
+ * @param event - The normalized hook event.
8196
+ * @param file - Session track file (the deny-notice state derives from its dir).
8197
+ * @param now - Current time in ms.
8198
+ * @param env - Environment for the core-guards wiring probe (injectable).
8199
+ * @returns The hook outcome: file-size deny, Go/Python deny, or allow.
8200
+ */
8201
+ function solidScopeOutcome(id, event, file, now, env = process.env) {
8202
+ const prompt = coreGuardsWired(id, env) ? null : solidFileSizeGate(event.tool, event.filePath, event.content, event.oldString, event.input.replace_all === true, event.agentType);
8203
+ if (prompt) return {
8204
+ stdout: withDenyNotice(id, respond(id, prompt), prompt, event.sessionId, dirname(file), now),
8205
+ exit: 0
8206
+ };
8207
+ return {
8208
+ stdout: validateSolidGate(event.tool, event.filePath ?? "", event.content ?? ""),
8209
+ exit: 0
8210
+ };
8211
+ }
8212
+ //#endregion
7758
8213
  //#region src/runtime/pre-allow.ts
7759
8214
  /**
7760
8215
  * PreToolUse ALLOW-path response assembly. Reached only after every gate
@@ -7913,11 +8368,8 @@ async function handlePre(ctx) {
7913
8368
  stdout: event.files?.length ? securityAdvisoryForPatch(event.files, opts.now) : securityAdvisory(event.tool, event.filePath ?? "", opts.now),
7914
8369
  exit: 0
7915
8370
  };
7916
- if (opts.scope === "solid") return {
7917
- stdout: validateSolidGate(event.tool, event.filePath ?? "", event.content ?? ""),
7918
- exit: 0
7919
- };
7920
- if (event.tool === "Task") {
8371
+ if (opts.scope === "solid") return solidScopeOutcome(id, event, file, opts.now);
8372
+ if (isAgentTool(event.tool)) {
7921
8373
  const taskCtx = taskContext(opts.cwd, id);
7922
8374
  if (taskCtx) return {
7923
8375
  stdout: taskCtx,
@@ -8139,9 +8591,8 @@ function shellReadRefPaths(command) {
8139
8591
  }
8140
8592
  //#endregion
8141
8593
  //#region src/runtime/activity.ts
8142
- /** Min response length (chars) for a lead agent call to count as `sufficient`. */
8594
+ /** Quality thresholds (chars): lead agent calls vs direct explore/research (parity Python `> 50`). */
8143
8595
  const AGENT_QUALITY_MIN = 500;
8144
- /** Min response length (chars) for direct exploration/research (parity Python `> 50`). */
8145
8596
  const EXPLORE_QUALITY_MIN = 50;
8146
8597
  /** Read tools across harnesses (Claude `Read`, Gemini/Cline `read_file`, …). */
8147
8598
  const READ_TOOLS = /* @__PURE__ */ new Set([
@@ -8201,7 +8652,7 @@ function activityFor(event) {
8201
8652
  source: docSource,
8202
8653
  ts: event.now
8203
8654
  });
8204
- if (event.tool === "Task" || event.tool === "Agent" || event.tool === "AgentSwarm") {
8655
+ if (isAgentTool(event.tool)) {
8205
8656
  const name = String(event.input?.subagent_type ?? event.input?.name ?? "").split(":").pop() ?? "";
8206
8657
  if (name) out.push(agentActivity(name, event.now, qualityFor(event.responseLength, AGENT_QUALITY_MIN)));
8207
8658
  return out;
@@ -8307,9 +8758,37 @@ function creditCodexSpawnAgent(id, tool, input, track, ts) {
8307
8758
  */
8308
8759
  async function recordCodexSpawnEvidence(file, id, tool, input, ts) {
8309
8760
  if (id !== "codex" || !isCodexSpawnAgentTool(tool)) return;
8310
- const track = await loadTrack(file);
8311
- const next = creditCodexSpawnAgent(id, tool, input, track, ts);
8312
- if (next !== track) await saveTrack(file, next);
8761
+ await withTrack(file, (track) => {
8762
+ const next = creditCodexSpawnAgent(id, tool, input, track, ts);
8763
+ return next !== track ? next : track;
8764
+ });
8765
+ }
8766
+ //#endregion
8767
+ //#region src/runtime/receipt-capture.ts
8768
+ /**
8769
+ * Bash verification-receipt extraction for PostToolUse: a `tsc`/`bun test`
8770
+ * run is parsed (exit code + output) into a signed receipt the TaskCompleted
8771
+ * gate later demands. Structured responses ONLY: Kimi Code CLI sends
8772
+ * `tool_output` as a truncated STRING (no exit_code, no stdout/stderr
8773
+ * channels) — parsing it would forge a success receipt (`exit 0`, empty
8774
+ * output) for possibly-failed runs, so a non-object source is skipped.
8775
+ */
8776
+ /**
8777
+ * Record a verification receipt for a Bash run when the response is structured.
8778
+ * @param file - Session track file.
8779
+ * @param tool - The tool name (only "Bash" receipts exist).
8780
+ * @param command - The executed command.
8781
+ * @param toolResult - `payload.tool_result` (preferred), when present.
8782
+ * @param response - `payload.tool_response ?? payload.tool_output` fallback.
8783
+ * @param now - Clock.
8784
+ */
8785
+ async function captureBashReceipt(file, tool, command, toolResult, response, now) {
8786
+ if (tool !== "Bash" || !command) return;
8787
+ const r = toolResult ?? response;
8788
+ if (!r || typeof r !== "object") return;
8789
+ const out = `${typeof r.stdout === "string" ? r.stdout : ""}\n${typeof r.stderr === "string" ? r.stderr : ""}`;
8790
+ const exit = Number(r.exit_code ?? 0);
8791
+ await captureReceipt(file, command, out, Number.isFinite(exit) ? exit : 0, now);
8313
8792
  }
8314
8793
  //#endregion
8315
8794
  //#region src/tracking/codex-post-failure.ts
@@ -8452,12 +8931,7 @@ async function handlePost(ctx) {
8452
8931
  const evidence = classifyAgentEvidence(event.tool, event.input, response);
8453
8932
  if (evidence) await recordAgentEvidence(file, evidence, opts.now, typeof payload.agent_id === "string" ? payload.agent_id : void 0);
8454
8933
  await recordCodexSpawnEvidence(file, id, event.tool, event.input, opts.now);
8455
- if (event.tool === "Bash" && event.command) {
8456
- const r = payload.tool_result ?? response;
8457
- const out = `${typeof r?.stdout === "string" ? r.stdout : ""}\n${typeof r?.stderr === "string" ? r.stderr : ""}`;
8458
- const exit = Number(r?.exit_code ?? 0);
8459
- await captureReceipt(file, event.command, out, Number.isFinite(exit) ? exit : 0, opts.now);
8460
- }
8934
+ await captureBashReceipt(file, event.tool, event.command, payload.tool_result, response, opts.now);
8461
8935
  if (id === "codex") recordCodexPostFailure(event.tool, payload.tool_result ?? response, {
8462
8936
  now: opts.now,
8463
8937
  dir: defaultStateDir(opts.cwd),
@@ -8534,6 +9008,10 @@ async function handlePost(ctx) {
8534
9008
  stdout: attachSystemMessage(extra, userMessage),
8535
9009
  exit: 0
8536
9010
  };
9011
+ if (id === "kimi") return {
9012
+ stdout: respond(id, withUserMessage, "PostToolUse"),
9013
+ exit: 0
9014
+ };
8537
9015
  return {
8538
9016
  stdout: respond(id, withUserMessage, "PostToolUse") || extra,
8539
9017
  exit: 0
@@ -8983,7 +9461,7 @@ async function handleHook(id, payload, opts) {
8983
9461
  };
8984
9462
  const userPrompt = typeof payload.prompt === "string" ? payload.prompt : void 0;
8985
9463
  if (userPrompt !== void 0) {
8986
- await saveTrack(file, recordBrainstormRequired(await loadTrack(file), detectCreationIntent(userPrompt)));
9464
+ await withTrack(file, (track) => recordBrainstormRequired(track, detectCreationIntent(userPrompt)));
8987
9465
  return {
8988
9466
  stdout: promptSubmitContext(userPrompt, opts.cwd, id),
8989
9467
  exit: 0