Imported from danverbraganza/red-scribble (
.agents/skills/update-self/SKILL.md). Install upstream withnpx skills add danverbraganza/red-scribble --skill update-self. Copyright stays with the author.
Pulling updates from the upstream template, safely
This repo was created from a template repo and stays connected to it via a git
remote (system/config/parent.toml has the URL and branch). Upstream carries the shared
infrastructure: skills, scripts, CLAUDE.md scaffolding, Dockerfile,
system/supervisord.conf, the system interface, the vendored mngr.
Merging upstream can break the live workspace -- a settings-schema change the
running system_interface can't parse, a bumped system/vendor/mngr, a new service.
So, like update-system-interface, this flow never mutates the live tree from an
unverified state: an isolated worker does the merge and validation on its own
branch, and only a known-good, user-approved result is landed and applied.
You are the lead: resolve the target, dispatch the worker, proxy its one gate, present the approval gate, and -- on approval -- land the merge and reveal each change by its class. The worker owns the merge, the conflict triage, and the validation; you own going live.
The default target is the latest stable minds-v* tag (released,
already-tested), not origin/main -- and never newer than the Minds app driving
this workspace, since the template ships the code that app talks to. The user may
override to a specific tag or to main.
Because the update flow itself evolves, once the target is resolved this pass
re-points itself at the target version's own copy of the update-self skill
(Step 2a) and runs the rest -- lead and worker -- from there. So a fix to the
conflict triage, validation, or reveal logic that shipped in the release is
applied on the way in, instead of staying a release behind in the local copy.
That copy is staged at one fixed path --
data/.tasks/update-self/skill-at-target/.agents/skills/update-self -- which the lead
and worker both address by literal (no shell state carried between commands, since
each bash invocation starts a fresh shell).
1. Preconditions
Back up first. Before dispatching anything, capture a restore point of the whole workspace so the pass is recoverable -- the reveal re-runs provisioners and restarts services, and a backup is the recovery path if one of those goes wrong:
uv run host-backup-now
It waits for any in-flight backup, forces a fresh tick, and prints the tick's
terminal event -- exit 0 means restic_backup_succeeded; confirm that before
continuing. Exit 3 means backups aren't configured
(tick_skipped_due_to_missing_secrets -- no data/.secrets/restic.env), so there
is no restore point: tell the user, and get their explicit go-ahead before
proceeding without one. Exit 1 is a failed backup attempt; exit 2 means the
outcome could not be observed at all (the tick may still be running, or the
service is not writing events) -- neither confirms a restore point, so treat both
the same way as exit 3.
Single-flight. One pass at a time (its worker name, branch, and runtime dir are fixed). Check for a live one:
tk ready > /tmp/update-self-inflight.txt
grep "update-self" /tmp/update-self-inflight.txt
If a live update-self ticket exists, stop and tell the user; if it looks
abandoned, take it over per .agents/shared/references/harden-contention.md.
Clean tree. The worker branches off your HEAD and the rollback captures it.
If git status --porcelain is non-empty, surface it and stop.
2. Resolve the target
Ensure the remote exists, fetch with tags, and resolve the ref:
git remote get-url upstream 2>/dev/null || git remote add upstream "$(python3 -c "
import tomllib
with open('system/config/parent.toml', 'rb') as f:
print(tomllib.load(f)['url'])
")"
# Heal a shallow-history workspace before fetching. Workspaces created from a
# pool host baked before the full-history bake fix carry a `--depth 1` clone:
# `git log` dead-ends at a parentless graft commit, `git describe` fails, and
# "what changed since the fork point" is unanswerable. The upstream template is
# public, so completing the history is one fetch; the guard keeps this a no-op
# everywhere else (`--unshallow` errors on a repo that is not shallow).
# `--git-common-dir` (not `--git-dir`) because the shallow marker is repo-wide
# state that lives in the common dir, which is what a worktree checkout shares.
if [ -f "$(git rev-parse --git-common-dir)/shallow" ]; then
git fetch --unshallow upstream
fi
git fetch upstream --tags
python3 .agents/skills/update-self/scripts/update_self.py resolve-target --local-tags \
> /tmp/update-self-target.json || exit 1
# `--local-tags` reads the tags the fetch above just landed (no second network
# round-trip). Honoring a user override, append e.g. `--override main` or
# `--override minds-v0.3.6` to the resolve-target call above. The `|| exit 1`
# leaves a refusal's `error:` line as the last thing printed -- without it the
# read below fails on the empty file and buries that line under a traceback.
cat /tmp/update-self-target.json
REF=$(python3 -c 'import json; print(json.load(open("/tmp/update-self-target.json"))["ref"])')
resolve-target prints {"ref": ..., "kind": "tag|branch|ref", "ceiling": ..., "exceeds_ceiling": ..., "latest_available": ..., "held_back_by_ceiling": ...};
main resolves to upstream/main (not the stale local branch). Keep $REF in
your shell for the dispatch below, and tell the user which version you're
updating to.
If the command exits non-zero, stop -- nothing is wrong with the workspace.
It prints a single plain-language error: line saying why no target could be
chosen. Relay that line in plain terms per the §5a composition rules and offer
the next step; the usual reasons each have a different answer for the user: the
minds app could not be reached (it is closed, or the gateway is down -- retry once
it is running); the app is too old to report its version (update the minds app
itself first, then re-run); every release upstream is already newer than the app;
or the workspace is already on the release it may take -- which is either "you are
current" or "updating the app unlocks the newer one," and the error: line says
which. Do not work around it by resolving a ref by hand.
The version ceiling
The default target is capped at the version of the minds app driving this
workspace, which resolve-target reads from the app itself (ceiling in the
output). This matters because the template carries the code the app talks to --
the system interface and the vendored mngr -- so a workspace running a template
newer than its app would be speaking a protocol the app does not know. When the
app reports a branch rather than a release tag (a dev build) there is nothing to
compare against and ceiling does not cap anything.
A workspace already sitting at the ceiling gets a refusal rather than a pass:
the capped target is the release it was created from, so there is nothing to
merge, and resolve-target says so instead of spending a backup, a worker and a
validation run on a no-op -- naming the newer release the app is holding back
when there is one. A workspace behind the ceiling still updates to it: being
capped is not the same as having nothing to gain, which is why the two cases are
distinguished by whether the resolved ref is already an ancestor of HEAD and
not by the ceiling alone.
"exceeds_ceiling": true means the user's --override names a version this
app cannot vouch for -- newer than the app, or a branch/commit whose version
can't be compared. Do not dispatch the worker on it silently. Tell the user
plainly what they asked for and what it risks ("that version is newer than your
Minds app, so parts of your workspace may stop working until you update the app
itself"), and get an explicit go-ahead before continuing. This is a separate
confirmation from the Step 5a approval gate, and it comes first: 5a asks whether
to apply a verified update, this asks whether to attempt an unsupported one at
all. An override at or below the ceiling needs no extra confirmation.
To preview what the release actually changes, always diff from the merge
base, never from HEAD -- a git diff HEAD "$REF" also shows every local
change as if upstream were reverting it, which reads as phantom upstream churn:
git diff --name-status "$(git merge-base HEAD "$REF")" "$REF"
2a. Hand off to the target's own update-self flow
Now re-point the rest of this pass at the update-self skill as it exists at
$REF. Stage that copy (from the already-fetched objects -- no network, no
working-tree mutation) at the fixed path
data/.tasks/update-self/skill-at-target/.agents/skills/update-self, and learn
whether it differs from your local one:
DIFFERS=$(python3 .agents/skills/update-self/scripts/update_self.py bootstrap-skill --ref "$REF" \
| python3 -c 'import sys, json; print(json.load(sys.stdin)["differs"])')
echo "differs=$DIFFERS"
bootstrap-skill always leaves a runnable flow at that fixed path (the target's
copy, or -- when the ref predates the skill -- the local copy), so the worker
runs from data/.tasks/update-self/skill-at-target/.agents/skills/update-self
regardless. differs decides only which SKILL.md prose you follow next:
-
differsisFalse(the staged flow is byte-identical to yours, or the ref predates the skill) -> continue with this document. -
differsisTrue-> the target's copy of the flow differs from your local one (it shipped changes, or this workspace customized the flow locally). Stop following this document and follow the staged copy'sSKILL.mdfrom Step 3 onward:# read and follow "data/.tasks/update-self/skill-at-target/.agents/skills/update-self/SKILL.md" from Step 3You have already completed Steps 1-2 (backup, single-flight, clean tree, target resolved), so do not re-run the staged doc's Step 2 or re-stage -- just carry
$REFforward into its Step 3.
Either way, data/.tasks/update-self/skill-at-target/.agents/skills/update-self now
holds the copy of the flow to run. Everything below reaches the skill's scripts
and worker reference through that literal path (and points the worker at it), so
both dispatch against the correct version.
The handoff contract (keep this boundary stable when editing this skill).
Steps 1-2 -- preconditions and target resolution -- always run from the local
copy: they are what decide $REF, so by construction they cannot come from the
target. The target's flow is entered at Step 3. So an edit to this skill must
preserve that boundary: a future version's Steps 1-2 must stay "capture a backup,
the single-flight/clean-tree checks, then resolve a ref into $REF", and its
Step 3 must stay the worker dispatch -- otherwise an older initiator handing off
into a newer copy (or vice versa) lands at the wrong step. The version ceiling is
part of resolving $REF, so Step 2 computes it from the local copy -- which on
a workspace whose template predates the ceiling does not compute one at all.
Step 3a therefore re-checks it from the staged target copy before the dispatch,
so the cap holds on the very first update into it. Keep 3a in any future
version: it, not Step 2, is what protects a workspace arriving from an older
template. Keep the staging path
(data/.tasks/update-self/skill-at-target/.agents/skills/update-self) stable for the
same reason. Note also that this handoff runs the target ref's update_self.py
and follows its prose before the Step 5a approval gate; for the default target
(a stable, already-tested minds-v* tag) that is the same trust basis as the
merge itself, but a --override to an untrusted ref means trusting that ref's
flow code and instructions -- only override to a ref you trust.
3. Dispatch the worker
3a. Re-check the ceiling from the staged copy (first, before anything else)
Run the version ceiling once more, from the staged target copy:
python3 data/.tasks/update-self/skill-at-target/.agents/skills/update-self/scripts/update_self.py \
resolve-target --local-tags --override "$REF" > /tmp/update-self-recheck.json || exit 1
cat /tmp/update-self-recheck.json
This is not redundant with Step 2 -- it is the only ceiling check that runs on
a workspace updating into the ceiling for the first time. Step 2 runs from
this workspace's local skill copy, and any workspace whose template predates
the ceiling has a local copy that does not check one: it happily resolves the
newest tag upstream, which is exactly the too-new target the ceiling exists to
refuse. The staged copy is by construction at least as new as $REF, so this
check runs no matter how stale the initiator was. That is precisely what §2a's
hand-off machinery is for -- "a fix that shipped in the release is applied on the
way in" -- and the ceiling is such a fix.
If exceeds_ceiling is true here and the user has not already confirmed an
over-ceiling --override in Step 2, stop and take that confirmation now, with
the same plain-language framing Step 2 describes. A default (no-override) resolve
that trips this means the local copy chose a target its app cannot support:
say so, and offer the ref this pass would cap to (re-run without --override
to learn it). Do not dispatch the worker until it is resolved.
If the user takes the capped ref instead, set $REF to it and then re-run
§2a for the new $REF before dispatching. Do not just reassign the variable:
§2a staged the skill at the old $REF, and the staged copy is what supplies
the worker guide, the update_self.py both of you run, and the prose you are
reading -- leaving it in place would run the too-new release's flow against a
target that is not it. bootstrap-skill re-stages destructively, so re-running
it is safe, and 2a's differs branch then decides which document you follow, as
on the first pass. The capped ref is by construction at or below the ceiling, so
the second time through 3a clears.
The boundary in §2a still holds: Step 2 is what resolves a target and Step 3 is the worker dispatch. 3a resolves nothing -- it either clears the target Step 2 chose or hands the pass back to 2a with the ceiling's answer.
3b. Launch
Open a tracking ticket, write the task file, launch via the launch-task
machinery, and background-poll.
mkdir -p data/.tasks/update-self
tk create "update-self" -t task \
--acceptance "worker launched; conflicts triaged; validated; branch merged; revealed"
Note the ticket id it prints, then start it. The tk hook requires tk start /
tk close to be the only command in their tool call -- never chain them after
another command or capture their output:
tk start <ticket-id>
Write the task file. Use the two-heredoc form the other worker skills use: an
unquoted frontmatter block so $MNGR_AGENT_NAME and $REF expand, then a
quoted body so its backticks stay literal.
Unlike every other worker skill, this template DOES set lead_agent, and the
line must stay: this SKILL.md is executed cross-version -- an older workspace's
lead follows this staged prose (via the differs branch of §2a) but launches
with its own launch-task/create_worker.py, which may predate launch-time
lead_agent stamping. Under a current launcher the line is harmless (launch
overwrites it from the environment); under an old launcher it is the only thing
that gives the worker a report address.
{
cat << FRONTMATTER_EOF
---
lead_agent: $MNGR_AGENT_NAME
finish_report_path: data/.tasks/update-self/reports/report.md
target_ref: $REF
---
FRONTMATTER_EOF
cat << 'BODY_EOF'
# Task: safe update-self
## What to do
Follow the worker guide at
`data/.tasks/update-self/skill-at-target/.agents/skills/update-self/references/update-self-worker.md`
end to end: trial-merge conflict triage, complete the merge (preserving the
`update-self:` merge-commit subject), validate the merged set, generate the
"what's new" report, and report `done`. That
`data/.tasks/update-self/skill-at-target/.agents/skills/update-self` path is the copy
of the update-self flow shipped with the version being updated to (staged by the
lead and synced into your worktree with this runtime dir) -- run *all* its
`update_self.py` calls from its `system/scripts/` too. Your target is the `target_ref` in
this file's frontmatter (already fetched into `upstream`).
## Reporting back
Per `.agents/shared/references/worker-reporting.md`. Valid `name:` values:
`question` (mid-flight gate: a genuine, unresolvable conflict, or the §4c
review-gate escape hatch), `done` / `stuck` (terminal). Substitutions:
`<TASK_FILE_GLOB>` -> `data/.tasks/update-self/task.md`;
`<RUNTIME_REPORTS_DIR>` -> `data/.tasks/update-self/reports`.
BODY_EOF
} > data/.tasks/update-self/task.md
Launch with the plain worker template (this flow uses its own worker guidance,
not the generic harden-worker), then background-poll (run_in_background: true), re-arming per lead-proxy.md:
uv run .agents/skills/launch-task/scripts/create_worker.py launch \
--name update-self --template worker \
--runtime-dir data/.tasks/update-self/ --task-file data/.tasks/update-self/task.md
uv run .agents/skills/launch-task/scripts/create_worker.py await \
--name update-self --task-file data/.tasks/update-self/task.md --timeout 90m
4. Proxy the question gate
Per .agents/shared/references/lead-proxy.md (worker update-self, branch
mngr/update-self, reports dir data/.tasks/update-self/reports/). Almost
always this is a genuine, unresolvable conflict -- a real decision about how to
reconcile a file both sides rewrote incompatibly. Escalate it to the user,
relay their resolution via mngr message, consume the report, and re-arm.
The one other thing a question can be is the worker's review-gate escape hatch
(its §4c): a process question about whether or at what scope the gates run.
That is not the user's to answer -- answer it yourself per lead-proxy.md.
The default answer is to apply the §4c rule as written: the rule already is the
proportionality decision, so any situation it covers gets the branch the rule
gives it. Where the rule is genuinely silent -- the case §4c routes here --
decide it on the rule's own principle rather than on the worker's proposal: the
fallback is always more coverage, never less, so the gates run unless the worker
has shown the skip branch's three conditions hold. Either way, reply, consume,
and re-arm without involving the user. Escalate only if the worker has surfaced
a real question of user intent inside it.
For the conflict case, compose the question per the §5a rules -- plain-language and pointed at a resolution, not the worker's raw conflict dump. Lead with where things stand ("The update is almost ready -- one file needs a decision from you before I can finish"), explain the choice in plain terms (what the new version does vs. what your workspace currently does, and what's at stake each way), and propose a way forward: a recommended option when you have one, the concrete trade-offs when you genuinely don't. Close by inviting the user to resolve it with you rather than only to rule on it -- "tell me which you'd prefer, or talk it through with me and we'll land on the best option together." Reassure that nothing has been applied and the workspace is untouched.
5. Terminal status
stuckor a dead-worker timeout -> surface via.agents/skills/launch-task/references/worker-failure.md. Nothing is merged or revealed; the live workspace is untouched. Don't relay the raw failure, but don't strip the specifics either -- this is the one message type where technical detail is preserved, not dropped, because the user often forwards a failure into a bug report and it must stand on its own to whoever reads it next. Compose it in two parts: a plain-language lead for the user (what happened and what it means -- "I couldn't complete this update cleanly; your workspace is untouched and nothing was applied" -- plus a next step or an offer to work through it together), followed by a clearly-marked technical detail block for whoever they escalate to: the target ref, the step or phase that failed, the specific file or component, and the actual error text or log excerpt verbatim (not paraphrased), with a pointer to the full report and logs underdata/.tasks/update-self/reports/. Never leave the user at a dead end, and never hand them a failure so vague it's useless in a bug report.done-> the approval gate below.
5a. Approval gate
Audit the report before composing anything. The worker contract (the
staged copy's references/update-self-worker.md, §4c and §6) makes the review
gates rule-driven and the report evidence-bearing: it must either show the
clean-pull skip's conditions held (has_merge_work: false, no impacted
user-created code, and no worker-authored in-branch edits such as 4a mirror
edits) or carry the gate run's own evidence (fix commits
kept/reverted, or a clean gate run, plus architecture-gate verdicts). Likewise
a side-picked conflict must carry the discarded-side accounting, not a bare
"superset" claim. A report missing any of this -- including one that openly
discloses skipping or narrowing a gate outside the rule -- goes back to the
worker to be completed: run the Step 4 gate cycle over it (say what is missing
via mngr message, consume this report into
data/.tasks/update-self/reports/consumed/, re-arm the background poll) and
audit the replacement, because done otherwise ends the poll and a report left
at the report path would satisfy the next await instantly. Do not compose an
approval message over the gap, and never repackage a worker-disclosed deviation
as reassurance. A deviation only stands when completing it is genuinely out of
reach -- the worker is gone and the gap cannot be closed from here, or you told
the user about it and they chose to go ahead anyway; not because the reasoning
behind it persuaded you. In that case the approval message states the deviation
itself, plainly, where the user will read it -- it is a caveat, never a footnote
to a reassurance.
The done report is your raw material, not the user's message. It is a
comprehensive, technical digest for the lead -- changelog entries in range, the
conflicts and how the worker resolved them, reveal-class breakdown, impact
analysis, lockfile handling, and validation. Do not forward it verbatim.
Keep it available (it is persisted under data/.tasks/update-self/reports/ -- offer
to show it if the user wants the specifics), and compose a plain-language
approval message from it. Then wait for explicit approval -- mandatory even
on a clean pull.
These composition rules govern every user-facing message this flow produces --
the approval message here, the question gate (Step 4), and a stuck result
(Step 5) alike. Whenever the update can't simply proceed, the message names the
blocker in plain terms and proposes a way forward, or invites the user to
resolve it with you -- it never dead-ends. The one thing that varies is how
much mechanism to keep: the approval and question messages drop technical
detail the user can't act on, but a stuck message deliberately preserves it
(Step 5) so it survives being pasted into a bug report.
Write the message a non-technical reader skims top-to-bottom, in this fixed order:
- Verdict headline (one line, first thing they see): "ready to apply," "ready to apply, with one caveat," or "needs your input on X."
- Held back by your app version -- include this line if and only if
held_back_by_ceilingistruein the resolve-target output (/tmp/update-self-target.json). Say it in one plain line -- "there's a newer version available (latest_available), but it needs a newer Minds app than you're running, so I stopped at X" -- so the user understands why they aren't getting the newest thing and knows updating the app unlocks it. Do not derive this yourself by comparingrefagainstlatest_available: those two also differ when the user's own--overridepicked an older tag, and saying "your app held this back" there blames the app for the user's choice. The flag already accounts for that. - What's new -- always first after the ceiling note. Keep this detailed: some readers want the specifics, others happily skim it as "great, they're on it." Do not thin it out -- carry the worker's digest, just in prose a lay reader parses (describe what each change does, not the file names).
- Conflicts -- "none," or what needed reconciling. When the worker kept local code over the release's version of the same file, do not present that as a settled fact: say what was kept, what the release's version would have changed, and offer the alternative in the same breath ("I kept your version; if you'd rather match the official release exactly there, I can do that instead"). The choice between a local divergence and the tested release is one the user may well care about, and it is cheap to offer now and expensive to unwind later.
- Validation -- did the suite pass; is any failure pre-existing/unrelated.
- Caveats -- only if any; what to expect after applying.
- Pre-existing issues -- only if any, and only after verifying attribution (see the worker guidance's §4a): state plainly whether each lives in built-in code (present at the target ref -> report upstream) or the user's own code. Never call built-in code "workspace-added."
- The ask -- see the language rule below.
Detail in the informational sections (3-5); plain language at the decision points. Spend deliberate plain-language care only where the message asks the user to decide or act -- the verdict headline, any caveat that needs their action, and the closing ask. Those carry no jargon: never "merge," "land," or "fast-forward" there. Frame the ask around applying the update to their workspace (many users just want their workspace improved and don't think in terms of merges), e.g. "Everything's ready -- want me to apply the update to your workspace now?"
Drop dependency/lockfile mechanics from the user message unless there is an action the user must take. Command rule: never print a command you will run -- describe it in plain language ("I'll refresh it automatically if it comes up"). Show a literal command only when the user must run it themselves.
Preview rule for the system interface: if upstream was strictly newer there
(no merge work needed), no preview is needed; if the worker's report says
nontrivial merge work was needed, give the user a live preview first, exactly as
update-system-interface Step 3 does (keep the worker alive until they
verdict). The report's per-surface merge-work judgment is what you go by.
WORK_DIR=$(mngr ls --include 'name == "update-self"' --format json \
| python3 -c 'import sys, json; print(json.load(sys.stdin)["agents"][0]["work_dir"])')
python3 .agents/skills/update-system-interface/scripts/reveal_system_interface.py preview \
--slug update-self --work-dir "$WORK_DIR"
python3 system/scripts/layout.py open si-preview
With no --view, the open goes to the view the connected client is looking
at, which is where the user expects the preview tab.
Other user apps are optional previews. When the report says another user
app took meaningful merge work, use your judgment: serve it from the
worker's worktree via .agents/shared/scripts/serve_isolated_instance.py as its
own preview tab -- or, when the system interface is also being previewed, link
it from inside that preview. Skip previews for services that came in clean.
5b. Land the merge
When the update touches system/apps/system_interface/ at all (merged or pulled
in -- anything that makes 5c run the safe-reveal), first take the
editing service system_interface lease and hold it through the end of 5c,
exactly as update-system-interface Step 4 does: the reveal's auto-rollback
restores a captured revision, so a foreign merge landing between here and the
reveal could be swept away by it. Check tk ready for another agent's lease
and surface instead of proceeding if one is held; then tk create "editing service system_interface" -t chore and tk start it (each as its own
command). Release it (tk close) after 5c.
Capture the rollback revision, then fast-forward the worker branch. It branched
off this exact HEAD, so the merge fast-forwards and preserves the worker's
update-self: merge commit verbatim (the marker assist relies on):
ROLLBACK_TO=$(git rev-parse HEAD)
git merge --ff-only mngr/update-self
No fetch is needed first: the worker runs in a linked worktree of this same
repository, so mngr/update-self already exists in the shared ref store (and a
git fetch . mngr/update-self:mngr/update-self would be refused anyway while
the worker's worktree has the branch checked out).
If the fast-forward is refused, HEAD moved under the pass: treat it as stale
per .agents/shared/references/harden-contention.md and re-dispatch off the
current HEAD -- do not hand-resolve.
Record the version, in the same landing. Landing an update is what makes the workspace a new version, so the entry belongs in the git tree, right here -- never left to a later turn. The merge sha only exists once the merge has landed, so this is a follow-up commit of exactly one file (the worker never writes it -- only the lead knows the merge sha).
Capture the merge sha right here -- immediately after the fast-forward, while
HEAD still is the merge and before the ledger commit moves it:
MERGE_SHA=$(git rev-parse HEAD)
Then write the entry directly into docs/VERSION_HISTORY.md. There is no helper
skill -- this block is the whole recording contract, and it owns the format so
update-self, publish-template, and update-published-template all write
identical lines. The rules: append-only (existing lines are copied through
verbatim, never re-flowed); every ## Workspace line ends in a commit; and a
retried landing must be a no-op, never a duplicate. Do the three parts below in
order.
Part 1 -- if docs/VERSION_HISTORY.md is missing (deleted since creation),
recreate the shipped starter first, then append. This heredoc is the canonical
starter that publish-template and update-published-template recreate by reference
to here:
[ -f docs/VERSION_HISTORY.md ] || cat > docs/VERSION_HISTORY.md <<'VERSION_HISTORY_EOF'
# Version history
Where this workspace came from, what it has migrated in, what it has published,
and the templates it has adopted. Entries are appended automatically -- by
`update-self` when it lands a template update, by `migrate-workspace` when it
pulls another workspace in, by `publish-template` and
`update-published-template` when they publish, and by
`update-installed-template` when it pulls a newer version of an adopted
template -- and earlier lines are never rewritten. Each Workspace, Migrations,
and Templates line ends in the commit it was cut from.
## Workspace
## Migrations
## Templates
## Adopted templates
Each template this mind has adopted and the version it is on;
`update-installed-template` appends here when it pulls a newer version.
VERSION_HISTORY_EOF
## Migrations is migrate-workspace's section (one line per workspace pulled
in); this starter ships it empty so a recreated file already has it, and
update-self never writes there.
Part 2 -- seed the ## Workspace origin line if it is absent -- exactly
once per workspace, inserted as the FIRST line under ## Workspace (the oldest
event, so it never appends at the end). Resolve the template base as the
OLDEST first-parent template-state marker (^update-self: or Initial workspace commit), and resolve its date/version/sha from that commit itself
so seeding late still records when the workspace was actually created:
if ! grep -q "created from" docs/VERSION_HISTORY.md; then
CREATION=$(git log --first-parent --format='%H %s' HEAD \
| awk '{h=$1; sub(/^[^ ]+ /,""); if ($0 ~ /^update-self:/ || $0 == "Initial workspace commit") last=h} END {if (last) print last}')
# Fallback (a hand-made or pre-bootstrap repo with no marker): the FIRST-PARENT
# root -- never `git rev-list --max-parents=0 HEAD`, whose parallel subtree roots
# are not the seed.
[ -n "$CREATION" ] || CREATION=$(git rev-list --first-parent HEAD | tail -1)
C_DATE=$(git log -1 --format=%ad --date=short "$CREATION")
C_SHA=$(git rev-parse --short=7 "$CREATION")
C_VERSION=$(git describe --tags --abbrev=0 --match 'minds-v*' "$CREATION" 2>/dev/null)
# Then insert `- <C_DATE> created from <C_VERSION or "the workspace template">
# <C_SHA>` as the FIRST line under the `## Workspace` heading, note padded
# per Part 3's rule (width 26, but never fewer than two spaces before the
# sha -- `created from minds-v0.3.NN` is exactly 26 chars, so a bare
# pad-to-26 would land the sha flush against the version).
fi
Use git describe (reachability), NEVER git tag --points-at. No tag is ever
on a template base: Initial workspace commit is an --allow-empty commit
bootstrap writes ON TOP of the cloned template commit, and an update-self:
marker is a merge commit -- in both cases the minds-v* tag is on an ancestor, so
a pointing-at lookup always comes up empty and every origin line would silently
degrade to the unnamed created from the workspace template fallback. (This walk
takes the OLDEST marker -- where the mind started. publish-template
§2's BASE_REF walk uses the same markers but takes the NEWEST; the
difference is load-bearing.)
Part 3 -- append the update line. Under ## Workspace, after its last
existing line, append exactly one line of the form:
- <today, YYYY-MM-DD> updated to <$REF> <7-char $MERGE_SHA>
Pad the note (updated to <$REF>) to width 26 so the sha lines up, and always
keep at least two spaces between the note and the sha: a note of 26 characters
or more takes a two-space gap and pushes its own sha right rather than landing
flush against it. Earlier lines are never re-flowed. Compute the
sha as git rev-parse --short=7 "$MERGE_SHA". Idempotence: if a ## Workspace line already carries this exact note AND this exact 7-char sha, it is
already recorded -- change nothing and skip the commit below.
Then commit exactly this one file:
git add docs/VERSION_HISTORY.md
git commit -m "version history: updated to $REF"
Stage docs/VERSION_HISTORY.md by name -- NEVER git add -A (it would sweep
up the mind's unrelated working state), and never a merge, checkout, or reset as
part of recording. If the idempotence check found the entry already recorded,
nothing is staged and you skip the commit.
Pass $MERGE_SHA, never HEAD. The append de-duplicates on note + sha, and
the git commit above moves HEAD onto the version-history commit: a re-run
that reaches for HEAD would pass a different sha, defeat the no-op, and append
a second line pointing at the ledger commit instead of the merge. On a re-run,
re-derive the merge sha rather than re-reading HEAD:
MERGE_SHA=$(git log --first-parent --grep '^update-self:' -1 --format=%H)
That prints the newest template-state marker -- the merge you just landed -- and keeps printing it afterwards, so the whole block is safe to re-run.
Never give this commit an update-self: subject: that prefix is the
template-state marker assist and publish-template §2 resolve BASE_REF
from, it belongs to the merge commit alone, and $MERGE_SHA above depends on it
staying that way.
5c. Reveal by change class
The report says which classes merged. Apply each; a clean pull-in is still applied (its dependent service restarted), only its validation was skipped.
-
system_interface-- reveal via the safe-reveal script (rebuildsstatic/, refreshes deps on a manifest change, pre-flights, health-checks, auto-rolls-back), then tear down any preview:python3 .agents/skills/update-system-interface/scripts/reveal_system_interface.py reveal \ --rollback-to "$ROLLBACK_TO" python3 .agents/skills/update-system-interface/scripts/reveal_system_interface.py unpreview --slug update-self python3 system/scripts/layout.py close si-previewExit codes per
update-system-interfaceStep 5 (0revealed;2auto-rolled-back;3emergency;1precondition). On exit 2 the rollback reverts$ROLLBACK_TO..HEAD-- the entire landed merge, every class, not just the system interface. Stop here: apply no other class (the tree no longer contains the update), surface the failure, and re-dispatch once the cause is fixed. Exit 3 means the restore itself failed -- surface immediately. -
service/system/supervisord.conf/bootstrap-- restart the whole services agent (do not usesupervisorctl reread && updatehere), then rebuild the user's view of the workspace, then refresh any affected tab (python3 system/scripts/layout.py refresh <name>):mngr start --restart system-services python3 system/scripts/refresh_workspace_view.pyThe refresh is not optional. Restarting the services agent bounces the system interface underneath whatever the user has open, and nothing reloads that view on its own: the Minds app only steps in when a workspace looks unreachable for a sustained stretch, which a quick restart never does. Without this the user keeps reading the page the previous build rendered. The helper is fire-and-forget and always exits 0 -- it names any channel that did not land on stderr and is never a reason to stop.
-
editable_tool(system/vendor/mngr/**) --.pyis picked up live; a manifest change needs an env refresh of both mngr installs a standard workspace carries: the root venvuv run mngruses (uv sync --all-packages) and the uv-managed tool the baremngron PATH is, whichsystem/scripts/build_workspace.shinstalls (uv tool install -e system/vendor/mngr/libs/mngr --reinstall; check withuv tool list). The vendored tree is the whole mngr monorepo, whose rootpyproject.tomlis not installable, so the tool package is itslibs/mngr. Re-register the tool's plugins right after that reinstall -- note them first withmngr plugin list, thenmngr plugin add --path system/vendor/mngr/libs/mngr_claude --path system/vendor/mngr/libs/mngr_wait(plus any others the list showed) -- because a reinstall rebuilds the tool environment from the base package alone and drops the plugin packagesbuild_workspace.shregistered, leaving anmngrthat cannot parse its own plugin config and so cannot create agents. Any otheris_manifestchange the report flags (a root-workspacepyproject.toml/uv.lock) likewise needsuv sync --all-packagesso the new dependencies resolve. -
Dockerfile-- apply the live-applicable hunks the report calls out (canonically aCLAUDE_CODE_VERSIONbump ->CLAUDE_CODE_VERSION=<v> bash system/scripts/setup_system.sh, keepingagent_types.claude.versionin.mngr/settings.tomlin sync). Tell the user any image-level hunk (baseFROM,apt-getpackages, build-time layout) needs a manual workspace rebuild. -
provisioner(system/scripts/setup_system.sh,system/scripts/install_secret_scanners.sh,system/scripts/_provision_guard.sh,.mngr/**) -- shapes how the workspace image and agents are provisioned, not live runtime code, so it doesn't reveal by merely restarting a dependent service the wayshared_runtimedoes. Work the report's apply plan by sub-case:-
A pinned-toolchain bump in
setup_system.sh/install_secret_scanners.sh(canonicallyLATCHKEY_VERSION, but alsoUV_,MODAL_,TTYD_,CADDY_,FRP_, scanner pins) does not reach the live workspace on its own -- the globally-installed CLI stays at the old version until a rebuild. Apply it live by re-running the provisioner:bash system/scripts/setup_system.shThis now actually runs (rather than skipping): the merge changed the repo tree, so the content-addressed provision guard's marker no longer matches, and the script re-installs the pinned tools idempotently. The report names which pins moved.
Exception -- a bump the report flags as coupled to a user-created dependent. The report says who depends on the bumped dep and classifies it by origin (not directory). If the dependent is built-in (its code is in the upstream template -- the same release tested it against the new dep), hot-running the provisioner is safe; apply it live as above. If the dependent is user-created (built in this workspace, absent from upstream -- e.g. a
build-appapp undersystem/apps/), do not hot-run the provisioner: upstream never tested that code against the new dep and the worker couldn't validate it either, so treat it as rebuild-only -- surface it to the user for a workspace recreate (which provisions the new substrate and re-runs the user code against it), exactly as an image-level hunk below. -
A hunk that only affects a fresh image build -- something the idempotent re-run does not reproduce -- needs a manual workspace rebuild; tell the user, exactly as for an image-level
Dockerfilehunk. -
.mngr/**create config governsmngr create, so the merged file governs every future create automatically (a fresh workspace, and the sub-agentslaunch-taskspawns) -- but the current workspace was built and launched under the old settings, so a create-time change does not reach it on its own. The worker's report carries a per-change apply plan (it best-effort mirrors each change into a live counterpart within the merge); carry it out:- Live-applicable (most changes, including env vars and agent behavior) --
the worker already made the in-repo edits mirroring the change into its live
counterpart (an env var into a
profile.dentry / a supervisord program'senvironment=; asettings_overrides/disable_pluginchange into what the running agent reads; a Claude/toolchain version pin intosetup_system.sh/ the Dockerfile). You run the restart the report names to make them take effect: re-run the provisioner for a mirrored toolchain pin, and/ormngr start --restart system-services(or a relaunch of the affected agent) so the next process start reads it. Keep lockstep pins (agent_types.claude.versionvs the DockerfileCLAUDE_CODE_VERSIONand the installed binary) consistent. - Rebuild-only for the current workspace (the narrow remainder) -- only a
container build/launch parameter an already-running container can't adopt: a
[create_templates.*]/[providers.*]build_arg,start_arg(--security-opt,--tmpfs,--cpus, …), or runtime flag (runsc/docker_runtime). Flag it to the user as needing a workspace recreate, exactly as an image-levelDockerfilehunk; don't imply it is already live.
(A change the worker judged neither live-applicable nor safe to defer to a rebuild comes back as
stuck, handled in Step 5's terminal status -- nothing is landed.) - Live-applicable (most changes, including env vars and agent behavior) --
the worker already made the in-repo edits mirroring the change into its live
counterpart (an env var into a
-
-
shared_runtime(system/scripts/**other than the provisioning scripts above,system/libs/**,system/services/**,system/apps/**,.agents/**) -- applies to future agents automatically unless a live service depends on the file. The report's impact analysis names any live consumer; restart that service (usuallymngr start --restart system-services, followed bypython3 system/scripts/refresh_workspace_view.pyfor the same reason as above). Only "nothing to reveal" when the analysis found none.
5c. Advance the environment (bundled, not optional)
update-self is the one moment package versions are allowed to move: the merged
template carries a (possibly newer) committed apt snapshot timestamp in
.mngr/apt-snapshot-timestamp, and the environment stays pinned to the OLD
timestamp until explicitly advanced. After the merge has landed and revealed,
run:
uv run env-converge upgrade
This re-renders the pinned apt sources at the new timestamp, apt-get full-upgrades against that frozen universe, re-runs the system/scripts/env.d/
units (whose pins may have advanced with the template), re-captures the
environment record, and prints the package-version deltas as JSON. Summarize
the delta count for the user in plain language ("system packages moved to the
newer pinned snapshot; N changed"). If the timestamp did not change, the
command is a cheap no-op pass -- run it anyway so unit-pin bumps still apply.
6. Teardown
If you previewed a non-system_interface service in 5a, tear that preview down
too: stop its isolated instance and close its tab
(python3 system/scripts/layout.py close <name>).
Then:
mkdir -p data/.tasks/update-self/reports/consumed
mv data/.tasks/update-self/reports/report.md \
data/.tasks/update-self/reports/consumed/$(date +%s)-done.md
uv run .agents/skills/launch-task/scripts/create_worker.py destroy --name update-self
Consuming the terminal report is not optional bookkeeping: create_worker.py launch refuses to start a worker while a leftover report sits at the report
path (a stale one would satisfy the next pass's await instantly), so skipping
this breaks the next update-self pass until someone cleans it up.
Close the tracking ticket last (its own tool call, nothing chained):
tk close <ticket-id> "Updated to <ref> -- worker branch merged and revealed."
To push local improvements back upstream
Use the submit-upstream-changes skill -- the complementary direction. This skill
only pulls.