@webpieces/ai-hook-rules 0.4.746 → 0.4.748
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 +2 -2
- package/src/core/rules/wait-spin-guard.d.ts +40 -23
- package/src/core/rules/wait-spin-guard.js +54 -34
- package/src/core/rules/wait-spin-guard.js.map +1 -1
- package/src/core/rules/wait-spin-scan.d.ts +49 -0
- package/src/core/rules/wait-spin-scan.js +86 -6
- package/src/core/rules/wait-spin-scan.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/ai-hook-rules",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.748",
|
|
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.
|
|
28
|
+
"@webpieces/rules-config": "0.4.748",
|
|
29
29
|
"inversify": "7.10.4",
|
|
30
30
|
"reflect-metadata": "0.2.2"
|
|
31
31
|
},
|
|
@@ -12,30 +12,45 @@ 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,
|
|
16
|
-
*
|
|
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:
|
|
17
19
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* calls — because a `while`/`until` with a redirect cannot be statically proven to stay inside the
|
|
24
|
-
* worktree. That refusal is Claude Code's, not webpieces'; it is not ours to relax and this guard
|
|
25
|
-
* does not try.
|
|
26
|
-
* 4. So `echo .` every three seconds is the only remaining way to stay alive.
|
|
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.
|
|
27
25
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
29
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
30
|
+
* What survives the correction unchanged: `Monitor` still does not block (its own result text says
|
|
31
|
+
* "Keep working — do not poll or sleep"), and a `Monitor` carrying a real polling loop is still refused
|
|
32
|
+
* by the HARNESS — 178 of 553 subagent Monitor calls — because a `while`/`until` with a redirect cannot
|
|
33
|
+
* 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.
|
|
37
|
+
*
|
|
38
|
+
* ─── THE CURE STILL DIFFERS BY AGENT KIND ──────────────────────────────────────────────────────────
|
|
39
|
+
* 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.
|
|
44
|
+
*
|
|
45
|
+
* ─── WHY THE SUBAGENT CURE NAMES WHAT IS PENDING CONDITIONALLY ─────────────────────────────────────
|
|
46
|
+
* The cure would ideally say "you have 4 reviewers running, they will wake you". It cannot, and the
|
|
47
|
+
* reason is structural rather than an omission: this hook is registered on `Write|Edit|MultiEdit|Bash|
|
|
48
|
+
* Read`, so an Agent spawn NEVER reaches it and no count of live children exists to read. A
|
|
49
|
+
* backgrounded Bash is no better — `run_in_background` is not carried on the normalized payload, and
|
|
50
|
+
* 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.
|
|
39
54
|
*
|
|
40
55
|
* ─── It acts UNCONDITIONALLY, and has NO config key ────────────────────────────────────────────────
|
|
41
56
|
* Like `commit-message-substitution-guard` and `build-output-pipe-guard`, and on the same two tests. A
|
|
@@ -82,8 +97,10 @@ export declare class WaitSpinGuardRule extends BashRuleBase<EmptyRuleConfig> {
|
|
|
82
97
|
*/
|
|
83
98
|
export declare const REPEATS_BEFORE_REFUSAL = 2;
|
|
84
99
|
/**
|
|
85
|
-
* The subagent cure.
|
|
86
|
-
*
|
|
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.
|
|
87
104
|
*/
|
|
88
105
|
export declare const SUBAGENT_CURE: string;
|
|
89
106
|
/**
|
|
@@ -22,30 +22,45 @@ 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,
|
|
26
|
-
*
|
|
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:
|
|
27
29
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* calls — because a `while`/`until` with a redirect cannot be statically proven to stay inside the
|
|
34
|
-
* worktree. That refusal is Claude Code's, not webpieces'; it is not ours to relax and this guard
|
|
35
|
-
* does not try.
|
|
36
|
-
* 4. So `echo .` every three seconds is the only remaining way to stay alive.
|
|
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.
|
|
37
35
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
39
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
40
|
+
* What survives the correction unchanged: `Monitor` still does not block (its own result text says
|
|
41
|
+
* "Keep working — do not poll or sleep"), and a `Monitor` carrying a real polling loop is still refused
|
|
42
|
+
* by the HARNESS — 178 of 553 subagent Monitor calls — because a `while`/`until` with a redirect cannot
|
|
43
|
+
* 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.
|
|
47
|
+
*
|
|
48
|
+
* ─── THE CURE STILL DIFFERS BY AGENT KIND ──────────────────────────────────────────────────────────
|
|
49
|
+
* 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.
|
|
54
|
+
*
|
|
55
|
+
* ─── WHY THE SUBAGENT CURE NAMES WHAT IS PENDING CONDITIONALLY ─────────────────────────────────────
|
|
56
|
+
* The cure would ideally say "you have 4 reviewers running, they will wake you". It cannot, and the
|
|
57
|
+
* reason is structural rather than an omission: this hook is registered on `Write|Edit|MultiEdit|Bash|
|
|
58
|
+
* Read`, so an Agent spawn NEVER reaches it and no count of live children exists to read. A
|
|
59
|
+
* backgrounded Bash is no better — `run_in_background` is not carried on the normalized payload, and
|
|
60
|
+
* 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.
|
|
49
64
|
*
|
|
50
65
|
* ─── It acts UNCONDITIONALLY, and has NO config key ────────────────────────────────────────────────
|
|
51
66
|
* Like `commit-message-substitution-guard` and `build-output-pipe-guard`, and on the same two tests. A
|
|
@@ -63,13 +78,14 @@ class WaitSpinGuardRule extends rule_base_1.BashRuleBase {
|
|
|
63
78
|
scan = new wait_spin_scan_1.WaitSpinScan(new command_scan_1.CommandScanner(), new shell_segment_scan_1.ShellSegmentScan());
|
|
64
79
|
history = new session_call_history_1.SessionCallHistory();
|
|
65
80
|
description = 'Block a Bash call whose whole purpose is to stay alive — a bare echo/true/date keep-alive, or ' +
|
|
66
|
-
'the same gh pr checks/view asked a third time
|
|
67
|
-
'calling agent kind
|
|
68
|
-
'
|
|
81
|
+
'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.';
|
|
69
85
|
get fixHint() {
|
|
70
86
|
return new fix_hint_1.FixHint('This command does nothing except keep your turn alive, and a turn costs your whole context — '
|
|
71
|
-
+ '~557,000 tokens measured, whatever the command was.', '
|
|
72
|
-
+ '
|
|
87
|
+
+ '~557,000 tokens measured, whatever the command was.', 'Stop waiting by taking turns. The lines above name what the agent you are should do '
|
|
88
|
+
+ 'instead, cheapest first; they are the only moves to make.', []);
|
|
73
89
|
}
|
|
74
90
|
check(ctx) {
|
|
75
91
|
// Blocklist-shaped, so match on commandCode — stripping heredocs and quoted prose can only ever
|
|
@@ -146,16 +162,20 @@ exports.WaitSpinGuardRule = WaitSpinGuardRule;
|
|
|
146
162
|
*/
|
|
147
163
|
exports.REPEATS_BEFORE_REFUSAL = 2;
|
|
148
164
|
/**
|
|
149
|
-
* The subagent cure.
|
|
150
|
-
*
|
|
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.
|
|
151
169
|
*/
|
|
152
|
-
exports.SUBAGENT_CURE = '
|
|
153
|
-
+ '
|
|
154
|
-
+ '
|
|
155
|
-
+ '
|
|
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'
|
|
175
|
+
+ ' pnpm wp-await-reviews # waiting on reviewer verdicts\n'
|
|
156
176
|
+ ' pnpm wp-await-checks --pr <n> # waiting on CI for a PR\n\n'
|
|
157
|
-
+ 'Either one exits before the harness ceiling and tells you to run it again if the wait
|
|
158
|
-
+ 'so a long wait costs about ten calls rather than several hundred.';
|
|
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.';
|
|
159
179
|
/**
|
|
160
180
|
* The main-agent cure. A main agent has the cheaper option — cost nothing while waiting — and telling
|
|
161
181
|
* 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, which is the part that decides the cure ─────────────────────────────────────\n * Not laziness, and not a missing instruction. A causal chain with no slack in it:\n *\n * 1. A subagent that stops making tool calls is FINISHED — its run returns to its parent. It cannot\n * end its turn and be woken up later; ending the turn IS the end of the agent.\n * 2. `Monitor` does not block. Its own result text says \"Keep working — do not poll or sleep\", which\n * is precisely what an agent with nothing left to do cannot act on.\n * 3. A `Monitor` with a real polling loop is refused by the HARNESS — 178 of 553 subagent Monitor\n * calls — because a `while`/`until` with a redirect cannot be statically proven to stay inside the\n * worktree. That refusal is Claude Code's, not webpieces'; it is not ours to relax and this guard\n * does not try.\n * 4. So `echo .` every three seconds is the only remaining way to stay alive.\n *\n * A BLOCKING BASH COMMAND is therefore the only wait primitive a worktree-isolated subagent has, which\n * is why `pnpm wp-await-reviews` and `pnpm wp-await-checks` exist and why this guard can afford to\n * refuse: for the first time there is something to refuse INTO.\n *\n * ─── THE CURE DIFFERS BY AGENT KIND, and prescribing the wrong one DESTROYS WORK ───────────────────\n * A main agent can end its turn and be re-invoked by a backgrounded command; that is the cheapest wait\n * there is, and telling it to block a Bash call for ten minutes would be worse advice. A\n * worktree-isolated subagent cannot: \"end your turn\" kills it mid-wait and loses everything it has\n * done. One cure per kind, and the kind is read the same way the decision log stamps it —\n * `dotWebpieces.worktreeName(root)`, git's own worktree name, empty for the primary clone. Same call,\n * so a `tree=` column and this verdict cannot disagree.\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 — and name the ONE blocking wait command the ' +\n 'calling agent kind can use: pnpm wp-await-reviews / pnpm wp-await-checks for a worktree ' +\n 'subagent, a Monitor plus ending the turn for a main agent.';\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 'Wait by BLOCKING on one command instead of by taking turns. The line above names the one '\n + 'that fits the agent you are; it is the only one to run.',\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. It opens by saying what the agent must NOT do, because \"end your turn\" is the\n * advice a subagent is most likely to reach for and the one that destroys it.\n */\nexport const SUBAGENT_CURE =\n 'You are a worktree-isolated subagent, so you CANNOT end your turn and be woken up — ending it ends\\n'\n + 'your run and loses this work. Block on one command instead. It heartbeats while it waits and\\n'\n + 'returns as soon as there is something to do:\\n\\n'\n + ' pnpm wp-await-reviews # waiting on reviewer subagents you spawned\\n'\n + ' pnpm wp-await-checks --pr <n> # waiting on CI for a PR\\n\\n'\n + 'Either one exits before the harness ceiling and tells you to run it again if the wait is longer,\\n'\n + '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 — 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"]}
|
|
@@ -33,6 +33,19 @@ import { ShellSegmentScan } from './shell-segment-scan';
|
|
|
33
33
|
* The argument shape is deliberately narrow too: at most ONE argument, and it must be a short bare
|
|
34
34
|
* token (`.`, `idle4`, `waiting-for-reviewers`). `echo $PATH` and `echo -n .` are allowed — missing a
|
|
35
35
|
* spin costs one turn, and refusing a real command costs the task.
|
|
36
|
+
*
|
|
37
|
+
* ─── The ONE exception to ONE SEGMENT ONLY, and it is POLL-shaped only (issue #878) ────────────────
|
|
38
|
+
* A pipe on a NO-OP is a second command doing work, and that carve-out stays exactly as written. A
|
|
39
|
+
* TRAILING PAGER on a POLL is not: `gh pr checks 1058 2>&1 | head` asks the identical question as
|
|
40
|
+
* `gh pr checks 1058 2>&1` and is the same spin wearing a hat. Measured over 30 days it is the
|
|
41
|
+
* majority form — `gh pr checks 464 2>&1 | head` 173 times, `gh pr checks 1058 2>&1 | head` 81,
|
|
42
|
+
* `gh pr checks 428 2>&1 | head -10` 72 — worth 15.5% of one repo's wait waste and 7.4% of another's,
|
|
43
|
+
* all of it invisible to the single-segment rule.
|
|
44
|
+
*
|
|
45
|
+
* So a trailing {@link PAGER_PROGRAMS} segment is normalised away before the head segment is
|
|
46
|
+
* classified, and the result is then accepted ONLY when it is a POLL. `date | xargs …` and
|
|
47
|
+
* `echo x | head` stay allowed, because a NO-OP hit reached through a pipe is discarded — the
|
|
48
|
+
* single-segment rule is not relaxed, it is bypassed for one shape whose repeat count is the defect.
|
|
36
49
|
*/
|
|
37
50
|
/** The whole command does nothing — an `echo`/`true`/`:`/`date` keep-alive. */
|
|
38
51
|
export declare const SPIN_NOOP = "noop";
|
|
@@ -42,6 +55,23 @@ export declare const SPIN_POLL = "poll";
|
|
|
42
55
|
export declare const NOOP_PROGRAMS: readonly string[];
|
|
43
56
|
/** `gh pr <subcommand>` reads that say nothing new when repeated against an unchanged PR. */
|
|
44
57
|
export declare const POLLED_GH_SUBCOMMANDS: readonly string[];
|
|
58
|
+
/**
|
|
59
|
+
* Pagers that may trail a POLL through a pipe without changing the question it asks. Narrow on
|
|
60
|
+
* purpose: every one of these only TRUNCATES its input, so the command in front of it is still the
|
|
61
|
+
* whole command. `grep`, `jq` and `awk` are deliberately absent — `gh pr checks 874 | grep fail` asks
|
|
62
|
+
* a narrower question than the bare poll, somebody is reading the answer, and it stays allowed.
|
|
63
|
+
*/
|
|
64
|
+
export declare const PAGER_PROGRAMS: readonly string[];
|
|
65
|
+
/**
|
|
66
|
+
* The flag that turns `gh pr checks` from a poll into a BLOCKING wait, and is therefore never a spin.
|
|
67
|
+
*
|
|
68
|
+
* Measured over the same window: 224 subagent calls and 84 main-agent calls carried it. It is a
|
|
69
|
+
* legitimate wait primitive — one call that blocks — which is the very thing this guard exists to push
|
|
70
|
+
* agents towards, so refusing it however often it appears would refuse the cure. (`wp-await-checks`
|
|
71
|
+
* still earns its place beside it: `--watch` has no bounded exit and the harness kills it at 600s
|
|
72
|
+
* having printed nothing, whereas `wp-await-checks` heartbeats and returns cleanly at 540s.)
|
|
73
|
+
*/
|
|
74
|
+
export declare const BLOCKING_WATCH_FLAG = "--watch";
|
|
45
75
|
/** One spin-shaped command: WHICH shape, and the program that produced it. Data-only (per CLAUDE.md). */
|
|
46
76
|
export declare class WaitSpinHit {
|
|
47
77
|
shape: string;
|
|
@@ -54,7 +84,22 @@ export declare class WaitSpinScan {
|
|
|
54
84
|
constructor(scanner: CommandScanner, segments: ShellSegmentScan);
|
|
55
85
|
/** The spin shape this whole command is, or null when it is a real command. */
|
|
56
86
|
classify(command: string): WaitSpinHit | null;
|
|
87
|
+
/**
|
|
88
|
+
* Classify ONE segment's words. `pollOnly` is set when the segment was reached by stripping a
|
|
89
|
+
* trailing pager, and it discards a NO-OP hit: `echo . | head` is a pipe into a real program and
|
|
90
|
+
* must stay allowed, while `gh pr checks 5 | head` is the same poll as `gh pr checks 5`.
|
|
91
|
+
*/
|
|
92
|
+
private classifySegment;
|
|
57
93
|
private classifyWords;
|
|
94
|
+
/** Exactly two segments, joined by a pipe, the second of which only truncates its input. */
|
|
95
|
+
private endsInPager;
|
|
96
|
+
/**
|
|
97
|
+
* `head`, `tail`, `cat`, `wc -l` — and ONLY with the argument shapes that keep them pure
|
|
98
|
+
* truncations. `head -c 1 file` names a file and `wc -c` counts something else, so an unrecognised
|
|
99
|
+
* argument means "not a pager", which means the whole command falls back to the segment rule and
|
|
100
|
+
* is allowed. Missing a spin costs one turn; refusing a real command costs the task.
|
|
101
|
+
*/
|
|
102
|
+
private isPagerWords;
|
|
58
103
|
/**
|
|
59
104
|
* `echo` must carry EXACTLY ONE keep-alive token. A BARE `echo` is deliberately not a hit: quoted
|
|
60
105
|
* prose is stripped out of `commandCode`, so `echo "<any sentence>"` arrives here as a bare `echo`,
|
|
@@ -64,6 +109,10 @@ export declare class WaitSpinScan {
|
|
|
64
109
|
* whatever its format string.
|
|
65
110
|
*/
|
|
66
111
|
private isNoop;
|
|
112
|
+
/**
|
|
113
|
+
* A `gh pr checks/view` read — EXCEPT when it carries {@link BLOCKING_WATCH_FLAG}, which makes it
|
|
114
|
+
* one blocking call rather than a poll. See that constant for why that exception is load-bearing.
|
|
115
|
+
*/
|
|
67
116
|
private isPoll;
|
|
68
117
|
private pollLabel;
|
|
69
118
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.WaitSpinScan = exports.WaitSpinHit = exports.POLLED_GH_SUBCOMMANDS = exports.NOOP_PROGRAMS = exports.SPIN_POLL = exports.SPIN_NOOP = void 0;
|
|
3
|
+
exports.WaitSpinScan = exports.WaitSpinHit = exports.BLOCKING_WATCH_FLAG = exports.PAGER_PROGRAMS = exports.POLLED_GH_SUBCOMMANDS = exports.NOOP_PROGRAMS = exports.SPIN_POLL = exports.SPIN_NOOP = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* Decides the one question `wait-spin-guard` asks of a command's TEXT: is this command, all by itself,
|
|
6
6
|
* a way of staying alive rather than a way of doing something?
|
|
@@ -34,6 +34,19 @@ exports.WaitSpinScan = exports.WaitSpinHit = exports.POLLED_GH_SUBCOMMANDS = exp
|
|
|
34
34
|
* The argument shape is deliberately narrow too: at most ONE argument, and it must be a short bare
|
|
35
35
|
* token (`.`, `idle4`, `waiting-for-reviewers`). `echo $PATH` and `echo -n .` are allowed — missing a
|
|
36
36
|
* spin costs one turn, and refusing a real command costs the task.
|
|
37
|
+
*
|
|
38
|
+
* ─── The ONE exception to ONE SEGMENT ONLY, and it is POLL-shaped only (issue #878) ────────────────
|
|
39
|
+
* A pipe on a NO-OP is a second command doing work, and that carve-out stays exactly as written. A
|
|
40
|
+
* TRAILING PAGER on a POLL is not: `gh pr checks 1058 2>&1 | head` asks the identical question as
|
|
41
|
+
* `gh pr checks 1058 2>&1` and is the same spin wearing a hat. Measured over 30 days it is the
|
|
42
|
+
* majority form — `gh pr checks 464 2>&1 | head` 173 times, `gh pr checks 1058 2>&1 | head` 81,
|
|
43
|
+
* `gh pr checks 428 2>&1 | head -10` 72 — worth 15.5% of one repo's wait waste and 7.4% of another's,
|
|
44
|
+
* all of it invisible to the single-segment rule.
|
|
45
|
+
*
|
|
46
|
+
* So a trailing {@link PAGER_PROGRAMS} segment is normalised away before the head segment is
|
|
47
|
+
* classified, and the result is then accepted ONLY when it is a POLL. `date | xargs …` and
|
|
48
|
+
* `echo x | head` stay allowed, because a NO-OP hit reached through a pipe is discarded — the
|
|
49
|
+
* single-segment rule is not relaxed, it is bypassed for one shape whose repeat count is the defect.
|
|
37
50
|
*/
|
|
38
51
|
/** The whole command does nothing — an `echo`/`true`/`:`/`date` keep-alive. */
|
|
39
52
|
exports.SPIN_NOOP = 'noop';
|
|
@@ -43,8 +56,26 @@ exports.SPIN_POLL = 'poll';
|
|
|
43
56
|
exports.NOOP_PROGRAMS = ['echo', 'true', ':', 'date'];
|
|
44
57
|
/** `gh pr <subcommand>` reads that say nothing new when repeated against an unchanged PR. */
|
|
45
58
|
exports.POLLED_GH_SUBCOMMANDS = ['checks', 'view'];
|
|
59
|
+
/**
|
|
60
|
+
* Pagers that may trail a POLL through a pipe without changing the question it asks. Narrow on
|
|
61
|
+
* purpose: every one of these only TRUNCATES its input, so the command in front of it is still the
|
|
62
|
+
* whole command. `grep`, `jq` and `awk` are deliberately absent — `gh pr checks 874 | grep fail` asks
|
|
63
|
+
* a narrower question than the bare poll, somebody is reading the answer, and it stays allowed.
|
|
64
|
+
*/
|
|
65
|
+
exports.PAGER_PROGRAMS = ['head', 'tail', 'cat', 'wc'];
|
|
66
|
+
/**
|
|
67
|
+
* The flag that turns `gh pr checks` from a poll into a BLOCKING wait, and is therefore never a spin.
|
|
68
|
+
*
|
|
69
|
+
* Measured over the same window: 224 subagent calls and 84 main-agent calls carried it. It is a
|
|
70
|
+
* legitimate wait primitive — one call that blocks — which is the very thing this guard exists to push
|
|
71
|
+
* agents towards, so refusing it however often it appears would refuse the cure. (`wp-await-checks`
|
|
72
|
+
* still earns its place beside it: `--watch` has no bounded exit and the harness kills it at 600s
|
|
73
|
+
* having printed nothing, whereas `wp-await-checks` heartbeats and returns cleanly at 540s.)
|
|
74
|
+
*/
|
|
75
|
+
exports.BLOCKING_WATCH_FLAG = '--watch';
|
|
46
76
|
const NOOP_SET = new Set(exports.NOOP_PROGRAMS);
|
|
47
77
|
const POLLED_SET = new Set(exports.POLLED_GH_SUBCOMMANDS);
|
|
78
|
+
const PAGER_SET = new Set(exports.PAGER_PROGRAMS);
|
|
48
79
|
/**
|
|
49
80
|
* The KEEP-ALIVE TOKENS, and nothing wider.
|
|
50
81
|
*
|
|
@@ -78,14 +109,27 @@ class WaitSpinScan {
|
|
|
78
109
|
/** The spin shape this whole command is, or null when it is a real command. */
|
|
79
110
|
classify(command) {
|
|
80
111
|
const parts = this.scanner.segmentsWithJoins(command);
|
|
81
|
-
if (parts.length
|
|
82
|
-
return
|
|
83
|
-
|
|
112
|
+
if (parts.length === 1)
|
|
113
|
+
return this.classifySegment(parts[0].text, false);
|
|
114
|
+
if (this.endsInPager(parts))
|
|
115
|
+
return this.classifySegment(parts[0].text, true);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Classify ONE segment's words. `pollOnly` is set when the segment was reached by stripping a
|
|
120
|
+
* trailing pager, and it discards a NO-OP hit: `echo . | head` is a pipe into a real program and
|
|
121
|
+
* must stay allowed, while `gh pr checks 5 | head` is the same poll as `gh pr checks 5`.
|
|
122
|
+
*/
|
|
123
|
+
classifySegment(text, pollOnly) {
|
|
124
|
+
const words = this.scanner.runnerStrippedWords(text);
|
|
84
125
|
if (words.length === 0)
|
|
85
126
|
return null;
|
|
86
127
|
if (this.segments.redirectsToFile(words))
|
|
87
128
|
return null;
|
|
88
|
-
|
|
129
|
+
const hit = this.classifyWords(words);
|
|
130
|
+
if (hit === null)
|
|
131
|
+
return null;
|
|
132
|
+
return pollOnly && hit.shape !== exports.SPIN_POLL ? null : hit;
|
|
89
133
|
}
|
|
90
134
|
classifyWords(words) {
|
|
91
135
|
const program = this.scanner.programName(words[0]);
|
|
@@ -95,6 +139,34 @@ class WaitSpinScan {
|
|
|
95
139
|
return null;
|
|
96
140
|
return this.isNoop(program, words) ? new WaitSpinHit(exports.SPIN_NOOP, program) : null;
|
|
97
141
|
}
|
|
142
|
+
/** Exactly two segments, joined by a pipe, the second of which only truncates its input. */
|
|
143
|
+
endsInPager(parts) {
|
|
144
|
+
if (parts.length !== 2 || parts[1].join !== '|')
|
|
145
|
+
return false;
|
|
146
|
+
const words = this.scanner.runnerStrippedWords(parts[1].text);
|
|
147
|
+
return words.length > 0 && this.isPagerWords(words);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* `head`, `tail`, `cat`, `wc -l` — and ONLY with the argument shapes that keep them pure
|
|
151
|
+
* truncations. `head -c 1 file` names a file and `wc -c` counts something else, so an unrecognised
|
|
152
|
+
* argument means "not a pager", which means the whole command falls back to the segment rule and
|
|
153
|
+
* is allowed. Missing a spin costs one turn; refusing a real command costs the task.
|
|
154
|
+
*/
|
|
155
|
+
isPagerWords(words) {
|
|
156
|
+
const program = this.scanner.programName(words[0]);
|
|
157
|
+
if (!PAGER_SET.has(program))
|
|
158
|
+
return false;
|
|
159
|
+
const args = words.slice(1);
|
|
160
|
+
if (program === 'cat')
|
|
161
|
+
return args.length === 0;
|
|
162
|
+
if (program === 'wc')
|
|
163
|
+
return args.length === 1 && args[0] === '-l';
|
|
164
|
+
if (args.length === 0)
|
|
165
|
+
return true;
|
|
166
|
+
if (args.length === 1)
|
|
167
|
+
return /^-\d+$/.test(args[0]);
|
|
168
|
+
return args.length === 2 && args[0] === '-n' && /^\d+$/.test(args[1]);
|
|
169
|
+
}
|
|
98
170
|
/**
|
|
99
171
|
* `echo` must carry EXACTLY ONE keep-alive token. A BARE `echo` is deliberately not a hit: quoted
|
|
100
172
|
* prose is stripped out of `commandCode`, so `echo "<any sentence>"` arrives here as a bare `echo`,
|
|
@@ -110,8 +182,16 @@ class WaitSpinScan {
|
|
|
110
182
|
return true;
|
|
111
183
|
return words.length === 2 && KEEP_ALIVE_TOKEN.test(words[1]);
|
|
112
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* A `gh pr checks/view` read — EXCEPT when it carries {@link BLOCKING_WATCH_FLAG}, which makes it
|
|
187
|
+
* one blocking call rather than a poll. See that constant for why that exception is load-bearing.
|
|
188
|
+
*/
|
|
113
189
|
isPoll(program, words) {
|
|
114
|
-
|
|
190
|
+
if (program !== 'gh' || words.length < 3 || words[1] !== 'pr')
|
|
191
|
+
return false;
|
|
192
|
+
if (!POLLED_SET.has(words[2]))
|
|
193
|
+
return false;
|
|
194
|
+
return !words.includes(exports.BLOCKING_WATCH_FLAG);
|
|
115
195
|
}
|
|
116
196
|
// `gh pr checks` / `gh pr view` — the label the refusal prints, without the PR number or the flags,
|
|
117
197
|
// which the guard already has in the raw command.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"wait-spin-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/wait-spin-scan.ts"],"names":[],"mappings":"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","sourcesContent":["import { CommandScanner, CommandSegment } from '../command-scan';\nimport { ShellSegmentScan } from './shell-segment-scan';\n\n/**\n * Decides the one question `wait-spin-guard` asks of a command's TEXT: is this command, all by itself,\n * a way of staying alive rather than a way of doing something?\n *\n * ─── The two shapes, and why only these ────────────────────────────────────────────────────────────\n *\n * NO-OP the whole command produces no effect an agent could act on — `echo .`, `echo idle3`,\n * `true`, `:`, `date -u +%H:%M`. Measured across the fleet in the 24h to 2026-09-07: ten\n * agent runs spun this way, 920M tokens (18.3% of fleet tokens) burned, because every turn\n * resends the whole conversation at ~557k tokens. Nobody types these to learn anything.\n *\n * POLL an identical `gh pr checks <n>` / `gh pr view <n>`, run over and over. ONE of these is a\n * legitimate snapshot and must stay allowed — it is the difference between asking a\n * question and refusing to stop asking it — so this scan only CLASSIFIES the shape, and\n * the guard decides on the session's own call history whether it has been asked before.\n *\n * ─── What is NOT a hit, and why the carve-outs are the whole risk ──────────────────────────────────\n * `echo` is everywhere in legitimate work, and a false positive here breaks ordinary commands rather\n * than merely annoying somebody. Three rules keep it honest, and every one of them is drawn from real\n * lines in this repo's own guard logs:\n *\n * ONE SEGMENT ONLY `echo \"=== IN-SCOPE DIFF ===\" && git diff …` and\n * `sed -n 1,50p f.ts; echo ---; sed -n 60,90p f.ts` are compound commands that DO\n * something. A command with a second segment is never a hit, whatever the first\n * segment is.\n * NO REDIRECT `echo \"$body\" > /tmp/pr-body.md` writes a file. `ShellSegmentScan.redirectsToFile`\n * owns that test (and its `2>&1` carve-out), shared rather than re-spelled here.\n * NO PIPE a pipe is a second segment, so this falls out of the segment rule; `date | xargs …`\n * is a real command and is allowed by construction.\n *\n * The argument shape is deliberately narrow too: at most ONE argument, and it must be a short bare\n * token (`.`, `idle4`, `waiting-for-reviewers`). `echo $PATH` and `echo -n .` are allowed — missing a\n * spin costs one turn, and refusing a real command costs the task.\n */\n\n/** The whole command does nothing — an `echo`/`true`/`:`/`date` keep-alive. */\nexport const SPIN_NOOP = 'noop';\n/** The whole command is a PR/CI status read. Only a REPEAT of one is a spin; the guard decides that. */\nexport const SPIN_POLL = 'poll';\n\n/** Programs whose bare invocation is a keep-alive and nothing else. */\nexport const NOOP_PROGRAMS: readonly string[] = ['echo', 'true', ':', 'date'];\n\n/** `gh pr <subcommand>` reads that say nothing new when repeated against an unchanged PR. */\nexport const POLLED_GH_SUBCOMMANDS: readonly string[] = ['checks', 'view'];\n\nconst NOOP_SET: ReadonlySet<string> = new Set(NOOP_PROGRAMS);\nconst POLLED_SET: ReadonlySet<string> = new Set(POLLED_GH_SUBCOMMANDS);\n\n/**\n * The KEEP-ALIVE TOKENS, and nothing wider.\n *\n * This started as \"any short bare token\" and that was measurably too wide. Two real commands matched:\n * `echo hi`, which is a benign line in this package's own golden fixtures, and the prose-stripped\n * remains of an `echo \"<a sentence>\"`, which `runner.spec.ts` asserts is NOT blocked. Both are commands\n * somebody meant, and a guard that refuses those is a guard someone turns off.\n *\n * So the family is NAMED rather than inferred: the dot runs, plus the words the measured spins actually\n * used, each free to carry a numeric or hyphenated tail (`idle3`, `waiting-for-reviewers`). Anything\n * else `echo` prints is content, and content is allowed.\n */\nconst KEEP_ALIVE_TOKEN =\n /^(?:\\.{1,3}|(?:idle|ok|okay|waiting|wait|standby|still|alive|ping|pong|tick|noop|nop|heartbeat|zzz)[a-z0-9_-]*)$/i;\n\n/** One spin-shaped command: WHICH shape, and the program that produced it. Data-only (per CLAUDE.md). */\nexport class WaitSpinHit {\n shape: string;\n program: string;\n\n constructor(shape: string, program: string) {\n this.shape = shape;\n this.program = program;\n }\n}\n\nexport class WaitSpinScan {\n constructor(\n private readonly scanner: CommandScanner,\n private readonly segments: ShellSegmentScan,\n ) {}\n\n /** The spin shape this whole command is, or null when it is a real command. */\n classify(command: string): WaitSpinHit | null {\n const parts = this.scanner.segmentsWithJoins(command);\n if (parts.length !== 1) return null;\n const words = this.scanner.runnerStrippedWords(parts[0].text);\n if (words.length === 0) return null;\n if (this.segments.redirectsToFile(words)) return null;\n return this.classifyWords(words);\n }\n\n private classifyWords(words: readonly string[]): WaitSpinHit | null {\n const program = this.scanner.programName(words[0]);\n if (this.isPoll(program, words)) return new WaitSpinHit(SPIN_POLL, this.pollLabel(words));\n if (!NOOP_SET.has(program)) return null;\n return this.isNoop(program, words) ? new WaitSpinHit(SPIN_NOOP, program) : null;\n }\n\n /**\n * `echo` must carry EXACTLY ONE keep-alive token. A BARE `echo` is deliberately not a hit: quoted\n * prose is stripped out of `commandCode`, so `echo \"<any sentence>\"` arrives here as a bare `echo`,\n * and blocking that shape refuses a command whose content nobody ever looked at.\n *\n * `true`, `:` and `date` are judged whole: `date -u +%H:%M` prints the clock and nothing else,\n * whatever its format string.\n */\n private isNoop(program: string, words: readonly string[]): boolean {\n if (program === 'true' || program === ':') return words.length === 1;\n if (program === 'date') return true;\n return words.length === 2 && KEEP_ALIVE_TOKEN.test(words[1]);\n }\n\n private isPoll(program: string, words: readonly string[]): boolean {\n return program === 'gh' && words.length >= 3 && words[1] === 'pr' && POLLED_SET.has(words[2]);\n }\n\n // `gh pr checks` / `gh pr view` — the label the refusal prints, without the PR number or the flags,\n // which the guard already has in the raw command.\n private pollLabel(words: readonly string[]): string {\n return `gh pr ${words[2]}`;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"wait-spin-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/wait-spin-scan.ts"],"names":[],"mappings":";;;AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AAEH,+EAA+E;AAClE,QAAA,SAAS,GAAG,MAAM,CAAC;AAChC,wGAAwG;AAC3F,QAAA,SAAS,GAAG,MAAM,CAAC;AAEhC,uEAAuE;AAC1D,QAAA,aAAa,GAAsB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,CAAC;AAE9E,6FAA6F;AAChF,QAAA,qBAAqB,GAAsB,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;AAE3E;;;;;GAKG;AACU,QAAA,cAAc,GAAsB,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC;AAE/E;;;;;;;;GAQG;AACU,QAAA,mBAAmB,GAAG,SAAS,CAAC;AAE7C,MAAM,QAAQ,GAAwB,IAAI,GAAG,CAAC,qBAAa,CAAC,CAAC;AAC7D,MAAM,UAAU,GAAwB,IAAI,GAAG,CAAC,6BAAqB,CAAC,CAAC;AACvE,MAAM,SAAS,GAAwB,IAAI,GAAG,CAAC,sBAAc,CAAC,CAAC;AAE/D;;;;;;;;;;;GAWG;AACH,MAAM,gBAAgB,GAClB,mHAAmH,CAAC;AAExH,yGAAyG;AACzG,MAAa,WAAW;IACpB,KAAK,CAAS;IACd,OAAO,CAAS;IAEhB,YAAY,KAAa,EAAE,OAAe;QACtC,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;IAC3B,CAAC;CACJ;AARD,kCAQC;AAED,MAAa,YAAY;IAEA;IACA;IAFrB,YACqB,OAAuB,EACvB,QAA0B;QAD1B,YAAO,GAAP,OAAO,CAAgB;QACvB,aAAQ,GAAR,QAAQ,CAAkB;IAC5C,CAAC;IAEJ,+EAA+E;IAC/E,QAAQ,CAAC,OAAe;QACpB,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,iBAAiB,CAAC,OAAO,CAAC,CAAC;QACtD,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;QAC1E,IAAI,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QAC9E,OAAO,IAAI,CAAC;IAChB,CAAC;IAED;;;;OAIG;IACK,eAAe,CAAC,IAAY,EAAE,QAAiB;QACnD,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,mBAAmB,CAAC,IAAI,CAAC,CAAC;QACrD,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QACpC,IAAI,IAAI,CAAC,QAAQ,CAAC,eAAe,CAAC,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QACtD,MAAM,GAAG,GAAG,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;QACtC,IAAI,GAAG,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC;QAC9B,OAAO,QAAQ,IAAI,GAAG,CAAC,KAAK,KAAK,iBAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC;IAC5D,CAAC;IAEO,aAAa,CAAC,KAAwB;QAC1C,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;QACnD,IAAI,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,KAAK,CAAC;YAAE,OAAO,IAAI,WAAW,CAAC,iBAAS,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC;QAC1F,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC;YAAE,OAAO,IAAI,CAAC;QACxC,OAAO,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,WAAW,CAAC,iBAAS,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IACpF,CAAC;IAED,4FAA4F;IACpF,WAAW,CAAC,KAAgC;QAChD,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,KAAK,GAAG;YAAE,OAAO,KAAK,CAAC;QAC9D,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,mBAAmB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;QAC9D,OAAO,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,IAAI,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC;IACxD,CAAC;IAED;;;;;OAKG;IACK,YAAY,CAAC,KAAwB;QACzC,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;QACnD,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,OAAO,CAAC;YAAE,OAAO,KAAK,CAAC;QAC1C,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QAC5B,IAAI,OAAO,KAAK,KAAK;YAAE,OAAO,IAAI,CAAC,MAAM,KAAK,CAAC,CAAC;QAChD,IAAI,OAAO,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC,MAAM,KAAK,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC;QACnE,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QACnC,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QACrD,OAAO,IAAI,CAAC,MAAM,KAAK,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;IAC1E,CAAC;IAED;;;;;;;OAOG;IACK,MAAM,CAAC,OAAe,EAAE,KAAwB;QACpD,IAAI,OAAO,KAAK,MAAM,IAAI,OAAO,KAAK,GAAG;YAAE,OAAO,KAAK,CAAC,MAAM,KAAK,CAAC,CAAC;QACrE,IAAI,OAAO,KAAK,MAAM;YAAE,OAAO,IAAI,CAAC;QACpC,OAAO,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,gBAAgB,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;IACjE,CAAC;IAED;;;OAGG;IACK,MAAM,CAAC,OAAe,EAAE,KAAwB;QACpD,IAAI,OAAO,KAAK,IAAI,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI;YAAE,OAAO,KAAK,CAAC;QAC5E,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;YAAE,OAAO,KAAK,CAAC;QAC5C,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC,2BAAmB,CAAC,CAAC;IAChD,CAAC;IAED,oGAAoG;IACpG,kDAAkD;IAC1C,SAAS,CAAC,KAAwB;QACtC,OAAO,SAAS,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;IAC/B,CAAC;CACJ;AAxFD,oCAwFC","sourcesContent":["import { CommandScanner, CommandSegment } from '../command-scan';\nimport { ShellSegmentScan } from './shell-segment-scan';\n\n/**\n * Decides the one question `wait-spin-guard` asks of a command's TEXT: is this command, all by itself,\n * a way of staying alive rather than a way of doing something?\n *\n * ─── The two shapes, and why only these ────────────────────────────────────────────────────────────\n *\n * NO-OP the whole command produces no effect an agent could act on — `echo .`, `echo idle3`,\n * `true`, `:`, `date -u +%H:%M`. Measured across the fleet in the 24h to 2026-09-07: ten\n * agent runs spun this way, 920M tokens (18.3% of fleet tokens) burned, because every turn\n * resends the whole conversation at ~557k tokens. Nobody types these to learn anything.\n *\n * POLL an identical `gh pr checks <n>` / `gh pr view <n>`, run over and over. ONE of these is a\n * legitimate snapshot and must stay allowed — it is the difference between asking a\n * question and refusing to stop asking it — so this scan only CLASSIFIES the shape, and\n * the guard decides on the session's own call history whether it has been asked before.\n *\n * ─── What is NOT a hit, and why the carve-outs are the whole risk ──────────────────────────────────\n * `echo` is everywhere in legitimate work, and a false positive here breaks ordinary commands rather\n * than merely annoying somebody. Three rules keep it honest, and every one of them is drawn from real\n * lines in this repo's own guard logs:\n *\n * ONE SEGMENT ONLY `echo \"=== IN-SCOPE DIFF ===\" && git diff …` and\n * `sed -n 1,50p f.ts; echo ---; sed -n 60,90p f.ts` are compound commands that DO\n * something. A command with a second segment is never a hit, whatever the first\n * segment is.\n * NO REDIRECT `echo \"$body\" > /tmp/pr-body.md` writes a file. `ShellSegmentScan.redirectsToFile`\n * owns that test (and its `2>&1` carve-out), shared rather than re-spelled here.\n * NO PIPE a pipe is a second segment, so this falls out of the segment rule; `date | xargs …`\n * is a real command and is allowed by construction.\n *\n * The argument shape is deliberately narrow too: at most ONE argument, and it must be a short bare\n * token (`.`, `idle4`, `waiting-for-reviewers`). `echo $PATH` and `echo -n .` are allowed — missing a\n * spin costs one turn, and refusing a real command costs the task.\n *\n * ─── The ONE exception to ONE SEGMENT ONLY, and it is POLL-shaped only (issue #878) ────────────────\n * A pipe on a NO-OP is a second command doing work, and that carve-out stays exactly as written. A\n * TRAILING PAGER on a POLL is not: `gh pr checks 1058 2>&1 | head` asks the identical question as\n * `gh pr checks 1058 2>&1` and is the same spin wearing a hat. Measured over 30 days it is the\n * majority form — `gh pr checks 464 2>&1 | head` 173 times, `gh pr checks 1058 2>&1 | head` 81,\n * `gh pr checks 428 2>&1 | head -10` 72 — worth 15.5% of one repo's wait waste and 7.4% of another's,\n * all of it invisible to the single-segment rule.\n *\n * So a trailing {@link PAGER_PROGRAMS} segment is normalised away before the head segment is\n * classified, and the result is then accepted ONLY when it is a POLL. `date | xargs …` and\n * `echo x | head` stay allowed, because a NO-OP hit reached through a pipe is discarded — the\n * single-segment rule is not relaxed, it is bypassed for one shape whose repeat count is the defect.\n */\n\n/** The whole command does nothing — an `echo`/`true`/`:`/`date` keep-alive. */\nexport const SPIN_NOOP = 'noop';\n/** The whole command is a PR/CI status read. Only a REPEAT of one is a spin; the guard decides that. */\nexport const SPIN_POLL = 'poll';\n\n/** Programs whose bare invocation is a keep-alive and nothing else. */\nexport const NOOP_PROGRAMS: readonly string[] = ['echo', 'true', ':', 'date'];\n\n/** `gh pr <subcommand>` reads that say nothing new when repeated against an unchanged PR. */\nexport const POLLED_GH_SUBCOMMANDS: readonly string[] = ['checks', 'view'];\n\n/**\n * Pagers that may trail a POLL through a pipe without changing the question it asks. Narrow on\n * purpose: every one of these only TRUNCATES its input, so the command in front of it is still the\n * whole command. `grep`, `jq` and `awk` are deliberately absent — `gh pr checks 874 | grep fail` asks\n * a narrower question than the bare poll, somebody is reading the answer, and it stays allowed.\n */\nexport const PAGER_PROGRAMS: readonly string[] = ['head', 'tail', 'cat', 'wc'];\n\n/**\n * The flag that turns `gh pr checks` from a poll into a BLOCKING wait, and is therefore never a spin.\n *\n * Measured over the same window: 224 subagent calls and 84 main-agent calls carried it. It is a\n * legitimate wait primitive — one call that blocks — which is the very thing this guard exists to push\n * agents towards, so refusing it however often it appears would refuse the cure. (`wp-await-checks`\n * still earns its place beside it: `--watch` has no bounded exit and the harness kills it at 600s\n * having printed nothing, whereas `wp-await-checks` heartbeats and returns cleanly at 540s.)\n */\nexport const BLOCKING_WATCH_FLAG = '--watch';\n\nconst NOOP_SET: ReadonlySet<string> = new Set(NOOP_PROGRAMS);\nconst POLLED_SET: ReadonlySet<string> = new Set(POLLED_GH_SUBCOMMANDS);\nconst PAGER_SET: ReadonlySet<string> = new Set(PAGER_PROGRAMS);\n\n/**\n * The KEEP-ALIVE TOKENS, and nothing wider.\n *\n * This started as \"any short bare token\" and that was measurably too wide. Two real commands matched:\n * `echo hi`, which is a benign line in this package's own golden fixtures, and the prose-stripped\n * remains of an `echo \"<a sentence>\"`, which `runner.spec.ts` asserts is NOT blocked. Both are commands\n * somebody meant, and a guard that refuses those is a guard someone turns off.\n *\n * So the family is NAMED rather than inferred: the dot runs, plus the words the measured spins actually\n * used, each free to carry a numeric or hyphenated tail (`idle3`, `waiting-for-reviewers`). Anything\n * else `echo` prints is content, and content is allowed.\n */\nconst KEEP_ALIVE_TOKEN =\n /^(?:\\.{1,3}|(?:idle|ok|okay|waiting|wait|standby|still|alive|ping|pong|tick|noop|nop|heartbeat|zzz)[a-z0-9_-]*)$/i;\n\n/** One spin-shaped command: WHICH shape, and the program that produced it. Data-only (per CLAUDE.md). */\nexport class WaitSpinHit {\n shape: string;\n program: string;\n\n constructor(shape: string, program: string) {\n this.shape = shape;\n this.program = program;\n }\n}\n\nexport class WaitSpinScan {\n constructor(\n private readonly scanner: CommandScanner,\n private readonly segments: ShellSegmentScan,\n ) {}\n\n /** The spin shape this whole command is, or null when it is a real command. */\n classify(command: string): WaitSpinHit | null {\n const parts = this.scanner.segmentsWithJoins(command);\n if (parts.length === 1) return this.classifySegment(parts[0].text, false);\n if (this.endsInPager(parts)) return this.classifySegment(parts[0].text, true);\n return null;\n }\n\n /**\n * Classify ONE segment's words. `pollOnly` is set when the segment was reached by stripping a\n * trailing pager, and it discards a NO-OP hit: `echo . | head` is a pipe into a real program and\n * must stay allowed, while `gh pr checks 5 | head` is the same poll as `gh pr checks 5`.\n */\n private classifySegment(text: string, pollOnly: boolean): WaitSpinHit | null {\n const words = this.scanner.runnerStrippedWords(text);\n if (words.length === 0) return null;\n if (this.segments.redirectsToFile(words)) return null;\n const hit = this.classifyWords(words);\n if (hit === null) return null;\n return pollOnly && hit.shape !== SPIN_POLL ? null : hit;\n }\n\n private classifyWords(words: readonly string[]): WaitSpinHit | null {\n const program = this.scanner.programName(words[0]);\n if (this.isPoll(program, words)) return new WaitSpinHit(SPIN_POLL, this.pollLabel(words));\n if (!NOOP_SET.has(program)) return null;\n return this.isNoop(program, words) ? new WaitSpinHit(SPIN_NOOP, program) : null;\n }\n\n /** Exactly two segments, joined by a pipe, the second of which only truncates its input. */\n private endsInPager(parts: readonly CommandSegment[]): boolean {\n if (parts.length !== 2 || parts[1].join !== '|') return false;\n const words = this.scanner.runnerStrippedWords(parts[1].text);\n return words.length > 0 && this.isPagerWords(words);\n }\n\n /**\n * `head`, `tail`, `cat`, `wc -l` — and ONLY with the argument shapes that keep them pure\n * truncations. `head -c 1 file` names a file and `wc -c` counts something else, so an unrecognised\n * argument means \"not a pager\", which means the whole command falls back to the segment rule and\n * is allowed. Missing a spin costs one turn; refusing a real command costs the task.\n */\n private isPagerWords(words: readonly string[]): boolean {\n const program = this.scanner.programName(words[0]);\n if (!PAGER_SET.has(program)) return false;\n const args = words.slice(1);\n if (program === 'cat') return args.length === 0;\n if (program === 'wc') return args.length === 1 && args[0] === '-l';\n if (args.length === 0) return true;\n if (args.length === 1) return /^-\\d+$/.test(args[0]);\n return args.length === 2 && args[0] === '-n' && /^\\d+$/.test(args[1]);\n }\n\n /**\n * `echo` must carry EXACTLY ONE keep-alive token. A BARE `echo` is deliberately not a hit: quoted\n * prose is stripped out of `commandCode`, so `echo \"<any sentence>\"` arrives here as a bare `echo`,\n * and blocking that shape refuses a command whose content nobody ever looked at.\n *\n * `true`, `:` and `date` are judged whole: `date -u +%H:%M` prints the clock and nothing else,\n * whatever its format string.\n */\n private isNoop(program: string, words: readonly string[]): boolean {\n if (program === 'true' || program === ':') return words.length === 1;\n if (program === 'date') return true;\n return words.length === 2 && KEEP_ALIVE_TOKEN.test(words[1]);\n }\n\n /**\n * A `gh pr checks/view` read — EXCEPT when it carries {@link BLOCKING_WATCH_FLAG}, which makes it\n * one blocking call rather than a poll. See that constant for why that exception is load-bearing.\n */\n private isPoll(program: string, words: readonly string[]): boolean {\n if (program !== 'gh' || words.length < 3 || words[1] !== 'pr') return false;\n if (!POLLED_SET.has(words[2])) return false;\n return !words.includes(BLOCKING_WATCH_FLAG);\n }\n\n // `gh pr checks` / `gh pr view` — the label the refusal prints, without the PR number or the flags,\n // which the guard already has in the raw command.\n private pollLabel(words: readonly string[]): string {\n return `gh pr ${words[2]}`;\n }\n}\n"]}
|