exloop 0.1.0__py3-none-any.whl
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.
- exloop/__init__.py +32 -0
- exloop/skills/exloop/SKILL.md +175 -0
- exloop/skills/exloop/agents/openai.yaml +4 -0
- exloop/skills/exloop/references/protocol.md +140 -0
- exloop/skills/exloop/references/state-model.md +157 -0
- exloop/skills/exloop/scripts/exploration_state.py +763 -0
- exloop-0.1.0.dist-info/METADATA +72 -0
- exloop-0.1.0.dist-info/RECORD +10 -0
- exloop-0.1.0.dist-info/WHEEL +4 -0
- exloop-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Append-only workbench state for the exloop skill.
|
|
3
|
+
|
|
4
|
+
One exploration lives in exactly one directory, `~/.exloop/explorations/<slug>/`,
|
|
5
|
+
no matter which AI tool (Claude Code, Codex, WorkBuddy, ...) the person is using
|
|
6
|
+
at the moment. The slug is chosen once at `init` and is the same name the archive
|
|
7
|
+
folder will carry in the exloop repository. **Never derive it from a session or
|
|
8
|
+
thread id** — that is how one exploration ended up scattered across
|
|
9
|
+
`~/.codex/explorations/<thread>/` and `~/.workbuddy/explorations/<session>/`
|
|
10
|
+
in 2026-08, and why the tool-specific defaults were removed.
|
|
11
|
+
|
|
12
|
+
Where the record goes when the exploration ends is the **research repository** (the one
|
|
13
|
+
`newlife start` creates): `archive --repo` puts it under `explorations/<slug>/process/`,
|
|
14
|
+
`handoff --repo` under `questions/<slug>/origin/`. Not the exloop repository.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import shutil
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
MARK_KINDS = ("node", "got", "stuck", "abandoned", "surprise", "framework")
|
|
32
|
+
NODE_STATES = {"node": "exploring", "got": "got", "stuck": "stuck", "abandoned": "abandoned", "framework": "framework"}
|
|
33
|
+
SAFE_ID = re.compile(r"^[A-Za-z0-9._-]{1,160}$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class StateError(RuntimeError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def now_iso() -> str:
|
|
41
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def state_root(raw: str | None) -> Path:
|
|
45
|
+
configured = raw or os.environ.get("EXLOOP_HOME")
|
|
46
|
+
root = Path(configured).expanduser() if configured else Path.home() / ".exloop" / "explorations"
|
|
47
|
+
return root.resolve()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def exploration_id(raw: str | None) -> str:
|
|
51
|
+
value = raw or os.environ.get("EXLOOP_ID")
|
|
52
|
+
if not value:
|
|
53
|
+
raise StateError(
|
|
54
|
+
"No exploration id given. Pass --id <slug>, where the slug is the name the archive "
|
|
55
|
+
"folder will carry (YYYY-MM-DD-<two-to-four-english-words>, e.g. "
|
|
56
|
+
"2026-09-05-yield-input-or-outcome). The same slug is used from every tool; a "
|
|
57
|
+
"session or thread id must not be used, because it changes when the tool changes."
|
|
58
|
+
)
|
|
59
|
+
if not SAFE_ID.fullmatch(value):
|
|
60
|
+
raise StateError("Exploration ID may contain only letters, digits, dot, underscore, and hyphen.")
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def state_paths(root: Path, ident: str) -> tuple[Path, Path, Path]:
|
|
65
|
+
directory = root / ident
|
|
66
|
+
return directory, directory / "events.jsonl", directory / "map.md"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def read_events(path: Path) -> list[dict[str, Any]]:
|
|
70
|
+
if not path.exists():
|
|
71
|
+
return []
|
|
72
|
+
events: list[dict[str, Any]] = []
|
|
73
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
74
|
+
for number, line in enumerate(handle, start=1):
|
|
75
|
+
if not line.strip():
|
|
76
|
+
continue
|
|
77
|
+
try:
|
|
78
|
+
event = json.loads(line)
|
|
79
|
+
except json.JSONDecodeError as exc:
|
|
80
|
+
raise StateError(f"Invalid JSON in {path} line {number}: {exc}") from exc
|
|
81
|
+
if not isinstance(event, dict) or not isinstance(event.get("type"), str):
|
|
82
|
+
raise StateError(f"Invalid event object in {path} line {number}")
|
|
83
|
+
events.append(event)
|
|
84
|
+
return events
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def append_event(path: Path, event: dict[str, Any]) -> None:
|
|
88
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
payload = json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
90
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
91
|
+
try:
|
|
92
|
+
import fcntl
|
|
93
|
+
|
|
94
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
95
|
+
except ImportError:
|
|
96
|
+
fcntl = None # type: ignore[assignment]
|
|
97
|
+
handle.write(payload)
|
|
98
|
+
handle.flush()
|
|
99
|
+
os.fsync(handle.fileno())
|
|
100
|
+
if fcntl is not None:
|
|
101
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def atomic_write(path: Path, text: str) -> None:
|
|
105
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
descriptor, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
107
|
+
try:
|
|
108
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
109
|
+
handle.write(text)
|
|
110
|
+
handle.flush()
|
|
111
|
+
os.fsync(handle.fileno())
|
|
112
|
+
os.replace(temp_name, path)
|
|
113
|
+
finally:
|
|
114
|
+
if os.path.exists(temp_name):
|
|
115
|
+
os.unlink(temp_name)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def empty_map(ident: str) -> dict[str, Any]:
|
|
119
|
+
return {
|
|
120
|
+
"id": ident,
|
|
121
|
+
"root_question": "",
|
|
122
|
+
"question": "",
|
|
123
|
+
"started_at": "",
|
|
124
|
+
"nodes": {},
|
|
125
|
+
"surprises": [],
|
|
126
|
+
"pivots": [],
|
|
127
|
+
"closed": None,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def fold_map(ident: str, events: list[dict[str, Any]]) -> dict[str, Any]:
|
|
132
|
+
result = empty_map(ident)
|
|
133
|
+
for event in events:
|
|
134
|
+
if event.get("id") != ident:
|
|
135
|
+
continue
|
|
136
|
+
event_type = event.get("type")
|
|
137
|
+
if event_type == "exploration/started":
|
|
138
|
+
question = str(event.get("question", ""))
|
|
139
|
+
result["root_question"] = question
|
|
140
|
+
result["question"] = question
|
|
141
|
+
result["started_at"] = str(event.get("at", ""))
|
|
142
|
+
elif event_type == "exploration/marked":
|
|
143
|
+
kind = event.get("kind")
|
|
144
|
+
if kind == "surprise":
|
|
145
|
+
note = event.get("note")
|
|
146
|
+
if isinstance(note, str) and note:
|
|
147
|
+
result["surprises"].append(note)
|
|
148
|
+
continue
|
|
149
|
+
if kind not in NODE_STATES:
|
|
150
|
+
continue
|
|
151
|
+
ref = str(event.get("ref", ""))
|
|
152
|
+
if not ref:
|
|
153
|
+
continue
|
|
154
|
+
nodes: dict[str, dict[str, Any]] = result["nodes"]
|
|
155
|
+
node = nodes.get(ref)
|
|
156
|
+
if node is None:
|
|
157
|
+
node = {
|
|
158
|
+
"ref": ref,
|
|
159
|
+
"label": str(event.get("label") or ref),
|
|
160
|
+
"state": NODE_STATES[kind],
|
|
161
|
+
"notes": [],
|
|
162
|
+
}
|
|
163
|
+
nodes[ref] = node
|
|
164
|
+
else:
|
|
165
|
+
node["state"] = NODE_STATES[kind]
|
|
166
|
+
if event.get("label"):
|
|
167
|
+
node["label"] = str(event["label"])
|
|
168
|
+
note = event.get("note")
|
|
169
|
+
if isinstance(note, str) and note and (not node["notes"] or node["notes"][-1] != note):
|
|
170
|
+
node["notes"].append(note)
|
|
171
|
+
elif event_type == "exploration/pivoted":
|
|
172
|
+
pivot = {
|
|
173
|
+
"from": str(event.get("from", "")),
|
|
174
|
+
"to": str(event.get("to", "")),
|
|
175
|
+
"why": str(event.get("why", "")),
|
|
176
|
+
"source": str(event.get("source", "human")),
|
|
177
|
+
}
|
|
178
|
+
result["pivots"].append(pivot)
|
|
179
|
+
result["question"] = pivot["to"]
|
|
180
|
+
elif event_type == "exploration/closed":
|
|
181
|
+
result["closed"] = {
|
|
182
|
+
"summary": str(event.get("summary", "")),
|
|
183
|
+
"at": str(event.get("at", "")),
|
|
184
|
+
}
|
|
185
|
+
return result
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def chunk_labels(labels: list[str], limit: int = 60) -> list[str]:
|
|
189
|
+
rows: list[str] = []
|
|
190
|
+
current: list[str] = []
|
|
191
|
+
for label in labels:
|
|
192
|
+
candidate = ", ".join([*current, label])
|
|
193
|
+
if current and len(candidate) > limit:
|
|
194
|
+
rows.append(", ".join(current))
|
|
195
|
+
current = []
|
|
196
|
+
current.append(label)
|
|
197
|
+
if current:
|
|
198
|
+
rows.append(", ".join(current))
|
|
199
|
+
return rows
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def render_map(result: dict[str, Any]) -> str:
|
|
203
|
+
lines = ["# Map of this exploration", ""]
|
|
204
|
+
root = result["root_question"]
|
|
205
|
+
current = result["question"]
|
|
206
|
+
if root and root != current:
|
|
207
|
+
lines.extend([f"Root question: {root}", f"Now pursuing: {current}"])
|
|
208
|
+
else:
|
|
209
|
+
lines.append(f"Current question: {current or '(not yet honed)'}")
|
|
210
|
+
lines.extend([f"Status: {'closed' if result['closed'] else 'open'}", ""])
|
|
211
|
+
|
|
212
|
+
nodes = list(result["nodes"].values())
|
|
213
|
+
by_state = {state: [node for node in nodes if node["state"] == state] for state in NODE_STATES.values()}
|
|
214
|
+
|
|
215
|
+
lines.append("## Boundary (the next step grows from here)")
|
|
216
|
+
if not by_state["stuck"]:
|
|
217
|
+
lines.append("- (none yet)")
|
|
218
|
+
for node in by_state["stuck"]:
|
|
219
|
+
first = node["notes"][0] if node["notes"] else ""
|
|
220
|
+
lines.append(f"- {node['label']} | stuck{f' | {first}' if first else ''}")
|
|
221
|
+
for note in node["notes"][1:]:
|
|
222
|
+
lines.append(f" - later: {note}")
|
|
223
|
+
lines.append("")
|
|
224
|
+
|
|
225
|
+
if by_state["framework"]:
|
|
226
|
+
lines.append("## Frameworks honed (reusable)")
|
|
227
|
+
for node in by_state["framework"]:
|
|
228
|
+
note = node["notes"][-1] if node["notes"] else ""
|
|
229
|
+
lines.append(f"- {node['label']}{f' | {note}' if note else ''}")
|
|
230
|
+
lines.append("")
|
|
231
|
+
|
|
232
|
+
if by_state["exploring"]:
|
|
233
|
+
lines.append("## In progress")
|
|
234
|
+
for node in by_state["exploring"]:
|
|
235
|
+
note = node["notes"][-1] if node["notes"] else ""
|
|
236
|
+
lines.append(f"- {node['label']}{f' | {note}' if note else ''}")
|
|
237
|
+
lines.append("")
|
|
238
|
+
|
|
239
|
+
if by_state["got"]:
|
|
240
|
+
lines.append("## Already understood (no need to explain again)")
|
|
241
|
+
for row in chunk_labels([node["label"] for node in by_state["got"]]):
|
|
242
|
+
lines.append(f"- {row}")
|
|
243
|
+
lines.append("")
|
|
244
|
+
|
|
245
|
+
if by_state["abandoned"]:
|
|
246
|
+
lines.append("## Abandoned (do not recommend again)")
|
|
247
|
+
for node in by_state["abandoned"][-5:]:
|
|
248
|
+
note = node["notes"][-1] if node["notes"] else ""
|
|
249
|
+
lines.append(f"- {node['label']}{f' | {note}' if note else ''}")
|
|
250
|
+
lines.append("")
|
|
251
|
+
|
|
252
|
+
if result["surprises"]:
|
|
253
|
+
lines.append("## Surprises")
|
|
254
|
+
lines.extend(f"- {surprise}" for surprise in result["surprises"])
|
|
255
|
+
lines.append("")
|
|
256
|
+
|
|
257
|
+
if result["pivots"]:
|
|
258
|
+
lines.append("## Pivots (where the question changed)")
|
|
259
|
+
for pivot in result["pivots"]:
|
|
260
|
+
tag = " | (detected in review)" if pivot["source"] == "detected" else ""
|
|
261
|
+
lines.append(f"- from “{pivot['from']}” to “{pivot['to']}” | {pivot['why']}{tag}")
|
|
262
|
+
lines.append("")
|
|
263
|
+
|
|
264
|
+
if result["closed"]:
|
|
265
|
+
lines.append("## Closure")
|
|
266
|
+
summary = result["closed"]["summary"]
|
|
267
|
+
lines.append(f"- {summary or 'closed at the person’s request'}")
|
|
268
|
+
lines.append("")
|
|
269
|
+
|
|
270
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def ensure_active(result: dict[str, Any]) -> None:
|
|
274
|
+
if not result["started_at"]:
|
|
275
|
+
raise StateError("This exploration has not been initialized. Run init first.")
|
|
276
|
+
if result["closed"]:
|
|
277
|
+
raise StateError("This exploration is closed. Start an unrelated exploration with a different --id.")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def refresh_map(ident: str, events_path: Path, map_path: Path) -> dict[str, Any]:
|
|
281
|
+
result = fold_map(ident, read_events(events_path))
|
|
282
|
+
atomic_write(map_path, render_map(result))
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def command_init(args: argparse.Namespace) -> None:
|
|
287
|
+
root = state_root(args.root)
|
|
288
|
+
ident = exploration_id(args.id)
|
|
289
|
+
directory, events_path, map_path = state_paths(root, ident)
|
|
290
|
+
question = args.question.strip()
|
|
291
|
+
if not question:
|
|
292
|
+
raise StateError("The root question cannot be empty.")
|
|
293
|
+
events = read_events(events_path)
|
|
294
|
+
result = fold_map(ident, events)
|
|
295
|
+
if result["started_at"]:
|
|
296
|
+
if result["closed"]:
|
|
297
|
+
raise StateError("This id already holds a closed exploration; pick a different --id.")
|
|
298
|
+
if result["root_question"] != question:
|
|
299
|
+
raise StateError("This id already has another root question; record a pivot instead of overwriting it.")
|
|
300
|
+
else:
|
|
301
|
+
append_event(
|
|
302
|
+
events_path,
|
|
303
|
+
{"type": "exploration/started", "id": ident, "question": question, "at": now_iso()},
|
|
304
|
+
)
|
|
305
|
+
refresh_map(ident, events_path, map_path)
|
|
306
|
+
print(f"state_dir={directory}")
|
|
307
|
+
print(f"map={map_path}")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def command_mark(args: argparse.Namespace) -> None:
|
|
311
|
+
root = state_root(args.root)
|
|
312
|
+
ident = exploration_id(args.id)
|
|
313
|
+
_, events_path, map_path = state_paths(root, ident)
|
|
314
|
+
result = fold_map(ident, read_events(events_path))
|
|
315
|
+
ensure_active(result)
|
|
316
|
+
|
|
317
|
+
note = args.note.strip() if args.note else ""
|
|
318
|
+
label = args.label.strip() if args.label else ""
|
|
319
|
+
ref_value = args.ref.strip() if args.ref else ""
|
|
320
|
+
if args.turn is not None and args.turn < 0:
|
|
321
|
+
raise StateError("Turn must be a non-negative integer.")
|
|
322
|
+
|
|
323
|
+
if args.kind == "surprise":
|
|
324
|
+
if not note:
|
|
325
|
+
raise StateError("A surprise requires --note.")
|
|
326
|
+
ref = ref_value or "-"
|
|
327
|
+
else:
|
|
328
|
+
if not ref_value:
|
|
329
|
+
raise StateError(f"A {args.kind} mark requires --ref.")
|
|
330
|
+
ref = ref_value
|
|
331
|
+
|
|
332
|
+
event: dict[str, Any] = {
|
|
333
|
+
"type": "exploration/marked",
|
|
334
|
+
"id": ident,
|
|
335
|
+
"kind": args.kind,
|
|
336
|
+
"ref": ref,
|
|
337
|
+
"at": now_iso(),
|
|
338
|
+
}
|
|
339
|
+
if label:
|
|
340
|
+
event["label"] = label
|
|
341
|
+
if note:
|
|
342
|
+
event["note"] = note
|
|
343
|
+
if args.turn is not None:
|
|
344
|
+
event["turn"] = args.turn
|
|
345
|
+
append_event(events_path, event)
|
|
346
|
+
refresh_map(ident, events_path, map_path)
|
|
347
|
+
print(f"recorded={args.kind}:{ref}")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def command_pivot(args: argparse.Namespace) -> None:
|
|
351
|
+
root = state_root(args.root)
|
|
352
|
+
ident = exploration_id(args.id)
|
|
353
|
+
_, events_path, map_path = state_paths(root, ident)
|
|
354
|
+
result = fold_map(ident, read_events(events_path))
|
|
355
|
+
ensure_active(result)
|
|
356
|
+
target = args.to.strip()
|
|
357
|
+
reason = args.why.strip()
|
|
358
|
+
if not target:
|
|
359
|
+
raise StateError("A pivot target cannot be empty.")
|
|
360
|
+
if not reason:
|
|
361
|
+
raise StateError("A pivot requires a reason so the change remains interpretable.")
|
|
362
|
+
append_event(
|
|
363
|
+
events_path,
|
|
364
|
+
{
|
|
365
|
+
"type": "exploration/pivoted",
|
|
366
|
+
"id": ident,
|
|
367
|
+
"from": result["question"],
|
|
368
|
+
"to": target,
|
|
369
|
+
"why": reason,
|
|
370
|
+
"source": args.source,
|
|
371
|
+
"at": now_iso(),
|
|
372
|
+
},
|
|
373
|
+
)
|
|
374
|
+
refresh_map(ident, events_path, map_path)
|
|
375
|
+
print(f"pivoted_to={target}")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def command_show(args: argparse.Namespace) -> None:
|
|
379
|
+
root = state_root(args.root)
|
|
380
|
+
ident = exploration_id(args.id)
|
|
381
|
+
_, events_path, map_path = state_paths(root, ident)
|
|
382
|
+
result = refresh_map(ident, events_path, map_path)
|
|
383
|
+
if not result["started_at"]:
|
|
384
|
+
raise StateError("This exploration has not been initialized. Run init first.")
|
|
385
|
+
print(render_map(result), end="")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def command_close(args: argparse.Namespace) -> None:
|
|
389
|
+
root = state_root(args.root)
|
|
390
|
+
ident = exploration_id(args.id)
|
|
391
|
+
directory, events_path, map_path = state_paths(root, ident)
|
|
392
|
+
result = fold_map(ident, read_events(events_path))
|
|
393
|
+
ensure_active(result)
|
|
394
|
+
append_event(
|
|
395
|
+
events_path,
|
|
396
|
+
{
|
|
397
|
+
"type": "exploration/closed",
|
|
398
|
+
"id": ident,
|
|
399
|
+
"summary": (args.summary or "").strip(),
|
|
400
|
+
"at": now_iso(),
|
|
401
|
+
},
|
|
402
|
+
)
|
|
403
|
+
refresh_map(ident, events_path, map_path)
|
|
404
|
+
print(f"closed={directory}")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def command_path(args: argparse.Namespace) -> None:
|
|
408
|
+
root = state_root(args.root)
|
|
409
|
+
ident = exploration_id(args.id)
|
|
410
|
+
directory, _, _ = state_paths(root, ident)
|
|
411
|
+
print(directory)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def command_list(args: argparse.Namespace) -> None:
|
|
415
|
+
"""List every exploration under the state root: slug, open/closed, start day, current question."""
|
|
416
|
+
root = state_root(args.root)
|
|
417
|
+
if not root.is_dir():
|
|
418
|
+
print(f"(no explorations under {root})")
|
|
419
|
+
return
|
|
420
|
+
for directory in sorted(p for p in root.iterdir() if p.is_dir()):
|
|
421
|
+
events_path = directory / "events.jsonl"
|
|
422
|
+
if not events_path.exists():
|
|
423
|
+
continue
|
|
424
|
+
result = fold_map(directory.name, read_events(events_path))
|
|
425
|
+
if not result["started_at"]:
|
|
426
|
+
continue
|
|
427
|
+
status = "closed" if result["closed"] else "open"
|
|
428
|
+
print(f"{directory.name}\t{status}\t{result['started_at'][:10]}\t{result['question']}")
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _target(args: argparse.Namespace, ident: str, default_rel: str) -> Path:
|
|
432
|
+
"""Where an archive or handoff lands: `--to <dir>` verbatim, or derived from `--repo`.
|
|
433
|
+
|
|
434
|
+
`--repo` is the research repository — the same one `newlife start` created and the
|
|
435
|
+
one that holds `questions/`. The archive goes to `explorations/<slug>/process/` there,
|
|
436
|
+
the handoff to `questions/<slug>/origin/`; same slug on both sides, so the two never
|
|
437
|
+
disagree about which exploration a question came from.
|
|
438
|
+
"""
|
|
439
|
+
if args.to:
|
|
440
|
+
return Path(args.to).expanduser().resolve()
|
|
441
|
+
if args.repo:
|
|
442
|
+
return Path(args.repo).expanduser().resolve() / default_rel.format(slug=ident)
|
|
443
|
+
raise StateError("Pass --repo <research repository> (the usual case) or --to <directory>.")
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def render_readme_skeleton(result: dict[str, Any], ident: str) -> str:
|
|
447
|
+
"""The four-section entry README the archive convention requires, all TODO.
|
|
448
|
+
|
|
449
|
+
Written only when the exploration folder is new. Conclusions must point at files;
|
|
450
|
+
the shortfalls section must never be empty — those are the convention's two hard
|
|
451
|
+
rules, and the skeleton says so where the person will write.
|
|
452
|
+
"""
|
|
453
|
+
q = result["question"] or result["root_question"] or ident
|
|
454
|
+
return "\n".join([
|
|
455
|
+
f"# {q}",
|
|
456
|
+
"",
|
|
457
|
+
f"- **Started**: {result['started_at'][:10] or 'unknown'} · **Status**: "
|
|
458
|
+
f"{'closed' if result['closed'] else 'open'}",
|
|
459
|
+
f"- **Root question**: {result['root_question'] or '(none recorded)'}",
|
|
460
|
+
"- **Record**: `process/events.jsonl` and `process/map.md`, archived by exloop `archive`.",
|
|
461
|
+
"",
|
|
462
|
+
"## Conclusions",
|
|
463
|
+
"",
|
|
464
|
+
"(TODO — one per line, and each must point at a file under `artifacts/` or `process/`.",
|
|
465
|
+
"A conclusion that points nowhere is either unverified or should be deleted.)",
|
|
466
|
+
"",
|
|
467
|
+
"## Shortfalls / boundaries",
|
|
468
|
+
"",
|
|
469
|
+
"(TODO — never empty: what was not found, which results do not count as evidence,",
|
|
470
|
+
"which numbers await a recheck.)",
|
|
471
|
+
"",
|
|
472
|
+
"## Navigation",
|
|
473
|
+
"",
|
|
474
|
+
"| File | One line |",
|
|
475
|
+
"|---|---|",
|
|
476
|
+
"| `process/map.md` | the map at closure |",
|
|
477
|
+
"| `process/events.jsonl` | the raw event stream |",
|
|
478
|
+
"",
|
|
479
|
+
])
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def command_archive(args: argparse.Namespace) -> None:
|
|
483
|
+
"""Copy events.jsonl and map.md into an archive directory, verbatim.
|
|
484
|
+
|
|
485
|
+
The intended target is `<exloop repo>/explorations/<slug>/process/`. Existing files
|
|
486
|
+
with different content are not overwritten unless --force is given, so re-running
|
|
487
|
+
after more events were appended is safe to do deliberately and impossible to do
|
|
488
|
+
by accident.
|
|
489
|
+
"""
|
|
490
|
+
root = state_root(args.root)
|
|
491
|
+
ident = exploration_id(args.id)
|
|
492
|
+
_, events_path, map_path = state_paths(root, ident)
|
|
493
|
+
result = fold_map(ident, read_events(events_path))
|
|
494
|
+
if not result["started_at"]:
|
|
495
|
+
raise StateError("This exploration has not been initialized. Run init first.")
|
|
496
|
+
atomic_write(map_path, render_map(result))
|
|
497
|
+
destination = _target(args, ident, "explorations/{slug}/process")
|
|
498
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
499
|
+
pairs = [(source, destination / source.name) for source in (events_path, map_path)]
|
|
500
|
+
# Check both before copying either: the two files land together or not at all, never half an archive.
|
|
501
|
+
for source, target in pairs:
|
|
502
|
+
if target.exists() and target.read_bytes() != source.read_bytes() and not args.force:
|
|
503
|
+
raise StateError(f"{target} exists with different content; pass --force to overwrite it.")
|
|
504
|
+
for source, target in pairs:
|
|
505
|
+
shutil.copyfile(source, target)
|
|
506
|
+
print(f"archived={target}")
|
|
507
|
+
# A fresh archive folder gets the convention's skeleton once; an existing README is
|
|
508
|
+
# the person's and is never touched.
|
|
509
|
+
if destination.name == "process":
|
|
510
|
+
folder = destination.parent
|
|
511
|
+
readme = folder / "README.md"
|
|
512
|
+
if not readme.exists():
|
|
513
|
+
readme.write_text(render_readme_skeleton(result, ident), encoding="utf-8")
|
|
514
|
+
(folder / "artifacts").mkdir(exist_ok=True)
|
|
515
|
+
print(f"skeleton={readme} (four sections, all TODO; artifacts/ created)")
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def render_goal_draft(result: dict[str, Any], ident: str, at: str) -> str:
|
|
519
|
+
"""A newlife `goal.md`-shaped draft with §1 / §3 / §4 pre-filled from the map.
|
|
520
|
+
|
|
521
|
+
The six anchors stay TODO on purpose: they are what the `newlife-goal` skill adds
|
|
522
|
+
(counterparty, attack layer, who changes behaviour, ...) and nothing in an exploration
|
|
523
|
+
map can supply them. Section headings match newlife's scaffolded `goal.md` so the text
|
|
524
|
+
can be pasted across section by section.
|
|
525
|
+
"""
|
|
526
|
+
nodes = list(result["nodes"].values())
|
|
527
|
+
by_state = {state: [n for n in nodes if n["state"] == state] for state in NODE_STATES.values()}
|
|
528
|
+
q = result["question"] or result["root_question"] or "(question not yet honed)"
|
|
529
|
+
lines = [
|
|
530
|
+
f"# Goal — {q}",
|
|
531
|
+
"",
|
|
532
|
+
f"> Draft generated by exloop `handoff` from exploration `{ident}`, record as of {at}.",
|
|
533
|
+
"> **Everything below was seen during the exploration** and counts as exploratory for",
|
|
534
|
+
"> any later preregistration. The six anchors are left as TODO on purpose: fill them",
|
|
535
|
+
"> with the `newlife-goal` skill, then carry the sections over into `goal.md`",
|
|
536
|
+
"> (which `newlife init` scaffolds red). Anchor bodies must not contain `>`.",
|
|
537
|
+
"",
|
|
538
|
+
"<!--@evidence: literature_searched=TODO, sources=TODO, verdict=TODO-->",
|
|
539
|
+
"<!--@counterparty: TODO-->",
|
|
540
|
+
"<!--@attack_layer: TODO-->",
|
|
541
|
+
"<!--@decides: TODO-->",
|
|
542
|
+
"<!--@who_changes_behavior: TODO-->",
|
|
543
|
+
"<!--@size_estimate: impl_lines=TODO, criteria=TODO, failure_modes=TODO-->",
|
|
544
|
+
"",
|
|
545
|
+
"## 1. What this buys",
|
|
546
|
+
"",
|
|
547
|
+
"### Where the question came from (the exploration map)",
|
|
548
|
+
"",
|
|
549
|
+
f"- Root question: {result['root_question'] or '(none recorded)'}",
|
|
550
|
+
]
|
|
551
|
+
for i, pivot in enumerate(result["pivots"], start=1):
|
|
552
|
+
tag = " (detected in review, not stated by the person)" if pivot["source"] == "detected" else ""
|
|
553
|
+
lines.append(f"- Pivot {i}: from “{pivot['from']}” to “{pivot['to']}” — why: {pivot['why']}{tag}")
|
|
554
|
+
lines.append(f"- Question at handoff: {q}")
|
|
555
|
+
lines.append("")
|
|
556
|
+
lines.append("### Surprises recorded (raw material for promoting an anomaly)")
|
|
557
|
+
lines.append("")
|
|
558
|
+
lines.extend(f"- {s}" for s in result["surprises"]) if result["surprises"] else lines.append("- (none recorded)")
|
|
559
|
+
lines.append("")
|
|
560
|
+
lines.append("### Frameworks named during the exploration (mechanism candidates)")
|
|
561
|
+
lines.append("")
|
|
562
|
+
if by_state["framework"]:
|
|
563
|
+
for n in by_state["framework"]:
|
|
564
|
+
note = n["notes"][-1] if n["notes"] else ""
|
|
565
|
+
lines.append(f"- {n['label']}{f' — {note}' if note else ''}")
|
|
566
|
+
else:
|
|
567
|
+
lines.append("- (none recorded)")
|
|
568
|
+
lines.extend([
|
|
569
|
+
"",
|
|
570
|
+
"## 2. Success criteria",
|
|
571
|
+
"",
|
|
572
|
+
"(A conjunction, independent of H1. Nothing in the map supplies these — write them.)",
|
|
573
|
+
"",
|
|
574
|
+
"## 3. Explicitly not doing",
|
|
575
|
+
"",
|
|
576
|
+
"Paths abandoned during the exploration, with the reason recorded at the time:",
|
|
577
|
+
"",
|
|
578
|
+
])
|
|
579
|
+
if by_state["abandoned"]:
|
|
580
|
+
for n in by_state["abandoned"]:
|
|
581
|
+
note = n["notes"][-1] if n["notes"] else ""
|
|
582
|
+
lines.append(f"- {n['label']}{f' — {note}' if note else ''}")
|
|
583
|
+
else:
|
|
584
|
+
lines.append("- (none recorded)")
|
|
585
|
+
lines.extend([
|
|
586
|
+
"",
|
|
587
|
+
"## 4. Risks declared in advance",
|
|
588
|
+
"",
|
|
589
|
+
"Boundaries still open when the exploration was handed off (each is a place the",
|
|
590
|
+
"question could still turn out to be ill-posed):",
|
|
591
|
+
"",
|
|
592
|
+
])
|
|
593
|
+
if by_state["stuck"]:
|
|
594
|
+
for n in by_state["stuck"]:
|
|
595
|
+
first = n["notes"][0] if n["notes"] else ""
|
|
596
|
+
lines.append(f"- {n['label']}{f' — {first}' if first else ''}")
|
|
597
|
+
for note in n["notes"][1:]:
|
|
598
|
+
lines.append(f" - later: {note}")
|
|
599
|
+
else:
|
|
600
|
+
lines.append("- (none recorded)")
|
|
601
|
+
lines.extend([
|
|
602
|
+
"",
|
|
603
|
+
"## 5. Closeout judgement",
|
|
604
|
+
"",
|
|
605
|
+
"(Filled in afterwards: achieved / not_achieved / regressed / not_applicable.)",
|
|
606
|
+
"",
|
|
607
|
+
])
|
|
608
|
+
return "\n".join(lines)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def command_handoff(args: argparse.Namespace) -> None:
|
|
612
|
+
"""Hand an exploration to a newlife question folder: raw record + a goal draft.
|
|
613
|
+
|
|
614
|
+
Writes `events.jsonl`, `map.md` and `goal-draft.md` into `--to`, normally
|
|
615
|
+
`<research repo>/questions/<slug>/origin/`. The raw record is copied verbatim; the
|
|
616
|
+
draft is rendered from the map. Refuses to overwrite a differing file unless --force.
|
|
617
|
+
"""
|
|
618
|
+
root = state_root(args.root)
|
|
619
|
+
ident = exploration_id(args.id)
|
|
620
|
+
_, events_path, map_path = state_paths(root, ident)
|
|
621
|
+
events = read_events(events_path)
|
|
622
|
+
result = fold_map(ident, events)
|
|
623
|
+
if not result["started_at"]:
|
|
624
|
+
raise StateError("This exploration has not been initialized. Run init first.")
|
|
625
|
+
atomic_write(map_path, render_map(result))
|
|
626
|
+
destination = _target(args, ident, "questions/{slug}/origin")
|
|
627
|
+
if not (destination.parent / "goal.md").is_file():
|
|
628
|
+
raise StateError(
|
|
629
|
+
f"{destination.parent} is not a newlife question folder (no goal.md). Run "
|
|
630
|
+
f"`newlife init {ident}` in the research repository first; handoff writes into the "
|
|
631
|
+
f"origin/ of an existing question. (Writing first would make init refuse, because "
|
|
632
|
+
f"the folder would already exist.)"
|
|
633
|
+
)
|
|
634
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
635
|
+
# Stamp the draft with the record's own last event, not the wall clock: the same record
|
|
636
|
+
# then renders the same bytes, so re-running handoff is a no-op instead of a refusal,
|
|
637
|
+
# and a changed record is refused (without --force) exactly when it should be.
|
|
638
|
+
as_of = str(events[-1].get("at", "")) if events else ""
|
|
639
|
+
draft = render_goal_draft(result, ident, as_of).encode("utf-8")
|
|
640
|
+
payloads = [(destination / "events.jsonl", events_path.read_bytes()),
|
|
641
|
+
(destination / "map.md", map_path.read_bytes()),
|
|
642
|
+
(destination / "goal-draft.md", draft)]
|
|
643
|
+
for target, body in payloads:
|
|
644
|
+
if target.exists() and target.read_bytes() != body and not args.force:
|
|
645
|
+
raise StateError(f"{target} exists with different content; pass --force to overwrite it.")
|
|
646
|
+
for target, body in payloads:
|
|
647
|
+
target.write_bytes(body)
|
|
648
|
+
print(f"handed_off={target}")
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def command_self_test(_: argparse.Namespace) -> None:
|
|
652
|
+
ident = "self-test"
|
|
653
|
+
events = [
|
|
654
|
+
{"type": "exploration/started", "id": ident, "question": "root", "at": "t0"},
|
|
655
|
+
{"type": "exploration/marked", "id": ident, "kind": "stuck", "ref": "gap", "label": "Missing link", "note": "first", "at": "t1"},
|
|
656
|
+
{"type": "exploration/marked", "id": ident, "kind": "stuck", "ref": "gap", "note": "first", "at": "t2"},
|
|
657
|
+
{"type": "exploration/marked", "id": ident, "kind": "stuck", "ref": "gap", "note": "reversal", "at": "t3"},
|
|
658
|
+
{"type": "exploration/marked", "id": ident, "kind": "framework", "ref": "f", "label": "Reusable frame", "note": "mechanism", "at": "t4"},
|
|
659
|
+
{"type": "exploration/marked", "id": ident, "kind": "surprise", "ref": "-", "note": "counterintuitive", "at": "t5"},
|
|
660
|
+
{"type": "exploration/pivoted", "id": ident, "from": "root", "to": "focus", "why": "new human fact", "source": "human", "at": "t6"},
|
|
661
|
+
{"type": "exploration/closed", "id": ident, "summary": "done", "at": "t7"},
|
|
662
|
+
]
|
|
663
|
+
result = fold_map(ident, events)
|
|
664
|
+
assert result["root_question"] == "root"
|
|
665
|
+
assert result["question"] == "focus"
|
|
666
|
+
assert result["nodes"]["gap"]["notes"] == ["first", "reversal"]
|
|
667
|
+
assert result["nodes"]["f"]["state"] == "framework"
|
|
668
|
+
assert result["surprises"] == ["counterintuitive"]
|
|
669
|
+
assert result["closed"]["summary"] == "done"
|
|
670
|
+
rendered = render_map(result)
|
|
671
|
+
assert "Root question: root" in rendered and "Now pursuing: focus" in rendered
|
|
672
|
+
assert "later: reversal" in rendered and "Frameworks honed" in rendered and "## Closure" in rendered
|
|
673
|
+
draft = render_goal_draft(result, ident, "t8")
|
|
674
|
+
assert draft.startswith("# Goal — focus")
|
|
675
|
+
assert draft.count("TODO") >= 6 and "<!--@counterparty: TODO-->" in draft
|
|
676
|
+
assert "Root question: root" in draft and "why: new human fact" in draft
|
|
677
|
+
assert "- counterintuitive" in draft and "Reusable frame — mechanism" in draft
|
|
678
|
+
assert "Missing link — first" in draft and "later: reversal" in draft
|
|
679
|
+
assert "## 3. Explicitly not doing" in draft and "## 5. Closeout judgement" in draft
|
|
680
|
+
skeleton = render_readme_skeleton(result, ident)
|
|
681
|
+
assert skeleton.startswith("# focus") and "## Shortfalls / boundaries" in skeleton
|
|
682
|
+
assert "process/events.jsonl" in skeleton and "TODO" in skeleton
|
|
683
|
+
print("self-test: ok")
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def add_common_options(parser: argparse.ArgumentParser) -> None:
|
|
687
|
+
parser.add_argument("--id", help="Exploration slug (or EXLOOP_ID); the archive folder name, identical in every tool")
|
|
688
|
+
parser.add_argument("--root", help="State root (or EXLOOP_HOME); defaults to ~/.exloop/explorations")
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
692
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
693
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
694
|
+
|
|
695
|
+
init_parser = subparsers.add_parser("init", help="Start or resume an exploration")
|
|
696
|
+
add_common_options(init_parser)
|
|
697
|
+
init_parser.add_argument("--question", required=True)
|
|
698
|
+
init_parser.set_defaults(func=command_init)
|
|
699
|
+
|
|
700
|
+
mark_parser = subparsers.add_parser("mark", help="Append a meaningful map mark")
|
|
701
|
+
add_common_options(mark_parser)
|
|
702
|
+
mark_parser.add_argument("--kind", required=True, choices=MARK_KINDS)
|
|
703
|
+
mark_parser.add_argument("--ref")
|
|
704
|
+
mark_parser.add_argument("--label")
|
|
705
|
+
mark_parser.add_argument("--note")
|
|
706
|
+
mark_parser.add_argument("--turn", type=int)
|
|
707
|
+
mark_parser.set_defaults(func=command_mark)
|
|
708
|
+
|
|
709
|
+
pivot_parser = subparsers.add_parser("pivot", help="Record a change of target")
|
|
710
|
+
add_common_options(pivot_parser)
|
|
711
|
+
pivot_parser.add_argument("--to", required=True)
|
|
712
|
+
pivot_parser.add_argument("--why", required=True)
|
|
713
|
+
pivot_parser.add_argument("--source", choices=("human", "detected"), default="human")
|
|
714
|
+
pivot_parser.set_defaults(func=command_pivot)
|
|
715
|
+
|
|
716
|
+
show_parser = subparsers.add_parser("show", help="Render and print the current map")
|
|
717
|
+
add_common_options(show_parser)
|
|
718
|
+
show_parser.set_defaults(func=command_show)
|
|
719
|
+
|
|
720
|
+
close_parser = subparsers.add_parser("close", help="Close an exploration explicitly")
|
|
721
|
+
add_common_options(close_parser)
|
|
722
|
+
close_parser.add_argument("--summary")
|
|
723
|
+
close_parser.set_defaults(func=command_close)
|
|
724
|
+
|
|
725
|
+
path_parser = subparsers.add_parser("path", help="Print the current state directory")
|
|
726
|
+
add_common_options(path_parser)
|
|
727
|
+
path_parser.set_defaults(func=command_path)
|
|
728
|
+
|
|
729
|
+
list_parser = subparsers.add_parser("list", help="List explorations under the state root")
|
|
730
|
+
list_parser.add_argument("--root", help="State root (or EXLOOP_HOME); defaults to ~/.exloop/explorations")
|
|
731
|
+
list_parser.set_defaults(func=command_list)
|
|
732
|
+
|
|
733
|
+
archive_parser = subparsers.add_parser("archive", help="Copy events.jsonl + map.md into an archive directory")
|
|
734
|
+
add_common_options(archive_parser)
|
|
735
|
+
archive_parser.add_argument("--repo", help="the research repository; the record goes to explorations/<slug>/process/ there")
|
|
736
|
+
archive_parser.add_argument("--to", help="an explicit target directory instead of --repo")
|
|
737
|
+
archive_parser.add_argument("--force", action="store_true", help="overwrite a target that exists with different content")
|
|
738
|
+
archive_parser.set_defaults(func=command_archive)
|
|
739
|
+
|
|
740
|
+
handoff_parser = subparsers.add_parser("handoff", help="Copy the record and write goal-draft.md into a newlife question's origin/")
|
|
741
|
+
add_common_options(handoff_parser)
|
|
742
|
+
handoff_parser.add_argument("--repo", help="the research repository; the record goes to questions/<slug>/origin/ there (run newlife init first)")
|
|
743
|
+
handoff_parser.add_argument("--to", help="an explicit origin/ directory instead of --repo")
|
|
744
|
+
handoff_parser.add_argument("--force", action="store_true", help="overwrite a target that exists with different content")
|
|
745
|
+
handoff_parser.set_defaults(func=command_handoff)
|
|
746
|
+
|
|
747
|
+
self_test_parser = subparsers.add_parser("self-test", help="Run deterministic projection checks")
|
|
748
|
+
self_test_parser.set_defaults(func=command_self_test)
|
|
749
|
+
return parser
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def main() -> int:
|
|
753
|
+
try:
|
|
754
|
+
args = build_parser().parse_args()
|
|
755
|
+
args.func(args)
|
|
756
|
+
return 0
|
|
757
|
+
except StateError as exc:
|
|
758
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
759
|
+
return 2
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
if __name__ == "__main__":
|
|
763
|
+
raise SystemExit(main())
|