Skip to content

Commit 40d5141

Browse files
committed
refactor: unify JvmScope/ClaudeHook hierarchy, introduce ClaudeTool interface, document minimum method visibility convention
1 parent 2a6dc71 commit 40d5141

263 files changed

Lines changed: 9056 additions & 7787 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"status": "open"}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Plan
2+
3+
## Goal
4+
5+
Add a new `cat:scan-and-edit` skill that implements a scan-first, batch-edit, compile-once pattern. The
6+
`plugin/agents/work-execute.md` implementation subagent will call this skill before editing any files. The skill greps
7+
all usages of every symbol being changed (renamed, removed, or moved), builds a complete file→changes map, applies all
8+
edits without intermediate compilation, then compiles once at the end. This eliminates the compile-fix loop that caused
9+
an 11-hour looping session when a Wave 2 implementation subagent encountered cascading Java refactoring errors.
10+
11+
## Pre-conditions
12+
13+
(none)
14+
15+
## Post-conditions
16+
17+
- [ ] `plugin/skills/scan-and-edit/SKILL.md` created with valid frontmatter (`description`, `user-invocable: false`,
18+
`allowed-tools`, `argument-hint`) and preprocessor directive pointing to `first-use.md`
19+
- [ ] `plugin/skills/scan-and-edit/first-use.md` created with license header and four-phase agent instructions:
20+
(1) scan — grep all usages of symbols being changed before any edits, (2) map — build complete file→changes list,
21+
(3) edit — apply all changes without recompiling between files, (4) compile — run build once at the end
22+
- [ ] `plugin/agents/work-execute.md` updated to invoke `cat:scan-and-edit` (via `skill: "cat:scan-and-edit"`)
23+
before any file editing steps
24+
- [ ] All existing tests pass after changes
25+
- [ ] E2E: Manually run a refactoring scenario with multi-file symbol removal and confirm build passes with zero
26+
intermediate compilations
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2-
"status": "open",
3-
"dependencies": ["2.1-fix-pre-rebase-check-false-positives"]
2+
"status": "closed",
3+
"resolution": "implemented",
4+
"target_branch": "v2.1"
45
}

.cat/issues/v2/v2.1/jvmenv-w1-claudeenv/plan.md

Lines changed: 689 additions & 35 deletions
Large diffs are not rendered by default.

.claude/rules/jackson.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
- Obtain the shared instance from `JvmScope.getJsonMapper()` — never call `JsonMapper.builder().build()` directly
77
- The shared instance is configured with pretty print (`SerializationFeature.INDENT_OUTPUT`)
88
- In production code, get the mapper from the `JvmScope` passed to your class
9-
- In tests, create a `TestJvmScope` and call `scope.getJsonMapper()`
10-
- In CLI `main()` methods, create a `MainJvmScope` and call `scope.getJsonMapper()`
9+
- In tests, create a `TestClaudeTool` and call `scope.getJsonMapper()`
10+
- In CLI `main()` methods, create a `MainClaudeTool` and call `scope.getJsonMapper()`
1111

1212
## JsonNode API
1313

.claude/rules/java.md

Lines changed: 204 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,25 @@ paths: ["*.java"]
1111
- **JSON Library:** Jackson 3.x with `JsonMapper`
1212
- **Validation Library:** requirements.java 13.2+
1313

14+
### Running the Build
15+
16+
**MANDATORY:** Use `mvn verify -e` (not `mvn test`) to run the full build including compiler and linters:
17+
18+
```bash
19+
mvn -f client/pom.xml verify -e
20+
```
21+
22+
Treat linter errors (Checkstyle, PMD) the same as compiler errors — both must be fixed before any commit.
23+
24+
**Do NOT skip linters:**
25+
-`mvn -f client/pom.xml test -Dcheckstyle.skip=true`
26+
-`mvn -f client/pom.xml verify -Dpmd.skip=true`
27+
-`mvn -f client/pom.xml verify -e`
28+
29+
**Fix ALL errors before rerunning the build.** After collecting the full output from one `mvn verify -e` run,
30+
apply all fixes across all files without any intermediate recompilation. Run `mvn verify -e` again only once
31+
all fixes have been applied.
32+
1433
## Code Style
1534

1635
### Braces
@@ -1280,6 +1299,65 @@ public final class SkillLoader
12801299
}
12811300
```
12821301

1302+
### Minimum Method Visibility
1303+
Always use the most restrictive visibility that still allows the method to function correctly. Work down from the
1304+
least-restrictive level needed:
1305+
1306+
| Visibility | Use when |
1307+
|------------|----------|
1308+
| `public` | Part of the class's public API; called from outside the package |
1309+
| `protected` | Must be accessible to subclasses or other classes in the same package |
1310+
| package-private (no modifier) | Used only within the same package; no subclass involvement |
1311+
| `private` | Used only within the same class |
1312+
1313+
**Final classes:** `protected` is meaningless on a `final` class — the class cannot be subclassed, so no override or
1314+
inheritance-based access is possible. Convert every `protected` method in a `final` class to `private` unless another
1315+
class in the same package calls it (in which case package-private is sufficient).
1316+
1317+
```java
1318+
// Good - final class uses private instead of protected
1319+
public final class MainClaudeTool implements ClaudeTool
1320+
{
1321+
private String getEnvVar(String name)
1322+
{
1323+
return System.getenv(name);
1324+
}
1325+
}
1326+
1327+
// Avoid - protected in a final class (no subclass can ever override this)
1328+
public final class MainClaudeTool implements ClaudeTool
1329+
{
1330+
protected String getEnvVar(String name)
1331+
{
1332+
return System.getenv(name);
1333+
}
1334+
}
1335+
```
1336+
1337+
**Non-final classes:** `protected` is appropriate only when a subclass actually overrides or calls the method.
1338+
If no subclass uses it, prefer package-private (no modifier) or `private`.
1339+
1340+
```java
1341+
// Good - package-private; only used within the same package by non-subclass code
1342+
String buildCacheKey()
1343+
{
1344+
return "prefix:" + id;
1345+
}
1346+
1347+
// Avoid - protected when no subclass calls or overrides it
1348+
protected String buildCacheKey()
1349+
{
1350+
return "prefix:" + id;
1351+
}
1352+
```
1353+
1354+
**Public API surface:** Restrict `public` to methods that form the class's intended contract. Helper and utility
1355+
methods used only within the package should be package-private or private even if their class is public.
1356+
1357+
**Remove unused methods:** After reducing visibility, delete any method that is now unreachable — i.e., `private`
1358+
methods not called within the class, or package-private methods not called anywhere in the package. Dead code adds
1359+
noise and misleads future readers into thinking a method has callers.
1360+
12831361
### Service Access via Pouch Scopes (No Dependency Injection)
12841362
Do not use dependency injection frameworks (Spring, Guice, Dagger, etc.). Use [pouch](https://github.com/cowwoc/pouch)
12851363
scope-based ServiceLocators for inversion of control. Scopes are explicit objects passed through constructors that
@@ -1330,8 +1408,11 @@ the scope internally. This keeps constructors stable when new dependencies are a
13301408
accessors through call chains.
13311409

13321410
**Scope implementations:**
1333-
- `MainJvmScope` — production use (in `main()` methods), reads environment configuration
1334-
- `TestJvmScope` — test use, accepts injectable paths: `new TestJvmScope(tempDir, tempDir)`
1411+
- `MainClaudeTool` — production use for session CLI tools (in `main()` methods that require `CLAUDE_SESSION_ID`
1412+
and `CLAUDE_ENV_FILE`), reads all session environment configuration
1413+
- `MainJvmScope` — production use for infrastructure CLI tools (in `main()` methods that do NOT require session
1414+
vars, e.g., `GetSkill`), reads only infrastructure vars
1415+
- `TestClaudeTool` — test use, accepts injectable paths: `new TestClaudeTool(tempDir, tempDir)`
13351416

13361417
**Why pouch over DI frameworks:**
13371418
- No magic — explicit constructor wiring, fully debuggable code flow
@@ -1355,7 +1436,7 @@ public final class GetDiffOutput
13551436

13561437
public static void main(String[] args) // CLI entry point via hook.sh
13571438
{
1358-
try (JvmScope scope = new MainJvmScope())
1439+
try (JvmScope scope = new MainClaudeTool())
13591440
{
13601441
String output = new GetDiffOutput(scope).getOutput();
13611442
if (output != null)
@@ -1370,7 +1451,7 @@ public final class RenderDiffCommand // Don't create this
13701451
public static void main(String[] args)
13711452
{
13721453
// Trivial delegation adds no value
1373-
new GetDiffOutput(new MainJvmScope()).getOutput();
1454+
new GetDiffOutput(new MainClaudeTool()).getOutput();
13741455
}
13751456
}
13761457
```
@@ -1394,6 +1475,75 @@ catch (IOException e)
13941475
}
13951476
```
13961477

1478+
### Try-With-Resources: Interface on Left Side
1479+
1480+
Always declare the variable with the interface type on the left side of `try-with-resources`, not the concrete class:
1481+
1482+
```java
1483+
// Good - interface type on left
1484+
try (ClaudeTool scope = new MainClaudeTool())
1485+
{
1486+
...
1487+
}
1488+
1489+
// Avoid - concrete class on left
1490+
try (MainClaudeTool scope = new MainClaudeTool())
1491+
{
1492+
...
1493+
}
1494+
```
1495+
1496+
### Single-Scope Error Handling in main()
1497+
1498+
When `main()` handles expected and unexpected errors, use a single scope with a nested try-catch inside it. Do NOT create
1499+
additional scope instances in catch blocks:
1500+
1501+
```java
1502+
// Good - one scope, nested try-catch inside
1503+
public static void main(String[] args)
1504+
{
1505+
try (ClaudeTool scope = new MainClaudeTool())
1506+
{
1507+
try
1508+
{
1509+
new MyClass(scope).run(args, System.out);
1510+
}
1511+
catch (IllegalArgumentException | IOException e)
1512+
{
1513+
System.out.println(new HookOutput(scope).block(
1514+
Objects.toString(e.getMessage(), e.getClass().getSimpleName())));
1515+
}
1516+
catch (RuntimeException | AssertionError e)
1517+
{
1518+
Logger log = LoggerFactory.getLogger(MyClass.class);
1519+
log.error("Unexpected error", e);
1520+
System.out.println(new HookOutput(scope).block(
1521+
Objects.toString(e.getMessage(), e.getClass().getSimpleName())));
1522+
}
1523+
}
1524+
}
1525+
1526+
// Avoid - multiple scope instances
1527+
public static void main(String[] args)
1528+
{
1529+
try (ClaudeTool scope = new MainClaudeTool())
1530+
{
1531+
new MyClass(scope).run(args, System.out);
1532+
}
1533+
catch (RuntimeException | AssertionError e)
1534+
{
1535+
try (ClaudeTool errorScope = new MainClaudeTool()) // Don't do this
1536+
{
1537+
System.out.println(new HookOutput(errorScope).block(...));
1538+
}
1539+
}
1540+
}
1541+
```
1542+
1543+
**Why:** Creating a new scope in the catch block reads environment variables again, adds latency, and opens two resources
1544+
sequentially when one would suffice. The single outer scope is still open during the catch block, so it can be used
1545+
directly for error reporting.
1546+
13971547
## Warnings Suppression
13981548

13991549
### @SuppressWarnings("unchecked")
@@ -1432,20 +1582,36 @@ depending on the execution context. Never call `System.getenv()` directly outsid
14321582

14331583
| Context | Correct API |
14341584
|---------|-------------|
1435-
| CLI commands (`main()` methods) | `new ClaudeEnv().getSessionId()``getSessionId()` is an instance method |
1585+
| Session CLI commands (`main()` methods) — have `CLAUDE_SESSION_ID` and `CLAUDE_ENV_FILE` | `scope.getSessionId()` via `ClaudeTool` |
1586+
| Infrastructure CLI commands (`main()` methods) — invoked outside a session (e.g., by skill preprocessor) | `scope.getPluginRoot()` etc. via `JvmScope` |
14361587
| Hook handlers | `HookInput.getSessionId()` |
14371588
| Skill directive variable substitution | `System.getenv(name)` (whitelisted; see below) |
14381589

14391590
**Why:** Hook handlers receive session-specific values from the `HookInput` JSON payload, not from environment
1440-
variables. Reading environment variables in hook handlers bypasses this contract. CLI commands that run outside of
1441-
hook invocation (e.g., `GetSkill`, `WorkPrepare`) use `ClaudeEnv` which wraps `System.getenv()` with validation.
1591+
variables. Reading environment variables in hook handlers bypasses this contract. Session CLI commands use
1592+
`MainClaudeTool` (a `ClaudeTool` implementation) which reads all env vars at startup. Infrastructure CLI commands
1593+
(e.g., `GetSkill`) use `MainJvmScope` which reads only infrastructure path vars and does not require
1594+
`CLAUDE_SESSION_ID` or `CLAUDE_ENV_FILE`.
14421595

14431596
```java
1444-
// Good - CLI main() method reads session ID via ClaudeEnv (instance method)
1597+
// Good - session CLI main() method reads session ID via scope
14451598
public static void main(String[] args)
14461599
{
1447-
String sessionId = new ClaudeEnv().getSessionId();
1448-
// ...
1600+
try (ClaudeTool scope = new MainClaudeTool())
1601+
{
1602+
String sessionId = scope.getSessionId();
1603+
// ...
1604+
}
1605+
}
1606+
1607+
// Good - infrastructure CLI main() method uses MainJvmScope (no session vars required)
1608+
public static void main(String[] args)
1609+
{
1610+
try (JvmScope scope = new MainJvmScope())
1611+
{
1612+
Path pluginRoot = scope.getPluginRoot();
1613+
// ...
1614+
}
14491615
}
14501616

14511617
// Bad - CLI main() method reads session ID via System.getenv()
@@ -1471,22 +1637,37 @@ public Result handle(HookInput input)
14711637
```
14721638

14731639
`EnforceJvmScopeEnvAccessTest` enforces this convention by scanning all Java source files and failing the build if
1474-
`System.getenv(` appears outside of `MainJvmScope.java`, `ClaudeEnv.java`, `GetSkill.java`, and `TerminalType.java`.
1475-
1476-
The four whitelisted files each have a specific reason for direct env var access:
1477-
- `MainJvmScope.java` — reads infrastructure path variables (`CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`,
1478-
`CLAUDE_CONFIG_DIR`, `TZ`) available in both hook and CLI contexts
1479-
- `ClaudeEnv.java` — the designated wrapper for session-specific variables (`CLAUDE_SESSION_ID`,
1480-
`CLAUDE_ENV_FILE`); all other code must use `ClaudeEnv` to access these
1640+
`System.getenv(` appears outside of `MainClaudeTool.java`, `MainJvmScope.java`, `MainClaudeHook.java`,
1641+
`GetSkill.java`, and `TerminalType.java`.
1642+
1643+
The five whitelisted files each have a specific reason for direct env var access:
1644+
- `MainClaudeTool.java` — reads all Claude env vars (`CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`,
1645+
`CLAUDE_SESSION_ID`, `CLAUDE_ENV_FILE`, `TZ`) at startup and stores them as fields; for CLI tools that run
1646+
as part of a Claude session
1647+
- `MainJvmScope.java` — reads only infrastructure path vars (`CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`,
1648+
`CLAUDE_CONFIG_DIR`, `TZ`); for CLI tools that run without a Claude session (e.g., `GetSkill` which is
1649+
invoked by the skill preprocessor before a session is established)
1650+
- `MainClaudeHook.java` — reads infrastructure path vars (`CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`,
1651+
`CLAUDE_CONFIG_DIR`, `CLAUDE_ENV_FILE`, `TZ`) from the environment; the production hook scope implementation
1652+
used in hook handler `main()` methods
14811653
- `GetSkill.java` — expands env var references in skill directive templates; requires direct access to
14821654
substitute variable values
14831655
- `TerminalType.java` — detects terminal type from standard terminal env vars (`TERM`, `TERM_PROGRAM`)
14841656

1657+
**Scope implementations:**
1658+
- `MainClaudeTool` — production use for session CLI tools (in `main()` methods that require `CLAUDE_SESSION_ID`
1659+
and `CLAUDE_ENV_FILE`), reads all session environment configuration
1660+
- `MainJvmScope` — production use for infrastructure CLI tools (in `main()` methods that do NOT require session
1661+
vars, e.g., `GetSkill`), reads only infrastructure vars
1662+
- `MainClaudeHook` — production use for hook handler `main()` methods, reads infrastructure path vars and hook
1663+
JSON from stdin
1664+
- `TestClaudeTool` — test use, accepts injectable paths: `new TestClaudeTool(tempDir, tempDir)`
1665+
14851666
```cat-rules
14861667
- pattern: "System\\.getenv\\("
14871668
files: "*.java"
14881669
severity: high
1489-
message: "Use ClaudeEnv (CLI commands) or HookInput (hook handlers) instead of System.getenv(). Direct access is only permitted in the four whitelisted files. See .claude/rules/java.md § Environment Variable Access."
1670+
message: "Use scope.getSessionId() (session CLI commands) or scope.getPluginRoot() via MainJvmScope (infrastructure CLI commands) or HookInput (hook handlers) instead of System.getenv(). Direct access is only permitted in the five whitelisted files. See .claude/rules/java.md § Environment Variable Access."
14901671
```
14911672

14921673
## Exception Handling
@@ -1500,10 +1681,7 @@ state is queryable, and the caller could have checked before calling.
15001681

15011682
```java
15021683
// Good - AssertionError for environment invariant (caller cannot prevent or query)
1503-
// Use ClaudeEnv to safely read session configuration
1504-
String sessionId = new ClaudeEnv().getSessionId();
1505-
if (sessionId.isBlank())
1506-
throw new AssertionError("CLAUDE_SESSION_ID is not set");
1684+
String sessionId = scope.getSessionId(); // throws AssertionError if env var not set
15071685

15081686
// Good - IllegalStateException for preventable state violation (caller can query)
15091687
public void stop()
@@ -1691,8 +1869,8 @@ these Java-specific constraints:
16911869
4. **No shared mutable state** - each test must be fully self-contained
16921870
5. **No TestBase classes** - each test method must inline its own setup. This boilerplate is intentional and preferred
16931871
over shared helpers or inheritance.
1694-
6. **Use `TestJvmScope`, not `MainJvmScope`** - tests must never use `MainJvmScope` because it reads environment
1695-
variables that may not be set in test contexts. Use `TestJvmScope(tempDir, tempDir)` with injectable paths instead.
1872+
6. **Use `TestClaudeTool`, not `MainClaudeTool`** - tests must never use `MainClaudeTool` because it reads environment
1873+
variables that may not be set in test contexts. Use `TestClaudeTool(tempDir, tempDir)` with injectable paths instead.
16961874
7. **Never use scope-provided objects after closing the scope** - objects returned by `JvmScope` (e.g., `JsonMapper`,
16971875
`DisplayUtils`) must not be used after the scope is closed. Keep the scope open for the entire duration of the test.
16981876
Do not create helper methods like `getTestMapper()` that open a scope, extract an object, and close the scope.
@@ -1706,12 +1884,12 @@ these Java-specific constraints:
17061884
missing file check), passing `"."` is acceptable since no git command actually runs.
17071885

17081886
```java
1709-
// Good - self-contained test with TestJvmScope
1887+
// Good - self-contained test with TestClaudeTool
17101888
@Test
17111889
public void testProcess() throws IOException
17121890
{
17131891
Path tempDir = Files.createTempDirectory("test-");
1714-
try (JvmScope scope = new TestJvmScope(tempDir, tempDir))
1892+
try (JvmScope scope = new TestClaudeTool(tempDir, tempDir))
17151893
{
17161894
JsonMapper mapper = scope.getJsonMapper();
17171895
var result = process(scope, input);

0 commit comments

Comments
 (0)