create-egregore 0.20.0 → 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 (147) hide show
  1. package/assets/egregore-launcher.js +25 -3
  2. package/lib/setup.js +21 -2
  3. package/package.json +1 -1
  4. package/runtime/codex/.codex/config.toml +3 -0
  5. package/runtime/codex/.codex/skills/archive/SKILL.md +1 -1
  6. package/runtime/codex/.codex/skills/branch/SKILL.md +1 -1
  7. package/runtime/codex/.codex/skills/checkup/SKILL.md +1 -1
  8. package/runtime/codex/.codex/skills/commit/SKILL.md +1 -1
  9. package/runtime/codex/.codex/skills/contribute/SKILL.md +1 -1
  10. package/runtime/codex/.codex/skills/delete-user/SKILL.md +1 -1
  11. package/runtime/codex/.codex/skills/ingest/SKILL.md +1 -1
  12. package/runtime/codex/.codex/skills/ingest-corpus/SKILL.md +29 -0
  13. package/runtime/codex/.codex/skills/ingest-notion/SKILL.md +1 -1
  14. package/runtime/codex/.codex/skills/ingest-user-interview/SKILL.md +1 -1
  15. package/runtime/codex/.codex/skills/issue/SKILL.md +1 -1
  16. package/runtime/codex/.codex/skills/me/SKILL.md +1 -1
  17. package/runtime/codex/.codex/skills/meeting/SKILL.md +1 -1
  18. package/runtime/codex/.codex/skills/note/SKILL.md +1 -1
  19. package/runtime/codex/.codex/skills/notion-connect/SKILL.md +1 -1
  20. package/runtime/codex/.codex/skills/onboarding/SKILL.md +1 -1
  21. package/runtime/codex/.codex/skills/pr/SKILL.md +1 -1
  22. package/runtime/codex/.codex/skills/pull/SKILL.md +1 -1
  23. package/runtime/codex/.codex/skills/push/SKILL.md +1 -1
  24. package/runtime/codex/.codex/skills/reflect/SKILL.md +1 -1
  25. package/runtime/codex/.codex/skills/review-pr/SKILL.md +1 -1
  26. package/runtime/codex/.codex/skills/save/SKILL.md +3 -1
  27. package/runtime/codex/.codex/skills/slack-connect/SKILL.md +1 -1
  28. package/runtime/codex/.codex/skills/summon/SKILL.md +1 -1
  29. package/runtime/codex/.codex/skills/sync-repos/SKILL.md +1 -1
  30. package/runtime/codex/.codex/skills/teams-connect/SKILL.md +1 -1
  31. package/runtime/codex/.codex/skills/telegram-connect/SKILL.md +1 -1
  32. package/runtime/codex/.codex/skills/telemetry/SKILL.md +1 -1
  33. package/runtime/codex/.codex/skills/test/SKILL.md +1 -1
  34. package/runtime/codex/.codex/skills/tutorial/SKILL.md +1 -1
  35. package/runtime/codex/.codex/skills/update/SKILL.md +1 -1
  36. package/runtime/codex/.codex/spec-manifest.json +3 -3
  37. package/runtime/codex/AGENTS.md +30 -31
  38. package/runtime/codex/bin/agent.sh +17 -5
  39. package/runtime/codex/bin/codex-render-spec.mjs +27 -32
  40. package/runtime/codex/bin/codex-sync-skills.sh +6 -1
  41. package/runtime/codex/bin/handoff-pr-backfill.sh +1 -1
  42. package/runtime/codex/bin/handoff-run.sh +8 -3
  43. package/runtime/codex/bin/handoff-save-egregore.sh +19 -7
  44. package/runtime/codex/bin/ingest.py +260 -5
  45. package/runtime/codex/bin/lib/git-message.sh +37 -0
  46. package/runtime/codex/bin/lib/git-sync.sh +10 -2
  47. package/runtime/codex/bin/session-autosave.sh +7 -1
  48. package/runtime/codex/bin/settings.sh +1 -1
  49. package/runtime/codex/manifest.json +4 -1
  50. package/runtime/pi/.codex/config.toml +3 -0
  51. package/runtime/pi/.codex/skills/archive/SKILL.md +1 -1
  52. package/runtime/pi/.codex/skills/branch/SKILL.md +1 -1
  53. package/runtime/pi/.codex/skills/checkup/SKILL.md +1 -1
  54. package/runtime/pi/.codex/skills/commit/SKILL.md +1 -1
  55. package/runtime/pi/.codex/skills/contribute/SKILL.md +1 -1
  56. package/runtime/pi/.codex/skills/delete-user/SKILL.md +1 -1
  57. package/runtime/pi/.codex/skills/ingest/SKILL.md +1 -1
  58. package/runtime/pi/.codex/skills/ingest-corpus/SKILL.md +29 -0
  59. package/runtime/pi/.codex/skills/ingest-notion/SKILL.md +1 -1
  60. package/runtime/pi/.codex/skills/ingest-user-interview/SKILL.md +1 -1
  61. package/runtime/pi/.codex/skills/issue/SKILL.md +1 -1
  62. package/runtime/pi/.codex/skills/me/SKILL.md +1 -1
  63. package/runtime/pi/.codex/skills/meeting/SKILL.md +1 -1
  64. package/runtime/pi/.codex/skills/note/SKILL.md +1 -1
  65. package/runtime/pi/.codex/skills/notion-connect/SKILL.md +1 -1
  66. package/runtime/pi/.codex/skills/onboarding/SKILL.md +1 -1
  67. package/runtime/pi/.codex/skills/pr/SKILL.md +1 -1
  68. package/runtime/pi/.codex/skills/pull/SKILL.md +1 -1
  69. package/runtime/pi/.codex/skills/push/SKILL.md +1 -1
  70. package/runtime/pi/.codex/skills/reflect/SKILL.md +1 -1
  71. package/runtime/pi/.codex/skills/review-pr/SKILL.md +1 -1
  72. package/runtime/pi/.codex/skills/save/SKILL.md +3 -1
  73. package/runtime/pi/.codex/skills/slack-connect/SKILL.md +1 -1
  74. package/runtime/pi/.codex/skills/summon/SKILL.md +1 -1
  75. package/runtime/pi/.codex/skills/sync-repos/SKILL.md +1 -1
  76. package/runtime/pi/.codex/skills/teams-connect/SKILL.md +1 -1
  77. package/runtime/pi/.codex/skills/telegram-connect/SKILL.md +1 -1
  78. package/runtime/pi/.codex/skills/telemetry/SKILL.md +1 -1
  79. package/runtime/pi/.codex/skills/test/SKILL.md +1 -1
  80. package/runtime/pi/.codex/skills/tutorial/SKILL.md +1 -1
  81. package/runtime/pi/.codex/skills/update/SKILL.md +1 -1
  82. package/runtime/pi/.codex/spec-manifest.json +3 -3
  83. package/runtime/pi/.pi/APPEND_SYSTEM.md +54 -31
  84. package/runtime/pi/.pi/spec-manifest.json +2 -2
  85. package/runtime/pi/AGENTS.md +30 -31
  86. package/runtime/pi/bin/agent.sh +17 -5
  87. package/runtime/pi/bin/codex-render-spec.mjs +27 -32
  88. package/runtime/pi/bin/codex-sync-skills.sh +6 -1
  89. package/runtime/pi/bin/handoff-pr-backfill.sh +1 -1
  90. package/runtime/pi/bin/handoff-run.sh +8 -3
  91. package/runtime/pi/bin/handoff-save-egregore.sh +19 -7
  92. package/runtime/pi/bin/ingest.py +260 -5
  93. package/runtime/pi/bin/lib/git-message.sh +37 -0
  94. package/runtime/pi/bin/lib/git-sync.sh +10 -2
  95. package/runtime/pi/bin/pi-render-spec.mjs +52 -26
  96. package/runtime/pi/bin/session-autosave.sh +7 -1
  97. package/runtime/pi/bin/settings.sh +1 -1
  98. package/runtime/pi/manifest.json +4 -1
  99. package/runtime/prime/.codex/config.toml +3 -0
  100. package/runtime/prime/.codex/skills/archive/SKILL.md +1 -1
  101. package/runtime/prime/.codex/skills/branch/SKILL.md +1 -1
  102. package/runtime/prime/.codex/skills/checkup/SKILL.md +1 -1
  103. package/runtime/prime/.codex/skills/commit/SKILL.md +1 -1
  104. package/runtime/prime/.codex/skills/contribute/SKILL.md +1 -1
  105. package/runtime/prime/.codex/skills/delete-user/SKILL.md +1 -1
  106. package/runtime/prime/.codex/skills/ingest/SKILL.md +1 -1
  107. package/runtime/prime/.codex/skills/ingest-corpus/SKILL.md +29 -0
  108. package/runtime/prime/.codex/skills/ingest-notion/SKILL.md +1 -1
  109. package/runtime/prime/.codex/skills/ingest-user-interview/SKILL.md +1 -1
  110. package/runtime/prime/.codex/skills/issue/SKILL.md +1 -1
  111. package/runtime/prime/.codex/skills/me/SKILL.md +1 -1
  112. package/runtime/prime/.codex/skills/meeting/SKILL.md +1 -1
  113. package/runtime/prime/.codex/skills/note/SKILL.md +1 -1
  114. package/runtime/prime/.codex/skills/notion-connect/SKILL.md +1 -1
  115. package/runtime/prime/.codex/skills/onboarding/SKILL.md +1 -1
  116. package/runtime/prime/.codex/skills/pr/SKILL.md +1 -1
  117. package/runtime/prime/.codex/skills/pull/SKILL.md +1 -1
  118. package/runtime/prime/.codex/skills/push/SKILL.md +1 -1
  119. package/runtime/prime/.codex/skills/reflect/SKILL.md +1 -1
  120. package/runtime/prime/.codex/skills/review-pr/SKILL.md +1 -1
  121. package/runtime/prime/.codex/skills/save/SKILL.md +3 -1
  122. package/runtime/prime/.codex/skills/slack-connect/SKILL.md +1 -1
  123. package/runtime/prime/.codex/skills/summon/SKILL.md +1 -1
  124. package/runtime/prime/.codex/skills/sync-repos/SKILL.md +1 -1
  125. package/runtime/prime/.codex/skills/teams-connect/SKILL.md +1 -1
  126. package/runtime/prime/.codex/skills/telegram-connect/SKILL.md +1 -1
  127. package/runtime/prime/.codex/skills/telemetry/SKILL.md +1 -1
  128. package/runtime/prime/.codex/skills/test/SKILL.md +1 -1
  129. package/runtime/prime/.codex/skills/tutorial/SKILL.md +1 -1
  130. package/runtime/prime/.codex/skills/update/SKILL.md +1 -1
  131. package/runtime/prime/.codex/spec-manifest.json +3 -3
  132. package/runtime/prime/.prime/agent/APPEND_SYSTEM.md +54 -31
  133. package/runtime/prime/.prime/agent/spec-manifest.json +2 -2
  134. package/runtime/prime/AGENTS.md +30 -31
  135. package/runtime/prime/bin/agent.sh +17 -5
  136. package/runtime/prime/bin/codex-render-spec.mjs +27 -32
  137. package/runtime/prime/bin/codex-sync-skills.sh +6 -1
  138. package/runtime/prime/bin/handoff-pr-backfill.sh +1 -1
  139. package/runtime/prime/bin/handoff-run.sh +8 -3
  140. package/runtime/prime/bin/handoff-save-egregore.sh +19 -7
  141. package/runtime/prime/bin/ingest.py +260 -5
  142. package/runtime/prime/bin/lib/git-message.sh +37 -0
  143. package/runtime/prime/bin/lib/git-sync.sh +10 -2
  144. package/runtime/prime/bin/prime-render-spec.mjs +51 -27
  145. package/runtime/prime/bin/session-autosave.sh +7 -1
  146. package/runtime/prime/bin/settings.sh +1 -1
  147. package/runtime/prime/manifest.json +4 -1
@@ -23,6 +23,15 @@ if [ -f "$SCRIPT_DIR/bin/lib/config.sh" ]; then
23
23
  # shellcheck source=bin/lib/config.sh
24
24
  source "$SCRIPT_DIR/bin/lib/config.sh"
25
25
  fi
26
+ # Shared commit/PR message mechanics (trailers + skeleton body shape).
27
+ # shellcheck source=bin/lib/git-message.sh
28
+ . "$SCRIPT_DIR/bin/lib/git-message.sh" 2>/dev/null || true
29
+ type egregore_commit >/dev/null 2>&1 || egregore_commit() {
30
+ local gd="$1" m="$3"; shift 3; git -C "$gd" commit -m "$m" "$@"
31
+ }
32
+ type egregore_pr_skeleton >/dev/null 2>&1 || egregore_pr_skeleton() {
33
+ printf '## What\n%s\n\n## Why\n%s\n\n## Verification\n%s\n\n%s\n' "$1" "$2" "$3" "$4"
34
+ }
26
35
 
27
36
  usage() {
28
37
  cat <<'EOF'
@@ -360,8 +369,11 @@ build_pr_body() {
360
369
  commits="$(git -C "$SCRIPT_DIR" log --pretty='- %s' "origin/$base..HEAD" 2>/dev/null | head -8)"
361
370
  [ -n "$commits" ] || commits="- (commit list unavailable — see the Files tab)"
362
371
  stat="$(git -C "$SCRIPT_DIR" diff --shortstat "origin/$base...HEAD" 2>/dev/null | sed 's/^ *//')"
363
- printf '## What\n%s\n\n## Why\nShell-agent session save — topic: %s. Body auto-generated by bin/agent.sh; pass --pr-body for a hand-written description.\n\n## Verification\nNot verified — auto-generated skeleton; review the diff before merging.%s\n\n🤖 Saved via bin/agent.sh\n' \
364
- "$commits" "$topic" "${stat:+ ($stat)}"
372
+ egregore_pr_skeleton \
373
+ "$commits" \
374
+ "Shell-agent session save — topic: $topic. Body auto-generated by bin/agent.sh; pass --pr-body for a hand-written description." \
375
+ "Not verified — auto-generated skeleton; review the diff before merging.${stat:+ ($stat)}" \
376
+ "🤖 Saved via bin/agent.sh"
365
377
  }
366
378
 
367
379
  cmd_save() {
@@ -379,7 +391,7 @@ cmd_save() {
379
391
  done
380
392
 
381
393
  [ -n "$topic" ] || topic="portable save"
382
- [ -n "$message" ] || message="Save: $topic"
394
+ [ -n "$message" ] || message="chore(save): $topic"
383
395
  base="$(resolve_base_branch)" || die "save stopped before Git changes"
384
396
 
385
397
  if [ -d "$MEMORY_DIR" ]; then
@@ -392,7 +404,7 @@ cmd_save() {
392
404
  branch="$(ensure_working_branch "$topic")"
393
405
  git -C "$SCRIPT_DIR" add -A >/dev/null 2>&1 || die "git add failed"
394
406
  if ! git -C "$SCRIPT_DIR" diff --cached --quiet 2>/dev/null; then
395
- git -C "$SCRIPT_DIR" commit -m "$message" --quiet || die "git commit failed"
407
+ egregore_commit "$SCRIPT_DIR" "$SCRIPT_DIR" "$message" --quiet || die "git commit failed"
396
408
  committed="true"
397
409
  fi
398
410
  fi
@@ -415,7 +427,7 @@ cmd_save() {
415
427
  pr_body="$(cat "$pr_body_file")"
416
428
  fi
417
429
  [ -n "$pr_body" ] || pr_body="$(build_pr_body "$topic")"
418
- pr_url="$(gh pr create --base "$base" --head "$branch" --title "$message" --body "$pr_body" 2>/dev/null || true)"
430
+ pr_url="$(gh pr create --base "$base" --head "$branch" --title "${message%%$'\n'*}" --body "$pr_body" 2>/dev/null || true)"
419
431
  fi
420
432
  fi
421
433
 
@@ -179,26 +179,17 @@ function transformTokens(body) {
179
179
 
180
180
  const INTRO_BODY = `You are a collaborator inside Egregore — a shared intelligence layer for organizations using AI coding agents. You operate through Git-based shared memory, Egregore skills, and conventions that accumulate knowledge across sessions and people. You are not a tool. You are a participant.
181
181
 
182
- This block is the Codex-native Egregore behavioral spec, rendered from CLAUDE.md (the source of truth for egregore behavior) by \`bin/codex-render-spec.mjs\`. The per-section adaptation manifest is \`.codex/spec-manifest.json\`. The thin agent protocol above this block applies to every shell-capable agent; this block is the full behavioral contract for Codex sessions.`;
182
+ This block is the Codex-native Egregore behavioral spec, rendered from CLAUDE.md by \`bin/codex-render-spec.mjs\`; the per-section manifest is \`.codex/spec-manifest.json\`. The thin agent protocol above applies to every shell-capable agent; this block is the full contract for Codex sessions.`;
183
183
 
184
- const ON_LAUNCH_BODY = `The \`egregore\` launcher renders the Egregore startup card (identity, team momentum, and pending work) via \`bin/codex-session-start.sh\` before Codex starts, and installs project skills. 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, run \`bash bin/codex-session-start.sh --card\`.`;
184
+ const ON_LAUNCH_BODY = `The \`egregore\` launcher renders the startup card (identity, momentum, pending work) via \`bin/codex-session-start.sh\` before Codex starts and installs project skills. Do not rerun or 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. Re-show with \`bash bin/codex-session-start.sh --card\`.`;
185
185
 
186
186
  const AFTER_GREETING_BODY = `**Mandatory behavioral rule.** When the user describes work, your **first action** — before reading files, exploring code, or anything else — is to get onto a working branch:
187
187
 
188
- The integration branch is \`develop\` by default. When top-level
189
- \`egregore.json.base_branch\` is set, that configured branch replaces
190
- \`develop\` as the branch point, rebase target, PR base, and protected
191
- integration branch.
188
+ The integration branch is \`develop\` unless top-level \`egregore.json.base_branch\` sets another; the configured branch is then the branch point, rebase target, PR base, and protected branch everywhere below.
192
189
 
193
190
  1. Derive a topic slug from what the user said (kebab-case, 2–4 words)
194
191
  2. Run \`bin/agent.sh branch --topic "<topic>"\` — it resolves the configured base and creates a work branch from \`origin/{base}\` in a task worktree (\`dev/{author}/{slug}\`, or \`feature/{slug}\` / \`bugfix/{slug}\` when the topic reads as a feature or fix). Continue all file work from the printed path.
195
- 3. Give a value-first workspace receipt. Do not lead with Git terminology:
196
-
197
- \`Your stable project is protected. I’m working in a separate workspace for **{topic}**, where changes stay isolated, reviewable, and reversible.\`
198
-
199
- Then expose the implementation as secondary detail:
200
-
201
- \`Workspace: {branch} (worktree).\`
192
+ 3. Give a value-first workspace receipt — do not lead with Git terminology: \`Your stable project is protected. I’m working in a separate workspace for **{topic}**, where changes stay isolated, reviewable, and reversible.\` Then, as secondary detail: \`Workspace: {branch} (worktree).\`
202
193
 
203
194
  **Fallback:** If \`bin/agent.sh branch\` fails, resolve \`{base}\` with
204
195
  \`_get_base_branch\`, then use
@@ -208,14 +199,20 @@ integration branch.
208
199
 
209
200
  ### Starting-work UX contract
210
201
 
211
- Before execution begins, make Egregore's useful structure legible without turning every task start into a tutorial:
202
+ Sequence: **intent → safe workspace → relevant context → consequential assumptions → execution**. Make Egregore's structure legible without turning task starts into a tutorial; keep technical identifiers secondary to user value.
203
+
204
+ - **Workspace** — the value-first receipt above, only for a new workspace or topic pivot; do not repeat it on the same branch.
205
+ - **Context** — when organizational retrieval materially informs the work, keep the required Egregore Retrieval Beat plus one compact receipt: \`↳ Context restored: {decision, handoff, or prior work} · {source/date}\`. Never claim context was restored when retrieval found nothing useful.
206
+ - **Assumptions** — surface only consequential ones: \`Assumption: {assumption} — based on {evidence}.\` Add a correction path when cheap; do not narrate obvious operational choices.
207
+ - **Transition** — state the outcome you are starting toward in one short sentence and begin.
208
+
209
+ ### Returning-work UX contract
212
210
 
213
- - **Workspace** — use the value-first receipt above for a newly created workspace or topic pivot. If already on the appropriate working branch, do not repeat it.
214
- - **Context** — when organizational retrieval materially informs the work, keep the required Egregore Retrieval Beat and then add one compact result receipt: \`↳ Context restored: {decision, handoff, or prior work} · {source/date}\`. Never claim context was restored when retrieval found nothing useful.
215
- - **Assumptions** — surface only consequential assumptions that could change the implementation. Use \`Assumption: {assumption} — based on {evidence}.\` Add a correction path when cheap; do not narrate obvious operational choices.
216
- - **Transition** — once workspace, context, and assumptions are settled, state the outcome you are starting toward in one short sentence and begin.
211
+ When the user continues the current branch's work or asks to resume — sequence: **continuation intent → prior workspace → restored context → open threads → resumed execution**:
217
212
 
218
- The intended sequence is: **intent → safe workspace → relevant context → consequential assumptions → execution**. Keep technical identifiers available but subordinate them to user value.
213
+ - Do not create a new workspace for the same topic. After confirming relevance, say: \`I found your previous work on **{topic}** and restored its workspace and context.\` Show \`Workspace: {branch} (worktree).\` secondarily.
214
+ - Retrieve relevant decisions, handoffs, or prior work; keep the Retrieval Beat and \`↳ Context restored:\` receipt. Never claim context was restored from a branch name alone.
215
+ - Name only unresolved items that could change the next move, state the next outcome briefly, and continue without replaying setup.
219
216
 
220
217
  ### Handoff claiming
221
218
 
@@ -224,7 +221,7 @@ If \`addressed_to_user\` handoffs exist and the user is picking one up, create t
224
221
  bash bin/graph-op.sh claim-handoff "$SESSION_ID" "$HANDOFF_SESSION_ID" 2>/dev/null &
225
222
  \`\`\`
226
223
 
227
- **Auto-checkout repos from handoff**: After claiming, check the \`addressed_rich\` context for \`repoState\`. If the handoff includes repo state (non-empty \`repoState\` array), check out the handoff's branches in each managed repo:
224
+ **Auto-checkout repos from handoff**: after claiming, if \`addressed_rich\` carries a non-empty \`repoState\`, check out each entry's branch in its managed repo:
228
225
 
229
226
  \`\`\`bash
230
227
  PARENT_DIR="$(cd .. && pwd)"
@@ -237,21 +234,19 @@ if [ -d "$REPO_DIR/.git" ] || [ -f "$REPO_DIR/.git" ]; then
237
234
  fi
238
235
  \`\`\`
239
236
 
240
- Report results: \`✓ Checked out {branch} in {repo1}, {repo2}\`. If a branch no longer exists (PR was merged): \`◐ {repo}: PR #{N} merged — on {base}\`. If \`repoState\` is absent or empty, skip auto-checkout silently.
237
+ Report \`✓ Checked out {branch} in {repo1}, {repo2}\`; for a merged-away branch, \`◐ {repo}: PR #{N} merged — on {base}\`. If \`repoState\` is absent or empty, skip silently.
241
238
 
242
- **Exceptions** — skip branching when:
243
- - The user explicitly created or named a branch themselves
244
- - Already on a working branch AND the user's intent continues the current branch's topic
239
+ **Exceptions** — skip branching when the user explicitly created or named a branch themselves, or the intent continues the current working branch's topic.
245
240
 
246
- **Topic pivot while on a working branch:** If the user describes work **unrelated** to the current branch's topic, treat it as a new topic and create a new branch (\`bin/agent.sh branch --topic "<new topic>"\`). Do NOT mix unrelated work on one branch.
241
+ **Topic pivot:** work **unrelated** to the current branch's topic gets a new branch (\`bin/agent.sh branch --topic "<new topic>"\`). Do NOT mix unrelated work on one branch.
247
242
 
248
243
  If still on the configured base branch after two messages, create a branch immediately from whatever context you have.
249
244
 
250
245
  ### Branch-guard protocol
251
246
 
252
- The \`.codex/hooks/branch-guard.js\` PreToolUse hook (enabled by the launcher via \`--enable hooks\`) protects project writes on the configured base branch as well as \`develop\`/\`main\`/\`master\`. Its block message is operational guidance, not a reason to interrupt the user with routine Git choices:
247
+ The \`.codex/hooks/branch-guard.js\` PreToolUse hook (launcher \`--enable hooks\`) protects project writes on the configured base plus \`develop\`/\`main\`/\`master\`. Its block message is operational guidance — do not interrupt the user with routine Git choices:
253
248
 
254
- - **Topic is clear** (the user said "fix X", "add Y feature") → run \`bin/agent.sh branch --topic "<topic>"\` automatically, continue in the printed worktree, and say one short sentence so the branch change is visible. Do not ask the user to approve routine branching.
249
+ - **Topic is clear** → run \`bin/agent.sh branch --topic "<topic>"\` automatically, continue in the printed worktree, and say one short sentence so the change is visible — never ask approval for routine branching.
255
250
  - **Topic is genuinely ambiguous** → ask only for the topic, using compact numbered options:
256
251
 
257
252
  \`\`\`text
@@ -264,7 +259,7 @@ The \`.codex/hooks/branch-guard.js\` PreToolUse hook (enabled by the launcher vi
264
259
  Wait for the reply, then branch.
265
260
  - **The user explicitly requested the protected branch** → that request is consent. Record it with \`echo '{branch}' > .egregore-branch-consent\`, then retry. Never create the marker merely to silence the hook.
266
261
 
267
- Memory, managed-repo, and runtime-state writes should bypass the project guard. If one triggers it, correct the operation target/context instead of asking for protected-branch consent.
262
+ Memory, managed-repo, and runtime-state writes bypass the project guard — if one triggers it, correct the target/context instead of asking for consent.
268
263
 
269
264
  If this Codex build does not support hooks, follow the same discipline as a standing instruction: never write or commit on the configured base, develop, main, or master.
270
265
 
@@ -272,7 +267,7 @@ If this Codex build does not support hooks, follow the same discipline as a stan
272
267
 
273
268
  If the startup card output contains \`onboarding_needed\`, invoke the \`$onboarding\` skill instead of greeting.`;
274
269
 
275
- const COMMAND_AWARENESS_PREPEND = `Codex reserves leading \`/\` for built-ins, so Egregore workflows are **skills**, not slash commands. Invoke them with the matching \`$name\` skill token or from natural language intent ("show activity", "make a handoff"). Hand-written native Codex skills: \`$activity\`, \`$handoff\`, \`$wrap\`, \`$announce\`, \`$harvest\`, \`$the-spiral\`, \`$dashboard\`, \`$deep-reflect\`, \`$quest\`, \`$invite\`, \`$ask\`, \`$save\`, \`$view\`, and \`$scroll\`. Every other workflow has a generated adapter skill of the same name. \`$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.`;
270
+ const COMMAND_AWARENESS_PREPEND = `Codex reserves leading \`/\` for built-ins, so Egregore workflows are **skills**, not slash commands. Invoke them with the matching \`$name\` skill token or from natural language intent ("show activity", "make a handoff"). Hand-written native Codex skills: \`$activity\`, \`$handoff\`, \`$wrap\`, \`$announce\`, \`$harvest\`, \`$the-spiral\`, \`$dashboard\`, \`$deep-reflect\`, \`$quest\`, \`$invite\`, \`$ask\`, \`$save\`, \`$view\`, \`$scroll\`; every other workflow has a generated adapter of the same name. \`$save\` is the user-facing abstraction for committing, pushing, opening or reusing PRs, and syncing memory — never make users manage git by hand.`;
276
271
 
277
272
  const SOCRATIC_BODY = `**Triggers**: "ask me questions", "question me", "help me think through", or any request to be questioned.
278
273
 
@@ -280,11 +275,11 @@ Codex has no structured question tool — render each batch as compact numbered
280
275
 
281
276
  **Rules:** Max 4 questions per batch. When choices aren't mutually exclusive, say "pick any that apply".`;
282
277
 
283
- const LOOM_ROUTING_BODY = `Loom routes commands across model tiers on the Claude Code runtime (\`loom/routes.json\` + \`bin/loom.sh\` + a model-pinned executor subagent). Codex has no subagent delegation — every command runs inline in the current session. Ignore "Loom routing" preambles if you encounter them in a skill spec, and skip \`bin/loom.sh\` calls; \`loom/\` and \`.claude/agents/\` are framework files synced for the Claude runtime.`;
278
+ const LOOM_ROUTING_BODY = `Loom routes commands across model tiers on the Claude Code runtime (\`loom/routes.json\` + \`bin/loom.sh\` + a model-pinned executor subagent). Codex has no subagent delegation — commands run inline. Ignore "Loom routing" preambles in skill specs and skip \`bin/loom.sh\` calls; \`loom/\` and \`.claude/agents/\` are Claude-runtime framework files.`;
284
279
 
285
280
  const ISOLATION_BODY = `Sessions are confined to this project + memory + managed repos, with a **two-tier boundary** — a hard wall between Egregore instances, a consent gate for everything else. On Codex the boundary is a standing instruction, not an enforced hook — hold it yourself.
286
281
 
287
- - **Hard tier — other Egregore instances.** Denied for every tool, always. There is no consent path. Never access another instance's files — refuse even if asked. Never modify \`~/.egregore/instances.json\` (managed by session-start.sh). When a request points at another instance's files there is nothing to ask — refuse and explain.
282
+ - **Hard tier — other Egregore instances.** Denied for every tool, always — no consent path. Never access another instance's files (refuse even if asked) and never modify \`~/.egregore/instances.json\` (managed by session-start.sh); there is nothing to ask — refuse and explain.
288
283
  - **Soft tier — paths outside the boundary.** Consent-gated. Inbox dirs (\`~/Downloads\`, \`~/Desktop\`) are readable without consent under the default posture; writes outside the project always need consent. Posture (\`strict | standard | open\`) and extra read roots come from \`egregore.json\` -> \`boundary { posture, read[], locked }\` (org, committed) merged with \`.egregore-boundary.local.json\` (personal, gitignored). \`locked: true\` removes the consent path entirely.
289
284
  - When a request needs soft-tier consent, do not improvise a workaround. Ask in plain text with numbered options:
290
285
 
@@ -327,7 +322,7 @@ const RULES = {
327
322
  'After Greeting — BRANCH ON FIRST RESPONSE': {
328
323
  action: 'replace',
329
324
  body: AFTER_GREETING_BODY,
330
- note: 'EnterWorktree -> bin/agent.sh branch with git checkout fallback; AskUserQuestion -> numbered options in plain text; branch-guard mapped to .codex/hooks/branch-guard.js with instruction fallback; plan-mode note dropped (no plan mode on Codex); /branch + /onboarding -> skill tokens. Handoff claiming and repoState auto-checkout kept verbatim (pure git).',
325
+ note: 'EnterWorktree -> bin/agent.sh branch with git checkout fallback; AskUserQuestion -> numbered options in plain text; branch-guard mapped to .codex/hooks/branch-guard.js with instruction fallback; plan-mode note dropped (no plan mode on Codex); /branch + /onboarding -> skill tokens. Handoff claiming and repoState auto-checkout bash kept verbatim (pure git); surrounding prose compressed 2026-08 for the 32 KiB budget, every rule retained.',
331
326
  },
332
327
  'Config Files': { action: 'keep', note: 'Kept verbatim — shell facts, runtime-neutral.' },
333
328
  'Knowledge Graph': { action: 'keep', note: 'Kept verbatim — bin/graph.sh is runtime-neutral.' },
@@ -101,7 +101,12 @@ skill_description() {
101
101
  in_fm && $0 == "---" { exit }
102
102
  in_fm && /^description:/ {
103
103
  sub(/^description:[[:space:]]*/, "")
104
- gsub(/^"|"$/, "")
104
+ # Block scalars (description: >) keep the text on continuation lines we
105
+ # do not read. Emit nothing so the caller falls back, which the skill-v1
106
+ # contract check reports, rather than publishing the indicator itself.
107
+ if ($0 ~ /^[>|][-+]?[[:space:]]*$/) { exit }
108
+ if ($0 ~ /^".*"$/) { gsub(/^"|"$/, "") }
109
+ else if ($0 ~ /^\047.*\047$/) { gsub(/^\047|\047$/, ""); gsub(/\047\047/, "\047") }
105
110
  print
106
111
  exit
107
112
  }
@@ -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 "")
@@ -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)