Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions .plans/solidify-project-hooks.md

Large diffs are not rendered by default.

219 changes: 219 additions & 0 deletions apps/server/src/hooks/T3HookRunner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import type * as LogLevel from "effect/LogLevel";
import * as Logger from "effect/Logger";
import * as Path from "effect/Path";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";

Expand All@@ -28,6 +30,18 @@ const successfulOutput = (stdout: string): ProcessRunOutput => ({
stderrInvalidUtf8: false,
});

function writeHooksConfig(root: string, hooks: unknown) {
return Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const configDirectory = path.join(root, ".t3code");
yield* fileSystem.makeDirectory(configDirectory, { recursive: true });
const configPath = path.join(configDirectory, "hooks.json");
yield* fileSystem.writeFileString(configPath, encodeJson({ hooks }));
return configPath;
});
}

function testLayer(run: (input: ProcessRunInput) => Effect.Effect<ProcessRunOutput>) {
return Layer.effect(T3HookRunner.T3HookRunner, T3HookRunner.make()).pipe(
Layer.provide(
Expand DownExpand Up@@ -195,4 +209,209 @@ describe("T3HookRunner", () => {
}),
);
});

it.layer(
testLayer(() =>
Effect.succeed({
...successfulOutput(""),
code: 2 as ProcessRunOutput["code"],
stderr: "Hook added while the session was running.",
}),
),
)("picks up a config created after prepare", (it) => {
it.effect("reports live hooks and evaluates them", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.equal(plan.configPath, undefined);
assert.isFalse(plan.hasPreToolUseHooks);
assert.isFalse(yield* plan.hasPreToolUseHooksNow);

yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

assert.isTrue(yield* plan.hasPreToolUseHooksNow);
const decision = yield* plan.evaluatePreToolUse({
provider: "claudeAgent",
threadId: "thread-created",
toolName: "Bash",
toolInput: { command: "git push" },
});

assert.deepEqual(decision, {
decision: "deny",
reason: "Hook added while the session was running.",
});
}),
);
});

it.layer(testLayer(() => Effect.die("a deleted config must not run a hook")))(
"picks up a config deleted after prepare",
(it) => {
it.effect("reports no live hooks and allows the tool call", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
const configPath = yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.isTrue(plan.hasPreToolUseHooks);
assert.isTrue(yield* plan.hasPreToolUseHooksNow);

yield* fileSystem.remove(configPath);

assert.isFalse(yield* plan.hasPreToolUseHooksNow);
assert.deepEqual(
yield* plan.evaluatePreToolUse({
provider: "claudeAgent",
threadId: "thread-deleted",
toolName: "Bash",
toolInput: { command: "git push" },
}),
{ decision: "allow" },
);
}),
);
},
);

it.layer(
testLayer(() =>
Effect.succeed(
successfulOutput(encodeJson({ decision: "ask", reason: "Bash needs confirmation." })),
),
),
)("applies an edited matcher without preparing again", (it) => {
it.effect("re-reads the config before each evaluation", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
const bashCall = {
provider: "claudeAgent",
threadId: "thread-edited",
toolName: "Bash",
toolInput: { command: "git push" },
};

assert.deepEqual(yield* plan.evaluatePreToolUse(bashCall), {
decision: "ask",
reason: "Bash needs confirmation.",
});

yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Write", hooks: [{ type: "command", command: "policy" }] }],
});

assert.deepEqual(yield* plan.evaluatePreToolUse(bashCall), { decision: "allow" });
}),
);
});

it.layer(testLayer(() => Effect.die("an unreadable config must not run a hook")))(
"fails closed when the config becomes invalid mid-session",
(it) => {
it.effect("reports live hooks and asks for confirmation", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.isFalse(plan.hasPreToolUseHooks);

const configDirectory = path.join(root, ".t3code");
yield* fileSystem.makeDirectory(configDirectory, { recursive: true });
const configPath = path.join(configDirectory, "hooks.json");
yield* fileSystem.writeFileString(configPath, "{ not json");

assert.isTrue(yield* plan.hasPreToolUseHooksNow);
assert.deepEqual(
yield* plan.evaluatePreToolUse({
provider: "codex",
threadId: "thread-invalid",
toolName: "Bash",
toolInput: { command: "git push" },
}),
{
decision: "ask",
title: "T3 hook config failed",
reason: `T3 project hooks could not be loaded from ${configPath}.`,
},
);
}),
);
},
);

it.layer(
testLayer(() =>
Effect.succeed(successfulOutput(encodeJson({ decision: "ask", reason: "Reviewed." }))),
),
)("warns about hook events it does not implement", (it) => {
it.effect("warns once and still runs PreToolUse", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
const configPath = yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
PostToolUse: [{ hooks: [{ type: "command", command: "after" }] }],
Stop: [{ hooks: [{ type: "command", command: "stop" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const records: Array<{ readonly logLevel: LogLevel.LogLevel; readonly message: unknown }> =
[];
const logger = Logger.make<unknown, void>(({ logLevel, message }) => {
records.push({ logLevel, message });
});

const decisions = yield* Effect.gen(function* () {
const plan = yield* runner.prepare(root);
const call = {
provider: "claudeAgent",
threadId: "thread-unsupported",
toolName: "Bash",
toolInput: { command: "git push" },
};
const initialDecisions = [
yield* plan.evaluatePreToolUse(call),
yield* plan.evaluatePreToolUse(call),
];
yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
"PostToolUse,Stop": [{ hooks: [{ type: "command", command: "combined" }] }],
});
return [...initialDecisions, yield* plan.evaluatePreToolUse(call)];
}).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));

const warnings = records.filter((record) => record.logLevel === "Warn");
assert.equal(warnings.length, 2);
const rendered = encodeJson(warnings[0]?.message);
assert.match(rendered, /PostToolUse/);
assert.match(rendered, /Stop/);
assert.include(rendered, encodeJson(configPath));
assert.match(encodeJson(warnings[1]?.message), /PostToolUse,Stop/);
assert.deepEqual(decisions, [
{ decision: "ask", reason: "Reviewed." },
{ decision: "ask", reason: "Reviewed." },
{ decision: "ask", reason: "Reviewed." },
]);
}),
);
});
});
113 changes: 92 additions & 21 deletions apps/server/src/hooks/T3HookRunner.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,6 +35,15 @@ const HooksConfig = Schema.Struct({
const HooksConfigJson = fromLenientJson(HooksConfig);
const decodeHooksConfigJson = Schema.decodeUnknownEffect(HooksConfigJson);

const HooksConfigEventKeys = Schema.Struct({
hooks: Schema.Record(Schema.String, Schema.Unknown),
});
const decodeHooksConfigEventKeysJson = Schema.decodeUnknownEffect(
fromLenientJson(HooksConfigEventKeys),
);

const SUPPORTED_HOOK_EVENTS = ["PreToolUse"] as const;

const HookSpecificOutput = Schema.Struct({
hookEventName: Schema.optional(Schema.String),
permissionDecision: Schema.optional(Schema.Literals(["allow", "ask", "deny"])),
Expand DownExpand Up@@ -87,6 +96,7 @@ export interface T3PreToolUseInput {
export interface T3HookPlan {
readonly configPath: string | undefined;
readonly hasPreToolUseHooks: boolean;
readonly hasPreToolUseHooksNow: Effect.Effect<boolean>;
readonly evaluatePreToolUse: (
input: Omit<T3PreToolUseInput, "cwd">,
) => Effect.Effect<T3HookDecision, T3HookCommandError>;
Expand DownExpand Up@@ -139,6 +149,14 @@ function normalizedDecision(output: typeof HookCommandOutput.Type): T3HookDecisi
};
}

function logConfigFailure(error: T3HookConfigError) {
return Effect.logWarning("T3 project hooks could not be loaded", {
path: error.configPath,
operation: error.operation,
cause: error.cause,
});
}

function expandProjectDirectory(command: string, projectDirectory: string): string {
return command
.replaceAll("${T3_PROJECT_DIR}", projectDirectory)
Expand DownExpand Up@@ -182,6 +200,7 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
const path = yield* Path.Path;
const processRunner = yield* ProcessRunner;
const platform = yield* HostProcessPlatform;
const warnedUnsupportedEvents = new Set<string>();

const findConfigPath = Effect.fn("T3HookRunner.findConfigPath")(function* (cwd: string) {
let current = cwd;
Expand DownExpand Up@@ -242,6 +261,28 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
});
}
}

const declaredEvents = yield* decodeHooksConfigEventKeysJson(raw).pipe(
Effect.map((decoded) => Object.keys(decoded.hooks)),
Effect.orElseSucceed(() => [] as ReadonlyArray<string>),
);
const unsupportedEvents = declaredEvents
.filter((event) => !SUPPORTED_HOOK_EVENTS.some((supported) => supported === event))
.sort();
if (unsupportedEvents.length > 0) {
const warningKey = [configPath, ...unsupportedEvents]
.map((part) => `${part.length}:${part}`)
.join("");
if (!warnedUnsupportedEvents.has(warningKey)) {
warnedUnsupportedEvents.add(warningKey);
yield* Effect.logWarning("ignoring unsupported T3 hook events", {
path: configPath,
unsupportedEvents,
supportedEvents: SUPPORTED_HOOK_EVENTS,
});
}
}

return config;
});

Expand DownExpand Up@@ -342,31 +383,61 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
return { decision: "allow" } satisfies T3HookDecision;
});

const resolvePlanState = Effect.fn("T3HookRunner.resolvePlanState")(function* (cwd: string) {
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
const configPathOption = yield* findConfigPath(cwd);
if (Option.isNone(configPathOption)) {
return {
configPath: undefined,
entries: [] as ReadonlyArray<HookMatcherConfig>,
projectDirectory: undefined,
};
}

const configPath = configPathOption.value;
const config = yield* readConfig(configPath);
return {
configPath,
entries: config.hooks.PreToolUse ?? ([] as ReadonlyArray<HookMatcherConfig>),
projectDirectory: path.dirname(path.dirname(configPath)),
};
});

const prepare: T3HookRunner["Service"]["prepare"] = Effect.fn("T3HookRunner.prepare")(
function* (cwd) {
const configPathOption = yield* findConfigPath(cwd);
if (Option.isNone(configPathOption)) {
return {
configPath: undefined,
hasPreToolUseHooks: false,
evaluatePreToolUse: () => Effect.succeed({ decision: "allow" as const }),
} satisfies T3HookPlan;
}

const configPath = configPathOption.value;
const config = yield* readConfig(configPath);
const entries = config.hooks.PreToolUse ?? [];
const projectDirectory = path.dirname(path.dirname(configPath));
const snapshot = yield* resolvePlanState(cwd);
const snapshotHasHooks = snapshot.entries.length > 0;
return {
configPath,
hasPreToolUseHooks: entries.length > 0,
configPath: snapshot.configPath,
hasPreToolUseHooks: snapshotHasHooks,
hasPreToolUseHooksNow: resolvePlanState(cwd).pipe(
Effect.map((state) => state.entries.length > 0),
Effect.catchTag("T3HookConfigError", (error) =>
logConfigFailure(error).pipe(Effect.as(true)),
),
),
evaluatePreToolUse: (input) =>
evaluateEntries({
entries,
configPath,
projectDirectory,
payload: { ...input, cwd },
}),
Effect.gen(function* () {
const state = yield* resolvePlanState(cwd);
if (state.configPath === undefined) {
return { decision: "allow" } satisfies T3HookDecision;
}
return yield* evaluateEntries({
entries: state.entries,
configPath: state.configPath,
projectDirectory: state.projectDirectory,
payload: { ...input, cwd },
});
}).pipe(
Effect.catchTag("T3HookConfigError", (error) =>
logConfigFailure(error).pipe(
Effect.as({
decision: "ask",
title: "T3 hook config failed",
reason: `T3 project hooks could not be loaded from ${error.configPath}.`,
} satisfies T3HookDecision),
),
),
),
} satisfies T3HookPlan;
},
);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions .plans/solidify-project-hooks.md

Large diffs are not rendered by default.

219 changes: 219 additions & 0 deletions apps/server/src/hooks/T3HookRunner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import type * as LogLevel from "effect/LogLevel";
import * as Logger from "effect/Logger";
import * as Path from "effect/Path";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";

Expand All@@ -28,6 +30,18 @@ const successfulOutput = (stdout: string): ProcessRunOutput => ({
stderrInvalidUtf8: false,
});

function writeHooksConfig(root: string, hooks: unknown) {
return Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const configDirectory = path.join(root, ".t3code");
yield* fileSystem.makeDirectory(configDirectory, { recursive: true });
const configPath = path.join(configDirectory, "hooks.json");
yield* fileSystem.writeFileString(configPath, encodeJson({ hooks }));
return configPath;
});
}

function testLayer(run: (input: ProcessRunInput) => Effect.Effect<ProcessRunOutput>) {
return Layer.effect(T3HookRunner.T3HookRunner, T3HookRunner.make()).pipe(
Layer.provide(
Expand DownExpand Up@@ -195,4 +209,209 @@ describe("T3HookRunner", () => {
}),
);
});

it.layer(
testLayer(() =>
Effect.succeed({
...successfulOutput(""),
code: 2 as ProcessRunOutput["code"],
stderr: "Hook added while the session was running.",
}),
),
)("picks up a config created after prepare", (it) => {
it.effect("reports live hooks and evaluates them", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.equal(plan.configPath, undefined);
assert.isFalse(plan.hasPreToolUseHooks);
assert.isFalse(yield* plan.hasPreToolUseHooksNow);

yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

assert.isTrue(yield* plan.hasPreToolUseHooksNow);
const decision = yield* plan.evaluatePreToolUse({
provider: "claudeAgent",
threadId: "thread-created",
toolName: "Bash",
toolInput: { command: "git push" },
});

assert.deepEqual(decision, {
decision: "deny",
reason: "Hook added while the session was running.",
});
}),
);
});

it.layer(testLayer(() => Effect.die("a deleted config must not run a hook")))(
"picks up a config deleted after prepare",
(it) => {
it.effect("reports no live hooks and allows the tool call", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
const configPath = yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.isTrue(plan.hasPreToolUseHooks);
assert.isTrue(yield* plan.hasPreToolUseHooksNow);

yield* fileSystem.remove(configPath);

assert.isFalse(yield* plan.hasPreToolUseHooksNow);
assert.deepEqual(
yield* plan.evaluatePreToolUse({
provider: "claudeAgent",
threadId: "thread-deleted",
toolName: "Bash",
toolInput: { command: "git push" },
}),
{ decision: "allow" },
);
}),
);
},
);

it.layer(
testLayer(() =>
Effect.succeed(
successfulOutput(encodeJson({ decision: "ask", reason: "Bash needs confirmation." })),
),
),
)("applies an edited matcher without preparing again", (it) => {
it.effect("re-reads the config before each evaluation", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
const bashCall = {
provider: "claudeAgent",
threadId: "thread-edited",
toolName: "Bash",
toolInput: { command: "git push" },
};

assert.deepEqual(yield* plan.evaluatePreToolUse(bashCall), {
decision: "ask",
reason: "Bash needs confirmation.",
});

yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Write", hooks: [{ type: "command", command: "policy" }] }],
});

assert.deepEqual(yield* plan.evaluatePreToolUse(bashCall), { decision: "allow" });
}),
);
});

it.layer(testLayer(() => Effect.die("an unreadable config must not run a hook")))(
"fails closed when the config becomes invalid mid-session",
(it) => {
it.effect("reports live hooks and asks for confirmation", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.isFalse(plan.hasPreToolUseHooks);

const configDirectory = path.join(root, ".t3code");
yield* fileSystem.makeDirectory(configDirectory, { recursive: true });
const configPath = path.join(configDirectory, "hooks.json");
yield* fileSystem.writeFileString(configPath, "{ not json");

assert.isTrue(yield* plan.hasPreToolUseHooksNow);
assert.deepEqual(
yield* plan.evaluatePreToolUse({
provider: "codex",
threadId: "thread-invalid",
toolName: "Bash",
toolInput: { command: "git push" },
}),
{
decision: "ask",
title: "T3 hook config failed",
reason: `T3 project hooks could not be loaded from ${configPath}.`,
},
);
}),
);
},
);

it.layer(
testLayer(() =>
Effect.succeed(successfulOutput(encodeJson({ decision: "ask", reason: "Reviewed." }))),
),
)("warns about hook events it does not implement", (it) => {
it.effect("warns once and still runs PreToolUse", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
const configPath = yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
PostToolUse: [{ hooks: [{ type: "command", command: "after" }] }],
Stop: [{ hooks: [{ type: "command", command: "stop" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const records: Array<{ readonly logLevel: LogLevel.LogLevel; readonly message: unknown }> =
[];
const logger = Logger.make<unknown, void>(({ logLevel, message }) => {
records.push({ logLevel, message });
});

const decisions = yield* Effect.gen(function* () {
const plan = yield* runner.prepare(root);
const call = {
provider: "claudeAgent",
threadId: "thread-unsupported",
toolName: "Bash",
toolInput: { command: "git push" },
};
const initialDecisions = [
yield* plan.evaluatePreToolUse(call),
yield* plan.evaluatePreToolUse(call),
];
yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
"PostToolUse,Stop": [{ hooks: [{ type: "command", command: "combined" }] }],
});
return [...initialDecisions, yield* plan.evaluatePreToolUse(call)];
}).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));

const warnings = records.filter((record) => record.logLevel === "Warn");
assert.equal(warnings.length, 2);
const rendered = encodeJson(warnings[0]?.message);
assert.match(rendered, /PostToolUse/);
assert.match(rendered, /Stop/);
assert.include(rendered, encodeJson(configPath));
assert.match(encodeJson(warnings[1]?.message), /PostToolUse,Stop/);
assert.deepEqual(decisions, [
{ decision: "ask", reason: "Reviewed." },
{ decision: "ask", reason: "Reviewed." },
{ decision: "ask", reason: "Reviewed." },
]);
}),
);
});
});
113 changes: 92 additions & 21 deletions apps/server/src/hooks/T3HookRunner.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,6 +35,15 @@ const HooksConfig = Schema.Struct({
const HooksConfigJson = fromLenientJson(HooksConfig);
const decodeHooksConfigJson = Schema.decodeUnknownEffect(HooksConfigJson);

const HooksConfigEventKeys = Schema.Struct({
hooks: Schema.Record(Schema.String, Schema.Unknown),
});
const decodeHooksConfigEventKeysJson = Schema.decodeUnknownEffect(
fromLenientJson(HooksConfigEventKeys),
);

const SUPPORTED_HOOK_EVENTS = ["PreToolUse"] as const;

const HookSpecificOutput = Schema.Struct({
hookEventName: Schema.optional(Schema.String),
permissionDecision: Schema.optional(Schema.Literals(["allow", "ask", "deny"])),
Expand DownExpand Up@@ -87,6 +96,7 @@ export interface T3PreToolUseInput {
export interface T3HookPlan {
readonly configPath: string | undefined;
readonly hasPreToolUseHooks: boolean;
readonly hasPreToolUseHooksNow: Effect.Effect<boolean>;
readonly evaluatePreToolUse: (
input: Omit<T3PreToolUseInput, "cwd">,
) => Effect.Effect<T3HookDecision, T3HookCommandError>;
Expand DownExpand Up@@ -139,6 +149,14 @@ function normalizedDecision(output: typeof HookCommandOutput.Type): T3HookDecisi
};
}

function logConfigFailure(error: T3HookConfigError) {
return Effect.logWarning("T3 project hooks could not be loaded", {
path: error.configPath,
operation: error.operation,
cause: error.cause,
});
}

function expandProjectDirectory(command: string, projectDirectory: string): string {
return command
.replaceAll("${T3_PROJECT_DIR}", projectDirectory)
Expand DownExpand Up@@ -182,6 +200,7 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
const path = yield* Path.Path;
const processRunner = yield* ProcessRunner;
const platform = yield* HostProcessPlatform;
const warnedUnsupportedEvents = new Set<string>();

const findConfigPath = Effect.fn("T3HookRunner.findConfigPath")(function* (cwd: string) {
let current = cwd;
Expand DownExpand Up@@ -242,6 +261,28 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
});
}
}

const declaredEvents = yield* decodeHooksConfigEventKeysJson(raw).pipe(
Effect.map((decoded) => Object.keys(decoded.hooks)),
Effect.orElseSucceed(() => [] as ReadonlyArray<string>),
);
const unsupportedEvents = declaredEvents
.filter((event) => !SUPPORTED_HOOK_EVENTS.some((supported) => supported === event))
.sort();
if (unsupportedEvents.length > 0) {
const warningKey = [configPath, ...unsupportedEvents]
.map((part) => `${part.length}:${part}`)
.join("");
if (!warnedUnsupportedEvents.has(warningKey)) {
warnedUnsupportedEvents.add(warningKey);
yield* Effect.logWarning("ignoring unsupported T3 hook events", {
path: configPath,
unsupportedEvents,
supportedEvents: SUPPORTED_HOOK_EVENTS,
});
}
}

return config;
});

Expand DownExpand Up@@ -342,31 +383,61 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
return { decision: "allow" } satisfies T3HookDecision;
});

const resolvePlanState = Effect.fn("T3HookRunner.resolvePlanState")(function* (cwd: string) {
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
const configPathOption = yield* findConfigPath(cwd);
if (Option.isNone(configPathOption)) {
return {
configPath: undefined,
entries: [] as ReadonlyArray<HookMatcherConfig>,
projectDirectory: undefined,
};
}

const configPath = configPathOption.value;
const config = yield* readConfig(configPath);
return {
configPath,
entries: config.hooks.PreToolUse ?? ([] as ReadonlyArray<HookMatcherConfig>),
projectDirectory: path.dirname(path.dirname(configPath)),
};
});

const prepare: T3HookRunner["Service"]["prepare"] = Effect.fn("T3HookRunner.prepare")(
function* (cwd) {
const configPathOption = yield* findConfigPath(cwd);
if (Option.isNone(configPathOption)) {
return {
configPath: undefined,
hasPreToolUseHooks: false,
evaluatePreToolUse: () => Effect.succeed({ decision: "allow" as const }),
} satisfies T3HookPlan;
}

const configPath = configPathOption.value;
const config = yield* readConfig(configPath);
const entries = config.hooks.PreToolUse ?? [];
const projectDirectory = path.dirname(path.dirname(configPath));
const snapshot = yield* resolvePlanState(cwd);
const snapshotHasHooks = snapshot.entries.length > 0;
return {
configPath,
hasPreToolUseHooks: entries.length > 0,
configPath: snapshot.configPath,
hasPreToolUseHooks: snapshotHasHooks,
hasPreToolUseHooksNow: resolvePlanState(cwd).pipe(
Effect.map((state) => state.entries.length > 0),
Effect.catchTag("T3HookConfigError", (error) =>
logConfigFailure(error).pipe(Effect.as(true)),
),
),
evaluatePreToolUse: (input) =>
evaluateEntries({
entries,
configPath,
projectDirectory,
payload: { ...input, cwd },
}),
Effect.gen(function* () {
const state = yield* resolvePlanState(cwd);
if (state.configPath === undefined) {
return { decision: "allow" } satisfies T3HookDecision;
}
return yield* evaluateEntries({
entries: state.entries,
configPath: state.configPath,
projectDirectory: state.projectDirectory,
payload: { ...input, cwd },
});
}).pipe(
Effect.catchTag("T3HookConfigError", (error) =>
logConfigFailure(error).pipe(
Effect.as({
decision: "ask",
title: "T3 hook config failed",
reason: `T3 project hooks could not be loaded from ${error.configPath}.`,
} satisfies T3HookDecision),
),
),
),
} satisfies T3HookPlan;
},
);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions .plans/solidify-project-hooks.md

Large diffs are not rendered by default.

219 changes: 219 additions & 0 deletions apps/server/src/hooks/T3HookRunner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import type * as LogLevel from "effect/LogLevel";
import * as Logger from "effect/Logger";
import * as Path from "effect/Path";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";

Expand All@@ -28,6 +30,18 @@ const successfulOutput = (stdout: string): ProcessRunOutput => ({
stderrInvalidUtf8: false,
});

function writeHooksConfig(root: string, hooks: unknown) {
return Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const configDirectory = path.join(root, ".t3code");
yield* fileSystem.makeDirectory(configDirectory, { recursive: true });
const configPath = path.join(configDirectory, "hooks.json");
yield* fileSystem.writeFileString(configPath, encodeJson({ hooks }));
return configPath;
});
}

function testLayer(run: (input: ProcessRunInput) => Effect.Effect<ProcessRunOutput>) {
return Layer.effect(T3HookRunner.T3HookRunner, T3HookRunner.make()).pipe(
Layer.provide(
Expand DownExpand Up@@ -195,4 +209,209 @@ describe("T3HookRunner", () => {
}),
);
});

it.layer(
testLayer(() =>
Effect.succeed({
...successfulOutput(""),
code: 2 as ProcessRunOutput["code"],
stderr: "Hook added while the session was running.",
}),
),
)("picks up a config created after prepare", (it) => {
it.effect("reports live hooks and evaluates them", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.equal(plan.configPath, undefined);
assert.isFalse(plan.hasPreToolUseHooks);
assert.isFalse(yield* plan.hasPreToolUseHooksNow);

yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

assert.isTrue(yield* plan.hasPreToolUseHooksNow);
const decision = yield* plan.evaluatePreToolUse({
provider: "claudeAgent",
threadId: "thread-created",
toolName: "Bash",
toolInput: { command: "git push" },
});

assert.deepEqual(decision, {
decision: "deny",
reason: "Hook added while the session was running.",
});
}),
);
});

it.layer(testLayer(() => Effect.die("a deleted config must not run a hook")))(
"picks up a config deleted after prepare",
(it) => {
it.effect("reports no live hooks and allows the tool call", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
const configPath = yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.isTrue(plan.hasPreToolUseHooks);
assert.isTrue(yield* plan.hasPreToolUseHooksNow);

yield* fileSystem.remove(configPath);

assert.isFalse(yield* plan.hasPreToolUseHooksNow);
assert.deepEqual(
yield* plan.evaluatePreToolUse({
provider: "claudeAgent",
threadId: "thread-deleted",
toolName: "Bash",
toolInput: { command: "git push" },
}),
{ decision: "allow" },
);
}),
);
},
);

it.layer(
testLayer(() =>
Effect.succeed(
successfulOutput(encodeJson({ decision: "ask", reason: "Bash needs confirmation." })),
),
),
)("applies an edited matcher without preparing again", (it) => {
it.effect("re-reads the config before each evaluation", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
const bashCall = {
provider: "claudeAgent",
threadId: "thread-edited",
toolName: "Bash",
toolInput: { command: "git push" },
};

assert.deepEqual(yield* plan.evaluatePreToolUse(bashCall), {
decision: "ask",
reason: "Bash needs confirmation.",
});

yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Write", hooks: [{ type: "command", command: "policy" }] }],
});

assert.deepEqual(yield* plan.evaluatePreToolUse(bashCall), { decision: "allow" });
}),
);
});

it.layer(testLayer(() => Effect.die("an unreadable config must not run a hook")))(
"fails closed when the config becomes invalid mid-session",
(it) => {
it.effect("reports live hooks and asks for confirmation", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.isFalse(plan.hasPreToolUseHooks);

const configDirectory = path.join(root, ".t3code");
yield* fileSystem.makeDirectory(configDirectory, { recursive: true });
const configPath = path.join(configDirectory, "hooks.json");
yield* fileSystem.writeFileString(configPath, "{ not json");

assert.isTrue(yield* plan.hasPreToolUseHooksNow);
assert.deepEqual(
yield* plan.evaluatePreToolUse({
provider: "codex",
threadId: "thread-invalid",
toolName: "Bash",
toolInput: { command: "git push" },
}),
{
decision: "ask",
title: "T3 hook config failed",
reason: `T3 project hooks could not be loaded from ${configPath}.`,
},
);
}),
);
},
);

it.layer(
testLayer(() =>
Effect.succeed(successfulOutput(encodeJson({ decision: "ask", reason: "Reviewed." }))),
),
)("warns about hook events it does not implement", (it) => {
it.effect("warns once and still runs PreToolUse", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
const configPath = yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
PostToolUse: [{ hooks: [{ type: "command", command: "after" }] }],
Stop: [{ hooks: [{ type: "command", command: "stop" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const records: Array<{ readonly logLevel: LogLevel.LogLevel; readonly message: unknown }> =
[];
const logger = Logger.make<unknown, void>(({ logLevel, message }) => {
records.push({ logLevel, message });
});

const decisions = yield* Effect.gen(function* () {
const plan = yield* runner.prepare(root);
const call = {
provider: "claudeAgent",
threadId: "thread-unsupported",
toolName: "Bash",
toolInput: { command: "git push" },
};
const initialDecisions = [
yield* plan.evaluatePreToolUse(call),
yield* plan.evaluatePreToolUse(call),
];
yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
"PostToolUse,Stop": [{ hooks: [{ type: "command", command: "combined" }] }],
});
return [...initialDecisions, yield* plan.evaluatePreToolUse(call)];
}).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));

const warnings = records.filter((record) => record.logLevel === "Warn");
assert.equal(warnings.length, 2);
const rendered = encodeJson(warnings[0]?.message);
assert.match(rendered, /PostToolUse/);
assert.match(rendered, /Stop/);
assert.include(rendered, encodeJson(configPath));
assert.match(encodeJson(warnings[1]?.message), /PostToolUse,Stop/);
assert.deepEqual(decisions, [
{ decision: "ask", reason: "Reviewed." },
{ decision: "ask", reason: "Reviewed." },
{ decision: "ask", reason: "Reviewed." },
]);
}),
);
});
});
113 changes: 92 additions & 21 deletions apps/server/src/hooks/T3HookRunner.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,6 +35,15 @@ const HooksConfig = Schema.Struct({
const HooksConfigJson = fromLenientJson(HooksConfig);
const decodeHooksConfigJson = Schema.decodeUnknownEffect(HooksConfigJson);

const HooksConfigEventKeys = Schema.Struct({
hooks: Schema.Record(Schema.String, Schema.Unknown),
});
const decodeHooksConfigEventKeysJson = Schema.decodeUnknownEffect(
fromLenientJson(HooksConfigEventKeys),
);

const SUPPORTED_HOOK_EVENTS = ["PreToolUse"] as const;

const HookSpecificOutput = Schema.Struct({
hookEventName: Schema.optional(Schema.String),
permissionDecision: Schema.optional(Schema.Literals(["allow", "ask", "deny"])),
Expand DownExpand Up@@ -87,6 +96,7 @@ export interface T3PreToolUseInput {
export interface T3HookPlan {
readonly configPath: string | undefined;
readonly hasPreToolUseHooks: boolean;
readonly hasPreToolUseHooksNow: Effect.Effect<boolean>;
readonly evaluatePreToolUse: (
input: Omit<T3PreToolUseInput, "cwd">,
) => Effect.Effect<T3HookDecision, T3HookCommandError>;
Expand DownExpand Up@@ -139,6 +149,14 @@ function normalizedDecision(output: typeof HookCommandOutput.Type): T3HookDecisi
};
}

function logConfigFailure(error: T3HookConfigError) {
return Effect.logWarning("T3 project hooks could not be loaded", {
path: error.configPath,
operation: error.operation,
cause: error.cause,
});
}

function expandProjectDirectory(command: string, projectDirectory: string): string {
return command
.replaceAll("${T3_PROJECT_DIR}", projectDirectory)
Expand DownExpand Up@@ -182,6 +200,7 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
const path = yield* Path.Path;
const processRunner = yield* ProcessRunner;
const platform = yield* HostProcessPlatform;
const warnedUnsupportedEvents = new Set<string>();

const findConfigPath = Effect.fn("T3HookRunner.findConfigPath")(function* (cwd: string) {
let current = cwd;
Expand DownExpand Up@@ -242,6 +261,28 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
});
}
}

const declaredEvents = yield* decodeHooksConfigEventKeysJson(raw).pipe(
Effect.map((decoded) => Object.keys(decoded.hooks)),
Effect.orElseSucceed(() => [] as ReadonlyArray<string>),
);
const unsupportedEvents = declaredEvents
.filter((event) => !SUPPORTED_HOOK_EVENTS.some((supported) => supported === event))
.sort();
if (unsupportedEvents.length > 0) {
const warningKey = [configPath, ...unsupportedEvents]
.map((part) => `${part.length}:${part}`)
.join("");
if (!warnedUnsupportedEvents.has(warningKey)) {
warnedUnsupportedEvents.add(warningKey);
yield* Effect.logWarning("ignoring unsupported T3 hook events", {
path: configPath,
unsupportedEvents,
supportedEvents: SUPPORTED_HOOK_EVENTS,
});
}
}

return config;
});

Expand DownExpand Up@@ -342,31 +383,61 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
return { decision: "allow" } satisfies T3HookDecision;
});

const resolvePlanState = Effect.fn("T3HookRunner.resolvePlanState")(function* (cwd: string) {
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
const configPathOption = yield* findConfigPath(cwd);
if (Option.isNone(configPathOption)) {
return {
configPath: undefined,
entries: [] as ReadonlyArray<HookMatcherConfig>,
projectDirectory: undefined,
};
}

const configPath = configPathOption.value;
const config = yield* readConfig(configPath);
return {
configPath,
entries: config.hooks.PreToolUse ?? ([] as ReadonlyArray<HookMatcherConfig>),
projectDirectory: path.dirname(path.dirname(configPath)),
};
});

const prepare: T3HookRunner["Service"]["prepare"] = Effect.fn("T3HookRunner.prepare")(
function* (cwd) {
const configPathOption = yield* findConfigPath(cwd);
if (Option.isNone(configPathOption)) {
return {
configPath: undefined,
hasPreToolUseHooks: false,
evaluatePreToolUse: () => Effect.succeed({ decision: "allow" as const }),
} satisfies T3HookPlan;
}

const configPath = configPathOption.value;
const config = yield* readConfig(configPath);
const entries = config.hooks.PreToolUse ?? [];
const projectDirectory = path.dirname(path.dirname(configPath));
const snapshot = yield* resolvePlanState(cwd);
const snapshotHasHooks = snapshot.entries.length > 0;
return {
configPath,
hasPreToolUseHooks: entries.length > 0,
configPath: snapshot.configPath,
hasPreToolUseHooks: snapshotHasHooks,
hasPreToolUseHooksNow: resolvePlanState(cwd).pipe(
Effect.map((state) => state.entries.length > 0),
Effect.catchTag("T3HookConfigError", (error) =>
logConfigFailure(error).pipe(Effect.as(true)),
),
),
evaluatePreToolUse: (input) =>
evaluateEntries({
entries,
configPath,
projectDirectory,
payload: { ...input, cwd },
}),
Effect.gen(function* () {
const state = yield* resolvePlanState(cwd);
if (state.configPath === undefined) {
return { decision: "allow" } satisfies T3HookDecision;
}
return yield* evaluateEntries({
entries: state.entries,
configPath: state.configPath,
projectDirectory: state.projectDirectory,
payload: { ...input, cwd },
});
}).pipe(
Effect.catchTag("T3HookConfigError", (error) =>
logConfigFailure(error).pipe(
Effect.as({
decision: "ask",
title: "T3 hook config failed",
reason: `T3 project hooks could not be loaded from ${error.configPath}.`,
} satisfies T3HookDecision),
),
),
),
} satisfies T3HookPlan;
},
);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions .plans/solidify-project-hooks.md

Large diffs are not rendered by default.

219 changes: 219 additions & 0 deletions apps/server/src/hooks/T3HookRunner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import type * as LogLevel from "effect/LogLevel";
import * as Logger from "effect/Logger";
import * as Path from "effect/Path";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";

Expand All@@ -28,6 +30,18 @@ const successfulOutput = (stdout: string): ProcessRunOutput => ({
stderrInvalidUtf8: false,
});

function writeHooksConfig(root: string, hooks: unknown) {
return Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const configDirectory = path.join(root, ".t3code");
yield* fileSystem.makeDirectory(configDirectory, { recursive: true });
const configPath = path.join(configDirectory, "hooks.json");
yield* fileSystem.writeFileString(configPath, encodeJson({ hooks }));
return configPath;
});
}

function testLayer(run: (input: ProcessRunInput) => Effect.Effect<ProcessRunOutput>) {
return Layer.effect(T3HookRunner.T3HookRunner, T3HookRunner.make()).pipe(
Layer.provide(
Expand DownExpand Up@@ -195,4 +209,209 @@ describe("T3HookRunner", () => {
}),
);
});

it.layer(
testLayer(() =>
Effect.succeed({
...successfulOutput(""),
code: 2 as ProcessRunOutput["code"],
stderr: "Hook added while the session was running.",
}),
),
)("picks up a config created after prepare", (it) => {
it.effect("reports live hooks and evaluates them", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.equal(plan.configPath, undefined);
assert.isFalse(plan.hasPreToolUseHooks);
assert.isFalse(yield* plan.hasPreToolUseHooksNow);

yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

assert.isTrue(yield* plan.hasPreToolUseHooksNow);
const decision = yield* plan.evaluatePreToolUse({
provider: "claudeAgent",
threadId: "thread-created",
toolName: "Bash",
toolInput: { command: "git push" },
});

assert.deepEqual(decision, {
decision: "deny",
reason: "Hook added while the session was running.",
});
}),
);
});

it.layer(testLayer(() => Effect.die("a deleted config must not run a hook")))(
"picks up a config deleted after prepare",
(it) => {
it.effect("reports no live hooks and allows the tool call", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
const configPath = yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.isTrue(plan.hasPreToolUseHooks);
assert.isTrue(yield* plan.hasPreToolUseHooksNow);

yield* fileSystem.remove(configPath);

assert.isFalse(yield* plan.hasPreToolUseHooksNow);
assert.deepEqual(
yield* plan.evaluatePreToolUse({
provider: "claudeAgent",
threadId: "thread-deleted",
toolName: "Bash",
toolInput: { command: "git push" },
}),
{ decision: "allow" },
);
}),
);
},
);

it.layer(
testLayer(() =>
Effect.succeed(
successfulOutput(encodeJson({ decision: "ask", reason: "Bash needs confirmation." })),
),
),
)("applies an edited matcher without preparing again", (it) => {
it.effect("re-reads the config before each evaluation", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
const bashCall = {
provider: "claudeAgent",
threadId: "thread-edited",
toolName: "Bash",
toolInput: { command: "git push" },
};

assert.deepEqual(yield* plan.evaluatePreToolUse(bashCall), {
decision: "ask",
reason: "Bash needs confirmation.",
});

yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Write", hooks: [{ type: "command", command: "policy" }] }],
});

assert.deepEqual(yield* plan.evaluatePreToolUse(bashCall), { decision: "allow" });
}),
);
});

it.layer(testLayer(() => Effect.die("an unreadable config must not run a hook")))(
"fails closed when the config becomes invalid mid-session",
(it) => {
it.effect("reports live hooks and asks for confirmation", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.isFalse(plan.hasPreToolUseHooks);

const configDirectory = path.join(root, ".t3code");
yield* fileSystem.makeDirectory(configDirectory, { recursive: true });
const configPath = path.join(configDirectory, "hooks.json");
yield* fileSystem.writeFileString(configPath, "{ not json");

assert.isTrue(yield* plan.hasPreToolUseHooksNow);
assert.deepEqual(
yield* plan.evaluatePreToolUse({
provider: "codex",
threadId: "thread-invalid",
toolName: "Bash",
toolInput: { command: "git push" },
}),
{
decision: "ask",
title: "T3 hook config failed",
reason: `T3 project hooks could not be loaded from ${configPath}.`,
},
);
}),
);
},
);

it.layer(
testLayer(() =>
Effect.succeed(successfulOutput(encodeJson({ decision: "ask", reason: "Reviewed." }))),
),
)("warns about hook events it does not implement", (it) => {
it.effect("warns once and still runs PreToolUse", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
const configPath = yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
PostToolUse: [{ hooks: [{ type: "command", command: "after" }] }],
Stop: [{ hooks: [{ type: "command", command: "stop" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const records: Array<{ readonly logLevel: LogLevel.LogLevel; readonly message: unknown }> =
[];
const logger = Logger.make<unknown, void>(({ logLevel, message }) => {
records.push({ logLevel, message });
});

const decisions = yield* Effect.gen(function* () {
const plan = yield* runner.prepare(root);
const call = {
provider: "claudeAgent",
threadId: "thread-unsupported",
toolName: "Bash",
toolInput: { command: "git push" },
};
const initialDecisions = [
yield* plan.evaluatePreToolUse(call),
yield* plan.evaluatePreToolUse(call),
];
yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
"PostToolUse,Stop": [{ hooks: [{ type: "command", command: "combined" }] }],
});
return [...initialDecisions, yield* plan.evaluatePreToolUse(call)];
}).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));

const warnings = records.filter((record) => record.logLevel === "Warn");
assert.equal(warnings.length, 2);
const rendered = encodeJson(warnings[0]?.message);
assert.match(rendered, /PostToolUse/);
assert.match(rendered, /Stop/);
assert.include(rendered, encodeJson(configPath));
assert.match(encodeJson(warnings[1]?.message), /PostToolUse,Stop/);
assert.deepEqual(decisions, [
{ decision: "ask", reason: "Reviewed." },
{ decision: "ask", reason: "Reviewed." },
{ decision: "ask", reason: "Reviewed." },
]);
}),
);
});
});
113 changes: 92 additions & 21 deletions apps/server/src/hooks/T3HookRunner.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,6 +35,15 @@ const HooksConfig = Schema.Struct({
const HooksConfigJson = fromLenientJson(HooksConfig);
const decodeHooksConfigJson = Schema.decodeUnknownEffect(HooksConfigJson);

const HooksConfigEventKeys = Schema.Struct({
hooks: Schema.Record(Schema.String, Schema.Unknown),
});
const decodeHooksConfigEventKeysJson = Schema.decodeUnknownEffect(
fromLenientJson(HooksConfigEventKeys),
);

const SUPPORTED_HOOK_EVENTS = ["PreToolUse"] as const;

const HookSpecificOutput = Schema.Struct({
hookEventName: Schema.optional(Schema.String),
permissionDecision: Schema.optional(Schema.Literals(["allow", "ask", "deny"])),
Expand DownExpand Up@@ -87,6 +96,7 @@ export interface T3PreToolUseInput {
export interface T3HookPlan {
readonly configPath: string | undefined;
readonly hasPreToolUseHooks: boolean;
readonly hasPreToolUseHooksNow: Effect.Effect<boolean>;
readonly evaluatePreToolUse: (
input: Omit<T3PreToolUseInput, "cwd">,
) => Effect.Effect<T3HookDecision, T3HookCommandError>;
Expand DownExpand Up@@ -139,6 +149,14 @@ function normalizedDecision(output: typeof HookCommandOutput.Type): T3HookDecisi
};
}

function logConfigFailure(error: T3HookConfigError) {
return Effect.logWarning("T3 project hooks could not be loaded", {
path: error.configPath,
operation: error.operation,
cause: error.cause,
});
}

function expandProjectDirectory(command: string, projectDirectory: string): string {
return command
.replaceAll("${T3_PROJECT_DIR}", projectDirectory)
Expand DownExpand Up@@ -182,6 +200,7 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
const path = yield* Path.Path;
const processRunner = yield* ProcessRunner;
const platform = yield* HostProcessPlatform;
const warnedUnsupportedEvents = new Set<string>();

const findConfigPath = Effect.fn("T3HookRunner.findConfigPath")(function* (cwd: string) {
let current = cwd;
Expand DownExpand Up@@ -242,6 +261,28 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
});
}
}

const declaredEvents = yield* decodeHooksConfigEventKeysJson(raw).pipe(
Effect.map((decoded) => Object.keys(decoded.hooks)),
Effect.orElseSucceed(() => [] as ReadonlyArray<string>),
);
const unsupportedEvents = declaredEvents
.filter((event) => !SUPPORTED_HOOK_EVENTS.some((supported) => supported === event))
.sort();
if (unsupportedEvents.length > 0) {
const warningKey = [configPath, ...unsupportedEvents]
.map((part) => `${part.length}:${part}`)
.join("");
if (!warnedUnsupportedEvents.has(warningKey)) {
warnedUnsupportedEvents.add(warningKey);
yield* Effect.logWarning("ignoring unsupported T3 hook events", {
path: configPath,
unsupportedEvents,
supportedEvents: SUPPORTED_HOOK_EVENTS,
});
}
}

return config;
});

Expand DownExpand Up@@ -342,31 +383,61 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
return { decision: "allow" } satisfies T3HookDecision;
});

const resolvePlanState = Effect.fn("T3HookRunner.resolvePlanState")(function* (cwd: string) {
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
const configPathOption = yield* findConfigPath(cwd);
if (Option.isNone(configPathOption)) {
return {
configPath: undefined,
entries: [] as ReadonlyArray<HookMatcherConfig>,
projectDirectory: undefined,
};
}

const configPath = configPathOption.value;
const config = yield* readConfig(configPath);
return {
configPath,
entries: config.hooks.PreToolUse ?? ([] as ReadonlyArray<HookMatcherConfig>),
projectDirectory: path.dirname(path.dirname(configPath)),
};
});

const prepare: T3HookRunner["Service"]["prepare"] = Effect.fn("T3HookRunner.prepare")(
function* (cwd) {
const configPathOption = yield* findConfigPath(cwd);
if (Option.isNone(configPathOption)) {
return {
configPath: undefined,
hasPreToolUseHooks: false,
evaluatePreToolUse: () => Effect.succeed({ decision: "allow" as const }),
} satisfies T3HookPlan;
}

const configPath = configPathOption.value;
const config = yield* readConfig(configPath);
const entries = config.hooks.PreToolUse ?? [];
const projectDirectory = path.dirname(path.dirname(configPath));
const snapshot = yield* resolvePlanState(cwd);
const snapshotHasHooks = snapshot.entries.length > 0;
return {
configPath,
hasPreToolUseHooks: entries.length > 0,
configPath: snapshot.configPath,
hasPreToolUseHooks: snapshotHasHooks,
hasPreToolUseHooksNow: resolvePlanState(cwd).pipe(
Effect.map((state) => state.entries.length > 0),
Effect.catchTag("T3HookConfigError", (error) =>
logConfigFailure(error).pipe(Effect.as(true)),
),
),
evaluatePreToolUse: (input) =>
evaluateEntries({
entries,
configPath,
projectDirectory,
payload: { ...input, cwd },
}),
Effect.gen(function* () {
const state = yield* resolvePlanState(cwd);
if (state.configPath === undefined) {
return { decision: "allow" } satisfies T3HookDecision;
}
return yield* evaluateEntries({
entries: state.entries,
configPath: state.configPath,
projectDirectory: state.projectDirectory,
payload: { ...input, cwd },
});
}).pipe(
Effect.catchTag("T3HookConfigError", (error) =>
logConfigFailure(error).pipe(
Effect.as({
decision: "ask",
title: "T3 hook config failed",
reason: `T3 project hooks could not be loaded from ${error.configPath}.`,
} satisfies T3HookDecision),
),
),
),
} satisfies T3HookPlan;
},
);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions .plans/solidify-project-hooks.md

Large diffs are not rendered by default.

219 changes: 219 additions & 0 deletions apps/server/src/hooks/T3HookRunner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import type * as LogLevel from "effect/LogLevel";
import * as Logger from "effect/Logger";
import * as Path from "effect/Path";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";

Expand All@@ -28,6 +30,18 @@ const successfulOutput = (stdout: string): ProcessRunOutput => ({
stderrInvalidUtf8: false,
});

function writeHooksConfig(root: string, hooks: unknown) {
return Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const configDirectory = path.join(root, ".t3code");
yield* fileSystem.makeDirectory(configDirectory, { recursive: true });
const configPath = path.join(configDirectory, "hooks.json");
yield* fileSystem.writeFileString(configPath, encodeJson({ hooks }));
return configPath;
});
}

function testLayer(run: (input: ProcessRunInput) => Effect.Effect<ProcessRunOutput>) {
return Layer.effect(T3HookRunner.T3HookRunner, T3HookRunner.make()).pipe(
Layer.provide(
Expand DownExpand Up@@ -195,4 +209,209 @@ describe("T3HookRunner", () => {
}),
);
});

it.layer(
testLayer(() =>
Effect.succeed({
...successfulOutput(""),
code: 2 as ProcessRunOutput["code"],
stderr: "Hook added while the session was running.",
}),
),
)("picks up a config created after prepare", (it) => {
it.effect("reports live hooks and evaluates them", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.equal(plan.configPath, undefined);
assert.isFalse(plan.hasPreToolUseHooks);
assert.isFalse(yield* plan.hasPreToolUseHooksNow);

yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

assert.isTrue(yield* plan.hasPreToolUseHooksNow);
const decision = yield* plan.evaluatePreToolUse({
provider: "claudeAgent",
threadId: "thread-created",
toolName: "Bash",
toolInput: { command: "git push" },
});

assert.deepEqual(decision, {
decision: "deny",
reason: "Hook added while the session was running.",
});
}),
);
});

it.layer(testLayer(() => Effect.die("a deleted config must not run a hook")))(
"picks up a config deleted after prepare",
(it) => {
it.effect("reports no live hooks and allows the tool call", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
const configPath = yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.isTrue(plan.hasPreToolUseHooks);
assert.isTrue(yield* plan.hasPreToolUseHooksNow);

yield* fileSystem.remove(configPath);

assert.isFalse(yield* plan.hasPreToolUseHooksNow);
assert.deepEqual(
yield* plan.evaluatePreToolUse({
provider: "claudeAgent",
threadId: "thread-deleted",
toolName: "Bash",
toolInput: { command: "git push" },
}),
{ decision: "allow" },
);
}),
);
},
);

it.layer(
testLayer(() =>
Effect.succeed(
successfulOutput(encodeJson({ decision: "ask", reason: "Bash needs confirmation." })),
),
),
)("applies an edited matcher without preparing again", (it) => {
it.effect("re-reads the config before each evaluation", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
const bashCall = {
provider: "claudeAgent",
threadId: "thread-edited",
toolName: "Bash",
toolInput: { command: "git push" },
};

assert.deepEqual(yield* plan.evaluatePreToolUse(bashCall), {
decision: "ask",
reason: "Bash needs confirmation.",
});

yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Write", hooks: [{ type: "command", command: "policy" }] }],
});

assert.deepEqual(yield* plan.evaluatePreToolUse(bashCall), { decision: "allow" });
}),
);
});

it.layer(testLayer(() => Effect.die("an unreadable config must not run a hook")))(
"fails closed when the config becomes invalid mid-session",
(it) => {
it.effect("reports live hooks and asks for confirmation", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.isFalse(plan.hasPreToolUseHooks);

const configDirectory = path.join(root, ".t3code");
yield* fileSystem.makeDirectory(configDirectory, { recursive: true });
const configPath = path.join(configDirectory, "hooks.json");
yield* fileSystem.writeFileString(configPath, "{ not json");

assert.isTrue(yield* plan.hasPreToolUseHooksNow);
assert.deepEqual(
yield* plan.evaluatePreToolUse({
provider: "codex",
threadId: "thread-invalid",
toolName: "Bash",
toolInput: { command: "git push" },
}),
{
decision: "ask",
title: "T3 hook config failed",
reason: `T3 project hooks could not be loaded from ${configPath}.`,
},
);
}),
);
},
);

it.layer(
testLayer(() =>
Effect.succeed(successfulOutput(encodeJson({ decision: "ask", reason: "Reviewed." }))),
),
)("warns about hook events it does not implement", (it) => {
it.effect("warns once and still runs PreToolUse", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
const configPath = yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
PostToolUse: [{ hooks: [{ type: "command", command: "after" }] }],
Stop: [{ hooks: [{ type: "command", command: "stop" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const records: Array<{ readonly logLevel: LogLevel.LogLevel; readonly message: unknown }> =
[];
const logger = Logger.make<unknown, void>(({ logLevel, message }) => {
records.push({ logLevel, message });
});

const decisions = yield* Effect.gen(function* () {
const plan = yield* runner.prepare(root);
const call = {
provider: "claudeAgent",
threadId: "thread-unsupported",
toolName: "Bash",
toolInput: { command: "git push" },
};
const initialDecisions = [
yield* plan.evaluatePreToolUse(call),
yield* plan.evaluatePreToolUse(call),
];
yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
"PostToolUse,Stop": [{ hooks: [{ type: "command", command: "combined" }] }],
});
return [...initialDecisions, yield* plan.evaluatePreToolUse(call)];
}).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));

const warnings = records.filter((record) => record.logLevel === "Warn");
assert.equal(warnings.length, 2);
const rendered = encodeJson(warnings[0]?.message);
assert.match(rendered, /PostToolUse/);
assert.match(rendered, /Stop/);
assert.include(rendered, encodeJson(configPath));
assert.match(encodeJson(warnings[1]?.message), /PostToolUse,Stop/);
assert.deepEqual(decisions, [
{ decision: "ask", reason: "Reviewed." },
{ decision: "ask", reason: "Reviewed." },
{ decision: "ask", reason: "Reviewed." },
]);
}),
);
});
});
113 changes: 92 additions & 21 deletions apps/server/src/hooks/T3HookRunner.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,6 +35,15 @@ const HooksConfig = Schema.Struct({
const HooksConfigJson = fromLenientJson(HooksConfig);
const decodeHooksConfigJson = Schema.decodeUnknownEffect(HooksConfigJson);

const HooksConfigEventKeys = Schema.Struct({
hooks: Schema.Record(Schema.String, Schema.Unknown),
});
const decodeHooksConfigEventKeysJson = Schema.decodeUnknownEffect(
fromLenientJson(HooksConfigEventKeys),
);

const SUPPORTED_HOOK_EVENTS = ["PreToolUse"] as const;

const HookSpecificOutput = Schema.Struct({
hookEventName: Schema.optional(Schema.String),
permissionDecision: Schema.optional(Schema.Literals(["allow", "ask", "deny"])),
Expand DownExpand Up@@ -87,6 +96,7 @@ export interface T3PreToolUseInput {
export interface T3HookPlan {
readonly configPath: string | undefined;
readonly hasPreToolUseHooks: boolean;
readonly hasPreToolUseHooksNow: Effect.Effect<boolean>;
readonly evaluatePreToolUse: (
input: Omit<T3PreToolUseInput, "cwd">,
) => Effect.Effect<T3HookDecision, T3HookCommandError>;
Expand DownExpand Up@@ -139,6 +149,14 @@ function normalizedDecision(output: typeof HookCommandOutput.Type): T3HookDecisi
};
}

function logConfigFailure(error: T3HookConfigError) {
return Effect.logWarning("T3 project hooks could not be loaded", {
path: error.configPath,
operation: error.operation,
cause: error.cause,
});
}

function expandProjectDirectory(command: string, projectDirectory: string): string {
return command
.replaceAll("${T3_PROJECT_DIR}", projectDirectory)
Expand DownExpand Up@@ -182,6 +200,7 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
const path = yield* Path.Path;
const processRunner = yield* ProcessRunner;
const platform = yield* HostProcessPlatform;
const warnedUnsupportedEvents = new Set<string>();

const findConfigPath = Effect.fn("T3HookRunner.findConfigPath")(function* (cwd: string) {
let current = cwd;
Expand DownExpand Up@@ -242,6 +261,28 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
});
}
}

const declaredEvents = yield* decodeHooksConfigEventKeysJson(raw).pipe(
Effect.map((decoded) => Object.keys(decoded.hooks)),
Effect.orElseSucceed(() => [] as ReadonlyArray<string>),
);
const unsupportedEvents = declaredEvents
.filter((event) => !SUPPORTED_HOOK_EVENTS.some((supported) => supported === event))
.sort();
if (unsupportedEvents.length > 0) {
const warningKey = [configPath, ...unsupportedEvents]
.map((part) => `${part.length}:${part}`)
.join("");
if (!warnedUnsupportedEvents.has(warningKey)) {
warnedUnsupportedEvents.add(warningKey);
yield* Effect.logWarning("ignoring unsupported T3 hook events", {
path: configPath,
unsupportedEvents,
supportedEvents: SUPPORTED_HOOK_EVENTS,
});
}
}

return config;
});

Expand DownExpand Up@@ -342,31 +383,61 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
return { decision: "allow" } satisfies T3HookDecision;
});

const resolvePlanState = Effect.fn("T3HookRunner.resolvePlanState")(function* (cwd: string) {
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
const configPathOption = yield* findConfigPath(cwd);
if (Option.isNone(configPathOption)) {
return {
configPath: undefined,
entries: [] as ReadonlyArray<HookMatcherConfig>,
projectDirectory: undefined,
};
}

const configPath = configPathOption.value;
const config = yield* readConfig(configPath);
return {
configPath,
entries: config.hooks.PreToolUse ?? ([] as ReadonlyArray<HookMatcherConfig>),
projectDirectory: path.dirname(path.dirname(configPath)),
};
});

const prepare: T3HookRunner["Service"]["prepare"] = Effect.fn("T3HookRunner.prepare")(
function* (cwd) {
const configPathOption = yield* findConfigPath(cwd);
if (Option.isNone(configPathOption)) {
return {
configPath: undefined,
hasPreToolUseHooks: false,
evaluatePreToolUse: () => Effect.succeed({ decision: "allow" as const }),
} satisfies T3HookPlan;
}

const configPath = configPathOption.value;
const config = yield* readConfig(configPath);
const entries = config.hooks.PreToolUse ?? [];
const projectDirectory = path.dirname(path.dirname(configPath));
const snapshot = yield* resolvePlanState(cwd);
const snapshotHasHooks = snapshot.entries.length > 0;
return {
configPath,
hasPreToolUseHooks: entries.length > 0,
configPath: snapshot.configPath,
hasPreToolUseHooks: snapshotHasHooks,
hasPreToolUseHooksNow: resolvePlanState(cwd).pipe(
Effect.map((state) => state.entries.length > 0),
Effect.catchTag("T3HookConfigError", (error) =>
logConfigFailure(error).pipe(Effect.as(true)),
),
),
evaluatePreToolUse: (input) =>
evaluateEntries({
entries,
configPath,
projectDirectory,
payload: { ...input, cwd },
}),
Effect.gen(function* () {
const state = yield* resolvePlanState(cwd);
if (state.configPath === undefined) {
return { decision: "allow" } satisfies T3HookDecision;
}
return yield* evaluateEntries({
entries: state.entries,
configPath: state.configPath,
projectDirectory: state.projectDirectory,
payload: { ...input, cwd },
});
}).pipe(
Effect.catchTag("T3HookConfigError", (error) =>
logConfigFailure(error).pipe(
Effect.as({
decision: "ask",
title: "T3 hook config failed",
reason: `T3 project hooks could not be loaded from ${error.configPath}.`,
} satisfies T3HookDecision),
),
),
),
} satisfies T3HookPlan;
},
);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions .plans/solidify-project-hooks.md

Large diffs are not rendered by default.

219 changes: 219 additions & 0 deletions apps/server/src/hooks/T3HookRunner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import type * as LogLevel from "effect/LogLevel";
import * as Logger from "effect/Logger";
import * as Path from "effect/Path";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";

Expand All@@ -28,6 +30,18 @@ const successfulOutput = (stdout: string): ProcessRunOutput => ({
stderrInvalidUtf8: false,
});

function writeHooksConfig(root: string, hooks: unknown) {
return Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const configDirectory = path.join(root, ".t3code");
yield* fileSystem.makeDirectory(configDirectory, { recursive: true });
const configPath = path.join(configDirectory, "hooks.json");
yield* fileSystem.writeFileString(configPath, encodeJson({ hooks }));
return configPath;
});
}

function testLayer(run: (input: ProcessRunInput) => Effect.Effect<ProcessRunOutput>) {
return Layer.effect(T3HookRunner.T3HookRunner, T3HookRunner.make()).pipe(
Layer.provide(
Expand DownExpand Up@@ -195,4 +209,209 @@ describe("T3HookRunner", () => {
}),
);
});

it.layer(
testLayer(() =>
Effect.succeed({
...successfulOutput(""),
code: 2 as ProcessRunOutput["code"],
stderr: "Hook added while the session was running.",
}),
),
)("picks up a config created after prepare", (it) => {
it.effect("reports live hooks and evaluates them", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.equal(plan.configPath, undefined);
assert.isFalse(plan.hasPreToolUseHooks);
assert.isFalse(yield* plan.hasPreToolUseHooksNow);

yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

assert.isTrue(yield* plan.hasPreToolUseHooksNow);
const decision = yield* plan.evaluatePreToolUse({
provider: "claudeAgent",
threadId: "thread-created",
toolName: "Bash",
toolInput: { command: "git push" },
});

assert.deepEqual(decision, {
decision: "deny",
reason: "Hook added while the session was running.",
});
}),
);
});

it.layer(testLayer(() => Effect.die("a deleted config must not run a hook")))(
"picks up a config deleted after prepare",
(it) => {
it.effect("reports no live hooks and allows the tool call", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
const configPath = yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.isTrue(plan.hasPreToolUseHooks);
assert.isTrue(yield* plan.hasPreToolUseHooksNow);

yield* fileSystem.remove(configPath);

assert.isFalse(yield* plan.hasPreToolUseHooksNow);
assert.deepEqual(
yield* plan.evaluatePreToolUse({
provider: "claudeAgent",
threadId: "thread-deleted",
toolName: "Bash",
toolInput: { command: "git push" },
}),
{ decision: "allow" },
);
}),
);
},
);

it.layer(
testLayer(() =>
Effect.succeed(
successfulOutput(encodeJson({ decision: "ask", reason: "Bash needs confirmation." })),
),
),
)("applies an edited matcher without preparing again", (it) => {
it.effect("re-reads the config before each evaluation", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
const bashCall = {
provider: "claudeAgent",
threadId: "thread-edited",
toolName: "Bash",
toolInput: { command: "git push" },
};

assert.deepEqual(yield* plan.evaluatePreToolUse(bashCall), {
decision: "ask",
reason: "Bash needs confirmation.",
});

yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Write", hooks: [{ type: "command", command: "policy" }] }],
});

assert.deepEqual(yield* plan.evaluatePreToolUse(bashCall), { decision: "allow" });
}),
);
});

it.layer(testLayer(() => Effect.die("an unreadable config must not run a hook")))(
"fails closed when the config becomes invalid mid-session",
(it) => {
it.effect("reports live hooks and asks for confirmation", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.isFalse(plan.hasPreToolUseHooks);

const configDirectory = path.join(root, ".t3code");
yield* fileSystem.makeDirectory(configDirectory, { recursive: true });
const configPath = path.join(configDirectory, "hooks.json");
yield* fileSystem.writeFileString(configPath, "{ not json");

assert.isTrue(yield* plan.hasPreToolUseHooksNow);
assert.deepEqual(
yield* plan.evaluatePreToolUse({
provider: "codex",
threadId: "thread-invalid",
toolName: "Bash",
toolInput: { command: "git push" },
}),
{
decision: "ask",
title: "T3 hook config failed",
reason: `T3 project hooks could not be loaded from ${configPath}.`,
},
);
}),
);
},
);

it.layer(
testLayer(() =>
Effect.succeed(successfulOutput(encodeJson({ decision: "ask", reason: "Reviewed." }))),
),
)("warns about hook events it does not implement", (it) => {
it.effect("warns once and still runs PreToolUse", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
const configPath = yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
PostToolUse: [{ hooks: [{ type: "command", command: "after" }] }],
Stop: [{ hooks: [{ type: "command", command: "stop" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const records: Array<{ readonly logLevel: LogLevel.LogLevel; readonly message: unknown }> =
[];
const logger = Logger.make<unknown, void>(({ logLevel, message }) => {
records.push({ logLevel, message });
});

const decisions = yield* Effect.gen(function* () {
const plan = yield* runner.prepare(root);
const call = {
provider: "claudeAgent",
threadId: "thread-unsupported",
toolName: "Bash",
toolInput: { command: "git push" },
};
const initialDecisions = [
yield* plan.evaluatePreToolUse(call),
yield* plan.evaluatePreToolUse(call),
];
yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
"PostToolUse,Stop": [{ hooks: [{ type: "command", command: "combined" }] }],
});
return [...initialDecisions, yield* plan.evaluatePreToolUse(call)];
}).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));

const warnings = records.filter((record) => record.logLevel === "Warn");
assert.equal(warnings.length, 2);
const rendered = encodeJson(warnings[0]?.message);
assert.match(rendered, /PostToolUse/);
assert.match(rendered, /Stop/);
assert.include(rendered, encodeJson(configPath));
assert.match(encodeJson(warnings[1]?.message), /PostToolUse,Stop/);
assert.deepEqual(decisions, [
{ decision: "ask", reason: "Reviewed." },
{ decision: "ask", reason: "Reviewed." },
{ decision: "ask", reason: "Reviewed." },
]);
}),
);
});
});
113 changes: 92 additions & 21 deletions apps/server/src/hooks/T3HookRunner.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,6 +35,15 @@ const HooksConfig = Schema.Struct({
const HooksConfigJson = fromLenientJson(HooksConfig);
const decodeHooksConfigJson = Schema.decodeUnknownEffect(HooksConfigJson);

const HooksConfigEventKeys = Schema.Struct({
hooks: Schema.Record(Schema.String, Schema.Unknown),
});
const decodeHooksConfigEventKeysJson = Schema.decodeUnknownEffect(
fromLenientJson(HooksConfigEventKeys),
);

const SUPPORTED_HOOK_EVENTS = ["PreToolUse"] as const;

const HookSpecificOutput = Schema.Struct({
hookEventName: Schema.optional(Schema.String),
permissionDecision: Schema.optional(Schema.Literals(["allow", "ask", "deny"])),
Expand DownExpand Up@@ -87,6 +96,7 @@ export interface T3PreToolUseInput {
export interface T3HookPlan {
readonly configPath: string | undefined;
readonly hasPreToolUseHooks: boolean;
readonly hasPreToolUseHooksNow: Effect.Effect<boolean>;
readonly evaluatePreToolUse: (
input: Omit<T3PreToolUseInput, "cwd">,
) => Effect.Effect<T3HookDecision, T3HookCommandError>;
Expand DownExpand Up@@ -139,6 +149,14 @@ function normalizedDecision(output: typeof HookCommandOutput.Type): T3HookDecisi
};
}

function logConfigFailure(error: T3HookConfigError) {
return Effect.logWarning("T3 project hooks could not be loaded", {
path: error.configPath,
operation: error.operation,
cause: error.cause,
});
}

function expandProjectDirectory(command: string, projectDirectory: string): string {
return command
.replaceAll("${T3_PROJECT_DIR}", projectDirectory)
Expand DownExpand Up@@ -182,6 +200,7 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
const path = yield* Path.Path;
const processRunner = yield* ProcessRunner;
const platform = yield* HostProcessPlatform;
const warnedUnsupportedEvents = new Set<string>();

const findConfigPath = Effect.fn("T3HookRunner.findConfigPath")(function* (cwd: string) {
let current = cwd;
Expand DownExpand Up@@ -242,6 +261,28 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
});
}
}

const declaredEvents = yield* decodeHooksConfigEventKeysJson(raw).pipe(
Effect.map((decoded) => Object.keys(decoded.hooks)),
Effect.orElseSucceed(() => [] as ReadonlyArray<string>),
);
const unsupportedEvents = declaredEvents
.filter((event) => !SUPPORTED_HOOK_EVENTS.some((supported) => supported === event))
.sort();
if (unsupportedEvents.length > 0) {
const warningKey = [configPath, ...unsupportedEvents]
.map((part) => `${part.length}:${part}`)
.join("");
if (!warnedUnsupportedEvents.has(warningKey)) {
warnedUnsupportedEvents.add(warningKey);
yield* Effect.logWarning("ignoring unsupported T3 hook events", {
path: configPath,
unsupportedEvents,
supportedEvents: SUPPORTED_HOOK_EVENTS,
});
}
}

return config;
});

Expand DownExpand Up@@ -342,31 +383,61 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
return { decision: "allow" } satisfies T3HookDecision;
});

const resolvePlanState = Effect.fn("T3HookRunner.resolvePlanState")(function* (cwd: string) {
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
const configPathOption = yield* findConfigPath(cwd);
if (Option.isNone(configPathOption)) {
return {
configPath: undefined,
entries: [] as ReadonlyArray<HookMatcherConfig>,
projectDirectory: undefined,
};
}

const configPath = configPathOption.value;
const config = yield* readConfig(configPath);
return {
configPath,
entries: config.hooks.PreToolUse ?? ([] as ReadonlyArray<HookMatcherConfig>),
projectDirectory: path.dirname(path.dirname(configPath)),
};
});

const prepare: T3HookRunner["Service"]["prepare"] = Effect.fn("T3HookRunner.prepare")(
function* (cwd) {
const configPathOption = yield* findConfigPath(cwd);
if (Option.isNone(configPathOption)) {
return {
configPath: undefined,
hasPreToolUseHooks: false,
evaluatePreToolUse: () => Effect.succeed({ decision: "allow" as const }),
} satisfies T3HookPlan;
}

const configPath = configPathOption.value;
const config = yield* readConfig(configPath);
const entries = config.hooks.PreToolUse ?? [];
const projectDirectory = path.dirname(path.dirname(configPath));
const snapshot = yield* resolvePlanState(cwd);
const snapshotHasHooks = snapshot.entries.length > 0;
return {
configPath,
hasPreToolUseHooks: entries.length > 0,
configPath: snapshot.configPath,
hasPreToolUseHooks: snapshotHasHooks,
hasPreToolUseHooksNow: resolvePlanState(cwd).pipe(
Effect.map((state) => state.entries.length > 0),
Effect.catchTag("T3HookConfigError", (error) =>
logConfigFailure(error).pipe(Effect.as(true)),
),
),
evaluatePreToolUse: (input) =>
evaluateEntries({
entries,
configPath,
projectDirectory,
payload: { ...input, cwd },
}),
Effect.gen(function* () {
const state = yield* resolvePlanState(cwd);
if (state.configPath === undefined) {
return { decision: "allow" } satisfies T3HookDecision;
}
return yield* evaluateEntries({
entries: state.entries,
configPath: state.configPath,
projectDirectory: state.projectDirectory,
payload: { ...input, cwd },
});
}).pipe(
Effect.catchTag("T3HookConfigError", (error) =>
logConfigFailure(error).pipe(
Effect.as({
decision: "ask",
title: "T3 hook config failed",
reason: `T3 project hooks could not be loaded from ${error.configPath}.`,
} satisfies T3HookDecision),
),
),
),
} satisfies T3HookPlan;
},
);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions .plans/solidify-project-hooks.md

Large diffs are not rendered by default.

219 changes: 219 additions & 0 deletions apps/server/src/hooks/T3HookRunner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import type * as LogLevel from "effect/LogLevel";
import * as Logger from "effect/Logger";
import * as Path from "effect/Path";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";

Expand All@@ -28,6 +30,18 @@ const successfulOutput = (stdout: string): ProcessRunOutput => ({
stderrInvalidUtf8: false,
});

function writeHooksConfig(root: string, hooks: unknown) {
return Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const configDirectory = path.join(root, ".t3code");
yield* fileSystem.makeDirectory(configDirectory, { recursive: true });
const configPath = path.join(configDirectory, "hooks.json");
yield* fileSystem.writeFileString(configPath, encodeJson({ hooks }));
return configPath;
});
}

function testLayer(run: (input: ProcessRunInput) => Effect.Effect<ProcessRunOutput>) {
return Layer.effect(T3HookRunner.T3HookRunner, T3HookRunner.make()).pipe(
Layer.provide(
Expand DownExpand Up@@ -195,4 +209,209 @@ describe("T3HookRunner", () => {
}),
);
});

it.layer(
testLayer(() =>
Effect.succeed({
...successfulOutput(""),
code: 2 as ProcessRunOutput["code"],
stderr: "Hook added while the session was running.",
}),
),
)("picks up a config created after prepare", (it) => {
it.effect("reports live hooks and evaluates them", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.equal(plan.configPath, undefined);
assert.isFalse(plan.hasPreToolUseHooks);
assert.isFalse(yield* plan.hasPreToolUseHooksNow);

yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

assert.isTrue(yield* plan.hasPreToolUseHooksNow);
const decision = yield* plan.evaluatePreToolUse({
provider: "claudeAgent",
threadId: "thread-created",
toolName: "Bash",
toolInput: { command: "git push" },
});

assert.deepEqual(decision, {
decision: "deny",
reason: "Hook added while the session was running.",
});
}),
);
});

it.layer(testLayer(() => Effect.die("a deleted config must not run a hook")))(
"picks up a config deleted after prepare",
(it) => {
it.effect("reports no live hooks and allows the tool call", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
const configPath = yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.isTrue(plan.hasPreToolUseHooks);
assert.isTrue(yield* plan.hasPreToolUseHooksNow);

yield* fileSystem.remove(configPath);

assert.isFalse(yield* plan.hasPreToolUseHooksNow);
assert.deepEqual(
yield* plan.evaluatePreToolUse({
provider: "claudeAgent",
threadId: "thread-deleted",
toolName: "Bash",
toolInput: { command: "git push" },
}),
{ decision: "allow" },
);
}),
);
},
);

it.layer(
testLayer(() =>
Effect.succeed(
successfulOutput(encodeJson({ decision: "ask", reason: "Bash needs confirmation." })),
),
),
)("applies an edited matcher without preparing again", (it) => {
it.effect("re-reads the config before each evaluation", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
const bashCall = {
provider: "claudeAgent",
threadId: "thread-edited",
toolName: "Bash",
toolInput: { command: "git push" },
};

assert.deepEqual(yield* plan.evaluatePreToolUse(bashCall), {
decision: "ask",
reason: "Bash needs confirmation.",
});

yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Write", hooks: [{ type: "command", command: "policy" }] }],
});

assert.deepEqual(yield* plan.evaluatePreToolUse(bashCall), { decision: "allow" });
}),
);
});

it.layer(testLayer(() => Effect.die("an unreadable config must not run a hook")))(
"fails closed when the config becomes invalid mid-session",
(it) => {
it.effect("reports live hooks and asks for confirmation", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.isFalse(plan.hasPreToolUseHooks);

const configDirectory = path.join(root, ".t3code");
yield* fileSystem.makeDirectory(configDirectory, { recursive: true });
const configPath = path.join(configDirectory, "hooks.json");
yield* fileSystem.writeFileString(configPath, "{ not json");

assert.isTrue(yield* plan.hasPreToolUseHooksNow);
assert.deepEqual(
yield* plan.evaluatePreToolUse({
provider: "codex",
threadId: "thread-invalid",
toolName: "Bash",
toolInput: { command: "git push" },
}),
{
decision: "ask",
title: "T3 hook config failed",
reason: `T3 project hooks could not be loaded from ${configPath}.`,
},
);
}),
);
},
);

it.layer(
testLayer(() =>
Effect.succeed(successfulOutput(encodeJson({ decision: "ask", reason: "Reviewed." }))),
),
)("warns about hook events it does not implement", (it) => {
it.effect("warns once and still runs PreToolUse", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
const configPath = yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
PostToolUse: [{ hooks: [{ type: "command", command: "after" }] }],
Stop: [{ hooks: [{ type: "command", command: "stop" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const records: Array<{ readonly logLevel: LogLevel.LogLevel; readonly message: unknown }> =
[];
const logger = Logger.make<unknown, void>(({ logLevel, message }) => {
records.push({ logLevel, message });
});

const decisions = yield* Effect.gen(function* () {
const plan = yield* runner.prepare(root);
const call = {
provider: "claudeAgent",
threadId: "thread-unsupported",
toolName: "Bash",
toolInput: { command: "git push" },
};
const initialDecisions = [
yield* plan.evaluatePreToolUse(call),
yield* plan.evaluatePreToolUse(call),
];
yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
"PostToolUse,Stop": [{ hooks: [{ type: "command", command: "combined" }] }],
});
return [...initialDecisions, yield* plan.evaluatePreToolUse(call)];
}).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));

const warnings = records.filter((record) => record.logLevel === "Warn");
assert.equal(warnings.length, 2);
const rendered = encodeJson(warnings[0]?.message);
assert.match(rendered, /PostToolUse/);
assert.match(rendered, /Stop/);
assert.include(rendered, encodeJson(configPath));
assert.match(encodeJson(warnings[1]?.message), /PostToolUse,Stop/);
assert.deepEqual(decisions, [
{ decision: "ask", reason: "Reviewed." },
{ decision: "ask", reason: "Reviewed." },
{ decision: "ask", reason: "Reviewed." },
]);
}),
);
});
});
113 changes: 92 additions & 21 deletions apps/server/src/hooks/T3HookRunner.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,6 +35,15 @@ const HooksConfig = Schema.Struct({
const HooksConfigJson = fromLenientJson(HooksConfig);
const decodeHooksConfigJson = Schema.decodeUnknownEffect(HooksConfigJson);

const HooksConfigEventKeys = Schema.Struct({
hooks: Schema.Record(Schema.String, Schema.Unknown),
});
const decodeHooksConfigEventKeysJson = Schema.decodeUnknownEffect(
fromLenientJson(HooksConfigEventKeys),
);

const SUPPORTED_HOOK_EVENTS = ["PreToolUse"] as const;

const HookSpecificOutput = Schema.Struct({
hookEventName: Schema.optional(Schema.String),
permissionDecision: Schema.optional(Schema.Literals(["allow", "ask", "deny"])),
Expand DownExpand Up@@ -87,6 +96,7 @@ export interface T3PreToolUseInput {
export interface T3HookPlan {
readonly configPath: string | undefined;
readonly hasPreToolUseHooks: boolean;
readonly hasPreToolUseHooksNow: Effect.Effect<boolean>;
readonly evaluatePreToolUse: (
input: Omit<T3PreToolUseInput, "cwd">,
) => Effect.Effect<T3HookDecision, T3HookCommandError>;
Expand DownExpand Up@@ -139,6 +149,14 @@ function normalizedDecision(output: typeof HookCommandOutput.Type): T3HookDecisi
};
}

function logConfigFailure(error: T3HookConfigError) {
return Effect.logWarning("T3 project hooks could not be loaded", {
path: error.configPath,
operation: error.operation,
cause: error.cause,
});
}

function expandProjectDirectory(command: string, projectDirectory: string): string {
return command
.replaceAll("${T3_PROJECT_DIR}", projectDirectory)
Expand DownExpand Up@@ -182,6 +200,7 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
const path = yield* Path.Path;
const processRunner = yield* ProcessRunner;
const platform = yield* HostProcessPlatform;
const warnedUnsupportedEvents = new Set<string>();

const findConfigPath = Effect.fn("T3HookRunner.findConfigPath")(function* (cwd: string) {
let current = cwd;
Expand DownExpand Up@@ -242,6 +261,28 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
});
}
}

const declaredEvents = yield* decodeHooksConfigEventKeysJson(raw).pipe(
Effect.map((decoded) => Object.keys(decoded.hooks)),
Effect.orElseSucceed(() => [] as ReadonlyArray<string>),
);
const unsupportedEvents = declaredEvents
.filter((event) => !SUPPORTED_HOOK_EVENTS.some((supported) => supported === event))
.sort();
if (unsupportedEvents.length > 0) {
const warningKey = [configPath, ...unsupportedEvents]
.map((part) => `${part.length}:${part}`)
.join("");
if (!warnedUnsupportedEvents.has(warningKey)) {
warnedUnsupportedEvents.add(warningKey);
yield* Effect.logWarning("ignoring unsupported T3 hook events", {
path: configPath,
unsupportedEvents,
supportedEvents: SUPPORTED_HOOK_EVENTS,
});
}
}

return config;
});

Expand DownExpand Up@@ -342,31 +383,61 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
return { decision: "allow" } satisfies T3HookDecision;
});

const resolvePlanState = Effect.fn("T3HookRunner.resolvePlanState")(function* (cwd: string) {
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
const configPathOption = yield* findConfigPath(cwd);
if (Option.isNone(configPathOption)) {
return {
configPath: undefined,
entries: [] as ReadonlyArray<HookMatcherConfig>,
projectDirectory: undefined,
};
}

const configPath = configPathOption.value;
const config = yield* readConfig(configPath);
return {
configPath,
entries: config.hooks.PreToolUse ?? ([] as ReadonlyArray<HookMatcherConfig>),
projectDirectory: path.dirname(path.dirname(configPath)),
};
});

const prepare: T3HookRunner["Service"]["prepare"] = Effect.fn("T3HookRunner.prepare")(
function* (cwd) {
const configPathOption = yield* findConfigPath(cwd);
if (Option.isNone(configPathOption)) {
return {
configPath: undefined,
hasPreToolUseHooks: false,
evaluatePreToolUse: () => Effect.succeed({ decision: "allow" as const }),
} satisfies T3HookPlan;
}

const configPath = configPathOption.value;
const config = yield* readConfig(configPath);
const entries = config.hooks.PreToolUse ?? [];
const projectDirectory = path.dirname(path.dirname(configPath));
const snapshot = yield* resolvePlanState(cwd);
const snapshotHasHooks = snapshot.entries.length > 0;
return {
configPath,
hasPreToolUseHooks: entries.length > 0,
configPath: snapshot.configPath,
hasPreToolUseHooks: snapshotHasHooks,
hasPreToolUseHooksNow: resolvePlanState(cwd).pipe(
Effect.map((state) => state.entries.length > 0),
Effect.catchTag("T3HookConfigError", (error) =>
logConfigFailure(error).pipe(Effect.as(true)),
),
),
evaluatePreToolUse: (input) =>
evaluateEntries({
entries,
configPath,
projectDirectory,
payload: { ...input, cwd },
}),
Effect.gen(function* () {
const state = yield* resolvePlanState(cwd);
if (state.configPath === undefined) {
return { decision: "allow" } satisfies T3HookDecision;
}
return yield* evaluateEntries({
entries: state.entries,
configPath: state.configPath,
projectDirectory: state.projectDirectory,
payload: { ...input, cwd },
});
}).pipe(
Effect.catchTag("T3HookConfigError", (error) =>
logConfigFailure(error).pipe(
Effect.as({
decision: "ask",
title: "T3 hook config failed",
reason: `T3 project hooks could not be loaded from ${error.configPath}.`,
} satisfies T3HookDecision),
),
),
),
} satisfies T3HookPlan;
},
);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions .plans/solidify-project-hooks.md

Large diffs are not rendered by default.

219 changes: 219 additions & 0 deletions apps/server/src/hooks/T3HookRunner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import type * as LogLevel from "effect/LogLevel";
import * as Logger from "effect/Logger";
import * as Path from "effect/Path";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";

Expand All@@ -28,6 +30,18 @@ const successfulOutput = (stdout: string): ProcessRunOutput => ({
stderrInvalidUtf8: false,
});

function writeHooksConfig(root: string, hooks: unknown) {
return Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const configDirectory = path.join(root, ".t3code");
yield* fileSystem.makeDirectory(configDirectory, { recursive: true });
const configPath = path.join(configDirectory, "hooks.json");
yield* fileSystem.writeFileString(configPath, encodeJson({ hooks }));
return configPath;
});
}

function testLayer(run: (input: ProcessRunInput) => Effect.Effect<ProcessRunOutput>) {
return Layer.effect(T3HookRunner.T3HookRunner, T3HookRunner.make()).pipe(
Layer.provide(
Expand DownExpand Up@@ -195,4 +209,209 @@ describe("T3HookRunner", () => {
}),
);
});

it.layer(
testLayer(() =>
Effect.succeed({
...successfulOutput(""),
code: 2 as ProcessRunOutput["code"],
stderr: "Hook added while the session was running.",
}),
),
)("picks up a config created after prepare", (it) => {
it.effect("reports live hooks and evaluates them", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.equal(plan.configPath, undefined);
assert.isFalse(plan.hasPreToolUseHooks);
assert.isFalse(yield* plan.hasPreToolUseHooksNow);

yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

assert.isTrue(yield* plan.hasPreToolUseHooksNow);
const decision = yield* plan.evaluatePreToolUse({
provider: "claudeAgent",
threadId: "thread-created",
toolName: "Bash",
toolInput: { command: "git push" },
});

assert.deepEqual(decision, {
decision: "deny",
reason: "Hook added while the session was running.",
});
}),
);
});

it.layer(testLayer(() => Effect.die("a deleted config must not run a hook")))(
"picks up a config deleted after prepare",
(it) => {
it.effect("reports no live hooks and allows the tool call", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
const configPath = yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.isTrue(plan.hasPreToolUseHooks);
assert.isTrue(yield* plan.hasPreToolUseHooksNow);

yield* fileSystem.remove(configPath);

assert.isFalse(yield* plan.hasPreToolUseHooksNow);
assert.deepEqual(
yield* plan.evaluatePreToolUse({
provider: "claudeAgent",
threadId: "thread-deleted",
toolName: "Bash",
toolInput: { command: "git push" },
}),
{ decision: "allow" },
);
}),
);
},
);

it.layer(
testLayer(() =>
Effect.succeed(
successfulOutput(encodeJson({ decision: "ask", reason: "Bash needs confirmation." })),
),
),
)("applies an edited matcher without preparing again", (it) => {
it.effect("re-reads the config before each evaluation", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
const bashCall = {
provider: "claudeAgent",
threadId: "thread-edited",
toolName: "Bash",
toolInput: { command: "git push" },
};

assert.deepEqual(yield* plan.evaluatePreToolUse(bashCall), {
decision: "ask",
reason: "Bash needs confirmation.",
});

yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Write", hooks: [{ type: "command", command: "policy" }] }],
});

assert.deepEqual(yield* plan.evaluatePreToolUse(bashCall), { decision: "allow" });
}),
);
});

it.layer(testLayer(() => Effect.die("an unreadable config must not run a hook")))(
"fails closed when the config becomes invalid mid-session",
(it) => {
it.effect("reports live hooks and asks for confirmation", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });

const runner = yield* T3HookRunner.T3HookRunner;
const plan = yield* runner.prepare(root);
assert.isFalse(plan.hasPreToolUseHooks);

const configDirectory = path.join(root, ".t3code");
yield* fileSystem.makeDirectory(configDirectory, { recursive: true });
const configPath = path.join(configDirectory, "hooks.json");
yield* fileSystem.writeFileString(configPath, "{ not json");

assert.isTrue(yield* plan.hasPreToolUseHooksNow);
assert.deepEqual(
yield* plan.evaluatePreToolUse({
provider: "codex",
threadId: "thread-invalid",
toolName: "Bash",
toolInput: { command: "git push" },
}),
{
decision: "ask",
title: "T3 hook config failed",
reason: `T3 project hooks could not be loaded from ${configPath}.`,
},
);
}),
);
},
);

it.layer(
testLayer(() =>
Effect.succeed(successfulOutput(encodeJson({ decision: "ask", reason: "Reviewed." }))),
),
)("warns about hook events it does not implement", (it) => {
it.effect("warns once and still runs PreToolUse", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-hook-runner-" });
const configPath = yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
PostToolUse: [{ hooks: [{ type: "command", command: "after" }] }],
Stop: [{ hooks: [{ type: "command", command: "stop" }] }],
});

const runner = yield* T3HookRunner.T3HookRunner;
const records: Array<{ readonly logLevel: LogLevel.LogLevel; readonly message: unknown }> =
[];
const logger = Logger.make<unknown, void>(({ logLevel, message }) => {
records.push({ logLevel, message });
});

const decisions = yield* Effect.gen(function* () {
const plan = yield* runner.prepare(root);
const call = {
provider: "claudeAgent",
threadId: "thread-unsupported",
toolName: "Bash",
toolInput: { command: "git push" },
};
const initialDecisions = [
yield* plan.evaluatePreToolUse(call),
yield* plan.evaluatePreToolUse(call),
];
yield* writeHooksConfig(root, {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "policy" }] }],
"PostToolUse,Stop": [{ hooks: [{ type: "command", command: "combined" }] }],
});
return [...initialDecisions, yield* plan.evaluatePreToolUse(call)];
}).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));

const warnings = records.filter((record) => record.logLevel === "Warn");
assert.equal(warnings.length, 2);
const rendered = encodeJson(warnings[0]?.message);
assert.match(rendered, /PostToolUse/);
assert.match(rendered, /Stop/);
assert.include(rendered, encodeJson(configPath));
assert.match(encodeJson(warnings[1]?.message), /PostToolUse,Stop/);
assert.deepEqual(decisions, [
{ decision: "ask", reason: "Reviewed." },
{ decision: "ask", reason: "Reviewed." },
{ decision: "ask", reason: "Reviewed." },
]);
}),
);
});
});
113 changes: 92 additions & 21 deletions apps/server/src/hooks/T3HookRunner.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,6 +35,15 @@ const HooksConfig = Schema.Struct({
const HooksConfigJson = fromLenientJson(HooksConfig);
const decodeHooksConfigJson = Schema.decodeUnknownEffect(HooksConfigJson);

const HooksConfigEventKeys = Schema.Struct({
hooks: Schema.Record(Schema.String, Schema.Unknown),
});
const decodeHooksConfigEventKeysJson = Schema.decodeUnknownEffect(
fromLenientJson(HooksConfigEventKeys),
);

const SUPPORTED_HOOK_EVENTS = ["PreToolUse"] as const;

const HookSpecificOutput = Schema.Struct({
hookEventName: Schema.optional(Schema.String),
permissionDecision: Schema.optional(Schema.Literals(["allow", "ask", "deny"])),
Expand DownExpand Up@@ -87,6 +96,7 @@ export interface T3PreToolUseInput {
export interface T3HookPlan {
readonly configPath: string | undefined;
readonly hasPreToolUseHooks: boolean;
readonly hasPreToolUseHooksNow: Effect.Effect<boolean>;
readonly evaluatePreToolUse: (
input: Omit<T3PreToolUseInput, "cwd">,
) => Effect.Effect<T3HookDecision, T3HookCommandError>;
Expand DownExpand Up@@ -139,6 +149,14 @@ function normalizedDecision(output: typeof HookCommandOutput.Type): T3HookDecisi
};
}

function logConfigFailure(error: T3HookConfigError) {
return Effect.logWarning("T3 project hooks could not be loaded", {
path: error.configPath,
operation: error.operation,
cause: error.cause,
});
}

function expandProjectDirectory(command: string, projectDirectory: string): string {
return command
.replaceAll("${T3_PROJECT_DIR}", projectDirectory)
Expand DownExpand Up@@ -182,6 +200,7 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
const path = yield* Path.Path;
const processRunner = yield* ProcessRunner;
const platform = yield* HostProcessPlatform;
const warnedUnsupportedEvents = new Set<string>();

const findConfigPath = Effect.fn("T3HookRunner.findConfigPath")(function* (cwd: string) {
let current = cwd;
Expand DownExpand Up@@ -242,6 +261,28 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
});
}
}

const declaredEvents = yield* decodeHooksConfigEventKeysJson(raw).pipe(
Effect.map((decoded) => Object.keys(decoded.hooks)),
Effect.orElseSucceed(() => [] as ReadonlyArray<string>),
);
const unsupportedEvents = declaredEvents
.filter((event) => !SUPPORTED_HOOK_EVENTS.some((supported) => supported === event))
.sort();
if (unsupportedEvents.length > 0) {
const warningKey = [configPath, ...unsupportedEvents]
.map((part) => `${part.length}:${part}`)
.join("");
if (!warnedUnsupportedEvents.has(warningKey)) {
warnedUnsupportedEvents.add(warningKey);
yield* Effect.logWarning("ignoring unsupported T3 hook events", {
path: configPath,
unsupportedEvents,
supportedEvents: SUPPORTED_HOOK_EVENTS,
});
}
}

return config;
});

Expand DownExpand Up@@ -342,31 +383,61 @@ export const make = Effect.fn("T3HookRunner.make")(function* () {
return { decision: "allow" } satisfies T3HookDecision;
});

const resolvePlanState = Effect.fn("T3HookRunner.resolvePlanState")(function* (cwd: string) {
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
const configPathOption = yield* findConfigPath(cwd);
if (Option.isNone(configPathOption)) {
return {
configPath: undefined,
entries: [] as ReadonlyArray<HookMatcherConfig>,
projectDirectory: undefined,
};
}

const configPath = configPathOption.value;
const config = yield* readConfig(configPath);
return {
configPath,
entries: config.hooks.PreToolUse ?? ([] as ReadonlyArray<HookMatcherConfig>),
projectDirectory: path.dirname(path.dirname(configPath)),
};
});

const prepare: T3HookRunner["Service"]["prepare"] = Effect.fn("T3HookRunner.prepare")(
function* (cwd) {
const configPathOption = yield* findConfigPath(cwd);
if (Option.isNone(configPathOption)) {
return {
configPath: undefined,
hasPreToolUseHooks: false,
evaluatePreToolUse: () => Effect.succeed({ decision: "allow" as const }),
} satisfies T3HookPlan;
}

const configPath = configPathOption.value;
const config = yield* readConfig(configPath);
const entries = config.hooks.PreToolUse ?? [];
const projectDirectory = path.dirname(path.dirname(configPath));
const snapshot = yield* resolvePlanState(cwd);
const snapshotHasHooks = snapshot.entries.length > 0;
return {
configPath,
hasPreToolUseHooks: entries.length > 0,
configPath: snapshot.configPath,
hasPreToolUseHooks: snapshotHasHooks,
hasPreToolUseHooksNow: resolvePlanState(cwd).pipe(
Effect.map((state) => state.entries.length > 0),
Effect.catchTag("T3HookConfigError", (error) =>
logConfigFailure(error).pipe(Effect.as(true)),
),
),
evaluatePreToolUse: (input) =>
evaluateEntries({
entries,
configPath,
projectDirectory,
payload: { ...input, cwd },
}),
Effect.gen(function* () {
const state = yield* resolvePlanState(cwd);
if (state.configPath === undefined) {
return { decision: "allow" } satisfies T3HookDecision;
}
return yield* evaluateEntries({
entries: state.entries,
configPath: state.configPath,
projectDirectory: state.projectDirectory,
payload: { ...input, cwd },
});
}).pipe(
Effect.catchTag("T3HookConfigError", (error) =>
logConfigFailure(error).pipe(
Effect.as({
decision: "ask",
title: "T3 hook config failed",
reason: `T3 project hooks could not be loaded from ${error.configPath}.`,
} satisfies T3HookDecision),
),
),
),
} satisfies T3HookPlan;
},
);
Expand Down
Loading
Loading