create-egregore 0.19.2 → 0.19.5

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 (38) hide show
  1. package/assets/egregore-launcher.js +3 -1
  2. package/bin/cli.js +4 -1
  3. package/lib/local.js +78 -12
  4. package/lib/setup.js +15 -0
  5. package/package.json +2 -2
  6. package/runtime/codex/.codex/hooks/search-hint.js +65 -0
  7. package/runtime/codex/.codex/hooks.json +13 -0
  8. package/runtime/codex/.codex/skills/deep-reflect/SKILL.md +14 -10
  9. package/runtime/codex/.codex/skills/minion/SKILL.md +25 -0
  10. package/runtime/codex/.codex/skills/scroll/SKILL.md +168 -0
  11. package/runtime/codex/.codex/skills/scroll/agents/openai.yaml +4 -0
  12. package/runtime/codex/.codex/skills/scroll/assets/shell.html +542 -0
  13. package/runtime/codex/.codex/skills/search/SKILL.md +56 -0
  14. package/runtime/codex/.codex/skills/session-view/SKILL.md +25 -0
  15. package/runtime/codex/.codex/skills/thread/SKILL.md +25 -0
  16. package/runtime/codex/.codex/skills/view/SKILL.md +147 -32
  17. package/runtime/codex/.codex/skills/view/agents/openai.yaml +4 -0
  18. package/runtime/codex/.codex/skills/view/assets/compose-scaffold.html +221 -0
  19. package/runtime/codex/.codex/spec-manifest.json +3 -3
  20. package/runtime/codex/AGENTS.md +24 -3
  21. package/runtime/codex/bin/activity-data.sh +46 -4
  22. package/runtime/codex/bin/agent.sh +10 -2
  23. package/runtime/codex/bin/codex-install-skills.sh +1 -1
  24. package/runtime/codex/bin/codex-render-spec.mjs +18 -1
  25. package/runtime/codex/bin/codex-session-start.sh +1 -1
  26. package/runtime/codex/bin/codex-skill-render.mjs +13 -7
  27. package/runtime/codex/bin/codex-sync-skills.sh +4 -0
  28. package/runtime/codex/bin/graph-op.sh +56 -5
  29. package/runtime/codex/bin/graph.sh +36 -0
  30. package/runtime/codex/bin/handoff-run.sh +157 -7
  31. package/runtime/codex/bin/handoff-save-egregore.sh +6 -1
  32. package/runtime/codex/bin/index-handoff.sh +9 -3
  33. package/runtime/codex/bin/lib/context.sh +89 -17
  34. package/runtime/codex/bin/lib/dashboard-artifact.sh +1 -1
  35. package/runtime/codex/bin/lib/git-sync.sh +35 -9
  36. package/runtime/codex/bin/lib/greeting.sh +25 -3
  37. package/runtime/codex/bin/publish-artifact.sh +29 -1
  38. package/runtime/codex/manifest.json +16 -1
@@ -71,7 +71,7 @@ for skill_dir in "$SOURCE_DIR"/*; do
71
71
  [ -f "$skill_dir/SKILL.md" ] || continue
72
72
  name="$(basename "$skill_dir")"
73
73
  mkdir -p "$TARGET_DIR/$name"
74
- cp "$skill_dir/SKILL.md" "$TARGET_DIR/$name/SKILL.md"
74
+ cp -R "$skill_dir/." "$TARGET_DIR/$name/"
75
75
  copied=$((copied + 1))
76
76
  done
77
77
 
@@ -73,6 +73,12 @@ portable memory protocol below.
73
73
  Use the runtime-neutral bridge instead of Claude Code slash commands:
74
74
 
75
75
  \`\`\`bash
76
+ bin/agent.sh branch --topic "auth review"
77
+ bin/agent.sh save --message "Save: auth review" --topic "auth review"
78
+ bin/agent.sh wrap --from alice --topic "auth review" \\
79
+ --summary "Implemented the OAuth callback parser and documented follow-ups." \\
80
+ --body "Open threads: review error cases and browser redirects."
81
+
76
82
  bin/agent.sh handoff --from alice --to bob --topic "auth review" \\
77
83
  --body "Implemented the OAuth callback parser. Bob should review error cases."
78
84
 
@@ -88,6 +94,17 @@ Each command writes to the existing Git-backed \`memory/\` repository and pushes
88
94
  when the memory repo has an \`origin\` remote. Agents that cannot run shell may
89
95
  write the same markdown files directly, following \`docs/AGENT-PROTOCOL.md\`.
90
96
 
97
+ ## Handoffs vs Emissaries
98
+
99
+ \`bin/agent.sh handoff\` is the runtime-neutral path for internal team
100
+ session-handoffs: it writes to \`memory/handoffs/\`, updates the index, and uses
101
+ the same \`bin/handoff-run.sh\` machinery as Claude Code's \`/handoff\`.
102
+
103
+ Portable external capsules are **emissaries**, not team handoffs. Claude Code
104
+ routes those through \`/emissary\`; non-Claude agents should use the
105
+ \`egregore-emissary\` CLI/skill when it is installed. Do not use the deprecated
106
+ \`egregore-handoff\` CLI for Egregore project \`/handoff\` work.
107
+
91
108
  ## Compatibility
92
109
 
93
110
  Claude Code users continue using \`/handoff\`, \`/activity\`, \`/ask\`, \`/save\`, and
@@ -216,7 +233,7 @@ If this Codex build does not support hooks, follow the same discipline as a stan
216
233
 
217
234
  If the startup card output contains \`onboarding_needed\`, invoke the \`$onboarding\` skill instead of greeting.`;
218
235
 
219
- 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\`. 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.`;
236
+ 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.`;
220
237
 
221
238
  const SOCRATIC_BODY = `**Triggers**: "ask me questions", "question me", "help me think through", or any request to be questioned.
222
239
 
@@ -229,7 +229,7 @@ Follow AGENTS.md before code changes.
229
229
  After the user names what they are working on, use bin/agent.sh branch --topic "<work topic>" if you are not already in an appropriate task worktree, then continue file work from the printed worktree path.
230
230
  Use bin/agent.sh to update shared activity: handoff, ask, and answer write to memory/ and push when possible.
231
231
  Use bin/agent.sh handoff for internal team session-handoffs. Portable external capsules are emissaries; use egregore-emissary when installed, not the deprecated egregore-handoff CLI.
232
- Codex reserves leading slash commands for built-ins, so custom Egregore /activity-style commands will not reach the agent. Egregore native Codex skills are: \$activity, \$handoff, \$wrap, \$announce, \$harvest, \$the-spiral, \$dashboard, \$deep-reflect, \$quest, \$invite, \$ask, and \$save. Natural-language requests such as "show activity" or "make a handoff" work too. Remaining mirrored workflows are adapter skills for long-tail coverage.
232
+ Codex reserves leading slash commands for built-ins, so custom Egregore /activity-style commands will not reach the agent. Egregore native Codex skills are: \$activity, \$handoff, \$wrap, \$announce, \$harvest, \$the-spiral, \$dashboard, \$deep-reflect, \$quest, \$invite, \$ask, \$save, \$view, and \$scroll. Natural-language requests such as "show activity", "make a handoff", or "render this as a scroll" work too. Remaining mirrored workflows are adapter skills for long-tail coverage.
233
233
  \$save is the user-facing abstraction for committing, pushing, opening or reusing pull requests, and syncing memory; do not make users manage the git workflow by hand.
234
234
  Egregore Codex workflows are skill invocations, not Claude Code slash commands. Do not rely on Claude Code-only command machinery.
235
235
  If onboarding_complete is false, use the memory protocol directly and make sure memory/people/${AUTHOR}.md exists before the first handoff.
@@ -143,7 +143,7 @@ function renderActivity(data) {
143
143
  const org = data.org || data.organization || 'Egregore';
144
144
  const date = data.date || '';
145
145
  const me = data.me?.github || data.me?.name || data.github_username || '';
146
- const handoffs = firstArray(data, ['handoffs', 'pending_handoffs', 'me.handoffs', 'addressed_handoffs']);
146
+ const handoffs = firstArray(data, ['handoffs_to_me', 'handoffs', 'pending_handoffs', 'me.handoffs', 'addressed_handoffs']);
147
147
  const questions = firstArray(data, ['questions', 'pending_questions', 'me.questions']);
148
148
  const prs = asArray(data.prs);
149
149
  const mine = firstArray(data, ['local_sessions.my_sessions', 'my_sessions', 'sessions']);
@@ -159,12 +159,18 @@ function renderActivity(data) {
159
159
  if (questions.length === 0 && handoffs.length === 0) {
160
160
  lines.push(padLine('nothing pending'));
161
161
  } else {
162
- questions.slice(0, 4).forEach((q) => {
163
- lines.push(padLine(`? ${valueOf(q, ['from', 'author', 'asker'], 'someone')}: ${valueOf(q, ['topic', 'title', 'question'], 'question')}`));
164
- });
165
- handoffs.slice(0, 4).forEach((h) => {
166
- lines.push(padLine(`> ${valueOf(h, ['author', 'from'], 'someone')}: ${valueOf(h, ['topic', 'title'], 'handoff')}`));
167
- });
162
+ if (questions.length > 0) {
163
+ lines.push(padLine('QUESTIONS'));
164
+ questions.slice(0, 4).forEach((q) => {
165
+ lines.push(padLine(`● ${valueOf(q, ['from', 'author', 'asker'], 'someone')}: ${valueOf(q, ['topic', 'title', 'question'], 'question')}`));
166
+ });
167
+ }
168
+ if (handoffs.length > 0) {
169
+ lines.push(padLine('HANDOFFS'));
170
+ handoffs.slice(0, 4).forEach((h) => {
171
+ lines.push(padLine(`○ ${valueOf(h, ['author', 'from'], 'someone')}: ${valueOf(h, ['topic', 'title'], 'handoff')}`));
172
+ });
173
+ }
168
174
  }
169
175
 
170
176
  lines.push(rule());
@@ -19,6 +19,9 @@ NATIVE_SKILLS=(
19
19
  invite
20
20
  ask
21
21
  save
22
+ search
23
+ view
24
+ scroll
22
25
  )
23
26
 
24
27
  STRUCTURED_UX_SKILLS=(
@@ -54,6 +57,7 @@ STRUCTURED_UX_SKILLS=(
54
57
  emissary
55
58
  launch-site
56
59
  view
60
+ scroll
57
61
  visual-explain
58
62
  tui-design
59
63
  )
@@ -19,8 +19,11 @@ set -euo pipefail
19
19
  # remove-name to keep-name, stores remove-name as alias
20
20
  # create-harvest <id> <topic> <intent> <initiator>
21
21
  # Create a Harvest node and link to initiator (WAL-backed)
22
- # create-harvest-session <harvest-id> <session-id> <person-name>
23
- # Create a HarvestSession linked to a Harvest and Person
22
+ # create-harvest-session <harvest-id> <session-id> <person-name> [status]
23
+ # Create a HarvestSession linked to a Harvest and Person.
24
+ # status defaults to 'active' (back-compat). Valid values:
25
+ # pending | active | answered | complete | incorporated.
26
+ # Async respondents should be created with 'pending'.
24
27
  # record-harvest-turn <session-id> <turn> <question> <intent> [answer] [eval]
25
28
  # Record a question-answer turn in a HarvestSession
26
29
  # complete-harvest <harvest-id> <artifact-path>
@@ -42,6 +45,7 @@ if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
42
45
  echo " merge-person <keep> <rm> Merge two Person nodes"
43
46
  echo " claim-handoff <sid> <ho> Link implementing session to handoff"
44
47
  echo " create-pr <sid> <num> <repo> <author> [title]"
48
+ echo " register-artifact <id> <url> <title> <type> <author> <topics> <excerpt>"
45
49
  echo " create-harvest / complete-harvest / record-harvest-turn"
46
50
  echo " wal-status Show WAL pending count"
47
51
  exit 0
@@ -202,6 +206,39 @@ case "$OP" in
202
206
  bash "$GS" query "$CYPHER" "$PARAMS"
203
207
  ;;
204
208
 
209
+ register-artifact)
210
+ # Index a published (hosted) artifact as an Artifact node so the finder can
211
+ # retrieve it from the graph in connected mode — by content (title/topics/
212
+ # excerpt CONTAINS) and by relationship (who made it, which quest). The node
213
+ # id is the registry FILENAME STEM (date-author-slug), matching sync-graph.sh
214
+ # so a later /save sync upserts the same node instead of duplicating it.
215
+ # args: <id> <url> <title> <type> <author-handle> <topics-csv> <excerpt>
216
+ A_ID="${1:?missing id}"
217
+ A_URL="${2:?missing url}"
218
+ A_TITLE="${3:-}"
219
+ A_TYPE="${4:-artifact}"
220
+ A_AUTHOR="$(printf '%s' "${5:-}" | tr '[:upper:]' '[:lower:]' | awk '{print $1}')"
221
+ A_TOPICS_JSON="$(printf '%s' "${6:-}" | jq -Rc 'split(",") | map(gsub("^\\s+|\\s+$";"")) | map(select(length > 0))' 2>/dev/null || echo '[]')"
222
+ A_EXCERPT="$(printf '%s' "${7:-}" | tr '\n' ' ' | cut -c1-2000)"
223
+ CYPHER="
224
+ MERGE (a:Artifact {id: \$id})
225
+ ON CREATE SET a.created = datetime()
226
+ SET a.title = \$title, a.type = \$type, a.url = \$url,
227
+ a.topics = \$topics, a.excerpt = \$excerpt, a.published = true
228
+ WITH a
229
+ OPTIONAL MATCH (p:Person)
230
+ WHERE \$author <> '' AND (toLower(p.name) = \$author OR p.github = \$author)
231
+ FOREACH (_ IN CASE WHEN p IS NOT NULL THEN [1] ELSE [] END |
232
+ MERGE (a)-[:CONTRIBUTED_BY]->(p))
233
+ RETURN a.id AS id
234
+ "
235
+ PARAMS="$(jq -n --arg id "$A_ID" --arg url "$A_URL" --arg title "$A_TITLE" --arg type "$A_TYPE" \
236
+ --arg author "$A_AUTHOR" --argjson topics "$A_TOPICS_JSON" --arg excerpt "$A_EXCERPT" \
237
+ '{id: $id, url: $url, title: $title, type: $type, author: $author, topics: $topics, excerpt: $excerpt}')"
238
+ bash "$SCRIPT_DIR/bin/graph-wal.sh" append "$CYPHER" "$PARAMS" 2>/dev/null || true
239
+ bash "$GS" query "$CYPHER" "$PARAMS"
240
+ ;;
241
+
205
242
  check-implements)
206
243
  SID="${1:?missing session-id}"
207
244
  bash "$GS" query "
@@ -305,16 +342,30 @@ case "$OP" in
305
342
  HID="${1:?missing harvest-id}"
306
343
  HSID="${2:?missing session-id}"
307
344
  PERSON="${3:?missing person-name}"
308
- bash "$GS" query "
345
+ STATUS="${4:-active}"
346
+ case "$STATUS" in
347
+ pending|active|answered|complete|incorporated) ;;
348
+ *) jq -n --arg s "$STATUS" '{error:("invalid status: "+$s),valid:["pending","active","answered","complete","incorporated"]}' >&2; exit 2 ;;
349
+ esac
350
+ CYPHER="
309
351
  MATCH (h:Harvest {id: \$hid})
310
352
  MERGE (hs:HarvestSession {id: \$hsid})
311
- ON CREATE SET hs.status = 'active', hs.created = datetime()
353
+ ON CREATE SET hs.status = \$status, hs.created = datetime()
312
354
  MERGE (h)-[:HAS_SESSION]->(hs)
313
355
  WITH hs
314
356
  MATCH (p:Person) WHERE toLower(p.name) = toLower(\$person) OR p.github = \$person
315
357
  MERGE (hs)-[:WITH]->(p)
316
358
  RETURN hs.id AS id, hs.status AS status
317
- " "$(jq -n --arg hid "$HID" --arg hsid "$HSID" --arg person "$PERSON" '{hid: $hid, hsid: $hsid, person: $person}')"
359
+ "
360
+ PARAMS="$(jq -n --arg hid "$HID" --arg hsid "$HSID" --arg person "$PERSON" --arg status "$STATUS" '{hid: $hid, hsid: $hsid, person: $person, status: $status}')"
361
+ bash "$SCRIPT_DIR/bin/graph-wal.sh" append "$CYPHER" "$PARAMS" 2>/dev/null || true
362
+ OUT="$(bash "$GS" query "$CYPHER" "$PARAMS")"
363
+ if [ "$(echo "$OUT" | jq -r '.results | length' 2>/dev/null)" = "0" ]; then
364
+ jq -n --arg hid "$HID" --arg hsid "$HSID" --arg person "$PERSON" \
365
+ '{error:"create-harvest-session: harvest or person not found",hid:$hid,hsid:$hsid,person:$person}' >&2
366
+ exit 3
367
+ fi
368
+ echo "$OUT"
318
369
  ;;
319
370
 
320
371
  record-harvest-turn)
@@ -42,6 +42,22 @@ _millis() {
42
42
  fi
43
43
  }
44
44
 
45
+ # --- Optional read-through cache (opt-in via EGREGORE_GRAPH_CACHE_TTL) ---
46
+ # Read-only queries can be served from a local cache that bin/attendant.sh
47
+ # keeps warm in the background, so callers on the launch path (session-start
48
+ # greeting) pay zero network round-trips when warm. TTL in seconds; unset/0
49
+ # disables. Mutating queries are never cached.
50
+ _CACHE_TTL="${EGREGORE_GRAPH_CACHE_TTL:-0}"
51
+ _CACHE_DIR="$HOME/.egregore/graph-cache/$(echo -n "$SCRIPT_DIR" | cksum | cut -d' ' -f1)"
52
+
53
+ _cache_key() {
54
+ echo -n "$1|${2:-}" | cksum | cut -d' ' -f1
55
+ }
56
+
57
+ _is_read_query() {
58
+ ! echo "$1" | grep -qiE '\b(MERGE|CREATE|SET|DELETE|DETACH|REMOVE)\b'
59
+ }
60
+
45
61
  if [ -n "$API_URL" ] && [ -n "$API_KEY" ]; then
46
62
  # === API MODE: Call Egregore API gateway ===
47
63
 
@@ -54,6 +70,19 @@ if [ -n "$API_URL" ] && [ -n "$API_KEY" ]; then
54
70
  params="{}"
55
71
  fi
56
72
 
73
+ local cache_file=""
74
+ if [ "$_CACHE_TTL" != "0" ] && _is_read_query "$cypher"; then
75
+ cache_file="$_CACHE_DIR/$(_cache_key "$cypher" "$params").json"
76
+ if [ -f "$cache_file" ]; then
77
+ local now cts
78
+ now=$(date +%s)
79
+ cts=$(jq -r '.ts // 0' "$cache_file" 2>/dev/null || echo 0)
80
+ if [ $(( now - cts )) -lt "$_CACHE_TTL" ]; then
81
+ jq -c '.result' "$cache_file" 2>/dev/null && return 0
82
+ fi
83
+ fi
84
+ fi
85
+
57
86
  local body
58
87
  body=$(jq -n --arg stmt "$cypher" --argjson params "$params" \
59
88
  '{statement: $stmt, parameters: $params}')
@@ -86,6 +115,13 @@ if [ -n "$API_URL" ] && [ -n "$API_KEY" ]; then
86
115
  bash "$SCRIPT_DIR/bin/telemetry.sh" emit "graph_query" \
87
116
  "$(jq -n --argjson latency "$latency_ms" '{latency_ms: $latency}')" 2>/dev/null &
88
117
 
118
+ # Store for the attendant to keep warm (query+params travel with the result)
119
+ if [ -n "$cache_file" ]; then
120
+ mkdir -p "$_CACHE_DIR" 2>/dev/null
121
+ jq -n --arg q "$cypher" --argjson p "$params" --argjson ts "$(date +%s)" --argjson r "$response" \
122
+ '{query: $q, params: $p, ts: $ts, result: $r}' > "$cache_file" 2>/dev/null || true
123
+ fi
124
+
89
125
  echo "$response"
90
126
  }
91
127
 
@@ -48,6 +48,7 @@ PROJECT=""
48
48
  NO_PUSH=0
49
49
  NO_NOTIFY=0
50
50
  NO_PUBLISH=0
51
+ COMPOSED="" # optional house-kit JSON — the agent-composed render spec
51
52
 
52
53
  while [ $# -gt 0 ]; do
53
54
  case "$1" in
@@ -55,6 +56,7 @@ while [ $# -gt 0 ]; do
55
56
  --topic) TOPIC="${2:?}"; shift 2 ;;
56
57
  --recipient) RECIPIENT="${2:-}"; shift 2 ;;
57
58
  --project) PROJECT="${2:-}"; shift 2 ;;
59
+ --composed) COMPOSED="${2:-}"; shift 2 ;;
58
60
  --no-push) NO_PUSH=1; shift ;;
59
61
  --no-notify) NO_NOTIFY=1; shift ;;
60
62
  --no-publish) NO_PUBLISH=1; shift ;;
@@ -62,6 +64,16 @@ while [ $# -gt 0 ]; do
62
64
  esac
63
65
  done
64
66
 
67
+ # The render source: the agent-composed house-kit JSON when supplied (rich
68
+ # component render), else the markdown handoff (deterministic floor). The
69
+ # markdown file is ALWAYS the memory record + graph index either way.
70
+ RENDER_TYPE="handoff"
71
+ RENDER_SRC="" # resolved to $ABS_FILE after the file is written, unless composed
72
+ if [ -n "$COMPOSED" ] && [ -f "$COMPOSED" ]; then
73
+ RENDER_TYPE="composed"
74
+ RENDER_SRC="$COMPOSED"
75
+ fi
76
+
65
77
  [ -z "$AUTHOR" ] && { echo "Missing --author" >&2; exit 1; }
66
78
  [ -z "$TOPIC" ] && { echo "Missing --topic" >&2; exit 1; }
67
79
 
@@ -203,14 +215,13 @@ PID_GRAPH=$!
203
215
  ARTIFACTS_JSON="[]"
204
216
  if [ "$MODE" = "connected" ]; then
205
217
  YY=$(date +%Y); MM=$(date +%-m); DD_NUM=$(date +%-d)
206
- PARAMS=$(jq -nc --arg gh "$AUTHOR" '{gh:$gh}')
207
218
  RAW=$(bash "$SCRIPT_DIR/bin/graph.sh" query "
208
219
  MATCH (a:Artifact)-[:CONTRIBUTED_BY]->(p:Person {github: \$gh})
209
220
  WHERE a.created >= datetime({year: $YY, month: $MM, day: $DD_NUM})
210
221
  AND NOT 'tutorial-generated' IN coalesce(a.topics, [])
211
222
  RETURN a.title AS title, a.type AS type, a.filePath AS path
212
223
  ORDER BY a.created DESC
213
- LIMIT 5" "$PARAMS" 2>/dev/null || echo '{}')
224
+ LIMIT 5" "$(jq -nc --arg gh "$AUTHOR" '{gh:$gh}')" 2>/dev/null || echo '{}')
214
225
  # Response shape: {"fields":[...], "values":[[...], ...]}. Convert to
215
226
  # an array of {title, type, path} objects.
216
227
  ARTIFACTS_JSON=$(echo "$RAW" | jq -c '
@@ -282,24 +293,145 @@ PID_ARTIFACTS=$!
282
293
  PID_MEMORY=$!
283
294
 
284
295
  # --- Branch C: Publish artifact → Notify ---
296
+ #
297
+ # Strategy: handoffs publish ONCE, through the emissary write API, using
298
+ # `render_mode: "custom"` so the server serves OUR pre-rendered HTML
299
+ # verbatim (the same egregore-artifacts handoff template /view/ has
300
+ # always used). This gives us:
301
+ # - one artifact, one URL (no legacy + new double-publish)
302
+ # - permanent egregore.xyz/emissary/e/<id> URL with /raw endpoint
303
+ # - handoff design fully owned client-side (egregore-artifacts) — the
304
+ # emissary server template is never invoked for handoffs and remains
305
+ # completely untouched
306
+ #
307
+ # Fallback: if emissary publish fails (token missing, network, size cap,
308
+ # rendering failure), fall back to the legacy publish-artifact.sh path so
309
+ # a handoff still gets a Telegram-able URL.
285
310
  (
311
+ V1URL=""
286
312
  AURL=""
287
- if [ "$NO_PUBLISH" = "0" ] && [ -x "$SCRIPT_DIR/bin/publish-artifact.sh" ]; then
288
- AURL=$(bash "$SCRIPT_DIR/bin/publish-artifact.sh" handoff "$ABS_FILE" \
313
+ if [ "$NO_PUBLISH" = "0" ]; then
314
+ EMISSARY_CONFIG="$HOME/.egregore/emissary-config.json"
315
+ EMISSARY_TOKEN=""
316
+ if [ -f "$EMISSARY_CONFIG" ]; then
317
+ EMISSARY_TOKEN=$(jq -r '.auth_token // empty' "$EMISSARY_CONFIG" 2>/dev/null)
318
+ fi
319
+ EMISSARY_API_URL="${EMISSARY_API_URL:-https://egregore-production-55f2.up.railway.app/api/v1/emissary}"
320
+
321
+ if [ -n "$EMISSARY_TOKEN" ]; then
322
+ # Step 1: render the handoff to HTML locally using egregore-artifacts.
323
+ # Same renderer + template publish-artifact.sh uses today — handoffs
324
+ # keep their familiar look.
325
+ RENDERED_HTML="$TMPD/handoff.html"
326
+ RENDER_OK=0
327
+ LOCAL_CLI="$SCRIPT_DIR/packages/egregore-artifacts/bin/cli.js"
328
+ if [ "${EGREGORE_USE_PUBLISHED:-0}" != "1" ] && [ -f "$LOCAL_CLI" ] && [ -d "$SCRIPT_DIR/packages/egregore-artifacts/node_modules/react" ]; then
329
+ if node "$LOCAL_CLI" "$RENDER_TYPE" "${RENDER_SRC:-$ABS_FILE}" --output "$RENDERED_HTML" >/dev/null 2>&1; then
330
+ RENDER_OK=1
331
+ fi
332
+ else
333
+ if npx egregore-artifacts "$RENDER_TYPE" "${RENDER_SRC:-$ABS_FILE}" --output "$RENDERED_HTML" >/dev/null 2>&1; then
334
+ RENDER_OK=1
335
+ fi
336
+ fi
337
+
338
+ # Step 2: build the emissary payload. If rendering succeeded AND the
339
+ # HTML fits the server's 100KB cap, send it with render_mode=custom.
340
+ # If not, fall through to the structured-only path (server-rendered
341
+ # via the emissary template — undesirable for handoffs but still
342
+ # better than no URL).
343
+ if [ "$RENDER_OK" = "1" ] && [ -f "$RENDERED_HTML" ]; then
344
+ RENDER_SIZE=$(wc -c < "$RENDERED_HTML" | tr -d ' ')
345
+ if [ "$RENDER_SIZE" -gt 0 ] && [ "$RENDER_SIZE" -lt 100000 ]; then
346
+ # Author display name
347
+ DISPLAY_NAME="$AUTHOR"
348
+ PERSON_FILE="$SCRIPT_DIR/memory/people/${AUTHOR}.md"
349
+ if [ -f "$PERSON_FILE" ]; then
350
+ DISPLAY_NAME=$(head -1 "$PERSON_FILE" | sed 's/^# //')
351
+ fi
352
+
353
+ # addressed_to: only set if there's a recipient
354
+ ADDRESSED_TO="[]"
355
+ if [ -n "$RECIPIENT" ]; then
356
+ RECIP_HANDLE=$(echo "$RECIPIENT" | tr '[:upper:]' '[:lower:]' | awk '{print $1}')
357
+ ADDRESSED_TO=$(jq -nc --arg h "$RECIP_HANDLE" --arg d "$RECIPIENT" '[{"handle":$h,"display":$d}]')
358
+ fi
359
+
360
+ CLAIM=$(echo "$BRIEFING_LEAD" | cut -c1-200)
361
+
362
+ # body.prose is schema-required non-empty even when render_mode is
363
+ # "custom" (server validates structurally regardless of render
364
+ # mode). The visible page is the custom HTML; prose just needs to
365
+ # be non-empty plain text for /raw consumers and validation. Use
366
+ # the briefing lead (which the lead/claim is already derived from).
367
+ PROSE_TEXT="${BRIEFING_LEAD:-$TOPIC}"
368
+
369
+ # Use --rawfile so the rendered HTML is read directly from disk
370
+ # into a JSON-string. Faster than shell-var roundtrip and avoids
371
+ # any quoting headaches for HTML.
372
+ V1JSON=$(jq -nc \
373
+ --arg topic "$TOPIC" \
374
+ --arg claim "$CLAIM" \
375
+ --arg summary "$CLAIM" \
376
+ --arg prose "$PROSE_TEXT" \
377
+ --argjson addressed "$ADDRESSED_TO" \
378
+ --rawfile render_html "$RENDERED_HTML" \
379
+ '{
380
+ kind: "continuation",
381
+ topic: $topic,
382
+ claim: $claim,
383
+ summary: $summary,
384
+ body: { prose: $prose },
385
+ audience: {
386
+ addressed_to: $addressed,
387
+ visible_to: "public",
388
+ extendable_by: "anyone"
389
+ },
390
+ parents: [],
391
+ render_mode: "custom",
392
+ render_html: $render_html
393
+ }')
394
+
395
+ V1RESULT=$(curl -sf -X POST "$EMISSARY_API_URL/emissaries" \
396
+ -H "Content-Type: application/json" \
397
+ -H "Authorization: Bearer $EMISSARY_TOKEN" \
398
+ -d "$V1JSON" 2>/dev/null || echo '{}')
399
+
400
+ V1URL=$(echo "$V1RESULT" | jq -r '.url // ""' 2>/dev/null || echo "")
401
+ fi
402
+ fi
403
+ fi
404
+ fi
405
+
406
+ # Fallback: if the emissary publish didn't produce a URL (no token, no
407
+ # network, size cap, render failure), fire the legacy publish-artifact.sh
408
+ # so handoffs still get a Telegram-able URL.
409
+ if [ -z "$V1URL" ] && [ "$NO_PUBLISH" = "0" ] && [ -x "$SCRIPT_DIR/bin/publish-artifact.sh" ]; then
410
+ AURL=$(bash "$SCRIPT_DIR/bin/publish-artifact.sh" "$RENDER_TYPE" "${RENDER_SRC:-$ABS_FILE}" \
289
411
  --title "$TOPIC" \
290
412
  --author "$AUTHOR" \
291
413
  --description "$BRIEFING_LEAD" 2>/dev/null || echo "")
292
414
  fi
293
- echo "$AURL" > "$TMPD/artifact"
415
+
416
+ # Prefer v1 (emissary-rendered) URL when available; fall back to the
417
+ # legacy egregore-artifacts URL only if the v1 publish failed.
418
+ NOTIFY_URL=""
419
+ if [ -n "$V1URL" ]; then
420
+ echo "$V1URL" > "$TMPD/artifact"
421
+ NOTIFY_URL="$V1URL"
422
+ else
423
+ echo "$AURL" > "$TMPD/artifact"
424
+ NOTIFY_URL="$AURL"
425
+ fi
294
426
 
295
427
  NS="skipped"
296
428
  if [ "$NO_NOTIFY" = "0" ]; then
297
- if [ -n "$AURL" ]; then
429
+ if [ -n "$NOTIFY_URL" ]; then
298
430
  MSG_NOTIFY="Handoff from ${AUTHOR}: ${TOPIC}
299
431
 
300
432
  \"${BRIEFING_SHORT}\"
301
433
 
302
- View: ${AURL}"
434
+ View: ${NOTIFY_URL}"
303
435
  else
304
436
  MSG_NOTIFY="Handoff from ${AUTHOR}: ${TOPIC}
305
437
 
@@ -345,9 +477,27 @@ if [ -s "$TMPD/graph" ]; then
345
477
  GRAPH_STATUS=$(sed -n '3p' "$TMPD/graph")
346
478
  fi
347
479
  [ -z "$RESOLVED" ] && RESOLVED=0
480
+ # Sanitize: --argjson requires a number. If RESOLVED came back as a slug or
481
+ # anything non-numeric (the index-handoff.sh output format has drifted in
482
+ # the past — defensive guard), fall back to 0 so the result file still builds.
483
+ if ! echo "$RESOLVED" | grep -qE '^[0-9]+$'; then
484
+ RESOLVED=0
485
+ fi
348
486
 
349
487
  SUBGRAPH_JSON=$(cat "$TMPD/subgraph" 2>/dev/null || echo "null")
350
488
  [ -z "$SUBGRAPH_JSON" ] && SUBGRAPH_JSON="null"
489
+ # Sanitize: --argjson requires valid JSON. Multiple writes to the same
490
+ # tmpfile have produced concatenated values in the past (e.g. "null\n{...}"),
491
+ # which jq rejects on multi-document input. Validate; on failure, take the
492
+ # LAST non-empty line and re-validate; if still bad, fall back to null.
493
+ if ! printf '%s' "$SUBGRAPH_JSON" | jq -s 'length == 1' 2>/dev/null | grep -q '^true$'; then
494
+ LAST_LINE=$(printf '%s\n' "$SUBGRAPH_JSON" | awk 'NF{last=$0} END{print last}')
495
+ if printf '%s' "$LAST_LINE" | jq -e '.' >/dev/null 2>&1; then
496
+ SUBGRAPH_JSON="$LAST_LINE"
497
+ else
498
+ SUBGRAPH_JSON="null"
499
+ fi
500
+ fi
351
501
 
352
502
  MEMORY_STATUS=$(cat "$TMPD/memory" 2>/dev/null || echo "skipped")
353
503
  ARTIFACT_URL=$(cat "$TMPD/artifact" 2>/dev/null || echo "")
@@ -45,6 +45,11 @@ SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
45
45
  [ -z "$REPO_DIR" ] && REPO_DIR="$SCRIPT_DIR"
46
46
  cd "$REPO_DIR" || exit 1
47
47
 
48
+ # Shared safe-staging guard — never auto-commit stray sibling-repo gitlinks.
49
+ # shellcheck source=bin/lib/git-safe.sh
50
+ . "$SCRIPT_DIR/bin/lib/git-safe.sh" 2>/dev/null || true
51
+ type git_add_guarded >/dev/null 2>&1 || git_add_guarded() { git add -A 2>/dev/null || true; }
52
+
48
53
  # --- Early exit if nothing to do -----------------------------------------
49
54
  DIRTY=0
50
55
  [ -n "$(git status --porcelain 2>/dev/null)" ] && DIRTY=1
@@ -75,7 +80,7 @@ esac
75
80
 
76
81
  # --- Commit whatever is uncommitted --------------------------------------
77
82
  if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
78
- git add -A 2>/dev/null || true
83
+ git_add_guarded
79
84
  git commit -m "Handoff: ${TOPIC}" --quiet 2>/dev/null || true
80
85
  fi
81
86
 
@@ -131,6 +131,10 @@ fi
131
131
  PROJECT="$(md_field 'Project')"
132
132
  [ -z "$PROJECT" ] && PROJECT="$(yaml_extract 'project')"
133
133
 
134
+ # Handoff kind: used by readers to distinguish automatic captures from manual handoffs.
135
+ KIND="$(md_field 'Kind')"
136
+ [ -z "$KIND" ] && KIND="$(yaml_extract 'kind')"
137
+
134
138
  # Summary: 5-level precedence chain
135
139
  # 1. Frontmatter summary field
136
140
  SUMMARY="$(yaml_extract 'summary')"
@@ -198,8 +202,9 @@ fi
198
202
 
199
203
  Q1_CYPHER="MATCH (p:Person) WHERE toLower(p.name) = \$author OR p.github = \$author OR toLower(p.fullName) = \$author OR \$author IN [x IN coalesce(p.previousNames, []) | toLower(x)]
200
204
  MERGE (s:Session {id: \$sessionId})
201
- ON CREATE SET s.date = date(\$date), s.topic = \$topic, s.summary = \$summary, s.filePath = \$filePath, s.handoffStatus = 'pending', s.repoState = \$repoState, s.startedAt = datetime(\$date + 'T00:00:00Z')
202
- ON MATCH SET s.topic = \$topic, s.summary = \$summary, s.filePath = \$filePath, s.repoState = \$repoState
205
+ ON CREATE SET s.date = date(\$date), s.topic = \$topic, s.summary = \$summary, s.filePath = \$filePath, s.author = \$author, s.handoffKind = CASE WHEN \$kind = '' THEN null ELSE \$kind END, s.handoffStatus = 'pending', s.repoState = \$repoState, s.startedAt = datetime(\$date + 'T00:00:00Z')
206
+ ON MATCH SET s.topic = \$topic, s.summary = \$summary, s.filePath = \$filePath, s.author = \$author, s.handoffKind = CASE WHEN \$kind = '' THEN null ELSE \$kind END, s.repoState = \$repoState
207
+ WITH s, p
203
208
  MERGE (s)-[:BY]->(p)${PROJECT_CYPHER}${HANDED_TO_CYPHER}
204
209
  RETURN s.id AS sessionId"
205
210
 
@@ -210,6 +215,7 @@ Q1_PARAMS=$(jq -n \
210
215
  --arg topic "$TOPIC" \
211
216
  --arg summary "$SUMMARY" \
212
217
  --arg filePath "$REL_PATH" \
218
+ --arg kind "$KIND" \
213
219
  --arg repoState "$REPO_STATE_JSON" \
214
220
  "${_PROJECT_ARGS[@]:+${_PROJECT_ARGS[@]}}" \
215
221
  "${_RECIP_ARGS[@]:+${_RECIP_ARGS[@]}}" \
@@ -273,7 +279,7 @@ CURRENT_SID=$(cat "$HOME/.egregore/session-${PROJ_HASH}.id" 2>/dev/null || echo
273
279
  if [ -n "$CURRENT_SID" ]; then
274
280
  bash "$SCRIPT_DIR/bin/graph.sh" query \
275
281
  "MATCH (s:Session {id: \$sid}) SET s.status = 'handed_off' RETURN s.id" \
276
- "$(jq -n --arg sid "$CURRENT_SID" '{sid: $sid}')" 2>/dev/null || true
282
+ "$(jq -n --arg sid "$CURRENT_SID" '{sid: $sid}')" >/dev/null 2>&1 || true
277
283
  fi
278
284
 
279
285
  # --- Parse response ---