chad-code 1.0.1__tar.gz → 1.0.2__tar.gz

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.
Files changed (86) hide show
  1. {chad_code-1.0.1/src/chad_code.egg-info → chad_code-1.0.2}/PKG-INFO +1 -1
  2. {chad_code-1.0.1 → chad_code-1.0.2}/pyproject.toml +1 -1
  3. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/__init__.py +1 -1
  4. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/agent.py +51 -17
  5. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/cli.py +17 -2
  6. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/guardrails.py +87 -8
  7. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/levers.py +8 -6
  8. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/prompt.py +15 -1
  9. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/syntaxgate.py +25 -4
  10. {chad_code-1.0.1 → chad_code-1.0.2/src/chad_code.egg-info}/PKG-INFO +1 -1
  11. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_agent_e2e.py +40 -8
  12. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_agent_guards.py +84 -0
  13. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_intent.py +27 -0
  14. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_syntaxgate.py +25 -0
  15. {chad_code-1.0.1 → chad_code-1.0.2}/LICENSE +0 -0
  16. {chad_code-1.0.1 → chad_code-1.0.2}/README.md +0 -0
  17. {chad_code-1.0.1 → chad_code-1.0.2}/setup.cfg +0 -0
  18. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/atif.py +0 -0
  19. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/base_engine.py +0 -0
  20. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/bench.py +0 -0
  21. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/compaction.py +0 -0
  22. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/completion_engine.py +0 -0
  23. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/config.py +0 -0
  24. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/diag.py +0 -0
  25. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/engine.py +0 -0
  26. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/ignore.py +0 -0
  27. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/lsp.py +0 -0
  28. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/mcp.py +0 -0
  29. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/mcp_oauth.py +0 -0
  30. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/mlx_fastpath.py +0 -0
  31. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/mlx_qsdpa.py +0 -0
  32. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/profiles.py +0 -0
  33. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/prove.py +0 -0
  34. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/render.py +0 -0
  35. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/repomap.py +0 -0
  36. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/session.py +0 -0
  37. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/skills.py +0 -0
  38. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/symbols.py +0 -0
  39. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/toolcall_parse.py +0 -0
  40. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/tools.py +0 -0
  41. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/tui.py +0 -0
  42. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad/validate.py +0 -0
  43. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad_code.egg-info/SOURCES.txt +0 -0
  44. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad_code.egg-info/dependency_links.txt +0 -0
  45. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad_code.egg-info/entry_points.txt +0 -0
  46. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad_code.egg-info/requires.txt +0 -0
  47. {chad_code-1.0.1 → chad_code-1.0.2}/src/chad_code.egg-info/top_level.txt +0 -0
  48. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_agent.py +0 -0
  49. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_atif.py +0 -0
  50. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_bench.py +0 -0
  51. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_cli.py +0 -0
  52. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_compact_notice.py +0 -0
  53. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_compaction.py +0 -0
  54. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_completion_engine.py +0 -0
  55. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_config.py +0 -0
  56. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_confirm_preview.py +0 -0
  57. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_done_audit.py +0 -0
  58. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_drift_warn.py +0 -0
  59. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_edit.py +0 -0
  60. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_edit_corruption.py +0 -0
  61. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_engine.py +0 -0
  62. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_engine_kvquant.py +0 -0
  63. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_feel_pack.py +0 -0
  64. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_gate.py +0 -0
  65. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_ignore.py +0 -0
  66. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_lever_bite.py +0 -0
  67. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_levers.py +0 -0
  68. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_log_redaction.py +0 -0
  69. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_lsp.py +0 -0
  70. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_mcp.py +0 -0
  71. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_mcp_oauth.py +0 -0
  72. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_mlx_fastpath.py +0 -0
  73. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_mlx_qsdpa.py +0 -0
  74. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_plan_review.py +0 -0
  75. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_prove.py +0 -0
  76. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_render.py +0 -0
  77. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_replace_lines.py +0 -0
  78. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_repomap.py +0 -0
  79. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_session.py +0 -0
  80. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_skills.py +0 -0
  81. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_subagent.py +0 -0
  82. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_symbols.py +0 -0
  83. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_toolcall_parse.py +0 -0
  84. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_tools.py +0 -0
  85. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_tui.py +0 -0
  86. {chad_code-1.0.1 → chad_code-1.0.2}/tests/test_validate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chad-code
3
- Version: 1.0.1
3
+ Version: 1.0.2
4
4
  Summary: Local MLX-backed, Claude-Code-style coding agent (Apple Silicon, Ornith 35B/9B)
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/nathansutton/chad
@@ -4,7 +4,7 @@
4
4
  # import name, and command name are independent. `uvx chad-code` runs the alias
5
5
  # script added under [project.scripts].
6
6
  name = "chad-code"
7
- version = "1.0.1"
7
+ version = "1.0.2"
8
8
  description = "Local MLX-backed, Claude-Code-style coding agent (Apple Silicon, Ornith 35B/9B)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -4,4 +4,4 @@ A flat collection of cooperating modules behind one console script (``chad``):
4
4
  the inference engine, the tool layer, the agent loop, and the terminal UI.
5
5
  """
6
6
 
7
- __version__ = "1.0.1"
7
+ __version__ = "1.0.2"
@@ -807,12 +807,18 @@ class Agent:
807
807
  # Turn-level cumulative think budget: distinct from self.think_tokens
808
808
  # (lifetime across the whole Agent instance, used for cross-turn telemetry) — this
809
809
  # is THIS turn's spend, reset every run_turn call, checked against a wall- and
810
- # decode-speed-aware budget. turn_think_exhausted is a PERSISTENT latch (unlike
811
- # capped_stall_streak's one-shot no_think_next above): once it fires, every
812
- # remaining step in the turn decodes with thinking off.
810
+ # decode-speed-aware budget. turn_think_exhausted latches the once-only
811
+ # log/steer; past it, no-think steps are paid on a duty cycle
812
+ # (guardrails.turn_think_throttle) rather than muting the rest of the turn
813
+ # the blanket mute regressed run1 passes with garbled no-think tails (plan 107).
814
+ # landing_no_think is the 103 landing's own unconditional latch: once the hard
815
+ # wrap-up fires, the landing and everything after it stay no-think regardless.
813
816
  turn_think_tokens = 0
814
817
  turn_think_half_fired = False
815
818
  turn_think_exhausted = False
819
+ turn_think_nothink_paid = 0
820
+ turn_think_budget_now = 0
821
+ landing_no_think = False
816
822
  # Runaway-turn governor state. turn_start drives the optional wall
817
823
  # budget; gov_band is the highest budget checkpoint already evaluated; gov_progress
818
824
  # tracks whether a change landed+verified within the CURRENT band (resets each time
@@ -845,6 +851,12 @@ class Agent:
845
851
  # where it also selects the plan-mode prefix.)
846
852
  action_task = self.mode != "plan" and _intent["action"]
847
853
  read_only_intent = _intent["read_only"]
854
+ # Run-task intent (start/boot/serve/… — system-state imperatives with no file
855
+ # deliverable): arms the anti-bail nudges alongside action_task but is kept OUT
856
+ # of the no-empty-diff done gates below, which demand a landed edit a run task
857
+ # legitimately never makes (plan 107 follow-up: qemu-startup classified as
858
+ # neither, so a prose give-up with 81% of the wall left took the weakest path).
859
+ run_task = self.mode != "plan" and _intent.get("run", False)
848
860
  # Progress-aware step cap (see guardrails.extend_step_cap): max_steps is the
849
861
  # WINDOW size, not a hard kill. A window that landed+verified a change earns
850
862
  # another window (warm cache — no re-prefill, unlike a governor rollover); a
@@ -963,9 +975,19 @@ class Agent:
963
975
  # <think> off so the model must act, then the flag clears and thinking restores.
964
976
  # Consumes the one-shot here; the render/decode/accounting below all key off
965
977
  # `step_thinking` rather than self.thinking for exactly this step.
966
- # Turn-level think budget: once exhausted, thinking stays off for
967
- # every remaining step a persistent override, unlike no_think_next below.
968
- step_thinking = self.thinking and not turn_think_exhausted
978
+ # Turn-level think budget: past exhaustion, forced no-think steps are
979
+ # paid on a duty cycle (one per TURN_THINK_REARM_TOK further think tokens)
980
+ # so thinking RESTORES once the model stops over-spending — a blanket
981
+ # rest-of-turn mute regressed run1 passes (plan 107 F1). The 103 landing's
982
+ # landing_no_think stays unconditional.
983
+ _tt_throttled = (turn_think_exhausted
984
+ and guardrails.turn_think_throttle(
985
+ turn_think_tokens, turn_think_budget_now,
986
+ turn_think_nothink_paid))
987
+ if _tt_throttled:
988
+ turn_think_nothink_paid += 1
989
+ step_thinking = (self.thinking and not landing_no_think
990
+ and not _tt_throttled)
969
991
  if no_think_next:
970
992
  no_think_next = False
971
993
  capped_stall_streak = 0
@@ -1203,9 +1225,15 @@ class Agent:
1203
1225
  # turns (a Q&A turn shouldn't be pushed to "act now") and without a configured
1204
1226
  # wall budget (interactive/unmetered runs, like wrapup_window above).
1205
1227
  turn_think_tokens += _think_delta
1206
- if (self._turn_budget_s and self.mode != "plan" and not read_only_intent
1228
+ # Inert below TURN_THINK_MIN_WALL_S: a short auto-continue tail clamps to
1229
+ # the LO budget and half-fires on its first step, churning against
1230
+ # hard_wrapup's landing (plan 107 F2 — the regex-log relaunch signature).
1231
+ if (self._turn_budget_s
1232
+ and self._turn_budget_s >= guardrails.TURN_THINK_MIN_WALL_S
1233
+ and self.mode != "plan" and not read_only_intent
1207
1234
  and levers.enabled("turn_think_budget")):
1208
1235
  _tt_budget = guardrails.turn_think_budget(self._turn_budget_s, self.tok_per_s)
1236
+ turn_think_budget_now = _tt_budget
1209
1237
  _tt_decision, turn_think_half_fired, turn_think_exhausted = (
1210
1238
  guardrails.turn_think_budget_check(
1211
1239
  turn_think_tokens, _tt_budget,
@@ -1217,10 +1245,11 @@ class Agent:
1217
1245
  "content": guardrails.TURN_THINK_BUDGET_STEER})
1218
1246
  elif _tt_decision == "exhausted":
1219
1247
  log.info("THINK-BUDGET exhausted at step %d: %d/%d cumulative think "
1220
- "tok this turn — no-think for the rest of the turn", step,
1221
- turn_think_tokens, _tt_budget)
1248
+ "tok this turn — throttling <think> (one action step per "
1249
+ "%d further think tok)", step, turn_think_tokens,
1250
+ _tt_budget, guardrails.TURN_THINK_REARM_TOK)
1222
1251
  self._emit("info", " [reasoning budget exhausted for this turn — "
1223
- "acting without further <think> for the rest of it]")
1252
+ "throttling further <think>]")
1224
1253
 
1225
1254
  log.info("step %d: %d tok @ %.1f tok/s | prefill %d new + %d cached | "
1226
1255
  "accept %.2f", step, stats.generated_tokens, stats.tok_per_s,
@@ -1277,7 +1306,7 @@ class Agent:
1277
1306
  # above) with its tokens in the KV cache. Force ONE time-boxed, no-think
1278
1307
  # landing turn so the model writes its best artifacts to the exact paths before
1279
1308
  # the harness kills the turn at the wall — instead of dying mid-token with
1280
- # nothing landed. turn_think_exhausted makes the landing (and any step after it)
1309
+ # nothing landed. landing_no_think makes the landing (and any step after it)
1281
1310
  # no-think so it can't re-open a spiral; the latch disarms _deadline_stop, so
1282
1311
  # the landing generation runs to its token box, not another abort. deadline_fired
1283
1312
  # is exclusive with rep_fired / the think-cap below (it is checked first in
@@ -1286,7 +1315,7 @@ class Agent:
1286
1315
  # there is rarely one, and partial-tool-call surgery is not worth its risk here.
1287
1316
  if deadline_fired[0] and not hard_wrapup_fired:
1288
1317
  hard_wrapup_fired = True
1289
- turn_think_exhausted = True
1318
+ landing_no_think = True
1290
1319
  _remaining = self._turn_budget_s - (time.monotonic() - turn_start)
1291
1320
  log.info("HARD-WRAPUP abort at step %d: %.0fs left, gen was %d tok",
1292
1321
  step, _remaining, stats.generated_tokens)
@@ -1374,8 +1403,8 @@ class Agent:
1374
1403
  or "<function=" in _stripped_for_markers)
1375
1404
  kind, nudge = guardrails.nudge_for_no_calls(
1376
1405
  text, hit_cap, made_edit, unverified_edit, read_only_intent,
1377
- action_task, truncation_nudges, answer_nudges, verify_nudges,
1378
- _has_open_tool_call(text), garbled_call=garbled)
1406
+ action_task or run_task, truncation_nudges, answer_nudges,
1407
+ verify_nudges, _has_open_tool_call(text), garbled_call=garbled)
1379
1408
  if kind == "truncated":
1380
1409
  truncation_nudges += 1
1381
1410
  elif kind == "no-edit":
@@ -1384,7 +1413,8 @@ class Agent:
1384
1413
  verify_nudges += 1
1385
1414
  if nudge:
1386
1415
  log.info("END-ANSWER rejected step %d: %s (hit_cap=%s, has_code=%s, "
1387
- "action_task=%s)", step, kind, hit_cap, has_code, action_task)
1416
+ "action_task=%s, run_task=%s)", step, kind, hit_cap,
1417
+ has_code, action_task, run_task)
1388
1418
  self.messages.append({"role": "tool", "name": "edit", "content": nudge})
1389
1419
  continue
1390
1420
  # Iter-3 did-nothing gate: in auto/headless mode (every benchmark run), a
@@ -1405,7 +1435,9 @@ class Agent:
1405
1435
  # and end as a hard stop, so --auto-continue (headless) or the
1406
1436
  # user's 'continue' (TUI) relaunches a fresh attempt with the
1407
1437
  # note instead of silently shipping nothing.
1408
- self.budget_note = guardrails.progress_note(self.messages)
1438
+ self.budget_note = guardrails.progress_note(
1439
+ self.messages,
1440
+ rejected_claim=strip_think(text).strip())
1409
1441
  log.info("END step %d: FINAL ANSWER blocked by no-empty-diff gate "
1410
1442
  "(made_edit=%s, unverified_edit=%s) — progress note banked",
1411
1443
  step, made_edit, unverified_edit)
@@ -1497,7 +1529,9 @@ class Agent:
1497
1529
  # nudges ran out) becomes a resumable hard stop, not a success
1498
1530
  # (matplotlib-25332 r3: done accepted at 84s with edits in tree
1499
1531
  # and zero successful post-edit commands).
1500
- self.budget_note = guardrails.progress_note(self.messages)
1532
+ self.budget_note = guardrails.progress_note(
1533
+ self.messages,
1534
+ rejected_claim=str(terminal.get("summary") or ""))
1501
1535
  log.info("END step %d: DONE blocked by no-empty-diff gate "
1502
1536
  "(made_edit=%s, unverified_edit=%s) — progress note banked",
1503
1537
  step, made_edit, unverified_edit)
@@ -637,9 +637,24 @@ def main():
637
637
  # ships as an empty diff (the NIGHT-7 bail signature).
638
638
  continues = args.auto_continue if args.auto_continue is not None \
639
639
  else (2 if run_mode == "auto" else 0)
640
- while agent.budget_note and continues > 0:
640
+ used_continues = 0
641
+ while agent.budget_note:
642
+ # Base allowance first; past it, keep granting fresh attempts while most
643
+ # of the task wall is still unspent (bounded by AUTO_CONTINUE_TOTAL_CAP) —
644
+ # the fixed base is wall-blind: build-pov-ray (TB2.1 v1.0.0) gave up after
645
+ # 3 step-capped turns with 94.7% of a 12000s budget unused (plan 107 F3).
646
+ if continues > 0:
647
+ continues -= 1
648
+ elif (args.auto_continue is None and args.turn_budget_s
649
+ and guardrails.replenish_continue(
650
+ args.turn_budget_s, time.monotonic() - task_start,
651
+ used_continues)):
652
+ sys.stderr.write("[governor] wall budget mostly unspent — granting an "
653
+ "extra continue\n")
654
+ else:
655
+ break
656
+ used_continues += 1
641
657
  note = agent.budget_note
642
- continues -= 1
643
658
  # The wall budget is a TASK-level deadline (the harness SIGKILLs the whole chad
644
659
  # process), not a per-turn one: a relaunch must inherit only the wall time that
645
660
  # REMAINS, or the governor / wrap-up / hard-abort windows (measured from each
@@ -924,7 +924,21 @@ PROGRESS_NOTE_HEADER = (
924
924
  "Progress so far (auto-summarized — the previous attempt ran out of budget):")
925
925
 
926
926
 
927
- def progress_note(messages, max_lines: int = 24) -> str:
927
+ # Matches a hypothesis that ASSERTS the work is already finished (the poison a
928
+ # rejected-done relaunch must not inherit as its leading "fact"), NOT a diagnosis that
929
+ # merely contains a success-ish word. Every completion word is anchored to a subject or
930
+ # to "already": a bare `build\s+is\s` matched "reBUILD IS failing", and a standalone
931
+ # `verified`/`successful` matched "SUCCESSFULLY reproduced the crash" — both real
932
+ # diagnoses, dropped on the exact path meant to preserve them (eng-review outside-voice
933
+ # F1). \b on the subjects also stops "build" from matching inside "rebuild".
934
+ _COMPLETION_CLAIM_RE = re.compile(
935
+ r"(already\s+(?:built|complete|done|installed|present|correct|verified|passe?[sd])"
936
+ r"|\b(?:build|task|project|everything|it)\s+is\s+"
937
+ r"(?:complete|done|built|ready|correct|passing)"
938
+ r"|task\s+is\s+(?:done|complete))", re.IGNORECASE)
939
+
940
+
941
+ def progress_note(messages, max_lines: int = 24, rejected_claim: str | None = None) -> str:
928
942
  """Synthesize a ≤`max_lines` progress note from the transcript with NO model call, so
929
943
  a hard-stopped turn can seed a fresh relaunch (sheds the ramble AND the huge prefill
930
944
  the stuck model was dragging around). Deterministic: pulls the model's last working
@@ -933,7 +947,15 @@ def progress_note(messages, max_lines: int = 24) -> str:
933
947
  facts the executor CANNOT reconstruct from a clean context — the diagnosis it reached,
934
948
  what it already tried, and what failed last. The old note carried only file names, so
935
949
  a relaunch re-derived the whole investigation and re-spent the budget it was meant to
936
- save (django-14007/-14404: the correct fix was stated in prose, then lost)."""
950
+ save (django-14007/-14404: the correct fix was stated in prose, then lost).
951
+
952
+ `rejected_claim` (plan 107 follow-up — the build-pov-ray poisoning loop): when the
953
+ turn ended via a REJECTED completion claim (done / final answer blocked by the
954
+ no-empty-diff gate), pass the claim text. The note then (a) leads with an explicit
955
+ warning that the claim was rejected, and (b) drops a working hypothesis that itself
956
+ asserts completion — otherwise every fresh relaunch inherits "the build is already
957
+ complete and verified" as its leading fact, re-confirms it, and re-dones (observed
958
+ 6 relaunches in a row, task 0/1 with 86% of the wall unused)."""
937
959
  from .toolcall_parse import parse_tool_calls
938
960
  edited, commands, examined = [], [], []
939
961
  last_error = None
@@ -968,6 +990,17 @@ def progress_note(messages, max_lines: int = 24) -> str:
968
990
  # lever adds, and therefore what its delta measures.
969
991
  rich = levers.enabled("progress_note_rich")
970
992
  lines = [PROGRESS_NOTE_HEADER]
993
+ if rejected_claim:
994
+ lines.append(
995
+ "WARNING: the previous attempt ended by CLAIMING the task was complete "
996
+ f"(\"{rejected_claim.strip()[:160]}\") but the claim was REJECTED — it had "
997
+ "landed no verified change. Treat every success statement below as "
998
+ "unverified and likely wrong: re-verify against the task statement with "
999
+ "fresh commands and MAKE the required change before calling done.")
1000
+ # A hypothesis that itself asserts completion is exactly the poison the
1001
+ # warning exists for — never carry it forward as the leading "fact".
1002
+ if hypothesis and _COMPLETION_CLAIM_RE.search(hypothesis):
1003
+ hypothesis = None
971
1004
  if rich and hypothesis:
972
1005
  # The single most valuable thing to carry: the diagnosis the stuck attempt
973
1006
  # reached. Keep the tail (the conclusion), clipped so it can't blow the budget.
@@ -1088,6 +1121,28 @@ def relaunch_budget(total_wall_s, task_elapsed_s) -> float | None:
1088
1121
  return remaining
1089
1122
 
1090
1123
 
1124
+ AUTO_CONTINUE_TOTAL_CAP = 6 # absolute relaunch ceiling per task (base + extras)
1125
+ AUTO_CONTINUE_REPLENISH_FRAC = 0.5 # grant extras only while this fraction of the task
1126
+ # wall is still unspent
1127
+
1128
+
1129
+ def replenish_continue(total_wall_s, elapsed_s, used_continues,
1130
+ cap: int = AUTO_CONTINUE_TOTAL_CAP,
1131
+ frac: float = AUTO_CONTINUE_REPLENISH_FRAC) -> bool:
1132
+ """Grant an auto-continue relaunch beyond the base allowance? The fixed base of 2
1133
+ is wall-blind: build-pov-ray (TB2.1 v1.0.0 run) burned 3 step-capped turns in 637s
1134
+ and gave up with 94.7% of a 12000s budget unused (plan 107 F3). While more than
1135
+ `frac` of the task wall remains unspent, keep granting fresh attempts, bounded by
1136
+ `cap` total relaunches so a pathological fast-stall loop still terminates well
1137
+ before the harness SIGKILL. No wall budget -> never (interactive runs keep the
1138
+ explicit base allowance only). Pure/testable; the caller counts usage."""
1139
+ if not total_wall_s:
1140
+ return False
1141
+ if used_continues >= cap:
1142
+ return False
1143
+ return (total_wall_s - elapsed_s) > frac * total_wall_s
1144
+
1145
+
1091
1146
  def hard_wrapup_deadline(wall_budget_s, already_fired, plan_mode, read_only) -> float | None:
1092
1147
  """Should the hard wrap-up abort ARM for this step? Returns the land_margin (seconds
1093
1148
  before the wall at which to cut the generation) when it should, else None. Lever-gated
@@ -1116,6 +1171,12 @@ TURN_THINK_BUDGET_LO = 8000 # floor: a *turn* budget, not a per-generation on
1116
1171
  # must clear passes' own median spend (10.7k) comfortably
1117
1172
  TURN_THINK_BUDGET_HI = 24000 # ceiling: clamp regardless of wall/decode-speed inputs
1118
1173
  TURN_THINK_BUDGET_FRAC = 0.35 # the budget's TIME cost should stay <= ~35% of the wall
1174
+ TURN_THINK_MIN_WALL_S = 300.0 # below this wall budget the mechanism is inert: a short
1175
+ # auto-continue tail clamps to LO and half-fires on its
1176
+ # first step, churning against hard_wrapup's landing
1177
+ # (plan 107 F2 — the regex-log relaunch signature)
1178
+ TURN_THINK_REARM_TOK = 3000 # past exhaustion, one forced no-think step is owed per
1179
+ # this many FURTHER think tokens spent (plan 107 F1)
1119
1180
 
1120
1181
 
1121
1182
  def turn_think_budget(wall_budget_s, decode_tps, frac: float = TURN_THINK_BUDGET_FRAC,
@@ -1139,12 +1200,12 @@ TURN_THINK_BUDGET_STEER = (
1139
1200
  def turn_think_budget_check(turn_think_tokens: int, budget: int, half_fired: bool,
1140
1201
  exhausted: bool):
1141
1202
  """Threshold latch for the cumulative per-turn reasoning budget: 'half' fires ONCE as
1142
- a soft steer at budget/2; 'exhausted' fires ONCE as the transition into a PERSISTENT
1143
- no-think state the caller holds for the rest of the turn (every subsequent step
1144
- decodes with thinking off not one-shot, unlike 086's capped-stall escalation, which
1145
- restores thinking after a single forced action). Once exhausted, always returns
1146
- (None, half_fired, True) nothing further to escalate. Returns
1147
- (decision, half_fired, exhausted). Pure/testable; the caller owns the state."""
1203
+ a soft steer at budget/2; 'exhausted' fires ONCE as the transition into the throttled
1204
+ state (see turn_think_throttle the TB2.1 v1.0.0 run showed a blanket rest-of-turn
1205
+ no-think mute degrades Ornith's tool-call syntax and capability over long tails,
1206
+ plan 107). Once exhausted, always returns (None, half_fired, True) — nothing further
1207
+ to escalate. Returns (decision, half_fired, exhausted). Pure/testable; the caller
1208
+ owns the state."""
1148
1209
  if exhausted:
1149
1210
  return None, half_fired, True
1150
1211
  if turn_think_tokens >= budget:
@@ -1154,6 +1215,24 @@ def turn_think_budget_check(turn_think_tokens: int, budget: int, half_fired: boo
1154
1215
  return None, half_fired, False
1155
1216
 
1156
1217
 
1218
+ def turn_think_throttle(turn_think_tokens: int, budget: int, nothink_paid: int,
1219
+ rearm: int = TURN_THINK_REARM_TOK) -> bool:
1220
+ """Should THIS step render with thinking off? A duty-cycle throttle for the
1221
+ post-exhaustion state: crossing the budget owes one forced no-think action step,
1222
+ and every further `rearm` think tokens spent owes one more. The caller counts the
1223
+ no-think steps it has already paid (`nothink_paid`). Self-correcting by design —
1224
+ a model that keeps burning reasoning is throttled toward always-off (the old
1225
+ persistent-mute behavior), while one that stops over-thinking gets its thinking
1226
+ back once the owed steps are paid. The v1.0.0 full run's evidence for restoring
1227
+ rather than muting: break-filter-js spent 26 straight no-think steps after
1228
+ exhaustion and regressed a run1 pass with garbled landing tool calls (plan 107 F1).
1229
+ Pure/testable; never touches an in-flight generation (086 contract)."""
1230
+ if turn_think_tokens < budget:
1231
+ return False
1232
+ owed = 1 + (turn_think_tokens - budget) // max(1, rearm)
1233
+ return nothink_paid < owed
1234
+
1235
+
1157
1236
  # Injected as the review turn's task preamble. A fresh Agent on a
1158
1237
  # reset KV cache re-derives the deliverables from a clean context — the poisoned context
1159
1238
  # that convinced the first attempt it was done is gone — so it can catch the confident-
@@ -256,12 +256,14 @@ LEVERS: dict[str, Lever] = {
256
256
  # off entirely in interactive/unmetered runs, like wrapup_window. -------------
257
257
  "turn_think_budget": Lever(
258
258
  "A cumulative per-turn reasoning-token budget (wall- and decode-speed-aware, "
259
- "clamped 8k-24k): a one-shot soft steer at half spent, then a PERSISTENT "
260
- "no-think for the rest of the turn once spent (every later step decodes with "
261
- "<think> off — not one-shot, unlike no_think_escalation above). Acts only at "
262
- "step boundaries on fresh generations, never an in-flight one (086's lesson). "
263
- "Off in plan mode and for read-only-intent turns. Only active with a wall "
264
- "budget configured.",
259
+ "clamped 8k-24k): a one-shot soft steer at half spent, then a no-think "
260
+ "THROTTLE once spent one forced no-think action step per 3k further think "
261
+ "tokens (guardrails.turn_think_throttle), so thinking restores when the model "
262
+ "stops over-spending; a blanket rest-of-turn mute regressed run1 passes with "
263
+ "garbled no-think tails (plan 107). Acts only at step boundaries on fresh "
264
+ "generations, never an in-flight one (086's lesson). Off in plan mode, for "
265
+ "read-only-intent turns, and below a 300s wall budget (short relaunch tails "
266
+ "belong to hard_wrapup). Only active with a wall budget configured.",
265
267
  "iter11"),
266
268
 
267
269
  # --- (103 hard wrap-up): the 085 wrapup_window nudge only lands at a step
@@ -92,6 +92,16 @@ _ACTION_WORDS = (
92
92
  "handle", "patch", "correct", "write", "test", "build", "wire", "convert",
93
93
  "generate", "rewrite", "extend", "append", "document", "save",
94
94
  )
95
+ # Run-task verbs: the ask is to put the SYSTEM in a state (start a service, boot an
96
+ # image, install a package), not to land a file edit — completable with zero edits, so
97
+ # these must NOT feed the `action` class (whose no-empty-diff done gate demands a landed
98
+ # edit; arming it here would hard-block every legitimate zero-edit completion). They get
99
+ # their own `run` class, which arms only the anti-bail nudges (TB2.1 qemu-startup,
100
+ # plan 107 follow-up: classified neither action nor read-only, so a prose give-up with
101
+ # 733s left sailed through the weakest path).
102
+ _RUN_WORDS_RE = re.compile(
103
+ r"\b(start|launch|boot|run|serve|deploy|install|configure|mount|restart|enable"
104
+ r"|spin up|bring up|set up)\b")
95
105
  # Explanatory openers: an ask that legitimately ends in prose. Deliberately NARROW —
96
106
  # a bare trailing "?" doesn't count (too ambiguous), and "can/could/should you…" are
97
107
  # excluded because they're usually polite ACTION requests ("can you fix this?").
@@ -161,7 +171,11 @@ def classify_intent(user_text: str) -> dict:
161
171
  or any(p in ut for p in _READ_ONLY_GLOBAL)
162
172
  or any(_global_negation(p) for p in _READ_ONLY_NEGATIONS if p in ut))
163
173
  action = any(w in ut for w in _ACTION_WORDS)
164
- return {"action": action, "read_only": read_only}
174
+ # `run`: a system-state imperative (start/boot/serve/…). Kept SEPARATE from
175
+ # `action` — run tasks are completable with zero file edits, so they arm the
176
+ # anti-bail nudges but never the edit-demanding no-empty-diff gates.
177
+ run = bool(_RUN_WORDS_RE.search(ut)) and not explanatory
178
+ return {"action": action, "read_only": read_only, "run": run}
165
179
 
166
180
 
167
181
  # Short base prompt for a spawned sub-agent. It runs in a fresh, isolated
@@ -104,6 +104,27 @@ def _ts_error_loc(text: str, line: int) -> str:
104
104
  # error delta, not on every still-broken landing, so a streak there is unobservable.
105
105
  _BROKEN_STREAK: dict[str, int] = {}
106
106
 
107
+ # Prose/data formats whose tree-sitter grammars exist but whose "syntax errors" are
108
+ # noise for this gate: the language pack maps `.txt` to VIMDOC, so plain-text
109
+ # deliverable writes (answer.txt, secret.txt, requirements.txt — the TB2.1 run1 README
110
+ # finding, plan 107 follow-up) got grammar-checked and warned on exactly the
111
+ # deliverable-landing write. A missing entry here costs a spurious warning, never a
112
+ # missed real one — code languages are not listed.
113
+ _NON_CODE_LANGS = frozenset({
114
+ "vimdoc", "markdown", "markdown_inline", "csv", "tsv", "psv", "text", "rst",
115
+ "org", "diff", "gitcommit", "git_rebase", "gitattributes", "gitignore",
116
+ "properties", "requirements",
117
+ })
118
+
119
+
120
+ def _code_lang(path: str) -> str | None:
121
+ """`lang_for`, filtered to languages this gate should police: None for both
122
+ unknown extensions and the prose/data formats above — every gate in this module
123
+ (warn, edit-revert, write-reject, severed-span) must skip those uniformly, or a
124
+ plain-text deliverable write gets warned on (or worse, REVERTED)."""
125
+ detected = repomap.service().lang_for(path)
126
+ return None if detected in _NON_CODE_LANGS else detected
127
+
107
128
 
108
129
  def check_syntax(path: str, before: str | None) -> str | None:
109
130
  """A warning string when the current on-disk content of `path` has a *newly
@@ -125,7 +146,7 @@ def check_syntax(path: str, before: str | None) -> str | None:
125
146
  if after == before: # the tool didn't actually change the file — nothing to flag
126
147
  return None
127
148
 
128
- lang = repomap.service().lang_for(path)
149
+ lang = _code_lang(path)
129
150
 
130
151
  if lang == "python":
131
152
  ap = os.path.abspath(path)
@@ -251,7 +272,7 @@ def edit_reject(path: str, before: str, after: str,
251
272
  return None
252
273
  if len(after) > _MAX_BYTES:
253
274
  return None
254
- lang = repomap.service().lang_for(path)
275
+ lang = _code_lang(path)
255
276
  if not lang:
256
277
  return None
257
278
  if lang != "python":
@@ -359,7 +380,7 @@ def write_reject(path: str, before: str | None, content: str) -> str | None:
359
380
  return None
360
381
  if len(content) > _MAX_BYTES or (before is not None and len(before) > _MAX_BYTES):
361
382
  return None
362
- lang = repomap.service().lang_for(path)
383
+ lang = _code_lang(path)
363
384
  if not lang:
364
385
  return None
365
386
  bash_hint = ("If this file is SUPPOSED to contain invalid syntax (a test fixture), "
@@ -568,7 +589,7 @@ def drift_warn(path: str, before: str | None, after: str) -> str | None:
568
589
  return None
569
590
  if before is None or len(before) > _MAX_BYTES or len(after) > _MAX_BYTES:
570
591
  return None
571
- lang = repomap.service().lang_for(path)
592
+ lang = _code_lang(path)
572
593
  if not lang:
573
594
  return None
574
595
  dropped: list[str] = []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chad-code
3
- Version: 1.0.1
3
+ Version: 1.0.2
4
4
  Summary: Local MLX-backed, Claude-Code-style coding agent (Apple Silicon, Ornith 35B/9B)
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/nathansutton/chad
@@ -758,34 +758,66 @@ class _BigThinkEngine(ScriptedEngine):
758
758
  return text, stats
759
759
 
760
760
 
761
- def _run_turn_think_budget(monkeypatch, *, ablated: bool):
761
+ def _run_turn_think_budget(monkeypatch, *, ablated: bool, script=None,
762
+ turn_budget_s=1e9):
762
763
  if ablated:
763
764
  monkeypatch.setenv("CHAD_DISABLE", "turn_think_budget")
764
765
  else:
765
766
  monkeypatch.delenv("CHAD_DISABLE", raising=False)
766
767
  stall = "<think>" + "reasoning " * 50 + "</think>"
767
- eng = _BigThinkEngine([stall] * 6)
768
+ eng = _BigThinkEngine(script if script is not None else [stall] * 6)
768
769
  tok = _ThinkFlagTok()
769
770
  eng.tok = tok
770
771
  # A configured wall budget is required to arm the mechanism at all (like
771
772
  # wrapup_window); huge so the UNRELATED governor/wrapup checks never fire and
772
773
  # muddy the transcript this test inspects.
773
774
  agent = Agent(eng, mode="auto", thinking=True, max_gen_tokens=20000,
774
- turn_budget_s=1e9)
775
+ turn_budget_s=turn_budget_s)
775
776
  agent.run_turn("change the config value") # an action task, not read-only
776
777
  return tok.flags
777
778
 
778
779
 
779
- def test_turn_think_budget_forces_persistent_no_think_once_exhausted(monkeypatch):
780
+ def test_turn_think_budget_throttles_no_think_while_overspending(monkeypatch):
780
781
  flags = _run_turn_think_budget(monkeypatch, ablated=False)
781
782
  # Each stall's ~14.7k-token think delta clears HALF (12k) on step 0 and EXHAUSTS
782
783
  # (24k) by step 1 — so step 0/1 still render thinking (the mechanism only acts on
783
- # steps AFTER the threshold is crossed), then every later step is forced no-think.
784
+ # steps AFTER the threshold is crossed). The scripted engine keeps burning ~14.7k
785
+ # think tokens on EVERY later step too, so the throttle's debt (one no-think step
786
+ # per 3k further think tokens) grows faster than it is paid — every remaining step
787
+ # renders no-think, converging on the old persistent behavior for a model that
788
+ # never stops over-spending.
784
789
  assert flags[:2] == [True, True], flags
785
790
  assert flags[2:] and all(f is False for f in flags[2:]), flags
786
- # Persistent, unlike no_think_escalation's one-shot: once it flips, it never
787
- # flips back for the rest of the turn.
788
- assert True not in flags[2:], flags
791
+
792
+
793
+ def test_turn_think_budget_restores_thinking_once_debt_paid(monkeypatch):
794
+ # Two big-think stalls exhaust the budget (~29.4k vs 24k -> debt = 1 + 5.4k//3k
795
+ # = 2 no-think steps owed); the model then STOPS thinking (plain tool-call turns
796
+ # accrue zero think delta), so after the two owed steps are paid, thinking must
797
+ # RESTORE — the plan-107 fix over the v1.0.0 persistent mute, which stayed off for
798
+ # the rest of the turn (break-filter-js: 26 straight no-think steps, garbled
799
+ # landing calls, regressed a run1 pass).
800
+ stall = "<think>" + "reasoning " * 50 + "</think>"
801
+ tc = _tool_call("bash", command="echo ok")
802
+ flags = _run_turn_think_budget(
803
+ monkeypatch, ablated=False,
804
+ script=[stall, stall, tc, tc, tc, "All set — the config value is updated."])
805
+ assert flags[:2] == [True, True], flags
806
+ assert flags[2:4] == [False, False], flags # paying the two owed steps
807
+ assert flags[4:] and all(f is True for f in flags[4:]), flags # restored
808
+
809
+
810
+ def test_turn_think_budget_inert_below_min_wall(monkeypatch):
811
+ # A short relaunch tail (wall budget under TURN_THINK_MIN_WALL_S=300) must leave
812
+ # the mechanism entirely inert — on the v1.0.0 run a 200s-class relaunch clamped
813
+ # to the LO budget and half-fired on its FIRST step, churning against the hard
814
+ # wrap-up landing (regex-log). The governor/wrapup paths may also act on such a
815
+ # short wall, so keep the script tiny.
816
+ stall = "<think>" + "reasoning " * 50 + "</think>"
817
+ flags = _run_turn_think_budget(monkeypatch, ablated=False,
818
+ script=[stall] * 3, turn_budget_s=200)
819
+ assert flags, "the turn should have taken at least one step"
820
+ assert False not in flags, flags
789
821
 
790
822
 
791
823
  def test_turn_think_budget_is_gone_when_ablated(monkeypatch):
@@ -49,11 +49,13 @@ from chad.guardrails import (
49
49
  progress_note,
50
50
  relaunch_budget,
51
51
  repeat_stop_abort,
52
+ replenish_continue,
52
53
  review_pass_should_fire,
53
54
  think_budget,
54
55
  turn_governor,
55
56
  turn_think_budget,
56
57
  turn_think_budget_check,
58
+ turn_think_throttle,
57
59
  update_thrash,
58
60
  update_work_flags,
59
61
  wrapup_landing_steer,
@@ -652,6 +654,53 @@ def test_progress_note():
652
654
  + '<tool_call>{"name": "read", "arguments": {"path": "z.py"}}</tool_call>'}]
653
655
  ln = progress_note(loopy)
654
656
  check("degenerate reasoning not carried as hypothesis", "hypothesis" not in ln.lower(), ln)
657
+ # Rejected-completion-claim path (plan 107 follow-up — the build-pov-ray poisoning
658
+ # loop: the banked note carried "the build is already complete and verified" as its
659
+ # leading hypothesis, and all 6 relaunches re-confirmed it and re-done'd).
660
+ poisoned = [
661
+ {"role": "user", "content": "build povray"},
662
+ {"role": "assistant",
663
+ "content": ("The build is already complete and verified. Let me confirm.\n"
664
+ '<tool_call>{"name": "bash", "arguments": {"command": "ls build"}}'
665
+ "</tool_call>")},
666
+ {"role": "tool", "name": "bash", "content": "povray"},
667
+ ]
668
+ rejected = progress_note(poisoned, rejected_claim="POV-Ray 2.2 is already built")
669
+ check("rejected claim -> warning present", "REJECTED" in rejected, rejected)
670
+ check("rejected claim text is quoted (clipped)",
671
+ "already built" in rejected, rejected)
672
+ check("completion-claim hypothesis is dropped",
673
+ "already complete and verified" not in rejected, rejected)
674
+ # A genuine (non-completion) diagnosis survives even on the rejected path.
675
+ diagnosed = list(poisoned)
676
+ diagnosed[1] = {"role": "assistant",
677
+ "content": ("The linker fails because unix.c is missing from build/.\n"
678
+ '<tool_call>{"name": "bash", "arguments": '
679
+ '{"command": "ls build"}}</tool_call>')}
680
+ kept = progress_note(diagnosed, rejected_claim="done")
681
+ check("real diagnosis survives the rejected path",
682
+ "unix.c is missing" in kept, kept)
683
+ # FP class the first regex dropped (eng-review outside-voice F1): a diagnosis that
684
+ # merely CONTAINS a success-ish substring is not a completion claim and must survive.
685
+ # "rebuild is failing" once matched bare `build\s+is\s`; "successfully reproduced"
686
+ # once matched the standalone verified/successful branch.
687
+ for diag_text, needle in (
688
+ ("The rebuild is failing because unix.c is missing from the target list.",
689
+ "rebuild is failing"),
690
+ ("I successfully reproduced the crash: it is an off-by-one in the parse loop.",
691
+ "off-by-one in the parse loop"),
692
+ ("Verified the repro; the bug is a null deref in frame_alloc.",
693
+ "null deref in frame_alloc"),
694
+ ):
695
+ d = list(poisoned)
696
+ d[1] = {"role": "assistant",
697
+ "content": (diag_text + '\n<tool_call>{"name": "bash", "arguments": '
698
+ '{"command": "ls build"}}</tool_call>')}
699
+ note = progress_note(d, rejected_claim="task is done")
700
+ check(f"diagnosis survives rejected path: {needle!r}", needle in note, note)
701
+ # Without rejected_claim the note is unchanged from the old contract: no warning.
702
+ check("no rejected claim -> no warning line",
703
+ "REJECTED" not in progress_note(poisoned), progress_note(poisoned))
655
704
 
656
705
 
657
706
  def test_bash_result_verifies_ignores_trivial_checks():
@@ -902,6 +951,41 @@ def test_turn_think_budget_check():
902
951
  check("exhausted is sticky", d is None and half and exh)
903
952
 
904
953
 
954
+ def test_turn_think_throttle():
955
+ # Below budget: never throttled, regardless of paid count.
956
+ check("below budget -> no throttle", turn_think_throttle(7999, 8000, 0) is False)
957
+ # Crossing the budget owes exactly ONE no-think step until paid.
958
+ check("at budget, unpaid -> throttle", turn_think_throttle(8000, 8000, 0) is True)
959
+ check("at budget, paid -> restored", turn_think_throttle(8000, 8000, 1) is False)
960
+ # Every further `rearm` think tokens owes one more: 8000 + 2*3000 = 3 owed total.
961
+ check("2 rearm chunks past budget, 2 paid -> still owes one",
962
+ turn_think_throttle(14000, 8000, 2) is True)
963
+ check("2 rearm chunks past budget, 3 paid -> restored",
964
+ turn_think_throttle(14000, 8000, 3) is False)
965
+ # A model that keeps burning reasoning is throttled toward always-off; one that
966
+ # stops spending accrues no new debt and keeps its thinking once paid up.
967
+ check("huge overshoot, few paid -> throttle",
968
+ turn_think_throttle(50000, 8000, 5) is True)
969
+ # rearm=0 must not divide by zero (guarded to 1).
970
+ check("zero rearm guarded", turn_think_throttle(9000, 8000, 10**6, rearm=0) is False)
971
+
972
+
973
+ def test_replenish_continue():
974
+ # More than half the wall unspent and under the cap -> grant.
975
+ check("early giveup, wall mostly unspent -> grant",
976
+ replenish_continue(12000, 637, 2) is True)
977
+ # Past the half-wall point -> no extras (the endgame belongs to wrap-up/landing).
978
+ check("more than half spent -> no grant", replenish_continue(900, 500, 2) is False)
979
+ # Exactly at the boundary is NOT more-than -> no grant.
980
+ check("exact half boundary -> no grant", replenish_continue(1000, 500, 2) is False)
981
+ # Absolute cap: never more than AUTO_CONTINUE_TOTAL_CAP total relaunches.
982
+ check("at the total cap -> no grant", replenish_continue(12000, 100, 6) is False)
983
+ check("just under the cap -> grant", replenish_continue(12000, 100, 5) is True)
984
+ # No wall budget (interactive) -> never; the explicit base allowance governs.
985
+ check("no wall budget -> never", replenish_continue(None, 0, 0) is False)
986
+ check("zero wall budget -> never", replenish_continue(0, 0, 0) is False)
987
+
988
+
905
989
  def test_review_pass_should_fire():
906
990
  # Clean end + a wall budget + >30% unspent -> fire. 900s budget, 500s elapsed:
907
991
  # 500 < 0.7*900 = 630 -> more than 30% left.
@@ -102,6 +102,32 @@ def test_intent():
102
102
  not arms_nudge("explain the slugify function"))
103
103
 
104
104
 
105
+ def test_run_intent():
106
+ # Run-task class (plan 107 follow-up): system-state imperatives — completable with
107
+ # zero file edits — must classify `run` so the anti-bail nudges arm, WITHOUT
108
+ # feeding `action` (whose no-empty-diff done gate demands a landed edit).
109
+ qemu = ("Start the /app/alpine.iso image in qemu in such a way that I can connect "
110
+ "to it via `telnet 127.0.0.1 6665`. Start the image in the background and "
111
+ "leave it running. Block until it's ready.")
112
+ got = classify_intent(qemu)
113
+ check("qemu-startup is a run task", got["run"], got)
114
+ check("qemu-startup is NOT an action task (no edit demanded)",
115
+ not got["action"], got)
116
+ check("qemu-startup is not read-only", not got["read_only"], got)
117
+ check("service imperative is a run task",
118
+ classify_intent("install nginx and serve /var/www on port 80")["run"])
119
+ # Word boundary: 'restarting'/'rerun' style inflections must not leak via substring.
120
+ check("'brunch' does not classify as run",
121
+ not classify_intent("describe the brunch menu parser")["run"])
122
+ # Explanatory asks never classify run — 'explain how to start the server' ends in prose.
123
+ check("explain-how-to-start is not a run task",
124
+ not classify_intent("explain how to start the server")["run"])
125
+ # An edit ask that also says 'run the tests' keeps action AND gains run — harmless,
126
+ # the merged nudge predicate is already armed by action.
127
+ both = classify_intent("fix the bug in app.py and run the tests")
128
+ check("edit+run ask keeps action", both["action"] and both["run"], both)
129
+
130
+
105
131
  def test_open_tool_call():
106
132
  # truncated mid-call -> True (drives the "write it in parts" nudge)
107
133
  check("open <tool_call> detected",
@@ -266,6 +292,7 @@ def test_step_tool_cap():
266
292
 
267
293
  if __name__ == "__main__":
268
294
  test_intent()
295
+ test_run_intent()
269
296
  test_open_tool_call()
270
297
  test_mentions()
271
298
  test_detect_test_command()
@@ -156,8 +156,33 @@ def test_indent_reject_names_enclosing_symbol():
156
156
  check("module-level: no enclosing symbol steer", "You're editing inside" not in msg2, msg2)
157
157
 
158
158
 
159
+ def test_plain_text_never_policed():
160
+ # The language pack maps .txt to VIMDOC, so plain-text deliverable writes
161
+ # (answer.txt / secret.txt / requirements.txt — the TB2.1 run1 README finding,
162
+ # plan 107 follow-up) were grammar-checked and warned on exactly the
163
+ # deliverable-landing write. Prose/data formats are now excluded from every gate.
164
+ for name, content in (
165
+ ("answer.txt", "The flag is: ABC-123\nsecond line < > { weird ] chars\n"),
166
+ ("requirements.txt", "numpy>=1.24\nscipy==1.11.*\n"),
167
+ ("notes.md", "# heading\n<unclosed <tag [bracket\n"),
168
+ ("data.csv", 'a,b\n1,"unclosed quote\n'),
169
+ ):
170
+ p = _tmp(name, "")
171
+ res = tool_write(p, content)
172
+ check(f"{name} write lands", res.startswith("[wrote"), res)
173
+ check(f"{name} write has no syntax warning",
174
+ "warning" not in res and "rejected" not in res, res)
175
+ res = tool_edit(p, content.splitlines()[0], "replaced first line")
176
+ check(f"{name} edit lands without warning",
177
+ "warning" not in res and "rejected" not in res, res)
178
+ # Sanity: real code languages are still policed (guard against an over-broad list).
179
+ check("python still policed",
180
+ syntaxgate.write_reject("x.py", "", "def f(:\n") is not None)
181
+
182
+
159
183
  if __name__ == "__main__":
160
184
  test_python()
185
+ test_plain_text_never_policed()
161
186
  test_tree_sitter_delta()
162
187
  test_opt_out()
163
188
  test_symbol_edit()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes