pythia-plsql 0.3.5__tar.gz → 0.4.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 (39) hide show
  1. {pythia_plsql-0.3.5/scripts/pythia_plsql.egg-info → pythia_plsql-0.4.1}/PKG-INFO +8 -2
  2. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/README.md +7 -1
  3. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/pyproject.toml +1 -1
  4. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/scripts/pythia.py +198 -6
  5. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1/scripts/pythia_plsql.egg-info}/PKG-INFO +8 -2
  6. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/skills/pythia-apply/SKILL.md +15 -0
  7. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/tests/test_phase3.py +81 -9
  8. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/LICENSE +0 -0
  9. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/queries/compile-errors.sql +0 -0
  10. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/queries/dependencies.sql +0 -0
  11. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/queries/impact.sql +0 -0
  12. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/queries/invalid-objects.sql +0 -0
  13. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/queries/name-occupants.sql +0 -0
  14. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/queries/object-source.sql +0 -0
  15. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/queries/plscope-enabled.sql +0 -0
  16. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/queries/plscope-statements.sql +0 -0
  17. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/queries/plscope-usages.sql +0 -0
  18. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/queries/session-privileges.sql +0 -0
  19. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/queries/similar-candidates.sql +0 -0
  20. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/queries/source.sql +0 -0
  21. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/scripts/pythia_plsql.egg-info/SOURCES.txt +0 -0
  22. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/scripts/pythia_plsql.egg-info/dependency_links.txt +0 -0
  23. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/scripts/pythia_plsql.egg-info/entry_points.txt +0 -0
  24. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/scripts/pythia_plsql.egg-info/requires.txt +0 -0
  25. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/scripts/pythia_plsql.egg-info/top_level.txt +0 -0
  26. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/setup.cfg +0 -0
  27. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/skills/pythia-explore/SKILL.md +0 -0
  28. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/skills/pythia-explore/reference/data-dictionary.md +0 -0
  29. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/skills/pythia-impact/SKILL.md +0 -0
  30. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/skills/pythia-review/SKILL.md +0 -0
  31. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/skills/pythia-review/reference/antipatterns.md +0 -0
  32. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/skills/pythia-setup/SKILL.md +0 -0
  33. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/skills/pythia-skill-author/SKILL.md +0 -0
  34. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/skills/pythia-write/SKILL.md +0 -0
  35. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/skills/pythia-write/reference/patterns.md +0 -0
  36. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/tests/test_install.py +0 -0
  37. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/tests/test_phase1.py +0 -0
  38. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/tests/test_phase2.py +0 -0
  39. {pythia_plsql-0.3.5 → pythia_plsql-0.4.1}/tests/test_phase5.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythia-plsql
3
- Version: 0.3.5
3
+ Version: 0.4.1
4
4
  Summary: PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/thaildhe172591/pythia
@@ -115,13 +115,19 @@ and WSL are all CI-tested.
115
115
  | `src` source, compiler line numbers | `errors` compile errors, line:col | `policy` show · set |
116
116
  | `args` signatures | `invalid` everything broken | `unistr` exact non-ASCII literals |
117
117
  | `ddl` via DBMS_METADATA | `plscope` exact identifier usages | `agent-user` least-privilege setup |
118
- | `cols` columns + types | `similar` programs named like this | |
118
+ | `cols` columns + types | `similar` programs named like this | `history` every captured version |
119
119
  | `grep` search all source | | |
120
120
  | `sql` free query (SELECT/WITH only) | | |
121
121
 
122
122
  Every command takes `--json` (machine output), `--conn` (pick a connection), and
123
123
  caps output with explicit truncation markers so context windows stay intact.
124
124
 
125
+ **The safety net covers hand edits too**: `src` and `impact` snapshot the
126
+ object silently into the journal, each with a runnable rollback file, so a
127
+ change made later in SQL Developer still has something to go back to —
128
+ `pythia history <OBJECT>` lists the versions. Source that moved with no
129
+ apply behind it is reported as drift.
130
+
125
131
  **Your house style is config, not folklore**: put naming patterns in
126
132
  `.pythia/conventions.json` and apply previews warn when a new object's name
127
133
  drifts; put the prose rules in `.pythia/conventions.md` and the skills make
@@ -101,13 +101,19 @@ and WSL are all CI-tested.
101
101
  | `src` source, compiler line numbers | `errors` compile errors, line:col | `policy` show · set |
102
102
  | `args` signatures | `invalid` everything broken | `unistr` exact non-ASCII literals |
103
103
  | `ddl` via DBMS_METADATA | `plscope` exact identifier usages | `agent-user` least-privilege setup |
104
- | `cols` columns + types | `similar` programs named like this | |
104
+ | `cols` columns + types | `similar` programs named like this | `history` every captured version |
105
105
  | `grep` search all source | | |
106
106
  | `sql` free query (SELECT/WITH only) | | |
107
107
 
108
108
  Every command takes `--json` (machine output), `--conn` (pick a connection), and
109
109
  caps output with explicit truncation markers so context windows stay intact.
110
110
 
111
+ **The safety net covers hand edits too**: `src` and `impact` snapshot the
112
+ object silently into the journal, each with a runnable rollback file, so a
113
+ change made later in SQL Developer still has something to go back to —
114
+ `pythia history <OBJECT>` lists the versions. Source that moved with no
115
+ apply behind it is reported as drift.
116
+
111
117
  **Your house style is config, not folklore**: put naming patterns in
112
118
  `.pythia/conventions.json` and apply previews warn when a new object's name
113
119
  drifts; put the prose rules in `.pythia/conventions.md` and the skills make
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pythia-plsql"
7
- version = "0.3.5"
7
+ version = "0.4.1"
8
8
  description = "PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -42,6 +42,7 @@ you always know whether you saw everything.
42
42
  pythia journal restore <id>
43
43
  pythia policy
44
44
  pythia install
45
+ pythia history MY_PACKAGE
45
46
  pythia unistr "Nhóm không được để trống"
46
47
  pythia agent-user --save
47
48
  """
@@ -631,6 +632,84 @@ def load_settings(root):
631
632
  sys.exit(f"Cannot parse {path}: {e}")
632
633
 
633
634
 
635
+ def source_sha(text):
636
+ import hashlib
637
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
638
+
639
+
640
+ def fetch_source(conn, schema, name):
641
+ """Current source of an object, as ALL_SOURCE holds it. ('', None) when
642
+ the name is not a source object (a table, say)."""
643
+ _, rows = run_query(conn, load_query("source.sql"), {"s": schema, "n": name})
644
+ if not rows:
645
+ return "", None
646
+ return "".join(cell(r[2]) for r in rows), rows[0][0]
647
+
648
+
649
+ def last_known_state(root, name):
650
+ """What the database held, as far as the journal knows: the newest entry
651
+ for this object, and the source that entry says was really there.
652
+ A preview wrote nothing, so its `before` is the real state."""
653
+ for eid in list_journal_entries(root): # newest first
654
+ e = read_journal_entry(root, eid)
655
+ m = e["meta"]
656
+ if str(m.get("object", "")).upper() != name.upper():
657
+ continue
658
+ real = e["after"] if (m.get("applied") or m.get("snapshot")) else e["before"]
659
+ return real, eid, m
660
+ return None, None, None
661
+
662
+
663
+ def auto_snapshot(conn, schema, name, ns):
664
+ """Capture an object's source the moment an agent focuses on it, so a
665
+ later hand-edit outside pythia still has something to go back to.
666
+
667
+ Silent by design: writing to the journal costs the agent no context, and
668
+ a line of "snapshot taken" on every read would cost it on every read.
669
+ The one thing worth saying is drift — the source moved without an apply
670
+ of ours explaining it — and that goes to stderr, so --json stays clean.
671
+
672
+ Returns a drift warning, or None.
673
+ """
674
+ root = getattr(ns, "project_root", None)
675
+ if root is None or not load_settings(root).get("auto_snapshot", True):
676
+ return None
677
+ try:
678
+ text, otype = fetch_source(conn, schema, name)
679
+ if not text.strip():
680
+ return None # not a source object; nothing to keep
681
+ known, eid, meta = last_known_state(root, name)
682
+ if known is not None and source_sha(known) == source_sha(text):
683
+ return None # unchanged — no entry, no output
684
+ write_journal_entry(root, otype or "OBJECT", name.upper(), text, text,
685
+ {"schema": schema, "connection": ns.conn_name,
686
+ "snapshot": True, "applied": False,
687
+ "sha": source_sha(text)})
688
+ if known is None:
689
+ return None # first sighting is a baseline
690
+ rollback = journal_root(root) / eid / "restore.sql"
691
+ return (f"! {name.upper()} changed outside pythia since {eid}.\n"
692
+ f" Rollback file for the previous version: {rollback}\n"
693
+ f" All versions: {invocation()} history {name.upper()}")
694
+ except Exception: # noqa: BLE001
695
+ return None # a safety net must never break a read
696
+
697
+
698
+ def report_drift(msg):
699
+ if msg:
700
+ print(paint(msg, "yellow", color_enabled(sys.stderr)), file=sys.stderr)
701
+
702
+
703
+ def journaled_objects(root):
704
+ names = {}
705
+ for eid in list_journal_entries(root): # newest first
706
+ m = read_journal_entry(root, eid)["meta"]
707
+ n = str(m.get("object", "")).upper()
708
+ if n and n != "STATEMENT" and n not in names:
709
+ names[n] = eid[:19] # the entry's timestamp
710
+ return names
711
+
712
+
634
713
  def undo_group_action(ns):
635
714
  """Undoing a CREATE is a DROP, which is `structural` — so the policy on
636
715
  that group decides whether the restore command we print can run."""
@@ -827,6 +906,43 @@ def cmd_check(conn, schema, ns):
827
906
  if warn:
828
907
  print("\n" + paint(warn, "yellow", color_enabled(sys.stderr)),
829
908
  file=sys.stderr)
909
+ report_drift(drift_summary(conn, schema, ns))
910
+
911
+
912
+ def drift_summary(conn, schema, ns):
913
+ """One line, only when objects pythia knows have moved since it last saw
914
+ them. Uses LAST_DDL_TIME — a single query for every journaled object,
915
+ instead of re-reading every source.
916
+ ponytail: LAST_DDL_TIME also ticks on a bare recompile, so this is a
917
+ 'go look' signal; `history` and the src/impact hash comparison are the
918
+ precise ones."""
919
+ root = getattr(ns, "project_root", None)
920
+ if root is None:
921
+ return None
922
+ try:
923
+ known = journaled_objects(root)
924
+ if not known:
925
+ return None
926
+ names = sorted(known)[:200] # one query, bounded
927
+ binds = {"s": schema}
928
+ placeholders = []
929
+ for i, n in enumerate(names):
930
+ binds[f"n{i}"] = n
931
+ placeholders.append(f":n{i}")
932
+ _, rows = run_query(
933
+ conn, "select object_name, to_char(last_ddl_time,"
934
+ "'yyyy-mm-dd\"T\"hh24-mi-ss') from all_objects "
935
+ f"where owner = :s and object_name in ({','.join(placeholders)})",
936
+ binds)
937
+ moved = [r[0] for r in rows if str(r[1]) > known.get(str(r[0]), "")]
938
+ if not moved:
939
+ return None
940
+ shown = ", ".join(moved[:3]) + ("…" if len(moved) > 3 else "")
941
+ return (f"! {len(moved)} object(s) changed since pythia last saw them: "
942
+ f"{shown}\n Versions and restores: {invocation()} history "
943
+ f"<NAME>")
944
+ except Exception: # noqa: BLE001
945
+ return None
830
946
 
831
947
 
832
948
  def cmd_ls(conn, schema, ns):
@@ -846,8 +962,10 @@ def cmd_src(conn, schema, ns):
846
962
  if not rows:
847
963
  sys.exit(f"No source found for {ns.name!r} in schema {schema}.")
848
964
  total = len(rows)
965
+ drift = auto_snapshot(conn, schema, ns.name, ns)
849
966
  shown, truncated = clip(rows, ns.max_lines, ns.offset)
850
967
  emit_source(ns, shown, truncated, total)
968
+ report_drift(drift)
851
969
 
852
970
 
853
971
  def cmd_args(conn, schema, ns):
@@ -955,19 +1073,26 @@ def cmd_deps(conn, schema, ns):
955
1073
  def cmd_impact(conn, schema, ns):
956
1074
  cols, rows = run_query(conn, load_query("impact.sql"),
957
1075
  {"s": schema, "n": ns.name, "depth": ns.depth})
1076
+ # impact is mandatory before any change, so it is the surest "about to
1077
+ # touch this" signal there is — the best possible place to capture a
1078
+ # before-state the developer never had to ask for
1079
+ drift = auto_snapshot(conn, schema, ns.name, ns)
958
1080
  shown, truncated = clip(rows, ns.limit)
959
1081
  if ns.json:
960
1082
  print(json_envelope(ns.command, ns.conn_name, ns.schema, cols, shown, truncated,
961
1083
  summary=impact_summary(rows)))
1084
+ report_drift(drift)
962
1085
  return
963
1086
  if not rows:
964
1087
  print(f"-- nothing depends on {ns.name.upper()} "
965
1088
  f"(within {schema}, depth {ns.depth})")
1089
+ report_drift(drift)
966
1090
  return
967
1091
  sys.stdout.write(render_tree(shown, f"{schema}.{ns.name.upper()}"))
968
1092
  if truncated:
969
1093
  print(f"-- truncated at {len(shown)} rows (raise --limit, or --limit 0 for no cap)")
970
1094
  print(impact_summary(rows))
1095
+ report_drift(drift)
971
1096
 
972
1097
 
973
1098
  def cmd_similar(conn, schema, ns):
@@ -1212,6 +1337,11 @@ def run_apply(conn, schema, ns, file_text, origin=None):
1212
1337
  print(f" {paint_diff_line(ln, en)}")
1213
1338
  print(paint(f"\n Snapshot saved: {journal_root(ns.project_root) / entry}",
1214
1339
  "dim", en))
1340
+ if not confirmed:
1341
+ print(paint(" Rollback file for the current database version — "
1342
+ "use it if this is\n run by hand instead: "
1343
+ f"{journal_root(ns.project_root) / entry / 'restore.sql'}",
1344
+ "dim", en))
1215
1345
  if not confirmed:
1216
1346
  print(f"\n To apply:\n "
1217
1347
  + paint(f"{invocation()} apply {ns.file} --confirm {token}",
@@ -1351,6 +1481,48 @@ def cmd_policy(conn, schema, ns):
1351
1481
  print(ROLLBACK_TABLE)
1352
1482
 
1353
1483
 
1484
+ def cmd_history(conn, schema, ns):
1485
+ """Every version of one object the journal holds, newest first. Compact
1486
+ on purpose — an agent reads this to choose an id, not to read source."""
1487
+ root = ns.project_root
1488
+ name = ns.name.upper()
1489
+ rows = []
1490
+ for eid in list_journal_entries(root):
1491
+ e = read_journal_entry(root, eid)
1492
+ m = e["meta"]
1493
+ if str(m.get("object", "")).upper() != name:
1494
+ continue
1495
+ real = e["after"] if (m.get("applied") or m.get("snapshot")) else e["before"]
1496
+ kind = ("applied" if m.get("applied") else
1497
+ "snapshot" if m.get("snapshot") else "preview")
1498
+ rows.append({"entry": eid, "kind": kind,
1499
+ "lines": len(real.splitlines()),
1500
+ "sha": source_sha(real),
1501
+ "rollback_file": str(journal_root(root) / eid
1502
+ / "restore.sql")})
1503
+ if ns.json:
1504
+ print(json.dumps({"object": name, "versions": rows}))
1505
+ return
1506
+ if not rows:
1507
+ print(f"-- no journal history for {name}. It is captured the first "
1508
+ f"time you run `{invocation()} src {name}` or "
1509
+ f"`{invocation()} impact {name}`.")
1510
+ return
1511
+ prev = None
1512
+ for r in rows: # newest first; compare to the older one
1513
+ older = rows[rows.index(r) + 1] if rows.index(r) + 1 < len(rows) else None
1514
+ delta = ""
1515
+ if older and older["sha"] != r["sha"]:
1516
+ n = r["lines"] - older["lines"]
1517
+ delta = f" {n:+d} lines" if n else " content changed"
1518
+ print(f" {r['entry']:<44} {r['kind']:<9} {r['lines']:>5} lines{delta}")
1519
+ prev = r
1520
+ print(f"\n-- every version above has a ready-to-run rollback file:")
1521
+ print(f" {journal_root(root)}\\<entry>\\restore.sql")
1522
+ print(f"-- through pythia (previews first, you approve):")
1523
+ print(f" {invocation()} journal restore <entry>")
1524
+
1525
+
1354
1526
  def cmd_journal(conn, schema, ns):
1355
1527
  root = ns.project_root
1356
1528
  if ns.action == "list":
@@ -1369,15 +1541,29 @@ def cmd_journal(conn, schema, ns):
1369
1541
  if ns.action == "prune":
1370
1542
  import shutil
1371
1543
  removed = 0
1372
- for eid in list_journal_entries(root):
1373
- if not read_journal_entry(root, eid)["meta"].get("applied"):
1544
+ # A preview wrote nothing to the database, but its restore.sql holds
1545
+ # the version that was live at the time — the only rollback there is
1546
+ # for a change the developer then ran by hand. So drop a preview only
1547
+ # when its rollback is byte-identical to one a newer entry already
1548
+ # keeps: redundant captures go, unique ones never do.
1549
+ kept = {}
1550
+ for eid in list_journal_entries(root): # newest first
1551
+ e = read_journal_entry(root, eid)
1552
+ meta, obj = e["meta"], str(e["meta"].get("object", ""))
1553
+ if meta.get("applied") or meta.get("snapshot"):
1554
+ kept.setdefault(obj, set()).add(e["restore"])
1555
+ continue
1556
+ if e["restore"] in kept.get(obj, set()):
1374
1557
  shutil.rmtree(journal_root(root) / eid)
1375
1558
  removed += 1
1559
+ else:
1560
+ kept.setdefault(obj, set()).add(e["restore"])
1376
1561
  if ns.json:
1377
1562
  print(json.dumps({"pruned": removed}))
1378
1563
  else:
1379
- print(f"-- pruned {removed} preview-only entries; applied entries "
1380
- "(the real snapshots) are all kept")
1564
+ print(f"-- pruned {removed} redundant previews; applied entries, "
1565
+ "snapshots, and any preview holding a rollback nothing "
1566
+ "else has are all kept")
1381
1567
  return
1382
1568
  if not ns.id:
1383
1569
  sys.exit(f"Usage: {invocation()} journal "
@@ -1752,9 +1938,11 @@ COMMANDS = {"check": cmd_check, "ls": cmd_ls, "src": cmd_src, "args": cmd_args,
1752
1938
  "impact": cmd_impact, "similar": cmd_similar, "plscope": cmd_plscope,
1753
1939
  "policy": cmd_policy, "journal": cmd_journal, "apply": cmd_apply,
1754
1940
  "conventions": cmd_conventions, "install": cmd_install,
1755
- "unistr": cmd_unistr, "agent-user": cmd_agent_user}
1941
+ "unistr": cmd_unistr, "agent-user": cmd_agent_user,
1942
+ "history": cmd_history}
1756
1943
 
1757
- NO_DB_COMMANDS = {"policy", "journal", "conventions", "install", "unistr"}
1944
+ NO_DB_COMMANDS = {"policy", "journal", "conventions", "install", "unistr",
1945
+ "history"}
1758
1946
 
1759
1947
 
1760
1948
  # --- CLI ---------------------------------------------------------------------
@@ -1860,6 +2048,10 @@ def build_parser():
1860
2048
  s.add_argument("--save", action="store_true",
1861
2049
  help="add the new credential as <conn>_agent and make it "
1862
2050
  "the default connection")
2051
+ s = sub.add_parser("history", parents=[common()],
2052
+ help="every captured version of one object, newest "
2053
+ "first — pick one to restore")
2054
+ s.add_argument("name")
1863
2055
  s = sub.add_parser("unistr", parents=[common()],
1864
2056
  help="Oracle unistr('...') literal for non-ASCII text "
1865
2057
  "(Vietnamese messages stay exact)")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythia-plsql
3
- Version: 0.3.5
3
+ Version: 0.4.1
4
4
  Summary: PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/thaildhe172591/pythia
@@ -115,13 +115,19 @@ and WSL are all CI-tested.
115
115
  | `src` source, compiler line numbers | `errors` compile errors, line:col | `policy` show · set |
116
116
  | `args` signatures | `invalid` everything broken | `unistr` exact non-ASCII literals |
117
117
  | `ddl` via DBMS_METADATA | `plscope` exact identifier usages | `agent-user` least-privilege setup |
118
- | `cols` columns + types | `similar` programs named like this | |
118
+ | `cols` columns + types | `similar` programs named like this | `history` every captured version |
119
119
  | `grep` search all source | | |
120
120
  | `sql` free query (SELECT/WITH only) | | |
121
121
 
122
122
  Every command takes `--json` (machine output), `--conn` (pick a connection), and
123
123
  caps output with explicit truncation markers so context windows stay intact.
124
124
 
125
+ **The safety net covers hand edits too**: `src` and `impact` snapshot the
126
+ object silently into the journal, each with a runnable rollback file, so a
127
+ change made later in SQL Developer still has something to go back to —
128
+ `pythia history <OBJECT>` lists the versions. Source that moved with no
129
+ apply behind it is reported as drift.
130
+
125
131
  **Your house style is config, not folklore**: put naming patterns in
126
132
  `.pythia/conventions.json` and apply previews warn when a new object's name
127
133
  drifts; put the prose rules in `.pythia/conventions.md` and the skills make
@@ -31,6 +31,21 @@ Three moves are FORBIDDEN for agents, and the CLI enforces the first two
31
31
  your turn; the token is only used after the developer's approval
32
32
  arrives as a NEW message.
33
33
 
34
+ ## When the change will NOT go through apply
35
+
36
+ Sometimes the developer runs the file themselves — a DBA executes it, a
37
+ release process owns it, or policy denies the group. The preview still ran,
38
+ so a rollback file for the version currently live already exists. Hand it
39
+ over in the same message as the .sql file, by path:
40
+
41
+ ```
42
+ .pythia/journal/<entry>/restore.sql
43
+ ```
44
+
45
+ Never let a change leave your hands for manual execution without naming
46
+ that file. `pythia history <OBJECT>` lists every captured version if an
47
+ older one is wanted.
48
+
34
49
  ## The Workflow
35
50
 
36
51
  **Before step 1:** if this conversation has not yet seen a standalone impact
@@ -540,25 +540,42 @@ def test_privilege_warning_speaks_proxy():
540
540
  FakeConn(direct), "APP", "APP")
541
541
 
542
542
 
543
- def test_journal_prune_keeps_applied_entries():
543
+ def test_prune_drops_only_redundant_previews():
544
+ """A preview wrote nothing, but its restore.sql is the live version at
545
+ that moment — the only rollback for a change run by hand. Prune may drop
546
+ it only when a newer entry already keeps the identical rollback."""
544
547
  import argparse
548
+ import contextlib
545
549
  import datetime
550
+ import io
546
551
  with tempfile.TemporaryDirectory() as td:
547
- base = datetime.datetime(2026, 8, 26, 12, 0, 0)
548
- pythia.write_journal_entry(td, "PROCEDURE", "P_PREVIEW", "a", "b",
552
+ base = datetime.datetime(2026, 8, 27, 9, 0, 0)
553
+ # two previews of the same object, same live version -> one is spare
554
+ pythia.write_journal_entry(td, "PROCEDURE", "P_A", "old", "new1",
549
555
  {"applied": False}, now=base)
550
- kept = pythia.write_journal_entry(
551
- td, "PROCEDURE", "P_APPLIED", "a", "b", {"applied": True},
556
+ keep_dup = pythia.write_journal_entry(
557
+ td, "PROCEDURE", "P_A", "old", "new2", {"applied": False},
552
558
  now=base.replace(minute=1))
559
+ # a preview of another object, unique rollback -> must survive
560
+ lone = pythia.write_journal_entry(
561
+ td, "PROCEDURE", "P_B", "only_copy", "x", {"applied": False},
562
+ now=base.replace(minute=2))
563
+ applied = pythia.write_journal_entry(
564
+ td, "PROCEDURE", "P_C", "a", "b", {"applied": True},
565
+ now=base.replace(minute=3))
566
+ snap = pythia.write_journal_entry(
567
+ td, "PROCEDURE", "P_D", "a", "a",
568
+ {"snapshot": True, "applied": False}, now=base.replace(minute=4))
553
569
  ns = argparse.Namespace(action="prune", project_root=td, json=False,
554
570
  id=None)
555
- import contextlib
556
- import io
557
571
  buf = io.StringIO()
558
572
  with contextlib.redirect_stdout(buf):
559
573
  pythia.cmd_journal(None, None, ns)
560
- assert "pruned 1" in buf.getvalue()
561
- assert pythia.list_journal_entries(td) == [kept]
574
+ left = pythia.list_journal_entries(td)
575
+ assert "pruned 1" in buf.getvalue(), buf.getvalue()
576
+ assert applied in left and snap in left # never touched
577
+ assert lone in left # unique rollback survives
578
+ assert keep_dup in left # newest of the pair kept
562
579
 
563
580
 
564
581
  def test_agent_user_alter_form_and_warning_prediction():
@@ -687,6 +704,61 @@ def test_report_admits_a_created_objects_undo_is_blocked():
687
704
  assert "will be refused" not in buf.getvalue().lower()
688
705
 
689
706
 
707
+ def test_auto_snapshot_captures_silently_and_writes_a_rollback_file():
708
+ """Every captured version leaves a runnable rollback file — that is the
709
+ whole point for work done by hand, outside pythia. And capturing costs
710
+ the agent no context: nothing is printed when nothing moved."""
711
+ import argparse
712
+ import contextlib
713
+ import io
714
+ src = "PROCEDURE P_X AS\nBEGIN\n NULL;\nEND;\n"
715
+ script = {"from all_source": ([("TYPE", "LINE", "TEXT")],
716
+ [("PROCEDURE", i + 1, ln + "\n")
717
+ for i, ln in enumerate(src.splitlines())])}
718
+ with tempfile.TemporaryDirectory() as td:
719
+ ns = argparse.Namespace(project_root=td, conn_name="DEV")
720
+ buf = io.StringIO()
721
+ with contextlib.redirect_stdout(buf):
722
+ drift = pythia.auto_snapshot(FakeConn(script), "APP", "P_X", ns)
723
+ assert drift is None # first sighting: a baseline
724
+ assert buf.getvalue() == "" # and not one token of output
725
+ ids = pythia.list_journal_entries(td)
726
+ assert len(ids) == 1
727
+ e = pythia.read_journal_entry(td, ids[0])
728
+ assert e["meta"]["snapshot"] is True
729
+ rollback = e["restore"]
730
+ assert rollback.startswith("CREATE OR REPLACE PROCEDURE P_X")
731
+ assert (pythia.journal_root(td) / ids[0] / "restore.sql").is_file()
732
+
733
+ # reading it again unchanged must not create a second entry
734
+ assert pythia.auto_snapshot(FakeConn(script), "APP", "P_X", ns) is None
735
+ assert pythia.list_journal_entries(td) == ids
736
+
737
+
738
+ def test_auto_snapshot_reports_drift_and_keeps_the_old_rollback():
739
+ """Source moved with no apply of ours behind it — say so once, and point
740
+ at the rollback file for the version that was there before."""
741
+ import argparse
742
+ old_src = "PROCEDURE P_X AS\nBEGIN\n NULL;\nEND;\n"
743
+ new_src = "PROCEDURE P_X AS\nBEGIN\n other_thing;\nEND;\n"
744
+
745
+ def script(text):
746
+ return {"from all_source": ([("TYPE", "LINE", "TEXT")],
747
+ [("PROCEDURE", i + 1, ln + "\n")
748
+ for i, ln in enumerate(text.splitlines())])}
749
+ with tempfile.TemporaryDirectory() as td:
750
+ ns = argparse.Namespace(project_root=td, conn_name="DEV")
751
+ pythia.auto_snapshot(FakeConn(script(old_src)), "APP", "P_X", ns)
752
+ first = pythia.list_journal_entries(td)[0]
753
+ drift = pythia.auto_snapshot(FakeConn(script(new_src)), "APP", "P_X", ns)
754
+ assert drift and "changed outside pythia" in drift
755
+ assert "restore.sql" in drift
756
+ # the older version's rollback is still on disk and still runnable
757
+ old_rollback = pythia.read_journal_entry(td, first)["restore"]
758
+ assert "NULL;" in old_rollback
759
+ assert len(pythia.list_journal_entries(td)) == 2
760
+
761
+
690
762
  def main():
691
763
  failed = 0
692
764
  for name, fn in sorted(globals().items()):
File without changes
File without changes