@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.
- package/README.md +74 -5
- package/assets/song/finish.mp3 +0 -0
- package/assets/song/need-human.mp3 +0 -0
- package/assets/song/permission-need.mp3 +0 -0
- package/dist/adapters/claude/index.mjs +1 -1
- package/dist/adapters/cline/index.mjs +1 -1
- package/dist/adapters/codex/index.mjs +2 -2
- package/dist/adapters/cursor/index.mjs +1 -1
- package/dist/adapters/gemini/index.mjs +1 -1
- package/dist/adapters/hermes/index.mjs +1 -1
- package/dist/{claude-Cu6rPxUZ.mjs → claude-CHpb1U0A.mjs} +1 -1
- package/dist/cli/bin.mjs +3 -3
- package/dist/cli/index.mjs +1 -1
- package/dist/{evaluate-CxgYAODk.mjs → evaluate-Dx6q1kmQ.mjs} +96 -25
- package/dist/{handle-CU5UdAei.mjs → handle-CVk0NlTg.mjs} +889 -129
- package/dist/{hermes-BpD0nlyR.mjs → hermes-CddsFoPN.mjs} +2 -2
- package/dist/{index-CU3oHxOe.d.mts → index-DzOJraAT.d.mts} +6 -0
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/policy/index.d.mts +1 -1
- package/dist/policy/index.mjs +1 -1
- package/dist/{run-BfJytXnW.mjs → run-B9Br7SEr.mjs} +1 -1
- package/dist/runtime/index.d.mts +21 -3
- package/dist/runtime/index.mjs +2 -2
- package/package.json +2 -1
|
@@ -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-
|
|
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-
|
|
18
|
-
import { r as toHermesResponse } from "./hermes-
|
|
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:
|
|
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
|
-
|
|
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
|
-
*
|
|
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(
|
|
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;
|
|
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
|
|
5115
|
-
*
|
|
5116
|
-
*
|
|
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":
|
|
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
|
|
5364
|
-
*
|
|
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
|
-
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
|
|
5380
|
-
|
|
5381
|
-
|
|
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
|
-
|
|
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"
|
|
8012
|
-
const solidWarn =
|
|
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"
|
|
8019
|
-
const tailwindWarn =
|
|
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
|
-
|
|
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 {
|
|
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 };
|