@fusengine/harness 0.1.67 → 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-CxgYAODk.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-Cu6rPxUZ.mjs";
18
- import { r as toHermesResponse } from "./hermes-BpD0nlyR.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
  }
@@ -113,6 +113,7 @@ function normalizeEvent(id, payload) {
113
113
  ...base,
114
114
  filePath: str(input.file_path) ?? str(input.path) ?? str(payload.file_path),
115
115
  content: str(input.content) ?? str(input.new_string),
116
+ oldString: str(input.old_string),
116
117
  command: commandToString(input.command) ?? commandToString(payload.command)
117
118
  };
118
119
  }
@@ -1979,6 +1980,215 @@ function harvestSubagentTrack(payload, cwd, now, baseDir = defaultStateDir(cwd))
1979
1980
  } catch {}
1980
1981
  }
1981
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
1982
2192
  //#region src/runtime/lifecycle/teammate-idle-check.ts
1983
2193
  /**
1984
2194
  * @module teammate-idle-check
@@ -2030,7 +2240,9 @@ function teammateIdleContext(data, cwd, home = homedir(), now = Date.now()) {
2030
2240
  })) notice = `Teammate '${teammate}' idle but expected deliverable(s) not found on disk: ${missing.slice(0, 5).join(", ")} — verify before treating as done.`;
2031
2241
  }
2032
2242
  const merged = [sniper, notice].filter(Boolean).join("\n\n");
2033
- return merged ? contextResponse("TeammateIdle", merged) : "";
2243
+ if (!merged) return "";
2244
+ notify("human");
2245
+ return contextResponse("TeammateIdle", merged);
2034
2246
  }
2035
2247
  //#endregion
2036
2248
  //#region src/policy/lessons/trigger-index.ts
@@ -2674,90 +2886,6 @@ function collectGit(root) {
2674
2886
  return lines.join("\n");
2675
2887
  }
2676
2888
  //#endregion
2677
- //#region src/cli/doctor.ts
2678
- /**
2679
- * `harness doctor` — diagnose which `@fusengine/harness` is actually running.
2680
- *
2681
- * A confirmed, still-open bun bug (oven-sh/bun #5791; scoped-pkg behaviour
2682
- * reinforced by #32019/#32150) makes `bunx <pkg>` (unpinned) prefer a stale
2683
- * GLOBAL install over npm-latest, so a consumer can silently run an old harness
2684
- * after a publish. This command surfaces the truth: the resolved version +
2685
- * package path of the code executing right now, the runtime binary, and the
2686
- * latest version published on npm. It queries the registry over HTTP (not
2687
- * `npm view`, whose exit code is 0 even on an empty result — npm/cli#6408) and
2688
- * never throws: an offline environment yields `latest: null`, never a crash.
2689
- */
2690
- const PKG = "@fusengine/harness";
2691
- /** Walk up from `startDir` for the `@fusengine/harness` `package.json`. */
2692
- function findPackage(startDir) {
2693
- let dir = startDir;
2694
- for (let depth = 0; depth < 6; depth++) {
2695
- try {
2696
- const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
2697
- if (pkg.name === PKG) return {
2698
- version: pkg.version ?? "unknown",
2699
- path: dir
2700
- };
2701
- } catch {}
2702
- const parent = dirname(dir);
2703
- if (parent === dir) break;
2704
- dir = parent;
2705
- }
2706
- return null;
2707
- }
2708
- /** Resolve the running version + package path (no network), from a module URL. */
2709
- function runningVersion(moduleUrl) {
2710
- const found = findPackage(dirname(fileURLToPath(moduleUrl)));
2711
- return {
2712
- version: found?.version ?? "unknown",
2713
- path: found?.path ?? "unknown"
2714
- };
2715
- }
2716
- /** One-line `pkg vX.Y.Z` banner (stderr, only on explicit `--version`/`doctor` commands — never on `hook`, to avoid spamming automated invocations). */
2717
- function versionBanner(moduleUrl) {
2718
- return `${PKG} v${runningVersion(moduleUrl).version}`;
2719
- }
2720
- /** Latest published version via the npm registry HTTP API. `null` on any failure. */
2721
- async function npmLatest() {
2722
- try {
2723
- const res = await fetch(`https://registry.npmjs.org/${PKG}/latest`, { signal: AbortSignal.timeout(8e3) });
2724
- if (!res.ok) return null;
2725
- return (await res.json()).version ?? null;
2726
- } catch {
2727
- return null;
2728
- }
2729
- }
2730
- /** Build the full diagnostic report for the module at `moduleUrl`. */
2731
- async function buildDoctorReport(moduleUrl) {
2732
- const { version, path } = runningVersion(moduleUrl);
2733
- const latest = await npmLatest();
2734
- return {
2735
- running: version,
2736
- packagePath: path,
2737
- runtime: process.execPath,
2738
- latest,
2739
- stale: latest !== null && latest !== version
2740
- };
2741
- }
2742
- /** Render a {@link DoctorReport} as human-readable stdout text. */
2743
- function formatDoctor(r) {
2744
- const lines = [
2745
- `${PKG} doctor`,
2746
- ` running: ${r.running}`,
2747
- ` package: ${r.packagePath}`,
2748
- ` runtime: ${r.runtime}`,
2749
- ` npm latest: ${r.latest ?? "(unavailable — offline or unreachable)"}`
2750
- ];
2751
- if (r.stale) lines.push(` ! stale — npm serves ${r.latest}. Pin "@fusengine/harness@${r.latest}" in hooks.json (see README).`);
2752
- else if (r.latest !== null) lines.push(` ok — running the latest published version.`);
2753
- return lines.join("\n");
2754
- }
2755
- /** Run `harness doctor`: print the diagnostic to stdout. Always resolves 0 (pure info). */
2756
- async function runDoctor(moduleUrl) {
2757
- process.stdout.write(formatDoctor(await buildDoctorReport(moduleUrl)) + "\n");
2758
- return 0;
2759
- }
2760
- //#endregion
2761
2889
  //#region src/runtime/lifecycle/snapshot/version.ts
2762
2890
  /** Read the `version` field of `<root>/package.json`, or `""` if absent/unreadable. */
2763
2891
  function pkgVersion(root) {
@@ -3021,14 +3149,18 @@ function collectViolations(files, max) {
3021
3149
  * Handle TaskCompleted (ports `task-completed/validate-task-solid.py`, plus the
3022
3150
  * receipt gate). SOLID violations surface first as `SOLID VIOLATION`
3023
3151
  * additionalContext; once the files comply, {@link receiptGate} refuses a "done"
3024
- * that has no fresh passing tsc/test receipt.
3025
- * @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`).
3026
3157
  * @param home - Home dir (defaults to `~`).
3027
3158
  * @param now - Clock (defaults to `Date.now()`).
3028
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`).
3029
3161
  * @returns The native hook stdout, or `""` when the session is clean.
3030
3162
  */
3031
- 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") {
3032
3164
  const sid = sanitizeSessionId(payload.session_id ?? "unknown");
3033
3165
  if (!sid) return "";
3034
3166
  const files = loadSessionState(sid, home).changes?.modifiedFiles ?? [];
@@ -3037,7 +3169,7 @@ function validateTaskSolid(payload, home = homedir(), now = Date.now(), stateDir
3037
3169
  const violations = collectViolations(files, max);
3038
3170
  if (violations.length === 0) return receiptGate(sid, files, now, stateDir) ?? "";
3039
3171
  const taskId = String(payload.task_id ?? "");
3040
- 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("; "));
3041
3173
  }
3042
3174
  //#endregion
3043
3175
  //#region src/runtime/lifecycle/cartographer/fs-util.ts
@@ -3431,7 +3563,7 @@ function parseTs$1(line) {
3431
3563
  return Date.UTC(+(m[1] ?? 0), mo - 1, d, +(m[4] ?? 0), +(m[5] ?? 0));
3432
3564
  }
3433
3565
  /** Content words (>=4 chars), timestamp & TRIGGERS marker stripped. */
3434
- function tokenize(text) {
3566
+ function tokenize$1(text) {
3435
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));
3436
3568
  }
3437
3569
  /** Jaccard overlap of two token sets (0 when both empty). */
@@ -3463,7 +3595,7 @@ function parse(content) {
3463
3595
  if (/^-\s/.test(l)) blocks.push({
3464
3596
  raw: [l],
3465
3597
  ts: parseTs$1(l),
3466
- tokens: tokenize(l)
3598
+ tokens: tokenize$1(l)
3467
3599
  });
3468
3600
  else if (l.trim() && last) last.raw.push(l);
3469
3601
  }
@@ -3894,6 +4026,35 @@ function dispatchLessons(event, payload, cwd, now) {
3894
4026
  }
3895
4027
  }
3896
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
3897
4058
  //#region src/runtime/lifecycle/aipilot/inject-apex.ts
3898
4059
  /**
3899
4060
  * SubagentStart (matcher "") for the ai-pilot scope: inject APEX AGENTS.md +
@@ -5091,8 +5252,7 @@ function contextTextOf(response) {
5091
5252
  }
5092
5253
  /**
5093
5254
  * Combine N SubagentStart responses into one (Claude concatenates every hook's
5094
- * additionalContext; collapsing the matcher-"" + type-specific scripts into one
5095
- * 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.
5096
5256
  */
