From 3f05e4e8ff2b46310a24511cd5dba209e2361472 Mon Sep 17 00:00:00 2001 From: Daniel Kucinski Date: Sun, 20 Sep 2026 10:00:16 -0700 Subject: [PATCH] chore: sync Scout skills 2026-09-20 --- README.md | 2 +- lights-out/SKILL.md | 169 +++-- lights-out/references/cli.md | 91 +-- lights-out/references/prompts.md | 7 +- lights-out/scripts/check-run.ps1 | 115 ++-- lights-out/scripts/lib.ps1 | 651 ++++++++++++++------ lights-out/scripts/start-run.ps1 | 169 ++--- lights-out/scripts/test/e2e.ps1 | 470 ++++++++++++-- lights-out/scripts/test/stub-gnhf.ps1 | 62 -- lights-out/scripts/test/stub-nightshift.ps1 | 94 +++ lights-out/vendor/package-lock.json | 32 +- lights-out/vendor/package.json | 2 +- 12 files changed, 1340 insertions(+), 524 deletions(-) delete mode 100644 lights-out/scripts/test/stub-gnhf.ps1 create mode 100644 lights-out/scripts/test/stub-nightshift.ps1 diff --git a/README.md b/README.md index f7f2785..21a1ed4 100644 --- a/README.md +++ b/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 \", \"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. | diff --git a/lights-out/SKILL.md b/lights-out/SKILL.md index 429d302..efc0fed 100644 --- a/lights-out/SKILL.md +++ b/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:`. - **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 — ", - schedule: "every day at 7am", // the next morning + name: "Lights Out ping — ", + triggerType: "condition", + conditionCheckInterval: 15, oneShot: true, teamsNotify: "always", - prompt: "Run the lights-out Morning Review for run : execute - $env:USERPROFILE\\.scout\\m-skills\\lights-out\\scripts\\check-run.ps1 -Id , - assess the result, write the assessment back into the vault note, and send a short digest." + condition: "The lights-out run with id has finished — i.e. check-run.ps1 -Id + reports a status other than \"running\" (completed, failed, or orphaned).", + prompt: "Run the lights-out Morning Review for run : execute check-run.ps1 -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. diff --git a/lights-out/references/cli.md b/lights-out/references/cli.md index e301c68..d6c3c9a 100644 --- a/lights-out/references/cli.md +++ b/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\\` 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 ` | Abort after this much cumulative usage-limit wait (`30m`, `2h`, `0`) | | `--fallback-model ` | Retry a Claude usage-limit rejection with this model instead of waiting | | `--stop-when ` | Natural-language completion condition; survives resume. `""` clears it | -| `--prevent-sleep ` | Keep the machine awake. Always `on` for overnight runs | +| `--prevent-sleep ` | 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/` | +| `--current-branch` | Commit on the current branch instead of creating `nightshift/` | | `--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 = "-" -branch = "gnhf/" +branch = "nightshift/" ``` -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 `\.gnhf\runs\\` (gitignored by gnhf, so it never pollutes the branch): +Per run, in `\.nightshift\runs\\`: | 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-.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: (`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. diff --git a/lights-out/references/prompts.md b/lights-out/references/prompts.md index eb68e6f..0904e7d 100644 --- a/lights-out/references/prompts.md +++ b/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) diff --git a/lights-out/scripts/check-run.ps1 b/lights-out/scripts/check-run.ps1 index afec34f..66efcb3 100644 --- a/lights-out/scripts/check-run.ps1 +++ b/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) { - $effectiveStatus = 'orphaned' + $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 '' diff --git a/lights-out/scripts/lib.ps1 b/lights-out/scripts/lib.ps1 index 2bacf0a..8b57284 100644 --- a/lights-out/scripts/lib.ps1 +++ b/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/-", runId = branch after the slash. + branch = "nightshift/-", 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 Resolve-GnhfCommand { +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 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, '^(?[A-Za-z]+)(?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 \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 \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 } + } - $exact = Join-Path $runsRoot $RunId - if (Test-Path -LiteralPath $exact) { return $exact } + 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 } + } - 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 + $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, '(?[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 } diff --git a/lights-out/scripts/start-run.ps1 b/lights-out/scripts/start-run.ps1 index 46899db..9e6916c 100644 --- a/lights-out/scripts/start-run.ps1 +++ b/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,24 +122,47 @@ 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 } - # 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" + 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 { - $after = Invoke-VerifyCommand -Command $run.verifyCommand -WorkingDir $run.repoPath -LogPath $vLog + $actualBranch = (git -C $checkoutPath rev-parse --abbrev-ref HEAD 2>$null) + if ($actualBranch) { $run | Add-Member -NotePropertyName branch -NotePropertyValue $actualBranch -Force } + } + catch { } + } + + if ($run.PSObject.Properties['verifyCommand'] -and $run.verifyCommand) { + $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 $verifyDir -LogPath $verifyLog $run | Add-Member -NotePropertyName verifyExitCodeAfter -NotePropertyValue $after -Force + } + catch { + $run | Add-Member -NotePropertyName verifyExitCodeAfter -NotePropertyValue 'error' -Force + } } - catch { - $run | Add-Member -NotePropertyName verifyExitCodeAfter -NotePropertyValue 'error' -Force + else { + $run | Add-Member -NotePropertyName verifyExitCodeAfter -NotePropertyValue 'skipped-unresolved-checkout' -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 diff --git a/lights-out/scripts/test/e2e.ps1 b/lights-out/scripts/test/e2e.ps1 index 240cd27..7223c72 100644 --- a/lights-out/scripts/test/e2e.ps1 +++ b/lights-out/scripts/test/e2e.ps1 @@ -1,21 +1,61 @@ $ErrorActionPreference = 'Continue' # Self-contained regression suite for the lights-out skill. # pwsh -NoProfile -File \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\\\\') +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 'Stub received correct gnhf flags' +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) + +$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 } diff --git a/lights-out/scripts/test/stub-gnhf.ps1 b/lights-out/scripts/test/stub-gnhf.ps1 deleted file mode 100644 index 3262650..0000000 --- a/lights-out/scripts/test/stub-gnhf.ps1 +++ /dev/null @@ -1,62 +0,0 @@ -# Fake gnhf for lights-out end-to-end testing. -# Mimics the real CLI's observable contract: creates .gnhf/runs// with notes.md, -# prompt.md and end-state.json, makes a real commit on a gnhf/ 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 diff --git a/lights-out/scripts/test/stub-nightshift.ps1 b/lights-out/scripts/test/stub-nightshift.ps1 new file mode 100644 index 0000000..684f82f --- /dev/null +++ b/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\\ with notes.md, +# prompt.md, nightshift.log, and end-state.json, makes a real commit on a nightshift/ +# 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 diff --git a/lights-out/vendor/package-lock.json b/lights-out/vendor/package-lock.json index ddb5859..d4fe6c3 100644 --- a/lights-out/vendor/package-lock.json +++ b/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" + } } } } diff --git a/lights-out/vendor/package.json b/lights-out/vendor/package.json index 01c2078..62934c9 100644 --- a/lights-out/vendor/package.json +++ b/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" } }