create-egregore 0.19.33 → 0.20.0

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 (171) hide show
  1. package/assets/egregore-launcher.js +118 -13
  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 +45 -3
  7. package/package.json +4 -3
  8. package/runtime/codex/.codex/skills/ingest-notion/SKILL.md +29 -0
  9. package/runtime/codex/.codex/skills/notion-connect/SKILL.md +29 -0
  10. package/runtime/codex/.codex/skills/teams-connect/SKILL.md +29 -0
  11. package/runtime/codex/.codex/spec-manifest.json +3 -3
  12. package/runtime/codex/AGENTS.md +11 -4
  13. package/runtime/codex/bin/activity-data.sh +4 -8
  14. package/runtime/codex/bin/attendant.sh +13 -0
  15. package/runtime/codex/bin/autosave.sh +27 -1
  16. package/runtime/codex/bin/codex-render-spec.mjs +6 -0
  17. package/runtime/codex/bin/codex-session-start.sh +3 -1
  18. package/runtime/codex/bin/graph-batch.sh +25 -6
  19. package/runtime/codex/bin/graph-op.sh +5 -4
  20. package/runtime/codex/bin/graph.sh +31 -5
  21. package/runtime/codex/bin/ingest.py +81 -5
  22. package/runtime/codex/bin/lib/context.sh +26 -1
  23. package/runtime/codex/bin/lib/greeting.sh +4 -1
  24. package/runtime/codex/bin/lib/worktree-links.sh +25 -0
  25. package/runtime/codex/manifest.json +7 -1
  26. package/runtime/pi/.codex/skills/ingest-notion/SKILL.md +29 -0
  27. package/runtime/pi/.codex/skills/notion-connect/SKILL.md +29 -0
  28. package/runtime/pi/.codex/skills/teams-connect/SKILL.md +29 -0
  29. package/runtime/pi/.codex/spec-manifest.json +3 -3
  30. package/runtime/pi/.pi/APPEND_SYSTEM.md +3 -2
  31. package/runtime/pi/.pi/extensions/egregore.ts +17 -6
  32. package/runtime/pi/.pi/spec-manifest.json +2 -2
  33. package/runtime/pi/AGENTS.md +11 -4
  34. package/runtime/pi/bin/activity-data.sh +4 -8
  35. package/runtime/pi/bin/attendant.sh +13 -0
  36. package/runtime/pi/bin/autosave.sh +27 -1
  37. package/runtime/pi/bin/codex-render-spec.mjs +6 -0
  38. package/runtime/pi/bin/codex-session-start.sh +3 -1
  39. package/runtime/pi/bin/graph-batch.sh +25 -6
  40. package/runtime/pi/bin/graph-op.sh +5 -4
  41. package/runtime/pi/bin/graph.sh +31 -5
  42. package/runtime/pi/bin/ingest.py +81 -5
  43. package/runtime/pi/bin/lib/context.sh +26 -1
  44. package/runtime/pi/bin/lib/greeting.sh +4 -1
  45. package/runtime/pi/bin/lib/worktree-links.sh +25 -0
  46. package/runtime/pi/bin/pi-branch-policy.mjs +138 -1
  47. package/runtime/pi/bin/pi-product-workflows.mjs +35 -7
  48. package/runtime/pi/manifest.json +7 -1
  49. package/runtime/prime/.claude/context/notification-consent.md +66 -0
  50. package/runtime/prime/.codex/config.toml +17 -0
  51. package/runtime/prime/.codex/hooks/branch-guard.js +461 -0
  52. package/runtime/prime/.codex/hooks/search-hint.js +67 -0
  53. package/runtime/prime/.codex/hooks.json +30 -0
  54. package/runtime/prime/.codex/skills/activity/SKILL.md +82 -0
  55. package/runtime/prime/.codex/skills/announce/SKILL.md +67 -0
  56. package/runtime/prime/.codex/skills/archive/SKILL.md +45 -0
  57. package/runtime/prime/.codex/skills/branch/SKILL.md +29 -0
  58. package/runtime/prime/.codex/skills/checkup/SKILL.md +45 -0
  59. package/runtime/prime/.codex/skills/commit/SKILL.md +29 -0
  60. package/runtime/prime/.codex/skills/contribute/SKILL.md +29 -0
  61. package/runtime/prime/.codex/skills/create-skill/SKILL.md +29 -0
  62. package/runtime/prime/.codex/skills/dark-mode/SKILL.md +29 -0
  63. package/runtime/prime/.codex/skills/dashboard/SKILL.md +59 -0
  64. package/runtime/prime/.codex/skills/deep-reflect/SKILL.md +109 -0
  65. package/runtime/prime/.codex/skills/delete-user/SKILL.md +29 -0
  66. package/runtime/prime/.codex/skills/emissary/SKILL.md +45 -0
  67. package/runtime/prime/.codex/skills/handoff/SKILL.md +166 -0
  68. package/runtime/prime/.codex/skills/harvest/QUESTION_PALETTE.md +76 -0
  69. package/runtime/prime/.codex/skills/harvest/SKILL.md +131 -0
  70. package/runtime/prime/.codex/skills/ingest/SKILL.md +29 -0
  71. package/runtime/prime/.codex/skills/ingest-notion/SKILL.md +29 -0
  72. package/runtime/prime/.codex/skills/ingest-user-interview/SKILL.md +45 -0
  73. package/runtime/prime/.codex/skills/invite/SKILL.md +44 -0
  74. package/runtime/prime/.codex/skills/issue/SKILL.md +45 -0
  75. package/runtime/prime/.codex/skills/me/SKILL.md +29 -0
  76. package/runtime/prime/.codex/skills/meeting/SKILL.md +45 -0
  77. package/runtime/prime/.codex/skills/note/SKILL.md +29 -0
  78. package/runtime/prime/.codex/skills/notion-connect/SKILL.md +29 -0
  79. package/runtime/prime/.codex/skills/onboarding/SKILL.md +45 -0
  80. package/runtime/prime/.codex/skills/pr/SKILL.md +29 -0
  81. package/runtime/prime/.codex/skills/pull/SKILL.md +29 -0
  82. package/runtime/prime/.codex/skills/push/SKILL.md +29 -0
  83. package/runtime/prime/.codex/skills/quest/SKILL.md +80 -0
  84. package/runtime/prime/.codex/skills/reflect/SKILL.md +45 -0
  85. package/runtime/prime/.codex/skills/review-pr/SKILL.md +45 -0
  86. package/runtime/prime/.codex/skills/save/SKILL.md +125 -0
  87. package/runtime/prime/.codex/skills/slack-connect/SKILL.md +29 -0
  88. package/runtime/prime/.codex/skills/summon/SKILL.md +45 -0
  89. package/runtime/prime/.codex/skills/sync-repos/SKILL.md +29 -0
  90. package/runtime/prime/.codex/skills/teams-connect/SKILL.md +29 -0
  91. package/runtime/prime/.codex/skills/telegram-connect/SKILL.md +29 -0
  92. package/runtime/prime/.codex/skills/telemetry/SKILL.md +29 -0
  93. package/runtime/prime/.codex/skills/test/SKILL.md +45 -0
  94. package/runtime/prime/.codex/skills/the-spiral/SKILL.md +84 -0
  95. package/runtime/prime/.codex/skills/tui-design/SKILL.md +45 -0
  96. package/runtime/prime/.codex/skills/tutorial/SKILL.md +45 -0
  97. package/runtime/prime/.codex/skills/update/SKILL.md +29 -0
  98. package/runtime/prime/.codex/skills/view/SKILL.md +156 -0
  99. package/runtime/prime/.codex/skills/view/agents/openai.yaml +4 -0
  100. package/runtime/prime/.codex/skills/view/assets/compose-scaffold.html +221 -0
  101. package/runtime/prime/.codex/skills/visual-explain/SKILL.md +45 -0
  102. package/runtime/prime/.codex/skills/wrap/SKILL.md +58 -0
  103. package/runtime/prime/.codex/spec-manifest.json +132 -0
  104. package/runtime/prime/.prime/agent/APPEND_SYSTEM.md +393 -0
  105. package/runtime/prime/.prime/agent/extensions/egregore.ts +460 -0
  106. package/runtime/prime/.prime/agent/settings.json +4 -0
  107. package/runtime/prime/.prime/agent/skills/egregore/SKILL.md +34 -0
  108. package/runtime/prime/.prime/agent/skills/egregore/pyproject.toml +13 -0
  109. package/runtime/prime/.prime/agent/skills/egregore/src/egregore/__init__.py +156 -0
  110. package/runtime/prime/.prime/agent/spec-manifest.json +12 -0
  111. package/runtime/prime/AGENTS.md +489 -0
  112. package/runtime/prime/bin/activity-data.sh +238 -0
  113. package/runtime/prime/bin/agent.sh +775 -0
  114. package/runtime/prime/bin/attendant.sh +707 -0
  115. package/runtime/prime/bin/autosave.sh +112 -0
  116. package/runtime/prime/bin/capture-reconcile.sh +69 -0
  117. package/runtime/prime/bin/capture-run.sh +341 -0
  118. package/runtime/prime/bin/codex-install-skills.sh +78 -0
  119. package/runtime/prime/bin/codex-render-spec.mjs +440 -0
  120. package/runtime/prime/bin/codex-session-start.sh +265 -0
  121. package/runtime/prime/bin/codex-skill-render.mjs +347 -0
  122. package/runtime/prime/bin/codex-sync-skills.sh +232 -0
  123. package/runtime/prime/bin/connect-refresh.sh +33 -0
  124. package/runtime/prime/bin/dashboard-data.sh +178 -0
  125. package/runtime/prime/bin/graph-batch.sh +79 -0
  126. package/runtime/prime/bin/graph-op.sh +1102 -0
  127. package/runtime/prime/bin/graph-wal.sh +229 -0
  128. package/runtime/prime/bin/graph.sh +223 -0
  129. package/runtime/prime/bin/handoff-pr-backfill.sh +86 -0
  130. package/runtime/prime/bin/handoff-run.sh +671 -0
  131. package/runtime/prime/bin/handoff-save-egregore.sh +212 -0
  132. package/runtime/prime/bin/index-handoff.sh +322 -0
  133. package/runtime/prime/bin/ingest-graph.sh +80 -0
  134. package/runtime/prime/bin/ingest-surface.html +495 -0
  135. package/runtime/prime/bin/ingest.py +756 -0
  136. package/runtime/prime/bin/ingest.sh +279 -0
  137. package/runtime/prime/bin/ingest_graph.py +856 -0
  138. package/runtime/prime/bin/ingest_surface.py +544 -0
  139. package/runtime/prime/bin/lib/artifact-id.sh +46 -0
  140. package/runtime/prime/bin/lib/config.sh +116 -0
  141. package/runtime/prime/bin/lib/context.sh +621 -0
  142. package/runtime/prime/bin/lib/dashboard-artifact.sh +242 -0
  143. package/runtime/prime/bin/lib/ensure-develop.sh +183 -0
  144. package/runtime/prime/bin/lib/git-sync.sh +406 -0
  145. package/runtime/prime/bin/lib/greeting.sh +519 -0
  146. package/runtime/prime/bin/lib/hash.sh +13 -0
  147. package/runtime/prime/bin/lib/identity.sh +213 -0
  148. package/runtime/prime/bin/lib/metrics.sh +374 -0
  149. package/runtime/prime/bin/lib/noncode.sh +31 -0
  150. package/runtime/prime/bin/lib/permissions.sh +106 -0
  151. package/runtime/prime/bin/lib/time.sh +56 -0
  152. package/runtime/prime/bin/lib/worktree-links.sh +79 -0
  153. package/runtime/prime/bin/notify.sh +636 -0
  154. package/runtime/prime/bin/person.py +630 -0
  155. package/runtime/prime/bin/person.sh +448 -0
  156. package/runtime/prime/bin/pi-branch-policy.mjs +312 -0
  157. package/runtime/prime/bin/pi-product-workflows.mjs +394 -0
  158. package/runtime/prime/bin/prime-render-spec.mjs +123 -0
  159. package/runtime/prime/bin/prime-session-start.sh +24 -0
  160. package/runtime/prime/bin/publish-artifact.sh +271 -0
  161. package/runtime/prime/bin/publish-references.sh +144 -0
  162. package/runtime/prime/bin/repo-state.sh +96 -0
  163. package/runtime/prime/bin/search.sh +240 -0
  164. package/runtime/prime/bin/session-autosave.sh +209 -0
  165. package/runtime/prime/bin/session-log.sh +199 -0
  166. package/runtime/prime/bin/settings.sh +304 -0
  167. package/runtime/prime/bin/telemetry.sh +391 -0
  168. package/runtime/prime/bin/worktree-create.sh +128 -0
  169. package/runtime/prime/bin/worktree-remove.sh +50 -0
  170. package/runtime/prime/bin/worktree.sh +198 -0
  171. package/runtime/prime/manifest.json +178 -0
