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