@fusengine/harness 0.1.68 → 0.1.69

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.
@@ -4,7 +4,7 @@ import { r as projectLayout } from "./layout-C0jaaCQC.mjs";
4
4
  import { i as walkUpFor, n as projectRoot, r as projectRootOrNull, t as isCodeFile } from "./project-root-3kk7gCOp.mjs";
5
5
  import { A as detectCreationIntent, E as isExcludedSwiftPath, F as docConsultedGate, H as detectProjectType$1, I as evaluateApex, M as POST_AUTH_GATES, N as PRE_AUTH_GATES, S as usesTailwindUtilities, T as isExcludedJsPath, V as detectModularArchitecture, W as requiredArchSkill, _ as scanPlugin, c as EXCLUDE_DIRS$1, d as buildApexTaskInjection, h as buildClaudeMdContext, k as capVerbosity, l as PROJECT_INDICATORS, n as missingSeoElements, o as parseEnrichment, r as descFromText, s as parseEntry, t as isHtmlLike, w as frameworkSolidGate, x as skillTriggerGate, y as parseField, z as detectFramework } from "./validate-DnOqIZD_.mjs";
6
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-D0RLWP8J.mjs";
7
- import { n as FAIL_CLOSED, t as evaluate } from "./evaluate-CRZ_Hq33.mjs";
7
+ import { n as FAIL_CLOSED, t as evaluate } from "./evaluate-Dx6q1kmQ.mjs";
8
8
  import { a as writeJsonFile, i as readJsonFile, r as hashText, t as atomicWrite } from "./json-io-DisYd2fb.mjs";
9
9
  import { r as isDocConsulted } from "./doc-helpers-CWZegVdR.mjs";
10
10
  import { n as findMarketplacePlugins, r as readPluginMeta, t as resolveSkillPath } from "./skill-path-DhItkBzk.mjs";
@@ -14,14 +14,14 @@ import { d as loadIndex, i as cacheLookupMeta, n as webfetchCacheWrite, o as cac
14
14
  import { t as loadRefs } from "./loader-AGz4nK7d.mjs";
15
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-CQ4roWrU.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-BmchLlp7.mjs";
18
- import { r as toHermesResponse } from "./hermes-dbvFJRuY.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-CHpb1U0A.mjs";
18
+ import { r as toHermesResponse } from "./hermes-CddsFoPN.mjs";
19
19
  import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
20
- import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
20
+ import { appendFileSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
21
21
  import { homedir } from "node:os";
22
22
  import { mkdir, rmdir } from "node:fs/promises";
23
23
  import { createHash } from "node:crypto";
24
- import { execFileSync, execSync } from "node:child_process";
24
+ import { execFileSync, execSync, spawn } from "node:child_process";
25
25
  import { fileURLToPath } from "node:url";
26
26
  import figures from "figures";
27
27
  //#region src/runtime/lifecycle/security/skill-state.ts
@@ -105,7 +105,7 @@ function normalizeEvent(id, payload) {
105
105
  }));
106
106
  return {
107
107
  ...base,
108
- phase: "pre",
108
+ phase: base.phase,
109
109
  files: files.length > 0 ? files : void 0
110
110
  };
111
111
  }
@@ -1980,6 +1980,215 @@ function harvestSubagentTrack(payload, cwd, now, baseDir = defaultStateDir(cwd))
1980
1980
  } catch {}
1981
1981
  }
1982
1982
  //#endregion
1983
+ //#region src/cli/doctor.ts
1984
+ /**
1985
+ * `harness doctor` — diagnose which `@fusengine/harness` is actually running.
1986
+ *
1987
+ * A confirmed, still-open bun bug (oven-sh/bun #5791; scoped-pkg behaviour
1988
+ * reinforced by #32019/#32150) makes `bunx <pkg>` (unpinned) prefer a stale
1989
+ * GLOBAL install over npm-latest, so a consumer can silently run an old harness
1990
+ * after a publish. This command surfaces the truth: the resolved version +
1991
+ * package path of the code executing right now, the runtime binary, and the
1992
+ * latest version published on npm. It queries the registry over HTTP (not
1993
+ * `npm view`, whose exit code is 0 even on an empty result — npm/cli#6408) and
1994
+ * never throws: an offline environment yields `latest: null`, never a crash.
1995
+ */
1996
+ const PKG = "@fusengine/harness";
1997
+ /** Walk up from `startDir` for the `@fusengine/harness` `package.json`. */
1998
+ function findPackage(startDir) {
1999
+ let dir = startDir;
2000
+ for (let depth = 0; depth < 6; depth++) {
2001
+ try {
2002
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
2003
+ if (pkg.name === PKG) return {
2004
+ version: pkg.version ?? "unknown",
2005
+ path: dir
2006
+ };
2007
+ } catch {}
2008
+ const parent = dirname(dir);
2009
+ if (parent === dir) break;
2010
+ dir = parent;
2011
+ }
2012
+ return null;
2013
+ }
2014
+ /** Resolve the running version + package path (no network), from a module URL. */
2015
+ function runningVersion(moduleUrl) {
2016
+ const found = findPackage(dirname(fileURLToPath(moduleUrl)));
2017
+ return {
2018
+ version: found?.version ?? "unknown",
2019
+ path: found?.path ?? "unknown"
2020
+ };
2021
+ }
2022
+ /** One-line `pkg vX.Y.Z` banner (stderr, only on explicit `--version`/`doctor` commands — never on `hook`, to avoid spamming automated invocations). */
2023
+ function versionBanner(moduleUrl) {
2024
+ return `${PKG} v${runningVersion(moduleUrl).version}`;
2025
+ }
2026
+ /** Latest published version via the npm registry HTTP API. `null` on any failure. */
2027
+ async function npmLatest() {
2028
+ try {
2029
+ const res = await fetch(`https://registry.npmjs.org/${PKG}/latest`, { signal: AbortSignal.timeout(8e3) });
2030
+ if (!res.ok) return null;
2031
+ return (await res.json()).version ?? null;
2032
+ } catch {
2033
+ return null;
2034
+ }
2035
+ }
2036
+ /** Build the full diagnostic report for the module at `moduleUrl`. */
2037
+ async function buildDoctorReport(moduleUrl) {
2038
+ const { version, path } = runningVersion(moduleUrl);
2039
+ const latest = await npmLatest();
2040
+ return {
2041
+ running: version,
2042
+ packagePath: path,
2043
+ runtime: process.execPath,
2044
+ latest,
2045
+ stale: latest !== null && latest !== version
2046
+ };
2047
+ }
2048
+ /** Render a {@link DoctorReport} as human-readable stdout text. */
2049
+ function formatDoctor(r) {
2050
+ const lines = [
2051
+ `${PKG} doctor`,
2052
+ ` running: ${r.running}`,
2053
+ ` package: ${r.packagePath}`,
2054
+ ` runtime: ${r.runtime}`,
2055
+ ` npm latest: ${r.latest ?? "(unavailable — offline or unreachable)"}`
2056
+ ];
2057
+ if (r.stale) lines.push(` ! stale — npm serves ${r.latest}. Pin "@fusengine/harness@${r.latest}" in hooks.json (see README).`);
2058
+ else if (r.latest !== null) lines.push(` ok — running the latest published version.`);
2059
+ return lines.join("\n");
2060
+ }
2061
+ /** Run `harness doctor`: print the diagnostic to stdout. Always resolves 0 (pure info). */
2062
+ async function runDoctor(moduleUrl) {
2063
+ process.stdout.write(formatDoctor(await buildDoctorReport(moduleUrl)) + "\n");
2064
+ return 0;
2065
+ }
2066
+ //#endregion
2067
+ //#region src/runtime/notification-sound.ts
2068
+ /**
2069
+ * @module notification-sound
2070
+ * Resolve the on-disk sound file for a hook notification kind: a per-kind env
2071
+ * override, else the package's own `assets/song/` bundle, else a plugin-root
2072
+ * fallback. Returns an absolute path or `null` ("stay silent"). NEVER throws.
2073
+ * @packageDocumentation
2074
+ */
2075
+ /** Kind → asset filename (under `assets/song/` and the plugin-root fallback). */
2076
+ const FILES = {
2077
+ stop: "finish.mp3",
2078
+ permission: "permission-need.mp3",
2079
+ human: "need-human.mp3"
2080
+ };
2081
+ /** Kind → env var holding an explicit override path. */
2082
+ const ENV_VARS = {
2083
+ stop: "FUSE_HARNESS_SOUND_STOP",
2084
+ permission: "FUSE_HARNESS_SOUND_PERMISSION",
2085
+ human: "FUSE_HARNESS_SOUND_HUMAN"
2086
+ };
2087
+ /**
2088
+ * Resolve the sound file for `kind`, or `null` when none is available. Cascade:
2089
+ * (1) the per-kind env override when present on disk; (2) the package's own
2090
+ * `assets/song/<f>.mp3`, located by walking up to the running `package.json`
2091
+ * ({@link runningVersion} — survives the flat/hashed `dist/` bundle a hardcoded
2092
+ * `../../` of non-deterministic depth would escape); (3) `$CLAUDE_PLUGIN_ROOT/song/<f>.mp3`. Never throws.
2093
+ * @param kind - Which event to voice.
2094
+ * @param env - Injectable environment (defaults to `process.env`).
2095
+ * @param moduleUrl - Injectable module URL (defaults to this module's; locates the package).
2096
+ */
2097
+ function resolveSound(kind, env = process.env, moduleUrl = import.meta.url) {
2098
+ try {
2099
+ const override = env[ENV_VARS[kind]];
2100
+ if (override && existsSync(override)) return override;
2101
+ const pkgRoot = runningVersion(moduleUrl).path;
2102
+ if (pkgRoot !== "unknown") {
2103
+ const asset = join(pkgRoot, "assets", "song", FILES[kind]);
2104
+ if (existsSync(asset)) return asset;
2105
+ }
2106
+ const pluginRoot = env.CLAUDE_PLUGIN_ROOT;
2107
+ if (pluginRoot) {
2108
+ const fallback = join(pluginRoot, "song", FILES[kind]);
2109
+ if (existsSync(fallback)) return fallback;
2110
+ }
2111
+ return null;
2112
+ } catch {
2113
+ return null;
2114
+ }
2115
+ }
2116
+ //#endregion
2117
+ //#region src/runtime/notifications.ts
2118
+ /**
2119
+ * @module notifications
2120
+ * Native OS notification sound for lifecycle hook events (turn Stop, permission
2121
+ * needed, human needed). ON by default; opt OUT with `FUSE_HARNESS_SOUND=0`.
2122
+ *
2123
+ * Only two events invoke {@link notify}: the core-scope `Stop` (Codex-only —
2124
+ * Claude voices Stop via its own native `afplay` hook, so this never double-
2125
+ * sounds) and `TeammateIdle` (Claude-only, which has no native sound). That
2126
+ * structural split IS the anti-double-sound guarantee — do NOT add a harness-id
2127
+ * gate here: it would silence `TeammateIdle`'s "human" sound, whose only home is
2128
+ * claude-code. PermissionRequest/Notification stay native-only and are
2129
+ * intentionally not wired here (no dead code).
2130
+ *
2131
+ * ABSOLUTE fail-open, `command || true` semantics: an opt-out, an unsupported
2132
+ * platform, a missing player binary, a missing/undecodable sound file, or a
2133
+ * non-zero exit is swallowed — this module NEVER throws and NEVER blocks the
2134
+ * caller, so a broken/absent player can never break a hook.
2135
+ * @packageDocumentation
2136
+ */
2137
+ /**
2138
+ * The player command for `platform` playing `file`, or undefined for an
2139
+ * unsupported platform. Pure — no filesystem/process access, so it is cheaply
2140
+ * unit-testable without spawning anything. mp3 decoding is native on darwin
2141
+ * (`afplay`); on linux (`paplay`/libsndfile) and win32 (`SoundPlayer`, wav-only)
2142
+ * a bare mp3 may not decode — harmless under the fail-open contract.
2143
+ * @param platform - `process.platform`-shaped value.
2144
+ * @param file - Absolute path to the sound file to play.
2145
+ */
2146
+ function resolvePlayer(platform, file) {
2147
+ if (platform === "darwin") return {
2148
+ bin: "afplay",
2149
+ args: [file]
2150
+ };
2151
+ if (platform === "linux") return {
2152
+ bin: "paplay",
2153
+ args: [file]
2154
+ };
2155
+ if (platform === "win32") return {
2156
+ bin: "powershell",
2157
+ args: [
2158
+ "-NoProfile",
2159
+ "-c",
2160
+ `(New-Object Media.SoundPlayer '${file}').PlaySync()`
2161
+ ]
2162
+ };
2163
+ }
2164
+ /** True unless the user opted OUT (`FUSE_HARNESS_SOUND=0`). ON by default. */
2165
+ function soundEnabled(env = process.env) {
2166
+ return env.FUSE_HARNESS_SOUND !== "0";
2167
+ }
2168
+ /**
2169
+ * Fire-and-forget a native notification sound for `kind`. See module doc for
2170
+ * the absolute fail-open contract. A `null` resolved sound (nothing on disk) is
2171
+ * a silent no-op.
2172
+ * @param kind - Which event to voice.
2173
+ * @param opts - Injectable platform/env/spawn for tests; production defaults to the real ones.
2174
+ */
2175
+ function notify(kind, opts = {}) {
2176
+ try {
2177
+ const env = opts.env ?? process.env;
2178
+ if (!soundEnabled(env)) return;
2179
+ const file = resolveSound(kind, env);
2180
+ if (!file) return;
2181
+ const player = resolvePlayer(opts.platform ?? process.platform, file);
2182
+ if (!player) return;
2183
+ const child = (opts.spawnFn ?? spawn)(player.bin, player.args, {
2184
+ stdio: "ignore",
2185
+ detached: true
2186
+ });
2187
+ child.on("error", () => {});
2188
+ child.unref();
2189
+ } catch {}
2190
+ }
2191
+ //#endregion
1983
2192
  //#region src/runtime/lifecycle/teammate-idle-check.ts
