pythia-plsql 0.7.1__tar.gz → 0.8.0__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 (44) hide show
  1. {pythia_plsql-0.7.1/scripts/pythia_plsql.egg-info → pythia_plsql-0.8.0}/PKG-INFO +13 -9
  2. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/README.md +12 -8
  3. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/pyproject.toml +1 -1
  4. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/scripts/pythia.py +268 -8
  5. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0/scripts/pythia_plsql.egg-info}/PKG-INFO +13 -9
  6. pythia_plsql-0.8.0/skills/pythia-apply/SKILL.md +149 -0
  7. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/tests/test_phase3.py +373 -0
  8. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/tests/test_phase5.py +21 -0
  9. pythia_plsql-0.7.1/skills/pythia-apply/SKILL.md +0 -147
  10. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/LICENSE +0 -0
  11. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/compile-errors.sql +0 -0
  12. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/dependencies.sql +0 -0
  13. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/impact.sql +0 -0
  14. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/invalid-objects.sql +0 -0
  15. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/name-occupants.sql +0 -0
  16. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/object-names.sql +0 -0
  17. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/object-source.sql +0 -0
  18. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/plscope-enabled.sql +0 -0
  19. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/plscope-statements.sql +0 -0
  20. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/plscope-usages.sql +0 -0
  21. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/session-privileges.sql +0 -0
  22. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/similar-candidates.sql +0 -0
  23. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/source.sql +0 -0
  24. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/scripts/pythia_plsql.egg-info/SOURCES.txt +0 -0
  25. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/scripts/pythia_plsql.egg-info/dependency_links.txt +0 -0
  26. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/scripts/pythia_plsql.egg-info/entry_points.txt +0 -0
  27. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/scripts/pythia_plsql.egg-info/requires.txt +0 -0
  28. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/scripts/pythia_plsql.egg-info/top_level.txt +0 -0
  29. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/setup.cfg +0 -0
  30. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/pythia-conventions/SKILL.md +0 -0
  31. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/pythia-explore/SKILL.md +0 -0
  32. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/pythia-explore/reference/data-dictionary.md +0 -0
  33. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/pythia-impact/SKILL.md +0 -0
  34. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/pythia-review/SKILL.md +0 -0
  35. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/pythia-review/reference/antipatterns.md +0 -0
  36. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/pythia-setup/SKILL.md +0 -0
  37. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/pythia-skill-author/SKILL.md +0 -0
  38. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/pythia-spec/SKILL.md +0 -0
  39. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/pythia-write/SKILL.md +0 -0
  40. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/pythia-write/reference/patterns.md +0 -0
  41. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/using-pythia/SKILL.md +0 -0
  42. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/tests/test_install.py +0 -0
  43. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/tests/test_phase1.py +0 -0
  44. {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/tests/test_phase2.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythia-plsql
3
- Version: 0.7.1
3
+ Version: 0.8.0
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
@@ -81,7 +81,8 @@ is the missing input, and forbids it to guess past them:
81
81
 
82
82
  - **Before any write**: the full preview — diff, dependents, warnings — is
83
83
  relayed verbatim, and the agent waits for a real yes. A compliment is not a
84
- yes. Silence is not a yes.
84
+ yes. Silence is not a yes. And the wait is no longer a matter of obedience:
85
+ the write cannot complete until the developer mints the approval themselves.
85
86
  - **When the blast radius is large**: ten or more dependents, or anything
86
87
  cross-schema, goes to the developer *before code is written*, not after.
87
88
  - **When sources of truth disagree**: a standards document says one thing,
@@ -96,12 +97,15 @@ is the missing input, and forbids it to guess past them:
96
97
  ### 3 · Do — act inside a pipeline that cannot lie
97
98
 
98
99
  Only after Learn and Ask does anything touch the database, and then only
99
- through one door: **snapshot → impact → preview → token → apply → verify →
100
- report**. DDL self-commits in Oracle, so the snapshot is the only real undo —
101
- it runs first and no flag disables it. A content-bound token guarantees what
102
- lands is byte-for-byte what was approved. And the CLI enforces the gates
103
- itself: a headless agent cannot `--yes` its own writes or loosen policy —
104
- that takes a human at a real terminal.
100
+ through one door: **snapshot → impact → preview → token → approve → apply →
101
+ verify → report**. DDL self-commits in Oracle, so the snapshot is the only real
102
+ undo — it runs first and no flag disables it. A content-bound token guarantees
103
+ what lands is byte-for-byte what was previewed, and a **developer-minted grant**
104
+ guarantees a person approved it at all: `pythia approve` runs only at a human's
105
+ own console, and without its one-time grant `apply --confirm` refuses. The
106
+ agent's command line is unchanged; what changed is that it now stops until
107
+ someone acts. A headless agent cannot `--yes` its own writes, cannot approve
108
+ them, and cannot loosen policy — each of those takes a human at a real terminal.
105
109
 
106
110
  The same discipline holds when the *developer* does the work: `src` and
107
111
  `impact` silently snapshot what they read, so even a change made by hand in
@@ -162,7 +166,7 @@ and WSL are all CI-tested.
162
166
  | `args` signatures | `invalid` everything broken | `unistr` exact non-ASCII literals |
163
167
  | `ddl` via DBMS_METADATA | `plscope` exact identifier usages | `agent-user` least-privilege setup |
164
168
  | `cols` columns + types | `similar` programs named like this | `history` every captured version |
165
- | `grep` search all source | | |
169
+ | `grep` search all source | | `approve` the developer's one-time grant |
166
170
  | `sql` free query (SELECT/WITH only) | | |
167
171
 
168
172
  Every command takes `--json` (machine output), `--conn` (pick a connection), and
@@ -67,7 +67,8 @@ is the missing input, and forbids it to guess past them:
67
67
 
68
68
  - **Before any write**: the full preview — diff, dependents, warnings — is
69
69
  relayed verbatim, and the agent waits for a real yes. A compliment is not a
70
- yes. Silence is not a yes.
70
+ yes. Silence is not a yes. And the wait is no longer a matter of obedience:
71
+ the write cannot complete until the developer mints the approval themselves.
71
72
  - **When the blast radius is large**: ten or more dependents, or anything
72
73
  cross-schema, goes to the developer *before code is written*, not after.
73
74
  - **When sources of truth disagree**: a standards document says one thing,
@@ -82,12 +83,15 @@ is the missing input, and forbids it to guess past them:
82
83
  ### 3 · Do — act inside a pipeline that cannot lie
83
84
 
84
85
  Only after Learn and Ask does anything touch the database, and then only
85
- through one door: **snapshot → impact → preview → token → apply → verify →
86
- report**. DDL self-commits in Oracle, so the snapshot is the only real undo —
87
- it runs first and no flag disables it. A content-bound token guarantees what
88
- lands is byte-for-byte what was approved. And the CLI enforces the gates
89
- itself: a headless agent cannot `--yes` its own writes or loosen policy —
90
- that takes a human at a real terminal.
86
+ through one door: **snapshot → impact → preview → token → approve → apply →
87
+ verify → report**. DDL self-commits in Oracle, so the snapshot is the only real
88
+ undo — it runs first and no flag disables it. A content-bound token guarantees
89
+ what lands is byte-for-byte what was previewed, and a **developer-minted grant**
90
+ guarantees a person approved it at all: `pythia approve` runs only at a human's
91
+ own console, and without its one-time grant `apply --confirm` refuses. The
92
+ agent's command line is unchanged; what changed is that it now stops until
93
+ someone acts. A headless agent cannot `--yes` its own writes, cannot approve
94
+ them, and cannot loosen policy — each of those takes a human at a real terminal.
91
95
 
92
96
  The same discipline holds when the *developer* does the work: `src` and
93
97
  `impact` silently snapshot what they read, so even a change made by hand in
@@ -148,7 +152,7 @@ and WSL are all CI-tested.
148
152
  | `args` signatures | `invalid` everything broken | `unistr` exact non-ASCII literals |
149
153
  | `ddl` via DBMS_METADATA | `plscope` exact identifier usages | `agent-user` least-privilege setup |
150
154
  | `cols` columns + types | `similar` programs named like this | `history` every captured version |
151
- | `grep` search all source | | |
155
+ | `grep` search all source | | `approve` the developer's one-time grant |
152
156
  | `sql` free query (SELECT/WITH only) | | |
153
157
 
154
158
  Every command takes `--json` (machine output), `--conn` (pick a connection), and
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pythia-plsql"
7
- version = "0.7.1"
7
+ version = "0.8.0"
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" }
@@ -380,6 +380,39 @@ HEADLESS_YES_MSG = (
380
380
  "approval in chat.\n(Real pipelines set PYTHIA_CI=1.)")
381
381
 
382
382
 
383
+ APPROVE_HEADLESS_MSG = (
384
+ "approve is the developer's command — it needs a real console.\n"
385
+ "Agents relay the preview and wait; a person runs approve in their own "
386
+ "terminal.\n(Real pipelines apply with --yes and PYTHIA_CI=1 instead.)")
387
+
388
+
389
+ def grant_refusal(status, token, grant, conn_name, file_hint):
390
+ """Why this write is refused, and the exact way to fix it. Each status
391
+ gets its own sentence: 'no' without the reason teaches nothing."""
392
+ approve_line = f" {invocation()} approve {token}"
393
+ preview_line = f" {invocation()} apply {file_hint}"
394
+ if status == "missing":
395
+ return ("The confirmation token matches, but no developer approval is "
396
+ "on file.\nAsk the developer to run, in their own terminal "
397
+ f"(agents cannot run it):\n{approve_line}\n"
398
+ "Then re-run this command.")
399
+ if status == "expired":
400
+ return (f"That approval expired ({GRANT_TTL_MINUTES} minutes after it "
401
+ "was given).\nAsk the developer to approve again:\n"
402
+ f"{approve_line}")
403
+ if status == "spent":
404
+ return ("That approval was already used by a previous apply — one "
405
+ "approval, one write.\nPreview again, then ask the developer "
406
+ f"to approve the new token:\n{preview_line}")
407
+ if status == "wrong_conn":
408
+ return (f"That approval was given on connection "
409
+ f"{grant.get('conn', '?')!r}, but this session targets "
410
+ f"{conn_name!r}.\nApproving on one database does not approve "
411
+ "another. On the right connection:\n"
412
+ f"{preview_line} --conn {conn_name}")
413
+ return "Refused: the developer approval on file is not valid."
414
+
415
+
383
416
  def human_at_the_keyboard():
384
417
  """True only when a person is typing at a real console.
385
418
 
@@ -407,6 +440,33 @@ def human_at_the_keyboard():
407
440
  return True
408
441
 
409
442
 
443
+ def console_session(ns=None):
444
+ """A real console, with no environment escape.
445
+
446
+ human_at_the_keyboard() honours PYTHIA_CI so real pipelines can apply
447
+ with --yes. approve gets no such door: a pipeline that wants to write
448
+ already has one, and a second escape would be the way around this gate.
449
+ `ns.console` is the test seam and nothing sets it in production."""
450
+ forced = getattr(ns, "console", None)
451
+ if forced is not None:
452
+ return bool(forced)
453
+ try:
454
+ if not sys.stdin.isatty():
455
+ return False
456
+ except (AttributeError, ValueError):
457
+ return False
458
+ if os.name == "nt":
459
+ try:
460
+ import ctypes
461
+ handle = ctypes.windll.kernel32.GetStdHandle(-10) # STD_INPUT
462
+ mode = ctypes.c_ulong()
463
+ return bool(ctypes.windll.kernel32.GetConsoleMode(
464
+ handle, ctypes.byref(mode)))
465
+ except Exception: # noqa: BLE001 — any failure means "not a console"
466
+ return False
467
+ return True
468
+
469
+
410
470
  def forbid_write_flag(argv):
411
471
  if "--write" in argv:
412
472
  sys.exit(f"There is no --write flag. The write path is `{invocation()} "
@@ -891,6 +951,113 @@ def journal_root(root):
891
951
  return pathlib.Path(root) / CONFIG_DIR / "journal"
892
952
 
893
953
 
954
+ # --- approval grants ---------------------------------------------------------
955
+ #
956
+ # The confirmation token proves the CONTENT did not move. A grant proves a
957
+ # HUMAN approved it. They are separate facts, so they are separate objects:
958
+ # the token is derived from the file and the database, the grant is minted by
959
+ # a person at a console (`pythia approve`) and spent by the apply that follows.
960
+
961
+ GRANT_TTL_MINUTES = 15
962
+
963
+
964
+ def grants_root(root):
965
+ return pathlib.Path(root) / CONFIG_DIR / "grants"
966
+
967
+
968
+ def grant_path(root, token):
969
+ return grants_root(root) / f"{token}.json"
970
+
971
+
972
+ def mint_grant(root, token, conn_name, now=None):
973
+ """Record one developer approval. Single use, short-lived, bound to the
974
+ connection the preview ran on."""
975
+ import datetime
976
+ now = now or datetime.datetime.now()
977
+ rec = {"token": token,
978
+ "conn": conn_name or "",
979
+ "minted_at": now.isoformat(timespec="seconds"),
980
+ "expires_at": (now + datetime.timedelta(
981
+ minutes=GRANT_TTL_MINUTES)).isoformat(timespec="seconds"),
982
+ "used_at": None,
983
+ # reserved: the data_dml spec re-checks the affected row set here
984
+ "revalidate": None}
985
+ d = grants_root(root)
986
+ d.mkdir(parents=True, exist_ok=True)
987
+ grant_path(root, token).write_text(json.dumps(rec, indent=2) + "\n",
988
+ encoding="utf-8")
989
+ return rec
990
+
991
+
992
+ def read_grant(root, token):
993
+ """The grant on file, or None. A corrupt or half-written file is not an
994
+ approval — it reads as absent, and absent refuses."""
995
+ p = grant_path(root, token)
996
+ if not p.is_file():
997
+ return None
998
+ try:
999
+ rec = json.loads(p.read_text(encoding="utf-8"))
1000
+ except (ValueError, OSError):
1001
+ return None
1002
+ return rec if isinstance(rec, dict) else None
1003
+
1004
+
1005
+ def grant_status(grant, conn_name, now=None):
1006
+ """Why this grant does or does not authorize a write right now.
1007
+
1008
+ Returns one of: ok, missing, expired, spent, wrong_conn. Each has its own
1009
+ refusal in apply, because "no" without the reason teaches nothing."""
1010
+ import datetime
1011
+ if not grant:
1012
+ return "missing"
1013
+ if grant.get("used_at"):
1014
+ return "spent"
1015
+ now = now or datetime.datetime.now()
1016
+ try:
1017
+ expires = datetime.datetime.fromisoformat(str(grant.get("expires_at")))
1018
+ except ValueError:
1019
+ return "expired" # unreadable stamp is not a licence
1020
+ if now > expires:
1021
+ return "expired"
1022
+ # connection names are typed by humans into connections.json; DEV and dev
1023
+ # are the same connection, and an approval must not hinge on the spelling
1024
+ if str(grant.get("conn", "")).upper() != str(conn_name or "").upper():
1025
+ return "wrong_conn"
1026
+ return "ok"
1027
+
1028
+
1029
+ def spend_grant(root, token, now=None):
1030
+ """One apply per approval. Stamped in the same run that wrote."""
1031
+ import datetime
1032
+ rec = read_grant(root, token)
1033
+ if not rec:
1034
+ return
1035
+ rec["used_at"] = (now or datetime.datetime.now()).isoformat(
1036
+ timespec="seconds")
1037
+ grant_path(root, token).write_text(json.dumps(rec, indent=2) + "\n",
1038
+ encoding="utf-8")
1039
+
1040
+
1041
+ def prune_expired_grants(root, now=None):
1042
+ """Expired grants are litter, not state: approve sweeps them on the way
1043
+ past. No prune command, no cron."""
1044
+ import datetime
1045
+ now = now or datetime.datetime.now()
1046
+ d = grants_root(root)
1047
+ if not d.is_dir():
1048
+ return 0
1049
+ gone = 0
1050
+ for p in sorted(d.glob("*.json")):
1051
+ rec = read_grant(root, p.stem)
1052
+ if rec is None or grant_status(rec, rec.get("conn"), now=now) == "expired":
1053
+ try:
1054
+ p.unlink()
1055
+ gone += 1
1056
+ except OSError:
1057
+ pass
1058
+ return gone
1059
+
1060
+
894
1061
  def render_restore(obj_type, name, before_text):
895
1062
  """The statement that puts things back. For an object that did not exist,
896
1063
  undo means DROP — a genuinely different promise than restoring source, so
@@ -1677,18 +1844,28 @@ def run_apply(conn, schema, ns, file_text, origin=None):
1677
1844
  token = apply_token(otype, name, file_text, db_source)
1678
1845
  if ns.yes and not human_at_the_keyboard():
1679
1846
  sys.exit(HEADLESS_YES_MSG)
1680
- confirmed = bool(ns.yes) or ns.confirm == token
1681
1847
  if ns.confirm and ns.confirm != token:
1682
1848
  sys.exit("The confirmation token does not match: the file or the "
1683
1849
  "database object changed since that preview. Preview again:\n"
1684
1850
  f" {invocation()} apply {ns.file}")
1851
+ # The token proves the content did not move; the grant proves a human
1852
+ # approved it. --yes is itself that human act, at a real console.
1853
+ grant = None
1854
+ if ns.confirm == token:
1855
+ grant = read_grant(ns.project_root, token)
1856
+ status = grant_status(grant, ns.conn_name)
1857
+ if status != "ok":
1858
+ sys.exit(grant_refusal(status, token, grant or {},
1859
+ ns.conn_name, ns.file))
1860
+ confirmed = bool(ns.yes) or ns.confirm == token
1685
1861
 
1686
1862
  _, inv_rows = run_query(conn, load_query("invalid-objects.sql"), {"s": schema})
1687
1863
  invalid_before = [(r[0], r[1]) for r in inv_rows]
1688
1864
  meta = {"schema": schema, "connection": ns.conn_name, "group": group,
1689
1865
  "token": token, "applied": False,
1690
1866
  "confirmed_via": ("yes" if ns.yes else
1691
- "token" if confirmed else "preview"),
1867
+ "grant" if confirmed else "preview"),
1868
+ "grant_minted_at": (grant or {}).get("minted_at"),
1692
1869
  "tty": human_at_the_keyboard(),
1693
1870
  "invalid_before": invalid_before, **(origin or {})}
