Browse Source

chore: sync Scout skills 2026-09-20

main
Daniel Kucinski 3 weeks ago
parent
commit
3f05e4e8ff
  1. 2
      README.md
  2. 169
      lights-out/SKILL.md
  3. 91
      lights-out/references/cli.md
  4. 7
      lights-out/references/prompts.md
  5. 113
      lights-out/scripts/check-run.ps1
  6. 645
      lights-out/scripts/lib.ps1
  7. 163
      lights-out/scripts/start-run.ps1
  8. 470
      lights-out/scripts/test/e2e.ps1
  9. 62
      lights-out/scripts/test/stub-gnhf.ps1
  10. 94
      lights-out/scripts/test/stub-nightshift.ps1
  11. 32
      lights-out/vendor/package-lock.json
  12. 2
      lights-out/vendor/package.json

2
README.md

@ -25,7 +25,7 @@ Generated by `sync-skills.ps1`; do not edit manually.
| [get-meeting-transcript](get-meeting-transcript/SKILL.md) | Grab a Teams/Stream meeting transcript by driving a logged-in browser when the Graph download is blocked. Captures the underlying .vtt from network traffic, or scrapes the recap transcript pane. Accepts a Teams recap, Stream/SharePoint, or meet.microsoft.com URL, or a calendar reference it resolves to one. Saves .vtt + .md + the meeting chat into the Obsidian vault. Triggers: get transcript, scrape transcript, grab the transcript, meeting chat, transcript for my meeting. |
| [kb](kb/SKILL.md) | Vault-first recall. Search Daniel's Obsidian KB (D:\\Repos\\Obsidian) BEFORE WorkIQ/M365, Azure DevOps, IcM, or the browser. Use for any lookup: \"who is X\", \"what's my/his alias, SC-ALT, email, ID, manager, office\", \"what did we decide about X\", \"when did we meet about X\", \"what is <acronym>\", \"remind me\", \"do we know\", \"look it up\", \"check the KB\", plus anything about squads, Connects, RISE, meetings, daily notes, or people Daniel works with. |
| [kblinter](kblinter/SKILL.md) | Run the knowledge-base linter against the Obsidian vault at D:\\Repos\\Obsidian and surface the findings. Use whenever the user asks to lint, audit, or check the health of the vault/Second Brain. Triggers: /kblinter, \"lint the vault\", \"audit my notes\", \"check vault health\", \"run the kb linter\", \"find orphan notes\", \"find broken links in the vault\". |
| [lights-out](lights-out/SKILL.md) | Use when the user is going to bed, going away, or wants unattended overnight coding work; says "lights out", "run this overnight", "keep working while I sleep", or names gnhf; asks to check on, steer, or stop a running overnight job; asks "how did last night's run go", says "good morning", or wants a morning review of an agent run; or gives review findings on the results of an overnight run. |
| [lights-out](lights-out/SKILL.md) | Use when the user wants unattended overnight or away-from-keyboard coding work; says \"lights out\", \"nightshift\", \"run this overnight\", or \"keep working while I sleep\"; asks to launch, check, steer, or stop a running overnight job; wants a morning review; or gives follow-up findings on an overnight agent run. |
| [mslearn](mslearn/SKILL.md) | Query the live Microsoft Learn MCP for grounded, first-party docs, code samples, and full-page fetches across Azure, M365, .NET, and Power Platform. Use when the user asks what Microsoft says about X, needs an authoritative answer for a customer, wants current official guidance, looks for a code sample, or asks to cite or quote the docs. Triggers: ms learn, microsoft learn, learn docs, look it up on learn, cite the docs, official microsoft guidance, what do the docs say. |
| [oneonone-pokedex](oneonone-pokedex/SKILL.md) | IC-side companion for Daniel's recurring 1:1 with his manager - the mirror of the manager coaching-pokedex. Preps an agenda from the Obsidian vault, reflects on a recorded 1:1 with an honest self-scorecard of how Daniel showed up (never grading the manager), assigns a Pokemon for session energy, tracks trends, and writes notes back to the vault as Connect evidence. Private. Triggers: /1on1, /oneonone, 1:1, one on one, prep for my 1:1, reflect on my 1:1, manager sync. |
| [receiving-code-review](receiving-code-review/SKILL.md) | Handle incoming code review feedback with technical rigor instead of reflexive agreement - verify each claim, push back with evidence when a suggestion is wrong or does not apply, ask when feedback is unclear, and apply a YAGNI check before adding suggested professional features. Covers GitHub thread replies and implementation order. Triggers: here is the review feedback, address these comments, the reviewer said, respond to this PR comment, they want me to change. |

169
lights-out/SKILL.md

