create-egregore 0.19.33 → 0.20.1

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 (250) hide show
  1. package/assets/egregore-launcher.js +141 -14
  2. package/bin/cli.js +19 -3
  3. package/lib/connect-runtime.js +4 -2
  4. package/lib/local.js +15 -1
  5. package/lib/prime-runtime.js +208 -0
  6. package/lib/setup.js +66 -5
  7. package/package.json +4 -3
  8. package/runtime/codex/.codex/config.toml +3 -0
  9. package/runtime/codex/.codex/skills/archive/SKILL.md +1 -1
  10. package/runtime/codex/.codex/skills/branch/SKILL.md +1 -1
  11. package/runtime/codex/.codex/skills/checkup/SKILL.md +1 -1
  12. package/runtime/codex/.codex/skills/commit/SKILL.md +1 -1
  13. package/runtime/codex/.codex/skills/contribute/SKILL.md +1 -1
  14. package/runtime/codex/.codex/skills/delete-user/SKILL.md +1 -1
  15. package/runtime/codex/.codex/skills/ingest/SKILL.md +1 -1
  16. package/runtime/codex/.codex/skills/ingest-corpus/SKILL.md +29 -0
  17. package/runtime/codex/.codex/skills/ingest-notion/SKILL.md +29 -0
  18. package/runtime/codex/.codex/skills/ingest-user-interview/SKILL.md +1 -1
  19. package/runtime/codex/.codex/skills/issue/SKILL.md +1 -1
  20. package/runtime/codex/.codex/skills/me/SKILL.md +1 -1
  21. package/runtime/codex/.codex/skills/meeting/SKILL.md +1 -1
  22. package/runtime/codex/.codex/skills/note/SKILL.md +1 -1
  23. package/runtime/codex/.codex/skills/notion-connect/SKILL.md +29 -0
  24. package/runtime/codex/.codex/skills/onboarding/SKILL.md +1 -1
  25. package/runtime/codex/.codex/skills/pr/SKILL.md +1 -1
  26. package/runtime/codex/.codex/skills/pull/SKILL.md +1 -1
  27. package/runtime/codex/.codex/skills/push/SKILL.md +1 -1
  28. package/runtime/codex/.codex/skills/reflect/SKILL.md +1 -1
  29. package/runtime/codex/.codex/skills/review-pr/SKILL.md +1 -1
  30. package/runtime/codex/.codex/skills/save/SKILL.md +3 -1
  31. package/runtime/codex/.codex/skills/slack-connect/SKILL.md +1 -1
  32. package/runtime/codex/.codex/skills/summon/SKILL.md +1 -1
  33. package/runtime/codex/.codex/skills/sync-repos/SKILL.md +1 -1
  34. package/runtime/codex/.codex/skills/teams-connect/SKILL.md +29 -0
  35. package/runtime/codex/.codex/skills/telegram-connect/SKILL.md +1 -1
  36. package/runtime/codex/.codex/skills/telemetry/SKILL.md +1 -1
  37. package/runtime/codex/.codex/skills/test/SKILL.md +1 -1
  38. package/runtime/codex/.codex/skills/tutorial/SKILL.md +1 -1
  39. package/runtime/codex/.codex/skills/update/SKILL.md +1 -1
  40. package/runtime/codex/.codex/spec-manifest.json +5 -5
  41. package/runtime/codex/AGENTS.md +41 -35
  42. package/runtime/codex/bin/activity-data.sh +4 -8
  43. package/runtime/codex/bin/agent.sh +17 -5
  44. package/runtime/codex/bin/attendant.sh +13 -0
  45. package/runtime/codex/bin/autosave.sh +27 -1
  46. package/runtime/codex/bin/codex-render-spec.mjs +33 -32
  47. package/runtime/codex/bin/codex-session-start.sh +3 -1
  48. package/runtime/codex/bin/codex-sync-skills.sh +6 -1
  49. package/runtime/codex/bin/graph-batch.sh +25 -6
  50. package/runtime/codex/bin/graph-op.sh +5 -4
  51. package/runtime/codex/bin/graph.sh +31 -5
  52. package/runtime/codex/bin/handoff-pr-backfill.sh +1 -1
  53. package/runtime/codex/bin/handoff-run.sh +8 -3
  54. package/runtime/codex/bin/handoff-save-egregore.sh +19 -7
  55. package/runtime/codex/bin/ingest.py +341 -10
  56. package/runtime/codex/bin/lib/context.sh +26 -1
  57. package/runtime/codex/bin/lib/git-message.sh +37 -0
  58. package/runtime/codex/bin/lib/git-sync.sh +10 -2
  59. package/runtime/codex/bin/lib/greeting.sh +4 -1
  60. package/runtime/codex/bin/lib/worktree-links.sh +25 -0
  61. package/runtime/codex/bin/session-autosave.sh +7 -1
  62. package/runtime/codex/bin/settings.sh +1 -1
  63. package/runtime/codex/manifest.json +10 -1
  64. package/runtime/pi/.codex/config.toml +3 -0
  65. package/runtime/pi/.codex/skills/archive/SKILL.md +1 -1
  66. package/runtime/pi/.codex/skills/branch/SKILL.md +1 -1
  67. package/runtime/pi/.codex/skills/checkup/SKILL.md +1 -1
  68. package/runtime/pi/.codex/skills/commit/SKILL.md +1 -1
  69. package/runtime/pi/.codex/skills/contribute/SKILL.md +1 -1
  70. package/runtime/pi/.codex/skills/delete-user/SKILL.md +1 -1
  71. package/runtime/pi/.codex/skills/ingest/SKILL.md +1 -1
  72. package/runtime/pi/.codex/skills/ingest-corpus/SKILL.md +29 -0
  73. package/runtime/pi/.codex/skills/ingest-notion/SKILL.md +29 -0
  74. package/runtime/pi/.codex/skills/ingest-user-interview/SKILL.md +1 -1
  75. package/runtime/pi/.codex/skills/issue/SKILL.md +1 -1
  76. package/runtime/pi/.codex/skills/me/SKILL.md +1 -1
  77. package/runtime/pi/.codex/skills/meeting/SKILL.md +1 -1
  78. package/runtime/pi/.codex/skills/note/SKILL.md +1 -1
  79. package/runtime/pi/.codex/skills/notion-connect/SKILL.md +29 -0
  80. package/runtime/pi/.codex/skills/onboarding/SKILL.md +1 -1
  81. package/runtime/pi/.codex/skills/pr/SKILL.md +1 -1
  82. package/runtime/pi/.codex/skills/pull/SKILL.md +1 -1
  83. package/runtime/pi/.codex/skills/push/SKILL.md +1 -1
  84. package/runtime/pi/.codex/skills/reflect/SKILL.md +1 -1
  85. package/runtime/pi/.codex/skills/review-pr/SKILL.md +1 -1
  86. package/runtime/pi/.codex/skills/save/SKILL.md +3 -1
  87. package/runtime/pi/.codex/skills/slack-connect/SKILL.md +1 -1
  88. package/runtime/pi/.codex/skills/summon/SKILL.md +1 -1
  89. package/runtime/pi/.codex/skills/sync-repos/SKILL.md +1 -1
  90. package/runtime/pi/.codex/skills/teams-connect/SKILL.md +29 -0
  91. package/runtime/pi/.codex/skills/telegram-connect/SKILL.md +1 -1
  92. package/runtime/pi/.codex/skills/telemetry/SKILL.md +1 -1
  93. package/runtime/pi/.codex/skills/test/SKILL.md +1 -1
  94. package/runtime/pi/.codex/skills/tutorial/SKILL.md +1 -1
  95. package/runtime/pi/.codex/skills/update/SKILL.md +1 -1
  96. package/runtime/pi/.codex/spec-manifest.json +5 -5
  97. package/runtime/pi/.pi/APPEND_SYSTEM.md +57 -33
  98. package/runtime/pi/.pi/extensions/egregore.ts +17 -6
  99. package/runtime/pi/.pi/spec-manifest.json +2 -2
  100. package/runtime/pi/AGENTS.md +41 -35
  101. package/runtime/pi/bin/activity-data.sh +4 -8
  102. package/runtime/pi/bin/agent.sh +17 -5
  103. package/runtime/pi/bin/attendant.sh +13 -0
  104. package/runtime/pi/bin/autosave.sh +27 -1
  105. package/runtime/pi/bin/codex-render-spec.mjs +33 -32
  106. package/runtime/pi/bin/codex-session-start.sh +3 -1
  107. package/runtime/pi/bin/codex-sync-skills.sh +6 -1
  108. package/runtime/pi/bin/graph-batch.sh +25 -6
  109. package/runtime/pi/bin/graph-op.sh +5 -4
  110. package/runtime/pi/bin/graph.sh +31 -5
  111. package/runtime/pi/bin/handoff-pr-backfill.sh +1 -1
  112. package/runtime/pi/bin/handoff-run.sh +8 -3
  113. package/runtime/pi/bin/handoff-save-egregore.sh +19 -7
  114. package/runtime/pi/bin/ingest.py +341 -10
  115. package/runtime/pi/bin/lib/context.sh +26 -1
  116. package/runtime/pi/bin/lib/git-message.sh +37 -0
  117. package/runtime/pi/bin/lib/git-sync.sh +10 -2
  118. package/runtime/pi/bin/lib/greeting.sh +4 -1
  119. package/runtime/pi/bin/lib/worktree-links.sh +25 -0
  120. package/runtime/pi/bin/pi-branch-policy.mjs +138 -1
  121. package/runtime/pi/bin/pi-product-workflows.mjs +35 -7
  122. package/runtime/pi/bin/pi-render-spec.mjs +52 -26
  123. package/runtime/pi/bin/session-autosave.sh +7 -1
  124. package/runtime/pi/bin/settings.sh +1 -1
  125. package/runtime/pi/manifest.json +10 -1
  126. package/runtime/prime/.claude/context/notification-consent.md +66 -0
  127. package/runtime/prime/.codex/config.toml +20 -0
  128. package/runtime/prime/.codex/hooks/branch-guard.js +461 -0
  129. package/runtime/prime/.codex/hooks/search-hint.js +67 -0
  130. package/runtime/prime/.codex/hooks.json +30 -0
  131. package/runtime/prime/.codex/skills/activity/SKILL.md +82 -0
  132. package/runtime/prime/.codex/skills/announce/SKILL.md +67 -0
  133. package/runtime/prime/.codex/skills/archive/SKILL.md +45 -0
  134. package/runtime/prime/.codex/skills/branch/SKILL.md +29 -0
  135. package/runtime/prime/.codex/skills/checkup/SKILL.md +45 -0
  136. package/runtime/prime/.codex/skills/commit/SKILL.md +29 -0
  137. package/runtime/prime/.codex/skills/contribute/SKILL.md +29 -0
  138. package/runtime/prime/.codex/skills/create-skill/SKILL.md +29 -0
  139. package/runtime/prime/.codex/skills/dark-mode/SKILL.md +29 -0
  140. package/runtime/prime/.codex/skills/dashboard/SKILL.md +59 -0
  141. package/runtime/prime/.codex/skills/deep-reflect/SKILL.md +109 -0
  142. package/runtime/prime/.codex/skills/delete-user/SKILL.md +29 -0
  143. package/runtime/prime/.codex/skills/emissary/SKILL.md +45 -0
  144. package/runtime/prime/.codex/skills/handoff/SKILL.md +166 -0
  145. package/runtime/prime/.codex/skills/harvest/QUESTION_PALETTE.md +76 -0
  146. package/runtime/prime/.codex/skills/harvest/SKILL.md +131 -0
  147. package/runtime/prime/.codex/skills/ingest/SKILL.md +29 -0
  148. package/runtime/prime/.codex/skills/ingest-corpus/SKILL.md +29 -0
  149. package/runtime/prime/.codex/skills/ingest-notion/SKILL.md +29 -0
  150. package/runtime/prime/.codex/skills/ingest-user-interview/SKILL.md +45 -0
  151. package/runtime/prime/.codex/skills/invite/SKILL.md +44 -0
  152. package/runtime/prime/.codex/skills/issue/SKILL.md +45 -0
  153. package/runtime/prime/.codex/skills/me/SKILL.md +29 -0
  154. package/runtime/prime/.codex/skills/meeting/SKILL.md +45 -0
  155. package/runtime/prime/.codex/skills/note/SKILL.md +29 -0
  156. package/runtime/prime/.codex/skills/notion-connect/SKILL.md +29 -0
  157. package/runtime/prime/.codex/skills/onboarding/SKILL.md +45 -0
  158. package/runtime/prime/.codex/skills/pr/SKILL.md +29 -0
  159. package/runtime/prime/.codex/skills/pull/SKILL.md +29 -0
  160. package/runtime/prime/.codex/skills/push/SKILL.md +29 -0
  161. package/runtime/prime/.codex/skills/quest/SKILL.md +80 -0
  162. package/runtime/prime/.codex/skills/reflect/SKILL.md +45 -0
  163. package/runtime/prime/.codex/skills/review-pr/SKILL.md +45 -0
  164. package/runtime/prime/.codex/skills/save/SKILL.md +127 -0
  165. package/runtime/prime/.codex/skills/slack-connect/SKILL.md +29 -0
  166. package/runtime/prime/.codex/skills/summon/SKILL.md +45 -0
  167. package/runtime/prime/.codex/skills/sync-repos/SKILL.md +29 -0
  168. package/runtime/prime/.codex/skills/teams-connect/SKILL.md +29 -0
  169. package/runtime/prime/.codex/skills/telegram-connect/SKILL.md +29 -0
  170. package/runtime/prime/.codex/skills/telemetry/SKILL.md +29 -0
  171. package/runtime/prime/.codex/skills/test/SKILL.md +45 -0
  172. package/runtime/prime/.codex/skills/the-spiral/SKILL.md +84 -0
  173. package/runtime/prime/.codex/skills/tui-design/SKILL.md +45 -0
  174. package/runtime/prime/.codex/skills/tutorial/SKILL.md +45 -0
  175. package/runtime/prime/.codex/skills/update/SKILL.md +29 -0
  176. package/runtime/prime/.codex/skills/view/SKILL.md +156 -0
  177. package/runtime/prime/.codex/skills/view/agents/openai.yaml +4 -0
  178. package/runtime/prime/.codex/skills/view/assets/compose-scaffold.html +221 -0
  179. package/runtime/prime/.codex/skills/visual-explain/SKILL.md +45 -0
  180. package/runtime/prime/.codex/skills/wrap/SKILL.md +58 -0
  181. package/runtime/prime/.codex/spec-manifest.json +132 -0
  182. package/runtime/prime/.prime/agent/APPEND_SYSTEM.md +416 -0
  183. package/runtime/prime/.prime/agent/extensions/egregore.ts +460 -0
  184. package/runtime/prime/.prime/agent/settings.json +4 -0
  185. package/runtime/prime/.prime/agent/skills/egregore/SKILL.md +34 -0
  186. package/runtime/prime/.prime/agent/skills/egregore/pyproject.toml +13 -0
  187. package/runtime/prime/.prime/agent/skills/egregore/src/egregore/__init__.py +156 -0
  188. package/runtime/prime/.prime/agent/spec-manifest.json +12 -0
  189. package/runtime/prime/AGENTS.md +488 -0
  190. package/runtime/prime/bin/activity-data.sh +238 -0
  191. package/runtime/prime/bin/agent.sh +787 -0
  192. package/runtime/prime/bin/attendant.sh +707 -0
  193. package/runtime/prime/bin/autosave.sh +112 -0
  194. package/runtime/prime/bin/capture-reconcile.sh +69 -0
  195. package/runtime/prime/bin/capture-run.sh +341 -0
  196. package/runtime/prime/bin/codex-install-skills.sh +78 -0
  197. package/runtime/prime/bin/codex-render-spec.mjs +435 -0
  198. package/runtime/prime/bin/codex-session-start.sh +265 -0
  199. package/runtime/prime/bin/codex-skill-render.mjs +347 -0
  200. package/runtime/prime/bin/codex-sync-skills.sh +237 -0
  201. package/runtime/prime/bin/connect-refresh.sh +33 -0
  202. package/runtime/prime/bin/dashboard-data.sh +178 -0
  203. package/runtime/prime/bin/graph-batch.sh +79 -0
  204. package/runtime/prime/bin/graph-op.sh +1102 -0
  205. package/runtime/prime/bin/graph-wal.sh +229 -0
  206. package/runtime/prime/bin/graph.sh +223 -0
  207. package/runtime/prime/bin/handoff-pr-backfill.sh +86 -0
  208. package/runtime/prime/bin/handoff-run.sh +676 -0
  209. package/runtime/prime/bin/handoff-save-egregore.sh +224 -0
  210. package/runtime/prime/bin/index-handoff.sh +322 -0
  211. package/runtime/prime/bin/ingest-graph.sh +80 -0
  212. package/runtime/prime/bin/ingest-surface.html +495 -0
  213. package/runtime/prime/bin/ingest.py +1011 -0
  214. package/runtime/prime/bin/ingest.sh +279 -0
  215. package/runtime/prime/bin/ingest_graph.py +856 -0
  216. package/runtime/prime/bin/ingest_surface.py +544 -0
  217. package/runtime/prime/bin/lib/artifact-id.sh +46 -0
  218. package/runtime/prime/bin/lib/config.sh +116 -0
  219. package/runtime/prime/bin/lib/context.sh +621 -0
  220. package/runtime/prime/bin/lib/dashboard-artifact.sh +242 -0
  221. package/runtime/prime/bin/lib/ensure-develop.sh +183 -0
  222. package/runtime/prime/bin/lib/git-message.sh +37 -0
  223. package/runtime/prime/bin/lib/git-sync.sh +414 -0
  224. package/runtime/prime/bin/lib/greeting.sh +519 -0
  225. package/runtime/prime/bin/lib/hash.sh +13 -0
  226. package/runtime/prime/bin/lib/identity.sh +213 -0
  227. package/runtime/prime/bin/lib/metrics.sh +374 -0
  228. package/runtime/prime/bin/lib/noncode.sh +31 -0
  229. package/runtime/prime/bin/lib/permissions.sh +106 -0
  230. package/runtime/prime/bin/lib/time.sh +56 -0
  231. package/runtime/prime/bin/lib/worktree-links.sh +79 -0
  232. package/runtime/prime/bin/notify.sh +636 -0
  233. package/runtime/prime/bin/person.py +630 -0
  234. package/runtime/prime/bin/person.sh +448 -0
  235. package/runtime/prime/bin/pi-branch-policy.mjs +312 -0
  236. package/runtime/prime/bin/pi-product-workflows.mjs +394 -0
  237. package/runtime/prime/bin/prime-render-spec.mjs +147 -0
  238. package/runtime/prime/bin/prime-session-start.sh +24 -0
  239. package/runtime/prime/bin/publish-artifact.sh +271 -0
  240. package/runtime/prime/bin/publish-references.sh +144 -0
  241. package/runtime/prime/bin/repo-state.sh +96 -0
  242. package/runtime/prime/bin/search.sh +240 -0
  243. package/runtime/prime/bin/session-autosave.sh +215 -0
  244. package/runtime/prime/bin/session-log.sh +199 -0
  245. package/runtime/prime/bin/settings.sh +304 -0
  246. package/runtime/prime/bin/telemetry.sh +391 -0
  247. package/runtime/prime/bin/worktree-create.sh +128 -0
  248. package/runtime/prime/bin/worktree-remove.sh +50 -0
  249. package/runtime/prime/bin/worktree.sh +198 -0
  250. package/runtime/prime/manifest.json +181 -0