1984
2193
  /**
1985
2194
  * @module teammate-idle-check
@@ -2031,7 +2240,9 @@ function teammateIdleContext(data, cwd, home = homedir(), now = Date.now()) {
2031
2240
  })) notice = `Teammate '${teammate}' idle but expected deliverable(s) not found on disk: ${missing.slice(0, 5).join(", ")} — verify before treating as done.`;
2032
2241
  }
2033
2242
  const merged = [sniper, notice].filter(Boolean).join("\n\n");
2034
- return merged ? contextResponse("TeammateIdle", merged) : "";
2243
+ if (!merged) return "";
2244
+ notify("human");
2245
+ return contextResponse("TeammateIdle", merged);
2035
2246
  }
2036
2247
  //#endregion
2037
2248
  //#region src/policy/lessons/trigger-index.ts
@@ -2675,90 +2886,6 @@ function collectGit(root) {
2675
2886
  return lines.join("\n");
2676
2887
  }
2677
2888
  //#endregion
2678
- //#region src/cli/doctor.ts
2679
- /**
2680
- * `harness doctor` — diagnose which `@fusengine/harness` is actually running.
2681
- *
2682
- * A confirmed, still-open bun bug (oven-sh/bun #5791; scoped-pkg behaviour
2683
- * reinforced by #32019/#32150) makes `bunx <pkg>` (unpinned) prefer a stale
2684
- * GLOBAL install over npm-latest, so a consumer can silently run an old harness
2685
- * after a publish. This command surfaces the truth: the resolved version +
2686
- * package path of the code executing right now, the runtime binary, and the
2687
- * latest version published on npm. It queries the registry over HTTP (not
2688
- * `npm view`, whose exit code is 0 even on an empty result — npm/cli#6408) and
2689
- * never throws: an offline environment yields `latest: null`, never a crash.
2690
- */
2691
- const PKG = "@fusengine/harness";
2692
- /** Walk up from `startDir` for the `@fusengine/harness` `package.json`. */
2693
- function findPackage(startDir) {
2694
- let dir = startDir;
2695
- for (let depth = 0; depth < 6; depth++) {
2696
- try {
2697
- const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
2698
- if (pkg.name === PKG) return {
2699
- version: pkg.version ?? "unknown",
2700
- path: dir
2701
- };
2702
- } catch {}
2703
- const parent = dirname(dir);
2704
- if (parent === dir) break;
2705
- dir = parent;
2706
- }
2707
- return null;
2708
- }
2709
- /** Resolve the running version + package path (no network), from a module URL. */
2710
- function runningVersion(moduleUrl) {
2711
- const found = findPackage(dirname(fileURLToPath(moduleUrl)));
2712
- return {
2713
- version: found?.version ?? "unknown",
2714
- path: found?.path ?? "unknown"
2715
- };
2716
- }
2717
- /** One-line `pkg vX.Y.Z` banner (stderr, only on explicit `--version`/`doctor` commands — never on `hook`, to avoid spamming automated invocations). */
2718
- function versionBanner(moduleUrl) {
2719
- return `${PKG} v${runningVersion(moduleUrl).version}`;
2720
- }
2721
- /** Latest published version via the npm registry HTTP API. `null` on any failure. */
2722
- async function npmLatest() {
2723
- try {
2724
- const res = await fetch(`https://registry.npmjs.org/${PKG}/latest`, { signal: AbortSignal.timeout(8e3) });
2725
- if (!res.ok) return null;
2726
- return (await res.json()).version ?? null;
2727
- } catch {
2728
- return null;
2729
- }
2730
- }
2731
- /** Build the full diagnostic report for the module at `moduleUrl`. */
2732
- async function buildDoctorReport(moduleUrl) {
2733
- const { version, path } = runningVersion(moduleUrl);
2734
- const latest = await npmLatest();
2735
- return {
2736
- running: version,
2737
- packagePath: path,
2738
- runtime: process.execPath,
2739
- latest,
2740
- stale: latest !== null && latest !== version
2741
- };
2742
- }
2743
- /** Render a {@link DoctorReport} as human-readable stdout text. */
2744
- function formatDoctor(r) {
2745
- const lines = [
2746
- `${PKG} doctor`,
2747
- ` running: ${r.running}`,
2748
- ` package: ${r.packagePath}`,
2749
- ` runtime: ${r.runtime}`,
2750
- ` npm latest: ${r.latest ?? "(unavailable — offline or unreachable)"}`
2751
- ];
2752
- if (r.stale) lines.push(` ! stale — npm serves ${r.latest}. Pin "@fusengine/harness@${r.latest}" in hooks.json (see README).`);
2753
- else if (r.latest !== null) lines.push(` ok — running the latest published version.`);
2754
- return lines.join("\n");
2755
- }
2756
- /** Run `harness doctor`: print the diagnostic to stdout. Always resolves 0 (pure info). */
2757
- async function runDoctor(moduleUrl) {
2758
- process.stdout.write(formatDoctor(await buildDoctorReport(moduleUrl)) + "\n");
2759
- return 0;
2760
- }
2761
- //#endregion
2762
2889
  //#region src/runtime/lifecycle/snapshot/version.ts
