Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65199cafa7 | ||
|
|
ab335b3061 | ||
|
|
5f9527a8f0 | ||
|
|
018339689b | ||
|
|
da4e709ef4 | ||
|
|
7a9b25538b | ||
|
|
a1e181fd54 | ||
|
|
fe3f9dae75 | ||
|
|
a9750957cd | ||
|
|
792e535a56 |
No files matched your search
+22
-3
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: docs-files-api
|
||||
description: JSDoc placement rules for TypeScript/JSDoc code — use when writing or reviewing JSDoc comments, deciding where @file/@see links go, or documenting external specs of exported symbols.
|
||||
description: JSDoc placement rules for TypeScript/JSDoc code — use when writing or reviewing code comments, JSDoc, or file headers; deciding where @file/@see links go; documenting exported symbols or external specs; when a `//` comment explains what a declaration is or does (it belongs in JSDoc).
|
||||
---
|
||||
|
||||
# Documentation Conventions
|
||||
@@ -14,9 +14,28 @@ JSDoc placement rules (from the
|
||||
- **Entry-point JSDoc:** Put the meaningful docs on the *exported* symbols —
|
||||
that's what shows up in IDE call sites. External URLs belong here when they
|
||||
define the canonical ABI/spec of the thing being exported.
|
||||
- **API docs are JSDoc, not `//`:** What a function, class, method,
|
||||
type, field, or constant *is* or *does* goes in a `/** … */` comment on
|
||||
its declaration — IDEs show it at call sites and on hover; a `//` line
|
||||
above the declaration shows nowhere. `//` is for implementation notes
|
||||
inside a body: the *why* of a non-obvious line, next to that line.
|
||||
- **Exception: program scripts** (run directly: a shebang, or
|
||||
`import.meta.main`). The manual goes at the top: a one-line purpose, a
|
||||
usage synopsis, the arguments, and what it writes or prints. People
|
||||
open a script to learn how to run it, and its `main` has no call sites
|
||||
for JSDoc to show at. If the script prints its usage, make the manual a
|
||||
`HELP` string constant right under a one-line `@file` (JavaScript has
|
||||
no docstrings; a string literal is the honest equivalent) rather than
|
||||
copying it or reading the script's own source. Helpers inside still get
|
||||
their own JSDoc.
|
||||
- **Write for the reader, not the conversation:** Comments say what the
|
||||
code is and why, in the reader's terms. No traces of how it came to be
|
||||
(design discussions, who asked, "for now", analogies used while
|
||||
deciding), and nothing the code or its shebang already says.
|
||||
- **External design constraints:** If a module is constrained by external
|
||||
contracts, standards, or off-repo design docs, add a short JSDoc note with a
|
||||
`@see` link to the authoritative source at the relevant entry point(s).
|
||||
|
||||
In short: brief at the top of the file, meaty on exports, `@see` to
|
||||
authoritative external sources only where they define the contract.
|
||||
In short: brief at the top of the file, meaty on exports (in JSDoc),
|
||||
`//` only for the why inside bodies, `@see` to authoritative external
|
||||
sources only where they define the contract.
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: io-between-turns
|
||||
description: I/O between turns — use when writing or reviewing code that reads or writes files, sockets, databases, or other shared external state; when choosing between sync and async APIs (readFileSync vs fs/promises, DatabaseSync, blocking calls); when someone says "sync I/O is a pointy instrument" or "I/O should be done between turns". Sync access is fine only for state that is local, prompt, and private to the one actor (e.g. a Durable Object's SQLite).
|
||||
---
|
||||
|
||||
# I/O between turns
|
||||
|
||||
Event-loop concurrency (E, JavaScript, Workers) runs one **turn** at a
|
||||
time: deliver one event, run the resulting calls to completion, then
|
||||
take the next. A turn that never waits on the outside world is
|
||||
isolated: nothing else can change what it can see while it runs. I/O
|
||||
on shared or slow resources belongs **between** turns: issue it, end
|
||||
the turn, and continue when the result arrives as a later event.
|
||||
|
||||
> "Execution proceeds by taking a pending-delivery from the queue,
|
||||
> delivering its message to its object, and processing all the
|
||||
> resulting immediate-calls in conventional call-return order. This is
|
||||
> called a turn. … Because each event's turn runs to completion before
|
||||
> the next is serviced, they are temporally isolated."
|
||||
> — [Concurrency Among Strangers: Programming in E as Plan Coordination](https://agoric.com/assets/pdf/papers/concurrency-among-strangers.pdf),
|
||||
> Miller, Tribble & Shapiro, TGC 2005, §5.2
|
||||
|
||||
> "The actual E system does provide synchronous file I/O operations.
|
||||
> When these files are local, prompt, and private to the vat accessing
|
||||
> them, this does not violate turn isolation, but since files may be
|
||||
> remote, non-prompt, or shared, the availability of these synchronous
|
||||
> I/O operations does violate the E model."
|
||||
> — same paper, §6, footnote 5
|
||||
|
||||
(Mark Miller's thesis, *Robust Composition*, 2006, builds on this
|
||||
paper.)
|
||||
|
||||
## the rule
|
||||
|
||||
- **Shared, remote, or slow state → async, between turns.** The
|
||||
filesystem, the network, other processes' databases: use promise
|
||||
APIs (`node:fs/promises`, `fetch`) and `await` them. No
|
||||
`readFileSync`/`writeFileSync`, no blocking calls inside a turn.
|
||||
- **Local, prompt, and private to the actor → sync is fine.** A
|
||||
Durable Object's SQLite (`ctx.storage.sql`) is reachable only by its
|
||||
own DO, so it is "more or less more RAM": synchronous access keeps
|
||||
turn isolation.
|
||||
- **Only a sync API exists, for shared state?** (e.g. `node:sqlite`'s
|
||||
`DatabaseSync` on a file other processes may touch.) Keep it at the
|
||||
program's edge and say why in a comment, so it doesn't read as a
|
||||
precedent.
|
||||
|
||||
## see also
|
||||
|
||||
- [ocap-discipline](../ocap-discipline/SKILL.md): who gets to do I/O at
|
||||
all (authority passed in, not ambient); this skill is about *when*.
|
||||
- [silent-failures-are-evil](../silent-failures-are-evil/SKILL.md): an
|
||||
awaited I/O failure still needs a trace.
|
||||
+94
-71
@@ -1,106 +1,129 @@
|
||||
---
|
||||
created: 2026-05-14
|
||||
updated: 2026-05-14
|
||||
author: gardener
|
||||
name: retcon
|
||||
description: Retcon a PR branch — use when a maintainer says "retcon", or asks to clean up, regroup, squash, or reorganize a PR's commits into sensibly grouped commits; one commit per affected package with implementation and tests together, conventional-commit messages, and yarn.lock isolated in its own commit. Resets to the base and restages the same net diff; the merge result is byte-identical. Not a rebase onto a new base.
|
||||
---
|
||||
|
||||
# Skill: retcon
|
||||
# Retcon
|
||||
|
||||
Reset a PR's branch to its base and restage the same net diff as a sensibly grouped commit history. The PR's diff is unchanged; only the commit grouping changes. The verb is the maintainer's: *"retcon"* = retroactively continuity-fix the branch's commit history.
|
||||
|
||||
The retcon's typical executor is the [fixer](../../roles/fixer/AGENT.md), dispatched against an open PR whose substance is right but whose commit grouping is not. The [weaver](../../roles/weaver/AGENT.md) handles rebase-onto-current-base; the retcon is the orthogonal operation of regrouping commits without moving the base.
|
||||
Reset a PR's branch to its base and restage the same net diff as a
|
||||
sensibly grouped commit history. The PR's diff doesn't change. Only the
|
||||
commit grouping changes. The name is short for *retroactive continuity*:
|
||||
fixing the story the branch's history tells.
|
||||
|
||||
## When to use
|
||||
|
||||
- A maintainer says "retcon #N" or "please retcon this branch". The verb names this procedure.
|
||||
- A PR's commit history grew sprawling during the jury-fixer loop (one commit per fixer round, intermediate work-in-progress commits, lockfile churn interleaved with code).
|
||||
- Pre-ferry: the boatman's upstream is reviewing commit-by-commit and the bot-side history is not in shape for that. Retcon, then ferry.
|
||||
- A maintainer says "retcon #N" or "please retcon this branch".
|
||||
- A PR's history sprawled during review: one commit per round of
|
||||
feedback, work-in-progress commits, lockfile churn mixed in with code.
|
||||
- Reviewers upstream read commit by commit, and the history isn't ready
|
||||
for that.
|
||||
|
||||
The retcon is **not** a rebase onto a new base. If the branch lags its base, run the weaver first (or chain "weave then retcon"). Retcon assumes the base is current.
|
||||
A retcon is **not** a rebase onto a new base. If the branch lags its
|
||||
base, rebase first, then retcon. A retcon assumes the base is current.
|
||||
|
||||
## What the retcon produces
|
||||
## What a retcon produces
|
||||
|
||||
A linear history on the PR branch with commits grouped as:
|
||||
A linear history with commits grouped as:
|
||||
|
||||
- **One commit per affected package.** Changes under `packages/foo/` ship in one commit; changes under `packages/bar/` ship in another. Conventional-commit message scoped to the package (`feat(foo): ...`, `fix(bar): ...`, etc.).
|
||||
- **One `chore: Update yarn.lock` commit** for the lockfile, separate from any `package.json` commit that caused it. Cite [yarn-lock-separate-commit](../yarn-lock-separate-commit/SKILL.md) for the discipline; the retcon is the place that discipline is *applied retroactively* across the whole branch, not commit-by-commit at write time.
|
||||
- **Implementation and tests in the same commit.** A package's behavior change and the tests covering that change ship together. This is a deliberate departure from the "test, then implement" or "implement, then test" patterns that produce two commits per feature; the retcon wants one.
|
||||
- **Conventional-commit messages** on every commit. The first line is `<type>(<scope>): <imperative summary>`; the body (when present) names *why*, not the diff.
|
||||
- **One commit per affected package.** Changes under `packages/foo/` go
|
||||
in one commit, and changes under `packages/bar/` go in another. Each
|
||||
message is scoped to its package: `feat(foo): ...`, `fix(bar): ...`.
|
||||
- **One `chore: Update yarn.lock` commit**, separate from the
|
||||
`package.json` change that caused it. This applies the
|
||||
[yarn.lock separate-commit rule](../yarn-lock-separate-commit/SKILL.md)
|
||||
to the whole branch at once.
|
||||
- **Implementation and tests in the same commit.** A package's behavior
|
||||
change and the tests covering it ship together, not as a "test" commit
|
||||
plus an "implement" commit.
|
||||
- **Conventional-commit messages.** The first line is
|
||||
`<type>(<scope>): <imperative summary>`. The body, if any, says *why*,
|
||||
not what the diff shows.
|
||||
|
||||
The PR's net diff (the merge result) is byte-identical before and after the retcon. The judge's panel re-runs against the new commit shape: each per-package commit gets its own scope of attention rather than the panel having to mentally re-group sprawling history.
|
||||
The PR's net diff (its merge result) is byte-identical before and
|
||||
after.
|
||||
|
||||
## Procedure
|
||||
|
||||
Run inside the PR's project worktree on its head branch. The branch must be up to date with its base; if not, run a weaver first.
|
||||
Run on the PR's head branch, in a worktree that is up to date with the
|
||||
base.
|
||||
|
||||
```sh
|
||||
# 0. Sanity: confirm the working tree is clean and the branch tip matches the
|
||||
# remote we'll push to.
|
||||
git -C project status --porcelain # must be empty
|
||||
git -C project log --oneline origin/<head-branch>..HEAD # must be empty
|
||||
# 0. Sanity: clean tree, and the tip matches the remote we'll push to.
|
||||
git status --porcelain # must be empty
|
||||
git log --oneline origin/<head-branch>..HEAD # must be empty
|
||||
|
||||
# 1. Identify the base. For a PR, this is the base branch named in the PR's
|
||||
# metadata (typically master or main, or llm for design-roadmap PRs).
|
||||
BASE=$(gh pr view <N> -R <owner>/<repo> --json baseRefName --jq '.baseRefName')
|
||||
# 1. Save the pre-retcon tip for the no-net-change check.
|
||||
git tag pre-retcon HEAD
|
||||
|
||||
# 2. Reset to the base, keeping the net diff as a working-tree state.
|
||||
# --mixed (the default) unstages everything; --soft keeps the index. Use
|
||||
# --mixed so the next step's `git add` is deliberate.
|
||||
git -C project reset --mixed origin/$BASE
|
||||
# 2. Find the base branch.
|
||||
BASE=$(gh pr view <N> --json baseRefName --jq .baseRefName)
|
||||
|
||||
# 3. Restage by package. For each affected package, add the package's files
|
||||
# and commit with a conventional-commit message.
|
||||
git -C project add packages/foo/
|
||||
git -C project commit -m "feat(foo): <one-line summary>"
|
||||
# 3. Reset to the base, keeping the net diff as unstaged changes.
|
||||
# --mixed, so each `git add` that follows is deliberate.
|
||||
git reset --mixed origin/$BASE
|
||||
|
||||
git -C project add packages/bar/
|
||||
git -C project commit -m "fix(bar): <one-line summary>"
|
||||
# 4. Restage by package: implementation + tests together.
|
||||
git add packages/foo/
|
||||
git commit -m "feat(foo): <summary>"
|
||||
git add packages/bar/
|
||||
git commit -m "fix(bar): <summary>"
|
||||
# ... one commit per affected package ...
|
||||
|
||||
# (... one commit per affected package, implementation+tests bundled ...)
|
||||
# 5. Lockfile last.
|
||||
git add yarn.lock
|
||||
git commit -m "chore: Update yarn.lock"
|
||||
|
||||
# 4. Stage and commit the lockfile last, per yarn-lock-separate-commit.
|
||||
git -C project add yarn.lock
|
||||
git -C project commit -m "chore: Update yarn.lock"
|
||||
# 6. Verify: no net change.
|
||||
git diff pre-retcon HEAD # must be empty
|
||||
|
||||
# 5. Confirm the net diff is unchanged.
|
||||
git -C project diff origin/$BASE..HEAD --stat # compare to the pre-retcon diff
|
||||
git -C project diff <pre-retcon-sha>..HEAD # must be empty (no net change)
|
||||
|
||||
# 6. Force-push with --force-with-lease.
|
||||
git -C project push --force-with-lease origin HEAD:<head-branch>
|
||||
# 7. Push without clobbering concurrent work, then clean up.
|
||||
git push --force-with-lease origin HEAD:<head-branch>
|
||||
git tag -d pre-retcon
|
||||
```
|
||||
|
||||
The `--force-with-lease` (not `--force`) refuses to overwrite work pushed since the agent's last fetch; if the lease is rejected, fetch again and abort the retcon rather than clobbering concurrent work.
|
||||
Run `git status` after each `git add` to confirm that only the intended
|
||||
files are staged.
|
||||
|
||||
### Save a pre-retcon reference
|
||||
## Changes that don't fit one package
|
||||
|
||||
Before step 2, tag (or note) the pre-retcon tip so the no-net-change verification in step 5 has something to diff against. `git tag pre-retcon-<short-id> HEAD` works locally; delete the tag after the push lands.
|
||||
Name these groupings when you report the retcon, so the reviewer reads
|
||||
the new history as a deliberate shape:
|
||||
|
||||
### Capture grouping decisions
|
||||
|
||||
When the branch's changes do not map cleanly to a per-package commit (a cross-package refactor, a touch in a top-level config file), name the groupings in the retcon's report so the panel and the maintainer can read the new history as a deliberate shape rather than guessing. Typical buckets when a per-package split does not exhaust the diff:
|
||||
|
||||
- **Top-level config or build changes.** One commit, `chore: <summary>` or `build: <summary>`.
|
||||
- **Cross-package refactors that have to land atomically.** One commit covering all packages it touches, `refactor(<area>): <summary>`. The "area" names the conceptual change, not a single package.
|
||||
- **Documentation-only files (READMEs, design notes).** One commit, `docs: <summary>` or `docs(<scope>): <summary>` when scoped.
|
||||
- **Top-level config or build:** one `chore:` or `build:` commit.
|
||||
- **Cross-package refactors that must land atomically:** one
|
||||
`refactor(<area>):` commit, where the area names the concept, not a
|
||||
package.
|
||||
- **Docs-only files** (READMEs, design notes): one `docs:` commit, or
|
||||
`docs(<scope>):` if it's scoped.
|
||||
|
||||
## Net diff is invariant
|
||||
|
||||
The single load-bearing property of a retcon: the PR's net diff (base ... new head) is byte-identical to the PR's pre-retcon diff (base ... pre-retcon head). The retcon changes commit grouping; it does not change what the PR proposes to merge. Step 5's two diffs verify this:
|
||||
|
||||
- `git diff origin/$BASE..HEAD --stat` against the pre-retcon stat: same files, same line counts.
|
||||
- `git diff <pre-retcon-sha>..HEAD`: empty. The two heads have the same tree.
|
||||
|
||||
If either check fails, the retcon went wrong; do not push. Roll back with `git reset --hard <pre-retcon-sha>` and start over.
|
||||
|
||||
## Notes from the field
|
||||
|
||||
- _2026-05-14_: the verb landed when kriskowal said *"A new verb I would like to use is 'retcon' meaning 'Please reset this branch and restage the changes in sensibly grouped commits. This should generally have a single commit for each affected package, a separate commit for chore: Update yarn.lock, conventional-commit messages, and a single implementation and test commit.'"* The directive bundles three disciplines (per-package grouping, lockfile separation, implementation+tests together) that the garden had as separate skills or norms; the retcon names the procedure that applies all three at once to a branch's full history.
|
||||
This is the property everything else rests on. `git diff pre-retcon
|
||||
HEAD` must be empty, because the two heads have the same tree. If it
|
||||
isn't empty, **don't push.** Run `git reset --hard pre-retcon` and start
|
||||
over.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Forgetting the pre-retcon tag.** Without it, step 5's no-net-change check has no reference. Tag before resetting.
|
||||
- **`git reset --hard` instead of `--mixed`.** `--hard` discards the working tree and loses the net diff. Use `--mixed` (or `--soft` if you want the index preserved) so the diff stays available as unstaged changes.
|
||||
- **Lockfile interleaved with a package commit.** If `git add packages/foo/` accidentally stages a lockfile that lives under that package (rare; typically lockfiles are root-level), the lockfile leaks into the package commit. Run `git status` after each `git add` and verify only the intended package's files are staged.
|
||||
- **Implementation without tests, or tests without implementation.** The discipline is one commit per package containing both. A package commit with no test changes when the implementation is new behavior is wrong; either the tests live elsewhere (a cross-package test commit, separately) or the implementation is genuinely untested (a panel finding for the next round, not a retcon target).
|
||||
- **Conflicting with concurrent fixer pushes.** A retcon force-pushes; if a fixer pushed since the agent's last fetch, `--force-with-lease` rejects the push. Do not override with `--force`; abort and rebase the retcon onto the new tip.
|
||||
- **No pre-retcon tag:** the no-net-change check has nothing to compare
|
||||
against. Tag before you reset.
|
||||
- **`git reset --hard` instead of `--mixed`:** it throws away the net
|
||||
diff. Use `--mixed`, or `--soft` to keep the index.
|
||||
- **Lockfile in a package commit:** `git add packages/foo/` can stage a
|
||||
lockfile that lives under that package. Check `git status`.
|
||||
- **Implementation without tests:** if a package commit adds behavior
|
||||
and changes no tests, either the tests live in a separate cross-package
|
||||
commit, or the code is untested. That's a review finding, not
|
||||
something the retcon should fix.
|
||||
- **The lease is rejected:** someone pushed since your last fetch. Don't
|
||||
fall back to `--force`. Fetch, then redo the retcon on the new tip.
|
||||
|
||||
## Origin
|
||||
|
||||
Kris Kowal coined the verb: *"Please reset this branch and restage the
|
||||
changes in sensibly grouped commits. This should generally have a single
|
||||
commit for each affected package, a separate commit for chore: Update
|
||||
yarn.lock, conventional-commit messages, and a single implementation and
|
||||
test commit."* Adapted from
|
||||
[the retcon skill in kriscendobot/garden](https://github.com/kriscendobot/garden/blob/aa94ebd0877a931ac67e29018b0fd4b62c257ece/skills/retcon/SKILL.md)
|
||||
(May 2026).
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: run-confined
|
||||
description: Run confined / sandbox an untrusted command — use for `curl ... | bash` installers, npm/npx/bunx of unvetted packages, or any third-party script that should not see ~/.ssh, ~/.config/gh, tokens, or the rest of $HOME; when someone says "run it confined", "sandbox this", or "don't pipe that into my shell". Runs the command in a transient systemd user unit whose only writable place is ~/.local.
|
||||
---
|
||||
|
||||
# Run confined: untrusted code in a one-shot systemd user unit
|
||||
|
||||
[run-confined.sh](run-confined.sh) runs a command in a transient
|
||||
`systemd-run --user` service with kernel-enforced sandboxing:
|
||||
|
||||
- `$HOME` is an unreadable tmpfs (`ProtectHome=yes`): no `~/.ssh`,
|
||||
`~/.config/gh`, `.env` files, or other secrets.
|
||||
- The only writable place is `/tmp/confined`, a bind mount of `~/.local`;
|
||||
inside the unit `$HOME` points there. Whatever gets installed lands in
|
||||
`~/.local` on the host.
|
||||
- System dirs are read-only; `/tmp` and `/dev` are private.
|
||||
- The calling shell's environment is not inherited (no `GH_TOKEN`,
|
||||
`SSH_AUTH_SOCK`, ...). Network is allowed.
|
||||
- Node (newest nvm v22, bound read-only) is on `PATH` when present.
|
||||
|
||||
Before running anything, the script does a wet test: if `~/.ssh` is still
|
||||
visible from inside a probe unit, it aborts with exit 3 and runs nothing.
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
.agents/skills/run-confined/run-confined.sh -- cmd [args...]
|
||||
```
|
||||
|
||||
Arguments naming paths under `$HOME` or `~/.local` are rewritten to the
|
||||
`/tmp/confined` equivalents; add `--preserve-paths` to opt out.
|
||||
|
||||
stdin, stdout, and stderr are piped through (`systemd-run -P`), so a
|
||||
download-and-run installer works as a pipe. Fetch with `curl` on the host
|
||||
(read it first), then run the interpreter confined. Pass environment
|
||||
explicitly with `env`, since the unit doesn't inherit any:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://bun.com/install -o /tmp/bun-install.sh
|
||||
less /tmp/bun-install.sh
|
||||
.agents/skills/run-confined/run-confined.sh -- \
|
||||
env BUN_INSTALL=/tmp/confined bash < /tmp/bun-install.sh
|
||||
# -> ~/.local/bin/bun on the host
|
||||
```
|
||||
|
||||
Point installers at `/tmp/confined` (e.g. `BUN_INSTALL`, `npm_config_prefix`,
|
||||
which the script already sets), not `$HOME/.foo`. That way binaries land in
|
||||
`~/.local/bin`, which is already on the host `PATH`. Rc-file edits an
|
||||
installer makes go to `~/.local/.bashrc` and similar, where they have no
|
||||
effect; check for them and delete them.
|
||||
|
||||
## When the wet test aborts
|
||||
|
||||
On Ubuntu 24.04, `kernel.apparmor_restrict_unprivileged_userns=1` (the
|
||||
default) stops the systemd user manager from creating namespaces, and
|
||||
systemd *silently* skips the sandbox. The script refuses rather than run
|
||||
unconfined. The operator decides whether to lift it:
|
||||
|
||||
```sh
|
||||
sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0 # until reboot
|
||||
```
|
||||
|
||||
Don't run the command unconfined as a fallback. Report the abort and
|
||||
let the operator choose. The script header lists the full symptom set.
|
||||
|
||||
The canonical copy of this script is
|
||||
`~/projects/office-admin/tools/run-confined.sh`.
|
||||
Executable
+214
@@ -0,0 +1,214 @@
|
||||
#!/bin/bash
|
||||
# Run a command inside a one-shot, kernel-confined systemd USER unit.
|
||||
#
|
||||
# Usage: run-confined.sh -- cmd [args...]
|
||||
#
|
||||
# Everything after the first "--" is executed in a transient user service
|
||||
# with strong sandboxing. Inside the unit:
|
||||
# * $HOME is an unreadable tmpfs - ~/.ssh, ~/.config/gh, CI secrets
|
||||
# do not exist / cannot be read. Denial by kernel namespace.
|
||||
# * the only writable place is /tmp/confined, which is a bind mount of
|
||||
# ~/.local, and $HOME points there. Installs for this machine land in
|
||||
# ~/.local and stay there.
|
||||
# * system dirs are read-only; /dev and /tmp are private.
|
||||
#
|
||||
# The unit is started by the systemd user manager, so the calling shell's
|
||||
# environment is NOT inherited (no GH_TOKEN, SSH_AUTH_SOCK, etc.) unless
|
||||
# explicitly passed via Environment= below.
|
||||
#
|
||||
# Requires the user manager to be able to create mount/user namespaces.
|
||||
# Ubuntu 24.04 ships kernel.apparmor_restrict_unprivileged_userns=1 which
|
||||
# blocks that; run-confined refuses to run unless the sandbox is proven
|
||||
# effective by a wet test, so it never silently runs unconfined.
|
||||
#
|
||||
# TROUBLESHOOTING
|
||||
# --------------
|
||||
# On Ubuntu 24.04 the AppArmor userns boundary (
|
||||
# kernel.apparmor_restrict_unprivileged_userns=1 , set by default) stops
|
||||
# the systemd user manager from creating namespaces. systemd then SILENTLY
|
||||
# skips sandbox setup rather than failing the unit, so the command still
|
||||
# "runs" while the sandbox is off. Symptoms:
|
||||
# * wet-test abort with a leak report that lists the full $HOME (incl.
|
||||
# ~/.ssh), instead of an empty / "Permission denied" home.
|
||||
# * "UTS namespace setup is prohibited (container manager?), ignoring
|
||||
# namespace setup." in `journalctl --user -b`.
|
||||
# * units die at once with status=218/CAPABILITIES + journal line
|
||||
# "Failed to drop capabilities: Operation not permitted" when any of
|
||||
# PrivateDevices/ProtectClock/ProtectHostname/ProtectKernel* is set.
|
||||
# * unrelated sandboxes (firejail, unshare -m, podman rootless) fail too.
|
||||
# None of this means the box is broken; it is a deliberate AppArmor
|
||||
# containment boundary. To use namespaced sandboxes again, either
|
||||
# temporarily with:
|
||||
# sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0
|
||||
# or persist it across reboots with:
|
||||
# echo 'kernel.apparmor_restrict_unprivileged_userns=0' \
|
||||
# | sudo tee /etc/sysctl.d/60-userns.conf
|
||||
#
|
||||
# Some hardening properties need capset() / extra namespaces and are not
|
||||
# available on every kernel; each is probed and dropped with a note.
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
sed -n '3,9p' "$0"
|
||||
exit 2
|
||||
}
|
||||
|
||||
[[ $# -ge 1 && $1 == "--" ]] || usage
|
||||
shift
|
||||
[[ $# -ge 1 ]] || usage
|
||||
|
||||
carve="/tmp/confined"
|
||||
writerowed="$HOME/.local"
|
||||
wet_marker=".run-confined-wet"
|
||||
|
||||
# Command args that reference files under $HOME are normally tab-completed
|
||||
# on the host, but inside the unit $HOME is blanked and the carve lives at
|
||||
# $carve. Rewrite such args so you type the host path and the unit still
|
||||
# finds it. Pass the literal directory with "--preserve-paths" to opt out.
|
||||
preserve_paths=0
|
||||
for a in "$@"; do
|
||||
[ "$a" = "--preserve-paths" ] && preserve_paths=1
|
||||
done
|
||||
|
||||
map_path() {
|
||||
case "$1" in
|
||||
"$writerowed"/*) printf '%s/%s' "$carve" "${1#"$writerowed"/}" ;;
|
||||
"$writerowed") printf '%s' "$carve" ;;
|
||||
"$HOME"/*) printf '%s/%s' "$carve" "${1#"$HOME"/}" ;;
|
||||
"$HOME") printf '%s' "$carve" ;;
|
||||
*) printf '%s' "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
cmd=()
|
||||
for a in "$@"; do
|
||||
if [ "$a" = "--preserve-paths" ]; then
|
||||
continue
|
||||
elif [ "$preserve_paths" -eq 1 ]; then
|
||||
cmd+=("$a")
|
||||
else
|
||||
cmd+=("$(map_path "$a")")
|
||||
fi
|
||||
done
|
||||
[[ ${#cmd[@]} -ge 1 ]] || usage
|
||||
|
||||
# Node for the unit. No downloads, no checksum theater: the newest nvm
|
||||
# v22 install is bound in READ-ONLY (so the unit gets node+npm but cannot
|
||||
# tamper with ~/.nvm). Override with CONFINED_NODE=/path/to/node-dir (the
|
||||
# dir that has bin/node). A standalone carve node is preferred if present.
|
||||
unit_path="/usr/local/bin:/usr/bin:/bin"
|
||||
bind_node_dir=""
|
||||
node_target="/tmp/confined-node"
|
||||
|
||||
if [[ -x "$HOME/.local/share/pi-node/current/bin/node" ]]; then
|
||||
unit_path="$HOME/.local/share/pi-node/current/bin:$unit_path"
|
||||
elif [[ -n "${CONFINED_NODE:-}" && -x "$CONFINED_NODE/bin/node" ]]; then
|
||||
bind_node_dir="$CONFINED_NODE"
|
||||
unit_path="$node_target/bin:$unit_path"
|
||||
else
|
||||
newest_bin=$(ls -d "$HOME"/.nvm/versions/node/v22.*/bin 2>/dev/null | sort -V | tail -1)
|
||||
if [[ -n "$newest_bin" && -x "$newest_bin/node" ]]; then
|
||||
bind_node_dir="${newest_bin%/bin}"
|
||||
unit_path="$node_target/bin:$unit_path"
|
||||
fi
|
||||
fi
|
||||
|
||||
base_props=(
|
||||
"ProtectSystem=strict"
|
||||
"ProtectHome=yes"
|
||||
"BindPaths=$writerowed:$carve"
|
||||
"PrivateTmp=yes"
|
||||
"PrivateMounts=yes"
|
||||
"ProtectKernelTunables=yes"
|
||||
"ProtectControlGroups=yes"
|
||||
"RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK"
|
||||
"RestrictRealtime=yes"
|
||||
"RestrictSUIDSGID=yes"
|
||||
"LockPersonality=yes"
|
||||
"NoNewPrivileges=yes"
|
||||
"MemoryMax=1G"
|
||||
"TasksMax=200"
|
||||
"TimeoutStartSec=infinity"
|
||||
)
|
||||
|
||||
# capset-dependent; known to fail under some kernels -> probe and drop.
|
||||
optional_props=(
|
||||
"PrivateDevices=yes"
|
||||
"ProtectClock=yes"
|
||||
"ProtectHostname=yes"
|
||||
"ProtectKernelModules=yes"
|
||||
"ProtectKernelLogs=yes"
|
||||
)
|
||||
|
||||
probe_pass() {
|
||||
timeout 10 systemd-run --user --wait -q --property="$1" -- /bin/true 2>/dev/null
|
||||
}
|
||||
|
||||
# A real sandbox blanks $HOME (unreadable), exposes only the carve, and
|
||||
# allows writes there. Concretely: $HOME/.ssh must not be listable, and a
|
||||
# touch/rm round-trip must work inside the carve. Anything else means
|
||||
# systemd silently skipped the namespace setup -> not confined.
|
||||
sandbox_wet_test() {
|
||||
timeout 20 systemd-run --user --wait -q \
|
||||
--property="ProtectHome=yes" \
|
||||
--property="BindPaths=$writerowed:$carve" \
|
||||
--property="PrivateTmp=yes" \
|
||||
--property="Environment=HOME=$carve" \
|
||||
-- /bin/sh -c "ls -d \"\$HOME/.ssh\" >/dev/null 2>&1 && exit 1; touch \"$carve/$wet_marker\" && rm \"$carve/$wet_marker\"" 2>/dev/null
|
||||
}
|
||||
|
||||
# Show what leaked, to diagnose an ineffective sandbox.
|
||||
sandbox_leak_report() {
|
||||
timeout 20 systemd-run --user --wait -P \
|
||||
--property="ProtectHome=yes" \
|
||||
--property="BindPaths=$writerowed:$carve" \
|
||||
--property="PrivateTmp=yes" \
|
||||
--property="Environment=HOME=$carve" \
|
||||
-- /bin/sh -c 'echo "HOME listing (expect Permission denied):"; ls -A "$HOME" 2>&1; echo; echo ".ssh (expect cannot access):"; ls -d "$HOME/.ssh" 2>&1; echo; echo "carve:"; ls "$carve" 2>&1 | head -5' 2>/dev/null
|
||||
}
|
||||
|
||||
props=("${base_props[@]}")
|
||||
if [[ -n "$bind_node_dir" ]]; then
|
||||
props+=("BindReadOnlyPaths=$bind_node_dir:$node_target")
|
||||
fi
|
||||
for p in "${optional_props[@]}"; do
|
||||
if probe_pass "$p"; then
|
||||
props+=("$p")
|
||||
else
|
||||
echo "note: dropping unsupported hardening property: $p" >&2
|
||||
fi
|
||||
done
|
||||
|
||||
if ! sandbox_wet_test; then
|
||||
echo "abort: sandbox is not actually enforced on this kernel/session" >&2
|
||||
echo " ~/.ssh must not exist inside the transient unit and the carve" >&2
|
||||
echo " write must succeed; the wet test failed, so NOTHING ran." >&2
|
||||
echo " This is the apparmor_restrict_unprivileged_userns=1 symptom on" >&2
|
||||
echo " Ubuntu 24.04 (see the header for the full symptom list). Fix:" >&2
|
||||
echo " sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0" >&2
|
||||
echo " or persist: echo 'kernel.apparmor_restrict_unprivileged_userns=0' |" >&2
|
||||
echo " sudo tee /etc/sysctl.d/60-userns.conf" >&2
|
||||
echo "Leak report from the probe unit follows:" >&2
|
||||
sandbox_leak_report >&2 || true
|
||||
exit 3
|
||||
fi
|
||||
|
||||
unit_name="run-confined-$(date +%s%N)"
|
||||
|
||||
systemd_run_args=(
|
||||
--user --wait -P
|
||||
--unit="$unit_name"
|
||||
--property="Type=oneshot"
|
||||
--property="WorkingDirectory=$carve"
|
||||
--property="Environment=HOME=$carve"
|
||||
--property="Environment=PATH=$unit_path"
|
||||
--property="Environment=TMPDIR=${TMPDIR:-/tmp}"
|
||||
--property="Environment=TERM=vt100"
|
||||
--property="Environment=npm_config_prefix=$carve"
|
||||
--property="Environment=npm_config_cache=$carve/share/node/npm-cache"
|
||||
)
|
||||
for p in "${props[@]}"; do
|
||||
systemd_run_args+=(--property="$p")
|
||||
done
|
||||
|
||||
systemd-run "${systemd_run_args[@]}" -- "${cmd[@]}"
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: silent-failures-are-evil
|
||||
description: No silent failures — use when writing or reviewing error handling: catch blocks, promise rejections, fallbacks for unsupported features, or any branch that treats a result as "didn't work"; when a UI shows a failure with nothing logged; when someone says "silent failure". Every failure path leaves a trace in the logs, and no failure is invented without checking what the API actually returns.
|
||||
---
|
||||
|
||||
# Silent failures are evil
|
||||
|
||||
A failure nobody can see is a failure nobody can fix. Every failure path
|
||||
must leave a trace.
|
||||
|
||||
## the rules
|
||||
|
||||
- **Log every failure path**: a caught exception, a rejected promise, a
|
||||
falsy result treated as "didn't work", an unsupported-feature
|
||||
fallback. Changing a label on screen is not enough — log it, with an
|
||||
`event` tag for grepping, and no secrets (no keys, cookies, header or
|
||||
param values).
|
||||
- **Don't invent failures.** Before branching on a result, check what
|
||||
the API actually returns (the spec or MDN, not a guess). A success
|
||||
misreported as failure is as bad as a failure reported as nothing.
|
||||
- **Say only what you know.** When an API can't tell you the outcome,
|
||||
report what it did tell you ("offered", not "saved").
|
||||
- **Make it testable.** Pass the logger in (see
|
||||
[ocap-discipline](../ocap-discipline/SKILL.md)) so a test can assert
|
||||
that the failure path logged; a discovered silent failure is a goof
|
||||
that owes a test ([lazy-tdd](../lazy-tdd/SKILL.md)).
|
||||
|
||||
## the goof that prompted this
|
||||
|
||||
diigo-cache's password-manager save button said "not saved", with
|
||||
nothing in the console. The code read `c ? "saved" : "not saved"` on the
|
||||
result of [`CredentialsContainer.store()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/store),
|
||||
which resolves with `undefined` per spec — even on success. So every
|
||||
save was reported as a failure, silently. The fix
|
||||
(`capsite/diigo-cache/src/pm-save.ts`) reports "offered", logs every
|
||||
outcome, and is covered by `tests/pm-save.test.ts`.
|
||||
+3
-4
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: watchme
|
||||
description: Use when the operator says "watch me", "losing", or "winning?" about a session recorded with `script -f xyz.script` — the typescript is shared ground truth. Unless otherwise stated, the newest `.tmp/*.script` is the file to look at. For "losing", read the tail for what was done and what the machine said, diagnosing from evidence instead of asking for a re-telling. For "winning?", answer one of exactly "Yes, that's as expected; next, do X." or "Yes, as expected. Done!" — no partial verdicts.
|
||||
description: Watch me — terminal / shell session recorded with `script -f` (a typescript, `.tmp/*.script`) as shared ground truth. Use when the operator says "watch me", "losing", or "winning?"; when diagnosing a shell session from its recorded transcript instead of asking for a re-telling; when asked how to record a terminal session.
|
||||
---
|
||||
|
||||
# Watch Me: a typescript as shared ground truth
|
||||
@@ -60,8 +60,7 @@ Script done on 2026-09-02 23:48:46-05:00 [COMMAND_EXIT_CODE="0"]
|
||||
```
|
||||
|
||||
The header records when, on which TTY, and the terminal geometry; the footer
|
||||
records the exit code. `watchme` is a short specimen — the smallest useful
|
||||
capture, including the escape noise described below.
|
||||
records the exit code.
|
||||
|
||||
For a longer session the value is that one file holds both halves of the
|
||||
story: the operator's commands and the machine's responses, interleaved in
|
||||
@@ -69,7 +68,7 @@ order. `tail` it while it is live; `grep` it after the fact.
|
||||
|
||||
## Relationship to the lab notebook
|
||||
|
||||
The typescript is raw evidence; the [lab notebook](../lab-notebook-template.md)
|
||||
The typescript is raw evidence; a lab notebook
|
||||
is interpretation. Read the script to establish what happened, then promote
|
||||
the finding — hypothesis, evidence, next question — into a notebook entry so
|
||||
it survives independently of the transcript.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: what-was-i-doing
|
||||
description: What was I doing on <date>? Reconstruct past activity on this machine — use when the operator asks what they were doing on a day, where a stray file / install / symlink came from, or "why is this here?"; when a timestamp (ls, stat, cache dir) is the only clue. Correlates file mtimes with git commits and reflogs across every repo `locate` finds.
|
||||
---
|
||||
|
||||
# What was I doing? Timestamps as a diary
|
||||
|
||||
The machine already keeps a diary; it's just scattered. A mystery artifact
|
||||
(a stray `~/.bun`, an odd symlink, a cache dir) carries an mtime. Line that
|
||||
time up against git activity across all repos, and the project usually
|
||||
falls out within a few minutes.
|
||||
|
||||
## 1. Pin the clue's timestamp to the minute
|
||||
|
||||
```sh
|
||||
ls -la --time-style=+'%F %H:%M' ~/.bun ~/.bun/* ~/.local/bin/bunx
|
||||
```
|
||||
|
||||
Look at the artifact *and its neighbors*: the directory, its children, and
|
||||
anything that points at it (symlink targets, `readlink`). The newest mtime
|
||||
inside a cache says when it was last used. Caches also say *what* was
|
||||
used: `ls ~/.bun/install/cache`, `~/.npm/_cacache`, `~/.cache/pip`.
|
||||
A cache full of `@vitejs`, `@reduxjs`, `@tailwindcss` means a frontend
|
||||
build.
|
||||
|
||||
## 2. Sweep every repo for that day
|
||||
|
||||
[day-in-repos.sh](day-in-repos.sh) lists commits *and reflog entries* for
|
||||
one day across every repo in the `locate` database. Reflog is the important
|
||||
half: pulls, checkouts, rebases, and amends are local-only, never show in
|
||||
`git log`, and are often exactly what you did that day.
|
||||
|
||||
```sh
|
||||
.agents/skills/what-was-i-doing/day-in-repos.sh 2026-05-25 | grep -v heartbeat
|
||||
.agents/skills/what-was-i-doing/day-in-repos.sh 2026-05-25 --author=dckc
|
||||
```
|
||||
|
||||
- Extra args go to `git log`. `--author` cuts upstream and bot commits, but
|
||||
reflog rows are never filtered. They're always yours.
|
||||
- A commit shared by several clones/worktrees prints once, listing them all.
|
||||
- Reflog begins at `clone: from <url>` and is pruned by `git gc` (default
|
||||
90 days, 30 for unreachable), so older days may be patchy. The mtime of
|
||||
`.git/FETCH_HEAD` gives the last fetch even when the reflog doesn't.
|
||||
- The `locate` DB is rebuilt nightly. Repos created today are missing;
|
||||
`sudo updatedb` if that matters.
|
||||
- Filter noise with `grep -v`. Bot heartbeats and dependabot bumps
|
||||
flood some repos.
|
||||
|
||||
## 3. Correlate, then confirm in the candidate repo
|
||||
|
||||
Look for repo activity within minutes of the artifact's mtime. Then confirm
|
||||
inside that repo: does it reference the tool (`git grep -i bun`, CI
|
||||
workflows, lockfiles), and do its build outputs (`node_modules`, `dist`)
|
||||
carry matching mtimes?
|
||||
|
||||
Report it as a timeline table: time, event, source. Say which links
|
||||
are evidence and which are inference ("probably linked by hand").
|
||||
|
||||
## Other diaries, when git is quiet
|
||||
|
||||
- `journalctl --user --since ... --until ...`: user units, systemd-run.
|
||||
- Shell history: `~/.bash_history` has timestamps only if `HISTTIMEFORMAT`
|
||||
was set; `.tmp/*.script` typescripts (see the watchme skill).
|
||||
- File mtimes broadly: `find ~ -xdev -newermt 2026-05-25 ! -newermt
|
||||
2026-05-26 -not -path '*/node_modules/*' -not -path '*/.cache/*'`
|
||||
([fselect](https://github.com/jhspetersson/fselect) does the same in SQL,
|
||||
if installed).
|
||||
|
||||
## Worked example
|
||||
|
||||
`~/.local/bin/bunx` → `~/.bun/bin/bun` (1.3.14), both 2026-05-25 12:27.
|
||||
The sweep showed `projects/pinchtab` reflog: pull 12:19, forced-update
|
||||
12:35, checkout `v0.13.0` 12:36. `pinchtab/dashboard/` has a `bun.lock`,
|
||||
CI runs `bun install`, and `dashboard/node_modules` was created at 12:28.
|
||||
Conclusion: bun was installed to build pinchtab's dashboard for v0.13.0.
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
# List git activity on one day across every repo `locate` knows about.
|
||||
#
|
||||
# Usage: day-in-repos.sh YYYY-MM-DD [git-log-args...]
|
||||
#
|
||||
# Prints commits (by committer date) and reflog entries (pulls, checkouts,
|
||||
# amends: local-only events that `git log` never shows), sorted by local
|
||||
# time. A commit present in several clones/worktrees is printed once, with
|
||||
# every repo it appears in. Extra args go to `git log`, e.g. --author=dckc
|
||||
# to drop bot and upstream commits.
|
||||
set -euo pipefail
|
||||
|
||||
[[ $# -ge 1 && $1 =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || {
|
||||
sed -n '2,10p' "$0"
|
||||
exit 2
|
||||
}
|
||||
day=$1
|
||||
shift
|
||||
next=$(date -d "$day + 1 day" +%F)
|
||||
|
||||
locate -r '/\.git$' \
|
||||
| grep "^$HOME/" \
|
||||
| grep -v -e /node_modules/ -e /.cache/ -e /.cargo/ -e /go/pkg/ \
|
||||
| while read -r g; do
|
||||
r=${g%/.git}
|
||||
name=${r#"$HOME"/}
|
||||
git -C "$r" log --all --since="$day 00:00" --until="$next 00:00" \
|
||||
--date=format:%H:%M --format="%cd %h $name %s" "$@" 2>/dev/null || true
|
||||
git -C "$r" reflog --all --date=format:'%F %H:%M' \
|
||||
--format="%gd $name %gs" 2>/dev/null \
|
||||
| awk -F'\t' -v day="$day" '
|
||||
match($1, /\{[0-9-]+ [0-9:]+\}/) {
|
||||
ts = substr($1, RSTART + 1, RLENGTH - 2)
|
||||
if (substr(ts, 1, 10) == day)
|
||||
printf "%s\treflog\t%s\t%s\n", substr(ts, 12), $2, $3
|
||||
}' || true
|
||||
done \
|
||||
| awk -F'\t' '
|
||||
# group by time+hash (reflog rows by time+subject)
|
||||
{ key = ($2 == "reflog") ? $1 FS $4 : $1 FS $2
|
||||
if (!(key in repos)) { order[++n] = key; line[key] = $0 }
|
||||
if (!((key, $3) in seen)) {
|
||||
seen[key, $3] = 1
|
||||
repos[key] = repos[key] (repos[key] ? " " : "") $3
|
||||
} }
|
||||
END {
|
||||
for (i = 1; i <= n; i++) {
|
||||
split(line[order[i]], f, FS)
|
||||
printf "%s %-7s %s [%s]\n", f[1], f[2], f[4], repos[order[i]]
|
||||
}
|
||||
}' \
|
||||
| sort
|
||||
@@ -1,51 +1,69 @@
|
||||
---
|
||||
created: 2026-05-13
|
||||
updated: 2026-05-17
|
||||
author: liaison, gardener
|
||||
name: yarn-lock-separate-commit
|
||||
description: yarn.lock in its own commit — use when changing Yarn dependencies (package.json deps, workspace renames, yarn install), when organizing or reviewing commits that touch yarn.lock, when a rebase conflicts on yarn.lock, or during a retcon. Every lockfile change ships alone as `chore: Update yarn.lock`, after the package.json commit that caused it.
|
||||
---
|
||||
|
||||
# Skill: yarn-lock-separate-commit
|
||||
# yarn.lock in a separate commit
|
||||
|
||||
Adopted from `references/endo-but-for-bots/skills/yarn-lock-separate-commit.md`.
|
||||
|
||||
Every change that touches `yarn.lock` ships in its own commit, separate from the `package.json` change that caused the update, with the message `chore: Update yarn.lock`.
|
||||
Every change that touches `yarn.lock` ships in its own commit, with the
|
||||
message `chore: Update yarn.lock`. It stays separate from the
|
||||
`package.json` change that caused it.
|
||||
|
||||
## Why
|
||||
|
||||
A separate lockfile commit can be dropped and regenerated cleanly on rebase. A combined commit turns lockfile churn into merge conflicts that have to be resolved by re-running `yarn install` and hand-stitching, which is error-prone and pollutes history.
|
||||
A separate lockfile commit can be dropped on a rebase and cleanly
|
||||
regenerated. A combined commit turns lockfile churn into merge
|
||||
conflicts, and those get resolved by re-running `yarn install` and
|
||||
stitching by hand. That's error-prone and clutters the history.
|
||||
|
||||
## Procedure
|
||||
|
||||
After making the dep change in `package.json`:
|
||||
After the dependency change in `package.json`:
|
||||
|
||||
```sh
|
||||
git add packages/foo/package.json
|
||||
git commit -m "feat(foo): add bar dependency (#NNNN)"
|
||||
git commit -m "feat(foo): add bar dependency"
|
||||
|
||||
npx corepack yarn install
|
||||
git add yarn.lock
|
||||
git commit -m "chore: Update yarn.lock"
|
||||
```
|
||||
|
||||
When force-pushing a rebased branch, the order of the two commits matters: put the lockfile commit *after* the package.json commit, so a reviewer dropping the lockfile commit lands at a still-coherent state.
|
||||
Order matters. Put the lockfile commit **after** the `package.json`
|
||||
commit, so dropping the lockfile commit still leaves a coherent state.
|
||||
|
||||
## Rebasing past a lockfile conflict
|
||||
|
||||
If another PR changed dependencies in the meantime, rebasing onto the
|
||||
moved base usually conflicts on `yarn.lock`. Don't hand-merge it. Drop
|
||||
the old lockfile commit and regenerate the lockfile:
|
||||
|
||||
```sh
|
||||
git rebase origin/<base>
|
||||
# conflict on yarn.lock
|
||||
git rebase --skip # drop the old lockfile commit
|
||||
# ... any other fixes on top ...
|
||||
npx corepack yarn install # regenerate against the new base
|
||||
git add yarn.lock
|
||||
git commit -m "chore: Update yarn.lock"
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **`git commit --amend` on the package.json commit silently drags the lockfile in if staged.** Stage and commit the lockfile last.
|
||||
- **Accidental combining.** If caught on review, `git reset HEAD~1`, re-stage selectively, and recommit as two.
|
||||
- **Package renames invalidate the lockfile.** Renaming a workspace's directory or `package.json:name` changes the workspace key (`"@scope/<name>@workspace:packages/<dir>"`) inside `yarn.lock`. Still ships in its own `chore: Update yarn.lock` commit, regenerated by re-running `npx corepack yarn install` after all renames are in place. Don't hand-edit the workspace key; the install produces the canonical form.
|
||||
- **`git commit --amend` on the `package.json` commit** quietly pulls
|
||||
the lockfile in if it's staged. Stage and commit the lockfile last.
|
||||
- **Combined by accident:** run `git reset HEAD~1`, restage selectively,
|
||||
and recommit as two commits.
|
||||
- **Package renames change the lockfile.** Renaming a workspace's
|
||||
directory or its `package.json` `name` changes the workspace key
|
||||
(`"@scope/<name>@workspace:packages/<dir>"`) in `yarn.lock`. Once all
|
||||
the renames are in, regenerate with `npx corepack yarn install`.
|
||||
Commit the result on its own as usual, and don't hand-edit the key.
|
||||
|
||||
## Notes from the field
|
||||
## See also
|
||||
|
||||
- _2026-05-13_: adopted from the reference. Session example from PR 71 lives in the journal, not here.
|
||||
- _2026-05-17_: a PR carrying `chore: Update yarn.lock` typically conflicts on the lockfile when rebased onto a moved base (any other PR that touched dependencies has landed in the interim). The workflow that resolved it cleanly: skip the old lockfile commit during rebase, apply any review-feedback fixes on top, then regenerate the lockfile against the new base as its own commit. Source: PR #238 fixer, `journal/entries/2026/05/17/200805Z-message-fixer-3e9fd0.md`.
|
||||
|
||||
```sh
|
||||
git rebase origin/<base>
|
||||
# conflict on yarn.lock
|
||||
git rebase --skip # drop the old lockfile commit
|
||||
# apply review-feedback fixes on top
|
||||
npx corepack yarn install # regenerate lockfile against new base
|
||||
git add yarn.lock
|
||||
git commit -m "chore: Update yarn.lock"
|
||||
```
|
||||
- [Retcon](../retcon/SKILL.md) applies this rule to a whole branch's
|
||||
history after the fact.
|
||||
- Adapted from
|
||||
[the yarn-lock-separate-commit skill in kriscendobot/garden](https://github.com/kriscendobot/garden/blob/f6fd8db6abe012366aa4c843874c07adae46e7f7/skills/yarn-lock-separate-commit/SKILL.md)
|
||||
(May 2026).
|
||||
Reference in new issue
Block a user