@@ -708,7 +708,8 @@ case "$OP" in
708
708
  fi
709
709
  bash "$GS" query "
710
710
  MATCH (s:Session)-[:HANDED_TO]->(p:Person)
711
- WHERE (
711
+ WHERE NOT coalesce(p.kind, '') IN ['identity_alias']
712
+ AND (
712
713
  toLower(coalesce(p.name, '')) = toLower(\$user)
713
714
  OR toLower(coalesce(p.github, '')) = toLower(\$user)
714
715
  OR toLower(coalesce(p.githubUsername, '')) = toLower(\$user)
@@ -718,13 +719,13 @@ case "$OP" in
718
719
  AND coalesce(s.handoffStatus, 'pending')
719
720
  IN ['pending','unread','read','claimed']
720
721
  WITH DISTINCT s
721
- OPTIONAL MATCH (s)-[:BY]->(author:Person)
722
- WITH s, author,
722
+ WITH s, head([(s)-[:BY]->(author:Person)
723
+ WHERE NOT coalesce(author.kind, '') IN ['identity_alias'] | author.name]) AS authorName,
723
724
  CASE WHEN s.date IS NULL THEN null
724
725
  ELSE duration.inDays(date(s.date), date()).days END AS ageDays
725
726
  RETURN s.id AS id,
726
727
  coalesce(s.topic, s.summary, s.branch) AS topic,
727
- coalesce(author.name, s.author, 'unknown') AS author,
728
+ coalesce(authorName, s.author, 'unknown') AS author,
728
729
  toString(s.date) AS date,
729
730
  coalesce(s.handoffStatus, 'pending') AS status,
730
731
  coalesce(s.handoffIntent, 'unclassified') AS intent,
@@ -21,10 +21,18 @@ if [ "$_MODE" = "local" ]; then
21
21
  exit 0
22
22
  fi
23
23
 
24
- # Load specific variables from .env if it exists (safe extraction, no arbitrary code execution)
25
- if [ -f "$SCRIPT_DIR/.env" ]; then
26
- EGREGORE_API_URL="${EGREGORE_API_URL:-$(grep '^EGREGORE_API_URL=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
27
- EGREGORE_API_KEY="${EGREGORE_API_KEY:-$(grep '^EGREGORE_API_KEY=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
24
+ # Load specific variables from .env if it exists (safe extraction, no arbitrary code execution).
25
+ # A worktree missing its .env symlink resolves through worktree-links.sh, which
26
+ # self-heals the link and falls back to the main checkout's .env.
27
+ _ENV_FILE="$SCRIPT_DIR/.env"
28
+ if [ ! -f "$_ENV_FILE" ] && [ -f "$SCRIPT_DIR/bin/lib/worktree-links.sh" ]; then
29
+ # shellcheck source=bin/lib/worktree-links.sh
30
+ source "$SCRIPT_DIR/bin/lib/worktree-links.sh" 2>/dev/null || true
31
+ _ENV_FILE="$(egregore_env_file "$SCRIPT_DIR" 2>/dev/null || true)"
32
+ fi
33
+ if [ -n "$_ENV_FILE" ] && [ -f "$_ENV_FILE" ]; then
34
+ EGREGORE_API_URL="${EGREGORE_API_URL:-$(grep '^EGREGORE_API_URL=' "$_ENV_FILE" 2>/dev/null | cut -d'=' -f2- || true)}"
35
+ EGREGORE_API_KEY="${EGREGORE_API_KEY:-$(grep '^EGREGORE_API_KEY=' "$_ENV_FILE" 2>/dev/null | cut -d'=' -f2- || true)}"
28
36
  fi
29
37
 
30
38
  # api_url comes from egregore.json (committed, non-secret)
@@ -168,8 +176,26 @@ if [ -n "$API_URL" ] && [ -n "$API_KEY" ]; then
168
176
  fi
169
177
  }
170
178
 
179
+ elif [ -n "$API_URL" ]; then
180
+ # === CONNECTED CONFIG, MISSING KEY ===
181
+ # api_url is configured, so the org expects a live graph, but no API key was
182
+ # found. Faking '{"results":[]}' here made a missing credential (e.g. a
183
+ # worktree without its .env symlink) indistinguishable from an empty graph —
184
+ # fail loudly instead. `test` keeps its offline JSON contract: dashboards,
185
+ # render-card, and startup-check consume it.
186
+ _missing_credentials() {
187
+ {
188
+ echo "Error: api_url is configured but EGREGORE_API_KEY is empty (looked in: ${_ENV_FILE:-$SCRIPT_DIR/.env})."
189
+ echo " If this is a worktree, its .env symlink may be missing. Run /env to configure."
190
+ } >&2
191
+ exit 1
192
+ }
193
+ run_query() { _missing_credentials; }
194
+ get_schema() { _missing_credentials; }
195
+ test_connection() { echo '{"status":"offline","reason":"no_api_key"}'; }
196
+
171
197
  else
172
- # === OFFLINE MODE: No API key — return empty results (OSS/local) ===
198
+ # === OFFLINE MODE: no api_url configured (OSS/unconfigured) ===
173
199
  run_query() { echo '{"results":[]}'; }
174
200
  get_schema() { echo '{}'; }
175
201
  test_connection() { echo '{"status":"offline","reason":"no_api_key"}'; }
@@ -77,7 +77,7 @@ if [ -d "$MEMORY_DIR" ]; then
77
77
  (
78
78
  cd "$MEMORY_DIR" || exit 0
79
79
  git add -A >/dev/null 2>&1
80
- git commit -m "Backfill PR numbers for handoff: $TOPIC" >/dev/null 2>&1 || exit 0
80
+ git commit -m "chore(handoff): backfill PR numbers for $TOPIC" >/dev/null 2>&1 || exit 0
81
81
  git pull --rebase origin main --quiet >/dev/null 2>&1
82
82
  git push origin main --quiet >/dev/null 2>&1
83
83
  ) || true
@@ -36,6 +36,11 @@ set -euo pipefail
36
36
  # 1 on unrecoverable failure (file write, memory push, bad args).
37
37
 
38
38
  SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
39
+ # shellcheck source=bin/lib/git-message.sh
40
+ . "$SCRIPT_DIR/bin/lib/git-message.sh" 2>/dev/null || true
41
+ type egregore_commit >/dev/null 2>&1 || egregore_commit() {
42
+ local gd="$1" m="$3"; shift 3; git -C "$gd" commit -m "$m" "$@"
43
+ }
39
44
  CONFIG="$SCRIPT_DIR/egregore.json"
40
45
 
41
46
  if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
@@ -326,12 +331,12 @@ PID_ARTIFACTS=$!
326
331
  git add "$REL_FILE" handoffs/index.md >/dev/null 2>&1
327
332
 
328
333
  if [ -n "$RECIPIENT" ]; then
329
- MSG_COMMIT="Handoff: ${TOPIC} (to ${RECIPIENT})"
334
+ MSG_COMMIT="chore(handoff): record ${TOPIC} (to ${RECIPIENT})"
330
335
  else
331
- MSG_COMMIT="Handoff: ${TOPIC}"
336
+ MSG_COMMIT="chore(handoff): record ${TOPIC}"
332
337
  fi
333
338
 
334
- git commit -m "$MSG_COMMIT" >/dev/null 2>&1 || true
339
+ egregore_commit . "$SCRIPT_DIR" "$MSG_COMMIT" >/dev/null 2>&1 || true
335
340
 
336
341
  STASHED=0
337
342
  if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
@@ -47,19 +47,16 @@ done
47
47
 
48
48
  case "$KIND" in
49
49
  autosave)
