create-egregore 0.20.0 → 0.20.2

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.
Files changed (160) hide show
  1. package/assets/egregore-launcher.js +80 -15
  2. package/lib/local.js +4 -20
  3. package/lib/setup.js +21 -4
  4. package/package.json +2 -2
  5. package/runtime/codex/.codex/config.toml +3 -0
  6. package/runtime/codex/.codex/skills/archive/SKILL.md +1 -1
  7. package/runtime/codex/.codex/skills/branch/SKILL.md +1 -1
  8. package/runtime/codex/.codex/skills/checkup/SKILL.md +1 -1
  9. package/runtime/codex/.codex/skills/commit/SKILL.md +1 -1
  10. package/runtime/codex/.codex/skills/contribute/SKILL.md +1 -1
  11. package/runtime/codex/.codex/skills/delete-user/SKILL.md +1 -1
  12. package/runtime/codex/.codex/skills/ingest/SKILL.md +1 -1
  13. package/runtime/codex/.codex/skills/ingest-corpus/SKILL.md +29 -0
  14. package/runtime/codex/.codex/skills/ingest-notion/SKILL.md +1 -1
  15. package/runtime/codex/.codex/skills/ingest-user-interview/SKILL.md +1 -1
  16. package/runtime/codex/.codex/skills/issue/SKILL.md +1 -1
  17. package/runtime/codex/.codex/skills/me/SKILL.md +1 -1
  18. package/runtime/codex/.codex/skills/meeting/SKILL.md +1 -1
  19. package/runtime/codex/.codex/skills/note/SKILL.md +1 -1
  20. package/runtime/codex/.codex/skills/notion-connect/SKILL.md +1 -1
  21. package/runtime/codex/.codex/skills/onboarding/SKILL.md +1 -1
  22. package/runtime/codex/.codex/skills/pr/SKILL.md +1 -1
  23. package/runtime/codex/.codex/skills/pull/SKILL.md +1 -1
  24. package/runtime/codex/.codex/skills/push/SKILL.md +1 -1
  25. package/runtime/codex/.codex/skills/reflect/SKILL.md +1 -1
  26. package/runtime/codex/.codex/skills/review-pr/SKILL.md +1 -1
  27. package/runtime/codex/.codex/skills/save/SKILL.md +3 -1
  28. package/runtime/codex/.codex/skills/slack-connect/SKILL.md +1 -1
  29. package/runtime/codex/.codex/skills/summon/SKILL.md +1 -1
  30. package/runtime/codex/.codex/skills/sync-repos/SKILL.md +1 -1
  31. package/runtime/codex/.codex/skills/teams-connect/SKILL.md +1 -1
  32. package/runtime/codex/.codex/skills/telegram-connect/SKILL.md +1 -1
  33. package/runtime/codex/.codex/skills/telemetry/SKILL.md +1 -1
  34. package/runtime/codex/.codex/skills/test/SKILL.md +1 -1
  35. package/runtime/codex/.codex/skills/tutorial/SKILL.md +1 -1
  36. package/runtime/codex/.codex/skills/update/SKILL.md +1 -1
  37. package/runtime/codex/.codex/spec-manifest.json +4 -4
  38. package/runtime/codex/AGENTS.md +52 -39
  39. package/runtime/codex/bin/agent.sh +32 -8
  40. package/runtime/codex/bin/codex-render-spec.mjs +31 -34
  41. package/runtime/codex/bin/codex-sync-skills.sh +6 -1
  42. package/runtime/codex/bin/contribute-guard.sh +79 -0
  43. package/runtime/codex/bin/handoff-pr-backfill.sh +1 -1
  44. package/runtime/codex/bin/handoff-run.sh +8 -3
  45. package/runtime/codex/bin/handoff-save-egregore.sh +20 -8
  46. package/runtime/codex/bin/ingest.py +260 -5
  47. package/runtime/codex/bin/lib/git-message.sh +37 -0
  48. package/runtime/codex/bin/lib/git-sync.sh +26 -2
  49. package/runtime/codex/bin/lib/greeting.sh +2 -2
  50. package/runtime/codex/bin/search.sh +46 -4
  51. package/runtime/codex/bin/session-autosave.sh +13 -2
  52. package/runtime/codex/bin/settings.sh +63 -4
  53. package/runtime/codex/bin/worktree-create.sh +4 -1
  54. package/runtime/codex/manifest.json +5 -1
  55. package/runtime/pi/.codex/config.toml +3 -0
  56. package/runtime/pi/.codex/skills/archive/SKILL.md +1 -1
  57. package/runtime/pi/.codex/skills/branch/SKILL.md +1 -1
  58. package/runtime/pi/.codex/skills/checkup/SKILL.md +1 -1
  59. package/runtime/pi/.codex/skills/commit/SKILL.md +1 -1
  60. package/runtime/pi/.codex/skills/contribute/SKILL.md +1 -1
  61. package/runtime/pi/.codex/skills/delete-user/SKILL.md +1 -1
  62. package/runtime/pi/.codex/skills/ingest/SKILL.md +1 -1
  63. package/runtime/pi/.codex/skills/ingest-corpus/SKILL.md +29 -0
  64. package/runtime/pi/.codex/skills/ingest-notion/SKILL.md +1 -1
  65. package/runtime/pi/.codex/skills/ingest-user-interview/SKILL.md +1 -1
  66. package/runtime/pi/.codex/skills/issue/SKILL.md +1 -1
  67. package/runtime/pi/.codex/skills/me/SKILL.md +1 -1
  68. package/runtime/pi/.codex/skills/meeting/SKILL.md +1 -1
  69. package/runtime/pi/.codex/skills/note/SKILL.md +1 -1
  70. package/runtime/pi/.codex/skills/notion-connect/SKILL.md +1 -1
  71. package/runtime/pi/.codex/skills/onboarding/SKILL.md +1 -1
  72. package/runtime/pi/.codex/skills/pr/SKILL.md +1 -1
  73. package/runtime/pi/.codex/skills/pull/SKILL.md +1 -1
  74. package/runtime/pi/.codex/skills/push/SKILL.md +1 -1
  75. package/runtime/pi/.codex/skills/reflect/SKILL.md +1 -1
  76. package/runtime/pi/.codex/skills/review-pr/SKILL.md +1 -1
  77. package/runtime/pi/.codex/skills/save/SKILL.md +3 -1
  78. package/runtime/pi/.codex/skills/slack-connect/SKILL.md +1 -1
  79. package/runtime/pi/.codex/skills/summon/SKILL.md +1 -1
  80. package/runtime/pi/.codex/skills/sync-repos/SKILL.md +1 -1
  81. package/runtime/pi/.codex/skills/teams-connect/SKILL.md +1 -1
  82. package/runtime/pi/.codex/skills/telegram-connect/SKILL.md +1 -1
  83. package/runtime/pi/.codex/skills/telemetry/SKILL.md +1 -1
  84. package/runtime/pi/.codex/skills/test/SKILL.md +1 -1
  85. package/runtime/pi/.codex/skills/tutorial/SKILL.md +1 -1
  86. package/runtime/pi/.codex/skills/update/SKILL.md +1 -1
  87. package/runtime/pi/.codex/spec-manifest.json +4 -4
  88. package/runtime/pi/.pi/APPEND_SYSTEM.md +76 -39
  89. package/runtime/pi/.pi/spec-manifest.json +2 -2
  90. package/runtime/pi/AGENTS.md +52 -39
  91. package/runtime/pi/bin/agent.sh +32 -8
  92. package/runtime/pi/bin/codex-render-spec.mjs +31 -34
  93. package/runtime/pi/bin/codex-sync-skills.sh +6 -1
  94. package/runtime/pi/bin/contribute-guard.sh +79 -0
  95. package/runtime/pi/bin/handoff-pr-backfill.sh +1 -1
  96. package/runtime/pi/bin/handoff-run.sh +8 -3
  97. package/runtime/pi/bin/handoff-save-egregore.sh +20 -8
  98. package/runtime/pi/bin/ingest.py +260 -5
  99. package/runtime/pi/bin/lib/git-message.sh +37 -0
  100. package/runtime/pi/bin/lib/git-sync.sh +26 -2
  101. package/runtime/pi/bin/lib/greeting.sh +2 -2
  102. package/runtime/pi/bin/pi-render-spec.mjs +52 -26
  103. package/runtime/pi/bin/search.sh +46 -4
  104. package/runtime/pi/bin/session-autosave.sh +13 -2
  105. package/runtime/pi/bin/settings.sh +63 -4
  106. package/runtime/pi/bin/worktree-create.sh +4 -1
  107. package/runtime/pi/manifest.json +5 -1
  108. package/runtime/prime/.codex/config.toml +3 -0
  109. package/runtime/prime/.codex/skills/archive/SKILL.md +1 -1
  110. package/runtime/prime/.codex/skills/branch/SKILL.md +1 -1
  111. package/runtime/prime/.codex/skills/checkup/SKILL.md +1 -1
  112. package/runtime/prime/.codex/skills/commit/SKILL.md +1 -1
  113. package/runtime/prime/.codex/skills/contribute/SKILL.md +1 -1
  114. package/runtime/prime/.codex/skills/delete-user/SKILL.md +1 -1
  115. package/runtime/prime/.codex/skills/ingest/SKILL.md +1 -1
  116. package/runtime/prime/.codex/skills/ingest-corpus/SKILL.md +29 -0
  117. package/runtime/prime/.codex/skills/ingest-notion/SKILL.md +1 -1
  118. package/runtime/prime/.codex/skills/ingest-user-interview/SKILL.md +1 -1
  119. package/runtime/prime/.codex/skills/issue/SKILL.md +1 -1
  120. package/runtime/prime/.codex/skills/me/SKILL.md +1 -1
  121. package/runtime/prime/.codex/skills/meeting/SKILL.md +1 -1
  122. package/runtime/prime/.codex/skills/note/SKILL.md +1 -1
  123. package/runtime/prime/.codex/skills/notion-connect/SKILL.md +1 -1
  124. package/runtime/prime/.codex/skills/onboarding/SKILL.md +1 -1
  125. package/runtime/prime/.codex/skills/pr/SKILL.md +1 -1
  126. package/runtime/prime/.codex/skills/pull/SKILL.md +1 -1
  127. package/runtime/prime/.codex/skills/push/SKILL.md +1 -1
  128. package/runtime/prime/.codex/skills/reflect/SKILL.md +1 -1
  129. package/runtime/prime/.codex/skills/review-pr/SKILL.md +1 -1
  130. package/runtime/prime/.codex/skills/save/SKILL.md +3 -1
  131. package/runtime/prime/.codex/skills/slack-connect/SKILL.md +1 -1
  132. package/runtime/prime/.codex/skills/summon/SKILL.md +1 -1
  133. package/runtime/prime/.codex/skills/sync-repos/SKILL.md +1 -1
  134. package/runtime/prime/.codex/skills/teams-connect/SKILL.md +1 -1
  135. package/runtime/prime/.codex/skills/telegram-connect/SKILL.md +1 -1
  136. package/runtime/prime/.codex/skills/telemetry/SKILL.md +1 -1
  137. package/runtime/prime/.codex/skills/test/SKILL.md +1 -1
  138. package/runtime/prime/.codex/skills/tutorial/SKILL.md +1 -1
  139. package/runtime/prime/.codex/skills/update/SKILL.md +1 -1
  140. package/runtime/prime/.codex/spec-manifest.json +4 -4
  141. package/runtime/prime/.prime/agent/APPEND_SYSTEM.md +77 -40
  142. package/runtime/prime/.prime/agent/spec-manifest.json +2 -2
  143. package/runtime/prime/AGENTS.md +52 -39
  144. package/runtime/prime/bin/agent.sh +32 -8
  145. package/runtime/prime/bin/codex-render-spec.mjs +31 -34
  146. package/runtime/prime/bin/codex-sync-skills.sh +6 -1
  147. package/runtime/prime/bin/contribute-guard.sh +79 -0
  148. package/runtime/prime/bin/handoff-pr-backfill.sh +1 -1
  149. package/runtime/prime/bin/handoff-run.sh +8 -3
  150. package/runtime/prime/bin/handoff-save-egregore.sh +20 -8
  151. package/runtime/prime/bin/ingest.py +260 -5
  152. package/runtime/prime/bin/lib/git-message.sh +37 -0
  153. package/runtime/prime/bin/lib/git-sync.sh +26 -2
  154. package/runtime/prime/bin/lib/greeting.sh +2 -2
  155. package/runtime/prime/bin/prime-render-spec.mjs +52 -28
  156. package/runtime/prime/bin/search.sh +46 -4
  157. package/runtime/prime/bin/session-autosave.sh +13 -2
  158. package/runtime/prime/bin/settings.sh +63 -4
  159. package/runtime/prime/bin/worktree-create.sh +4 -1
  160. package/runtime/prime/manifest.json +5 -1
@@ -154,6 +154,36 @@ def chunks(text: str, limit: int = 7000) -> list[str]:
154
154
  return result
155
155
 
156
156
 
157
+ # Catalogue columns that carry provenance, by what they mean rather than by
158
+ # what any one organisation calls them. A profile's `map` already renames its
159
+ # own columns to these, so no further configuration is needed.
160
+ CATALOGUE_URL_FIELDS = ("url", "source_url", "link")
161
+ CATALOGUE_INSTITUTION_FIELDS = ("institution", "publisher", "source_type", "source")
162
+
163
+
164
+ def catalogue_provenance(catalogued: dict) -> tuple:
165
+ """The URL and institution a catalogue records for a document.
166
+
167
+ The resolver has always accepted these and ranks both above a filename,
168
+ because a filename is what someone happened to save a file as. Ingest
169
+ passed only the filename, so the stronger evidence sitting in the row it
170
+ had just read went unused.
171
+
172
+ Measured on a real archive that was not a marginal loss. Seventy articles
173
+ downloaded from two journal indexes were saved under bare article titles,
174
+ carrying no publisher token to match. The profile had rules for both
175
+ indexes and the catalogue named the index for every one of them, and still
176
+ nothing matched — the one input that could have identified them was the one
177
+ input never passed.
178
+ """
179
+ if not catalogued:
180
+ return "", ""
181
+ url = next((str(catalogued[f]) for f in CATALOGUE_URL_FIELDS if catalogued.get(f)), "")
182
+ institution = next(
183
+ (str(catalogued[f]) for f in CATALOGUE_INSTITUTION_FIELDS if catalogued.get(f)), "")
184
+ return url, institution
185
+
186
+
157
187
  def find_publisher_profile() -> Path | None:
