pythia-plsql 0.9.0__tar.gz → 0.10.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.9.0/scripts/pythia_plsql.egg-info → pythia_plsql-0.10.0}/PKG-INFO +9 -6
  2. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/README.md +8 -5
  3. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/pyproject.toml +1 -1
  4. pythia_plsql-0.10.0/queries/current-schema.sql +6 -0
  5. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/scripts/pythia.py +226 -58
  6. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0/scripts/pythia_plsql.egg-info}/PKG-INFO +9 -6
  7. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/scripts/pythia_plsql.egg-info/SOURCES.txt +1 -0
  8. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-apply/SKILL.md +17 -17
  9. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/tests/test_phase3.py +154 -2
  10. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/LICENSE +0 -0
  11. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/compile-errors.sql +0 -0
  12. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/dependencies.sql +0 -0
  13. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/impact.sql +0 -0
  14. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/invalid-objects.sql +0 -0
  15. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/name-occupants.sql +0 -0
  16. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/object-names.sql +0 -0
  17. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/object-source.sql +0 -0
  18. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/plscope-enabled.sql +0 -0
  19. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/plscope-statements.sql +0 -0
  20. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/plscope-usages.sql +0 -0
  21. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/session-privileges.sql +0 -0
  22. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/similar-candidates.sql +0 -0
  23. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/source.sql +0 -0
  24. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/scripts/pythia_plsql.egg-info/dependency_links.txt +0 -0
  25. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/scripts/pythia_plsql.egg-info/entry_points.txt +0 -0
  26. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/scripts/pythia_plsql.egg-info/requires.txt +0 -0
  27. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/scripts/pythia_plsql.egg-info/top_level.txt +0 -0
  28. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/setup.cfg +0 -0
  29. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-conventions/SKILL.md +0 -0
  30. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-explore/SKILL.md +0 -0
  31. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-explore/reference/data-dictionary.md +0 -0
  32. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-impact/SKILL.md +0 -0
  33. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-review/SKILL.md +0 -0
  34. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-review/reference/antipatterns.md +0 -0
  35. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-setup/SKILL.md +0 -0
  36. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-skill-author/SKILL.md +0 -0
  37. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-spec/SKILL.md +0 -0
  38. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-write/SKILL.md +0 -0
  39. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-write/reference/patterns.md +0 -0
  40. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/using-pythia/SKILL.md +0 -0
  41. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/tests/test_install.py +0 -0
  42. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/tests/test_phase1.py +0 -0
  43. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/tests/test_phase2.py +0 -0
  44. {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/tests/test_phase5.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythia-plsql
3
- Version: 0.9.0
3
+ Version: 0.10.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
@@ -101,11 +101,14 @@ through one door: **snapshot → impact → preview → token → approve → ap
101
101
  verify → report**. DDL self-commits in Oracle, so the snapshot is the only real
102
102
  undo — it runs first and no flag disables it. A content-bound token guarantees
103
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.
104
+ guarantees a person approved it at all — through one of two doors, both the
105
+ developer's. In chat: the agent asks with `AskUserQuestion`, the question text
106
+ being pythia's own approval card (`pythia approve --card <token>`), and the
107
+ answer **Approve** — written into the hook payload by Claude Code, never by
108
+ the agent — mints the one-time grant. At a console: `pythia approve <token>`.
109
+ Without that grant `apply --confirm` refuses. A headless agent cannot `--yes`
110
+ its own writes, cannot mint approval, and cannot loosen policy — each of those
111
+ takes a human, answering or typing.
109
112
 
110
113
  The same discipline holds when the *developer* does the work: `src` and
111
114
  `impact` silently snapshot what they read, so even a change made by hand in
@@ -87,11 +87,14 @@ through one door: **snapshot → impact → preview → token → approve → ap
87
87
  verify → report**. DDL self-commits in Oracle, so the snapshot is the only real
88
88
  undo — it runs first and no flag disables it. A content-bound token guarantees
89
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.
90
+ guarantees a person approved it at all — through one of two doors, both the
91
+ developer's. In chat: the agent asks with `AskUserQuestion`, the question text
92
+ being pythia's own approval card (`pythia approve --card <token>`), and the
93
+ answer **Approve** — written into the hook payload by Claude Code, never by
94
+ the agent — mints the one-time grant. At a console: `pythia approve <token>`.
95
+ Without that grant `apply --confirm` refuses. A headless agent cannot `--yes`
96
+ its own writes, cannot mint approval, and cannot loosen policy — each of those
97
+ takes a human, answering or typing.
95
98
 
96
99
  The same discipline holds when the *developer* does the work: `src` and
97
100
  `impact` silently snapshot what they read, so even a change made by hand in
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pythia-plsql"
7
- version = "0.9.0"
7
+ version = "0.10.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" }
@@ -0,0 +1,6 @@
1
+ -- Purpose: the schema an unqualified CREATE lands in for this session. A proxy
2
+ -- or a mis-set connections.json can put it somewhere other than the
3
+ -- schema pythia snapshots and verifies; apply refuses that mismatch.
4
+ -- Binds: none
5
+ -- Returns: CURRENT_SCHEMA
6
+ select sys_context('userenv', 'current_schema') current_schema from dual
@@ -84,6 +84,7 @@ QUERY_BINDS = {
84
84
  "source.sql": {"s", "n"},
85
85
  "object-source.sql": {"s", "n", "t"},
86
86
  "session-privileges.sql": set(),
87
+ "current-schema.sql": set(),
87
88
  "name-occupants.sql": {"s", "n"},
88
89
  "object-names.sql": {"s"},
89
90
  }
@@ -382,8 +383,11 @@ HEADLESS_YES_MSG = (
382
383
 
383
384
  APPROVE_HEADLESS_MSG = (
384
385
  "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.)")
386
+ "Agents ask instead: AskUserQuestion with the text of `approve --card "
387
+ "<token>` and the options Approve / Reject — the pythia hook mints the "
388
+ "grant when the developer answers Approve. Or a person runs approve in "
389
+ "their own terminal.\n(Real pipelines apply with --yes and PYTHIA_CI=1 "
390
+ "instead.)")
387
391
 
388
392
 
389
393
  def grant_refusal(status, token, grant, conn_name, file_hint):
@@ -393,7 +397,10 @@ def grant_refusal(status, token, grant, conn_name, file_hint):
393
397
  preview_line = f" {invocation()} apply {file_hint}"
394
398
  if status == "missing":
395
399
  return ("The confirmation token matches, but no developer approval is "
396
- "on file.\nAsk the developer to run, in their own terminal "
400
+ "on file.\nAsk in chat — AskUserQuestion carrying the text of\n"
401
+ f" {invocation()} approve --card {token}\nwith the options "
402
+ "Approve / Reject (the pythia hook mints the grant on Approve) "
403
+ "— or ask the developer to run, in their own terminal "
397
404
  f"(agents cannot run it):\n{approve_line}\n"
398
405
  "Then re-run this command.")
399
406
  if status == "expired":
@@ -1129,11 +1136,14 @@ def grant_path(root, token):
1129
1136
  return grants_root(root) / f"{token}.json"
1130
1137
 
1131
1138
 
1132
- def mint_grant(root, token, conn_name, now=None, revalidate=None):
1139
+ def mint_grant(root, token, conn_name, now=None, revalidate=None,
1140
+ approver="console", session=None):
1133
1141
  """Record one developer approval. Single use, short-lived, bound to the
1134
1142
  connection the preview ran on. `revalidate` is the row-set fingerprint the
1135
1143
  human was shown — audit data, not a second gate: the enforcement is the
1136
- token, which already hashes that same fingerprint."""
1144
+ token, which already hashes that same fingerprint. `approver` says which
1145
+ door the human came through — console or chat — and `session` is the
1146
+ chat session's id; both are audit data too."""
1137
1147
  import datetime
1138
1148
  now = now or datetime.datetime.now()
1139
1149
  rec = {"token": token,
@@ -1142,7 +1152,9 @@ def mint_grant(root, token, conn_name, now=None, revalidate=None):
1142
1152
  "expires_at": (now + datetime.timedelta(
1143
1153
  minutes=GRANT_TTL_MINUTES)).isoformat(timespec="seconds"),
1144
1154
  "used_at": None,
1145
- "revalidate": revalidate or None}
1155
+ "revalidate": revalidate or None,
1156
+ "approver": approver,
1157
+ "session": session or None}
1146
1158
  d = grants_root(root)
1147
1159
  d.mkdir(parents=True, exist_ok=True)
1148
1160
  grant_path(root, token).write_text(json.dumps(rec, indent=2) + "\n",
@@ -1437,6 +1449,14 @@ def cmd_check(conn, schema, ns):
1437
1449
  (select count(*) from all_triggers where owner = :s) triggers
1438
1450
  from dual""", {"s": schema})
1439
1451
  emit_table(ns, cols, rows, False)
1452
+ current = session_schema(conn)
1453
+ if current and current != str(schema).upper():
1454
+ print("\n" + paint(
1455
+ f"! Session schema is {current}, but this connection is configured "
1456
+ f"for {schema}: unqualified writes would land in {current} while "
1457
+ f"pythia reads {schema}. apply refuses until connections.json says "
1458
+ f"\"schema\": \"{current}\".", "yellow", color_enabled(sys.stderr)),
1459
+ file=sys.stderr)
1440
1460
  warn = privilege_warning(conn, schema, ns.conn_user)
1441
1461
  if warn:
1442
1462
  print("\n" + paint(warn, "yellow", color_enabled(sys.stderr)),
@@ -1942,6 +1962,31 @@ def privilege_warning(conn, schema, conn_user):
1942
1962
  "explains what is at stake.")
1943
1963
 
1944
1964
 
1965
+ def session_schema(conn):
1966
+ """Where an unqualified name lands for this session, or None if the
1967
+ database did not say (it always does on Oracle; test doubles may not)."""
1968
+ _, rows = run_query(conn, load_query("current-schema.sql"), {})
1969
+ return str(rows[0][0]).upper() if rows and rows[0] and rows[0][0] else None
1970
+
1971
+
1972
+ def refuse_schema_mismatch(conn, schema, ns):
1973
+ """An unqualified CREATE lands in the session's current schema, while
1974
+ every snapshot, impact and verify query here asks the configured one. If
1975
+ those differ — a proxy into another user, a stale `schema` in
1976
+ connections.json — the write would land in one place and be verified in
1977
+ another, and "Applied, compiled clean" would be about the wrong object.
1978
+ Found live: PYTHIA_AGENT[PYTHIA_DEV] with schema still set to PYTHIA."""
1979
+ current = session_schema(conn)
1980
+ if current and current != str(schema).upper():
1981
+ sys.exit(f"Refused: this session's schema is {current!r}, but connection "
1982
+ f"{getattr(ns, 'conn_name', None) or '?'!r} is configured for "
1983
+ f"{schema!r}.\nAn unqualified statement would land in {current!r} "
1984
+ f"while pythia snapshots and verifies {schema!r}.\nSet "
1985
+ f"\"schema\": \"{current}\" for this connection in "
1986
+ f".pythia/connections.json (or connect as {schema!r}), then "
1987
+ "preview again.")
1988
+
1989
+
1945
1990
  def run_apply(conn, schema, ns, file_text, origin=None):
1946
1991
  """The six steps: SNAPSHOT, IMPACT, PREVIEW, APPLY, VERIFY, REPORT.
1947
1992
  Returns the exit code. Refusals raise SystemExit (exit 1)."""
@@ -2002,6 +2047,8 @@ def run_apply(conn, schema, ns, file_text, origin=None):
2002
2047
  # journal records the statement itself so at least *what ran* is kept.
2003
2048
  otype, name = group.upper(), "STATEMENT"
2004
2049
 
2050
+ refuse_schema_mismatch(conn, schema, ns) # the write must land where it is verified
2051
+
2005
2052
  # 1. SNAPSHOT — before anything else, unconditionally.
2006
2053
  db_source = ""
2007
2054
  row_set = None
@@ -2111,10 +2158,12 @@ def run_apply(conn, schema, ns, file_text, origin=None):
2111
2158
  f"{journal_root(ns.project_root) / entry / 'restore.sql'}",
2112
2159
  "dim", en))
2113
2160
  if not confirmed:
2114
- print("\n To apply — two steps, two people:")
2115
- print(" developer, in your own terminal: "
2161
+ print("\n To apply — the developer approves, then the agent confirms:")
2162
+ print(" in chat: the agent asks (AskUserQuestion) with the text of "
2163
+ + paint(f"{invocation()} approve --card {token}", "cyan", en))
2164
+ print(" or in a terminal: "
2116
2165
  + paint(f"{invocation()} approve {token}", "cyan", en))
2117
- print(" then the agent: "
2166
+ print(" then the agent: "
2118
2167
  + paint(f"{invocation()} apply {ns.file} --confirm {token}",
2119
2168
  "cyan", en))
2120
2169
  if not confirmed:
@@ -2229,7 +2278,10 @@ def cmd_apply(conn, schema, ns):
2229
2278
  path = pathlib.Path(ns.file)
2230
2279
  if not path.is_file():
2231
2280
  sys.exit(f"No such file: {path}")
2232
- code = run_apply(conn, schema, ns, path.read_text(encoding="utf-8"))
2281
+ # utf-8-sig: Windows editors prepend a BOM, which read as a stray byte
2282
+ # turned every such file into "cannot classify". A BOM-less file reads
2283
+ # identically.
2284
+ code = run_apply(conn, schema, ns, path.read_text(encoding="utf-8-sig"))
2233
2285
  if code:
2234
2286
  sys.exit(code)
2235
2287
 
@@ -2274,14 +2326,13 @@ def token_mismatch_message(ns, row_set):
2274
2326
  f" {invocation()} apply {ns.file}")
2275
2327
 
2276
2328
 
2277
- def cmd_approve(conn, schema, ns):
2278
- """The developer's half of the gate: one human act, at a real console,
2279
- that lets the agent's next `apply --confirm` through. Touches no
2280
- database — a terminal with no connection configured can still approve."""
2281
- token = str(ns.token).strip().lower()
2282
- if not console_session(ns):
2283
- sys.exit(APPROVE_HEADLESS_MSG)
2284
- entry, meta = find_preview_by_token(ns.project_root, token)
2329
+ def approval_card(root, token):
2330
+ """What a human approves, in pythia's own words, read from the preview's
2331
+ journal entry and never recomputed. Returns (entry, meta, lines). The same
2332
+ text is printed at the console, asked with in chat, and checked by the
2333
+ hook — so what is approved is what was shown, whichever door it came
2334
+ through. Refuses a token no pending preview carries."""
2335
+ entry, meta = find_preview_by_token(root, token)
2285
2336
  if not meta:
2286
2337
  sys.exit(f"No pending preview carries token {token!r}.\n"
2287
2338
  "Approve what you have seen: run the preview first, then "
@@ -2291,49 +2342,154 @@ def cmd_approve(conn, schema, ns):
2291
2342
  sys.exit(f"Token {token!r} belongs to a preview that was already "
2292
2343
  "applied.\nPreview again to get a fresh token:\n"
2293
2344
  f" {invocation()} apply <file>")
2294
- conn_name = meta.get("connection") or ""
2295
- prune_expired_grants(ns.project_root)
2296
- row_set = meta.get("row_set")
2297
- rec = mint_grant(ns.project_root, token, conn_name,
2298
- revalidate=fingerprint_text(row_set))
2299
- obj, otype = meta.get("object", "?"), meta.get("type", "?")
2300
2345
  group = meta.get("group", "")
2301
- if ns.json:
2302
- print(json.dumps({"ok": True, "token": token, "conn": conn_name,
2303
- "object": obj, "type": otype, "group": group,
2304
- "rows": (row_set or {}).get("count"),
2305
- "entry": entry, "minted_at": rec["minted_at"],
2306
- "expires_at": rec["expires_at"],
2307
- "ttl_minutes": GRANT_TTL_MINUTES}))
2308
- return
2309
- en = getattr(ns, "color", False)
2310
- print()
2346
+ row_set = meta.get("row_set")
2347
+ lines = []
2311
2348
  if group and group != "plsql_source":
2312
2349
  # no object identity, no snapshot: show the statement itself and say
2313
2350
  # plainly that there is no undo waiting behind it
2314
- stmt = read_journal_entry(ns.project_root, entry)["after"].strip()
2315
- print(f" Approving a {group} statement on {meta.get('schema', '?')}:")
2316
- for ln in stmt.splitlines():
2317
- print(f" {ln}")
2351
+ stmt = read_journal_entry(root, entry)["after"].strip()
2352
+ lines.append(f"Approving a {group} statement on {meta.get('schema', '?')}:")
2353
+ lines += [f" {ln}" for ln in stmt.splitlines()]
2318
2354
  if row_set:
2319
2355
  # the rows themselves, exactly as the preview showed them: what is
2320
2356
  # approved has to be what was seen
2321
- print()
2322
- for ln in render_row_set(row_set, en, indent=" "):
2323
- print(ln)
2324
- print(paint("\n There is no snapshot for this group — after commit "
2325
- "it cannot be undone from the journal.", "yellow", en))
2357
+ lines.append("")
2358
+ lines += render_row_set(row_set, False, indent=" ")
2359
+ lines.append("There is no snapshot for this group — after commit it "
2360
+ "cannot be undone from the journal.")
2326
2361
  else:
2327
- print(f" Approving: {paint(f'{obj} ({otype})', 'bold', en)} in "
2328
- f"{meta.get('schema', '?')}")
2362
+ lines.append(f"Approving: {meta.get('object', '?')} "
2363
+ f"({meta.get('type', '?')}) in {meta.get('schema', '?')}")
2329
2364
  if meta.get("summary"):
2330
- print(f" Impact: {meta['summary'].lstrip('- ')}")
2331
- print(f" Previewed {entry[:19].replace('T', ' ')} on connection "
2332
- f"{conn_name or '?'}.")
2333
- print(paint(f"\n Grant minted — single use, expires in "
2334
- f"{GRANT_TTL_MINUTES} minutes.", "green", en))
2335
- print(" The agent may now run: "
2336
- + paint(f"{invocation()} apply <file> --confirm {token}", "cyan", en))
2365
+ lines.append(f"Impact: {meta['summary'].lstrip('- ')}")
2366
+ lines.append(f"Previewed {entry[:19].replace('T', ' ')} on connection "
2367
+ f"{meta.get('connection') or '?'}.")
2368
+ return entry, meta, lines
2369
+
2370
+
2371
+ def squash(text):
2372
+ """Whitespace-insensitive form, for 'did the question carry the card'."""
2373
+ return " ".join(str(text).split()).lower()
2374
+
2375
+
2376
+ TOKEN_IN_QUESTION = re.compile(r"approve\s+(?:--card\s+)?([0-9a-f]{6,})\b", re.I)
2377
+
2378
+
2379
+ def hook_approve(ns):
2380
+ """The chat door. Claude Code runs this as a PostToolUse hook on
2381
+ AskUserQuestion, with the payload on stdin; the developer's answer in it
2382
+ was filled in by the client, not the agent. A grant is minted only when
2383
+ the answer is exactly Approve AND the question carried pythia's own card
2384
+ for that token — otherwise the human approved the agent's words, not the
2385
+ preview, and that is no approval. Everything else is silence: a hook that
2386
+ talks on every question is noise."""
2387
+ try:
2388
+ payload = json.load(sys.stdin)
2389
+ except ValueError:
2390
+ return
2391
+ if not isinstance(payload, dict) or payload.get("tool_name") != "AskUserQuestion":
2392
+ return
2393
+ resp = payload.get("tool_response")
2394
+ answers = (resp.get("answers") if isinstance(resp, dict) else None) \
2395
+ or (payload.get("tool_input") or {}).get("answers") or {}
2396
+ notes = []
2397
+ for question, answer in answers.items():
2398
+ m = TOKEN_IN_QUESTION.search(str(question))
2399
+ if not m:
2400
+ continue
2401
+ token = m.group(1).lower()
2402
+ if squash(answer) != "approve":
2403
+ notes.append(f"{token}: the developer did NOT approve (answered "
2404
+ f"{str(answer)!r}). No grant minted — do not apply; "
2405
+ "ask what should change.")
2406
+ continue
2407
+ try:
2408
+ _, meta, body = approval_card(ns.project_root, token)
2409
+ except SystemExit as e:
2410
+ notes.append(f"{token}: {e}")
2411
+ continue
2412
+ if squash("\n".join(body)) not in squash(question):
2413
+ notes.append(f"{token}: the question did not carry pythia's approval "
2414
+ "card verbatim, so the developer approved your words, "
2415
+ "not the preview. No grant minted. Run "
2416
+ f"`{invocation()} approve --card {token}` and ask "
2417
+ "again with exactly that text.")
2418
+ continue
2419
+ prune_expired_grants(ns.project_root)
2420
+ mint_grant(ns.project_root, token, meta.get("connection") or "",
2421
+ revalidate=fingerprint_text(meta.get("row_set")),
2422
+ approver="chat", session=payload.get("session_id"))
2423
+ notes.append(f"{token}: approved in chat — grant minted, single use, "
2424
+ f"expires in {GRANT_TTL_MINUTES} minutes. Run: "
2425
+ f"{invocation()} apply <file> --confirm {token}")
2426
+ if notes:
2427
+ print(json.dumps({"hookSpecificOutput": {
2428
+ "hookEventName": "PostToolUse",
2429
+ "additionalContext": "pythia: " + "\n".join(notes)}}))
2430
+
2431
+
2432
+ def cmd_approve(conn, schema, ns):
2433
+ """The developer's half of the gate: one human act that lets the agent's
2434
+ next `apply --confirm` through — at a real console, or in chat via the
2435
+ hook (--hook). --card prints what the agent asks with and mints nothing.
2436
+ Touches no database — a terminal with no connection configured can still
2437
+ approve."""
2438
+ if getattr(ns, "hook", False):
2439
+ return hook_approve(ns)
2440
+ tokens = [ns.token] if isinstance(ns.token, str) else list(ns.token or [])
2441
+ tokens = [str(t).strip().lower() for t in tokens]
2442
+ card_only = getattr(ns, "card", False)
2443
+ if not tokens:
2444
+ sys.exit(f"Usage: {invocation()} approve <token> [<token> ...]\n"
2445
+ f" {invocation()} approve --card <token>")
2446
+ if not card_only and not console_session(ns):
2447
+ sys.exit(APPROVE_HEADLESS_MSG)
2448
+ en = getattr(ns, "color", False)
2449
+ out = []
2450
+ for token in tokens:
2451
+ entry, meta, body = approval_card(ns.project_root, token)
2452
+ conn_name = meta.get("connection") or ""
2453
+ row_set = meta.get("row_set")
2454
+ rec = None
2455
+ if not card_only:
2456
+ prune_expired_grants(ns.project_root)
2457
+ rec = mint_grant(ns.project_root, token, conn_name,
2458
+ revalidate=fingerprint_text(row_set))
2459
+ if ns.json:
2460
+ card = f"{invocation()} approve {token}\n" + "\n".join(body)
2461
+ out.append({"ok": True, "token": token, "conn": conn_name,
2462
+ "object": meta.get("object", "?"),
2463
+ "type": meta.get("type", "?"),
2464
+ "group": meta.get("group", ""),
2465
+ "rows": (row_set or {}).get("count"),
2466
+ "entry": entry, "card": card,
2467
+ "question": {"question": card, "header": "pythia",
2468
+ "multiSelect": False,
2469
+ "options": [
2470
+ {"label": "Approve",
2471
+ "description": "mint the one-time grant; the agent then runs apply --confirm"},
2472
+ {"label": "Reject",
2473
+ "description": "nothing is written; say what should change"}]},
2474
+ "minted": rec is not None,
2475
+ "minted_at": (rec or {}).get("minted_at"),
2476
+ "expires_at": (rec or {}).get("expires_at"),
2477
+ "ttl_minutes": GRANT_TTL_MINUTES})
2478
+ continue
2479
+ print()
2480
+ if card_only:
2481
+ # plain text on purpose: this goes into a question, not a terminal
2482
+ print(f"{invocation()} approve {token}")
2483
+ print("\n".join(body))
2484
+ continue
2485
+ for ln in body:
2486
+ print(f" {paint(ln, 'yellow', en) if ln.startswith('There is no snapshot') else ln}")
2487
+ print(paint(f"\n Grant minted — single use, expires in "
2488
+ f"{GRANT_TTL_MINUTES} minutes.", "green", en))
2489
+ print(" The agent may now run: "
2490
+ + paint(f"{invocation()} apply <file> --confirm {token}", "cyan", en))
2491
+ if ns.json:
2492
+ print(json.dumps(out[0] if len(out) == 1 else out))
2337
2493
 
2338
2494
 
2339
2495
  def run_restore(conn, schema, ns):
@@ -2513,7 +2669,9 @@ ASK relay apply previews verbatim and wait for a real yes. >=10 dependents
2513
2669
  exit 3 (written-but-broken) are relayed, never routed around. Do not
2514
2670
  read exit codes through a pipe.
2515
2671
  DO writes go through `pythia apply` only - snapshot, token, verify. The
2516
- developer runs `pythia approve <token>`; you never can. Full
2672
+ developer approves: you ask in chat (AskUserQuestion, text from
2673
+ `pythia approve --card <token>`, options Approve/Reject) or they run
2674
+ `pythia approve <token>` themselves; you never mint it. Full
2517
2675
  contract: `pythia guide`.
2518
2676
  """
2519
2677
 
@@ -2565,10 +2723,13 @@ Stop at exactly these moments; a guess past any of them is a defect.
2565
2723
  One door for writes: snapshot -> impact -> preview -> token -> approve ->
2566
2724
  apply -> verify -> report.
2567
2725
 
2568
- approve THE DEVELOPER'S COMMAND, at their own terminal:
2569
- mints the one-time grant that --confirm requires.
2570
- An agent cannot run it - that is the point. Relay
2571
- the line the preview printed and wait.
2726
+ approve THE DEVELOPER'S ACT: mints the one-time grant that
2727
+ --confirm requires. Two doors, both theirs: answer
2728
+ Approve to your AskUserQuestion (question text =
2729
+ `approve --card <token>`, verbatim - the hook mints
2730
+ only then), or `approve <token>` at their own
2731
+ terminal. An agent never mints it - that is the
2732
+ point.
2572
2733
  apply the six-step write; --confirm binds to the preview
2573
2734
  AND to the developer's approval
2574
2735
  journal restore undo, through the same six steps and the same gate
@@ -3142,7 +3303,14 @@ def build_parser():
3142
3303
  s = sub.add_parser("approve", parents=[common()],
3143
3304
  help="the developer approves a preview: mints the "
3144
3305
  "one-time grant apply --confirm requires")
3145
- s.add_argument("token", help="the token printed by the preview")
3306
+ s.add_argument("token", nargs="*", help="token(s) printed by the preview")
3307
+ s.add_argument("--card", action="store_true",
3308
+ help="print the approval card the agent asks with in chat "
3309
+ "(AskUserQuestion); mints nothing, needs no console")
3310
+ s.add_argument("--hook", action="store_true",
3311
+ help="Claude Code PostToolUse hook on AskUserQuestion: "
3312
+ "reads the payload on stdin and mints the grant the "
3313
+ "developer answered Approve to")
3146
3314
  sub.add_parser("connections", parents=[common()],
3147
3315
  help="list configured connections — names, users, "
3148
3316
  "targets; never passwords")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythia-plsql
3
- Version: 0.9.0
3
+ Version: 0.10.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
@@ -101,11 +101,14 @@ through one door: **snapshot → impact → preview → token → approve → ap
101
101
  verify → report**. DDL self-commits in Oracle, so the snapshot is the only real
102
102
  undo — it runs first and no flag disables it. A content-bound token guarantees
103
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.
104
+ guarantees a person approved it at all — through one of two doors, both the
105
+ developer's. In chat: the agent asks with `AskUserQuestion`, the question text
106
+ being pythia's own approval card (`pythia approve --card <token>`), and the
107
+ answer **Approve** — written into the hook payload by Claude Code, never by
108
+ the agent — mints the one-time grant. At a console: `pythia approve <token>`.
109
+ Without that grant `apply --confirm` refuses. A headless agent cannot `--yes`
110
+ its own writes, cannot mint approval, and cannot loosen policy — each of those
111
+ takes a human, answering or typing.
109
112
 
110
113
  The same discipline holds when the *developer* does the work: `src` and
111
114
  `impact` silently snapshot what they read, so even a change made by hand in
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  queries/compile-errors.sql
5
+ queries/current-schema.sql
5
6
  queries/dependencies.sql
6
7
  queries/impact.sql
7
8
  queries/invalid-objects.sql
@@ -26,15 +26,13 @@ Three moves are FORBIDDEN for agents, and the CLI enforces all three (no
26
26
  terminal attached → refusal):
27
27
 
28
28
  - `--yes` — it is the developer's flag, never yours.
29
- - `pythia approve <token>` — the developer's command, run at their own
30
- terminal. It mints the one-time grant `--confirm` requires, and an agent
31
- cannot run it. Relay the line; never run it yourself.
29
+ - Minting the approval. `pythia approve <token>` is the developer's console
30
+ command; an agent cannot run it. Your door is to ASK (step 3): the pythia
31
+ hook mints the grant only from the developer's own answer to your question.
32
32
  - `pythia policy set <group>` to anything LOOSER — loosening policy is the
33
33
  developer's decision; hand them the exact command to run themselves.
34
34
 
35
- One move is yours but never in the same turn as the preview: `--confirm`. The
36
- preview ends your turn; the token is used only after approval arrives as a NEW
37
- message.
35
+ One move is yours: `--confirm` — only after `Approve`, or a console approve.
38
36
 
39
37
  ## When the change will NOT go through apply
40
38
 
@@ -55,16 +53,17 @@ confirms a number you already knew.
55
53
  2. **Relay the preview — verbatim.** The diff, the `impact:` line, every `!`
56
54
  warning, exactly as printed. The developer approves what they see, not
57
55
  your paraphrase.
58
- 3. **Wait for an explicit yes, and for the approval to be minted.** The
59
- preview prints two follow-up lines — `pythia approve <token>` for the
60
- developer, `apply --confirm` for you. Relay both. A yes is an instruction
61
- to proceed with THIS preview: "yes, apply it", "go ahead". Not a yes: a
62
- compliment, a question, silence, or approval given for an earlier preview.
63
- If the developer changes the file instead, start over at step 1.
56
+ 3. **Ask for approval — one question per token.** `pythia approve --card
57
+ <token>` prints the card; ask an `AskUserQuestion` whose text is that card
58
+ verbatim (header `pythia`; options exactly `Approve` / `Reject`, no
59
+ "(Recommended)"; up to four tokens per call). `Approve` mints the grant via
60
+ the hook. Anything else — `Reject`, free text, silence, an earlier preview's
61
+ yes — is not approval: stop and ask what should change. File changed → step 1.
62
+ No hook installed? Relay `pythia approve <token>` for their own terminal.
64
63
  4. **Apply** by running the exact `then the agent:` line pythia printed. Two
65
64
  refusals are normal here, and neither is a malfunction:
66
- - *"no developer approval is on file"* — they have not run `approve` yet.
67
- Say so and wait; retrying does not create approval.
65
+ - *"no developer approval is on file"* — the answer was not `Approve`, or
66
+ the hook is not installed. Say so and wait; retrying does not create it.
68
67
  - *"the confirmation token does not match"* — the file or the database
69
68
  changed since the preview. Go back to step 1, never "retry". If the fresh
70
69
  preview's before-side differs from what you last saw, say so: someone may
@@ -85,8 +84,8 @@ confirms a number you already knew.
85
84
  ## Restores
86
85
 
87
86
  `pythia journal restore <id>` is itself a write: same six steps, same gate,
88
- approval included. Preview the reverse diff, relay both follow-up lines, wait
89
- for their approve, then confirm. Restoring an object that did not exist before
87
+ approval included. Preview the reverse diff, ask with the card the same way,
88
+ then confirm. Restoring an object that did not exist before
90
89
  means DROP — policy refuses it under `structural: deny`, and that is correct.
91
90
 
92
91
  ## Batch mode
@@ -135,7 +134,8 @@ affected count and up to ten rows, and `--confirm` refuses if that set moved.
135
134
  | "apply refused it; run-sql will take it" | The refusal is the product working. Relay it. |
136
135
  | "I'll restore quietly to clean up my mistake" | Restores are writes. Same gate, same visibility. |
137
136
  | "`$?` said 0 after I piped to tail" | That was tail's 0. Read pythia's own words, or its unpiped code. |
138
- | "I'll run approve myself to unblock this" | The developer's console act. It refuses you, and routing around it is what this gate exists to stop. |
137
+ | "I'll run approve myself to unblock this" | The developer's act. Console approve refuses you; the hook mints only from *their* answer. Routing around it is what this gate exists to stop. |
138
+ | "I'll summarise the card so the question reads nicer" | The hook refuses a paraphrase — the developer must approve pythia's words, not yours. Paste the card verbatim. |
139
139
 
140
140
  ## When NOT to use this skill
141
141
 
@@ -240,6 +240,7 @@ def base_script(errors=(), invalid_after=None, db_source=OLD_SRC):
240
240
  "from all_errors": ([("NAME", "TYPE", "SEQUENCE", "LINE", "POSITION",
241
241
  "ATTRIBUTE", "TEXT")], list(errors)),
242
242
  "from session_privs": ([("PRIVILEGE",)], []),
243
+ "current_schema": ([("CURRENT_SCHEMA",)], [("APP",)]),
243
244
  # main-namespace occupants of the target name (type-conflict check)
244
245
  "object_name = upper(:n)": ([("OBJECT_TYPE",)], [("PACKAGE BODY",)]),
245
246
  }
@@ -1128,9 +1129,10 @@ def test_preview_prints_both_follow_up_lines():
1128
1129
  pythia.run_apply(conn, "APP", apply_ns(td, file="f.sql"), NEW_FILE)
1129
1130
  out = buf.getvalue()
1130
1131
  tok = pythia.apply_token("PACKAGE BODY", "PKG_ORDER", NEW_FILE, OLD_SRC)
1131
- assert f"approve {tok}" in out
1132
+ assert f"approve --card {tok}" in out # the chat door
1133
+ assert f"approve {tok}" in out # the console door
1132
1134
  assert f"apply f.sql --confirm {tok}" in out
1133
- assert "your own terminal" in out
1135
+ assert "in chat" in out and "terminal" in out
1134
1136
 
1135
1137
 
1136
1138
  def test_journal_restore_needs_a_grant_too():
@@ -1338,6 +1340,156 @@ def test_approve_needs_no_database():
1338
1340
  assert "approve" in pythia.COMMANDS
1339
1341
 
1340
1342
 
1343
+ # --- the chat door: approve --card / approve --hook ---------------------------
1344
+
1345
+ def _hook_payload(question, answer, tool="AskUserQuestion", session="sess-1"):
1346
+ q = {"question": question, "header": "pythia", "multiSelect": False,
1347
+ "options": [{"label": "Approve", "description": "mint"},
1348
+ {"label": "Reject", "description": "stop"}]}
1349
+ return {"hook_event_name": "PostToolUse", "session_id": session,
1350
+ "tool_name": tool, "tool_input": {"questions": [q]},
1351
+ "tool_response": {"questions": [q], "answers": {question: answer}}}
1352
+
1353
+
1354
+ def _run_hook(td, payload):
1355
+ """A headless agent session with the hook payload on stdin — exactly how
1356
+ Claude Code invokes it. Returns stdout."""
1357
+ import contextlib
1358
+ import io
1359
+ buf = io.StringIO()
1360
+ with _NoTTY():
1361
+ sys.stdin = io.StringIO(json.dumps(payload) if not isinstance(payload, str)
1362
+ else payload)
1363
+ with contextlib.redirect_stdout(buf):
1364
+ pythia.cmd_approve(None, None, approve_ns(td, [], hook=True))
1365
+ return buf.getvalue()
1366
+
1367
+
1368
+ def _pending_preview(td, token="7f3a91"):
1369
+ pythia.write_journal_entry(
1370
+ td, "PACKAGE BODY", "PKG_ORDER", OLD_SRC, NEW_FILE,
1371
+ {"token": token, "connection": "DEV", "schema": "APP",
1372
+ "group": "plsql_source", "applied": False,
1373
+ "summary": "12 dependent objects, 11 currently VALID"})
1374
+ _, _, body = pythia.approval_card(td, token)
1375
+ return f"pythia approve {token}\n" + "\n".join(body)
1376
+
1377
+
1378
+ def test_hook_mints_the_grant_the_developer_approved_in_chat():
1379
+ """The developer answered Approve to a question carrying pythia's own
1380
+ card: that is the console act, through the other door. The grant says
1381
+ which door, and which session."""
1382
+ with tempfile.TemporaryDirectory() as td:
1383
+ card = _pending_preview(td)
1384
+ out = _run_hook(td, _hook_payload(" Please approve:\n" + card, "Approve"))
1385
+ g = pythia.read_grant(td, "7f3a91")
1386
+ assert g and g["approver"] == "chat" and g["session"] == "sess-1"
1387
+ assert g["conn"] == "DEV" and g["used_at"] is None
1388
+ ctx = json.loads(out)["hookSpecificOutput"]
1389
+ assert ctx["hookEventName"] == "PostToolUse"
1390
+ assert "--confirm 7f3a91" in ctx["additionalContext"]
1391
+
1392
+
1393
+ def test_hook_refuses_a_question_that_is_not_pythias_card():
1394
+ """An agent's paraphrase is not a preview. If the question did not carry
1395
+ the card verbatim, the human approved the agent's words — no grant, and
1396
+ the agent is told to ask again with the card."""
1397
+ with tempfile.TemporaryDirectory() as td:
1398
+ _pending_preview(td)
1399
+ out = _run_hook(td, _hook_payload(
1400
+ "pythia approve 7f3a91\nJust a tiny, totally safe change.", "Approve"))
1401
+ assert pythia.read_grant(td, "7f3a91") is None
1402
+ assert "approve --card 7f3a91" in json.loads(out)["hookSpecificOutput"]["additionalContext"]
1403
+
1404
+
1405
+ def test_hook_mints_nothing_on_reject_other_tools_or_garbage():
1406
+ with tempfile.TemporaryDirectory() as td:
1407
+ card = _pending_preview(td)
1408
+ out = _run_hook(td, _hook_payload(card, "Reject"))
1409
+ assert pythia.read_grant(td, "7f3a91") is None
1410
+ assert "did not approve" in out.lower()
1411
+ assert _run_hook(td, _hook_payload(card, "Approve", tool="Bash")) == ""
1412
+ assert _run_hook(td, "not json at all") == ""
1413
+ assert _run_hook(td, _hook_payload("no token here", "Approve")) == ""
1414
+ assert not pythia.grants_root(td).exists()
1415
+ # a token no preview carries cannot be approved blind, in chat either
1416
+ out = _run_hook(td, _hook_payload("pythia approve abc123", "Approve"))
1417
+ assert "no pending preview" in out.lower()
1418
+ assert pythia.read_grant(td, "abc123") is None
1419
+
1420
+
1421
+ def test_approve_card_needs_no_console_and_mints_nothing():
1422
+ """--card is the agent's half: it prints what to ask with. Headless is
1423
+ fine, and nothing is minted by printing."""
1424
+ import contextlib
1425
+ import io
1426
+ with tempfile.TemporaryDirectory() as td, _NoTTY():
1427
+ _pending_preview(td)
1428
+ buf = io.StringIO()
1429
+ with contextlib.redirect_stdout(buf):
1430
+ pythia.cmd_approve(None, None, approve_ns(td, ["7f3a91"], card=True))
1431
+ out = buf.getvalue()
1432
+ assert "approve 7f3a91" in out and "PKG_ORDER" in out
1433
+ assert "12 dependent objects" in out and "DEV" in out
1434
+ assert "\x1b[" not in out # plain text: it goes into a question
1435
+ assert not pythia.grants_root(td).exists()
1436
+ buf = io.StringIO()
1437
+ with contextlib.redirect_stdout(buf):
1438
+ pythia.cmd_approve(None, None, approve_ns(td, ["7f3a91"], card=True,
1439
+ json=True))
1440
+ d = json.loads(buf.getvalue())
1441
+ assert d["minted"] is False and d["question"]["header"] == "pythia"
1442
+ assert [o["label"] for o in d["question"]["options"]] == ["Approve", "Reject"]
1443
+ assert d["card"] in d["question"]["question"]
1444
+
1445
+
1446
+ def test_approve_several_tokens_in_one_console_act():
1447
+ """Four previews, one approve line — not four."""
1448
+ import contextlib
1449
+ import io
1450
+ with tempfile.TemporaryDirectory() as td:
1451
+ _pending_preview(td, "aa1111")
1452
+ _pending_preview(td, "bb2222")
1453
+ with contextlib.redirect_stdout(io.StringIO()):
1454
+ pythia.cmd_approve(None, None,
1455
+ approve_ns(td, ["aa1111", "bb2222"], console=True))
1456
+ assert pythia.read_grant(td, "aa1111")["approver"] == "console"
1457
+ assert pythia.read_grant(td, "bb2222")["approver"] == "console"
1458
+
1459
+
1460
+ def test_apply_reads_a_bom_file_as_the_statement_it_is():
1461
+ """Windows editors prepend a UTF-8 BOM. Read as a stray byte it turned a
1462
+ plain CREATE OR REPLACE into 'cannot classify' — a refusal with the wrong
1463
+ reason, found live in 0.10.0's dogfood run."""
1464
+ import contextlib
1465
+ import io
1466
+ import pathlib
1467
+ with tempfile.TemporaryDirectory() as td:
1468
+ f = pathlib.Path(td) / "f.sql"
1469
+ import codecs
1470
+ f.write_bytes(codecs.BOM_UTF8 + NEW_FILE.encode("utf-8"))
1471
+ buf = io.StringIO()
1472
+ with contextlib.redirect_stdout(buf):
1473
+ pythia.cmd_apply(FakeConn(base_script()), "APP",
1474
+ apply_ns(td, file=str(f)))
1475
+ assert "approve --card" in buf.getvalue() # it previewed
1476
+
1477
+
1478
+ def test_apply_refuses_when_the_session_schema_is_not_the_configured_one():
1479
+ """A proxy into another user, or a stale `schema` in connections.json:
1480
+ an unqualified CREATE would land in one schema while pythia snapshots
1481
+ and verifies another. Refuse before the snapshot, with the fix."""
1482
+ with tempfile.TemporaryDirectory() as td:
1483
+ script = base_script()
1484
+ script["current_schema"] = ([("CURRENT_SCHEMA",)], [("PYTHIA_DEV",)])
1485
+ conn = FakeConn(script)
1486
+ expect_exit(lambda: pythia.run_apply(conn, "APP", apply_ns(td, file="f.sql"),
1487
+ NEW_FILE),
1488
+ "PYTHIA_DEV", "configured for 'APP'", "connections.json")
1489
+ assert wrote_ddl(conn) == []
1490
+ assert not pythia.journal_root(td).exists() # refused before step 1
1491
+
1492
+
1341
1493
  def main():
1342
1494
  failed = 0
1343
1495
  for name, fn in sorted(globals().items()):
File without changes
File without changes