blitz-cli 0.15.0__tar.gz → 0.16.1__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 (136) hide show
  1. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/.gitignore +7 -0
  2. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/PKG-INFO +1 -1
  3. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/__init__.py +1 -1
  4. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_activity.py +30 -0
  5. blitz_cli-0.16.1/blitz_cli/_corpus.py +542 -0
  6. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_gitignore.py +19 -6
  7. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/cli.py +23 -0
  8. {blitz_cli-0.15.0 → blitz_cli-0.16.1/blitz_cli/playbooks/scaffold}/.blitz/scripts/bash/check-prerequisites.sh +0 -0
  9. {blitz_cli-0.15.0 → blitz_cli-0.16.1/blitz_cli/playbooks/scaffold}/.blitz/scripts/bash/common.sh +0 -0
  10. {blitz_cli-0.15.0 → blitz_cli-0.16.1/blitz_cli/playbooks/scaffold}/.blitz/scripts/bash/create-new-feature.sh +0 -0
  11. {blitz_cli-0.15.0 → blitz_cli-0.16.1/blitz_cli/playbooks/scaffold}/.blitz/scripts/bash/setup-plan.sh +0 -0
  12. {blitz_cli-0.15.0 → blitz_cli-0.16.1/blitz_cli/playbooks/scaffold}/.blitz/scripts/bash/setup-tasks.sh +0 -0
  13. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/templates/locker_room_compose.yml.tmpl +2 -0
  14. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/pyproject.toml +1 -1
  15. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/003-init-gitignore-rules/contracts/ensure-rules.md +8 -0
  16. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/003-init-gitignore-rules/contracts/gitignore-block.md +11 -0
  17. blitz_cli-0.16.1/specs/006-init-ignore-generated/checklists/requirements.md +38 -0
  18. blitz_cli-0.16.1/specs/006-init-ignore-generated/contracts/gitignore-block.md +94 -0
  19. blitz_cli-0.16.1/specs/006-init-ignore-generated/data-model.md +68 -0
  20. blitz_cli-0.16.1/specs/006-init-ignore-generated/plan.md +144 -0
  21. blitz_cli-0.16.1/specs/006-init-ignore-generated/quickstart.md +160 -0
  22. blitz_cli-0.16.1/specs/006-init-ignore-generated/research.md +68 -0
  23. blitz_cli-0.16.1/specs/006-init-ignore-generated/spec.md +224 -0
  24. blitz_cli-0.16.1/specs/006-init-ignore-generated/tasks.md +277 -0
  25. blitz_cli-0.16.1/specs/007-usage-data-layer/checklists/requirements.md +38 -0
  26. blitz_cli-0.16.1/specs/007-usage-data-layer/contracts/activity-db-v2.md +93 -0
  27. blitz_cli-0.16.1/specs/007-usage-data-layer/data-model.md +81 -0
  28. blitz_cli-0.16.1/specs/007-usage-data-layer/plan.md +90 -0
  29. blitz_cli-0.16.1/specs/007-usage-data-layer/quickstart.md +109 -0
  30. blitz_cli-0.16.1/specs/007-usage-data-layer/research.md +182 -0
  31. blitz_cli-0.16.1/specs/007-usage-data-layer/spec.md +151 -0
  32. blitz_cli-0.16.1/specs/007-usage-data-layer/tasks.md +161 -0
  33. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_activity.py +76 -0
  34. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_cli.py +53 -0
  35. blitz_cli-0.16.1/tests/test_corpus.py +847 -0
  36. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_gitignore.py +58 -7
  37. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_scaffold.py +15 -0
  38. blitz_cli-0.15.0/.blitz/templates/plan-template.md +0 -113
  39. blitz_cli-0.15.0/.blitz/templates/tasks-template.md +0 -252
  40. blitz_cli-0.15.0/blitz_cli/_corpus.py +0 -260
  41. blitz_cli-0.15.0/blitz_cli/playbooks/scaffold/.blitz/scripts/bash/check-prerequisites.sh +0 -195
  42. blitz_cli-0.15.0/blitz_cli/playbooks/scaffold/.blitz/scripts/bash/common.sh +0 -692
  43. blitz_cli-0.15.0/blitz_cli/playbooks/scaffold/.blitz/scripts/bash/create-new-feature.sh +0 -301
  44. blitz_cli-0.15.0/blitz_cli/playbooks/scaffold/.blitz/scripts/bash/setup-plan.sh +0 -83
  45. blitz_cli-0.15.0/blitz_cli/playbooks/scaffold/.blitz/scripts/bash/setup-tasks.sh +0 -91
  46. blitz_cli-0.15.0/blitz_cli/playbooks/scaffold/.blitz/templates/spec-template.md +0 -131
  47. blitz_cli-0.15.0/tests/test_corpus.py +0 -350
  48. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/.blitz/memory/playbook.md +0 -0
  49. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/.github/workflows/homebrew.yml +0 -0
  50. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/.github/workflows/publish.yml +0 -0
  51. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/.mcp.json +0 -0
  52. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/LICENSE +0 -0
  53. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/README.md +0 -0
  54. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_code_graph.py +0 -0
  55. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_collect.py +0 -0
  56. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_collect_http.py +0 -0
  57. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_collect_server.py +0 -0
  58. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_dataset.py +0 -0
  59. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_execute.py +0 -0
  60. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_git.py +0 -0
  61. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_make.py +0 -0
  62. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_ontology.py +0 -0
  63. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_playbooks.py +0 -0
  64. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_redact.py +0 -0
  65. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_scaffold.py +0 -0
  66. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_tool_schemas.py +0 -0
  67. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_train.py +0 -0
  68. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/__init__.py +0 -0
  69. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/commands/__init__.py +0 -0
  70. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/commands/analyze.md +0 -0
  71. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/commands/clarify.md +0 -0
  72. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/commands/implement.md +0 -0
  73. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/commands/plan.md +0 -0
  74. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/commands/playbook.md +0 -0
  75. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/commands/specify.md +0 -0
  76. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/commands/tasks.md +0 -0
  77. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/scaffold/.blitz/memory/playbook.md +0 -0
  78. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/scaffold/.blitz/templates/plan-template.md +0 -0
  79. {blitz_cli-0.15.0 → blitz_cli-0.16.1/blitz_cli/playbooks/scaffold}/.blitz/templates/spec-template.md +0 -0
  80. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/scaffold/.blitz/templates/tasks-template.md +0 -0
  81. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/workspace/__init__.py +0 -0
  82. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/workspace/huddle.md +0 -0
  83. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/templates/__init__.py +0 -0
  84. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/templates/locker_room_Makefile.tmpl +0 -0
  85. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/templates/locker_room_README.md.tmpl +0 -0
  86. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/templates/workspace_playbook.md.tmpl +0 -0
  87. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/001-cli-model-namespace/checklists/requirements.md +0 -0
  88. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/001-cli-model-namespace/contracts/cli-commands.md +0 -0
  89. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/001-cli-model-namespace/plan.md +0 -0
  90. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/001-cli-model-namespace/quickstart.md +0 -0
  91. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/001-cli-model-namespace/research.md +0 -0
  92. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/001-cli-model-namespace/spec.md +0 -0
  93. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/001-cli-model-namespace/tasks.md +0 -0
  94. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/002-label-experimental-surfaces/checklists/requirements.md +0 -0
  95. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/002-label-experimental-surfaces/contracts/experimental-labels.md +0 -0
  96. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/002-label-experimental-surfaces/data-model.md +0 -0
  97. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/002-label-experimental-surfaces/plan.md +0 -0
  98. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/002-label-experimental-surfaces/quickstart.md +0 -0
  99. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/002-label-experimental-surfaces/research.md +0 -0
  100. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/002-label-experimental-surfaces/spec.md +0 -0
  101. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/002-label-experimental-surfaces/tasks.md +0 -0
  102. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/003-init-gitignore-rules/checklists/requirements.md +0 -0
  103. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/003-init-gitignore-rules/contracts/cli-output.md +0 -0
  104. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/003-init-gitignore-rules/data-model.md +0 -0
  105. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/003-init-gitignore-rules/plan.md +0 -0
  106. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/003-init-gitignore-rules/quickstart.md +0 -0
  107. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/003-init-gitignore-rules/research.md +0 -0
  108. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/003-init-gitignore-rules/spec.md +0 -0
  109. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/003-init-gitignore-rules/tasks.md +0 -0
  110. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/004-dpo-collect-pairs/checklists/requirements.md +0 -0
  111. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/004-dpo-collect-pairs/contracts/cli.md +0 -0
  112. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/004-dpo-collect-pairs/contracts/export-format.md +0 -0
  113. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/004-dpo-collect-pairs/data-model.md +0 -0
  114. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/004-dpo-collect-pairs/plan.md +0 -0
  115. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/004-dpo-collect-pairs/quickstart.md +0 -0
  116. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/004-dpo-collect-pairs/research.md +0 -0
  117. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/004-dpo-collect-pairs/spec.md +0 -0
  118. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/004-dpo-collect-pairs/tasks.md +0 -0
  119. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/005-collect-ingest-endpoint/checklists/requirements.md +0 -0
  120. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/005-collect-ingest-endpoint/contracts/client-sink.md +0 -0
  121. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/005-collect-ingest-endpoint/contracts/ingest-api.md +0 -0
  122. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/005-collect-ingest-endpoint/data-model.md +0 -0
  123. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/005-collect-ingest-endpoint/plan.md +0 -0
  124. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/005-collect-ingest-endpoint/quickstart.md +0 -0
  125. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/005-collect-ingest-endpoint/research.md +0 -0
  126. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/005-collect-ingest-endpoint/spec.md +0 -0
  127. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/005-collect-ingest-endpoint/tasks.md +0 -0
  128. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_collect.py +0 -0
  129. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_collect_http.py +0 -0
  130. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_collect_server.py +0 -0
  131. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_execute.py +0 -0
  132. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_make.py +0 -0
  133. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_ontology.py +0 -0
  134. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_playbooks.py +0 -0
  135. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_redact.py +0 -0
  136. {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_tool_schemas.py +0 -0
@@ -21,3 +21,10 @@ dist/
21
21
  # the blitz.toml, which is not always the repo root. The `*` covers the SQLite
22
22
  # -wal/-shm sidecars.
23
23
  **/.blitz/traces.db*
24
+
25
+ # Regenerable `blitz init` output: scripts/ and templates/ are refreshed from the
26
+ # package on --force, so a clone reproduces them exactly by running init.
27
+ # memory/playbook.md is deliberately NOT here: it is seeded once and never
28
+ # overwritten, so it stays tracked.
29
+ /.blitz/scripts/
30
+ /.blitz/templates/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blitz-cli
3
- Version: 0.15.0
3
+ Version: 0.16.1
4
4
  Summary: Developer CLI for Blitz locker-room: spec-driven playbooks, a codebase/huddle ontology graph with an MCP tool, and the composable watcher/trainer/graph containers.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -30,4 +30,4 @@ Commands:
30
30
 
31
31
  __all__ = ["__version__"]
32
32
 
33
- __version__ = "0.15.0"
33
+ __version__ = "0.16.0"
@@ -21,6 +21,9 @@ from pathlib import Path
21
21
  from typing import Optional
22
22
 
23
23
  _SYSTEM_REMINDER_RE = re.compile(r"<system-reminder>.*?</system-reminder>\s*", re.DOTALL)
24
+ # Slash-command invocations recorded inside the raw user prompt, e.g.
25
+ # <command-name>/huddle</command-name>. Matched BEFORE _clean_prompt() runs.
26
+ _COMMAND_NAME_RE = re.compile(r"<command-name>\s*(/?[\w:-]+)\s*</command-name>")
24
27
  # Compact, single-line caps for the terminal's quick-glance labels.
25
28
  _MAX_SUMMARY_LEN = 80
26
29
  _MAX_SNIPPET_LEN = 200
@@ -57,6 +60,9 @@ class Turn:
57
60
  cache_creation_tokens: int = 0
58
61
  models: list = field(default_factory=list)
59
62
  turn_uuid: Optional[str] = None
63
+ commands: list = field(default_factory=list)
64
+ did_git_push: bool = False
65
+ did_create_pr: bool = False
60
66
 
61
67
 
62
68
  @dataclass
@@ -198,6 +204,18 @@ def _prompt_text(content) -> Optional[str]:
198
204
  return None
199
205
 
200
206
 
207
+ def _raw_prompt_text(content) -> str:
208
+ """The user record's prompt text with nothing stripped, for extraction
209
+ of markers (like <command-name> blocks) that _clean_prompt may remove."""
210
+ if isinstance(content, str):
211
+ return content
212
+ if isinstance(content, list):
213
+ return "\n".join(
214
+ b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"
215
+ )
216
+ return ""
217
+
218
+
201
219
  def _truncate(text: str, limit: int) -> str:
202
220
  """Collapse whitespace to one line and cap length — for compact labels."""
203
221
  text = " ".join(text.split())
@@ -520,6 +538,12 @@ def parse_session(path: Path, file_history_dir: Optional[Path] = None) -> Sessio
520
538
  if prompt is not None:
521
539
  _finalize()
522
540
  current = Turn(timestamp=rec.get("timestamp"), prompt=prompt, turn_uuid=rec.get("uuid"))
541
+ # Slash commands are extracted from the RAW prompt text,
542
+ # before _clean_prompt strips anything (research R1).
543
+ current.commands = [
544
+ name if name.startswith("/") else f"/{name}"
545
+ for name in _COMMAND_NAME_RE.findall(_raw_prompt_text(content))
546
+ ]
523
547
  continue
524
548
  # tool-result line: match each block back to its tool_use action
525
549
  if isinstance(content, list):
@@ -574,6 +598,12 @@ def parse_session(path: Path, file_history_dir: Optional[Path] = None) -> Sessio
574
598
  file_history_dir, session_id, tool_name, tool_input or {},
575
599
  file_path, backup_info["backupFileName"],
576
600
  )
601
+ if tool_name == "Bash":
602
+ command = (tool_input or {}).get("command") or ""
603
+ if "git push" in command:
604
+ current.did_git_push = True
605
+ if "gh pr create" in command:
606
+ current.did_create_pr = True
577
607
  current.actions.append(action)
578
608
  if block.get("id"):
579
609
  pending_tool_uses[block["id"]] = action
@@ -0,0 +1,542 @@
1
+ """Convert Claude Code activity (parsed by `_activity.py`) into a fine-tuning
2
+ corpus stored in SQLite, for the `blitz locker-room watch`
3
+ container.
4
+
5
+ This is a wholly separate store from `_local.py`'s `spans` table —
6
+ `_dataset.py:span_to_example()` explicitly rejects any tool-call-shaped span
7
+ and only extracts plain-string message content, so it's the wrong shape for
8
+ agentic Claude Code trajectories. `turns` here holds one row per Turn (an
9
+ OpenAI/Qwen-chat-style `{"messages": [...]}` example using Claude Code's own
10
+ native tool names), and `raw_sessions` holds a redacted archival copy of each
11
+ session's full transcript — kept independent of Claude Code's own
12
+ `cleanupPeriodDays` auto-deletion (default 30 days), and independent of our
13
+ own lossy `turns` conversion, so a better conversion can be re-derived later
14
+ without needing the original (possibly already-deleted) source transcript.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import sqlite3
21
+ import tempfile
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from typing import Optional
25
+
26
+ from blitz_cli import _activity, _redact
27
+
28
+ _SCHEMA = """
29
+ CREATE TABLE IF NOT EXISTS turns (
30
+ turn_id TEXT PRIMARY KEY,
31
+ session_id TEXT NOT NULL,
32
+ project_path TEXT,
33
+ turn_uuid TEXT,
34
+ turn_ordinal INTEGER,
35
+ started_at TEXT,
36
+ duration_ms INTEGER,
37
+ models TEXT,
38
+ input_tokens INTEGER,
39
+ output_tokens INTEGER,
40
+ cache_read_tokens INTEGER,
41
+ cache_creation_tokens INTEGER,
42
+ action_count INTEGER,
43
+ had_error INTEGER,
44
+ messages_json TEXT NOT NULL,
45
+ ingested_at TEXT NOT NULL,
46
+ source_mtime REAL NOT NULL,
47
+ commands_json TEXT,
48
+ did_git_push INTEGER,
49
+ did_create_pr INTEGER
50
+ );
51
+ CREATE INDEX IF NOT EXISTS idx_turns_session_id ON turns(session_id);
52
+ CREATE INDEX IF NOT EXISTS idx_turns_started_at ON turns(started_at);
53
+ CREATE INDEX IF NOT EXISTS idx_turns_project_path ON turns(project_path);
54
+
55
+ CREATE TABLE IF NOT EXISTS ingest_state (
56
+ session_path TEXT PRIMARY KEY,
57
+ last_mtime REAL NOT NULL,
58
+ last_ingested_at TEXT NOT NULL
59
+ );
60
+
61
+ CREATE TABLE IF NOT EXISTS raw_sessions (
62
+ session_id TEXT PRIMARY KEY,
63
+ project_path TEXT,
64
+ raw_content TEXT NOT NULL,
65
+ source_mtime REAL NOT NULL,
66
+ ingested_at TEXT NOT NULL
67
+ );
68
+ CREATE INDEX IF NOT EXISTS idx_raw_sessions_project_path ON raw_sessions(project_path);
69
+
70
+ CREATE TABLE IF NOT EXISTS local_usage (
71
+ run_id TEXT PRIMARY KEY,
72
+ repo_path TEXT,
73
+ spec TEXT,
74
+ model TEXT,
75
+ input_tokens INTEGER,
76
+ output_tokens INTEGER,
77
+ started_at TEXT,
78
+ source_path TEXT,
79
+ ingested_at TEXT
80
+ );
81
+ CREATE INDEX IF NOT EXISTS idx_local_usage_started_at ON local_usage(started_at);
82
+ """
83
+
84
+ # Additive v2 columns on `turns`, applied by ALTER TABLE when opening a db
85
+ # created before they existed (CREATE TABLE IF NOT EXISTS never adds columns).
86
+ _TURNS_V2_COLUMNS = (
87
+ ("commands_json", "TEXT"),
88
+ ("did_git_push", "INTEGER"),
89
+ ("did_create_pr", "INTEGER"),
90
+ )
91
+
92
+
93
+ def _now_iso() -> str:
94
+ return datetime.now(timezone.utc).isoformat()
95
+
96
+
97
+ def turn_to_messages(turn) -> Optional[dict]:
98
+ """Turn -> {"messages": [...]} training example: a user message
99
+ (turn.prompt), one assistant message carrying every action's tool_calls
100
+ (native Claude Code tool names — Read/Edit/Write/Bash/... — not remapped),
101
+ one tool-role message per result, then a final assistant text message.
102
+ All text is redacted. Returns None if the turn has no usable content at
103
+ all (empty prompt, no actions, no response — a dangling trailing turn)."""
104
+ if not turn.prompt and not turn.response and not turn.actions:
105
+ return None
106
+
107
+ messages = [{"role": "user", "content": _redact.redact(turn.prompt or "")}]
108
+
109
+ if turn.actions:
110
+ tool_calls = []
111
+ for i, action in enumerate(turn.actions):
112
+ arguments = json.dumps(
113
+ _redact.redact_json_strings(action.tool_input or {}), ensure_ascii=False
114
+ )
115
+ tool_calls.append({
116
+ "id": f"call_{i}",
117
+ "type": "function",
118
+ "function": {"name": action.tool, "arguments": arguments},
119
+ })
120
+ messages.append({"role": "assistant", "content": None, "tool_calls": tool_calls})
121
+ for i, action in enumerate(turn.actions):
122
+ messages.append({
123
+ "role": "tool",
124
+ "tool_call_id": f"call_{i}",
125
+ "content": _redact.redact(action.result_full or ""),
126
+ })
127
+
128
+ if turn.response:
129
+ messages.append({"role": "assistant", "content": _redact.redact(turn.response)})
130
+
131
+ return {"messages": messages}
132
+
133
+
134
+ def turn_to_row(
135
+ session_id: str, project_path: str, ordinal: int, turn, source_mtime: float
136
+ ) -> Optional[dict]:
137
+ """Build the full `turns` table row for one Turn, or None if it has no
138
+ usable content (see turn_to_messages)."""
139
+ example = turn_to_messages(turn)
140
+ if example is None:
141
+ return None
142
+ turn_key = turn.turn_uuid or f"ordinal-{ordinal}"
143
+ return {
144
+ "turn_id": f"{session_id}:{turn_key}",
145
+ "session_id": session_id,
146
+ "project_path": project_path,
147
+ "turn_uuid": turn.turn_uuid,
148
+ "turn_ordinal": ordinal,
149
+ "started_at": turn.timestamp,
150
+ "duration_ms": turn.duration_ms,
151
+ "models": json.dumps(turn.models),
152
+ "input_tokens": turn.input_tokens,
153
+ "output_tokens": turn.output_tokens,
154
+ "cache_read_tokens": turn.cache_read_tokens,
155
+ "cache_creation_tokens": turn.cache_creation_tokens,
156
+ "action_count": len(turn.actions),
157
+ "had_error": 1 if any(a.ok is False for a in turn.actions) else 0,
158
+ "messages_json": json.dumps(example, ensure_ascii=False),
159
+ "ingested_at": _now_iso(),
160
+ "source_mtime": source_mtime,
161
+ "commands_json": json.dumps(turn.commands),
162
+ "did_git_push": 1 if turn.did_git_push else 0,
163
+ "did_create_pr": 1 if turn.did_create_pr else 0,
164
+ }
165
+
166
+
167
+ class TurnStore:
168
+ """Writer for the locker-room activity fine-tuning corpus. Unlike _local.py's
169
+ read-only LocalStore (which assumes an externally-stubbed db and returns
170
+ None if missing), this store owns its schema and creates it on first
171
+ connect — it's the one and only writer, structurally closer to
172
+ serve.py.tmpl's _init_db() than to a CLI-side read path."""
173
+
174
+ def __init__(self, db_path: Path):
175
+ self._db_path = Path(db_path)
176
+ self._schema_ready = False
177
+
178
+ def _connect(self) -> sqlite3.Connection:
179
+ db = str(self._db_path)
180
+ is_uri = db.startswith("file:")
181
+ if not is_uri:
182
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
183
+ conn = sqlite3.connect(db, uri=is_uri)
184
+ conn.row_factory = sqlite3.Row
185
+ if not self._schema_ready:
186
+ try:
187
+ conn.execute("PRAGMA journal_mode=WAL")
188
+ conn.executescript(_SCHEMA)
189
+ existing = {
190
+ row[1] for row in conn.execute("PRAGMA table_info(turns)")
191
+ }
192
+ for name, col_type in _TURNS_V2_COLUMNS:
193
+ if name not in existing:
194
+ conn.execute(f"ALTER TABLE turns ADD COLUMN {name} {col_type}")
195
+ conn.commit()
196
+ except sqlite3.OperationalError:
197
+ # Read-only database: readers need no DDL; proceed without it.
198
+ pass
199
+ self._schema_ready = True
200
+ return conn
201
+
202
+ def get_last_mtime(self, session_path: str) -> Optional[float]:
203
+ conn = self._connect()
204
+ try:
205
+ row = conn.execute(
206
+ "SELECT last_mtime FROM ingest_state WHERE session_path = ?", (session_path,)
207
+ ).fetchone()
208
+ return row["last_mtime"] if row else None
209
+ finally:
210
+ conn.close()
211
+
212
+ def set_last_mtime(self, session_path: str, mtime: float) -> None:
213
+ conn = self._connect()
214
+ try:
215
+ conn.execute(
216
+ "INSERT OR REPLACE INTO ingest_state (session_path, last_mtime, last_ingested_at) "
217
+ "VALUES (?, ?, ?)",
218
+ (session_path, mtime, _now_iso()),
219
+ )
220
+ conn.commit()
221
+ finally:
222
+ conn.close()
223
+
224
+ def upsert_turns(self, rows: list) -> int:
225
+ if not rows:
226
+ return 0
227
+ # NULL in the v2 columns means "not derived" (contract C1); callers
228
+ # predating v2 may omit the keys entirely.
229
+ rows = [
230
+ {"commands_json": None, "did_git_push": None, "did_create_pr": None, **row}
231
+ for row in rows
232
+ ]
233
+ conn = self._connect()
234
+ try:
235
+ conn.executemany(
236
+ "INSERT OR REPLACE INTO turns ("
237
+ "turn_id, session_id, project_path, turn_uuid, turn_ordinal, started_at, "
238
+ "duration_ms, models, input_tokens, output_tokens, cache_read_tokens, "
239
+ "cache_creation_tokens, action_count, had_error, messages_json, ingested_at, "
240
+ "source_mtime, commands_json, did_git_push, did_create_pr"
241
+ ") VALUES ("
242
+ ":turn_id, :session_id, :project_path, :turn_uuid, :turn_ordinal, :started_at, "
243
+ ":duration_ms, :models, :input_tokens, :output_tokens, :cache_read_tokens, "
244
+ ":cache_creation_tokens, :action_count, :had_error, :messages_json, :ingested_at, "
245
+ ":source_mtime, :commands_json, :did_git_push, :did_create_pr"
246
+ ")",
247
+ rows,
248
+ )
249
+ conn.commit()
250
+ return len(rows)
251
+ finally:
252
+ conn.close()
253
+
254
+ def upsert_local_usage(self, rows: list) -> int:
255
+ if not rows:
256
+ return 0
257
+ conn = self._connect()
258
+ try:
259
+ conn.executemany(
260
+ "INSERT OR REPLACE INTO local_usage ("
261
+ "run_id, repo_path, spec, model, input_tokens, output_tokens, "
262
+ "started_at, source_path, ingested_at"
263
+ ") VALUES ("
264
+ ":run_id, :repo_path, :spec, :model, :input_tokens, :output_tokens, "
265
+ ":started_at, :source_path, :ingested_at"
266
+ ")",
267
+ rows,
268
+ )
269
+ conn.commit()
270
+ return len(rows)
271
+ finally:
272
+ conn.close()
273
+
274
+ def usage_summary(self, from_ts: str, to_ts: str, bucket: str = "hour") -> dict:
275
+ """Token usage over time by (bucket, source, model). Window:
276
+ from_ts <= started_at < to_ts (ISO-8601 UTC strings). Frontier rows
277
+ come from `turns` (tokens attributed to the turn's first model),
278
+ local rows from `local_usage`. Returns {"series": [...]} ordered by
279
+ bucket. Safe on a read-only database (SELECT only)."""
280
+ formats = {"hour": "%Y-%m-%dT%H:00:00Z", "day": "%Y-%m-%dT00:00:00Z"}
281
+ if bucket not in formats:
282
+ raise ValueError(f"bucket must be 'hour' or 'day', got {bucket!r}")
283
+ fmt = formats[bucket]
284
+ conn = self._connect()
285
+ try:
286
+ series = []
287
+ frontier = conn.execute(
288
+ "SELECT strftime(?, started_at) AS bucket, "
289
+ "COALESCE(json_extract(models, '$[0]'), '') AS model, "
290
+ "SUM(COALESCE(input_tokens, 0)) AS input_tokens, "
291
+ "SUM(COALESCE(output_tokens, 0)) AS output_tokens "
292
+ "FROM turns WHERE started_at >= ? AND started_at < ? "
293
+ "GROUP BY bucket, model",
294
+ (fmt, from_ts, to_ts),
295
+ ).fetchall()
296
+ for row in frontier:
297
+ series.append({
298
+ "bucket": row["bucket"], "source": "frontier", "model": row["model"],
299
+ "input_tokens": row["input_tokens"], "output_tokens": row["output_tokens"],
300
+ })
301
+ try:
302
+ local = conn.execute(
303
+ "SELECT strftime(?, started_at) AS bucket, "
304
+ "COALESCE(model, '') AS model, "
305
+ "SUM(COALESCE(input_tokens, 0)) AS input_tokens, "
306
+ "SUM(COALESCE(output_tokens, 0)) AS output_tokens "
307
+ "FROM local_usage WHERE started_at >= ? AND started_at < ? "
308
+ "GROUP BY bucket, model",
309
+ (fmt, from_ts, to_ts),
310
+ ).fetchall()
311
+ except sqlite3.OperationalError:
312
+ # Read-only pre-v2 db without local_usage: no local rows.
313
+ local = []
314
+ for row in local:
315
+ series.append({
316
+ "bucket": row["bucket"], "source": "local", "model": row["model"],
317
+ "input_tokens": row["input_tokens"], "output_tokens": row["output_tokens"],
318
+ })
319
+ series.sort(key=lambda s: (s["bucket"], s["source"], s["model"]))
320
+ return {"series": series}
321
+ finally:
322
+ conn.close()
323
+
324
+ def usage_sessions(self, from_ts: str, to_ts: str) -> dict:
325
+ """Per-session aggregates over turns in the window. `models` and
326
+ `commands` are merged across turns, deduped in first-seen order;
327
+ flags are true when any turn set them; NULL commands_json/flags are
328
+ treated as empty/false. Returns {"sessions": [...]} ordered by
329
+ first_ts. Safe on a read-only database (SELECT only)."""
330
+ conn = self._connect()
331
+ try:
332
+ rows = conn.execute(
333
+ "SELECT * FROM turns WHERE started_at >= ? AND started_at < ? "
334
+ "ORDER BY started_at",
335
+ (from_ts, to_ts),
336
+ ).fetchall()
337
+ finally:
338
+ conn.close()
339
+
340
+ sessions: dict = {}
341
+ order = []
342
+ for row in rows:
343
+ keys = row.keys()
344
+ sid = row["session_id"]
345
+ agg = sessions.get(sid)
346
+ if agg is None:
347
+ agg = {
348
+ "session_id": sid,
349
+ "project_path": row["project_path"],
350
+ "first_ts": row["started_at"],
351
+ "last_ts": row["started_at"],
352
+ "input_tokens": 0, "output_tokens": 0,
353
+ "cache_read_tokens": 0, "cache_creation_tokens": 0,
354
+ "models": [], "commands": [],
355
+ "did_git_push": False, "did_create_pr": False,
356
+ "turn_count": 0, "error_count": 0,
357
+ }
358
+ sessions[sid] = agg
359
+ order.append(sid)
360
+ ts = row["started_at"]
361
+ if ts is not None:
362
+ if agg["first_ts"] is None or ts < agg["first_ts"]:
363
+ agg["first_ts"] = ts
364
+ if agg["last_ts"] is None or ts > agg["last_ts"]:
365
+ agg["last_ts"] = ts
366
+ for col in ("input_tokens", "output_tokens", "cache_read_tokens", "cache_creation_tokens"):
367
+ agg[col] += row[col] or 0
368
+ agg["turn_count"] += 1
369
+ agg["error_count"] += 1 if row["had_error"] else 0
370
+ try:
371
+ models = json.loads(row["models"]) if row["models"] else []
372
+ except ValueError:
373
+ models = []
374
+ for m in models:
375
+ if m not in agg["models"]:
376
+ agg["models"].append(m)
377
+ commands_json = row["commands_json"] if "commands_json" in keys else None
378
+ try:
379
+ commands = json.loads(commands_json) if commands_json else []
380
+ except ValueError:
381
+ commands = []
382
+ for c in commands:
383
+ if c not in agg["commands"]:
384
+ agg["commands"].append(c)
385
+ if "did_git_push" in keys and row["did_git_push"]:
386
+ agg["did_git_push"] = True
387
+ if "did_create_pr" in keys and row["did_create_pr"]:
388
+ agg["did_create_pr"] = True
389
+
390
+ result = sorted(
391
+ (sessions[sid] for sid in order),
392
+ key=lambda s: s["first_ts"] or "",
393
+ )
394
+ return {"sessions": result}
395
+
396
+ def upsert_raw_session(
397
+ self, session_id: str, project_path: str, raw_content: str, source_mtime: float
398
+ ) -> None:
399
+ conn = self._connect()
400
+ try:
401
+ conn.execute(
402
+ "INSERT OR REPLACE INTO raw_sessions "
403
+ "(session_id, project_path, raw_content, source_mtime, ingested_at) "
404
+ "VALUES (?, ?, ?, ?, ?)",
405
+ (session_id, project_path, raw_content, source_mtime, _now_iso()),
406
+ )
407
+ conn.commit()
408
+ finally:
409
+ conn.close()
410
+
411
+
412
+ def ingest_once(projects_dir: Path, store: TurnStore, print_fn=print) -> int:
413
+ """Walk every project dir under projects_dir, re-ingest any session whose
414
+ mtime has advanced since the last recorded ingest, and return the number
415
+ of turn rows upserted this pass. A session file's raw (redacted) content
416
+ is archived to `raw_sessions` on the same pass. Never raises on a single
417
+ bad file — logs and continues, since a transcript can be mid-write."""
418
+ total = 0
419
+ if not projects_dir.is_dir():
420
+ return total
421
+ for project_dir in sorted(projects_dir.iterdir()):
422
+ if not project_dir.is_dir():
423
+ continue
424
+ for session_path in sorted(project_dir.glob("*.jsonl")):
425
+ try:
426
+ mtime = session_path.stat().st_mtime
427
+ except OSError:
428
+ continue
429
+ last = store.get_last_mtime(str(session_path))
430
+ if last is not None and mtime <= last:
431
+ continue
432
+ try:
433
+ raw_text = session_path.read_text(encoding="utf-8", errors="replace")
434
+ session = _activity.parse_session(session_path)
435
+ except Exception as exc: # a malformed/mid-write file must never kill the watcher
436
+ print_fn(f"[watch] skip {session_path}: {exc}")
437
+ continue
438
+
439
+ store.upsert_raw_session(
440
+ session.id, project_dir.name, _redact.redact_jsonl_text(raw_text), mtime
441
+ )
442
+ rows = []
443
+ for i, turn in enumerate(session.turns):
444
+ row = turn_to_row(session.id, project_dir.name, i, turn, mtime)
445
+ if row is not None:
446
+ rows.append(row)
447
+ total += store.upsert_turns(rows)
448
+ store.set_last_mtime(str(session_path), mtime)
449
+ return total
450
+
451
+
452
+ def backfill_turn_usage(store: TurnStore, print_fn=print) -> int:
453
+ """Re-derive commands_json/did_git_push/did_create_pr for existing turns
454
+ rows by re-parsing raw_sessions.raw_content with the same parser ingest
455
+ uses. Idempotent (parsing is deterministic). Unmatched turn ids skip
456
+ silently; malformed raw content logs via print_fn and continues. Returns
457
+ the number of rows updated."""
458
+ conn = store._connect()
459
+ try:
460
+ raw_rows = conn.execute("SELECT session_id, raw_content FROM raw_sessions").fetchall()
461
+ updated = 0
462
+ for raw in raw_rows:
463
+ session_id = raw["session_id"]
464
+ try:
465
+ with tempfile.TemporaryDirectory() as tmp_dir:
466
+ session_path = Path(tmp_dir) / f"{session_id}.jsonl"
467
+ session_path.write_text(raw["raw_content"], encoding="utf-8")
468
+ session = _activity.parse_session(session_path)
469
+ except Exception as exc: # malformed archives must not kill the backfill
470
+ print_fn(f"[backfill] skip {session_id}: {exc}")
471
+ continue
472
+ for i, turn in enumerate(session.turns):
473
+ turn_key = turn.turn_uuid or f"ordinal-{i}"
474
+ cursor = conn.execute(
475
+ "UPDATE turns SET commands_json = ?, did_git_push = ?, did_create_pr = ? "
476
+ "WHERE turn_id = ?",
477
+ (
478
+ json.dumps(turn.commands),
479
+ 1 if turn.did_git_push else 0,
480
+ 1 if turn.did_create_pr else 0,
481
+ f"{session_id}:{turn_key}",
482
+ ),
483
+ )
484
+ updated += cursor.rowcount
485
+ conn.commit()
486
+ return updated
487
+ finally:
488
+ conn.close()
489
+
490
+
491
+ def ingest_local_usage(workspace_root: Path, store: TurnStore, print_fn=print) -> int:
492
+ """Scan the workspace's repos (up to three directory levels deep, matching
493
+ huddle discovery) for .blitz/execute/runs/*/usage.json and upsert one
494
+ local_usage row per run, keyed on "<repo>:<run-dir>" so the pass is
495
+ idempotent. Missing root or malformed records never raise. Returns the
496
+ number of rows upserted."""
497
+ workspace_root = Path(workspace_root)
498
+ if not workspace_root.is_dir():
499
+ return 0
500
+ usage_paths = sorted(
501
+ p
502
+ for depth in ("*", "*/*", "*/*/*")
503
+ for p in workspace_root.glob(f"{depth}/.blitz/execute/runs/*/usage.json")
504
+ )
505
+ rows = []
506
+ for usage_path in usage_paths:
507
+ try:
508
+ record = json.loads(usage_path.read_text())
509
+ except (OSError, ValueError) as exc:
510
+ print_fn(f"[local-usage] skip {usage_path}: {exc}")
511
+ continue
512
+ spec = record.get("spec")
513
+ total = record.get("total")
514
+ if not spec or not isinstance(spec, str) or not isinstance(total, dict):
515
+ continue
516
+ run_dir = usage_path.parent
517
+ repo_dir = usage_path.parents[4] # <repo>/.blitz/execute/runs/<run>/usage.json
518
+ try:
519
+ started_local = datetime.strptime(run_dir.name, "%Y%m%d-%H%M%S").astimezone()
520
+ started_utc = started_local.astimezone(timezone.utc)
521
+ except ValueError:
522
+ try:
523
+ mtime = usage_path.stat().st_mtime
524
+ except OSError:
525
+ continue
526
+ started_utc = datetime.fromtimestamp(mtime, timezone.utc)
527
+ # "Z" suffix matches the timestamp format Claude Code transcripts use,
528
+ # so lexicographic window comparisons stay chronological across sources.
529
+ started_at = started_utc.strftime("%Y-%m-%dT%H:%M:%SZ")
530
+ repo_rel = repo_dir.relative_to(workspace_root).as_posix()
531
+ rows.append({
532
+ "run_id": f"{repo_rel}:{run_dir.name}",
533
+ "repo_path": repo_rel,
534
+ "spec": spec,
535
+ "model": record.get("model"),
536
+ "input_tokens": total.get("input", 0),
537
+ "output_tokens": total.get("output", 0),
538
+ "started_at": started_at,
539
+ "source_path": str(usage_path),
540
+ "ingested_at": _now_iso(),
541
+ })
542
+ return store.upsert_local_usage(rows)
@@ -1,10 +1,13 @@
1
1
  """Reconcile an install target's .gitignore with the playbook working area rules.
2
2
 
3
- The .blitz/ directory splits into content that must stay trackable (memory/,
4
- scripts/, templates/) and per-run state that must not be committed (execute/,
5
- feature.json, and the blitz-sdk trace store). This module ensures the rules for
6
- the state half are present in the target's root .gitignore, append-only, and
7
- never touches a rule the user wrote.
3
+ The .blitz/ directory splits into what a clone can reproduce and what it cannot.
4
+ Regenerable output (scripts/, templates/) and per-run state (execute/,
5
+ feature.json, and the blitz-sdk trace store) are ignored. Seeded-once content
6
+ (memory/, which holds the repo's playbook) stays trackable, because
7
+ install_scaffold() never overwrites it and so nothing can reconstruct it.
8
+
9
+ This module ensures the ignored half's rules are present in the target's root
10
+ .gitignore, append-only, and never touches a rule the user wrote.
8
11
  """
9
12
 
10
13
  from __future__ import annotations
@@ -14,7 +17,7 @@ from pathlib import Path
14
17
 
15
18
  from blitz_cli._git import _git
16
19
 
17
- COMMENT = "# Blitz playbook working area: per-run state, not content."
20
+ COMMENT = "# Blitz working area: regenerable output and per-run state."
18
21
 
19
22
  RULES: tuple[str, ...] = (
20
23
  "/.blitz/execute/",
@@ -24,6 +27,16 @@ RULES: tuple[str, ...] = (
24
27
  # which is not always the repo root. An anchored rule would silently miss
25
28
  # it. The trailing * covers the SQLite -wal and -shm sidecars.
26
29
  "**/.blitz/traces.db*",
30
+ # Regenerable output: install_scaffold() refreshes both from the package
31
+ # under --force, and nothing detects on either, so a clone reproduces them
32
+ # exactly by running init. The test is regenerable AND not an input to the
33
+ # thing that regenerates it. memory/ fails the first half: it is seeded
34
+ # once and never overwritten, so nothing can reconstruct it. A rendered
35
+ # agent command directory fails the second: it is what AGENTS[...] detects
36
+ # on, so ignoring it means init finds no agent and installs nothing.
37
+ # Neither belongs here. See specs/006-init-ignore-generated/spec.md.
38
+ "/.blitz/scripts/",
39
+ "/.blitz/templates/",
27
40
  )
28
41
 
29
42