13 changed files with 1592 additions and 2 deletions
@ -0,0 +1,207 @@ |
|||||
|
--- |
||||
|
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. |
||||
|
--- |
||||
|
|
||||
|
# 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**. |
||||
|
|
||||
|
Core rule: **you orchestrate, gnhf implements.** Do not hand-edit files inside a scope a live gnhf |
||||
|
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 |
||||
|
|
||||
|
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. |
||||
|
|
||||
|
| Tier | Runs as | Guaranteed | Responsibility | |
||||
|
|---|---|---|---| |
||||
|
| 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 | |
||||
|
|
||||
|
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. |
||||
|
|
||||
|
```powershell |
||||
|
$LO = "$env:USERPROFILE\.scout\m-skills\lights-out\scripts" |
||||
|
``` |
||||
|
|
||||
|
| Script | Use | |
||||
|
|---|---| |
||||
|
| `$LO\start-run.ps1` | Preflight, launch detached, register the run | |
||||
|
| `$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. |
||||
|
|
||||
|
## gnhf is vendored |
||||
|
|
||||
|
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 |
||||
|
|
||||
|
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. |
||||
|
|
||||
|
To update the pinned copy: |
||||
|
|
||||
|
```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. |
||||
|
|
||||
|
## Launch |
||||
|
|
||||
|
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. |
||||
|
- **Verification command** — how success is checked. Must actually exist on this machine. |
||||
|
- **Agent** — default `copilot`. Others: `claude`, `codex`, `cursor`, `opencode`, `rovodev`, `pi`, |
||||
|
`acp:<target>`. |
||||
|
- **Mode** — Hands-Off or Companion (below). |
||||
|
|
||||
|
```powershell |
||||
|
& "$LO\start-run.ps1" ` |
||||
|
-RepoPath 'D:\Repos\myapp' ` |
||||
|
-Prompt '<worker prompt — see references\prompts.md>' ` |
||||
|
-Title 'Short Title For The Vault Folder' ` |
||||
|
-StopWhen '<observable condition>' ` |
||||
|
-VerifyCommand 'npm test' ` |
||||
|
-Agent copilot ` |
||||
|
-MaxIterations 40 ` |
||||
|
-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: |
||||
|
|
||||
|
| 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 | |
||||
|
| Agent CLI missing | Run dies on iteration 1 at 2am | |
||||
|
| Verify command not on PATH | The classic: docs say `pnpm`, machine only has `npm` | |
||||
|
|
||||
|
Pass `-Worktree` to run alongside other work, `-CurrentBranch` to commit on the current branch, and |
||||
|
`-Push` to push after each successful iteration. |
||||
|
|
||||
|
## Modes |
||||
|
|
||||
|
**Hands-Off** — bounded task, clear verification, user is asleep. Launch, report the run id, stop. |
||||
|
Intervene only for hard failure or destructive behavior. |
||||
|
|
||||
|
**Companion** — uncertain, exploratory, or design-heavy work; the user is around and wants steering. |
||||
|
Default to Companion when the user asks to iterate until satisfied, supplies review findings, or |
||||
|
asks for supervision. Poll with `check-run.ps1` between iterations. |
||||
|
|
||||
|
## Steer |
||||
|
|
||||
|
In Companion mode, judge each poll: |
||||
|
|
||||
|
| Signal | Action | |
||||
|
|---|---| |
||||
|
| Real blocker found | Stop; relaunch with blocker-specific instructions | |
||||
|
| Good partial slice | Let it run, or tighten the next stop condition | |
||||
|
| Skipped requested research | Relaunch with research as the explicit first deliverable | |
||||
|
| Touching unrelated files | Stop and review before continuing | |
||||
|
| Claims success, verification disagrees | Review now; relaunch only with an evidence-based stop condition | |
||||
|
| User says not good enough | Relaunch with that finding as the sole bounded correction | |
||||
|
|
||||
|
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: |
||||
|
|
||||
|
```powershell |
||||
|
& "$LO\check-run.ps1" -Since 2 |
||||
|
``` |
||||
|
|
||||
|
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**. |
||||
|
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. |
||||
|
|
||||
|
## Writing the assessment back |
||||
|
|
||||
|
The wrapper leaves `## Assessment` as a stub. Complete it: |
||||
|
|
||||
|
1. Open the `vaultNote` path from `check-run.ps1`. |
||||
|
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). |
||||
|
|
||||
|
## Findings |
||||
|
|
||||
|
When the user says "that's 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 |
||||
|
|
||||
|
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: |
||||
|
|
||||
|
``` |
||||
|
m_create_automation( |
||||
|
name: "Lights Out digest — <run id>", |
||||
|
schedule: "every day at 7am", // the next morning |
||||
|
oneShot: true, |
||||
|
teamsNotify: "always", |
||||
|
prompt: "Run the lights-out Morning Review for run <run id>: execute |
||||
|
$env:USERPROFILE\\.scout\\m-skills\\lights-out\\scripts\\check-run.ps1 -Id <run id>, |
||||
|
assess the result, write the assessment back into the vault note, and send a short digest." |
||||
|
) |
||||
|
``` |
||||
|
|
||||
|
The digest is a convenience. The artifacts land on disk with or without it. |
||||
|
|
||||
|
## Safety |
||||
|
|
||||
|
- Never run destructive git commands to tidy a gnhf 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. |
||||
|
- A run left on `status: running` with a dead worker is `orphaned`, not successful. Say so. |
||||
|
- Stop conditions must be observable. Rewrite vague ones before launching. |
||||
|
|
||||
|
## 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). |
||||
@ -0,0 +1,100 @@ |
|||||
|
# gnhf 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**. |
||||
|
|
||||
|
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. |
||||
|
|
||||
|
## Install |
||||
|
|
||||
|
The skill **vendors its own copy** — you should not need a global install: |
||||
|
|
||||
|
```powershell |
||||
|
npm install gnhf --prefix "$env:USERPROFILE\.scout\m-skills\lights-out\vendor" |
||||
|
``` |
||||
|
|
||||
|
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). |
||||
|
|
||||
|
Resolution order is `$env:LIGHTS_OUT_GNHF` → vendored → `gnhf` 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`. |
||||
|
|
||||
|
A global `npm install -g gnhf` still works as a fallback, but nothing in the skill requires it. |
||||
|
|
||||
|
## Flags |
||||
|
|
||||
|
| Flag | Notes | |
||||
|
|---|---| |
||||
|
| `--agent <agent>` | `claude`, `codex`, `rovodev`, `opencode`, `copilot`, `pi`, `cursor`, or `acp:<target-or-command>` | |
||||
|
| `--model <model>` | Overrides `agentModel.<agent>` from config | |
||||
|
| `--max-iterations <n>` | Abort after N iterations | |
||||
|
| `--max-tokens <n>` | Abort after N total input+output+cache tokens | |
||||
|
| `--max-rate-limit-wait <dur>` | Abort after this much cumulative usage-limit wait (`30m`, `2h`, `0`) | |
||||
|
| `--fallback-model <model>` | Retry a Claude usage-limit rejection with this model instead of waiting | |
||||
|
| `--stop-when <condition>` | Natural-language completion condition; survives resume. `""` clears it | |
||||
|
| `--prevent-sleep <on\|off>` | Keep the machine awake. Always `on` for overnight runs | |
||||
|
| `--worktree` | Run in a separate git worktree, enabling parallel runs on one repo | |
||||
|
| `--current-branch` | Commit on the current branch instead of creating `gnhf/<runId>` | |
||||
|
| `--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 | |
||||
|
|
||||
|
## Run identity |
||||
|
|
||||
|
Deterministic, derived from the prompt — so a run is findable months later without guessing: |
||||
|
|
||||
|
``` |
||||
|
slug = prompt.toLowerCase() |
||||
|
.replace(/[^a-z0-9]+/g,'-') |
||||
|
.replace(/^-+|-+$/g,'') |
||||
|
.slice(0,20) |
||||
|
.replace(/-+$/,'') |
||||
|
hash = sha256(prompt).hex.slice(0,6) |
||||
|
runId = "<slug>-<hash>" |
||||
|
branch = "gnhf/<runId>" |
||||
|
``` |
||||
|
|
||||
|
On collision gnhf appends `-1`, `-2`, …, so the predicted id may not be the final one. |
||||
|
`Resolve-LightsOutRunDir` in `scripts\lib.ps1` handles that. |
||||
|
|
||||
|
## Artifacts |
||||
|
|
||||
|
Per run, in `<repo>\.gnhf\runs\<runId>\` (gitignored by gnhf, so it never pollutes the branch): |
||||
|
|
||||
|
| File | Contents | |
||||
|
|---|---| |
||||
|
| `prompt.md` | The objective as given | |
||||
|
| `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 | |
||||
|
| `iteration-<n>.jsonl` | Raw agent stream per iteration. Large; never copied to the vault | |
||||
|
| `base-commit` | Commit the run branched from | |
||||
|
| `stop-when` | Persisted stop condition | |
||||
|
|
||||
|
## Behaviors that break unattended runs |
||||
|
|
||||
|
Both are enforced by the wrapper's preflight; know them when diagnosing a failure. |
||||
|
|
||||
|
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 |
||||
|
forever. |
||||
|
|
||||
|
Also: gnhf requires a clean working tree, never force-pushes, never auto-pulls, and commits each |
||||
|
successful iteration separately so a night can be reviewed or reverted one change at a time. |
||||
|
|
||||
|
## Docs |
||||
|
|
||||
|
Upstream project: <https://github.com/kunchenguid/gnhf> (`README.md`, `VISION.md`). |
||||
|
|
||||
|
The vendored package also ships its own README locally — no network needed: |
||||
|
`vendor\node_modules\gnhf\README.md`. |
||||
|
|
||||
|
This skill does **not** depend on a local clone of the gnhf source repo. |
||||
@ -0,0 +1,103 @@ |
|||||
|
# 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. |
||||
|
|
||||
|
## Worker prompt (new run) |
||||
|
|
||||
|
```text |
||||
|
Objective: <one concrete outcome>. |
||||
|
|
||||
|
Work in this repository. Treat this as a long-running unattended task — nobody is watching, so |
||||
|
never wait for confirmation and never leave the tree in a broken state between commits. |
||||
|
|
||||
|
Before changing anything, read the relevant code, docs, and recent commits to understand current |
||||
|
behavior. Preserve existing user work. Do not refactor anything outside <scope>. |
||||
|
|
||||
|
Non-goals: <what must not change — files, APIs, behavior, dependencies>. |
||||
|
|
||||
|
After each meaningful slice, run: <verification command>. If it fails, fix it before moving on. |
||||
|
|
||||
|
If you hit a genuine blocker, stop and write the blocker plus the evidence into your notes. Never |
||||
|
fabricate success, never disable or skip a test to make something pass, and never commit a change |
||||
|
you have not verified. |
||||
|
|
||||
|
Stop only when: <observable completion condition>. |
||||
|
``` |
||||
|
|
||||
|
### Writing the scope and non-goals |
||||
|
|
||||
|
Vague scope is the main cause of a morning spent reading an unwanted refactor. Be concrete: |
||||
|
|
||||
|
- Good scope: "only files under `src/utils/`" |
||||
|
- Good non-goal: "do not change any public export signature; do not add dependencies" |
||||
|
- Bad: "don't break anything" |
||||
|
|
||||
|
### Writing the stop condition |
||||
|
|
||||
|
It must be checkable by looking at the repo, not by asking an opinion. |
||||
|
|
||||
|
| Bad | Good | |
||||
|
|---|---| |
||||
|
| the code is cleaner | `npm test` exits 0 and no file outside `src/utils/` is modified | |
||||
|
| tests look fine | the full suite passes with zero skipped tests | |
||||
|
| done with the refactor | `npm run typecheck` and `npm test` both exit 0, and `git diff --stat` shows no change to `package.json` | |
||||
|
|
||||
|
## Steering prompt (continue after a partial success) |
||||
|
|
||||
|
```text |
||||
|
Continue from the current repository state. A previous run partially succeeded: <specific evidence |
||||
|
— commits, files, what now works>. |
||||
|
|
||||
|
Do not redo that completed work and do not revisit <areas already settled>. |
||||
|
|
||||
|
Focus only on: <single bounded correction>. |
||||
|
|
||||
|
The specific problem to fix is: <observed issue, with file and line where known>. |
||||
|
|
||||
|
Verify with: <commands or checks>. |
||||
|
|
||||
|
Stop only when: <observable condition for this correction alone>. |
||||
|
``` |
||||
|
|
||||
|
## Findings prompt (user rejected the result) |
||||
|
|
||||
|
One finding per run. Bundling corrections is how scope drift restarts. |
||||
|
|
||||
|
```text |
||||
|
Continue from the current repository state on this branch. |
||||
|
|
||||
|
A review found this blocking issue: <finding, in the user's own words, with severity and file/line |
||||
|
scope preserved>. |
||||
|
|
||||
|
Keep all other completed work intact — do not revert or rewrite anything unrelated to this finding. |
||||
|
|
||||
|
Fix only that issue. Verify with: <commands>. |
||||
|
|
||||
|
Stop only when: <the finding is demonstrably no longer true>. |
||||
|
``` |
||||
|
|
||||
|
## Research-first prompt (exploratory work) |
||||
|
|
||||
|
Use when the objective is not yet well understood. Forces investigation before edits. |
||||
|
|
||||
|
```text |
||||
|
Objective: <outcome>. |
||||
|
|
||||
|
First deliverable, before any code change: investigate <question> and write your findings into your |
||||
|
notes — what you examined, what you concluded, and which approach you chose with the reason. |
||||
|
|
||||
|
Only after recording that, implement the chosen approach in <scope>. |
||||
|
|
||||
|
Verify with: <command>. Non-goals: <...>. |
||||
|
|
||||
|
Stop only when: <observable condition>. |
||||
|
``` |
||||
|
|
||||
|
## Anti-patterns |
||||
|
|
||||
|
- **No verification command.** The agent grades its own homework and always passes. |
||||
|
- **Unbounded scope.** "Improve the codebase" produces a diff nobody wants to review. |
||||
|
- **Opinion stop conditions.** The run either never stops or stops arbitrarily. |
||||
|
- **Multiple objectives in one prompt.** Split them into separate runs, or use `-Worktree`. |
||||
|
- **Omitting non-goals.** Agents reformat, upgrade dependencies, and rename things when unconstrained. |
||||
@ -0,0 +1,161 @@ |
|||||
|
<# |
||||
|
.SYNOPSIS |
||||
|
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 |
||||
|
that has no memory of the conversation that launched the run. |
||||
|
.EXAMPLE |
||||
|
.\check-run.ps1 -Latest |
||||
|
.EXAMPLE |
||||
|
.\check-run.ps1 -Since 1 -Json |
||||
|
#> |
||||
|
[CmdletBinding()] |
||||
|
param( |
||||
|
[string]$Id, |
||||
|
[switch]$Latest, |
||||
|
[switch]$Active, |
||||
|
[int]$Since = 0, |
||||
|
[int]$NotesTail = 40, |
||||
|
[switch]$Json |
||||
|
) |
||||
|
|
||||
|
Set-StrictMode -Version Latest |
||||
|
$ErrorActionPreference = 'Stop' |
||||
|
|
||||
|
. (Join-Path $PSScriptRoot 'lib.ps1') |
||||
|
|
||||
|
$runs = @(Read-LightsOutRuns) |
||||
|
if ($runs.Count -eq 0) { |
||||
|
if ($Json) { '[]' } else { Write-Host 'No lights-out runs have ever been registered.' } |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
$runs = $runs | Sort-Object { [datetime]::Parse($_.startedAt) } -Descending |
||||
|
|
||||
|
if ($Id) { $runs = @($runs | Where-Object { $_.id -eq $Id }) } |
||||
|
if ($Active) { $runs = @($runs | Where-Object { $_.status -in @('running', 'launching', 'finalizing') }) } |
||||
|
if ($Since -gt 0) { |
||||
|
$cutoff = (Get-Date).AddDays(-$Since) |
||||
|
$runs = @($runs | Where-Object { [datetime]::Parse($_.startedAt) -ge $cutoff }) |
||||
|
} |
||||
|
if ($Latest) { $runs = @($runs | Select-Object -First 1) } |
||||
|
|
||||
|
$results = foreach ($run in $runs) { |
||||
|
$alive = $false |
||||
|
if ($run.PSObject.Properties['workerPid'] -and $run.workerPid) { |
||||
|
$alive = Test-LightsOutProcessAlive -ProcessId ([int]$run.workerPid) -StartedAtIso $run.startedAt |
||||
|
} |
||||
|
|
||||
|
$runDir = $null |
||||
|
if (Test-Path -LiteralPath $run.repoPath) { |
||||
|
$runDir = Resolve-LightsOutRunDir -RepoPath $run.repoPath -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 { } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
$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) |
||||
|
$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 ' ' |
||||
|
} |
||||
|
|
||||
|
$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" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
$elapsed = $null |
||||
|
try { |
||||
|
$end = if ($run.PSObject.Properties['endedAt'] -and $run.endedAt) { [datetime]::Parse($run.endedAt) } else { Get-Date } |
||||
|
$elapsed = '{0:h\hm\m}' -f ($end - [datetime]::Parse($run.startedAt)) |
||||
|
} |
||||
|
catch { } |
||||
|
|
||||
|
$vaultNote = $null |
||||
|
if ($run.PSObject.Properties['vaultNote'] -and $run.vaultNote) { $vaultNote = $run.vaultNote } |
||||
|
else { |
||||
|
$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' |
||||
|
} |
||||
|
|
||||
|
[pscustomobject]@{ |
||||
|
id = $run.id |
||||
|
status = $effectiveStatus |
||||
|
recordedStatus = $run.status |
||||
|
workerAlive = $alive |
||||
|
workerPid = if ($run.PSObject.Properties['workerPid']) { $run.workerPid } else { $null } |
||||
|
mode = $run.mode |
||||
|
agent = $run.agent |
||||
|
repoPath = $run.repoPath |
||||
|
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 } |
||||
|
commitCount = $commits.Count |
||||
|
commits = $commits |
||||
|
diffStat = $diffStat |
||||
|
stopWhen = if ($run.PSObject.Properties['stopWhen']) { $run.stopWhen } else { $null } |
||||
|
endState = $endState |
||||
|
verifyCommand = if ($run.PSObject.Properties['verifyCommand']) { $run.verifyCommand } else { $null } |
||||
|
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 } |
||||
|
vaultNote = $vaultNote |
||||
|
notesTail = $notesTailText |
||||
|
prompt = $run.prompt |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
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 } |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
foreach ($r 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 " --- notes tail ---" |
||||
|
$r.notesTail -split "`n" | ForEach-Object { Write-Host " $_" } |
||||
|
} |
||||
|
} |
||||
|
Write-Host '' |
||||
@ -0,0 +1,447 @@ |
|||||
|
<# |
||||
|
.SYNOPSIS |
||||
|
Shared helpers for the lights-out skill. |
||||
|
.DESCRIPTION |
||||
|
Dot-source this file; it defines functions only and performs no side effects on load. |
||||
|
Everything here must keep working with no console attached, because the finalize phase |
||||
|
runs inside a detached process long after the launching Scout session is gone. |
||||
|
#> |
||||
|
|
||||
|
Set-StrictMode -Version Latest |
||||
|
|
||||
|
$script:LightsOutRoot = if ($env:LIGHTS_OUT_ROOT) { $env:LIGHTS_OUT_ROOT } else { Join-Path $env:USERPROFILE '.scout\lights-out' } |
||||
|
$script:LightsOutStatePath = Join-Path $script:LightsOutRoot 'runs.json' |
||||
|
$script:LightsOutVaultDir = if ($env:LIGHTS_OUT_VAULT_DIR) { $env:LIGHTS_OUT_VAULT_DIR } else { 'D:\Repos\Obsidian\07 - Lights Out' } |
||||
|
|
||||
|
function Get-LightsOutPaths { |
||||
|
[CmdletBinding()] |
||||
|
param() |
||||
|
[pscustomobject]@{ |
||||
|
Root = $script:LightsOutRoot |
||||
|
StatePath = $script:LightsOutStatePath |
||||
|
VaultDir = $script:LightsOutVaultDir |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
<# |
||||
|
Mirrors gnhf's slugifyPrompt() exactly (src/utils/slugify.ts): |
||||
|
slug = prompt.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,20).replace(/-+$/,'') |
||||
|
hash = sha256(prompt).hex.slice(0,6) |
||||
|
branch = "gnhf/<slug>-<hash>", runId = branch after the slash. |
||||
|
Deriving this rather than guessing "newest folder" is what makes a run findable months later. |
||||
|
#> |
||||
|
function Get-LightsOutRunId { |
||||
|
[CmdletBinding()] |
||||
|
param([Parameter(Mandatory)][string]$Prompt) |
||||
|
|
||||
|
$slug = $Prompt.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 { |
||||
|
$bytes = [System.Text.Encoding]::UTF8.GetBytes($Prompt) |
||||
|
$hex = -join ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) |
||||
|
} |
||||
|
finally { $sha.Dispose() } |
||||
|
|
||||
|
"$slug-$($hex.Substring(0,6))" |
||||
|
} |
||||
|
|
||||
|
function Get-LightsOutSafeName { |
||||
|
[CmdletBinding()] |
||||
|
param([Parameter(Mandatory)][AllowEmptyString()][string]$Text, |
||||
|
[int]$MaxLength = 60) |
||||
|
|
||||
|
$clean = [regex]::Replace($Text, '[\\/:*?"<>|]', ' ') |
||||
|
$clean = [regex]::Replace($clean, '\s+', ' ').Trim() |
||||
|
if ($clean.Length -gt $MaxLength) { $clean = $clean.Substring(0, $MaxLength).Trim() } |
||||
|
if ([string]::IsNullOrWhiteSpace($clean)) { $clean = 'run' } |
||||
|
$clean |
||||
|
} |
||||
|
|
||||
|
function Get-LightsOutKebab { |
||||
|
[CmdletBinding()] |
||||
|
param([Parameter(Mandatory)][string]$Text) |
||||
|
$k = [regex]::Replace($Text.ToLowerInvariant(), '[^a-z0-9]+', '-') |
||||
|
[regex]::Replace($k, '^-+|-+$', '') |
||||
|
} |
||||
|
|
||||
|
function Resolve-GnhfBinary { |
||||
|
<# |
||||
|
Back-compat shim: returns just the executable path. Prefer Resolve-GnhfCommand. |
||||
|
#> |
||||
|
[CmdletBinding()] |
||||
|
param() |
||||
|
(Resolve-GnhfCommand).File |
||||
|
} |
||||
|
|
||||
|
function Resolve-GnhfCommand { |
||||
|
<# |
||||
|
Resolve how to invoke gnhf, in priority order: |
||||
|
1. $env:LIGHTS_OUT_GNHF -- explicit override (used by the test suite) |
||||
|
2. the vendored copy in <skill>\vendor -- pinned, survives `npm uninstall -g gnhf` |
||||
|
3. `gnhf` on PATH -- whatever global install exists |
||||
|
|
||||
|
Returns a descriptor: Kind, File (executable), Prefix (args to prepend), Source, Version. |
||||
|
Invoke as: & $cmd.File @($cmd.Prefix) @userArgs |
||||
|
#> |
||||
|
[CmdletBinding()] |
||||
|
param() |
||||
|
|
||||
|
$skillRoot = Split-Path -Parent $PSScriptRoot |
||||
|
|
||||
|
if ($env:LIGHTS_OUT_GNHF) { |
||||
|
$ovr = Get-Command $env:LIGHTS_OUT_GNHF -ErrorAction SilentlyContinue |
||||
|
if ($ovr) { |
||||
|
return [pscustomobject]@{ |
||||
|
Kind = 'override'; File = $ovr.Source; Prefix = @() |
||||
|
Source = $ovr.Source; Version = $null |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
$vendored = Join-Path $skillRoot 'vendor\node_modules\gnhf\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' |
||||
|
if (Test-Path -LiteralPath $pkg) { |
||||
|
try { $ver = (Get-Content -LiteralPath $pkg -Raw | ConvertFrom-Json).version } catch { } |
||||
|
} |
||||
|
return [pscustomobject]@{ |
||||
|
Kind = 'vendored'; File = $node.Source; Prefix = @($vendored) |
||||
|
Source = $vendored; Version = $ver |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
$global = Get-Command gnhf -ErrorAction SilentlyContinue |
||||
|
if ($global) { |
||||
|
return [pscustomobject]@{ |
||||
|
Kind = 'path'; File = $global.Source; Prefix = @() |
||||
|
Source = $global.Source; Version = $null |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
$null |
||||
|
} |
||||
|
|
||||
|
# --- state registry ------------------------------------------------------- |
||||
|
# A single JSON array at ~\.scout\lights-out\runs.json. This is the handoff file that lets a |
||||
|
# brand-new Scout session, with zero memory of the launching conversation, find every run. |
||||
|
|
||||
|
function Read-LightsOutRuns { |
||||
|
[CmdletBinding()] |
||||
|
param() |
||||
|
if (-not (Test-Path -LiteralPath $script:LightsOutStatePath)) { return @() } |
||||
|
try { |
||||
|
$raw = Get-Content -LiteralPath $script:LightsOutStatePath -Raw -ErrorAction Stop |
||||
|
if ([string]::IsNullOrWhiteSpace($raw)) { return @() } |
||||
|
$parsed = $raw | ConvertFrom-Json -ErrorAction Stop |
||||
|
if ($null -eq $parsed) { return @() } |
||||
|
@($parsed) |
||||
|
} |
||||
|
catch { |
||||
|
Write-Warning "runs.json unreadable ($($_.Exception.Message)); treating as empty." |
||||
|
@() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function Save-LightsOutRun { |
||||
|
[CmdletBinding()] |
||||
|
param([Parameter(Mandatory)][pscustomobject]$Run) |
||||
|
|
||||
|
if (-not (Test-Path -LiteralPath $script:LightsOutRoot)) { |
||||
|
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++) { |
||||
|
try { |
||||
|
$lock = [System.IO.File]::Open($lockPath, 'OpenOrCreate', 'ReadWrite', 'None') |
||||
|
} |
||||
|
catch { Start-Sleep -Milliseconds 100 } |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
$runs = @(Read-LightsOutRuns | Where-Object { $_.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 |
||||
|
} |
||||
|
finally { |
||||
|
if ($lock) { $lock.Dispose(); Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function Get-LightsOutRun { |
||||
|
[CmdletBinding()] |
||||
|
param([Parameter(Mandatory)][string]$Id) |
||||
|
Read-LightsOutRuns | Where-Object { $_.id -eq $Id } | Select-Object -First 1 |
||||
|
} |
||||
|
|
||||
|
# --- run directory resolution -------------------------------------------- |
||||
|
# gnhf 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 { |
||||
|
[CmdletBinding()] |
||||
|
param([Parameter(Mandatory)][string]$RepoPath, |
||||
|
[Parameter(Mandatory)][string]$RunId) |
||||
|
|
||||
|
$runsRoot = Join-Path $RepoPath '.gnhf\runs' |
||||
|
if (-not (Test-Path -LiteralPath $runsRoot)) { return $null } |
||||
|
|
||||
|
$exact = Join-Path $runsRoot $RunId |
||||
|
if (Test-Path -LiteralPath $exact) { return $exact } |
||||
|
|
||||
|
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 |
||||
|
} |
||||
|
|
||||
|
function Test-LightsOutProcessAlive { |
||||
|
[CmdletBinding()] |
||||
|
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. |
||||
|
if ($StartedAtIso) { |
||||
|
try { |
||||
|
$expected = [datetime]::Parse($StartedAtIso).ToUniversalTime() |
||||
|
if ($p.StartTime.ToUniversalTime() -gt $expected.AddMinutes(2)) { return $false } |
||||
|
} |
||||
|
catch { } |
||||
|
} |
||||
|
$true |
||||
|
} |
||||
|
|
||||
|
# --- vault write-back ----------------------------------------------------- |
||||
|
|
||||
|
function Get-LightsOutVaultFolder { |
||||
|
[CmdletBinding()] |
||||
|
param([Parameter(Mandatory)][pscustomobject]$Run) |
||||
|
|
||||
|
$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" |
||||
|
} |
||||
|
|
||||
|
function Write-LightsOutVaultNote { |
||||
|
<# |
||||
|
Writes the canonical vault record for a finished run: immutable raw artifacts plus a |
||||
|
summary.md wiki page. Mirrors the 02 - Meetings pattern (raw + summary.md). |
||||
|
The Assessment section is deliberately left as a stub for Scout to complete. |
||||
|
#> |
||||
|
[CmdletBinding()] |
||||
|
param([Parameter(Mandatory)][pscustomobject]$Run, |
||||
|
[string]$RunDir, |
||||
|
[string]$ConsoleLogPath) |
||||
|
|
||||
|
$folder = Get-LightsOutVaultFolder -Run $Run |
||||
|
New-Item -ItemType Directory -Force -Path $folder | Out-Null |
||||
|
|
||||
|
$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 |
||||
|
} |
||||
|
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 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
# git evidence, captured before any branch can be deleted |
||||
|
$commits = @(); $diffStat = ''; $changedFiles = @() |
||||
|
$base = $Run.baseCommit |
||||
|
if ($base -and (Test-Path -LiteralPath $Run.repoPath)) { |
||||
|
$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 |
||||
|
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 |
||||
|
} |
||||
|
} |
||||
|
if ($ConsoleLogPath -and (Test-Path -LiteralPath $ConsoleLogPath)) { |
||||
|
Get-Content -LiteralPath $ConsoleLogPath -Tail 400 -ErrorAction SilentlyContinue | |
||||
|
Set-Content -LiteralPath (Join-Path $folder 'console.tail.log') -Encoding UTF8 |
||||
|
} |
||||
|
|
||||
|
$today = (Get-Date).ToString('yyyy-MM-dd') |
||||
|
$date = ([datetime]::Parse($Run.startedAt)).ToString('yyyy-MM-dd') |
||||
|
$repoTag = Get-LightsOutKebab -Text $Run.repoName |
||||
|
$endedAt = if ($Run.PSObject.Properties['endedAt'] -and $Run.endedAt) { $Run.endedAt } else { (Get-Date).ToString('o') } |
||||
|
$elapsed = '' |
||||
|
try { |
||||
|
$span = ([datetime]::Parse($endedAt)) - ([datetime]::Parse($Run.startedAt)) |
||||
|
$elapsed = '{0:h\hm\m}' -f $span |
||||
|
} |
||||
|
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) } |
||||
|
$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 |
||||
|
if ($dt.Kind -eq 'Unspecified') { $dt = [datetime]::SpecifyKind($dt, 'Local') } |
||||
|
$dt.ToString('o') |
||||
|
} |
||||
|
$startedIso = & $isoFmt $Run.startedAt |
||||
|
$endedIso = & $isoFmt $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() |
||||
|
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() |
||||
|
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() |
||||
|
if ($Run.PSObject.Properties['verifyCommand'] -and $Run.verifyCommand) { |
||||
|
[void]$sb.AppendLine("Command: ``$($Run.verifyCommand)``") |
||||
|
[void]$sb.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``") } |
||||
|
} |
||||
|
|
||||
|
$summaryPath = Join-Path $folder 'summary.md' |
||||
|
Set-Content -LiteralPath $summaryPath -Value $sb.ToString() -Encoding UTF8 |
||||
|
$summaryPath |
||||
|
} |
||||
@ -0,0 +1,301 @@ |
|||||
|
<# |
||||
|
.SYNOPSIS |
||||
|
Launch a gnhf 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 |
||||
|
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 |
||||
|
would silently never fire. |
||||
|
.EXAMPLE |
||||
|
.\start-run.ps1 -RepoPath D:\Repos\myapp -Prompt "reduce complexity in src/utils" ` |
||||
|
-StopWhen "npm test passes and no unrelated files changed" -VerifyCommand "npm test" |
||||
|
#> |
||||
|
[CmdletBinding()] |
||||
|
param( |
||||
|
[ValidateSet('Launch', 'Worker')] |
||||
|
[string]$Phase = 'Launch', |
||||
|
|
||||
|
# Worker phase only |
||||
|
[string]$RunStateId, |
||||
|
|
||||
|
# Launch phase |
||||
|
[string]$RepoPath, |
||||
|
[string]$Prompt, |
||||
|
[string]$Title, |
||||
|
[string]$Agent = 'copilot', |
||||
|
[string]$Model, |
||||
|
[string]$StopWhen, |
||||
|
[string]$VerifyCommand, |
||||
|
[int]$MaxIterations = 40, |
||||
|
[long]$MaxTokens = 0, |
||||
|
[ValidateSet('hands-off', 'companion')] |
||||
|
[string]$Mode = 'hands-off', |
||||
|
[switch]$Worktree, |
||||
|
[switch]$CurrentBranch, |
||||
|
[switch]$Push, |
||||
|
[switch]$Mock, |
||||
|
[switch]$SkipVerifyPreflight |
||||
|
) |
||||
|
|
||||
|
Set-StrictMode -Version Latest |
||||
|
$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 |
||||
|
'^cursor$' { return 'cursor-agent' } |
||||
|
default { return $AgentName } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function Invoke-VerifyCommand { |
||||
|
param([string]$Command, [string]$WorkingDir, [string]$LogPath) |
||||
|
Push-Location -LiteralPath $WorkingDir |
||||
|
try { |
||||
|
if ($LogPath) { |
||||
|
& pwsh -NoProfile -NonInteractive -Command $Command *> $LogPath |
||||
|
} |
||||
|
else { |
||||
|
& pwsh -NoProfile -NonInteractive -Command $Command *> $null |
||||
|
} |
||||
|
return $LASTEXITCODE |
||||
|
} |
||||
|
finally { Pop-Location } |
||||
|
} |
||||
|
|
||||
|
# ========================================================================= |
||||
|
# WORKER |
||||
|
# ========================================================================= |
||||
|
if ($Phase -eq 'Worker') { |
||||
|
$run = Get-LightsOutRun -Id $RunStateId |
||||
|
if (-not $run) { throw "No run registered with id '$RunStateId'." } |
||||
|
|
||||
|
$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. |
||||
|
$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 |
||||
|
Save-LightsOutRun -Run $run |
||||
|
exit 1 |
||||
|
} |
||||
|
$invokeArgs = @($gnhfCmd.Prefix) + $gnhfArgs |
||||
|
|
||||
|
# No TTY here, so the star field is pointless and ANSI would just pollute the log. |
||||
|
$env:NO_COLOR = '1' |
||||
|
$env:TERM = 'dumb' |
||||
|
|
||||
|
$exitCode = -1 |
||||
|
try { |
||||
|
Push-Location -LiteralPath $run.repoPath |
||||
|
try { |
||||
|
& $gnhfCmd.File @invokeArgs *> $consoleLog |
||||
|
$exitCode = $LASTEXITCODE |
||||
|
} |
||||
|
finally { Pop-Location } |
||||
|
} |
||||
|
catch { |
||||
|
$exitCode = -1 |
||||
|
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 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 } |
||||
|
|
||||
|
# 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" |
||||
|
try { |
||||
|
$after = Invoke-VerifyCommand -Command $run.verifyCommand -WorkingDir $run.repoPath -LogPath $vLog |
||||
|
$run | Add-Member -NotePropertyName verifyExitCodeAfter -NotePropertyValue $after -Force |
||||
|
} |
||||
|
catch { |
||||
|
$run | Add-Member -NotePropertyName verifyExitCodeAfter -NotePropertyValue 'error' -Force |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
$finalStatus = if ($exitCode -eq 0) { 'completed' } else { 'failed' } |
||||
|
$run | Add-Member -NotePropertyName status -NotePropertyValue $finalStatus -Force |
||||
|
|
||||
|
try { |
||||
|
$notePath = Write-LightsOutVaultNote -Run $run -RunDir $runDir -ConsoleLogPath $consoleLog |
||||
|
$run | Add-Member -NotePropertyName vaultNote -NotePropertyValue $notePath -Force |
||||
|
} |
||||
|
catch { |
||||
|
$run | Add-Member -NotePropertyName vaultNoteError -NotePropertyValue $_.Exception.Message -Force |
||||
|
} |
||||
|
|
||||
|
Save-LightsOutRun -Run $run |
||||
|
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.' |
||||
|
} |
||||
|
|
||||
|
$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)" |
||||
|
} |
||||
|
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.' |
||||
|
} |
||||
|
|
||||
|
# 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." |
||||
|
} |
||||
|
|
||||
|
# 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." |
||||
|
} |
||||
|
$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." |
||||
|
} |
||||
|
|
||||
|
# 6. the verification command is actually runnable *now* |
||||
|
$verifyBefore = $null |
||||
|
if ($VerifyCommand -and -not $SkipVerifyPreflight) { |
||||
|
$firstToken = ($VerifyCommand.Trim() -split '\s+')[0] |
||||
|
if (-not (Get-Command $firstToken -ErrorAction SilentlyContinue)) { |
||||
|
$problems += "Verification command '$VerifyCommand' cannot run: '$firstToken' is not on PATH. Pick a command that exists on this machine." |
||||
|
} |
||||
|
elseif ($problems.Count -eq 0) { |
||||
|
$logDir = Join-Path $paths.Root 'logs' |
||||
|
New-Item -ItemType Directory -Force -Path $logDir | Out-Null |
||||
|
$verifyBefore = Invoke-VerifyCommand -Command $VerifyCommand -WorkingDir $RepoPath -LogPath (Join-Path $logDir 'verify-before.log') |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if ($problems.Count -gt 0) { |
||||
|
[pscustomobject]@{ |
||||
|
ok = $false |
||||
|
problems = $problems |
||||
|
} | ConvertTo-Json -Depth 5 |
||||
|
exit 1 |
||||
|
} |
||||
|
|
||||
|
$runId = Get-LightsOutRunId -Prompt $Prompt |
||||
|
$repoName = Split-Path -Leaf $RepoPath |
||||
|
if (-not $Title) { |
||||
|
$Title = ($Prompt -split "`n" | Where-Object { $_.Trim() } | Select-Object -First 1) |
||||
|
} |
||||
|
|
||||
|
$run = [pscustomobject]@{ |
||||
|
id = $runId |
||||
|
schemaVersion = 1 |
||||
|
status = 'running' |
||||
|
repoPath = $RepoPath |
||||
|
repoName = $repoName |
||||
|
branch = if ($CurrentBranch) { $branch } else { "gnhf/$runId" } |
||||
|
baseCommit = $baseCommit |
||||
|
prompt = $Prompt |
||||
|
title = (Get-LightsOutSafeName -Text $Title -MaxLength 60) |
||||
|
agent = $Agent |
||||
|
model = $Model |
||||
|
mode = $Mode |
||||
|
stopWhen = $StopWhen |
||||
|
maxIterations = $MaxIterations |
||||
|
maxTokens = $MaxTokens |
||||
|
worktree = [bool]$Worktree |
||||
|
currentBranch = [bool]$CurrentBranch |
||||
|
push = [bool]$Push |
||||
|
mock = [bool]$Mock |
||||
|
verifyCommand = $VerifyCommand |
||||
|
verifyExitCodeBefore = $verifyBefore |
||||
|
gnhfKind = $gnhfCmd.Kind |
||||
|
gnhfSource = $gnhfCmd.Source |
||||
|
gnhfVersion = $gnhfCmd.Version |
||||
|
startedAt = (Get-Date).ToString('o') |
||||
|
launchedBy = 'scout:lights-out' |
||||
|
workerPid = $null |
||||
|
} |
||||
|
Save-LightsOutRun -Run $run |
||||
|
|
||||
|
$workerArgs = @( |
||||
|
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', |
||||
|
'-File', $PSCommandPath, |
||||
|
'-Phase', 'Worker', |
||||
|
'-RunStateId', $runId |
||||
|
) |
||||
|
$proc = 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 |
||||
|
branch = $run.branch |
||||
|
repoPath = $RepoPath |
||||
|
agent = $Agent |
||||
|
mode = $Mode |
||||
|
workerPid = $proc.Id |
||||
|
stopWhen = $StopWhen |
||||
|
verifyCommand = $VerifyCommand |
||||
|
verifyBefore = $verifyBefore |
||||
|
gnhf = "$($gnhfCmd.Kind): $($gnhfCmd.Source)$(if ($gnhfCmd.Version) { " (v$($gnhfCmd.Version))" })" |
||||
|
statePath = $paths.StatePath |
||||
|
expectedVault = (Get-LightsOutVaultFolder -Run $run) |
||||
|
} | ConvertTo-Json -Depth 5 |
||||
@ -0,0 +1,130 @@ |
|||||
|
$ErrorActionPreference = 'Continue' |
||||
|
# Self-contained regression suite for the lights-out skill. |
||||
|
# pwsh -NoProfile -File <skill>\scripts\test\e2e.ps1 |
||||
|
# Uses a stubbed gnhf and a sandboxed state dir + vault, so it never touches the real |
||||
|
# registry at ~\.scout\lights-out or the Obsidian vault. |
||||
|
$sand = Join-Path $env:TEMP 'lights-out-e2e' |
||||
|
$stubSrc = Join-Path $PSScriptRoot 'stub-gnhf.ps1' |
||||
|
$stub = Join-Path $sand 'bin' |
||||
|
$env:LIGHTS_OUT_ROOT = "$sand\state" |
||||
|
$env:LIGHTS_OUT_VAULT_DIR = "$sand\vault" |
||||
|
$scriptsDir = Split-Path -Parent $PSScriptRoot |
||||
|
$S = Join-Path $scriptsDir 'start-run.ps1' |
||||
|
$C = Join-Path $scriptsDir 'check-run.ps1' |
||||
|
|
||||
|
Remove-Item -Recurse -Force $sand -ErrorAction SilentlyContinue |
||||
|
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 |
||||
|
Push-Location "$sand\repo" |
||||
|
git init -q -b main |
||||
|
git config user.email 't@t.local' |
||||
|
git config user.name 'T' |
||||
|
'# 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 |
||||
|
|
||||
|
function Show($n) { Write-Host "`n### $n" -ForegroundColor Yellow } |
||||
|
$script:pass = 0 |
||||
|
$script:fail = 0 |
||||
|
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. |
||||
|
. (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 |
||||
|
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' |
||||
|
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)' |
||||
|
'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')) |
||||
|
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: 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: 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' |
||||
|
$res = $out | ConvertFrom-Json |
||||
|
Check 'launch ok' ($res.ok -eq $true) |
||||
|
Write-Host " run id: $($res.id) pid: $($res.workerPid)" |
||||
|
|
||||
|
Show 'Waiting for detached worker...' |
||||
|
$final = $null |
||||
|
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 } |
||||
|
} |
||||
|
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 'verify ran before and after' (($final.verifyExitCodeBefore -eq 0) -and ($final.verifyExitCodeAfter -eq 0)) |
||||
|
Check 'run dir resolved' ($null -ne $final.runDir) |
||||
|
Check 'notes tail captured' ([bool]$final.notesTail) |
||||
|
Check 'gnhf provenance recorded' ($final.gnhfKind -eq 'override') |
||||
|
} |
||||
|
|
||||
|
Show 'Stub received correct gnhf 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') |
||||
|
} |
||||
|
else { Check 'stub was invoked' $false } |
||||
|
|
||||
|
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 |
||||
|
Check 'summary.md written' ($null -ne $note) |
||||
|
if ($note) { |
||||
|
$body = Get-Content $note.FullName -Raw |
||||
|
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 '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 '' |
||||
|
Check 'frontmatter is valid YAML' ($yamlOk -match 'OK') |
||||
|
if ($yamlOk -notmatch 'OK') { Write-Host " $yamlOk" -ForegroundColor Red } |
||||
|
Check 'ISO started timestamp' ($body -match '\| Started \| \d{4}-\d{2}-\d{2}T[\d:.]+[+-]\d{2}:\d{2} \|') |
||||
|
} |
||||
|
|
||||
|
Show "RESULT: $($script:pass) passed, $($script:fail) failed" |
||||
|
if ($note) { Write-Host "`n--- summary.md ---`n"; Get-Content $note.FullName } |
||||
|
$env:LIGHTS_OUT_GNHF = '' |
||||
|
Remove-Item -Recurse -Force $sand -ErrorAction SilentlyContinue |
||||
|
if ($script:fail -gt 0) { exit 1 } |
||||
@ -0,0 +1,62 @@ |
|||||
|
# Fake gnhf for lights-out end-to-end testing. |
||||
|
# Mimics the real CLI's observable contract: creates .gnhf/runs/<runId>/ with notes.md, |
||||
|
# prompt.md and end-state.json, makes a real commit on a gnhf/<runId> branch, then exits. |
||||
|
param() |
||||
|
|
||||
|
$allArgs = @($args) |
||||
|
New-Item -ItemType Directory -Force -Path $env:LIGHTS_OUT_ROOT | Out-Null |
||||
|
$allArgs | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $env:LIGHTS_OUT_ROOT 'stub-args.json') -Encoding UTF8 |
||||
|
|
||||
|
# The prompt is the final positional argument. |
||||
|
$prompt = $allArgs[-1] |
||||
|
|
||||
|
function Get-StubRunId([string]$P) { |
||||
|
$slug = $P.ToLowerInvariant() |
||||
|
$slug = [regex]::Replace($slug, '[^a-z0-9]+', '-') |
||||
|
$slug = [regex]::Replace($slug, '^-+', '') |
||||
|
$slug = [regex]::Replace($slug, '-+$', '') |
||||
|
if ($slug.Length -gt 20) { $slug = $slug.Substring(0, 20) } |
||||
|
$slug = [regex]::Replace($slug, '-+$', '') |
||||
|
$sha = [System.Security.Cryptography.SHA256]::Create() |
||||
|
try { |
||||
|
$hex = -join ($sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($P)) | ForEach-Object { $_.ToString('x2') }) |
||||
|
} |
||||
|
finally { $sha.Dispose() } |
||||
|
"$slug-$($hex.Substring(0,6))" |
||||
|
} |
||||
|
|
||||
|
$runId = Get-StubRunId $prompt |
||||
|
$stopWhen = '' |
||||
|
for ($i = 0; $i -lt $allArgs.Count - 1; $i++) { |
||||
|
if ($allArgs[$i] -eq '--stop-when') { $stopWhen = $allArgs[$i + 1] } |
||||
|
} |
||||
|
|
||||
|
Write-Output "stub-gnhf: starting run $runId" |
||||
|
|
||||
|
$runDir = Join-Path (Get-Location) ".gnhf\runs\$runId" |
||||
|
New-Item -ItemType Directory -Force -Path $runDir | Out-Null |
||||
|
Set-Content -LiteralPath (Join-Path $runDir 'prompt.md') -Value $prompt -Encoding UTF8 |
||||
|
$notes = "# gnhf run: $runId`n`n## Iteration Log`n`n### Iteration 1`nSimplified the widget helper. Tests still green.`n`n### Iteration 2`nRemoved a redundant branch in index.js.`n" |
||||
|
Set-Content -LiteralPath (Join-Path $runDir 'notes.md') -Value $notes -Encoding UTF8 |
||||
|
|
||||
|
# Do real git work so the wrapper's diff/commit capture is exercised for real. |
||||
|
git checkout -q -b "gnhf/$runId" |
||||
|
'export const widget = (x) => x + 1;' | Set-Content -LiteralPath 'index.js' |
||||
|
git add -A |
||||
|
git commit -q -m 'refactor: simplify widget helper' |
||||
|
'// tidied' | Add-Content -LiteralPath 'README.md' |
||||
|
git add -A |
||||
|
git commit -q -m 'docs: tidy readme' |
||||
|
|
||||
|
@{ |
||||
|
status = 'completed' |
||||
|
stopCondition = $stopWhen |
||||
|
agentError = $null |
||||
|
iterations = 2 |
||||
|
successCount = 2 |
||||
|
failCount = 0 |
||||
|
endedAt = (Get-Date).ToString('o') |
||||
|
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $runDir 'end-state.json') -Encoding UTF8 |
||||
|
|
||||
|
Write-Output "stub-gnhf: done" |
||||
|
exit 0 |
||||
@ -0,0 +1,64 @@ |
|||||
|
{ |
||||
|
"name": "vendor", |
||||
|
"lockfileVersion": 3, |
||||
|
"requires": true, |
||||
|
"packages": { |
||||
|
"": { |
||||
|
"dependencies": { |
||||
|
"gnhf": "^0.1.49" |
||||
|
} |
||||
|
}, |
||||
|
"node_modules/argparse": { |
||||
|
"version": "2.0.1", |
||||
|
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/argparse/-/argparse-2.0.1.tgz", |
||||
|
"integrity": "sha1-JG9Q88p4oyQPbJl+ipvR6sSeSzg=", |
||||
|
"license": "Python-2.0" |
||||
|
}, |
||||
|
"node_modules/commander": { |
||||
|
"version": "14.0.3", |
||||
|
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/commander/-/commander-14.0.3.tgz", |
||||
|
"integrity": "sha1-Ql15tI+a+C/Nnk/B6or2xewHu8I=", |
||||
|
"license": "MIT", |
||||
|
"engines": { |
||||
|
"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", |
||||
|
"integrity": "sha1-jkT7FKJkPFlya7FXh7XxUSyz0/s=", |
||||
|
"funding": [ |
||||
|
{ |
||||
|
"type": "github", |
||||
|
"url": "https://github.com/sponsors/puzrin" |
||||
|
}, |
||||
|
{ |
||||
|
"type": "github", |
||||
|
"url": "https://github.com/sponsors/nodeca" |
||||
|
} |
||||
|
], |
||||
|
"license": "MIT", |
||||
|
"dependencies": { |
||||
|
"argparse": "^2.0.1" |
||||
|
}, |
||||
|
"bin": { |
||||
|
"js-yaml": "bin/js-yaml.js" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,5 @@ |
|||||
|
{ |
||||
|
"dependencies": { |
||||
|
"gnhf": "^0.1.49" |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue