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.
@@ -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())