@webpieces/ai-hook-rules 0.4.756 → 0.4.757

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/ai-hook-rules",
3
- "version": "0.4.756",
3
+ "version": "0.4.757",
4
4
  "description": "Pluggable write-time validation framework for AI coding agents (@webpieces/ai-hook-rules). Claude Code PreToolUse + openclaw before_tool_call adapters share one rule engine.",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -25,7 +25,7 @@
25
25
  "directory": "packages/tooling/ai-hook-rules"
26
26
  },
27
27
  "dependencies": {
28
- "@webpieces/rules-config": "0.4.756",
28
+ "@webpieces/rules-config": "0.4.757",
29
29
  "inversify": "7.10.4",
30
30
  "reflect-metadata": "0.2.2"
31
31
  },
@@ -12,45 +12,59 @@ import { FixHint } from '../fix-hint';
12
12
  * arithmetic is not subtle — every turn resends the whole conversation, measured at ~557,000 tokens per
13
13
  * turn, so a three-second `echo .` costs more than most of the work around it.
14
14
  *
15
- * ─── WHY agents do it — CORRECTED, and the correction changed the cure (issue #878) ────────────────
16
- * The first cut of this guard shipped a causal chain whose first link was FALSE. It said a subagent
17
- * that stops making tool calls is finished, so it cannot end its turn and be woken up. Measured over
18
- * every subagent transcript on this machine, that is not what happens:
15
+ * ─── WHY agents do it, and TWO corrections to the cure (issues #878, then #900) ────────────────────
16
+ * The first cut of this guard told a subagent it could not end its turn at all. #878 measured that and
17
+ * found it false 2,605 subagent turns ended with `stop_reason: end_turn`, 588 were followed by MORE
18
+ * turns, and 449 of those resumptions arrived as a `[SYSTEM NOTIFICATION] … background-task event`
19
+ * (288 of them while waiting on spawned reviewers). So the cure was rewritten to lead with ENDING THE
20
+ * TURN and to offer a blocking command second.
19
21
  *
20
- * 2,605 subagent turns ended with `stop_reason: end_turn`. 2,017 were genuinely the run's last turn.
21
- * 588 were followed by MORE turns the agent was RESUMED and 449 of those were resumed by a
22
- * `[SYSTEM NOTIFICATION] … background-task event`. Broken down by what was pending when the turn
23
- * ended: 170 after spawning Agents, 118 after spawning Agents AND a backgrounded Bash, 109 after a
24
- * backgrounded Bash alone. So 288 of them are exactly the reviewer-wait this feature was built for.
22
+ * **#900 measured the other half of that claim, and it does not hold.** Those 449 resumptions say a
23
+ * wake-up CAN happen; they never established that it RELIABLY happens for the case this cure creates
24
+ * a worktree-isolated subagent whose only pending work is one `run_in_background` wait. Measured
25
+ * directly: three stalls across two subagents in a single session, each of which HAD launched its wait
26
+ * with `run_in_background: true` exactly as instructed. Every task-notification read
27
+ * "stops with no live background children of its own", and each run made ZERO further progress until a
28
+ * human poked it. On a 19-minute CI job that is 19 minutes of dead time per wait plus a parent
29
+ * round-trip to notice it.
25
30
  *
26
- * The root cause is therefore NOT "a subagent has no way to pause". It is "a subagent does not know it
27
- * will be woken up" a knowledge problem and a cure asserting the opposite steers an agent away
28
- * from the one wait that is FREE toward one that costs a turn per 540 seconds.
31
+ * So the SUBAGENT cure no longer asks the agent to end its turn and be re-invoked. It names the
32
+ * FOREGROUND blocking waitthe same shape Codex already gets, which returns under its harness ceiling
33
+ * and is re-run — because a wait that re-invokes on its own is only cheaper than a foreground call when
34
+ * the re-invocation actually fires, and here it does not. The honest cost, stated so nobody has to
35
+ * rediscover it: a stop costs ~650 tokens, while foregrounding re-enters the agent roughly every nine
36
+ * minutes and resends its context each time. That is more expensive per wait and still right, because
37
+ * the alternative is a run that does not finish.
29
38
  *
30
- * What survives the correction unchanged: `Monitor` still does not block (its own result text says
39
+ * **REVERT TO THE END-YOUR-TURN CURE IF THE HARNESS BEHAVIOUR IS FIXED.** The root cause is Claude
40
+ * Code's, was reported to Anthropic separately, and is not webpieces' to repair; this guard only
41
+ * mitigates it. When background re-invocation is observed to fire reliably for a subagent with a
42
+ * backgrounded Bash and nothing else pending, the free wait is the better cure again.
43
+ *
44
+ * What both corrections leave untouched: `Monitor` still does not block (its own result text says
31
45
  * "Keep working — do not poll or sleep"), and a `Monitor` carrying a real polling loop is still refused
32
46
  * by the HARNESS — 178 of 553 subagent Monitor calls — because a `while`/`until` with a redirect cannot
33
47
  * be statically proven to stay inside the worktree. That refusal is Claude Code's, not webpieces'; it
34
- * is not ours to relax and this guard does not try. So when NOTHING pending would wake the agent, a
35
- * blocking Bash call is still the only wait it can express, and that is what `pnpm wp-await-reviews`
36
- * and `pnpm wp-await-checks` are for. They are the SECOND answer now, not the first.
48
+ * is not ours to relax and this guard does not try. A blocking Bash call remains the only wait a
49
+ * subagent can actually express, and that is what `pnpm wp-await-reviews` and `pnpm wp-await-checks`
50
+ * are for. They are the FIRST answer now.
37
51
  *
38
52
  * ─── THE CURE STILL DIFFERS BY AGENT KIND ──────────────────────────────────────────────────────────
39
53
  * A main agent has `Monitor` and `run_in_background` genuinely available and its own cure names them;
40
- * it is unchanged by #878 and was always correct. A subagent's differs in what it can REACH — Monitor
41
- * is effectively closed to it not in whether it may end its turn. The kind is read the same way the
42
- * decision log stamps it: `dotWebpieces.worktreeName(root)`, git's own worktree name, empty for the
43
- * primary clone. Same call, so a `tree=` column and this verdict cannot disagree.
54
+ * #900's evidence is entirely about worktree-isolated subagents, so MAIN_AGENT_CURE is unchanged. The
55
+ * kind is read the same way the decision log stamps it: `dotWebpieces.worktreeName(root)`, git's own
56
+ * worktree name, empty for the primary clone. Same call, so a `tree=` column and this verdict cannot
57
+ * disagree.
44
58
  *
45
- * ─── WHY THE SUBAGENT CURE NAMES WHAT IS PENDING CONDITIONALLY ─────────────────────────────────────
59
+ * ─── WHY THE SUBAGENT CURE NAMES A COMMAND AND NOT THE AGENT'S PENDING WORK ────────────────────────
46
60
  * The cure would ideally say "you have 4 reviewers running, they will wake you". It cannot, and the
47
61
  * reason is structural rather than an omission: this hook is registered on `Write|Edit|MultiEdit|Bash|
48
62
  * Read`, so an Agent spawn NEVER reaches it and no count of live children exists to read. A
49
63
  * backgrounded Bash is no better — `run_in_background` is not carried on the normalized payload, and
50
64
  * even if it were, a background command's EXIT is unobservable from a PreToolUse hook, so a remembered
51
- * spawn could not be distinguished from one that finished an hour ago. Asserting a wake-up that may not
52
- * be coming is the same class of defect as asserting one cannot come, so the cure asks the agent — who
53
- * can see its own pending work instead of guessing on its behalf.
65
+ * spawn could not be distinguished from one that finished an hour ago. That header note is now the
66
+ * measured story of #900 rather than a caveat: the guard cannot see the wake-up, and the wake-up cannot
67
+ * be relied on, so the cure names the one move that works from where the agent is standing.
54
68
  *
55
69
  * ─── It acts UNCONDITIONALLY, and has NO config key ────────────────────────────────────────────────
56
70
  * Like `commit-message-substitution-guard` and `build-output-pipe-guard`, and on the same two tests. A
@@ -79,7 +93,8 @@ export declare class WaitSpinGuardRule extends BashRuleBase<EmptyRuleConfig> {
79
93
  private what;
80
94
  /**
81
95
  * The ONE cure for the agent that is actually calling, and never both. See the class docstring: a
82
- * main agent told to block wastes ten minutes; a subagent told to end its turn is destroyed.
96
+ * main agent told to block wastes ten minutes it did not have to spend, and a subagent told to end
97
+ * its turn stalls until a human notices (issue #900).
83
98
  */
84
99
  private cure;
85
100
  /**
@@ -97,10 +112,11 @@ export declare class WaitSpinGuardRule extends BashRuleBase<EmptyRuleConfig> {
97
112
  */
98
113
  export declare const REPEATS_BEFORE_REFUSAL = 2;
99
114
  /**
100
- * The subagent cure. The FREE option is first because it is free: ending the turn costs nothing at all,
101
- * where a blocking command costs one turn per 540 seconds. An earlier cut led with "you CANNOT end your
102
- * turn", which is false and was the most harmful line in the guard see the class docstring's measured
103
- * correction, and the paragraph there on why the pending work is named conditionally.
115
+ * The subagent cure: BLOCK IN THE FOREGROUND. An earlier cut led with ending the turn, on the strength
116
+ * of 449 measured re-invocations and #900 measured that the re-invocation does not fire for the case
117
+ * that cure creates (three stalls, two subagents, one session, every one of them backgrounded exactly
118
+ * as told). A wait that never wakes is not a cheap wait; it is a run that does not finish. See the
119
+ * class docstring, including the condition under which this reverts to the free wait.
104
120
  */
105
121
  export declare const SUBAGENT_CURE: string;
106
122
  /**
@@ -22,45 +22,59 @@ const wait_spin_scan_1 = require("./wait-spin-scan");
22
22
  * arithmetic is not subtle — every turn resends the whole conversation, measured at ~557,000 tokens per
23
23
  * turn, so a three-second `echo .` costs more than most of the work around it.
24
24
  *
25
- * ─── WHY agents do it — CORRECTED, and the correction changed the cure (issue #878) ────────────────
26
- * The first cut of this guard shipped a causal chain whose first link was FALSE. It said a subagent
27
- * that stops making tool calls is finished, so it cannot end its turn and be woken up. Measured over
28
- * every subagent transcript on this machine, that is not what happens:
25
+ * ─── WHY agents do it, and TWO corrections to the cure (issues #878, then #900) ────────────────────
26
+ * The first cut of this guard told a subagent it could not end its turn at all. #878 measured that and
27
+ * found it false 2,605 subagent turns ended with `stop_reason: end_turn`, 588 were followed by MORE
28
+ * turns, and 449 of those resumptions arrived as a `[SYSTEM NOTIFICATION] … background-task event`
29
+ * (288 of them while waiting on spawned reviewers). So the cure was rewritten to lead with ENDING THE
30
+ * TURN and to offer a blocking command second.
29
31
  *
30
- * 2,605 subagent turns ended with `stop_reason: end_turn`. 2,017 were genuinely the run's last turn.
31
- * 588 were followed by MORE turns the agent was RESUMED and 449 of those were resumed by a
32
- * `[SYSTEM NOTIFICATION] … background-task event`. Broken down by what was pending when the turn
33
- * ended: 170 after spawning Agents, 118 after spawning Agents AND a backgrounded Bash, 109 after a
34
- * backgrounded Bash alone. So 288 of them are exactly the reviewer-wait this feature was built for.
32
+ * **#900 measured the other half of that claim, and it does not hold.** Those 449 resumptions say a
33
+ * wake-up CAN happen; they never established that it RELIABLY happens for the case this cure creates
34
+ * a worktree-isolated subagent whose only pending work is one `run_in_background` wait. Measured
35
+ * directly: three stalls across two subagents in a single session, each of which HAD launched its wait
36
+ * with `run_in_background: true` exactly as instructed. Every task-notification read
37
+ * "stops with no live background children of its own", and each run made ZERO further progress until a
38
+ * human poked it. On a 19-minute CI job that is 19 minutes of dead time per wait plus a parent
39
+ * round-trip to notice it.
35
40
  *
36
- * The root cause is therefore NOT "a subagent has no way to pause". It is "a subagent does not know it
37
- * will be woken up" a knowledge problem and a cure asserting the opposite steers an agent away
38
- * from the one wait that is FREE toward one that costs a turn per 540 seconds.
41
+ * So the SUBAGENT cure no longer asks the agent to end its turn and be re-invoked. It names the
42
+ * FOREGROUND blocking waitthe same shape Codex already gets, which returns under its harness ceiling
43
+ * and is re-run — because a wait that re-invokes on its own is only cheaper than a foreground call when
44
+ * the re-invocation actually fires, and here it does not. The honest cost, stated so nobody has to
45
+ * rediscover it: a stop costs ~650 tokens, while foregrounding re-enters the agent roughly every nine
46
+ * minutes and resends its context each time. That is more expensive per wait and still right, because
47
+ * the alternative is a run that does not finish.
39
48
  *
40
- * What survives the correction unchanged: `Monitor` still does not block (its own result text says
49
+ * **REVERT TO THE END-YOUR-TURN CURE IF THE HARNESS BEHAVIOUR IS FIXED.** The root cause is Claude
50
+ * Code's, was reported to Anthropic separately, and is not webpieces' to repair; this guard only
51
+ * mitigates it. When background re-invocation is observed to fire reliably for a subagent with a
52
+ * backgrounded Bash and nothing else pending, the free wait is the better cure again.
53
+ *
54
+ * What both corrections leave untouched: `Monitor` still does not block (its own result text says
41
55
  * "Keep working — do not poll or sleep"), and a `Monitor` carrying a real polling loop is still refused
42
56
  * by the HARNESS — 178 of 553 subagent Monitor calls — because a `while`/`until` with a redirect cannot
43
57
  * be statically proven to stay inside the worktree. That refusal is Claude Code's, not webpieces'; it
44
- * is not ours to relax and this guard does not try. So when NOTHING pending would wake the agent, a
45
- * blocking Bash call is still the only wait it can express, and that is what `pnpm wp-await-reviews`
46
- * and `pnpm wp-await-checks` are for. They are the SECOND answer now, not the first.
58
+ * is not ours to relax and this guard does not try. A blocking Bash call remains the only wait a
59
+ * subagent can actually express, and that is what `pnpm wp-await-reviews` and `pnpm wp-await-checks`
60
+ * are for. They are the FIRST answer now.
47
61
  *
48
62
  * ─── THE CURE STILL DIFFERS BY AGENT KIND ──────────────────────────────────────────────────────────
49
63
  * A main agent has `Monitor` and `run_in_background` genuinely available and its own cure names them;
50
- * it is unchanged by #878 and was always correct. A subagent's differs in what it can REACH — Monitor
51
- * is effectively closed to it not in whether it may end its turn. The kind is read the same way the
52
- * decision log stamps it: `dotWebpieces.worktreeName(root)`, git's own worktree name, empty for the
53
- * primary clone. Same call, so a `tree=` column and this verdict cannot disagree.
64
+ * #900's evidence is entirely about worktree-isolated subagents, so MAIN_AGENT_CURE is unchanged. The
65
+ * kind is read the same way the decision log stamps it: `dotWebpieces.worktreeName(root)`, git's own
66
+ * worktree name, empty for the primary clone. Same call, so a `tree=` column and this verdict cannot
67
+ * disagree.
54
68
  *
55
- * ─── WHY THE SUBAGENT CURE NAMES WHAT IS PENDING CONDITIONALLY ─────────────────────────────────────
69
+ * ─── WHY THE SUBAGENT CURE NAMES A COMMAND AND NOT THE AGENT'S PENDING WORK ────────────────────────
56
70
  * The cure would ideally say "you have 4 reviewers running, they will wake you". It cannot, and the
57
71
  * reason is structural rather than an omission: this hook is registered on `Write|Edit|MultiEdit|Bash|
58
72
  * Read`, so an Agent spawn NEVER reaches it and no count of live children exists to read. A
59
73
  * backgrounded Bash is no better — `run_in_background` is not carried on the normalized payload, and
60
74
  * even if it were, a background command's EXIT is unobservable from a PreToolUse hook, so a remembered
61
- * spawn could not be distinguished from one that finished an hour ago. Asserting a wake-up that may not
62
- * be coming is the same class of defect as asserting one cannot come, so the cure asks the agent — who
63
- * can see its own pending work instead of guessing on its behalf.
75
+ * spawn could not be distinguished from one that finished an hour ago. That header note is now the
76
+ * measured story of #900 rather than a caveat: the guard cannot see the wake-up, and the wake-up cannot
77
+ * be relied on, so the cure names the one move that works from where the agent is standing.
64
78
  *
65
79
  * ─── It acts UNCONDITIONALLY, and has NO config key ────────────────────────────────────────────────
66
80
  * Like `commit-message-substitution-guard` and `build-output-pipe-guard`, and on the same two tests. A
@@ -79,9 +93,10 @@ class WaitSpinGuardRule extends rule_base_1.BashRuleBase {
79
93
  history = new session_call_history_1.SessionCallHistory();
80
94
  description = 'Block a Bash call whose whole purpose is to stay alive — a bare echo/true/date keep-alive, or ' +
81
95
  'the same gh pr checks/view asked a third time (a trailing | head/tail/cat/wc -l included) — ' +
82
- 'and name the cheapest wait the calling agent kind has: end the turn and be re-invoked by ' +
83
- 'pending work, or pnpm wp-await-reviews / pnpm wp-await-checks when nothing pending would wake ' +
84
- 'it. gh pr checks --watch already blocks and is never refused.';
96
+ 'and name the wait its kind of agent can actually use: a worktree subagent blocks in the ' +
97
+ 'FOREGROUND with pnpm wp-await-reviews / pnpm wp-await-checks and re-runs it while it is still ' +
98
+ 'waiting, a main agent starts a Monitor and ends its turn. gh pr checks --watch already blocks ' +
99
+ 'and is never refused.';
85
100
  get fixHint() {
86
101
  return new fix_hint_1.FixHint('This command does nothing except keep your turn alive, and a turn costs your whole context — '
87
102
  + '~557,000 tokens measured, whatever the command was.', 'Stop waiting by taking turns. The lines above name what the agent you are should do '
@@ -134,7 +149,8 @@ class WaitSpinGuardRule extends rule_base_1.BashRuleBase {
134
149
  }
135
150
  /**
136
151
  * The ONE cure for the agent that is actually calling, and never both. See the class docstring: a
137
- * main agent told to block wastes ten minutes; a subagent told to end its turn is destroyed.
152
+ * main agent told to block wastes ten minutes it did not have to spend, and a subagent told to end
153
+ * its turn stalls until a human notices (issue #900).
138
154
  */
139
155
  cure(ctx) {
140
156
  return this.isWorktreeIsolated(ctx) ? exports.SUBAGENT_CURE : exports.MAIN_AGENT_CURE;
@@ -162,20 +178,26 @@ exports.WaitSpinGuardRule = WaitSpinGuardRule;
162
178
  */
163
179
  exports.REPEATS_BEFORE_REFUSAL = 2;
164
180
  /**
165
- * The subagent cure. The FREE option is first because it is free: ending the turn costs nothing at all,
166
- * where a blocking command costs one turn per 540 seconds. An earlier cut led with "you CANNOT end your
167
- * turn", which is false and was the most harmful line in the guard see the class docstring's measured
168
- * correction, and the paragraph there on why the pending work is named conditionally.
181
+ * The subagent cure: BLOCK IN THE FOREGROUND. An earlier cut led with ending the turn, on the strength
182
+ * of 449 measured re-invocations and #900 measured that the re-invocation does not fire for the case
183
+ * that cure creates (three stalls, two subagents, one session, every one of them backgrounded exactly
184
+ * as told). A wait that never wakes is not a cheap wait; it is a run that does not finish. See the
185
+ * class docstring, including the condition under which this reverts to the free wait.
169
186
  */
170
- exports.SUBAGENT_CURE = 'END YOUR TURN. If ANYTHING of yours is still pending — subagents you spawned, or a command you\n'
171
- + 'started with run_in_background — the harness re-invokes you when it finishes. Measured 449 times\n'
172
- + "across this machine's subagent transcripts, 288 of them waiting on spawned reviewers exactly like\n"
173
- + 'this. It is not the end of your run, and it costs nothing at all.\n\n'
174
- + 'ONLY if nothing pending would wake you, block in ONE call instead:\n\n'
187
+ exports.SUBAGENT_CURE = 'BLOCK IN ONE FOREGROUND CALL. Run whichever of these you are waiting on, and do NOT pass\n'
188
+ + 'run_in_background:\n\n'
175
189
  + ' pnpm wp-await-reviews # waiting on reviewer verdicts\n'
176
190
  + ' pnpm wp-await-checks --pr <n> # waiting on CI for a PR\n\n'
177
- + 'Either one heartbeats, exits before the harness ceiling and tells you to run it again if the wait\n'
178
- + 'is longer, so a long wait costs about ten calls rather than several hundred.';
191
+ + 'Either one heartbeats throughout and RETURNS on its own at 540s under the 600s ceiling at which\n'
192
+ + 'the harness demotes a foreground call to the background and the result is lost. When it comes\n'
193
+ + 'back saying it is still waiting, run the IDENTICAL command again; that is the wait continuing,\n'
194
+ + 'not a failure. A 100-minute wait costs about eleven calls this way.\n\n'
195
+ + 'Same contract if you are waiting on something these two do not cover and you have the full-cycle\n'
196
+ + "skill's wp-await.sh: run it in the FOREGROUND with --timeout 545 and re-run the identical command\n"
197
+ + 'on exit 2.\n\n'
198
+ + 'Do NOT end your turn expecting a backgrounded wait to re-invoke you. Measured three times in one\n'
199
+ + 'session (issue #900): the notification read "no live background children of its own" and the run\n'
200
+ + 'made zero further progress until a human poked it.';
179
201
  /**
180
202
  * The main-agent cure. A main agent has the cheaper option — cost nothing while waiting — and telling
181
203
  * it to sit inside a blocking command instead would be worse advice than the spin it just wrote.
@@ -1 +1 @@
1
- {"version":3,"file":"wait-spin-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/wait-spin-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { dotWebpieces } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase, EmptyRuleConfig } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { L0_FAULT_NONE } from '../l0-fault-codes';\nimport { logGuardDecision, GuardDecision, Verdict, MATRIX_L2_UNROWED } from '../decision-log';\nimport { CommandScanner } from '../command-scan';\nimport { SessionCallHistory } from '../session-call-history';\nimport { ShellSegmentScan } from './shell-segment-scan';\nimport { WaitSpinScan, WaitSpinHit, SPIN_POLL } from './wait-spin-scan';\n\n/**\n * Blocks the two shapes an agent uses to WAIT when it has nothing left to do — a bare `echo .` /\n * `true` / `date` keep-alive, and the same `gh pr checks <n>` asked for the third time — and hands back\n * the ONE blocking command that its kind of agent can actually use instead.\n *\n * ─── The incident, measured (issue #874) ───────────────────────────────────────────────────────────\n * In the 24 hours to 2026-09-07, ten agent runs across four repos spun this way: 920M tokens, 18.3% of\n * every token the fleet spent, ~$1,389. Excluding the single worst run entirely it is still 9.0%. The\n * arithmetic is not subtle — every turn resends the whole conversation, measured at ~557,000 tokens per\n * turn, so a three-second `echo .` costs more than most of the work around it.\n *\n * ─── WHY agents do it — CORRECTED, and the correction changed the cure (issue #878) ────────────────\n * The first cut of this guard shipped a causal chain whose first link was FALSE. It said a subagent\n * that stops making tool calls is finished, so it cannot end its turn and be woken up. Measured over\n * every subagent transcript on this machine, that is not what happens:\n *\n * 2,605 subagent turns ended with `stop_reason: end_turn`. 2,017 were genuinely the run's last turn.\n * 588 were followed by MORE turns — the agent was RESUMED — and 449 of those were resumed by a\n * `[SYSTEM NOTIFICATION] … background-task event`. Broken down by what was pending when the turn\n * ended: 170 after spawning Agents, 118 after spawning Agents AND a backgrounded Bash, 109 after a\n * backgrounded Bash alone. So 288 of them are exactly the reviewer-wait this feature was built for.\n *\n * The root cause is therefore NOT \"a subagent has no way to pause\". It is \"a subagent does not know it\n * will be woken up\" — a knowledge problem — and a cure asserting the opposite steers an agent away\n * from the one wait that is FREE toward one that costs a turn per 540 seconds.\n *\n * What survives the correction unchanged: `Monitor` still does not block (its own result text says\n * \"Keep working — do not poll or sleep\"), and a `Monitor` carrying a real polling loop is still refused\n * by the HARNESS — 178 of 553 subagent Monitor calls — because a `while`/`until` with a redirect cannot\n * be statically proven to stay inside the worktree. That refusal is Claude Code's, not webpieces'; it\n * is not ours to relax and this guard does not try. So when NOTHING pending would wake the agent, a\n * blocking Bash call is still the only wait it can express, and that is what `pnpm wp-await-reviews`\n * and `pnpm wp-await-checks` are for. They are the SECOND answer now, not the first.\n *\n * ─── THE CURE STILL DIFFERS BY AGENT KIND ──────────────────────────────────────────────────────────\n * A main agent has `Monitor` and `run_in_background` genuinely available and its own cure names them;\n * it is unchanged by #878 and was always correct. A subagent's differs in what it can REACH — Monitor\n * is effectively closed to it — not in whether it may end its turn. The kind is read the same way the\n * decision log stamps it: `dotWebpieces.worktreeName(root)`, git's own worktree name, empty for the\n * primary clone. Same call, so a `tree=` column and this verdict cannot disagree.\n *\n * ─── WHY THE SUBAGENT CURE NAMES WHAT IS PENDING CONDITIONALLY ─────────────────────────────────────\n * The cure would ideally say \"you have 4 reviewers running, they will wake you\". It cannot, and the\n * reason is structural rather than an omission: this hook is registered on `Write|Edit|MultiEdit|Bash|\n * Read`, so an Agent spawn NEVER reaches it and no count of live children exists to read. A\n * backgrounded Bash is no better — `run_in_background` is not carried on the normalized payload, and\n * even if it were, a background command's EXIT is unobservable from a PreToolUse hook, so a remembered\n * spawn could not be distinguished from one that finished an hour ago. Asserting a wake-up that may not\n * be coming is the same class of defect as asserting one cannot come, so the cure asks the agent — who\n * can see its own pending work — instead of guessing on its behalf.\n *\n * ─── It acts UNCONDITIONALLY, and has NO config key ────────────────────────────────────────────────\n * Like `commit-message-substitution-guard` and `build-output-pipe-guard`, and on the same two tests. A\n * NEW key under `hookGuards` is a key every consumer must ADD or have every Bash call blocked on\n * upgrade (fault Y) — that shipped once, with `whole-repo-build-guard`. And there is nothing for a\n * switch to rescue: the cure is available for every input, is strictly better than what was blocked,\n * and can never itself match this guard.\n */\nexport class WaitSpinGuardRule extends BashRuleBase<EmptyRuleConfig> {\n constructor() {\n // configKey === name and is DELIBERATELY not a real webpieces.config.json key — see the class\n // docstring on fault Y.\n super(new EmptyRuleConfig(), 'wait-spin-guard', 'wait-spin-guard');\n }\n\n private readonly scan = new WaitSpinScan(new CommandScanner(), new ShellSegmentScan());\n private readonly history = new SessionCallHistory();\n\n readonly description =\n 'Block a Bash call whose whole purpose is to stay alive — a bare echo/true/date keep-alive, or ' +\n 'the same gh pr checks/view asked a third time (a trailing | head/tail/cat/wc -l included) — ' +\n 'and name the cheapest wait the calling agent kind has: end the turn and be re-invoked by ' +\n 'pending work, or pnpm wp-await-reviews / pnpm wp-await-checks when nothing pending would wake ' +\n 'it. gh pr checks --watch already blocks and is never refused.';\n\n get fixHint(): FixHint {\n return new FixHint(\n 'This command does nothing except keep your turn alive, and a turn costs your whole context — '\n + '~557,000 tokens measured, whatever the command was.',\n 'Stop waiting by taking turns. The lines above name what the agent you are should do '\n + 'instead, cheapest first; they are the only moves to make.',\n [],\n );\n }\n\n check(ctx: BashContext): readonly Violation[] {\n // Blocklist-shaped, so match on commandCode — stripping heredocs and quoted prose can only ever\n // block LESS, and this repo's docs and commit messages are full of these command names.\n const hit = this.scan.classify(ctx.commandCode);\n if (hit === null) return this.allow(ctx, 'not-a-wait-spin');\n if (hit.shape === SPIN_POLL) return this.judgePoll(ctx, hit);\n return this.block(ctx, hit);\n }\n\n /**\n * ONE `gh pr checks 874` is a snapshot somebody acts on and must stay allowed — the difference\n * between asking a question and refusing to stop asking it is the number of times, and nothing else.\n * So the third identical call is the one refused, and the count comes from this session's own call\n * log (which holds calls 1..N-1 by the time this runs) and fails OPEN when it cannot be read.\n */\n private judgePoll(ctx: BashContext, hit: WaitSpinHit): readonly Violation[] {\n const prior = this.history.priorBashCalls(ctx.workspaceRoot, ctx.command);\n if (prior < REPEATS_BEFORE_REFUSAL) return this.allow(ctx, `poll-${String(prior)}-prior`);\n return this.block(ctx, hit);\n }\n\n private allow(ctx: BashContext, reason: string): readonly Violation[] {\n this.logDecision(ctx, 'ALLOW', reason);\n return [];\n }\n\n private block(ctx: BashContext, hit: WaitSpinHit): readonly Violation[] {\n // BLOCK_AI_CURE: the cure is a command the agent runs itself, right now, in place of this one.\n this.logDecision(ctx, 'BLOCK_AI_CURE', `${hit.shape}-${hit.program}`);\n return [new V(1, this.truncate(ctx.command), this.message(ctx, hit))];\n }\n\n // Short on purpose: it is read mid-wait by an agent that needs the ONE next move.\n private message(ctx: BashContext, hit: WaitSpinHit): string {\n return `Blocked: \\`${hit.program}\\` ${this.what(hit)}. `\n + 'Every turn resends your whole conversation — ~557,000 tokens measured — so waiting by '\n + 'taking turns is the most expensive thing you can do, and it was 18.3% of all fleet '\n + 'tokens in one measured day.\\n\\n'\n + this.cure(ctx);\n }\n\n private what(hit: WaitSpinHit): string {\n return hit.shape === SPIN_POLL\n ? 'has already been run twice in this session with the identical arguments, and the answer '\n + 'has not changed because you have not done anything between the calls'\n : 'does nothing at all — it exists only to keep your turn alive';\n }\n\n /**\n * The ONE cure for the agent that is actually calling, and never both. See the class docstring: a\n * main agent told to block wastes ten minutes; a subagent told to end its turn is destroyed.\n */\n private cure(ctx: BashContext): string {\n return this.isWorktreeIsolated(ctx) ? SUBAGENT_CURE : MAIN_AGENT_CURE;\n }\n\n /**\n * A linked worktree means a worktree-isolated subagent — git's own answer, via the same call the\n * decision log's `tree=` column makes. Fails to `false` (the main-agent cure) only when git says\n * this is the primary clone, which is what the primary clone is.\n */\n private isWorktreeIsolated(ctx: BashContext): boolean {\n return dotWebpieces.worktreeName(ctx.workspaceRoot) !== '';\n }\n\n private truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n }\n\n private logDecision(ctx: BashContext, verdict: Verdict, reason: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision(\n 'wait-spin-guard', 'Bash', ctx.command, '-', verdict, reason,\n '-', L0_FAULT_NONE, MATRIX_L2_UNROWED,\n ),\n );\n }\n}\n\n/**\n * How many identical prior calls make the next one a spin. TWO, so the THIRD is refused: one call is a\n * snapshot, two is a re-check after doing something, three in a row with nothing in between is a loop.\n */\nexport const REPEATS_BEFORE_REFUSAL = 2;\n\n/**\n * The subagent cure. The FREE option is first because it is free: ending the turn costs nothing at all,\n * where a blocking command costs one turn per 540 seconds. An earlier cut led with \"you CANNOT end your\n * turn\", which is false and was the most harmful line in the guard — see the class docstring's measured\n * correction, and the paragraph there on why the pending work is named conditionally.\n */\nexport const SUBAGENT_CURE =\n 'END YOUR TURN. If ANYTHING of yours is still pending — subagents you spawned, or a command you\\n'\n + 'started with run_in_background — the harness re-invokes you when it finishes. Measured 449 times\\n'\n + \"across this machine's subagent transcripts, 288 of them waiting on spawned reviewers exactly like\\n\"\n + 'this. It is not the end of your run, and it costs nothing at all.\\n\\n'\n + 'ONLY if nothing pending would wake you, block in ONE call instead:\\n\\n'\n + ' pnpm wp-await-reviews # waiting on reviewer verdicts\\n'\n + ' pnpm wp-await-checks --pr <n> # waiting on CI for a PR\\n\\n'\n + 'Either one heartbeats, exits before the harness ceiling and tells you to run it again if the wait\\n'\n + 'is longer, so a long wait costs about ten calls rather than several hundred.';\n\n/**\n * The main-agent cure. A main agent has the cheaper option — cost nothing while waiting — and telling\n * it to sit inside a blocking command instead would be worse advice than the spin it just wrote.\n */\nexport const MAIN_AGENT_CURE =\n 'You are the main agent, so you have the cheapest wait there is: start a `Monitor` (or run the\\n'\n + 'command you are waiting on with run_in_background) and then END YOUR TURN. A backgrounded command\\n'\n + 're-invokes you when it exits, and you burn nothing at all in the meantime.';\n"]}
1
+ {"version":3,"file":"wait-spin-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/wait-spin-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { dotWebpieces } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase, EmptyRuleConfig } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { L0_FAULT_NONE } from '../l0-fault-codes';\nimport { logGuardDecision, GuardDecision, Verdict, MATRIX_L2_UNROWED } from '../decision-log';\nimport { CommandScanner } from '../command-scan';\nimport { SessionCallHistory } from '../session-call-history';\nimport { ShellSegmentScan } from './shell-segment-scan';\nimport { WaitSpinScan, WaitSpinHit, SPIN_POLL } from './wait-spin-scan';\n\n/**\n * Blocks the two shapes an agent uses to WAIT when it has nothing left to do — a bare `echo .` /\n * `true` / `date` keep-alive, and the same `gh pr checks <n>` asked for the third time — and hands back\n * the ONE blocking command that its kind of agent can actually use instead.\n *\n * ─── The incident, measured (issue #874) ───────────────────────────────────────────────────────────\n * In the 24 hours to 2026-09-07, ten agent runs across four repos spun this way: 920M tokens, 18.3% of\n * every token the fleet spent, ~$1,389. Excluding the single worst run entirely it is still 9.0%. The\n * arithmetic is not subtle — every turn resends the whole conversation, measured at ~557,000 tokens per\n * turn, so a three-second `echo .` costs more than most of the work around it.\n *\n * ─── WHY agents do it, and TWO corrections to the cure (issues #878, then #900) ────────────────────\n * The first cut of this guard told a subagent it could not end its turn at all. #878 measured that and\n * found it false — 2,605 subagent turns ended with `stop_reason: end_turn`, 588 were followed by MORE\n * turns, and 449 of those resumptions arrived as a `[SYSTEM NOTIFICATION] … background-task event`\n * (288 of them while waiting on spawned reviewers). So the cure was rewritten to lead with ENDING THE\n * TURN and to offer a blocking command second.\n *\n * **#900 measured the other half of that claim, and it does not hold.** Those 449 resumptions say a\n * wake-up CAN happen; they never established that it RELIABLY happens for the case this cure creates —\n * a worktree-isolated subagent whose only pending work is one `run_in_background` wait. Measured\n * directly: three stalls across two subagents in a single session, each of which HAD launched its wait\n * with `run_in_background: true` exactly as instructed. Every task-notification read\n * \"stops with no live background children of its own\", and each run made ZERO further progress until a\n * human poked it. On a 19-minute CI job that is 19 minutes of dead time per wait plus a parent\n * round-trip to notice it.\n *\n * So the SUBAGENT cure no longer asks the agent to end its turn and be re-invoked. It names the\n * FOREGROUND blocking wait — the same shape Codex already gets, which returns under its harness ceiling\n * and is re-run — because a wait that re-invokes on its own is only cheaper than a foreground call when\n * the re-invocation actually fires, and here it does not. The honest cost, stated so nobody has to\n * rediscover it: a stop costs ~650 tokens, while foregrounding re-enters the agent roughly every nine\n * minutes and resends its context each time. That is more expensive per wait and still right, because\n * the alternative is a run that does not finish.\n *\n * **REVERT TO THE END-YOUR-TURN CURE IF THE HARNESS BEHAVIOUR IS FIXED.** The root cause is Claude\n * Code's, was reported to Anthropic separately, and is not webpieces' to repair; this guard only\n * mitigates it. When background re-invocation is observed to fire reliably for a subagent with a\n * backgrounded Bash and nothing else pending, the free wait is the better cure again.\n *\n * What both corrections leave untouched: `Monitor` still does not block (its own result text says\n * \"Keep working — do not poll or sleep\"), and a `Monitor` carrying a real polling loop is still refused\n * by the HARNESS — 178 of 553 subagent Monitor calls — because a `while`/`until` with a redirect cannot\n * be statically proven to stay inside the worktree. That refusal is Claude Code's, not webpieces'; it\n * is not ours to relax and this guard does not try. A blocking Bash call remains the only wait a\n * subagent can actually express, and that is what `pnpm wp-await-reviews` and `pnpm wp-await-checks`\n * are for. They are the FIRST answer now.\n *\n * ─── THE CURE STILL DIFFERS BY AGENT KIND ──────────────────────────────────────────────────────────\n * A main agent has `Monitor` and `run_in_background` genuinely available and its own cure names them;\n * #900's evidence is entirely about worktree-isolated subagents, so MAIN_AGENT_CURE is unchanged. The\n * kind is read the same way the decision log stamps it: `dotWebpieces.worktreeName(root)`, git's own\n * worktree name, empty for the primary clone. Same call, so a `tree=` column and this verdict cannot\n * disagree.\n *\n * ─── WHY THE SUBAGENT CURE NAMES A COMMAND AND NOT THE AGENT'S PENDING WORK ────────────────────────\n * The cure would ideally say \"you have 4 reviewers running, they will wake you\". It cannot, and the\n * reason is structural rather than an omission: this hook is registered on `Write|Edit|MultiEdit|Bash|\n * Read`, so an Agent spawn NEVER reaches it and no count of live children exists to read. A\n * backgrounded Bash is no better — `run_in_background` is not carried on the normalized payload, and\n * even if it were, a background command's EXIT is unobservable from a PreToolUse hook, so a remembered\n * spawn could not be distinguished from one that finished an hour ago. That header note is now the\n * measured story of #900 rather than a caveat: the guard cannot see the wake-up, and the wake-up cannot\n * be relied on, so the cure names the one move that works from where the agent is standing.\n *\n * ─── It acts UNCONDITIONALLY, and has NO config key ────────────────────────────────────────────────\n * Like `commit-message-substitution-guard` and `build-output-pipe-guard`, and on the same two tests. A\n * NEW key under `hookGuards` is a key every consumer must ADD or have every Bash call blocked on\n * upgrade (fault Y) — that shipped once, with `whole-repo-build-guard`. And there is nothing for a\n * switch to rescue: the cure is available for every input, is strictly better than what was blocked,\n * and can never itself match this guard.\n */\nexport class WaitSpinGuardRule extends BashRuleBase<EmptyRuleConfig> {\n constructor() {\n // configKey === name and is DELIBERATELY not a real webpieces.config.json key — see the class\n // docstring on fault Y.\n super(new EmptyRuleConfig(), 'wait-spin-guard', 'wait-spin-guard');\n }\n\n private readonly scan = new WaitSpinScan(new CommandScanner(), new ShellSegmentScan());\n private readonly history = new SessionCallHistory();\n\n readonly description =\n 'Block a Bash call whose whole purpose is to stay alive — a bare echo/true/date keep-alive, or ' +\n 'the same gh pr checks/view asked a third time (a trailing | head/tail/cat/wc -l included) — ' +\n 'and name the wait its kind of agent can actually use: a worktree subagent blocks in the ' +\n 'FOREGROUND with pnpm wp-await-reviews / pnpm wp-await-checks and re-runs it while it is still ' +\n 'waiting, a main agent starts a Monitor and ends its turn. gh pr checks --watch already blocks ' +\n 'and is never refused.';\n\n get fixHint(): FixHint {\n return new FixHint(\n 'This command does nothing except keep your turn alive, and a turn costs your whole context — '\n + '~557,000 tokens measured, whatever the command was.',\n 'Stop waiting by taking turns. The lines above name what the agent you are should do '\n + 'instead, cheapest first; they are the only moves to make.',\n [],\n );\n }\n\n check(ctx: BashContext): readonly Violation[] {\n // Blocklist-shaped, so match on commandCode — stripping heredocs and quoted prose can only ever\n // block LESS, and this repo's docs and commit messages are full of these command names.\n const hit = this.scan.classify(ctx.commandCode);\n if (hit === null) return this.allow(ctx, 'not-a-wait-spin');\n if (hit.shape === SPIN_POLL) return this.judgePoll(ctx, hit);\n return this.block(ctx, hit);\n }\n\n /**\n * ONE `gh pr checks 874` is a snapshot somebody acts on and must stay allowed — the difference\n * between asking a question and refusing to stop asking it is the number of times, and nothing else.\n * So the third identical call is the one refused, and the count comes from this session's own call\n * log (which holds calls 1..N-1 by the time this runs) and fails OPEN when it cannot be read.\n */\n private judgePoll(ctx: BashContext, hit: WaitSpinHit): readonly Violation[] {\n const prior = this.history.priorBashCalls(ctx.workspaceRoot, ctx.command);\n if (prior < REPEATS_BEFORE_REFUSAL) return this.allow(ctx, `poll-${String(prior)}-prior`);\n return this.block(ctx, hit);\n }\n\n private allow(ctx: BashContext, reason: string): readonly Violation[] {\n this.logDecision(ctx, 'ALLOW', reason);\n return [];\n }\n\n private block(ctx: BashContext, hit: WaitSpinHit): readonly Violation[] {\n // BLOCK_AI_CURE: the cure is a command the agent runs itself, right now, in place of this one.\n this.logDecision(ctx, 'BLOCK_AI_CURE', `${hit.shape}-${hit.program}`);\n return [new V(1, this.truncate(ctx.command), this.message(ctx, hit))];\n }\n\n // Short on purpose: it is read mid-wait by an agent that needs the ONE next move.\n private message(ctx: BashContext, hit: WaitSpinHit): string {\n return `Blocked: \\`${hit.program}\\` ${this.what(hit)}. `\n + 'Every turn resends your whole conversation — ~557,000 tokens measured — so waiting by '\n + 'taking turns is the most expensive thing you can do, and it was 18.3% of all fleet '\n + 'tokens in one measured day.\\n\\n'\n + this.cure(ctx);\n }\n\n private what(hit: WaitSpinHit): string {\n return hit.shape === SPIN_POLL\n ? 'has already been run twice in this session with the identical arguments, and the answer '\n + 'has not changed because you have not done anything between the calls'\n : 'does nothing at all — it exists only to keep your turn alive';\n }\n\n /**\n * The ONE cure for the agent that is actually calling, and never both. See the class docstring: a\n * main agent told to block wastes ten minutes it did not have to spend, and a subagent told to end\n * its turn stalls until a human notices (issue #900).\n */\n private cure(ctx: BashContext): string {\n return this.isWorktreeIsolated(ctx) ? SUBAGENT_CURE : MAIN_AGENT_CURE;\n }\n\n /**\n * A linked worktree means a worktree-isolated subagent — git's own answer, via the same call the\n * decision log's `tree=` column makes. Fails to `false` (the main-agent cure) only when git says\n * this is the primary clone, which is what the primary clone is.\n */\n private isWorktreeIsolated(ctx: BashContext): boolean {\n return dotWebpieces.worktreeName(ctx.workspaceRoot) !== '';\n }\n\n private truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n }\n\n private logDecision(ctx: BashContext, verdict: Verdict, reason: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision(\n 'wait-spin-guard', 'Bash', ctx.command, '-', verdict, reason,\n '-', L0_FAULT_NONE, MATRIX_L2_UNROWED,\n ),\n );\n }\n}\n\n/**\n * How many identical prior calls make the next one a spin. TWO, so the THIRD is refused: one call is a\n * snapshot, two is a re-check after doing something, three in a row with nothing in between is a loop.\n */\nexport const REPEATS_BEFORE_REFUSAL = 2;\n\n/**\n * The subagent cure: BLOCK IN THE FOREGROUND. An earlier cut led with ending the turn, on the strength\n * of 449 measured re-invocations — and #900 measured that the re-invocation does not fire for the case\n * that cure creates (three stalls, two subagents, one session, every one of them backgrounded exactly\n * as told). A wait that never wakes is not a cheap wait; it is a run that does not finish. See the\n * class docstring, including the condition under which this reverts to the free wait.\n */\nexport const SUBAGENT_CURE =\n 'BLOCK IN ONE FOREGROUND CALL. Run whichever of these you are waiting on, and do NOT pass\\n'\n + 'run_in_background:\\n\\n'\n + ' pnpm wp-await-reviews # waiting on reviewer verdicts\\n'\n + ' pnpm wp-await-checks --pr <n> # waiting on CI for a PR\\n\\n'\n + 'Either one heartbeats throughout and RETURNS on its own at 540s — under the 600s ceiling at which\\n'\n + 'the harness demotes a foreground call to the background and the result is lost. When it comes\\n'\n + 'back saying it is still waiting, run the IDENTICAL command again; that is the wait continuing,\\n'\n + 'not a failure. A 100-minute wait costs about eleven calls this way.\\n\\n'\n + 'Same contract if you are waiting on something these two do not cover and you have the full-cycle\\n'\n + \"skill's wp-await.sh: run it in the FOREGROUND with --timeout 545 and re-run the identical command\\n\"\n + 'on exit 2.\\n\\n'\n + 'Do NOT end your turn expecting a backgrounded wait to re-invoke you. Measured three times in one\\n'\n + 'session (issue #900): the notification read \"no live background children of its own\" and the run\\n'\n + 'made zero further progress until a human poked it.';\n\n/**\n * The main-agent cure. A main agent has the cheaper option — cost nothing while waiting — and telling\n * it to sit inside a blocking command instead would be worse advice than the spin it just wrote.\n */\nexport const MAIN_AGENT_CURE =\n 'You are the main agent, so you have the cheapest wait there is: start a `Monitor` (or run the\\n'\n + 'command you are waiting on with run_in_background) and then END YOUR TURN. A backgrounded command\\n'\n + 're-invokes you when it exits, and you burn nothing at all in the meantime.';\n"]}