Skip to content
Draft
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
9 changes: 9 additions & 0 deletions .agents/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,14 @@
"timeout": 90
}
]
},
"skill-lint": {
"Stop": [
{
"type": "command",
"command": "../tool/dart_hooks/bin/agent_skill_lint.dart --source hook --log",
"timeout": 120
}
]
}
}
9 changes: 9 additions & 0 deletions tool/.agents/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,14 @@
"timeout": 90
}
]
},
"skill-lint": {
"Stop": [
{
"type": "command",
"command": "../dart_hooks/bin/agent_skill_lint.dart --source hook --log",
"timeout": 120
}
]
}
}
9 changes: 9 additions & 0 deletions tool/dart_hooks/.agents/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,14 @@
"timeout": 90
}
]
},
"skill-lint": {
"Stop": [
{
"type": "command",
"command": "../bin/agent_skill_lint.dart --source hook --log",
"timeout": 120
}
]
}
}
75 changes: 75 additions & 0 deletions tool/dart_hooks/.agents/skills/author-agent-hook/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
name: author-agent-hook
description: Helps scaffold deterministic script execution triggered by agent lifecycle events (jetski/antigravity hooks) against a minimal set of changes. Make sure to invoke this skill eagerly whenever a user mentions they want to author a hook, automate tasks/scripts on every change, integrate custom scripts/linters into the agent loop, or set up event handlers inside hooks.json, even if they don't explicitly ask for 'hook scaffolding.'
---

# Authoring Agent Lifecycle Hooks (`author-agent-hook`)

This skill establishes standard, deterministic scaffolding to execute a user-provided script or command during specific agent lifecycle events within the `dart_hooks` repository.

## 1. Initial Context Gathering
Before authoring code, confirm with the user:
- **Target Script/Command**: The exact path or command string the user wants to execute.
- **Lifecycle Event Type**: The target event type (`PreToolUse`, `PostToolUse`, `PreInvocation`, `PostInvocation`, or `Stop`). If the user does not specify or does not know the event type, **assume `"Stop"`** by default.

## 2. Scaffolding Implementation Details
Implement the hook functionality by generating the following standard file structure:

### A. Executable Runner Script (`bin/agent_<hook_name>.dart`)
Create a thin entry point script inside the `bin/` directory delegating execution to the shared `runHookMain` utility.
- **CRITICAL**: Ensure the script contains a proper shebang (`#!/usr/bin/env dart`).
- **CRITICAL**: Ensure the script file has POSIX executable permissions enabled (`chmod +x`). Without execution bits, the shell will reject execution with `Permission denied` (exit code 126) when triggered via `hooks.json`.
- **Implementation Pattern**:
```dart
#!/usr/bin/env dart
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:io';
import 'package:dart_hooks/src/<hook_name>_hook.dart';
import 'package:dart_hooks/src/hook_utils.dart';

Future<void> main(List<String> args) async {
await runHookMain(
args: args,
logFileName: '<hook_name>.log',
executeHook: (String source, Future<void> Function(String) logToFile) async {
final String packageRoot = Directory.current.parent.path;
final <HookClassName> hook = <HookClassName>(logToFile: logToFile);
await hook.run(
args: args,
currentPath: Directory.current.path,
packageRoot: packageRoot,
triggerSource: source,
);
},
);
}
```

### B. Core Hook Subclass (`lib/src/<hook_name>_hook.dart`)
Implement the custom hook logic by extending `BaseGitHook`.
- Provide standard overrides for `allowedExtensions`, `hookName`, and `executeCommand`.
- If the target script needs to process specific filtered paths or directories, override `transformScopedFiles` to map scoped files to the target command arguments.

### C. Configuration Registration (`.agents/hooks.json`)
Register the hook under the user-specified (or defaulted `"Stop"`) event type key inside `.agents/hooks.json`.
- **Command String Details**: Format the command string exactly as required for direct execution via `sh -c`.
```json
"<hook_name>": {
"<EventType>": [
{
"type": "command",
"command": "../bin/agent_<hook_name>.dart --source hook --log",
"timeout": 120
}
]
}
```
*(Note: For `Stop` events, handlers use a flat array structure directly under the event key without `matcher` or nested `hooks` wrappers).*

## 3. Static Analysis & Testing Hygiene
Ensure all generated code strictly adheres to repository static analysis standards:
- **Typing Rules**: Run `dart analyze` to ensure complete absence of info, warning, or error messages.
- **Unit & Integration Tests**: Author comprehensive test coverage in `test/agent_<hook_name>_test.dart` and `test/agent_<hook_name>_integration_test.dart` verifying behavior via mock process runners and actual temp Git repositories. Verify success using the `run_tests` tool.
29 changes: 29 additions & 0 deletions tool/dart_hooks/bin/agent_skill_lint.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env dart
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:io';
import 'package:dart_hooks/src/hook_utils.dart';
import 'package:dart_hooks/src/skill_lint_hook.dart';

/// Runs `dart_skills_lint` against any skill whose `SKILL.md` was modified.
/// Typically invoked automatically by Antigravity via `.agents/hooks.json`.
/// To run manually, execute from the project root:
/// `dart tool/dart_hooks/bin/agent_skill_lint.dart`
Future<void> main(List<String> args) async {
await runHookMain(
args: args,
logFileName: 'skill_lint.log',
executeHook: (source, logToFile) async {
final String packageRoot = Directory.current.parent.path;
final hook = SkillLintHook(logToFile: logToFile);
await hook.run(
args: args,
currentPath: Directory.current.path,
packageRoot: packageRoot,
triggerSource: source,
);
},
);
}
45 changes: 35 additions & 10 deletions tool/dart_hooks/lib/src/base_git_hook.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,28 @@ abstract class BaseGitHook {
/// The name of the hook for logging purposes.
String get hookName;

/// Absolute path to the repository root. Set during [run] before any
/// invocation of [executeCommand] or [transformScopedFiles].
@protected
late String repoRoot;

/// Optional hook for subclasses to rewrite the scoped file list before
/// chunking. The default implementation is the identity function.
///
/// Subclasses can use this to apply additional filtering (e.g., matching
/// only specific basenames) or to map file paths to other arguments
/// (e.g., mapping `path/to/SKILL.md` to `path/to`). Returning an empty
/// list short-circuits the hook to `{"decision": "stop"}`.
@protected
List<String> transformScopedFiles(List<String> scopedFiles) => scopedFiles;

/// Prints a stop decision and invokes the exit callback with a success code.
@protected
void stopHook() {
printStdout(jsonEncode({'decision': 'stop'}));
onExit(0);
}

/// Runs the specific command on the files (e.g., `dart analyze`).
@protected
Future<ProcessResult> executeCommand(List<String> files);
Expand Down Expand Up @@ -59,9 +81,7 @@ abstract class BaseGitHook {
}
final String repoRootRaw = (repoRootResult.stdout as String).trim();
final repoDir = Directory(repoRootRaw);
final String repoRoot = repoDir.existsSync()
? repoDir.resolveSymbolicLinksSync()
: repoRootRaw;
repoRoot = repoDir.existsSync() ? repoDir.resolveSymbolicLinksSync() : repoRootRaw;

// 2. Get modified files
final List<String> files;
Expand Down Expand Up @@ -104,12 +124,18 @@ abstract class BaseGitHook {

if (scopedFiles.isEmpty) {
await logToFile('No matching files found to process in scope: $scopeDir.');
printStdout(jsonEncode({'decision': 'stop'}));
onExit(0);
stopHook();
return;
}

await logToFile('Running command on ${scopedFiles.length} files...');
final List<String> transformedFiles = transformScopedFiles(scopedFiles);
if (transformedFiles.isEmpty) {
await logToFile('No files to process after transform.');
stopHook();
return;
}

await logToFile('Running command on ${transformedFiles.length} files...');

// 4. Execute the specific command in chunks to avoid ARG_MAX limits.
// Determining the exact ARG_MAX is hard as it varies by OS and depends on environment size.
Expand All @@ -121,8 +147,8 @@ abstract class BaseGitHook {
var currentChunk = <String>[];
var currentChunkLength = 0;

for (final file in scopedFiles) {
// Add 1 for the space separator between arguments
for (final file in transformedFiles) {
// Add 1 for the space separator between arguments.
final int fileLen = file.length + 1;
Comment thread
reidbaker marked this conversation as resolved.

if (currentChunkLength + fileLen > maxCharsPerChunk && currentChunk.isNotEmpty) {
Expand Down Expand Up @@ -164,8 +190,7 @@ abstract class BaseGitHook {
// 5. Handle result
if (exitCode == 0) {
await logToFile('Command passed');
printStdout(jsonEncode({'decision': 'stop'}));
onExit(0);
stopHook();
return;
}

Expand Down
70 changes: 70 additions & 0 deletions tool/dart_hooks/lib/src/skill_lint_hook.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:io';
import 'package:path/path.dart' as p;
import 'base_git_hook.dart';
import 'process_runner.dart';

/// Implements a hook that runs `dart_skills_lint` against any skill whose
/// `SKILL.md` was modified.
///
/// Unlike file-oriented hooks (analyze, format), this hook is skill-directory
/// oriented: it filters the modified-file list to entries whose basename is
/// exactly `SKILL.md`, then runs the linter once with each skill's containing
/// directory passed as a `-s` argument. A single pass; the agent is
/// responsible for fixing reported errors.
class SkillLintHook extends BaseGitHook {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this hook interact with a packages dart_skills_lint.yaml especially its ignore.json ?

/// Creates a [SkillLintHook].
SkillLintHook({
super.processRunner = const RealProcessRunner(),
super.fileExists = _defaultFileExists,
super.printStdout = _defaultPrintStdout,
required super.logToFile,
super.onExit = exit,
});

static bool _defaultFileExists(String path) => File(path).existsSync();
static void _defaultPrintStdout(String message) => stdout.writeln(message);

/// Path to the `dart_skills_lint` package, relative to the repository root.
static const String _lintPackageRelativePath = 'tool/dart_skills_lint';

/// CLI entrypoint inside the `dart_skills_lint` package.
static const String _lintBinRelativePath = 'bin/cli.dart';

/// Filters the raw git status modified files by extension (e.g., ['.md']) before
/// scoping and chunking.
@override
List<String> get allowedExtensions => ['.md'];
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where is this used?


@override
String get hookName => 'dart_skills_lint';

/// Filters the scoped file list to entries whose basename is case-insensitively
/// `SKILL.md`, then maps each to its parent directory. Duplicates are
/// removed and the result is sorted for deterministic command-line output.
@override
List<String> transformScopedFiles(List<String> scopedFiles) {
final skillDirectories = <String>{};
for (final file in scopedFiles) {
if (p.basename(file).toLowerCase() == 'skill.md') {
skillDirectories.add(p.normalize(p.dirname(file)));
}
}
return skillDirectories.toList()..sort();
}

@override
Future<ProcessResult> executeCommand(List<String> skillDirectories) {
final String lintPackageDir = p.join(repoRoot, _lintPackageRelativePath);
final String lintBinPath = p.join(lintPackageDir, _lintBinRelativePath);
final args = <String>[
'run',
lintBinPath,
for (final dir in skillDirectories) ...['-s', dir],
];
return processRunner.run('dart', args);
}
Comment thread
reidbaker marked this conversation as resolved.
}
10 changes: 6 additions & 4 deletions tool/dart_hooks/test/agent_dart_format_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ void main() {
}),
fileExists: (path) => true,
printStdout: (msg) {},
logToFile: (msg) async => loggedMessage = msg,
logToFile: (msg) async => loggedMessage = "${loggedMessage ?? ''}$msg\n",
onExit: (code) {},
);

await hook.run(
Expand Down Expand Up @@ -60,7 +61,8 @@ void main() {
return ProcessResult(0, 0, '', '');
}),
fileExists: (path) => true,
logToFile: (msg) async => loggedMessage = msg,
logToFile: (msg) async => loggedMessage = "${loggedMessage ?? ''}$msg\n",
onExit: (code) {},
);

await hook.run(
Expand Down Expand Up @@ -181,7 +183,7 @@ void main() {
expect(exitCode, equals(1));
});

test('Exits 1 when git rev-parse fails', () async {
test('Exits 0 and continues when git rev-parse fails', () async {
int? exitCode;

final hook = DartFormatHook(
Expand Down Expand Up @@ -209,7 +211,7 @@ void main() {
triggerSource: 'MANUAL',
);

expect(exitCode, equals(1));
expect(exitCode, equals(0));
});
});
}
Loading
Loading