5097
5257
  function combineContext(...responses) {
5098
5258
  const parts = responses.map(contextTextOf).filter(Boolean);
@@ -5111,10 +5271,9 @@ async function typeSpecificCache(agent, cwd, now) {
5111
5271
  return "";
5112
5272
  }
5113
5273
  /**
5114
- * SubagentStart routing. Parity with the Python ai-pilot hooks: the two
5115
- * matcher-"" entries (APEX context + lessons "known issues") fire for EVERY
5116
- * sub-agent, then the type-specific cache (explore/doc/test) is concatenated on
5117
- * 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.
5118
5277
  */
5119
5278
  async function onSubagentStart(payload, cwd, now) {
5120
5279
  const agent = agentTypeOf(payload);
@@ -5138,7 +5297,7 @@ async function onSubagentStop(payload, cwd) {
5138
5297
  async function dispatchAipilot(event, payload, cwd, now) {
5139
5298
  if (event === "SubagentStart") return onSubagentStart(payload, cwd, now);
5140
5299
  if (event === "SubagentStop") return onSubagentStop(payload, cwd);
5141
- if (event === "SessionEnd") {
5300
+ if (event === "SessionEnd" || event === "Stop") {
5142
5301
  await cacheAnalyticsSave(void 0, now);
5143
5302
  return "";
5144
5303
  }
@@ -5176,7 +5335,9 @@ function dispatchLifecycle(input) {
5176
5335
  if (input.scope === "aipilot") return "";
5177
5336
  if (input.scope === "lessons") return dispatchLessons("SubagentStart", input.payload, input.cwd, input.now);
5178
5337
  return subagentCacheContext(input.payload.session_id);
5179
- 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;
5180
5341
  case "SubagentStop":
5181
5342
  if (input.scope === "aipilot") return "";
5182
5343
  harvestSubagentTrack(input.payload, input.cwd, input.now);
@@ -5359,9 +5520,22 @@ function postTrackingSideEffects(scope, event, input, now, payload = {}, cwd = p
5359
5520
  */
5360
5521
  const CODE_RE = /\.(ts|tsx|js|jsx|py|php|swift|go|rs|rb|java)$/;
5361
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
+ }
5362
5533
  /**
5363
- * Build a non-blocking PreToolUse `allow` response with a security advisory when
5364
- * 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.
5365
5539
  * @param tool - The tool name (`Write`/`Edit`).
5366
5540
  * @param filePath - The target file path.
5367
5541
  * @param now - Clock.
@@ -5371,15 +5545,26 @@ const ADVISORY = "SECURITY: Read security skill references before modifying code
5371
5545
  function securityAdvisory(tool, filePath, now = Date.now(), home = homedir()) {
5372
5546
  if (tool !== "Write" && tool !== "Edit") return "";
5373
5547
  if (!CODE_RE.test(filePath)) return "";
5374
- const path = securityStatePath(now, home);
5375
- if (existsSync(path)) try {
5376
- if (JSON.parse(readFileSync(path, "utf-8")).skill_read === true) return "";
5377
- } catch {}
5378
- return JSON.stringify({ hookSpecificOutput: {
5379
- hookEventName: "PreToolUse",
5380
- permissionDecision: "allow",
5381
- additionalContext: ADVISORY
5382
- } });
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 "";
5383
5568
  }
5384
5569
  //#endregion
5385
5570
  //#region src/runtime/lifecycle/memory/client.ts
@@ -5620,7 +5805,7 @@ function isMemoryTool(tool) {
5620
5805
  */
5621
5806
  async function dispatchMemory(event, payload, cwd, now) {
5622
5807
  if (event === "SessionStart") return recallOnSession(cwd, now);
5623
- if (event === "SubagentStop") {
5808
+ if (event === "SubagentStop" || event === "Stop") {
5624
5809
  await captureAgentLesson(payload, now);
5625
5810
  return "";
5626
5811
  }
@@ -5864,7 +6049,8 @@ function existingLineCounts(path) {
5864
6049
  const content = readFileSync(path, "utf8");
5865
6050
  return {
5866
6051
  raw: countLines(content),
5867
- code: countFrameworkCodeLines(content)
6052
+ code: countFrameworkCodeLines(content),
6053
+ content
5868
6054
  };
5869
6055
  } catch {
5870
6056
  return {};
@@ -6805,7 +6991,7 @@ async function runGates(input) {
6805
6991
  if (precommit) return precommit;
6806
6992
  const protectedDeny = protectedPathGate(input.tool, input.filePath);
6807
6993
  if (protectedDeny) return protectedDeny;
6808
- const { raw: existingLines, code: existingCodeLines } = existingLineCounts(input.filePath);
6994
+ const { raw: existingLines, code: existingCodeLines, content: existingContent } = existingLineCounts(input.filePath);
6809
6995
  let quick;
6810
6996
  try {
6811
6997
  quick = evaluate({
@@ -6815,6 +7001,9 @@ async function runGates(input) {
6815
7001
  command: input.command,
6816
7002
  agentType: input.agentType,
6817
7003
  existingLines,
7004
+ existingContent,
7005
+ oldString: input.oldString,
7006
+ isReplaceAll: input.isReplaceAll,
6818
7007
  neverApproval: input.neverApproval
6819
7008
  });
6820
7009
  } catch {
@@ -7673,7 +7862,7 @@ async function handlePre(ctx) {
7673
7862
  exit: 0
7674
7863
  };
7675
7864
  if (opts.scope === "security") return {
7676
- 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),
7677
7866
  exit: 0
7678
7867
  };
7679
7868
  if (opts.scope === "solid") return {
@@ -7704,6 +7893,7 @@ async function handlePre(ctx) {
7704
7893
  cwd: opts.cwd,
7705
7894
  refs: opts.refsDir ? await loadRefs(opts.refsDir) : void 0,
7706
7895
  isReplaceAll: event.input.replace_all === true,
7896
+ oldString: event.oldString,
7707
7897
  agentType: event.agentType,
7708
7898
  agentId: typeof payload.agent_id === "string" ? payload.agent_id : void 0,
7709
7899
  windowMs: opts.windowMs,
@@ -7802,6 +7992,104 @@ function docFramework(input, fallback) {
7802
7992
  return frameworkFromQuery(docQueryOf(input)) ?? fallback;
7803
7993
  }
7804
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
7805
8093
  //#region src/runtime/activity.ts
7806
8094
  /** Min response length (chars) for a lead agent call to count as `sufficient`. */
7807
8095
  const AGENT_QUALITY_MIN = 500;
@@ -7882,6 +8170,11 @@ function activityFor(event) {
7882
8170
  ts: event.now
7883
8171
  });
7884
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
+ });
7885
8178
  return out;
7886
8179
  }
7887
8180
  //#endregion
@@ -7971,11 +8264,126 @@ async function recordCodexSpawnEvidence(file, id, tool, input, ts) {
7971
8264
  if (next !== track) await saveTrack(file, next);
7972
8265
  }
7973
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
7974
8380
  //#region src/runtime/handle-post.ts
7975
8381
  /**
7976
8382
  * Run the PostToolUse pipeline: store the MCP response, emit a design warning,
7977
8383
  * record the activity into the session track, apply per-scope side-effects (SEO
7978
- * 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.
7979
8387
  * @param ctx - The resolved context (same shape as the pre pipeline).
7980
8388
  * @returns The native hook outcome.
7981
8389
  */
@@ -8002,21 +8410,27 @@ async function handlePost(ctx) {
8002
8410
  const exit = Number(r?.exit_code ?? 0);
8003
8411
  await captureReceipt(file, event.command, out, Number.isFinite(exit) ? exit : 0, opts.now);
8004
8412
  }
8005
- 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);
8006
8420
  const seoDeny = opts.scope === "seo" ? seoPostToolUseResponse(payload) : null;
8007
8421
  if (seoDeny) return {
8008
8422
  stdout: seoDeny,
8009
8423
  exit: 0
8010
8424
  };
8011
- if (opts.scope === "solid" && event.filePath) {
8012
- const solidWarn = checkFileSize(event.tool, event.filePath);
8425
+ if (opts.scope === "solid") {
8426
+ const solidWarn = firstFileMatch(files, checkFileSize);
8013
8427
  if (solidWarn) return {
8014
8428
  stdout: solidWarn,
8015
8429
  exit: 0
8016
8430
  };
8017
8431
  }
8018
- if (opts.scope === "tailwindcss" && event.filePath) {
8019
- const tailwindWarn = validateTailwind(event.tool, event.filePath);
8432
+ if (opts.scope === "tailwindcss") {
8433
+ const tailwindWarn = firstFileMatch(files, validateTailwind);
8020
8434
  if (tailwindWarn) return {
8021
8435
  stdout: tailwindWarn,
8022
8436
  exit: 0
@@ -8029,7 +8443,11 @@ async function handlePost(ctx) {
8029
8443
  exit: 0
8030
8444
  };
8031
8445
  }
8032
- 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
+ }
8033
8451
  const notice = designPassNotice({
8034
8452
  agentId: typeof payload.agent_id === "string" ? payload.agent_id : "",
8035
8453
  tool: event.tool,
@@ -8095,6 +8513,347 @@ async function asyncScopeStdout(scope, event, payload, cwd, now) {
8095
8513
  return null;
8096
8514
  }
8097
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
8098
8857
  //#region src/runtime/inject-budget-recap.ts
8099
8858
  /**
8100
8859
  * @module inject-budget-recap
@@ -8158,10 +8917,11 @@ async function handleHook(id, payload, opts) {
8158
8917
  const file = trackFile(event.sessionId, defaultStateDir(opts.cwd));
8159
8918
  const mcpDir = layout.cacheDir;
8160
8919
  const framework = detectFramework(event.filePath ?? "", event.content ?? "");
8161
- 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 {
8162
8921
  stdout: "",
8163
8922
  exit: 0
8164
8923
  };
8924
+ if (id === "codex" && rawEventName(payload) === "SessionStart") resyncCodexAgents();
8165
8925
  const asyncOut = await asyncScopeStdout(opts.scope, rawEventName(payload), payload, opts.cwd, opts.now);
8166
8926
  if (asyncOut !== null) return {
8167
8927
  stdout: asyncOut,
@@ -8200,4 +8960,4 @@ async function handleHook(id, payload, opts) {
8200
8960
  });
8201
8961
  }
8202
8962
  //#endregion
8203
- 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 };