Skip to content

Commit e2c4427

Browse files
hyperpolymathclaude
andcommitted
feat(a2ml-templates): add state-scm-to-v2.py (.scm → STATE.a2ml v2 converter)
Companion to state-migrate-v1-to-v2.sh. That script converts a v1 *a2ml* file to the v2 thin journal; this one converts a v1 *STATE.scm* s-expr machine-state file directly to v2, since most estate repos hold .scm (not v1 .a2ml). Robust hand-rolled s-expr tokenizer/parser (no Guile dependency). Mapping per STATE.a2ml.v2.spec.adoc: phase <- (current-position (phase ..)) | (current-phase ..) next_action <- critical-next-actions[0] last_action <- last session-history accomplishment blockers <- blockers-and-issues (critical/high/medium/low) updated <- (metadata (updated|last-updated ..)) else today Validated: 9/9 in-scope estate STATE.scm convert clean; output conforms to the v2 spec grammar (incl. the @blockers example). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d22e258 commit e2c4427

1 file changed

Lines changed: 201 additions & 0 deletions

File tree

a2ml-templates/state-scm-to-v2.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
#!/usr/bin/env python3
2+
# SPDX-License-Identifier: PMPL-1.0-or-later
3+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
4+
#
5+
# state_scm_to_v2.py — convert a v1 STATE.scm (s-expr machine state) to the
6+
# canonical STATE.a2ml v2 "thin session journal" directive format.
7+
#
8+
# Spec: standards/a2ml-templates/STATE.a2ml.v2.spec.adoc
9+
# v2 deliberately discards everything derivable; keeps only:
10+
# phase, next_action, last_action, updated, and @blockers entries.
11+
#
12+
# Usage:
13+
# state_scm_to_v2.py <STATE.scm> # print v2 to stdout (dry-run)
14+
# state_scm_to_v2.py <STATE.scm> --write # write .machine_readable/STATE.a2ml
15+
#
16+
# Exit codes: 0 ok, 2 parse/usage error, 3 not a (state ...) form.
17+
18+
import sys, os, re, datetime
19+
20+
def tokenize(s):
21+
# Strip ;; line comments, then tokenize parens and "strings" and atoms.
22+
s = re.sub(r';;[^\n]*', '', s)
23+
toks, i, n = [], 0, len(s)
24+
while i < n:
25+
c = s[i]
26+
if c in '()':
27+
toks.append(c); i += 1
28+
elif c == '"':
29+
j = i + 1; buf = []
30+
while j < n and s[j] != '"':
31+
if s[j] == '\\' and j + 1 < n:
32+
buf.append(s[j+1]); j += 2
33+
else:
34+
buf.append(s[j]); j += 1
35+
toks.append(('str', ''.join(buf))); i = j + 1
36+
elif c.isspace():
37+
i += 1
38+
else:
39+
j = i
40+
while j < n and not s[j].isspace() and s[j] not in '()"':
41+
j += 1
42+
toks.append(('atom', s[i:j])); i = j
43+
return toks
44+
45+
def parse(toks):
46+
# Returns nested lists; strings -> ('s', value), atoms -> ('a', value).
47+
pos = 0
48+
def rd():
49+
nonlocal pos
50+
t = toks[pos]; pos += 1
51+
if t == '(':
52+
lst = []
53+
while toks[pos] != ')':
54+
lst.append(rd())
55+
pos += 1
56+
return lst
57+
if t == ')':
58+
raise ValueError('unexpected )')
59+
return t # ('str',..) or ('atom',..)
60+
forms = []
61+
while pos < len(toks):
62+
forms.append(rd())
63+
return forms
64+
65+
def head(node):
66+
return node[0][1] if (isinstance(node, list) and node and isinstance(node[0], tuple)) else None
67+
68+
def find(node, name):
69+
"""First child list whose head atom == name."""
70+
if not isinstance(node, list):
71+
return None
72+
for ch in node:
73+
if isinstance(ch, list) and ch and isinstance(ch[0], tuple) and ch[0][1] == name:
74+
return ch
75+
return None
76+
77+
def first_string(node):
78+
"""First string literal anywhere under node (depth-first)."""
79+
if isinstance(node, tuple):
80+
return node[1] if node[0] == 'str' else None
81+
if isinstance(node, list):
82+
for ch in node:
83+
r = first_string(ch)
84+
if r:
85+
return r
86+
return None
87+
88+
def all_strings(node):
89+
out = []
90+
if isinstance(node, tuple):
91+
if node[0] == 'str':
92+
out.append(node[1])
93+
elif isinstance(node, list):
94+
for ch in node:
95+
out.extend(all_strings(ch))
96+
return out
97+
98+
def q(s):
99+
return '"' + str(s).replace('\\', '\\\\').replace('"', '\\"') + '"'
100+
101+
def slug(s):
102+
return re.sub(r'[^a-z0-9]+', '-', s.lower()).strip('-')[:40] or 'blocker'
103+
104+
def convert(path):
105+
src = open(path, encoding='utf-8', errors='replace').read()
106+
forms = parse(tokenize(src))
107+
state = next((f for f in forms if head(f) == 'state'), None)
108+
if state is None:
109+
sys.stderr.write(f"ERR: no (state ...) form in {path}\n")
110+
sys.exit(3)
111+
112+
# phase <- (current-position (phase "..")) | (current-phase "..")
113+
phase = None
114+
cp = find(state, 'current-position')
115+
if cp:
116+
ph = find(cp, 'phase')
117+
if ph:
118+
phase = first_string(ph)
119+
if not phase:
120+
cph = find(state, 'current-phase')
121+
if cph:
122+
phase = first_string(cph)
123+
phase = phase or 'unknown'
124+
125+
# next_action <- critical-next-actions, first string (immediate first)
126+
next_action = 'TODO — review and set'
127+
cna = find(state, 'critical-next-actions')
128+
if cna:
129+
imm = find(cna, 'immediate')
130+
next_action = first_string(imm) or first_string(cna) or next_action
131+
132+
# last_action <- last session-history entry's first accomplishment string
133+
last_action = 'migrated from v1 STATE.scm'
134+
sh = find(state, 'session-history')
135+
if sh:
136+
sessions = [c for c in sh if isinstance(c, list) and head(c) == 'session']
137+
if sessions:
138+
acc = find(sessions[-1], 'accomplishments')
139+
last_action = first_string(acc) or first_string(sessions[-1]) or last_action
140+
141+
# updated <- (metadata (updated|last-updated "..")) else today
142+
updated = None
143+
md = find(state, 'metadata')
144+
if md:
145+
for key in ('updated', 'last-updated'):
146+
u = find(md, key)
147+
if u:
148+
updated = first_string(u)
149+
break
150+
updated = updated or datetime.date.today().isoformat()
151+
152+
# blockers <- (blockers-and-issues (critical ..)(high ..)(medium ..)(low ..))
153+
blockers = []
154+
bi = find(state, 'blockers-and-issues')
155+
if bi:
156+
for sev in ('critical', 'high', 'medium', 'low'):
157+
grp = find(bi, sev)
158+
if grp:
159+
for desc in all_strings(grp):
160+
blockers.append((sev, desc))
161+
162+
lines = []
163+
lines.append('# SPDX-License-Identifier: PMPL-1.0-or-later')
164+
lines.append(f'# Migrated from {os.path.basename(path)} by state_scm_to_v2.py on '
165+
f'{datetime.date.today().isoformat()}')
166+
lines.append('')
167+
lines.append('@state(version="2.0"):')
168+
lines.append(f'phase: {q(phase)}')
169+
lines.append(f'next_action: {q(next_action)}')
170+
lines.append(f'last_action: {q(last_action)}')
171+
lines.append(f'updated: {updated}')
172+
if blockers:
173+
lines.append('')
174+
lines.append('@blockers:')
175+
for sev, desc in blockers:
176+
lines.append(f'- id: {slug(desc)}')
177+
lines.append(f' description: {q(desc)}')
178+
lines.append(f' waiting_on: {q(sev + "-priority — internal")}')
179+
lines.append(f' since: {updated}')
180+
lines.append('@end')
181+
lines.append('')
182+
lines.append('@end')
183+
return '\n'.join(lines) + '\n'
184+
185+
def main():
186+
args = [a for a in sys.argv[1:] if a != '--write']
187+
write = '--write' in sys.argv
188+
if len(args) != 1:
189+
sys.stderr.write("usage: state_scm_to_v2.py <STATE.scm> [--write]\n")
190+
sys.exit(2)
191+
scm = args[0]
192+
out = convert(scm)
193+
if write:
194+
dest = os.path.join(os.path.dirname(scm), 'STATE.a2ml')
195+
open(dest, 'w', encoding='utf-8').write(out)
196+
sys.stderr.write(f"wrote {dest}\n")
197+
else:
198+
sys.stdout.write(out)
199+
200+
if __name__ == '__main__':
201+
main()

0 commit comments

Comments
 (0)