Skip to content

feat(afk): judge patch_apply edits in the rule hook - #36

Merged
0x7067 merged 2 commits into
0x7067:mainfrom
griffinwork40:afk-rules-patch-apply
Oct 5, 2026
Merged

0x7067 merged 2 commits into
0x7067:mainfrom
griffinwork40:afk-rules-patch-apply

Conversation

@griffinwork40

@griffinwork40 griffinwork40 commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Why

agent-afk has a patch_apply tool for atomic multi-file edits. The AFK rule hook (adapters/afk/src/rules.ts) only matched edit_file and write_file, so edits made through patch_apply landed unjudged and were never recorded for the Stop sweep. The adapter README listed this as a known gap.

What agent-afk sends (checked in agent-afk 5.286.1 source)

  • src/agent/hooks/command-executor.ts buildStdinPayload: a PreToolUse command hook gets tool_name: context.toolName (the AFK name, "patch_apply") and tool_input: context.input, the raw tool input { changes: [{ path, edits?: [{ old, new }], content?, expected_hash? }], dry_run? }. No file_path, no old_string/new_string.
  • src/agent/hooks/matcher.ts compileMatcher: a /pattern/ matcher is tested against the AFK tool name only. The CLAUDE_CODE_ALIASES table (patch_apply: ['MultiEdit']) is used only for bare-name and anchored-regex matchers, so /^(edit_file|write_file)$/ never fired for patch_apply. Listing patch_apply in the regex is what makes it fire. The MultiEdit alias does not matter here: the hook reads AFK's changes input, not Claude Code's MultiEdit input.
  • src/agent/tools/handlers/patch-apply.ts: relative paths resolve against the session cwd (the same cwd the hook receives), and dry_run defaults to false.

What

  • hooks.json: matcher becomes /^(edit_file|write_file|patch_apply)$/.
  • adapters/afk/src/shared/edit-targets.ts (new) turns any of the three inputs into a list of { filePath, rel, hunk }. edit_file and write_file produce the same hunk as before. Each patch_apply change becomes the same REMOVED:/ADDED: hunk an edit_file call would, or its full content. Two changes to one path merge. Entries without a path or content are skipped. dry_run: true produces nothing.
  • rules.ts:
    • Rules load once. Each file is judged in its own Jev call against the rules scoped to it, with the existing scope globs and subject filter.
    • Calls run in parallel, up to 8 at a time, and share one 12 s budget inside the 15 s hook timeout. A file that has not been asked by the time the budget runs out is logged as rules-error and not judged (fail open).
    • Each file writes its own log row, so stats.py sees a file-level check exactly as it does for edit_file.
    • The two-blocks-per-rule-per-file cap is unchanged and keyed per file.
    • The patch is atomic, so if any file has an act-band hit the whole call is blocked. The block message lists only the files that broke a rule and says none of the files were written. Nothing is recorded for the Stop sweep in that case.
    • A clean or flagged patch records every file it would write.
    • The single-file block message is unchanged.
  • README: updated the hook-table row, and removed the patch_apply Known gaps bullet. No other README lines changed, because a parallel PR refreshes the rest.
  • CHANGELOG [Unreleased] bullet.

Not changed: the AFK hook has no out-of-project skip today (isOutside is used only by the root src/rules.ts). patch_apply files are treated the same as edit_file ones. Adding that skip would change edit_file behavior too, so it should be a separate PR.

No version bump. Other AFK adapter PRs are open at the same time, and bumping in each would conflict. I left it to you.

