create-egregore 0.19.18 → 0.19.20

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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // egregore-launcher-version: 0.19.18
2
+ // egregore-launcher-version: 0.19.20
3
3
  //
4
4
  // egregore — launch and browse Egregore instances from your terminal.
5
5
  // Installed by create-egregore. Reads ~/.egregore/instances.json.
@@ -33,7 +33,7 @@ const crypto = require("node:crypto");
33
33
  const { spawnSync, spawn } = require("node:child_process");
34
34
  const readline = require("node:readline");
35
35
 
36
- const VERSION = "0.19.18";
36
+ const VERSION = "0.19.20";
37
37
  const REGISTRY = path.join(os.homedir(), ".egregore", "instances.json");
38
38
  const DEFAULT_FILE = path.join(os.homedir(), ".egregore", "default");
39
39
  const UPDATE_CACHE_FILE = path.join(os.homedir(), ".egregore", "update-check.json");
@@ -42,7 +42,6 @@ const UPDATE_REGISTRY_URL =
42
42
  process.env.EGREGORE_UPDATE_REGISTRY_URL ||
43
43
  `https://registry.npmjs.org/${PUBLIC_NPX_PACKAGE}/latest`;
44
44
  const UPDATE_AVAILABLE_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
45
- const UPDATE_CURRENT_CACHE_TTL_MS = 5 * 60 * 1000;
46
45
  const UPDATE_FAILURE_CACHE_TTL_MS = 5 * 60 * 1000;
47
46
  const UPDATE_CHECK_TIMEOUT_MS = 1500;
48
47
  const CONNECT_API_URL = (
@@ -750,6 +749,9 @@ async function launch(inst, runtimeOverride) {
750
749
  }
751
750
  const runtime = await chooseRuntimeForLaunch(inst, runtimeOverride);
752
751
  if (!runtime) return;
752
+ if (runtime === "claude-code") {
753
+ normalizeClaudeDenyRules(inst.path);
754
+ }
753
755
  const binary = runtimeBinary(runtime);
754
756
  if (!commandExists(binary)) {
755
757
  die(`${runtimeLabel(runtime)} is not installed.\n Install it with:\n ${assistantInstallCommand(runtime)}\n Then run: egregore`);
@@ -1051,11 +1053,14 @@ function readUpdateCache() {
1051
1053
  const age = Date.now() - new Date(cache.checked_at).getTime();
1052
1054
  const cachedUpdate = cache.latest ? cliUpdateState(cache.latest) : null;
1053
1055
  if (cache.latest && !cachedUpdate) return null;
1056
+ // A cached "current" result is never authoritative. Startup has already
1057
+ // rendered by the time this check runs, so revalidate it in the background
1058
+ // on every launch. Otherwise a release published seconds after a user opens
1059
+ // Egregore stays invisible until the cache TTL expires.
1060
+ if (cachedUpdate && !cachedUpdate.available) return null;
1054
1061
  const ttl = !cache.latest
1055
1062
  ? UPDATE_FAILURE_CACHE_TTL_MS
1056
- : cachedUpdate.available
1057
- ? UPDATE_AVAILABLE_CACHE_TTL_MS
1058
- : UPDATE_CURRENT_CACHE_TTL_MS;
1063
+ : UPDATE_AVAILABLE_CACHE_TTL_MS;
1059
1064
  if (!Number.isFinite(age) || age < 0 || age > ttl) return null;
1060
1065
  if (!cache.latest) return { current: VERSION, latest: null, available: false };
1061
1066
  return cachedUpdate;
@@ -1211,15 +1216,19 @@ function writeJsonFileAtomic(file, value) {
1211
1216
  atomicWrite(file, JSON.stringify(value, null, 2) + "\n", 0o644);
1212
1217
  }
1213
1218
 
1214
- function normalizeClaudeRegistryDenyRule(instancePath) {
1215
- const stale = "Write(~/.egregore/instances.json)";
1216
- const supported = "Edit(~/.egregore/instances.json)";
1219
+ function normalizeClaudeDenyRules(instancePath) {
1217
1220
  for (const relative of [".claude/settings.json", ".claude/settings.local.json"]) {
1218
1221
  const file = path.join(instancePath, relative);
1219
1222
  const settings = readJsonFile(file);
1220
1223
  const deny = settings?.permissions?.deny;
1221
- if (!Array.isArray(deny) || !deny.includes(stale)) continue;
1222
- settings.permissions.deny = [...new Set(deny.map((rule) => rule === stale ? supported : rule))];
1224
+ if (!Array.isArray(deny)) continue;
1225
+ const normalized = deny.map((rule) => (
1226
+ typeof rule === "string" && /^Write\(.+\)$/.test(rule)
1227
+ ? `Edit${rule.slice("Write".length)}`
1228
+ : rule
1229
+ ));
1230
+ if (normalized.every((rule, index) => rule === deny[index])) continue;
1231
+ settings.permissions.deny = [...new Set(normalized)];
1223
1232
  writeJsonFileAtomic(file, settings);
1224
1233
  }
1225
1234
  }
@@ -1452,7 +1461,7 @@ async function cmdConnect(args) {
1452
1461
  api_url: claim.data.api_url,
1453
1462
  });
1454
1463
  writeEnvValueAtomic(envFile, "EGREGORE_API_KEY", claim.data.api_key);
1455
- normalizeClaudeRegistryDenyRule(inst.path);
1464
+ normalizeClaudeDenyRules(inst.path);
1456
1465
  persistConnectState(inst, {
1457
1466
  status: "backfilling",
1458
1467
  activation_protocol: claim.data.activation_protocol,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-egregore",
3
- "version": "0.19.18",
3
+ "version": "0.19.20",
4
4
  "description": "Set up Egregore for your team in one command — installs the egregore launcher and your choice of Claude Code, Codex, or Pi.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -12,7 +12,7 @@
12
12
  "scripts": {
13
13
  "build:runtime": "node scripts/build-runtime-bundle.mjs && node scripts/build-pi-runtime-bundle.mjs",
14
14
  "build:pi-runtime": "node scripts/build-pi-runtime-bundle.mjs",
15
- "test": "node scripts/build-runtime-bundle.mjs --check && node scripts/build-pi-runtime-bundle.mjs --check && node --test test/context.test.js test/parse-join-target.test.js test/skip-repos.test.js test/onboarding-shortcircuit.test.js test/welcome-screen.test.js test/runtime-selection.test.js test/command-generation.test.js test/auth-gh.test.js test/gauntlet-fixes.test.js test/codex-runtime.test.js test/pi-runtime.test.js test/pi-product-workflows.test.js test/installer-runtime-source.test.js test/scroll-repro.test.js test/launcher-refocus-redraw.test.js test/multiselect-remote-search.test.js test/back-navigation.test.js test/manifest-form.test.js test/launcher-delete.test.js test/launcher-connect.test.js test/launcher-update.test.js test/connected-config.test.js",
15
+ "test": "node scripts/build-runtime-bundle.mjs --check && node scripts/build-pi-runtime-bundle.mjs --check && node --test test/context.test.js test/parse-join-target.test.js test/skip-repos.test.js test/onboarding-shortcircuit.test.js test/welcome-screen.test.js test/runtime-selection.test.js test/command-generation.test.js test/auth-gh.test.js test/gauntlet-fixes.test.js test/codex-runtime.test.js test/pi-runtime.test.js test/pi-product-workflows.test.js test/installer-runtime-source.test.js test/scroll-repro.test.js test/launcher-refocus-redraw.test.js test/multiselect-remote-search.test.js test/back-navigation.test.js test/manifest-form.test.js test/launcher-delete.test.js test/launcher-connect.test.js test/launcher-permissions.test.js test/launcher-update.test.js test/connected-config.test.js",
16
16
  "test:e2e": "node --test test/e2e-local.test.js",
17
17
  "test:all": "node scripts/build-runtime-bundle.mjs --check && node scripts/build-pi-runtime-bundle.mjs --check && node --test test/"
18
18
  },
@@ -36,6 +36,7 @@ Runtime-neutral commands:
36
36
  Commit and push current repo + memory changes;
37
37
  PR body follows .claude/context/pr-format.md
38
38
  (auto-generated skeleton if not provided)
39
+ autosave [status|on|off|scope|publish|merge] Personal ambient-save settings + consent
39
40
  wrap --topic T --summary TEXT Write a session wrap to memory/wraps
40
41
  handoff --from A --to B --topic T [--intent action|feedback|fyi]
41
42
  [--body TEXT|--body-file PATH] [--no-push] [--no-publish] [--no-notify] [--json]
@@ -732,6 +733,7 @@ case "${1:-}" in
732
733
  people) shift; cmd_people "$@" ;;
733
734
  branch) shift; cmd_branch "$@" ;;
734
735
  save) shift; cmd_save "$@" ;;
736
+ autosave) shift; bash "$SCRIPT_DIR/bin/autosave.sh" "$@" ;;
735
737
  wrap) shift; cmd_wrap "$@" ;;
736
738
  handoff) shift; cmd_handoff "$@" ;;
737
739
  ask) shift; cmd_ask "$@" ;;