50
- MSG_PREFIX="Auto-save"
50
+ MSG_PREFIX="chore(autosave): save"
51
51
  PR_WHAT="Save session content captured by the opted-in autosave workflow."
52
52
  PR_WHY="This preserves unattended non-coding work without requiring an explicit /save."
53
53
  ;;
54
54
  *)
55
- MSG_PREFIX="Handoff"
55
+ MSG_PREFIX="chore(handoff): record"
56
56
  PR_WHAT="Save core-repository session work captured when the handoff was created."
57
57
  PR_WHY="The handoff records shared context immediately; this companion PR keeps the originating checkout's work from remaining only local."
58
58
  ;;
59
59
  esac
60
- PR_BODY=$(printf '## What\n\n- %s\n\n## Why\n\n%s Topic: `%s`.\n\n## Verification\n\nNot verified — background %s save; review code or configuration changes before merging.\n\n🤖 Saved via `bin/handoff-save-egregore.sh`\n' \
61
- "$PR_WHAT" "$PR_WHY" "$TOPIC" "$KIND")
62
-
63
60
  [ -n "$AUTHOR" ] || { echo "handoff-save-egregore: missing author" >&2; exit 1; }
64
61
 
65
62
  SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
@@ -88,6 +85,21 @@ BASE_REF="origin/$BASE_BRANCH"
88
85
  . "$SCRIPT_DIR/bin/lib/git-safe.sh" 2>/dev/null || true
89
86
  type git_add_guarded >/dev/null 2>&1 || git_add_guarded() { git add -A 2>/dev/null || true; }
90
87
 
88
+ # Shared commit/PR message mechanics (trailers + skeleton body shape).
89
+ # shellcheck source=bin/lib/git-message.sh
90
+ . "$SCRIPT_DIR/bin/lib/git-message.sh" 2>/dev/null || true
91
+ type egregore_commit >/dev/null 2>&1 || egregore_commit() {
92
+ local gd="$1" m="$3"; shift 3; git -C "$gd" commit -m "$m" "$@"
93
+ }
94
+ type egregore_pr_skeleton >/dev/null 2>&1 || egregore_pr_skeleton() {
95
+ printf '## What\n%s\n\n## Why\n%s\n\n## Verification\n%s\n\n%s\n' "$1" "$2" "$3" "$4"
96
+ }
97
+ PR_BODY=$(egregore_pr_skeleton \
98
+ "- $PR_WHAT" \
99
+ "$PR_WHY Topic: \`$TOPIC\`." \
100
+ "Not verified — background $KIND save; review code or configuration changes before merging." \
101
+ "🤖 Saved via \`bin/handoff-save-egregore.sh\`")
102
+
91
103
  # Shared non-coding classifier (auto-merge policy lives in one place).
92
104
  # shellcheck source=bin/lib/noncode.sh
93
105
  . "$SCRIPT_DIR/bin/lib/noncode.sh" 2>/dev/null || true
@@ -131,7 +143,7 @@ fi
131
143
  # --- Commit whatever is uncommitted --------------------------------------
132
144
  if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
133
145
  git_add_guarded
134
- git commit -m "${MSG_PREFIX}: ${TOPIC}" --quiet 2>/dev/null || true
146
+ egregore_commit . "$REPO_DIR" "${MSG_PREFIX} ${TOPIC}" --quiet 2>/dev/null || true
135
147
  fi
136
148
 
137
149
  # --- Rebase onto the configured base, fall back to merge on conflicts ----
@@ -182,7 +194,7 @@ else
182
194
  PR_URL=$(gh pr create \
183
195
  --base "$BASE_BRANCH" \
184
196
  --head "$CURRENT_BRANCH" \
185
- --title "${MSG_PREFIX}: ${TOPIC}" \
197
+ --title "${MSG_PREFIX} ${TOPIC}" \
186
198
  --body "$PR_BODY" \
187
199
  2>/dev/null || echo "")
188
200
  PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$' || echo "")
@@ -23,10 +23,12 @@ from datetime import datetime, timezone
23
23
  from pathlib import Path
24
24
  from xml.etree import ElementTree
25
25
 
26
- sys.path.insert(0, str(Path(__file__).resolve().parent))
26
+ sys.path.append(str(Path(__file__).resolve().parent))
27
+ import catalogue as catalogue_module # noqa: E402
27
28
  import doc_segment # noqa: E402 (same-directory module, path set above)
28
29
  import pdf_extract # noqa: E402
29
30
  import source_profile # noqa: E402
31
+ import text_repair # noqa: E402
30
32
 
31
33
 
32
34
  ROOT = Path(__file__).resolve().parent.parent
@@ -152,6 +154,65 @@ def chunks(text: str, limit: int = 7000) -> list[str]:
152
154
  return result
153
155
 
154
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
+
187
+ def find_publisher_profile() -> Path | None:
188
+ """Where this instance's publisher profile is, if it has one.
189
+
190
+ The profile is what assigns a zone and a trust tier to each document, and
191
+ zone is a hard retrieval boundary. Ingesting without it produces a corpus
192
+ where every document is unplaced, so every later question is refused — a
193
+ failure that looks like an empty archive rather than a missing setting.
194
+
195
+ So this looks in the places a profile actually lives rather than in one
196
+ fixed path. An instance keeps its ontology under memory/knowledge/tools/,
197
+ not beside its intake metadata, and requiring an environment variable to
198
+ bridge that gap means the common case is the broken one.
199
+ """
200
+ override = os.environ.get("EGREGORE_PUBLISHER_PROFILE")
201
+ if override:
202
+ path = Path(override)
203
+ return path if path.is_file() else None
204
+
205
+ candidates = [metadata_root() / "publisher-profile.json"]
206
+ memory = Path("memory")
207
+ if memory.is_dir():
208
+ candidates.extend(sorted(memory.glob("knowledge/tools/*/publisher-profile.json")))
209
+ candidates.append(memory / "publisher-profile.json")
210
+ for path in candidates:
211
+ if path.is_file():
212
+ return path
213
+ return None
214
+
215
+
155
216
  def publisher_profile() -> dict | None:
156
217
  """Load the instance's publisher profile, if one is configured.
157
218
 
@@ -159,9 +220,8 @@ def publisher_profile() -> dict | None:
159
220
  operator set: deriving a region is an inference, and an inference must not
160
221
  silently replace a stated fact.
161
222
  """
162
- override = os.environ.get("EGREGORE_PUBLISHER_PROFILE")
163
- path = Path(override) if override else (metadata_root() / "publisher-profile.json")
164
- if not path.is_file():
223
+ path = find_publisher_profile()
224
+ if path is None:
165
225
  return None
166
226
  try:
167
227
  return json.loads(path.read_text(encoding="utf-8"))
@@ -223,8 +283,31 @@ def add_path(args: argparse.Namespace) -> dict:
223
283
  existing_manifest = load_manifest(directory)
224
284
  previous = {d["id"]: d for d in existing_manifest.get("documents", [])}
225
285
  now = datetime.now(timezone.utc).isoformat()
286
+ profile_path = find_publisher_profile()
226
287
  profile = publisher_profile()
288
+ if profile is None:
289
+ # Said once, plainly, because the alternative is a corpus that ingests
290
+ # cleanly and then refuses every question with no indication why.
291
+ print("ingest: no publisher-profile.json found — documents will carry no zone "
292
+ "or trust tier, and a zoneless document can never be served. Put one "
293
+ "under memory/knowledge/tools/<ontology>/ or set "
294
+ "EGREGORE_PUBLISHER_PROFILE.", file=sys.stderr)
295
+ catalogue = catalogue_module.load(
296
+ profile or {}, base=profile_path.parent if profile_path else None
297
+ )
298
+ repair_map = text_repair.load_mapping(profile or {})
227
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", [])
228
311
  errors: list[dict] = []
229
312
  seen_paths: set[str] = set()
230
313
 
@@ -252,6 +335,15 @@ def add_path(args: argparse.Namespace) -> dict:
252
335
  extraction = {"kind": "local", "extractor": extractor} if extractor else None
253
336
  if extraction and detail:
254
337
  extraction["pages"] = detail
338
+ # Fix characters the document's own font encoding got wrong, before
339
+ # anything downstream indexes or quotes them.
340
+ repaired = None
341
+ if text and repair_map:
342
+ outcome = text_repair.repair(text, repair_map)
343
+ if outcome.replaced:
344
+ text = outcome.text
345
+ repaired = outcome.as_report()
346
+
255
347
  if error or text is None or not text.strip():
256
348
  skipped += 1
257
349
  errors.append({"path": relative, "error": error or "empty extracted text"})
@@ -283,6 +375,9 @@ def add_path(args: argparse.Namespace) -> dict:
283
375
  continue
284
376
  # Segment before chunking so no passage straddles two works. A document
285
377
  # holding a single work yields one segment, and chunking is unchanged.
378
+ if repaired and extraction:
379
+ extraction["repair"] = repaired
380
+
286
381
  segments = doc_segment.segment(text)
287
382
  document_chunks: list[tuple[str, doc_segment.Segment]] = []
288
383
  for seg in segments:
@@ -314,12 +409,37 @@ def add_path(args: argparse.Namespace) -> dict:
314
409
  # is absent filters nothing. A derived value is marked as derived: anything
315
410
  # downstream can tell it apart from one a person stated, and weigh it by the
316
411
  # confidence recorded alongside. An operator's value is never overwritten.
412
+ # Evidence strongest first: what the operator stated, then what the
413
+ # organisation's own catalogue records, then what can be inferred from
414
+ # the document's origin. A weaker source never overwrites a stronger one.
317
415
  provenance = None
318
416
  doc_boundaries = dict(boundaries)
417
+ catalogued = catalogue_module.lookup(catalogue, relative)
418
+
419
+ if catalogued:
420
+ provenance = {"from_catalogue": {k: v for k, v in catalogued.items()}}
421
+ for field_name, value in catalogued.items():
422
+ if field_name in ("zone", "crop", "topic", "customer", "jurisdiction", "project"):
423
+ if field_name not in doc_boundaries:
424
+ doc_boundaries[field_name] = value
425
+ provenance["applied_boundary"] = field_name
426
+ if catalogued.get("year"):
427
+ structure.setdefault("publication_year", catalogued["year"])
428
+ structure["year_source"] = structure.get("year_source") or "catalogue"
429
+ if catalogued.get("tier"):
430
+ provenance["tier"] = catalogued["tier"]
431
+ provenance["confidence"] = "high"
432
+
319
433
  if profile:
320
- 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)
321
438
  if resolved.publisher_class or resolved.notes:
322
- provenance = resolved.as_dict()
439
+ inferred = resolved.as_dict()
440
+ # Keep the inference visible even when the catalogue answered,
441
+ # so a disagreement between the two is inspectable.
442
+ provenance = {**inferred, **(provenance or {})} if provenance else inferred
323
443
  key = source_profile.boundary_key(profile)
324
444
  if key and resolved.zone and key not in doc_boundaries:
325
445
  doc_boundaries[key] = resolved.zone
@@ -362,12 +482,18 @@ def add_path(args: argparse.Namespace) -> dict:
362
482
  "status": "unverified",
363
483
  "ingested_at": now,
364
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
365
492
  if existing and existing.get("content_hash") == content_hash:
366
493
  metadata_updated += 1
367
494
  else:
368
495
  ingested += 1
369
496
 
370
- tombstones = existing_manifest.get("tombstones", [])
371
497
  if args.prune:
372
498
  if not input_path.is_dir():
373
499
  raise ValueError("--prune requires a directory snapshot")
@@ -389,11 +515,23 @@ def add_path(args: argparse.Namespace) -> dict:
389
515
  # Current documents always win over historical deletion records.
390
516
  tombstones = [row for row in tombstones if row["id"] not in previous]
391
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
+ """
392
530
  source_record = {
393
531
  "id": source_id,
394
532
  "org": org,
395
- "name": args.name or source_id,
396
- "kind": args.kind,
533
+ "name": getattr(args, "name", None) or source_id,
534
+ "kind": getattr(args, "kind", None),
397
535
  "boundaries": boundaries,
398
536
  "root_hint": base.name,
399
537
  "updated_at": now,
@@ -410,7 +548,6 @@ def add_path(args: argparse.Namespace) -> dict:
410
548
  "errors": errors,
411
549
  },
412
550
  )
413
- 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)}
414
551
 
415
552
 
416
553
  def cmd_add(args: argparse.Namespace) -> int:
@@ -418,6 +555,191 @@ def cmd_add(args: argparse.Namespace) -> int:
418
555
  return 0
419
556
 
420
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
+
421
743
  def selection_relative_path(value: object) -> str:
422
744
  if not isinstance(value, str) or not value or "\x00" in value or "\\" in value:
423
745
  raise ValueError("selection contains an invalid relative path")
@@ -643,6 +965,15 @@ def parser() -> argparse.ArgumentParser:
643
965
  add.add_argument("--boundary", action="append", default=[])
644
966
  add.add_argument("--prune", action="store_true", help="tombstone files absent from this authoritative directory snapshot")
645
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)
646
977
  add_selection = sub.add_parser("add-selection")
647
978
  add_selection.add_argument("manifest")
648
979
  add_selection.set_defaults(func=cmd_add_selection)