Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1162,13 +1162,17 @@ def _served_platform_identity(data_dir: str) -> dict:
out = {"serving_source": "unknown", "served_sha": None, "platform_sha": None,
"platform_dirty": None, "baked_sha": None}
try:
sentinel = Path("/tmp/serving-source").read_text(encoding="utf-8").strip()
sentinel = Path(
os.environ.get("MOBIUS_SERVING_SOURCE_FILE", "/tmp/serving-source")
).read_text(encoding="utf-8").strip()
if sentinel:
out["serving_source"] = sentinel
except Exception: # incl. UnicodeError, which is not an OSError — never raise
pass
try:
served_sha = Path("/tmp/serving-sha").read_text(encoding="utf-8").strip()
served_sha = Path(
os.environ.get("MOBIUS_SERVING_SHA_FILE", "/tmp/serving-sha")
).read_text(encoding="utf-8").strip()
out["served_sha"] = served_sha or None
except Exception:
pass
Expand Down
105 changes: 67 additions & 38 deletions backend/scripts/pm-commit
Original file line number Diff line number Diff line change
@@ -1,44 +1,73 @@
#!/bin/sh
# pm-commit: stage and commit agent-touched files with a denylist guard.
#
# Default: stage everything visible to git (.gitignore already filters
# most runtime state), then unstage anything in the denylist below. If
# more than 50 files end up staged after that, abort — caller almost
# certainly meant to scope narrower. Pass --allow-broad as the FIRST
# arg to override.
#!/usr/bin/env bash
# pm-commit: create a commit owned by declared paths, without sweeping /data.
#
# Usage:
# pm-commit "message"
# pm-commit --allow-broad "message" # for legitimate big sweeps
set -e
cd /data

ALLOW_BROAD=0
if [ "$1" = "--allow-broad" ]; then
ALLOW_BROAD=1
shift
fi
# START_SHA="$(git -C /data rev-parse HEAD)" # capture before editing
# pm-commit --from "$START_SHA" "message" -- path [path ...]

set -euo pipefail
cd "${PM_COMMIT_ROOT:-/data}"

DENY='^(agent-browser-profiles|compiled|logs|cron-logs|push|generated)/'

git add -A
# Unstage anything matching the denylist (belt-and-braces with .gitignore).
# NUL-delimited the whole way so filenames with spaces / quotes / newlines
# don't trip xargs or the shell.
git diff --cached --name-only -z \
| grep -zE "$DENY" \
| xargs -0r git reset HEAD --

staged=$(git diff --cached --name-only | wc -l)
if [ "$staged" -gt 50 ] && [ "$ALLOW_BROAD" -ne 1 ]; then
echo "pm-commit: $staged files staged — refusing without --allow-broad" >&2
echo "Top of staged set:" >&2
git diff --cached --stat | head -20 >&2
# Unstage everything so the caller isn't left with a fully-staged tree
# they have to clean up by hand.
git reset HEAD -- . >/dev/null
DENY='^(agent-browser-profiles|compiled|logs|cron-logs|push|generated)(/|$)'

usage() {
cat >&2 <<'EOF'
Usage:
pm-commit --from <starting-sha> "message" -- <path> [path ...]
EOF
exit 2
}

[ "${1:-}" = "--from" ] || usage
START="${2:-}"
shift 2
MESSAGE="${1:-}"
[ -n "$MESSAGE" ] || usage
shift
[ "${1:-}" = "--" ] || usage
shift
[ "$#" -gt 0 ] || usage
PATHS=("$@")

git rev-parse --verify "${START}^{commit}" >/dev/null 2>&1 || {
echo "pm-commit: starting revision is not a commit: $START" >&2
exit 2
}
git merge-base --is-ancestor "$START" HEAD || {
echo "pm-commit: history no longer descends from starting revision $START" >&2
exit 3
}

for path in "${PATHS[@]}"; do
case "$path" in
""|/*|../*|*/../*|*/..|-*)
echo "pm-commit: path must be repository-relative: $path" >&2
exit 2 ;;
esac
if [[ "$path" =~ $DENY ]]; then
echo "pm-commit: runtime path may not be committed: $path" >&2
exit 2
fi
done

if ! git diff --quiet "$START"..HEAD -- "${PATHS[@]}"; then
echo "pm-commit: a declared path changed since task start; reconcile it first" >&2
git diff --stat "$START"..HEAD -- "${PATHS[@]}" >&2
exit 3
fi
if ! git diff --cached --quiet -- "${PATHS[@]}"; then
echo "pm-commit: a declared path is already staged; preserve or unstage it first" >&2
exit 3
fi

STATUS="$(git status --porcelain=v1 --untracked-files=all -- "${PATHS[@]}")"
[ -n "$STATUS" ] || exit 0

git diff --cached --quiet && exit 0
git commit -m "$1"
# Intent-to-add lets `git commit --only` include new files while that commit
# ignores every unrelated staged or unstaged change. On failure, restore only
# the paths this command introduced to the index.
git add -N -- "${PATHS[@]}"
cleanup_index() { git reset HEAD -- "${PATHS[@]}" >/dev/null 2>&1 || true; }
trap cleanup_index EXIT
git commit --only -m "$MESSAGE" -- "${PATHS[@]}"
trap - EXIT
42 changes: 0 additions & 42 deletions backend/scripts/reflection_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,6 @@
CLAUDE_CONFIG_DIR = DATA_DIR / "cli-auth" / "claude"
CODEX_HOME = DATA_DIR / "cli-auth" / "codex"
CLI_PATH = "/usr/local/bin/claude"
# The denylist-guarded `git add -A && git commit` helper baked into the image.
PM_COMMIT = "/app/scripts/pm-commit"

# The brief template is baked into the image at /app/scripts; the agent runs
# with cwd=/data and the SDK Read tool is scoped to that subtree, so a Read of
# the /app path fails ("result error: error") even though the file is
Expand Down Expand Up @@ -440,39 +437,6 @@ def write_static_usage_limit_brief(brief_path: Path) -> bool:
)


def _safety_snapshot(label: str) -> None:
"""Best-effort git snapshot of /data BEFORE Reflection mutates anything.

The nightly run rewrites skills, fixes apps, and writes reports — edits to
agent-owned files under /data that the "git is the undo" contract promises are
recoverable. Until now that promise rested entirely on the agent's own
`pm-commit` discipline MID-run, so an early edit before the first commit had
no pre-state restore point beyond LAST night's. Committing the current tree as
the very first thing the run does guarantees one.

`--allow-broad` so a full day's accumulated changes aren't refused by
pm-commit's 50-file guard; a no-op (nothing changed) exits 0. Any failure is
logged and swallowed — a snapshot must NEVER block the night's run.
"""
try:
proc = subprocess.run(
[PM_COMMIT, "--allow-broad", label],
cwd=str(DATA_DIR),
capture_output=True,
text=True,
timeout=120,
)
if proc.returncode == 0:
_log("pre-run safety snapshot committed (or no-op)")
else:
_log(
f"WARN pre-run snapshot rc={proc.returncode}: "
f"{(proc.stderr or '').strip()[:200]}"
)
except Exception as exc:
_log(f"WARN pre-run snapshot failed: {exc!r}")


def _log(message: str) -> None:
"""Appends one timestamped line to the reflection log.

Expand Down Expand Up @@ -1237,12 +1201,6 @@ async def run() -> int:
f"effort={effort or '(default)'} max_turns={max_turns} cwd={DATA_DIR}"
)

# Guaranteed pre-run restore point: commit /data BEFORE the agent rewrites
# skills or apps, so "git is the undo" holds even if tonight's run edits a
# file before its own first pm-commit. Best-effort; never blocks.
from datetime import date
_safety_snapshot(f"reflection: pre-run safety snapshot {date.today().isoformat()}")

try:
rc = await _run_agent_choice(
primary, goal=goal, skill_text=skill_text, env=env,
Expand Down
33 changes: 27 additions & 6 deletions backend/scripts/seed-skills/recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,34 @@ SQLAlchemy `create_all` only CREATEs missing tables; it never adds a column to a

## `/data` is a git repo — commit agent-owned state

`/data/` is a git repo initialized on first boot. After substantial changes (apps, shell, shared data, theme), commit so undo is clean:
For platform source, record the starting revision before editing, then use the
path-owned helper against the platform clone:

```bash
pm-commit 'one-line what and why'
git -C /data/platform rev-parse HEAD
PM_COMMIT_ROOT=/data/platform pm-commit --from <sha-before-edit> \
'one-line what and why' -- <exact changed paths>
```

It stages, unstages a runtime-state denylist (profiles, compiled, logs, generated), then commits; it refuses (exit 2) if >50 files stage after filtering. Re-run with `--allow-broad` only after confirming the staged set is what you meant. Shared user data and editable skills are tracked here, so this history is your undo for a bad app-owned data rewrite or a skill edit you regret. Scheduled background agents should take pre-run snapshots before they touch anything.
The helper commits only those paths, preserves unrelated staged and unstaged
work, and stops when another commit touched one of them since the task began.
Never use `git add -A` for platform work.

`/data/` is a separate git repo. After substantial changes to agent-owned state,
commit so undo is clean:

```bash
git -C /data rev-parse HEAD # record this before editing
pm-commit --from <sha-before-edit> 'one-line what and why' -- <exact paths>
```

It commits only the declared paths and leaves every unrelated staged or
unstaged change alone. If one of those paths changed in another commit since
the recorded starting revision, it stops for reconciliation instead of
guessing who owns the newest `HEAD`. Shared user data and editable skills are
tracked here, so this history is your undo for a bad app-owned data rewrite or
a skill edit you regret. Scheduled background agents follow the same exact-path
contract; they never snapshot another task's working tree.

To actually roll one back, find the commit that last had the good version and restore just that path:

Expand Down Expand Up @@ -150,10 +171,10 @@ Chat files are purged when the chat is permanently deleted (after 7 days). For d

## Viewing apps directly (debugging)

To check an app's rendered output, use the preview helper — it loads the app inside the authenticated Möbius shell, the realistic path the partner takes:
To check an app's rendered output, use the canonical capture helper — it loads the app inside the authenticated Möbius shell, the realistic path the partner takes:

```bash
bash "$SCRIPTS_DIR/preview_app.sh" <id>
bash "$SCRIPTS_DIR/agent-screenshot.sh" --content-only /app/<id>
```

The frame URL (`$API_BASE_URL/api/apps/<id>/frame`) is stable per-app (ETag + browser cache handles freshness, no `?v=`), but the frame waits for a parent-shell `moebius:frame-init` postMessage — opening it standalone just shows "Loading timeout." Always go through the preview helper or the live shell.
The frame URL (`$API_BASE_URL/api/apps/<id>/frame`) is stable per-app (ETag + browser cache handles freshness, no `?v=`), but the frame waits for a parent-shell `moebius:frame-init` postMessage — opening it standalone just shows "Loading timeout." Always go through the authenticated capture helper or the live shell.
10 changes: 5 additions & 5 deletions backend/scripts/seed-skills/reflection.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ preserving the brief and safety contracts.
## The contract for the whole run

- **Be conservative and reversible.** You are operating on the partner's live platform while they sleep. Everything you change is in `/data`'s git history — but prefer changes you'd be comfortable explaining in the morning. **Never auto-apply anything risky** (security fixes with behavior change, destructive data ops, dependency major-bumps, anything that hits paid external APIs or notifies other people). Surface those in the brief as a proposal with a one-tap question, don't do them.
- **Commit as you go.** After each discrete chunk — a skill edit, a system-improvement note, an app fix — `pm-commit '<area>: <what and why>'`. One green-on-green sweep is hard to undo; small commits are easy.
- **Commit as you go, by ownership.** Before each discrete `/data` chunk, record `git -C /data rev-parse HEAD`. After the edit, run `pm-commit --from <that-sha> '<area>: <what and why>' -- <exact paths>`. It commits only those paths and stops if another commit changed one of them. One green-on-green sweep is hard to undo; small path-owned commits are easy.
- **Anti-noise is the whole game.** Every item that reaches the brief MUST carry **trigger** (what you observed), **why** (why it matters to the partner), and **next-action** (the one concrete thing — ideally a tap). An item without all three is noise; drop it or keep digging until it has them. The same rule applies to your own diagnostics: a command without a fresh trigger or an explicit due date is resource noise. A short brief the partner reads fully beats a long one they skim.
- **Leverage the other skills — don't reinvent them.** Batch-read the complete
set implied by the work: `building-apps-quickstart.md` +
Expand Down Expand Up @@ -178,7 +178,7 @@ Capture each answer to a working file (e.g. `/data/apps/reflection/runs/<date>/i

The interviews just told you where the skills failed today's agents. Act on it.

- For each skill-improvement the interviews surfaced, `Read` the named skill under `/data/shared/skills/`, make the **smallest edit that fixes the real gap** (a new gotcha line, a corrected contract, a sharper rule), and `pm-commit 'skill(<name>): <what and why>'`. One commit per skill so each is reversible on its own.
- For each skill-improvement the interviews surfaced, `Read` the named skill under `/data/shared/skills/`, record the `/data` revision, make the **smallest edit that fixes the real gap** (a new gotcha line, a corrected contract, a sharper rule), and `pm-commit --from <sha-before-edit> 'skill(<name>): <what and why>' -- shared/skills/<name>.md`. One commit per skill so each is reversible on its own.
- **Edit THIS skill (`/data/shared/skills/reflection.md`) too.** Reflection is a skill like any other, and you're the agent best placed to improve it. If a phase wasted time, a question got shallow answers, the brief was too long, or you found a better order — change the rule and commit it. Adapt what you prioritize, what you stop doing, how you phrase the interviews. This is the loop that makes each night's reflection better than the last.
- **Treat the prompt as a distilled procedure, not the learning log.** Edit it only when evidence supports a rule that will generalize across future runs. Prefer replacing or removing a stale rule over appending another exception. Record the finding and why it changed the procedure in the bounded meta-learning log described in phase 6.
- **Act on your own run-history (`inputs/reflection-run-history.txt`), not just the interviews.** A failure or friction that recurs across nights (e.g. repeated `exit=2` max_turns nights) is a real signal: if the cause is in this skill, make the smallest durable fix and commit it; if it's code you can't change here — the runner's `max_turns`, the wrapper, the timeout — put a one-line proposal in the brief instead (a daytime `/app` edit doesn't survive a container rebake). Skim your recent self-edits first so you don't re-add a rule a past night removed.
Expand Down Expand Up @@ -225,7 +225,7 @@ Then act on the **system** signal:

Use `/data/shared/skills/memory.md` as the contract for what Memory should have
done, not as permission for Reflection to do that work. When you make a
system-facing change, commit it with `pm-commit 'memory-system: <what and why>'`.
system-facing change, commit it with `pm-commit --from <sha-before-edit> 'memory-system: <what and why>' -- <exact memory paths>`.

### 3.5. IMPROVE THE SYSTEM — follow the strongest operational signal

Expand Down Expand Up @@ -326,7 +326,7 @@ Then, for the apps the digest + interviews confirm the partner actually uses:
- **Suggest a NEW app when a topic recurs with no home for it.** Improving existing apps is only half of it. Scan the day's chats, the interviews, and Memory's `about-the-user` interests for a topic the partner **keeps returning to that no app serves** — they keep asking you about films, tracking the same thing by hand, re-deriving the same numbers in chat. That recurring pull is the signal to propose building one. Same anti-noise bar (trigger: the recurring signal you saw; why: what an app would save them; next-action: a one-tap "build it?") and the same ranking (recurrence × usefulness ÷ effort). At most one strong new-app idea per night; a generic "you could build an app for X" with no usage behind it is noise. A proposal for the brief, never an unattended build.
- **Light security pass (surface, don't auto-fix the risky ones).** A SAST-ish read of changed/owned app source for the usual mini-app footguns — unsanitized HTML injection (needs DOMPurify), secrets or tokens written to storage or logs, a `connect-src`-violating external fetch, an over-broad token scope, an `eval`/`dangerouslySetInnerHTML` on untrusted input. Plus a dependency sanity check (anything pinned to a known-bad or wildly-stale version). **Auto-apply only the trivially-safe, behavior-preserving fixes** (wrap a render in DOMPurify, tighten a token scope) and only when you're certain. **Surface everything else as a proposal** — a security fix that changes behavior is exactly the kind of thing that must wait for a tap.

Commit each fix on its own: `pm-commit 'app(<slug>): <what and why>'`.
Commit each `/data` fix on its own: `pm-commit --from <sha-before-edit> 'app(<slug>): <what and why>' -- <exact paths>`.

### Turn-budget guide

Expand Down Expand Up @@ -491,7 +491,7 @@ After the brief is written, one cheap closing step remains — and one thing you
```
(Bare JSON object, no envelope. `<one-line headline>` is the exec-summary's single most important line.)

Commit the brief + run artifacts: `pm-commit 'reflection: brief for <date>'`.
Commit the brief + run artifacts: `pm-commit --from <sha-before-write> 'reflection: brief for <date>' -- <brief and run-artifact paths>`.

---

Expand Down
3 changes: 3 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@
# MOBIUS_BAKED_STATIC_DIR overrides it — off the host that path is absent, so
# point the override at the stub below (created before app.main imports).
os.environ["MOBIUS_BAKED_STATIC_DIR"] = str(_static)
os.environ["MOBIUS_BUILD_INFO_PATH"] = str(_Path(_tmp) / "missing-build-info.json")
os.environ["MOBIUS_SERVING_SOURCE_FILE"] = str(_Path(_tmp) / "serving-source")
os.environ["MOBIUS_SERVING_SHA_FILE"] = str(_Path(_tmp) / "serving-sha")
if not (_static / "index.html").is_file():
(_static / "assets").mkdir(parents=True, exist_ok=True)
(_static / "index.html").write_text(
Expand Down
Loading