create-egregore 0.21.0-runtime-mvp.10 → 0.21.0-runtime-mvp.12
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.
- package/assets/egregore-launcher.js +2 -2
- package/lib/claude-runtime.js +23 -1
- package/package.json +1 -1
- package/runtime/claude/.claude/skills/handoff/SKILL.md +13 -0
- package/runtime/claude/bin/codex-sync-skills.sh +1 -0
- package/runtime/claude/bin/handoff-preview.sh +71 -7
- package/runtime/claude/bin/lib/git-sync.sh +14 -1
- package/runtime/claude/bin/lib/greeting.sh +14 -0
- package/runtime/claude/bin/lib/settings-drift.sh +196 -0
- package/runtime/claude/bin/settings.sh +36 -0
- package/runtime/claude/egregore_runtime/issue_cli.py +3 -3
- package/runtime/claude/manifest.json +1 -0
- package/runtime/codex/.codex/skills/handoff/SKILL.md +13 -0
- package/runtime/codex/bin/codex-sync-skills.sh +1 -0
- package/runtime/codex/bin/handoff-preview.sh +71 -7
- package/runtime/codex/bin/lib/git-sync.sh +14 -1
- package/runtime/codex/bin/lib/greeting.sh +14 -0
- package/runtime/codex/bin/lib/settings-drift.sh +196 -0
- package/runtime/codex/bin/settings.sh +36 -0
- package/runtime/codex/egregore_runtime/issue_cli.py +3 -3
- package/runtime/codex/manifest.json +1 -0
- package/runtime/pi/.codex/skills/handoff/SKILL.md +13 -0
- package/runtime/pi/bin/codex-sync-skills.sh +1 -0
- package/runtime/pi/bin/handoff-preview.sh +71 -7
- package/runtime/pi/bin/lib/git-sync.sh +14 -1
- package/runtime/pi/bin/lib/greeting.sh +14 -0
- package/runtime/pi/bin/lib/settings-drift.sh +196 -0
- package/runtime/pi/bin/settings.sh +36 -0
- package/runtime/pi/egregore_runtime/issue_cli.py +3 -3
- package/runtime/pi/manifest.json +1 -0
- package/runtime/prime/.codex/skills/handoff/SKILL.md +13 -0
- package/runtime/prime/bin/codex-sync-skills.sh +1 -0
- package/runtime/prime/bin/handoff-preview.sh +71 -7
- package/runtime/prime/bin/lib/git-sync.sh +14 -1
- package/runtime/prime/bin/lib/greeting.sh +14 -0
- package/runtime/prime/bin/lib/settings-drift.sh +196 -0
- package/runtime/prime/bin/settings.sh +36 -0
- package/runtime/prime/egregore_runtime/issue_cli.py +3 -3
- package/runtime/prime/manifest.json +1 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// egregore-launcher-version: 0.21.0-runtime-mvp.
|
|
2
|
+
// egregore-launcher-version: 0.21.0-runtime-mvp.12
|
|
3
3
|
//
|
|
4
4
|
// egregore — launch and browse Egregore instances from your terminal.
|
|
5
5
|
// Installed by create-egregore. Reads ~/.egregore/instances.json.
|
|
@@ -35,7 +35,7 @@ const crypto = require("node:crypto");
|
|
|
35
35
|
const { spawnSync, spawn } = require("node:child_process");
|
|
36
36
|
const readline = require("node:readline");
|
|
37
37
|
|
|
38
|
-
const VERSION = "0.21.0-runtime-mvp.
|
|
38
|
+
const VERSION = "0.21.0-runtime-mvp.12";
|
|
39
39
|
const REGISTRY = path.join(os.homedir(), ".egregore", "instances.json");
|
|
40
40
|
const DEFAULT_FILE = path.join(os.homedir(), ".egregore", "default");
|
|
41
41
|
const UPDATE_CACHE_FILE = path.join(os.homedir(), ".egregore", "update-check.json");
|
package/lib/claude-runtime.js
CHANGED
|
@@ -76,6 +76,24 @@ function copyFile(src, dest) {
|
|
|
76
76
|
try { fs.chmodSync(dest, fs.statSync(src).mode & 0o777); } catch {}
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// The generated settings adapter may replace a stale template settings.json —
|
|
80
|
+
// one carrying nothing the adapter lacks — but an org's customized settings
|
|
81
|
+
// (extra hook events or permission allows, e.g. PreCompact/Stop) are
|
|
82
|
+
// authoritative and are never adopted, even when clean and tracked.
|
|
83
|
+
function settingsSafeToAdopt(destFile, srcFile) {
|
|
84
|
+
try {
|
|
85
|
+
const local = JSON.parse(fs.readFileSync(destFile, "utf8"));
|
|
86
|
+
const bundle = JSON.parse(fs.readFileSync(srcFile, "utf8"));
|
|
87
|
+
const bundleHooks = bundle.hooks || {};
|
|
88
|
+
const extraHooks = Object.keys(local.hooks || {}).filter((k) => !(k in bundleHooks));
|
|
89
|
+
const bundleAllow = new Set(bundle.permissions?.allow || []);
|
|
90
|
+
const extraAllow = (local.permissions?.allow || []).filter((entry) => !bundleAllow.has(entry));
|
|
91
|
+
return extraHooks.length === 0 && extraAllow.length === 0;
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
79
97
|
// One-time ownership migration for pre-bundle Claude installs. A clean,
|
|
80
98
|
// tracked path is part of the cloned framework template and may be adopted by
|
|
81
99
|
// the package. Dirty/untracked paths are user work and remain untouched.
|
|
@@ -153,7 +171,11 @@ function installClaudeRuntime(egregoreDir) {
|
|
|
153
171
|
summary.skipped += 1; record();
|
|
154
172
|
} else if (previousHash && currentHash === previousHash) {
|
|
155
173
|
copyFile(src, dest); summary.updated += 1; record();
|
|
156
|
-
} else if (
|
|
174
|
+
} else if (
|
|
175
|
+
!previousHash
|
|
176
|
+
&& (rel.replaceAll("\\", "/") !== ".claude/settings.json" || settingsSafeToAdopt(dest, src))
|
|
177
|
+
&& isCleanTrackedFrameworkFile(egregoreDir, rel)
|
|
178
|
+
) {
|
|
157
179
|
copyFile(src, dest); summary.updated += 1; record();
|
|
158
180
|
} else {
|
|
159
181
|
summary.conflicted += 1; summary.conflicts.push(rel);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-egregore",
|
|
3
|
-
"version": "0.21.0-runtime-mvp.
|
|
3
|
+
"version": "0.21.0-runtime-mvp.12",
|
|
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": {
|
|
@@ -74,6 +74,19 @@ Preserve supplied wording, order, headings, lists, code, and links. For
|
|
|
74
74
|
generated content, use only the useful subset of Briefing, Current State, Open
|
|
75
75
|
Threads, and Next Steps. Keep it operational and concise.
|
|
76
76
|
|
|
77
|
+
## Staircase (staging)
|
|
78
|
+
|
|
79
|
+
A second face for addressed handoffs exists: the staircase, a
|
|
80
|
+
progressive-disclosure page the receiver walks one step at a time and closes
|
|
81
|
+
with a readback (docs/specs/handoff-staircase.md). It is in staging and does
|
|
82
|
+
not change this skill. When the user asks for a staircase and this instance
|
|
83
|
+
has the staging command (.claude/skills/handoff-staircase/SKILL.md, Codex: the
|
|
84
|
+
handoff-staircase adapter; internal to Curve Labs while in staging), route to
|
|
85
|
+
it: it reuses this preview and approval bridge with a composed spec and adds
|
|
86
|
+
one feedback question at the end. Without it, say the staircase is not
|
|
87
|
+
installed here and continue with this skill. When its feedback
|
|
88
|
+
promotes it, the staircase becomes the default here and that command retires.
|
|
89
|
+
|
|
77
90
|
## One-command preview
|
|
78
91
|
|
|
79
92
|
Pipe the exact canonical Markdown once to the private preview bridge. It stores
|
|
@@ -13,7 +13,7 @@ usage() {
|
|
|
13
13
|
usage:
|
|
14
14
|
bash bin/handoff-preview.sh preview --author HANDLE --recipient HANDLE \
|
|
15
15
|
--topic TOPIC --intent action|feedback|fyi \
|
|
16
|
-
--content-mode supplied|generated < handoff.md
|
|
16
|
+
--content-mode supplied|generated [--composed SPEC.json] < handoff.md
|
|
17
17
|
bash bin/handoff-preview.sh approve TOKEN
|
|
18
18
|
EOF
|
|
19
19
|
}
|
|
@@ -44,7 +44,7 @@ validate_token() {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
preview() {
|
|
47
|
-
local author="" recipient="" topic="" intent="" content_mode=""
|
|
47
|
+
local author="" recipient="" topic="" intent="" content_mode="" composed=""
|
|
48
48
|
while [ $# -gt 0 ]; do
|
|
49
49
|
case "$1" in
|
|
50
50
|
--author) author="${2:-}"; shift 2 ;;
|
|
@@ -52,6 +52,7 @@ preview() {
|
|
|
52
52
|
--topic) topic="${2:-}"; shift 2 ;;
|
|
53
53
|
--intent) intent="${2:-}"; shift 2 ;;
|
|
54
54
|
--content-mode) content_mode="${2:-}"; shift 2 ;;
|
|
55
|
+
--composed) composed="${2:-}"; shift 2 ;;
|
|
55
56
|
*) echo "unknown preview option: $1" >&2; usage; exit 2 ;;
|
|
56
57
|
esac
|
|
57
58
|
done
|
|
@@ -60,16 +61,25 @@ preview() {
|
|
|
60
61
|
[ -n "$topic" ] || { echo "handoff preview requires --topic" >&2; exit 2; }
|
|
61
62
|
case "$intent" in action|feedback|fyi) ;; *) echo "invalid handoff intent" >&2; exit 2 ;; esac
|
|
62
63
|
case "$content_mode" in supplied|generated) ;; *) echo "invalid handoff content mode" >&2; exit 2 ;; esac
|
|
64
|
+
if [ -n "$composed" ]; then
|
|
65
|
+
# A composed spec (for example a staircase) is a presentation map over the
|
|
66
|
+
# canonical Markdown. It rides the same private record and is judged by the
|
|
67
|
+
# same fidelity gate; it never replaces the Markdown as the record.
|
|
68
|
+
[ "$content_mode" = "generated" ] || { echo "--composed requires --content-mode generated" >&2; exit 2; }
|
|
69
|
+
[ -s "$composed" ] || { echo "handoff preview --composed file is missing or empty" >&2; exit 2; }
|
|
70
|
+
jq -e . "$composed" >/dev/null 2>&1 || { echo "handoff preview --composed file is not JSON" >&2; exit 2; }
|
|
71
|
+
fi
|
|
63
72
|
|
|
64
73
|
umask 077
|
|
65
74
|
mkdir -p "$STORE_ROOT"
|
|
66
75
|
chmod 700 "$STORE_ROOT"
|
|
67
76
|
|
|
68
|
-
local token content_file metadata_file digest
|
|
77
|
+
local token content_file metadata_file spec_file digest spec_digest
|
|
69
78
|
token="$(new_token)"
|
|
70
79
|
validate_token "$token"
|
|
71
80
|
content_file="$STORE_ROOT/$token.md"
|
|
72
81
|
metadata_file="$STORE_ROOT/$token.json"
|
|
82
|
+
spec_file="$STORE_ROOT/$token.spec.json"
|
|
73
83
|
cat > "$content_file"
|
|
74
84
|
[ -s "$content_file" ] || {
|
|
75
85
|
rm -f "$content_file"
|
|
@@ -77,6 +87,11 @@ preview() {
|
|
|
77
87
|
exit 2
|
|
78
88
|
}
|
|
79
89
|
digest="$(sha256_file "$content_file")"
|
|
90
|
+
spec_digest=""
|
|
91
|
+
if [ -n "$composed" ]; then
|
|
92
|
+
cp "$composed" "$spec_file"
|
|
93
|
+
spec_digest="$(sha256_file "$spec_file")"
|
|
94
|
+
fi
|
|
80
95
|
jq -n \
|
|
81
96
|
--arg author "$author" \
|
|
82
97
|
--arg recipient "$recipient" \
|
|
@@ -84,14 +99,25 @@ preview() {
|
|
|
84
99
|
--arg intent "$intent" \
|
|
85
100
|
--arg contentMode "$content_mode" \
|
|
86
101
|
--arg digest "$digest" \
|
|
102
|
+
--arg specDigest "$spec_digest" \
|
|
87
103
|
--argjson createdAt "$(date +%s)" \
|
|
88
|
-
'{author:$author,recipient:$recipient,topic:$topic,intent:$intent,contentMode:$contentMode,digest:$digest,createdAt:$createdAt}' \
|
|
104
|
+
'{author:$author,recipient:$recipient,topic:$topic,intent:$intent,contentMode:$contentMode,digest:$digest,specDigest:$specDigest,createdAt:$createdAt}' \
|
|
89
105
|
> "$metadata_file"
|
|
90
106
|
|
|
91
|
-
|
|
107
|
+
local render_ok=0
|
|
108
|
+
if [ -n "$composed" ]; then
|
|
109
|
+
if bash "$ROOT/bin/node-run.sh" \
|
|
110
|
+
"$ROOT/packages/egregore-artifacts/bin/cli.js" composed "$spec_file" \
|
|
111
|
+
--source "$content_file" --verify-fidelity; then
|
|
112
|
+
render_ok=1
|
|
113
|
+
fi
|
|
114
|
+
elif bash "$ROOT/bin/node-run.sh" \
|
|
92
115
|
"$ROOT/packages/egregore-artifacts/bin/cli.js" handoff "$content_file" \
|
|
93
116
|
--verify-fidelity; then
|
|
94
|
-
|
|
117
|
+
render_ok=1
|
|
118
|
+
fi
|
|
119
|
+
if [ "$render_ok" != "1" ]; then
|
|
120
|
+
rm -f "$content_file" "$metadata_file" "$spec_file"
|
|
95
121
|
exit 1
|
|
96
122
|
fi
|
|
97
123
|
|
|
@@ -132,12 +158,26 @@ approve() {
|
|
|
132
158
|
exit 1
|
|
133
159
|
fi
|
|
134
160
|
|
|
135
|
-
local author recipient topic intent content_mode
|
|
161
|
+
local author recipient topic intent content_mode spec_digest
|
|
136
162
|
author="$(jq -er '.author' "$metadata_file")"
|
|
137
163
|
recipient="$(jq -er '.recipient' "$metadata_file")"
|
|
138
164
|
topic="$(jq -er '.topic' "$metadata_file")"
|
|
139
165
|
intent="$(jq -er '.intent' "$metadata_file")"
|
|
140
166
|
content_mode="$(jq -er '.contentMode' "$metadata_file")"
|
|
167
|
+
spec_digest="$(jq -r '.specDigest // ""' "$metadata_file")"
|
|
168
|
+
|
|
169
|
+
# A previewed composed spec is bound to the same token and the same digest
|
|
170
|
+
# discipline as the Markdown: approval sends exactly the previewed bytes.
|
|
171
|
+
local spec_file="$STORE_ROOT/$token.spec.json"
|
|
172
|
+
if [ -n "$spec_digest" ]; then
|
|
173
|
+
if [ ! -f "$spec_file" ] || [ "$(sha256_file "$spec_file")" != "$spec_digest" ]; then
|
|
174
|
+
rmdir "$lock_dir" 2>/dev/null || true
|
|
175
|
+
echo "handoff preview composed spec is missing or changed after approval was requested" >&2
|
|
176
|
+
exit 1
|
|
177
|
+
fi
|
|
178
|
+
else
|
|
179
|
+
spec_file=""
|
|
180
|
+
fi
|
|
141
181
|
|
|
142
182
|
local result_tmp
|
|
143
183
|
result_tmp="$(mktemp -d -t egregore-handoff-result-XXXXXX)"
|
|
@@ -146,6 +186,7 @@ approve() {
|
|
|
146
186
|
--intent "$intent" --content-mode "$content_mode"
|
|
147
187
|
)
|
|
148
188
|
[ -z "$recipient" ] || args+=(--recipient "$recipient")
|
|
189
|
+
[ -z "$spec_file" ] || args+=(--composed "$spec_file")
|
|
149
190
|
|
|
150
191
|
if ! TMPDIR="$result_tmp" bash "$ROOT/bin/artifact-writeback.sh" "${args[@]}" \
|
|
151
192
|
< "$content_file"; then
|
|
@@ -153,10 +194,33 @@ approve() {
|
|
|
153
194
|
exit 1
|
|
154
195
|
fi
|
|
155
196
|
|
|
197
|
+
# Persist the composed spec beside the canonical record so the face can be
|
|
198
|
+
# re-rendered or shared later from memory alone. The record is already
|
|
199
|
+
# canonical at this point; a spec persistence failure is reported, not
|
|
200
|
+
# retried, so the approval can never write the record twice.
|
|
201
|
+
if [ -n "$spec_file" ]; then
|
|
202
|
+
local record_abs record_rel spec_rel
|
|
203
|
+
record_abs="$(jq -r '.absFile // empty' "$result_tmp/capture-run-result.json" 2>/dev/null || true)"
|
|
204
|
+
record_rel="${record_abs#"$ROOT/"}"
|
|
205
|
+
case "$record_rel" in
|
|
206
|
+
memory/*.md)
|
|
207
|
+
spec_rel="${record_rel%.md}.staircase.json"
|
|
208
|
+
if ! bash "$ROOT/bin/artifact-writeback.sh" create \
|
|
209
|
+
--path "$spec_rel" --input "$spec_file" >/dev/null; then
|
|
210
|
+
echo "warning: the canonical record was written but its composed spec could not be persisted at $spec_rel" >&2
|
|
211
|
+
fi
|
|
212
|
+
;;
|
|
213
|
+
*)
|
|
214
|
+
echo "warning: the canonical record path is not under memory/; the composed spec was not persisted" >&2
|
|
215
|
+
;;
|
|
216
|
+
esac
|
|
217
|
+
fi
|
|
218
|
+
|
|
156
219
|
# Consume the approved token before rendering. A renderer failure must not
|
|
157
220
|
# make a successful canonical write eligible for accidental replay.
|
|
158
221
|
mv "$content_file" "$result_tmp/approved-handoff.md"
|
|
159
222
|
mv "$metadata_file" "$result_tmp/approved-handoff.json"
|
|
223
|
+
[ -z "$spec_file" ] || mv "$spec_file" "$result_tmp/approved-handoff.staircase.json"
|
|
160
224
|
rmdir "$lock_dir" 2>/dev/null || true
|
|
161
225
|
bash "$ROOT/bin/render-card.sh" \
|
|
162
226
|
--result "$result_tmp/capture-run-result.json"
|
|
@@ -24,6 +24,10 @@ FRAMEWORK_UPDATED_FILES=""
|
|
|
24
24
|
HEALTH_RETRIEVAL="skip"
|
|
25
25
|
SYNC_RECEIPT=""
|
|
26
26
|
|
|
27
|
+
# Settings drift healer + unshared-settings reporting (see the lib header).
|
|
28
|
+
# shellcheck source=bin/lib/settings-drift.sh
|
|
29
|
+
source "$SCRIPT_DIR/bin/lib/settings-drift.sh"
|
|
30
|
+
|
|
27
31
|
# The core repo's integration branch: "develop" unless egregore.json sets
|
|
28
32
|
# base_branch. session-start.sh resolves this before sourcing; resolve it here
|
|
29
33
|
# too so the lib stands alone for other callers.
|
|
@@ -324,8 +328,17 @@ setup_develop() {
|
|
|
324
328
|
return 1
|
|
325
329
|
fi
|
|
326
330
|
else
|
|
327
|
-
# Already on it — fetch couldn't update a checked-out branch, so pull
|
|
331
|
+
# Already on it — fetch couldn't update a checked-out branch, so pull.
|
|
332
|
+
# Launcher/installer settings drift blocks this merge and the refusal
|
|
333
|
+
# is otherwise silent; heal it first, re-apply the launcher's intent
|
|
334
|
+
# after (bin/lib/settings-drift.sh).
|
|
335
|
+
settings_drift_heal
|
|
328
336
|
git merge --ff-only "origin/$BASE_BRANCH" --quiet 2>/dev/null || true
|
|
337
|
+
settings_drift_reapply
|
|
338
|
+
# Connected mode: reconcile with the control plane (org settings row) —
|
|
339
|
+
# pull a teammate's confirmed write down, or re-deliver a failed push.
|
|
340
|
+
settings_drift_pull
|
|
341
|
+
settings_drift_report
|
|
329
342
|
fi
|
|
330
343
|
|
|
331
344
|
# Never report the intended branch as the actual branch. A checkout can fail
|
|
@@ -95,6 +95,20 @@ if [ -n "$SAVED_BRANCH" ]; then
|
|
|
95
95
|
echo " ✓ Auto-saved uncommitted work on $SAVED_BRANCH"
|
|
96
96
|
fi
|
|
97
97
|
|
|
98
|
+
# --- Unshared org-settings drift (launcher writes not yet shared) ---
|
|
99
|
+
if [ "${SETTINGS_ADAPTER_RESTORED:-false}" = "true" ]; then
|
|
100
|
+
echo " ◇ restored org .claude/settings.json — a generated adapter had replaced it locally"
|
|
101
|
+
fi
|
|
102
|
+
if [ -n "${SETTINGS_SYNCED_FROM_ORG:-}" ]; then
|
|
103
|
+
echo " ◇ org settings synced from the control plane (${SETTINGS_SYNCED_FROM_ORG})"
|
|
104
|
+
fi
|
|
105
|
+
if [ -n "${SETTINGS_POSTURE_HELD:-}" ]; then
|
|
106
|
+
echo " ◇ boundary posture was set to ${SETTINGS_POSTURE_HELD} elsewhere — relaxing access needs your confirm: bash bin/settings.sh posture ${SETTINGS_POSTURE_HELD}"
|
|
107
|
+
fi
|
|
108
|
+
if [ -n "${UNSHARED_SETTINGS_KEYS:-}" ]; then
|
|
109
|
+
echo " ◇ org settings changed on this machine (${UNSHARED_SETTINGS_KEYS}) — say \"share settings\" to make them org-wide"
|
|
110
|
+
fi
|
|
111
|
+
|
|
98
112
|
# --- Footer: health + repos + memory (tertiary) ---
|
|
99
113
|
echo "$SEPARATOR"
|
|
100
114
|
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# settings-drift.sh — heal launcher/installer settings drift around the
|
|
3
|
+
# base-branch fast-forward, and surface unshared org-settings changes.
|
|
4
|
+
#
|
|
5
|
+
# Two org-config surfaces accumulate legitimate local modifications outside
|
|
6
|
+
# any session: .claude/settings.json (the installer's generated public-safe
|
|
7
|
+
# adapter can replace the org's richer committed file) and egregore.json
|
|
8
|
+
# (launcher settings verbs: posture, workflow, people removals). Local
|
|
9
|
+
# modifications to files a fast-forward also changes make git refuse the
|
|
10
|
+
# merge — and that refusal used to be silently swallowed, leaving the
|
|
11
|
+
# checkout behind with no explanation.
|
|
12
|
+
#
|
|
13
|
+
# Contract (sourced; caller provides $BASE_BRANCH and runs from the repo
|
|
14
|
+
# root):
|
|
15
|
+
# settings_drift_heal — before the ff-merge. Restores settings.json
|
|
16
|
+
# only when the local copy is a reduced
|
|
17
|
+
# generated adapter (its hook events and
|
|
18
|
+
# permission allows are a subset of the
|
|
19
|
+
# committed file's). Snapshots egregore.json
|
|
20
|
+
# and returns it to HEAD so the merge can run.
|
|
21
|
+
# settings_drift_reapply — after the ff-merge. Re-applies the snapshot's
|
|
22
|
+
# changed settings-owned keys on top of the
|
|
23
|
+
# incoming egregore.json.
|
|
24
|
+
# settings_drift_report — sets UNSHARED_SETTINGS_KEYS to the
|
|
25
|
+
# settings-owned keys where the working file
|
|
26
|
+
# differs from HEAD, for the greeting.
|
|
27
|
+
#
|
|
28
|
+
# Anything dirty beyond these two files is user work and is never touched.
|
|
29
|
+
|
|
30
|
+
_SETTINGS_OWNED_KEYS='["boundary","base_branch","people_removed","features","repos","admins","people"]'
|
|
31
|
+
_SETTINGS_DRIFT_SNAPSHOT=""
|
|
32
|
+
_SETTINGS_DRIFT_BASE=""
|
|
33
|
+
# Read by bin/lib/greeting.sh after git-sync completes.
|
|
34
|
+
export SETTINGS_ADAPTER_RESTORED="false"
|
|
35
|
+
export UNSHARED_SETTINGS_KEYS=""
|
|
36
|
+
|
|
37
|
+
# The generated adapter only ever removes relative to the org file: fewer
|
|
38
|
+
# hook events, fewer permission allows. A local settings.json that carries
|
|
39
|
+
# anything the committed file lacks is user work and stays untouched.
|
|
40
|
+
_settings_is_reduced_adapter() {
|
|
41
|
+
local tmp_head
|
|
42
|
+
tmp_head=$(mktemp) || return 1
|
|
43
|
+
if ! git show "HEAD:.claude/settings.json" > "$tmp_head" 2>/dev/null; then
|
|
44
|
+
rm -f "$tmp_head"
|
|
45
|
+
return 1
|
|
46
|
+
fi
|
|
47
|
+
jq -es '
|
|
48
|
+
.[0] as $local | .[1] as $head |
|
|
49
|
+
($local != $head) and
|
|
50
|
+
((($local.hooks // {}) | keys) - (($head.hooks // {}) | keys) | length == 0) and
|
|
51
|
+
((($local.permissions.allow // []) - ($head.permissions.allow // [])) | length == 0)
|
|
52
|
+
' .claude/settings.json "$tmp_head" >/dev/null 2>&1
|
|
53
|
+
local verdict=$?
|
|
54
|
+
rm -f "$tmp_head"
|
|
55
|
+
return "$verdict"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
settings_drift_heal() {
|
|
59
|
+
git rev-parse -q --verify "origin/$BASE_BRANCH" >/dev/null 2>&1 || return 0
|
|
60
|
+
[ "$(git rev-parse HEAD 2>/dev/null)" = "$(git rev-parse "origin/$BASE_BRANCH" 2>/dev/null)" ] && return 0
|
|
61
|
+
|
|
62
|
+
if ! git diff --quiet -- .claude/settings.json 2>/dev/null; then
|
|
63
|
+
if _settings_is_reduced_adapter; then
|
|
64
|
+
git checkout -- .claude/settings.json 2>/dev/null && SETTINGS_ADAPTER_RESTORED="true"
|
|
65
|
+
fi
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
if ! git diff --quiet -- egregore.json 2>/dev/null; then
|
|
69
|
+
_SETTINGS_DRIFT_SNAPSHOT=$(cat egregore.json 2>/dev/null || true)
|
|
70
|
+
_SETTINGS_DRIFT_BASE=$(git show "HEAD:egregore.json" 2>/dev/null || true)
|
|
71
|
+
if [ -n "$_SETTINGS_DRIFT_SNAPSHOT" ] && [ -n "$_SETTINGS_DRIFT_BASE" ] \
|
|
72
|
+
&& printf '%s' "$_SETTINGS_DRIFT_SNAPSHOT" | jq -e . >/dev/null 2>&1; then
|
|
73
|
+
git checkout -- egregore.json 2>/dev/null || _SETTINGS_DRIFT_SNAPSHOT=""
|
|
74
|
+
else
|
|
75
|
+
_SETTINGS_DRIFT_SNAPSHOT=""
|
|
76
|
+
fi
|
|
77
|
+
fi
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
settings_drift_reapply() {
|
|
81
|
+
[ -n "$_SETTINGS_DRIFT_SNAPSHOT" ] || return 0
|
|
82
|
+
local tmp_local tmp_base merged
|
|
83
|
+
tmp_local=$(mktemp) && tmp_base=$(mktemp) || return 0
|
|
84
|
+
printf '%s' "$_SETTINGS_DRIFT_SNAPSHOT" > "$tmp_local"
|
|
85
|
+
printf '%s' "$_SETTINGS_DRIFT_BASE" > "$tmp_base"
|
|
86
|
+
merged=$(jq -s --argjson keys "$_SETTINGS_OWNED_KEYS" '
|
|
87
|
+
.[0] as $incoming | .[1] as $local | .[2] as $base |
|
|
88
|
+
reduce $keys[] as $k ($incoming;
|
|
89
|
+
if ($local[$k] // null) != ($base[$k] // null) then
|
|
90
|
+
if ($local | has($k)) then .[$k] = $local[$k] else del(.[$k]) end
|
|
91
|
+
else . end)
|
|
92
|
+
' egregore.json "$tmp_local" "$tmp_base" 2>/dev/null || true)
|
|
93
|
+
rm -f "$tmp_local" "$tmp_base"
|
|
94
|
+
if [ -n "$merged" ] && printf '%s' "$merged" | jq -e . >/dev/null 2>&1; then
|
|
95
|
+
printf '%s\n' "$merged" > egregore.json
|
|
96
|
+
fi
|
|
97
|
+
_SETTINGS_DRIFT_SNAPSHOT=""
|
|
98
|
+
_SETTINGS_DRIFT_BASE=""
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# ── control-plane sync (Connected mode) ─────────────────────────────────
|
|
102
|
+
# The control plane is authoritative for org settings in Connected mode: a
|
|
103
|
+
# teammate's confirmed CLI write lands in the orgs row immediately, and every
|
|
104
|
+
# session start pulls it down here. One trust rule: an incoming posture that
|
|
105
|
+
# RELAXES local file access is never auto-applied — it is held and surfaced
|
|
106
|
+
# for local confirm; tightening applies immediately. If the server has not
|
|
107
|
+
# moved but the local settings-owned keys have (an offline write whose push
|
|
108
|
+
# failed), the local state is pushed instead.
|
|
109
|
+
_settings_posture_rank() {
|
|
110
|
+
case "$1" in strict) echo 0 ;; open) echo 2 ;; *) echo 1 ;; esac
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export SETTINGS_POSTURE_HELD=""
|
|
114
|
+
export SETTINGS_SYNCED_FROM_ORG=""
|
|
115
|
+
# true once the control plane and the local settings-owned keys agree — the
|
|
116
|
+
# greeting then never asks the user to share what is already org-wide.
|
|
117
|
+
export SETTINGS_CP_SYNCED="false"
|
|
118
|
+
|
|
119
|
+
settings_drift_pull() {
|
|
120
|
+
local api_url api_key resp revision applied local_subset server_settings
|
|
121
|
+
api_url=$(jq -r '.api_url // empty' egregore.json 2>/dev/null)
|
|
122
|
+
[ -n "$api_url" ] || return 0
|
|
123
|
+
api_key=$(grep '^EGREGORE_API_KEY=' .env 2>/dev/null | cut -d'=' -f2-)
|
|
124
|
+
[ -n "$api_key" ] || return 0
|
|
125
|
+
resp=$(curl -s --max-time 8 -H "Authorization: Bearer $api_key" "$api_url/api/org/settings" 2>/dev/null) || return 0
|
|
126
|
+
printf '%s' "$resp" | jq -e '.settings' >/dev/null 2>&1 || return 0
|
|
127
|
+
revision=$(printf '%s' "$resp" | jq -r '.settings_revision // ""')
|
|
128
|
+
server_settings=$(printf '%s' "$resp" | jq -c '.settings')
|
|
129
|
+
applied=$(jq -r '.org_settings_revision // ""' .egregore-state.json 2>/dev/null)
|
|
130
|
+
local_subset=$(jq -c --argjson keys "$_SETTINGS_OWNED_KEYS" \
|
|
131
|
+
'with_entries(select(.key as $k | $keys | index($k)))' egregore.json 2>/dev/null)
|
|
132
|
+
|
|
133
|
+
if [ -n "$revision" ] && [ "$revision" != "$applied" ] && [ "$server_settings" != "{}" ]; then
|
|
134
|
+
# Server moved: apply it, holding a posture relaxation for local confirm.
|
|
135
|
+
local incoming_posture local_posture hold="false"
|
|
136
|
+
incoming_posture=$(printf '%s' "$resp" | jq -r '.settings.boundary.posture // empty')
|
|
137
|
+
local_posture=$(jq -r '.boundary.posture // "standard"' egregore.json 2>/dev/null)
|
|
138
|
+
if [ -n "$incoming_posture" ] \
|
|
139
|
+
&& [ "$(_settings_posture_rank "$incoming_posture")" -gt "$(_settings_posture_rank "$local_posture")" ]; then
|
|
140
|
+
hold="true"
|
|
141
|
+
SETTINGS_POSTURE_HELD="$incoming_posture"
|
|
142
|
+
export SETTINGS_POSTURE_HELD
|
|
143
|
+
fi
|
|
144
|
+
local merged tmp
|
|
145
|
+
merged=$(printf '%s' "$server_settings" | jq -c \
|
|
146
|
+
--argjson keys "$_SETTINGS_OWNED_KEYS" --argjson hold "$hold" --slurpfile cfg egregore.json '
|
|
147
|
+
. as $s | $cfg[0] as $local |
|
|
148
|
+
reduce $keys[] as $k ($local;
|
|
149
|
+
if ($s | has($k)) then
|
|
150
|
+
if $k == "boundary" and $hold then
|
|
151
|
+
.boundary = (($s.boundary // {}) + {posture: ($local.boundary.posture // "standard")})
|
|
152
|
+
else .[$k] = $s[$k] end
|
|
153
|
+
else del(.[$k]) end)
|
|
154
|
+
' 2>/dev/null || true)
|
|
155
|
+
if [ -n "$merged" ] && printf '%s' "$merged" | jq -e . >/dev/null 2>&1; then
|
|
156
|
+
tmp=$(mktemp "egregore.json.XXXXXX") || return 0
|
|
157
|
+
printf '%s\n' "$merged" | jq . > "$tmp" 2>/dev/null && [ -s "$tmp" ] && mv "$tmp" egregore.json || { rm -f "$tmp"; return 0; }
|
|
158
|
+
SETTINGS_SYNCED_FROM_ORG=$(printf '%s' "$resp" | jq -r '.settings | keys | join(", ")')
|
|
159
|
+
export SETTINGS_SYNCED_FROM_ORG
|
|
160
|
+
# A held posture keeps the revision unapplied so the next start retries.
|
|
161
|
+
if [ "$hold" = "false" ] && [ -f .egregore-state.json ]; then
|
|
162
|
+
tmp=$(mktemp ".egregore-state.json.XXXXXX") || return 0
|
|
163
|
+
jq --arg rev "$revision" '.org_settings_revision = $rev' .egregore-state.json > "$tmp" 2>/dev/null \
|
|
164
|
+
&& [ -s "$tmp" ] && mv "$tmp" .egregore-state.json || rm -f "$tmp"
|
|
165
|
+
SETTINGS_CP_SYNCED="true"; export SETTINGS_CP_SYNCED
|
|
166
|
+
fi
|
|
167
|
+
fi
|
|
168
|
+
elif [ -n "$revision" ] && [ "$revision" = "$applied" ] && [ "$local_subset" != "$server_settings" ]; then
|
|
169
|
+
# Server unchanged but local settings moved (offline write, failed push):
|
|
170
|
+
# deliver the local state now. bin/settings.sh owns the push mechanics.
|
|
171
|
+
if bash bin/settings.sh push >/dev/null 2>&1; then
|
|
172
|
+
SETTINGS_CP_SYNCED="true"; export SETTINGS_CP_SYNCED
|
|
173
|
+
fi
|
|
174
|
+
elif [ "$local_subset" = "$server_settings" ]; then
|
|
175
|
+
SETTINGS_CP_SYNCED="true"; export SETTINGS_CP_SYNCED
|
|
176
|
+
fi
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
settings_drift_report() {
|
|
180
|
+
UNSHARED_SETTINGS_KEYS=""
|
|
181
|
+
# Control plane already carries these settings — nothing to ask the user.
|
|
182
|
+
[ "${SETTINGS_CP_SYNCED:-false}" = "true" ] && return 0
|
|
183
|
+
git diff --quiet -- egregore.json 2>/dev/null && return 0
|
|
184
|
+
local tmp_head
|
|
185
|
+
tmp_head=$(mktemp) || return 0
|
|
186
|
+
if ! git show "HEAD:egregore.json" > "$tmp_head" 2>/dev/null; then
|
|
187
|
+
rm -f "$tmp_head"
|
|
188
|
+
return 0
|
|
189
|
+
fi
|
|
190
|
+
UNSHARED_SETTINGS_KEYS=$(jq -rs --argjson keys "$_SETTINGS_OWNED_KEYS" '
|
|
191
|
+
.[0] as $local | .[1] as $head |
|
|
192
|
+
[$keys[] | select(($local[.] // null) != ($head[.] // null))] | join(", ")
|
|
193
|
+
' egregore.json "$tmp_head" 2>/dev/null || true)
|
|
194
|
+
rm -f "$tmp_head"
|
|
195
|
+
export UNSHARED_SETTINGS_KEYS
|
|
196
|
+
}
|
|
@@ -40,12 +40,47 @@ _save() {
|
|
|
40
40
|
tmp="$(mktemp "$CONFIG.XXXXXX")" || _die "cannot create temp file" 1
|
|
41
41
|
if jq "$@" "$filter" "$CONFIG" > "$tmp" 2>/dev/null && [ -s "$tmp" ]; then
|
|
42
42
|
mv "$tmp" "$CONFIG"
|
|
43
|
+
_control_plane_push
|
|
43
44
|
else
|
|
44
45
|
rm -f "$tmp"
|
|
45
46
|
_die "failed to update $CONFIG (invalid jq filter or unwritable file)" 1
|
|
46
47
|
fi
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
# ── control-plane push (Connected mode) ──────────────────────────────────
|
|
51
|
+
# A confirmed settings write reaches the control plane immediately — no
|
|
52
|
+
# harness session, no git ceremony in the loop. Fail-soft: the file write
|
|
53
|
+
# already landed, and a missed push is reconciled by the next session
|
|
54
|
+
# start's settings sync (bin/lib/settings-drift.sh).
|
|
55
|
+
_control_plane_push() {
|
|
56
|
+
local api_url api_key payload actor response revision
|
|
57
|
+
api_url=$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)
|
|
58
|
+
[ -n "$api_url" ] || return 0
|
|
59
|
+
api_key=$(grep '^EGREGORE_API_KEY=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d'=' -f2-)
|
|
60
|
+
[ -n "$api_key" ] || return 0
|
|
61
|
+
actor=$(jq -r '.github_username // empty' "$SCRIPT_DIR/.egregore-state.json" 2>/dev/null)
|
|
62
|
+
payload=$(jq -c --arg by "$actor" '{
|
|
63
|
+
settings: ({boundary, base_branch, people_removed, features, repos, admins, people}
|
|
64
|
+
| with_entries(select(.value != null))),
|
|
65
|
+
updated_by: (if $by == "" then null else $by end)
|
|
66
|
+
}' "$CONFIG" 2>/dev/null)
|
|
67
|
+
[ -n "$payload" ] || return 0
|
|
68
|
+
response=$(curl -s --max-time 10 -X PUT "$api_url/api/org/settings" \
|
|
69
|
+
-H "Authorization: Bearer $api_key" -H "Content-Type: application/json" \
|
|
70
|
+
--data-binary "$payload" 2>/dev/null) || return 0
|
|
71
|
+
revision=$(printf '%s' "$response" | jq -r '.settings_revision // empty' 2>/dev/null)
|
|
72
|
+
if [ -n "$revision" ] && [ -f "$SCRIPT_DIR/.egregore-state.json" ]; then
|
|
73
|
+
local st
|
|
74
|
+
st="$(mktemp "$SCRIPT_DIR/.egregore-state.json.XXXXXX")" || return 0
|
|
75
|
+
if jq --arg rev "$revision" '.org_settings_revision = $rev' "$SCRIPT_DIR/.egregore-state.json" > "$st" 2>/dev/null && [ -s "$st" ]; then
|
|
76
|
+
mv "$st" "$SCRIPT_DIR/.egregore-state.json"
|
|
77
|
+
else
|
|
78
|
+
rm -f "$st"
|
|
79
|
+
fi
|
|
80
|
+
fi
|
|
81
|
+
return 0
|
|
82
|
+
}
|
|
83
|
+
|
|
49
84
|
_has_repo() { jq -e --arg n "$1" 'any(.repos[]?; (if type=="object" then .name else . end) == $n)' "$CONFIG" >/dev/null; }
|
|
50
85
|
_has_admin() { jq -e --arg h "$1" 'any(.admins[]?; . == $h)' "$CONFIG" >/dev/null; }
|
|
51
86
|
|
|
@@ -559,6 +594,7 @@ case "$DOMAIN" in
|
|
|
559
594
|
people) cmd_people "$@" ;;
|
|
560
595
|
privacy) cmd_privacy ;;
|
|
561
596
|
posture) cmd_posture "$@" ;;
|
|
597
|
+
push) _control_plane_push ;;
|
|
562
598
|
boundary) cmd_boundary "$@" ;;
|
|
563
599
|
dump) cmd_dump ;;
|
|
564
600
|
""|-h|--help|help) usage 0 ;;
|
|
@@ -61,7 +61,7 @@ def _snapshot(runtime, actor, service, permission: Permission = Permission.READ)
|
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
def _resolve(runtime, actor, service, reference: str) -> IssueSnapshot:
|
|
64
|
-
rows, _ = _snapshot(runtime, actor)
|
|
64
|
+
rows, _ = _snapshot(runtime, actor, service)
|
|
65
65
|
return service.resolve(rows, reference)
|
|
66
66
|
|
|
67
67
|
|
|
@@ -72,7 +72,7 @@ def _write_result(issue: IssueSnapshot, receipt) -> int:
|
|
|
72
72
|
|
|
73
73
|
def _list(args):
|
|
74
74
|
_, runtime, actor, service = _context()
|
|
75
|
-
rows, _ = _snapshot(runtime, actor)
|
|
75
|
+
rows, _ = _snapshot(runtime, actor, service)
|
|
76
76
|
selected = rows if args.status == "all" else tuple(
|
|
77
77
|
row for row in rows if row.status == args.status
|
|
78
78
|
)
|
|
@@ -89,7 +89,7 @@ def _show(args):
|
|
|
89
89
|
|
|
90
90
|
def _search(args):
|
|
91
91
|
_, runtime, actor, service = _context()
|
|
92
|
-
rows, _ = _snapshot(runtime, actor)
|
|
92
|
+
rows, _ = _snapshot(runtime, actor, service)
|
|
93
93
|
terms = tuple(part for part in args.term.casefold().split() if part)
|
|
94
94
|
|
|
95
95
|
def matches(row: IssueSnapshot) -> bool:
|
|
@@ -70,6 +70,19 @@ Preserve supplied wording, order, headings, lists, code, and links. For
|
|
|
70
70
|
generated content, use only the useful subset of Briefing, Current State, Open
|
|
71
71
|
Threads, and Next Steps. Keep it operational and concise.
|
|
72
72
|
|
|
73
|
+
## Staircase (staging)
|
|
74
|
+
|
|
75
|
+
A second face for addressed handoffs exists: the staircase, a
|
|
76
|
+
progressive-disclosure page the receiver walks one step at a time and closes
|
|
77
|
+
with a readback (docs/specs/handoff-staircase.md). It is in staging and does
|
|
78
|
+
not change this skill. When the user asks for a staircase and this instance
|
|
79
|
+
has the staging command (.claude/skills/handoff-staircase/SKILL.md, Codex: the
|
|
80
|
+
handoff-staircase adapter; internal to Curve Labs while in staging), route to
|
|
81
|
+
it: it reuses this preview and approval bridge with a composed spec and adds
|
|
82
|
+
one feedback question at the end. Without it, say the staircase is not
|
|
83
|
+
installed here and continue with this skill. When its feedback
|
|
84
|
+
promotes it, the staircase becomes the default here and that command retires.
|
|
85
|
+
|
|
73
86
|
## One-command preview
|
|
74
87
|
|
|
75
88
|
Pipe the exact canonical Markdown once to the private preview bridge. It stores
|