agent-lineup 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.
- agent_lineup-0.1.0.dist-info/METADATA +107 -0
- agent_lineup-0.1.0.dist-info/RECORD +25 -0
- agent_lineup-0.1.0.dist-info/WHEEL +5 -0
- agent_lineup-0.1.0.dist-info/entry_points.txt +2 -0
- agent_lineup-0.1.0.dist-info/licenses/LICENSE +21 -0
- agent_lineup-0.1.0.dist-info/top_level.txt +1 -0
- lineup/__init__.py +3 -0
- lineup/__main__.py +5 -0
- lineup/banner.py +70 -0
- lineup/changes.py +403 -0
- lineup/cli.py +440 -0
- lineup/functions.py +152 -0
- lineup/git.py +161 -0
- lineup/guard.py +187 -0
- lineup/hookjson.py +58 -0
- lineup/hooks.py +326 -0
- lineup/lineup-banner.txt +8 -0
- lineup/llm.py +158 -0
- lineup/paths.py +57 -0
- lineup/rollback.py +252 -0
- lineup/rules.py +229 -0
- lineup/snapshot.py +170 -0
- lineup/store.py +232 -0
- lineup/summary.py +54 -0
- lineup/table.py +54 -0
lineup/cli.py
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""Command-line entry point: `lineup <command>`."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
import textwrap
|
|
6
|
+
|
|
7
|
+
from lineup import __version__, banner, changes, git, guard, hooks, llm, rollback, rules, snapshot, store, summary
|
|
8
|
+
from lineup import table as tables
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _root():
|
|
12
|
+
try:
|
|
13
|
+
return git.repo_root()
|
|
14
|
+
except git.GitError as e:
|
|
15
|
+
print(f"lineup: {e}", file=sys.stderr)
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _commit_label(commit):
|
|
20
|
+
return "no commits yet" if commit == git.EMPTY_TREE else commit[:7]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _hook(args):
|
|
24
|
+
if args.event != "start" or (args.agent and not args.fresh): # called by Claude Code / Codex
|
|
25
|
+
return hooks.run_hook(args.event, args.agent)
|
|
26
|
+
root = _root()
|
|
27
|
+
if root is None:
|
|
28
|
+
return 2
|
|
29
|
+
table, kept = snapshot.start_session(root, fresh=args.fresh, agent=args.agent)
|
|
30
|
+
if kept:
|
|
31
|
+
word = "file" if kept == 1 else "files"
|
|
32
|
+
print(f"Keeping the earlier snapshot: {kept} uncommitted {word} from before this session. "
|
|
33
|
+
"Some may be your own edits. Commit them, or run lineup hook start --fresh to start over.")
|
|
34
|
+
else:
|
|
35
|
+
print(f"lineup: photo taken at {_commit_label(table['start_commit'])} ({snapshot.summary(table)})")
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _collect(root):
|
|
40
|
+
"""Shared by diff and risky: flag new commits, find changes, give IDs.
|
|
41
|
+
|
|
42
|
+
Returns (table, found, committed, label). `committed` are files in new commits.
|
|
43
|
+
"""
|
|
44
|
+
if store.has_snapshot(root):
|
|
45
|
+
table = store.load_table(root)
|
|
46
|
+
committed = snapshot.check_commits(root, table)
|
|
47
|
+
found = changes.find_changes(root, table)
|
|
48
|
+
snapshot.record_check(root, table, found)
|
|
49
|
+
label = f"photo taken {table['taken_at']} at {_commit_label(table['start_commit'])}"
|
|
50
|
+
return table, found, committed, label
|
|
51
|
+
found = changes.find_changes(root)
|
|
52
|
+
head = git.head_commit(root)
|
|
53
|
+
return None, found, [], f"last commit {head[:7]}" if head else "an empty repo (no commits yet)"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _no_photo(root):
|
|
57
|
+
"""No photo yet: take one now so the next check is exact."""
|
|
58
|
+
snapshot.take(root)
|
|
59
|
+
print("\nNo snapshot from the session start. These changes may include your own edits, "
|
|
60
|
+
"and they can't be rolled back. Tracking from now on.")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def scores(root, found):
|
|
64
|
+
"""{path: "HIGH" | "MED" | "LOW"} from the rules (the same scores `risky` shows)."""
|
|
65
|
+
return {c.path: rules.score(rules.rules_for(c, found, root)) for c in found}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _ai_summary(root, table, found):
|
|
69
|
+
"""The AI part of `diff`: Summary text plus one line per file. Never stops `diff` from printing."""
|
|
70
|
+
levels = scores(root, found)
|
|
71
|
+
items, skipped = guard.ai_input(found, levels)
|
|
72
|
+
agent = llm.pick_agent(table)
|
|
73
|
+
if agent is None:
|
|
74
|
+
return "Summary unavailable: no agent found.\n\n"
|
|
75
|
+
is_installed = bool(llm.installed(llm.AGENTS[agent][0]))
|
|
76
|
+
|
|
77
|
+
def say_asking():
|
|
78
|
+
if is_installed: # no "Asking" line for an agent that can't be asked
|
|
79
|
+
print(f"Asking {agent} for a summary...", file=sys.stderr, flush=True)
|
|
80
|
+
|
|
81
|
+
result = summary.summarize(root, items, agent, on_ask=say_asking)
|
|
82
|
+
notes = {path: note for path, _, note, _ in items if note}
|
|
83
|
+
ids = {p: r["id"] for p, r in (table or {}).get("files", {}).items() if "id" in r}
|
|
84
|
+
cut = [path for path, _, _, diff in items if diff.endswith(guard.CUT_MARK)]
|
|
85
|
+
rows = []
|
|
86
|
+
for path in sorted(levels, key=lambda p: (-rules.LEVELS[levels[p]], p)):
|
|
87
|
+
if result["files"].get(path):
|
|
88
|
+
line = result["files"][path] + (" (diff cut)" if path in cut else "")
|
|
89
|
+
elif path in skipped:
|
|
90
|
+
line = f"not summarized ({levels[path]}, diff too big)"
|
|
91
|
+
elif path in notes:
|
|
92
|
+
line = notes[path][:1].upper() + notes[path][1:]
|
|
93
|
+
else:
|
|
94
|
+
line = "no summary yet"
|
|
95
|
+
rows.append((ids.get(path, "-"), path, levels[path], line))
|
|
96
|
+
|
|
97
|
+
out = []
|
|
98
|
+
if result["overview"]:
|
|
99
|
+
text = textwrap.fill(result["overview"], 78, initial_indent=" ", subsequent_indent=" ")
|
|
100
|
+
out.append(f"Summary (AI guess, by {agent})\n{text}\n")
|
|
101
|
+
else:
|
|
102
|
+
out.append(f"Summary unavailable: {result['reason'] or 'no answer'}.\n")
|
|
103
|
+
out.append(tables.draw(("ID", "File", "Risk", "What changed (AI guess)"), [rows], (4, 40, 4, 50)).rstrip("\n"))
|
|
104
|
+
limits = []
|
|
105
|
+
if cut:
|
|
106
|
+
limits.append(f"Not fully summarized: {', '.join(cut)} (diff cut, too long).")
|
|
107
|
+
if skipped:
|
|
108
|
+
limits.append(f"Not sent: {', '.join(skipped)} (over the {guard.AI_LINE_LIMIT:,}-line limit).")
|
|
109
|
+
if limits:
|
|
110
|
+
out.append(" ".join(limits))
|
|
111
|
+
return "\n".join(out) + "\n\n"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _diff(args):
|
|
115
|
+
root = _root()
|
|
116
|
+
if root is None:
|
|
117
|
+
return 2
|
|
118
|
+
table, found, committed, label = _collect(root)
|
|
119
|
+
store.count_usage(root, diff_runs=1)
|
|
120
|
+
if committed:
|
|
121
|
+
names = ", ".join(f"{c.path} ({c.file_kind})" for c in committed)
|
|
122
|
+
print(f"Committed since last check: {names}\n")
|
|
123
|
+
if found:
|
|
124
|
+
try:
|
|
125
|
+
ai = _ai_summary(root, table, found)
|
|
126
|
+
except Exception: # the AI part must never stop diff from printing
|
|
127
|
+
ai = "Summary unavailable: unexpected error.\n\n"
|
|
128
|
+
ids = {p: r["id"] for p, r in (table or {}).get("files", {}).items() if "id" in r}
|
|
129
|
+
groups = changes.format_groups(changes.sort_into_groups(found), label, ids=ids)
|
|
130
|
+
title, _, body = groups.partition("\n\n")
|
|
131
|
+
print(f"{title}\n\n{ai}{body}", end="")
|
|
132
|
+
else:
|
|
133
|
+
print(_no_changes("diff", root, table))
|
|
134
|
+
_print_staged(root, found)
|
|
135
|
+
if table is None:
|
|
136
|
+
_no_photo(root)
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _other(change):
|
|
141
|
+
notes = []
|
|
142
|
+
if change.kind == "added":
|
|
143
|
+
notes.append("New")
|
|
144
|
+
if change.kind == "deleted":
|
|
145
|
+
notes.append("Deleted")
|
|
146
|
+
if change.private or change.big:
|
|
147
|
+
notes.append("Can't roll back")
|
|
148
|
+
return ", ".join(notes)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _why(reasons):
|
|
152
|
+
return "\n".join(reason if i == 0 else f"+ {reason}" for i, reason in enumerate(reasons))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def risky_rows(root, table, found):
|
|
156
|
+
"""[(id, path, level, why, other)], HIGH first, then by ID."""
|
|
157
|
+
rows = []
|
|
158
|
+
for change in found:
|
|
159
|
+
flags = sorted(rules.rules_for(change, found, root), key=lambda f: -rules.LEVELS[f[0]])
|
|
160
|
+
row_id = table["files"][change.path]["id"] if table else "-"
|
|
161
|
+
rows.append((row_id, change.path, rules.score(flags), _why([r for _, r in flags]), _other(change)))
|
|
162
|
+
rows.sort(key=lambda r: (-rules.LEVELS[r[2]], r[0] if isinstance(r[0], int) else 0, r[1]))
|
|
163
|
+
return rows
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def committed_rows(table):
|
|
167
|
+
"""Saved scores of files in commits since the photo: [(commit, path, level, why)], HIGH first."""
|
|
168
|
+
saved = (table or {}).get("committed", [])
|
|
169
|
+
rows = [(c["commit"][:7], c["path"], c["risk"], _why(c["reasons"])) for c in saved]
|
|
170
|
+
return sorted(rows, key=lambda r: -rules.LEVELS[r[2]]) # stable: same level keeps commit order
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _risky(args):
|
|
174
|
+
root = _root()
|
|
175
|
+
if root is None:
|
|
176
|
+
return 2
|
|
177
|
+
table, found, _, label = _collect(root)
|
|
178
|
+
store.count_usage(root, risky_runs=1)
|
|
179
|
+
committed = committed_rows(table)
|
|
180
|
+
if not found and not committed:
|
|
181
|
+
print(_no_changes("risky", root, table))
|
|
182
|
+
else:
|
|
183
|
+
print(f"lineup risky (compared with {label})\n")
|
|
184
|
+
if found:
|
|
185
|
+
rows = risky_rows(root, table, found)
|
|
186
|
+
print(tables.draw(("ID", "File", "Risk", "Why (rule)", "Other"), [[r] for r in rows], (4, 40, 4, 50, 20)), end="")
|
|
187
|
+
elif committed:
|
|
188
|
+
print("No uncommitted changes since the photo.")
|
|
189
|
+
if committed:
|
|
190
|
+
print("\nCommitted (lineup can't roll these back; use git to undo them)\n")
|
|
191
|
+
print(tables.draw(("Commit", "File", "Risk", "Why (rule)"), [[r] for r in committed], (7, 40, 4, 50)), end="")
|
|
192
|
+
_print_staged(root, found or committed)
|
|
193
|
+
if table is None:
|
|
194
|
+
_no_photo(root)
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _no_changes(command, root, table):
|
|
199
|
+
since = "photo" if table else "last commit"
|
|
200
|
+
if git.staged_differs(root): # never say "no changes" while a staged copy still differs
|
|
201
|
+
return f"lineup {command}: no changes in your files since the {since}, but a staged copy still has a change:"
|
|
202
|
+
return f"lineup {command}: no changes since the {since}"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _print_staged(root, shown_something):
|
|
206
|
+
lines = staged_note(root)
|
|
207
|
+
if lines:
|
|
208
|
+
print(("\n" if shown_something else "") + "\n".join(lines))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def staged_note(root, only=None):
|
|
212
|
+
"""Warning lines for files whose staged copy still differs from the file on disk."""
|
|
213
|
+
staged = sorted(p for p in git.staged_differs(root) if only is None or p in only)
|
|
214
|
+
return [f"The staged copy of {p} still has the old change. Run git restore --staged {p} before committing."
|
|
215
|
+
for p in staged]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _rollback(args):
|
|
219
|
+
root = _root()
|
|
220
|
+
if root is None:
|
|
221
|
+
return 2
|
|
222
|
+
if not store.has_snapshot(root):
|
|
223
|
+
print("No snapshot from the session start, so rollback could wipe your own work. Nothing was changed.")
|
|
224
|
+
return 1
|
|
225
|
+
last_check = store.load_table(root) # before refreshing: to spot files changed since the last check
|
|
226
|
+
table, _, _, _ = _collect(root) # new commits first, so committed files are refused
|
|
227
|
+
resolved, problems_found = [], []
|
|
228
|
+
for token in args.ids: # IDs or file names
|
|
229
|
+
file_id, problem = rollback.resolve(table, token)
|
|
230
|
+
if problem:
|
|
231
|
+
problems_found.append(problem)
|
|
232
|
+
else:
|
|
233
|
+
resolved.append(file_id)
|
|
234
|
+
ids = list(dict.fromkeys(resolved)) # "3 3" (or "3 auth.py") counts once
|
|
235
|
+
unknown = [i for i in ids if i not in rollback.valid_ids(table)]
|
|
236
|
+
if problems_found or unknown: # a typo or an unclear name: change nothing
|
|
237
|
+
valid = ", ".join(str(i) for i in rollback.valid_ids(table)) or "none yet (run lineup risky first)"
|
|
238
|
+
lines = [f"{problem[:1].upper()}{problem[1:]}." for problem in problems_found]
|
|
239
|
+
if unknown:
|
|
240
|
+
lines.append(f"No file with ID {', '.join(str(i) for i in unknown)}.")
|
|
241
|
+
print(" ".join(lines) + f" Valid IDs: {valid}. Nothing was changed.")
|
|
242
|
+
return 1
|
|
243
|
+
|
|
244
|
+
undos = [rollback.plan_undo(root, table, i, last_check) for i in ids]
|
|
245
|
+
run = rollback.new_backup_run(root)
|
|
246
|
+
width = max(len(u.path) for u in undos)
|
|
247
|
+
print("lineup rollback\n")
|
|
248
|
+
problems = False
|
|
249
|
+
for undo in undos:
|
|
250
|
+
word = rollback.apply_undo(root, undo, run)
|
|
251
|
+
if undo.action == "refuse":
|
|
252
|
+
problems = True
|
|
253
|
+
word = f"skipped: {undo.reason}"
|
|
254
|
+
elif undo.action == "nothing":
|
|
255
|
+
word = f"nothing to do: {undo.reason}"
|
|
256
|
+
elif word.startswith("failed"):
|
|
257
|
+
problems = True
|
|
258
|
+
outcome = ("refused" if undo.action == "refuse" else "nothing_to_do" if undo.action == "nothing"
|
|
259
|
+
else "failed" if word.startswith("failed") else "done")
|
|
260
|
+
store.count_usage(root, **{f"rollback_{outcome}": 1})
|
|
261
|
+
print(f"{undo.id:<3} {undo.path:<{width}} {word}")
|
|
262
|
+
if undo.action in rollback.DONE and not word.startswith("failed"):
|
|
263
|
+
for warning in undo.warnings:
|
|
264
|
+
print(f"{'':<3} {'':<{width}} Warning: {warning}")
|
|
265
|
+
done = [u.path for u in undos if u.action in rollback.DONE]
|
|
266
|
+
if done:
|
|
267
|
+
store.add_rollback_note(root, done) # the agent is told on its next turn (phase 7 hooks)
|
|
268
|
+
if store.backup_run_exists(root, run):
|
|
269
|
+
print(f"\nYour versions from before this rollback are saved in .git/lineup/backups/{run}/.")
|
|
270
|
+
staged = staged_note(root, set(done))
|
|
271
|
+
if staged:
|
|
272
|
+
print("\n" + "\n".join(staged))
|
|
273
|
+
if done:
|
|
274
|
+
notes = rollback.other_files_notes(root, table, done, set(ids))
|
|
275
|
+
if notes:
|
|
276
|
+
print("\n" + "\n".join(notes))
|
|
277
|
+
if hooks.installed(root):
|
|
278
|
+
print("\nThe agent will be told on your next message that these files were rolled back.")
|
|
279
|
+
return 1 if problems else 0
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def report_text(usage):
|
|
283
|
+
"""The usage summary for testers to send: counts and dates only, from a fixed list of names."""
|
|
284
|
+
c, days = usage["counts"], sorted(usage["days"])
|
|
285
|
+
if not c and not days:
|
|
286
|
+
return "lineup report: no usage recorded in this repo yet."
|
|
287
|
+
n = lambda name: c.get(name, 0) # noqa: E731
|
|
288
|
+
period = f" ({days[0]} to {days[-1]})" if days else ""
|
|
289
|
+
rows = [
|
|
290
|
+
("lineup version", __version__),
|
|
291
|
+
("Days active", f"{len(days)}{period}"),
|
|
292
|
+
("Sessions started", n("sessions")),
|
|
293
|
+
("Agent replies with changes", n("replies_with_changes")),
|
|
294
|
+
("Files flagged after replies", f"HIGH {n('flagged_HIGH')}, MED {n('flagged_MED')}, LOW {n('flagged_LOW')}"),
|
|
295
|
+
("lineup diff runs", n("diff_runs")),
|
|
296
|
+
("lineup risky runs", n("risky_runs")),
|
|
297
|
+
("Rollbacks", f"done {n('rollback_done')}, refused {n('rollback_refused')}, "
|
|
298
|
+
f"failed {n('rollback_failed')}, nothing to do {n('rollback_nothing_to_do')}"),
|
|
299
|
+
]
|
|
300
|
+
width = max(len(name) for name, _ in rows)
|
|
301
|
+
lines = ["lineup report (this repo only)", ""]
|
|
302
|
+
lines += [f"{name:<{width}} {value}" for name, value in rows]
|
|
303
|
+
lines += ["", "That's everything in the report: counts and dates only, no code, file names or secrets.",
|
|
304
|
+
"Nothing has been sent. To share it, copy the lines above into your feedback."]
|
|
305
|
+
return "\n".join(lines)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _report(args):
|
|
309
|
+
root = _root()
|
|
310
|
+
if root is None:
|
|
311
|
+
return 2
|
|
312
|
+
print(report_text(store.load_usage(root)))
|
|
313
|
+
return 0
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _confirm(args, question, flag):
|
|
317
|
+
"""True to go ahead: --yes, or the user says yes. Never asks when there's no one to answer."""
|
|
318
|
+
if args.yes:
|
|
319
|
+
return True
|
|
320
|
+
if not sys.stdin.isatty():
|
|
321
|
+
print(f"\nNothing was written. Run lineup init {flag}--yes to do it.")
|
|
322
|
+
return False
|
|
323
|
+
try:
|
|
324
|
+
answer = input(f"\n{question} [y/N] ")
|
|
325
|
+
except EOFError: # no one to answer after all (e.g. NUL on Windows looks like a terminal)
|
|
326
|
+
print(f"\nNothing was written. Run lineup init {flag}--yes to do it.")
|
|
327
|
+
return False
|
|
328
|
+
if answer.strip().lower() in ("y", "yes"):
|
|
329
|
+
return True
|
|
330
|
+
print("Nothing was written.")
|
|
331
|
+
return False
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _init(args):
|
|
335
|
+
banner.show()
|
|
336
|
+
root = _root()
|
|
337
|
+
if root is None:
|
|
338
|
+
return 2
|
|
339
|
+
if args.remove:
|
|
340
|
+
return _init_remove(root, args)
|
|
341
|
+
agents = ["claude", "codex"] if args.agent == "both" else [args.agent]
|
|
342
|
+
plan = hooks.plan_init(root, agents)
|
|
343
|
+
if not plan:
|
|
344
|
+
print("lineup hooks are already installed. Nothing to change.")
|
|
345
|
+
for warning in hooks.tracked_warnings(root):
|
|
346
|
+
print(warning)
|
|
347
|
+
return 0
|
|
348
|
+
print("lineup init will add these hooks (your other settings stay as they are):\n")
|
|
349
|
+
for rel, (_, added) in plan.items():
|
|
350
|
+
print(f" {rel}")
|
|
351
|
+
for event, command in added:
|
|
352
|
+
print(f" {event:<17} {command}")
|
|
353
|
+
if any(event == "SessionStart" for _, added in plan.values() for event, _ in added):
|
|
354
|
+
note = textwrap.fill(hooks.SESSION_NOTE, 76, initial_indent=" ", subsequent_indent=" ")
|
|
355
|
+
print(f"\nAt each session start, the agent is also given this note:\n{note}")
|
|
356
|
+
if not _confirm(args, "Write these changes?", ""):
|
|
357
|
+
return 1
|
|
358
|
+
hooks.write_plan(root, plan)
|
|
359
|
+
excluded = [rel for rel in plan if hooks.keep_out_of_git(root, rel)]
|
|
360
|
+
print("\nDone.")
|
|
361
|
+
if excluded:
|
|
362
|
+
print(f"Kept out of git (added to .git/info/exclude): {', '.join(excluded)}")
|
|
363
|
+
for warning in hooks.tracked_warnings(root):
|
|
364
|
+
print(warning)
|
|
365
|
+
if any(rel.startswith(".claude") for rel in plan):
|
|
366
|
+
print("Claude Code: the hooks load in your next session (or run /hooks to check them).")
|
|
367
|
+
if any(rel.startswith(".codex") for rel in plan):
|
|
368
|
+
print("Codex: open Codex in this repo and run /hooks to review and trust the lineup hooks. "
|
|
369
|
+
"They don't run until you trust them.")
|
|
370
|
+
return 0
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _init_remove(root, args):
|
|
374
|
+
plan = hooks.plan_remove(root)
|
|
375
|
+
if not plan:
|
|
376
|
+
print("No lineup hooks found. Nothing to remove.")
|
|
377
|
+
return 0
|
|
378
|
+
print("lineup init --remove will take out only lineup's hooks (everything else stays):\n")
|
|
379
|
+
for rel, (data, removed) in plan.items():
|
|
380
|
+
print(f" {rel}{' (only lineup was in it, so the file is deleted)' if data is None else ''}")
|
|
381
|
+
for event, command in removed:
|
|
382
|
+
print(f" {event:<17} {command}")
|
|
383
|
+
if not _confirm(args, "Remove them?", "--remove "):
|
|
384
|
+
return 1
|
|
385
|
+
hooks.write_plan(root, plan)
|
|
386
|
+
print("\nDone. lineup's hooks are removed, so the agent no longer gets lineup's session-start note either.")
|
|
387
|
+
return 0
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def build_parser():
|
|
391
|
+
parser = argparse.ArgumentParser(
|
|
392
|
+
prog="lineup",
|
|
393
|
+
description="See what your coding agent just changed, which edits are risky, and undo a file.",
|
|
394
|
+
)
|
|
395
|
+
parser.add_argument("--version", action="store_true", help="show the version and exit")
|
|
396
|
+
sub = parser.add_subparsers(dest="command")
|
|
397
|
+
|
|
398
|
+
init = sub.add_parser("init", help="install the session hooks in this repo")
|
|
399
|
+
init.add_argument("--agent", choices=["both", *llm.AGENTS], default="both", help="which agent's hooks (default: both)")
|
|
400
|
+
init.add_argument("--yes", action="store_true", help="write without asking")
|
|
401
|
+
init.add_argument("--remove", action="store_true", help="remove lineup's hooks (and nothing else)")
|
|
402
|
+
init.set_defaults(func=_init)
|
|
403
|
+
sub.add_parser("diff", help="show what changed since the snapshot").set_defaults(func=_diff)
|
|
404
|
+
sub.add_parser("risky", help="score each change HIGH / MED / LOW").set_defaults(func=_risky)
|
|
405
|
+
|
|
406
|
+
undo = sub.add_parser("rollback", help="undo one or more files by ID")
|
|
407
|
+
undo.add_argument("ids", nargs="+", metavar="id_or_file",
|
|
408
|
+
help="file IDs or names from lineup risky / diff, e.g. 3 or auth.py")
|
|
409
|
+
undo.set_defaults(func=_rollback)
|
|
410
|
+
sub.add_parser("report", help="usage counts for testers to send (nothing is sent by lineup)").set_defaults(
|
|
411
|
+
func=_report)
|
|
412
|
+
|
|
413
|
+
hook = sub.add_parser("hook", help="run by the agent's hooks (start: photo, stop: score line, prompt: notes)")
|
|
414
|
+
hook.add_argument("event", choices=["start", "stop", "prompt"])
|
|
415
|
+
hook.add_argument("--fresh", action="store_true", help="always take a new photo")
|
|
416
|
+
hook.add_argument("--agent", choices=list(llm.AGENTS), help="the agent running this session")
|
|
417
|
+
hook.set_defaults(func=_hook)
|
|
418
|
+
|
|
419
|
+
return parser
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def main(argv=None):
|
|
423
|
+
parser = build_parser()
|
|
424
|
+
args = parser.parse_args(argv)
|
|
425
|
+
if args.version or args.command is None:
|
|
426
|
+
banner.show()
|
|
427
|
+
if args.version:
|
|
428
|
+
print(f"lineup {__version__}")
|
|
429
|
+
else:
|
|
430
|
+
parser.print_help()
|
|
431
|
+
return 0
|
|
432
|
+
try:
|
|
433
|
+
return args.func(args)
|
|
434
|
+
except (OSError, ValueError, git.GitError) as error: # one clear line, never a Python traceback
|
|
435
|
+
print(f"lineup: {error}", file=sys.stderr)
|
|
436
|
+
return 2
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
if __name__ == "__main__":
|
|
440
|
+
sys.exit(main())
|
lineup/functions.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Finds functions and dependencies in file contents, so changes can name them one by one."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import posixpath
|
|
5
|
+
import re
|
|
6
|
+
from collections import Counter
|
|
7
|
+
|
|
8
|
+
_JS_KEYWORDS = {"if", "for", "while", "switch", "catch", "function", "return", "with", "else"}
|
|
9
|
+
|
|
10
|
+
_PATTERNS = {
|
|
11
|
+
"python": [re.compile(r"^\s*(?:async\s+)?def\s+(\w+)\s*\(", re.M)],
|
|
12
|
+
"js": [
|
|
13
|
+
re.compile(r"\bfunction\s*\*?\s*(\w+)\s*\("),
|
|
14
|
+
re.compile(r"\b(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|\w+\s*=>)"),
|
|
15
|
+
re.compile(r"^\s*(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{", re.M),
|
|
16
|
+
],
|
|
17
|
+
"go": [re.compile(r"^func\s+(?:\([^)]*\)\s*)?(\w+)\s*\(", re.M)],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
# JS test blocks: it("does x"), test.skip("does y") -> named `test "does x"`.
|
|
21
|
+
_JS_TEST = re.compile(r"\b(?:it|test)(?:\.\w+)?\s*\(\s*(['\"`])(.+?)\1")
|
|
22
|
+
|
|
23
|
+
_LANGUAGES = {
|
|
24
|
+
".py": "python",
|
|
25
|
+
".js": "js", ".jsx": "js", ".mjs": "js", ".cjs": "js", ".ts": "js", ".tsx": "js",
|
|
26
|
+
".go": "go",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def language(path):
|
|
31
|
+
return _LANGUAGES.get(posixpath.splitext(path)[1].lower())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def find_functions(path, text):
|
|
35
|
+
"""Function names defined in `text`, as a Counter (a name can appear twice)."""
|
|
36
|
+
lang = language(path)
|
|
37
|
+
if not lang or not text:
|
|
38
|
+
return Counter()
|
|
39
|
+
names = Counter()
|
|
40
|
+
for pattern in _PATTERNS[lang]:
|
|
41
|
+
for name in pattern.findall(text):
|
|
42
|
+
if lang == "js" and name in _JS_KEYWORDS:
|
|
43
|
+
continue
|
|
44
|
+
names[name] += 1
|
|
45
|
+
if lang == "js":
|
|
46
|
+
for _, title in _JS_TEST.findall(text):
|
|
47
|
+
names[f'test "{title}"'] += 1
|
|
48
|
+
return names
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def function_changes(path, old_text, new_text):
|
|
52
|
+
"""(added, removed) function names. A rename shows as one removed plus one added."""
|
|
53
|
+
old, new = find_functions(path, old_text), find_functions(path, new_text)
|
|
54
|
+
return sorted((new - old).elements()), sorted((old - new).elements())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---- dependencies ----
|
|
58
|
+
|
|
59
|
+
def _requirements(text):
|
|
60
|
+
names = set()
|
|
61
|
+
for line in text.splitlines():
|
|
62
|
+
line = line.split("#", 1)[0].strip()
|
|
63
|
+
if not line or line.startswith("-"):
|
|
64
|
+
continue
|
|
65
|
+
name = re.split(r"[<>=!~\[;@\s]", line, maxsplit=1)[0]
|
|
66
|
+
if name:
|
|
67
|
+
names.add(name.lower())
|
|
68
|
+
return names
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _package_json(text):
|
|
72
|
+
try:
|
|
73
|
+
data = json.loads(text)
|
|
74
|
+
except ValueError:
|
|
75
|
+
return set()
|
|
76
|
+
names = set()
|
|
77
|
+
for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
|
|
78
|
+
section = data.get(key)
|
|
79
|
+
if isinstance(section, dict):
|
|
80
|
+
names.update(section)
|
|
81
|
+
return names
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _toml_sections(text, wanted):
|
|
85
|
+
"""Keys inside TOML tables whose name passes `wanted(table_name)`."""
|
|
86
|
+
names, inside = set(), False
|
|
87
|
+
for line in text.splitlines():
|
|
88
|
+
stripped = line.strip()
|
|
89
|
+
if stripped.startswith("["):
|
|
90
|
+
inside = wanted(stripped.strip("[]").strip())
|
|
91
|
+
continue
|
|
92
|
+
if inside:
|
|
93
|
+
match = re.match(r"([A-Za-z0-9_.\-]+)\s*=", stripped)
|
|
94
|
+
if match and match.group(1).lower() != "python":
|
|
95
|
+
names.add(match.group(1).lower())
|
|
96
|
+
return names
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _pyproject(text):
|
|
100
|
+
names = set()
|
|
101
|
+
# PEP 621 lists: dependencies = ["requests>=2", ...] (also optional-dependencies groups)
|
|
102
|
+
for block in re.findall(r"^\s*[\w-]*dependencies\s*=\s*\[(.*?)\]", text, re.M | re.S):
|
|
103
|
+
for spec in re.findall(r"['\"]([^'\"]+)['\"]", block):
|
|
104
|
+
names |= _requirements(spec)
|
|
105
|
+
# Poetry tables: [tool.poetry.dependencies] / [tool.poetry.group.dev.dependencies]
|
|
106
|
+
names |= _toml_sections(text, lambda t: t.startswith("tool.poetry") and t.endswith("dependencies"))
|
|
107
|
+
return names
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _cargo(text):
|
|
111
|
+
return _toml_sections(text, lambda t: t.endswith("dependencies"))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _pipfile(text):
|
|
115
|
+
return _toml_sections(text, lambda t: t in ("packages", "dev-packages"))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _go_mod(text):
|
|
119
|
+
names = set()
|
|
120
|
+
for block in re.findall(r"^require\s*\((.*?)\)", text, re.M | re.S):
|
|
121
|
+
names.update(re.findall(r"^\s*(\S+)\s+v", block, re.M))
|
|
122
|
+
names.update(re.findall(r"^require\s+(\S+)\s+v", text, re.M))
|
|
123
|
+
return names
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _dependency_parser(path):
|
|
127
|
+
name = posixpath.basename(path).lower()
|
|
128
|
+
if name == "package.json":
|
|
129
|
+
return _package_json
|
|
130
|
+
if name == "pyproject.toml":
|
|
131
|
+
return _pyproject
|
|
132
|
+
if name == "cargo.toml":
|
|
133
|
+
return _cargo
|
|
134
|
+
if name == "pipfile":
|
|
135
|
+
return _pipfile
|
|
136
|
+
if name == "go.mod":
|
|
137
|
+
return _go_mod
|
|
138
|
+
if re.fullmatch(r"requirements.*\.txt", name):
|
|
139
|
+
return _requirements
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def is_dependency_file(path):
|
|
144
|
+
return _dependency_parser(path) is not None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def new_dependencies(path, old_text, new_text):
|
|
148
|
+
"""Packages listed in `new_text` but not in `old_text`, sorted."""
|
|
149
|
+
parser = _dependency_parser(path)
|
|
150
|
+
if not parser:
|
|
151
|
+
return []
|
|
152
|
+
return sorted(parser(new_text or "") - parser(old_text or ""))
|