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.
- {pythia_plsql-0.7.1/scripts/pythia_plsql.egg-info → pythia_plsql-0.8.0}/PKG-INFO +13 -9
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/README.md +12 -8
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/pyproject.toml +1 -1
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/scripts/pythia.py +268 -8
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0/scripts/pythia_plsql.egg-info}/PKG-INFO +13 -9
- pythia_plsql-0.8.0/skills/pythia-apply/SKILL.md +149 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/tests/test_phase3.py +373 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/tests/test_phase5.py +21 -0
- pythia_plsql-0.7.1/skills/pythia-apply/SKILL.md +0 -147
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/LICENSE +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/compile-errors.sql +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/dependencies.sql +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/impact.sql +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/invalid-objects.sql +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/name-occupants.sql +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/object-names.sql +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/object-source.sql +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/plscope-enabled.sql +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/plscope-statements.sql +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/plscope-usages.sql +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/session-privileges.sql +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/similar-candidates.sql +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/queries/source.sql +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/scripts/pythia_plsql.egg-info/SOURCES.txt +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/scripts/pythia_plsql.egg-info/dependency_links.txt +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/scripts/pythia_plsql.egg-info/entry_points.txt +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/scripts/pythia_plsql.egg-info/requires.txt +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/scripts/pythia_plsql.egg-info/top_level.txt +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/setup.cfg +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/pythia-conventions/SKILL.md +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/pythia-explore/SKILL.md +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/pythia-explore/reference/data-dictionary.md +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/pythia-impact/SKILL.md +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/pythia-review/SKILL.md +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/pythia-review/reference/antipatterns.md +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/pythia-setup/SKILL.md +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/pythia-skill-author/SKILL.md +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/pythia-spec/SKILL.md +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/pythia-write/SKILL.md +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/pythia-write/reference/patterns.md +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/skills/using-pythia/SKILL.md +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/tests/test_install.py +0 -0
- {pythia_plsql-0.7.1 → pythia_plsql-0.8.0}/tests/test_phase1.py +0 -0
- {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.
|
|
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 →
|
|
100
|
-
report**. DDL self-commits in Oracle, so the snapshot is the only real
|
|
101
|
-
it runs first and no flag disables it. A content-bound token guarantees
|
|
102
|
-
lands is byte-for-byte what was
|
|
103
|
-
|
|
104
|
-
|
|
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 →
|
|
86
|
-
report**. DDL self-commits in Oracle, so the snapshot is the only real
|
|
87
|
-
it runs first and no flag disables it. A content-bound token guarantees
|
|
88
|
-
lands is byte-for-byte what was
|
|
89
|
-
|
|
90
|
-
|
|
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
|
+
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
|
-
"
|
|
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(
|
|
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
|
-
|
|
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 ->
|
|
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
|
-
|
|
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.
|
|
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 →
|
|
100
|
-
report**. DDL self-commits in Oracle, so the snapshot is the only real
|
|
101
|
-
it runs first and no flag disables it. A content-bound token guarantees
|
|
102
|
-
lands is byte-for-byte what was
|
|
103
|
-
|
|
104
|
-
|
|
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
|