From 5d0e1648649b3b57113279ddcddce6c3aad939af Mon Sep 17 00:00:00 2001 From: Daniel Kucinski Date: Mon, 14 Sep 2026 17:58:49 +0100 Subject: [PATCH] chore: sync Scout skills 2026-09-14 --- README.md | 1 + install-system-sync-task.ps1 | 11 +- lights-out/SKILL.md | 207 ++++++++++++ lights-out/references/cli.md | 100 ++++++ lights-out/references/prompts.md | 103 ++++++ lights-out/scripts/check-run.ps1 | 161 ++++++++++ lights-out/scripts/lib.ps1 | 447 ++++++++++++++++++++++++++ lights-out/scripts/start-run.ps1 | 301 +++++++++++++++++ lights-out/scripts/test/e2e.ps1 | 130 ++++++++ lights-out/scripts/test/stub-gnhf.ps1 | 62 ++++ lights-out/vendor/package-lock.json | 64 ++++ lights-out/vendor/package.json | 5 + sync-skills.ps1 | 2 +- 13 files changed, 1592 insertions(+), 2 deletions(-) create mode 100644 lights-out/SKILL.md create mode 100644 lights-out/references/cli.md create mode 100644 lights-out/references/prompts.md create mode 100644 lights-out/scripts/check-run.ps1 create mode 100644 lights-out/scripts/lib.ps1 create mode 100644 lights-out/scripts/start-run.ps1 create mode 100644 lights-out/scripts/test/e2e.ps1 create mode 100644 lights-out/scripts/test/stub-gnhf.ps1 create mode 100644 lights-out/vendor/package-lock.json create mode 100644 lights-out/vendor/package.json diff --git a/README.md b/README.md index 889af80..a08fac0 100644 --- a/README.md +++ b/README.md @@ -25,6 +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. | | [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/install-system-sync-task.ps1 b/install-system-sync-task.ps1 index 12450c0..c1fa9a4 100644 --- a/install-system-sync-task.ps1 +++ b/install-system-sync-task.ps1 @@ -13,7 +13,14 @@ $action = New-ScheduledTaskAction ` -Execute 'powershell.exe' ` -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$syncScript`"" $trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Sunday -At 10:00AM -$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest + +# Must run as the interactive user, NOT SYSTEM. The git remote is HTTPS and +# authenticates via credential.helper=manager, whose vault is per-user — SYSTEM +# cannot read it, so `git fetch origin main` fails and the whole sync aborts. +# Interactive logon keeps access to that vault; StartWhenAvailable catches up a +# missed run at the next logon. +$currentUser = "$env:USERDOMAIN\$env:USERNAME" +$principal = New-ScheduledTaskPrincipal -UserId $currentUser -LogonType Interactive -RunLevel Limited $settings = New-ScheduledTaskSettingsSet ` -StartWhenAvailable ` -RunOnlyIfNetworkAvailable ` @@ -28,3 +35,5 @@ Register-ScheduledTask ` -Settings $settings ` -Description 'Mirrors custom Microsoft Scout skills to PromptWorks.' ` -Force | Out-Null + +Write-Host "Registered 'PromptWorks Weekly Skill Sync' to run as $currentUser, Sundays at 10:00 AM." diff --git a/lights-out/SKILL.md b/lights-out/SKILL.md new file mode 100644 index 0000000..429d302 --- /dev/null +++ b/lights-out/SKILL.md @@ -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:`. +- **Mode** — Hands-Off or Companion (below). + +```powershell +& "$LO\start-run.ps1" ` + -RepoPath 'D:\Repos\myapp' ` + -Prompt '' ` + -Title 'Short Title For The Vault Folder' ` + -StopWhen '' ` + -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 — ", + schedule: "every day at 7am", // the next morning + 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." +) +``` + +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). diff --git a/lights-out/references/cli.md b/lights-out/references/cli.md new file mode 100644 index 0000000..e301c68 --- /dev/null +++ b/lights-out/references/cli.md @@ -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 ` | `claude`, `codex`, `rovodev`, `opencode`, `copilot`, `pi`, `cursor`, or `acp:` | +| `--model ` | Overrides `agentModel.` from config | +| `--max-iterations ` | Abort after N iterations | +| `--max-tokens ` | Abort after N total input+output+cache tokens | +| `--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 | +| `--worktree` | Run in a separate git worktree, enabling parallel runs on one repo | +| `--current-branch` | Commit on the current branch instead of creating `gnhf/` | +| `--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 = "-" +branch = "gnhf/" +``` + +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 `\.gnhf\runs\\` (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-.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: (`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. diff --git a/lights-out/references/prompts.md b/lights-out/references/prompts.md new file mode 100644 index 0000000..eb68e6f --- /dev/null +++ b/lights-out/references/prompts.md @@ -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: . + +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 . + +Non-goals: . + +After each meaningful slice, run: . 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: . +``` + +### 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: . + +Do not redo that completed work and do not revisit . + +Focus only on: . + +The specific problem to fix is: . + +Verify with: . + +Stop only when: . +``` + +## 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: . + +Keep all other completed work intact — do not revert or rewrite anything unrelated to this finding. + +Fix only that issue. Verify with: . + +Stop only when: . +``` + +## Research-first prompt (exploratory work) + +Use when the objective is not yet well understood. Forces investigation before edits. + +```text +Objective: . + +First deliverable, before any code change: investigate 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 . + +Verify with: . Non-goals: <...>. + +Stop only when: . +``` + +## 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. diff --git a/lights-out/scripts/check-run.ps1 b/lights-out/scripts/check-run.ps1 new file mode 100644 index 0000000..afec34f --- /dev/null +++ b/lights-out/scripts/check-run.ps1 @@ -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 '' diff --git a/lights-out/scripts/lib.ps1 b/lights-out/scripts/lib.ps1 new file mode 100644 index 0000000..2bacf0a --- /dev/null +++ b/lights-out/scripts/lib.ps1 @@ -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/-", 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 \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 +} diff --git a/lights-out/scripts/start-run.ps1 b/lights-out/scripts/start-run.ps1 new file mode 100644 index 0000000..46899db --- /dev/null +++ b/lights-out/scripts/start-run.ps1 @@ -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 diff --git a/lights-out/scripts/test/e2e.ps1 b/lights-out/scripts/test/e2e.ps1 new file mode 100644 index 0000000..240cd27 --- /dev/null +++ b/lights-out/scripts/test/e2e.ps1 @@ -0,0 +1,130 @@ +$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' +$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 } diff --git a/lights-out/scripts/test/stub-gnhf.ps1 b/lights-out/scripts/test/stub-gnhf.ps1 new file mode 100644 index 0000000..3262650 --- /dev/null +++ b/lights-out/scripts/test/stub-gnhf.ps1 @@ -0,0 +1,62 @@ +# 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/vendor/package-lock.json b/lights-out/vendor/package-lock.json new file mode 100644 index 0000000..ddb5859 --- /dev/null +++ b/lights-out/vendor/package-lock.json @@ -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" + } + } + } +} diff --git a/lights-out/vendor/package.json b/lights-out/vendor/package.json new file mode 100644 index 0000000..01c2078 --- /dev/null +++ b/lights-out/vendor/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "gnhf": "^0.1.49" + } +} diff --git a/sync-skills.ps1 b/sync-skills.ps1 index 059839f..de6e170 100644 --- a/sync-skills.ps1 +++ b/sync-skills.ps1 @@ -10,7 +10,7 @@ $repositoryRoot = (Resolve-Path -LiteralPath $RepositoryRoot).Path $sourceRoot = (Resolve-Path -LiteralPath $SourceRoot).Path $logDirectory = Join-Path $repositoryRoot 'logs' $logPath = Join-Path $logDirectory 'sync.log' -$allowedExtensions = @('.gitignore', '.js', '.json', '.mjs', '.ps1', '.psm1', '.yaml', '.yml') +$allowedExtensions = @('.gitignore', '.js', '.json', '.md', '.mjs', '.ps1', '.psm1', '.yaml', '.yml') $excludedDirectories = @('.git', 'cache', 'caches', 'logs', 'node_modules') $excludedFiles = @('.token-cache.json', 'skills-metadata.json')