capt-hook 8.3.0__tar.gz → 8.4.0__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 (115) hide show
  1. {capt_hook-8.3.0 → capt_hook-8.4.0}/PKG-INFO +1 -1
  2. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/general/models.py +3 -3
  3. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/steering/capt-hook.toml +1 -1
  4. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/steering/steering.py +334 -16
  5. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/review/judge.py +23 -1
  6. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/review/scan.py +4 -0
  7. capt_hook-8.4.0/captain_hook/signals/__init__.py +108 -0
  8. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/types.py +8 -1
  9. {capt_hook-8.3.0 → capt_hook-8.4.0}/pyproject.toml +1 -1
  10. capt_hook-8.3.0/captain_hook/signals/__init__.py +0 -81
  11. {capt_hook-8.3.0 → capt_hook-8.4.0}/LICENSE +0 -0
  12. {capt_hook-8.3.0 → capt_hook-8.4.0}/README.md +0 -0
  13. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/.claude-plugin/plugin.json +0 -0
  14. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/__init__.py +0 -0
  15. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/__main__.py +0 -0
  16. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/app.py +0 -0
  17. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/ast_grep.py +0 -0
  18. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/classifiers/__init__.py +0 -0
  19. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/classifiers/conductor.py +0 -0
  20. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/classifiers/droid.py +0 -0
  21. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/classifiers/native.py +0 -0
  22. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/cli.py +0 -0
  23. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/conditions.py +0 -0
  24. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/context.py +0 -0
  25. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/contexts.py +0 -0
  26. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/decisions.py +0 -0
  27. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/dispatch.py +0 -0
  28. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/durable.py +0 -0
  29. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/events.py +0 -0
  30. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/fields.py +0 -0
  31. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/file.py +0 -0
  32. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/loader.py +0 -0
  33. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/log.py +0 -0
  34. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/__init__.py +0 -0
  35. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/fixes/__init__.py +0 -0
  36. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/fixes/capt-hook.toml +0 -0
  37. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/fixes/teammate_permissions.py +0 -0
  38. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/general/__init__.py +0 -0
  39. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/general/_lib.py +0 -0
  40. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/general/capt-hook.toml +0 -0
  41. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/general/commands.py +0 -0
  42. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/general/comments.py +0 -0
  43. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/general/detours.py +0 -0
  44. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/general/docs.py +0 -0
  45. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/general/plans.py +0 -0
  46. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/general/prompts.py +0 -0
  47. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/general/review.py +0 -0
  48. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/general/tasks.py +0 -0
  49. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/general/tombstones.py +0 -0
  50. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/go/__init__.py +0 -0
  51. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/go/capt-hook.toml +0 -0
  52. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/go/testing.py +0 -0
  53. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/go/toolchain.py +0 -0
  54. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/manager.py +0 -0
  55. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/python/__init__.py +0 -0
  56. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/python/capt-hook.toml +0 -0
  57. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/python/style.py +0 -0
  58. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/python/testing.py +0 -0
  59. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/python/toolchain.py +0 -0
  60. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/packs/steering/__init__.py +0 -0
  61. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/primitives/__init__.py +0 -0
  62. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/primitives/commands.py +0 -0
  63. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/primitives/lint.py +0 -0
  64. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/primitives/llm.py +0 -0
  65. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/primitives/nudge.py +0 -0
  66. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/primitives/permissions.py +0 -0
  67. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/primitives/rewrite.py +0 -0
  68. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/primitives/workflow.py +0 -0
  69. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/prompt.py +0 -0
  70. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/py.typed +0 -0
  71. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/review/__init__.py +0 -0
  72. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/review/cli.py +0 -0
  73. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/review/dashboard.py +0 -0
  74. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/review/fix.py +0 -0
  75. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/review/formats.py +0 -0
  76. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/review/pipeline.py +0 -0
  77. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/review/repo.py +0 -0
  78. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/review/settings.py +0 -0
  79. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/review/store.py +0 -0
  80. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/review/sync.py +0 -0
  81. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/session.py +0 -0
  82. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/settings.py +0 -0
  83. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/signals/nlp.py +0 -0
  84. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
  85. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +0 -0
  86. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +0 -0
  87. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
  88. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +0 -0
  89. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +0 -0
  90. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
  91. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
  92. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
  93. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
  94. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
  95. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
  96. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
  97. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/state.py +0 -0
  98. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/style/__init__.py +0 -0
  99. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/style/ast_grep.py +0 -0
  100. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/style/matchers.py +0 -0
  101. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/style/scope.py +0 -0
  102. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/style/types.py +0 -0
  103. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/tasks.py +0 -0
  104. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  105. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/testing/__init__.py +0 -0
  106. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/testing/helpers.py +0 -0
  107. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/testing/session_cache.py +0 -0
  108. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/testing/types.py +0 -0
  109. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/util/__init__.py +0 -0
  110. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/util/automode.py +0 -0
  111. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/util/fs.py +0 -0
  112. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/util/http.py +0 -0
  113. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/util/model_cache.py +0 -0
  114. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/util/proc.py +0 -0
  115. {capt_hook-8.3.0 → capt_hook-8.4.0}/captain_hook/util/text.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: capt-hook
3
- Version: 8.3.0
3
+ Version: 8.4.0
4
4
  Summary: Stop repeating yourself to Claude.
5
5
  Keywords: claude,claude-code,hooks,llm,agents,guardrails,cli
6
6
  Author: Yasyf Mohamedali
@@ -770,9 +770,9 @@ An unpinned security audit inherits fable; security review/audit is the codex-wr
770
770
  script="agent(`Synthesize the confirmed review findings and decide which to fix`)",
771
771
  llm={"fire": False},
772
772
  ): Allow(),
773
- Input(
774
- script="agent(`Audit the login flow for auth bypass and injection; return findings as JSON`)"
775
- ): Warn(pattern="gpt-5.5"),
773
+ Input(script="agent(`Audit the login flow for auth bypass and injection; return findings as JSON`)"): Warn(
774
+ pattern="gpt-5.5"
775
+ ),
776
776
  Input(script="agent('Verify the CLI renders the last page correctly')"): Allow(),
777
777
  },
778
778
  )
@@ -1,5 +1,5 @@
1
1
  name = "steering"
2
- version = "0.4.0"
2
+ version = "0.5.0"
3
3
  description = "Steer the agent's judgment on what's worth fixing: prefer fundamental, first-principles fixes over band-aids, don't dismiss a real pre-existing issue, don't chase trivial type noise, and don't defer the requested fix by swapping in docs or a follow-up nobody asked for."
4
4
  hooks = "."
5
5
  nlp = true
@@ -358,17 +358,21 @@ decided it) in `reasoning`.""",
358
358
 
359
359
 
360
360
  llm_gate(
361
- """You are a senior engineer. Another engineer ("the agent") is trying to end its turn. You
362
- are running in agent mode in the project's working directory, with read tools. Your one job:
363
- decide whether the agent DELIVERED the fix the user asked for, or silently DOWNGRADED the
364
- deliverable while the real fix stays undone.
361
+ """You are a senior engineer. Another engineer ("the agent") is ending its turn or has just
362
+ produced deferral-shaped content mid-work (a thinking block, a review finding, a task or todo it
363
+ filed). You are running in agent mode in the project's working directory, with read tools. Your
364
+ one job: decide whether the agent DELIVERED the fix the user asked for, or silently DOWNGRADED
365
+ the deliverable while the real fix stays undone.
365
366
 
366
367
  Read first, judge second:
367
368
  - The session transcript is rendered above inside `<transcript path="...">`; long content is
368
369
  clipped (you'll see `…(+Nch)` markers). Read the FULL exchange from that path (prefer
369
370
  `cc-transcript show`/`grep`; else read the file). You need: (1) what the user actually asked
370
371
  for (including any later approval of a reduced scope), (2) what the agent actually changed
371
- this turn, and (3) the agent's closing justification (the flagged lines are in `<context>`).
372
+ this turn, and (3) the agent's justification (the flagged lines are in `<context>`). On a
373
+ mid-turn firing the flagged content may be deliberation in flight — check whether the agent
374
+ has COMMITTED to the downgrade (substitute edits landed, a softer task filed as the plan of
375
+ record, the real fix abandoned), not merely weighed it.
372
376
  - Then inspect the working directory enough to confirm whether the requested fix was made or
373
377
  only a softer substitute (docs, help text, error copy) shipped.
374
378
 
@@ -391,21 +395,28 @@ how to proceed before substituting anything; the softer deliverable IS the task
391
395
  requested; the user approved a plan or message that explicitly named the smaller deliverable (an
392
396
  approved ExitPlanMode plan counts as the go-ahead); a genuine hard blockage the agent reported
393
397
  plainly (no access to the other repo, missing credentials, an API that does not exist); the
394
- agent shipped the real fix AND improved docs alongside it.
398
+ agent shipped the real fix AND improved docs alongside it; deliberation that names the softer
399
+ option in order to reject it and commits to the real fix; a finding or quoted text reporting
400
+ the codebase's or someone else's existing deferral debt or a genuine upstream bug — reporting
401
+ is not deferring, UNLESS the same content prescribes the downgrade as the remediation (e.g.
402
+ "fix requires a release, so document the constraint instead"); a task recording a genuine
403
+ environmental constraint that replaces no requested fix.
395
404
 
396
405
  Do NOT fire when: the user explicitly asked for the docs/help-text/error-copy change; the turn
397
406
  ends in a question to the user about the blocker (asking is the sanctioned escape hatch, not
398
407
  laziness); the deferral language refers to genuinely optional extras after the requested fix
399
- landed; or the blockage is real, outside the agent's reach, and clearly reported rather than
400
- papered over.
408
+ landed; the flagged content is mid-turn deliberation where no substitute has been executed yet;
409
+ or the blockage is real, outside the agent's reach, and clearly reported rather than papered
410
+ over.
401
411
 
402
412
  When uncertain, return block=false. A missed deferral costs one nag; a false alarm on an honest
403
413
  stop teaches the agent to ignore this gate. Fire only when a specific tell is clearly present
404
414
  and no "do not fire" condition applies. Put your reasoning (under 60 words, ending with the one
405
415
  tell that decided it) in `reasoning`.""",
406
416
  message=lambda r: (
407
- "You appear to be deferring the real fix — declaring it out of reach and "
408
- f"substituting a softer deliverable without asking. Why: {r.reasoning} "
417
+ "You appear to be deferring the real fix — whether closing the turn or still mid-work, "
418
+ "you have declared it out of reach and substituted a softer deliverable (or filed one "
419
+ f"as the plan of record) without asking. Why: {r.reasoning} "
409
420
  "Do the fix the user asked for: a release, version bump, or cross-repo change is "
410
421
  "routine work here, not a blocker — plan it and do it. If you are genuinely blocked, "
411
422
  "stop and ask the user how to proceed instead of substituting docs, help text, or a "
@@ -445,14 +456,14 @@ tell that decided it) in `reasoning`.""",
445
456
  Signal(
446
457
  pattern=(
447
458
  r"(?i)\b(?:improv|updat|expand|clarif|document|add)\w*\s+(?:the\s+|this\s+|that\s+|a\s+)?"
448
- r"(?:documentation|docs\b|help\s+text|readme|error\s+(?:message|copy|text)|limitation)"
459
+ r"(?:documentation|docs\b|help\s+text|readme|error\s+(?:message|copy|text))"
449
460
  ),
450
461
  weight=1,
451
462
  ),
452
463
  Signal(
453
464
  pattern=(
454
465
  r"(?i)\b(?:document\w*|not(?:e|es|ed|ing)|record\w*)\s+(?:the\s+|this\s+|that\s+|a\s+|its\s+)?"
455
- r"(?:limitation|caveat|constraint|shortcoming|known\s+issue|behavior)"
466
+ r"(?:[\w'-]+\s+){0,2}?(?:limitation|caveat|constraint|shortcoming|known\s+issue|behavior)"
456
467
  ),
457
468
  weight=2,
458
469
  ),
@@ -460,7 +471,7 @@ tell that decided it) in `reasoning`.""",
460
471
  pattern=(
461
472
  r"(?i)(?:(?:left|leaves?|leaving)\s+(?:the\s+|it\s+|this\s+|that\s+)?"
462
473
  r"(?:code|implementation|behavior|bug|issue|it)?\s*(?:as-?is|untouched|unchanged|in\s+place)"
463
- r"|still\s+(?:present|broken|unfixed|unaddressed|reproduc\w+)"
474
+ r"|still\s+(?:present|broken|unfixed|unaddressed|there\b|reproduc\w+)"
464
475
  r"|remains?\s+(?:broken|unfixed|in\s+place))"
465
476
  ),
466
477
  weight=2,
@@ -474,7 +485,7 @@ tell that decided it) in `reasoning`.""",
474
485
  ),
475
486
  Signal(
476
487
  pattern=(
477
- r"(?i)(?:as\s+a\s+workaround|stop-?gap|interim\s+(?:fix|solution|measure)|band-?aid"
488
+ r"(?i)(?:workaround|stop-?gap|interim\s+(?:fix|solution|measure)|band-?aid"
478
489
  r"|(?:as\s+a\s+)?temporary\s+(?:measure|fix|solution|patch|guard)"
479
490
  r"|in\s+the\s+meantime|for\s+the\s+time\s+being)"
480
491
  ),
@@ -483,7 +494,8 @@ tell that decided it) in `reasoning`.""",
483
494
  Signal(pattern=r"(?i)\bfor\s+now\b", weight=1),
484
495
  Signal(
485
496
  pattern=(
486
- r"(?i)(?:follow-?up\s+(?:pr|work|issue|task|change)|future\s+(?:pr|work|release)"
497
+ r"(?i)(?:follow-?up\s+(?:pr|work|issue|task|change)|(?:in|as)\s+a\s+follow-?up\b"
498
+ r"|future\s+(?:pr|work|release)"
487
499
  r"|file\s+(?:an?\s+)?issue|(?:separate|later|subsequent)\s+(?:pr|patch|pass|iteration)|backlog"
488
500
  r"|out\s+of\s+scope\s+for\s+this)"
489
501
  ),
@@ -502,8 +514,20 @@ tell that decided it) in `reasoning`.""",
502
514
  r"|so\s+(?:it|this|that|the\s+\w+)\s+(?:no\s+longer|doesn['’]t|does\s+not|won['’]t)\s+"
503
515
  r"(?:crash|die|fail|error)\w*)"
504
516
  ),
517
+ weight=2,
518
+ ),
519
+ Signal(
520
+ pattern=(
521
+ r"(?i)\b(?:punt|defer|postpon)\w*\s+(?:the|this|that|it)\b"
522
+ r"|\bskip\w*\s+the\s+(?:real|proper|actual|root)\b"
523
+ ),
524
+ weight=2,
525
+ ),
526
+ Signal(
527
+ pattern=r"(?i)\brevisit\s+(?:it\s+|this\s+)?later\b|\bcome\s+back\s+to\s+(?:it|this)\b",
505
528
  weight=1,
506
529
  ),
530
+ Signal(pattern=r"(?i)\bmov(?:e[ds]?|ing)\s+on\b", weight=1),
507
531
  NlpSignal(
508
532
  clauses=[Clause(noun=Phrase.expand("fix"), verb=Phrase("defer", "postpone", "punt"))],
509
533
  weight=2,
@@ -523,8 +547,9 @@ tell that decided it) in `reasoning`.""",
523
547
  ),
524
548
  ],
525
549
  threshold=3,
526
- window=10,
550
+ window="turn",
527
551
  ),
552
+ events=Event.PostToolUse | Event.Stop | Event.SubagentStop,
528
553
  tests={
529
554
  Input(
530
555
  transcript=[
@@ -727,5 +752,298 @@ tell that decided it) in `reasoning`.""",
727
752
  }
728
753
  ]
729
754
  ): Allow(),
755
+ Input(
756
+ transcript=[
757
+ {
758
+ "type": "assistant",
759
+ "message": {
760
+ "content": [
761
+ {
762
+ "type": "tool_use",
763
+ "id": "tu_findings",
764
+ "name": "ReportFindings",
765
+ "input": {
766
+ "findings": [
767
+ {
768
+ "file": "internal/daemon/daemon.go",
769
+ "line": 105,
770
+ "summary": (
771
+ "OpClose --stale and OpList are documented as cross-repo "
772
+ "commands, but cc-interact's dispatch runs ScopeResolve "
773
+ "(vcs.Root) on the caller's cwd for every domain op with a "
774
+ "hardcoded exemption set, so both error when invoked "
775
+ "outside a git/jj repo."
776
+ ),
777
+ "failure_scenario": (
778
+ "User cd ~ && cc-review list → error from vcs.Root instead "
779
+ "of the cross-repo listing; fix requires a cc-interact "
780
+ "release, so cc-review documents the run-inside-a-repo "
781
+ "constraint instead."
782
+ ),
783
+ "verdict": "CONFIRMED",
784
+ }
785
+ ]
786
+ },
787
+ }
788
+ ]
789
+ },
790
+ }
791
+ ]
792
+ ): Block(),
793
+ Input(
794
+ transcript=[
795
+ {
796
+ "type": "assistant",
797
+ "message": {
798
+ "content": [
799
+ {
800
+ "type": "thinking",
801
+ "thinking": (
802
+ "I'm seeing that cc-interact has hardcoded exemptions for certain "
803
+ "operations, which means any domain operation without an exemption "
804
+ "gets an error when the scope can't be resolved. The issue is that "
805
+ "`cc-review list` and `close --stale` commands don't have consumer "
806
+ "hooks to exempt them, so running these from outside a repo directory "
807
+ "triggers a vcs.Root error. Since fixing this requires a cc-interact "
808
+ "release, the practical solution is to improve the documentation and "
809
+ "help text to guide users on where to run these commands from."
810
+ ),
811
+ }
812
+ ]
813
+ },
814
+ }
815
+ ]
816
+ ): Block(),
817
+ Input(
818
+ transcript=[
819
+ {
820
+ "type": "assistant",
821
+ "message": {
822
+ "content": [
823
+ {
824
+ "type": "text",
825
+ "text": (
826
+ "Since fixing this requires a cc-interact release, the practical "
827
+ "solution is to document the limitation in the help text."
828
+ ),
829
+ }
830
+ ]
831
+ },
832
+ },
833
+ *(
834
+ block
835
+ for i in range(6)
836
+ for block in (
837
+ {
838
+ "type": "assistant",
839
+ "message": {
840
+ "content": [
841
+ {
842
+ "type": "tool_use",
843
+ "id": f"tu_{i}",
844
+ "name": "Read",
845
+ "input": {"file_path": f"/tmp/f{i}.py"},
846
+ }
847
+ ]
848
+ },
849
+ },
850
+ {
851
+ "type": "user",
852
+ "message": {
853
+ "content": [{"type": "tool_result", "tool_use_id": f"tu_{i}", "content": "ok"}]
854
+ },
855
+ },
856
+ )
857
+ ),
858
+ ]
859
+ ): Block(),
860
+ Input(
861
+ transcript=[
862
+ {
863
+ "type": "assistant",
864
+ "message": {
865
+ "content": [
866
+ {
867
+ "type": "tool_use",
868
+ "id": "tu_findings",
869
+ "name": "ReportFindings",
870
+ "input": {
871
+ "findings": [
872
+ {
873
+ "file": "captain_hook/signals/__init__.py",
874
+ "line": 46,
875
+ "summary": (
876
+ "Off-by-one in windowed() drops the final event when stop "
877
+ "lands on a turn boundary."
878
+ ),
879
+ "failure_scenario": (
880
+ "Session.recent(1) on a three-event transcript returns an "
881
+ "empty window, so signal scoring sees no text."
882
+ ),
883
+ "verdict": "CONFIRMED",
884
+ }
885
+ ]
886
+ },
887
+ }
888
+ ]
889
+ },
890
+ }
891
+ ]
892
+ ): Allow(),
893
+ Input(
894
+ transcript=[
895
+ {
896
+ "type": "assistant",
897
+ "message": {
898
+ "content": [
899
+ {
900
+ "type": "tool_use",
901
+ "id": "tu_todos",
902
+ "name": "TodoWrite",
903
+ "input": {
904
+ "todos": [
905
+ {
906
+ "content": "Add SSE reconnect test",
907
+ "status": "pending",
908
+ "activeForm": "Adding SSE reconnect test",
909
+ },
910
+ {
911
+ "content": "Wire the backoff cap into the client config",
912
+ "status": "in_progress",
913
+ "activeForm": "Wiring the backoff cap",
914
+ },
915
+ ]
916
+ },
917
+ }
918
+ ]
919
+ },
920
+ }
921
+ ]
922
+ ): Allow(),
923
+ Input(
924
+ transcript=[
925
+ {
926
+ "type": "assistant",
927
+ "message": {
928
+ "content": [
929
+ {
930
+ "type": "tool_use",
931
+ "id": "tu_task_workaround",
932
+ "name": "TaskCreate",
933
+ "input": {
934
+ "subject": "Workaround for the crash",
935
+ "description": (
936
+ "Wrap the call in try/except so the session survives; revisit later."
937
+ ),
938
+ },
939
+ }
940
+ ]
941
+ },
942
+ }
943
+ ]
944
+ ): Block(),
945
+ Input(
946
+ transcript=[
947
+ {
948
+ "type": "assistant",
949
+ "message": {
950
+ "content": [
951
+ {
952
+ "type": "tool_use",
953
+ "id": "tu_todo_defer",
954
+ "name": "TodoWrite",
955
+ "input": {
956
+ "todos": [
957
+ {
958
+ "content": "Defer the parser rewrite to a later pass",
959
+ "status": "pending",
960
+ "activeForm": "Deferring the parser rewrite",
961
+ },
962
+ ]
963
+ },
964
+ }
965
+ ]
966
+ },
967
+ }
968
+ ]
969
+ ): Block(),
970
+ Input(
971
+ transcript=[
972
+ {
973
+ "type": "assistant",
974
+ "message": {
975
+ "content": [
976
+ {
977
+ "type": "tool_use",
978
+ "id": "tu_task_skip",
979
+ "name": "TaskCreate",
980
+ "input": {
981
+ "subject": "Skip the real fix",
982
+ "description": (
983
+ "Too big for this pass; just note it somewhere and come back to it."
984
+ ),
985
+ },
986
+ }
987
+ ]
988
+ },
989
+ }
990
+ ]
991
+ ): Block(),
992
+ Input(
993
+ transcript=[
994
+ {
995
+ "type": "assistant",
996
+ "message": {
997
+ "content": [
998
+ {
999
+ "type": "tool_use",
1000
+ "id": "tu_findings_deadcode",
1001
+ "name": "ReportFindings",
1002
+ "input": {
1003
+ "findings": [
1004
+ {
1005
+ "file": "captain_hook/signals/__init__.py",
1006
+ "line": 90,
1007
+ "summary": (
1008
+ "The `document the limitation` fallback branch in "
1009
+ "cite_message is dead code — no caller reaches it."
1010
+ ),
1011
+ "failure_scenario": (
1012
+ "extract_signal_context always returns a non-empty list "
1013
+ "for these patterns, so the else arm never runs."
1014
+ ),
1015
+ "verdict": "CONFIRMED",
1016
+ }
1017
+ ]
1018
+ },
1019
+ }
1020
+ ]
1021
+ },
1022
+ }
1023
+ ]
1024
+ ): Allow(),
1025
+ Input(
1026
+ transcript=[
1027
+ {
1028
+ "type": "assistant",
1029
+ "message": {
1030
+ "content": [
1031
+ {
1032
+ "type": "tool_use",
1033
+ "id": "tu_task_spacy_docs",
1034
+ "name": "TaskCreate",
1035
+ "input": {
1036
+ "subject": "Document the limitation",
1037
+ "description": (
1038
+ "Note in the README that NLP scoring needs the spaCy "
1039
+ "en_core_web_sm model provisioned."
1040
+ ),
1041
+ },
1042
+ }
1043
+ ]
1044
+ },
1045
+ }
1046
+ ]
1047
+ ): Allow(),
730
1048
  },
731
1049
  )
@@ -26,6 +26,7 @@ from cc_transcript.judge.llm import resolved_model, structured_judge
26
26
  from cc_transcript.judge.verdicts import run_verdicts
27
27
  from cc_transcript.mining.candidates import DedupKey
28
28
  from cc_transcript.mining.confidence import NOISE_FLOOR
29
+ from cc_transcript.mining.sourcekind import QUESTION_ANSWER
29
30
  from cc_transcript.render import Budget
30
31
  from pydantic import BaseModel, Field
31
32
 
@@ -131,6 +132,27 @@ async def render_context(window: ContextWindow) -> tuple[str, Fidelity]:
131
132
  )
132
133
 
133
134
 
135
+ def question_answer_block(row: Mapping[str, object]) -> str:
136
+ if str(row["source_kind"]) != QUESTION_ANSWER:
137
+ return ""
138
+ payload: dict[str, object] = json.loads(str(row["payload_json"]))
139
+ match payload["picked_labels"]:
140
+ case []:
141
+ resolved = "The developer picked none of the offered options and wrote a freeform answer."
142
+ case [*labels]:
143
+ choice = "options" if payload["multi_select"] else "option"
144
+ standing = (
145
+ "which is the option the assistant marked (Recommended)"
146
+ if payload["recommended_pick"]
147
+ else "which is not the recommended option"
148
+ )
149
+ resolved = f"The answer resolves to the {choice} {'; '.join(map(str, labels))} — {standing}."
150
+ return (
151
+ f"=== QUESTION THE ASSISTANT ASKED ===\n{payload['question']}\n{resolved}\n"
152
+ "The feedback below is the developer's answer to that question.\n"
153
+ )
154
+
155
+
134
156
  def build_create_prompt(row: Mapping[str, object], context: str) -> str:
135
157
  return f"""\
136
158
  You are auditing one piece of feedback a developer gave an AI coding assistant
@@ -170,7 +192,7 @@ Respond with strict JSON matching the schema — no extra keys, no prose.
170
192
 
171
193
  [source: {row["source_kind"]}]
172
194
  {context}
173
- === FEEDBACK TO CLASSIFY ===
195
+ {question_answer_block(row)}=== FEEDBACK TO CLASSIFY ===
174
196
  {row["text"]}"""
175
197
 
176
198
 
@@ -150,6 +150,8 @@ def rule_parts(sig: MiningSignal) -> tuple[str, ...]:
150
150
  str(sig.evidence["line_end"] or ""),
151
151
  sig.text,
152
152
  )
153
+ case "ask_user_question":
154
+ return ("question_answer", str(sig.evidence["question"] or ""), sig.text)
153
155
  case _:
154
156
  raise AssertionError(sig.detector)
155
157
 
@@ -171,6 +173,8 @@ def payload_of(sig: MiningSignal) -> Mapping[str, Any] | None:
171
173
  return dict(sig.evidence) or None
172
174
  case "review_comment":
173
175
  return {key: sig.evidence[key] for key in ("format", "file", "line_start", "line_end")}
176
+ case "ask_user_question":
177
+ return dict(sig.evidence)
174
178
  case _:
175
179
  raise AssertionError(sig.detector)
176
180
 
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Callable, Iterator, Mapping, Sequence
5
+ from typing import TYPE_CHECKING, Any, Literal
6
+
7
+ from cc_transcript.models import AssistantEvent, ThinkingBlock, ToolUseBlock, UserEvent
8
+
9
+ from captain_hook.signals.nlp import NlpSignal
10
+ from captain_hook.types import Event, Signal, Signals
11
+
12
+ if TYPE_CHECKING:
13
+ from captain_hook.events import BaseHookEvent
14
+
15
+ PROSE_TOOLS: dict[str, Callable[[Mapping[str, Any]], list[str]]] = {
16
+ "ReportFindings": lambda inp: [
17
+ " ".join(filter(None, (f.get("summary"), f.get("failure_scenario")))) for f in inp.get("findings", ())
18
+ ],
19
+ "TaskCreate": lambda inp: [" ".join(filter(None, (inp.get("subject"), inp.get("description"))))],
20
+ "TaskUpdate": lambda inp: [" ".join(filter(None, (inp.get("subject"), inp.get("description"))))],
21
+ "TodoWrite": lambda inp: [
22
+ " ".join(filter(None, (t.get("content"), t.get("subject")))) for t in inp.get("todos", ())
23
+ ],
24
+ }
25
+
26
+ TSignalPattern = Signal | NlpSignal
27
+
28
+
29
+ def score_signals(patterns: Sequence[TSignalPattern], text: str) -> int:
30
+ from captain_hook.signals.nlp import nlp_scan
31
+
32
+ total = 0
33
+ for s in patterns:
34
+ match s:
35
+ case NlpSignal(clauses=clauses) if nlp_scan(clauses, text):
36
+ total += s.weight
37
+ case Signal() if re.search(s.pattern, text, s.flags):
38
+ total += s.weight
39
+ case _:
40
+ pass
41
+ return total
42
+
43
+
44
+ def extract_signal_context(patterns: Sequence[TSignalPattern], text: str) -> list[str]:
45
+ from captain_hook.signals.nlp import nlp_scan
46
+
47
+ result: list[str] = []
48
+ for s in patterns:
49
+ match s:
50
+ case NlpSignal(clauses=clauses):
51
+ result.extend(nlp_scan(clauses, text))
52
+ case Signal():
53
+ result.extend(line for line in text.splitlines() if re.search(s.pattern, line, s.flags))
54
+ return result
55
+
56
+
57
+ def block_texts(event: UserEvent | AssistantEvent) -> Iterator[str]:
58
+ for block in event.blocks:
59
+ match block:
60
+ case ThinkingBlock(thinking=thinking):
61
+ yield thinking
62
+ case ToolUseBlock(name=name, input=payload) if extract := PROSE_TOOLS.get(name):
63
+ yield from extract(payload)
64
+ case _:
65
+ pass
66
+
67
+
68
+ def transcript_texts(evt: BaseHookEvent, window: int | Literal["turn"]) -> list[str]:
69
+ """Extract prose from recent transcript events for signal scoring.
70
+
71
+ For ``UserPromptSubmit`` events, returns just the user prompt. Otherwise scans
72
+ the last ``window`` events — the whole current turn when ``window`` is
73
+ ``"turn"`` — and returns one entry per prose source: each event's ``.text``,
74
+ each thinking block, and the prose fields of prose-carrying tool calls
75
+ (``ReportFindings`` findings, ``TaskCreate``/``TaskUpdate`` subjects and
76
+ descriptions, ``TodoWrite`` todos).
77
+ """
78
+ if evt.event == Event.UserPromptSubmit and evt.user_prompt:
79
+ return [evt.user_prompt]
80
+ scope = evt.ctx.turn if window == "turn" else evt.ctx.t.recent(window)
81
+ return [
82
+ text
83
+ for event in scope.events
84
+ if isinstance(event, UserEvent | AssistantEvent)
85
+ for text in (event.text, *block_texts(event))
86
+ if text
87
+ ]
88
+
89
+
90
+ def cite_message(sig: Signals, triggering: list[str], message: str) -> str:
91
+ """Append trigger context to a message when signal matches are found."""
92
+ return (
93
+ f"{message}\n\nTriggered by: {'; '.join(context)}"
94
+ if (context := extract_signal_context(sig.patterns, "\n".join(triggering)))
95
+ else message
96
+ )
97
+
98
+
99
+ def resolve_signals(signals: Sequence[Signal | NlpSignal] | Signals | None) -> Signals | None:
100
+ """Normalize signals input into a ``Signals`` bundle, or None.
101
+
102
+ A bare ``list[Signal]`` is wrapped with ``threshold=1`` (any single match triggers).
103
+ """
104
+ if signals is None:
105
+ return None
106
+ if isinstance(signals, Signals):
107
+ return signals
108
+ return Signals(patterns=list(signals), threshold=1)
@@ -9,6 +9,7 @@ from typing import (
9
9
  TYPE_CHECKING,
10
10
  Any,
11
11
  Generic,
12
+ Literal,
12
13
  Protocol,
13
14
  TypeVar,
14
15
  get_args,
@@ -657,11 +658,17 @@ class Signals:
657
658
  When a bare ``list[Signal]`` is passed to a primitive, ``resolve_signals``
658
659
  wraps it with ``threshold=1`` — meaning *any* single signal match triggers.
659
660
  Pass a higher threshold to require multiple signals to fire together.
661
+
662
+ ``window`` bounds how much transcript each scoring pass reads: the last
663
+ ``window`` events by default, or the whole current turn with the ``"turn"``
664
+ sentinel. Use ``"turn"`` when the tell can sit hundreds of events before the
665
+ turn ends — a fixed window at ``Stop`` only reaches the last few tool
666
+ exchanges.
660
667
  """
661
668
 
662
669
  patterns: Sequence[Signal | NlpSignal]
663
670
  threshold: int
664
- window: int = 15
671
+ window: int | Literal["turn"] = 15
665
672
 
666
673
 
667
674
  @dataclass(frozen=True, kw_only=True)
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "capt-hook"
3
3
  # Inert sentinel — the real version is stamped from the release tag (uv version --frozen).
4
- version = "8.3.0"
4
+ version = "8.4.0"
5
5
  description = "Stop repeating yourself to Claude."
6
6
  readme = "README.md"
7
7
  license = "PolyForm-Noncommercial-1.0.0"
@@ -1,81 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import re
4
- from collections.abc import Sequence
5
- from typing import TYPE_CHECKING
6
-
7
- from cc_transcript.models import AssistantEvent, UserEvent
8
-
9
- from captain_hook.signals.nlp import NlpSignal
10
- from captain_hook.types import Event, Signal, Signals
11
-
12
- if TYPE_CHECKING:
13
- from captain_hook.events import BaseHookEvent
14
-
15
- TSignalPattern = Signal | NlpSignal
16
-
17
-
18
- def score_signals(patterns: Sequence[TSignalPattern], text: str) -> int:
19
- from captain_hook.signals.nlp import nlp_scan
20
-
21
- total = 0
22
- for s in patterns:
23
- match s:
24
- case NlpSignal(clauses=clauses) if nlp_scan(clauses, text):
25
- total += s.weight
26
- case Signal() if re.search(s.pattern, text, s.flags):
27
- total += s.weight
28
- case _:
29
- pass
30
- return total
31
-
32
-
33
- def extract_signal_context(patterns: Sequence[TSignalPattern], text: str) -> list[str]:
34
- from captain_hook.signals.nlp import nlp_scan
35
-
36
- result: list[str] = []
37
- for s in patterns:
38
- match s:
39
- case NlpSignal(clauses=clauses):
40
- result.extend(nlp_scan(clauses, text))
41
- case Signal():
42
- result.extend(line for line in text.splitlines() if re.search(s.pattern, line, s.flags))
43
- return result
44
-
45
-
46
- def transcript_texts(evt: BaseHookEvent, window: int) -> list[str]:
47
- """Extract text from recent transcript events for signal scoring.
48
-
49
- For ``UserPromptSubmit`` events, returns just the user prompt.
50
- Otherwise returns ``.text`` from the last ``window`` events.
51
- """
52
- return (
53
- [evt.user_prompt]
54
- if evt.event == Event.UserPromptSubmit and evt.user_prompt
55
- else [
56
- text
57
- for event in evt.ctx.t.recent(window).events
58
- if isinstance(event, UserEvent | AssistantEvent) and (text := event.text)
59
- ]
60
- )
61
-
62
-
63
- def cite_message(sig: Signals, triggering: list[str], message: str) -> str:
64
- """Append trigger context to a message when signal matches are found."""
65
- return (
66
- f"{message}\n\nTriggered by: {'; '.join(context)}"
67
- if (context := extract_signal_context(sig.patterns, "\n".join(triggering)))
68
- else message
69
- )
70
-
71
-
72
- def resolve_signals(signals: Sequence[Signal | NlpSignal] | Signals | None) -> Signals | None:
73
- """Normalize signals input into a ``Signals`` bundle, or None.
74
-
75
- A bare ``list[Signal]`` is wrapped with ``threshold=1`` (any single match triggers).
76
- """
77
- if signals is None:
78
- return None
79
- if isinstance(signals, Signals):
80
- return signals
81
- return Signals(patterns=list(signals), threshold=1)
File without changes
File without changes
File without changes
File without changes
File without changes