pythia-plsql 0.11.2__tar.gz → 0.13.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.11.2/scripts/pythia_plsql.egg-info → pythia_plsql-0.13.0}/PKG-INFO +6 -2
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/README.md +5 -1
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/pyproject.toml +1 -1
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/queries/name-occupants.sql +3 -2
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/scripts/pythia.py +246 -17
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0/scripts/pythia_plsql.egg-info}/PKG-INFO +6 -2
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/scripts/pythia_plsql.egg-info/SOURCES.txt +1 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/skills/pythia-apply/SKILL.md +12 -12
- pythia_plsql-0.13.0/skills/pythia-comment/SKILL.md +73 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/skills/pythia-write/SKILL.md +2 -1
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/skills/using-pythia/SKILL.md +1 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/tests/test_install.py +104 -2
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/tests/test_phase3.py +128 -4
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/tests/test_phase5.py +2 -2
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/LICENSE +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/queries/compile-errors.sql +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/queries/current-schema.sql +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/queries/dependencies.sql +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/queries/impact.sql +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/queries/invalid-objects.sql +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/queries/object-names.sql +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/queries/object-source.sql +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/queries/plscope-enabled.sql +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/queries/plscope-statements.sql +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/queries/plscope-usages.sql +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/queries/session-privileges.sql +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/queries/similar-candidates.sql +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/queries/source.sql +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/scripts/pythia_plsql.egg-info/dependency_links.txt +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/scripts/pythia_plsql.egg-info/entry_points.txt +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/scripts/pythia_plsql.egg-info/requires.txt +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/scripts/pythia_plsql.egg-info/top_level.txt +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/setup.cfg +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/skills/pythia-conventions/SKILL.md +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/skills/pythia-explore/SKILL.md +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/skills/pythia-explore/reference/data-dictionary.md +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/skills/pythia-impact/SKILL.md +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/skills/pythia-review/SKILL.md +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/skills/pythia-review/reference/antipatterns.md +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/skills/pythia-setup/SKILL.md +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/skills/pythia-skill-author/SKILL.md +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/skills/pythia-spec/SKILL.md +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/skills/pythia-write/reference/patterns.md +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/tests/test_phase1.py +0 -0
- {pythia_plsql-0.11.2 → pythia_plsql-0.13.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.13.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
|
|
@@ -128,7 +128,7 @@ Or the same thing piecewise:
|
|
|
128
128
|
|
|
129
129
|
```bash
|
|
130
130
|
pip install pythia-plsql # the CLI (thin driver — no Oracle Instant Client needed)
|
|
131
|
-
python -m pythia install # skills
|
|
131
|
+
python -m pythia install # skills + .pythia/ scaffold + Claude Code hooks + Codex AGENTS.md
|
|
132
132
|
pythia check # fill in connections.json first, then verify
|
|
133
133
|
```
|
|
134
134
|
|
|
@@ -212,6 +212,10 @@ stops it prompting for the read-only commands and asks it to pause on
|
|
|
212
212
|
writes — optional, and yours to install
|
|
213
213
|
([why pythia does not](GUIDE.md#11-optional-claude-code-permission-settings)).
|
|
214
214
|
|
|
215
|
+
On Codex, the same command writes the harness into `AGENTS.md` (loaded every
|
|
216
|
+
session) and a session-start hook into `.codex/hooks.json`; approve previews in
|
|
217
|
+
the terminal with `pythia approve <token>`, since Codex has no chat-mint.
|
|
218
|
+
|
|
215
219
|
Per-group write policy, `.pythia/policy.json` (defaults shown):
|
|
216
220
|
|
|
217
221
|
| Group | Default | Is rollback real? |
|
|
@@ -114,7 +114,7 @@ Or the same thing piecewise:
|
|
|
114
114
|
|
|
115
115
|
```bash
|
|
116
116
|
pip install pythia-plsql # the CLI (thin driver — no Oracle Instant Client needed)
|
|
117
|
-
python -m pythia install # skills
|
|
117
|
+
python -m pythia install # skills + .pythia/ scaffold + Claude Code hooks + Codex AGENTS.md
|
|
118
118
|
pythia check # fill in connections.json first, then verify
|
|
119
119
|
```
|
|
120
120
|
|
|
@@ -198,6 +198,10 @@ stops it prompting for the read-only commands and asks it to pause on
|
|
|
198
198
|
writes — optional, and yours to install
|
|
199
199
|
([why pythia does not](GUIDE.md#11-optional-claude-code-permission-settings)).
|
|
200
200
|
|
|
201
|
+
On Codex, the same command writes the harness into `AGENTS.md` (loaded every
|
|
202
|
+
session) and a session-start hook into `.codex/hooks.json`; approve previews in
|
|
203
|
+
the terminal with `pythia approve <token>`, since Codex has no chat-mint.
|
|
204
|
+
|
|
201
205
|
Per-group write policy, `.pythia/policy.json` (defaults shown):
|
|
202
206
|
|
|
203
207
|
| Group | Default | Is rollback real? |
|
|
@@ -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.13.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" }
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
-- ORA-00955. Found by an agent during the first field test.
|
|
6
6
|
-- Binds: :s schema (object owner)
|
|
7
7
|
-- :n object name
|
|
8
|
-
-- Returns: OBJECT_TYPE
|
|
9
|
-
|
|
8
|
+
-- Returns: OBJECT_TYPE, EDITIONABLE ('Y'/'N', null when the type
|
|
9
|
+
-- has no edition — the apply reconciles the keyword to it)
|
|
10
|
+
select object_type, editionable
|
|
10
11
|
from all_objects
|
|
11
12
|
where owner = :s
|
|
12
13
|
and object_name = upper(:n)
|
|
@@ -1240,6 +1240,46 @@ EDITIONABLE_KW_RE = re.compile(
|
|
|
1240
1240
|
r"^create\s+(?:or\s+replace\s+)?((?:non?)?editionable)\b", re.I)
|
|
1241
1241
|
|
|
1242
1242
|
|
|
1243
|
+
CREATE_HEAD_RE = re.compile(
|
|
1244
|
+
r"^(create\s+(?:or\s+replace\s+)?)((?:non?)?editionable\s+)?", re.I)
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
def reconcile_editionable(stmt, editionable):
|
|
1248
|
+
"""Make the statement agree with the property the database already holds.
|
|
1249
|
+
|
|
1250
|
+
ALL_SOURCE stores no CREATE header, so a body read back with `src` comes
|
|
1251
|
+
back bare and the keyword is the easiest thing in Oracle to lose — and
|
|
1252
|
+
Oracle refuses to *change* the property through CREATE OR REPLACE, so the
|
|
1253
|
+
statement cannot succeed. Left alone, ORA-38824 lands after the developer
|
|
1254
|
+
has already approved the preview. Nothing here is guessed:
|
|
1255
|
+
ALL_OBJECTS.EDITIONABLE is the answer, and the edit is announced.
|
|
1256
|
+
|
|
1257
|
+
Returns (statement, note). The file on disk is never touched."""
|
|
1258
|
+
if editionable not in ("Y", "N"):
|
|
1259
|
+
return stmt, None # a new object, or a type that has no edition
|
|
1260
|
+
body = skip_leading_noise(stmt)
|
|
1261
|
+
m = CREATE_HEAD_RE.match(body)
|
|
1262
|
+
if not m:
|
|
1263
|
+
return stmt, None
|
|
1264
|
+
have = (m.group(2) or "").strip().upper()
|
|
1265
|
+
if editionable == "N":
|
|
1266
|
+
want = "NONEDITIONABLE"
|
|
1267
|
+
else:
|
|
1268
|
+
# only NONEDITIONABLE contradicts an editionable object; a bare header
|
|
1269
|
+
# and an explicit EDITIONABLE both say the same thing to Oracle
|
|
1270
|
+
want = "" if have.endswith("EDITIONABLE") and have != "EDITIONABLE" else have
|
|
1271
|
+
if have == want:
|
|
1272
|
+
return stmt, None
|
|
1273
|
+
head = stmt[:len(stmt) - len(body)]
|
|
1274
|
+
fixed = head + m.group(1) + (want + " " if want else "") + body[m.end():]
|
|
1275
|
+
verb = "added" if want else "removed"
|
|
1276
|
+
# two lines, indented like the other preview warnings: one long line wraps
|
|
1277
|
+
# into noise in a terminal
|
|
1278
|
+
return fixed, (f"NONEDITIONABLE {verb} to match the object in the "
|
|
1279
|
+
"database — Oracle cannot\n change that property through "
|
|
1280
|
+
"CREATE OR REPLACE (ORA-38824). Your file is unchanged.")
|
|
1281
|
+
|
|
1282
|
+
|
|
1243
1283
|
def create_prefix(after_text):
|
|
1244
1284
|
"""The CREATE header ALL_SOURCE does not store, rebuilt for the version
|
|
1245
1285
|
held in the database. The editionable keyword is read off the file being
|
|
@@ -1253,6 +1293,54 @@ def create_prefix(after_text):
|
|
|
1253
1293
|
return "CREATE OR REPLACE " + (f"{m.group(1).upper()} " if m else "")
|
|
1254
1294
|
|
|
1255
1295
|
|
|
1296
|
+
def source_body(after_text):
|
|
1297
|
+
"""The ALL_SOURCE-shaped half of a stored statement. create_prefix builds
|
|
1298
|
+
the header ALL_SOURCE does not keep; this drops it again, plus the
|
|
1299
|
+
SQL*Plus terminator, so the two can be compared as equals."""
|
|
1300
|
+
body = skip_leading_noise(prepare_statement(after_text, "plsql_source"))
|
|
1301
|
+
m = CREATE_HEAD_RE.match(body)
|
|
1302
|
+
return body[m.end():] if m else body
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def drift_note(db_source, last_applied, entry):
|
|
1306
|
+
"""Whether the object moved since pythia last wrote it.
|
|
1307
|
+
|
|
1308
|
+
The snapshot proves what pythia did; nothing ever proved what anyone else
|
|
1309
|
+
did. In the field a comment line was written, read back intact two seconds
|
|
1310
|
+
later, and gone eight minutes on with no journal entry in between — and
|
|
1311
|
+
the developer went hunting a lying `src` instead of a second writer. The
|
|
1312
|
+
journal held both halves of the answer the whole time and nothing compared
|
|
1313
|
+
them. Warn, never refuse: editing by hand is legitimate, not noticing is
|
|
1314
|
+
not."""
|
|
1315
|
+
if not last_applied or not db_source.strip():
|
|
1316
|
+
return None
|
|
1317
|
+
mine = source_body(last_applied).replace("\r\n", "\n").rstrip()
|
|
1318
|
+
now = db_source.replace("\r\n", "\n").rstrip()
|
|
1319
|
+
if mine == now:
|
|
1320
|
+
return None
|
|
1321
|
+
delta = len(now.splitlines()) - len(mine.splitlines())
|
|
1322
|
+
how = (f"{abs(delta)} line{'s' if abs(delta) != 1 else ''} "
|
|
1323
|
+
+ ("more" if delta > 0 else "fewer")) if delta else "the same line count, different text"
|
|
1324
|
+
return (f"This object changed outside pythia since {entry} ({how}). The "
|
|
1325
|
+
"diff below is\n against the database as it stands now, not "
|
|
1326
|
+
"against what pythia wrote.")
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def last_applied_after(root, obj_type, name):
|
|
1330
|
+
"""The text pythia last actually wrote to this object, from the journal."""
|
|
1331
|
+
for eid in list_journal_entries(root):
|
|
1332
|
+
try:
|
|
1333
|
+
e = read_journal_entry(root, eid)
|
|
1334
|
+
except SystemExit:
|
|
1335
|
+
continue
|
|
1336
|
+
meta = e["meta"]
|
|
1337
|
+
if (meta.get("applied")
|
|
1338
|
+
and str(meta.get("object", "")).upper() == name.upper()
|
|
1339
|
+
and str(meta.get("type", "")).upper() == obj_type.upper()):
|
|
1340
|
+
return eid, e["after"]
|
|
1341
|
+
return None, None
|
|
1342
|
+
|
|
1343
|
+
|
|
1256
1344
|
def render_restore(obj_type, name, before_text, after_text=""):
|
|
1257
1345
|
"""The statement that puts things back. For an object that did not exist,
|
|
1258
1346
|
undo means DROP — a genuinely different promise than restoring source, so
|
|
@@ -2046,6 +2134,7 @@ def run_apply(conn, schema, ns, file_text, origin=None):
|
|
|
2046
2134
|
"invocation's connection, which is now over.")
|
|
2047
2135
|
return 0
|
|
2048
2136
|
|
|
2137
|
+
edition_note = None
|
|
2049
2138
|
if group == "plsql_source":
|
|
2050
2139
|
otype, name, file_schema = parse_object(file_text)
|
|
2051
2140
|
if file_schema and file_schema.upper() != schema:
|
|
@@ -2056,6 +2145,9 @@ def run_apply(conn, schema, ns, file_text, origin=None):
|
|
|
2056
2145
|
_, occ_rows = run_query(conn, load_query("name-occupants.sql"),
|
|
2057
2146
|
{"s": schema, "n": name})
|
|
2058
2147
|
blockers = name_conflicts(otype, [r[0] for r in occ_rows])
|
|
2148
|
+
stmt, edition_note = reconcile_editionable(
|
|
2149
|
+
stmt, next((cell(r[1]) for r in occ_rows
|
|
2150
|
+
if str(r[0]).upper() == otype.upper()), None))
|
|
2059
2151
|
if blockers:
|
|
2060
2152
|
sys.exit(f"{name} already exists as {', '.join(blockers)} in {schema} "
|
|
2061
2153
|
"— CREATE OR REPLACE cannot change an object's type "
|
|
@@ -2112,12 +2204,17 @@ def run_apply(conn, schema, ns, file_text, origin=None):
|
|
|
2112
2204
|
"tty": human_at_the_keyboard(),
|
|
2113
2205
|
"invalid_before": invalid_before, **(origin or {})}
|
|
2114
2206
|
entry = write_journal_entry(ns.project_root, otype, name, db_source,
|
|
2115
|
-
file_text, meta)
|
|
2207
|
+
stmt if edition_note else file_text, meta)
|
|
2116
2208
|
# "created" is about an OBJECT that did not exist. A DML/DDL statement has
|
|
2117
2209
|
# no object identity, so it is never "new" — and saying so would promise a
|
|
2118
2210
|
# DROP-shaped undo that does not exist for those groups.
|
|
2119
2211
|
created = not db_source.strip() and group == "plsql_source"
|
|
2120
2212
|
|
|
2213
|
+
drift = None
|
|
2214
|
+
if group == "plsql_source" and db_source.strip():
|
|
2215
|
+
eid, last = last_applied_after(ns.project_root, otype, name)
|
|
2216
|
+
drift = drift_note(db_source, last, eid)
|
|
2217
|
+
|
|
2121
2218
|
# 2. IMPACT
|
|
2122
2219
|
summary = ""
|
|
2123
2220
|
if group == "plsql_source":
|
|
@@ -2140,7 +2237,9 @@ def run_apply(conn, schema, ns, file_text, origin=None):
|
|
|
2140
2237
|
"created": created, "changed_lines": changed,
|
|
2141
2238
|
"summary": summary, "warning": warn,
|
|
2142
2239
|
"row_set": row_set,
|
|
2143
|
-
"naming_warning": style,
|
|
2240
|
+
"naming_warning": style,
|
|
2241
|
+
"editionable_note": edition_note, "drift": drift,
|
|
2242
|
+
"token": token,
|
|
2144
2243
|
"journal": entry, "will_apply": confirmed}))
|
|
2145
2244
|
else:
|
|
2146
2245
|
en = getattr(ns, "color", False)
|
|
@@ -2164,6 +2263,10 @@ def run_apply(conn, schema, ns, file_text, origin=None):
|
|
|
2164
2263
|
elif group == "data_dml":
|
|
2165
2264
|
print("\n INSERT — no rows exist beforehand, so there is no row "
|
|
2166
2265
|
"set to revalidate.")
|
|
2266
|
+
if drift:
|
|
2267
|
+
print(f"\n {paint('! ' + drift, 'yellow', en)}")
|
|
2268
|
+
if edition_note:
|
|
2269
|
+
print(f"\n {paint('! ' + edition_note, 'yellow', en)}")
|
|
2167
2270
|
if warn:
|
|
2168
2271
|
print(f"\n {paint(warn, 'yellow', en)}")
|
|
2169
2272
|
if style:
|
|
@@ -2203,7 +2306,15 @@ def run_apply(conn, schema, ns, file_text, origin=None):
|
|
|
2203
2306
|
cur.execute("ALTER SESSION SET plscope_settings = "
|
|
2204
2307
|
"'IDENTIFIERS:ALL, STATEMENTS:ALL'")
|
|
2205
2308
|
with conn.cursor() as cur:
|
|
2206
|
-
|
|
2309
|
+
try:
|
|
2310
|
+
cur.execute(stmt)
|
|
2311
|
+
except Exception as e: # noqa: BLE001 - the driver refused
|
|
2312
|
+
# the preview and "Snapshot saved" printed before this line ran,
|
|
2313
|
+
# so without a closing verdict the run reads like one that landed
|
|
2314
|
+
sys.exit(f"FAILED — nothing was written.\n{e}\n"
|
|
2315
|
+
"The object is unchanged. The approval is unspent: retry "
|
|
2316
|
+
"the same file with the same token, or fix the file and "
|
|
2317
|
+
"preview again for a new one.")
|
|
2207
2318
|
affected = cur.rowcount if group == "data_dml" else None
|
|
2208
2319
|
if group == "data_dml":
|
|
2209
2320
|
# the last-millisecond check: the probe ran before this statement, and
|
|
@@ -2707,6 +2818,51 @@ DO writes go through `pythia apply` only - snapshot, token, verify. The
|
|
|
2707
2818
|
"""
|
|
2708
2819
|
|
|
2709
2820
|
|
|
2821
|
+
AGENTS_BEGIN = "<!-- pythia:begin (managed by `pythia install`; edit outside this block) -->"
|
|
2822
|
+
AGENTS_END = "<!-- pythia:end -->"
|
|
2823
|
+
|
|
2824
|
+
|
|
2825
|
+
def agents_md_block():
|
|
2826
|
+
"""The pythia section for AGENTS.md — Codex's always-loaded instruction
|
|
2827
|
+
file. Body is BRIEF_GUIDE verbatim (the same constant `guide --brief`
|
|
2828
|
+
prints and the Claude SessionStart hook runs, so the two cannot drift),
|
|
2829
|
+
plus the one line Codex needs that Claude gets from its hook: where the
|
|
2830
|
+
approval door is, since Codex has no chat-mint. Marker-wrapped so a re-run
|
|
2831
|
+
replaces it in place and the developer's own text is untouched."""
|
|
2832
|
+
return (f"{AGENTS_BEGIN}\n"
|
|
2833
|
+
"## pythia — developing PL/SQL on Oracle\n\n"
|
|
2834
|
+
f"{BRIEF_GUIDE.rstrip()}\n\n"
|
|
2835
|
+
"On Codex there is no chat approval hook: the developer approves a "
|
|
2836
|
+
"preview in this terminal with `pythia approve <token>`, then the "
|
|
2837
|
+
"agent runs `pythia apply <file> --confirm <token>`.\n"
|
|
2838
|
+
f"{AGENTS_END}")
|
|
2839
|
+
|
|
2840
|
+
|
|
2841
|
+
def merge_agents_md(path):
|
|
2842
|
+
"""Write the pythia block into an AGENTS.md. The file is the developer's,
|
|
2843
|
+
shared with every AGENTS.md-reading agent, so only the delimited block is
|
|
2844
|
+
ours: present -> replaced in place, absent -> appended below their text,
|
|
2845
|
+
everything else byte-identical. Returns (path, action) where action is
|
|
2846
|
+
'created' | 'updated' | 'unchanged'."""
|
|
2847
|
+
path = pathlib.Path(path)
|
|
2848
|
+
block = agents_md_block()
|
|
2849
|
+
old = path.read_text(encoding="utf-8") if path.is_file() else None
|
|
2850
|
+
if old is None:
|
|
2851
|
+
new, action = block + "\n", "created"
|
|
2852
|
+
elif AGENTS_BEGIN in old and AGENTS_END in old:
|
|
2853
|
+
pre = old[:old.index(AGENTS_BEGIN)]
|
|
2854
|
+
post = old[old.index(AGENTS_END) + len(AGENTS_END):]
|
|
2855
|
+
new = pre + block + post
|
|
2856
|
+
action = "unchanged" if new == old else "updated"
|
|
2857
|
+
else:
|
|
2858
|
+
tail = "" if old.endswith("\n\n") else "\n" if old.endswith("\n") else "\n\n"
|
|
2859
|
+
new, action = old + tail + block + "\n", "updated"
|
|
2860
|
+
if new != old:
|
|
2861
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
2862
|
+
path.write_text(new, encoding="utf-8")
|
|
2863
|
+
return path, action
|
|
2864
|
+
|
|
2865
|
+
|
|
2710
2866
|
OPERATING_GUIDE = """\
|
|
2711
2867
|
THE OPERATING MODEL — Learn, Ask, Do (Hoc - Hoi - Lam)
|
|
2712
2868
|
|
|
@@ -3125,29 +3281,36 @@ def global_pack_present(home=None):
|
|
|
3125
3281
|
|
|
3126
3282
|
|
|
3127
3283
|
def copy_bundled_skills(base_dir):
|
|
3128
|
-
"""No-Node fallback: copy the wheel-bundled pack into
|
|
3129
|
-
<base>/.claude/skills/
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3284
|
+
"""No-Node fallback: copy the wheel-bundled pack into BOTH conventional
|
|
3285
|
+
roots — <base>/.claude/skills/ (Claude Code) and <base>/.agents/skills/
|
|
3286
|
+
(Codex, and ~/.agents/skills on -g). base_dir is the project root, or the
|
|
3287
|
+
home directory for -g. Node users get the same reach from `npx skills
|
|
3288
|
+
add`; this is the machine that has only Python.
|
|
3289
|
+
|
|
3290
|
+
Trade-off, stated plainly: this reverses an earlier one-root-only choice.
|
|
3291
|
+
A Claude Code version that reads both roots may list a pack entry twice —
|
|
3292
|
+
accepted, because a Codex user without Node otherwise gets no skills at
|
|
3293
|
+
all, and Codex reads only .agents/skills.
|
|
3133
3294
|
|
|
3134
3295
|
Both destinations are cleared link-first before the copy. An earlier
|
|
3135
3296
|
`npx skills add` leaves .claude/skills/<name> as a symlink into
|
|
3136
|
-
.agents/skills/<name>;
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
skills in those directories are left alone."""
|
|
3297
|
+
.agents/skills/<name>; removing both links first means each copytree
|
|
3298
|
+
writes a fresh real directory, never through a link into a target the
|
|
3299
|
+
other copy would then overwrite. Only the pack's own names are touched;
|
|
3300
|
+
other skills in those directories are left alone."""
|
|
3140
3301
|
import shutil
|
|
3141
3302
|
base = pathlib.Path(base_dir)
|
|
3142
|
-
|
|
3303
|
+
claude_root = base / ".claude" / "skills" # Claude Code reads this
|
|
3304
|
+
agents_root = base / ".agents" / "skills" # Codex reads this
|
|
3143
3305
|
for pack in sorted(SKILLS_DIR.iterdir()):
|
|
3144
3306
|
if not (pack / "SKILL.md").is_file():
|
|
3145
3307
|
continue
|
|
3146
|
-
_remove_link_first(
|
|
3147
|
-
_remove_link_first(
|
|
3148
|
-
shutil.copytree(pack,
|
|
3308
|
+
_remove_link_first(claude_root / pack.name)
|
|
3309
|
+
_remove_link_first(agents_root / pack.name)
|
|
3310
|
+
shutil.copytree(pack, claude_root / pack.name)
|
|
3311
|
+
shutil.copytree(pack, agents_root / pack.name)
|
|
3149
3312
|
clean_legacy_skills(base_dir)
|
|
3150
|
-
return [
|
|
3313
|
+
return [claude_root, agents_root]
|
|
3151
3314
|
|
|
3152
3315
|
|
|
3153
3316
|
def _remove_link_first(path):
|
|
@@ -3191,6 +3354,43 @@ CLAUDE_DENY = ["Bash(pythia approve --hook*)",
|
|
|
3191
3354
|
# is recognised by what it runs, not by how it invokes it
|
|
3192
3355
|
HOOK_MARKS = {"SessionStart": "guide --brief", "PostToolUse": "approve --hook"}
|
|
3193
3356
|
|
|
3357
|
+
# Codex loads hooks from <repo>/.codex/hooks.json once the layer is trusted
|
|
3358
|
+
# (/hooks). Only the session-start guide is wired — Codex has no answer-reading
|
|
3359
|
+
# PostToolUse and no question tool, so the chat approve-mint cannot port; the
|
|
3360
|
+
# developer approves in the terminal. Same shape and merge discipline as the
|
|
3361
|
+
# Claude hooks.
|
|
3362
|
+
CODEX_HOOKS = {
|
|
3363
|
+
"SessionStart": [{"hooks": [
|
|
3364
|
+
{"type": "command", "command": "python -m pythia guide --brief"}]}],
|
|
3365
|
+
}
|
|
3366
|
+
CODEX_HOOK_MARKS = {"SessionStart": "guide --brief"}
|
|
3367
|
+
|
|
3368
|
+
|
|
3369
|
+
def merge_codex_hooks(path, events=None):
|
|
3370
|
+
"""Merge pythia's Codex hooks into a hooks.json ({"hooks": {...}}). Every
|
|
3371
|
+
other key survives, a hook already present in any spelling is recognised by
|
|
3372
|
+
what it runs, and a file that will not parse is left byte-identical.
|
|
3373
|
+
Returns (path, added|None)."""
|
|
3374
|
+
path = pathlib.Path(path)
|
|
3375
|
+
try:
|
|
3376
|
+
s = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
|
|
3377
|
+
hooks = s.setdefault("hooks", {})
|
|
3378
|
+
added = []
|
|
3379
|
+
for event in events or CODEX_HOOKS:
|
|
3380
|
+
present = hooks.setdefault(event, [])
|
|
3381
|
+
cmds = " ".join(h.get("command", "")
|
|
3382
|
+
for e in present for h in e.get("hooks", []))
|
|
3383
|
+
if CODEX_HOOK_MARKS[event] not in cmds:
|
|
3384
|
+
present.extend(json.loads(json.dumps(CODEX_HOOKS[event])))
|
|
3385
|
+
added.append(f"hooks.{event}")
|
|
3386
|
+
except (ValueError, TypeError, AttributeError):
|
|
3387
|
+
return path, None
|
|
3388
|
+
if added:
|
|
3389
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
3390
|
+
path.write_text(json.dumps(s, indent=2, ensure_ascii=False) + "\n",
|
|
3391
|
+
encoding="utf-8")
|
|
3392
|
+
return path, added
|
|
3393
|
+
|
|
3194
3394
|
|
|
3195
3395
|
def install_claude_hooks(base_dir, events=None):
|
|
3196
3396
|
"""Merge the two hooks and the deny rule into <base>/.claude/settings.json
|
|
@@ -3246,6 +3446,33 @@ def report_claude_hooks(base_dir, ns, events=None):
|
|
|
3246
3446
|
print(f"\nClaude Code hooks already in {path}.")
|
|
3247
3447
|
|
|
3248
3448
|
|
|
3449
|
+
def report_codex_agents_md(project_root):
|
|
3450
|
+
"""Project scope: write the harness into <project>/AGENTS.md, the file
|
|
3451
|
+
Codex loads every session. Not done on -g — a global AGENTS.md is noise in
|
|
3452
|
+
every non-Oracle session, as the Claude guide is per-project."""
|
|
3453
|
+
path, action = merge_agents_md(pathlib.Path(project_root) / "AGENTS.md")
|
|
3454
|
+
verb = {"created": "Wrote the pythia harness into",
|
|
3455
|
+
"updated": "Updated the pythia block in",
|
|
3456
|
+
"unchanged": "pythia harness already current in"}[action]
|
|
3457
|
+
print(f"\n{verb} {path} (Codex and other AGENTS.md agents load it each session).")
|
|
3458
|
+
|
|
3459
|
+
|
|
3460
|
+
def report_codex_hooks(project_root, ns):
|
|
3461
|
+
"""Project scope: the Codex session-start guide hook. --no-hooks skips it,
|
|
3462
|
+
as with the Claude hooks. AGENTS.md already carries the guide, so this is
|
|
3463
|
+
reinforcement for hook-driven Codex users."""
|
|
3464
|
+
if getattr(ns, "no_hooks", False):
|
|
3465
|
+
return
|
|
3466
|
+
path, added = merge_codex_hooks(pathlib.Path(project_root) / ".codex" / "hooks.json")
|
|
3467
|
+
if added is None:
|
|
3468
|
+
print(f"\n! {path} is not valid JSON, so it was left untouched.")
|
|
3469
|
+
elif added:
|
|
3470
|
+
print(f"\nWired the Codex session-start hook into {path}.")
|
|
3471
|
+
print("In Codex, run /hooks once to trust this project's .codex layer.")
|
|
3472
|
+
else:
|
|
3473
|
+
print(f"\nCodex session-start hook already in {path}.")
|
|
3474
|
+
|
|
3475
|
+
|
|
3249
3476
|
def cmd_install(conn, schema, ns):
|
|
3250
3477
|
import shutil
|
|
3251
3478
|
en = getattr(ns, "color", False)
|
|
@@ -3287,6 +3514,8 @@ def cmd_install(conn, schema, ns):
|
|
|
3287
3514
|
else:
|
|
3288
3515
|
clean_legacy_skills(ns.project_root)
|
|
3289
3516
|
report_claude_hooks(ns.project_root, ns)
|
|
3517
|
+
report_codex_agents_md(ns.project_root)
|
|
3518
|
+
report_codex_hooks(ns.project_root, ns)
|
|
3290
3519
|
print(f"\nNext: fill in {path}")
|
|
3291
3520
|
print(f"Then: {invocation()} check")
|
|
3292
3521
|
scripts_dir = installed_scripts_dir()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pythia-plsql
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.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
|
|
@@ -128,7 +128,7 @@ Or the same thing piecewise:
|
|
|
128
128
|
|
|
129
129
|
```bash
|
|
130
130
|
pip install pythia-plsql # the CLI (thin driver — no Oracle Instant Client needed)
|
|
131
|
-
python -m pythia install # skills
|
|
131
|
+
python -m pythia install # skills + .pythia/ scaffold + Claude Code hooks + Codex AGENTS.md
|
|
132
132
|
pythia check # fill in connections.json first, then verify
|
|
133
133
|
```
|
|
134
134
|
|
|
@@ -212,6 +212,10 @@ stops it prompting for the read-only commands and asks it to pause on
|
|
|
212
212
|
writes — optional, and yours to install
|
|
213
213
|
([why pythia does not](GUIDE.md#11-optional-claude-code-permission-settings)).
|
|
214
214
|
|
|
215
|
+
On Codex, the same command writes the harness into `AGENTS.md` (loaded every
|
|
216
|
+
session) and a session-start hook into `.codex/hooks.json`; approve previews in
|
|
217
|
+
the terminal with `pythia approve <token>`, since Codex has no chat-mint.
|
|
218
|
+
|
|
215
219
|
Per-group write policy, `.pythia/policy.json` (defaults shown):
|
|
216
220
|
|
|
217
221
|
| Group | Default | Is rollback real? |
|
|
@@ -23,6 +23,7 @@ scripts/pythia_plsql.egg-info/entry_points.txt
|
|
|
23
23
|
scripts/pythia_plsql.egg-info/requires.txt
|
|
24
24
|
scripts/pythia_plsql.egg-info/top_level.txt
|
|
25
25
|
skills/pythia-apply/SKILL.md
|
|
26
|
+
skills/pythia-comment/SKILL.md
|
|
26
27
|
skills/pythia-conventions/SKILL.md
|
|
27
28
|
skills/pythia-explore/SKILL.md
|
|
28
29
|
skills/pythia-explore/reference/data-dictionary.md
|
|
@@ -10,8 +10,7 @@ description: Use when a PL/SQL change is ready to reach the database - applying
|
|
|
10
10
|
**Phase:** Ask → Do — the preview is relayed and approved before the one write door opens
|
|
11
11
|
|
|
12
12
|
DDL in Oracle commits itself, so the snapshot pythia takes before writing is
|
|
13
|
-
the only undo there is —
|
|
14
|
-
net is always used, and used honestly.
|
|
13
|
+
the only undo there is — for DML not even that. This skill is how it gets used.
|
|
15
14
|
|
|
16
15
|
## The Iron Law
|
|
17
16
|
|
|
@@ -54,20 +53,21 @@ confirms a number you already knew.
|
|
|
54
53
|
warning, exactly as printed. The developer approves what they see, not
|
|
55
54
|
your paraphrase.
|
|
56
55
|
3. **Ask for approval — one question per token.** `pythia approve --card
|
|
57
|
-
<token>` prints the card;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
56
|
+
<token>` prints the card; the `AskUserQuestion` text is that card and
|
|
57
|
+
nothing else — no word before it, no translation after it, no framing of
|
|
58
|
+
your own. The hook compares question to card, mints nothing when they
|
|
59
|
+
differ, and by then the developer has already clicked. Put the explanation
|
|
60
|
+
in the option descriptions; the hook does not read those. Header `pythia`;
|
|
61
|
+
options exactly `Approve` / `Reject`, no "(Recommended)"; up to four tokens
|
|
62
|
+
per call. Anything but `Approve` is not approval: stop and ask what should
|
|
63
|
+
change. File changed → step 1. No hook (Codex, or none)? Relay `pythia approve <token>` in the terminal — Codex has no chat-mint.
|
|
63
64
|
4. **Apply** by running the exact `then the agent:` line pythia printed. Two
|
|
64
65
|
refusals are normal here, and neither is a malfunction:
|
|
65
66
|
- *"no developer approval is on file"* — the answer was not `Approve`, or
|
|
66
67
|
the hook is not installed. Say so and wait; retrying does not create it.
|
|
67
68
|
- *"the confirmation token does not match"* — the file or the database
|
|
68
|
-
changed since the preview. Go back to step 1, never "retry"
|
|
69
|
-
preview
|
|
70
|
-
have changed the object on this shared database.
|
|
69
|
+
changed since the preview. Go back to step 1, never "retry"; the fresh
|
|
70
|
+
preview warns by itself when the object moved outside pythia.
|
|
71
71
|
5. **Read the exit code — it is the verdict. Never through a pipe:**
|
|
72
72
|
`apply … | tail` returns *tail's* code, always 0. Unpiped, or `${PIPESTATUS[0]}`.
|
|
73
73
|
|
|
@@ -135,7 +135,7 @@ affected count and up to ten rows, and `--confirm` refuses if that set moved.
|
|
|
135
135
|
| "I'll restore quietly to clean up my mistake" | Restores are writes. Same gate, same visibility. |
|
|
136
136
|
| "`$?` said 0 after I piped to tail" | That was tail's 0. Read pythia's own words, or its unpiped code. |
|
|
137
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
|
|
138
|
+
| "I'll add a line of my own around the card" | Any added word voids it, and the refusal arrives after they have clicked. Explanation goes in the option description. |
|
|
139
139
|
|
|
140
140
|
## When NOT to use this skill
|
|
141
141
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pythia-comment
|
|
3
|
+
description: Use when you are about to write or edit a comment in PL/SQL you are changing, or in a .sql deliverable headed for the database. One line, one fixed shape, and anything longer goes where it belongs instead. It fires on the act of commenting rather than on a request - catching yourself opening a second comment line above one statement is the trigger.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Comments - one line, or somewhere else
|
|
7
|
+
|
|
8
|
+
**Announce at start:** "Using pythia-comment — one line, date - author - what changed."
|
|
9
|
+
|
|
10
|
+
**Phase:** Do — it applies while writing, before `pythia apply` sees the file
|
|
11
|
+
|
|
12
|
+
A comment is the only part of a change no compiler checks and no test covers,
|
|
13
|
+
so it is the part that rots. One line survives the next rewrite. A paragraph
|
|
14
|
+
becomes a claim the next reader has to disprove before they can work.
|
|
15
|
+
|
|
16
|
+
## The shape
|
|
17
|
+
|
|
18
|
+
```sql
|
|
19
|
+
-- 11/09/2026 - jdoe - source flag reads the mapping table, not the join
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`date - author - what changed`, with the date as `DD/MM/YYYY`. The author is
|
|
23
|
+
the developer's handle, never the agent's: the agent did not decide this, and
|
|
24
|
+
a blame trail that names a tool is a dead end.
|
|
25
|
+
|
|
26
|
+
`--` for PL/SQL and SQL. That is the whole surface this skill covers — what
|
|
27
|
+
pythia applies.
|
|
28
|
+
|
|
29
|
+
**ASCII only.** Parts of the chain around a .sql file still read it as ANSI,
|
|
30
|
+
and a diacritic there becomes mojibake in something the database keeps
|
|
31
|
+
forever. Vietnamese without diacritics is a house style for exactly this
|
|
32
|
+
reason, and it is a fine one.
|
|
33
|
+
|
|
34
|
+
## One line
|
|
35
|
+
|
|
36
|
+
If it needs a second line, the comment is the wrong container. Move it:
|
|
37
|
+
|
|
38
|
+
| What you were about to write | Where it goes |
|
|
39
|
+
|---|---|
|
|
40
|
+
| why this shape, and what breaks without it | the header block of the .sql deliverable |
|
|
41
|
+
| a rule that must not regress | a test or verifier, as an assertion with a message |
|
|
42
|
+
| the decision, and the alternatives you rejected | the spec, or the ticket |
|
|
43
|
+
| what changed and why, for a reader of history | the commit message |
|
|
44
|
+
| what the statement does | nowhere — the statement already says it |
|
|
45
|
+
|
|
46
|
+
Comment only what the code cannot show: a trap, a decision, a why.
|
|
47
|
+
|
|
48
|
+
## Do not
|
|
49
|
+
|
|
50
|
+
- Multi-line explanatory blocks above one statement.
|
|
51
|
+
- Banners, box drawing, `-- ==== section ====`.
|
|
52
|
+
- Bullet lists, quoted error codes, revision archaeology inline.
|
|
53
|
+
- Commented-out code kept as documentation. Delete it — the journal and the
|
|
54
|
+
VCS both remember, and neither of them lies about what is live.
|
|
55
|
+
- Touching comments on code you are not changing. That is a different diff,
|
|
56
|
+
and it buries yours.
|
|
57
|
+
|
|
58
|
+
## Before you finish
|
|
59
|
+
|
|
60
|
+
Count the comment lines you added. More than one per statement is a rewrite,
|
|
61
|
+
not a trim.
|
|
62
|
+
|
|
63
|
+
Then confirm the comment actually reached the database. A comment is exactly
|
|
64
|
+
the kind of line a second writer drops without noticing, and it costs nothing
|
|
65
|
+
to check:
|
|
66
|
+
|
|
67
|
+
```sql
|
|
68
|
+
select count(*) from all_source
|
|
69
|
+
where owner = :owner and name = :name and text like :marker
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The preview's *changed outside pythia* warning is the other half of that
|
|
73
|
+
check — it fires when the object moved since pythia last wrote it.
|
|
@@ -42,7 +42,8 @@ existing ones — not to introduce a better style.
|
|
|
42
42
|
`unistr('...')`, so the text survives every client/DB charset exactly.
|
|
43
43
|
5. **Check yourself before handing off.** Reread against
|
|
44
44
|
`reference/patterns.md` — cursor and bulk patterns, exception discipline,
|
|
45
|
-
bind variables, commit ownership.
|
|
45
|
+
bind variables, commit ownership. Every comment you wrote or touched goes
|
|
46
|
+
through `pythia-comment` — one line, or it belongs somewhere else.
|
|
46
47
|
|
|
47
48
|
## Scope is the developer's sentence, not yours
|
|
48
49
|
|
|
@@ -30,6 +30,7 @@ sessions drift: some ask, some silently decide.
|
|
|
30
30
|
| "our standards / adopt this base's style" | `pythia-conventions` | |
|
|
31
31
|
| "make a skill for how we do X" | `pythia-skill-author` | |
|
|
32
32
|
| setup, connection failures, privilege warnings | `pythia-setup` | |
|
|
33
|
+
| about to write a comment in code you are changing | `pythia-comment` — one line, or it belongs somewhere else | |
|
|
33
34
|
| an ORA-/PLS- error to investigate | `pythia-explore` (`errors`, `src`) | `pythia-review` |
|
|
34
35
|
|
|
35
36
|
Priority when several apply: **process before construction** — spec before
|
|
@@ -57,16 +57,27 @@ def test_missing_npx_falls_back_to_the_bundled_pack():
|
|
|
57
57
|
(agents / "pythia-apply").mkdir() # stale copy from 0.2.0-0.2.3
|
|
58
58
|
(claude / "plsql-review").mkdir(parents=True)
|
|
59
59
|
targets = pythia.copy_bundled_skills(td)
|
|
60
|
-
assert targets == [claude]
|
|
60
|
+
assert targets == [claude, agents]
|
|
61
61
|
assert (claude / "pythia-apply" / "SKILL.md").is_file()
|
|
62
62
|
assert (claude / "pythia-review" / "reference" / "antipatterns.md").is_file()
|
|
63
|
-
assert
|
|
63
|
+
assert (agents / "pythia-apply" / "SKILL.md").is_file() # Codex reads this
|
|
64
64
|
assert (agents / "my-team-skill" / "SKILL.md").read_text(
|
|
65
65
|
encoding="utf-8") == "x" # merge, not wipe
|
|
66
66
|
assert not (claude / "plsql-review").exists() # legacy cleaned
|
|
67
67
|
pythia.copy_bundled_skills(td) # idempotent
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
def test_fallback_serves_both_claude_and_codex():
|
|
71
|
+
"""Claude reads .claude/skills; Codex reads .agents/skills. The no-Node
|
|
72
|
+
fallback must populate both, or a Codex user without Node gets nothing."""
|
|
73
|
+
with tempfile.TemporaryDirectory() as td:
|
|
74
|
+
base = pathlib.Path(td)
|
|
75
|
+
targets = pythia.copy_bundled_skills(td)
|
|
76
|
+
assert targets == [base / ".claude" / "skills", base / ".agents" / "skills"]
|
|
77
|
+
for root in targets:
|
|
78
|
+
assert (root / "pythia-apply" / "SKILL.md").is_file()
|
|
79
|
+
|
|
80
|
+
|
|
70
81
|
def test_copy_survives_a_symlinked_destination():
|
|
71
82
|
"""`npx skills add` leaves .claude/skills/<name> as a symlink into
|
|
72
83
|
.agents/skills/<name>. Copying through that link and then cleaning the
|
|
@@ -350,6 +361,97 @@ def test_global_install_wires_the_approve_hook_only():
|
|
|
350
361
|
assert s["hooks"]["PostToolUse"] == pythia.CLAUDE_HOOKS["PostToolUse"]
|
|
351
362
|
|
|
352
363
|
|
|
364
|
+
def test_cmd_install_serves_codex_in_the_project_scope():
|
|
365
|
+
"""Project install writes the harness into AGENTS.md always, and the
|
|
366
|
+
session-start hook unless --no-hooks. (The .agents/skills copy is gated by
|
|
367
|
+
global_pack_present() — a machine with a global pack skips it — so that is
|
|
368
|
+
proven directly in test_fallback_serves_both_claude_and_codex, not here.)"""
|
|
369
|
+
import argparse
|
|
370
|
+
import contextlib
|
|
371
|
+
import io
|
|
372
|
+
old = os.environ.get("PATH")
|
|
373
|
+
os.environ["PATH"] = "" # no npx: bundled fallback
|
|
374
|
+
try:
|
|
375
|
+
for no_hooks in (False, True):
|
|
376
|
+
with tempfile.TemporaryDirectory() as td:
|
|
377
|
+
ns = argparse.Namespace(project_root=td, glob=False, source=None,
|
|
378
|
+
color=False, json=False, no_hooks=no_hooks)
|
|
379
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
380
|
+
pythia.cmd_install(None, None, ns)
|
|
381
|
+
base = pathlib.Path(td)
|
|
382
|
+
agents_md = base / "AGENTS.md"
|
|
383
|
+
assert agents_md.is_file() # always
|
|
384
|
+
assert pythia.BRIEF_GUIDE.rstrip() in agents_md.read_text(encoding="utf-8")
|
|
385
|
+
hooks = base / ".codex" / "hooks.json"
|
|
386
|
+
assert hooks.is_file() != no_hooks # skipped iff --no-hooks
|
|
387
|
+
finally:
|
|
388
|
+
if old is not None:
|
|
389
|
+
os.environ["PATH"] = old
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# --- Codex: AGENTS.md harness (TP1) ------------------------------------------
|
|
393
|
+
|
|
394
|
+
def test_agents_md_block_is_the_guide_verbatim():
|
|
395
|
+
block = pythia.agents_md_block()
|
|
396
|
+
assert block.startswith(pythia.AGENTS_BEGIN)
|
|
397
|
+
assert block.rstrip().endswith(pythia.AGENTS_END)
|
|
398
|
+
assert pythia.BRIEF_GUIDE.rstrip() in block # one source, no drift
|
|
399
|
+
assert "pythia approve <token>" in block # the Codex approval door
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def test_merge_agents_md_creates_updates_and_is_idempotent():
|
|
403
|
+
with tempfile.TemporaryDirectory() as td:
|
|
404
|
+
path = pathlib.Path(td) / "AGENTS.md"
|
|
405
|
+
p, action = pythia.merge_agents_md(path)
|
|
406
|
+
assert p == path and action == "created"
|
|
407
|
+
assert pythia.BRIEF_GUIDE.rstrip() in path.read_text(encoding="utf-8")
|
|
408
|
+
assert pythia.merge_agents_md(path)[1] == "unchanged"
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def test_merge_agents_md_preserves_the_developers_text():
|
|
412
|
+
with tempfile.TemporaryDirectory() as td:
|
|
413
|
+
path = pathlib.Path(td) / "AGENTS.md"
|
|
414
|
+
path.write_text("# House rules\n\nUse tabs.\n", encoding="utf-8")
|
|
415
|
+
pythia.merge_agents_md(path)
|
|
416
|
+
body = path.read_text(encoding="utf-8")
|
|
417
|
+
assert body.startswith("# House rules\n\nUse tabs.\n") # kept, on top
|
|
418
|
+
assert pythia.AGENTS_BEGIN in body and pythia.BRIEF_GUIDE.rstrip() in body
|
|
419
|
+
pythia.merge_agents_md(path) # a stale block
|
|
420
|
+
assert path.read_text(encoding="utf-8").count(pythia.AGENTS_BEGIN) == 1
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# --- Codex: session-start hook (TP3) -----------------------------------------
|
|
424
|
+
|
|
425
|
+
def test_merge_codex_hooks_creates_and_is_idempotent():
|
|
426
|
+
with tempfile.TemporaryDirectory() as td:
|
|
427
|
+
path = pathlib.Path(td) / ".codex" / "hooks.json"
|
|
428
|
+
p, added = pythia.merge_codex_hooks(path)
|
|
429
|
+
assert p == path and added == ["hooks.SessionStart"]
|
|
430
|
+
s = json.loads(path.read_text(encoding="utf-8"))
|
|
431
|
+
assert s["hooks"]["SessionStart"] == pythia.CODEX_HOOKS["SessionStart"]
|
|
432
|
+
assert pythia.merge_codex_hooks(path) == (path, [])
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def test_merge_codex_hooks_recognises_an_existing_spelling():
|
|
436
|
+
with tempfile.TemporaryDirectory() as td:
|
|
437
|
+
path = pathlib.Path(td) / ".codex" / "hooks.json"
|
|
438
|
+
path.parent.mkdir(parents=True)
|
|
439
|
+
path.write_text(json.dumps({"hooks": {"SessionStart": [
|
|
440
|
+
{"hooks": [{"type": "command",
|
|
441
|
+
"command": "python3 -m pythia guide --brief"}]}]}}),
|
|
442
|
+
encoding="utf-8")
|
|
443
|
+
assert pythia.merge_codex_hooks(path) == (path, []) # not doubled
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def test_merge_codex_hooks_refuses_a_file_it_cannot_parse():
|
|
447
|
+
with tempfile.TemporaryDirectory() as td:
|
|
448
|
+
path = pathlib.Path(td) / ".codex" / "hooks.json"
|
|
449
|
+
path.parent.mkdir(parents=True)
|
|
450
|
+
path.write_text("{not json", encoding="utf-8")
|
|
451
|
+
assert pythia.merge_codex_hooks(path) == (path, None)
|
|
452
|
+
assert path.read_text(encoding="utf-8") == "{not json"
|
|
453
|
+
|
|
454
|
+
|
|
353
455
|
# Native paths: on POSIX ':' is the PATH separator and would cut a
|
|
354
456
|
# Windows path in half, so these are built from os.sep.
|
|
355
457
|
SCRIPTS = os.path.join(os.sep + 'opt', 'a', 'Scripts')
|
|
@@ -249,7 +249,8 @@ OLD_SRC = "PACKAGE BODY pkg_order AS\n old line;\nEND;\n"
|
|
|
249
249
|
NEW_FILE = "CREATE OR REPLACE PACKAGE BODY pkg_order AS\n new line;\nEND;\n/\n"
|
|
250
250
|
|
|
251
251
|
|
|
252
|
-
def base_script(errors=(), invalid_after=None, db_source=OLD_SRC
|
|
252
|
+
def base_script(errors=(), invalid_after=None, db_source=OLD_SRC,
|
|
253
|
+
editionable="Y"):
|
|
253
254
|
invalid_before = [("X_ALREADY_BROKEN", "PROCEDURE")]
|
|
254
255
|
return {
|
|
255
256
|
"from all_source": ([("TEXT",)], [(ln + "\n",) for ln in db_source.splitlines()]),
|
|
@@ -268,7 +269,8 @@ def base_script(errors=(), invalid_after=None, db_source=OLD_SRC):
|
|
|
268
269
|
"from session_privs": ([("PRIVILEGE",)], []),
|
|
269
270
|
"current_schema": ([("CURRENT_SCHEMA",)], [("APP",)]),
|
|
270
271
|
# main-namespace occupants of the target name (type-conflict check)
|
|
271
|
-
"object_name = upper(:n)": ([("OBJECT_TYPE",
|
|
272
|
+
"object_name = upper(:n)": ([("OBJECT_TYPE", "EDITIONABLE")],
|
|
273
|
+
[("PACKAGE BODY", editionable)]),
|
|
272
274
|
}
|
|
273
275
|
|
|
274
276
|
|
|
@@ -447,6 +449,87 @@ def test_insert_needs_no_row_set():
|
|
|
447
449
|
assert pythia.read_journal_entry(td, entry)["meta"]["row_set"] is None
|
|
448
450
|
|
|
449
451
|
|
|
452
|
+
def test_reconcile_editionable_matches_the_database():
|
|
453
|
+
"""ALL_SOURCE stores no CREATE header, so a body read back with `src`
|
|
454
|
+
comes back bare and the keyword is the easiest thing in Oracle to lose.
|
|
455
|
+
Oracle will not change the property through CREATE OR REPLACE, so the
|
|
456
|
+
statement has to agree with the object before it runs."""
|
|
457
|
+
r = pythia.reconcile_editionable
|
|
458
|
+
bare = "CREATE OR REPLACE PROCEDURE p AS BEGIN NULL; END;"
|
|
459
|
+
non = "CREATE OR REPLACE NONEDITIONABLE PROCEDURE p AS BEGIN NULL; END;"
|
|
460
|
+
stmt, note = r(bare, "N")
|
|
461
|
+
assert stmt == non and "NONEDITIONABLE" in note
|
|
462
|
+
stmt, note = r(non, "Y")
|
|
463
|
+
assert stmt == bare and "NONEDITIONABLE" in note
|
|
464
|
+
# already agreed: untouched, and nothing to announce
|
|
465
|
+
assert r(non, "N") == (non, None)
|
|
466
|
+
assert r(bare, "Y") == (bare, None)
|
|
467
|
+
# no property to match: a new object, or a type that cannot be editioned
|
|
468
|
+
assert r(bare, None) == (bare, None)
|
|
469
|
+
# the old misspelling is not the property Oracle holds either
|
|
470
|
+
stmt, _ = r("create or replace NOEDITIONABLE procedure p as begin null; end;", "N")
|
|
471
|
+
assert stmt.startswith("create or replace NONEDITIONABLE ")
|
|
472
|
+
# leading comments keep their place
|
|
473
|
+
stmt, _ = r("-- header\ncreate or replace procedure p as begin null; end;", "N")
|
|
474
|
+
assert stmt.startswith("-- header\ncreate or replace NONEDITIONABLE ")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def test_apply_writes_the_editionable_keyword_the_database_holds():
|
|
478
|
+
"""The whole point is that ORA-38824 never reaches the developer: they
|
|
479
|
+
approved a preview, and the write has to be able to succeed."""
|
|
480
|
+
with tempfile.TemporaryDirectory() as td:
|
|
481
|
+
tok = pythia.apply_token("PACKAGE BODY", "PKG_ORDER", NEW_FILE, OLD_SRC)
|
|
482
|
+
pythia.mint_grant(td, tok, "DEV")
|
|
483
|
+
conn = FakeConn(base_script(editionable="N"))
|
|
484
|
+
code = pythia.run_apply(conn, "APP", apply_ns(td, confirm=tok), NEW_FILE)
|
|
485
|
+
assert code == 0
|
|
486
|
+
ddl = wrote_ddl(conn)
|
|
487
|
+
assert len(ddl) == 1 and ddl[0].lstrip().upper().startswith(
|
|
488
|
+
"CREATE OR REPLACE NONEDITIONABLE PACKAGE BODY"), ddl
|
|
489
|
+
# the journal records what ran, so the undo keeps the property too
|
|
490
|
+
e = pythia.read_journal_entry(td, pythia.list_journal_entries(td)[0])
|
|
491
|
+
assert "NONEDITIONABLE" in e["after"]
|
|
492
|
+
assert e["restore"].upper().startswith("CREATE OR REPLACE NONEDITIONABLE")
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def test_drift_note_fires_only_when_the_object_moved():
|
|
496
|
+
"""The field case: pythia wrote 118 lines, read them back two seconds
|
|
497
|
+
later, and eight minutes on the database held 117 — with no journal entry
|
|
498
|
+
in between. `src` was honest both times; nothing ever compared them."""
|
|
499
|
+
mine = "CREATE OR REPLACE PACKAGE BODY pkg_order AS\n a;\n b;\nEND;\n"
|
|
500
|
+
same = "PACKAGE BODY pkg_order AS\n a;\n b;\nEND;"
|
|
501
|
+
assert pythia.drift_note(same, mine, "E1") is None
|
|
502
|
+
# trailing whitespace is not drift
|
|
503
|
+
assert pythia.drift_note(same + "\n\n", mine, "E1") is None
|
|
504
|
+
assert pythia.drift_note(same.replace("\n", "\r\n"), mine, "E1") is None
|
|
505
|
+
# a line gone is
|
|
506
|
+
note = pythia.drift_note("PACKAGE BODY pkg_order AS\n a;\nEND;", mine, "E1")
|
|
507
|
+
assert note and "E1" in note and "1 line" in note
|
|
508
|
+
# nothing to compare against
|
|
509
|
+
assert pythia.drift_note(same, "", "E1") is None
|
|
510
|
+
assert pythia.drift_note(same, None, None) is None
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def test_preview_warns_when_the_object_changed_outside_pythia():
|
|
514
|
+
with tempfile.TemporaryDirectory() as td:
|
|
515
|
+
# pythia applied this text once
|
|
516
|
+
pythia.write_journal_entry(
|
|
517
|
+
td, "PACKAGE BODY", "PKG_ORDER", "older\n",
|
|
518
|
+
"CREATE OR REPLACE " + OLD_SRC,
|
|
519
|
+
{"token": "aaaaaa", "connection": "DEV", "schema": "APP",
|
|
520
|
+
"group": "plsql_source", "applied": True})
|
|
521
|
+
# ...and the database now holds something else
|
|
522
|
+
moved = OLD_SRC.replace(" old line;\n", "")
|
|
523
|
+
import contextlib
|
|
524
|
+
import io
|
|
525
|
+
buf = io.StringIO()
|
|
526
|
+
with contextlib.redirect_stdout(buf):
|
|
527
|
+
pythia.run_apply(FakeConn(base_script(db_source=moved)), "APP",
|
|
528
|
+
apply_ns(td), NEW_FILE)
|
|
529
|
+
out = buf.getvalue()
|
|
530
|
+
assert "outside pythia" in out and "1 line" in out, out
|
|
531
|
+
|
|
532
|
+
|
|
450
533
|
def test_apply_preview_writes_nothing_and_gives_token():
|
|
451
534
|
with tempfile.TemporaryDirectory() as td:
|
|
452
535
|
conn = FakeConn(base_script())
|
|
@@ -473,6 +556,45 @@ def test_apply_correct_token_writes_and_verifies_clean():
|
|
|
473
556
|
assert e["meta"]["applied"] is True
|
|
474
557
|
|
|
475
558
|
|
|
559
|
+
class ExplodingConn(FakeConn):
|
|
560
|
+
"""The driver refuses the write. ORA-38824 on an editions-enabled schema
|
|
561
|
+
is the case that prompted this: by the time it lands, the preview and
|
|
562
|
+
"Snapshot saved" have already printed, so the run reads like a success."""
|
|
563
|
+
|
|
564
|
+
def __init__(self, script, error="ORA-38824: cannot change the EDITIONABLE "
|
|
565
|
+
"property of an existing object"):
|
|
566
|
+
super().__init__(script)
|
|
567
|
+
self.error = error
|
|
568
|
+
|
|
569
|
+
def cursor(self):
|
|
570
|
+
cur = super().cursor()
|
|
571
|
+
passthrough = cur.execute
|
|
572
|
+
|
|
573
|
+
def execute(sql, binds=None):
|
|
574
|
+
if sql.lstrip().lower().startswith("create"):
|
|
575
|
+
raise RuntimeError(self.error)
|
|
576
|
+
return passthrough(sql, binds)
|
|
577
|
+
|
|
578
|
+
cur.execute = execute
|
|
579
|
+
return cur
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def test_failed_apply_ends_in_a_verdict_and_keeps_the_approval():
|
|
583
|
+
"""A write that the driver refused must not read like one that landed:
|
|
584
|
+
the last word is the failure, and it says nothing was written."""
|
|
585
|
+
with tempfile.TemporaryDirectory() as td:
|
|
586
|
+
tok = pythia.apply_token("PACKAGE BODY", "PKG_ORDER", NEW_FILE, OLD_SRC)
|
|
587
|
+
pythia.mint_grant(td, tok, "DEV")
|
|
588
|
+
conn = ExplodingConn(base_script())
|
|
589
|
+
expect_exit(lambda: pythia.run_apply(conn, "APP", apply_ns(td, confirm=tok),
|
|
590
|
+
NEW_FILE),
|
|
591
|
+
"failed", "nothing was written", "ORA-38824")
|
|
592
|
+
eid = pythia.list_journal_entries(td)[0]
|
|
593
|
+
assert pythia.read_journal_entry(td, eid)["meta"]["applied"] is False
|
|
594
|
+
# one approval, one write — and there was no write, so it still stands
|
|
595
|
+
assert pythia.read_grant(td, tok)["used_at"] is None
|
|
596
|
+
|
|
597
|
+
|
|
476
598
|
def test_apply_stale_token_refused():
|
|
477
599
|
with tempfile.TemporaryDirectory() as td:
|
|
478
600
|
tok = pythia.apply_token("PACKAGE BODY", "PKG_ORDER", "something else", OLD_SRC)
|
|
@@ -538,7 +660,7 @@ def test_apply_snapshot_survives_failed_execute():
|
|
|
538
660
|
conn = Exploding(base_script())
|
|
539
661
|
try:
|
|
540
662
|
pythia.run_apply(conn, "APP", apply_ns(td, confirm=tok), NEW_FILE)
|
|
541
|
-
except
|
|
663
|
+
except SystemExit: # the driver error, now a closing verdict
|
|
542
664
|
pass
|
|
543
665
|
ids = pythia.list_journal_entries(td)
|
|
544
666
|
assert len(ids) == 1 # snapshot was already on disk
|
|
@@ -1518,7 +1640,9 @@ def test_apply_previews_a_noneditionable_object_end_to_end():
|
|
|
1518
1640
|
f.write_text(src, encoding="utf-8")
|
|
1519
1641
|
buf = io.StringIO()
|
|
1520
1642
|
with contextlib.redirect_stdout(buf):
|
|
1521
|
-
|
|
1643
|
+
# the object really is NONEDITIONABLE in the database, which is
|
|
1644
|
+
# what makes the keyword in the file the right one
|
|
1645
|
+
pythia.cmd_apply(FakeConn(base_script(editionable="N")), "APP",
|
|
1522
1646
|
apply_ns(td, file=str(f)))
|
|
1523
1647
|
out = buf.getvalue()
|
|
1524
1648
|
assert "more than one statement" not in out
|
|
@@ -13,7 +13,7 @@ SKILLS = ROOT / "skills"
|
|
|
13
13
|
EXPECTED = {"pythia-setup", "pythia-explore", "pythia-impact",
|
|
14
14
|
"pythia-write", "pythia-apply", "pythia-review",
|
|
15
15
|
"pythia-skill-author", "pythia-conventions", "pythia-spec",
|
|
16
|
-
"using-pythia"}
|
|
16
|
+
"pythia-comment", "using-pythia"}
|
|
17
17
|
|
|
18
18
|
# spec: SKILL.md under 150 lines, detail pushed to reference/
|
|
19
19
|
MAX_LINES = 150
|
|
@@ -153,7 +153,7 @@ def test_no_manifest_hardcodes_a_stale_skill_count():
|
|
|
153
153
|
so the check is: no manifest states a number the code can disprove."""
|
|
154
154
|
import json
|
|
155
155
|
n = len(EXPECTED)
|
|
156
|
-
words = {7: "seven", 8: "eight", 9: "nine"}
|
|
156
|
+
words = {7: "seven", 8: "eight", 9: "nine", 10: "ten", 11: "eleven"}
|
|
157
157
|
stale = [w for k, w in words.items() if k != n]
|
|
158
158
|
for rel in (".claude-plugin/marketplace.json", "npm/package.json",
|
|
159
159
|
"npm/README.md"):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/scripts/pythia_plsql.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pythia_plsql-0.11.2 → pythia_plsql-0.13.0}/skills/pythia-explore/reference/data-dictionary.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|