1694
1871
  entry = write_journal_entry(ns.project_root, otype, name, db_source,
@@ -1743,7 +1920,10 @@ def run_apply(conn, schema, ns, file_text, origin=None):
1743
1920
  f"{journal_root(ns.project_root) / entry / 'restore.sql'}",
1744
1921
  "dim", en))
1745
1922
  if not confirmed:
1746
- print(f"\n To apply:\n "
1923
+ print("\n To apply — two steps, two people:")
1924
+ print(" developer, in your own terminal: "
1925
+ + paint(f"{invocation()} approve {token}", "cyan", en))
1926
+ print(" then the agent: "
1747
1927
  + paint(f"{invocation()} apply {ns.file} --confirm {token}",
1748
1928
  "cyan", en))
1749
1929
  if not confirmed:
@@ -1760,6 +1940,8 @@ def run_apply(conn, schema, ns, file_text, origin=None):
1760
1940
  "'IDENTIFIERS:ALL, STATEMENTS:ALL'")
1761
1941
  with conn.cursor() as cur:
1762
1942
  cur.execute(stmt)
1943
+ if grant is not None:
1944
+ spend_grant(ns.project_root, token) # one approval, one write
1763
1945
 
1764
1946
  # 5. VERIFY
1765
1947
  err_rows = []
@@ -1837,6 +2019,73 @@ def cmd_apply(conn, schema, ns):
1837
2019
  sys.exit(code)
1838
2020
 
1839
2021
 
2022
+ def find_preview_by_token(root, token):
2023
+ """The newest journal entry whose preview carried this token. A human
2024
+ approves an object and a diff, never a bare hash — so no entry means no
2025
+ approval."""
2026
+ for eid in list_journal_entries(root): # newest first
2027
+ try:
2028
+ meta = read_journal_entry(root, eid)["meta"]
2029
+ except SystemExit:
2030
+ continue
2031
+ if str(meta.get("token", "")) == token:
2032
+ return eid, meta
2033
+ return None, None
2034
+
2035
+
2036
+ def cmd_approve(conn, schema, ns):
2037
+ """The developer's half of the gate: one human act, at a real console,
2038
+ that lets the agent's next `apply --confirm` through. Touches no
2039
+ database — a terminal with no connection configured can still approve."""
2040
+ token = str(ns.token).strip().lower()
2041
+ if not console_session(ns):
2042
+ sys.exit(APPROVE_HEADLESS_MSG)
2043
+ entry, meta = find_preview_by_token(ns.project_root, token)
2044
+ if not meta:
2045
+ sys.exit(f"No pending preview carries token {token!r}.\n"
2046
+ "Approve what you have seen: run the preview first, then "
2047
+ "approve the token it prints:\n"
2048
+ f" {invocation()} apply <file>")
2049
+ if meta.get("applied"):
2050
+ sys.exit(f"Token {token!r} belongs to a preview that was already "
2051
+ "applied.\nPreview again to get a fresh token:\n"
2052
+ f" {invocation()} apply <file>")
2053
+ conn_name = meta.get("connection") or ""
2054
+ prune_expired_grants(ns.project_root)
2055
+ rec = mint_grant(ns.project_root, token, conn_name)
2056
+ obj, otype = meta.get("object", "?"), meta.get("type", "?")
2057
+ group = meta.get("group", "")
2058
+ if ns.json:
2059
+ print(json.dumps({"ok": True, "token": token, "conn": conn_name,
2060
+ "object": obj, "type": otype, "group": group,
2061
+ "entry": entry, "minted_at": rec["minted_at"],
2062
+ "expires_at": rec["expires_at"],
2063
+ "ttl_minutes": GRANT_TTL_MINUTES}))
2064
+ return
2065
+ en = getattr(ns, "color", False)
2066
+ print()
2067
+ if group and group != "plsql_source":
2068
+ # no object identity, no snapshot: show the statement itself and say
2069
+ # plainly that there is no undo waiting behind it
2070
+ stmt = read_journal_entry(ns.project_root, entry)["after"].strip()
2071
+ print(f" Approving a {group} statement on {meta.get('schema', '?')}:")
2072
+ for ln in stmt.splitlines():
2073
+ print(f" {ln}")
2074
+ print(paint("\n There is no snapshot for this group — after commit "
2075
+ "it cannot be undone from the journal.", "yellow", en))
2076
+ else:
2077
+ print(f" Approving: {paint(f'{obj} ({otype})', 'bold', en)} in "
2078
+ f"{meta.get('schema', '?')}")
2079
+ if meta.get("summary"):
2080
+ print(f" Impact: {meta['summary'].lstrip('- ')}")
2081
+ print(f" Previewed {entry[:19].replace('T', ' ')} on connection "
2082
+ f"{conn_name or '?'}.")
2083
+ print(paint(f"\n Grant minted — single use, expires in "
2084
+ f"{GRANT_TTL_MINUTES} minutes.", "green", en))
2085
+ print(" The agent may now run: "
2086
+ + paint(f"{invocation()} apply <file> --confirm {token}", "cyan", en))
2087
+
2088
+
1840
2089
  def run_restore(conn, schema, ns):