@@ -58,6 +58,12 @@ const AGENTS_PREAMBLE = `# Egregore Agent Protocol
58
58
  > \`.pi/APPEND_SYSTEM.md\`, and your workflows are exposed through the
59
59
  > project-local \`.pi/\` runtime. Do not follow the Codex-specific block below.
60
60
  >
61
+ > **If you are Prime Agent** (the \`prime-agent\` harness): **stop here.** Your
62
+ > authoritative Egregore instructions are loaded from
63
+ > \`.prime/agent/APPEND_SYSTEM.md\`, and your workflows are exposed through the
64
+ > project-local \`.prime/agent/\` runtime. Do not follow the Codex-specific
65
+ > block below.
66
+ >
61
67
  > **If you are Codex or another shell-only agent:** this file is yours — continue.
62
68
 
63
69
  Egregore is no longer only a Claude Code workspace. Claude Code remains the
@@ -210,7 +210,9 @@ render_card() {
210
210
 
211
211
  # Staged auto-saves awaiting the user's merge (the autosave consent gate).
212
212
  # Live gh query so the line persists until the PRs actually merge.
213
- if command -v gh >/dev/null 2>&1; then
213
+ # Shown only while the autosave experiment is enabled.
214
+ autosave_on=$(jq -r '.autosave_enabled // false' "$STATE_FILE" 2>/dev/null)
215
+ if [ "$autosave_on" = "true" ] && command -v gh >/dev/null 2>&1; then
214
216
  gh_self=$(jq -r '.github_username // empty' "$STATE_FILE" 2>/dev/null)
215
217
  if [ -n "$gh_self" ]; then
216
218
  waiting_as=$(gh pr list --state open --author "$gh_self" --limit 20 \
@@ -24,21 +24,40 @@ if [ "$_MODE" = "local" ]; then
24
24
  exit 0
25
25
  fi
26
26
 
27
- # Load specific variables from .env if it exists (safe extraction, no arbitrary code execution)
28
- if [ -f "$SCRIPT_DIR/.env" ]; then
29
- EGREGORE_API_URL="${EGREGORE_API_URL:-$(grep '^EGREGORE_API_URL=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
30
- EGREGORE_API_KEY="${EGREGORE_API_KEY:-$(grep '^EGREGORE_API_KEY=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
27
+ # Load specific variables from .env if it exists (safe extraction, no arbitrary code execution).
28
+ # A worktree missing its .env symlink resolves through worktree-links.sh, which
29
+ # self-heals the link and falls back to the main checkout's .env.
30
+ _ENV_FILE="$SCRIPT_DIR/.env"
31
+ if [ ! -f "$_ENV_FILE" ] && [ -f "$SCRIPT_DIR/bin/lib/worktree-links.sh" ]; then
32
+ # shellcheck source=bin/lib/worktree-links.sh
33
+ source "$SCRIPT_DIR/bin/lib/worktree-links.sh" 2>/dev/null || true
34
+ _ENV_FILE="$(egregore_env_file "$SCRIPT_DIR" 2>/dev/null || true)"
35
+ fi
36
+ if [ -n "$_ENV_FILE" ] && [ -f "$_ENV_FILE" ]; then
37
+ EGREGORE_API_URL="${EGREGORE_API_URL:-$(grep '^EGREGORE_API_URL=' "$_ENV_FILE" 2>/dev/null | cut -d'=' -f2- || true)}"
38
+ EGREGORE_API_KEY="${EGREGORE_API_KEY:-$(grep '^EGREGORE_API_KEY=' "$_ENV_FILE" 2>/dev/null | cut -d'=' -f2- || true)}"
31
39
  fi
32
40
 
33
41
  API_URL="${EGREGORE_API_URL:-$(jq -r '.api_url // empty' "$CONFIG")}"
34
42
  API_KEY="${EGREGORE_API_KEY:-}"
35
43
 
36
- if [ -z "$API_URL" ] || [ -z "$API_KEY" ]; then
37
- # === OFFLINE MODE: No API key — return empty results (OSS/local) ===
44
+ if [ -z "$API_URL" ]; then
45
+ # === OFFLINE MODE: no api_url configured (OSS/unconfigured) ===
38
46
  echo '{"results":[]}'
39
47
  exit 0
40
48
  fi
41
49
 
50
+ if [ -z "$API_KEY" ]; then
51
+ # api_url is configured but no key was found — the local-mode gate exited
52
+ # above, so the org expects a live graph. Faking empty results here hides a
53
+ # missing .env (e.g. a worktree without its symlink) from every caller.
54
+ {
55
+ echo "Error: api_url is configured but EGREGORE_API_KEY is empty (looked in: ${_ENV_FILE:-$SCRIPT_DIR/.env})."
56
+ echo " If this is a worktree, its .env symlink may be missing. Run /env to configure."
57
+ } >&2
58
+ exit 1
59
+ fi
60
+
42
61
  QUERIES_JSON="${1:?Usage: graph-batch.sh '<json array of queries>'}"
43
62
 
44
63
  # Wrap the array into the batch request format
@@ -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"}'; }
@@ -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,35 @@ def chunks(text: str, limit: int = 7000) -> list[str]:
152
154
  return result
153
155
 
154
156
 
157
+ def find_publisher_profile() -> Path | None:
158
+ """Where this instance's publisher profile is, if it has one.
159
+
160
+ The profile is what assigns a zone and a trust tier to each document, and
161
+ zone is a hard retrieval boundary. Ingesting without it produces a corpus
162
+ where every document is unplaced, so every later question is refused — a
163
+ failure that looks like an empty archive rather than a missing setting.
164
+
165
+ So this looks in the places a profile actually lives rather than in one
166
+ fixed path. An instance keeps its ontology under memory/knowledge/tools/,
167
+ not beside its intake metadata, and requiring an environment variable to
168
+ bridge that gap means the common case is the broken one.
169
+ """
170
+ override = os.environ.get("EGREGORE_PUBLISHER_PROFILE")
171
+ if override:
172
+ path = Path(override)
173
+ return path if path.is_file() else None
174
+
175
+ candidates = [metadata_root() / "publisher-profile.json"]
176
+ memory = Path("memory")
177
+ if memory.is_dir():
178
+ candidates.extend(sorted(memory.glob("knowledge/tools/*/publisher-profile.json")))
179
+ candidates.append(memory / "publisher-profile.json")
180
+ for path in candidates:
181
+ if path.is_file():
182
+ return path
183
+ return None
184
+
185
+
155
186
  def publisher_profile() -> dict | None:
156
187
  """Load the instance's publisher profile, if one is configured.
157
188
 
@@ -159,9 +190,8 @@ def publisher_profile() -> dict | None:
159
190
  operator set: deriving a region is an inference, and an inference must not
160
191
  silently replace a stated fact.
161
192
  """
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():
193
+ path = find_publisher_profile()
194
+ if path is None:
165
195
  return None
166
196
  try:
167
197
  return json.loads(path.read_text(encoding="utf-8"))
@@ -223,7 +253,19 @@ def add_path(args: argparse.Namespace) -> dict:
223
253
  existing_manifest = load_manifest(directory)
224
254
  previous = {d["id"]: d for d in existing_manifest.get("documents", [])}
225
255
  now = datetime.now(timezone.utc).isoformat()
256
+ profile_path = find_publisher_profile()
226
257
  profile = publisher_profile()
258
+ if profile is None:
259
+ # Said once, plainly, because the alternative is a corpus that ingests
260
+ # cleanly and then refuses every question with no indication why.
261
+ print("ingest: no publisher-profile.json found — documents will carry no zone "
262
+ "or trust tier, and a zoneless document can never be served. Put one "
263
+ "under memory/knowledge/tools/<ontology>/ or set "
264
+ "EGREGORE_PUBLISHER_PROFILE.", file=sys.stderr)
265
+ catalogue = catalogue_module.load(
266
+ profile or {}, base=profile_path.parent if profile_path else None
267
+ )
268
+ repair_map = text_repair.load_mapping(profile or {})
227
269
  ingested = unchanged = metadata_updated = skipped = pruned = 0
228
270
  errors: list[dict] = []
229
271
  seen_paths: set[str] = set()
@@ -252,6 +294,15 @@ def add_path(args: argparse.Namespace) -> dict:
252
294
  extraction = {"kind": "local", "extractor": extractor} if extractor else None
253
295
  if extraction and detail:
254
296
  extraction["pages"] = detail
297
+ # Fix characters the document's own font encoding got wrong, before
298
+ # anything downstream indexes or quotes them.
299
+ repaired = None
300
+ if text and repair_map:
301
+ outcome = text_repair.repair(text, repair_map)
302
+ if outcome.replaced:
303
+ text = outcome.text
304
+ repaired = outcome.as_report()
305
+
255
306
  if error or text is None or not text.strip():
256
307
  skipped += 1
257
308
  errors.append({"path": relative, "error": error or "empty extracted text"})
@@ -283,6 +334,9 @@ def add_path(args: argparse.Namespace) -> dict:
283
334
  continue
284
335
  # Segment before chunking so no passage straddles two works. A document
285
336
  # holding a single work yields one segment, and chunking is unchanged.
337
+ if repaired and extraction:
338
+ extraction["repair"] = repaired
339
+
286
340
  segments = doc_segment.segment(text)
287
341
  document_chunks: list[tuple[str, doc_segment.Segment]] = []
288
342
  for seg in segments:
@@ -314,12 +368,34 @@ def add_path(args: argparse.Namespace) -> dict:
314
368
  # is absent filters nothing. A derived value is marked as derived: anything
315
369
  # downstream can tell it apart from one a person stated, and weigh it by the
316
370
  # confidence recorded alongside. An operator's value is never overwritten.
371
+ # Evidence strongest first: what the operator stated, then what the
372
+ # organisation's own catalogue records, then what can be inferred from
373
+ # the document's origin. A weaker source never overwrites a stronger one.
317
374
  provenance = None
318
375
  doc_boundaries = dict(boundaries)
376
+ catalogued = catalogue_module.lookup(catalogue, relative)
377
+
378
+ if catalogued:
379
+ provenance = {"from_catalogue": {k: v for k, v in catalogued.items()}}
380
+ for field_name, value in catalogued.items():
381
+ if field_name in ("zone", "crop", "topic", "customer", "jurisdiction", "project"):
382
+ if field_name not in doc_boundaries:
383
+ doc_boundaries[field_name] = value
384
+ provenance["applied_boundary"] = field_name
385
+ if catalogued.get("year"):
386
+ structure.setdefault("publication_year", catalogued["year"])
387
+ structure["year_source"] = structure.get("year_source") or "catalogue"
388
+ if catalogued.get("tier"):
389
+ provenance["tier"] = catalogued["tier"]
390
+ provenance["confidence"] = "high"
391
+
319
392
  if profile:
320
393
  resolved = source_profile.resolve(profile, filename=relative)
321
394
  if resolved.publisher_class or resolved.notes:
322
- provenance = resolved.as_dict()
395
+ inferred = resolved.as_dict()
396
+ # Keep the inference visible even when the catalogue answered,
397
+ # so a disagreement between the two is inspectable.
398
+ provenance = {**inferred, **(provenance or {})} if provenance else inferred
323
399
  key = source_profile.boundary_key(profile)
324
400
  if key and resolved.zone and key not in doc_boundaries:
325
401
  doc_boundaries[key] = resolved.zone
@@ -370,6 +370,29 @@ fi
370
370
  JSON="[]"
371
371
  RICH="[]"
372
372
  if [ -d "$SCRIPT_DIR/memory/handoffs" ]; then
373
+ # One definition everywhere: in connected mode the list comes from the
374
+ # graph's open-handoffs named read (open = pending/unread/read/claimed,
375
+ # matched by name/github/aliases) — the same definition the dashboard
376
+ # uses. The file grep below stays as the local-mode/offline fallback.
377
+ ADDRESSED=""
378
+ if [ "${LOCAL_MODE:-false}" != "true" ]; then
379
+ _GRAPH_LIST=$(bash "$SCRIPT_DIR/bin/graph-op.sh" open-handoffs "$AUTHOR" 5 2>/dev/null || true)
380
+ if echo "$_GRAPH_LIST" | jq -e '.values | length > 0' >/dev/null 2>&1; then
381
+ ADDRESSED=$(echo "$_GRAPH_LIST" | jq -r '.values[][0]' 2>/dev/null | awk '!seen[$0]++' | while IFS= read -r _SID; do
382
+ # Two on-disk layouts: dated subdirs (YYYY-MM/DD-slug.md, the id's
383
+ # own shape) and flat files at the handoffs root. Probe both, else
384
+ # a graph-listed handoff silently vanishes from the greeting.
385
+ _MONTH="${_SID:0:7}"
386
+ _REST="${_SID:8}"
387
+ _F="$SCRIPT_DIR/memory/handoffs/$_MONTH/$_REST.md"
388
+ if [ -f "$_F" ]; then
389
+ echo "$_F"
390
+ elif [ -f "$SCRIPT_DIR/memory/handoffs/$_SID.md" ]; then
391
+ echo "$SCRIPT_DIR/memory/handoffs/$_SID.md"
392
+ fi
393
+ done | head -5)
394
+ fi
395
+ fi
373
396
  # Match github username, display name, github_name first word, and people-file name
374
397
  # Handoff files may use "to: name", "**To**: name", or "To: name"
375
398
  _DISPLAY=$(jq -r '.display_name // empty' "$STATE_FILE" 2>/dev/null)
@@ -387,7 +410,9 @@ fi
387
410
  echo "$_GREP_PAT" | grep -qF "$_VARIANT" && continue
388
411
  _GREP_PAT="$_GREP_PAT\|[Tt]o[*]*: *$_VARIANT\|[Tt]o[*]*:$_VARIANT"
389
412
  done
390
- ADDRESSED=$(grep -rli "$_GREP_PAT" "$SCRIPT_DIR/memory/handoffs/" 2>/dev/null | sort -r | head -5 || true)
413
+ if [ -z "$ADDRESSED" ]; then
414
+ ADDRESSED=$(grep -rli "$_GREP_PAT" "$SCRIPT_DIR/memory/handoffs/" 2>/dev/null | sort -r | head -5 || true)
415
+ fi
391
416
  JSON="["
392
417
  RICH="["
393
418
  FIRST=true
@@ -188,7 +188,10 @@ fi
188
188
  # Staged auto-saves awaiting the user's merge (the /autosave consent gate).
189
189
  # Live gh query so the line persists until the PRs actually merge — the
190
190
  # ledger cursor above only reports once. Fail-soft when gh/network is out.
191
- if command -v gh >/dev/null 2>&1; then
191
+ # Shown only while the autosave experiment is enabled: stale PRs from a
192
+ # disabled experiment should not nag every greeting.
193
+ AUTOSAVE_ON=$(jq -r '.autosave_enabled // false' "$STATE_FILE" 2>/dev/null)
194
+ if [ "$AUTOSAVE_ON" = "true" ] && command -v gh >/dev/null 2>&1; then
192
195
  GH_SELF=$(jq -r '.github_username // empty' "$STATE_FILE" 2>/dev/null)
193
196
  if [ -n "$GH_SELF" ]; then
194
197
  WAITING_AS=$(gh pr list --state open --author "$GH_SELF" --limit 20 \
@@ -52,3 +52,28 @@ egregore_link_shared_state() {
52
52
  fi
53
53
  done
54
54
  }
55
+
56
+ # Resolve the .env that governs a checkout. A worktree can be missing its .env
57
+ # symlink (created before .env existed, or by a runtime that skipped worktree
58
+ # setup) — self-heal the links, then fall back to the main checkout's .env so
59
+ # credential loading never silently comes up empty. Prints the usable path, or
60
+ # nothing when neither checkout has one. Always returns 0.
61
+ egregore_env_file() {
62
+ local project_dir="${1:?project dir required}"
63
+ if [ ! -f "$project_dir/.env" ] && [ -f "$project_dir/.git" ]; then
64
+ egregore_link_shared_state "$project_dir" 2>/dev/null || true
65
+ fi
66
+ if [ -f "$project_dir/.env" ]; then
67
+ echo "$project_dir/.env"
68
+ return 0
69
+ fi
70
+ if [ -f "$project_dir/.git" ]; then
71
+ local main_dir
72
+ main_dir="$(egregore_main_project_dir "$project_dir" 2>/dev/null || true)"
73
+ if [ -n "$main_dir" ] && [ -f "$main_dir/.env" ]; then
74
+ echo "$main_dir/.env"
75
+ return 0
76
+ fi
77
+ fi
78
+ return 0
79
+ }
@@ -1,7 +1,11 @@
1
1
  import { isAbsolute, relative, resolve } from "node:path";
2
2
 
3
3
  const MUTATING_COMMANDS = /(^|[;&|]\s*)(?:sudo\s+)?(rm|mv|cp|mkdir|rmdir|touch|chmod|chown|install|tee)\b/;
4
- const MUTATING_GIT = /(^|[;&|]\s*)(?:sudo\s+)?git\s+(add|commit|push|merge|rebase|cherry-pick|tag|rm|mv)\b/;
4
+ // Global options (-C <path>, -c k=v, --git-dir=…) may sit between `git` and
5
+ // the subcommand. The repo convention actively teaches `git -C <abs-path>`,
6
+ // so a classifier that only matches bare `git commit` misses the shape the
7
+ // model is most likely to emit.
8
+ const MUTATING_GIT = /(^|[;&|]\s*)(?:sudo\s+)?git\s+(?:(?:-C|-c|--git-dir|--work-tree)(?:=\S+|\s+\S+)\s+)*(add|commit|push|merge|rebase|cherry-pick|tag|rm|mv|reset|restore|clean)\b/;
5
9
  const MUTATING_PACKAGES = /(^|[;&|]\s*)(?:sudo\s+)?(npm|pnpm|yarn|bun)\s+(install|add|remove|uninstall|update)\b/;
6
10
 
7
11
  // A `>` only acts as a shell redirect when it sits OUTSIDE quotes. Quoted
@@ -112,6 +116,7 @@ export function isRuntimeStatePath(target, projectDir) {
112
116
  rel === ".claude" || rel.startsWith(".claude/") ||
113
117
  rel === ".codex" || rel.startsWith(".codex/") ||
114
118
  rel === ".pi" || rel.startsWith(".pi/") ||
119
+ rel === ".prime" || rel.startsWith(".prime/") ||
115
120
  rel === ".egregore-state.json" ||
116
121
  rel === ".egregore-branch-consent" ||
117
122
  rel === ".env";
@@ -162,6 +167,20 @@ export function mutatesOnlyRuntimeState(command, projectDir) {
162
167
  return foundTarget;
163
168
  }
164
169
 
170
+ // The documented escape hatch must itself be runnable, but ONLY as the whole
171
+ // command: an unanchored match lets `git push origin develop && echo 'develop'
172
+ // > .egregore-branch-consent` pass the gate in one call, and the block message
173
+ // teaches that exact echo string. Canonical form mirrors
174
+ // .codex/hooks/branch-guard.js isConsentCommand — anchored, branch escaped,
175
+ // optional ./ prefix — and is shared so the three runtimes cannot drift.
176
+ export function isConsentCommand(command, branch) {
177
+ if (!branch) return false;
178
+ const escaped = String(branch).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
179
+ return new RegExp(
180
+ `^\\s*echo\\s+['"]?${escaped}['"]?\\s*>\\s*(?:\\./)?\\.egregore-branch-consent\\s*$`
181
+ ).test(String(command || ""));
182
+ }
183
+
165
184
  export function isMutatingBash(command) {
166
185
  if (!command) return false;
167
186
  const meaningfulRedirects = stripQuotedSegments(String(command))
@@ -173,3 +192,121 @@ export function isMutatingBash(command) {
173
192
  /(^|[^<])>>?\s*[^&]/.test(meaningfulRedirects) ||
174
193
  /\bsed\s+[^\n]*\s-i(?:\s|$)/.test(command);
175
194
  }
195
+
196
+ // ---- IPython code classification (Prime Agent) -----------------------------
197
+ // Prime Agent's default tool surface is a persistent IPython kernel: file
198
+ // writes and shell escapes arrive as Python code, not bash tool calls. This
199
+ // classifier is deliberately static and therefore porous (exec/eval and
200
+ // string-built commands pass); the generated runtime spec carries the
201
+ // standing-instruction discipline as the backstop, and the adversarial test
202
+ // fixtures pin what this actually catches.
203
+
204
+ // Strip Python string literals so call-name detection ignores docstrings and
205
+ // string content. Triple quotes first, then single-line quotes; f-string
206
+ // prefixes fall out naturally because only the quoted part is replaced.
207
+ function stripPythonStrings(code) {
208
+ return String(code)
209
+ .replace(/[rbuf]{0,2}("""|''')[\s\S]*?\1/gi, '""')
210
+ .replace(/[rbuf]{0,2}"(?:\\.|[^"\\\n])*"/gi, '""')
211
+ .replace(/[rbuf]{0,2}'(?:\\.|[^'\\\n])*'/gi, "''");
212
+ }
213
+
214
+ function pythonShellCommands(code) {
215
+ const shell = [];
216
+ const lines = String(code).split("\n");
217
+ const first = (lines[0] || "").trim();
218
+ if (/^%%(bash|sh|script\s+(ba)?sh)\b/.test(first)) {
219
+ shell.push(lines.slice(1).join("\n"));
220
+ return { shell, rest: "" };
221
+ }
222
+ if (/^%%writefile\b/.test(first)) {
223
+ const target = first.replace(/^%%writefile\s+(-a\s+)?/, "").trim();
224
+ return { shell: [`tee ${target || "unknown-target"}`], rest: "" };
225
+ }
226
+ const rest = [];
227
+ for (const line of lines) {
228
+ const bang = line.match(/^\s*!(.+)$/);
229
+ const sysMagic = line.match(/^\s*%(?:system|sx)\s+(.+)$/);
230
+ if (bang) shell.push(bang[1]);
231
+ else if (sysMagic) shell.push(sysMagic[1]);
232
+ else rest.push(line);
233
+ }
234
+ return { shell, rest: rest.join("\n") };
235
+ }
236
+
237
+ const PY_MUTATING_CALLS =
238
+ /\b(?:os\.(?:remove|unlink|rename|replace|rmdir|removedirs|makedirs|mkdir|symlink|link|chmod|chown|truncate)|shutil\.(?:copy|copy2|copyfile|copytree|move|rmtree|make_archive|unpack_archive))\s*\(/;
239
+ const PY_WRITE_METHODS = /\.\s*(?:write_text|write_bytes|unlink|touch|mkdir|rmdir|hardlink_to|symlink_to)\s*\(/;
240
+ const PY_OPEN_WRITE =
241
+ /\bopen\s*\(\s*[^,]+,\s*(?:mode\s*=\s*)?['"](?:[wax]|r\+)[rwxab+]*['"]/;
242
+ const PY_OPEN_MODE_KWARG = /\bopen\s*\([^)]*mode\s*=\s*['"](?:[wax]|r\+)[rwxab+]*['"]/;
243
+
244
+ function pythonSubprocessArgv(code) {
245
+ const argvs = [];
246
+ const calls =
247
+ /\b(?:subprocess\.(?:run|call|check_call|check_output|Popen)|os\.system|os\.popen)\s*\(\s*(\[[^\]]*\]|[rbuf]{0,2}["'](?:\\.|[^"'\\\n])*["'])/g;
248
+ let match;
249
+ while ((match = calls.exec(String(code)))) {
250
+ const arg = match[1];
251
+ if (arg.startsWith("[")) {
252
+ const words = [...arg.matchAll(/["']((?:\\.|[^"'\\])*)["']/g)].map((m) => m[1]);
253
+ if (words.length) argvs.push(words.join(" "));
254
+ } else {
255
+ argvs.push(arg.replace(/^[rbuf]{0,2}["']/i, "").replace(/["']$/, ""));
256
+ }
257
+ }
258
+ return argvs;
259
+ }
260
+
261
+ // Classify IPython cell code. Returns true when the code statically performs a
262
+ // repo mutation that should meet the protected-branch gate. Mutations that
263
+ // target only Egregore runtime state (memory/, dot-dirs, consent files) are
264
+ // exempt when the target is statically extractable, mirroring
265
+ // mutatesOnlyRuntimeState for bash.
266
+ export function isMutatingIpython(code, projectDir) {
267
+ if (!code) return false;
268
+ const { shell, rest } = pythonShellCommands(code);
269
+ for (const cmd of shell) {
270
+ if (isMutatingBash(cmd) && !(projectDir && mutatesOnlyRuntimeState(cmd, projectDir))) return true;
271
+ }
272
+ if (!rest) return false;
273
+
274
+ for (const argv of pythonSubprocessArgv(rest)) {
275
+ if (isMutatingBash(argv) && !(projectDir && mutatesOnlyRuntimeState(argv, projectDir))) return true;
276
+ }
277
+
278
+ const stripped = stripPythonStrings(rest);
279
+ if (PY_MUTATING_CALLS.test(stripped) || PY_WRITE_METHODS.test(stripped)) {
280
+ // Exempt when every extractable string target is runtime state. The
281
+ // mutated location differs by call: copy/move/rename mutate their LAST
282
+ // argument (the destination); remove/rmtree/mkdir mutate their first.
283
+ if (projectDir) {
284
+ const DEST_LAST = /^(?:copy|copy2|copyfile|copytree|move|rename|replace|symlink|link)$/;
285
+ const targets = [...String(rest).matchAll(
286
+ /\b(?:os|shutil)\.(\w+)\s*\(([^)]*)\)/g
287
+ )].flatMap((m) => {
288
+ const args = [...m[2].matchAll(/["']((?:\\.|[^"'\\])*)["']/g)].map((s) => s[1]);
289
+ if (!args.length) return [];
290
+ return [DEST_LAST.test(m[1]) ? args[args.length - 1] : args[0]];
291
+ });
292
+ const pathTargets = [...String(rest).matchAll(
293
+ /Path\s*\(\s*[rbuf]{0,2}["']((?:\\.|[^"'\\\n])*)["']\s*\)\s*\.\s*(?:write_text|write_bytes|unlink|touch|mkdir|rmdir)/g
294
+ )].map((m) => m[1]);
295
+ const all = [...targets, ...pathTargets];
296
+ if (all.length && all.every((t) => isRuntimeStatePath(t, projectDir))) return false;
297
+ }
298
+ return true;
299
+ }
300
+
301
+ if (PY_OPEN_WRITE.test(rest) || PY_OPEN_MODE_KWARG.test(rest)) {
302
+ if (projectDir) {
303
+ const openTargets = [...String(rest).matchAll(
304
+ /\bopen\s*\(\s*[rbuf]{0,2}["']((?:\\.|[^"'\\\n])*)["']\s*,\s*(?:mode\s*=\s*)?["'](?:[wax]|r\+)/g
305
+ )].map((m) => m[1]);
306
+ if (openTargets.length && openTargets.every((t) => isRuntimeStatePath(t, projectDir))) return false;
307
+ }
308
+ return true;
309
+ }
310
+
311
+ return false;
312
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import {
4
4
  existsSync,
5
+ mkdirSync,
5
6
  mkdtempSync,
6
7
  readFileSync,
7
8
  readdirSync,
@@ -141,12 +142,37 @@ export async function renderView(pi, root, request, signal) {
141
142
  return { text, file, type: request.type };
142
143
  }
143
144
 
144
- export async function renderGreeting(pi, root, signal) {
145
- const script = join(root, "bin", "pi-session-start.sh");
146
- if (!existsSync(script)) throw new Error("Pi startup renderer is missing");
145
+ export async function renderGreeting(pi, root, signal, startScript = "pi-session-start.sh") {
146
+ const script = join(root, "bin", startScript);
147
+ if (!existsSync(script)) throw new Error("Egregore startup renderer is missing");
147
148
  return checkedExec(pi, "bash", [script, "--card"], { signal, timeout: 45000 }, "Egregore startup");
148
149
  }
149
150
 
151
+ // Raw JSON from the most recent activity-data.sh run, so follow-up focus
152
+ // turns can receive the data inline instead of re-running the script inside
153
+ // a harness surface that may cap command time (Prime's kernel kills execs at
154
+ // ~5s, truncating the 10-20s data script to a partial fragment).
155
+ let lastActivityData = "";
156
+ export function lastActivityJson() {
157
+ return lastActivityData;
158
+ }
159
+
160
+ // Stable on-disk copy of the same data. Focus turns point the model at this
161
+ // path instead of an inline excerpt: a file read is one fast kernel call,
162
+ // never truncated, and removes any reason to re-run the data script.
163
+ export function activityDataPath(root) {
164
+ return join(root, ".egregore", "activity-data.json");
165
+ }
166
+
167
+ function persistActivityData(root, json) {
168
+ try {
169
+ mkdirSync(join(root, ".egregore"), { recursive: true });
170
+ writeFileSync(activityDataPath(root), json);
171
+ } catch {
172
+ // Cache only — rendering proceeds from memory when the disk write fails.
173
+ }
174
+ }
175
+
150
176
  export async function renderActivity(pi, root, signal, options = {}) {
151
177
  if (options.sync !== false) {
152
178
  await checkedExec(
@@ -177,6 +203,8 @@ export async function renderActivity(pi, root, signal, options = {}) {
177
203
  { signal, timeout: 120000 },
178
204
  "Activity refresh",
179
205
  );
206
+ lastActivityData = String(json || "");
207
+ persistActivityData(root, lastActivityData);
180
208
  const temp = mkdtempSync(join(tmpdir(), "egregore-pi-activity-"));
181
209
  const input = join(temp, "activity.json");
182
210
  try {
@@ -276,7 +304,7 @@ export async function createHandoff(pi, root, params, signal) {
276
304
  }
277
305
  }
278
306
 
279
- export async function captureSessionEnd(pi, root, sessionFile, signal) {
307
+ export async function captureSessionEnd(pi, root, sessionFile, signal, runtime = "pi") {
280
308
  const sessionIdFile = join(root, ".egregore-session-id");
281
309
  if (!existsSync(sessionIdFile)) return false;
282
310
  const sessionId = cleanArgs(readFileSync(sessionIdFile, "utf8"));
@@ -287,7 +315,7 @@ export async function captureSessionEnd(pi, root, sessionFile, signal) {
287
315
  writeFileSync(input, JSON.stringify({
288
316
  session_id: sessionId,
289
317
  transcript_path: cleanArgs(sessionFile),
290
- runtime: "pi",
318
+ runtime,
291
319
  }));
292
320
 
293
321
  try {
@@ -353,11 +381,11 @@ export async function publishScroll(pi, root, params, signal) {
353
381
  };
354
382
  }
355
383
 
356
- export function workflowPrompt(name, args, extra = "") {
384
+ export function workflowPrompt(name, args, extra = "", spec = { runtimeName: "Pi", specPath: ".pi/APPEND_SYSTEM.md" }) {
357
385
  const userArgs = cleanArgs(args);
358
386
  return [
359
387
  `Run the Egregore /${name} workflow now.`,
360
- `Read .codex/skills/${name}/SKILL.md completely and adapt Codex-specific wording to Pi using .pi/APPEND_SYSTEM.md.`,
388
+ `Read .codex/skills/${name}/SKILL.md completely and adapt Codex-specific wording to ${spec.runtimeName} using ${spec.specPath}.`,
361
389
  "Work quietly: do not narrate progress, expose raw command output, or repeat tool results.",
362
390
  "Use the fewest targeted reads and commands that preserve the workflow contract.",
363
391
  userArgs ? `User arguments: ${userArgs}` : "",