Skip to content
dsh.fish
Bundle

nightshift

Run an agent unattended for hours and get a morning brief that is an audit, not a summary. Signs a manifest before the work, holds the run to it, and reports the difference.

Source
liyixuan201211
License
MIT
Updated
Updated 4 hours ago

Readme

# nightshift

**Run an agent unattended for hours — and get a morning brief that is an audit, not a summary.**

Sign a manifest before the work: what it is for, which commands prove it worked,
what must never run, and what it is allowed to spend. Then wrap each mutating
command in a checkpoint that rolls itself back when verification fails. In the
morning, get a brief generated from the run's own journal — what changed, what
was verified, **what was not**, and the exact command to undo the night.

```bash
dsh plugin --profile web add github:liyixuan201211/nightshift
```

No install needed to try it:

```bash
npx --yes github:liyixuan201211/nightshift --help
```

## The problem this is for

The tools this belongs to each answer one question about one moment —
`ctx-budget` what it will cost, `blastradius` what it would destroy, `deadend`
whether this failed before, `mcp-cap` what a server can do, `skillnotary` what a
skill may do, `rewind` how to get it back. All of them are point-in-time and
stateless.

An unattended run is neither. Once the person leaves, nobody is standing at
those checkpoints — and the failures that matter over hours are the quiet ones.
Drift: the objective changing, the budget passing, a step failing silently, a
loop spinning. No single-point tool is watching for those.

**nightshift is the layer that watches.** It is also the generalisation of
something the family already does by hand — `ctx-budget`'s own bundle notes that
it reads a `tools/list` dump that `mcp-cap` already produced.

## The one idea

> **The manifest is a contract you sign while you are still awake. The brief is
> the audit of that contract.**

```bash
nightshift init --root ~/work \
  --objective "finish the auth refactor and get the suite green" \
  --deliverable "npm test passes" \
  --accept "tests=npm test" \
  --accept "types=npx tsc --noEmit" \
  --forbid "git push" \
  --command "npm run build"

nightshift preflight --root ~/work     # may I start?
nightshift step --root ~/work --verify "npm test" -- npm run build
nightshift acceptance --root ~/work
nightshift brief --root ~/work
```

Only two of the declared things are enforceable, and that is the point:
`acceptance[]` is a list of **shell commands**, so "done" is an exit code rather
than a claim; `forbidden[]` is matched **before execution**, so a rule is a gate
rather than a post-mortem. An objective with no acceptance checks produces a
brief that says `unclear` and means it.

## What the brief contains

The brief is a **rendering of the journal**, never a summary written from
memory. That direction is what makes it worth reading:

- a claim with no event behind it cannot appear
- a declared acceptance check that never ran is reported as **not run**, not omitted
- a step whose success was never verified is reported as **unverified** — a
  different fact from "passed"
- **every action the gate refused** is listed, because a report of what a run
  *did* cannot show whether the gate ever fired
- tools that were unavailable are named, with the count (`5/6 family tools`)
- the baseline snapshot id and the exact `rewind undo` line to reverse the night

Its bottom line is one word: `verified`, `failed`, `incomplete`, `recovered`,
`nothing-done`, `refused`, or `unclear`.

## The gate sits next to the action, not the session

`preflight` judges the commands a run **declares**. But a step can run anything,
and an unattended run has hours in which to change its mind. So the same
judgement runs again at every execution:

1. **forbidden patterns** — a standing instruction, matched first and never
   routable-around
2. **irreversibility class** — priced by `blastradius`, read from the **exit
   code** (`lost` → terminal, `costly` → recoverable, `safe` → two-way,
   anything else → unknown)
3. **curfew** — after `--curfew-minutes`, no *new* one-way action may start
   (two-way actions stay allowed: refusing to save a file at 6am would be a worse
   failure than the one a curfew prevents)
4. **class policy** — the manifest's own knobs

A refusal means the command **did not run** and nothing was changed.

### A real disagreement between two of the tools

`blastradius` calls this `lost`:

```json
{ "recoverability": "lost",
  "why": "not inside a git repository, so nothing can restore it",
  "samples": ["data.txt"], "verdict": "lost" }
```

It means *git* cannot restore it. But `rewind` exists precisely to restore files
**git never tracked**, with or without git — so for a path inside the protected
root, the checkpoint taken moments later does restore it.

Trusting either tool alone is wrong: believing `blastradius` refuses nearly every
real refactor; believing the snapshot treats `rm -rf ~/something-else` as
recoverable when the snapshot never covered it. So nightshift uses
`blastradius`'s own `samples` to decide:

| at-risk paths | result |
|---|---|
| all inside the protected root | downgraded to `one_way_recoverable` — warned, allowed |
| any outside the root | stays `one_way_terminal` — **refused** |
| findings name no paths | stays `one_way_terminal` — **refused** |

The last row is deliberate: **being unable to tell is never an upgrade.**

## Refusing to start is a feature

`preflight` exits `4` and starts nothing when a run would be unrecoverable. The
usual cause is that `rewind` is unavailable — **an unattended run with no way
back is the one thing this refuses outright.** Fix the cause; there is no flag
that gets past it.

## Exit codes are the contract

| Exit | Meaning |
|---|---|
| `0` | ok |
| `1` | error |
| `2` | usage error |
| `3` | attention — a preflight warning, or a step that failed and was rolled back |
| `4` | refused — nothing was done, on purpose |
| `5` | degraded — it ran, but some checks could not be performed |
| `6` | nothing — there was nothing to do |

`-q` suppresses output and never the exit code.

## Notes on the seams with its siblings

- **`--exclude .nightshift` is not optional.** nightshift keeps its journal
  inside the directory `rewind` protects, so every `rewind` call passes the
  exclude. Without it the journal would dirty its own snapshots and `rewind
  status` would never be clean. (Tested.)
- **Exit codes are translated, not trusted.** `3` means OVER_BUDGET in
  ctx-budget, LOST in blastradius, DRIFT in deadend, CHANGED in mcp-cap, and
  "the tree differs" in rewind. Each tool's dialect is data in `src/tools.js`,
  overridable per run, and `nightshift tools` prints the table.
- **`unverifiable` is not `ok`.** `blastradius` exits 5 for any command outside
  its vocabulary, and its JSON says `"verdict": "none"` when it means *"I have no
  analysis for you"*. Reading that string as "no risk" waves through
  `curl … | sh`, so nightshift maps on the **exit code** and treats the verdict
  as corroboration only.
- **"Not priced" is recorded as `info`, never as a pass.** `blastradius` only
  understands destructive commands, so pricing `touch` legitimately returns "I
  cannot tell you". Escalating that to a warning every time would train the
  operator to ignore warnings — so it is visible in the brief without raising the
  verdict. (`onUnpriced`, default `ignore`.)

## Honest limits

- **It does not decide what to do.** It holds a run to what was already decided.
- **It is not a scheduler.** It does not wake up; something drives it.
- **It is not a sandbox.** It predicts with `blastradius` and recovers with
  `rewind`; it does not isolate.
- **It fetches nothing.** There is no `npx` fallback unless `--allow-npx` is
  passed. Resolution is local: `--tools-dir`, `$NIGHTSHIFT_TOOLS_DIR`, the
  configured search path, or `PATH`.
- **The brief is only as good as the acceptance checks.** No oracle, no
  verification — and the brief will say so rather than implying otherwise.

## Design

See [DESIGN.md](./DESIGN.md) for the architecture, the exit-code normalisation
table, and the reasoning behind the ordering rule that a refused run must not
leave a snapshot behind.

## License

MIT

Install

dsh plugin --profile web add github:liyixuan201211/nightshift

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source