158
188
  """Where this instance's publisher profile is, if it has one.
159
189
 
@@ -267,6 +297,17 @@ def add_path(args: argparse.Namespace) -> dict:
267
297
  )
268
298
  repair_map = text_repair.load_mapping(profile or {})
269
299
  ingested = unchanged = metadata_updated = skipped = pruned = 0
300
+ # How often progress is written. The manifest used to be saved once, after
301
+ # every file, so an interrupted run lost everything it had extracted — on a
302
+ # 921-document folder that was two and a half hours of OCR with nothing to
303
+ # show. Documents already on disk were re-extracted because nothing recorded
304
+ # them. Writing periodically means an interruption costs at most this many.
305
+ checkpoint_every = int(os.environ.get("EGREGORE_INGEST_CHECKPOINT", "25") or 0)
306
+ since_checkpoint = 0
307
+ # Read before the loop rather than after it, so a checkpoint writes the real
308
+ # deletion records instead of dropping them. Pruning still happens later; a
309
+ # mid-run save simply carries forward what the previous run recorded.
310
+ tombstones = existing_manifest.get("tombstones", [])
270
311
  errors: list[dict] = []
271
312
  seen_paths: set[str] = set()
272
313
 
@@ -390,7 +431,10 @@ def add_path(args: argparse.Namespace) -> dict:
390
431
  provenance["confidence"] = "high"
391
432
 
392
433
  if profile:
393
- resolved = source_profile.resolve(profile, filename=relative)
434
+ catalogue_url, catalogue_institution = catalogue_provenance(catalogued)
435
+ resolved = source_profile.resolve(
436
+ profile, url=catalogue_url, institution=catalogue_institution,
437
+ filename=relative)
394
438
  if resolved.publisher_class or resolved.notes:
395
439
  inferred = resolved.as_dict()
396
440
  # Keep the inference visible even when the catalogue answered,
@@ -438,12 +482,18 @@ def add_path(args: argparse.Namespace) -> dict:
438
482
  "status": "unverified",
439
483
  "ingested_at": now,
440
484
  }
485
+ since_checkpoint += 1
486
+ if checkpoint_every and since_checkpoint >= checkpoint_every:
487
+ # A crash between the document write and this one costs re-extraction
488
+ # of at most `checkpoint_every` files, never the whole run.
489
+ write_manifest(directory, source_id, org, args, boundaries, base, now,
490
+ previous, tombstones, errors)
491
+ since_checkpoint = 0
441
492
  if existing and existing.get("content_hash") == content_hash:
442
493
  metadata_updated += 1
443
494
  else:
444
495
  ingested += 1
445
496
 
446
- tombstones = existing_manifest.get("tombstones", [])
447
497
  if args.prune:
448
498
  if not input_path.is_dir():
449
499
  raise ValueError("--prune requires a directory snapshot")
@@ -465,11 +515,23 @@ def add_path(args: argparse.Namespace) -> dict:
465
515
  # Current documents always win over historical deletion records.
466
516
  tombstones = [row for row in tombstones if row["id"] not in previous]
467
517
 
518
+ write_manifest(directory, source_id, org, args, boundaries, base, now,
519
+ previous, tombstones, errors)
520
+ return {"org": org, "source": source_id, "ingested": ingested, "unchanged": unchanged, "metadata_updated": metadata_updated, "skipped": skipped, "pruned": pruned, "documents": len(previous), "metadata_root": str(directory), "data_root": str(docs_dir)}
521
+
522
+
523
+ def write_manifest(directory, source_id, org, args, boundaries, base, now,
524
+ previous, tombstones, errors) -> None:
525
+ """Persist progress. Called periodically during a run and once at the end.
526
+
527
+ One writer for both so a checkpoint and a completed run cannot record the
528
+ corpus differently.
529
+ """
468
530
  source_record = {
469
531
  "id": source_id,
470
532
  "org": org,
471
- "name": args.name or source_id,
472
- "kind": args.kind,
533
+ "name": getattr(args, "name", None) or source_id,
534
+ "kind": getattr(args, "kind", None),
473
535
  "boundaries": boundaries,
474
536
  "root_hint": base.name,
475
537
  "updated_at": now,
@@ -486,7 +548,6 @@ def add_path(args: argparse.Namespace) -> dict:
486
548
  "errors": errors,
487
549
  },
488
550
  )
489
- return {"org": org, "source": source_id, "ingested": ingested, "unchanged": unchanged, "metadata_updated": metadata_updated, "skipped": skipped, "pruned": pruned, "documents": len(previous), "metadata_root": str(directory), "data_root": str(docs_dir)}
490
551
 
491
552
 
492
553
  def cmd_add(args: argparse.Namespace) -> int:
@@ -494,6 +555,191 @@ def cmd_add(args: argparse.Namespace) -> int:
494
555
  return 0
495
556
 
496
557
 
558
+ def rewrite_frontmatter(path: Path, updates: dict) -> bool:
559
+ """Replace named frontmatter fields in place, leaving the text untouched.
560
+
561
+ A document's placement lives in two files — the manifest record and this
562
+ document's own frontmatter — and a reader may consult either. Updating one
563
+ and not the other produces a corpus that disagrees with itself, which is
564
+ worse than the stale value it replaced.
565
+
566
+ Only the named lines are rewritten. The body is never reparsed, so nothing
567
+ that survived extraction can be lost by a maintenance pass.
568
+ """
569
+ try:
570
+ text = path.read_text(encoding="utf-8")
571
+ except OSError:
572
+ return False
573
+ lines = text.split("\n")
574
+ if not lines or lines[0].strip() != "---":
575
+ return False
576
+ try:
577
+ close = next(i for i in range(1, len(lines)) if lines[i].strip() == "---")
578
+ except StopIteration:
579
+ return False
580
+ remaining = dict(updates)
581
+ for i in range(1, close):
582
+ key = lines[i].split(":", 1)[0].strip()
583
+ if key in remaining:
584
+ lines[i] = f"{key}: {json.dumps(remaining.pop(key), ensure_ascii=False)}"
585
+ # A field the original write omitted is appended rather than dropped, so a
586
+ # document ingested before a field existed still gains it.
587
+ for key, value in remaining.items():
588
+ lines.insert(close, f"{key}: {json.dumps(value, ensure_ascii=False)}")
589
+ close += 1
590
+ atomic_text(path, "\n".join(lines))
591
+ return True
592
+
593
+
594
+ def reresolve_source(args: argparse.Namespace) -> dict:
595
+ """Re-derive placement for documents already ingested.
596
+
597
+ Placement is written once, at ingest, and never revisited. That is right
598
+ for content — the text of a document does not change because a rule did —
599
+ but wrong for the rules themselves. Correcting a publisher profile changed
600
+ nothing about the corpus it governs: `add` skips on the content hash and
601
+ returns before it ever consults the profile, so it reported every document
602
+ unchanged while the corrected rule went unapplied. The only way to apply a
603
+ fix was to re-extract thousands of documents, redoing OCR to recompute a
604
+ field that depends on nothing but the filename.
605
+
606
+ So this re-runs the resolver alone. No file is reopened, no text is
607
+ re-extracted; the stored path is all the resolver ever needed.
608
+
609
+ Evidence precedence is the same as at ingest, and is the reason this is
610
+ safe to run at any time: a value the operator stated, or one the
611
+ organisation's own catalogue recorded, is never overwritten by an
612
+ inference. Only a placement this resolver derived — or the absence of one —
613
+ is open to revision.
614
+ """
615
+ config = load_config()
616
+ source_id = slug(args.source)
617
+ directory = source_dir(source_id)
618
+ manifest = load_manifest(directory)
619
+ records = manifest.get("documents") or []
620
+ if not records:
621
+ return {"source": source_id, "error": "no manifest, or no documents in it",
622
+ "metadata_root": str(directory)}
623
+
624
+ profile = publisher_profile()
625
+ if profile is None:
626
+ return {"source": source_id,
627
+ "error": "no publisher-profile.json found — nothing to resolve against"}
628
+ key = source_profile.boundary_key(profile)
629
+ if not key:
630
+ return {"source": source_id, "error": "profile declares no boundary key"}
631
+
632
+ docs_dir = data_root() / "sources" / source_id / "documents"
633
+ placed = rezoned = identified = unchanged = protected = missing = 0
634
+ changes: list = []
635
+
636
+ for record in records:
637
+ relative = record.get("source_path")
638
+ if not relative:
639
+ unchanged += 1
640
+ continue
641
+ boundaries = dict(record.get("boundaries") or {})
642
+ provenance = dict(record.get("provenance") or {})
643
+ catalogued = provenance.get("from_catalogue") or {}
644
+
645
+ # The same evidence the original ingest had, re-read from the record so
646
+ # a document identified only by its catalogue row resolves here too.
647
+ catalogue_url, catalogue_institution = catalogue_provenance(catalogued)
648
+ resolved = source_profile.resolve(
649
+ profile, url=catalogue_url, institution=catalogue_institution,
650
+ filename=relative)
651
+
652
+ # Placement and identification are decided separately. A document can
653
+ # become identifiable — a journal index naming its publisher — without
654
+ # that saying anything about which region it applies to, and reporting
655
+ # only the placement would call that document unchanged when the thing
656
+ # that decides how far to trust it has just been settled.
657
+ current = boundaries.get(key)
658
+ # Anything the operator stated or the catalogue recorded outranks an
659
+ # inference and is left exactly as it is.
660
+ derived_here = (provenance.get("derived") is True
661
+ and provenance.get("applied_boundary") == key)
662
+ zone_open = not ((current and not derived_here) or catalogued.get(key))
663
+ zone_changes = bool(zone_open and resolved.zone and resolved.zone != current)
664
+
665
+ # A tier the catalogue stated is likewise not ours to revise.
666
+ class_open = not catalogued.get("tier")
667
+ class_changes = bool(
668
+ class_open and resolved.publisher_class
669
+ and (resolved.publisher_class != provenance.get("publisher_class")
670
+ or resolved.tier != provenance.get("tier")))
671
+
672
+ if not zone_changes and not class_changes:
673
+ if not zone_open and not class_open:
674
+ protected += 1
675
+ elif current and not zone_open:
676
+ protected += 1
677
+ else:
678
+ unchanged += 1
679
+ continue
680
+
681
+ # The fresh resolution replaces the recorded inference whenever either
682
+ # half moved. Refreshing only on a class change once left a re-zoned
683
+ # document carrying the confidence and notes of the resolution it no
684
+ # longer had — a record that reported an inferred national zone with
685
+ # the confidence of a precise one.
686
+ updated_provenance = dict(provenance)
687
+ updated_provenance.update(resolved.as_dict())
688
+ if class_changes:
689
+ identified += 1
690
+ if catalogued:
691
+ updated_provenance["from_catalogue"] = catalogued
692
+ if zone_changes:
693
+ updated_provenance["applied_boundary"] = key
694
+ updated_provenance["derived"] = True
695
+ boundaries[key] = resolved.zone
696
+ if current:
697
+ rezoned += 1
698
+ else:
699
+ placed += 1
700
+
701
+ changes.append({"id": record["id"], "source_path": relative,
702
+ "zone": {"from": current, "to": boundaries.get(key)},
703
+ "class": {"from": provenance.get("publisher_class"),
704
+ "to": updated_provenance.get("publisher_class")},
705
+ "matched_by": resolved.matched_by})
706
+
707
+ if args.dry_run:
708
+ continue
709
+ record["boundaries"] = boundaries
710
+ record["provenance"] = updated_provenance
711
+ file_path = record.get("file_path")
712
+ target = (data_root() / file_path) if file_path else (docs_dir / f"{record['id']}.md")
713
+ if not rewrite_frontmatter(target, {"boundaries": boundaries,
714
+ "provenance": updated_provenance}):
715
+ missing += 1
716
+
717
+ if not args.dry_run and (placed or rezoned or identified):
718
+ manifest["documents"] = sorted(records, key=lambda d: d["id"])
719
+ atomic_json(directory / "manifest.json", manifest)
720
+
721
+ return {
722
+ "org": slug(config.get("slug") or "egregore"),
723
+ "source": source_id,
724
+ "dry_run": bool(args.dry_run),
725
+ "documents": len(records),
726
+ "placed": placed,
727
+ "rezoned": rezoned,
728
+ "identified": identified,
729
+ "unchanged": unchanged,
730
+ "protected": protected,
731
+ "frontmatter_not_found": missing,
732
+ "examples": changes[: max(0, args.examples)],
733
+ "metadata_root": str(directory),
734
+ }
735
+
736
+
737
+ def cmd_reresolve(args: argparse.Namespace) -> int:
738
+ result = reresolve_source(args)
739
+ print(json.dumps(result, indent=2, ensure_ascii=False))
740
+ return 1 if result.get("error") else 0
741
+
742
+
497
743
  def selection_relative_path(value: object) -> str:
498
744
  if not isinstance(value, str) or not value or "\x00" in value or "\\" in value:
499
745
  raise ValueError("selection contains an invalid relative path")
@@ -719,6 +965,15 @@ def parser() -> argparse.ArgumentParser:
719
965
  add.add_argument("--boundary", action="append", default=[])
720
966
  add.add_argument("--prune", action="store_true", help="tombstone files absent from this authoritative directory snapshot")
721
967
  add.set_defaults(func=cmd_add)
968
+ reresolve = sub.add_parser(
969
+ "reresolve",
970
+ help="re-derive placement from the current profile, without re-reading any document")
971
+ reresolve.add_argument("--source", required=True, help="source id to re-resolve")
972
+ reresolve.add_argument("--dry-run", action="store_true",
973
+ help="report what would change and write nothing")
974
+ reresolve.add_argument("--examples", type=int, default=10,
975
+ help="how many changed documents to list (default 10)")
976
+ reresolve.set_defaults(func=cmd_reresolve)
722
977
  add_selection = sub.add_parser("add-selection")
723
978
  add_selection.add_argument("manifest")
724
979
  add_selection.set_defaults(func=cmd_add_selection)
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bash
2
+ # git-message.sh — shared commit/PR message mechanics, sourced by composers.
3
+ # Spec: .claude/context/commit-format.md + .claude/context/pr-format.md.
4
+ #
5
+ # Provides:
6
+ # egregore_session_trailer <hub_dir>
7
+ # Print "Egregore-Session: <id>" when <hub_dir>/.egregore-session-id
8
+ # exists; print nothing otherwise. Never fails.
9
+ # egregore_commit <git_dir> <hub_dir> <message> [git-commit flags…]
10
+ # git -C <git_dir> commit with the session trailer appended as the
11
+ # final paragraph (a valid git trailer block) when an id exists.
12
+ # egregore_pr_skeleton <what> <why> <verification> <footer>
13
+ # The one source of the skeleton PR body shape.
14
+
15
+ egregore_session_trailer() {
16
+ local sid
17
+ sid="$(cat "${1:-.}/.egregore-session-id" 2>/dev/null || true)"
18
+ [ -n "$sid" ] && printf 'Egregore-Session: %s' "$sid"
19
+ return 0
20
+ }
21
+
22
+ egregore_commit() {
23
+ local git_dir="$1" hub_dir="$2" msg="$3"
24
+ shift 3
25
+ local trailer
26
+ trailer="$(egregore_session_trailer "$hub_dir")"
27
+ if [ -n "$trailer" ]; then
28
+ git -C "$git_dir" commit -m "$msg" -m "$trailer" "$@"
29
+ else
30
+ git -C "$git_dir" commit -m "$msg" "$@"
31
+ fi
32
+ }
33
+
34
+ egregore_pr_skeleton() {
35
+ printf '## What\n%s\n\n## Why\n%s\n\n## Verification\n%s\n\n%s\n' \
36
+ "$1" "$2" "$3" "$4"
37
+ }
@@ -32,6 +32,22 @@ if [ -z "${BASE_BRANCH:-}" ]; then
32
32
  fi
33
33
  fi
34
34
 
35
+ # Migrate task branches created by older Egregore versions. `git branch
36
+ # <task> origin/<base>` implicitly made the task branch track the protected
37
+ # integration branch, so a bare push could target it when push.default was
38
+ # `upstream`. Task branches remain unpublished until the explicit save/push
39
+ # flow creates their same-name remote and sets that as upstream.
40
+ _CURRENT_TASK_BRANCH="$(git branch --show-current 2>/dev/null || true)"
41
+ case "$_CURRENT_TASK_BRANCH" in
42
+ dev/*|feature/*|bugfix/*)
43
+ _CURRENT_TASK_UPSTREAM="$(git rev-parse --abbrev-ref '@{upstream}' 2>/dev/null || true)"
44
+ if [ "$_CURRENT_TASK_UPSTREAM" = "origin/$BASE_BRANCH" ]; then
45
+ git branch --unset-upstream "$_CURRENT_TASK_BRANCH" >/dev/null 2>&1 || true
46
+ fi
47
+ ;;
48
+ esac
49
+ unset _CURRENT_TASK_BRANCH _CURRENT_TASK_UPSTREAM
50
+
35
51
  # --- Ensure pull.rebase is set (prevents "divergent branches" errors) ---
36
52
  git config pull.rebase true 2>/dev/null || true
37
53
 
@@ -185,7 +201,7 @@ _apply_framework_update() {
185
201
  FRAMEWORK_UPDATED_COUNT=$(printf '%s\n' "$_changed" | grep -c . || echo 0)
186
202
  FRAMEWORK_UPDATED_FILES=$(printf '%s\n' "$_changed" | awk '{print $NF}' | head -3 | tr '\n' ' ')
187
203
  git add bin/ .claude/ loom/ CLAUDE.md skills/ 2>/dev/null
188
- EGREGORE_FRAMEWORK_UPDATE=1 git commit -m "Auto-update Egregore framework" --quiet 2>/dev/null || true
204
+ EGREGORE_FRAMEWORK_UPDATE=1 git commit -m "chore(sync): update framework from upstream" --quiet 2>/dev/null || true
189
205
  FRAMEWORK_UPDATED="true"
190
206
  fi
191
207
  }
@@ -214,6 +230,14 @@ fi
214
230
  # Named setup_develop for continuity; it operates on $BASE_BRANCH, which is
215
231
  # "develop" by default and whatever egregore.json's base_branch says otherwise.
216
232
  setup_develop() {
233
+ # Detached HEAD (CI merge-ref checkouts, bisects): never create or switch
234
+ # branches here — `git checkout -b $BASE_BRANCH origin/$BASE_BRANCH` would
235
+ # silently replace the checked-out tree with the base branch. This is how
236
+ # CI jobs that invoke session-start machinery ended up testing develop
237
+ # instead of the PR under test. Syncing means updating the base-branch ref,
238
+ # not claiming HEAD; on a detached HEAD there is nothing safe to do.
239
+ [ -n "$(git branch --show-current 2>/dev/null)" ] || return 0
240
+
217
241
  # Ensure the base branch exists locally
218
242
  if ! git show-ref --verify --quiet "refs/heads/$BASE_BRANCH" 2>/dev/null; then
219
243
  if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH" 2>/dev/null; then
@@ -267,7 +291,7 @@ setup_develop() {
267
291
  # Save uncommitted work so nothing is lost (guarded: never auto-commit
268
292
  # stray submodule pointers from sibling repos in the checkout)
269
293
  git_add_guarded
270
- git commit -m "Auto-save: uncommitted work from $CURRENT_BRANCH" --quiet 2>/dev/null || true
294
+ git commit -m "chore(autosave): save uncommitted work from $CURRENT_BRANCH" --quiet 2>/dev/null || true
271
295
  SAVED_BRANCH="$CURRENT_BRANCH"
272
296
  fi
273
297
 
@@ -13,7 +13,7 @@
13
13
  # - Telemetry emit
14
14
  # - First session welcome
15
15
  #
16
- # Inputs: SCRIPT_DIR, STATE_FILE, CONFIG, CTX_DIR, BRANCH, COMMITS_AHEAD,
16
+ # Inputs: SCRIPT_DIR, STATE_FILE, CONFIG, CTX_DIR, BRANCH, BASE_BRANCH, COMMITS_AHEAD,
17
17
  # AUTHOR, LOCAL_MODE, MEMORY_SYNCED, SAVED_BRANCH,
18
18
  # HEALTH_*, FRAMEWORK_VERSION, TIME_OF_DAY, EGREGORE_SESSION_ID,
19
19
  # FIRST_SESSION, DISPLAY_NAME_STATE, DASHBOARD_URL
@@ -515,5 +515,5 @@ else
515
515
  echo ""
516
516
  echo "IMPORTANT: Display the above greeting to the user exactly as-is (preserve the ASCII art formatting and ornamented status) on their first message. Then ask: What are you working on?"
517
517
  echo ""
518
- echo "BRANCH RULE: When the user responds with what they're working on, your FIRST action is to create a working branch: git fetch origin develop --quiet && git checkout -b dev/{author}/{topic-slug} origin/develop. Do this BEFORE any other work. Derive the topic slug from their description. If they ask a pure question with no work intent, skip branching."
518
+ echo "BRANCH RULE: When the user responds with what they're working on, your FIRST action is to create a working branch: git fetch origin $BASE_BRANCH --quiet && git checkout --no-track -b dev/{author}/{topic-slug} origin/$BASE_BRANCH. Do this BEFORE any other work. Derive the topic slug from their description. If they ask a pure question with no work intent, skip branching."
519
519
  fi
@@ -40,24 +40,57 @@ function replaceSection(markdown, heading, body) {
40
40
  return `${markdown.slice(0, start)}${marker}\n\n${body}${markdown.slice(end)}`;
41
41
  }
42
42
 
43
+ // Keep an inherited section and put runtime-specific framing in front of it.
44
+ // Command Awareness is the case that matters: the canonical category rows and
45
+ // disambiguation map live in CLAUDE.md, and a full replace here silently
46
+ // shipped a truncated copy that drifted for months. The Codex spec opens the
47
+ // section with its own skills-not-slash-commands paragraph, which is wrong for
48
+ // a runtime that does have slash commands, so that one paragraph is dropped.
49
+ function prependToSection(markdown, heading, body, dropLeadParagraphStartingWith) {
50
+ const marker = `## ${heading}`;
51
+ const start = markdown.indexOf(marker);
52
+ if (start < 0) throw new Error(`missing section in AGENTS.md: ${heading}`);
53
+ const rest = markdown.slice(start + marker.length);
54
+ const boundary = rest.search(/\n\n---\n\n## /);
55
+ const end = boundary < 0 ? markdown.length : start + marker.length + boundary;
56
+ let inherited = markdown.slice(start + marker.length, end).trim();
57
+ if (dropLeadParagraphStartingWith) {
58
+ if (!inherited.startsWith(dropLeadParagraphStartingWith)) {
59
+ throw new Error(`pi/prime-render-spec: "${heading}" no longer opens with "${dropLeadParagraphStartingWith}" — the Codex wording moved; update this adaptation`);
60
+ }
61
+ inherited = inherited.slice(inherited.indexOf("\n\n") + 2).trim();
62
+ }
63
+ return replaceSection(markdown, heading, `${body}\n\n${inherited}`);
64
+ }
65
+
66
+ const CODEX_COMMAND_LEAD = "Codex reserves leading";
67
+
43
68
  function adapt(sourceSpec) {
44
- let text = sourceSpec
45
- .replaceAll("Egregore on Codex", "Egregore on Pi")
46
- .replaceAll("Codex-native", "Pi-native")
47
- .replaceAll("Codex sessions", "Pi sessions")
48
- .replaceAll("On Codex", "On Pi")
49
- .replaceAll("from Codex", "from Pi")
50
- .replaceAll("inside Codex", "inside Pi")
51
- .replaceAll("Codex has no", "Pi has no built-in")
52
- .replaceAll("normal sandboxed Codex shell", "the Pi shell")
53
- .replaceAll("Codex graph network access", "Pi graph network access")
54
- .replaceAll("structured Codex question tooling", "Pi UI question tooling")
55
- .replaceAll("structured Codex", "structured Pi")
56
- .replaceAll("bin/codex-session-start.sh", "bin/pi-session-start.sh")
57
- .replaceAll(".codex/hooks/branch-guard.js", ".pi/extensions/egregore.ts")
58
- .replaceAll("PreToolUse hook (enabled by the launcher via `--enable hooks`)", "`tool_call` gate (loaded after Pi project trust)")
59
- .replaceAll("If this Codex build does not support hooks", "If Pi project resources are disabled")
60
- .replaceAll("change permissions in `.claude/settings.json` anytime", "review or disable project resources in `.pi/` anytime")
69
+ // Anchored replacements: every `from` string MUST exist in the generated
70
+ // Codex spec. A missing anchor once no-opped silently (the tool_call gate
71
+ // wording vanished from this file for a whole PR cycle before a test caught
72
+ // it) — now it fails the render loudly so the anchor gets updated alongside
73
+ // the Codex text that moved.
74
+ const ANCHORED = [
75
+ ["Egregore on Codex", "Egregore on Pi"],
76
+ ["Codex-native", "Pi-native"],
77
+ ["Codex sessions", "Pi sessions"],
78
+ ["On Codex", "On Pi"],
79
+ ["Codex has no", "Pi has no built-in"],
80
+ ["bin/codex-session-start.sh", "bin/pi-session-start.sh"],
81
+ [".codex/hooks/branch-guard.js", ".pi/extensions/egregore.ts"],
82
+ ["PreToolUse hook (launcher `--enable hooks`)", "`tool_call` gate (loaded after Pi project trust)"],
83
+ ["If this Codex build does not support hooks", "If Pi project resources are disabled"],
84
+ ["change permissions in `.claude/settings.json` anytime", "review or disable project resources in `.pi/` anytime"],
85
+ ];
86
+ let text = sourceSpec;
87
+ for (const [from, to] of ANCHORED) {
88
+ if (!text.includes(from)) {
89
+ throw new Error(`pi-render-spec: anchor not found in generated Codex spec: "${from}" — the Codex wording moved; update this replacement`);
90
+ }
91
+ text = text.replaceAll(from, to);
92
+ }
93
+ text = text
61
94
  .replace(/`\$([a-z][a-z0-9-]*)/g, "`/$1")
62
95
  .replace(/\$([a-z][a-z0-9-]*)/g, "/$1");
63
96
 
@@ -67,16 +100,9 @@ This appendix is generated from the reviewed shell-runtime translation of \`CLAU
67
100
 
68
101
  text = replaceSection(text, "On Launch — MANDATORY FIRST ACTION", `The project-local Pi extension renders the Egregore startup card (identity, handoffs, team activity) inside Pi via \`bin/pi-session-start.sh\`. This works for direct \`pi\` launches and launcher-managed sessions. Do not rerun startup checks and do not narrate startup. The card ends with **"What are you working on?"** — that question is already on screen; treat the user's first message as the answer to it. To re-show the card outside Pi, run \`bash bin/pi-session-start.sh --card\`.`);
69
102
 
70
- text = replaceSection(text, "Command Awareness", `Egregore workflows are available as Pi slash commands such as \`/activity\`, \`/handoff\`, \`/save\`, and \`/view\`. They are registered from the shared skill inventory by \`.pi/extensions/egregore.ts\`. Pi also exposes the underlying Agent Skills as \`/skill:name\`, and natural-language requests work normally. \`/save\` is the user-facing abstraction for committing, pushing, opening or reusing pull requests, and syncing memory — never make users manage the git workflow by hand.
71
-
72
- Invoke commands from user intent — don't wait for the slash. Read the selected skill's \`SKILL.md\` completely before acting. Codex wording inside the shared portable skills means the shell-capable runtime adapter; in Pi, run referenced \`bin/\` scripts directly, use ordinary Pi shell/network access, ignore Codex approval-channel wording, and render compact numbered questions when no Pi UI question surface is available.
103
+ text = prependToSection(text, "Command Awareness", `Egregore workflows are available as Pi slash commands such as \`/activity\`, \`/handoff\`, \`/save\`, and \`/view\`. They are registered from the shared skill inventory by \`.pi/extensions/egregore.ts\`. Pi also exposes the underlying Agent Skills as \`/skill:name\`, and natural-language requests work normally. \`/save\` is the user-facing abstraction for committing, pushing, opening or reusing pull requests, and syncing memory — never make users manage the git workflow by hand.
73
104
 
74
- **Core loop** — \`/activity\` \`/dashboard\` \`/handoff\` \`/wrap\` \`/save\` \`/reflect\` \`/todo\`
75
- **Knowledge** — \`/search\` \`/deep-reflect\` \`/archive\` \`/note\` \`/add\` \`/meeting\` \`/ingest\` \`/scroll\`
76
- **Identity** — \`/me\`
77
- **Coordination** — \`/ask\` \`/quest\` \`/issue\` \`/invite\` \`/delete-user\` \`/announce\`
78
- **Git** — \`/branch\` \`/commit\` \`/push\` \`/pr\` \`/save\` \`/review-pr\` \`/contribute\`
79
- **Infra** — \`/setup\` \`/update\` \`/pull\` \`/env\` \`/infra\` \`/sync-repos\` \`/release\` \`/checkup\``);
105
+ Read the selected skill's \`SKILL.md\` completely before acting. Codex wording inside the shared portable skills means the shell-capable runtime adapter; in Pi, run referenced \`bin/\` scripts directly, use ordinary Pi shell/network access, ignore Codex approval-channel wording, and render compact numbered questions when no Pi UI question surface is available.`, CODEX_COMMAND_LEAD);
80
106
 
81
107
  return `# Egregore on Pi\n\nThis file is generated by \`bin/pi-render-spec.mjs\`; do not edit it by hand.\n\n${text}\n`;
82
108
  }
@@ -29,6 +29,48 @@ SLUG=$(jq -r '.slug // "egregore"' "$SCRIPT_DIR/egregore.json" 2>/dev/null || ec
29
29
  COLLECTION="${SLUG}-memory"
30
30
  MEM_PATH="$(readlink -f "$SCRIPT_DIR/memory" 2>/dev/null || true)"
31
31
 
32
+ # qmd 2.5.x may render --full-path results as absolute `**file:**` paths,
33
+ # while older output uses qmd:// URIs. Normalize both to the canonical
34
+ # memory/... form users can open from an Egregore checkout, and parse both
35
+ # forms when counting hits or attaching graph state.
36
+ _normalize_file_paths() {
37
+ local line absolute_prefix uri_prefix
38
+ absolute_prefix="**file:** \`$MEM_PATH/"
39
+ uri_prefix="**file:** \`qmd://${COLLECTION}/"
40
+ while IFS= read -r line || [ -n "$line" ]; do
41
+ if [[ "$line" == "$absolute_prefix"* ]]; then
42
+ printf '**file:** `memory/%s\n' "${line#"$absolute_prefix"}"
43
+ elif [[ "$line" == "$uri_prefix"* ]]; then
44
+ printf '**file:** `memory/%s\n' "${line#"$uri_prefix"}"
45
+ else
46
+ printf '%s\n' "$line"
47
+ fi
48
+ done
49
+ }
50
+
51
+ _result_paths() {
52
+ local line path canonical_prefix absolute_prefix uri_prefix
53
+ canonical_prefix="**file:** \`memory/"
54
+ absolute_prefix="**file:** \`$MEM_PATH/"
55
+ uri_prefix="**file:** \`qmd://${COLLECTION}/"
56
+ while IFS= read -r line || [ -n "$line" ]; do
57
+ path=""
58
+ if [[ "$line" == "$canonical_prefix"* ]]; then
59
+ path="${line#"$canonical_prefix"}"
60
+ elif [[ "$line" == "$absolute_prefix"* ]]; then
61
+ path="${line#"$absolute_prefix"}"
62
+ elif [[ "$line" == "$uri_prefix"* ]]; then
63
+ path="${line#"$uri_prefix"}"
64
+ elif [[ "$line" == *"qmd://${COLLECTION}/"* ]]; then
65
+ path="${line#*"qmd://${COLLECTION}/"}"
66
+ fi
67
+ path="${path%%\`*}"
68
+ case "$path" in
69
+ *.md*) printf '%s\n' "${path%%.md*}.md" ;;
70
+ esac
71
+ done | sort -u
72
+ }
73
+
32
74
  QMD_VERSION="2.5.3" # pinned — bump deliberately, never float @latest
33
75
 
34
76
  _qmd() {
@@ -104,7 +146,7 @@ _enrich() {
104
146
  local results paths params out
105
147
  results=$(cat)
106
148
  echo "$results"
107
- paths=$(echo "$results" | grep -o "qmd://${COLLECTION}/[^: ]*\.md" | sed "s|qmd://${COLLECTION}/||" | sort -u)
149
+ paths=$(printf '%s\n' "$results" | _result_paths)
108
150
  [ -z "$paths" ] && return 0
109
151
  params=$(printf '%s\n' "$paths" | jq -R . | jq -sc '{paths: .}')
110
152
  out=$(bash "$SCRIPT_DIR/bin/graph.sh" query "UNWIND \$paths AS fp
@@ -167,7 +209,7 @@ cmd_query() {
167
209
  local t0 out label
168
210
  t0=$(date +%s)
169
211
  if [ "$mode" != "search" ]; then
170
- out=$(_qmd "$mode" "$q" -c "$COLLECTION" -n "$n" --format md --full-path "${all[@]}" 2>/dev/null | _clean)
212
+ out=$(_qmd "$mode" "$q" -c "$COLLECTION" -n "$n" --format md --full-path "${all[@]}" 2>/dev/null | _clean | _normalize_file_paths)
171
213
  if [ -n "$out" ] && [ "$out" != "No results found." ]; then
172
214
  [ "$mode" = "query" ] && label="hybrid" || label="semantic"
173
215
  _banner_and_emit "$label" "$t0" "$emit" "$q" "$out"
@@ -176,7 +218,7 @@ cmd_query() {
176
218
  echo "_(hybrid/semantic returned nothing — showing keyword results. If this persists, run: bash bin/search.sh reindex --embed)_"
177
219
  echo ""
178
220
  fi
179
- out=$(_qmd search "$q" -c "$COLLECTION" -n "$n" --format md --full-path "${all[@]}" | _clean)
221
+ out=$(_qmd search "$q" -c "$COLLECTION" -n "$n" --format md --full-path "${all[@]}" | _clean | _normalize_file_paths)
180
222
  _banner_and_emit "keyword" "$t0" "$emit" "$q" "$out"
181
223
  }
182
224
 
@@ -189,7 +231,7 @@ cmd_query() {
189
231
  _banner_and_emit() {
190
232
  local label="$1" t0="$2" emit="$3" q="$4" out="$5"
191
233
  local hits dur modef product surface plural=""
192
- hits=$(printf '%s\n' "$out" | grep -c '^qmd://' 2>/dev/null | tr -d ' ')
234
+ hits=$(printf '%s\n' "$out" | _result_paths | wc -l | tr -d ' ')
193
235
  dur=$(( $(date +%s) - t0 ))
194
236
  modef="$label"
195
237
  product="Egregore"
@@ -29,6 +29,12 @@
29
29
 
30
30
  set -uo pipefail
31
31
 
32
+ # shellcheck source=bin/lib/git-message.sh
33
+ . "$(cd "$(dirname "$0")" && pwd)/lib/git-message.sh" 2>/dev/null || true
34
+ type egregore_commit >/dev/null 2>&1 || egregore_commit() {
35
+ local gd="$1" m="$3"; shift 3; git -C "$gd" commit -m "$m" "$@"
36
+ }
37
+
32
38
  DIR=""
33
39
  AUTHOR=""
34
40
  DEBOUNCE=0
@@ -79,7 +85,12 @@ git rev-parse --git-dir >/dev/null 2>&1 || exit 0
79
85
  DIR_HASH=$( { md5sum <<<"$DIR" 2>/dev/null || md5 -q -s "$DIR" 2>/dev/null || echo default; } | cut -c1-8 )
80
86
  STAMP="/tmp/.egregore-autosave-${DIR_HASH}"
81
87
  if [ "$DEBOUNCE" -gt 0 ] && [ -f "$STAMP" ]; then
82
- LAST=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0)
88
+ if stat -c %Y "$STAMP" >/dev/null 2>&1; then
89
+ LAST=$(stat -c %Y "$STAMP")
90
+ else
91
+ LAST=$(stat -f %m "$STAMP" 2>/dev/null || echo 0)
92
+ fi
93
+ case "$LAST" in ""|*[!0-9]*) LAST=0 ;; esac
83
94
  [ $(( $(date +%s) - LAST )) -lt "$DEBOUNCE" ] && exit 0
84
95
  fi
85
96
  touch "$STAMP" 2>/dev/null || true
@@ -118,7 +129,7 @@ fi
118
129
  if git -C "$DIR/memory" rev-parse --git-dir >/dev/null 2>&1; then
119
130
  if [ -n "$(git -C "$DIR/memory" status --porcelain 2>/dev/null)" ]; then
120
131
  git -C "$DIR/memory" add -A >/dev/null 2>&1 || true
121
- git -C "$DIR/memory" commit -m "Auto-save: session content" --quiet 2>/dev/null || true
132
+ egregore_commit "$DIR/memory" "$DIR" "chore(autosave): save session work" --quiet 2>/dev/null || true
122
133
  fi
123
134
  if [ -n "$(git -C "$DIR/memory" log origin/main..HEAD --oneline 2>/dev/null)" ]; then
124
135
  for _try in 1 2 3; do