2763
2890
  /** Read the `version` field of `<root>/package.json`, or `""` if absent/unreadable. */
2764
2891
  function pkgVersion(root) {
@@ -3022,14 +3149,18 @@ function collectViolations(files, max) {
3022
3149
  * Handle TaskCompleted (ports `task-completed/validate-task-solid.py`, plus the
3023
3150
  * receipt gate). SOLID violations surface first as `SOLID VIOLATION`
3024
3151
  * additionalContext; once the files comply, {@link receiptGate} refuses a "done"
3025
- * that has no fresh passing tsc/test receipt.
3026
- * @param payload - The TaskCompleted payload (`task_id`, `task_subject`, `session_id`).
3152
+ * that has no fresh passing tsc/test receipt. Also reused verbatim from `Stop`
3153
+ * for Codex's core scope ({@link stopCore}): Codex never emits `TaskCompleted`
3154
+ * (codex-plugins/docs/reference/hooks.md) so this is its only completion check;
3155
+ * `event` lets that caller stamp the real hook name instead of a false one.
3156
+ * @param payload - The TaskCompleted/Stop payload (`task_id`, `task_subject`, `session_id`).
3027
3157
  * @param home - Home dir (defaults to `~`).
3028
3158
  * @param now - Clock (defaults to `Date.now()`).
3029
3159
  * @param stateDir - Track base dir (defaults to the cwd-derived state dir; matches `handleHook`).
3160
+ * @param event - The hook event name to stamp on the SOLID-violation response (default `TaskCompleted`).
3030
3161
  * @returns The native hook stdout, or `""` when the session is clean.
3031
3162
  */
3032
- function validateTaskSolid(payload, home = homedir(), now = Date.now(), stateDir = defaultStateDir(process.cwd())) {
3163
+ function validateTaskSolid(payload, home = homedir(), now = Date.now(), stateDir = defaultStateDir(process.cwd()), event = "TaskCompleted") {
3033
3164
  const sid = sanitizeSessionId(payload.session_id ?? "unknown");
3034
3165
  if (!sid) return "";
3035
3166
  const files = loadSessionState(sid, home).changes?.modifiedFiles ?? [];
@@ -3038,7 +3169,7 @@ function validateTaskSolid(payload, home = homedir(), now = Date.now(), stateDir
3038
3169
  const violations = collectViolations(files, max);
3039
3170
  if (violations.length === 0) return receiptGate(sid, files, now, stateDir) ?? "";
3040
3171
  const taskId = String(payload.task_id ?? "");
3041
- return contextResponse("TaskCompleted", `SOLID VIOLATION in task '${String(payload.task_subject ?? "")}' (${taskId}): ${violations.length} file(s) exceed ${max} lines: ` + violations.slice(0, 5).join("; "));
3172
+ return contextResponse(event, `SOLID VIOLATION in task '${String(payload.task_subject ?? "")}' (${taskId}): ${violations.length} file(s) exceed ${max} lines: ` + violations.slice(0, 5).join("; "));
3042
3173
  }
3043
3174
  //#endregion
3044
3175
  //#region src/runtime/lifecycle/cartographer/fs-util.ts
@@ -3432,7 +3563,7 @@ function parseTs$1(line) {
3432
3563
  return Date.UTC(+(m[1] ?? 0), mo - 1, d, +(m[4] ?? 0), +(m[5] ?? 0));
3433
3564
  }
3434
3565
  /** Content words (>=4 chars), timestamp & TRIGGERS marker stripped. */
3435
- function tokenize(text) {
3566
+ function tokenize$1(text) {
3436
3567
  return new Set(text.toLowerCase().replace(/\[triggers[^\]]*\]/g, " ").replace(/\[\d{4}-\d{2}-\d{2}[^\]]*\]/g, " ").replace(/[^a-z0-9àâäéèêëîïôöùûüç/._-]+/gi, " ").split(/\s+/).filter((t) => t.length >= 4));
3437
3568
  }
3438
3569
  /** Jaccard overlap of two token sets (0 when both empty). */
@@ -3464,7 +3595,7 @@ function parse(content) {
3464
3595
  if (/^-\s/.test(l)) blocks.push({
3465
3596
  raw: [l],
3466
3597
  ts: parseTs$1(l),
3467
- tokens: tokenize(l)
3598
+ tokens: tokenize$1(l)
3468
3599
  });
3469
3600
  else if (l.trim() && last) last.raw.push(l);
3470
3601
  }
@@ -3895,6 +4026,35 @@ function dispatchLessons(event, payload, cwd, now) {
3895
4026
  }
3896
4027
  }
3897
4028
  //#endregion
4029
+ //#region src/runtime/lifecycle/stop-core.ts
4030
+ /**
4031
+ * Handle the core scope's `Stop` event — Codex parity. Codex never emits
4032
+ * `SessionEnd`/`TaskCompleted` (codex-plugins/docs/reference/hooks.md,
4033
+ * "Harness Runtime Limits": "lifecycle dispatch includes Claude-only names
4034
+ * such as TaskCompleted, PostToolUseFailure, and SessionEnd; Codex does not
4035
+ * emit those events") but its own hooks.json wires `Stop` to `hook codex
4036
+ * core`, and its own docs table defines `Stop` as "turn finishes — cleanup
4037
+ * and completion notification". Both ported behaviors collapse onto it here
4038
+ * rather than being invented anew: {@link cleanupSession} (normally
4039
+ * SessionEnd-only) for cleanup, {@link validateTaskSolid} (normally
4040
+ * TaskCompleted-only) for the SOLID/receipt completion check.
4041
+ *
4042
+ * Claude-side, this branch is unreachable: core-guards' Claude `Stop` hooks
4043
+ * are a native `afplay` sound + an LLM `type:"prompt"` check, neither of
4044
+ * which invokes the harness binary (see `hooks/hooks.json` in claude-plugins
4045
+ * vs codex-plugins) — `dispatchLifecycle`'s `case "Stop"` for scope `"core"`
4046
+ * only ever receives a real payload from Codex.
4047
+ * @param payload - The raw Stop hook payload.
4048
+ * @param cwd - Project root (drives the state dir).
4049
+ * @param now - Clock.
4050
+ * @returns The native hook stdout ("" when the session is clean).
4051
+ */
4052
+ function stopCore(payload, cwd, now) {
4053
+ cleanupSession(void 0, now);
4054
+ notify("stop");
4055
+ return validateTaskSolid(payload, homedir(), now, defaultStateDir(cwd), "Stop");
4056
+ }
4057
+ //#endregion
3898
4058
  //#region src/runtime/lifecycle/aipilot/inject-apex.ts
3899
4059
  /**
3900
4060
  * SubagentStart (matcher "") for the ai-pilot scope: inject APEX AGENTS.md +
@@ -5092,8 +5252,7 @@ function contextTextOf(response) {
5092
5252
  }
5093
5253
  /**
5094
5254
  * Combine N SubagentStart responses into one (Claude concatenates every hook's
5095
- * additionalContext; collapsing the matcher-"" + type-specific scripts into one
5096
- * dispatch call must do that join itself). Returns "" when all are empty.
5255
+ * additionalContext; this single dispatch call must do that join itself). "" when all empty.
5097
5256
  */
5098
5257
  function combineContext(...responses) {
5099
5258
  const parts = responses.map(contextTextOf).filter(Boolean);
@@ -5112,10 +5271,9 @@ async function typeSpecificCache(agent, cwd, now) {
5112
5271
  return "";
5113
5272
  }
5114
5273
  /**
5115
- * SubagentStart routing. Parity with the Python ai-pilot hooks: the two
5116
- * matcher-"" entries (APEX context + lessons "known issues") fire for EVERY
5117
- * sub-agent, then the type-specific cache (explore/doc/test) is concatenated on
5118
- * top. Sniper must still receive the lessons block — hence no early return.
5274
+ * SubagentStart routing (parity with the Python ai-pilot hooks): the two matcher-""
5275
+ * entries (APEX context + lessons) fire for EVERY sub-agent, then the type-specific
5276
+ * cache is concatenated on top sniper too, hence no early return.
5119
5277
  */
5120
5278
  async function onSubagentStart(payload, cwd, now) {
5121
5279
  const agent = agentTypeOf(payload);
@@ -5139,7 +5297,7 @@ async function onSubagentStop(payload, cwd) {
5139
5297
  async function dispatchAipilot(event, payload, cwd, now) {
5140
5298
  if (event === "SubagentStart") return onSubagentStart(payload, cwd, now);
5141
5299
  if (event === "SubagentStop") return onSubagentStop(payload, cwd);
5142
- if (event === "SessionEnd") {
5300
+ if (event === "SessionEnd" || event === "Stop") {
5143
5301
  await cacheAnalyticsSave(void 0, now);
5144
5302
  return "";
5145
5303
  }
@@ -5177,7 +5335,9 @@ function dispatchLifecycle(input) {
5177
5335
  if (input.scope === "aipilot") return "";
5178
5336
  if (input.scope === "lessons") return dispatchLessons("SubagentStart", input.payload, input.cwd, input.now);
5179
5337
  return subagentCacheContext(input.payload.session_id);
5180
- case "Stop": return input.scope === "lessons" ? dispatchLessons("Stop", input.payload, input.cwd, input.now) : null;
5338
+ case "Stop":
5339
+ if (input.scope === "lessons") return dispatchLessons("Stop", input.payload, input.cwd, input.now);
5340
+ return input.scope === "core" ? stopCore(input.payload, input.cwd, input.now) : null;
5181
5341
  case "SubagentStop":
5182
5342
  if (input.scope === "aipilot") return "";
5183
5343
  harvestSubagentTrack(input.payload, input.cwd, input.now);
@@ -5360,9 +5520,22 @@ function postTrackingSideEffects(scope, event, input, now, payload = {}, cwd = p
5360
5520
  */
5361
5521
  const CODE_RE = /\.(ts|tsx|js|jsx|py|php|swift|go|rs|rb|java)$/;
5362
5522
  const ADVISORY = "SECURITY: Read security skill references before modifying code. Use: Read skills/security-scan/references/scan-patterns.md";
5523
+ /** True once today's security skill is marked read. Fail-open: missing/corrupt state reads as unread. */
5524
+ function skillAlreadyRead(now, home) {
5525
+ const path = securityStatePath(now, home);
5526
+ if (!existsSync(path)) return false;
5527
+ try {
5528
+ return JSON.parse(readFileSync(path, "utf-8")).skill_read === true;
5529
+ } catch {
5530
+ return false;
5531
+ }
5532
+ }
5363
5533
  /**
5364
- * Build a non-blocking PreToolUse `allow` response with a security advisory when
5365
- * editing a code file before the security skill has been read. "" otherwise.
5534
+ * Build a non-blocking PreToolUse advisory when editing a code file before the
5535
+ * security skill has been read, "" otherwise. Renders through the shared
5536
+ * {@link contextResponse} builder (`additionalContext` only) — NEVER a naked
5537
+ * `permissionDecision: "allow"`, which the Codex adapter's own hook shape
5538
+ * (`src/adapters/claude/index.ts`) never emits and which Codex rejects.
5366
5539
  * @param tool - The tool name (`Write`/`Edit`).
5367
5540
  * @param filePath - The target file path.
5368
5541
  * @param now - Clock.
@@ -5372,15 +5545,26 @@ const ADVISORY = "SECURITY: Read security skill references before modifying code
5372
5545
  function securityAdvisory(tool, filePath, now = Date.now(), home = homedir()) {
5373
5546
  if (tool !== "Write" && tool !== "Edit") return "";
5374
5547
  if (!CODE_RE.test(filePath)) return "";
5375
- const path = securityStatePath(now, home);
5376
- if (existsSync(path)) try {
5377
- if (JSON.parse(readFileSync(path, "utf-8")).skill_read === true) return "";
5378
- } catch {}
5379
- return JSON.stringify({ hookSpecificOutput: {
5380
- hookEventName: "PreToolUse",
5381
- permissionDecision: "allow",
5382
- additionalContext: ADVISORY
5383
- } });
5548
+ if (skillAlreadyRead(now, home)) return "";
5549
+ return contextResponse("PreToolUse", ADVISORY);
5550
+ }
5551
+ /**
5552
+ * Multi-file counterpart for a Codex `apply_patch` envelope: evaluate EACH
5553
+ * add/update file (delete ignored outright; non-code filtered by the same
5554
+ * `CODE_RE` inside {@link securityAdvisory}) and return the advisory
5555
+ * triggered by the FIRST qualifying file, or "" once the skill has been read
5556
+ * or no file qualifies.
5557
+ * @param files - The patch's per-file changes ({@link NormalizedFile}).
5558
+ * @param now - Clock.
5559
+ * @param home - Home dir.
5560
+ */
5561
+ function securityAdvisoryForPatch(files, now = Date.now(), home = homedir()) {
5562
+ for (const f of files) {
5563
+ if (f.op === "delete") continue;
5564
+ const advisory = securityAdvisory(f.op === "add" ? "Write" : "Edit", f.filePath, now, home);
5565
+ if (advisory) return advisory;
5566
+ }
5567
+ return "";
5384
5568
  }
5385
5569
  //#endregion
5386
5570
  //#region src/runtime/lifecycle/memory/client.ts
@@ -5621,7 +5805,7 @@ function isMemoryTool(tool) {
5621
5805
  */
5622
5806
  async function dispatchMemory(event, payload, cwd, now) {
5623
5807
  if (event === "SessionStart") return recallOnSession(cwd, now);
5624
- if (event === "SubagentStop") {
5808
+ if (event === "SubagentStop" || event === "Stop") {
5625
5809
  await captureAgentLesson(payload, now);
5626
5810
  return "";
5627
5811
  }
@@ -7678,7 +7862,7 @@ async function handlePre(ctx) {
7678
7862
  exit: 0
7679
7863
  };
7680
7864
  if (opts.scope === "security") return {
7681
- stdout: securityAdvisory(event.tool, event.filePath ?? "", opts.now),
7865
+ stdout: event.files?.length ? securityAdvisoryForPatch(event.files, opts.now) : securityAdvisory(event.tool, event.filePath ?? "", opts.now),
7682
7866
  exit: 0
7683
7867
  };
7684
7868
  if (opts.scope === "solid") return {
@@ -7808,6 +7992,104 @@ function docFramework(input, fallback) {
7808
7992
  return frameworkFromQuery(docQueryOf(input)) ?? fallback;
7809
7993
  }
7810
7994
  //#endregion
7995
+ //#region src/policy/shell-read-refs.ts
7996
+ /**
7997
+ * @module shell-read-refs
7998
+ * Codex teammates frequently read skill/SOLID reference `.md` files through a
7999
+ * shell Bash call (`cat`, `head`, …) instead of a native `Read` — the
8000
+ * refsRead/ref-journal freshness gates only ever credited a native `Read`
8001
+ * (`src/runtime/activity.ts`'s `READ_TOOLS` branch), so a shell-read skill
8002
+ * consultation was invisible to every SOLID/skill gate. This detects the
8003
+ * `.md` paths a KNOWN read-only command targets in a Bash `command`, for a
8004
+ * caller to fold into the SAME `{kind:"ref", path, ts}` activity
8005
+ * `src/runtime/record.ts` already persists (no new store, no new gate).
8006
+ *
8007
+ * Fail-open by construction: only a whitelisted read-only command name
8008
+ * credits its `.md` arguments — a non-read command (`echo`, `mv`, `tee`, …)
8009
+ * sharing a chained/piped segment is never credited, and `sed -i`/
8010
+ * `--in-place` (a MUTATION despite the `sed` name) is explicitly excluded.
8011
+ * @packageDocumentation
8012
+ */
8013
+ /** Read-only shell commands that can target a `.md` reference by path. */
8014
+ const READ_COMMANDS = /* @__PURE__ */ new Set([
8015
+ "cat",
8016
+ "head",
8017
+ "tail",
8018
+ "sed",
8019
+ "rg",
8020
+ "ripgrep",
8021
+ "less",
8022
+ "more",
8023
+ "bat"
8024
+ ]);
8025
+ /** POSIX shells whose `-c`-style argv (or inline string) wraps a real script. */
8026
+ const SHELL_BINS = /* @__PURE__ */ new Set([
8027
+ "bash",
8028
+ "sh",
8029
+ "zsh",
8030
+ "dash"
8031
+ ]);
8032
+ /** `sed -i` / `sed --in-place` mutates the file in place — never a read. */
8033
+ const SED_INPLACE = /(^|\s)(-i\b|--in-place\b)/;
8034
+ /** Split a command string on `&&`, `||`, `;`, `|`, and newlines — each side scanned independently. */
8035
+ function segments(command) {
8036
+ return command.split(/&&|\|\||[;|\n]/);
8037
+ }
8038
+ /** Strip a redirection (`>`, `>>`, `<`, `2>`, `&>`, …) and everything after — its target is WRITTEN, not read. */
8039
+ function beforeRedirect(segment) {
8040
+ const m = segment.match(/\s(?:\d*>{1,2}|<|&>)\s*\S/);
8041
+ return m ? segment.slice(0, m.index) : segment;
8042
+ }
8043
+ /** Naive shell tokenizer: whitespace-split, stripping one matching layer of quotes per token. */
8044
+ function tokenize(segment) {
8045
+ return (segment.match(/(?:"[^"]*"|'[^']*'|\S+)/g) ?? []).map((t) => /^(['"]).*\1$/.test(t) ? t.slice(1, -1) : t);
8046
+ }
8047
+ /** Index of the first token that isn't an env-assignment prefix (`KEY=VAL cmd …`). */
8048
+ function firstCommandIndex(tokens) {
8049
+ let i = 0;
8050
+ while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i] ?? "")) i++;
8051
+ return i;
8052
+ }
8053
+ /** The inner script of a `sh|bash|zsh|dash -c <script>` wrapper (as ONE token, since {@link tokenize} keeps a quoted phrase whole), or undefined when `tokens` isn't that shape. */
8054
+ function unwrapShellC(tokens) {
8055
+ const i = firstCommandIndex(tokens);
8056
+ const bin = tokens[i]?.slice(tokens[i].lastIndexOf("/") + 1);
8057
+ if (!bin || !SHELL_BINS.has(bin)) return void 0;
8058
+ const flag = tokens[i + 1];
8059
+ if (!flag || !/^-[a-z]*c$/.test(flag)) return void 0;
8060
+ return tokens[i + 2];
8061
+ }
8062
+ /** Credit the `.md` arguments of one segment if its command is a whitelisted read; recurses once into a `sh -c` wrapper. */
8063
+ function scanSegment(raw, out) {
8064
+ const segment = beforeRedirect(raw);
8065
+ const tokens = tokenize(segment);
8066
+ const inner = unwrapShellC(tokens);
8067
+ if (inner !== void 0) {
8068
+ for (const s of segments(inner)) scanSegment(s, out);
8069
+ return;
8070
+ }
8071
+ const i = firstCommandIndex(tokens);
8072
+ const name = tokens[i]?.slice(tokens[i].lastIndexOf("/") + 1);
8073
+ if (!name || !READ_COMMANDS.has(name)) return;
8074
+ if (name === "sed" && SED_INPLACE.test(segment)) return;
8075
+ for (const t of tokens.slice(i + 1)) if (t.endsWith(".md") && !t.startsWith("-")) out.push(t);
8076
+ }
8077
+ /**
8078
+ * The `.md` paths a read-only shell command reads in `command` (a Bash
8079
+ * `tool_input.command` — plain string or Codex's argv-array form, both
8080
+ * normalized via {@link commandToString}). Empty for a non-read command,
8081
+ * `sed -i` in-place, or an unparseable/absent command — fail-open, never a
8082
+ * false credit.
8083
+ * @param command - Raw `tool_input.command` value (string | string[] | unknown).
8084
+ */
8085
+ function shellReadRefPaths(command) {
8086
+ const str = commandToString(command);
8087
+ if (!str) return [];
8088
+ const out = [];
8089
+ for (const seg of segments(str)) scanSegment(seg, out);
8090
+ return out;
8091
+ }
8092
+ //#endregion
7811
8093
  //#region src/runtime/activity.ts
7812
8094
  /** Min response length (chars) for a lead agent call to count as `sufficient`. */
7813
8095
  const AGENT_QUALITY_MIN = 500;
@@ -7888,6 +8170,11 @@ function activityFor(event) {
7888
8170
  ts: event.now
7889
8171
  });
7890
8172
  }
8173
+ if (event.tool === "Bash") for (const path of shellReadRefPaths(event.input?.command)) out.push({
8174
+ kind: "ref",
8175
+ path,
8176
+ ts: event.now
8177
+ });
7891
8178
  return out;
7892
8179
  }
7893
8180
  //#endregion
@@ -7977,11 +8264,126 @@ async function recordCodexSpawnEvidence(file, id, tool, input, ts) {
7977
8264
  if (next !== track) await saveTrack(file, next);
7978
8265
  }
7979
8266
  //#endregion
8267
+ //#region src/tracking/codex-post-failure.ts
8268
+ /**
8269
+ * @module codex-post-failure
8270
+ * Codex has no native `PostToolUseFailure` event (Claude Code's own failure
8271
+ * signal, ported in `src/runtime/lifecycle/tool-failure.ts`'s `logToolFailure`)
8272
+ * — every Codex tool outcome, success or failure, arrives on the SAME
8273
+ * `PostToolUse` event. This infers a failure from the regular payload's
8274
+ * `tool_result`/`tool_response` and journals it into the EXISTING one-shot
8275
+ * failure tally ({@link recordFailure}) — no new store, no new gate.
8276
+ *
8277
+ * Design choice (fail-open): a payload this module cannot positively prove
8278
+ * failed — `null`/`undefined`, a non-object shape, or any parse exception —
8279
+ * classifies as `"success"` (ignored), NEVER `"failure"`. Many legitimate
8280
+ * successful tool calls return no structured result at all (see
8281
+ * `handle-post.ts`'s `spawn_agent` `tool_response: { nickname: "scout" }`),
8282
+ * so treating "can't tell" as a failure would flood the tally with false
8283
+ * positives. Only an EXPLICIT signal (non-zero exit, an error field) counts.
8284
+ * @packageDocumentation
8285
+ */
8286
+ /** Truthy interruption markers across harness result shapes (parity `tool-failure.ts`'s `is_interrupt`). */
8287
+ const INTERRUPT_KEYS = [
8288
+ "is_interrupt",
8289
+ "isInterrupted",
8290
+ "interrupted",
8291
+ "aborted",
8292
+ "cancelled",
8293
+ "canceled"
8294
+ ];
8295
+ /** True when `result` carries any known interruption marker — an interruption is never a failure. */
8296
+ function isInterruption(result) {
8297
+ return INTERRUPT_KEYS.some((k) => result[k] === true);
8298
+ }
8299
+ /** True when `result` reports a non-zero exit code (Bash-shaped tool result). */
8300
+ function hasNonZeroExit(result) {
8301
+ const exit = result.exit_code ?? result.exitCode;
8302
+ return typeof exit === "number" && exit !== 0;
8303
+ }
8304
+ /** True when `result` carries an explicit error signal (`error`, `is_error`/`isError`, or `success: false`). */
8305
+ function hasErrorField(result) {
8306
+ if (result.success === false) return true;
8307
+ if (result.is_error === true || result.isError === true) return true;
8308
+ const err = result.error;
8309
+ return typeof err === "string" ? err.length > 0 : err !== void 0 && err !== null;
8310
+ }
8311
+ /**
8312
+ * Classify a Codex PostToolUse `tool_response`/`tool_result` payload.
8313
+ * Fail-open (see module doc): unprovable shapes resolve to `"success"`,
8314
+ * never `"failure"`, and this NEVER throws.
8315
+ * @param result - The raw result value off the payload (any shape).
8316
+ * @returns `"success"` | `"failure"` | `"interrupted"`.
8317
+ */
8318
+ function classifyCodexOutcome(result) {
8319
+ try {
8320
+ if (result === null || typeof result !== "object" || Array.isArray(result)) return "success";
8321
+ const r = result;
8322
+ if (isInterruption(r)) return "interrupted";
8323
+ if (hasNonZeroExit(r) || hasErrorField(r)) return "failure";
8324
+ return "success";
8325
+ } catch {
8326
+ return "success";
8327
+ }
8328
+ }
8329
+ /**
8330
+ * Journal a Codex tool failure into the existing one-shot failure tally.
8331
+ * No-op for `"success"`/`"interrupted"` — only a genuine failure is recorded.
8332
+ * @param tool - The tool name.
8333
+ * @param result - The raw PostToolUse result payload.
8334
+ * @param opts - Clock + state dir (+ optional session id), forwarded as-is to {@link recordFailure}.
8335
+ */
8336
+ function recordCodexPostFailure(tool, result, opts) {
8337
+ if (classifyCodexOutcome(result) !== "failure") return;
8338
+ recordFailure(tool, opts);
8339
+ }
8340
+ //#endregion
8341
+ //#region src/runtime/post-fanout.ts
8342
+ /**
8343
+ * Fan a Codex `apply_patch` envelope's `event.files` into one synthetic
8344
+ * per-file event per touched file, so the per-file PostToolUse gates (SOLID
8345
+ * size, Tailwind, tracking, post-edit context) see EACH file instead of the
8346
+ * whole patch (whose own `filePath`/`content` are always undefined). `add`
8347
+ * maps to `Write`, `update` to `Edit` (mirrors the Pre-phase `applyPatchGate`'s
8348
+ * tool mapping); `delete`/`move` map to a tool none of those Write|Edit-gated
8349
+ * checks recognize, so they no-op on it without special-casing each gate.
8350
+ * Returns `[event]` unchanged for every non-`apply_patch` tool/harness.
8351
+ * @param event - The normalized PostToolUse event.
8352
+ * @returns One event per touched file, or the original event.
8353
+ */
8354
+ function fanOutFiles(event) {
8355
+ if (!event.files || event.files.length === 0) return [event];
8356
+ return event.files.map((f) => ({
8357
+ ...event,
8358
+ tool: f.op === "add" ? "Write" : f.op === "update" ? "Edit" : "apply_patch:delete",
8359
+ filePath: f.filePath,
8360
+ content: f.op === "delete" ? void 0 : f.content
8361
+ }));
8362
+ }
8363
+ /**
8364
+ * Run `check(tool, filePath)` over each fanned-out file, OR-ing the verdict —
8365
+ * the first non-empty result wins (parity with `applyPatchGate`'s "one
8366
+ * violating hunk blocks the whole envelope").
8367
+ * @param files - Per-file events from {@link fanOutFiles}.
8368
+ * @param check - A PostToolUse gate keyed on `(tool, filePath)`.
8369
+ * @returns The first non-empty result, or `""` when every file is clean.
8370
+ */
8371
+ function firstFileMatch(files, check) {
8372
+ for (const f of files) {
8373
+ if (!f.filePath) continue;
8374
+ const result = check(f.tool, f.filePath);
8375
+ if (result) return result;
8376
+ }
8377
+ return "";
8378
+ }
8379
+ //#endregion
7980
8380
  //#region src/runtime/handle-post.ts
7981
8381
  /**
7982
8382
  * Run the PostToolUse pipeline: store the MCP response, emit a design warning,
7983
8383
  * record the activity into the session track, apply per-scope side-effects (SEO
7984
- * deny, aipilot task cache), then inject the post-edit context.
8384
+ * deny, aipilot task cache), then inject the post-edit context. Codex
8385
+ * `apply_patch` is fanned into per-file events ({@link fanOutFiles}) before the
8386
+ * per-file gates (tracking, SOLID size, Tailwind, post-edit context) run.
7985
8387
  * @param ctx - The resolved context (same shape as the pre pipeline).
7986
8388
  * @returns The native hook outcome.
7987
8389
  */
@@ -8008,21 +8410,27 @@ async function handlePost(ctx) {
8008
8410
  const exit = Number(r?.exit_code ?? 0);
8009
8411
  await captureReceipt(file, event.command, out, Number.isFinite(exit) ? exit : 0, opts.now);
8010
8412
  }
8011
- postTrackingSideEffects(opts.scope ?? "core", event, event.input, opts.now, payload, opts.cwd);
8413
+ if (id === "codex") recordCodexPostFailure(event.tool, payload.tool_result ?? response, {
8414
+ now: opts.now,
8415
+ dir: defaultStateDir(opts.cwd),
8416
+ sessionId: event.sessionId
8417
+ });
8418
+ const files = fanOutFiles(event);
8419
+ for (const f of files) postTrackingSideEffects(opts.scope ?? "core", f, f.input, opts.now, payload, opts.cwd);
8012
8420
  const seoDeny = opts.scope === "seo" ? seoPostToolUseResponse(payload) : null;
8013
8421
  if (seoDeny) return {
8014
8422
  stdout: seoDeny,
8015
8423
  exit: 0
8016
8424
  };
8017
- if (opts.scope === "solid" && event.filePath) {
8018
- const solidWarn = checkFileSize(event.tool, event.filePath);
8425
+ if (opts.scope === "solid") {
8426
+ const solidWarn = firstFileMatch(files, checkFileSize);
8019
8427
  if (solidWarn) return {
8020
8428
  stdout: solidWarn,
8021
8429
  exit: 0
8022
8430
  };
8023
8431
  }
8024
- if (opts.scope === "tailwindcss" && event.filePath) {
8025
- const tailwindWarn = validateTailwind(event.tool, event.filePath);
8432
+ if (opts.scope === "tailwindcss") {
8433
+ const tailwindWarn = firstFileMatch(files, validateTailwind);
8026
8434
  if (tailwindWarn) return {
8027
8435
  stdout: tailwindWarn,
8028
8436
  exit: 0
@@ -8035,7 +8443,11 @@ async function handlePost(ctx) {
8035
8443
  exit: 0
8036
8444
  };
8037
8445
  }
8038
- const extra = await postEditContext(opts.scope ?? "core", event, opts.now);
8446
+ let extra = "";
8447
+ for (const f of files) {
8448
+ extra = await postEditContext(opts.scope ?? "core", f, opts.now);
8449
+ if (extra) break;
8450
+ }
8039
8451
  const notice = designPassNotice({
8040
8452
  agentId: typeof payload.agent_id === "string" ? payload.agent_id : "",
8041
8453
  tool: event.tool,
@@ -8101,6 +8513,347 @@ async function asyncScopeStdout(scope, event, payload, cwd, now) {
8101
8513
  return null;
8102
8514
  }
8103
8515
  //#endregion
8516
+ //#region src/runtime/lifecycle/codex-resync/lock.ts
8517
+ /** Stale-after window (ms) — survives a session crashing before its `finally`. */
8518
+ const STALE_MS = 3e4;
8519
+ function lockPath(codexHome) {
8520
+ return join(codexHome, "fusengine", "state", "agents-resync.lock");
8521
+ }
8522
+ /**
8523
+ * Best-effort inter-process lock for the agents resync. `writeFileSync(path,
8524
+ * pid, { flag: "wx" })` uses `O_EXCL`: it fails with `EEXIST` when the lock
8525
+ * file already exists, so two Codex sessions starting at once cannot both
8526
+ * resolve then write DIFFERENT versions of the agents cache in interleave (a
8527
+ * torn TOML). Stale after {@link STALE_MS} to survive a crash that never
8528
+ * reached its `finally`. No live-PID check (a permanent, accepted limit of any
8529
+ * mtime-based lock — same tradeoff `proper-lockfile` makes).
8530
+ * @param codexHome - The Codex home directory.
8531
+ * @returns `true` when the lock was acquired; `false` when another live
8532
+ * holder has it.
8533
+ */
8534
+ function acquireResyncLock(codexHome) {
8535
+ const path = lockPath(codexHome);
8536
+ mkdirSync(dirname(path), { recursive: true });
8537
+ try {
8538
+ writeFileSync(path, String(process.pid), { flag: "wx" });
8539
+ return true;
8540
+ } catch {
8541
+ try {
8542
+ if (Date.now() - statSync(path).mtimeMs <= STALE_MS) return false;
8543
+ unlinkSync(path);
8544
+ writeFileSync(path, String(process.pid), { flag: "wx" });
8545
+ return true;
8546
+ } catch {
8547
+ return false;
8548
+ }
8549
+ }
8550
+ }
8551
+ /** Release the lock. Best-effort — never throws. */
8552
+ function releaseResyncLock(codexHome) {
8553
+ try {
8554
+ unlinkSync(lockPath(codexHome));
8555
+ } catch {}
8556
+ }
8557
+ //#endregion
8558
+ //#region src/runtime/lifecycle/codex-resync/plugin-roots.ts
8559
+ /** Frozen marketplace name (matches codex-plugins' own `scripts/install-codex.ts`). */
8560
+ const MARKETPLACE = "fusengine-codex";
8561
+ /** The plugin cache root under a Codex home. */
8562
+ function pluginsCacheRoot(codexHome) {
8563
+ return join(codexHome, "plugins", "cache", MARKETPLACE);
8564
+ }
8565
+ /** Descending semver-ish compare (`"2.1" > "10.0"` stays false — numeric per segment). */
8566
+ function compareVersionsDesc(a, b) {
8567
+ const left = a.split(/[.-]/).map((part) => Number.parseInt(part, 10));
8568
+ const right = b.split(/[.-]/).map((part) => Number.parseInt(part, 10));
8569
+ const max = Math.max(left.length, right.length);
8570
+ for (let i = 0; i < max; i++) {
8571
+ const av = Number.isNaN(left[i]) ? 0 : left[i] ?? 0;
8572
+ const bv = Number.isNaN(right[i]) ? 0 : right[i] ?? 0;
8573
+ if (av !== bv) return bv - av;
8574
+ }
8575
+ return b.localeCompare(a);
8576
+ }
8577
+ /** Resolve one plugin's active root: unversioned dir first, else the newest version dir. */
8578
+ function resolvePluginRoot(pluginsRoot, plugin) {
8579
+ const root = join(pluginsRoot, plugin);
8580
+ if (existsSync(join(root, ".codex-plugin")) || existsSync(join(root, "skills"))) return root;
8581
+ let entries;
8582
+ try {
8583
+ entries = readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
8584
+ } catch {
8585
+ return;
8586
+ }
8587
+ const versions = entries.sort(compareVersionsDesc);
8588
+ return versions[0] ? join(root, versions[0]) : void 0;
8589
+ }
8590
+ /**
8591
+ * Resolve the active root directory of every installed plugin under a plugins
8592
+ * cache root, keyed by plugin name. Ports `plugin-root-resolver.ts::buildPluginRoots`.
8593
+ * @param pluginsRoot - The plugin cache root (see {@link pluginsCacheRoot}).
8594
+ * @returns Plugin name -> resolved root dir (missing entries are silently skipped).
8595
+ */
8596
+ function buildPluginRoots(pluginsRoot) {
8597
+ const roots = /* @__PURE__ */ new Map();
8598
+ let entries;
8599
+ try {
8600
+ entries = readdirSync(pluginsRoot, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "_shared").map((e) => e.name);
8601
+ } catch {
8602
+ return roots;
8603
+ }
8604
+ for (const plugin of entries) {
8605
+ const root = resolvePluginRoot(pluginsRoot, plugin);
8606
+ if (root) roots.set(plugin, root);
8607
+ }
8608
+ return roots;
8609
+ }
8610
+ //#endregion
8611
+ //#region src/runtime/lifecycle/codex-resync/fingerprint.ts
8612
+ /** Where the last-applied plugin-cache fingerprint is persisted. */
8613
+ function manifestPath(codexHome) {
8614
+ return join(codexHome, "fusengine", "state", "agents-cache-fingerprint.json");
8615
+ }
8616
+ /** sha256 over sorted "plugin=root" pairs — stable while cache resolution is unchanged. */
8617
+ function fingerprint(roots) {
8618
+ const sorted = [...roots.entries()].sort(([a], [b]) => a.localeCompare(b));
8619
+ const hash = createHash("sha256");
8620
+ for (const [plugin, root] of sorted) hash.update(`${plugin}=${root}\n`);
8621
+ return hash.digest("hex");
8622
+ }
8623
+ /** The last-persisted fingerprint, or `undefined` (missing/corrupt — treated as "never resynced"). */
8624
+ function readFingerprint(codexHome) {
8625
+ try {
8626
+ return JSON.parse(readFileSync(manifestPath(codexHome), "utf8")).fingerprint;
8627
+ } catch {
8628
+ return;
8629
+ }
8630
+ }
8631
+ /** Persist the applied fingerprint atomically (tmp+rename via {@link atomicWrite}). */
8632
+ function writeFingerprint(codexHome, value) {
8633
+ atomicWrite(manifestPath(codexHome), JSON.stringify({ fingerprint: value }));
8634
+ }
8635
+ /** True when a direct symlink child of `dir` points at a now-missing target. */
8636
+ function hasDanglingSymlink(dir) {
8637
+ if (!existsSync(dir)) return false;
8638
+ try {
8639
+ for (const name of readdirSync(dir)) {
8640
+ const full = join(dir, name);
8641
+ if (lstatSync(full).isSymbolicLink() && !existsSync(full)) return true;
8642
+ }
8643
+ } catch {}
8644
+ return false;
8645
+ }
8646
+ /**
8647
+ * Resolve the current plugin roots + fingerprint. `undefined` = nothing cached
8648
+ * (fail-open — the initial installer, not this hook, owns that case).
8649
+ * @param pluginsRoot - The plugin cache root.
8650
+ */
8651
+ function resolveCurrentFingerprint(pluginsRoot) {
8652
+ if (!pathExists(pluginsRoot)) return void 0;
8653
+ const roots = buildPluginRoots(pluginsRoot);
8654
+ if (roots.size === 0) return void 0;
8655
+ return {
8656
+ roots,
8657
+ value: fingerprint(roots)
8658
+ };
8659
+ }
8660
+ /** True when a resync is due: fingerprint changed/never recorded, or a command symlink is dangling. */
8661
+ function needsResync(codexHome, currentValue, promptsDir) {
8662
+ return currentValue !== readFingerprint(codexHome) || hasDanglingSymlink(promptsDir);
8663
+ }
8664
+ //#endregion
8665
+ //#region src/runtime/lifecycle/codex-resync/plugin-files.ts
8666
+ /** Files matching `extension` directly under `dir`, tagged with `plugin`. */
8667
+ function filesIn(dir, plugin, extension) {
8668
+ if (!existsSync(dir)) return [];
8669
+ return readdirSync(dir).filter((file) => file.endsWith(extension)).map((file) => ({
8670
+ plugin,
8671
+ file,
8672
+ src: join(dir, file)
8673
+ }));
8674
+ }
8675
+ /** Versioned plugin layout: newest version subdir wins, first match returned. */
8676
+ function listVersionedFiles(pluginRoot, plugin, subdir, extension) {
8677
+ let versions;
8678
+ try {
8679
+ versions = readdirSync(pluginRoot, { withFileTypes: true }).filter((v) => v.isDirectory() && !v.name.startsWith(".")).map((v) => v.name).sort(compareVersionsDesc);
8680
+ } catch {
8681
+ return [];
8682
+ }
8683
+ for (const version of versions) {
8684
+ const found = filesIn(join(pluginRoot, version, subdir), plugin, extension);
8685
+ if (found.length > 0) return found;
8686
+ }
8687
+ return [];
8688
+ }
8689
+ /**
8690
+ * Discover every plugin's files of a given kind (agents `.toml`, commands
8691
+ * `.md`) across a plugins cache root — unversioned layout first, else the
8692
+ * newest version subdir. Ports `plugin-file-discovery.ts::listPluginFiles`.
8693
+ * @param pluginsRoot - The plugin cache root.
8694
+ * @param subdir - `"agents"` or `"commands"`.
8695
+ * @param extension - File extension to match, including the dot.
8696
+ */
8697
+ function listPluginFiles(pluginsRoot, subdir, extension) {
8698
+ const out = [];
8699
+ let entries;
8700
+ try {
8701
+ entries = readdirSync(pluginsRoot, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "_shared").map((e) => e.name);
8702
+ } catch {
8703
+ return out;
8704
+ }
8705
+ for (const plugin of entries) {
8706
+ const direct = filesIn(join(pluginsRoot, plugin, subdir), plugin, extension);
8707
+ if (direct.length > 0) {
8708
+ out.push(...direct);
8709
+ continue;
8710
+ }
8711
+ out.push(...listVersionedFiles(join(pluginsRoot, plugin), plugin, subdir, extension));
8712
+ }
8713
+ return out;
8714
+ }
8715
+ //#endregion
8716
+ //#region src/runtime/lifecycle/codex-resync/managed-destination.ts
8717
+ /** Marker line stamped at the top of a materialized (non-symlinked) agent TOML. */
8718
+ const MANAGED_AGENT_MARKER = "# Managed by fusengine-codex; source:";
8719
+ /** True when a symlink target lives under a plugin cache dir (ours to manage). */
8720
+ function isManagedPluginTarget(target) {
8721
+ return target.includes("/plugins/") || target.includes("\\plugins\\");
8722
+ }
8723
+ /**
8724
+ * Clear a previously-installed destination before reinstalling it, but ONLY
8725
+ * when it is clearly ours: a symlink into a plugin cache dir, or a text file
8726
+ * stamped with {@link MANAGED_AGENT_MARKER}. Anything else (a user's own file,
8727
+ * a foreign symlink) is left untouched. Ports the merged behavior of
8728
+ * `plugin-managed-destination.ts` + the local helper in
8729
+ * `plugin-file-symlinks.ts` (this harness always runs hook-silent, so the
8730
+ * `@clack/prompts` warnings those ports had are dropped, not translated).
8731
+ * @param path - The destination path to check/clear.
8732
+ * @returns `"missing"` (nothing there), `"removed"` (ours, cleared), or
8733
+ * `"skip"` (foreign — left alone).
8734
+ */
8735
+ function clearManagedDestination(path) {
8736
+ try {
8737
+ if (lstatSync(path).isSymbolicLink()) {
8738
+ if (!isManagedPluginTarget(readlinkSync(path))) return "skip";
8739
+ unlinkSync(path);
8740
+ return "removed";
8741
+ }
8742
+ if (readFileSync(path, "utf8").startsWith("# Managed by fusengine-codex; source:")) {
8743
+ unlinkSync(path);
8744
+ return "removed";
8745
+ }
8746
+ return "skip";
8747
+ } catch {
8748
+ return "missing";
8749
+ }
8750
+ }
8751
+ //#endregion
8752
+ //#region src/runtime/lifecycle/codex-resync/materialize-agents.ts
8753
+ const PORTABLE_SKILL_PATH_RE = /^plugins\/([^/]+)\/skills\/(.+)$/;
8754
+ const CACHE_SKILL_PATH_RE = /\/\.codex\/plugins\/cache\/fusengine-codex\/([^/]+)\/[^/]+\/skills\/(.+)$/;
8755
+ function tomlString(value) {
8756
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
8757
+ }
8758
+ /** Rewrite one `path = "..."` value to an absolute path (portable/cache skill refs, or relative-to-source). */
8759
+ function rewriteAgentSkillPath(value, item, pluginRoots) {
8760
+ const portable = PORTABLE_SKILL_PATH_RE.exec(value);
8761
+ if (portable) {
8762
+ const root = pluginRoots.get(portable[1] ?? "");
8763
+ return root ? join(root, "skills", portable[2] ?? "") : value;
8764
+ }
8765
+ const cached = CACHE_SKILL_PATH_RE.exec(value);
8766
+ if (cached) {
8767
+ const root = pluginRoots.get(cached[1] ?? "");
8768
+ return root ? join(root, "skills", cached[2] ?? "") : value;
8769
+ }
8770
+ if (value.startsWith("./") || value.startsWith("../")) return resolve(dirname(item.src), value);
8771
+ return value;
8772
+ }
8773
+ /** Rewrite every `path = "..."` line in an agent TOML + stamp the managed-source marker. */
8774
+ function materializeAgentToml(raw, item, pluginRoots) {
8775
+ const rewritten = raw.replace(/^(\s*path\s*=\s*)"([^"]+)"/gm, (_m, prefix, value) => `${prefix}${tomlString(rewriteAgentSkillPath(value, item, pluginRoots))}`);
8776
+ return rewritten.startsWith("# Managed by fusengine-codex; source:") ? rewritten : `${MANAGED_AGENT_MARKER} ${item.src}\n${rewritten}`;
8777
+ }
8778
+ /**
8779
+ * Materialize agent TOMLs into `destDir` (Codex ignores symlinked agent
8780
+ * TOMLs — openai/codex#15345, never fixed — so these are COPIED, not linked).
8781
+ * Silent: no `@clack/prompts` (this always runs inside a hook, which must
8782
+ * stay stdout-clean). Ports `agent-materializer.ts::materializeAgentFiles`.
8783
+ * @param files - Discovered agent files (see {@link import("./plugin-files").listPluginFiles}).
8784
+ * @param destDir - Destination dir (`<codexHome>/agents`).
8785
+ * @param pluginRoots - Resolved plugin roots, for `path=` rewriting.
8786
+ */
8787
+ function materializeAgentFiles(files, destDir, pluginRoots) {
8788
+ mkdirSync(destDir, { recursive: true });
8789
+ const seen = /* @__PURE__ */ new Set();
8790
+ for (const item of files) {
8791
+ if (seen.has(item.file)) continue;
8792
+ seen.add(item.file);
8793
+ clearManagedDestination(join(destDir, `${item.plugin}-${item.file}`));
8794
+ const destPath = join(destDir, item.file);
8795
+ if (clearManagedDestination(destPath) === "skip") continue;
8796
+ writeFileSync(destPath, materializeAgentToml(readFileSync(item.src, "utf8"), item, pluginRoots));
8797
+ }
8798
+ }
8799
+ //#endregion
8800
+ //#region src/runtime/lifecycle/codex-resync/symlink-commands.ts
8801
+ /**
8802
+ * Symlink command files (`.md`) into `destDir` — unlike agents, Codex has no
8803
+ * known symlink-loading issue for prompts, so these stay linked rather than
8804
+ * copied. Silent, no `@clack/prompts` (see {@link materializeAgentFiles} for
8805
+ * why). Ports `plugin-file-symlinks.ts::symlinkPluginFiles`.
8806
+ * @param files - Discovered command files (see {@link import("./plugin-files").listPluginFiles}).
8807
+ * @param destDir - Destination dir (`<codexHome>/prompts`).
8808
+ */
8809
+ function symlinkPluginFiles(files, destDir) {
8810
+ mkdirSync(destDir, { recursive: true });
8811
+ const seen = /* @__PURE__ */ new Set();
8812
+ for (const item of files) {
8813
+ if (seen.has(item.file)) continue;
8814
+ seen.add(item.file);
8815
+ clearManagedDestination(join(destDir, `${item.plugin}-${item.file}`));
8816
+ const linkPath = join(destDir, item.file);
8817
+ if (clearManagedDestination(linkPath) === "skip") continue;
8818
+ symlinkSync(item.src, linkPath);
8819
+ }
8820
+ }
8821
+ //#endregion
8822
+ //#region src/runtime/lifecycle/codex-resync/resync.ts
8823
+ /** The Codex home dir: `$CODEX_HOME`, else `~/.codex`. */
8824
+ function defaultCodexHome() {
8825
+ return process.env.CODEX_HOME ?? join(homedir(), ".codex");
8826
+ }
8827
+ /**
8828
+ * Re-materialize the Codex plugin agents/commands cache on SessionStart, but
8829
+ * only when the plugin-cache fingerprint changed (or a command symlink dangles)
8830
+ * since the last apply — otherwise a cheap no-op. Agents are COPIED into
8831
+ * `<codexHome>/agents` (Codex won't load symlinked agent TOMLs), commands are
8832
+ * SYMLINKED into `<codexHome>/prompts`. Guarded by a best-effort inter-process
8833
+ * lock so two sessions starting at once can't write a torn cache; the sha256
8834
+ * fingerprint is the real correctness backstop (idempotent skip), the lock only
8835
+ * reduces redundant concurrent rebuilds. ABSOLUTELY fail-open: any error is
8836
+ * swallowed so a resync can never break SessionStart.
8837
+ * @param codexHome - The Codex home directory (defaults to {@link defaultCodexHome}).
8838
+ */
8839
+ function resyncCodexAgents(codexHome = defaultCodexHome()) {
8840
+ try {
8841
+ const pluginsRoot = pluginsCacheRoot(codexHome);
8842
+ const current = resolveCurrentFingerprint(pluginsRoot);
8843
+ if (!current) return;
8844
+ const promptsDir = join(codexHome, "prompts");
8845
+ if (!needsResync(codexHome, current.value, promptsDir)) return;
8846
+ if (!acquireResyncLock(codexHome)) return;
8847
+ try {
8848
+ materializeAgentFiles(listPluginFiles(pluginsRoot, "agents", ".toml"), join(codexHome, "agents"), current.roots);
8849
+ symlinkPluginFiles(listPluginFiles(pluginsRoot, "commands", ".md"), promptsDir);
8850
+ writeFingerprint(codexHome, current.value);
8851
+ } finally {
8852
+ releaseResyncLock(codexHome);
8853
+ }
8854
+ } catch {}
8855
+ }
8856
+ //#endregion
8104
8857
  //#region src/runtime/inject-budget-recap.ts
8105
8858
  /**
8106
8859
  * @module inject-budget-recap
@@ -8164,10 +8917,11 @@ async function handleHook(id, payload, opts) {
8164
8917
  const file = trackFile(event.sessionId, defaultStateDir(opts.cwd));
8165
8918
  const mcpDir = layout.cacheDir;
8166
8919
  const framework = detectFramework(event.filePath ?? "", event.content ?? "");
8167
- if (id === "claude-code" && designLifecycle(payload, mcpDir, opts.cwd, String(opts.now), opts.now)) return {
8920
+ if ((id === "claude-code" || id === "codex") && designLifecycle(payload, mcpDir, opts.cwd, String(opts.now), opts.now)) return {
8168
8921
  stdout: "",
8169
8922
  exit: 0
8170
8923
  };
8924
+ if (id === "codex" && rawEventName(payload) === "SessionStart") resyncCodexAgents();
8171
8925
  const asyncOut = await asyncScopeStdout(opts.scope, rawEventName(payload), payload, opts.cwd, opts.now);
8172
8926
  if (asyncOut !== null) return {
8173
8927
  stdout: asyncOut,
@@ -8206,4 +8960,4 @@ async function handleHook(id, payload, opts) {
8206
8960
  });
8207
8961
  }
8208
8962
  //#endregion
8209
- export { postEditTypescript as $, trackSkillRead as A, trackFile as At, isProject as B, seoPostToolUse as C, gitContext as Ct, postTrackingSideEffects as D, taskContext as Dt, securityAdvisory as E, promptSubmitContext as Et, dispatchLessons as F, securityStateDir as Ft, getFileDesc as G, loadEnriched as H, cartoSessionStart as I, securityStatePath as It, runningVersion as J, listChildren as K, generateEcosystemMap as L, todayUtc as Lt, dispatchLifecycle as M, isoUtc as Mt, aipilotPostToolUse as N, loadSecurityState as Nt, trackWatchResearch as O, defaultStateDir as Ot, dispatchAipilot as P, saveSecurityState as Pt, lessonsStateFileFor as Q, writePluginMap as R, postEditContext as S, devContext as St, dispatchMemory as T, claudeMdKey as Tt, mergeLines as U, writeTree as V, countFiles as W, lessonsArchiveFileFor as X, versionBanner as Y, lessonsFileFor as Z, preCommitGate as _, sessionStartCore as _t, recordActivity as a, validateTeammateOutput as at, extractSymbols as b, removeOldFiles as bt, MCP_TTL_MS as c, validateTailwind as ct, isMcpTool as d, countLoc as dt, trackSessionChanges as et, queryOf as f, detectSolidProfile as ft, gate as g, runSessionStartCleanups as gt, TRIVIAL_BUDGET as h, readRules as ht, respond as i, logToolFailure as it, trackEnrichment as j, normalizeEvent as jt, trackMcpResearch as k, projectHash$1 as kt, WEBFETCH_TTL_MS as l, validateSolidGate as lt, REQUIRED_AGENTS as m, injectRules as mt, activityFor as n, cleanupSession as nt, mcpPostStore as o, trackAgentMemory as ot, DEFAULT_WINDOW_MS as p, solidDetectStart as pt, runDoctor as q, handlePre as r, saveApexState as rt, mcpPreIntercept as s, subagentCacheContext as st, handleHook as t, validateRulesLoaded as tt, cacheQueryOf as u, checkFileSize as ut, detectDuplication as v, pruneEmptyDirs as vt, seoPostToolUseResponse as w, projectContext as wt, lifecycleStdout as x, trimLogFile as xt, dryGate as y, purgeTtlTree as yt, generateProjectMap as z };
8963
+ export { versionBanner as $, trackMcpResearch as A, projectHash$1 as At, generateProjectMap as B, seoPostToolUse as C, devContext as Ct, securityAdvisoryForPatch as D, promptSubmitContext as Dt, securityAdvisory as E, claudeMdKey as Et, dispatchAipilot as F, saveSecurityState as Ft, countFiles as G, writeTree as H, dispatchLessons as I, securityStateDir as It, lessonsArchiveFileFor as J, getFileDesc as K, cartoSessionStart as L, securityStatePath as Lt, trackEnrichment as M, normalizeEvent as Mt, dispatchLifecycle as N, isoUtc as Nt, postTrackingSideEffects as O, taskContext as Ot, aipilotPostToolUse as P, loadSecurityState as Pt, runningVersion as Q, generateEcosystemMap as R, todayUtc as Rt, postEditContext as S, trimLogFile as St, dispatchMemory as T, projectContext as Tt, loadEnriched as U, isProject as V, mergeLines as W, lessonsStateFileFor as X, lessonsFileFor as Y, runDoctor as Z, preCommitGate as _, runSessionStartCleanups as _t, recordActivity as a, logToolFailure as at, extractSymbols as b, purgeTtlTree as bt, MCP_TTL_MS as c, subagentCacheContext as ct, isMcpTool as d, checkFileSize as dt, postEditTypescript as et, queryOf as f, countLoc as ft, gate as g, readRules as gt, TRIVIAL_BUDGET as h, injectRules as ht, respond as i, saveApexState as it, trackSkillRead as j, trackFile as jt, trackWatchResearch as k, defaultStateDir as kt, WEBFETCH_TTL_MS as l, validateTailwind as lt, REQUIRED_AGENTS as m, solidDetectStart as mt, activityFor as n, validateRulesLoaded as nt, mcpPostStore as o, validateTeammateOutput as ot, DEFAULT_WINDOW_MS as p, detectSolidProfile as pt, listChildren as q, handlePre as r, cleanupSession as rt, mcpPreIntercept as s, trackAgentMemory as st, handleHook as t, trackSessionChanges as tt, cacheQueryOf as u, validateSolidGate as ut, detectDuplication as v, sessionStartCore as vt, seoPostToolUseResponse as w, gitContext as wt, lifecycleStdout as x, removeOldFiles as xt, dryGate as y, pruneEmptyDirs as yt, writePluginMap as z };