backstory-cli 0.1.0__py3-none-any.whl
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.
- backstory/__init__.py +2 -0
- backstory/__main__.py +6 -0
- backstory/attach.py +131 -0
- backstory/cli.py +381 -0
- backstory/config.py +49 -0
- backstory/contradiction.py +75 -0
- backstory/dump.py +235 -0
- backstory/git.py +32 -0
- backstory/hooks.py +94 -0
- backstory/init.py +96 -0
- backstory/okf.py +346 -0
- backstory/retrieval.py +239 -0
- backstory/storage.py +58 -0
- backstory/summarize.py +184 -0
- backstory/transcript.py +177 -0
- backstory_cli-0.1.0.dist-info/METADATA +5 -0
- backstory_cli-0.1.0.dist-info/RECORD +20 -0
- backstory_cli-0.1.0.dist-info/WHEEL +5 -0
- backstory_cli-0.1.0.dist-info/entry_points.txt +2 -0
- backstory_cli-0.1.0.dist-info/top_level.txt +1 -0
backstory/__init__.py
ADDED
backstory/__main__.py
ADDED
backstory/attach.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from backstory.dump import clear_pending_session, load_pending_session
|
|
8
|
+
from backstory.okf import parse_session_markdown, render_session_markdown, session_id_to_filename
|
|
9
|
+
from backstory.storage import build_storage_paths, ensure_storage_layout
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def attach_pending_to_commit(repo_root: Path, commit_hash: str) -> dict[str, Any] | None:
|
|
13
|
+
"""Attach the latest pending AI session to a Git commit.
|
|
14
|
+
|
|
15
|
+
Steps:
|
|
16
|
+
1. Load the pending session.
|
|
17
|
+
2. Update it with the commit hash.
|
|
18
|
+
3. Write a summary file.
|
|
19
|
+
4. Save commit-to-session mapping via Git notes.
|
|
20
|
+
5. Clear the pending session.
|
|
21
|
+
|
|
22
|
+
Returns the updated session dict, or ``None`` if no pending session exists.
|
|
23
|
+
"""
|
|
24
|
+
session = load_pending_session(repo_root)
|
|
25
|
+
if session is None:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
# --- Link session to commit ---
|
|
29
|
+
commit_msg = _get_commit_message(repo_root, commit_hash)
|
|
30
|
+
session["commit"] = {
|
|
31
|
+
"hash": commit_hash,
|
|
32
|
+
"message": commit_msg or "",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
paths = ensure_storage_layout(repo_root)
|
|
36
|
+
stable_path = paths.sessions / session_id_to_filename(session["session_id"])
|
|
37
|
+
stable_path.write_text(render_session_markdown(session), encoding="utf-8")
|
|
38
|
+
|
|
39
|
+
# --- Write a Git note ---
|
|
40
|
+
_write_git_note(repo_root, commit_hash, session)
|
|
41
|
+
|
|
42
|
+
# --- Clear pending ---
|
|
43
|
+
clear_pending_session(repo_root)
|
|
44
|
+
|
|
45
|
+
return session
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_commit_message(repo_root: Path, commit_hash: str) -> str | None:
|
|
49
|
+
"""Get the subject line of a commit."""
|
|
50
|
+
try:
|
|
51
|
+
result = subprocess.run(
|
|
52
|
+
["git", "log", "-1", "--format=%s", commit_hash],
|
|
53
|
+
cwd=repo_root,
|
|
54
|
+
capture_output=True,
|
|
55
|
+
text=True,
|
|
56
|
+
check=False,
|
|
57
|
+
)
|
|
58
|
+
if result.returncode == 0:
|
|
59
|
+
return result.stdout.strip() or None
|
|
60
|
+
return None
|
|
61
|
+
except OSError:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _write_git_note(repo_root: Path, commit_hash: str, session: dict) -> None:
|
|
66
|
+
"""Attach a Git note with session metadata to the commit."""
|
|
67
|
+
import json
|
|
68
|
+
|
|
69
|
+
note = json.dumps(
|
|
70
|
+
{
|
|
71
|
+
"ai_session": session.get("session_id"),
|
|
72
|
+
"agent": session.get("agent", {}).get("name"),
|
|
73
|
+
"created_at": session.get("created_at"),
|
|
74
|
+
},
|
|
75
|
+
indent=2,
|
|
76
|
+
)
|
|
77
|
+
try:
|
|
78
|
+
subprocess.run(
|
|
79
|
+
["git", "notes", "add", "-f", "-m", note, commit_hash],
|
|
80
|
+
cwd=repo_root,
|
|
81
|
+
capture_output=True,
|
|
82
|
+
text=True,
|
|
83
|
+
check=False,
|
|
84
|
+
)
|
|
85
|
+
except OSError:
|
|
86
|
+
pass # Git notes are best-effort
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _render_summary(session: dict, commit_hash: str, commit_msg: str | None) -> str:
|
|
90
|
+
"""Render a human-readable summary markdown file."""
|
|
91
|
+
rendered = render_session_markdown(session)
|
|
92
|
+
parsed = parse_session_markdown(rendered)
|
|
93
|
+
lines: list[str] = []
|
|
94
|
+
lines.append(f"# backstory for Commit {commit_hash}")
|
|
95
|
+
lines.append("")
|
|
96
|
+
if commit_msg:
|
|
97
|
+
lines.append(f"**{commit_msg}**")
|
|
98
|
+
lines.append("")
|
|
99
|
+
lines.append("## Task")
|
|
100
|
+
lines.append("")
|
|
101
|
+
lines.append(parsed.task_title)
|
|
102
|
+
lines.append("")
|
|
103
|
+
if parsed.why:
|
|
104
|
+
lines.append("## Why")
|
|
105
|
+
lines.append("")
|
|
106
|
+
lines.append(parsed.why)
|
|
107
|
+
lines.append("")
|
|
108
|
+
if parsed.files_changed:
|
|
109
|
+
lines.append("## Files Changed")
|
|
110
|
+
lines.append("")
|
|
111
|
+
for f in parsed.files_changed:
|
|
112
|
+
lines.append(f"- `{f}`")
|
|
113
|
+
lines.append("")
|
|
114
|
+
if parsed.decisions:
|
|
115
|
+
lines.append("## Key Decisions")
|
|
116
|
+
lines.append("")
|
|
117
|
+
for d in parsed.decisions:
|
|
118
|
+
lines.append(f"- {d}")
|
|
119
|
+
lines.append("")
|
|
120
|
+
if parsed.risks:
|
|
121
|
+
lines.append("## Risks")
|
|
122
|
+
lines.append("")
|
|
123
|
+
for r in parsed.risks:
|
|
124
|
+
lines.append(f"- {r}")
|
|
125
|
+
lines.append("")
|
|
126
|
+
lines.append(f"Agent: {parsed.agent_name}")
|
|
127
|
+
if parsed.agent_model:
|
|
128
|
+
lines.append(f"Model: {parsed.agent_model}")
|
|
129
|
+
lines.append("")
|
|
130
|
+
lines.append(f"Session ID: {parsed.session_id or session.get('session_id', 'unknown')}")
|
|
131
|
+
return "\n".join(lines)
|
backstory/cli.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from backstory.attach import attach_pending_to_commit
|
|
8
|
+
from backstory.contradiction import detect_potential_contradictions
|
|
9
|
+
from backstory.dump import capture_session, save_pending_session
|
|
10
|
+
from backstory.dump import discover_transcript_path
|
|
11
|
+
from backstory.init import initialize_repo, print_init_summary
|
|
12
|
+
from backstory.retrieval import (
|
|
13
|
+
commit_for_line,
|
|
14
|
+
commits_for_file,
|
|
15
|
+
commits_for_range,
|
|
16
|
+
files_in_diff,
|
|
17
|
+
format_retrieval_result,
|
|
18
|
+
resolve_repo_root,
|
|
19
|
+
)
|
|
20
|
+
from backstory.summarize import summarize_transcript
|
|
21
|
+
from backstory.transcript import (
|
|
22
|
+
ExtractedDecisions,
|
|
23
|
+
detect_agent_name,
|
|
24
|
+
detect_model,
|
|
25
|
+
format_decisions,
|
|
26
|
+
import_transcript,
|
|
27
|
+
normalize_messages,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
COMMANDS = [
|
|
31
|
+
"init",
|
|
32
|
+
"dump",
|
|
33
|
+
"attach",
|
|
34
|
+
"why",
|
|
35
|
+
"show",
|
|
36
|
+
"search",
|
|
37
|
+
"context",
|
|
38
|
+
"status",
|
|
39
|
+
"redact",
|
|
40
|
+
"repair",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
RETRIEVAL_COMMANDS = [
|
|
44
|
+
"file",
|
|
45
|
+
"line",
|
|
46
|
+
"range",
|
|
47
|
+
"diff",
|
|
48
|
+
"code",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
53
|
+
parser = argparse.ArgumentParser(prog="backstory")
|
|
54
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
55
|
+
|
|
56
|
+
# --- init ---
|
|
57
|
+
init_p = subparsers.add_parser("init", help="Initialize backstory in this repo")
|
|
58
|
+
init_p.add_argument("--no-hooks", action="store_true", help="Skip Git hook installation")
|
|
59
|
+
init_p.add_argument("--force", action="store_true", help="Overwrite existing config and hooks")
|
|
60
|
+
|
|
61
|
+
# --- dump ---
|
|
62
|
+
dump_p = subparsers.add_parser("dump", help="Capture an AI session")
|
|
63
|
+
dump_p.add_argument("--agent", default=None, help="Agent name (claude, codex)")
|
|
64
|
+
dump_p.add_argument("--transcript", type=Path, default=None, help="Path to transcript JSON file")
|
|
65
|
+
dump_p.add_argument("--task", default=None, help="Short task description")
|
|
66
|
+
dump_p.add_argument("--hook", default=None, help=argparse.SUPPRESS) # internal: called from hook
|
|
67
|
+
|
|
68
|
+
# --- attach ---
|
|
69
|
+
attach_p = subparsers.add_parser("attach", help="Attach pending session to a commit")
|
|
70
|
+
attach_p.add_argument("commit_spec", nargs="?", default="HEAD", help="Commit hash or reference")
|
|
71
|
+
attach_p.add_argument("--hook", default=None, help=argparse.SUPPRESS) # internal
|
|
72
|
+
|
|
73
|
+
# --- basic commands (no extra args yet) ---
|
|
74
|
+
for name in ("why", "show", "search", "context", "status", "redact", "repair"):
|
|
75
|
+
subparsers.add_parser(name)
|
|
76
|
+
|
|
77
|
+
# --- retrieval commands ---
|
|
78
|
+
file_p = subparsers.add_parser("file", help="Show commits affecting a file")
|
|
79
|
+
file_p.add_argument("path", help="File path to query")
|
|
80
|
+
|
|
81
|
+
line_p = subparsers.add_parser("line", help="Show what changed a specific line")
|
|
82
|
+
line_p.add_argument("spec", help="path/to/file.py:42")
|
|
83
|
+
|
|
84
|
+
range_p = subparsers.add_parser("range", help="Show commits affecting a line range")
|
|
85
|
+
range_p.add_argument("spec", help="path/to/file.py:10-20")
|
|
86
|
+
|
|
87
|
+
code_p = subparsers.add_parser("code", help="Alias for range")
|
|
88
|
+
code_p.add_argument("spec", help="path/to/file.py:10-20")
|
|
89
|
+
|
|
90
|
+
subparsers.add_parser("diff", help="Show prior context for uncommitted changes")
|
|
91
|
+
|
|
92
|
+
return parser
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main(argv: list[str] | None = None) -> int:
|
|
96
|
+
args = build_parser().parse_args(argv)
|
|
97
|
+
return _dispatch(args)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _dispatch(args: argparse.Namespace) -> int:
|
|
101
|
+
handler = _get_handler(args.command)
|
|
102
|
+
if handler is None:
|
|
103
|
+
print(f"Unknown command: {args.command}", file=sys.stderr)
|
|
104
|
+
return 1
|
|
105
|
+
return handler(args)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _get_handler(command: str):
|
|
109
|
+
return {
|
|
110
|
+
"init": _handle_init,
|
|
111
|
+
"dump": _handle_dump,
|
|
112
|
+
"attach": _handle_attach,
|
|
113
|
+
"file": _handle_file,
|
|
114
|
+
"line": _handle_line,
|
|
115
|
+
"range": _handle_range,
|
|
116
|
+
"code": _handle_range,
|
|
117
|
+
"diff": _handle_diff,
|
|
118
|
+
}.get(command)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _resolve_repo() -> Path | None:
|
|
122
|
+
return resolve_repo_root(Path.cwd())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# init handler
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _handle_init(args: argparse.Namespace) -> int:
|
|
131
|
+
repo = _resolve_repo()
|
|
132
|
+
if repo is None:
|
|
133
|
+
print("Not in a Git repository.", file=sys.stderr)
|
|
134
|
+
print("Run this command inside a Git repository.", file=sys.stderr)
|
|
135
|
+
return 1
|
|
136
|
+
|
|
137
|
+
result = initialize_repo(
|
|
138
|
+
repo_root=repo,
|
|
139
|
+
install_git_hooks=not args.no_hooks,
|
|
140
|
+
force=args.force,
|
|
141
|
+
)
|
|
142
|
+
print_init_summary(repo, result)
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# dump handler
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _handle_dump(args: argparse.Namespace) -> int:
|
|
152
|
+
repo = _resolve_repo()
|
|
153
|
+
if repo is None:
|
|
154
|
+
print("Not in a Git repository.", file=sys.stderr)
|
|
155
|
+
return 1
|
|
156
|
+
|
|
157
|
+
# Resolve agent name
|
|
158
|
+
agent_name = args.agent or "manual"
|
|
159
|
+
model: str | None = None
|
|
160
|
+
decisions = None
|
|
161
|
+
|
|
162
|
+
transcript_path = args.transcript
|
|
163
|
+
if transcript_path is None:
|
|
164
|
+
transcript_path = discover_transcript_path(repo)
|
|
165
|
+
if transcript_path is not None:
|
|
166
|
+
print(f"Auto-detected transcript: {transcript_path}", file=sys.stderr)
|
|
167
|
+
|
|
168
|
+
if transcript_path:
|
|
169
|
+
# Step 1: Read the raw transcript file
|
|
170
|
+
raw = import_transcript(transcript_path)
|
|
171
|
+
if raw is None:
|
|
172
|
+
print(f"Warning: could not read transcript from {transcript_path}", file=sys.stderr)
|
|
173
|
+
else:
|
|
174
|
+
# Step 2: Detect agent metadata from the transcript envelope
|
|
175
|
+
agent_name = detect_agent_name(raw) or agent_name
|
|
176
|
+
model = detect_model(raw)
|
|
177
|
+
|
|
178
|
+
# Step 3: Normalize to message list
|
|
179
|
+
messages = normalize_messages(raw)
|
|
180
|
+
|
|
181
|
+
if messages:
|
|
182
|
+
# Step 4: Ask the agent to summarize its own transcript
|
|
183
|
+
print(f"Asking {agent_name} to summarize transcript...", file=sys.stderr)
|
|
184
|
+
decisions = summarize_transcript(
|
|
185
|
+
messages=messages,
|
|
186
|
+
agent_name=agent_name,
|
|
187
|
+
model=model,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if decisions:
|
|
191
|
+
print(format_decisions(decisions))
|
|
192
|
+
print()
|
|
193
|
+
else:
|
|
194
|
+
print(
|
|
195
|
+
" (agent summarization unavailable — "
|
|
196
|
+
"install claude CLI for automatic extraction)",
|
|
197
|
+
file=sys.stderr,
|
|
198
|
+
)
|
|
199
|
+
# Still capture the session with basic info
|
|
200
|
+
decisions = ExtractedDecisions(
|
|
201
|
+
agent_name=agent_name,
|
|
202
|
+
model=model,
|
|
203
|
+
task=args.task or "",
|
|
204
|
+
decisions=[],
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
print("Warning: no messages found in transcript", file=sys.stderr)
|
|
208
|
+
|
|
209
|
+
# Override agent name from --agent flag if explicitly provided
|
|
210
|
+
if args.agent:
|
|
211
|
+
agent_name = args.agent
|
|
212
|
+
|
|
213
|
+
# Capture session (NO raw conversation stored — only extracted decisions)
|
|
214
|
+
session = capture_session(
|
|
215
|
+
repo_root=repo,
|
|
216
|
+
task=args.task,
|
|
217
|
+
agent=agent_name,
|
|
218
|
+
decisions=decisions,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
path = save_pending_session(repo, session)
|
|
222
|
+
print(f"Session saved: {session['session_id']}")
|
|
223
|
+
print(f"Pending: {path}")
|
|
224
|
+
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# attach handler
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _handle_attach(args: argparse.Namespace) -> int:
|
|
234
|
+
repo = _resolve_repo()
|
|
235
|
+
if repo is None:
|
|
236
|
+
print("Not in a Git repository.", file=sys.stderr)
|
|
237
|
+
return 1
|
|
238
|
+
|
|
239
|
+
result = attach_pending_to_commit(repo, args.commit_spec)
|
|
240
|
+
if result is None:
|
|
241
|
+
if not args.hook:
|
|
242
|
+
print("No pending session to attach.")
|
|
243
|
+
return 0
|
|
244
|
+
|
|
245
|
+
print(f"Attached session {result['session_id']} to commit {args.commit_spec}")
|
|
246
|
+
return 0
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
# file handler
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _handle_file(args: argparse.Namespace) -> int:
|
|
255
|
+
repo = _resolve_repo()
|
|
256
|
+
if repo is None:
|
|
257
|
+
print("Not in a Git repository.", file=sys.stderr)
|
|
258
|
+
return 1
|
|
259
|
+
|
|
260
|
+
commits = commits_for_file(repo, args.path)
|
|
261
|
+
output = format_retrieval_result(
|
|
262
|
+
file_path=args.path,
|
|
263
|
+
line_range=None,
|
|
264
|
+
commits=commits,
|
|
265
|
+
)
|
|
266
|
+
print(output)
|
|
267
|
+
return 0
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
# Parse helpers for line/range specs
|
|
272
|
+
# ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _parse_line_spec(spec: str) -> tuple[str, int] | None:
|
|
276
|
+
"""Parse 'path/to/file.py:42' into (path, 42)."""
|
|
277
|
+
if ":" not in spec:
|
|
278
|
+
return None
|
|
279
|
+
parts = spec.rsplit(":", 1)
|
|
280
|
+
try:
|
|
281
|
+
return parts[0], int(parts[1])
|
|
282
|
+
except (ValueError, IndexError):
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _parse_range_spec(spec: str) -> tuple[str, int, int] | None:
|
|
287
|
+
"""Parse 'path/to/file.py:10-20' into (path, 10, 20)."""
|
|
288
|
+
if ":" not in spec or "-" not in spec:
|
|
289
|
+
return None
|
|
290
|
+
path_part, range_part = spec.rsplit(":", 1)
|
|
291
|
+
if "-" not in range_part:
|
|
292
|
+
return None
|
|
293
|
+
try:
|
|
294
|
+
start_str, end_str = range_part.split("-", 1)
|
|
295
|
+
return path_part, int(start_str), int(end_str)
|
|
296
|
+
except (ValueError, IndexError):
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
# line / range / diff handlers
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _handle_line(args: argparse.Namespace) -> int:
|
|
306
|
+
repo = _resolve_repo()
|
|
307
|
+
if repo is None:
|
|
308
|
+
print("Not in a Git repository.", file=sys.stderr)
|
|
309
|
+
return 1
|
|
310
|
+
|
|
311
|
+
parsed = _parse_line_spec(args.spec)
|
|
312
|
+
if parsed is None:
|
|
313
|
+
print("Invalid format. Use: backstory line <file>:<line>", file=sys.stderr)
|
|
314
|
+
return 1
|
|
315
|
+
|
|
316
|
+
path, line = parsed
|
|
317
|
+
commit = commit_for_line(repo, path, line)
|
|
318
|
+
commits = [commit] if commit else []
|
|
319
|
+
output = format_retrieval_result(
|
|
320
|
+
file_path=path,
|
|
321
|
+
line_range=(line, line),
|
|
322
|
+
commits=commits,
|
|
323
|
+
)
|
|
324
|
+
print(output)
|
|
325
|
+
return 0
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _handle_range(args: argparse.Namespace) -> int:
|
|
329
|
+
repo = _resolve_repo()
|
|
330
|
+
if repo is None:
|
|
331
|
+
print("Not in a Git repository.", file=sys.stderr)
|
|
332
|
+
return 1
|
|
333
|
+
|
|
334
|
+
parsed = _parse_range_spec(args.spec)
|
|
335
|
+
if parsed is None:
|
|
336
|
+
print("Invalid format. Use: backstory range <file>:<start>-<end>", file=sys.stderr)
|
|
337
|
+
return 1
|
|
338
|
+
|
|
339
|
+
path, start, end = parsed
|
|
340
|
+
commits = commits_for_range(repo, path, start, end)
|
|
341
|
+
output = format_retrieval_result(
|
|
342
|
+
file_path=path,
|
|
343
|
+
line_range=(start, end),
|
|
344
|
+
commits=commits,
|
|
345
|
+
)
|
|
346
|
+
print(output)
|
|
347
|
+
return 0
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _handle_diff(args: argparse.Namespace) -> int:
|
|
351
|
+
repo = _resolve_repo()
|
|
352
|
+
if repo is None:
|
|
353
|
+
print("Not in a Git repository.", file=sys.stderr)
|
|
354
|
+
return 1
|
|
355
|
+
|
|
356
|
+
changed_files = files_in_diff(repo)
|
|
357
|
+
if not changed_files:
|
|
358
|
+
print("No uncommitted changes detected.")
|
|
359
|
+
return 0
|
|
360
|
+
|
|
361
|
+
print("Relevant prior context for current diff:")
|
|
362
|
+
print("")
|
|
363
|
+
|
|
364
|
+
for idx, file_path in enumerate(changed_files, 1):
|
|
365
|
+
commits = commits_for_file(repo, file_path)
|
|
366
|
+
print(f"{idx}. {file_path}")
|
|
367
|
+
if commits:
|
|
368
|
+
print(f" Most recent commit: {commits[0].hash} - {commits[0].message}")
|
|
369
|
+
print(f" Author: {commits[0].author}")
|
|
370
|
+
else:
|
|
371
|
+
print(" No previous commits found.")
|
|
372
|
+
print()
|
|
373
|
+
|
|
374
|
+
warnings = detect_potential_contradictions(repo, changed_files)
|
|
375
|
+
if warnings:
|
|
376
|
+
print("Potential contradictions:")
|
|
377
|
+
for warning in warnings:
|
|
378
|
+
print(f" - {warning}")
|
|
379
|
+
print()
|
|
380
|
+
|
|
381
|
+
return 0
|
backstory/config.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from backstory.storage import (
|
|
7
|
+
INDEX_DB_NAME,
|
|
8
|
+
KNOWLEDGE_DIR_NAME,
|
|
9
|
+
PENDING_SESSION_NAME,
|
|
10
|
+
REDACTIONS_DIR_NAME,
|
|
11
|
+
SESSIONS_DIR_NAME,
|
|
12
|
+
STORAGE_DIR_NAME,
|
|
13
|
+
ensure_storage_layout,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DEFAULT_CONFIG = {
|
|
18
|
+
"version": 1,
|
|
19
|
+
"storage": {
|
|
20
|
+
"root": STORAGE_DIR_NAME,
|
|
21
|
+
"knowledge_dir": KNOWLEDGE_DIR_NAME,
|
|
22
|
+
"sessions_dir": SESSIONS_DIR_NAME,
|
|
23
|
+
"pending_file": PENDING_SESSION_NAME,
|
|
24
|
+
"redactions_dir": REDACTIONS_DIR_NAME,
|
|
25
|
+
"index_db": INDEX_DB_NAME,
|
|
26
|
+
},
|
|
27
|
+
"capture": {
|
|
28
|
+
"store_git_diff": True,
|
|
29
|
+
"store_transcripts": True,
|
|
30
|
+
},
|
|
31
|
+
"redaction": {
|
|
32
|
+
"enabled": True,
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def config_path(repo_root: Path) -> Path:
|
|
38
|
+
return repo_root / ".backstory" / "config.json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def write_default_config(repo_root: Path) -> Path:
|
|
42
|
+
ensure_storage_layout(repo_root)
|
|
43
|
+
path = config_path(repo_root)
|
|
44
|
+
path.write_text(json.dumps(DEFAULT_CONFIG, indent=2) + "\n")
|
|
45
|
+
return path
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_config(repo_root: Path) -> dict:
|
|
49
|
+
return json.loads(config_path(repo_root).read_text())
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from backstory.okf import parse_session_markdown
|
|
8
|
+
from backstory.storage import build_storage_paths
|
|
9
|
+
|
|
10
|
+
NEGATION_PATTERNS = (
|
|
11
|
+
r"\bnot\b",
|
|
12
|
+
r"\bnever\b",
|
|
13
|
+
r"\binstead\b",
|
|
14
|
+
r"\bavoid\b",
|
|
15
|
+
r"\bprevent\b",
|
|
16
|
+
r"\bno longer\b",
|
|
17
|
+
r"\brather than\b",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def detect_potential_contradictions(repo_root: Path, changed_files: list[str]) -> list[str]:
|
|
22
|
+
"""Return human-readable warnings for likely contradictions.
|
|
23
|
+
|
|
24
|
+
The first implementation is conservative: it only warns when a current
|
|
25
|
+
changed file overlaps with a previously attached session and that session
|
|
26
|
+
contains at least one negated or reversing decision.
|
|
27
|
+
"""
|
|
28
|
+
changed = {path for path in changed_files if path}
|
|
29
|
+
if not changed:
|
|
30
|
+
return []
|
|
31
|
+
|
|
32
|
+
warnings: list[str] = []
|
|
33
|
+
for session in _load_attached_sessions(repo_root):
|
|
34
|
+
session_files = set(session.get("files", {}).get("changed", []))
|
|
35
|
+
overlap = sorted(changed.intersection(session_files))
|
|
36
|
+
if not overlap:
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
reasoning = session.get("reasoning_summary", {})
|
|
40
|
+
decisions = reasoning.get("decisions", [])
|
|
41
|
+
for decision in decisions:
|
|
42
|
+
if _looks_like_reversal(decision):
|
|
43
|
+
commit_hash = session.get("commit", {}).get("hash") or session.get("session_id", "unknown")
|
|
44
|
+
warnings.append(
|
|
45
|
+
f"Potential contradiction from {str(commit_hash)[:7]} on {', '.join(overlap)}: {decision}"
|
|
46
|
+
)
|
|
47
|
+
break
|
|
48
|
+
|
|
49
|
+
return warnings
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _load_attached_sessions(repo_root: Path) -> list[dict[str, Any]]:
|
|
53
|
+
"""Load attached session objects from local storage."""
|
|
54
|
+
paths = build_storage_paths(repo_root)
|
|
55
|
+
sessions: list[dict[str, Any]] = []
|
|
56
|
+
|
|
57
|
+
if not paths.sessions.exists():
|
|
58
|
+
return sessions
|
|
59
|
+
|
|
60
|
+
for path in sorted(paths.sessions.glob("*.md")):
|
|
61
|
+
if path.name == "latest.md":
|
|
62
|
+
continue
|
|
63
|
+
try:
|
|
64
|
+
knowledge = parse_session_markdown(path.read_text(encoding="utf-8"))
|
|
65
|
+
except OSError:
|
|
66
|
+
continue
|
|
67
|
+
sessions.append(knowledge.to_session_dict())
|
|
68
|
+
|
|
69
|
+
return sessions
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _looks_like_reversal(text: str) -> bool:
|
|
73
|
+
"""Detect the negated phrasing typically used in a contradictory decision."""
|
|
74
|
+
lowered = text.lower()
|
|
75
|
+
return any(re.search(pattern, lowered) for pattern in NEGATION_PATTERNS)
|