1841
2090
  """Restore is itself a write: feed the saved statement back through the
1842
2091
  same six steps. There is no second write path and no silent restore."""
@@ -2002,7 +2251,8 @@ ASK relay apply previews verbatim and wait for a real yes. >=10 dependents
2002
2251
  exit 3 (written-but-broken) are relayed, never routed around. Do not
2003
2252
  read exit codes through a pipe.
2004
2253
  DO writes go through `pythia apply` only - snapshot, token, verify. The
2005
- full contract: `pythia guide`.
2254
+ developer runs `pythia approve <token>`; you never can. Full
2255
+ contract: `pythia guide`.
2006
2256
  """
2007
2257
 
2008
2258
 
@@ -2050,11 +2300,16 @@ Stop at exactly these moments; a guess past any of them is a defect.
2050
2300
  the rollback line. journal (show/diff) is your evidence.
2051
2301
 
2052
2302
  === 3. DO — act inside a pipeline that cannot lie ===============
2053
- One door for writes: snapshot -> impact -> preview -> token -> apply ->
2054
- verify -> report.
2303
+ One door for writes: snapshot -> impact -> preview -> token -> approve ->
2304
+ apply -> verify -> report.
2055
2305
 
2306
+ approve THE DEVELOPER'S COMMAND, at their own terminal:
2307
+ mints the one-time grant that --confirm requires.
2308
+ An agent cannot run it - that is the point. Relay
2309
+ the line the preview printed and wait.
2056
2310
  apply the six-step write; --confirm binds to the preview
2057
- journal restore undo, through the same six steps
2311
+ AND to the developer's approval
2312
+ journal restore undo, through the same six steps and the same gate
2058
2313
  unistr exact non-ASCII literals for what you are writing
2059
2314
  install · agent-user · guide setting the harness itself up
2060
2315
 
@@ -2519,12 +2774,13 @@ COMMANDS = {"check": cmd_check, "ls": cmd_ls, "src": cmd_src, "args": cmd_args,
2519
2774
  "invalid": cmd_invalid, "errors": cmd_errors, "deps": cmd_deps,
2520
2775
  "impact": cmd_impact, "similar": cmd_similar, "plscope": cmd_plscope,
2521
2776
  "policy": cmd_policy, "journal": cmd_journal, "apply": cmd_apply,
2777
+ "approve": cmd_approve,
2522
2778
  "conventions": cmd_conventions, "guide": cmd_guide, "connections": cmd_connections, "install": cmd_install,
2523
2779
  "unistr": cmd_unistr, "agent-user": cmd_agent_user,
2524
2780
  "history": cmd_history}
2525
2781
 
2526
2782
  NO_DB_COMMANDS = {"policy", "journal", "install", "unistr", "guide", "connections",
2527
- "history"}
2783
+ "history", "approve"}
2528
2784
 
2529
2785
 
2530
2786
  # --- CLI ---------------------------------------------------------------------
@@ -2621,6 +2877,10 @@ def build_parser():
2621
2877
  help="apply without stopping; the full preview still prints and journals")
2622
2878
  s.add_argument("--depth", type=int, default=3,
2623
2879
  help="impact depth for the preview (default 3)")
2880
+ s = sub.add_parser("approve", parents=[common()],
2881
+ help="the developer approves a preview: mints the "
2882
+ "one-time grant apply --confirm requires")
2883
+ s.add_argument("token", help="the token printed by the preview")
2624
2884
  sub.add_parser("connections", parents=[common()],
2625
2885
  help="list configured connections — names, users, "
2626
2886
  "targets; never passwords")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythia-plsql
3
- Version: 0.7.1
3
+ Version: 0.8.0
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
@@ -81,7 +81,8 @@ is the missing input, and forbids it to guess past them:
81
81
 
82
82
  - **Before any write**: the full preview — diff, dependents, warnings — is
83
83
  relayed verbatim, and the agent waits for a real yes. A compliment is not a
84
- yes. Silence is not a yes.
84
+ yes. Silence is not a yes. And the wait is no longer a matter of obedience:
85
+ the write cannot complete until the developer mints the approval themselves.
85
86
  - **When the blast radius is large**: ten or more dependents, or anything
86
87
  cross-schema, goes to the developer *before code is written*, not after.
87
88
  - **When sources of truth disagree**: a standards document says one thing,
@@ -96,12 +97,15 @@ is the missing input, and forbids it to guess past them:
96
97
  ### 3 · Do — act inside a pipeline that cannot lie
97
98
 
98
99
  Only after Learn and Ask does anything touch the database, and then only
99
- through one door: **snapshot → impact → preview → token → apply → verify →
100
- report**. DDL self-commits in Oracle, so the snapshot is the only real undo —
101
- it runs first and no flag disables it. A content-bound token guarantees what
102
- lands is byte-for-byte what was approved. And the CLI enforces the gates
103
- itself: a headless agent cannot `--yes` its own writes or loosen policy —
104
- that takes a human at a real terminal.
100
+ through one door: **snapshot → impact → preview → token → approve → apply →
101
+ verify → report**. DDL self-commits in Oracle, so the snapshot is the only real
102
+ undo — it runs first and no flag disables it. A content-bound token guarantees
103
+ what lands is byte-for-byte what was previewed, and a **developer-minted grant**
104
+ guarantees a person approved it at all: `pythia approve` runs only at a human's
105
+ own console, and without its one-time grant `apply --confirm` refuses. The
106
+ agent's command line is unchanged; what changed is that it now stops until
107
+ someone acts. A headless agent cannot `--yes` its own writes, cannot approve
108
+ them, and cannot loosen policy — each of those takes a human at a real terminal.
105
109
 
106
110
  The same discipline holds when the *developer* does the work: `src` and
107
111
  `impact` silently snapshot what they read, so even a change made by hand in
@@ -162,7 +166,7 @@ and WSL are all CI-tested.
162
166
  | `args` signatures | `invalid` everything broken | `unistr` exact non-ASCII literals |
163
167
  | `ddl` via DBMS_METADATA | `plscope` exact identifier usages | `agent-user` least-privilege setup |
164
168
  | `cols` columns + types | `similar` programs named like this | `history` every captured version |
165
- | `grep` search all source | | |
169
+ | `grep` search all source | | `approve` the developer's one-time grant |
166
170
  | `sql` free query (SELECT/WITH only) | | |
167
171
 
168
172
  Every command takes `--json` (machine output), `--conn` (pick a connection), and