@@ -0,0 +1,86 @@
1
+ #!/bin/bash
2
+ # autosave.sh — per-user autosave settings. The consent surface for ambient
3
+ # session capture (see bin/session-autosave.sh, docs/autosave.md).
4
+ #
5
+ # Settings live in .egregore-state.json (gitignored, personal — same pattern
6
+ # as the transcript_sharing consent flag):
7
+ # autosave_enabled true|false ambient capture on/off (default FALSE — opt-in experiment)
8
+ # autosave_scope all|handoffs what it touches (default all)
9
+ # all = memory repo + core-repo non-coding work
10
+ # handoffs = memory repo only; core repo never touched
11
+ # ambiently
12
+ # autosave_publish gate|auto what happens to the PR (default gate)
13
+ # gate = PR opened, merge waits for your explicit word
14
+ # auto = PR merged to develop automatically
15
+ #
16
+ # Usage:
17
+ # autosave.sh status
18
+ # autosave.sh on | off
19
+ # autosave.sh scope all|handoffs
20
+ # autosave.sh publish gate|auto
21
+ # autosave.sh merge # the consent act: merge YOUR staged auto-save PRs
22
+ #
23
+ # Exposed to Claude via the /autosave skill and to Codex/Pi via
24
+ # `bin/agent.sh autosave ...`.
25
+
26
+ set -uo pipefail
27
+
28
+ SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
29
+ STATE_FILE="$SCRIPT_DIR/.egregore-state.json"
30
+
31
+ [ -f "$STATE_FILE" ] || echo '{}' > "$STATE_FILE"
32
+
33
+ # NB: jq's `//` swallows false — status would show "on" for enabled:false.
34
+ # Test key presence instead.
35
+ get() {
36
+ local v
37
+ v=$(jq -r "if ($1 != null) then ($1|tostring) else empty end" "$STATE_FILE" 2>/dev/null)
38
+ [ -n "$v" ] && echo "$v" || echo "$2"
39
+ }
40
+
41
+ set_key() { # key value(json-encoded)
42
+ jq ".$1 = $2" "$STATE_FILE" > "$STATE_FILE.tmp" && mv "$STATE_FILE.tmp" "$STATE_FILE"
43
+ }
44
+
45
+ status() {
46
+ local enabled scope publish
47
+ enabled=$(get '.autosave_enabled' 'false')
48
+ scope=$(get '.autosave_scope' 'all')
49
+ publish=$(get '.autosave_publish' 'gate')
50
+ echo "autosave: $([ "$enabled" = "true" ] && echo on || echo "off (opt-in — 'autosave on' to join the experiment)")"
51
+ echo "scope: $scope (all = handoffs + artifacts + docs · handoffs = memory repo only)"
52
+ echo "publish: $publish (gate = PRs wait for your merge · auto = merge to develop automatically)"
53
+ }
54
+
55
+ case "${1:-status}" in
56
+ status) status ;;
57
+ on) set_key autosave_enabled true && echo "autosave: on" ;;
58
+ off) set_key autosave_enabled false && echo "autosave: off — nothing is captured ambiently; /save and /handoff still work" ;;
59
+ scope)
60
+ case "${2:-}" in
61
+ all|handoffs) set_key autosave_scope "\"$2\"" && echo "scope: $2" ;;
62
+ *) echo "usage: autosave.sh scope all|handoffs" >&2; exit 2 ;;
63
+ esac ;;
64
+ publish)
65
+ case "${2:-}" in
66
+ gate|auto) set_key autosave_publish "\"$2\"" && echo "publish: $2" ;;
67
+ *) echo "usage: autosave.sh publish gate|auto" >&2; exit 2 ;;
68
+ esac ;;
69
+ merge)
70
+ # The consent act under publish=gate: merge this user's own staged
71
+ # Auto-save PRs. Never touches anyone else's.
72
+ command -v gh >/dev/null 2>&1 || { echo "gh not available" >&2; exit 1; }
73
+ GH_SELF=$(get '.github_username' '')
74
+ [ -n "$GH_SELF" ] || { echo "no github_username in .egregore-state.json" >&2; exit 1; }
75
+ PRS=$(gh pr list --state open --author "$GH_SELF" --limit 20 \
76
+ --json number,title --jq '.[] | select(.title | startswith("Auto-save:")) | .number' 2>/dev/null)
77
+ [ -n "$PRS" ] || { echo "no auto-saves waiting"; exit 0; }
78
+ for n in $PRS; do
79
+ if gh pr merge "$n" --merge >/dev/null 2>&1; then
80
+ echo "✓ merged auto-save PR #$n → develop"
81
+ else
82
+ echo "✗ PR #$n did not merge (conflict with develop?) — it stays open"
83
+ fi
84
+ done ;;
85
+ *) echo "usage: autosave.sh [status|on|off|scope all|handoffs|publish gate|auto|merge]" >&2; exit 2 ;;
86
+ esac
@@ -183,15 +183,26 @@ render_card() {
183
183
  if [ "$total_as" -gt "$seen_as" ]; then
184
184
  new_as=$((total_as - seen_as))
185
185
  merged_as=$(grep '|autosave|' "$autosave_log" 2>/dev/null | tail -n "$new_as" | grep -c '|merged|' || echo 0)
186
- if [ "$merged_as" -ge "$new_as" ]; then
187
- printf ' ⟲ auto-saved %s non-coding save(s) → develop while you were away\n' "$new_as"
188
- else
189
- printf ' ⟲ auto-saved %s non-coding save(s) — %s merged, rest in open PRs\n' "$new_as" "$merged_as"
186
+ if [ "$merged_as" -gt 0 ]; then
187
+ printf ' ⟲ auto-saved %s non-coding save(s) → develop while you were away\n' "$merged_as"
190
188
  fi
191
189
  fi
192
190
  printf '%s' "$total_as" > "$autosave_seen" 2>/dev/null || true
193
191
  fi
194
192
 
193
+ # Staged auto-saves awaiting the user's merge (the autosave consent gate).
194
+ # Live gh query so the line persists until the PRs actually merge.
195
+ if command -v gh >/dev/null 2>&1; then
196
+ gh_self=$(jq -r '.github_username // empty' "$STATE_FILE" 2>/dev/null)
197
+ if [ -n "$gh_self" ]; then
198
+ waiting_as=$(gh pr list --state open --author "$gh_self" --limit 20 \
199
+ --json number,title --jq '[.[] | select(.title | startswith("Auto-save:")) | .number] | join(", #")' 2>/dev/null || true)
200
+ if [ -n "$waiting_as" ]; then
201
+ printf ' ⟲ auto-saves awaiting your merge: PR #%s — \$autosave merge (or bin/agent.sh autosave merge)\n' "$waiting_as"
202
+ fi
203
+ fi
204
+ fi
205
+
195
206
  # Greeting links. Org config `pinned_links` in egregore.json replaces the
196
207
  # default board link — same contract as bin/lib/greeting.sh.
197
208
  pinned_links=$(jq -c '.pinned_links // []' "$CONFIG" 2>/dev/null || echo "[]")
@@ -33,11 +33,13 @@ shift 2 2>/dev/null || true
33
33
  REPO_DIR=""
34
34
  KIND="handoff"
35
35
  BRANCH_SUFFIX=""
36
+ PUBLISH="auto"
36
37
  while [ $# -gt 0 ]; do
37
38
  case "$1" in
38
39
  --repo-dir) REPO_DIR="${2:-}"; shift 2 ;;
39
40
  --kind) KIND="${2:-handoff}"; shift 2 ;;
40
41
  --branch-suffix) BRANCH_SUFFIX="${2:-}"; shift 2 ;;
42
+ --publish) PUBLISH="${2:-auto}"; shift 2 ;;
41
43
  *) shift ;;
42
44
  esac
43
45
  done
@@ -158,14 +160,18 @@ fi
158
160
 
159
161
  [ -n "$PR_NUMBER" ] || { ledger_write "-" "pushed"; exit 0; }
160
162
 
161
- # --- Auto-merge iff non-coding -------------------------------------------
163
+ # --- Merge decision -------------------------------------------------------
164
+ # Non-coding + publish=auto → merge now. publish=gate → the PR is staged and
165
+ # the merge waits for the user's explicit word (the /autosave consent gate).
162
166
  # `--auto` only works when the repo allows auto-merge AND required checks are
163
167
  # pending; on a repo with no branch protection it always errors. Fall back to
164
168
  # an immediate merge so non-coding PRs never sit open.
165
169
  OUTCOME="open"
166
170
  NON_CODE_BLOCKER=$(noncode_blockers origin/develop .)
167
171
  if [ -z "$NON_CODE_BLOCKER" ]; then
168
- if gh pr merge "$PR_NUMBER" --auto --merge >/dev/null 2>&1 \
172
+ if [ "$PUBLISH" = "gate" ]; then
173
+ OUTCOME="staged"
174
+ elif gh pr merge "$PR_NUMBER" --auto --merge >/dev/null 2>&1 \
169
175
  || gh pr merge "$PR_NUMBER" --merge >/dev/null 2>&1; then
170
176
  OUTCOME="merged"
171
177
  fi
@@ -164,15 +164,27 @@ if [ -f "$AUTOSAVE_LOG" ]; then
164
164
  if [ "$TOTAL_AS" -gt "$SEEN_AS" ]; then
165
165
  NEW_AS=$((TOTAL_AS - SEEN_AS))
166
166
  MERGED_AS=$(grep '|autosave|' "$AUTOSAVE_LOG" 2>/dev/null | tail -n "$NEW_AS" | grep -c '|merged|' || echo 0)
167
- if [ "$MERGED_AS" -ge "$NEW_AS" ]; then
168
- printf " ⟲ auto-saved %s non-coding save(s) → develop while you were away\n" "$NEW_AS"
169
- else
170
- printf " ⟲ auto-saved %s non-coding save(s) — %s merged, rest in open PRs\n" "$NEW_AS" "$MERGED_AS"
167
+ if [ "$MERGED_AS" -gt 0 ]; then
168
+ printf " ⟲ auto-saved %s non-coding save(s) → develop while you were away\n" "$MERGED_AS"
171
169
  fi
172
170
  fi
173
171
  printf '%s' "$TOTAL_AS" > "$AUTOSAVE_SEEN" 2>/dev/null || true
174
172
  fi
175
173
 
174
+ # Staged auto-saves awaiting the user's merge (the /autosave consent gate).
175
+ # Live gh query so the line persists until the PRs actually merge — the
176
+ # ledger cursor above only reports once. Fail-soft when gh/network is out.
177
+ if command -v gh >/dev/null 2>&1; then
178
+ GH_SELF=$(jq -r '.github_username // empty' "$STATE_FILE" 2>/dev/null)
179
+ if [ -n "$GH_SELF" ]; then
180
+ WAITING_AS=$(gh pr list --state open --author "$GH_SELF" --limit 20 \
181
+ --json number,title --jq '[.[] | select(.title | startswith("Auto-save:")) | .number] | join(", #")' 2>/dev/null || true)
182
+ if [ -n "$WAITING_AS" ]; then
183
+ printf " ⟲ auto-saves awaiting your merge: PR #%s — say \"merge my auto-saves\"\n" "$WAITING_AS"
184
+ fi
185
+ fi
186
+ fi
187
+
176
188
  # Greeting links. Org config `pinned_links` in egregore.json replaces the
177
189
  # default dashboard + board links. Entries are strings or {url, label}.
178
190
  # (OSC 8 hyperlink — clickable text in supported terminals)
@@ -87,6 +87,26 @@ touch "$STAMP" 2>/dev/null || true
87
87
  # shellcheck source=bin/lib/noncode.sh
88
88
  . "$SCRIPT_ROOT/bin/lib/noncode.sh" 2>/dev/null || exit 0
89
89
 
90
+ # --- Per-user consent settings (bin/autosave.sh / the /autosave skill) ----
91
+ # enabled=false → fully off, nothing captured ambiently
92
+ # scope=handoffs → memory repo only; the core repo is never touched ambiently
93
+ # publish=gate → PR is opened but NEVER merged without the user's word
94
+ # NB: jq's `//` swallows false (falsy) — `.k // empty` on k:false yields the
95
+ # default, silently re-enabling autosave. Test key presence instead (this is
96
+ # the same trap that broke auto_update:false).
97
+ _as_conf() { # key default
98
+ local v
99
+ v=$(jq -r "if (.$1 != null) then (.$1|tostring) else empty end" "$DIR/.egregore-state.json" 2>/dev/null)
100
+ [ -n "$v" ] || v=$(jq -r "if (.$1 != null) then (.$1|tostring) else empty end" "$SCRIPT_ROOT/.egregore-state.json" 2>/dev/null)
101
+ [ -n "$v" ] && echo "$v" || echo "$2"
102
+ }
103
+ # Autosave is an opt-in experiment: enabled defaults to FALSE. Nothing is
104
+ # captured ambiently until the user runs `/autosave on` (or autosave.sh on).
105
+ AS_ENABLED=$(_as_conf autosave_enabled false)
106
+ AS_SCOPE=$(_as_conf autosave_scope all)
107
+ AS_PUBLISH=$(_as_conf autosave_publish gate)
108
+ [ "$AS_ENABLED" = "true" ] || exit 0
109
+
90
110
  if [ -z "$AUTHOR" ]; then
91
111
  AUTHOR=$(jq -r '.github_username // .name // empty' "$DIR/.egregore-state.json" 2>/dev/null || true)
92
112
  [ -n "$AUTHOR" ] || AUTHOR=$(jq -r '.github_username // .name // empty' "$SCRIPT_ROOT/.egregore-state.json" 2>/dev/null || true)
@@ -111,6 +131,10 @@ if git -C "$DIR/memory" rev-parse --git-dir >/dev/null 2>&1; then
111
131
  fi
112
132
  fi
113
133
 
134
+ # scope=handoffs: memory (above) is the handoff channel; stop before the
135
+ # core repo — it is never captured ambiently under this scope.
136
+ [ "$AS_SCOPE" = "handoffs" ] && exit 0
137
+
114
138
  # --- Core repo gate: everything pending must be non-coding ---------------
115
139
  # -uall expands untracked dirs to individual files: a collapsed "docs/" entry
116
140
  # would defeat both the classifier (code nested under a content dir) and the
@@ -144,7 +168,7 @@ ORIG_BRANCH=$(git branch --show-current 2>/dev/null || echo "")
144
168
 
145
169
  bash "$SCRIPT_ROOT/bin/handoff-save-egregore.sh" "$AUTHOR" \
146
170
  "session content $(date +%Y-%m-%d)" --kind autosave --repo-dir "$DIR" \
147
- --branch-suffix "$DIR_HASH" \
171
+ --branch-suffix "$DIR_HASH" --publish "$AS_PUBLISH" \
148
172
  >/dev/null 2>&1 || true
149
173
 
150
174
  # Tell the user what just happened. Under the Stop hook this line surfaces in
@@ -162,6 +186,7 @@ if [ -n "$LAST" ]; then
162
186
  if [ $(( NOW_EPOCH - TS_EPOCH )) -lt 120 ]; then
163
187
  case "$RESULT" in
164
188
  merged) echo "⟲ auto-saved: $NF non-coding file(s) → develop (PR #$PR merged)" ;;
189
+ staged) echo "⟲ auto-saved: $NF non-coding file(s) → PR #$PR — yours to merge (say \"merge my auto-saves\" or /autosave)" ;;
165
190
  open) echo "⟲ auto-saved: $NF non-coding file(s) → PR #$PR (merge pending)" ;;
166
191
  *) echo "⟲ auto-saved: $NF non-coding file(s) → branch pushed (PR pending)" ;;
167
192
  esac
@@ -36,6 +36,7 @@ Runtime-neutral commands:
36
36
  Commit and push current repo + memory changes;
37
37
  PR body follows .claude/context/pr-format.md
38
38
  (auto-generated skeleton if not provided)
39
+ autosave [status|on|off|scope|publish|merge] Personal ambient-save settings + consent
39
40
  wrap --topic T --summary TEXT Write a session wrap to memory/wraps
40
41
  handoff --from A --to B --topic T [--intent action|feedback|fyi]
41
42
  [--body TEXT|--body-file PATH] [--no-push] [--no-publish] [--no-notify] [--json]
@@ -732,6 +733,7 @@ case "${1:-}" in
732
733
  people) shift; cmd_people "$@" ;;
733
734
  branch) shift; cmd_branch "$@" ;;
734
735
  save) shift; cmd_save "$@" ;;
736
+ autosave) shift; bash "$SCRIPT_DIR/bin/autosave.sh" "$@" ;;
735
737
  wrap) shift; cmd_wrap "$@" ;;
736
738
  handoff) shift; cmd_handoff "$@" ;;
737
739
  ask) shift; cmd_ask "$@" ;;
@@ -0,0 +1,86 @@
1
+ #!/bin/bash
2
+ # autosave.sh — per-user autosave settings. The consent surface for ambient
3
+ # session capture (see bin/session-autosave.sh, docs/autosave.md).
4
+ #
5
+ # Settings live in .egregore-state.json (gitignored, personal — same pattern
6
+ # as the transcript_sharing consent flag):
7
+ # autosave_enabled true|false ambient capture on/off (default FALSE — opt-in experiment)
8
+ # autosave_scope all|handoffs what it touches (default all)
9
+ # all = memory repo + core-repo non-coding work
10
+ # handoffs = memory repo only; core repo never touched
11
+ # ambiently
12
+ # autosave_publish gate|auto what happens to the PR (default gate)
13
+ # gate = PR opened, merge waits for your explicit word
14
+ # auto = PR merged to develop automatically
15
+ #
16
+ # Usage:
17
+ # autosave.sh status
18
+ # autosave.sh on | off
19
+ # autosave.sh scope all|handoffs
20
+ # autosave.sh publish gate|auto
21
+ # autosave.sh merge # the consent act: merge YOUR staged auto-save PRs
22
+ #
23
+ # Exposed to Claude via the /autosave skill and to Codex/Pi via
24
+ # `bin/agent.sh autosave ...`.
25
+
26
+ set -uo pipefail
27
+
28
+ SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
29
+ STATE_FILE="$SCRIPT_DIR/.egregore-state.json"
30
+
31
+ [ -f "$STATE_FILE" ] || echo '{}' > "$STATE_FILE"
32
+
33
+ # NB: jq's `//` swallows false — status would show "on" for enabled:false.
34
+ # Test key presence instead.
35
+ get() {
36
+ local v
37
+ v=$(jq -r "if ($1 != null) then ($1|tostring) else empty end" "$STATE_FILE" 2>/dev/null)
38
+ [ -n "$v" ] && echo "$v" || echo "$2"
39
+ }
40
+
41
+ set_key() { # key value(json-encoded)
42
+ jq ".$1 = $2" "$STATE_FILE" > "$STATE_FILE.tmp" && mv "$STATE_FILE.tmp" "$STATE_FILE"
43
+ }
44
+
45
+ status() {
46
+ local enabled scope publish
47
+ enabled=$(get '.autosave_enabled' 'false')
48
+ scope=$(get '.autosave_scope' 'all')
49
+ publish=$(get '.autosave_publish' 'gate')
50
+ echo "autosave: $([ "$enabled" = "true" ] && echo on || echo "off (opt-in — 'autosave on' to join the experiment)")"
51
+ echo "scope: $scope (all = handoffs + artifacts + docs · handoffs = memory repo only)"
52
+ echo "publish: $publish (gate = PRs wait for your merge · auto = merge to develop automatically)"
53
+ }
54
+
55
+ case "${1:-status}" in
56
+ status) status ;;
57
+ on) set_key autosave_enabled true && echo "autosave: on" ;;
58
+ off) set_key autosave_enabled false && echo "autosave: off — nothing is captured ambiently; /save and /handoff still work" ;;
59
+ scope)
60
+ case "${2:-}" in
61
+ all|handoffs) set_key autosave_scope "\"$2\"" && echo "scope: $2" ;;
62
+ *) echo "usage: autosave.sh scope all|handoffs" >&2; exit 2 ;;
63
+ esac ;;
64
+ publish)
65
+ case "${2:-}" in
66
+ gate|auto) set_key autosave_publish "\"$2\"" && echo "publish: $2" ;;
67
+ *) echo "usage: autosave.sh publish gate|auto" >&2; exit 2 ;;
68
+ esac ;;
69
+ merge)
70
+ # The consent act under publish=gate: merge this user's own staged
71
+ # Auto-save PRs. Never touches anyone else's.
72
+ command -v gh >/dev/null 2>&1 || { echo "gh not available" >&2; exit 1; }
73
+ GH_SELF=$(get '.github_username' '')
74
+ [ -n "$GH_SELF" ] || { echo "no github_username in .egregore-state.json" >&2; exit 1; }
75
+ PRS=$(gh pr list --state open --author "$GH_SELF" --limit 20 \
76
+ --json number,title --jq '.[] | select(.title | startswith("Auto-save:")) | .number' 2>/dev/null)
77
+ [ -n "$PRS" ] || { echo "no auto-saves waiting"; exit 0; }
78
+ for n in $PRS; do
79
+ if gh pr merge "$n" --merge >/dev/null 2>&1; then
80
+ echo "✓ merged auto-save PR #$n → develop"
81
+ else
82
+ echo "✗ PR #$n did not merge (conflict with develop?) — it stays open"
83
+ fi
84
+ done ;;
85
+ *) echo "usage: autosave.sh [status|on|off|scope all|handoffs|publish gate|auto|merge]" >&2; exit 2 ;;
86
+ esac
@@ -183,15 +183,26 @@ render_card() {
183
183
  if [ "$total_as" -gt "$seen_as" ]; then
184
184
  new_as=$((total_as - seen_as))
185
185
  merged_as=$(grep '|autosave|' "$autosave_log" 2>/dev/null | tail -n "$new_as" | grep -c '|merged|' || echo 0)
186
- if [ "$merged_as" -ge "$new_as" ]; then
187
- printf ' ⟲ auto-saved %s non-coding save(s) → develop while you were away\n' "$new_as"
188
- else
189
- printf ' ⟲ auto-saved %s non-coding save(s) — %s merged, rest in open PRs\n' "$new_as" "$merged_as"
186
+ if [ "$merged_as" -gt 0 ]; then
187
+ printf ' ⟲ auto-saved %s non-coding save(s) → develop while you were away\n' "$merged_as"
190
188
  fi
191
189
  fi
192
190
  printf '%s' "$total_as" > "$autosave_seen" 2>/dev/null || true
193
191
  fi
194
192
 
193
+ # Staged auto-saves awaiting the user's merge (the autosave consent gate).
194
+ # Live gh query so the line persists until the PRs actually merge.
195
+ if command -v gh >/dev/null 2>&1; then
196
+ gh_self=$(jq -r '.github_username // empty' "$STATE_FILE" 2>/dev/null)
197
+ if [ -n "$gh_self" ]; then
198
+ waiting_as=$(gh pr list --state open --author "$gh_self" --limit 20 \
199
+ --json number,title --jq '[.[] | select(.title | startswith("Auto-save:")) | .number] | join(", #")' 2>/dev/null || true)
200
+ if [ -n "$waiting_as" ]; then
201
+ printf ' ⟲ auto-saves awaiting your merge: PR #%s — \$autosave merge (or bin/agent.sh autosave merge)\n' "$waiting_as"
202
+ fi
203
+ fi
204
+ fi
205
+
195
206
  # Greeting links. Org config `pinned_links` in egregore.json replaces the
196
207
  # default board link — same contract as bin/lib/greeting.sh.
197
208
  pinned_links=$(jq -c '.pinned_links // []' "$CONFIG" 2>/dev/null || echo "[]")
@@ -33,11 +33,13 @@ shift 2 2>/dev/null || true
33
33
  REPO_DIR=""
34
34
  KIND="handoff"
35
35
  BRANCH_SUFFIX=""
36
+ PUBLISH="auto"
36
37
  while [ $# -gt 0 ]; do
37
38
  case "$1" in
38
39
  --repo-dir) REPO_DIR="${2:-}"; shift 2 ;;
39
40
  --kind) KIND="${2:-handoff}"; shift 2 ;;
40
41
  --branch-suffix) BRANCH_SUFFIX="${2:-}"; shift 2 ;;
42
+ --publish) PUBLISH="${2:-auto}"; shift 2 ;;
41
43
  *) shift ;;
42
44
  esac
43
45
  done
@@ -158,14 +160,18 @@ fi
158
160
 
159
161
  [ -n "$PR_NUMBER" ] || { ledger_write "-" "pushed"; exit 0; }
160
162
 
161
- # --- Auto-merge iff non-coding -------------------------------------------
163
+ # --- Merge decision -------------------------------------------------------
164
+ # Non-coding + publish=auto → merge now. publish=gate → the PR is staged and
165
+ # the merge waits for the user's explicit word (the /autosave consent gate).
162
166
  # `--auto` only works when the repo allows auto-merge AND required checks are
163
167
  # pending; on a repo with no branch protection it always errors. Fall back to
164
168
  # an immediate merge so non-coding PRs never sit open.
165
169
  OUTCOME="open"
166
170
  NON_CODE_BLOCKER=$(noncode_blockers origin/develop .)
167
171
  if [ -z "$NON_CODE_BLOCKER" ]; then
168
- if gh pr merge "$PR_NUMBER" --auto --merge >/dev/null 2>&1 \
172
+ if [ "$PUBLISH" = "gate" ]; then
173
+ OUTCOME="staged"
174
+ elif gh pr merge "$PR_NUMBER" --auto --merge >/dev/null 2>&1 \
169
175
  || gh pr merge "$PR_NUMBER" --merge >/dev/null 2>&1; then
170
176
  OUTCOME="merged"
171
177
  fi
@@ -164,15 +164,27 @@ if [ -f "$AUTOSAVE_LOG" ]; then
164
164
  if [ "$TOTAL_AS" -gt "$SEEN_AS" ]; then
165
165
  NEW_AS=$((TOTAL_AS - SEEN_AS))
166
166
  MERGED_AS=$(grep '|autosave|' "$AUTOSAVE_LOG" 2>/dev/null | tail -n "$NEW_AS" | grep -c '|merged|' || echo 0)
167
- if [ "$MERGED_AS" -ge "$NEW_AS" ]; then
168
- printf " ⟲ auto-saved %s non-coding save(s) → develop while you were away\n" "$NEW_AS"
169
- else
170
- printf " ⟲ auto-saved %s non-coding save(s) — %s merged, rest in open PRs\n" "$NEW_AS" "$MERGED_AS"
167
+ if [ "$MERGED_AS" -gt 0 ]; then
168
+ printf " ⟲ auto-saved %s non-coding save(s) → develop while you were away\n" "$MERGED_AS"
171
169
  fi
172
170
  fi
173
171
  printf '%s' "$TOTAL_AS" > "$AUTOSAVE_SEEN" 2>/dev/null || true
174
172
  fi
175
173
 
174
+ # Staged auto-saves awaiting the user's merge (the /autosave consent gate).
175
+ # Live gh query so the line persists until the PRs actually merge — the
176
+ # ledger cursor above only reports once. Fail-soft when gh/network is out.
177
+ if command -v gh >/dev/null 2>&1; then
178
+ GH_SELF=$(jq -r '.github_username // empty' "$STATE_FILE" 2>/dev/null)
179
+ if [ -n "$GH_SELF" ]; then
180
+ WAITING_AS=$(gh pr list --state open --author "$GH_SELF" --limit 20 \
181
+ --json number,title --jq '[.[] | select(.title | startswith("Auto-save:")) | .number] | join(", #")' 2>/dev/null || true)
182
+ if [ -n "$WAITING_AS" ]; then
183
+ printf " ⟲ auto-saves awaiting your merge: PR #%s — say \"merge my auto-saves\"\n" "$WAITING_AS"
184
+ fi
185
+ fi
186
+ fi
187
+
176
188
  # Greeting links. Org config `pinned_links` in egregore.json replaces the
177
189
  # default dashboard + board links. Entries are strings or {url, label}.
178
190
  # (OSC 8 hyperlink — clickable text in supported terminals)
@@ -87,6 +87,26 @@ touch "$STAMP" 2>/dev/null || true
87
87
  # shellcheck source=bin/lib/noncode.sh
88
88
  . "$SCRIPT_ROOT/bin/lib/noncode.sh" 2>/dev/null || exit 0
89
89
 
90
+ # --- Per-user consent settings (bin/autosave.sh / the /autosave skill) ----
91
+ # enabled=false → fully off, nothing captured ambiently
92
+ # scope=handoffs → memory repo only; the core repo is never touched ambiently
93
+ # publish=gate → PR is opened but NEVER merged without the user's word
94
+ # NB: jq's `//` swallows false (falsy) — `.k // empty` on k:false yields the
95
+ # default, silently re-enabling autosave. Test key presence instead (this is
96
+ # the same trap that broke auto_update:false).
97
+ _as_conf() { # key default
98
+ local v
99
+ v=$(jq -r "if (.$1 != null) then (.$1|tostring) else empty end" "$DIR/.egregore-state.json" 2>/dev/null)
100
+ [ -n "$v" ] || v=$(jq -r "if (.$1 != null) then (.$1|tostring) else empty end" "$SCRIPT_ROOT/.egregore-state.json" 2>/dev/null)
101
+ [ -n "$v" ] && echo "$v" || echo "$2"
102
+ }
103
+ # Autosave is an opt-in experiment: enabled defaults to FALSE. Nothing is
104
+ # captured ambiently until the user runs `/autosave on` (or autosave.sh on).
105
+ AS_ENABLED=$(_as_conf autosave_enabled false)
106
+ AS_SCOPE=$(_as_conf autosave_scope all)
107
+ AS_PUBLISH=$(_as_conf autosave_publish gate)
108
+ [ "$AS_ENABLED" = "true" ] || exit 0
109
+
90
110
  if [ -z "$AUTHOR" ]; then
91
111
  AUTHOR=$(jq -r '.github_username // .name // empty' "$DIR/.egregore-state.json" 2>/dev/null || true)
92
112
  [ -n "$AUTHOR" ] || AUTHOR=$(jq -r '.github_username // .name // empty' "$SCRIPT_ROOT/.egregore-state.json" 2>/dev/null || true)
@@ -111,6 +131,10 @@ if git -C "$DIR/memory" rev-parse --git-dir >/dev/null 2>&1; then
111
131
  fi
112
132
  fi
113
133
 
134
+ # scope=handoffs: memory (above) is the handoff channel; stop before the
135
+ # core repo — it is never captured ambiently under this scope.
136
+ [ "$AS_SCOPE" = "handoffs" ] && exit 0
137
+
114
138
  # --- Core repo gate: everything pending must be non-coding ---------------
115
139
  # -uall expands untracked dirs to individual files: a collapsed "docs/" entry
116
140
  # would defeat both the classifier (code nested under a content dir) and the
@@ -144,7 +168,7 @@ ORIG_BRANCH=$(git branch --show-current 2>/dev/null || echo "")
144
168
 
145
169
  bash "$SCRIPT_ROOT/bin/handoff-save-egregore.sh" "$AUTHOR" \
146
170
  "session content $(date +%Y-%m-%d)" --kind autosave --repo-dir "$DIR" \
147
- --branch-suffix "$DIR_HASH" \
171
+ --branch-suffix "$DIR_HASH" --publish "$AS_PUBLISH" \
148
172
  >/dev/null 2>&1 || true
149
173
 
150
174
  # Tell the user what just happened. Under the Stop hook this line surfaces in
@@ -162,6 +186,7 @@ if [ -n "$LAST" ]; then
162
186
  if [ $(( NOW_EPOCH - TS_EPOCH )) -lt 120 ]; then
163
187
  case "$RESULT" in
164
188
  merged) echo "⟲ auto-saved: $NF non-coding file(s) → develop (PR #$PR merged)" ;;
189
+ staged) echo "⟲ auto-saved: $NF non-coding file(s) → PR #$PR — yours to merge (say \"merge my auto-saves\" or /autosave)" ;;
165
190
  open) echo "⟲ auto-saved: $NF non-coding file(s) → PR #$PR (merge pending)" ;;
166
191
  *) echo "⟲ auto-saved: $NF non-coding file(s) → branch pushed (PR pending)" ;;
167
192
  esac