Verified

  • node --experimental-strip-types --test adapters/afk/test/*.test.ts: 13 pass, 0 fail. The new adapters/afk/test/patch-apply.test.ts runs the real hook as a subprocess against a local HTTP server passed in through the existing JEV_BASE_URL, the same way classify-failure.test.ts does. That server stands in for the Jev endpoint, so these are wiring tests, not a measure of Jev's judgments. They cover:
    • the hooks.json matcher, compiled the way agent-afk compiles /…/, matching patch_apply and not MultiEdit
    • editTargets parsing for all three tools
    • a 3-file clean patch: 3 Jev calls, more than one in flight at once, 3 log rows, 3 Stop-sweep records
    • one violating file out of two: whole call blocked, only the bad file named, no Stop-sweep record, block counter keyed to the bad file only
    • dry_run: no Jev call, no log, no record
    • the edit_file block message unchanged
    • malformed patch_apply input exits 0 with no output
  • npx tsc --noEmit (root, which includes the adapter tests) and adapters/afk tsc --noEmit: clean.
  • npx oxlint: 0 warnings, 0 errors.
  • check_no_comments.py, check_no_stubs.py: ok.
  • echo 'not json' | node --experimental-strip-types adapters/afk/src/rules.ts exits 0. A no-key patch_apply event exits 0 with no stdout and logs rules-error.

Not verified live. I did not run a real agent-afk session with a real key against patch_apply.


Summary by cubic

patch_apply edits from agent-afk now go through the AFK rule hook, instead of landing unjudged and never reaching the Stop sweep.

  • Extends the hook matcher to patch_apply and parses its { changes, dry_run } input into the same per-file hunks edit_file uses, via the new adapters/afk/src/shared/edit-targets.ts.
  • Judges each file in its own Jev call, in parallel (up to 8), against the rules scoped to that file, under a shared 12 s budget inside the 15 s hook timeout; each check writes its own log row.
  • Blocks the whole call when any file hits the act band (>= 0.80), naming only the violating files and writing nothing; clean or flagged patches record every file for the Stop sweep.
  • Skips dry_run calls entirely.
  • Drops the patch_apply known-gap bullet from the README (the merge from main kept the other gaps) and adds a CHANGELOG entry.

Notes

  • No version bump: other AFK adapter PRs are open in parallel and bumping each would conflict.
  • Out-of-project skip (isOutside) is still not applied to patch_apply; adding it would change edit_file behavior too, so it stays out of this PR.

Written for commit ad131f0. Summary will update on new commits.

Review in cubic

agent-afk's patch_apply tool writes several files atomically, and the AFK
rule hook only matched edit_file and write_file, so those edits landed
unjudged and never reached the Stop sweep.

agent-afk hands a plugin command hook tool_name "patch_apply" with the raw
{ changes, dry_run } input (src/agent/hooks/command-executor.ts
buildStdinPayload), and tests a /.../ matcher against that name alone; the
MultiEdit alias in src/agent/hooks/matcher.ts applies only to bare-name
matchers. The matcher now lists patch_apply.

Each file in the patch is judged in its own Jev call, in parallel, against
the rules scoped to that file, under one 12 s budget inside the hook's
15 s timeout, and each check writes its own log row. One file at >= 0.80
blocks the whole call and nothing is recorded for the Stop sweep; a clean
or flagged patch records every file. A dry_run call is not judged. The
input parsing moves to adapters/afk/src/shared/edit-targets.ts.

No version bump; left to the maintainer.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

7 issues found across 6 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="adapters/afk/src/shared/edit-targets.ts">

<violation number="1" location="adapters/afk/src/shared/edit-targets.ts:51">
P3: The `edits` loop in `editHunks` dereferences `e.old_string`/`e.new_string` before confirming `e` is an object, so one malformed element (e.g. `null`) throws inside `editTargets`. The top-level `try/catch` in `rules.ts` then swallows the event silently, so that edit lands unjudged and is never recorded for the Stop sweep — while `changeHunk` in the same parser guards elements with `isJsonObject(e)`. Apply the same guard here so the shared parser consistently skips malformed inputs as its contract states.</violation>

<violation number="2" location="adapters/afk/src/shared/edit-targets.ts:73">
P2: `changeHunk` accepts non-string runtime `old`/`new` values and turns them into hunks, so malformed patch inputs can be judged, logged, or blocked instead of being skipped. Validate both fields as strings before calling `pairHunk`.</violation>
</file>

<file name="adapters/afk/src/rules.ts">

<violation number="1" location="adapters/afk/src/rules.ts:195">
P3: Budget exhaustion is logged as kind `rules-error`, the same row type used for actual Jev API failures (`stats.py` and the README describe it as "Jev call failed"). Files skipped here were never attempted and still land unjudged via the fail-open path, so the row misrepresents a scheduled skip as an API failure. Drop the row (it still needs no block output) or use a dedicated skip reason.</violation>

<violation number="2" location="adapters/afk/src/rules.ts:252">
P2: A rejected per-file assessment is silently treated as if it did not exist, so a malformed Jev answer can let that patch file through without a `rules-error` row or a judgment. Log the rejected target as a check error before continuing fail-open.</violation>
</file>

<file name="adapters/afk/README.md">

<violation number="1" location="adapters/afk/README.md:14">
P2: This row overstates the patch guarantee: after the same rule/file reaches the two-block session limit, a third >= 0.80 hit does not block the atomic `patch_apply` call. Qualify the statement with that existing per-rule/file block-limit exception.</violation>
</file>

<file name="adapters/afk/test/patch-apply.test.ts">

<violation number="1" location="adapters/afk/test/patch-apply.test.ts:78">
P2: The mock answers every judgment question with noul 0.97 or 0.02, so no answer can ever land in the 0.50–0.80 flag band. The patch flag path — `hookSpecificOutput.additionalContext` plus recording all affected files for the Stop sweep (explicitly claimed in the PR description) — is never exercised by these tests. Make the mock answer configurable in the flag band (e.g. a per-state noul value) and add a test asserting a flagged patch writes `additionalContext` and still records every file.</violation>

<violation number="2" location="adapters/afk/test/patch-apply.test.ts:291">
P3: `maxInFlight > 1` only proves two judgments overlapped; it never verifies the eight-way batching (`MAX_PARALLEL` waves) that the patch judge is meant to perform, and with only three files the assertion would pass even if a max-parallel limit were removed entirely. Feed more than eight files in the clean patch and assert bounded concurrency (e.g. `1 < maxInFlight && maxInFlight <= 8`).</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread adapters/afk/src/rules.ts
Comment on lines +252 to 254
for (const r of settled) {
if (r.status === "fulfilled" && r.value !== null) out.push(r.value);
}

@cubic-dev-ai cubic-dev-ai Bot Oct 3, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: A rejected per-file assessment is silently treated as if it did not exist, so a malformed Jev answer can let that patch file through without a rules-error row or a judgment. Log the rejected target as a check error before continuing fail-open.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At adapters/afk/src/rules.ts, line 252:

<comment>A rejected per-file assessment is silently treated as if it did not exist, so a malformed Jev answer can let that patch file through without a `rules-error` row or a judgment. Log the rejected target as a check error before continuing fail-open.</comment>

<file context>
@@ -242,84 +231,187 @@ async function judge(
-    if (h.band === "act" && (state.blocks[blockKey] ?? 0) < MAX_BLOCKS) {
-      state.blocks[blockKey] = (state.blocks[blockKey] ?? 0) + 1;
-      acting.push(h);
+    for (const r of settled) {
+      if (r.status === "fulfilled" && r.value !== null) out.push(r.value);
     }
</file context>
Suggested change
for (const r of settled) {
if (r.status === "fulfilled" && r.value !== null) out.push(r.value);
}
for (const [i, r] of settled.entries()) {
if (r.status === "fulfilled" && r.value !== null) {
out.push(r.value);
} else if (r.status === "rejected") {
logCheckError(ctxFor(wave[i]!), String(r.reason), 0);
}
}
Fix with cubic

Comment thread adapters/afk/README.md
| `prompt-router.ts` | `UserPromptSubmit` | Classifies each prompt as chat / lookup / fix / feature / ops and injects a routing hint when confidence >= 0.75. Interactive REPL only. |
| `subagent-router.ts` | `PreToolUse` (`agent`) | Recommends a model tier when the spawn names no `agent_type`, and flags a brief that changes files but leaves out paths, acceptance criteria, verification, or commit policy. Advisory only, and AFK does not deliver it yet (see Known gaps). |
| `rules.ts` | `PreToolUse` (`edit_file`, `write_file`) | Loads rules from instruction files, classifies them, and judges each edit before it is written. **Blocks at >= 0.80**, so the edit never lands (agent cannot override). Flags 0.50-0.80 as advisory context, which AFK does not deliver yet. |
| `rules.ts` | `PreToolUse` (`edit_file`, `write_file`, `patch_apply`) | Loads rules from instruction files, classifies them, and judges each edit before it is written. **Blocks at >= 0.80**, so the edit never lands (agent cannot override). A `patch_apply` call is judged file by file, and one file at >= 0.80 blocks the whole call, so none of its files land; a `dry_run` call is not judged. Flags 0.50-0.80 as advisory context, which AFK does not deliver yet. |

@cubic-dev-ai cubic-dev-ai Bot Oct 3, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: This row overstates the patch guarantee: after the same rule/file reaches the two-block session limit, a third >= 0.80 hit does not block the atomic patch_apply call. Qualify the statement with that existing per-rule/file block-limit exception.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At adapters/afk/README.md, line 14:

<comment>This row overstates the patch guarantee: after the same rule/file reaches the two-block session limit, a third >= 0.80 hit does not block the atomic `patch_apply` call. Qualify the statement with that existing per-rule/file block-limit exception.</comment>

<file context>
@@ -11,7 +11,7 @@ Jev judgment hooks for [agent-afk](https://github.com/griffinwork40/agent-afk).
 | `prompt-router.ts` | `UserPromptSubmit` | Classifies each prompt as chat / lookup / fix / feature / ops and injects a routing hint when confidence >= 0.75. Interactive REPL only. |
 | `subagent-router.ts` | `PreToolUse` (`agent`) | Recommends a model tier when the spawn names no `agent_type`, and flags a brief that changes files but leaves out paths, acceptance criteria, verification, or commit policy. Advisory only, and AFK does not deliver it yet (see Known gaps). |
-| `rules.ts` | `PreToolUse` (`edit_file`, `write_file`) | Loads rules from instruction files, classifies them, and judges each edit before it is written. **Blocks at >= 0.80**, so the edit never lands (agent cannot override). Flags 0.50-0.80 as advisory context, which AFK does not deliver yet. |
+| `rules.ts` | `PreToolUse` (`edit_file`, `write_file`, `patch_apply`) | Loads rules from instruction files, classifies them, and judges each edit before it is written. **Blocks at >= 0.80**, so the edit never lands (agent cannot override). A `patch_apply` call is judged file by file, and one file at >= 0.80 blocks the whole call, so none of its files land; a `dry_run` call is not judged. Flags 0.50-0.80 as advisory context, which AFK does not deliver yet. |
 | `stop-sweep.ts` | `Stop` | Judges the edits made since its last completed judgment against turn-scope rules (scope creep, cross-file patterns), and hands what it finds to the next turn. Interactive REPL only. |
 
</file context>
Fix with cubic

for (const e of change.edits) {
if (!isJsonObject(e)) continue;

const hunk = pairHunk(e.old, e.new);

@cubic-dev-ai cubic-dev-ai Bot Oct 3, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: changeHunk accepts non-string runtime old/new values and turns them into hunks, so malformed patch inputs can be judged, logged, or blocked instead of being skipped. Validate both fields as strings before calling pairHunk.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At adapters/afk/src/shared/edit-targets.ts, line 73:

<comment>`changeHunk` accepts non-string runtime `old`/`new` values and turns them into hunks, so malformed patch inputs can be judged, logged, or blocked instead of being skipped. Validate both fields as strings before calling `pairHunk`.</comment>

<file context>
@@ -0,0 +1,133 @@
+    for (const e of change.edits) {
+      if (!isJsonObject(e)) continue;
+
+      const hunk = pairHunk(e.old, e.new);
+
+      if (hunk) parts.push(hunk);
</file context>
Fix with cubic

ruleStates.push(state);

for (const key of keys) {
answers[key] = { noul: state.includes("console.log") ? 0.97 : 0.02 };

@cubic-dev-ai cubic-dev-ai Bot Oct 3, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The mock answers every judgment question with noul 0.97 or 0.02, so no answer can ever land in the 0.50–0.80 flag band. The patch flag path — hookSpecificOutput.additionalContext plus recording all affected files for the Stop sweep (explicitly claimed in the PR description) — is never exercised by these tests. Make the mock answer configurable in the flag band (e.g. a per-state noul value) and add a test asserting a flagged patch writes additionalContext and still records every file.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At adapters/afk/test/patch-apply.test.ts, line 78:

<comment>The mock answers every judgment question with noul 0.97 or 0.02, so no answer can ever land in the 0.50–0.80 flag band. The patch flag path — `hookSpecificOutput.additionalContext` plus recording all affected files for the Stop sweep (explicitly claimed in the PR description) — is never exercised by these tests. Make the mock answer configurable in the flag band (e.g. a per-state noul value) and add a test asserting a flagged patch writes `additionalContext` and still records every file.</comment>

<file context>
@@ -0,0 +1,411 @@
+  ruleStates.push(state);
+
+  for (const key of keys) {
+    answers[key] = { noul: state.includes("console.log") ? 0.97 : 0.02 };
+  }
+
</file context>
Fix with cubic

const parts: string[] = [];

for (const e of inp.edits) {
const hunk = pairHunk(e.old_string, e.new_string);

@cubic-dev-ai cubic-dev-ai Bot Oct 3, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The edits loop in editHunks dereferences e.old_string/e.new_string before confirming e is an object, so one malformed element (e.g. null) throws inside editTargets. The top-level try/catch in rules.ts then swallows the event silently, so that edit lands unjudged and is never recorded for the Stop sweep — while changeHunk in the same parser guards elements with isJsonObject(e). Apply the same guard here so the shared parser consistently skips malformed inputs as its contract states.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At adapters/afk/src/shared/edit-targets.ts, line 51:

<comment>The `edits` loop in `editHunks` dereferences `e.old_string`/`e.new_string` before confirming `e` is an object, so one malformed element (e.g. `null`) throws inside `editTargets`. The top-level `try/catch` in `rules.ts` then swallows the event silently, so that edit lands unjudged and is never recorded for the Stop sweep — while `changeHunk` in the same parser guards elements with `isJsonObject(e)`. Apply the same guard here so the shared parser consistently skips malformed inputs as its contract states.</comment>

<file context>
@@ -0,0 +1,133 @@
+    const parts: string[] = [];
+
+    for (const e of inp.edits) {
+      const hunk = pairHunk(e.old_string, e.new_string);
+
+      if (hunk) parts.push(hunk);
</file context>
Suggested change
const hunk = pairHunk(e.old_string, e.new_string);
if (!isJsonObject(e)) continue;
const hunk = pairHunk(e.old_string, e.new_string);
Fix with cubic

Comment thread adapters/afk/src/rules.ts
const timeoutMs = Math.min(DEFAULT_TIMEOUT_MS, deadline - performance.now());

if (timeoutMs < MIN_CALL_MS) {
logCheckError(ctx, "hook budget spent before this file was judged", 0);

@cubic-dev-ai cubic-dev-ai Bot Oct 3, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Budget exhaustion is logged as kind rules-error, the same row type used for actual Jev API failures (stats.py and the README describe it as "Jev call failed"). Files skipped here were never attempted and still land unjudged via the fail-open path, so the row misrepresents a scheduled skip as an API failure. Drop the row (it still needs no block output) or use a dedicated skip reason.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At adapters/afk/src/rules.ts, line 195:

<comment>Budget exhaustion is logged as kind `rules-error`, the same row type used for actual Jev API failures (`stats.py` and the README describe it as "Jev call failed"). Files skipped here were never attempted and still land unjudged via the fail-open path, so the row misrepresents a scheduled skip as an API failure. Drop the row (it still needs no block output) or use a dedicated skip reason.</comment>

<file context>
@@ -180,48 +174,43 @@ async function judge(
+  const timeoutMs = Math.min(DEFAULT_TIMEOUT_MS, deadline - performance.now());
+
+  if (timeoutMs < MIN_CALL_MS) {
+    logCheckError(ctx, "hook budget spent before this file was judged", 0);
+
+    return null;
</file context>
Fix with cubic


assert.equal(out, "");
assert.equal(ruleStates.length, 3);
assert.equal(maxInFlight > 1, true, `max in flight ${maxInFlight}`);

@cubic-dev-ai cubic-dev-ai Bot Oct 3, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: maxInFlight > 1 only proves two judgments overlapped; it never verifies the eight-way batching (MAX_PARALLEL waves) that the patch judge is meant to perform, and with only three files the assertion would pass even if a max-parallel limit were removed entirely. Feed more than eight files in the clean patch and assert bounded concurrency (e.g. 1 < maxInFlight && maxInFlight <= 8).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At adapters/afk/test/patch-apply.test.ts, line 291:

<comment>`maxInFlight > 1` only proves two judgments overlapped; it never verifies the eight-way batching (`MAX_PARALLEL` waves) that the patch judge is meant to perform, and with only three files the assertion would pass even if a max-parallel limit were removed entirely. Feed more than eight files in the clean patch and assert bounded concurrency (e.g. `1 < maxInFlight && maxInFlight <= 8`).</comment>

<file context>
@@ -0,0 +1,411 @@
+
+  assert.equal(out, "");
+  assert.equal(ruleStates.length, 3);
+  assert.equal(maxInFlight > 1, true, `max in flight ${maxInFlight}`);
+  assert.deepEqual(
+    ruleStates.map((s) => s.split("\n")[0]).sort(),
</file context>
Fix with cubic

Keep 0x7067#35 Known-gaps wording; drop only the patch_apply gap bullet (0x7067#36 fixes it). CHANGELOG kept both Unreleased entries.
@0x7067
0x7067 merged commit 7868b11 into 0x7067:main Oct 5, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants