agentpack-cli 0.1.16__tar.gz → 0.1.20__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 (84) hide show
  1. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/PKG-INFO +20 -4
  2. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/README.md +19 -3
  3. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/pyproject.toml +1 -1
  4. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/__init__.py +1 -1
  5. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/application/pack_service.py +6 -1
  6. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/cli.py +2 -2
  7. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/commands/doctor.py +30 -2
  8. agentpack_cli-0.1.20/src/agentpack/commands/hook_cmd.py +248 -0
  9. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/commands/pack.py +13 -2
  10. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/core/git.py +64 -27
  11. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/installers/claude.py +22 -51
  12. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/.gitignore +0 -0
  13. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/LICENSE +0 -0
  14. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/adapters/__init__.py +0 -0
  15. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/adapters/antigravity.py +0 -0
  16. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/adapters/base.py +0 -0
  17. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/adapters/claude.py +0 -0
  18. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/adapters/codex.py +0 -0
  19. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/adapters/cursor.py +0 -0
  20. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/adapters/detect.py +0 -0
  21. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/adapters/generic.py +0 -0
  22. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/adapters/windsurf.py +0 -0
  23. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/analysis/__init__.py +0 -0
  24. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/analysis/dependency_graph.py +0 -0
  25. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/analysis/go_imports.py +0 -0
  26. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/analysis/java_imports.py +0 -0
  27. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/analysis/js_ts_imports.py +0 -0
  28. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/analysis/python_imports.py +0 -0
  29. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/analysis/ranking.py +0 -0
  30. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/analysis/rust_imports.py +0 -0
  31. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/analysis/symbols.py +0 -0
  32. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/analysis/tests.py +0 -0
  33. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/application/__init__.py +0 -0
  34. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/commands/__init__.py +0 -0
  35. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/commands/_shared.py +0 -0
  36. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/commands/benchmark.py +0 -0
  37. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/commands/claude_cmd.py +0 -0
  38. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/commands/diff.py +0 -0
  39. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/commands/explain.py +0 -0
  40. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/commands/init.py +0 -0
  41. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/commands/install.py +0 -0
  42. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/commands/mcp_cmd.py +0 -0
  43. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/commands/monitor.py +0 -0
  44. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/commands/scan.py +0 -0
  45. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/commands/stats.py +0 -0
  46. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/commands/status.py +0 -0
  47. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/commands/summarize.py +0 -0
  48. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/commands/watch.py +0 -0
  49. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/core/__init__.py +0 -0
  50. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/core/bootstrap.py +0 -0
  51. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/core/cache.py +0 -0
  52. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/core/config.py +0 -0
  53. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/core/context_pack.py +0 -0
  54. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/core/diff.py +0 -0
  55. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/core/git_hooks.py +0 -0
  56. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/core/global_install.py +0 -0
  57. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/core/ignore.py +0 -0
  58. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/core/merkle.py +0 -0
  59. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/core/models.py +0 -0
  60. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/core/redactor.py +0 -0
  61. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/core/scanner.py +0 -0
  62. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/core/snapshot.py +0 -0
  63. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/core/token_estimator.py +0 -0
  64. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/core/vscode_tasks.py +0 -0
  65. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/data/agentpack.md +0 -0
  66. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/installers/__init__.py +0 -0
  67. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/installers/antigravity.py +0 -0
  68. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/installers/codex.py +0 -0
  69. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/installers/cursor.py +0 -0
  70. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/installers/windsurf.py +0 -0
  71. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/integrations/__init__.py +0 -0
  72. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/integrations/git_hooks.py +0 -0
  73. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/integrations/global_install.py +0 -0
  74. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/integrations/vscode_tasks.py +0 -0
  75. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/mcp_server.py +0 -0
  76. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/renderers/__init__.py +0 -0
  77. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/renderers/compact.py +0 -0
  78. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/renderers/markdown.py +0 -0
  79. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/renderers/receipts.py +0 -0
  80. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/session/__init__.py +0 -0
  81. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/session/state.py +0 -0
  82. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/summaries/__init__.py +0 -0
  83. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/summaries/base.py +0 -0
  84. {agentpack_cli-0.1.16 → agentpack_cli-0.1.20}/src/agentpack/summaries/offline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentpack-cli
3
- Version: 0.1.16
3
+ Version: 0.1.20
4
4
  Summary: Token-aware context packing for AI coding agents — Claude, Cursor, Windsurf, and Codex
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -44,7 +44,7 @@ Description-Content-Type: text/markdown
44
44
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
45
45
  [![CI](https://github.com/vishal2612200/agentpack/actions/workflows/ci.yml/badge.svg)](https://github.com/vishal2612200/agentpack/actions/workflows/ci.yml)
46
46
 
47
- > **Status: alpha (v0.1.17).** Works, tested, used in real sessions. Python and JavaScript/TypeScript are the best-supported languages. Not yet validated across a wide range of repos. API may change before 1.0.
47
+ > **Status: alpha (v0.1.20).** Works, tested, used in real sessions. Python and JavaScript/TypeScript are the best-supported languages. Not yet validated across a wide range of repos. API may change before 1.0.
48
48
  >
49
49
  > **Platform note:** macOS and Linux are fully supported. Windows support is not yet implemented (git hooks use POSIX shell; the Claude Code session hooks use `python3`/`rm -f`). Contributions welcome.
50
50
 
@@ -297,7 +297,7 @@ Configures:
297
297
  - `CLAUDE.md` — tells Claude to read the context pack before each task
298
298
  - `.claude/settings.json` — two hooks:
299
299
  - `SessionStart`: clears injection sentinel so first prompt gets context
300
- - `UserPromptSubmit`: detects repo changes via content hash (not file mtime), triggers background repack using your prompt as the task description so keyword scoring matches the current conversation
300
+ - `UserPromptSubmit`: runs `agentpack hook` — detects repo changes via `root_hash`, triggers background repack using your prompt as task. With MCP: emits Option-B hint (~100 tokens, task + top files). Without MCP: emits capped fallback (top 8 files, ≤3k chars)
301
301
 
302
302
  After this, context is injected automatically into every Claude Code session. No `/agentpack` command needed — it just happens.
303
303
 
@@ -1298,7 +1298,23 @@ Skip writing a task description — agentpack infers it from your branch name, c
1298
1298
  agentpack pack --task auto
1299
1299
  ```
1300
1300
 
1301
- Priority order: branch name changed file paths → recent commit message. The more descriptive your branch names (`feat/add-rate-limiting` beats `dev`), the better the inferred task.
1301
+ Priority order (strongestweakest):
1302
+
1303
+ | Source | Example output |
1304
+ |--------|---------------|
1305
+ | `task.md` (explicit) | `"migrate DB schema to multi-tenant"` |
1306
+ | branch + staged files | `"feat add-rate-limiting: payments, throttle"` |
1307
+ | staged files only | `"payments, throttle"` |
1308
+ | branch + unstaged | `"feat add-rate-limiting: session, token"` |
1309
+ | branch + latest commit | `"feat add-rate-limiting: fix token expiry check"` |
1310
+ | branch name alone | `"feat add-rate-limiting"` |
1311
+ | unstaged files | `"session, token"` |
1312
+ | recent commit messages | `"fix token expiry check; add pagination"` |
1313
+ | recently modified files | `"session, payments"` (noisy — last resort) |
1314
+
1315
+ The heuristic that fired is logged: `Auto task (branch+staged): feat add-rate-limiting: payments`.
1316
+
1317
+ The more descriptive your branch names (`feat/add-rate-limiting` beats `dev`) and the more you stage before running, the more accurate the inference.
1302
1318
 
1303
1319
  ### Concept synonym expansion
1304
1320
 
@@ -5,7 +5,7 @@
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
  [![CI](https://github.com/vishal2612200/agentpack/actions/workflows/ci.yml/badge.svg)](https://github.com/vishal2612200/agentpack/actions/workflows/ci.yml)
7
7
 
8
- > **Status: alpha (v0.1.17).** Works, tested, used in real sessions. Python and JavaScript/TypeScript are the best-supported languages. Not yet validated across a wide range of repos. API may change before 1.0.
8
+ > **Status: alpha (v0.1.20).** Works, tested, used in real sessions. Python and JavaScript/TypeScript are the best-supported languages. Not yet validated across a wide range of repos. API may change before 1.0.
9
9
  >
10
10
  > **Platform note:** macOS and Linux are fully supported. Windows support is not yet implemented (git hooks use POSIX shell; the Claude Code session hooks use `python3`/`rm -f`). Contributions welcome.
11
11
 
@@ -258,7 +258,7 @@ Configures:
258
258
  - `CLAUDE.md` — tells Claude to read the context pack before each task
259
259
  - `.claude/settings.json` — two hooks:
260
260
  - `SessionStart`: clears injection sentinel so first prompt gets context
261
- - `UserPromptSubmit`: detects repo changes via content hash (not file mtime), triggers background repack using your prompt as the task description so keyword scoring matches the current conversation
261
+ - `UserPromptSubmit`: runs `agentpack hook` — detects repo changes via `root_hash`, triggers background repack using your prompt as task. With MCP: emits Option-B hint (~100 tokens, task + top files). Without MCP: emits capped fallback (top 8 files, ≤3k chars)
262
262
 
263
263
  After this, context is injected automatically into every Claude Code session. No `/agentpack` command needed — it just happens.
264
264
 
@@ -1259,7 +1259,23 @@ Skip writing a task description — agentpack infers it from your branch name, c
1259
1259
  agentpack pack --task auto
1260
1260
  ```
1261
1261
 
1262
- Priority order: branch name changed file paths → recent commit message. The more descriptive your branch names (`feat/add-rate-limiting` beats `dev`), the better the inferred task.
1262
+ Priority order (strongestweakest):
1263
+
1264
+ | Source | Example output |
1265
+ |--------|---------------|
1266
+ | `task.md` (explicit) | `"migrate DB schema to multi-tenant"` |
1267
+ | branch + staged files | `"feat add-rate-limiting: payments, throttle"` |
1268
+ | staged files only | `"payments, throttle"` |
1269
+ | branch + unstaged | `"feat add-rate-limiting: session, token"` |
1270
+ | branch + latest commit | `"feat add-rate-limiting: fix token expiry check"` |
1271
+ | branch name alone | `"feat add-rate-limiting"` |
1272
+ | unstaged files | `"session, token"` |
1273
+ | recent commit messages | `"fix token expiry check; add pagination"` |
1274
+ | recently modified files | `"session, payments"` (noisy — last resort) |
1275
+
1276
+ The heuristic that fired is logged: `Auto task (branch+staged): feat add-rate-limiting: payments`.
1277
+
1278
+ The more descriptive your branch names (`feat/add-rate-limiting` beats `dev`) and the more you stage before running, the more accurate the inference.
1263
1279
 
1264
1280
  ### Concept synonym expansion
1265
1281
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentpack-cli"
3
- version = "0.1.16"
3
+ version = "0.1.20"
4
4
  description = "Token-aware context packing for AI coding agents — Claude, Cursor, Windsurf, and Codex"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """AgentPack — token-aware context packing for AI coding agents."""
2
2
 
3
- __version__ = "0.1.17"
3
+ __version__ = "0.1.20"
@@ -304,6 +304,8 @@ class PackService:
304
304
  token_estimate=packed_tokens,
305
305
  )
306
306
  excluded_receipts = [r for r in plan.receipts if r.action == "excluded"]
307
+ # Budget-cut: files that scored OK but didn't fit — more useful signal than "score too low"
308
+ budget_cut = [r.path for r in plan.receipts if r.reason == "budget exhausted"][:10]
307
309
  _record_metrics(
308
310
  root,
309
311
  task=request.task,
@@ -315,9 +317,10 @@ class PackService:
315
317
  selected_count=len(plan.selected),
316
318
  changed_count=len(plan.all_changed),
317
319
  selected_paths=[sf.path for sf in plan.selected],
320
+ selected_hints=[{"path": sf.path, "why": sf.reasons[0] if sf.reasons else ""} for sf in plan.selected[:8]],
318
321
  current_changed=plan.all_changed,
319
322
  excluded_count=len(excluded_receipts),
320
- excluded_paths=[r.path for r in excluded_receipts if r.reason == "score too low"][:10],
323
+ excluded_paths=budget_cut,
321
324
  )
322
325
 
323
326
  return PackResult(
@@ -407,6 +410,7 @@ def _record_metrics(
407
410
  changed_count: int,
408
411
  selected_paths: list[str],
409
412
  current_changed: set[str],
413
+ selected_hints: list[dict] | None = None,
410
414
  excluded_count: int = 0,
411
415
  excluded_paths: list[str] | None = None,
412
416
  ) -> None:
@@ -424,6 +428,7 @@ def _record_metrics(
424
428
  "excluded_files": excluded_count,
425
429
  "excluded_paths": excluded_paths or [],
426
430
  "selected_paths": selected_paths,
431
+ "selected_hints": selected_hints or [],
427
432
  "phases": {k: round(v, 3) for k, v in phase_times.items()},
428
433
  "total_s": round(sum(phase_times.values()), 3),
429
434
  }
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import typer
4
- from agentpack.commands import init, scan, diff, status, stats, summarize, pack, install, monitor, explain, doctor, watch, claude_cmd, benchmark, mcp_cmd
4
+ from agentpack.commands import init, scan, diff, status, stats, summarize, pack, install, monitor, explain, doctor, watch, claude_cmd, benchmark, mcp_cmd, hook_cmd
5
5
  from agentpack import __version__
6
6
 
7
7
 
@@ -21,7 +21,7 @@ def _main(
21
21
  pass
22
22
 
23
23
 
24
- for mod in [init, scan, diff, status, stats, summarize, pack, install, monitor, explain, doctor, watch, claude_cmd, benchmark, mcp_cmd]:
24
+ for mod in [init, scan, diff, status, stats, summarize, pack, install, monitor, explain, doctor, watch, claude_cmd, benchmark, mcp_cmd, hook_cmd]:
25
25
  mod.register(app)
26
26
 
27
27
 
@@ -119,12 +119,37 @@ def register(app: typer.Typer) -> None:
119
119
  import json as _json
120
120
  _local_has_hooks = False
121
121
  _global_has_hooks = False
122
+
123
+ def _has_stale_hooks(hooks: dict) -> bool:
124
+ """Detect old inline-Python or context-injection hooks that should be upgraded."""
125
+ all_cmds = [
126
+ h.get("command", "")
127
+ for event_hooks in hooks.values()
128
+ for entry in event_hooks
129
+ for h in entry.get("hooks", [])
130
+ ]
131
+ return any(
132
+ "context.claude.md" in cmd
133
+ or ".context_injected" in cmd
134
+ or (".mcp_reminded" in cmd and "python3" in cmd)
135
+ for cmd in all_cmds
136
+ )
137
+
138
+ def _has_current_hooks(hooks: dict) -> bool:
139
+ return "agentpack hook" in str(hooks)
140
+
122
141
  if claude_settings.exists():
123
142
  try:
124
143
  data = _json.loads(claude_settings.read_text())
125
144
  hooks = data.get("hooks", {})
126
145
  if "UserPromptSubmit" in hooks or "SessionStart" in hooks:
127
- console.print(f" [green]✓[/] Claude hooks present (local): {claude_settings}")
146
+ if _has_stale_hooks(hooks):
147
+ console.print(" [yellow]![/] Claude hooks stale (local) — old injection hook detected. Run: agentpack install --agent claude")
148
+ ok = False
149
+ elif _has_current_hooks(hooks):
150
+ console.print(f" [green]✓[/] Claude hooks present (local): {claude_settings}")
151
+ else:
152
+ console.print(f" [green]✓[/] Claude hooks present (local): {claude_settings}")
128
153
  _local_has_hooks = True
129
154
  else:
130
155
  console.print(" [yellow]![/] Claude hooks missing (local) — run: agentpack install --agent claude")
@@ -138,7 +163,10 @@ def register(app: typer.Typer) -> None:
138
163
  data = _json.loads(global_claude_settings.read_text())
139
164
  hooks = data.get("hooks", {})
140
165
  if "UserPromptSubmit" in hooks or "SessionStart" in hooks:
141
- console.print(f" [green]✓[/] Claude hooks present (global): {global_claude_settings}")
166
+ if _has_stale_hooks(hooks):
167
+ console.print(" [yellow]![/] Claude hooks stale (global) — old injection hook detected. Run: agentpack install --agent claude --global")
168
+ else:
169
+ console.print(f" [green]✓[/] Claude hooks present (global): {global_claude_settings}")
142
170
  _global_has_hooks = True
143
171
  else:
144
172
  console.print(" [yellow]![/] Claude hooks missing (global) — run: agentpack install --agent claude --global")
@@ -0,0 +1,248 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import typer
10
+
11
+ from agentpack.commands._shared import _root
12
+ from agentpack.core import git as _git
13
+
14
+ _TASK_FILE = ".agentpack/task.md"
15
+ _TASK_FILE_DEFAULT_MARKER = "Write or update the current coding task here."
16
+ _CODING_PROMPT_RE = re.compile(
17
+ r"(?:fix|add|refactor|impl|implement|update|write|debug|test|build|migrate|remove|delete|rename|optimize)\b",
18
+ re.IGNORECASE,
19
+ )
20
+
21
+
22
+ def register(app: typer.Typer) -> None:
23
+ @app.command(name="hook")
24
+ def hook(
25
+ event: str = typer.Option("UserPromptSubmit", "--event", help="Hook event name."),
26
+ ) -> None:
27
+ """Run as a Claude Code hook. Reads stdin (JSON), emits additionalContext."""
28
+ root = _root()
29
+ if event == "UserPromptSubmit":
30
+ _run_user_prompt_submit(root)
31
+ elif event == "SessionStart":
32
+ _run_session_start(root)
33
+ else:
34
+ sys.exit(0)
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Public helpers (tested directly)
39
+ # ---------------------------------------------------------------------------
40
+
41
+ def _mcp_installed(root: Path) -> bool:
42
+ local_mcp = root / ".mcp.json"
43
+ if local_mcp.exists():
44
+ try:
45
+ cfg = json.loads(local_mcp.read_text())
46
+ if "agentpack" in cfg.get("mcpServers", {}):
47
+ return True
48
+ except Exception:
49
+ pass
50
+ global_settings = Path.home() / ".claude" / "settings.json"
51
+ if global_settings.exists():
52
+ try:
53
+ cfg = json.loads(global_settings.read_text())
54
+ if "agentpack" in cfg.get("mcpServers", {}):
55
+ return True
56
+ except Exception:
57
+ pass
58
+ return False
59
+
60
+
61
+ def _load_task_md(root: Path) -> str:
62
+ """Return task.md content if user has written a real task (not the default placeholder)."""
63
+ task_path = root / _TASK_FILE
64
+ if not task_path.exists():
65
+ return ""
66
+ try:
67
+ content = task_path.read_text(encoding="utf-8").strip()
68
+ # Strip markdown heading
69
+ lines = [ln for ln in content.splitlines() if not ln.startswith("#")]
70
+ body = "\n".join(lines).strip()
71
+ if not body or _TASK_FILE_DEFAULT_MARKER in body:
72
+ return ""
73
+ return body[:200]
74
+ except Exception:
75
+ return ""
76
+
77
+
78
+ def _looks_like_coding_prompt(prompt: str) -> bool:
79
+ """Return True if prompt looks like a coding task (not a slash command or chat)."""
80
+ stripped = prompt.strip()
81
+ if stripped.startswith("/"):
82
+ return False
83
+ return bool(_CODING_PROMPT_RE.search(stripped))
84
+
85
+
86
+ def _resolve_task(root: Path, prompt: str) -> str:
87
+ """Merge task.md + prompt into best task description for repack."""
88
+ task_md = _load_task_md(root)
89
+ if task_md:
90
+ return task_md
91
+ if prompt and _looks_like_coding_prompt(prompt):
92
+ return prompt[:200].strip()
93
+ return "auto"
94
+
95
+
96
+ def _load_hints(root: Path, n: int = 5) -> list[dict]:
97
+ """Return top-n selected_hints (path + why) from last metrics record."""
98
+ metrics_path = root / ".agentpack" / "metrics.jsonl"
99
+ if not metrics_path.exists():
100
+ return []
101
+ try:
102
+ lines = metrics_path.read_text(encoding="utf-8").splitlines()
103
+ for line in reversed(lines):
104
+ line = line.strip()
105
+ if not line:
106
+ continue
107
+ rec = json.loads(line)
108
+ hints = rec.get("selected_hints", [])
109
+ if hints:
110
+ return hints[:n]
111
+ # Fallback: old metrics without hints
112
+ paths = rec.get("selected_paths", [])
113
+ if paths:
114
+ return [{"path": p, "why": ""} for p in paths[:n]]
115
+ except Exception:
116
+ pass
117
+ return []
118
+
119
+
120
+ def _load_top_files(root: Path, n: int = 5) -> list[dict]:
121
+ """Alias kept for backward compat with tests."""
122
+ return _load_hints(root, n)
123
+
124
+
125
+ def _load_pack_task(root: Path) -> str:
126
+ meta_path = root / ".agentpack" / "pack_metadata.json"
127
+ if not meta_path.exists():
128
+ return ""
129
+ try:
130
+ return json.loads(meta_path.read_text()).get("task", "")
131
+ except Exception:
132
+ return ""
133
+
134
+
135
+ def _infer_live_task(root: Path) -> str:
136
+ """Live task: git priority chain (no stale metadata). Falls back to 'unknown'."""
137
+ try:
138
+ task, _ = _git.infer_task_with_source(root)
139
+ return task
140
+ except Exception:
141
+ return "unknown"
142
+
143
+
144
+ def _current_root_hash(root: Path) -> str | None:
145
+ snap = root / ".agentpack" / "snapshots" / "latest.json"
146
+ if not snap.exists():
147
+ return None
148
+ try:
149
+ return json.loads(snap.read_text()).get("root_hash")
150
+ except Exception:
151
+ return None
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Event handlers
156
+ # ---------------------------------------------------------------------------
157
+
158
+ def _run_session_start(root: Path) -> None:
159
+ """Clear sentinels so first prompt gets fresh context."""
160
+ for sentinel in [
161
+ root / ".agentpack" / ".mcp_reminded",
162
+ root / ".agentpack" / ".context_injected",
163
+ ]:
164
+ try:
165
+ sentinel.unlink(missing_ok=True)
166
+ except Exception:
167
+ pass
168
+ # No output needed — SessionStart hooks don't inject additionalContext
169
+
170
+
171
+ def _run_user_prompt_submit(root: Path) -> None:
172
+ snap_sentinel = root / ".agentpack" / ".mcp_reminded"
173
+
174
+ try:
175
+ hook_data = json.loads(sys.stdin.read())
176
+ prompt = hook_data.get("prompt", "")
177
+ except Exception:
178
+ prompt = ""
179
+
180
+ task = _resolve_task(root, prompt)
181
+
182
+ current_hash = _current_root_hash(root)
183
+ reminded_hash = snap_sentinel.read_text().strip() if snap_sentinel.exists() else None
184
+ repo_changed = current_hash != reminded_hash
185
+
186
+ if repo_changed:
187
+ subprocess.Popen(
188
+ ["agentpack", "pack", "--task", task, "--mode", "balanced", "--since", "HEAD~1"],
189
+ stdout=subprocess.DEVNULL,
190
+ stderr=subprocess.DEVNULL,
191
+ )
192
+ try:
193
+ snap_sentinel.write_text(current_hash or "1")
194
+ except Exception:
195
+ pass
196
+
197
+ has_mcp = _mcp_installed(root)
198
+
199
+ if has_mcp:
200
+ hints = _load_hints(root, n=5)
201
+ if hints:
202
+ files_lines = "\n".join(
203
+ f" - {h['path']}" + (f" — {h['why']}" if h.get("why") else "")
204
+ for h in hints
205
+ )
206
+ status_note = "(repacking — call pack_context for fresh results)" if repo_changed else "(index fresh)"
207
+ current_task = _load_task_md(root) or _infer_live_task(root)
208
+ msg = (
209
+ f"AgentPack {status_note}\n"
210
+ f"task: {current_task}\n"
211
+ f"top files:\n{files_lines}\n"
212
+ f"Call agentpack_pack_context(task=\"...\") for full ranked context."
213
+ )
214
+ else:
215
+ msg = (
216
+ "AgentPack active. No pack yet — call agentpack_pack_context(task=\"...\") "
217
+ "to build context for this task."
218
+ )
219
+ else:
220
+ hints = _load_hints(root, n=8)
221
+ current_task = _load_task_md(root) or _infer_live_task(root)
222
+ if hints:
223
+ files_lines = "\n".join(
224
+ f" - {h['path']}" + (f" — {h['why']}" if h.get("why") else "")
225
+ for h in hints
226
+ )
227
+ changed_note = " (repacking in background)" if repo_changed else ""
228
+ msg = (
229
+ f"AgentPack context{changed_note}\n"
230
+ f"task: {current_task}\n"
231
+ f"top files:\n{files_lines}\n\n"
232
+ f"For richer context, install MCP: agentpack install --agent claude"
233
+ )
234
+ else:
235
+ msg = (
236
+ "AgentPack active. Run `agentpack pack --task \"<task>\"` to build context.\n"
237
+ "For auto context, install MCP: agentpack install --agent claude"
238
+ )
239
+
240
+ if len(msg) > 3000:
241
+ msg = msg[:2970] + "\n... [truncated]"
242
+
243
+ print(json.dumps({
244
+ "hookSpecificOutput": {
245
+ "hookEventName": "UserPromptSubmit",
246
+ "additionalContext": msg,
247
+ }
248
+ }))
@@ -64,8 +64,19 @@ def _resolve_agent(agent: str) -> str:
64
64
  def _resolve_task(task: str) -> str:
65
65
  if task != "auto":
66
66
  return task
67
- inferred = git.infer_task_from_git(_root())
68
- console.print(f"[dim]Auto task: {inferred}[/]")
67
+ root = _root()
68
+ # task.md takes priority over all git heuristics
69
+ task_md_path = root / ".agentpack" / "task.md"
70
+ if task_md_path.exists():
71
+ content = task_md_path.read_text(encoding="utf-8").strip()
72
+ lines = [ln for ln in content.splitlines() if ln.strip() and not ln.startswith("#")]
73
+ body = lines[0].strip() if lines else ""
74
+ _PLACEHOLDER = "Write or update the current coding task here."
75
+ if body and _PLACEHOLDER not in body:
76
+ console.print(f"[dim]Auto task (task.md): {body}[/]")
77
+ return body
78
+ inferred, source = git.infer_task_with_source(root)
79
+ console.print(f"[dim]Auto task ({source}): {inferred}[/]")
69
80
  return inferred
70
81
 
71
82
 
@@ -94,12 +94,44 @@ def file_churn_counts(root: Path, max_commits: int = 200) -> dict[str, int]:
94
94
  return counts
95
95
 
96
96
 
97
- def infer_task_from_git(root: Path) -> str:
98
- """Infer a task description from branch name, changed files, and recent commits.
99
-
100
- Priority: branch name + changed files → branch name → changed files →
101
- recent commit messages (up to 3) → recently modified files → fallback.
97
+ def staged_files(root: Path) -> set[str]:
98
+ """Files staged for commit (git index only)."""
99
+ out = _run(["git", "diff", "--cached", "--name-only"], root)
100
+ if not out:
101
+ return set()
102
+ return {line.strip() for line in out.splitlines() if line.strip()}
103
+
104
+
105
+ def infer_task_with_source(root: Path) -> tuple[str, str]:
106
+ """Infer task description with the heuristic that fired.
107
+
108
+ Priority (strongest → weakest):
109
+ branch+staged staged files present + branch name
110
+ staged staged files, no branch
111
+ branch+unstaged unstaged changes + branch name
112
+ branch+commit branch + latest commit message
113
+ branch branch name alone
114
+ unstaged unstaged changes, no branch
115
+ commits recent commit messages
116
+ recently_modified git log history (noisy — last resort)
117
+ fallback "general development"
102
118
  """
119
+ if not is_git_repo(root):
120
+ return "general development", "fallback"
121
+
122
+ staged = staged_files(root)
123
+
124
+ unstaged_out = _run(["git", "diff", "--name-only"], root)
125
+ unstaged: set[str] = set()
126
+ if unstaged_out:
127
+ for line in unstaged_out.splitlines():
128
+ line = line.strip()
129
+ if line:
130
+ unstaged.add(line)
131
+
132
+ staged_topic = _topic_from_paths(staged) if staged else None
133
+ unstaged_topic = _topic_from_paths(unstaged) if unstaged else None
134
+
103
135
  branch: str | None = None
104
136
  branch_out = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], root)
105
137
  if branch_out:
@@ -108,11 +140,6 @@ def infer_task_from_git(root: Path) -> str:
108
140
  slug = b.split("/", 1)[-1]
109
141
  branch = slug.replace("-", " ").replace("_", " ")
110
142
 
111
- # Changed files are the strongest signal for *current* work
112
- changed = changed_files(root)
113
- file_topic = _topic_from_paths(changed) if changed else None
114
-
115
- # Collect recent non-merge commit messages (up to 3) for richer fallback
116
143
  commit_msgs: list[str] = []
117
144
  log_out = _run(["git", "log", "--oneline", "-10"], root)
118
145
  if log_out:
@@ -126,26 +153,36 @@ def infer_task_from_git(root: Path) -> str:
126
153
  if len(commit_msgs) == 3:
127
154
  break
128
155
 
129
- # When branch is clean (no changed files), fall back to recently touched files
130
- # so keyword scoring has something to work with beyond the branch name alone.
131
- if not file_topic and not branch:
132
- recent = recently_modified_files(root, n=10)
133
- file_topic = _topic_from_paths(set(recent)) if recent else None
134
-
135
- if branch and file_topic:
136
- return f"{branch}: {file_topic}"
156
+ if branch and staged_topic:
157
+ return f"{branch}: {staged_topic}", "branch+staged"
158
+ if staged_topic:
159
+ return staged_topic, "staged"
160
+ if branch and unstaged_topic:
161
+ return f"{branch}: {unstaged_topic}", "branch+unstaged"
137
162
  if branch and commit_msgs:
138
- # Augment bare branch name with latest commit context
139
- return f"{branch}: {commit_msgs[0]}"
163
+ return f"{branch}: {commit_msgs[0]}", "branch+commit"
140
164
  if branch:
141
- return branch
142
- if file_topic and commit_msgs:
143
- return f"{file_topic}: {commit_msgs[0]}"
144
- if file_topic:
145
- return file_topic
165
+ return branch, "branch"
166
+ if unstaged_topic and commit_msgs:
167
+ return f"{unstaged_topic}: {commit_msgs[0]}", "unstaged+commit"
168
+ if unstaged_topic:
169
+ return unstaged_topic, "unstaged"
146
170
  if commit_msgs:
147
- return "; ".join(commit_msgs[:2])
148
- return "general development"
171
+ return "; ".join(commit_msgs[:2]), "commits"
172
+
173
+ # Last resort: historical git log — only fires when no live signal found
174
+ recent = recently_modified_files(root, n=10)
175
+ recent_topic = _topic_from_paths(set(recent)) if recent else None
176
+ if recent_topic:
177
+ return recent_topic, "recently_modified"
178
+
179
+ return "general development", "fallback"
180
+
181
+
182
+ def infer_task_from_git(root: Path) -> str:
183
+ """Infer a task description from git state. See infer_task_with_source for priority chain."""
184
+ task, _ = infer_task_with_source(root)
185
+ return task
149
186
 
150
187
 
151
188
  def _topic_from_paths(paths: set[str]) -> str | None:
@@ -77,70 +77,41 @@ class ClaudeInstaller:
77
77
 
78
78
  hooks = existing.setdefault("hooks", {})
79
79
 
80
- # SessionStart: delete sentinel + kick off background repack so first prompt
81
- # gets fresh context without blocking the session.
82
- # Use session refresh if session exists (respects task.md), else fall back to pack.
83
- sentinel_cmd = (
84
- "rm -f .agentpack/.context_injected .agentpack/.mcp_reminded"
85
- " && ([ -f .agentpack/session.json ]"
86
- " && agentpack session refresh >/dev/null 2>&1"
87
- " || agentpack pack --task auto --mode balanced >/dev/null 2>&1) &"
88
- )
80
+ # SessionStart: delegate to `agentpack hook` CLI subcommand.
81
+ # Clears sentinels so first UserPromptSubmit gets fresh context.
82
+ session_hook_cmd = "agentpack hook --event SessionStart"
89
83
  session_start = hooks.setdefault("SessionStart", [])
90
- # Replace any stale agentpack session hooks (old cmd only deleted sentinel).
84
+ # Remove stale agentpack session hooks (old rm -f / session refresh shell commands).
85
+ def _is_stale_session_hook(cmd: str) -> bool:
86
+ return (
87
+ ".context_injected" in cmd and "rm -f" in cmd
88
+ ) or "agentpack session refresh" in cmd
91
89
  for entry in session_start:
92
90
  entry["hooks"] = [
93
91
  h for h in entry.get("hooks", [])
94
- if not (".context_injected" in h.get("command", "") and "rm -f" in h.get("command", ""))
92
+ if not _is_stale_session_hook(h.get("command", ""))
95
93
  ]
96
94
  session_start[:] = [e for e in session_start if e.get("hooks")]
97
95
  already_has_session_hook = any(
98
- any(h.get("command", "") == sentinel_cmd for h in entry.get("hooks", []))
96
+ any(h.get("command", "") == session_hook_cmd for h in entry.get("hooks", []))
99
97
  for entry in session_start
100
98
  )
101
99
  if not already_has_session_hook:
102
- session_start.append({"hooks": [{"type": "command", "command": sentinel_cmd}]})
103
-
104
- # UserPromptSubmit: tiny MCP reminder no context injection, no file reads.
105
- # MCP server handles actual context retrieval on demand (pull-based).
106
- # Background repack keeps the index fresh for MCP queries, using the
107
- # user's prompt as the task so keyword scoring matches current work.
108
- # root_hash (not md5 of whole file) avoids false "changed" on created_at churn.
109
- mcp_reminder_cmd = (
110
- "python3 -c \"\n"
111
- "import json, pathlib, subprocess, sys\n"
112
- "snap = pathlib.Path('.agentpack/snapshots/latest.json')\n"
113
- "sentinel = pathlib.Path('.agentpack/.mcp_reminded')\n"
114
- "try:\n"
115
- " current_hash = json.loads(snap.read_text()).get('root_hash') if snap.exists() else None\n"
116
- "except Exception:\n"
117
- " current_hash = None\n"
118
- "reminded_hash = sentinel.read_text().strip() if sentinel.exists() else None\n"
119
- "try:\n"
120
- " hook_data = json.loads(sys.stdin.read())\n"
121
- " prompt = hook_data.get('prompt', '')\n"
122
- "except Exception:\n"
123
- " prompt = ''\n"
124
- "task = (prompt[:200].strip() or 'auto') if prompt else 'auto'\n"
125
- # Background repack when repo changed since last pack.
126
- "if current_hash != reminded_hash:\n"
127
- " subprocess.Popen(['agentpack', 'pack', '--task', task, '--mode', 'balanced'],\n"
128
- " stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n"
129
- " sentinel.write_text(current_hash or '1')\n"
130
- " msg = 'AgentPack: repo changed — repacking index. Call agentpack_pack_context(task=\\\"...\\\") for fresh context.'\n"
131
- "else:\n"
132
- " msg = 'AgentPack MCP ready. Call agentpack_pack_context(task=\\\"...\\\") before editing files.'\n"
133
- "print(json.dumps({'hookSpecificOutput': {'hookEventName': 'UserPromptSubmit',\n"
134
- " 'additionalContext': msg}}))\n"
135
- "\""
136
- )
100
+ session_start.append({"hooks": [{"type": "command", "command": session_hook_cmd}]})
101
+
102
+ # UserPromptSubmit: delegate to `agentpack hook` CLI subcommand.
103
+ # - Reads prompt from stdin, uses it as pack task keyword.
104
+ # - With MCP: emits Option-B hint (task + top files list, ~100 tokens).
105
+ # - Without MCP: emits capped fallback (top files, hard cap 3k chars).
106
+ # - Background repacks when root_hash changes (content-addressed, not mtime).
107
+ hook_cmd = "agentpack hook --event UserPromptSubmit"
137
108
  user_prompt = hooks.setdefault("UserPromptSubmit", [])
138
- # Remove stale agentpack hooks (old injection hooks and old MCP reminder versions).
109
+ # Remove stale agentpack hooks (old injection hooks, old inline MCP reminder).
139
110
  def _is_stale_agentpack_hook(cmd: str) -> bool:
140
111
  return (
141
112
  "context.claude.md" in cmd
142
113
  or ".context_injected" in cmd
143
- or (".mcp_reminded" in cmd and "hashlib" in cmd) # old md5-based reminder
114
+ or (".mcp_reminded" in cmd and "python3" in cmd) # old inline python hooks
144
115
  )
145
116
  for entry in user_prompt:
146
117
  entry["hooks"] = [
@@ -149,14 +120,14 @@ class ClaudeInstaller:
149
120
  ]
150
121
  user_prompt[:] = [e for e in user_prompt if e.get("hooks")]
151
122
  already_has_prompt_hook = any(
152
- any(h.get("command", "") == mcp_reminder_cmd for h in entry.get("hooks", []))
123
+ any(h.get("command", "") == hook_cmd for h in entry.get("hooks", []))
153
124
  for entry in user_prompt
154
125
  )
155
126
  if not already_has_prompt_hook:
156
127
  user_prompt.append({
157
128
  "hooks": [{
158
129
  "type": "command",
159
- "command": mcp_reminder_cmd,
130
+ "command": hook_cmd,
160
131
  "timeout": 5,
161
132
  "statusMessage": "Checking agentpack index...",
162
133
  }]
File without changes