@ -1,22 +1,23 @@
---
name: lights-out
description: Use when the user is going to bed, going away, or wants unattended overnight coding work; says "lights out", "run this overnight", "keep working while I sleep", or names gnhf; asks to check on, steer, or stop a running overnight job; asks "how did last night's run go", says "good morning", or wants a morning review of an agent run; or gives review findings on the results of an overnight run.
name: "lights-out"
description: "Use when the user wants unattended overnight or away-from-keyboard coding work; says \"lights out\", \"nightshift\", \"run this overnight\", or \"keep working while I sleep\"; asks to launch, check, steer, or stop a running overnight job; wants a morning review; or gives follow-up findings on an overnight agent run."
---
# Lights Out
## Overview
Lights Out delegates a long-running coding objective to `gnhf`, which loops a coding agent until a
natural-language stop condition is met. Scout prepares and supervises the run; **gnhf executes it**.
Lights Out delegates a long-running coding objective to `nightshift`, which loops a coding agent
until a natural-language stop condition is met. Scout prepares and supervises the run;
**nightshift executes it**.
Core rule: **you orchestrate, gnhf implements.** Do not hand-edit files inside a scope a live gnhf
Core rule: **you orchestrate, nightshift implements.** Do not hand-edit files inside a scope a live
worker owns. If the work needs correcting, launch a new bounded run rather than taking over.
Second rule: **a stop condition being met is not acceptance.** "Stopped" only means the worker
stopped. Compare the result against the user's actual request and your own fresh verification.
## Why a wrapper instead of running gnhf directly
## Why a wrapper instead of running the engine directly
The Scout session that starts a night run is usually gone before it ends — the app gets closed, the
machine is left alone. So anything that must happen at exit cannot live in the conversation.
@ -25,13 +26,13 @@ machine is left alone. So anything that must happen at exit cannot live in the c
|---|---|---|---|
| Finalize | PowerShell, inside the detached worker | Always | Capture commits, diff, end-state, re-run verification, write the vault note |
| Judgment | You, on demand | When asked | Assess quality, decide merge vs follow-up, fill in the Assessment section |
| Push | One-shot automation | Opt-in | Morning Teams digest |
| Push | One-shot automation | Opt-in, always asked as a plain, non-blocking question | Completion ping (see "Digest: ask, don't assume") |
Never promise the user a summary that depends on this session still being alive.
## Scripts
Always go through these. Never invoke `gnhf` directly — the preflight is the entire point.
Always go through these. Never invoke `nightshift` directly — the preflight is the entire point.
```powershell
$LO = "$env:USERPROFILE\.scout\m-skills\lights-out\scripts"
@ -43,30 +44,59 @@ $LO = "$env:USERPROFILE\.scout\m-skills\lights-out\scripts"
| `$LO\check-run.ps1` | Reconstruct state: polling, morning review, "what happened" |
State registry: `~\.scout\lights-out\runs.json`. **This is the handoff file.** A brand-new session
with no memory of the launch conversation finds every run here. Read it before saying you don't know
about a run.
with no memory of the launch conversation finds every run here. Read it before saying you do not
know about a run.
## gnhf is vendored
## Nightshift is vendored
The skill ships its own pinned copy at `vendor\node_modules\nightshift\`, so it does not depend on
installing any unrelated published package at runtime.
The skill ships its own pinned copy of gnhf at `vendor\node_modules\gnhf\`, so it does not depend on
a global `npm install -g gnhf` and cannot be broken by someone uninstalling or upgrading one.
Resolution order:
1. `$env:LIGHTS_OUT_GNHF` — explicit override (used by the test suite)
2. the vendored copy, run as `node vendor\node_modules\gnhf\dist\cli.mjs`
3. `gnhf` on PATH — fallback only
1. `$env:LIGHTS_OUT_NIGHTSHIFT` — explicit override, used by the test suite
2. the vendored copy, run as `node vendor\node_modules\nightshift\dist\cli.mjs`
3. `nightshift` on PATH — fallback only
Only `node` needs to be on PATH. Every run records which engine it used in `engineKind`,
`engineSource`, `engineVersion`, and `engineExitCode`, plus the human-readable `engine` field in
launch output, so behavior changes stay traceable.
This vendored copy is the maintained local fork `0.1.51-lights-out`, based on
upstream `0.1.49`, commit `0227fe415d38e6df0fad4828c7999ba2a3b065de`, plus local
Nightshift changes. It is not byte-for-byte identical to upstream. Usage analytics
were removed at source level, including the client, CLI call sites, environment
handling, and build/release settings. Former telemetry variables cannot restore
it. Local logs and agent-provider networking are unchanged. Required upstream
copyright and license notices remain in the vendored `LICENSE`.
Only `node` needs to be on PATH. Every run records which one it used (`gnhfKind`, `gnhfVersion`) in
the registry and the vault note, so a behavior change after an upgrade is traceable.
Maintenance archives are stored separately from the shareable skill:
To update the pinned copy:
- Source: `D:\OneDrive - Microsoft\HERE\nightshift-src.zip`
- Installer: `D:\OneDrive - Microsoft\HERE\nightshift-0.1.51-lights-out.tgz`
Normal runs do not read these archives or require access to that directory. Share the complete
skill folder, including its preinstalled `vendor\node_modules`, without either archive. The vendor
npm metadata references the maintainer's external installer only for maintenance; on another
machine, obtain that installer separately and update its path before attempting npm maintenance.
Do not run `npm install` merely to use a copied skill.
The source archive includes the maintained working-tree changes, without Git
history or installed dependencies. To refresh the pinned copy, build that source
with `pnpm run build`, create its installer with `pnpm pack`, and install the
rebuilt local archive into `vendor\node_modules\nightshift`. Never use an older
telemetry-capable archive. Then run:
```powershell
npm install gnhf --prefix "$env:USERPROFILE\.scout\m-skills\lights-out\vendor"
pwsh -NoProfile -File "$env:USERPROFILE\.scout\m-skills\lights-out\scripts\test\e2e.ps1"
```
Re-run the suite after upgrading — it asserts the flags the wrapper passes still exist.
No runtime install step is required.
On Windows, the skill folder can contain spaces. The pinned engine retains a
separate limitation: native `.cmd` agent paths configured through
`agentPathOverride` must not contain spaces. This does not require reinstalling
the engine or its dependencies.
## Launch
@ -74,12 +104,14 @@ Gather these before launching. Ask for anything missing rather than guessing:
- **Repo path** — must be a git repo with a clean tree.
- **Objective** — one concrete outcome.
- **Stop condition** — observable. "Looks good" is not one; "`npm test` exits 0 and no files outside
`src/utils` changed" is.
- **Stop condition** — observable. "Looks good" is not one; "`npm test` exits 0 and no files
outside `src/utils` changed" is.
- **Verification command** — how success is checked. Must actually exist on this machine.
- **Agent** — default `copilot`. Others: `claude`, `codex`, `cursor`, `opencode`, `rovodev`, `pi`,
`acp:<target>`.
- **Mode** — Hands-Off or Companion (below).
- **Completion ping** — end your launch message with the plain question from "Digest: ask,
don't assume" below. Do not block on it and do not use `m_ask_user` for it.
```powershell
& "$LO\start-run.ps1" `
@ -93,17 +125,16 @@ Gather these before launching. Ask for anything missing rather than guessing:
-Mode hands-off
```
Returns JSON. If `ok` is `false`, **report the `problems` list to the user and stop.** Do not try to
work around a failed preflight — each check maps to a way the night silently dies:
Returns JSON. If `ok` is `false`, report the `problems` list to the user and stop.
| Check | What it prevents |
|---|---|
| gnhf unresolvable | Vendored copy deleted and no global install — nothing to run |
| Empty prompt | gnhf blocks forever reading stdin with no TTY |
| Dirty working tree | gnhf refuses to start |
| Already on a `gnhf/*` branch | Triggers an interactive overwrite prompt that hard-fails without a TTY |
| Nightshift unresolvable | Vendored copy missing and no fallback command available |
| Empty prompt | Engine blocks forever reading stdin with no TTY |
| Dirty working tree | Engine refuses to start |
| Already on a `nightshift/*` branch | Interactive overwrite prompt hard-fails without a TTY |
| Agent CLI missing | Run dies on iteration 1 at 2am |
| Verify command not on PATH | The classic: docs say `pnpm`, machine only has `npm` |
| Verify command not on PATH | Morning review starts from a broken check |
Pass `-Worktree` to run alongside other work, `-CurrentBranch` to commit on the current branch, and
`-Push` to push after each successful iteration.
@ -134,8 +165,8 @@ Steering means a **new bounded run**, not editing the files yourself.
## Morning Review
Triggered by "good morning", "how did it go", or any post-run check-in. **Never answer from memory
and never ask the user what to review.** Reconstruct:
Triggered by "good morning", "how did it go", or any post-run check-in. Never answer from memory
and never ask the user what to review. Reconstruct:
```powershell
& "$LO\check-run.ps1" -Since 2
@ -143,16 +174,16 @@ and never ask the user what to review.** Reconstruct:
Then:
1. If `status` is `running`, say so first — it is still working; report progress, don't judge it.
2. If `status` is `orphaned`, the worker died without finalizing (reboot, kill, power loss). Say
that plainly; the vault note will be missing and artifacts may be partial.
3. Read `vaultNote`, `notesTail`, and the commits. Treat gnhf's notes as **claims, not evidence**.
1. If `status` is `running`, say so first — it is still working; report progress, do not judge it.
2. If `status` is `orphaned`, the worker died without finalizing. Say that plainly; the vault note
will be missing and artifacts may be partial.
3. Read `vaultNote`, `notesTail`, and the commits. Treat notes as **claims, not evidence**.
4. Run independent verification yourself — the recorded `verifyExitCodeAfter` is a starting point,
not a substitute for looking at the diff.
5. Compare against the stop condition *and* what the user originally asked for.
6. Decide: **Mergeable**, **Needs follow-up run**, or **Do not merge**.
Then write the judgment back into the vault note (below). Do not merge unless explicitly authorized.
Then write the judgment back into the vault note. Do not merge unless explicitly authorized.
## Writing the assessment back
@ -162,39 +193,74 @@ The wrapper leaves `## Assessment` as a stub. Complete it:
2. Replace the Assessment section with your findings and verdict.
3. Set `status: active` in frontmatter, swap the `status/draft` tag for `status/active`, bump
`updated:`.
4. Leave every other file in the folder untouched — they are raw artifacts (vault rule 4).
4. Leave every other file in the folder untouched — they are raw artifacts.
## Findings
When the user says "that's not right", "scope drift", "you missed X", or "why did it stop":
When the user says "that is not right", "scope drift", "you missed X", or "why did it stop":
1. Treat the run as Companion from now on.
2. Convert each finding into one observable correction, preserving the user's wording and scope.
3. Relaunch on the same repo with a bounded prompt (see `references\prompts.md`).
4. Review again. Repeat until no blocking findings remain or a real blocker surfaces.
## Optional morning digest
## JSON schema note
Only if the user wants a phone ping. Offer it at launch; never create it silently. Use a **one-shot**
automation so it auto-disables and does not accumulate:
Wrapper metadata now uses the generic `engine*` fields instead of the prior engine-specific prefix.
Historical entries are still read from one coherent legacy descriptor prefix when matching
`Kind`+`Source` fields are present; ambiguous legacy prefixes are rejected instead of guessed. New
runs write `engineKind`, `engineSource`, `engineVersion`, `engineExitCode`, and the launch-output
`engine` summary field.
```
## Digest: ask, don't assume
Do this on **every** launch, not just when the user brings it up first. Ask it as one plain,
direct question inside your normal launch message — not a special prompt, and not buried
inside other prose where it reads as a mention rather than a question.
**Do not use `m_ask_user` for this.** The user should be able to answer by *not* answering.
End your launch message with a question like "Want me to ping you on Teams when this
finishes?" and then stop there — do not add a follow-up tool call that blocks the turn
waiting for a reply. Treat non-response as "no" for this run: create nothing, and don't
chase an answer. If they answer yes — in that same turn or a later one — register the
ping then, exactly as if they'd brought it up unprompted.
A "no" (including silence) is not a standing preference for next time. Ask again, the same
low-friction way, on the next launch.
If they accept, register a **condition-triggered** one-shot automation immediately (not a
fixed daily schedule — a fixed time means the user waits until that time even if the run
finished hours earlier, or misses it if the run runs past it). It should poll
`check-run.ps1` and fire the moment status leaves `running`:
```text
m_create_automation(
name: "Lights Out digest — <run id>",
schedule: "every day at 7am", // the next morning
name: "Lights Out ping — <run id>",
triggerType: "condition",
conditionCheckInterval: 15,
oneShot: true,
teamsNotify: "always",
prompt: "Run the lights-out Morning Review for run <run id>: execute
$env:USERPROFILE\\.scout\\m-skills\\lights-out\\scripts\\check-run.ps1 -Id <run id>,
assess the result, write the assessment back into the vault note, and send a short digest."
condition: "The lights-out run with id <run id> has finished — i.e. check-run.ps1 -Id <run id>
reports a status other than \"running\" (completed, failed, or orphaned).",
prompt: "Run the lights-out Morning Review for run <run id>: execute check-run.ps1 -Id <run id>.
Independently re-run verification and spot-check the actual artifacts yourself — do not
just relay the worker's self-report. Write the assessment back into the vault note per
'Writing the assessment back'. Then send a concise Teams message with the verdict
(Mergeable / Needs follow-up / Do not merge), the vault note path, and the branch name.
If status is still 'running' when you check, say so and skip the Teams message rather
than asserting completion."
)
```
The digest is a convenience. The artifacts land on disk with or without it.
The condition check interval (15 minutes here) trades promptness against automation-check
overhead; widen it for long expected runs, tighten it for short ones. The ping is a
convenience layered on top of guaranteed finalize — the artifacts and vault note exist
with or without it.
## Safety
- Never run destructive git commands to tidy a gnhf branch. The branch *is* the night's work.
- Never run destructive git commands to tidy a `nightshift/*` branch. The branch *is* the night's
work.
- Never present a worker's success summary as verified fact.
- If the user is away, produce branches and a report — not merges, not pushes to shared branches,
unless they explicitly authorized it beforehand.
@ -204,4 +270,5 @@ The digest is a convenience. The artifacts land on disk with or without it.
## References
- `references\prompts.md` — worker, steering, and findings prompt templates.
- `references\cli.md` — gnhf flags and agent roster (a cache; `gnhf --help` is the source of truth).
- `references\cli.md` — Nightshift flags and agent roster; `nightshift --help` is the source of
truth.

91
lights-out/references/cli.md

@ -1,32 +1,38 @@
# gnhf CLI reference
# Nightshift CLI reference
**This file is a cache, not the source of truth.** Verify with `gnhf --help` before relying on a
flag; the roster and options change between releases. Captured against **gnhf v0.1.49**.
**This file is a cache, not the source of truth.** Verify with `nightshift --help` before relying
on a flag; the roster and options change between releases. Captured against the
maintained local fork **0.1.51-lights-out**, based on upstream **0.1.49**, commit
`0227fe415d38e6df0fad4828c7999ba2a3b065de`, plus local Nightshift changes.
Usage analytics are removed at source level, not merely disabled. This is not
an unmodified upstream artifact; required notices remain in `LICENSE`.
Normally you should not call `gnhf` directly — use `scripts\start-run.ps1`, which adds the preflight
checks and the guaranteed finalize step. This reference exists for reading the flags the wrapper
passes and for diagnosing a run.
Normally you should not call `nightshift` directly — use `scripts\start-run.ps1`, which adds the
preflight checks and the guaranteed finalize step. This reference exists for reading the flags the
wrapper passes and for diagnosing a run.
## Install
## Packaging
The skill **vendors its own copy** — you should not need a global install:
The skill vendors its own copy. There is no runtime install step and no dependency on an unrelated
published package:
```powershell
npm install gnhf --prefix "$env:USERPROFILE\.scout\m-skills\lights-out\vendor"
```
- engine bundle: `vendor\node_modules\nightshift\dist\cli.mjs`
- package metadata: `vendor\node_modules\nightshift\package.json`
- separately stored source: `D:\OneDrive - Microsoft\HERE\nightshift-src.zip`
- separately stored installer: `D:\OneDrive - Microsoft\HERE\nightshift-0.1.51-lights-out.tgz`
That produces `vendor\node_modules\gnhf\dist\cli.mjs`, which the scripts run via `node`. Only `node`
needs to be on PATH; the npm-generated `gnhf` / `gnhf.cmd` / `gnhf.ps1` shims are irrelevant here
(they are 28-line launchers that resolve paths relative to their own location, so they cannot be
moved or copied anywhere useful).
Neither archive is needed for normal runs or included when sharing the skill folder. Keep the
preinstalled runtime modules and launchers in the shared copy. Vendor npm metadata refers to the
external installer for maintenance only; recipients need their own installer path if reinstalling,
not when running the existing copy.
Resolution order is `$env:LIGHTS_OUT_GNHF` → vendored → `gnhf` on PATH.
Resolution order is `$env:LIGHTS_OUT_NIGHTSHIFT` → vendored copy → `nightshift` on PATH.
On this machine, `registry.npmjs.org` is TLS-intercepted and fails with
`ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE`. Use the configured Microsoft proxy registry — that is the
default, so a plain `npm install` works; do **not** pass `--registry=https://registry.npmjs.org`.
Only `node` needs to be on PATH. The `.bin` launchers stay beside the vendored package; the wrapper
invokes the bundle through `node`.
A global `npm install -g gnhf` still works as a fallback, but nothing in the skill requires it.
Nightshift keeps its engine-owned repo state under `.nightshift\runs\<runId>\` and its user config
under `~\.nightshift\config.yml`.
## Flags
@ -39,18 +45,18 @@ A global `npm install -g gnhf` still works as a fallback, but nothing in the ski
| `--max-rate-limit-wait <dur>` | Abort after this much cumulative usage-limit wait (`30m`, `2h`, `0`) |
| `--fallback-model <model>` | Retry a Claude usage-limit rejection with this model instead of waiting |
| `--stop-when <condition>` | Natural-language completion condition; survives resume. `""` clears it |
| `--prevent-sleep <on\|off>` | Keep the machine awake. Always `on` for overnight runs |
| `--prevent-sleep <on\|off>` | Keep the machine awake. The wrapper always sets `on` for overnight runs |
| `--worktree` | Run in a separate git worktree, enabling parallel runs on one repo |
| `--current-branch` | Commit on the current branch instead of creating `gnhf/<runId>` |
| `--current-branch` | Commit on the current branch instead of creating `nightshift/<runId>` |
| `--push` | Push after each successful iteration |
| `--meteor-frequency <0-5>` | TUI meteors. The wrapper sets `0` (no TTY, so it is only log noise) |
| `--mock` | **TUI demo only.** Replays canned iterations, does no git work, does not exit. Never use it to validate a real run |
| `--meteor-frequency <0-5>` | TUI meteors. The wrapper sets `0` because there is no interactive console |
| `--mock` | TUI demo only. Replays canned iterations, does no git work, does not exit. Never use it to validate a real run |
## Run identity
Deterministic, derived from the prompt — so a run is findable months later without guessing:
Deterministic, derived from the prompt:
```
```text
slug = prompt.toLowerCase()
.replace(/[^a-z0-9]+/g,'-')
.replace(/^-+|-+$/g,'')
@ -58,43 +64,40 @@ slug = prompt.toLowerCase()
.replace(/-+$/,'')
hash = sha256(prompt).hex.slice(0,6)
runId = "<slug>-<hash>"
branch = "gnhf/<runId>"
branch = "nightshift/<runId>"
```
On collision gnhf appends `-1`, `-2`, …, so the predicted id may not be the final one.
On collision the engine appends `-1`, `-2`, …, so the predicted id may not be the final one.
`Resolve-LightsOutRunDir` in `scripts\lib.ps1` handles that.
## Artifacts
Per run, in `<repo>\.gnhf\runs\<runId>\` (gitignored by gnhf, so it never pollutes the branch):
Per run, in `<repo>\.nightshift\runs\<runId>\`:
| File | Contents |
|---|---|
| `prompt.md` | The objective as given |
| `notes.md` | The agent's own iteration log — **claims, not evidence** |
| `notes.md` | The agent's own iteration log — claims, not evidence |
| `end-state.json` | `status`, `stopCondition`, `agentError`, `iterations`, `successCount`, `failCount`, `endedAt` |
| `gnhf.log` | JSONL lifecycle log with timings and full `error.cause` chains |
| `nightshift.log` | JSONL lifecycle log with timings and full `error.cause` chains |
| `iteration-<n>.jsonl` | Raw agent stream per iteration. Large; never copied to the vault |
| `base-commit` | Commit the run branched from |
| `stop-when` | Persisted stop condition |
## Behaviors that break unattended runs
Both are enforced by the wrapper's preflight; know them when diagnosing a failure.
Both are enforced by the wrapper's preflight:
1. **Launching from a `gnhf/*` branch** prompts interactively to overwrite/continue, and hard-fails
when stdin is not a TTY: *"Cannot show the overwrite prompt because stdin is not interactive."*
2. **An empty prompt with a non-TTY stdin** makes gnhf read the prompt *from* stdin and block
1. Launching from a `nightshift/*` branch prompts interactively to overwrite or continue, and
hard-fails when stdin is not a TTY.
2. An empty prompt with a non-TTY stdin makes the engine read the prompt from stdin and block
forever.
Also: gnhf requires a clean working tree, never force-pushes, never auto-pulls, and commits each
successful iteration separately so a night can be reviewed or reverted one change at a time.
## Docs
Upstream project: <https://github.com/kunchenguid/gnhf> (`README.md`, `VISION.md`).
Also: the engine requires a clean working tree, never force-pushes, never auto-pulls, and commits
each successful iteration separately so a night can be reviewed or reverted one change at a time.
The vendored package also ships its own README locally — no network needed:
`vendor\node_modules\gnhf\README.md`.
## Provenance
This skill does **not** depend on a local clone of the gnhf source repo.
The maintained source archive is stored separately at
`D:\OneDrive - Microsoft\HERE\nightshift-src.zip`. Rebuild the vendored bundle from that fork when
intentionally updating the engine; do not add a runtime install instruction to this skill.

7
lights-out/references/prompts.md

@ -1,7 +1,10 @@
# Prompt templates
The prompt passed via `-Prompt` is what the coding agent sees on every iteration. It is the single
biggest lever on overnight quality. Fill the placeholders; do not send a bare one-liner.
These templates are for Lights Out runs powered by the vendored `nightshift` engine. The prompt
passed via `-Prompt` is what the coding agent sees on every iteration.
It is the single biggest lever on overnight quality. Fill the placeholders; do not send a bare
one-liner.
## Worker prompt (new run)

113
lights-out/scripts/check-run.ps1

@ -3,7 +3,7 @@
Reconstruct the state of lights-out runs for Companion polling and Morning Review.
.DESCRIPTION
Reads the durable registry at ~\.scout\lights-out\runs.json and, for each run, re-derives
live facts from git and gnhf's own artifacts. Safe to call from a brand-new Scout session
live facts from git and engine artifacts. Safe to call from a brand-new Scout session
that has no memory of the conversation that launched the run.
.EXAMPLE
.\check-run.ps1 -Latest
@ -33,7 +33,20 @@ if ($runs.Count -eq 0) {
$runs = $runs | Sort-Object { [datetime]::Parse($_.startedAt) } -Descending
if ($Id) { $runs = @($runs | Where-Object { $_.id -eq $Id }) }
if ($Id) {
$runs = @($runs | Where-Object {
$_.id -eq $Id -or
($_.PSObject.Properties.Name -contains 'registryId' -and $_.registryId -eq $Id) -or
($_.PSObject.Properties.Name -contains 'launchId' -and $_.launchId -eq $Id) -or
($_.PSObject.Properties.Name -contains 'engineRunId' -and $_.engineRunId -eq $Id)
})
$launchMatches = @($runs | Where-Object {
$_.PSObject.Properties.Name -contains 'launchId' -and $_.launchId -eq $Id
} | Sort-Object { [datetime]::Parse($_.startedAt) } -Descending)
if ($launchMatches.Count -gt 0) {
$runs = @($launchMatches | Select-Object -First 1)
}
}
if ($Active) { $runs = @($runs | Where-Object { $_.status -in @('running', 'launching', 'finalizing') }) }
if ($Since -gt 0) {
$cutoff = (Get-Date).AddDays(-$Since)
@ -47,34 +60,39 @@ $results = foreach ($run in $runs) {
$alive = Test-LightsOutProcessAlive -ProcessId ([int]$run.workerPid) -StartedAtIso $run.startedAt
}
$checkoutPath = Resolve-LightsOutCheckoutPath -Run $run
$runDir = $null
if (Test-Path -LiteralPath $run.repoPath) {
$runDir = Resolve-LightsOutRunDir -RepoPath $run.repoPath -RunId $run.id
if ($checkoutPath -and (Test-Path -LiteralPath $checkoutPath)) {
$runDir = Resolve-LightsOutRunDir -RepoPath $checkoutPath -RunId $run.id
}
$endState = $null
if ($runDir) {
$esPath = Join-Path $runDir 'end-state.json'
if (Test-Path -LiteralPath $esPath) {
try { $endState = Get-Content -LiteralPath $esPath -Raw | ConvertFrom-Json } catch { }
$endStatePath = Join-Path $runDir 'end-state.json'
if (Test-Path -LiteralPath $endStatePath) {
try { $endState = Get-Content -LiteralPath $endStatePath -Raw | ConvertFrom-Json } catch { }
}
}
$commits = @()
$diffStat = ''
$branchExists = $false
if ((Test-Path -LiteralPath $run.repoPath) -and $run.baseCommit) {
$branchExists = [bool](git -C $run.repoPath rev-parse --verify --quiet $run.branch 2>$null)
$canReadDiff = $checkoutPath -and (Test-Path -LiteralPath $checkoutPath)
if (($run.PSObject.Properties['worktree'] -and $run.worktree) -and -not $runDir) {
$canReadDiff = $false
}
if ($canReadDiff -and $run.baseCommit) {
$branchExists = [bool](git -C $checkoutPath rev-parse --verify --quiet $run.branch 2>$null)
$ref = if ($branchExists) { $run.branch } else { 'HEAD' }
$commits = @(git -C $run.repoPath --no-pager log --oneline "$($run.baseCommit)..$ref" 2>$null)
$diffStat = (git -C $run.repoPath --no-pager diff --shortstat "$($run.baseCommit)..$ref" 2>$null) -join ' '
$commits = @(git -C $checkoutPath --no-pager log --oneline "$($run.baseCommit)..$ref" 2>$null)
$diffStat = (git -C $checkoutPath --no-pager diff --shortstat "$($run.baseCommit)..$ref" 2>$null) -join ' '
}
$notesTailText = $null
if ($runDir) {
$np = Join-Path $runDir 'notes.md'
if (Test-Path -LiteralPath $np) {
$notesTailText = (Get-Content -LiteralPath $np -Tail $NotesTail -ErrorAction SilentlyContinue) -join "`n"
$notesPath = Join-Path $runDir 'notes.md'
if (Test-Path -LiteralPath $notesPath) {
$notesTailText = (Get-Content -LiteralPath $notesPath -Tail $NotesTail -ErrorAction SilentlyContinue) -join "`n"
}
}
@ -85,22 +103,29 @@ $results = foreach ($run in $runs) {
}
catch { }
$vaultNote = $null
if ($run.PSObject.Properties['vaultNote'] -and $run.vaultNote) { $vaultNote = $run.vaultNote }
else {
$vaultNote = Get-RunPropertyValue -InputObject $run -Names @('vaultNote')
if (-not $vaultNote) {
$guess = Join-Path (Get-LightsOutVaultFolder -Run $run) 'summary.md'
if (Test-Path -LiteralPath $guess) { $vaultNote = $guess }
}
# A run the registry thinks is live, whose worker process is gone, died without finalizing
# (reboot, forced kill, power loss). Say so rather than reporting it as still working.
$effectiveStatus = $run.status
if ($run.status -in @('running', 'launching', 'finalizing') -and -not $alive) {
$hasWorkerPid = $run.PSObject.Properties['workerPid'] -and $run.workerPid
$startedRecently = $false
try { $startedRecently = ([datetime]::Parse($run.startedAt)) -gt (Get-Date).AddMinutes(-2) } catch { }
if ($hasWorkerPid -or -not $startedRecently) {
$effectiveStatus = 'orphaned'
}
}
$engine = Get-RunEngineDescriptor -Run $run
[pscustomobject]@{
id = $run.id
registryId = if ($run.PSObject.Properties['registryId']) { $run.registryId } else { $null }
launchId = if ($run.PSObject.Properties['launchId']) { $run.launchId } else { $null }
engineRunId = if ($run.PSObject.Properties['engineRunId']) { $run.engineRunId } else { $null }
status = $effectiveStatus
recordedStatus = $run.status
workerAlive = $alive
@ -108,12 +133,13 @@ $results = foreach ($run in $runs) {
mode = $run.mode
agent = $run.agent
repoPath = $run.repoPath
checkoutPath = $checkoutPath
branch = $run.branch
branchExists = $branchExists
startedAt = $run.startedAt
endedAt = if ($run.PSObject.Properties['endedAt']) { $run.endedAt } else { $null }
elapsed = $elapsed
gnhfExitCode = if ($run.PSObject.Properties['gnhfExitCode']) { $run.gnhfExitCode } else { $null }
engineExitCode = $engine.ExitCode
commitCount = $commits.Count
commits = $commits
diffStat = $diffStat
@ -123,8 +149,9 @@ $results = foreach ($run in $runs) {
verifyExitCodeBefore = if ($run.PSObject.Properties['verifyExitCodeBefore']) { $run.verifyExitCodeBefore } else { $null }
verifyExitCodeAfter = if ($run.PSObject.Properties['verifyExitCodeAfter']) { $run.verifyExitCodeAfter } else { $null }
runDir = $runDir
gnhfKind = if ($run.PSObject.Properties['gnhfKind']) { $run.gnhfKind } else { $null }
gnhfVersion = if ($run.PSObject.Properties['gnhfVersion']) { $run.gnhfVersion } else { $null }
engineKind = $engine.Kind
engineSource = $engine.Source
engineVersion = $engine.Version
vaultNote = $vaultNote
notesTail = $notesTailText
prompt = $run.prompt
@ -132,30 +159,34 @@ $results = foreach ($run in $runs) {
}
if ($Json) {
$arr = @($results)
# NOTE: do not name this $json -- PowerShell variables are case-insensitive, so it would
# assign a string to the [switch]$Json parameter and throw at the call site.
$jsonText = $arr | ConvertTo-Json -Depth 10
if ($arr.Count -eq 1) { "[$jsonText]" } else { $jsonText }
$array = @($results)
$jsonText = $array | ConvertTo-Json -Depth 10
if ($array.Count -eq 1) { "[$jsonText]" } else { $jsonText }
return
}
foreach ($r in $results) {
foreach ($result in $results) {
Write-Host ''
Write-Host "=== $($r.id) ===" -ForegroundColor Cyan
Write-Host " status : $($r.status)$(if ($r.status -eq 'orphaned') { ' <- worker died without finalizing' })"
Write-Host " repo/branch : $($r.repoPath) [$($r.branch)$(if (-not $r.branchExists) { ' MISSING' })]"
Write-Host " agent/mode : $($r.agent) / $($r.mode)"
Write-Host " started : $($r.startedAt) elapsed: $($r.elapsed)"
Write-Host " commits : $($r.commitCount) $($r.diffStat)"
if ($r.stopWhen) { Write-Host " stop-when : $($r.stopWhen)" }
if ($r.endState) { Write-Host " end-state : $($r.endState.status) (iterations=$($r.endState.iterations))" }
if ($r.verifyCommand) { Write-Host " verify : '$($r.verifyCommand)' before=$($r.verifyExitCodeBefore) after=$($r.verifyExitCodeAfter)" }
if ($r.vaultNote) { Write-Host " vault note : $($r.vaultNote)" }
if ($r.runDir) { Write-Host " run dir : $($r.runDir)" }
if ($r.notesTail) {
Write-Host "=== $($result.id) ===" -ForegroundColor Cyan
Write-Host " status : $($result.status)$(if ($result.status -eq 'orphaned') { ' <- worker died without finalizing' })"
Write-Host " repo/branch : $($result.repoPath) [$($result.branch)$(if (-not $result.branchExists) { ' MISSING' })]"
Write-Host " agent/mode : $($result.agent) / $($result.mode)"
if ($result.engineKind) {
$engineLine = $result.engineKind
if ($result.engineVersion) { $engineLine += " v$($result.engineVersion)" }
if ($null -ne $result.engineExitCode) { $engineLine += " exit=$($result.engineExitCode)" }
Write-Host " engine : $engineLine"
}
Write-Host " started : $($result.startedAt) elapsed: $($result.elapsed)"
Write-Host " commits : $($result.commitCount) $($result.diffStat)"
if ($result.stopWhen) { Write-Host " stop-when : $($result.stopWhen)" }
if ($result.endState) { Write-Host " end-state : $($result.endState.status) (iterations=$($result.endState.iterations))" }
if ($result.verifyCommand) { Write-Host " verify : '$($result.verifyCommand)' before=$($result.verifyExitCodeBefore) after=$($result.verifyExitCodeAfter)" }
if ($result.vaultNote) { Write-Host " vault note : $($result.vaultNote)" }
if ($result.runDir) { Write-Host " run dir : $($result.runDir)" }
if ($result.notesTail) {
Write-Host " --- notes tail ---"
$r.notesTail -split "`n" | ForEach-Object { Write-Host " $_" }
$result.notesTail -split "`n" | ForEach-Object { Write-Host " $_" }
}
}
Write-Host ''

645
lights-out/scripts/lib.ps1

@ -24,10 +24,10 @@ function Get-LightsOutPaths {
}
<#
Mirrors gnhf's slugifyPrompt() exactly (src/utils/slugify.ts):
Mirrors the engine slug behavior exactly:
slug = prompt.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,20).replace(/-+$/,'')
hash = sha256(prompt).hex.slice(0,6)
branch = "gnhf/<slug>-<hash>", runId = branch after the slash.
branch = "nightshift/<slug>-<hash>", runId = branch after the slash.
Deriving this rather than guessing "newest folder" is what makes a run findable months later.
#>
function Get-LightsOutRunId {
@ -70,21 +70,102 @@ function Get-LightsOutKebab {
[regex]::Replace($k, '^-+|-+$', '')
}
function Resolve-GnhfBinary {
<#
Back-compat shim: returns just the executable path. Prefer Resolve-GnhfCommand.
#>
function Get-RunPropertyValue {
[CmdletBinding()]
param()
(Resolve-GnhfCommand).File
param(
[Parameter(Mandatory)][pscustomobject]$InputObject,
[Parameter(Mandatory)][string[]]$Names
)
foreach ($name in $Names) {
if ($InputObject.PSObject.Properties.Name -contains $name) {
return $InputObject.$name
}
}
$null
}
function Get-RunEngineField {
[CmdletBinding()]
param(
[Parameter(Mandatory)][pscustomobject]$Run,
[string]$Prefix,
[Parameter(Mandatory)][ValidateSet('Kind', 'Source', 'Version', 'ExitCode')][string]$Suffix
)
if ([string]::IsNullOrWhiteSpace($Prefix)) {
return $null
}
$name = "$Prefix$Suffix"
if ($Run.PSObject.Properties.Name -contains $name) {
return $Run.$name
}
$null
}
function Resolve-GnhfCommand {
function Get-RunEnginePrefix {
[CmdletBinding()]
param([Parameter(Mandatory)][pscustomobject]$Run)
$propertyNames = @($Run.PSObject.Properties.Name)
if ($propertyNames | Where-Object { $_ -cmatch '^engine(Kind|Source|Version|ExitCode)$' } | Select-Object -First 1) {
return 'engine'
}
$prefixesByField = @{}
foreach ($name in $propertyNames) {
$match = [regex]::Match($name, '^(?<prefix>[A-Za-z]+)(?<suffix>Kind|Source|Version|ExitCode)$')
if (-not $match.Success) { continue }
$prefix = $match.Groups['prefix'].Value
$suffix = $match.Groups['suffix'].Value
if ($prefix -ceq 'engine') { continue }
if (-not $prefixesByField.ContainsKey($prefix)) {
$prefixesByField[$prefix] = New-Object 'System.Collections.Generic.HashSet[string]'
}
$null = $prefixesByField[$prefix].Add($suffix)
}
$coherentPrefixes = @(
$prefixesByField.GetEnumerator() |
Where-Object { $_.Value.Contains('Kind') -and $_.Value.Contains('Source') } |
ForEach-Object { $_.Key } |
Sort-Object
)
if ($coherentPrefixes.Count -gt 1) {
throw "Ambiguous legacy engine descriptor prefixes: $($coherentPrefixes -join ', ')."
}
if ($coherentPrefixes.Count -eq 1) {
return $coherentPrefixes[0]
}
$null
}
function Get-RunEngineDescriptor {
[CmdletBinding()]
param([Parameter(Mandatory)][pscustomobject]$Run)
$prefix = Get-RunEnginePrefix -Run $Run
[pscustomobject]@{
Kind = Get-RunEngineField -Run $Run -Prefix $prefix -Suffix Kind
Source = Get-RunEngineField -Run $Run -Prefix $prefix -Suffix Source
Version = Get-RunEngineField -Run $Run -Prefix $prefix -Suffix Version
ExitCode = Get-RunEngineField -Run $Run -Prefix $prefix -Suffix ExitCode
}
}
function Resolve-NightshiftCommand {
<#
Resolve how to invoke gnhf, in priority order:
1. $env:LIGHTS_OUT_GNHF -- explicit override (used by the test suite)
2. the vendored copy in <skill>\vendor -- pinned, survives `npm uninstall -g gnhf`
3. `gnhf` on PATH -- whatever global install exists
Resolve how to invoke the engine, in priority order:
1. $env:LIGHTS_OUT_NIGHTSHIFT -- explicit override (used by the test suite)
2. the vendored copy in <skill>\vendor -- pinned, self-contained, rebuildable from source
3. `nightshift` on PATH -- fallback only
Returns a descriptor: Kind, File (executable), Prefix (args to prepend), Source, Version.
Invoke as: & $cmd.File @($cmd.Prefix) @userArgs
@ -94,37 +175,46 @@ function Resolve-GnhfCommand {
$skillRoot = Split-Path -Parent $PSScriptRoot
if ($env:LIGHTS_OUT_GNHF) {
$ovr = Get-Command $env:LIGHTS_OUT_GNHF -ErrorAction SilentlyContinue
if ($ovr) {
if ($env:LIGHTS_OUT_NIGHTSHIFT) {
$override = Get-Command $env:LIGHTS_OUT_NIGHTSHIFT -ErrorAction SilentlyContinue
if ($override) {
return [pscustomobject]@{
Kind = 'override'; File = $ovr.Source; Prefix = @()
Source = $ovr.Source; Version = $null
Kind = 'override'
File = $override.Source
Prefix = @()
Source = $override.Source
Version = $null
}
}
}
$vendored = Join-Path $skillRoot 'vendor\node_modules\gnhf\dist\cli.mjs'
$vendored = Join-Path $skillRoot 'vendor\node_modules\nightshift\dist\cli.mjs'
if (Test-Path -LiteralPath $vendored) {
$node = Get-Command node -ErrorAction SilentlyContinue
if ($node) {
$ver = $null
$pkg = Join-Path $skillRoot 'vendor\node_modules\gnhf\package.json'
$version = $null
$pkg = Join-Path $skillRoot 'vendor\node_modules\nightshift\package.json'
if (Test-Path -LiteralPath $pkg) {
try { $ver = (Get-Content -LiteralPath $pkg -Raw | ConvertFrom-Json).version } catch { }
try { $version = (Get-Content -LiteralPath $pkg -Raw | ConvertFrom-Json).version } catch { }
}
return [pscustomobject]@{
Kind = 'vendored'; File = $node.Source; Prefix = @($vendored)
Source = $vendored; Version = $ver
Kind = 'vendored'
File = $node.Source
Prefix = @($vendored)
Source = $vendored
Version = $version
}
}
}
$global = Get-Command gnhf -ErrorAction SilentlyContinue
$global = Get-Command nightshift -ErrorAction SilentlyContinue
if ($global) {
return [pscustomobject]@{
Kind = 'path'; File = $global.Source; Prefix = @()
Source = $global.Source; Version = $null
Kind = 'path'
File = $global.Source
Prefix = @()
Source = $global.Source
Version = $null
}
}
@ -160,7 +250,6 @@ function Save-LightsOutRun {
New-Item -ItemType Directory -Force -Path $script:LightsOutRoot | Out-Null
}
# Crude cross-process lock so two concurrent runs can't clobber the registry.
$lockPath = "$script:LightsOutStatePath.lock"
$lock = $null
for ($i = 0; $i -lt 50 -and $null -eq $lock; $i++) {
@ -171,10 +260,18 @@ function Save-LightsOutRun {
}
try {
$runs = @(Read-LightsOutRuns | Where-Object { $_.id -ne $Run.id })
$runRegistryId = if ($Run.PSObject.Properties.Name -contains 'registryId') { $Run.registryId } else { $null }
$runs = @(Read-LightsOutRuns | Where-Object {
$existingRegistryId = if ($_.PSObject.Properties.Name -contains 'registryId') { $_.registryId } else { $null }
if ($runRegistryId) {
$existingRegistryId -ne $runRegistryId
}
else {
$_.id -ne $Run.id
}
})
$runs += $Run
$json = ($runs | ConvertTo-Json -Depth 12)
# ConvertTo-Json emits a bare object (not an array) for a single element.
if ($runs.Count -eq 1) { $json = "[$json]" }
Set-Content -LiteralPath $script:LightsOutStatePath -Value $json -Encoding UTF8
}
@ -186,11 +283,59 @@ function Save-LightsOutRun {
function Get-LightsOutRun {
[CmdletBinding()]
param([Parameter(Mandatory)][string]$Id)
Read-LightsOutRuns | Where-Object { $_.id -eq $Id } | Select-Object -First 1
$matches = @(Read-LightsOutRuns | Where-Object {
$_.id -eq $Id -or
($_.PSObject.Properties.Name -contains 'registryId' -and $_.registryId -eq $Id) -or
($_.PSObject.Properties.Name -contains 'launchId' -and $_.launchId -eq $Id) -or
($_.PSObject.Properties.Name -contains 'engineRunId' -and $_.engineRunId -eq $Id)
})
if ($matches.Count -eq 0) { return $null }
$launchMatches = @($matches | Where-Object {
$_.PSObject.Properties.Name -contains 'launchId' -and $_.launchId -eq $Id
} | Sort-Object { [datetime]::Parse($_.startedAt) } -Descending)
if ($launchMatches.Count -gt 0) { return $launchMatches[0] }
$registryMatch = @($matches | Where-Object {
$_.PSObject.Properties.Name -contains 'registryId' -and $_.registryId -eq $Id
} | Select-Object -First 1)
if ($registryMatch.Count -gt 0) { return $registryMatch[0] }
$exact = @($matches | Where-Object { $_.id -eq $Id -and $_.status -notin @('running', 'launching', 'finalizing') } | Select-Object -First 1)
if ($exact.Count -gt 0) { return $exact[0] }
$finalizedLaunch = @($matches | Where-Object {
$_.PSObject.Properties.Name -contains 'launchId' -and
$_.launchId -eq $Id -and
$_.status -notin @('running', 'launching', 'finalizing')
} | Select-Object -First 1)
if ($finalizedLaunch.Count -gt 0) { return $finalizedLaunch[0] }
$matches | Select-Object -First 1
}
function Get-LightsOutRunRoots {
[CmdletBinding()]
param([Parameter(Mandatory)][string]$RepoPath)
$roots = [System.Collections.Generic.List[string]]::new()
$preferred = Join-Path $RepoPath '.nightshift\runs'
if (Test-Path -LiteralPath $preferred) { [void]$roots.Add($preferred) }
Get-ChildItem -LiteralPath $RepoPath -Force -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like '.*' } |
ForEach-Object {
$candidate = Join-Path $_.FullName 'runs'
if ((Test-Path -LiteralPath $candidate) -and -not $roots.Contains($candidate)) {
[void]$roots.Add($candidate)
}
}
$roots.ToArray()
}
# --- run directory resolution --------------------------------------------
# gnhf appends -1, -2, ... on runId collision, so the predicted id may not be the real one.
# The engine appends -1, -2, ... on runId collision, so the predicted id may not be the real one.
# Prefer the exact id; otherwise take the newest suffixed sibling.
function Resolve-LightsOutRunDir {
@ -198,16 +343,126 @@ function Resolve-LightsOutRunDir {
param([Parameter(Mandatory)][string]$RepoPath,
[Parameter(Mandatory)][string]$RunId)
$runsRoot = Join-Path $RepoPath '.gnhf\runs'
if (-not (Test-Path -LiteralPath $runsRoot)) { return $null }
foreach ($runsRoot in Get-LightsOutRunRoots -RepoPath $RepoPath) {
$exact = Join-Path $runsRoot $RunId
if (Test-Path -LiteralPath $exact) { return $exact }
}
Get-ChildItem -LiteralPath $runsRoot -Directory -ErrorAction SilentlyContinue |
foreach ($runsRoot in Get-LightsOutRunRoots -RepoPath $RepoPath) {
$match = Get-ChildItem -LiteralPath $runsRoot -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match ('^' + [regex]::Escape($RunId) + '(-\d+)?$') } |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1 -ExpandProperty FullName
if ($match) { return $match }
}
$null
}
function Resolve-LightsOutRunDirFromConsoleLog {
[CmdletBinding()]
param([string]$ConsoleLogPath)
if (-not $ConsoleLogPath -or -not (Test-Path -LiteralPath $ConsoleLogPath)) { return $null }
$runDirCandidates = [System.Collections.Generic.List[string]]::new()
foreach ($line in @(Get-Content -LiteralPath $ConsoleLogPath -ErrorAction SilentlyContinue)) {
$clean = $line -replace '\x1b\[[0-9;?]*[A-Za-z]', ''
foreach ($match in [regex]::Matches($clean, '(?<path>[A-Za-z]:\\[^\r\n`"]+?\.nightshift\\runs\\[^\r\n\\`"]+\\nightshift\.log)')) {
$path = Split-Path -Parent ([string]$match.Groups['path'].Value.Trim())
if ((Test-Path -LiteralPath $path) -and -not $runDirCandidates.Contains($path)) {
[void]$runDirCandidates.Add([string]$path)
}
}
}
if ($runDirCandidates.Count -eq 1) { return $runDirCandidates[0] }
if ($runDirCandidates.Count -gt 1) {
$exactDirs = @($runDirCandidates | Where-Object { Test-Path -LiteralPath (Join-Path $_ 'end-state.json') })
if ($exactDirs.Count -eq 1) { return $exactDirs[0] }
}
$null
}
function Get-LightsOutCheckoutPathFromRunDir {
[CmdletBinding()]
param([string]$RunDir)
if (-not $RunDir) { return $null }
$marker = '\.nightshift\runs\'
$index = $RunDir.IndexOf($marker, [System.StringComparison]::OrdinalIgnoreCase)
if ($index -lt 0) { return $null }
$checkoutPath = $RunDir.Substring(0, $index)
if (Test-Path -LiteralPath $checkoutPath) { return $checkoutPath }
$null
}
function Get-GitWorktreePaths {
[CmdletBinding()]
param([Parameter(Mandatory)][string]$RepoPath)
$paths = [System.Collections.Generic.List[string]]::new()
$current = $null
foreach ($line in @(git -C $RepoPath worktree list --porcelain 2>$null)) {
if ($line -match '^worktree\s+(.+)$') {
if ($current) { [void]$paths.Add($current) }
$current = $Matches[1]
}
}
if ($current) { [void]$paths.Add($current) }
$paths.ToArray()
}
function Resolve-LightsOutCheckoutPath {
[CmdletBinding()]
param([Parameter(Mandatory)][pscustomobject]$Run)
if (-not (Test-Path -LiteralPath $Run.repoPath)) { return $null }
if (-not ($Run.PSObject.Properties['worktree'] -and $Run.worktree)) { return $Run.repoPath }
$consoleLogStem = if ($Run.PSObject.Properties.Name -contains 'registryId' -and $Run.registryId) { $Run.registryId } else { $Run.id }
$consoleRunDir = Resolve-LightsOutRunDirFromConsoleLog -ConsoleLogPath (Join-Path $script:LightsOutRoot "logs\$consoleLogStem.console.log")
if ($consoleRunDir) {
$checkoutFromConsole = Get-LightsOutCheckoutPathFromRunDir -RunDir $consoleRunDir
if ($checkoutFromConsole) { return $checkoutFromConsole }
}
$candidates = [System.Collections.Generic.List[string]]::new()
$repoParent = Split-Path -Parent $Run.repoPath
$repoName = Split-Path -Leaf $Run.repoPath
$predicted = Join-Path $repoParent "$repoName-nightshift-worktrees\$($Run.id)"
if (Test-Path -LiteralPath $predicted) { [void]$candidates.Add((Resolve-Path -LiteralPath $predicted).Path) }
foreach ($path in @(Get-GitWorktreePaths -RepoPath $Run.repoPath)) {
if ((Test-Path -LiteralPath $path) -and -not $candidates.Contains($path)) {
[void]$candidates.Add($path)
}
}
$matchingRunDirs = [System.Collections.Generic.List[string]]::new()
foreach ($candidate in $candidates) {
$runDir = Resolve-LightsOutRunDir -RepoPath $candidate -RunId $Run.id
if (-not $runDir) { continue }
if ($Run.PSObject.Properties.Name -contains 'startedAt' -and $Run.startedAt) {
try {
$startedAt = [datetime]::Parse($Run.startedAt).ToUniversalTime()
$runDirUpdatedAt = (Get-Item -LiteralPath $runDir).LastWriteTimeUtc
if ($runDirUpdatedAt -lt $startedAt) { continue }
}
catch { }
}
[void]$matchingRunDirs.Add([string]$runDir)
}
if ($matchingRunDirs.Count -gt 0) {
$newestRunDir = $matchingRunDirs |
Sort-Object { (Get-Item -LiteralPath $_).LastWriteTimeUtc } -Descending |
Select-Object -First 1
$checkoutFromRunDir = Get-LightsOutCheckoutPathFromRunDir -RunDir $newestRunDir
if ($checkoutFromRunDir) { return $checkoutFromRunDir }
}
$null
}
function Test-LightsOutProcessAlive {
@ -215,19 +470,30 @@ function Test-LightsOutProcessAlive {
param([Parameter(Mandatory)][int]$ProcessId,
[string]$StartedAtIso)
$p = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue
if (-not $p) { return $false }
# Guard against PID reuse: a recycled PID will have started after we recorded the launch.
$process = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue
if (-not $process) { return $false }
if ($StartedAtIso) {
try {
$expected = [datetime]::Parse($StartedAtIso).ToUniversalTime()
if ($p.StartTime.ToUniversalTime() -gt $expected.AddMinutes(2)) { return $false }
if ($process.StartTime.ToUniversalTime() -gt $expected.AddMinutes(2)) { return $false }
}
catch { }
}
$true
}
function Resolve-LightsOutEngineLogPath {
[CmdletBinding()]
param([Parameter(Mandatory)][string]$RunDir)
$preferred = Join-Path $RunDir 'nightshift.log'
if (Test-Path -LiteralPath $preferred) { return $preferred }
Get-ChildItem -LiteralPath $RunDir -File -Filter '*.log' -ErrorAction SilentlyContinue |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1 -ExpandProperty FullName
}
# --- vault write-back -----------------------------------------------------
function Get-LightsOutVaultFolder {
@ -237,7 +503,13 @@ function Get-LightsOutVaultFolder {
$date = ([datetime]::Parse($Run.startedAt)).ToString('yyyy-MM-dd')
$repo = Get-LightsOutSafeName -Text $Run.repoName -MaxLength 30
$title = Get-LightsOutSafeName -Text $Run.title -MaxLength 50
Join-Path $script:LightsOutVaultDir "$date - $repo - $title"
$identity = if ($Run.PSObject.Properties.Name -contains 'registryId' -and $Run.registryId) {
([string]$Run.registryId).Substring(0, [Math]::Min(8, ([string]$Run.registryId).Length))
} else {
$null
}
$folderName = if ($identity) { "$date - $repo - $title - $identity" } else { "$date - $repo - $title" }
Join-Path $script:LightsOutVaultDir $folderName
}
function Write-LightsOutVaultNote {
@ -256,39 +528,44 @@ function Write-LightsOutVaultNote {
$endState = $null
if ($RunDir) {
$esPath = Join-Path $RunDir 'end-state.json'
if (Test-Path -LiteralPath $esPath) {
try { $endState = Get-Content -LiteralPath $esPath -Raw | ConvertFrom-Json } catch { }
Copy-Item -LiteralPath $esPath -Destination (Join-Path $folder 'end-state.json') -Force -ErrorAction SilentlyContinue
$endStatePath = Join-Path $RunDir 'end-state.json'
if (Test-Path -LiteralPath $endStatePath) {
try { $endState = Get-Content -LiteralPath $endStatePath -Raw | ConvertFrom-Json } catch { }
Copy-Item -LiteralPath $endStatePath -Destination (Join-Path $folder 'end-state.json') -Force -ErrorAction SilentlyContinue
}
foreach ($f in 'notes.md', 'prompt.md') {
$p = Join-Path $RunDir $f
if (Test-Path -LiteralPath $p) {
Copy-Item -LiteralPath $p -Destination (Join-Path $folder $f) -Force -ErrorAction SilentlyContinue
foreach ($fileName in 'notes.md', 'prompt.md') {
$path = Join-Path $RunDir $fileName
if (Test-Path -LiteralPath $path) {
Copy-Item -LiteralPath $path -Destination (Join-Path $folder $fileName) -Force -ErrorAction SilentlyContinue
}
}
}
# git evidence, captured before any branch can be deleted
$commits = @(); $diffStat = ''; $changedFiles = @()
$commits = @()
$diffStat = ''
$changedFiles = @()
$base = $Run.baseCommit
if ($base -and (Test-Path -LiteralPath $Run.repoPath)) {
$checkoutPath = if ($Run.PSObject.Properties['checkoutPath'] -and $Run.checkoutPath) { $Run.checkoutPath } else { Resolve-LightsOutCheckoutPath -Run $Run }
$isWorktreeRun = $Run.PSObject.Properties['worktree'] -and $Run.worktree
if (-not $checkoutPath -and -not $isWorktreeRun) { $checkoutPath = $Run.repoPath }
$repoResolved = try { (Resolve-Path -LiteralPath $Run.repoPath).Path } catch { $Run.repoPath }
$checkoutResolved = try { (Resolve-Path -LiteralPath $checkoutPath).Path } catch { $checkoutPath }
$checkoutDiffers = $isWorktreeRun -and $checkoutResolved -ne $repoResolved
if ($base -and (Test-Path -LiteralPath $checkoutPath)) {
$range = "$base..HEAD"
$commits = @(git -C $Run.repoPath --no-pager log --oneline $range 2>$null)
$diffStat = (git -C $Run.repoPath --no-pager diff --stat $range 2>$null) -join "`n"
$changedFiles = @(git -C $Run.repoPath --no-pager diff --name-only $range 2>$null)
$patch = git -C $Run.repoPath --no-pager diff $range 2>$null
$commits = @(git -C $checkoutPath --no-pager log --oneline $range 2>$null)
$diffStat = (git -C $checkoutPath --no-pager diff --stat $range 2>$null) -join "`n"
$changedFiles = @(git -C $checkoutPath --no-pager diff --name-only $range 2>$null)
$patch = git -C $checkoutPath --no-pager diff $range 2>$null
if ($patch) { Set-Content -LiteralPath (Join-Path $folder 'diff.patch') -Value ($patch -join "`n") -Encoding UTF8 }
}
$status = if ($endState -and $endState.PSObject.Properties['status']) { [string]$endState.status } else { 'unknown' }
$failed = $status -notin @('completed', 'stopped', 'stop-condition-met')
# The debug log is large JSONL; only worth keeping when something went wrong.
if ($failed -and $RunDir) {
$lg = Join-Path $RunDir 'gnhf.log'
if (Test-Path -LiteralPath $lg) {
Copy-Item -LiteralPath $lg -Destination (Join-Path $folder 'gnhf.log') -Force -ErrorAction SilentlyContinue
$engineLog = Resolve-LightsOutEngineLogPath -RunDir $RunDir
if ($engineLog) {
Copy-Item -LiteralPath $engineLog -Destination (Join-Path $folder (Split-Path $engineLog -Leaf)) -Force -ErrorAction SilentlyContinue
}
}
if ($ConsoleLogPath -and (Test-Path -LiteralPath $ConsoleLogPath)) {
@ -296,6 +573,7 @@ function Write-LightsOutVaultNote {
Set-Content -LiteralPath (Join-Path $folder 'console.tail.log') -Encoding UTF8
}
$engine = Get-RunEngineDescriptor -Run $Run
$today = (Get-Date).ToString('yyyy-MM-dd')
$date = ([datetime]::Parse($Run.startedAt)).ToString('yyyy-MM-dd')
$repoTag = Get-LightsOutKebab -Text $Run.repoName
@ -307,141 +585,142 @@ function Write-LightsOutVaultNote {
}
catch { }
$iterTxt = if ($endState -and $endState.PSObject.Properties['iterations']) { "$($endState.iterations)" } else { 'unknown' }
$okTxt = if ($endState -and $endState.PSObject.Properties['successCount']) { "$($endState.successCount)" } else { '?' }
$failTxt = if ($endState -and $endState.PSObject.Properties['failCount']) { "$($endState.failCount)" } else { '?' }
$stopCond = if ($endState -and $endState.PSObject.Properties['stopCondition'] -and $endState.stopCondition) { [string]$endState.stopCondition } else { '_none recorded_' }
$agentErr = if ($endState -and $endState.PSObject.Properties['agentError'] -and $endState.agentError) { [string]$endState.agentError } else { $null }
# Single-quoted YAML: backslashes in Windows paths are literal there, whereas in
# double-quoted YAML "D:\Repos" is an invalid escape and breaks the parser.
$yq = { param($s) "'" + ($s -replace "'", "''") + "'" }
$sourceList = @((& $yq $Run.repoPath))
if ($RunDir) { $sourceList += (& $yq $RunDir) }
$iterText = if ($endState -and $endState.PSObject.Properties['iterations']) { "$($endState.iterations)" } else { 'unknown' }
$okText = if ($endState -and $endState.PSObject.Properties['successCount']) { "$($endState.successCount)" } else { '?' }
$failText = if ($endState -and $endState.PSObject.Properties['failCount']) { "$($endState.failCount)" } else { '?' }
$stopCondition = if ($endState -and $endState.PSObject.Properties['stopCondition'] -and $endState.stopCondition) { [string]$endState.stopCondition } else { '_none recorded_' }
$agentError = if ($endState -and $endState.PSObject.Properties['agentError'] -and $endState.agentError) { [string]$endState.agentError } else { $null }
$quoteYaml = { param($s) "'" + ($s -replace "'", "''") + "'" }
$sourceList = @((& $quoteYaml $Run.repoPath))
if ($checkoutPath -and $checkoutDiffers) { $sourceList += (& $quoteYaml $checkoutPath) }
if ($RunDir) { $sourceList += (& $quoteYaml $RunDir) }
$sourcesLine = 'sources: [' + ($sourceList -join ', ') + ']'
# ConvertFrom-Json turns ISO strings into local-time DateTime with Kind=Unspecified, which
# renders without an offset. Vault conventions require ISO 8601 *with* offset.
$isoFmt = {
param($v)
$dt = [datetime]$v
$toIso = {
param($value)
$dt = [datetime]$value
if ($dt.Kind -eq 'Unspecified') { $dt = [datetime]::SpecifyKind($dt, 'Local') }
$dt.ToString('o')
}
$startedIso = & $isoFmt $Run.startedAt
$endedIso = & $isoFmt $endedAt
$startedIso = & $toIso $Run.startedAt
$endedIso = & $toIso $endedAt
$title = "$date $($Run.repoName) — $($Run.title)"
$sb = [System.Text.StringBuilder]::new()
[void]$sb.AppendLine('---')
[void]$sb.AppendLine("title: $(& $yq $title)")
[void]$sb.AppendLine('type: lights-out-run')
[void]$sb.AppendLine("date: $date")
[void]$sb.AppendLine("created: $date")
[void]$sb.AppendLine("updated: $today")
[void]$sb.AppendLine('status: draft')
[void]$sb.AppendLine("tags: [lights-out, project/$repoTag, status/draft]")
[void]$sb.AppendLine($sourcesLine)
[void]$sb.AppendLine('owner: agent:lights-out')
[void]$sb.AppendLine('sensitivity: internal')
[void]$sb.AppendLine('---')
[void]$sb.AppendLine()
[void]$sb.AppendLine("# $title")
[void]$sb.AppendLine()
[void]$sb.AppendLine('> [!warning] Assessment pending')
[void]$sb.AppendLine('> Facts below are machine-captured. The judgment section is empty until Scout reviews it.')
[void]$sb.AppendLine('> Ask Scout: "how did last night''s run go?"')
[void]$sb.AppendLine()
[void]$sb.AppendLine('## Run')
[void]$sb.AppendLine()
[void]$sb.AppendLine('| Field | Value |')
[void]$sb.AppendLine('|---|---|')
[void]$sb.AppendLine("| Run id | ``$($Run.id)`` |")
[void]$sb.AppendLine("| Repo | ``$($Run.repoPath)`` |")
[void]$sb.AppendLine("| Branch | ``$($Run.branch)`` |")
[void]$sb.AppendLine("| Agent | $($Run.agent) |")
if ($Run.PSObject.Properties['gnhfKind']) {
$gv = if ($Run.PSObject.Properties['gnhfVersion'] -and $Run.gnhfVersion) { " v$($Run.gnhfVersion)" } else { '' }
[void]$sb.AppendLine("| gnhf | $($Run.gnhfKind)$gv |")
}
[void]$sb.AppendLine("| Mode | $($Run.mode) |")
[void]$sb.AppendLine("| Started | $startedIso |")
[void]$sb.AppendLine("| Ended | $endedIso |")
[void]$sb.AppendLine("| Elapsed | $elapsed |")
[void]$sb.AppendLine("| Exit status | **$status** |")
[void]$sb.AppendLine("| Iterations | $iterTxt ($okTxt ok / $failTxt failed) |")
[void]$sb.AppendLine("| Commits | $($commits.Count) |")
[void]$sb.AppendLine()
[void]$sb.AppendLine('## Objective')
[void]$sb.AppendLine()
[void]$sb.AppendLine('```text')
[void]$sb.AppendLine($Run.prompt)
[void]$sb.AppendLine('```')
[void]$sb.AppendLine()
[void]$sb.AppendLine('## Stop condition')
[void]$sb.AppendLine()
[void]$sb.AppendLine($stopCond)
[void]$sb.AppendLine()
if ($agentErr) {
[void]$sb.AppendLine('## Agent error')
[void]$sb.AppendLine()
[void]$sb.AppendLine('```text')
[void]$sb.AppendLine($agentErr)
[void]$sb.AppendLine('```')
[void]$sb.AppendLine()
}
[void]$sb.AppendLine('## Commits')
[void]$sb.AppendLine()
$builder = [System.Text.StringBuilder]::new()
[void]$builder.AppendLine('---')
[void]$builder.AppendLine("title: $(& $quoteYaml $title)")
[void]$builder.AppendLine('type: lights-out-run')
[void]$builder.AppendLine("date: $date")
[void]$builder.AppendLine("created: $date")
[void]$builder.AppendLine("updated: $today")
[void]$builder.AppendLine('status: draft')
[void]$builder.AppendLine("tags: [lights-out, project/$repoTag, status/draft]")
[void]$builder.AppendLine($sourcesLine)
[void]$builder.AppendLine('owner: agent:lights-out')
[void]$builder.AppendLine('sensitivity: internal')
[void]$builder.AppendLine('---')
[void]$builder.AppendLine()
[void]$builder.AppendLine("# $title")
[void]$builder.AppendLine()
[void]$builder.AppendLine('> [!warning] Assessment pending')
[void]$builder.AppendLine('> Facts below are machine-captured. The judgment section is empty until Scout reviews it.')
[void]$builder.AppendLine('> Ask Scout: "how did last night''s run go?"')
[void]$builder.AppendLine()
[void]$builder.AppendLine('## Run')
[void]$builder.AppendLine()
[void]$builder.AppendLine('| Field | Value |')
[void]$builder.AppendLine('|---|---|')
[void]$builder.AppendLine("| Run id | ``$($Run.id)`` |")
[void]$builder.AppendLine("| Repo | ``$($Run.repoPath)`` |")
if ($checkoutPath -and $checkoutDiffers) {
[void]$builder.AppendLine("| Checkout | ``$checkoutPath`` |")
}
[void]$builder.AppendLine("| Branch | ``$($Run.branch)`` |")
[void]$builder.AppendLine("| Agent | $($Run.agent) |")
if ($engine.Kind) {
$engineText = $engine.Kind
if ($engine.Version) { $engineText += " v$($engine.Version)" }
[void]$builder.AppendLine("| Engine | $engineText |")
}
[void]$builder.AppendLine("| Mode | $($Run.mode) |")
[void]$builder.AppendLine("| Started | $startedIso |")
[void]$builder.AppendLine("| Ended | $endedIso |")
[void]$builder.AppendLine("| Elapsed | $elapsed |")
[void]$builder.AppendLine("| Exit status | **$status** |")
[void]$builder.AppendLine("| Iterations | $iterText ($okText ok / $failText failed) |")
[void]$builder.AppendLine("| Commits | $($commits.Count) |")
[void]$builder.AppendLine()
[void]$builder.AppendLine('## Objective')
[void]$builder.AppendLine()
[void]$builder.AppendLine('```text')
[void]$builder.AppendLine($Run.prompt)
[void]$builder.AppendLine('```')
[void]$builder.AppendLine()
[void]$builder.AppendLine('## Stop condition')
[void]$builder.AppendLine()
[void]$builder.AppendLine($stopCondition)
[void]$builder.AppendLine()
if ($agentError) {
[void]$builder.AppendLine('## Agent error')
[void]$builder.AppendLine()
[void]$builder.AppendLine('```text')
[void]$builder.AppendLine($agentError)
[void]$builder.AppendLine('```')
[void]$builder.AppendLine()
}
[void]$builder.AppendLine('## Commits')
[void]$builder.AppendLine()
if ($commits.Count -gt 0) {
[void]$sb.AppendLine('```text')
foreach ($c in $commits) { [void]$sb.AppendLine($c) }
[void]$sb.AppendLine('```')
}
else { [void]$sb.AppendLine('_No commits were made._') }
[void]$sb.AppendLine()
[void]$sb.AppendLine('## Changed files')
[void]$sb.AppendLine()
[void]$builder.AppendLine('```text')
foreach ($commit in $commits) { [void]$builder.AppendLine($commit) }
[void]$builder.AppendLine('```')
}
else { [void]$builder.AppendLine('_No commits were made._') }
[void]$builder.AppendLine()
[void]$builder.AppendLine('## Changed files')
[void]$builder.AppendLine()
if ($changedFiles.Count -gt 0) {
[void]$sb.AppendLine('```text')
[void]$sb.AppendLine($diffStat)
[void]$sb.AppendLine('```')
}
else { [void]$sb.AppendLine('_No files changed._') }
[void]$sb.AppendLine()
[void]$sb.AppendLine('## Verification')
[void]$sb.AppendLine()
[void]$builder.AppendLine('```text')
[void]$builder.AppendLine($diffStat)
[void]$builder.AppendLine('```')
}
else { [void]$builder.AppendLine('_No files changed._') }
[void]$builder.AppendLine()
[void]$builder.AppendLine('## Verification')
[void]$builder.AppendLine()
if ($Run.PSObject.Properties['verifyCommand'] -and $Run.verifyCommand) {
[void]$sb.AppendLine("Command: ``$($Run.verifyCommand)``")
[void]$sb.AppendLine()
[void]$builder.AppendLine("Command: ``$($Run.verifyCommand)``")
[void]$builder.AppendLine()
$pre = if ($Run.PSObject.Properties['verifyExitCodeBefore']) { $Run.verifyExitCodeBefore } else { 'n/a' }
$post = if ($Run.PSObject.Properties['verifyExitCodeAfter']) { $Run.verifyExitCodeAfter } else { 'n/a' }
[void]$sb.AppendLine("- Exit code before the run: **$pre**")
[void]$sb.AppendLine("- Exit code after the run: **$post**")
[void]$sb.AppendLine()
[void]$sb.AppendLine('> Captured by the wrapper, independent of anything the agent claimed.')
}
else { [void]$sb.AppendLine('_No verification command was configured for this run._') }
[void]$sb.AppendLine()
[void]$sb.AppendLine('## Assessment')
[void]$sb.AppendLine()
[void]$sb.AppendLine('_Pending Scout review. Replace this section, set `status:` to `active`, swap the')
[void]$sb.AppendLine('`status/draft` tag for `status/active`, and bump `updated:`._')
[void]$sb.AppendLine()
[void]$sb.AppendLine('- Met the stop condition:')
[void]$sb.AppendLine('- Quality:')
[void]$sb.AppendLine('- Scope discipline:')
[void]$sb.AppendLine('- Verdict: Mergeable / Needs follow-up run / Do not merge')
[void]$sb.AppendLine('- Recommended next action:')
[void]$sb.AppendLine()
[void]$sb.AppendLine('## Artifacts')
[void]$sb.AppendLine()
[void]$sb.AppendLine('Raw, immutable (vault rule 4 — never hand-edit):')
[void]$sb.AppendLine()
foreach ($a in 'diff.patch', 'notes.md', 'prompt.md', 'end-state.json', 'gnhf.log', 'console.tail.log') {
if (Test-Path -LiteralPath (Join-Path $folder $a)) { [void]$sb.AppendLine("- ``$a``") }
}
[void]$builder.AppendLine("- Exit code before the run: **$pre**")
[void]$builder.AppendLine("- Exit code after the run: **$post**")
[void]$builder.AppendLine()
[void]$builder.AppendLine('> Captured by the wrapper, independent of anything the agent claimed.')
}
else { [void]$builder.AppendLine('_No verification command was configured for this run._') }
[void]$builder.AppendLine()
[void]$builder.AppendLine('## Assessment')
[void]$builder.AppendLine()
[void]$builder.AppendLine('_Pending Scout review. Replace this section, set `status:` to `active`, swap the')
[void]$builder.AppendLine('`status/draft` tag for `status/active`, and bump `updated:`._')
[void]$builder.AppendLine()
[void]$builder.AppendLine('- Met the stop condition:')
[void]$builder.AppendLine('- Quality:')
[void]$builder.AppendLine('- Scope discipline:')
[void]$builder.AppendLine('- Verdict: Mergeable / Needs follow-up run / Do not merge')
[void]$builder.AppendLine('- Recommended next action:')
[void]$builder.AppendLine()
[void]$builder.AppendLine('## Artifacts')
[void]$builder.AppendLine()
[void]$builder.AppendLine('Raw, immutable (vault rule 4 — never hand-edit):')
[void]$builder.AppendLine()
Get-ChildItem -LiteralPath $folder -File -ErrorAction SilentlyContinue |
Sort-Object Name |
ForEach-Object { [void]$builder.AppendLine("- ``$($_.Name)``") }
$summaryPath = Join-Path $folder 'summary.md'
Set-Content -LiteralPath $summaryPath -Value $sb.ToString() -Encoding UTF8
Set-Content -LiteralPath $summaryPath -Value $builder.ToString() -Encoding UTF8
$summaryPath
}

163
lights-out/scripts/start-run.ps1

@ -1,16 +1,16 @@
<#
.SYNOPSIS
Launch a gnhf overnight run, detached, with a guaranteed finalize step.
Launch a nightshift overnight run, detached, with a guaranteed finalize step.
.DESCRIPTION
Phase "Launch" (default) runs preflight, records the run in the durable registry, and spawns a
detached worker. It returns quickly and prints a JSON object for Scout to read.
Phase "Worker" is internal. It runs gnhf to completion, re-runs verification, and writes the
Obsidian vault record. It takes only a run id -- every other value is read back out of
Phase "Worker" is internal. It runs the engine to completion, re-runs verification, and writes
the Obsidian vault record. It takes only a run id -- every other value is read back out of
runs.json, which avoids quoting a multi-line prompt through a process boundary.
The finalize work lives here, in the detached process, on purpose: the Scout session that
launched the run is usually gone by the time gnhf exits, so an agent-side "on exit" hook
launched the run is usually gone by the time the engine exits, so an agent-side "on exit" hook
would silently never fire.
.EXAMPLE
.\start-run.ps1 -RepoPath D:\Repos\myapp -Prompt "reduce complexity in src/utils" `
@ -21,10 +21,8 @@ param(
[ValidateSet('Launch', 'Worker')]
[string]$Phase = 'Launch',
# Worker phase only
[string]$RunStateId,
# Launch phase
[string]$RepoPath,
[string]$Prompt,
[string]$Title,
@ -49,11 +47,10 @@ $ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'lib.ps1')
$paths = Get-LightsOutPaths
# Map a gnhf --agent value to the CLI it shells out to, so we fail at launch instead of at 2am.
function Get-AgentCommandName {
param([string]$AgentName)
switch -Regex ($AgentName) {
'^acp:' { return $null } # ACP targets are resolved by acpx, skip the check
'^acp:' { return $null }
'^cursor$' { return 'cursor-agent' }
default { return $AgentName }
}
@ -74,42 +71,40 @@ function Invoke-VerifyCommand {
finally { Pop-Location }
}
# =========================================================================
# WORKER
# =========================================================================
if ($Phase -eq 'Worker') {
$run = Get-LightsOutRun -Id $RunStateId
if (-not $run) { throw "No run registered with id '$RunStateId'." }
$run | Add-Member -NotePropertyName workerPid -NotePropertyValue $PID -Force
$run | Add-Member -NotePropertyName status -NotePropertyValue 'running' -Force
Save-LightsOutRun -Run $run
$logDir = Join-Path $paths.Root 'logs'
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
$consoleLog = Join-Path $logDir "$($run.id).console.log"
$gnhfArgs = @()
if ($run.agent) { $gnhfArgs += @('--agent', $run.agent) }
if ($run.PSObject.Properties['model'] -and $run.model) { $gnhfArgs += @('--model', $run.model) }
if ($run.PSObject.Properties['stopWhen'] -and $run.stopWhen) { $gnhfArgs += @('--stop-when', $run.stopWhen) }
if ($run.PSObject.Properties['maxIterations'] -and $run.maxIterations -gt 0) { $gnhfArgs += @('--max-iterations', "$($run.maxIterations)") }
if ($run.PSObject.Properties['maxTokens'] -and $run.maxTokens -gt 0) { $gnhfArgs += @('--max-tokens', "$($run.maxTokens)") }
if ($run.PSObject.Properties['worktree'] -and $run.worktree) { $gnhfArgs += '--worktree' }
if ($run.PSObject.Properties['currentBranch'] -and $run.currentBranch) { $gnhfArgs += '--current-branch' }
if ($run.PSObject.Properties['push'] -and $run.push) { $gnhfArgs += '--push' }
if ($run.PSObject.Properties['mock'] -and $run.mock) { $gnhfArgs += '--mock' }
$gnhfArgs += @('--prevent-sleep', 'on', '--meteor-frequency', '0', $run.prompt)
$gnhfCmd = Resolve-GnhfCommand
if (-not $gnhfCmd) {
# Recorded rather than thrown: the launching session is long gone, so the only way the
# user ever learns about this is the registry and the vault note.
$consoleLogStem = if ($run.PSObject.Properties.Name -contains 'registryId' -and $run.registryId) { $run.registryId } else { $run.id }
$consoleLog = Join-Path $logDir "$consoleLogStem.console.log"
$engineArgs = @()
if ($run.agent) { $engineArgs += @('--agent', $run.agent) }
if ($run.PSObject.Properties['model'] -and $run.model) { $engineArgs += @('--model', $run.model) }
if ($run.PSObject.Properties['stopWhen'] -and $run.stopWhen) { $engineArgs += @('--stop-when', $run.stopWhen) }
if ($run.PSObject.Properties['maxIterations'] -and $run.maxIterations -gt 0) { $engineArgs += @('--max-iterations', "$($run.maxIterations)") }
if ($run.PSObject.Properties['maxTokens'] -and $run.maxTokens -gt 0) { $engineArgs += @('--max-tokens', "$($run.maxTokens)") }
if ($run.PSObject.Properties['worktree'] -and $run.worktree) { $engineArgs += '--worktree' }
if ($run.PSObject.Properties['currentBranch'] -and $run.currentBranch) { $engineArgs += '--current-branch' }
if ($run.PSObject.Properties['push'] -and $run.push) { $engineArgs += '--push' }
if ($run.PSObject.Properties['mock'] -and $run.mock) { $engineArgs += '--mock' }
$engineArgs += @('--prevent-sleep', 'on', '--meteor-frequency', '0', $run.prompt)
$engineCommand = Resolve-NightshiftCommand
if (-not $engineCommand) {
$run | Add-Member -NotePropertyName status -NotePropertyValue 'failed' -Force
$run | Add-Member -NotePropertyName endedAt -NotePropertyValue (Get-Date).ToString('o') -Force
$run | Add-Member -NotePropertyName launchError -NotePropertyValue 'gnhf could not be resolved (no override, no vendored copy, not on PATH).' -Force
$run | Add-Member -NotePropertyName launchError -NotePropertyValue 'Nightshift could not be resolved (no override, no vendored copy, not on PATH).' -Force
Save-LightsOutRun -Run $run
exit 1
}
$invokeArgs = @($gnhfCmd.Prefix) + $gnhfArgs
$invokeArgs = @($engineCommand.Prefix) + $engineArgs
# No TTY here, so the star field is pointless and ANSI would just pollute the log.
$env:NO_COLOR = '1'
$env:TERM = 'dumb'
@ -117,7 +112,7 @@ if ($Phase -eq 'Worker') {
try {
Push-Location -LiteralPath $run.repoPath
try {
& $gnhfCmd.File @invokeArgs *> $consoleLog
& $engineCommand.File @invokeArgs *> $consoleLog
$exitCode = $LASTEXITCODE
}
finally { Pop-Location }
@ -127,26 +122,49 @@ if ($Phase -eq 'Worker') {
Add-Content -LiteralPath $consoleLog -Value "`n[lights-out] launcher exception: $($_.Exception.Message)"
}
# --- finalize: this must run no matter how gnhf exited --------------
$run | Add-Member -NotePropertyName endedAt -NotePropertyValue (Get-Date).ToString('o') -Force
$run | Add-Member -NotePropertyName gnhfExitCode -NotePropertyValue $exitCode -Force
$run | Add-Member -NotePropertyName engineExitCode -NotePropertyValue $exitCode -Force
$run | Add-Member -NotePropertyName status -NotePropertyValue 'finalizing' -Force
Save-LightsOutRun -Run $run
$runDir = Resolve-LightsOutRunDir -RepoPath $run.repoPath -RunId $run.id
if ($runDir) { $run | Add-Member -NotePropertyName runDir -NotePropertyValue $runDir -Force }
$runDir = Resolve-LightsOutRunDirFromConsoleLog -ConsoleLogPath $consoleLog
$checkoutPath = if ($runDir) { Get-LightsOutCheckoutPathFromRunDir -RunDir $runDir } else { Resolve-LightsOutCheckoutPath -Run $run }
if ($checkoutPath) { $run | Add-Member -NotePropertyName checkoutPath -NotePropertyValue $checkoutPath -Force }
if (-not $runDir) {
$runDir = if ($checkoutPath) { Resolve-LightsOutRunDir -RepoPath $checkoutPath -RunId $run.id } else { $null }
}
if ($runDir) {
$actualRunId = Split-Path -Leaf $runDir
$run | Add-Member -NotePropertyName launchId -NotePropertyValue $(if ($run.PSObject.Properties.Name -contains 'launchId') { $run.launchId } else { $run.id }) -Force
$run | Add-Member -NotePropertyName engineRunId -NotePropertyValue $actualRunId -Force
$run | Add-Member -NotePropertyName id -NotePropertyValue $actualRunId -Force
$run | Add-Member -NotePropertyName runDir -NotePropertyValue $runDir -Force
}
if ($checkoutPath -and (Test-Path -LiteralPath $checkoutPath)) {
try {
$actualBranch = (git -C $checkoutPath rev-parse --abbrev-ref HEAD 2>$null)
if ($actualBranch) { $run | Add-Member -NotePropertyName branch -NotePropertyValue $actualBranch -Force }
}
catch { }
}
# Independent verification. The agent's own claim of success is not evidence.
if ($run.PSObject.Properties['verifyCommand'] -and $run.verifyCommand) {
$vLog = Join-Path $logDir "$($run.id).verify-after.log"
$verifyLog = Join-Path $logDir "$($run.id).verify-after.log"
$verifyDir = if ($checkoutPath) { $checkoutPath } elseif (-not ($run.PSObject.Properties['worktree'] -and $run.worktree)) { $run.repoPath } else { $null }
if ($verifyDir) {
try {
$after = Invoke-VerifyCommand -Command $run.verifyCommand -WorkingDir $run.repoPath -LogPath $vLog
$after = Invoke-VerifyCommand -Command $run.verifyCommand -WorkingDir $verifyDir -LogPath $verifyLog
$run | Add-Member -NotePropertyName verifyExitCodeAfter -NotePropertyValue $after -Force
}
catch {
$run | Add-Member -NotePropertyName verifyExitCodeAfter -NotePropertyValue 'error' -Force
}
}
else {
$run | Add-Member -NotePropertyName verifyExitCodeAfter -NotePropertyValue 'skipped-unresolved-checkout' -Force
}
}
$finalStatus = if ($exitCode -eq 0) { 'completed' } else { 'failed' }
$run | Add-Member -NotePropertyName status -NotePropertyValue $finalStatus -Force
@ -163,57 +181,45 @@ if ($Phase -eq 'Worker') {
exit 0
}
# =========================================================================
# LAUNCH
# =========================================================================
if (-not $RepoPath) { throw 'RepoPath is required.' }
if ([string]::IsNullOrWhiteSpace($Prompt)) {
# gnhf blocks forever reading stdin when the prompt is empty and stdin is not a TTY.
throw 'Prompt is required and must be non-empty. An empty prompt makes a detached gnhf hang on stdin.'
throw 'Prompt is required and must be non-empty. An empty prompt makes a detached nightshift run hang on stdin.'
}
$RepoPath = (Resolve-Path -LiteralPath $RepoPath).Path
$problems = @()
# 1. gnhf resolvable (vendored copy preferred over any global install)
$gnhfCmd = Resolve-GnhfCommand
if (-not $gnhfCmd) {
$problems += "gnhf could not be resolved. Restore the vendored copy with: npm install gnhf --prefix `"$(Split-Path -Parent $PSScriptRoot)\vendor`" (uses the configured MS proxy registry; forcing registry.npmjs.org fails with a TLS error on this machine)"
$engineCommand = Resolve-NightshiftCommand
if (-not $engineCommand) {
$problems += 'Nightshift could not be resolved. Restore the complete vendored runtime from a working Lights Out copy, or use the separately stored maintenance archives described in SKILL.md. Do not install an unrelated published package.'
}
elseif ($gnhfCmd.Kind -eq 'vendored' -and -not (Get-Command node -ErrorAction SilentlyContinue)) {
$problems += 'The vendored gnhf needs node.exe on PATH, and it was not found.'
elseif ($engineCommand.Kind -eq 'vendored' -and -not (Get-Command node -ErrorAction SilentlyContinue)) {
$problems += 'The vendored nightshift engine needs node.exe on PATH, and it was not found.'
}
# 2. real git repo
$insideRepo = (git -C $RepoPath rev-parse --is-inside-work-tree 2>$null)
if ($insideRepo -ne 'true') { $problems += "$RepoPath is not a git repository. Run 'git init' first." }
$branch = $null
$baseCommit = $null
if ($insideRepo -eq 'true') {
# 3. clean tree -- gnhf refuses to start otherwise
$dirty = @(git -C $RepoPath status --porcelain 2>$null)
if ($dirty.Count -gt 0) {
$problems += "Working tree is dirty ($($dirty.Count) change(s)). gnhf requires a clean tree. Commit or stash first."
$problems += "Working tree is dirty ($($dirty.Count) change(s)). Nightshift requires a clean tree. Commit or stash first."
}
# 4. NOT already on a gnhf branch -- this triggers an interactive overwrite prompt that
# hard-fails without a TTY, killing a detached run within seconds of launch.
$branch = (git -C $RepoPath rev-parse --abbrev-ref HEAD 2>$null)
if ($branch -and $branch.StartsWith('gnhf/') -and -not $CurrentBranch) {
$problems += "Currently on gnhf branch '$branch'. A detached run cannot answer the overwrite prompt. Switch to the base branch first."
if ($branch -and $branch.StartsWith('nightshift/') -and -not $CurrentBranch) {
$problems += "Currently on nightshift branch '$branch'. A detached run cannot answer the overwrite prompt. Switch to the base branch first."
}
$baseCommit = (git -C $RepoPath rev-parse HEAD 2>$null)
}
# 5. the agent's own CLI exists (a mock run never shells out to it)
$agentCmd = if ($Mock) { $null } else { Get-AgentCommandName -AgentName $Agent }
if ($agentCmd -and -not (Get-Command $agentCmd -ErrorAction SilentlyContinue)) {
$problems += "Agent CLI '$agentCmd' (for --agent $Agent) is not on PATH. Install and authenticate it before an unattended run."
$agentCommand = if ($Mock) { $null } else { Get-AgentCommandName -AgentName $Agent }
if ($agentCommand -and -not (Get-Command $agentCommand -ErrorAction SilentlyContinue)) {
$problems += "Agent CLI '$agentCommand' (for --agent $Agent) is not on PATH. Install and authenticate it before an unattended run."
}
# 6. the verification command is actually runnable *now*
$verifyBefore = $null
if ($VerifyCommand -and -not $SkipVerifyPreflight) {
$firstToken = ($VerifyCommand.Trim() -split '\s+')[0]
@ -242,12 +248,14 @@ if (-not $Title) {
}
$run = [pscustomobject]@{
id = $runId
registryId = [guid]::NewGuid().ToString('n')
id = $runId
launchId = $runId
schemaVersion = 1
status = 'running'
status = 'launching'
repoPath = $RepoPath
repoName = $repoName
branch = if ($CurrentBranch) { $branch } else { "gnhf/$runId" }
branch = if ($CurrentBranch) { $branch } else { "nightshift/$runId" }
baseCommit = $baseCommit
prompt = $Prompt
title = (Get-LightsOutSafeName -Text $Title -MaxLength 60)
@ -263,9 +271,9 @@ $run = [pscustomobject]@{
mock = [bool]$Mock
verifyCommand = $VerifyCommand
verifyExitCodeBefore = $verifyBefore
gnhfKind = $gnhfCmd.Kind
gnhfSource = $gnhfCmd.Source
gnhfVersion = $gnhfCmd.Version
engineKind = $engineCommand.Kind
engineSource = $engineCommand.Source
engineVersion = $engineCommand.Version
startedAt = (Get-Date).ToString('o')
launchedBy = 'scout:lights-out'
workerPid = $null
@ -274,28 +282,27 @@ Save-LightsOutRun -Run $run
$workerArgs = @(
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
'-File', $PSCommandPath,
'-File', "`"$PSCommandPath`"",
'-Phase', 'Worker',
'-RunStateId', $runId
'-RunStateId', $run.registryId
)
$proc = Start-Process -FilePath (Get-Process -Id $PID).Path `
$process = Start-Process -FilePath (Get-Process -Id $PID).Path `
-ArgumentList $workerArgs -WindowStyle Hidden -PassThru
$run | Add-Member -NotePropertyName workerPid -NotePropertyValue $proc.Id -Force
Save-LightsOutRun -Run $run
[pscustomobject]@{
ok = $true
id = $runId
registryId = $run.registryId
launchId = $run.launchId
branch = $run.branch
repoPath = $RepoPath
agent = $Agent
mode = $Mode
workerPid = $proc.Id
workerPid = $process.Id
stopWhen = $StopWhen
verifyCommand = $VerifyCommand
verifyBefore = $verifyBefore
gnhf = "$($gnhfCmd.Kind): $($gnhfCmd.Source)$(if ($gnhfCmd.Version) { " (v$($gnhfCmd.Version))" })"
engine = "$($engineCommand.Kind): $($engineCommand.Source)$(if ($engineCommand.Version) { " (v$($engineCommand.Version))" })"
statePath = $paths.StatePath
expectedVault = (Get-LightsOutVaultFolder -Run $run)
} | ConvertTo-Json -Depth 5

470
lights-out/scripts/test/e2e.ps1

@ -1,21 +1,61 @@
$ErrorActionPreference = 'Continue'
# Self-contained regression suite for the lights-out skill.
# pwsh -NoProfile -File <skill>\scripts\test\e2e.ps1
# Uses a stubbed gnhf and a sandboxed state dir + vault, so it never touches the real
# registry at ~\.scout\lights-out or the Obsidian vault.
$sand = Join-Path $env:TEMP 'lights-out-e2e'
$stubSrc = Join-Path $PSScriptRoot 'stub-gnhf.ps1'
# Uses a stubbed nightshift engine and a sandboxed state dir + vault, so it never touches the
# real registry at ~\.scout\lights-out or the Obsidian vault.
$sandRoot = Join-Path $env:TEMP 'lights-out-e2e'
$sand = Join-Path $sandRoot 'active'
$stubSrc = Join-Path $PSScriptRoot 'stub-nightshift.ps1'
$stub = Join-Path $sand 'bin'
$env:LIGHTS_OUT_ROOT = "$sand\state"
$env:LIGHTS_OUT_VAULT_DIR = "$sand\vault"
$scriptsDir = Split-Path -Parent $PSScriptRoot
$skillRoot = Split-Path -Parent $scriptsDir
$S = Join-Path $scriptsDir 'start-run.ps1'
$C = Join-Path $scriptsDir 'check-run.ps1'
Remove-Item -Recurse -Force $sand -ErrorAction SilentlyContinue
function Wait-SandboxWorkerExit {
param([string]$StatePath)
if (-not (Test-Path -LiteralPath $StatePath)) { return }
try {
$runs = @(Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json)
}
catch {
return
}
foreach ($run in $runs) {
if (-not $run.workerPid) { continue }
for ($i = 0; $i -lt 30; $i++) {
if (-not (Get-Process -Id $run.workerPid -ErrorAction SilentlyContinue)) { break }
Start-Sleep -Seconds 1
}
}
}
function Reset-Sandbox {
param([string]$Path)
Wait-SandboxWorkerExit -StatePath (Join-Path $Path 'state\runs.json')
for ($i = 0; $i -lt 30; $i++) {
Remove-Item -Recurse -Force $Path -ErrorAction SilentlyContinue
if (-not (Test-Path -LiteralPath $Path)) { return }
Start-Sleep -Seconds 1
}
throw "Could not reset sandbox at $Path."
}
New-Item -ItemType Directory -Force -Path $sandRoot | Out-Null
Reset-Sandbox -Path $sand
New-Item -ItemType Directory -Force -Path "$sand\repo", $stub | Out-Null
# Get-Command resolves .ps1 from PATH, so the stub must be named exactly gnhf.ps1.
Copy-Item $stubSrc (Join-Path $stub 'gnhf.ps1') -Force
Copy-Item $stubSrc (Join-Path $stub 'stub-nightshift.ps1') -Force
@"
@echo off
pwsh -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$stub\stub-nightshift.ps1" %*
exit /b %ERRORLEVEL%
"@ | Set-Content -LiteralPath (Join-Path $stub 'nightshift.cmd') -Encoding ASCII
@'
@echo off
exit /b 0
'@ | Set-Content -LiteralPath (Join-Path $stub 'cursor-agent.cmd') -Encoding ASCII
Push-Location "$sand\repo"
git init -q -b main
git config user.email 't@t.local'
@ -33,47 +73,117 @@ function Check($label, $cond) {
if ($cond) { Write-Host " PASS $label" -ForegroundColor Green; $script:pass++ }
else { Write-Host " FAIL $label" -ForegroundColor Red; $script:fail++ }
}
# Load the shared lib so the suite can assert on resolution directly.
function Has-Property($Object, $Name) {
$null -ne $Object -and $Object.PSObject.Properties.Name -contains $Name
}
. (Join-Path $scriptsDir 'lib.ps1')
function Resolve-GnhfCommandForTest { Resolve-GnhfCommand }
Show 'TEST 0: vendored gnhf is self-contained'
$vendored = Join-Path (Split-Path -Parent $scriptsDir) 'vendor\node_modules\gnhf\dist\cli.mjs'
Check 'vendored gnhf present' (Test-Path $vendored)
$env:LIGHTS_OUT_GNHF = ''
$resolved = Resolve-GnhfCommand
function Resolve-NightshiftCommandForTest {
if (Get-Command Resolve-NightshiftCommand -ErrorAction SilentlyContinue) {
Resolve-NightshiftCommand
}
else {
$null
}
}
$skillDoc = Get-Content (Join-Path $skillRoot 'SKILL.md') -Raw
$cliDoc = Get-Content (Join-Path $skillRoot 'references\cli.md') -Raw
$promptDoc = Get-Content (Join-Path $skillRoot 'references\prompts.md') -Raw
$startText = Get-Content $S -Raw
$checkText = Get-Content $C -Raw
$libText = Get-Content (Join-Path $scriptsDir 'lib.ps1') -Raw
$agentFn = [regex]::Match(
$startText,
'function Get-AgentCommandName\s*\{.*?^\}',
[System.Text.RegularExpressions.RegexOptions]::Singleline -bor
[System.Text.RegularExpressions.RegexOptions]::Multiline
)
if ($agentFn.Success) { Invoke-Expression $agentFn.Value }
Show 'TEST 0: rename surface and public API'
Check 'skill mentions nightshift trigger' ($skillDoc -match '(?i)nightshift')
Check 'skill documents schema break generically' ($skillDoc -match '(?i)legacy prefix')
Check 'cli doc uses .nightshift run path' ($cliDoc -match '\\\.nightshift\\runs\\<runId>\\')
Check 'cli doc names the config file' ($cliDoc -match '~\\\.nightshift\\config\.yml')
Check 'prompt templates still mention lights out' ($promptDoc -match '(?i)lights out')
Check 'start-run exposes engine output field' ($startText -match '\bengine\s*=')
Check 'check-run exposes engine exit code' ($checkText -match '\bengineExitCode\b')
Check 'lib resolves nightshift command' ($libText -match 'Resolve-NightshiftCommand')
Check 'start-run keeps Agent parameter' ($startText -match '\[string\]\$Agent')
Check 'start-run keeps Prompt parameter' ($startText -match '\[string\]\$Prompt')
Check 'start-run keeps StopWhen parameter' ($startText -match '\[string\]\$StopWhen')
Check 'start-run keeps VerifyCommand parameter' ($startText -match '\[string\]\$VerifyCommand')
Check 'worker handoff uses registryId' ($startText -match "'-RunStateId',\s*\`$run\.registryId")
Check 'worker owns workerPid persistence' (
[regex]::IsMatch($startText, 'if \(\$Phase -eq ''Worker''\).*workerPid.*\$PID.*Save-LightsOutRun', [System.Text.RegularExpressions.RegexOptions]::Singleline) -and
-not [regex]::IsMatch($startText, 'NotePropertyName workerPid -NotePropertyValue \$process\.Id -Force\s*\r?\nSave-LightsOutRun')
)
Check 'launch state starts as launching' ($startText -match "status\s+=\s+'launching'")
Check 'console log uses registryId' ($startText -match 'consoleLogStem.*registryId')
Check 'vault folder includes registry id' ($libText -match 'registryId' -and $libText -match 'Get-LightsOutVaultFolder')
Check 'wrapper parses engine run dir from console log' ($libText -match 'Resolve-LightsOutRunDirFromConsoleLog')
Check 'worktree fallback filters old run dirs' ($libText -match 'LastWriteTimeUtc' -and $libText -match 'startedAt')
Check 'worktree resolution has no branch-only fallback' ($libText -notmatch 'if \(\$branch -eq \$Run\.branch\)')
Check 'worktree verify skips unresolved checkout' ($startText -match 'skipped-unresolved-checkout')
Check 'Get-AgentCommandName is still present' ($agentFn.Success)
if ($agentFn.Success) {
Check 'Get-AgentCommandName keeps cursor mapping' ((Get-AgentCommandName -AgentName 'cursor') -eq 'cursor-agent')
Check 'Get-AgentCommandName keeps acp bypass' ($null -eq (Get-AgentCommandName -AgentName 'acp:loopback'))
}
Show 'TEST 1: vendored nightshift is self-contained'
$vendored = Join-Path $skillRoot 'vendor\node_modules\nightshift\dist\cli.mjs'
Check 'vendored nightshift present' (Test-Path $vendored)
if (Test-Path $vendored) {
$bundle = Get-Content -LiteralPath $vendored -Raw
Check 'vendored bundle has no analytics client or configuration' ($bundle -notmatch 'a\.kunchenguid\.com|NIGHTSHIFT_TELEMETRY|NIGHTSHIFT_UMAMI_HOST|NIGHTSHIFT_UMAMI_WEBSITE_ID|/api/send|initDefaultTelemetry|getDefaultTelemetry|createTelemetryClient')
}
$env:LIGHTS_OUT_NIGHTSHIFT = ''
$resolved = Resolve-NightshiftCommandForTest
Check 'resolves to vendored by default' ($resolved -and $resolved.Kind -eq 'vendored')
Check 'vendored version reported' ($resolved -and $resolved.Version)
if ($resolved) { Write-Host " -> $($resolved.Kind) v$($resolved.Version): $($resolved.Source)" }
Show 'TEST 1: empty prompt rejected'
Show 'TEST 2: empty prompt rejected'
try { & $S -RepoPath "$sand\repo" -Prompt ' ' | Out-Null; Check 'empty prompt rejected' $false }
catch { Check 'empty prompt rejected' $true }
Show 'TEST 2: dirty tree + missing verify tool (pnpm trap)'
Show 'TEST 3: dirty tree + missing verify tool'
'dirty' | Set-Content "$sand\repo\scratch.txt"
$r2 = (& $S -RepoPath "$sand\repo" -Prompt 'tidy things up' -VerifyCommand 'pnpm run test') | ConvertFrom-Json
Check 'preflight failed' (-not $r2.ok)
Check 'dirty tree reported' ([bool]($r2.problems -match 'dirty'))
Check 'missing pnpm reported' ([bool]($r2.problems -match 'pnpm'))
$r3 = (& $S -RepoPath "$sand\repo" -Prompt 'tidy things up' -VerifyCommand 'definitely-missing-lightsout-tool run test') | ConvertFrom-Json
Check 'preflight failed' (-not $r3.ok)
Check 'dirty tree reported' ([bool]($r3.problems -match 'dirty'))
Check 'missing verify tool reported' ([bool]($r3.problems -match 'definitely-missing-lightsout-tool'))
Remove-Item "$sand\repo\scratch.txt" -Force
Show 'TEST 3: already on a gnhf/* branch (non-TTY overwrite trap)'
Push-Location "$sand\repo"; git checkout -q -b 'gnhf/fake'; Pop-Location
$r3 = (& $S -RepoPath "$sand\repo" -Prompt 'tidy things up') | ConvertFrom-Json
Check 'gnhf-branch launch blocked' (-not $r3.ok)
Push-Location "$sand\repo"; git checkout -q main; git branch -q -D 'gnhf/fake'; Pop-Location
Show 'TEST 4: already on a nightshift branch (non-TTY overwrite trap)'
Push-Location "$sand\repo"; git checkout -q -b 'nightshift/fake'; Pop-Location
$r4 = (& $S -RepoPath "$sand\repo" -Prompt 'tidy things up') | ConvertFrom-Json
Check 'nightshift-branch launch blocked' (-not $r4.ok)
Push-Location "$sand\repo"; git checkout -q main; git branch -q -D 'nightshift/fake'; Pop-Location
Show 'TEST 4: unknown agent CLI blocked'
$r4 = (& $S -RepoPath "$sand\repo" -Prompt 'tidy things up' -Agent 'notarealagent') | ConvertFrom-Json
Check 'missing agent CLI blocked' (-not $r4.ok)
Show 'TEST 5: unknown agent CLI blocked'
$r5 = (& $S -RepoPath "$sand\repo" -Prompt 'tidy things up' -Agent 'notarealagent') | ConvertFrom-Json
Check 'missing agent CLI blocked' (-not $r5.ok)
Show 'TEST 5: happy path with stubbed gnhf'
$env:LIGHTS_OUT_GNHF = (Join-Path $stub 'gnhf.ps1')
Check 'stub gnhf resolves via override' ((Resolve-GnhfCommandForTest).Kind -eq 'override')
$out = & $S -RepoPath "$sand\repo" -Prompt 'reduce complexity in the widget helper' -Title 'Reduce Complexity' -StopWhen 'npm test passes and no unrelated files changed' -VerifyCommand 'git --version' -MaxIterations 5 -Mode companion -Agent 'copilot'
Show 'TEST 6: copied skill happy path with stubbed nightshift'
$copiedSkill = Join-Path $sand 'skill copy with spaces'
Copy-Item -LiteralPath $skillRoot -Destination $copiedSkill -Recurse
$copiedStart = Join-Path $copiedSkill 'scripts\start-run.ps1'
$copiedCli = Join-Path $copiedSkill 'vendor\node_modules\nightshift\dist\cli.mjs'
$copiedVersion = & node $copiedCli --version
Check 'copied engine runs without installation' ($LASTEXITCODE -eq 0 -and $copiedVersion -eq '0.1.51-lights-out')
$env:LIGHTS_OUT_NIGHTSHIFT = (Join-Path $stub 'nightshift.cmd')
$resolvedOverride = Resolve-NightshiftCommandForTest
Check 'stub nightshift resolves via override' ($resolvedOverride -and $resolvedOverride.Kind -eq 'override')
$out = & $copiedStart -RepoPath "$sand\repo" -Prompt 'reduce complexity in the widget helper' -Title 'Reduce Complexity' -StopWhen 'git --version exits 0 and no unrelated files changed' -VerifyCommand 'git --version' -MaxIterations 5 -Mode companion -Agent 'copilot'
$res = $out | ConvertFrom-Json
Check 'launch ok' ($res.ok -eq $true)
Check 'launch output keeps engine field' ($res.PSObject.Properties.Name -contains 'engine')
Check 'launch output keeps verifyCommand field' ($res.verifyCommand -eq 'git --version')
Check 'launch output uses nightshift branch' ($res.branch -like 'nightshift/*')
Write-Host " run id: $($res.id) pid: $($res.workerPid)"
Show 'Waiting for detached worker...'
@ -82,30 +192,312 @@ for ($i = 0; $i -lt 45; $i++) {
Start-Sleep -Seconds 2
$j = & $C -Id $res.id -Json
if ($j) { $final = ($j | ConvertFrom-Json)[0] }
if ($final -and $final.status -in @('completed', 'failed', 'orphaned')) { break }
if ($final -and $final.status -in @('completed', 'failed')) { break }
}
if ($final -and $final.status -notin @('completed', 'failed')) {
Start-Sleep -Seconds 2
$j = & $C -Id $res.id -Json
if ($j) { $final = ($j | ConvertFrom-Json)[0] }
}
Check 'worker finished' ($null -ne $final)
if ($final) {
Check 'status completed' ($final.status -eq 'completed')
Check 'two commits captured' ($final.commitCount -eq 2)
Check 'end-state parsed' ($final.endState.status -eq 'completed')
Check 'end-state parsed' ((Has-Property $final 'endState') -and (Has-Property $final.endState 'status') -and ($final.endState.status -eq 'completed'))
Check 'verify ran before and after' (($final.verifyExitCodeBefore -eq 0) -and ($final.verifyExitCodeAfter -eq 0))
Check 'run dir resolved' ($null -ne $final.runDir)
Check 'run dir uses .nightshift path' ($final.runDir -match '\\\.nightshift\\runs\\')
Check 'notes tail captured' ([bool]$final.notesTail)
Check 'gnhf provenance recorded' ($final.gnhfKind -eq 'override')
Check 'engine provenance recorded' ((Has-Property $final 'engineKind') -and ($final.engineKind -eq 'override'))
Check 'engine exit code recorded' ((Has-Property $final 'engineExitCode') -and ($final.engineExitCode -eq 0))
}
Show 'State file uses engine-prefixed fields'
$statePath = Join-Path $env:LIGHTS_OUT_ROOT 'runs.json'
$savedRuns = Get-Content $statePath -Raw -ErrorAction SilentlyContinue | ConvertFrom-Json
$savedRun = if ($savedRuns) { @($savedRuns | Where-Object { $_.id -eq $res.id })[0] } else { $null }
Check 'state file written' ($null -ne $savedRun)
if ($savedRun) {
Check 'state records engineKind' ($savedRun.PSObject.Properties.Name -contains 'engineKind')
Check 'state records engineSource' ($savedRun.PSObject.Properties.Name -contains 'engineSource')
Check 'state records engineVersion' ($savedRun.PSObject.Properties.Name -contains 'engineVersion')
Check 'state records engineExitCode' ($savedRun.PSObject.Properties.Name -contains 'engineExitCode')
}
Show 'TEST 6b: worktree runs finalize against generated worktree'
$verifyWorktreeScript = Join-Path $sand 'verify-worktree.ps1'
'if ((Get-Content index.js -Raw) -match ''=> x \+ 1'') { exit 0 } else { exit 1 }' | Set-Content -LiteralPath $verifyWorktreeScript -Encoding UTF8
$verifyWorktreeCommand = "pwsh -NoProfile -File `"$verifyWorktreeScript`""
$unsuffixedRepo = Join-Path $sand 'unsuffixed-worktree-repo'
New-Item -ItemType Directory -Force -Path $unsuffixedRepo | Out-Null
Push-Location $unsuffixedRepo
git init -q -b main
git config user.email 't@t.local'
git config user.name 'T'
'# unsuffixed worktree scratch' | Set-Content README.md
'export const widget = (x) => { if (true) { return x + 1; } };' | Set-Content index.js
git add -A
git commit -q -m 'init'
Pop-Location
$unsuffixedRunId = Get-LightsOutRunId -Prompt 'simplify widget in an unsuffixed worktree'
$outUnsuffixed = & $copiedStart -RepoPath $unsuffixedRepo -Prompt 'simplify widget in an unsuffixed worktree' -Title 'Unsuffixed Worktree' -StopWhen 'worktree-local verification exits 0' -VerifyCommand $verifyWorktreeCommand -MaxIterations 5 -Mode companion -Agent 'copilot' -Worktree
$resUnsuffixed = $outUnsuffixed | ConvertFrom-Json
Check 'unsuffixed worktree launch ok' ($resUnsuffixed.ok -eq $true)
$finalUnsuffixed = $null
if ($resUnsuffixed.PSObject.Properties.Name -contains 'id') {
for ($i = 0; $i -lt 45; $i++) {
Start-Sleep -Seconds 2
$j = & $C -Id $resUnsuffixed.id -Json
if ($j) { $finalUnsuffixed = ($j | ConvertFrom-Json)[0] }
if ($finalUnsuffixed -and $finalUnsuffixed.status -in @('completed', 'failed')) { break }
}
}
Check 'unsuffixed worktree completed on unsuffixed id' ($finalUnsuffixed -and $finalUnsuffixed.id -eq $unsuffixedRunId)
$preWorkerUnsuffixed = [pscustomobject]@{
registryId = [guid]::NewGuid().ToString('n')
id = $unsuffixedRunId
launchId = $unsuffixedRunId
schemaVersion = 1
status = 'launching'
repoPath = $unsuffixedRepo
repoName = 'unsuffixed-worktree-repo'
branch = "nightshift/$unsuffixedRunId"
baseCommit = (git -C $unsuffixedRepo rev-parse HEAD)
prompt = 'simplify widget in an unsuffixed worktree'
title = 'Unsuffixed Pre Worker'
agent = 'copilot'
model = ''
mode = 'companion'
stopWhen = 'worktree-local verification exits 0'
maxIterations = 5
maxTokens = 0
worktree = $true
currentBranch = $false
push = $false
mock = $false
verifyCommand = $verifyWorktreeCommand
verifyExitCodeBefore = 1
engineKind = 'override'
engineSource = $env:LIGHTS_OUT_NIGHTSHIFT
engineVersion = $null
startedAt = (Get-Date).ToString('o')
launchedBy = 'scout:lights-out'
workerPid = $null
}
Save-LightsOutRun -Run $preWorkerUnsuffixed
$preWorkerUnsuffixedPoll = (& $C -Id $preWorkerUnsuffixed.registryId -Json | ConvertFrom-Json)[0]
Check 'unsuffixed pre-worker launch is not orphaned immediately' ($preWorkerUnsuffixedPoll.status -eq 'launching')
Check 'unsuffixed pre-worker polling has no borrowed checkout' (-not $preWorkerUnsuffixedPoll.checkoutPath)
Check 'unsuffixed pre-worker polling has no borrowed run dir' (-not $preWorkerUnsuffixedPoll.runDir)
Check 'unsuffixed pre-worker polling has no borrowed notes' (-not $preWorkerUnsuffixedPoll.notesTail)
Check 'unsuffixed pre-worker polling has no borrowed branch result' (-not $preWorkerUnsuffixedPoll.branchExists)
Check 'unsuffixed pre-worker polling has no borrowed commits' ($preWorkerUnsuffixedPoll.commitCount -eq 0)
Check 'unsuffixed pre-worker polling has no borrowed diff' (-not $preWorkerUnsuffixedPoll.diffStat)
Show 'Stub received correct gnhf flags'
$worktreeRepo = Join-Path $sand 'worktree-repo'
New-Item -ItemType Directory -Force -Path $worktreeRepo | Out-Null
Push-Location $worktreeRepo
git init -q -b main
git config user.email 't@t.local'
git config user.name 'T'
'# worktree scratch' | Set-Content README.md
'export const widget = (x) => { if (true) { return x + 1; } };' | Set-Content index.js
git add -A
git commit -q -m 'init'
Pop-Location
$worktreeRunId = Get-LightsOutRunId -Prompt 'simplify widget in a worktree'
Push-Location $worktreeRepo
git branch "nightshift/$worktreeRunId"
Pop-Location
$outWorktree = & $copiedStart -RepoPath $worktreeRepo -Prompt 'simplify widget in a worktree' -Title 'Worktree Finalize' -StopWhen 'worktree-local verification exits 0' -VerifyCommand $verifyWorktreeCommand -MaxIterations 5 -Mode companion -Agent 'copilot' -Worktree
$resWorktree = $outWorktree | ConvertFrom-Json
Check 'worktree launch ok' ($resWorktree.ok -eq $true)
if ($resWorktree.ok -ne $true) {
Write-Host (' problems: ' + ($resWorktree.problems -join '; ')) -ForegroundColor Red
}
Check 'worktree launch output uses nightshift branch' (($resWorktree.PSObject.Properties.Name -contains 'branch') -and ($resWorktree.branch -like 'nightshift/*'))
$finalWorktree = $null
if ($resWorktree.PSObject.Properties.Name -contains 'id') {
for ($i = 0; $i -lt 45; $i++) {
Start-Sleep -Seconds 2
$j = & $C -Id $resWorktree.id -Json
if ($j) { $finalWorktree = ($j | ConvertFrom-Json)[0] }
if ($finalWorktree -and $finalWorktree.status -in @('completed', 'failed')) { break }
}
}
Check 'worktree worker finished' ($null -ne $finalWorktree)
if ($finalWorktree) {
Check 'worktree collision recorded suffixed id' ($finalWorktree.id -eq "$worktreeRunId-1")
Check 'worktree registry id exposed' ([bool]$finalWorktree.registryId)
Check 'worktree launch id preserved' ($finalWorktree.launchId -eq $worktreeRunId)
Check 'worktree branch recorded suffixed branch' ($finalWorktree.branch -eq "nightshift/$worktreeRunId-1")
Check 'worktree status completed' ($finalWorktree.status -eq 'completed')
Check 'worktree verify ran after in worktree' ($finalWorktree.verifyExitCodeAfter -eq 0)
Check 'worktree run dir resolved' ([bool]$finalWorktree.runDir)
Check 'worktree run dir uses generated worktree' ($finalWorktree.runDir -match "\\worktree-repo-nightshift-worktrees\\$worktreeRunId-1\\")
Check 'worktree notes tail captured' ([bool]$finalWorktree.notesTail)
Check 'worktree vault note captured' ([bool]$finalWorktree.vaultNote)
if ($finalWorktree.vaultNote) {
$worktreeBody = Get-Content $finalWorktree.vaultNote -Raw
Check 'worktree vault records checkout row' ($worktreeBody -match '\| Checkout \|')
Check 'worktree vault records suffixed branch' ($worktreeBody -match [regex]::Escape("| Branch | ``nightshift/$worktreeRunId-1`` |"))
Check 'worktree vault saved diff patch' (Test-Path (Join-Path (Split-Path -Parent $finalWorktree.vaultNote) 'diff.patch'))
Check 'worktree vault path includes registry id' ((Split-Path -Leaf (Split-Path -Parent $finalWorktree.vaultNote)) -match [regex]::Escape($finalWorktree.registryId.Substring(0, 8)))
}
}
Show 'TEST 6c: same-prompt pre-worker polling does not borrow old artifacts'
$preWorker = [pscustomobject]@{
registryId = [guid]::NewGuid().ToString('n')
id = $worktreeRunId
launchId = $worktreeRunId
schemaVersion = 1
status = 'launching'
repoPath = $worktreeRepo
repoName = 'worktree-repo'
branch = "nightshift/$worktreeRunId"
baseCommit = (git -C $worktreeRepo rev-parse HEAD)
prompt = 'simplify widget in a worktree'
title = 'Worktree Pre Worker'
agent = 'copilot'
model = ''
mode = 'companion'
stopWhen = 'worktree-local verification exits 0'
maxIterations = 5
maxTokens = 0
worktree = $true
currentBranch = $false
push = $false
mock = $false
verifyCommand = $verifyWorktreeCommand
verifyExitCodeBefore = 1
engineKind = 'override'
engineSource = $env:LIGHTS_OUT_NIGHTSHIFT
engineVersion = $null
startedAt = (Get-Date).ToString('o')
launchedBy = 'scout:lights-out'
workerPid = $null
}
Save-LightsOutRun -Run $preWorker
$preWorkerPoll = (& $C -Id $preWorker.registryId -Json | ConvertFrom-Json)[0]
Check 'pre-worker launch is not orphaned immediately' ($preWorkerPoll.status -eq 'launching')
Check 'pre-worker polling has no borrowed checkout' (-not $preWorkerPoll.checkoutPath)
Check 'pre-worker polling has no borrowed run dir' (-not $preWorkerPoll.runDir)
Check 'pre-worker polling has no borrowed notes' (-not $preWorkerPoll.notesTail)
Check 'pre-worker polling has no borrowed branch result' (-not $preWorkerPoll.branchExists)
Check 'pre-worker polling has no borrowed commits' ($preWorkerPoll.commitCount -eq 0)
Check 'pre-worker polling has no borrowed diff' (-not $preWorkerPoll.diffStat)
Show 'TEST 6d: same-prompt worktree relaunch preserves registry history'
$outWorktreeAgain = & $copiedStart -RepoPath $worktreeRepo -Prompt 'simplify widget in a worktree' -Title 'Worktree Finalize Again' -StopWhen 'worktree-local verification exits 0' -VerifyCommand $verifyWorktreeCommand -MaxIterations 5 -Mode companion -Agent 'copilot' -Worktree
$resWorktreeAgain = $outWorktreeAgain | ConvertFrom-Json
Check 'same-prompt relaunch ok' ($resWorktreeAgain.ok -eq $true)
$finalWorktreeAgain = $null
if ($resWorktreeAgain.PSObject.Properties.Name -contains 'id') {
for ($i = 0; $i -lt 45; $i++) {
Start-Sleep -Seconds 2
$j = & $C -Id $resWorktreeAgain.id -Json
if ($j) { $finalWorktreeAgain = ($j | ConvertFrom-Json)[0] }
if ($finalWorktreeAgain -and $finalWorktreeAgain.status -in @('completed', 'failed')) { break }
}
}
Check 'same-prompt latest worker finished' ($null -ne $finalWorktreeAgain)
if ($finalWorktreeAgain) {
Check 'same-prompt latest uses newer suffixed id' ($finalWorktreeAgain.id -match "^$([regex]::Escape($worktreeRunId))-\d+$" -and $finalWorktreeAgain.id -ne $finalWorktree.id)
Check 'same-prompt latest has distinct registry id' ($finalWorktreeAgain.registryId -and $finalWorktree.registryId -and $finalWorktreeAgain.registryId -ne $finalWorktree.registryId)
$firstCheckout = if ($finalWorktree.runDir) { Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $finalWorktree.runDir)) } else { $null }
$secondCheckout = if ($finalWorktreeAgain.runDir) { Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $finalWorktreeAgain.runDir)) } else { $null }
Check 'same-prompt first preserved worktree still exists' ($firstCheckout -and (Test-Path -LiteralPath $firstCheckout))
Check 'same-prompt second preserved worktree exists' ($secondCheckout -and (Test-Path -LiteralPath $secondCheckout))
Check 'same-prompt preserved worktrees are distinct' ($firstCheckout -and $secondCheckout -and $firstCheckout -ne $secondCheckout)
$history = @(Get-Content $statePath -Raw | ConvertFrom-Json | Where-Object { $_.launchId -eq $worktreeRunId -and $_.status -eq 'completed' })
Check 'same-prompt registry preserves both runs' ($history.Count -eq 2)
Check 'same-prompt vault preserves both notes' (@(Get-ChildItem -Recurse "$sand\vault" -Filter 'summary.md' | Where-Object { $_.FullName -like '*Worktree Finalize*' }).Count -eq 2)
$latestByLaunchId = (& $C -Id $worktreeRunId -Json | ConvertFrom-Json)[0]
Check 'same-prompt launch id resolves latest run' ($latestByLaunchId.id -eq $finalWorktreeAgain.id)
if ($finalWorktree.registryId) {
$priorByRegistryId = (& $C -Id $finalWorktree.registryId -Json | ConvertFrom-Json)[0]
Check 'same-prompt prior run remains addressable by registry id' ($priorByRegistryId.id -eq "$worktreeRunId-1")
}
}
Show 'Stub received correct nightshift flags'
$stubArgs = Get-Content "$sand\state\stub-args.json" -Raw -ErrorAction SilentlyContinue | ConvertFrom-Json
if ($stubArgs) {
Write-Host (' ' + ($stubArgs -join ' '))
Check 'passed --stop-when' ([bool]($stubArgs -contains '--stop-when'))
Check 'passed --prevent-sleep' ([bool]($stubArgs -contains '--prevent-sleep'))
Check 'passed --max-iterations' ([bool]($stubArgs -contains '--max-iterations'))
Check 'prompt is last arg' ($stubArgs[-1] -eq 'reduce complexity in the widget helper')
Check 'prompt is last arg' ($stubArgs[-1] -eq 'simplify widget in a worktree')
}
else { Check 'stub was invoked' $false }
Show 'Legacy engine field fallback'
if ($savedRun) {
$savedRun | Add-Member -NotePropertyName archivalKind -NotePropertyValue $(if ((Has-Property $savedRun 'engineKind')) { $savedRun.engineKind } else { $null }) -Force
$savedRun | Add-Member -NotePropertyName archivalSource -NotePropertyValue $(if ((Has-Property $savedRun 'engineSource')) { $savedRun.engineSource } else { $null }) -Force
$savedRun | Add-Member -NotePropertyName archivalVersion -NotePropertyValue $(if ((Has-Property $savedRun 'engineVersion')) { $savedRun.engineVersion } else { $null }) -Force
$savedRun | Add-Member -NotePropertyName archivalExitCode -NotePropertyValue $(if ((Has-Property $savedRun 'engineExitCode')) { $savedRun.engineExitCode } else { $null }) -Force
if ((Has-Property $savedRun 'engineKind')) { $savedRun.PSObject.Properties.Remove('engineKind') }
if ((Has-Property $savedRun 'engineSource')) { $savedRun.PSObject.Properties.Remove('engineSource') }
if ((Has-Property $savedRun 'engineVersion')) { $savedRun.PSObject.Properties.Remove('engineVersion') }
if ((Has-Property $savedRun 'engineExitCode')) { $savedRun.PSObject.Properties.Remove('engineExitCode') }
@($savedRuns) | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $statePath -Encoding UTF8
$legacy = (& $C -Id $res.id -Json | ConvertFrom-Json)[0]
Check 'legacy kind still projects as engineKind' ((Has-Property $legacy 'engineKind') -and ($legacy.engineKind -eq 'override'))
Check 'legacy source still projects as engineSource' ((Has-Property $legacy 'engineSource') -and [bool]$legacy.engineSource)
Check 'legacy version still projects as engineVersion' (
(Has-Property $legacy 'engineVersion') -or
($null -eq $resolvedOverride.Version)
)
Check 'legacy exit still projects as engineExitCode' ((Has-Property $legacy 'engineExitCode') -and ($legacy.engineExitCode -eq 0))
}
Show 'Legacy engine descriptor inference'
$legacyOrdered = [pscustomobject][ordered]@{
schemaVersion = 1
previousKind = 'path'
previousSource = 'C:\legacy\nightshift.cmd'
previousVersion = '0.1.49'
previousExitCode = 0
}
$legacyDescriptor = Get-RunEngineDescriptor -Run $legacyOrdered
Check 'schemaVersion does not override legacy version' ($legacyDescriptor.Version -eq '0.1.49')
Check 'legacy prefix projects exit code' ($legacyDescriptor.ExitCode -eq 0)
$preferredDescriptor = Get-RunEngineDescriptor -Run ([pscustomobject][ordered]@{
engineKind = 'vendored'
engineSource = 'C:\actual\cli.mjs'
previousKind = 'path'
previousSource = 'C:\legacy\nightshift.cmd'
previousVersion = '0.1.49'
})
Check 'engine descriptor precedence keeps actual kind' ($preferredDescriptor.Kind -eq 'vendored')
Check 'engine descriptor precedence keeps actual source' ($preferredDescriptor.Source -eq 'C:\actual\cli.mjs')
Check 'engine descriptor precedence does not backfill version from legacy' ($null -eq $preferredDescriptor.Version)
$spuriousDescriptor = Get-RunEngineDescriptor -Run ([pscustomobject][ordered]@{
schemaVersion = 1
randomVersion = '9.9.9'
verifyExitCodeBefore = 0
})
Check 'no spurious version selected from unrelated fields' ($null -eq $spuriousDescriptor.Version)
Check 'no spurious exit code selected from unrelated fields' ($null -eq $spuriousDescriptor.ExitCode)
$ambiguousMessage = $null
try {
$null = Get-RunEngineDescriptor -Run ([pscustomobject][ordered]@{
previousKind = 'path'
previousSource = 'C:\legacy\nightshift.cmd'
archivalKind = 'override'
archivalSource = 'C:\alt\nightshift.cmd'
})
}
catch {
$ambiguousMessage = $_.Exception.Message
}
Check 'ambiguous legacy descriptors rejected explicitly' ($ambiguousMessage -match 'Ambiguous')
Show 'Vault output'
Get-ChildItem -Recurse "$sand\vault" -File -ErrorAction SilentlyContinue | ForEach-Object { ' ' + $_.FullName.Replace("$sand\vault\", '') }
$note = Get-ChildItem -Recurse -Filter 'summary.md' "$sand\vault" -ErrorAction SilentlyContinue | Select-Object -First 1
@ -115,6 +507,8 @@ if ($note) {
Check 'has lights-out-run type' ($body -match 'type: lights-out-run')
Check 'has assessment stub' ($body -match '## Assessment')
Check 'records commits' ($body -match 'refactor: simplify widget helper')
Check 'records nightshift branch' ($body -match '\| Branch \| `nightshift/')
Check 'records engine row' ($body -match '\| Engine \| override')
Check 'diff.patch saved' (Test-Path (Join-Path $note.DirectoryName 'diff.patch'))
Check 'notes.md copied' (Test-Path (Join-Path $note.DirectoryName 'notes.md'))
$yamlOk = (python -c "import yaml,io,sys; t=io.open(sys.argv[1],encoding='utf-8').read(); yaml.safe_load(t.split('---')[1]); print('OK')" $note.FullName 2>&1) -join ''
@ -125,6 +519,6 @@ if ($note) {
Show "RESULT: $($script:pass) passed, $($script:fail) failed"
if ($note) { Write-Host "`n--- summary.md ---`n"; Get-Content $note.FullName }
$env:LIGHTS_OUT_GNHF = ''
$env:LIGHTS_OUT_NIGHTSHIFT = ''
Remove-Item -Recurse -Force $sand -ErrorAction SilentlyContinue
if ($script:fail -gt 0) { exit 1 }

62
lights-out/scripts/test/stub-gnhf.ps1

@ -1,62 +0,0 @@
# Fake gnhf for lights-out end-to-end testing.
# Mimics the real CLI's observable contract: creates .gnhf/runs/<runId>/ with notes.md,
# prompt.md and end-state.json, makes a real commit on a gnhf/<runId> branch, then exits.
param()
$allArgs = @($args)
New-Item -ItemType Directory -Force -Path $env:LIGHTS_OUT_ROOT | Out-Null
$allArgs | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $env:LIGHTS_OUT_ROOT 'stub-args.json') -Encoding UTF8
# The prompt is the final positional argument.
$prompt = $allArgs[-1]
function Get-StubRunId([string]$P) {
$slug = $P.ToLowerInvariant()
$slug = [regex]::Replace($slug, '[^a-z0-9]+', '-')
$slug = [regex]::Replace($slug, '^-+', '')
$slug = [regex]::Replace($slug, '-+$', '')
if ($slug.Length -gt 20) { $slug = $slug.Substring(0, 20) }
$slug = [regex]::Replace($slug, '-+$', '')
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$hex = -join ($sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($P)) | ForEach-Object { $_.ToString('x2') })
}
finally { $sha.Dispose() }
"$slug-$($hex.Substring(0,6))"
}
$runId = Get-StubRunId $prompt
$stopWhen = ''
for ($i = 0; $i -lt $allArgs.Count - 1; $i++) {
if ($allArgs[$i] -eq '--stop-when') { $stopWhen = $allArgs[$i + 1] }
}
Write-Output "stub-gnhf: starting run $runId"
$runDir = Join-Path (Get-Location) ".gnhf\runs\$runId"
New-Item -ItemType Directory -Force -Path $runDir | Out-Null
Set-Content -LiteralPath (Join-Path $runDir 'prompt.md') -Value $prompt -Encoding UTF8
$notes = "# gnhf run: $runId`n`n## Iteration Log`n`n### Iteration 1`nSimplified the widget helper. Tests still green.`n`n### Iteration 2`nRemoved a redundant branch in index.js.`n"
Set-Content -LiteralPath (Join-Path $runDir 'notes.md') -Value $notes -Encoding UTF8
# Do real git work so the wrapper's diff/commit capture is exercised for real.
git checkout -q -b "gnhf/$runId"
'export const widget = (x) => x + 1;' | Set-Content -LiteralPath 'index.js'
git add -A
git commit -q -m 'refactor: simplify widget helper'
'// tidied' | Add-Content -LiteralPath 'README.md'
git add -A
git commit -q -m 'docs: tidy readme'
@{
status = 'completed'
stopCondition = $stopWhen
agentError = $null
iterations = 2
successCount = 2
failCount = 0
endedAt = (Get-Date).ToString('o')
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $runDir 'end-state.json') -Encoding UTF8
Write-Output "stub-gnhf: done"
exit 0

94
lights-out/scripts/test/stub-nightshift.ps1

@ -0,0 +1,94 @@
# Fake nightshift for lights-out end-to-end testing.
# Mimics the real CLI's observable contract: creates .nightshift\runs\<runId>\ with notes.md,
# prompt.md, nightshift.log, and end-state.json, makes a real commit on a nightshift/<runId>
# branch, then exits.
param()
$allArgs = @($args)
New-Item -ItemType Directory -Force -Path $env:LIGHTS_OUT_ROOT | Out-Null
$allArgs | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $env:LIGHTS_OUT_ROOT 'stub-args.json') -Encoding UTF8
# The prompt is the final positional argument.
$prompt = $allArgs[-1]
function Get-StubRunId([string]$P) {
$slug = $P.ToLowerInvariant()
$slug = [regex]::Replace($slug, '[^a-z0-9]+', '-')
$slug = [regex]::Replace($slug, '^-+', '')
$slug = [regex]::Replace($slug, '-+$', '')
if ($slug.Length -gt 20) { $slug = $slug.Substring(0, 20) }
$slug = [regex]::Replace($slug, '-+$', '')
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$hex = -join ($sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($P)) | ForEach-Object { $_.ToString('x2') })
}
finally { $sha.Dispose() }
"$slug-$($hex.Substring(0,6))"
}
$runId = Get-StubRunId $prompt
$stopWhen = ''
$useWorktree = $false
for ($i = 0; $i -lt $allArgs.Count - 1; $i++) {
if ($allArgs[$i] -eq '--stop-when') { $stopWhen = $allArgs[$i + 1] }
if ($allArgs[$i] -eq '--worktree') { $useWorktree = $true }
}
Write-Output "stub-nightshift: starting run $runId"
$effectiveRoot = (Get-Location).Path
if ($useWorktree) {
$repoRoot = (git rev-parse --show-toplevel)
$repoName = Split-Path -Leaf $repoRoot
$repoParent = Split-Path -Parent $repoRoot
for ($suffix = 0; $suffix -lt 100; $suffix++) {
$actualRunId = if ($suffix -eq 0) { $runId } else { "$runId-$suffix" }
$worktreePath = Join-Path $repoParent "$repoName-nightshift-worktrees\$actualRunId"
git worktree add -q -b "nightshift/$actualRunId" $worktreePath HEAD 2>$null
if ($LASTEXITCODE -eq 0) {
$runId = $actualRunId
break
}
}
Set-Location -LiteralPath $worktreePath
$effectiveRoot = $worktreePath
}
$runDir = Join-Path (Get-Location) ".nightshift\runs\$runId"
New-Item -ItemType Directory -Force -Path $runDir | Out-Null
Set-Content -LiteralPath (Join-Path $runDir 'prompt.md') -Value $prompt -Encoding UTF8
$notes = "# nightshift run: $runId`n`n## Iteration Log`n`n### Iteration 1`nSimplified the widget helper. Tests still green.`n`n### Iteration 2`nRemoved a redundant branch in index.js.`n"
Set-Content -LiteralPath (Join-Path $runDir 'notes.md') -Value $notes -Encoding UTF8
'{"level":"info","message":"stub engine log"}' | Set-Content -LiteralPath (Join-Path $runDir 'nightshift.log') -Encoding UTF8
if (-not $useWorktree) {
for ($suffix = 0; $suffix -lt 100; $suffix++) {
$actualRunId = if ($suffix -eq 0) { $runId } else { "$runId-$suffix" }
git checkout -q -b "nightshift/$actualRunId" 2>$null
if ($LASTEXITCODE -eq 0) {
$runId = $actualRunId
break
}
}
}
'export const widget = (x) => x + 1;' | Set-Content -LiteralPath 'index.js'
git add -A
git commit -q -m 'refactor: simplify widget helper'
'// tidied' | Add-Content -LiteralPath 'README.md'
git add -A
git commit -q -m 'docs: tidy readme'
@{
status = 'completed'
stopCondition = $stopWhen
agentError = $null
iterations = 2
successCount = 2
failCount = 0
endedAt = (Get-Date).ToString('o')
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $runDir 'end-state.json') -Encoding UTF8
Write-Output " debug log $(Join-Path $runDir 'nightshift.log')"
if ($useWorktree) { Write-Output "nightshift: worktree preserved at $effectiveRoot" }
Write-Output "stub-nightshift: done"
exit 0

32
lights-out/vendor/package-lock.json

@ -5,7 +5,7 @@
"packages": {
"": {
"dependencies": {
"gnhf": "^0.1.49"
"nightshift": "file:D:/OneDrive - Microsoft/HERE/nightshift-0.1.51-lights-out.tgz"
}
},
"node_modules/argparse": {
@ -23,21 +23,6 @@
"node": ">=20"
}
},
"node_modules/gnhf": {
"version": "0.1.49",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/gnhf/-/gnhf-0.1.49.tgz",
"integrity": "sha1-CoIedo1xq25xfWsisfEumPczp8U=",
"dependencies": {
"commander": "^14.0.3",
"js-yaml": "^4.1.1"
},
"bin": {
"gnhf": "dist/cli.mjs"
},
"engines": {
"node": ">=20"
}
},
"node_modules/js-yaml": {
"version": "4.3.2",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/js-yaml/-/js-yaml-4.3.2.tgz",
@ -59,6 +44,21 @@
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/nightshift": {
"version": "0.1.51-lights-out",
"resolved": "file:D:/OneDrive - Microsoft/HERE/nightshift-0.1.51-lights-out.tgz",
"integrity": "sha512-wwNr44uf3+2tY3mflHVa3euyK63p2BzLErfnmXf2cokfMlH+B3Om7BfFGapvPnnjAr/+Za/AMpUK8zwrWgcimw==",
"dependencies": {
"commander": "^14.0.3",
"js-yaml": "^4.1.1"
},
"bin": {
"nightshift": "dist/cli.mjs"
},
"engines": {
"node": ">=20"
}
}
}
}

2
lights-out/vendor/package.json

@ -1,5 +1,5 @@
{
"dependencies": {
"gnhf": "^0.1.49"
"nightshift": "file:D:/OneDrive - Microsoft/HERE/nightshift-0.1.51-lights-out.tgz"
}
}

Loading…
Cancel
Save