diff --git a/.codex/automation-prompts/dropwheel-auto-fix.md b/.codex/automation-prompts/dropwheel-auto-fix.md new file mode 100644 index 0000000..1271944 --- /dev/null +++ b/.codex/automation-prompts/dropwheel-auto-fix.md @@ -0,0 +1,132 @@ +# Dropwheel Auto-Fix Automation Prompt + +Apply Dropwheel-owned fixes when the harness allows it. + +## Assigned Fix Worker Mode + +When invoked by the orchestrator as an assigned fix worker, process only the +single finding, fingerprint, or inbox item named by the coordinator. + +In this mode: + +- create a unique isolated local worktree under `.codex\auto-fix-worktrees`; +- create or use a feature branch inside that isolated worktree; +- do not edit the shared Dropwheel checkout directly; +- do not move inbox items into `_processed`; +- write fix and verification metadata back to the run manifest; +- do not merge, push, release, reset, or force-push. + +The coordinator moves inbox items only after the branch is verified and either +accepted or explicitly routed away. + +## Inputs + +Check these sources, newest first: + +- `C:\Users\poweruser\projects\csharp\dropwheel\inbox\auto-fix\*.md` +- `C:\Users\poweruser\projects\csharp\dropwheel\inbox\auto-fix\*.json` +- failed canary reports under + `C:\Users\poweruser\projects\csharp\dropwheel\reports\harness-canary` + when their owner is `dropwheel-or-contract` or `dropwheel-harness-update` + +Ignore files already moved under `inbox\auto-fix\_processed`. +For canary reports, ignore older failed reports when a newer report for the +same target SHA has already passed or the same finding was already committed. + +## Ownership + +Auto-fix only applies to Dropwheel product work: + +- `src/**` +- `tests/**` +- product docs/assets that are directly related to the fix +- build or packaging files only when the failure is clearly Dropwheel-owned + +Auto-fix may also accept generated harness updates in Dropwheel when the owner +is `dropwheel-harness-update`. In that case, only run the agents installer and +commit its generated output; do not manually patch harness files. + +Do not auto-fix harness-owned areas here: + +- `hooks/**` +- `harness.config.json` +- `lefthook.yml` +- `.gitleaks.toml` +- `cog.toml` +- `.github/workflows/**` +- `.github/rulesets/**` +- `.github/CODEOWNERS` +- `AGENTS.md` harness loop behavior + +If a finding is harness-owned behavior rather than generated target install +output, create or update an agents handoff using +`.codex/automation-prompts/dropwheel-review-handoff.md`. + +## Process + +1. Read the newest unprocessed item and classify it: + - `dropwheel`: proceed with auto-fix. + - `dropwheel-harness-update`: proceed by accepting installer output in + Dropwheel. + - `agents-harness`: route to agents; do not patch Dropwheel. + - `needs-triage`: stop with a concise question only if local evidence cannot + classify ownership. +2. Before editing, run: + + ```powershell + node hooks\doctor.js + git status --short + ``` + +3. If `doctor` fails because the harness is missing, inconsistent, or blocked, + stop and route to agents. Do not weaken or bypass the harness. +4. If the checkout is dirty before the run, create an isolated local worktree + under `.codex\auto-fix-worktrees` and make code changes there. Keep inbox + bookkeeping in the main checkout. +5. For `dropwheel-harness-update`, run: + + ```powershell + node C:\Users\poweruser\projects\llms\agents-main\install.js --target --force --json + git add -A + node hooks\doctor.js --json + node hooks\verify.js + ``` + + The installer may return a non-zero exit before `git add -A` because its + built-in doctor sees installer-created files as untracked. Continue only when + the installer JSON reason is the expected bootstrap/untracked state. After + staging, `node hooks\doctor.js --json` and `node hooks\verify.js` must pass. + If they are green, create a local feature-branch commit with a Conventional + Commit message such as `chore(harness): update generated harness`. +6. For `dropwheel`, make the smallest product fix and add or update focused + tests. +7. If UI files are touched, obey design-gate. Create mockups if needed, but do + not mark a new direction as approved without user approval. +8. Verify with: + + ```powershell + node hooks\verify.js + ``` + + Also run narrower tests first when useful. +9. Read the verify output, not just the exit code. Fix new warnings or explain + why they are unrelated. +10. If the tree was clean before the run and verify is green, create a local + feature branch and local commit with a conventional commit message. Do not + push, merge, release, reset, or force-push. +11. If the tree was dirty before the run and no isolated worktree was used, + leave verified changes uncommitted and report exactly what was changed. +12. In normal serialized mode, when an inbox item is closed or routed away, + move its `.md` and `.json` pair into `inbox\auto-fix\_processed`. In + assigned fix-worker mode, leave inbox movement to the coordinator. + +## Safety Rules + +- Never use `--no-verify`, `LEFTHOOK=0`, `HARNESS_ACK_BYPASS=1`, or + `HARNESS_DISABLED_CHECKS` from this automation. +- Never manually edit harness files to make a Dropwheel product fix pass. +- For `dropwheel-harness-update`, only accept generated installer output after + `doctor` and `verify` pass. +- Prefer a small failing test before the fix when the bug is reproducible. +- If the minimal fix needs product judgment, leave a narrow proposal instead of + guessing. diff --git a/.codex/automation-prompts/dropwheel-harness-canary.md b/.codex/automation-prompts/dropwheel-harness-canary.md new file mode 100644 index 0000000..bf690e2 --- /dev/null +++ b/.codex/automation-prompts/dropwheel-harness-canary.md @@ -0,0 +1,39 @@ +# Dropwheel Harness Canary Automation Prompt + +Run the Dropwheel harness canary with minimal user involvement. + +1. From the Dropwheel repository, run: + + ```powershell + powershell -ExecutionPolicy Bypass -File .\scripts\harness-canary.ps1 -MirrorToAgentsInbox + ``` + +2. If the command succeeds, report only the report path and archive/finish the + automation run. + `-MirrorToAgentsInbox` only mirrors `agents-harness` and `needs-triage` + failures; Dropwheel-owned reports stay in Dropwheel for auto-fix. + +3. If the command fails, read the generated `report.md` and `report.json`. + Classify the owner: + + - `agents-harness`: do not patch harness files in Dropwheel. Send a concise + report to the agents harness inbox thread if available, and make sure the + report files are mirrored under `C:\Users\poweruser\projects\llms\agents\inbox\dropwheel`. + - `dropwheel-harness-update`: the latest agents installer produced harness + files that must be accepted in Dropwheel, for example doctor reports + `harness not bootstrapped ... untracked` for installer-created files such + as `hooks/verify-core.js`, `hooks/release-preflight.js`, or + `.github/CODEOWNERS`, while product verify passes. Use + `.codex/automation-prompts/dropwheel-auto-fix.md` to create a local + Dropwheel harness update branch/commit from installer output. Do not + manually patch those files. + - `dropwheel-or-contract`: inspect the failing build/test output. If it is a + Dropwheel bug, use `.codex/automation-prompts/dropwheel-auto-fix.md` and + fix it in Dropwheel when the harness allows it. If it is a harness + contract bug, route it to agents. + - `needs-triage`: keep the failing worktree, summarize the uncertainty, and + ask for user input only if the report cannot be routed. + +4. Do not commit, push, force-push, or reset unless the user explicitly asks. + Leave a short final report with changed files, verification commands, and + the generated canary report path. diff --git a/.codex/automation-prompts/dropwheel-negative-canary.md b/.codex/automation-prompts/dropwheel-negative-canary.md new file mode 100644 index 0000000..161ac7a --- /dev/null +++ b/.codex/automation-prompts/dropwheel-negative-canary.md @@ -0,0 +1,37 @@ +# Dropwheel Negative Canary Prompt + +Verify that the harness catches deliberately injected failures in isolated +Dropwheel worktrees. + +## Process + +1. Run: + + ```powershell + powershell -ExecutionPolicy Bypass -File .\scripts\harness-negative-canary.ps1 -MirrorToAgentsInbox + ``` + +2. Read the generated `report.md` and `report.json`. +3. Interpret the result: + - `ok: true`, `ownerHint: none`: every injected failure was caught. Finish + quietly with the report path. + - `ownerHint: agents-harness`: a false negative or setup failure points to + harness behavior. Confirm that the report was mirrored into + `C:\Users\poweruser\projects\llms\agents\inbox\dropwheel`. + - `ownerHint: needs-triage`: keep the failing worktree, summarize the + uncertainty, and ask for user input only if local evidence cannot classify + it. +4. Do not modify the main Dropwheel checkout. The script must use temporary + worktrees only. +5. Do not push, merge, release, reset, force-push, weaken harness checks, or use + bypass environment variables. + +## Expected Negative Cases + +The script intentionally injects: + +- a C# compile failure; +- a failing xUnit test; +- broken harness JavaScript syntax. + +The canary passes only when `node hooks\verify.js` rejects all three cases. diff --git a/.codex/automation-prompts/dropwheel-pipeline-orchestrator.md b/.codex/automation-prompts/dropwheel-pipeline-orchestrator.md new file mode 100644 index 0000000..a609846 --- /dev/null +++ b/.codex/automation-prompts/dropwheel-pipeline-orchestrator.md @@ -0,0 +1,390 @@ +# Dropwheel Pipeline Orchestrator Prompt + +Run the whole Dropwheel/agents harness pipeline as one coordinated workflow. +Parallelize read-only discovery and isolated fix work when multi-agent tools are +available; keep accepted-main mutation and merge commits serialized. + +This replaces separate scheduled cards. Keep the UI simple: this automation is +the single recurring entry point, while the other prompt files are subroutines. + +## Roots + +- Pipeline config/scripts root: + `C:\Users\poweruser\projects\csharp\dropwheel-release` +- Dropwheel main worktree for accepted/merged code: + `C:\Users\poweruser\projects\csharp\dropwheel-release` +- Agents harness development/config root: + `C:\Users\poweruser\projects\llms\agents` +- Agents harness accepted main worktree: + `C:\Users\poweruser\projects\llms\agents-main` +- Automation state root for generated reports and memory: + `C:\Users\poweruser\.codex\automations\dropwheel-pipeline-orchestrator` + +Set the working directory to the pipeline config/scripts root before running +relative `scripts\...` commands. The versioned accepted-main checkout is the +source of truth for automation code; do not depend on untracked files in a +development branch. + +When running canaries for the accepted state, use the scripts from the pipeline +root and pass `-AgentsRoot C:\Users\poweruser\projects\llms\agents-main` +and `-DropwheelRoot C:\Users\poweruser\projects\csharp\dropwheel-release`. +Also pass explicit `-ReportRoot` and `-WorktreeRoot` paths outside +`dropwheel-release`; generated reports and temporary worktrees must not make the +accepted main checkout dirty. + +## Run Manifest + +Every run creates one manifest under the automation state root. The manifest is +the coordinator/worker contract and the merge arbiter's evidence ledger. + +```powershell +$automationRoot = "C:\Users\poweruser\.codex\automations\dropwheel-pipeline-orchestrator" +$runStamp = Get-Date -Format "yyyyMMdd-HHmmss" +$runRoot = Join-Path $automationRoot "runs\$runStamp" +$manifest = powershell -ExecutionPolicy Bypass -File .\scripts\pipeline-manifest.ps1 ` + -Mode New ` + -RunRoot $runRoot ` + -RunId $runStamp +``` + +Workers must write structured results into the manifest with +`scripts\pipeline-manifest.ps1` before the coordinator decides to fix or merge: + +- `AddWorker`: worker started/completed/skipped metadata and evidence summary. +- `AddFinding`: normalized finding with owner, evidence, fingerprint, and + recommendation in `DataJson`. +- `UpdateFindingDisposition`: terminal `fixed`, `open`, or `deferred` status + plus non-empty evidence for one finding id or fingerprint. +- `AddFix`: branch/worktree/commit metadata for a fix. +- `AddVerification`: verify/canary evidence, including report path and SHA. +- `AddMerge`: accepted-main merge metadata. +- `Complete`: final status. + +Use `DataJson` for task-specific details. Keep large logs in report files and +store only report paths plus pass/fail summaries in the manifest. +Do not write empty `{}` worker data. A completed discovery worker must include: + +```json +{ + "checkedScope": ["src/..."], + "evidenceSources": ["git diff", "tests/..."], + "candidatesConsidered": 0, + "rejectedCandidates": [], + "noFindingReason": "No product invariant or regression gap survived evidence review.", + "residualRisk": "Narrow risk statement, or none.", + "confidence": "High|Medium|Low" +} +``` + +If a worker finds an actionable candidate, also write `AddFinding` with +`fingerprint`, `severity`, `confidence`, `category`, `evidence`, `impact`, +`recommendation`, and `testToAdd`. + +The manifest script serializes concurrent JSON updates with a per-manifest +lock, so parallel workers may append records without overwriting each other. +When JSON is generated dynamically, write it under +`$runRoot\manifest-data\.json` and pass `-DataJsonPath`; this avoids +PowerShell argument quoting loss through nested `powershell -File` calls. Use +`-DataJson` only for short single-quoted literals. +`Complete` is terminal: write all worker results, findings, verification +records, merge records, dispositions, and the coordinator summary before +calling it. The manifest rejects completion until every finding has received +`UpdateFindingDisposition`. Do not append records after `Complete`. + +## Multi-Agent Model + +- Coordinator: owns the manifest, budgets, dedupe, routing, canonical + inbox/handoff writes, `_processed` movement, and final summary. +- Health/negative workers: may run in parallel after the initial accepted-root + snapshot when they only touch isolated temp worktrees. +- Discovery workers: read-only and safe to parallelize. Run independent slices + for Dropwheel regression mining, Dropwheel product discovery, agents harness + regression mining, and agents harness discovery when budget allows. If tools + are unavailable, run the same slices serially and still write manifest records. +- Fix workers: one finding per isolated worktree/branch. A fix worker owns only + its assigned files, must not touch accepted main directly, and must not move + shared inbox items into `_processed`. +- Verification workers: verify a committed fix branch and write report SHA, + branch SHA, command results, and canary paths into the manifest. +- Merge arbiter: the only role allowed to mutate `dropwheel-release/main` or + `agents-main/main`. It processes verified fixes one at a time. + +Finding dedupe uses a stable fingerprint: + +```text +||| +``` + +Merge duplicate findings before creating fix branches. Do not run two fix +workers for the same fingerprint in one run. + +Run budgets: + +- Maximum Dropwheel fixes per run: 2. +- Maximum Agents fixes per run: 1. +- Maximum actionable findings accepted per run: 6. +- Stop after post-merge canary fails; route that failure before continuing. + +## Product Discovery Rotation + +Dropwheel product discovery alternates between narrow and broad slices. The +goal is to avoid overfitting discovery to only the latest changed files. + +Run a broad product slice when any of these are true: + +- the previous accepted-state run had no actionable product finding; +- two consecutive runs focused only on recent commits or post-fix validation; +- automation memory shows no broad product slice in the last 7 days. + +A broad product slice is still bounded. Review 2-3 related subsystems plus +their focused tests, not the whole repository. Prefer one of these rotating +coverage keys: + +- `drop-flow`: `OverlayWindow*`, `FileOps`, undo/history, drag/drop edge cases. +- `watcher-flow`: `WatcherService`, `SortService`, file collision and lifecycle + tests. +- `target-config-flow`: target persistence, sorter rules, app config, corrupt + config recovery, compatibility. +- `runtime-flow`: startup/tray/hotkey lifecycle, cancellation, shutdown, + user-visible failure reporting. + +The product discovery worker must record `sliceKind`, `coverageKey`, +`checkedSubsystems`, `entrypoints`, `testsReviewed`, `invariantsChecked`, +`candidatesConsidered`, `rejectedCandidates`, `noFindingReason`, and +`residualRisk` in manifest `DataJson`. + +## Subroutines + +- `.codex/automation-prompts/dropwheel-harness-canary.md` +- `.codex/automation-prompts/dropwheel-negative-canary.md` +- `.codex/automation-prompts/dropwheel-auto-fix.md` +- `.codex/automation-prompts/dropwheel-review-handoff.md` +- `.codex/automation-prompts/dropwheel-regression-mining.md` +- `.codex/automation-prompts/dropwheel-product-discovery.md` +- `C:\Users\poweruser\projects\llms\agents-main\.codex\automation-prompts\agents-dropwheel-inbox-triage.md` +- `C:\Users\poweruser\projects\llms\agents-main\.codex\automation-prompts\agents-harness-regression-mining.md` +- `C:\Users\poweruser\projects\llms\agents-main\.codex\automation-prompts\agents-harness-discovery-review.md` + +## Sequence + +1. Set location to `C:\Users\poweruser\projects\csharp\dropwheel-release`, + create the run manifest as described above, then capture the accepted-root + snapshot: + - `git status --short --branch` and `git rev-parse HEAD` for + `dropwheel-release`. + - `git status --short --branch` and `git rev-parse HEAD` for `agents-main`. + Accepted roots must be clean except for generated artifacts explicitly moved + into the automation state root. + +2. Run the Dropwheel health canary: + + ```powershell + powershell -ExecutionPolicy Bypass -File .\scripts\harness-canary.ps1 ` + -AgentsRoot C:\Users\poweruser\projects\llms\agents-main ` + -DropwheelRoot C:\Users\poweruser\projects\csharp\dropwheel-release ` + -ReportRoot (Join-Path $runRoot "health\reports") ` + -WorktreeRoot (Join-Path ([System.IO.Path]::GetTempPath()) "dw-canary") ` + -MirrorToAgentsInbox + ``` + +3. If it fails, read `report.md` and `report.json`, write an `AddVerification` + manifest record, then route immediately: + - `dropwheel-harness-update`: run the Dropwheel auto-fix prompt now. Accept + only generated installer output after staged `doctor --json` and `verify` + pass. + - `dropwheel-or-contract`: inspect evidence. If Dropwheel-owned, create or + use an `inbox\auto-fix` item and run the Dropwheel auto-fix prompt now. + If harness-owned, create an agents handoff. + - `agents-harness` or `needs-triage`: make sure the report is mirrored into + `C:\Users\poweruser\projects\llms\agents\inbox\dropwheel`, then run the + agents inbox triage prompt now. + +4. If health is green, run the negative canary and discovery workers. The + negative canary may run in parallel with read-only discovery workers because + it uses isolated temp worktrees: + + ```powershell + powershell -ExecutionPolicy Bypass -File .\scripts\harness-negative-canary.ps1 ` + -AgentsRoot C:\Users\poweruser\projects\llms\agents-main ` + -DropwheelRoot C:\Users\poweruser\projects\csharp\dropwheel-release ` + -ReportRoot (Join-Path $runRoot "negative\reports") ` + -WorktreeRoot (Join-Path ([System.IO.Path]::GetTempPath()) "dw-neg-canary") ` + -MirrorToAgentsInbox + ``` + + If it reports `agents-harness`, immediately run the agents inbox triage + prompt before starting merges. + +5. Run discovery: + - first, process existing active inbox/handoff items; + - otherwise run the next rotation slice, selecting a broad Dropwheel product + slice according to the Product Discovery Rotation rules above when due; + - when multi-agent tools are available and budget allows, run independent + read-only discovery slices in parallel: + - Dropwheel regression mining; + - Dropwheel product discovery, using `sliceKind=broad` when the rotation + rules say a broad slice is due; + - agents harness regression mining; + - agents harness discovery review. + + Each worker writes `AddWorker` with non-empty `DataJson` and either + `AddFinding` or a no-finding `AddEvent` with checked scope and residual + risk. The coordinator dedupes by fingerprint and selects fixes within + budget. A no-finding worker is only useful if the manifest says what scope + was checked, what evidence was read, why candidates were rejected, and what + residual risk remains. + +6. If discovery or routing creates a local branch, finish the fix commit before running + the health canary against that fix root. The canary creates an isolated + worktree from `HEAD`, so a dirty fix root would otherwise verify the previous + commit instead of the fix. Treat `ownerHint: pipeline-precondition` as a + workflow error: commit the intended fix on the local branch, remove generated + artifacts, or otherwise make the fix root clean, then rerun the canary. The + current main checkout may still report an expected `dropwheel-harness-update` + until the branch is accepted. + +7. Auto-merge verified local fixes into the Dropwheel main worktree when all of + these are true: + - the fix branch is local; + - `doctor --json`, `verify`, and canary are green for the fix root; + - the canary report's Dropwheel SHA equals the verified fix branch `HEAD`; + - the target main worktree is clean; + - the merge does not require product judgment. + + Merge into `C:\Users\poweruser\projects\csharp\dropwheel-release` with + hooks enabled. If branch-guard blocks a direct main commit, use the harness + main-commit exception only for this merge commit: + + ```powershell + $env:HARNESS_ALLOW_MAIN = "1" + git commit -m "" + Remove-Item Env:\HARNESS_ALLOW_MAIN + ``` + + Do not use `--no-verify`, do not push, and do not release. If conflicts + occur, preserve current main release metadata unless the fix explicitly + changes it, then rerun `doctor --json`, `verify`, and canary before + committing. + + Before deciding that the target main worktree is dirty, check whether the + only changes are generated pipeline artifacts such as `reports/`. Move or + remove those generated artifacts after preserving the report under the + automation state root; do not commit generated reports into Dropwheel. + +8. Auto-merge verified local fixes into the Agents accepted main worktree when + all of these are true: + - the fix branch is local and clean; + - `node hooks/verify.js` is green for the fix root; + - a Dropwheel health canary using `-AgentsRoot ` is green; + - the canary report's Agents SHA equals the verified fix branch `HEAD`; + - `C:\Users\poweruser\projects\llms\agents-main` is clean; + - the merge is a test/harness maintenance fix and does not require product + judgment. + + Merge into `C:\Users\poweruser\projects\llms\agents-main` with hooks + enabled. If branch-guard blocks a direct main commit, use the same scoped + main-commit exception only for this merge commit: + + ```powershell + $env:HARNESS_ALLOW_MAIN = "1" + git commit -m "" + Remove-Item Env:\HARNESS_ALLOW_MAIN + ``` + + Do not use `--no-verify`, do not push, and do not release. After the merge, + rerun `node hooks/verify.js` in `agents-main` and a Dropwheel health canary + using `-AgentsRoot C:\Users\poweruser\projects\llms\agents-main`; the canary + report's Agents SHA must equal the new `agents-main` `HEAD`. + + After verification, synchronize the development `main` without rewriting + history, then clean the exact pipeline-owned fix branch: + + ```powershell + git -C C:\Users\poweruser\projects\llms\agents fetch C:\Users\poweruser\projects\llms\agents-main main + git -C C:\Users\poweruser\projects\llms\agents checkout main + git -C C:\Users\poweruser\projects\llms\agents merge --ff-only FETCH_HEAD + node C:\Users\poweruser\projects\llms\agents-main\hooks\post-merge-cleanup.js ` + --root C:\Users\poweruser\projects\llms\agents ` + --branch --base main --no-fetch --apply + ``` + + Run these commands only from a clean development checkout. If it contains + unrelated user work or cannot fast-forward, leave the finding open and do + not complete the run; never reset or discard it. + +9. If discovery creates a Dropwheel auto-fix item, run Dropwheel auto-fix in the + same orchestrator run. If discovery creates an agents handoff or agents + regression fix, run agents inbox triage or agents auto-fix in the same + orchestrator run, then apply the Agents accepted-main auto-merge rule above + when verification is green. + +10. Give every finding an `UpdateFindingDisposition` record. Then run the + terminal topology gate: + + ```powershell + node C:\Users\poweruser\projects\llms\agents-main\hooks\repo-state-audit.js ` + --root C:\Users\poweruser\projects\llms\agents ` + --accepted-root C:\Users\poweruser\projects\llms\agents-main ` + --base main --strict + ``` + + The run cannot complete while main SHAs differ, a worktree is dirty, or an + extra branch/worktree remains. Preserve unmerged or dirty user work and + report the run as blocked instead of deleting it. + +11. Finish with a compact summary, write the coordinator discovery/run summary, + then mark the manifest complete: + - reports generated; + - owner classifications; + - worker scopes and no-finding reasons; + - branches/commits created; + - merge commits created on local main; + - verification commands and pass/fail counts; + - active inbox items left open; + - whether current main checkout is blocked only because a local branch has + not been accepted. + + ```powershell + $summaryJson = @{ + reports = @("...") + findings = @() + fixes = @() + merges = @() + inboxOpen = @() + residualRisk = "..." + } | ConvertTo-Json -Depth 8 -Compress + $summaryPath = Join-Path $runRoot "manifest-data\discovery-summary.json" + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $summaryPath) | Out-Null + Set-Content -LiteralPath $summaryPath -Value $summaryJson -Encoding utf8 + $completePath = Join-Path $runRoot "manifest-data\complete.json" + Set-Content -LiteralPath $completePath ` + -Value '{"summary":"Run closed after all worker/coordinator records were written."}' ` + -Encoding utf8 + + powershell -ExecutionPolicy Bypass -File .\scripts\pipeline-manifest.ps1 ` + -Mode AddEvent ` + -ManifestPath $manifest ` + -WorkerId discovery-summary ` + -Role coordinator ` + -Status no-findings ` + -DataJsonPath $summaryPath + + powershell -ExecutionPolicy Bypass -File .\scripts\pipeline-manifest.ps1 ` + -Mode Complete ` + -ManifestPath $manifest ` + -Status complete ` + -DataJsonPath $completePath + ``` + +## Safety + +- Do not push, release, reset, force-push, weaken harness checks, or use bypass + environment variables except the scoped `HARNESS_ALLOW_MAIN=1` main-commit + exception described above for verified local auto-merges. +- If the current checkout is dirty, use isolated local worktrees for code + changes. Keep inbox bookkeeping in the main checkout. +- Do not manufacture findings. A discovery slice can end with no actionable + item, but must state checked scope and residual risk. +- Never allow worker agents to merge, push, release, reset, or mutate accepted + main. Workers may only write in their assigned isolated worktree or write + manifest/report artifacts under the automation state root. diff --git a/.codex/automation-prompts/dropwheel-product-discovery.md b/.codex/automation-prompts/dropwheel-product-discovery.md new file mode 100644 index 0000000..71a6722 --- /dev/null +++ b/.codex/automation-prompts/dropwheel-product-discovery.md @@ -0,0 +1,147 @@ +# Dropwheel Product Discovery Prompt + +Find real Dropwheel product risks and feed only actionable findings into the +existing auto-fix loop. + +This automation is not a ritual review. It should either produce a concrete, +evidence-backed finding or state exactly what scope was checked and why no +substantial finding was found. + +## Parallel Worker Mode + +When invoked by the orchestrator as a parallel discovery worker, this prompt is +read-only: + +- do not create `inbox\auto-fix\*.md` or `.json`; +- do not move `_processed` items; +- do not edit code, run installers, commit, merge, push, or release; +- return structured candidate findings only. + +For each candidate include `owner`, `severity`, `confidence`, `category`, +`evidence`, `impact`, `recommendation`, `testToAdd`, `scope`, and a stable +`fingerprint` in the form: + +```text +dropwheel||| +``` + +The orchestrator owns dedupe and creates canonical inbox items after all +parallel workers finish. + +When the orchestrator passes or implies `sliceKind=broad`, return a broad-slice +worker record even if no finding is produced. Include: + +- `sliceKind`: `broad` +- `coverageKey` +- `checkedSubsystems` +- `entrypoints` +- `testsReviewed` +- `invariantsChecked` +- `candidatesConsidered` +- `rejectedCandidates` +- `noFindingReason` +- `residualRisk` +- `confidence` + +## Scope + +Review Dropwheel product code and product tests: + +- `src/**` +- `tests/**` +- product docs or packaging files only when they affect runtime behavior, + compatibility, release safety, or developer workflow + +Do not modify product code from this automation. The output of this discovery +task is an inbox item for `Dropwheel Auto Fix`. + +Do not treat generated harness files as product findings: + +- `hooks/**` +- `harness.config.json` +- `lefthook.yml` +- `.gitleaks.toml` +- `cog.toml` +- `.github/workflows/**` +- `.github/rulesets/**` +- `.github/CODEOWNERS` + +If the evidence points to harness behavior, route it through +`.codex/automation-prompts/dropwheel-review-handoff.md`. + +## Process + +1. Inspect current state: + + ```powershell + git status --short + node hooks\verify.js --list + ``` + +2. Choose the review slice: + - if `sliceKind=broad`, review one bounded broad coverage key from the list + below; + - otherwise choose a narrow slice: changed product files first, then one + product subsystem with tests, rotating across `Services`, `UI`, `Models`, + and app startup/tray/drop flows; + - include matching tests when they exist. + + Broad coverage keys: + + - `drop-flow`: `OverlayWindow*`, `FileOps`, undo/history, drag/drop edge + cases. + - `watcher-flow`: `WatcherService`, `SortService`, collision handling, + watcher lifecycle, cancellation. + - `target-config-flow`: target persistence, sorter rules, app config, + corrupt config recovery, compatibility. + - `runtime-flow`: startup/tray/hotkey lifecycle, shutdown, user-visible + failure reporting. + + A broad slice should cover 2-3 related subsystems and their tests. It should + not try to audit every file in `src/**` in one run. +3. Run an evidence-first Color Team Review on that slice: + - prefer correctness, data loss, file operation safety, drag/drop edge + cases, hotkey/watch lifecycle, configuration compatibility, silent + failures, and missing regression tests; + - do not invent findings to fill the format; + - distinguish confirmed issues, likely issues, and hypotheses. +4. In normal serialized mode, for each confirmed or likely product finding that + is P0/P1 and can be fixed without product judgment, create an inbox item + pair: + + - `inbox\auto-fix\auto-fix-YYYYMMDD-HHMMSS-.md` + - `inbox\auto-fix\auto-fix-YYYYMMDD-HHMMSS-.json` + +5. The JSON item must include: + + ```json + { + "schema": "dropwheel-auto-fix/v1", + "source": "dropwheel-product-discovery", + "owner": "dropwheel", + "severity": "High|Medium|Low", + "confidence": "Confirmed|Likely", + "category": "correctness|security|performance|architecture|maintainability|DX|compatibility|operations", + "evidence": ["file:line or scenario"], + "impact": "...", + "recommendation": "...", + "testToAdd": "...", + "scope": ["src/...","tests/..."] + } + ``` + +6. The Markdown item should be short and actionable: evidence, impact, + recommended fix, and the test to add. +7. For a broad no-finding result, report the coverage key, checked subsystems, + entrypoints, tests reviewed, invariants checked, rejected candidates, and + residual risk. This is required evidence, not optional prose. +8. Do not create inbox items for cosmetic notes, speculative ideas, or findings + that need user product judgment. Report those as residual risk only. +9. Finish quietly when no actionable finding exists, but include the checked + files/subsystem and residual risk in the final answer. + +## Safety + +- Never push, merge, release, reset, force-push, or bypass the harness. +- Never weaken tests or harness checks to manufacture a green result. +- Keep duplicate findings collapsed into one inbox item. diff --git a/.codex/automation-prompts/dropwheel-regression-mining.md b/.codex/automation-prompts/dropwheel-regression-mining.md new file mode 100644 index 0000000..ebca845 --- /dev/null +++ b/.codex/automation-prompts/dropwheel-regression-mining.md @@ -0,0 +1,73 @@ +# Dropwheel Regression Mining Prompt + +Look for missing regression coverage and repeated failure patterns in Dropwheel. + +This task is different from normal product review: it mines recent evidence, +not the whole codebase. + +## Parallel Worker Mode + +When invoked by the orchestrator as a parallel discovery worker, this prompt is +read-only: + +- do not create `inbox\auto-fix` items; +- do not create agents handoff files; +- do not move `_processed` items; +- do not edit code, run installers, commit, merge, push, or release; +- return structured candidate findings only. + +For each candidate include `owner`, `severity`, `confidence`, `evidence`, +`impact`, `recommendation`, `testToAdd`, checked reports/commits, and a stable +`fingerprint`: + +```text +|regression|| +``` + +The orchestrator owns dedupe and creates canonical inbox or handoff items after +parallel workers finish. + +## Inputs + +Newest first: + +- `git status --short` +- `git log --oneline --decorate -20` +- `git diff --stat` +- `reports\harness-canary\**\report.json` +- `inbox\auto-fix\**` +- `tests\Dropwheel.Tests\**` + +Ignore `_processed` items unless checking whether the same issue already has a +fix or a newer duplicate. + +## Process + +1. Identify candidate regressions: + - product files changed without corresponding tests; + - recurring canary or verify failures with the same signature; + - code paths with high impact but no focused test for failure modes; + - TODO/FIXME comments only when they point to runtime risk, data loss, + compatibility, or silent failure. +2. For each candidate, look for concrete evidence in code, tests, reports, or a + reproducible scenario. +3. Classify ownership: + - `dropwheel`: product code or product test gap; + - `agents-harness`: false positive/false negative or harness behavior; + - `dropwheel-harness-update`: generated target harness files need to be + accepted in Dropwheel. +4. In normal serialized mode, for confirmed or likely Dropwheel-owned P0/P1 + gaps, create an `inbox\auto-fix\auto-fix-*.md` and matching `.json` using + schema `dropwheel-auto-fix/v1`. +5. In normal serialized mode, for true harness findings, use + `.codex/automation-prompts/dropwheel-review-handoff.md`. +6. Do not edit product code directly. `Dropwheel Auto Fix` owns implementation. +7. If no actionable item exists, report: + - which reports/commits/tests were checked; + - why the candidates were rejected or deferred; + - one residual risk if confidence is low. + +## Quality Bar + +Do not create an item just because a file changed. The item must say what could +break, why current tests would miss it, and what regression test would catch it. diff --git a/.codex/automation-prompts/dropwheel-review-handoff.md b/.codex/automation-prompts/dropwheel-review-handoff.md new file mode 100644 index 0000000..8fca0cc --- /dev/null +++ b/.codex/automation-prompts/dropwheel-review-handoff.md @@ -0,0 +1,53 @@ +# Dropwheel Review Handoff Prompt + +Use this after any Dropwheel review, especially `$color-team-review`. + +If the review finds issues in harness-owned files or behavior, create a handoff +for `agents` even when the Dropwheel canary passes. + +Harness-owned areas include: + +- `hooks/**` +- `harness.config.json` +- `lefthook.yml` +- `.gitleaks.toml` +- `cog.toml` +- `.github/workflows/**` +- `.github/rulesets/**` +- `.github/CODEOWNERS` +- `AGENTS.md` rules about the harness loop +- installer, doctor, verify, design-gate, guard, release-preflight behavior + +Write the handoff under: + +```text +C:\Users\poweruser\projects\llms\agents\inbox\dropwheel +``` + +Use filenames: + +```text +review-handoff-YYYYMMDD-HHMMSS.md +review-handoff-YYYYMMDD-HHMMSS.json +``` + +The Markdown handoff must include: + +- source Dropwheel thread URL, if known +- source Dropwheel repo path and branch +- related canary report path, if any +- each harness finding with severity, confidence, evidence, impact, + recommendation, and suggested regression test +- explicit note not to edit Dropwheel app code from `agents` + +If a known agents inbox thread is available, also send the same summary there. +If no thread is known, the file handoff is still required and sufficient. + +Do not create an agents handoff for pure Dropwheel application findings. For +those, create a local Dropwheel auto-fix item under: + +```text +C:\Users\poweruser\projects\csharp\dropwheel\inbox\auto-fix +``` + +Then use `.codex/automation-prompts/dropwheel-auto-fix.md`. diff --git a/.gitattributes b/.gitattributes index 54ebe78..3a0a423 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,6 +4,7 @@ hooks/*.js text eol=lf hooks/**/*.js text eol=lf install.js text eol=lf *.sh text eol=lf +*.ps1 text eol=lf # Config files are LF too: otherwise Windows with autocrlf=true can make the working # tree CRLF, and `node hooks/doctor.js` reads the working tree and reports a false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5547edb..f869fd8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,9 +12,9 @@ jobs: release: runs-on: windows-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - - uses: actions/setup-dotnet@v5 + - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # actions/setup-dotnet@v5 with: dotnet-version: 10.0.x @@ -33,7 +33,7 @@ jobs: Compress-Archive -Path dist-sc/* -DestinationPath "Dropwheel-${{ github.ref_name }}-win-x64-self-contained.zip" - name: Create GitHub Release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # softprops/action-gh-release@v3 with: files: Dropwheel-*.zip generate_release_notes: true diff --git a/.gitignore b/.gitignore index f4fc0e7..a247efe 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ dist/ # agent runtime (персональные настройки раннера — не коммитим) .claude/settings.local.json + +# harness automation output +inbox/ +reports/ diff --git a/AGENTS.md b/AGENTS.md index 8dde87f..0289586 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,12 +9,13 @@ enforce. ```text 1. EXPLORE - read the codebase, patterns, and risks 2. PLAN - plan -> user approval -2.5 DESIGN (GUI) - >=4 mockups -> user approval -> APPROVED file +2.5 DESIGN (GUI) - classify UI impact -> matching variants -> approval 3. IMPLEMENT+TEST - code and tests together; edge cases become tests 4. VERIFY - node hooks/verify.js + read output + git diff self-review - failure/new warnings -> return to step 3 - green -> step 5 5. COMMIT on branch -> PR, never directly on main +5.5 MERGE+CLEANUP - confirmed MERGED + green main -> exact branch cleanup 6. REPORT - changed / verified / remaining / manual test notes 7. USER DECISION - accept = DONE; revise -> 2 or 3; reject -> revert ``` @@ -45,13 +46,40 @@ need a plan. implementation. The plan names the files, rationale, tests, risks, and edge cases. -**2.5 DESIGN (GUI).** GUI work matching `harness.config.json -> ui.globs` is -designed before code. New GUI work needs at least four stylistically distinct -mockups from `node hooks/new-mockups.js `, user selection, and -`design/mockups//APPROVED`. GUI changes also need a mockup for the new -state. `hooks/design-gate.js` passes UI changes only when the approved set is -touched in the same branch diff; to reuse an old set, append a date/branch line -to its `APPROVED` file. +**2.5 DESIGN (GUI).** Classify the user-visible change before generating DESIGN +evidence. Do not use four unrelated visual themes for every kind of UI work. + +- Backend-only work with no user-visible UI impact skips DESIGN. A mixed task + designs only its UI slice. `design-gate.js` skips automatically when no changed + file matches `harness.config.json -> ui.globs`. +- Animation uses one concrete scenario. The low-cost option is at least four + written motion variants (`--fidelity text`); use executable HTML/JavaScript + variants (`--fidelity js`) when timing, gesture, or physical feel cannot be + judged from prose. +- Changes to an existing UI keep its real visual language and components while + comparing layouts, placement, or interaction patterns. Pass at least one + current UI source file through `--baseline`. +- UI created from scratch compares at least four stylistically distinct visual + directions. + +Use one explicit mode; the generator has no generic default: + +```text +node hooks/new-mockups.js --kind existing-ui --baseline +node hooks/new-mockups.js --kind new-ui +node hooks/new-mockups.js --kind animation --fidelity text|js --example +node hooks/new-mockups.js --kind backend +``` + +The generator writes `DESIGN.json`, mode-appropriate variants, and `NOTES.md`. +After user selection, create `design/mockups//APPROVED` with a +`ui: ` line. The gate checks the manifest and passes UI +changes only when that approved set is both touched and scoped to the changed UI +paths in the same branch diff. Legacy sets without `DESIGN.json` remain valid +only when `APPROVED` carries the same `ui:` scope. If the user explicitly waives +new mockups for a UI-path change, create `design/mockups//WAIVER.json` +with `schemaVersion`, `feature`, `uiPaths`, `reason`, `date`, and +`approvedBy` or `approvalSource`. **3. IMPLEMENT+TEST.** Code and tests move together. If a target project has no test runner, report that and propose a minimal one. @@ -75,6 +103,35 @@ or `BREAKING CHANGE:` means MAJOR. Lefthook rejects co-author and generated-by trailers. `git push`, force-push, and `reset --hard` require an explicit user request. +**5.5 MERGE -> CLEANUP.** Feature, fix, docs, chore, refactor, test, CI, and +other development branches do not wait for a release. After the PR is confirmed +server-side `MERGED` and the resulting `main` push CI is green, run from a +separate clean base worktree: + +```powershell +node hooks/post-merge-cleanup.js --branch feat/example --base origin/main +node hooks/post-merge-cleanup.js --branch feat/example --base origin/main --apply +``` + +The helper requires both local and remote refs to be ancestors of the base, +removes only clean linked worktrees, and is idempotent when the branch was +already deleted. Dirty, diverged, or unmerged refs block exact cleanup and are +never forced. `release/*` and `hotfix/*` are deliberately ineligible: retain +them until their tag, published artifacts, and smoke tests succeed. The +release-wide cleanup remains a final audit for merged branches missed earlier. + +For pipelines with separate development and accepted-main roots, finish the +merge/cleanup sequence with a strict, read-only topology audit: + +```powershell +node hooks/repo-state-audit.js --root --accepted-root --base main --strict +``` + +Completion requires matching local `main` SHAs, clean expected worktrees, and +no leftover local branches or linked worktrees. A mismatch means the pipeline +is still active or incomplete; synchronize and clean it explicitly, then rerun +the audit. Do not make the audit delete or reset work automatically. + **6. REPORT.** Report what changed, what was verified with commands and results, what remains, and how the user can test manually. @@ -82,25 +139,44 @@ what remains, and how the user can test manually. ## Release Flow -Run this only on explicit request and after merge to `main`. +Run this only on an explicit release request. A request to make a **full +release** is standing authorization for the normal release actions below: +pushing feature/release branches, creating and merging their PRs, pushing the +computed SemVer tag, publishing the GitHub Release, and deleting merged +development/release branches after smoke testing. Do not pause for a second +approval after the SemVer preview. + +Standing authorization does not allow bypassing hooks/rulesets, force-pushing, +continuing after a failed gate, deleting unmerged branches, or discarding a +dirty worktree. Unexpected history repair, rollback, or destructive recovery +still requires a new user decision. | Step | Action | Gate | |---|---|---| -| R1 | Start from a clean worktree at `origin/main`; `node hooks/doctor.js` is green; read current version with `git describe --tags --abbrev=0`. | | -| R2 | Derive SemVer from Conventional Commits: first `cog bump --auto --dry-run`, then `cog bump --auto --annotated "vX.Y.Z"` to create an annotated tag and changelog. | Show tag, diff, and notes; wait for approval. | -| R2.5 | `node hooks/release-preflight.js --tag vX.Y.Z --base origin/main`: clean tree, tag points to HEAD, remote tag absent, project/package versions match the tag. | | -| R3 | `git push origin && git push origin vX.Y.Z`. | Only after an explicit yes. | -| R4 | If release workflow exists: `gh run watch`; release workflow is green and has no skipped steps. | | -| R5 | `gh release view vX.Y.Z`: release is published and artifacts exist. | | -| R6 | Download artifact, smoke-test it, and verify binary version equals the tag. | | -| R7 | Know rollback: before publication, recreate the tag; after publication, use `gh release delete` plus revert with approval. | | - -Hotfix: branch from the previous tag, fix, PR to `main`, then tag through R2-R6. -A legitimate release commit on `main` can use `HARNESS_ALLOW_MAIN=1 git commit ...`. - -`release-preflight.js` intentionally fails if the tag/changelog are ready but a -project manifest still reports an old version. If no version manifest exists, it -warns; R6 still verifies the binary version. +| R0 | Merge all intended feature/fix work through PRs into `main`; verify each PR and the resulting `main` push are green, then run exact post-merge cleanup for each branch. | No release from an unmerged feature branch; merged development branches should not accumulate while releases are deferred. | +| R1 | Fetch/prune, then create a new clean release worktree from `origin/main`. Run `node hooks/doctor.js`, `node hooks/verify.js`, and `git describe --tags --abbrev=0`. | The latest tag must be an ancestor of `origin/main`; stop on a broken release graph. | +| R2 | Derive SemVer from merged Conventional Commits with `cog bump --auto --dry-run`. Report the computed tag/diff/notes, create `release/vX.Y.Z`, run `node hooks/release-manifest-bump.js --tag vX.Y.Z`, commit manifest changes as `chore(release): prepare vX.Y.Z`, then run `cog bump --auto --annotated "vX.Y.Z"`. | A full-release request continues without another approval. Stop if the bump is inconsistent with the merged commits or manifests. | +| R2.5 | Run prepare preflight: `node hooks/release-preflight.js --tag vX.Y.Z --base origin/main`. | Clean tree; annotated local tag points at release HEAD; remote tag absent; manifests and CHANGELOG match. | +| R3 | Push **only** `release/vX.Y.Z`, create its PR to `main`, wait for required checks, merge it with a merge commit, and verify server-side `MERGED`. | Do not squash/rebase the PR: the locally tagged release commit must remain in `main`. Do not push the tag yet. | +| R4 | Fetch `origin/main`, wait for its push CI, then run `node hooks/release-preflight.js --tag vX.Y.Z --base origin/main --require-tag-in-base`. | The tag commit must now be an ancestor of `origin/main`; remote tag must still be absent. | +| R5 | Push `vX.Y.Z`. Watch the tag-triggered release workflow and require every release step to pass. | The workflow must build from the exact tag, verify, create a source ZIP + SHA-256, smoke-test it, and publish the GitHub Release. | +| R6 | Run `gh release view vX.Y.Z`; download the published ZIP/checksum; compare SHA-256 and smoke-test the downloaded asset. | For this source-only harness, the tag plus matching CHANGELOG version is the version check. Binary/package projects must also verify their reported binary/package version. | +| R7 | From a separate clean base worktree run `node hooks/release-cleanup.js --base origin/main` and then `node hooks/release-cleanup.js --base origin/main --apply`. | Final audit: delete any remaining merged managed branches and clean linked worktrees. Dirty merged branches block cleanup; unmerged branches are skipped and reported. Tags are retained. | +| R8 | Report tag, merge SHAs, workflow URL, Release URL, asset hash, smoke results, cleanup results, and rollback boundary. | The release is complete only after R6 and R7. | + +The source repository's `.github/workflows/release.yml` implements the source +ZIP path. Installed target repositories must provide a release workflow suited +to their own binary/package artifacts; when none exists, reproduce R5-R6 +manually from the exact tag and do not claim the release is complete early. + +Hotfix: branch from the previous tag, fix, PR to `main`, then follow R1-R8. +Do not make a release commit directly on `main`; the version/changelog commit +uses the release PR in R3. + +`release-manifest-bump.js` synchronizes known project manifests before the tag is +created. `release-preflight.js` intentionally fails if the tag/changelog are +ready but a project manifest still reports an old version. If no version +manifest exists, it warns; R6 still verifies the binary version. After `gh pr merge --delete-branch`, GitHub can merge the PR server-side even if the local post-merge pull/rebase fails because of a dirty worktree. Verify with @@ -109,6 +185,11 @@ merge happened. Sync locally only from a clean tree with `git fetch origin` and `git merge --ff-only origin/main`. For releases, prefer a new clean worktree from `origin/main`. +Rollback remains a separate decision: before publication, delete/recreate the +tag only with approval; after publication, use `gh release delete` plus a revert +PR with approval. Branch cleanup never deletes tags, so published release +anchors remain available. + ## Harness Layers **Layer 0 - server ruleset.** The only real enforcement layer. Versioned in diff --git a/README.md b/README.md index 1087e81..a4a78e9 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ common loops: `run.cmd [run|build|publish|stop]`. Run the tests with | Add a target | drop a folder/exe/link onto the “+” tile or the orb | | Create a group | right-click the orb → “New group…” | | Enter a group | click its tile, or hover it for 0.5 s while dragging | +| Enter by group code | hover the orb and type the group's one- or two-digit badge | | Sort a sorter now | middle-click a sorter tile | | Reorder tiles | left-drag a target tile onto another tile; drop on “+” to move it last | | Move the orb | Alt + left-drag (any monitor) | @@ -65,6 +66,11 @@ common loops: `run.cmd [run|build|publish|stop]`. Run the tests with The orb hides automatically in full-screen apps (games, presentations) and can fade out when idle (see Settings). +Group codes stay attached to their groups when tiles are reordered. If both `1` +and `11` exist, `1` opens after the configurable sequence timeout, while typing +the second `1` before that timeout opens `11` immediately. Right-click a group +to edit or disable its code. + ## Interface names Use these names when describing the wheel in issues, docs, or UI changes: diff --git a/cog.toml b/cog.toml index b1b5615..be8230f 100644 --- a/cog.toml +++ b/cog.toml @@ -15,8 +15,8 @@ branch_whitelist = ["main", "master", "feat/**", "fix/**", "docs/**", "hotfix/** [changelog] path = "CHANGELOG.md" -template = "remote" remote = "github.com" +template = "remote" owner = "IvanLarinDev" repository = "dropwheel" authors = [] diff --git a/design/mockups/group-hotkeys/WAIVER.json b/design/mockups/group-hotkeys/WAIVER.json new file mode 100644 index 0000000..d82e2a9 --- /dev/null +++ b/design/mockups/group-hotkeys/WAIVER.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": 1, + "feature": "group-hotkeys", + "uiPaths": [ + "src/Dropwheel/UI/**" + ], + "reason": "Sequential numeric group shortcuts are a functional addition, not a redesign. The only visual change is a small one- or two-digit code badge reusing the existing badge slot, style and palette on group tiles; wheel geometry, layout, colours and animations are untouched. The rest of the diff is keyboard-hook plumbing, settings plumbing and the group-code field in the target editor. No new screen or visual language was introduced, so there is nothing for a mockup set to compare against.", + "approvedBy": "Ivan Larin", + "date": "2026-07-10" +} diff --git a/harness.config.json b/harness.config.json index 8cb4174..09df642 100644 --- a/harness.config.json +++ b/harness.config.json @@ -20,7 +20,9 @@ ".webp", ".pdf" ], - "approvalFile": "APPROVED" + "manifestFile": "DESIGN.json", + "approvalFile": "APPROVED", + "waiverFile": "WAIVER.json" } }, "debugAudit": { diff --git a/hooks/_lib.js b/hooks/_lib.js index 0137167..8493c9a 100644 --- a/hooks/_lib.js +++ b/hooks/_lib.js @@ -11,7 +11,9 @@ const DEFAULT_MOCKUPS = { dir: "design/mockups", min: 4, mockupExtensions: [".html", ".svg", ".png", ".jpg", ".jpeg", ".webp", ".pdf"], + manifestFile: "DESIGN.json", approvalFile: "APPROVED", + waiverFile: "WAIVER.json", }; const DEFAULT_PROTECTED = [ "hooks/", "lefthook.yml", "harness.config.json", ".gitleaks.toml", "cog.toml", diff --git a/hooks/agent/guard.js b/hooks/agent/guard.js index a312395..fb2aa6d 100644 --- a/hooks/agent/guard.js +++ b/hooks/agent/guard.js @@ -345,8 +345,9 @@ function run(ctx, env = process.env) { const mockRoot = cfg.mockups.dir.replace(/\\/g, "/").replace(/\/$/, ""); if (checkEnabled("design-note", env) && !rel.startsWith(mockRoot + "/") && cfg.uiGlobs.map(globToRe).some((re) => re.test(rel))) - notes.push(`guard: GUI file edit (${rel}). DESIGN stage requires >=${cfg.mockups.min} mockups plus APPROVED before code ` + - `(node hooks/new-mockups.js ). The hard gate is design-gate.js in pre-push/CI.`); + notes.push(`guard: GUI file edit (${rel}). Classify the DESIGN evidence as existing-ui, new-ui, or animation, ` + + `then create >=${cfg.mockups.min} matching variants plus APPROVED (node hooks/new-mockups.js --kind ...). ` + + `Backend-only diffs outside ui.globs skip automatically. The hard gate is design-gate.js in pre-push/CI.`); if (checkEnabled("main-note", env)) { const branch = currentBranch(projectDir); if (["main", "master"].includes(branch)) diff --git a/hooks/design-gate.js b/hooks/design-gate.js index 063426a..6544e0a 100644 --- a/hooks/design-gate.js +++ b/hooks/design-gate.js @@ -1,9 +1,9 @@ #!/usr/bin/env node // design-gate.js - DESIGN-stage gate. // -// Policy: GUI work must be preceded by design review. If a branch's changes touch UI -// paths, the SAME branch diff must also touch an APPROVED set of >= N mockups - -// otherwise one old approval would open the gate for all future UI work forever. +// Policy: user-visible GUI work must be preceded by design review. If a branch's +// changes touch UI paths, the SAME branch diff must also touch an APPROVED set of +// mode-appropriate DESIGN evidence. Legacy sets remain valid for compatibility. // // Usage: // node hooks/design-gate.js [--base ] [--root ] [--files a,b,c] [--json] [--strict] @@ -51,36 +51,216 @@ function defaultBase(root) { } // ---------- config shared with guard.js and verify.js ---------- -const { globToRe, loadConfig, changedFiles } = require(path.join(__dirname, "_lib.js")); +const { globToRe, loadConfig, changedFiles, normRel } = require(path.join(__dirname, "_lib.js")); + +// ---------- DESIGN evidence scan ---------- +function safeVariantFile(file) { + return typeof file === "string" && file.length > 0 && !/[\\/]/.test(file) && + path.basename(file) === file && file !== "." && file !== ".."; +} + +function baselineCheck(root, references) { + if (!Array.isArray(references) || references.length === 0) + return { ok: false, reason: "existing-ui evidence requires baselineReferences from the current UI" }; + for (const reference of references) { + if (typeof reference !== "string" || !reference.trim()) + return { ok: false, reason: "baselineReferences must contain repo-relative file paths" }; + const absolute = path.resolve(root, reference); + const relative = path.relative(root, absolute); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) + return { ok: false, reason: `baseline reference is outside the repository: ${reference}` }; + try { + if (!fs.statSync(absolute).isFile()) + return { ok: false, reason: `baseline reference is not a file: ${reference}` }; + } catch { + return { ok: false, reason: `baseline reference does not exist: ${reference}` }; + } + } + return { ok: true }; +} + +function normalizeScopePattern(root, value) { + const rel = normRel(value, root).replace(/\\/g, "/"); + if (!rel || rel === "." || rel === ".." || rel.startsWith("../") || /^(?:[A-Za-z]:)?\//.test(rel)) return ""; + return rel; +} + +function scopeValues(value) { + return String(value || "").split(/[\n,]/).map((s) => s.trim()).filter(Boolean); +} + +function approvalScopePatterns(root, dir, files, m) { + if (!files.includes(m.approvalFile)) return []; + let text = ""; + try { text = fs.readFileSync(path.join(dir, m.approvalFile), "utf8"); } catch { return []; } + const patterns = []; + for (const line of text.split(/\r?\n/)) { + const scoped = line.match(/^\s*(?:ui|ui-paths?|scope)\s*:\s*(.+)$/i); + if (scoped) patterns.push(...scopeValues(scoped[1])); + } + return patterns.map((p) => normalizeScopePattern(root, p)).filter(Boolean); +} + +function manifestScopePatterns(root, manifest) { + const patterns = []; + if (manifest && manifest.scope && Array.isArray(manifest.scope.uiPaths)) + patterns.push(...manifest.scope.uiPaths); + if (manifest && Array.isArray(manifest.uiPaths)) + patterns.push(...manifest.uiPaths); + if (manifest && manifest.kind === "existing-ui" && Array.isArray(manifest.baselineReferences)) + patterns.push(...manifest.baselineReferences); + return patterns.map((p) => normalizeScopePattern(root, p)).filter(Boolean); +} + +function scopeMatches(file, pattern) { + const p = String(pattern || "").replace(/\\/g, "/"); + if (!p) return false; + if (p.endsWith("/")) return file.startsWith(p); + if (/[*?[\]{}]/.test(p)) return globToRe(p).test(file); + return file === p || file.startsWith(p + "/"); +} + +function scopeCoversUi(root, uiChanged, patterns) { + const normalized = [...new Set((patterns || []).map((p) => normalizeScopePattern(root, p)).filter(Boolean))]; + if (!normalized.length) + return { ok: false, reason: "approval scope is missing; add `ui: ` to APPROVED or use WAIVER.json" }; + const uncovered = uiChanged.filter((file) => !normalized.some((pattern) => scopeMatches(file, pattern))); + return uncovered.length + ? { ok: false, reason: `approval scope does not cover UI file(s): ${uncovered.slice(0, 6).join(", ")}` } + : { ok: true, patterns: normalized }; +} + +function validateManifestSet(root, dir, files, m) { + const manifestFile = m.manifestFile || "DESIGN.json"; + const approvalScopes = approvalScopePatterns(root, dir, files, m); + if (!files.includes(manifestFile)) { + const mockups = files.filter((file) => m.mockupExtensions.includes(path.extname(file).toLowerCase())); + return mockups.length >= m.min + ? { ok: true, count: mockups.length, kind: "legacy", legacy: true, scopePatterns: approvalScopes } + : { ok: false, reason: `legacy set has ${mockups.length}/${m.min} visual mockups` }; + } + + let manifest; + try { manifest = JSON.parse(fs.readFileSync(path.join(dir, manifestFile), "utf8")); } + catch { return { ok: false, reason: `${manifestFile} is not valid JSON` }; } + if (manifest.schemaVersion !== 1) + return { ok: false, reason: `${manifestFile} schemaVersion must be 1` }; + if (!new Set(["existing-ui", "new-ui", "animation"]).has(manifest.kind)) + return { ok: false, reason: `${manifestFile} kind must be existing-ui, new-ui, or animation` }; + if (!Array.isArray(manifest.variants) || manifest.variants.length < m.min) + return { ok: false, reason: `${manifestFile} declares fewer than ${m.min} variants` }; + + const variantFiles = manifest.variants.map((variant) => variant && variant.file); + if (variantFiles.some((file) => !safeVariantFile(file))) + return { ok: false, reason: `${manifestFile} variants must be direct child files` }; + if (new Set(variantFiles).size !== variantFiles.length) + return { ok: false, reason: `${manifestFile} variants must be unique` }; + const missing = variantFiles.filter((file) => !files.includes(file)); + if (missing.length) + return { ok: false, reason: `${manifestFile} references missing variant(s): ${missing.join(", ")}` }; + + if (manifest.kind === "existing-ui") { + const baseline = baselineCheck(root, manifest.baselineReferences); + if (!baseline.ok) return baseline; + } + + if (manifest.kind === "animation") { + if (!new Set(["text", "js"]).has(manifest.fidelity)) + return { ok: false, reason: "animation evidence requires fidelity text or js" }; + if (typeof manifest.example !== "string" || !manifest.example.trim()) + return { ok: false, reason: "animation evidence requires a concrete example" }; + const expectedExtension = manifest.fidelity === "text" ? ".md" : ".html"; + const wrongType = variantFiles.find((file) => path.extname(file).toLowerCase() !== expectedExtension); + if (wrongType) + return { ok: false, reason: `animation/${manifest.fidelity} variant has the wrong file type: ${wrongType}` }; + if (manifest.fidelity === "js") { + for (const file of variantFiles) { + let source = ""; + try { source = fs.readFileSync(path.join(dir, file), "utf8"); } catch {} + if (!/)/i.test(source) || !/(?:\.animate\s*\(|requestAnimationFrame\s*\()/i.test(source)) + return { ok: false, reason: `animation/js variant is not an executable motion prototype: ${file}` }; + } + } + } else { + const wrongType = variantFiles.find((file) => !m.mockupExtensions.includes(path.extname(file).toLowerCase())); + if (wrongType) + return { ok: false, reason: `${manifest.kind} variant has an unsupported visual file type: ${wrongType}` }; + } + + return { + ok: true, + count: variantFiles.length, + kind: manifest.kind, + scopePatterns: [...manifestScopePatterns(root, manifest), ...approvalScopes], + ...(manifest.fidelity ? { fidelity: manifest.fidelity } : {}), + }; +} + +function validateWaiverSet(root, dir, files, m, uiChanged) { + const waiverFile = m.waiverFile || "WAIVER.json"; + if (!files.includes(waiverFile)) return null; + let waiver; + try { waiver = JSON.parse(fs.readFileSync(path.join(dir, waiverFile), "utf8")); } + catch { return { ok: false, reason: `${waiverFile} is not valid JSON` }; } + if (waiver.schemaVersion !== 1) + return { ok: false, reason: `${waiverFile} schemaVersion must be 1` }; + if (!Array.isArray(waiver.uiPaths) || waiver.uiPaths.length === 0) + return { ok: false, reason: `${waiverFile} requires uiPaths` }; + if (typeof waiver.reason !== "string" || !waiver.reason.trim()) + return { ok: false, reason: `${waiverFile} requires a reason` }; + if (typeof (waiver.approvedBy || waiver.approvalSource) !== "string" || !(waiver.approvedBy || waiver.approvalSource).trim()) + return { ok: false, reason: `${waiverFile} requires approvedBy or approvalSource` }; + if (typeof waiver.date !== "string" || !/^\d{4}-\d{2}-\d{2}/.test(waiver.date)) + return { ok: false, reason: `${waiverFile} requires an ISO date` }; + const scoped = scopeCoversUi(root, uiChanged, waiver.uiPaths); + return scoped.ok ? { ok: true, count: 0, kind: "waiver", waiver: true, scopePatterns: scoped.patterns } : scoped; +} -// ---------- mockups scan ---------- // An approved set counts only if that same set is touched in this branch diff. // Otherwise one old approval would unlock future UI work forever. -function hasApprovedMockups(root, m, changed) { +function hasApprovedMockups(root, m, changed, uiChanged) { const base = path.join(root, m.dir); const mockRoot = m.dir.replace(/\\/g, "/").replace(/\/$/, ""); + const waiverFile = m.waiverFile || "WAIVER.json"; let dirs; try { dirs = fs.readdirSync(base, { withFileTypes: true }).filter((d) => d.isDirectory()); } catch { return { ok: false, reason: `missing directory ${m.dir}/` }; } const stale = []; + const invalid = []; for (const d of dirs) { const dir = path.join(base, d.name); let files; try { files = fs.readdirSync(dir); } catch { continue; } - const mockups = files.filter((f) => m.mockupExtensions.includes(path.extname(f).toLowerCase())); const approved = files.includes(m.approvalFile); - if (mockups.length < m.min || !approved) continue; - if (changed.some((c) => c.startsWith(`${mockRoot}/${d.name}/`))) - return { ok: true, feature: d.name, count: mockups.length }; + const touched = changed.some((c) => c.startsWith(`${mockRoot}/${d.name}/`)); + const waiverTouched = changed.includes(`${mockRoot}/${d.name}/${waiverFile}`); + if (waiverTouched) { + const waiver = validateWaiverSet(root, dir, files, m, uiChanged); + if (waiver && waiver.ok) return { ok: true, feature: d.name, ...waiver }; + if (waiver) invalid.push(`${d.name}: ${waiver.reason}`); + } + if (!approved) continue; + const check = validateManifestSet(root, dir, files, m); + if (!check.ok) { + if (touched) invalid.push(`${d.name}: ${check.reason}`); + continue; + } + if (touched) { + const scoped = scopeCoversUi(root, uiChanged, check.scopePatterns); + if (scoped.ok) return { ok: true, feature: d.name, ...check, scopePatterns: scoped.patterns }; + invalid.push(`${d.name}: ${scoped.reason}`); + continue; + } stale.push(d.name); } return { ok: false, - reason: stale.length - ? `approved set(s) (${stale.join(", ")}) are not touched in this branch diff; ` + - `touch ${m.dir}//${m.approvalFile} to bind an existing approval to this change` - : `no ${m.dir}// with >=${m.min} mockups and ${m.approvalFile} touched in this branch`, + reason: invalid.length + ? `invalid DESIGN evidence (${invalid.join("; ")})` + : stale.length + ? `approved set(s) (${stale.join(", ")}) are valid but not scoped/touched for this branch diff` + : `no valid scoped DESIGN approval or WAIVER.json touched in this branch`, }; } @@ -114,11 +294,14 @@ function hasApprovedMockups(root, m, changed) { process.exit(0); } - const mk = hasApprovedMockups(a.root, cfg.mockups, files); + const mk = hasApprovedMockups(a.root, cfg.mockups, files, res.uiChanged); res.mockups = mk; if (mk.ok) { if (a.json) console.log(JSON.stringify(res)); - else console.log(`OK design-gate: UI changes have an approved mockup set touched in this branch (${mk.feature}, ${mk.count}).`); + else { + const mode = mk.kind ? `, ${mk.kind}${mk.fidelity ? `/${mk.fidelity}` : ""}` : ""; + console.log(`OK design-gate: UI changes have scoped DESIGN approval touched in this branch (${mk.feature}, ${mk.count}${mode}).`); + } process.exit(0); } @@ -127,9 +310,11 @@ function hasApprovedMockups(root, m, changed) { `BLOCK design-gate: GUI changes require DESIGN approval.\n` + ` UI files: ${res.uiChanged.slice(0, 8).join(", ")}${res.uiChanged.length > 8 ? " ..." : ""}\n` + ` Required: ${mk.reason}.\n` + - ` New set: node hooks/new-mockups.js , get approval, then create ${cfg.mockups.dir}//${cfg.mockups.approvalFile}.\n` + - ` Existing set: touch its ${cfg.mockups.approvalFile} so it appears in this branch diff.\n` + - ` Policy: new/changed GUI needs >=${cfg.mockups.min} stylistically distinct mockups plus approval.` + ` Existing UI: node hooks/new-mockups.js --kind existing-ui --baseline .\n` + + ` Motion: use --kind animation --fidelity text|js --example ; new UI: use --kind new-ui.\n` + + ` Existing set: touch its ${cfg.mockups.approvalFile} and include a ui: scope covering the changed UI path(s).\n` + + ` Waiver: create ${cfg.mockups.dir}//${cfg.mockups.waiverFile || "WAIVER.json"} with uiPaths, reason, date, and approvedBy.\n` + + ` Policy: DESIGN evidence must match the UI change type; backend-only diffs outside ui.globs need none.` ); process.exit(1); })(); diff --git a/hooks/doctor.js b/hooks/doctor.js index 76e3ffb..d17170e 100644 --- a/hooks/doctor.js +++ b/hooks/doctor.js @@ -155,6 +155,22 @@ function checkWorkflowSupplyChain(workflowPath) { : fail("CI AgentShield npm package must pin dist.integrity and set NPM_CONFIG_IGNORE_SCRIPTS=true"); } } +function checkReleaseWorkflowContract(workflowPath) { + const text = readText(workflowPath); + const checks = [ + { name: "v* tag trigger", re: /tags:\s*\[[^\]]*["']?v\*/i }, + { name: "contents write permission", re: /contents:\s*write/i }, + { name: "post-merge preflight", re: /release-preflight\.js[^\n]*--require-tag-in-base[^\n]*--allow-remote-tag/i }, + { name: "VERIFY", re: /node\s+hooks\/verify\.js\b/i }, + { name: "exact-tag git archive", re: /git\s+archive\b/i }, + { name: "SHA-256", re: /Get-FileHash[^\n]*SHA256/i }, + { name: "archive smoke test", re: /Expand-Archive/i }, + { name: "GitHub Release publication", re: /gh\s+release\s+(?:create|upload)/i }, + ]; + const missing = checks.filter((check) => !check.re.test(text)).map((check) => check.name); + if (missing.length) fail(`release workflow is missing required step(s): ${missing.join(", ")}`); + else ok("release workflow validates merged tags, builds/checksums/smokes source ZIP, and publishes GitHub Release"); +} // node / git ok("node " + process.version); @@ -172,10 +188,11 @@ if (!inRepo) { // lefthook wired into .git/hooks? (lefthook install writes a stub referencing lefthook) const hooksDir = gitSafe(["rev-parse", "--git-path", "hooks"]) || ".git/hooks"; + const hooksDirAbs = path.isAbsolute(hooksDir) ? hooksDir : path.join(ROOT, hooksDir); let wired = false; for (const h of ["pre-commit", "commit-msg", "pre-push"]) { try { - if (/lefthook/i.test(fs.readFileSync(path.join(ROOT, hooksDir, h), "utf8"))) { wired = true; break; } + if (/lefthook/i.test(fs.readFileSync(path.join(hooksDirAbs, h), "utf8"))) { wired = true; break; } } catch {} } wired ? ok("lefthook wired into .git/hooks") : warn("hooks are not installed; run: lefthook install"); @@ -218,7 +235,11 @@ const requiredHarnessFiles = [ "hooks/verify.js", "hooks/verify-core.js", "hooks/design-gate.js", + "hooks/release-manifest-bump.js", "hooks/release-preflight.js", + "hooks/post-merge-cleanup.js", + "hooks/release-cleanup.js", + "hooks/repo-state-audit.js", "hooks/new-mockups.js", "hooks/doctor.js", "hooks/apply-ruleset.js", @@ -263,6 +284,7 @@ const textCritical = requiredHarnessFiles.concat([ "CLAUDE.md", "BACKLOG.md", ".github/workflows/ci.yml", + ".github/workflows/release.yml", ".github/CODEOWNERS", ".github/dependabot.yml", "install.cmd", @@ -272,9 +294,11 @@ for (const f of textCritical) checkTextFile(f); // harness.config.json valid JSON const cfgPath = path.join(ROOT, "harness.config.json"); +let harnessConfig = {}; if (fs.existsSync(cfgPath)) { try { const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); + harnessConfig = cfg; ok("harness.config.json is valid JSON"); const hasSelfTest = fs.existsSync(path.join(ROOT, "hooks", "test.js")); const stacks = cfg.verify && Array.isArray(cfg.verify.stacks) ? cfg.verify.stacks : null; @@ -338,6 +362,7 @@ if (fs.existsSync(cogPath)) { } const workflowPath = ".github/workflows/ci.yml"; +const releaseWorkflowPath = ".github/workflows/release.yml"; const rulesetPath = ".github/rulesets/main.json"; if (fs.existsSync(path.join(ROOT, rulesetPath))) { const jobs = workflowJobIds(workflowPath); @@ -355,6 +380,14 @@ if (fs.existsSync(path.join(ROOT, rulesetPath))) { } checkRulesetPrReview(rulesetPath); } +if (fs.existsSync(path.join(ROOT, releaseWorkflowPath))) { + if (harnessConfig.release && harnessConfig.release.sourceZip === true) { + checkReleaseWorkflowContract(releaseWorkflowPath); + } else { + ok("release workflow uses a target-specific artifact contract"); + } + checkWorkflowSupplyChain(releaseWorkflowPath); +} // report const fails = results.filter((r) => r.level === "FAIL").length; diff --git a/hooks/new-mockups.js b/hooks/new-mockups.js index f758305..7f3e9a2 100644 --- a/hooks/new-mockups.js +++ b/hooks/new-mockups.js @@ -1,16 +1,27 @@ #!/usr/bin/env node -// new-mockups.js - scaffold N stylistically-distinct single-file HTML mockups for a -// GUI feature, satisfying the DESIGN stage (BACKLOG P1-5). +// new-mockups.js - scaffold DESIGN evidence that matches the UI change type. // -// Usage: node hooks/new-mockups.js -// Creates design/mockups// with 4 openable HTML mockups (4 different style -// directions) + NOTES.md. Does NOT create APPROVED; you add that after picking a -// direction, which is what the gate checks. +// Usage: +// node hooks/new-mockups.js --kind existing-ui --baseline +// node hooks/new-mockups.js --kind new-ui +// node hooks/new-mockups.js --kind animation --fidelity text|js --example +// node hooks/new-mockups.js --kind backend +// +// The command creates DESIGN.json, NOTES.md, and four mode-specific variants. +// It never creates APPROVED; approval is a separate user decision. const fs = require("fs"); const path = require("path"); const ROOT = process.env.HARNESS_ROOT || path.join(__dirname, ".."); +const VALID_KINDS = new Set(["existing-ui", "new-ui", "animation", "backend"]); + +const LAYOUTS = [ + { id: "01-inline", name: "Inline", focus: "Place the new or changed element in the primary content flow." }, + { id: "02-toolbar", name: "Toolbar", focus: "Place the element in the existing command area." }, + { id: "03-side-panel", name: "Side panel", focus: "Give the element persistent space beside the main content." }, + { id: "04-contextual", name: "Contextual", focus: "Reveal the element next to the object or action that invokes it." }, +]; const STYLES = [ { id: "01-minimal-light", name: "Minimal / Light", bg: "#ffffff", panel: "#f5f6f8", fg: "#1c1e21", accent: "#2d6cdf", radius: "6px", font: "system-ui, 'Segoe UI', sans-serif" }, @@ -19,18 +30,176 @@ const STYLES = [ { id: "04-playful-rounded", name: "Playful / Rounded", bg: "#fef6f0", panel: "#fff0e6", fg: "#3b2b2b", accent: "#ff6b6b", radius: "18px", font: "'Nunito', 'Segoe UI', sans-serif" }, ]; -function mockupHtml(feature, s) { +const MOTIONS = [ + { + id: "01-direct-ease", name: "Direct ease", duration: 160, easing: "cubic-bezier(.2,.8,.2,1)", stagger: 0, + focus: "Fast, restrained feedback with no overshoot.", + sequence: ["The affected element starts immediately.", "It follows the shortest path to the new state.", "The final state holds without a secondary flourish."], + tradeoff: "Clear and inexpensive, but intentionally subtle.", + keyframes: [{ transform: "translate(0, 0) scale(1)" }, { transform: "translate(150px, 0) scale(1)" }], + }, + { + id: "02-soft-settle", name: "Soft settle", duration: 240, easing: "cubic-bezier(.2,.9,.3,1)", stagger: 0, + focus: "A visible arrival with a small overshoot and settle.", + sequence: ["The element accelerates quickly out of its old state.", "It passes the destination by a few pixels.", "It settles into the final position and scale."], + tradeoff: "Feels more physical, but can be distracting when repeated often.", + keyframes: [ + { transform: "translate(0, 0) scale(1)" }, + { transform: "translate(164px, 0) scale(1.04)", offset: 0.78 }, + { transform: "translate(150px, 0) scale(1)" }, + ], + }, + { + id: "03-staggered-reflow", name: "Staggered reflow", duration: 210, easing: "cubic-bezier(.25,.75,.25,1)", stagger: 55, + focus: "Affected elements move in a short sequence so the reflow is easy to follow.", + sequence: ["The directly manipulated element moves first.", "Neighbouring elements follow with a short stagger.", "All elements finish in stable positions."], + tradeoff: "Explains a multi-element change well, but increases total completion time.", + keyframes: [{ transform: "translate(0, 0) scale(1)" }, { transform: "translate(150px, 0) scale(1)" }], + }, + { + id: "04-guided-arc", name: "Guided arc", duration: 280, easing: "cubic-bezier(.22,.7,.2,1)", stagger: 20, + focus: "A curved path makes the source and destination relationship explicit.", + sequence: ["The element lifts away from the source state.", "It travels on a shallow arc above neighbouring content.", "It lands at the destination with a restrained settle."], + tradeoff: "Highly legible for spatial changes, but too expressive for dense repeated actions.", + keyframes: [ + { transform: "translate(0, 0) scale(1)" }, + { transform: "translate(75px, -30px) scale(1.05)", offset: 0.5 }, + { transform: "translate(150px, 0) scale(1)" }, + ], + }, +]; + +function usage() { + return [ + "usage:", + " node hooks/new-mockups.js --kind existing-ui --baseline ", + " node hooks/new-mockups.js --kind new-ui", + " node hooks/new-mockups.js --kind animation --fidelity text|js --example ", + " node hooks/new-mockups.js --kind backend", + ].join("\n"); +} + +function fail(message) { + console.error(message); + console.error(usage()); + process.exit(1); +} + +function parseArgs(argv) { + if (argv.includes("--help") || argv.includes("-h")) { + console.log(usage()); + process.exit(0); + } + const rawFeature = String(argv[0] || "").trim(); + const feature = rawFeature.replace(/[^a-zA-Z0-9._-]/g, "-"); + if (!feature) fail("feature name is required."); + + const out = { feature, kind: "", fidelity: "", example: "", baselines: [] }; + for (let i = 1; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--kind") out.kind = String(argv[++i] || "").trim(); + else if (arg === "--fidelity") out.fidelity = String(argv[++i] || "").trim(); + else if (arg === "--example") out.example = String(argv[++i] || "").trim(); + else if (arg === "--baseline") out.baselines.push(String(argv[++i] || "").trim()); + else fail(`unknown argument: ${arg}`); + } + + if (!VALID_KINDS.has(out.kind)) fail("--kind must be existing-ui, new-ui, animation, or backend."); + if (out.kind === "existing-ui" && out.baselines.length === 0) + fail("existing-ui mockups require at least one --baseline path from the current UI."); + if (out.kind === "animation") { + if (!new Set(["text", "js"]).has(out.fidelity)) fail("animation mockups require --fidelity text or js."); + if (!out.example) fail("animation mockups require --example with a concrete user-visible scenario."); + } + return out; +} + +function normalizeBaselines(values) { + return values.map((value) => { + if (!value) fail("--baseline cannot be empty."); + const absolute = path.resolve(ROOT, value); + const relative = path.relative(ROOT, absolute); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) + fail(`baseline must resolve inside the repository: ${value}`); + if (!fs.existsSync(absolute)) fail(`baseline does not exist: ${value}`); + return relative.replace(/\\/g, "/"); + }); +} + +function html(value) { + return String(value).replace(/&/g, "&").replace(//g, ">") + .replace(/"/g, """).replace(/'/g, "'"); +} + +function layoutMarkup(id) { + if (id === "01-inline") return ` +
+

Current screen

Keep the existing hierarchy and visual tokens.

+
Primary content

The changed element sits in the normal content flow.

+
Changed UI element
+
`; + if (id === "02-toolbar") return ` +
+
Current screen
+

Primary content

The content stays quiet while the changed element uses the command area.

+
`; + if (id === "03-side-panel") return ` +
+

Primary content

The main work area keeps its current structure.

+ +
`; + return ` +
+

Primary content

The changed element appears only near its trigger.

+
Changed UI element

Contextual controls.

+
+
`; +} + +function existingUiHtml(feature, layout, baselines) { + const baselineLabel = baselines.map(html).join(", "); return ` -${feature} - ${s.name} - +${html(feature)} - ${html(layout.name)} + +
${html(feature)}${html(layout.name)} layoutSame visual language, different placement
+
${layoutMarkup(layout.id)}
+`; +} + +function newUiHtml(feature, style) { + return ` + +${html(feature)} - ${html(style.name)} + + +
${html(feature)}${html(style.name)}NEW UI DIRECTION
+
+

Primary area

Develop the new ${html(feature)} experience in the ${html(style.name)} direction.

+

Supporting area

Use this concept to compare a genuinely different visual language.

+`; +} + +function animationText(feature, motion, example, baselines) { + const baseline = baselines.length ? baselines.map((x) => `\`${x}\``).join(", ") : "Current product state"; + return `# ${motion.name} + +Feature: ${feature} +Concrete example: ${example} +Visual baseline: ${baseline} + +## Sequence + +${motion.sequence.map((step, i) => `${i + 1}. ${step}`).join("\n")} + +## Motion spec + +- Duration: ${motion.duration}ms +- Easing: \`${motion.easing}\` +- Stagger: ${motion.stagger}ms +- Intent: ${motion.focus} + +## Tradeoff + +${motion.tradeoff} +`; +} + +function animationJsHtml(feature, motion, example, baselines) { + const baseline = baselines.length ? baselines.join(", ") : "current product state"; + const spec = JSON.stringify({ keyframes: motion.keyframes, duration: motion.duration, easing: motion.easing, stagger: motion.stagger }); + return ` + +${html(feature)} - ${html(motion.name)} + -
${feature}${s.name}MOCKUP - ${s.id}
- -
-

Panel A

-

Replace this skeleton with the real ${feature} screen in the ${s.name} direction.

-
-

Panel B

-

Use distinct directions to compare the design before implementation.

-
+

${html(motion.name)}

${html(example)}
Baseline: ${html(baseline)}
+
A
B
C
+
${html(motion.focus)} ${motion.duration}ms.
+ `; } -const NOTES = (feature, styles) => `# Mockups - ${feature} +function notes(args, variants) { + const details = []; + if (args.baselines.length) details.push(`Baseline references: ${args.baselines.map((x) => `\`${x}\``).join(", ")}.`); + if (args.example) details.push(`Concrete example: ${args.example}.`); + let guidance = "Keep the four concepts stylistically distinct because this UI is being created from scratch."; + if (args.kind === "existing-ui") + guidance = "Match the referenced UI's real components, spacing, type, and colours. Compare layout and placement only; do not turn the variants into unrelated themes."; + if (args.kind === "animation" && args.fidelity === "text") + guidance = "Review the written motion sequences first. Build an interactive prototype only if timing or physical feel remains ambiguous."; + if (args.kind === "animation" && args.fidelity === "js") + guidance = "Each HTML file must remain an executable JavaScript motion prototype for the same concrete scenario and visual baseline."; + return `# Mockups - ${args.feature} + +DESIGN classification: \`${args.kind}\`${args.kind === "animation" ? ` / \`${args.fidelity}\`` : ""}. +${details.join("\n")} + +## Review rule -DESIGN stage for the GUI feature "${feature}". Rule: >=${styles.length} stylistically distinct -mockups plus approval before GUI implementation. +${guidance} ## Variants -${styles.map((s) => `- \`${s.id}.html\` - ${s.name}`).join("\n")} + +${variants.map((variant) => `- \`${variant.file}\` - ${variant.focus}`).join("\n")} ## Closing the gate -1. Turn the skeletons into real screen mockups. -2. Review and choose a direction. -3. Create an \`APPROVED\` file in this directory. -4. Implement GUI code only after approval. + +1. Refine every variant into credible evidence for this feature. +2. Review the alternatives and choose a direction. +3. Create an \`APPROVED\` file in this directory with a \`ui: \` line. +4. Implement the user-visible change only after approval. `; +} function main() { - const feature = (process.argv[2] || "").trim().replace(/[^a-zA-Z0-9._-]/g, "-"); - if (!feature) { console.error("usage: node hooks/new-mockups.js "); process.exit(1); } - const dir = path.join(ROOT, "design", "mockups", feature); - if (fs.existsSync(dir)) { console.error(`already exists: design/mockups/${feature}/; not overwriting.`); process.exit(1); } + const args = parseArgs(process.argv.slice(2)); + args.baselines = normalizeBaselines(args.baselines); + + if (args.kind === "backend") { + console.log(`SKIP mockups: ${args.feature} is backend-only with no user-visible UI impact.`); + console.log("design-gate skips automatically only when the changed files do not match ui.globs."); + return; + } + + const dir = path.join(ROOT, "design", "mockups", args.feature); + if (fs.existsSync(dir)) fail(`already exists: design/mockups/${args.feature}/; not overwriting.`); fs.mkdirSync(dir, { recursive: true }); - for (const s of STYLES) fs.writeFileSync(path.join(dir, s.id + ".html"), mockupHtml(feature, s)); - fs.writeFileSync(path.join(dir, "NOTES.md"), NOTES(feature, STYLES)); - console.log(`created ${STYLES.length} mockups: design/mockups/${feature}/`); - STYLES.forEach((s) => console.log(` - ${s.id}.html (${s.name})`)); - console.log(`Next: refine mockups, get approval, then create design/mockups/${feature}/APPROVED`); + + let variants; + if (args.kind === "existing-ui") { + variants = LAYOUTS.map((layout) => ({ file: `${layout.id}.html`, focus: layout.focus, content: existingUiHtml(args.feature, layout, args.baselines) })); + } else if (args.kind === "new-ui") { + variants = STYLES.map((style) => ({ file: `${style.id}.html`, focus: style.name, content: newUiHtml(args.feature, style) })); + } else if (args.fidelity === "text") { + variants = MOTIONS.map((motion) => ({ file: `${motion.id}.md`, focus: motion.focus, content: animationText(args.feature, motion, args.example, args.baselines) })); + } else { + variants = MOTIONS.map((motion) => ({ file: `${motion.id}.html`, focus: motion.focus, content: animationJsHtml(args.feature, motion, args.example, args.baselines) })); + } + + for (const variant of variants) fs.writeFileSync(path.join(dir, variant.file), variant.content); + const manifest = { + schemaVersion: 1, + feature: args.feature, + kind: args.kind, + ...(args.kind === "animation" ? { fidelity: args.fidelity, example: args.example } : {}), + ...(args.baselines.length ? { baselineReferences: args.baselines } : {}), + variants: variants.map(({ file, focus }) => ({ file, focus })), + }; + fs.writeFileSync(path.join(dir, "DESIGN.json"), JSON.stringify(manifest, null, 2) + "\n"); + fs.writeFileSync(path.join(dir, "NOTES.md"), notes(args, manifest.variants)); + + console.log(`created ${variants.length} ${args.kind}${args.kind === "animation" ? `/${args.fidelity}` : ""} variants: design/mockups/${args.feature}/`); + variants.forEach((variant) => console.log(` - ${variant.file}`)); + console.log(`Next: refine variants, get approval, then create design/mockups/${args.feature}/APPROVED with a ui: scope`); } + main(); diff --git a/hooks/post-merge-cleanup.js b/hooks/post-merge-cleanup.js new file mode 100644 index 0000000..cd5c284 --- /dev/null +++ b/hooks/post-merge-cleanup.js @@ -0,0 +1,15 @@ +#!/usr/bin/env node +// Remove one merged development branch after its PR is confirmed MERGED and +// the resulting main CI succeeds. Release/hotfix branches use release cleanup +// after artifact smoke testing instead. +// +// Usage: +// node hooks/post-merge-cleanup.js --branch feat/name [--root ] +// [--base origin/main] [--remote origin] [--no-fetch] [--apply] [--json] + +const { main } = require("./release-cleanup"); + +main(process.argv.slice(2), { + requireBranch: true, + label: "post-merge cleanup", +}); diff --git a/hooks/release-cleanup.js b/hooks/release-cleanup.js new file mode 100644 index 0000000..92a9a03 --- /dev/null +++ b/hooks/release-cleanup.js @@ -0,0 +1,252 @@ +#!/usr/bin/env node +// release-cleanup.js - remove merged development/release branches after a +// published release has passed artifact smoke testing. The shared --branch +// mode powers post-merge cleanup for one development branch. +// +// Dry-run is the default. --apply authorizes deletion, but only for branches +// with a known development prefix whose commits are ancestors of --base. +// Dirty worktrees and diverged refs block cleanup. Unmerged branches block +// exact mode and are reported-but-skipped in release-wide mode. Tags are never +// touched. Local and remote deletion use the OIDs that passed ancestry checks. +// +// Usage: +// node hooks/release-cleanup.js [--root ] [--base origin/main] +// [--remote origin] [--branch feat/name] [--no-fetch] [--apply] [--json] + +const path = require("path"); +const { execFileSync } = require("child_process"); + +const BRANCH_PREFIXES = [ + "codex/", "feat/", "feature/", "fix/", "bugfix/", "docs/", "chore/", + "refactor/", "perf/", "build/", "style/", "release/", "hotfix/", + "test/", "ci/", "improvement/", +]; +const PROTECTED_BRANCHES = new Set(["main", "master"]); +const RELEASE_PREFIXES = ["release/", "hotfix/"]; + +function parseArgs(argv) { + const a = { + root: process.cwd(), base: "origin/main", remote: "origin", + branch: "", fetch: true, apply: false, json: false, argErrors: [], + }; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--root") a.root = argv[++i]; + else if (argv[i] === "--base") a.base = argv[++i]; + else if (argv[i] === "--remote") a.remote = argv[++i]; + else if (argv[i] === "--branch") { + if (!argv[i + 1] || argv[i + 1].startsWith("--")) a.argErrors.push("--branch requires a branch name"); + else a.branch = argv[++i]; + } + else if (argv[i] === "--no-fetch") a.fetch = false; + else if (argv[i] === "--apply") a.apply = true; + else if (argv[i] === "--json") a.json = true; + } + a.root = path.resolve(a.root); + return a; +} + +function run(root, args, opts = {}) { + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: opts.remote ? 60000 : 10000, + killSignal: "SIGKILL", + }).trim(); +} + +function result(root, args, opts) { + try { return { ok: true, out: run(root, args, opts) }; } + catch (e) { return { ok: false, error: String((e && e.stderr) || (e && e.message) || "").trim() }; } +} + +function lines(text) { + return String(text || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean); +} + +function isManagedBranch(name) { + return !PROTECTED_BRANCHES.has(name) && BRANCH_PREFIXES.some((prefix) => name.startsWith(prefix)); +} + +function isPostMergeBranch(name) { + return isManagedBranch(name) && !RELEASE_PREFIXES.some((prefix) => name.startsWith(prefix)); +} + +function isAncestor(root, ref, base) { + return result(root, ["merge-base", "--is-ancestor", ref, base]).ok; +} + +function parseWorktrees(text) { + const out = []; + let item = null; + for (const raw of String(text || "").split(/\r?\n/)) { + if (raw.startsWith("worktree ")) { + if (item) out.push(item); + item = { path: raw.slice("worktree ".length), branch: "" }; + } else if (item && raw.startsWith("branch refs/heads/")) { + item.branch = raw.slice("branch refs/heads/".length); + } + } + if (item) out.push(item); + return out; +} + +function samePath(a, b) { + const normalize = (value) => path.resolve(value).replace(/\\/g, "/").toLowerCase(); + return normalize(a) === normalize(b); +} + +function main(argv = process.argv.slice(2), options = {}) { + const a = parseArgs(argv); + const label = options.label || (a.branch ? "post-merge cleanup" : "release cleanup"); + const report = { + ok: true, + apply: a.apply, + mode: a.branch ? "branch" : "release", + branch: a.branch, + base: a.base, + remote: a.remote, + candidates: [], + removedWorktrees: [], + deletedLocal: [], + deletedRemote: [], + blocked: [], + skipped: [], + absent: [], + errors: [], + }; + + report.errors.push(...a.argErrors); + if (options.requireBranch && !a.branch) report.errors.push("--branch is required for post-merge cleanup"); + if (a.branch && !isPostMergeBranch(a.branch)) { + report.errors.push(`branch is not eligible for post-merge cleanup: ${a.branch}`); + } + + if (report.errors.length === 0 && !result(a.root, ["rev-parse", "--is-inside-work-tree"]).ok) { + report.errors.push("not a git repository"); + } + if (report.errors.length === 0 && !result(a.root, ["rev-parse", "--verify", "--quiet", a.base]).ok) { + report.errors.push(`base ref not found: ${a.base}`); + } + if (report.errors.length === 0 && !result(a.root, ["remote", "get-url", a.remote]).ok) { + report.errors.push(`remote not found: ${a.remote}`); + } + if (report.errors.length === 0 && a.fetch) { + const fetched = result(a.root, ["fetch", a.remote, "--prune"], { remote: true }); + if (!fetched.ok) report.errors.push(`fetch failed: ${fetched.error}`); + } + + if (report.errors.length === 0) { + const localRefs = lines(run(a.root, ["for-each-ref", "--format=%(refname:short)%09%(objectname)", "refs/heads"])) + .map((line) => { + const [name, oid] = line.split("\t"); + return { name, oid }; + }); + const remotePrefix = `${a.remote}/`; + const remoteRefs = lines(run(a.root, ["for-each-ref", "--format=%(refname:short)%09%(objectname)", `refs/remotes/${a.remote}`])) + .map((line) => { + const [ref, oid] = line.split("\t"); + return { ref, oid, name: ref.startsWith(remotePrefix) ? ref.slice(remotePrefix.length) : "" }; + }) + .filter((item) => item.name && item.ref !== `${a.remote}/HEAD`); + const worktrees = parseWorktrees(run(a.root, ["worktree", "list", "--porcelain"])); + const worktreeByBranch = new Map(worktrees.filter((item) => item.branch).map((item) => [item.branch, item.path])); + const localMap = new Map(localRefs.map((item) => [item.name, item])); + const remoteMap = new Map(remoteRefs.map((item) => [item.name, item])); + const names = new Set([...localRefs.map((item) => item.name), ...remoteRefs.map((item) => item.name)]); + const scopedNames = a.branch ? [a.branch] : [...names].filter(isManagedBranch).sort(); + + for (const name of scopedNames) { + const local = localMap.get(name); + const localExists = !!local; + const remote = remoteMap.get(name); + const remoteRef = remote ? remote.ref : ""; + if (!localExists && !remoteRef) { + report.absent.push(name); + continue; + } + const localMerged = localExists && isAncestor(a.root, name, a.base); + const remoteMerged = !!remoteRef && isAncestor(a.root, remoteRef, a.base); + const entry = { + branch: name, + local: localExists, + localOid: local ? local.oid : "", + remote: !!remoteRef, + remoteOid: remote ? remote.oid : "", + worktree: worktreeByBranch.get(name) || "", + }; + const reasons = []; + if (localExists && !localMerged) reasons.push("local branch contains commits not merged into base"); + if (remoteRef && !remoteMerged) reasons.push("remote branch contains commits not merged into base"); + if (!localMerged && !remoteMerged) { + const target = a.branch ? report.blocked : report.skipped; + target.push({ ...entry, reasons }); + continue; + } + if (entry.worktree && samePath(entry.worktree, a.root)) reasons.push("branch is checked out in the cleanup worktree"); + if (entry.worktree && !samePath(entry.worktree, a.root)) { + const status = result(entry.worktree, ["status", "--porcelain"]); + if (!status.ok) reasons.push("cannot inspect linked worktree"); + else if (status.out) reasons.push("linked worktree is dirty"); + } + + if (reasons.length) { + report.blocked.push({ ...entry, reasons }); + continue; + } + report.candidates.push(entry); + } + + if (a.apply) { + for (const entry of report.candidates) { + let localSafe = true; + if (entry.worktree) { + const removed = result(a.root, ["worktree", "remove", entry.worktree]); + if (!removed.ok) { + localSafe = false; + report.errors.push(`cannot remove worktree for ${entry.branch}: ${removed.error}`); + } else { + report.removedWorktrees.push(entry.worktree); + } + } + if (entry.local && localSafe) { + const deleted = result(a.root, ["update-ref", "-d", `refs/heads/${entry.branch}`, entry.localOid]); + if (!deleted.ok) { + localSafe = false; + report.errors.push(`cannot delete local branch ${entry.branch} at ${entry.localOid}: ${deleted.error}`); + } else { + report.deletedLocal.push(entry.branch); + } + } + if (entry.remote && localSafe) { + const lease = `--force-with-lease=refs/heads/${entry.branch}:${entry.remoteOid}`; + const deleted = result(a.root, ["push", lease, a.remote, "--delete", entry.branch], { remote: true }); + if (!deleted.ok) report.errors.push(`cannot delete remote branch ${entry.branch}: ${deleted.error}`); + else report.deletedRemote.push(entry.branch); + } + } + } + } + + report.ok = report.errors.length === 0 && report.blocked.length === 0; + if (a.json) { + console.log(JSON.stringify(report)); + } else { + console.log(`${label} (${a.apply ? "apply" : "dry-run"}): ${a.base}`); + for (const entry of report.candidates) console.log(` ${a.apply ? "deleted" : "candidate"}: ${entry.branch}`); + for (const entry of report.blocked) console.log(` blocked: ${entry.branch} (${entry.reasons.join("; ")})`); + for (const entry of report.skipped) console.log(` skipped: ${entry.branch} (${entry.reasons.join("; ")})`); + for (const branch of report.absent) console.log(` absent: ${branch}`); + for (const error of report.errors) console.log(` error: ${error}`); + if (!a.apply && report.candidates.length) { + const gate = a.branch ? "the PR is MERGED and main CI succeeds" : "release smoke testing succeeds"; + console.log(`\nRe-run with --apply after ${gate}.`); + } + console.log(report.ok ? `\n${label} passed.` : `\n${label} incomplete.`); + } + process.exit(report.ok ? 0 : 1); +} + +if (require.main === module) main(); + +module.exports = { main, parseArgs, isManagedBranch, isPostMergeBranch }; diff --git a/hooks/release-manifest-bump.js b/hooks/release-manifest-bump.js new file mode 100644 index 0000000..8b6bde9 --- /dev/null +++ b/hooks/release-manifest-bump.js @@ -0,0 +1,169 @@ +#!/usr/bin/env node +// release-manifest-bump.js - synchronize project version manifests before cog tags. +// +// Usage: +// node hooks/release-manifest-bump.js --tag v1.2.3 [--root ] [--dry-run] [--json] +// node hooks/release-manifest-bump.js --version 1.2.3 [--root ] [--dry-run] [--json] + +const fs = require("fs"); +const path = require("path"); + +const SKIP_DIRS = new Set([".git", "node_modules", "target", "bin", "obj", "dist", "build", ".venv", "venv", "__pycache__", ".next"]); +const MAX_DEPTH = 6; + +function parseArgs(argv) { + const a = { root: process.cwd(), tag: "", version: "", dryRun: false, json: false }; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--root") a.root = argv[++i]; + else if (argv[i] === "--tag") a.tag = argv[++i]; + else if (argv[i] === "--version") a.version = argv[++i]; + else if (argv[i] === "--dry-run") a.dryRun = true; + else if (argv[i] === "--json") a.json = true; + } + a.root = path.resolve(a.root); + return a; +} + +function semverFromTag(tag) { + const m = String(tag || "").match(/^v(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)$/); + return m ? m[1] : ""; +} + +function validVersion(version) { + return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(version || "")); +} + +function escapeRe(s) { + return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function walk(root, visit) { + function rec(dir, depth) { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const e of entries) { + const p = path.join(dir, e.name); + if (e.isDirectory()) { + if (depth < MAX_DEPTH && !SKIP_DIRS.has(e.name)) rec(p, depth + 1); + } else if (e.isFile()) { + visit(p, path.relative(root, p).replace(/\\/g, "/")); + } + } + } + rec(root, 0); +} + +function replaceXmlVersion(text, version) { + let from = ""; + let next = text.replace(/(\s*)([^<\s]+)(\s*<\/Version>)/i, (_m, pre, old, post) => { + from = old; + return pre + version + post; + }); + if (from) return { next, from }; + next = text.replace(/(\s*)([^<\s]+)(\s*<\/VersionPrefix>)/i, (_m, pre, old, post) => { + from = old; + return pre + version + post; + }); + return from ? { next, from } : null; +} + +function replaceTomlVersion(text, section, version) { + const re = new RegExp(`(^\\s*\\[${escapeRe(section)}\\]\\s*$)([\\s\\S]*?)(?=^\\s*\\[[^\\]]+\\]\\s*$|\\s*$)`, "m"); + const m = re.exec(text); + if (!m) return null; + let from = ""; + const body = m[2].replace(/^(\s*version\s*=\s*")([^"]+)(".*)$/m, (_line, pre, old, post) => { + from = old; + return pre + version + post; + }); + if (!from) return null; + return { next: text.slice(0, m.index) + m[1] + body + text.slice(m.index + m[0].length), from }; +} + +function packageJsonUpdate(abs, version) { + if (path.basename(abs) !== "package.json") return null; + let text = ""; + try { text = fs.readFileSync(abs, "utf8"); } catch { return null; } + let json; + try { json = JSON.parse(text); } catch { return null; } + if (typeof json.version !== "string" || !json.version) return null; + const from = json.version; + json.version = version; + return { kind: "package.json", from, next: JSON.stringify(json, null, 2) + "\n" }; +} + +function textUpdate(abs, rel, version) { + let text = ""; + try { text = fs.readFileSync(abs, "utf8"); } catch { return null; } + if (rel.toLowerCase().endsWith(".csproj")) { + const hit = replaceXmlVersion(text, version); + return hit ? { kind: "csproj", from: hit.from, next: hit.next } : null; + } + if (path.basename(rel) === "Cargo.toml") { + const hit = replaceTomlVersion(text, "package", version); + return hit ? { kind: "Cargo.toml", from: hit.from, next: hit.next } : null; + } + if (path.basename(rel) === "pyproject.toml") { + const hit = replaceTomlVersion(text, "project", version); + return hit ? { kind: "pyproject.toml", from: hit.from, next: hit.next } : null; + } + return null; +} + +function updateManifest(abs, rel, version, dryRun) { + const hit = packageJsonUpdate(abs, version) || textUpdate(abs, rel, version); + if (!hit) return null; + const changed = hit.from !== version; + if (changed && !dryRun) fs.writeFileSync(abs, hit.next); + return { rel, kind: hit.kind, from: hit.from, to: version, changed }; +} + +function add(res, level, msg, extra = {}) { + res.results.push({ level, msg, ...extra }); +} +function fail(res, msg, extra) { add(res, "FAIL", msg, extra); } +function warn(res, msg, extra) { add(res, "WARN", msg, extra); } +function pass(res, msg, extra) { add(res, "PASS", msg, extra); } + +function main() { + const a = parseArgs(process.argv.slice(2)); + const version = a.version || semverFromTag(a.tag); + const res = { ok: true, root: a.root, tag: a.tag, version, dryRun: a.dryRun, results: [], manifests: [] }; + + if (!validVersion(version)) { + fail(res, "release version is required; pass --tag vX.Y.Z or --version X.Y.Z"); + } else { + walk(a.root, (abs, rel) => { + try { + const hit = updateManifest(abs, rel, version, a.dryRun); + if (hit) res.manifests.push(hit); + } catch (e) { + fail(res, `could not update ${rel}: ${e.message || e}`); + } + }); + if (!res.manifests.length) { + warn(res, "no project version manifests found"); + } else { + const changed = res.manifests.filter((m) => m.changed); + changed.length + ? pass(res, `${a.dryRun ? "would update" : "updated"} ${changed.length}/${res.manifests.length} project version manifest(s)`) + : pass(res, `project version manifests already match ${version}`); + } + } + + res.ok = !res.results.some((r) => r.level === "FAIL"); + if (a.json) { + console.log(JSON.stringify(res)); + } else { + const icon = { PASS: "+", WARN: "!", FAIL: "X" }; + console.log(`release manifest bump: ${version || "(missing version)"}`); + for (const r of res.results) console.log(` ${icon[r.level]} ${r.msg}`); + for (const m of res.manifests) { + console.log(` ${m.changed ? "~" : "="} ${m.rel}: ${m.from} -> ${m.to} (${m.kind})`); + } + console.log(res.ok ? "\nrelease manifest bump passed." : "\nrelease manifest bump failed."); + } + process.exit(res.ok ? 0 : 1); +} + +if (require.main === module) main(); diff --git a/hooks/release-preflight.js b/hooks/release-preflight.js index bdeb547..8640ec1 100644 --- a/hooks/release-preflight.js +++ b/hooks/release-preflight.js @@ -3,7 +3,8 @@ // // Checks the risky parts that are easy to miss by prose: // - clean worktree; -// - release HEAD is based on the configured base ref; +// - prepare mode: release HEAD is based on the configured base ref; +// - post-merge mode: the release tag commit is already included in the base ref; // - the local release tag exists and points at HEAD; // - the remote tag does not already exist; // - project manifest versions match the release tag. @@ -13,6 +14,7 @@ // --allow-dirty do not fail on dirty worktree // --allow-missing-tag do not require a local tag at HEAD yet // --allow-remote-tag allow the tag to already exist on origin +// --require-tag-in-base require the tag commit to be an ancestor of --base const fs = require("fs"); const path = require("path"); @@ -30,6 +32,7 @@ function parseArgs(argv) { allowDirty: false, allowMissingTag: false, allowRemoteTag: false, + requireTagInBase: false, }; for (let i = 0; i < argv.length; i++) { if (argv[i] === "--root") a.root = argv[++i]; @@ -39,6 +42,7 @@ function parseArgs(argv) { else if (argv[i] === "--allow-dirty") a.allowDirty = true; else if (argv[i] === "--allow-missing-tag") a.allowMissingTag = true; else if (argv[i] === "--allow-remote-tag") a.allowRemoteTag = true; + else if (argv[i] === "--require-tag-in-base") a.requireTagInBase = true; } a.root = path.resolve(a.root); return a; @@ -125,8 +129,10 @@ function csprojVersion(abs, rel) { if (!rel.toLowerCase().endsWith(".csproj")) return null; let text = ""; try { text = fs.readFileSync(abs, "utf8"); } catch { return null; } - const version = firstMatch(text, /\s*([^<\s]+)\s*<\/Version>/i) || - firstMatch(text, /\s*([^<\s]+)\s*<\/VersionPrefix>/i); + const explicitVersion = firstMatch(text, /\s*([^<\s]+)\s*<\/Version>/i); + const versionPrefix = firstMatch(text, /\s*([^<\s]+)\s*<\/VersionPrefix>/i); + const versionSuffix = firstMatch(text, /\s*([^<\s]+)\s*<\/VersionSuffix>/i); + const version = explicitVersion || (versionPrefix ? (versionSuffix ? `${versionPrefix}-${versionSuffix}` : versionPrefix) : ""); return version ? { rel, kind: "csproj", version } : null; } @@ -170,6 +176,15 @@ function checkGitState(a, res) { if (a.base) { if (!gitOk(a.root, ["rev-parse", "--verify", "--quiet", a.base])) { fail(res, `base ref not found: ${a.base}`); + } else if (a.requireTagInBase) { + const tagRef = a.tag ? `refs/tags/${a.tag}^{}` : ""; + if (!tagRef || !gitOk(a.root, ["rev-parse", "--verify", "--quiet", tagRef])) { + fail(res, `cannot verify tag ancestry in ${a.base}: local tag is missing`); + } else if (gitOk(a.root, ["merge-base", "--is-ancestor", tagRef, a.base])) { + pass(res, `tag ${a.tag} is included in ${a.base}`); + } else { + fail(res, `tag ${a.tag} is not included in ${a.base}`); + } } else if (gitOk(a.root, ["merge-base", "--is-ancestor", a.base, "HEAD"])) { pass(res, `HEAD is based on ${a.base}`); } else { @@ -245,7 +260,13 @@ function checkChangelog(a, res) { function main() { const a = parseArgs(process.argv.slice(2)); - const res = { ok: true, tag: a.tag, root: a.root, results: [] }; + const res = { + ok: true, + tag: a.tag, + root: a.root, + mode: a.requireTagInBase ? "post-merge" : "prepare", + results: [], + }; const version = semverFromTag(a.tag); checkGitState(a, res); diff --git a/hooks/repo-state-audit.js b/hooks/repo-state-audit.js new file mode 100644 index 0000000..9a42553 --- /dev/null +++ b/hooks/repo-state-audit.js @@ -0,0 +1,184 @@ +#!/usr/bin/env node +// repo-state-audit.js - verify that development and accepted-main checkouts +// converge to one clean base commit with no leftover branches or worktrees. +// +// Usage: +// node hooks/repo-state-audit.js [--root ] [--accepted-root ] +// [--base main] [--strict] [--json] + +const fs = require("fs"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +function parseArgs(argv) { + const out = { + root: process.cwd(), acceptedRoot: "", base: "main", + strict: false, json: false, errors: [], + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--root" || arg === "--accepted-root" || arg === "--base") { + if (!argv[i + 1] || argv[i + 1].startsWith("--")) out.errors.push(`${arg} requires a value`); + else if (arg === "--root") out.root = argv[++i]; + else if (arg === "--accepted-root") out.acceptedRoot = argv[++i]; + else out.base = argv[++i]; + } else if (arg === "--strict") out.strict = true; + else if (arg === "--json") out.json = true; + else out.errors.push(`unknown option: ${arg}`); + } + out.root = path.resolve(out.root); + if (out.acceptedRoot) out.acceptedRoot = path.resolve(out.acceptedRoot); + return out; +} + +function run(root, args) { + return execFileSync("git", args, { + cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], + timeout: 10000, killSignal: "SIGKILL", + }).trim(); +} + +function result(root, args) { + try { return { ok: true, out: run(root, args) }; } + catch (e) { return { ok: false, error: String(e.stderr || e.message || "").trim() }; } +} + +function normalized(value) { + const resolved = path.resolve(value); + let canonical = resolved; + try { canonical = fs.realpathSync.native(resolved); } catch {} + return canonical.replace(/\\/g, "/").toLowerCase(); +} + +function parseWorktrees(text) { + const out = []; + let item = null; + for (const raw of String(text || "").split(/\r?\n/)) { + if (raw.startsWith("worktree ")) { + if (item) out.push(item); + item = { path: raw.slice("worktree ".length), branch: "", detached: false }; + } else if (item && raw.startsWith("branch refs/heads/")) { + item.branch = raw.slice("branch refs/heads/".length); + } else if (item && raw === "detached") { + item.detached = true; + } + } + if (item) out.push(item); + return out; +} + +function issue(report, code, message, details = {}) { + report.issues.push({ code, message, ...details }); +} + +function inspectRoot(root, role, base, report) { + if (!fs.existsSync(root)) { + issue(report, "missing_root", `${role} root does not exist: ${root}`, { role, root }); + return null; + } + if (!result(root, ["rev-parse", "--is-inside-work-tree"]).ok) { + issue(report, "not_git", `${role} root is not a git worktree: ${root}`, { role, root }); + return null; + } + const common = result(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]); + const baseRef = base.startsWith("refs/") ? base : `refs/heads/${base}`; + const baseResult = result(root, ["rev-parse", "--verify", baseRef]); + if (!baseResult.ok) { + issue(report, "missing_base", `${role} base ref does not exist: ${base}`, { role, root, base }); + } + const info = { + role, root, commonDir: common.ok ? normalized(common.out) : normalized(path.join(root, ".git")), + baseSha: baseResult.ok ? baseResult.out : "", + }; + report.roots.push(info); + return info; +} + +function audit(argv = process.argv.slice(2)) { + const args = parseArgs(argv); + const report = { + ok: true, strict: args.strict, base: args.base, roots: [], issues: [], + mergedBranches: [], unmergedBranches: [], dirtyWorktrees: [], extraWorktrees: [], + }; + for (const error of args.errors) issue(report, "invalid_argument", error); + if (args.errors.length) return report; + + const inputs = [{ root: args.root, role: "development" }]; + if (args.acceptedRoot) inputs.push({ root: args.acceptedRoot, role: "accepted" }); + const inspected = inputs.map((item) => inspectRoot(item.root, item.role, args.base, report)).filter(Boolean); + const groups = new Map(); + for (const item of inspected) { + if (!groups.has(item.commonDir)) groups.set(item.commonDir, []); + groups.get(item.commonDir).push(item); + } + + for (const members of groups.values()) { + const probe = members[0].root; + const allowed = new Set(members.map((item) => normalized(item.root))); + const worktreesResult = result(probe, ["worktree", "list", "--porcelain"]); + if (!worktreesResult.ok) { + issue(report, "worktree_list_failed", `cannot list worktrees: ${worktreesResult.error}`, { root: probe }); + continue; + } + for (const worktree of parseWorktrees(worktreesResult.out)) { + const worktreePath = path.resolve(worktree.path); + if (!allowed.has(normalized(worktreePath))) { + const entry = { root: probe, path: worktreePath, branch: worktree.branch, detached: worktree.detached }; + report.extraWorktrees.push(entry); + issue(report, "extra_worktree", `unexpected worktree remains: ${worktreePath}`, entry); + } + const status = result(worktreePath, ["status", "--porcelain"]); + if (!status.ok || status.out) { + const entry = { root: probe, path: worktreePath, status: status.ok ? status.out.split(/\r?\n/) : [], error: status.error || "" }; + report.dirtyWorktrees.push(entry); + issue(report, "dirty_worktree", `worktree is not clean: ${worktreePath}`, entry); + } + } + + const localRefs = result(probe, ["for-each-ref", "--format=%(refname:short)%09%(objectname)", "refs/heads"]); + if (!localRefs.ok) { + issue(report, "branch_list_failed", `cannot list local branches: ${localRefs.error}`, { root: probe }); + continue; + } + const baseRef = args.base.startsWith("refs/") ? args.base : `refs/heads/${args.base}`; + for (const line of String(localRefs.out || "").split(/\r?\n/).filter(Boolean)) { + const [branch, oid] = line.split("\t"); + if (`refs/heads/${branch}` === baseRef) continue; + const ancestor = result(probe, ["merge-base", "--is-ancestor", oid, baseRef]).ok; + const entry = { root: probe, branch, oid }; + if (ancestor) { + report.mergedBranches.push(entry); + issue(report, "merged_branch", `merged local branch remains: ${branch}`, entry); + } else { + report.unmergedBranches.push(entry); + issue(report, "unmerged_branch", `local branch contains commits outside ${args.base}: ${branch}`, entry); + } + } + } + + if (args.acceptedRoot && inspected.length === 2 && inspected[0].baseSha && inspected[1].baseSha && + inspected[0].baseSha !== inspected[1].baseSha) { + issue(report, "base_mismatch", `${args.base} differs between development and accepted roots`, { + developmentSha: inspected[0].baseSha, acceptedSha: inspected[1].baseSha, + }); + } + report.ok = report.issues.length === 0; + return report; +} + +function main(argv = process.argv.slice(2)) { + const report = audit(argv); + const json = argv.includes("--json"); + if (json) console.log(JSON.stringify(report)); + else { + console.log(`repo state audit: ${report.base}`); + for (const root of report.roots) console.log(` ${root.role}: ${root.baseSha || "missing"} (${root.root})`); + for (const item of report.issues) console.log(` ${item.code}: ${item.message}`); + console.log(report.ok ? "\nRepository topology is converged." : "\nRepository topology is not converged."); + } + process.exit(report.strict && !report.ok ? 1 : 0); +} + +if (require.main === module) main(); + +module.exports = { audit, parseArgs, parseWorktrees }; diff --git a/scripts/harness-canary.ps1 b/scripts/harness-canary.ps1 new file mode 100644 index 0000000..18674bc --- /dev/null +++ b/scripts/harness-canary.ps1 @@ -0,0 +1,389 @@ +[CmdletBinding()] +param( + [string]$AgentsRoot = "C:\Users\poweruser\projects\llms\agents-main", + [string]$DropwheelRoot = "", + [string]$ReportRoot = "", + [string]$WorktreeRoot = "", + [int]$StepTimeoutSeconds = 900, + [switch]$KeepWorktree, + [switch]$MirrorToAgentsInbox, + [string]$AgentsInboxRoot = "" +) + +$ErrorActionPreference = "Stop" + +if (-not $DropwheelRoot) { + $scriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path } + $DropwheelRoot = (Resolve-Path (Join-Path $scriptDir "..")).Path +} +if (-not $ReportRoot) { + $ReportRoot = "C:\Users\poweruser\.codex\automations\dropwheel-pipeline-orchestrator\manual-reports\harness-canary" +} +if (-not $WorktreeRoot) { + $WorktreeRoot = Join-Path ([System.IO.Path]::GetTempPath()) "dw-canary" +} +if (-not $AgentsInboxRoot) { + $AgentsInboxRoot = "C:\Users\poweruser\projects\llms\agents\inbox\dropwheel" +} + +function Format-Command { + param([string]$File, [string[]]$Arguments) + return (@($File) + $Arguments) -join " " +} + +function Quote-ProcessArgument { + param([string]$Argument) + if ($null -eq $Argument) { return '""' } + if ($Argument.Length -eq 0) { return '""' } + if ($Argument -notmatch '[\s"]') { return $Argument } + + $result = '"' + $slashes = 0 + foreach ($ch in $Argument.ToCharArray()) { + if ($ch -eq '\') { + $slashes += 1 + } elseif ($ch -eq '"') { + $result += ('\' * ($slashes * 2 + 1)) + $result += '"' + $slashes = 0 + } else { + if ($slashes -gt 0) { $result += ('\' * $slashes); $slashes = 0 } + $result += $ch + } + } + if ($slashes -gt 0) { $result += ('\' * ($slashes * 2)) } + $result += '"' + return $result +} + +function Join-ProcessArguments { + param([string[]]$Arguments) + return (($Arguments | ForEach-Object { Quote-ProcessArgument ([string]$_) }) -join " ") +} + +function Stop-ProcessTree { + param([int]$ProcessId) + $children = @(Get-CimInstance Win32_Process -Filter "ParentProcessId = $ProcessId" -ErrorAction SilentlyContinue) + foreach ($child in $children) { + Stop-ProcessTree -ProcessId ([int]$child.ProcessId) + } + Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue +} + +function Get-JsonOkResult { + param([string[]]$Lines) + foreach ($line in $Lines) { + $trimmed = $line.Trim() + if (-not $trimmed.StartsWith("{")) { continue } + try { + $parsed = $trimmed | ConvertFrom-Json -ErrorAction Stop + $okProp = $parsed.PSObject.Properties["ok"] + if ($null -ne $okProp) { + $reason = "" + $reasonProp = $parsed.PSObject.Properties["reason"] + if ($null -ne $reasonProp) { $reason = [string]$reasonProp.Value } + return [pscustomobject]@{ + Found = $true + Ok = [bool]$okProp.Value + Reason = $reason + } + } + } catch { + continue + } + } + return [pscustomobject]@{ + Found = $false + Ok = $true + Reason = "" + } +} + +function Invoke-CanaryStep { + param( + [string]$Name, + [string]$File, + [string[]]$Arguments, + [string]$WorkingDirectory, + [switch]$RequireJsonOk + ) + + $started = Get-Date + $lines = @() + $exitCode = 0 + $timedOut = $false + + try { + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $File + $psi.Arguments = Join-ProcessArguments $Arguments + $psi.WorkingDirectory = $WorkingDirectory + $psi.UseShellExecute = $false + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.CreateNoWindow = $true + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $psi + [void]$process.Start() + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + $timeoutMs = [Math]::Max(1, $StepTimeoutSeconds) * 1000 + if (-not $process.WaitForExit($timeoutMs)) { + $timedOut = $true + Stop-ProcessTree -ProcessId $process.Id + $process.WaitForExit(5000) | Out-Null + $exitCode = 124 + } else { + $process.WaitForExit() + $exitCode = $process.ExitCode + } + $stdoutText = $stdoutTask.Result + $stderrText = $stderrTask.Result + $stdout = @(if ($stdoutText) { $stdoutText -split "\r?\n" } else { @() }) + $stderr = @(if ($stderrText) { $stderrText -split "\r?\n" } else { @() }) + $lines = @($stdout + $stderr | Where-Object { $_ -ne $null } | ForEach-Object { $_.ToString() }) + if ($timedOut) { + $lines += "Timed out after $StepTimeoutSeconds second(s)." + } + if ($RequireJsonOk -and $exitCode -eq 0) { + $jsonOk = Get-JsonOkResult $lines + if ($jsonOk.Found -and -not $jsonOk.Ok) { + $exitCode = 1 + $message = "Semantic failure: command returned JSON ok=false." + if ($jsonOk.Reason) { $message += " Reason: $($jsonOk.Reason)" } + $lines += $message + } + } + } catch { + $lines = @($_.Exception.Message) + $exitCode = 1 + } + + [pscustomobject]@{ + name = $Name + command = Format-Command $File $Arguments + cwd = $WorkingDirectory + exitCode = $exitCode + timedOut = $timedOut + startedAt = $started.ToString("o") + finishedAt = (Get-Date).ToString("o") + output = @($lines) + } +} + +function Invoke-GitText { + param([string]$Root, [string[]]$Arguments) + Push-Location $Root + try { + $out = & git @Arguments 2>$null + return (($out | ForEach-Object { $_.ToString() }) -join "`n").Trim() + } catch { + return "" + } finally { + Pop-Location + } +} + +function Get-GitInfo { + param([string]$Root) + $statusText = Invoke-GitText $Root @("status", "--short") + $status = @() + if ($statusText) { + $status = @($statusText -split "\r?\n" | Where-Object { $_ }) + } + [ordered]@{ + root = $Root + branch = Invoke-GitText $Root @("branch", "--show-current") + sha = Invoke-GitText $Root @("rev-parse", "HEAD") + status = $status + } +} + +function New-CanaryStepResult { + param( + [string]$Name, + [string]$Command, + [string]$WorkingDirectory, + [int]$ExitCode, + [string[]]$Output, + [datetime]$StartedAt = (Get-Date) + ) + + [pscustomobject]@{ + name = $Name + command = $Command + cwd = $WorkingDirectory + exitCode = $ExitCode + timedOut = $false + startedAt = $StartedAt.ToString("o") + finishedAt = (Get-Date).ToString("o") + output = @($Output) + } +} + +function Get-OwnerHint { + param([object[]]$Steps) + $failed = @($Steps | Where-Object { $_.exitCode -ne 0 -and $_.name -ne "cleanup isolated worktree" }) + if ($failed.Count -eq 0) { + $cleanupFailed = @($Steps | Where-Object { $_.exitCode -ne 0 -and $_.name -eq "cleanup isolated worktree" }) + if ($cleanupFailed.Count -gt 0) { return "pipeline-cleanup" } + return "none" + } + + $first = $failed[0].name + $joined = (($failed | ForEach-Object { $_.output }) -join "`n") + + if ($first -eq "validate dropwheel root clean") { + return "pipeline-precondition" + } + if ($joined -match "harness not bootstrapped into repository main" -and + $joined -match "untracked:" -and + $joined -match "hooks[/\\]verify-core\.js|hooks[/\\]release-preflight\.js|\.github[/\\]CODEOWNERS") { + return "dropwheel-harness-update" + } + if ($first -match "install|doctor" -or $joined -match "hooks[/\\]|harness|install\.js|doctor\.js|verify\.js") { + return "agents-harness" + } + if ($joined -match "dotnet|Dropwheel|\.csproj|xUnit|tests[/\\]Dropwheel") { + return "dropwheel-or-contract" + } + return "needs-triage" +} + +function Get-CanaryWorktreePath { + param([string]$Root, [string]$RunId) + + $path = Join-Path $Root $RunId + if ($path.Length -lt 120) { return $path } + + $shortRoot = Join-Path ([System.IO.Path]::GetTempPath()) "dw-canary" + New-Item -ItemType Directory -Force -Path $shortRoot | Out-Null + return Join-Path $shortRoot $RunId +} + +function Clip-Lines { + param([string[]]$Lines, [int]$Max = 160) + if ($Lines.Count -le $Max) { return $Lines } + $head = [Math]::Floor($Max / 2) + $tail = $Max - $head + return @($Lines[0..($head - 1)] + "... clipped ..." + $Lines[($Lines.Count - $tail)..($Lines.Count - 1)]) +} + +if (-not (Test-Path $AgentsRoot)) { + throw "AgentsRoot does not exist: $AgentsRoot" +} +if (-not (Test-Path $DropwheelRoot)) { + throw "DropwheelRoot does not exist: $DropwheelRoot" +} + +New-Item -ItemType Directory -Force -Path $ReportRoot | Out-Null +New-Item -ItemType Directory -Force -Path $WorktreeRoot | Out-Null + +$timestamp = Get-Date -Format "yyyyMMdd-HHmmss" +$runId = "dropwheel-canary-$timestamp" +$reportDir = Join-Path $ReportRoot $runId +$worktree = Get-CanaryWorktreePath $WorktreeRoot $runId +New-Item -ItemType Directory -Force -Path $reportDir | Out-Null + +$steps = @() +$sourceInfo = Get-GitInfo $DropwheelRoot +if (@($sourceInfo.status).Count -gt 0) { + $steps += New-CanaryStepResult ` + "validate dropwheel root clean" ` + "git status --short" ` + $DropwheelRoot ` + 1 ` + (@("DropwheelRoot has uncommitted changes. Commit/stash/revert generated artifacts before canary so the isolated worktree verifies the intended HEAD.") + @($sourceInfo.status)) +} else { + $steps += New-CanaryStepResult "validate dropwheel root clean" "git status --short" $DropwheelRoot 0 @("clean") +} + +if ($steps[-1].exitCode -eq 0) { + $steps += Invoke-CanaryStep "create isolated dropwheel worktree" "git" @("worktree", "add", "--detach", $worktree, "HEAD") $DropwheelRoot +} + +if ($steps[-1].exitCode -eq 0) { + $installer = Join-Path $AgentsRoot "install.js" + $steps += Invoke-CanaryStep "install latest harness from agents" "node" @($installer, "--target", $worktree, "--force", "--json") $AgentsRoot -RequireJsonOk + $steps += Invoke-CanaryStep "doctor installed harness" "node" @("hooks\doctor.js", "--json") $worktree -RequireJsonOk + $steps += Invoke-CanaryStep "show verify plan" "node" @("hooks\verify.js", "--list") $worktree + $steps += Invoke-CanaryStep "run verify" "node" @("hooks\verify.js") $worktree + $steps += Invoke-CanaryStep "capture worktree status" "git" @("status", "--short") $worktree +} + +if (-not $KeepWorktree -and (Test-Path $worktree)) { + $steps += Invoke-CanaryStep "cleanup isolated worktree" "git" @("worktree", "remove", "--force", $worktree) $DropwheelRoot +} + +$criticalFailures = @($steps | Where-Object { + $_.exitCode -ne 0 -and $_.name -ne "capture worktree status" +}) +$ok = $criticalFailures.Count -eq 0 + +$ownerHint = Get-OwnerHint $steps +$report = [ordered]@{ + schema = "dropwheel-harness-canary/v1" + runId = $runId + generatedAt = (Get-Date).ToString("o") + ok = $ok + ownerHint = $ownerHint + agents = Get-GitInfo $AgentsRoot + dropwheel = Get-GitInfo $DropwheelRoot + worktree = $worktree + reportDir = $reportDir + steps = $steps +} + +$jsonPath = Join-Path $reportDir "report.json" +$markdownPath = Join-Path $reportDir "report.md" +$report | ConvertTo-Json -Depth 10 | Set-Content -Path $jsonPath -Encoding utf8 + +$md = @() +$md += "# Dropwheel harness canary report" +$md += "" +$md += "- Run: ``$runId``" +$md += "- OK: ``$ok``" +$md += "- Owner hint: ``$ownerHint``" +$md += "- Agents SHA: ``$($report.agents.sha)``" +$md += "- Dropwheel SHA: ``$($report.dropwheel.sha)``" +$md += "- Worktree: ``$worktree``" +$md += "" +$md += "## Steps" +foreach ($step in $steps) { + $md += "" + $md += "### $($step.name)" + $md += "" + $md += "- Exit: ``$($step.exitCode)``" + $md += "- Command: ``$($step.command)``" + $md += "- CWD: ``$($step.cwd)``" + if ($step.exitCode -ne 0) { + $md += "" + $md += '```text' + $md += Clip-Lines $step.output 160 + $md += '```' + } +} +$md += "" +$md += "## Triage rule" +$md += "" +$md += 'If install/doctor/harness syntax fails, fix `agents`. If install/doctor is green and only Dropwheel build/tests fail, fix `dropwheel` unless the failure proves a bad harness contract. If unclear, send this report to the `agents` harness inbox.' +$md | Set-Content -Path $markdownPath -Encoding utf8 + +if ($MirrorToAgentsInbox) { + if ($ownerHint -eq "agents-harness" -or $ownerHint -eq "needs-triage") { + New-Item -ItemType Directory -Force -Path $AgentsInboxRoot | Out-Null + Copy-Item -Path $jsonPath -Destination (Join-Path $AgentsInboxRoot "$runId.json") -Force + Copy-Item -Path $markdownPath -Destination (Join-Path $AgentsInboxRoot "$runId.md") -Force + Write-Host "mirrored: $AgentsInboxRoot" + } else { + Write-Host "not mirrored to agents inbox: ownerHint $ownerHint" + } +} + +Write-Host "report: $markdownPath" +Write-Host "json: $jsonPath" +Write-Host "ownerHint: $ownerHint" + +if ($ok) { exit 0 } +exit 1 diff --git a/scripts/harness-negative-canary.ps1 b/scripts/harness-negative-canary.ps1 new file mode 100644 index 0000000..c242b3e --- /dev/null +++ b/scripts/harness-negative-canary.ps1 @@ -0,0 +1,445 @@ +[CmdletBinding()] +param( + [string]$AgentsRoot = "C:\Users\poweruser\projects\llms\agents-main", + [string]$DropwheelRoot = "", + [string]$ReportRoot = "", + [string]$WorktreeRoot = "", + [int]$StepTimeoutSeconds = 900, + [switch]$KeepWorktree, + [switch]$MirrorToAgentsInbox, + [string]$AgentsInboxRoot = "" +) + +$ErrorActionPreference = "Stop" + +if (-not $DropwheelRoot) { + $scriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path } + $DropwheelRoot = (Resolve-Path (Join-Path $scriptDir "..")).Path +} +if (-not $ReportRoot) { + $ReportRoot = "C:\Users\poweruser\.codex\automations\dropwheel-pipeline-orchestrator\manual-reports\harness-negative-canary" +} +if (-not $WorktreeRoot) { + $WorktreeRoot = Join-Path ([System.IO.Path]::GetTempPath()) "dw-neg-canary" +} +if (-not $AgentsInboxRoot) { + $AgentsInboxRoot = "C:\Users\poweruser\projects\llms\agents\inbox\dropwheel" +} + +function Format-Command { + param([string]$File, [string[]]$Arguments) + return (@($File) + $Arguments) -join " " +} + +function Quote-ProcessArgument { + param([string]$Argument) + if ($null -eq $Argument) { return '""' } + if ($Argument.Length -eq 0) { return '""' } + if ($Argument -notmatch '[\s"]') { return $Argument } + + $result = '"' + $slashes = 0 + foreach ($ch in $Argument.ToCharArray()) { + if ($ch -eq '\') { + $slashes += 1 + } elseif ($ch -eq '"') { + $result += ('\' * ($slashes * 2 + 1)) + $result += '"' + $slashes = 0 + } else { + if ($slashes -gt 0) { $result += ('\' * $slashes); $slashes = 0 } + $result += $ch + } + } + if ($slashes -gt 0) { $result += ('\' * ($slashes * 2)) } + $result += '"' + return $result +} + +function Join-ProcessArguments { + param([string[]]$Arguments) + return (($Arguments | ForEach-Object { Quote-ProcessArgument ([string]$_) }) -join " ") +} + +function Stop-ProcessTree { + param([int]$ProcessId) + $children = @(Get-CimInstance Win32_Process -Filter "ParentProcessId = $ProcessId" -ErrorAction SilentlyContinue) + foreach ($child in $children) { + Stop-ProcessTree -ProcessId ([int]$child.ProcessId) + } + Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue +} + +function Get-JsonOkResult { + param([string[]]$Lines) + foreach ($line in $Lines) { + $trimmed = $line.Trim() + if (-not $trimmed.StartsWith("{")) { continue } + try { + $parsed = $trimmed | ConvertFrom-Json -ErrorAction Stop + $okProp = $parsed.PSObject.Properties["ok"] + if ($null -ne $okProp) { + $reason = "" + $reasonProp = $parsed.PSObject.Properties["reason"] + if ($null -ne $reasonProp) { $reason = [string]$reasonProp.Value } + return [pscustomobject]@{ + Found = $true + Ok = [bool]$okProp.Value + Reason = $reason + } + } + } catch { + continue + } + } + return [pscustomobject]@{ + Found = $false + Ok = $true + Reason = "" + } +} + +function Invoke-CanaryStep { + param( + [string]$Name, + [string]$File, + [string[]]$Arguments, + [string]$WorkingDirectory, + [switch]$RequireJsonOk, + [switch]$AllowBootstrapJsonFailure + ) + + $started = Get-Date + $lines = @() + $exitCode = 0 + $timedOut = $false + + try { + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $File + $psi.Arguments = Join-ProcessArguments $Arguments + $psi.WorkingDirectory = $WorkingDirectory + $psi.UseShellExecute = $false + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.CreateNoWindow = $true + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $psi + [void]$process.Start() + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + $timeoutMs = [Math]::Max(1, $StepTimeoutSeconds) * 1000 + if (-not $process.WaitForExit($timeoutMs)) { + $timedOut = $true + Stop-ProcessTree -ProcessId $process.Id + $process.WaitForExit(5000) | Out-Null + $exitCode = 124 + } else { + $process.WaitForExit() + $exitCode = $process.ExitCode + } + $stdoutText = $stdoutTask.Result + $stderrText = $stderrTask.Result + $stdout = @(if ($stdoutText) { $stdoutText -split "\r?\n" } else { @() }) + $stderr = @(if ($stderrText) { $stderrText -split "\r?\n" } else { @() }) + $lines = @($stdout + $stderr | Where-Object { $_ -ne $null } | ForEach-Object { $_.ToString() }) + if ($timedOut) { + $lines += "Timed out after $StepTimeoutSeconds second(s)." + } + + $jsonOk = Get-JsonOkResult $lines + if ($RequireJsonOk -and $jsonOk.Found -and -not $jsonOk.Ok) { + $joinedLines = ($lines -join "`n") + $bootstrapFailure = + $AllowBootstrapJsonFailure -and + $joinedLines -match "harness not bootstrapped into repository main" -and + $joinedLines -match "untracked:" + + if ($bootstrapFailure) { + $lines += "Accepted setup bootstrap JSON failure; generated files will be staged before doctor." + $exitCode = 0 + } elseif ($exitCode -eq 0) { + $exitCode = 1 + $message = "Semantic failure: command returned JSON ok=false." + if ($jsonOk.Reason) { $message += " Reason: $($jsonOk.Reason)" } + $lines += $message + } + } + } catch { + $lines = @($_.Exception.Message) + $exitCode = 1 + } + + [pscustomobject]@{ + name = $Name + command = Format-Command $File $Arguments + cwd = $WorkingDirectory + exitCode = $exitCode + timedOut = $timedOut + startedAt = $started.ToString("o") + finishedAt = (Get-Date).ToString("o") + output = @($lines) + } +} + +function New-MutationStep { + param( + [string]$Name, + [string]$WorkingDirectory, + [scriptblock]$Mutation + ) + + $started = Get-Date + $lines = @() + $exitCode = 0 + try { + & $Mutation + $lines += "mutation applied" + } catch { + $exitCode = 1 + $lines += $_.Exception.Message + } + + [pscustomobject]@{ + name = $Name + command = "local mutation" + cwd = $WorkingDirectory + exitCode = $exitCode + timedOut = $false + startedAt = $started.ToString("o") + finishedAt = (Get-Date).ToString("o") + output = @($lines) + } +} + +function Clip-Lines { + param([string[]]$Lines, [int]$Max = 120) + if ($Lines.Count -le $Max) { return $Lines } + $head = [Math]::Floor($Max / 2) + $tail = $Max - $head + return @($Lines[0..($head - 1)] + "... clipped ..." + $Lines[($Lines.Count - $tail)..($Lines.Count - 1)]) +} + +function Get-NegativeCaseWorktreePath { + param([string]$Root, [string]$RunId, [string]$Slug) + + $leaf = "$RunId-$Slug" + $path = Join-Path $Root $leaf + if ($path.Length -lt 120) { return $path } + + $shortRoot = Join-Path ([System.IO.Path]::GetTempPath()) "dw-neg-canary" + New-Item -ItemType Directory -Force -Path $shortRoot | Out-Null + return Join-Path $shortRoot $leaf +} + +function Invoke-NegativeCase { + param( + [string]$RunId, + [string]$Slug, + [string]$Title, + [scriptblock]$Mutation, + [string]$ExpectedFailurePattern = "" + ) + + $caseRoot = Get-NegativeCaseWorktreePath $WorktreeRoot $RunId $Slug + $steps = @() + $steps += Invoke-CanaryStep "create isolated worktree" "git" @("worktree", "add", "--detach", $caseRoot, "HEAD") $DropwheelRoot + + $setupOk = $steps[-1].exitCode -eq 0 + if ($setupOk) { + $installer = Join-Path $AgentsRoot "install.js" + $steps += Invoke-CanaryStep "install latest harness from agents" "node" @($installer, "--target", $caseRoot, "--force", "--json") $AgentsRoot -RequireJsonOk -AllowBootstrapJsonFailure + $setupOk = $setupOk -and $steps[-1].exitCode -eq 0 + } + if ($setupOk) { + $steps += Invoke-CanaryStep "stage generated harness output" "git" @("add", "-A") $caseRoot + $setupOk = $setupOk -and $steps[-1].exitCode -eq 0 + } + if ($setupOk) { + $steps += Invoke-CanaryStep "doctor installed harness" "node" @("hooks\doctor.js", "--json") $caseRoot -RequireJsonOk + $setupOk = $setupOk -and $steps[-1].exitCode -eq 0 + } + if ($setupOk) { + $steps += New-MutationStep "inject $Title" $caseRoot $Mutation + $setupOk = $setupOk -and $steps[-1].exitCode -eq 0 + } + + $caught = $false + $expectedFailureMatched = $false + if ($setupOk) { + $steps += Invoke-CanaryStep "run verify expecting failure" "node" @("hooks\verify.js") $caseRoot + $caught = $steps[-1].exitCode -ne 0 + if ($caught) { + if ([string]::IsNullOrWhiteSpace($ExpectedFailurePattern)) { + $expectedFailureMatched = $true + } else { + $joinedOutput = ($steps[-1].output -join "`n") + $expectedFailureMatched = $joinedOutput -match $ExpectedFailurePattern + if (-not $expectedFailureMatched) { + $steps[-1].output += "Expected failure pattern was not observed: $ExpectedFailurePattern" + } + } + } + } + + $caseOk = $setupOk -and $caught -and $expectedFailureMatched + $cleanup = -not $KeepWorktree + if ($cleanup -and (Test-Path $caseRoot)) { + $steps += Invoke-CanaryStep "cleanup isolated worktree" "git" @("worktree", "remove", "--force", $caseRoot) $DropwheelRoot + $caseOk = $caseOk -and $steps[-1].exitCode -eq 0 + } + + [pscustomobject]@{ + slug = $Slug + title = $Title + worktree = $caseRoot + setupOk = $setupOk + caught = $caught + expectedFailurePattern = $ExpectedFailurePattern + expectedFailureMatched = $expectedFailureMatched + ok = $caseOk + steps = $steps + } +} + +if (-not (Test-Path $AgentsRoot)) { + throw "AgentsRoot does not exist: $AgentsRoot" +} +if (-not (Test-Path $DropwheelRoot)) { + throw "DropwheelRoot does not exist: $DropwheelRoot" +} + +New-Item -ItemType Directory -Force -Path $ReportRoot | Out-Null +New-Item -ItemType Directory -Force -Path $WorktreeRoot | Out-Null + +$timestamp = Get-Date -Format "yyyyMMdd-HHmmss" +$runId = "dropwheel-negative-canary-$timestamp" +$reportDir = Join-Path $ReportRoot $runId +New-Item -ItemType Directory -Force -Path $reportDir | Out-Null + +$cases = @() +$cases += Invoke-NegativeCase $runId "compile-failure" "C# compile failure" { + $path = Join-Path $caseRoot "src\Dropwheel\NegativeCanaryCompileBreak.cs" + @( + "namespace Dropwheel;" + "" + "internal static class NegativeCanaryCompileBreak" + "{" + " public static void Broken()" + " {" + " this is not valid csharp" + " }" + "}" + ) | Set-Content -Path $path -Encoding utf8 +} "FAIL dotnet/build @ src/Dropwheel|dotnet/build @ src/Dropwheel: exit 1" +$cases += Invoke-NegativeCase $runId "failing-test" "failing xUnit test" { + $path = Join-Path $caseRoot "tests\Dropwheel.Tests\NegativeCanaryFailTests.cs" + @( + "namespace Dropwheel.Tests;" + "" + "public sealed class NegativeCanaryFailTests" + "{" + " [Fact]" + " public void Harness_negative_canary_must_fail()" + " {" + " Assert.Fail(""negative canary"");" + " }" + "}" + ) | Set-Content -Path $path -Encoding utf8 +} "FAIL dotnet/test @ tests/Dropwheel.Tests|dotnet/test @ tests/Dropwheel.Tests: exit 1" +$cases += Invoke-NegativeCase $runId "harness-syntax" "broken harness JavaScript syntax" { + $path = Join-Path $caseRoot "hooks\verify-core.js" + Add-Content -Path $path -Encoding utf8 -Value "" + Add-Content -Path $path -Encoding utf8 -Value "this is not valid JavaScript !!!" +} "SyntaxError: Unexpected identifier" + +$ok = @($cases | Where-Object { -not $_.ok }).Count -eq 0 +$ownerHint = if ($ok) { + "none" +} elseif (@($cases | Where-Object { $_.setupOk -and -not $_.caught }).Count -gt 0) { + "agents-harness" +} elseif (@($cases | Where-Object { $_.setupOk -and $_.caught -and -not $_.expectedFailureMatched }).Count -gt 0) { + "needs-triage" +} elseif (@($cases | Where-Object { -not $_.setupOk }).Count -gt 0) { + "agents-harness" +} else { + "needs-triage" +} + +$report = [ordered]@{ + schema = "dropwheel-negative-canary/v1" + runId = $runId + generatedAt = (Get-Date).ToString("o") + ok = $ok + ownerHint = $ownerHint + agentsRoot = $AgentsRoot + dropwheelRoot = $DropwheelRoot + reportDir = $reportDir + cases = $cases +} + +$jsonPath = Join-Path $reportDir "report.json" +$markdownPath = Join-Path $reportDir "report.md" +$report | ConvertTo-Json -Depth 12 | Set-Content -Path $jsonPath -Encoding utf8 + +$md = @() +$md += "# Dropwheel negative canary report" +$md += "" +$md += "- Run: ``$runId``" +$md += "- OK: ``$ok``" +$md += "- Owner hint: ``$ownerHint``" +$md += "- Agents root: ``$AgentsRoot``" +$md += "- Dropwheel root: ``$DropwheelRoot``" +$md += "" +$md += "## Cases" +foreach ($case in $cases) { + $md += "" + $md += "### $($case.title)" + $md += "" + $md += "- OK: ``$($case.ok)``" + $md += "- Setup OK: ``$($case.setupOk)``" + $md += "- Failure caught: ``$($case.caught)``" + if ($case.expectedFailurePattern) { + $md += "- Expected failure matched: ``$($case.expectedFailureMatched)``" + $md += "- Expected failure pattern: ``$($case.expectedFailurePattern)``" + } + $md += "- Worktree: ``$($case.worktree)``" + foreach ($step in $case.steps) { + $md += "" + $md += "#### $($step.name)" + $md += "" + $md += "- Exit: ``$($step.exitCode)``" + $md += "- Command: ``$($step.command)``" + $md += "- CWD: ``$($step.cwd)``" + if ($step.exitCode -ne 0 -or $step.name -eq "run verify expecting failure") { + $md += "" + $md += '```text' + $md += Clip-Lines $step.output 120 + $md += '```' + } + } +} +$md += "" +$md += "## Triage rule" +$md += "" +$md += "This canary passes only when every injected failure is rejected by `node hooks\verify.js` at the expected verification stage. A case with setup OK and failure not caught is a harness false negative and belongs to `agents`. A case caught at the wrong stage is a negative-canary coverage gap and needs triage." +$md | Set-Content -Path $markdownPath -Encoding utf8 + +if ($MirrorToAgentsInbox) { + if ($ownerHint -eq "agents-harness" -or $ownerHint -eq "needs-triage") { + New-Item -ItemType Directory -Force -Path $AgentsInboxRoot | Out-Null + Copy-Item -Path $jsonPath -Destination (Join-Path $AgentsInboxRoot "$runId.json") -Force + Copy-Item -Path $markdownPath -Destination (Join-Path $AgentsInboxRoot "$runId.md") -Force + Write-Host "mirrored: $AgentsInboxRoot" + } else { + Write-Host "not mirrored to agents inbox: ownerHint $ownerHint" + } +} + +Write-Host "report: $markdownPath" +Write-Host "json: $jsonPath" +Write-Host "ownerHint: $ownerHint" + +if ($ok) { exit 0 } +exit 1 diff --git a/scripts/pipeline-manifest.ps1 b/scripts/pipeline-manifest.ps1 new file mode 100644 index 0000000..3852651 --- /dev/null +++ b/scripts/pipeline-manifest.ps1 @@ -0,0 +1,319 @@ +[CmdletBinding()] +param( + [ValidateSet("New", "AddWorker", "AddFinding", "UpdateFindingDisposition", "AddFix", "AddVerification", "AddMerge", "AddEvent", "Complete")] + [string]$Mode = "New", + [string]$ManifestPath = "", + [string]$RunRoot = "", + [string]$RunId = "", + [string]$WorkerId = "", + [string]$Role = "", + [string]$Owner = "", + [string]$Status = "", + [string]$Branch = "", + [string]$Sha = "", + [string]$Path = "", + [string]$ReportPath = "", + [string]$DataJsonPath = "", + [string]$DataJson = "{}" +) + +$ErrorActionPreference = "Stop" + +function Get-ManifestLockName { + param([string]$Path) + $fullPath = [System.IO.Path]::GetFullPath($Path).ToLowerInvariant() + $bytes = [System.Text.Encoding]::UTF8.GetBytes($fullPath) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $hash = $sha.ComputeHash($bytes) + } finally { + $sha.Dispose() + } + $hashText = -join ($hash | ForEach-Object { $_.ToString("x2") }) + return "Local\DropwheelPipelineManifest-$hashText" +} + +function Invoke-WithManifestLock { + param( + [string]$Path, + [scriptblock]$Body + ) + + $mutex = [System.Threading.Mutex]::new($false, (Get-ManifestLockName $Path)) + $lockTaken = $false + try { + $lockTaken = $mutex.WaitOne([TimeSpan]::FromSeconds(30)) + if (-not $lockTaken) { + throw "Timed out waiting for manifest lock: $Path" + } + & $Body + } finally { + if ($lockTaken) { $mutex.ReleaseMutex() } + $mutex.Dispose() + } +} + +function ConvertTo-Hashtable { + param([AllowNull()][object]$InputObject) + if ($null -eq $InputObject) { return $null } + if ($InputObject -is [System.Collections.IDictionary]) { return $InputObject } + if ($InputObject -is [array]) { + $items = [System.Collections.ArrayList]::new() + foreach ($item in $InputObject) { + [void]$items.Add((ConvertTo-Hashtable -InputObject $item)) + } + return ,@($items.ToArray()) + } + if ($InputObject.GetType().FullName -ne "System.Management.Automation.PSCustomObject") { + return $InputObject + } + + $hash = [ordered]@{} + foreach ($prop in $InputObject.PSObject.Properties) { + $hash[$prop.Name] = ConvertTo-Hashtable -InputObject $prop.Value + } + return $hash +} + +function Read-Data { + if (-not [string]::IsNullOrWhiteSpace($DataJsonPath)) { + if (-not (Test-Path -LiteralPath $DataJsonPath)) { + throw "DataJsonPath does not exist: $DataJsonPath" + } + $jsonText = Get-Content -Raw -LiteralPath $DataJsonPath + if ([string]::IsNullOrWhiteSpace($jsonText)) { return [ordered]@{} } + return ConvertTo-Hashtable ($jsonText | ConvertFrom-Json -ErrorAction Stop) + } + + if ([string]::IsNullOrWhiteSpace($DataJson)) { return [ordered]@{} } + return ConvertTo-Hashtable ($DataJson | ConvertFrom-Json -ErrorAction Stop) +} + +function Read-Manifest { + param([string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { throw "Manifest does not exist: $Path" } + return ConvertTo-Hashtable (Get-Content -Raw -LiteralPath $Path | ConvertFrom-Json -ErrorAction Stop) +} + +function Write-Manifest { + param([string]$Path, [object]$Manifest) + $Manifest.updatedAt = (Get-Date).ToString("o") + foreach ($collection in @("workers", "findings", "fixes", "verifications", "merges", "events")) { + if (-not $Manifest.Contains($collection) -or $null -eq $Manifest[$collection]) { + $Manifest[$collection] = @() + } elseif ($Manifest[$collection] -is [array]) { + $Manifest[$collection] = @($Manifest[$collection]) + } elseif ($Manifest[$collection] -is [System.Collections.IDictionary] -and $Manifest[$collection].Count -eq 0) { + $Manifest[$collection] = @() + } else { + $Manifest[$collection] = @($Manifest[$collection]) + } + } + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $Path) | Out-Null + $tmp = "$Path.tmp" + $Manifest | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $tmp -Encoding utf8 + Move-Item -LiteralPath $tmp -Destination $Path -Force +} + +function New-Record { + param([string]$Kind, [System.Collections.IDictionary]$Data) + $record = [ordered]@{ + kind = $Kind + createdAt = (Get-Date).ToString("o") + } + foreach ($key in $Data.Keys) { + if ($null -ne $Data[$key] -and "$($Data[$key])" -ne "") { + $record[$key] = $Data[$key] + } + } + return $record +} + +function Add-ManifestRecord { + param( + [System.Collections.IDictionary]$Manifest, + [string]$Collection, + [System.Collections.IDictionary]$Record + ) + + $items = @() + if ($Manifest.Contains($Collection) -and $null -ne $Manifest[$Collection]) { + if ($Manifest[$Collection] -is [array]) { + $items = @($Manifest[$Collection]) + } elseif ($Manifest[$Collection] -is [System.Collections.IDictionary] -and $Manifest[$Collection].Count -eq 0) { + $items = @() + } else { + $items = @($Manifest[$Collection]) + } + } + + $Manifest[$Collection] = @($items + $Record) +} + +if ($Mode -eq "New") { + if (-not $RunRoot) { throw "-RunRoot is required for Mode=New" } + if (-not $RunId) { $RunId = Get-Date -Format "yyyyMMdd-HHmmss" } + if (-not $ManifestPath) { $ManifestPath = Join-Path $RunRoot "manifest.json" } + + Invoke-WithManifestLock $ManifestPath { + $manifest = [ordered]@{ + schema = "dropwheel-pipeline-run/v1" + runId = $RunId + status = "running" + generatedAt = (Get-Date).ToString("o") + updatedAt = (Get-Date).ToString("o") + roots = [ordered]@{ + pipeline = "C:\Users\poweruser\projects\csharp\dropwheel-release" + dropwheelAccepted = "C:\Users\poweruser\projects\csharp\dropwheel-release" + agentsDevelopment = "C:\Users\poweruser\projects\llms\agents" + agentsAccepted = "C:\Users\poweruser\projects\llms\agents-main" + automation = "C:\Users\poweruser\.codex\automations\dropwheel-pipeline-orchestrator" + } + budgets = [ordered]@{ + maxDropwheelFixes = 2 + maxAgentsFixes = 1 + maxFindingsPerRun = 6 + } + workers = @() + findings = @() + fixes = @() + verifications = @() + merges = @() + events = @() + } + Write-Manifest $ManifestPath $manifest + } + Write-Output $ManifestPath + exit 0 +} + +if (-not $ManifestPath) { throw "-ManifestPath is required for Mode=$Mode" } + +Invoke-WithManifestLock $ManifestPath { + $manifest = Read-Manifest $ManifestPath + if ($Mode -ne "Complete" -and $manifest.status -ne "running") { + throw "Manifest is already '$($manifest.status)' and cannot accept Mode=${Mode}: $ManifestPath" + } + $data = Read-Data + + switch ($Mode) { + "AddWorker" { + Add-ManifestRecord $manifest "workers" (New-Record "worker" ([ordered]@{ + id = $WorkerId + role = $Role + owner = $Owner + status = $Status + reportPath = $ReportPath + data = $data + })) + } + "AddFinding" { + Add-ManifestRecord $manifest "findings" (New-Record "finding" ([ordered]@{ + id = $WorkerId + owner = $Owner + status = $Status + path = $Path + reportPath = $ReportPath + data = $data + })) + } + "UpdateFindingDisposition" { + if ([string]::IsNullOrWhiteSpace($WorkerId)) { + throw "-WorkerId must identify a finding id or fingerprint" + } + if ($Status -notin @("fixed", "open", "deferred")) { + throw "Finding disposition status must be fixed, open, or deferred" + } + if ($data.Count -eq 0) { + throw "Finding disposition requires non-empty DataJson or DataJsonPath evidence" + } + + $matches = @($manifest.findings | Where-Object { + $idMatches = $_.Contains("id") -and $_.id -eq $WorkerId + $fingerprintMatches = $_.Contains("data") -and + $_.data -is [System.Collections.IDictionary] -and + $_.data.Contains("fingerprint") -and $_.data.fingerprint -eq $WorkerId + $idMatches -or $fingerprintMatches + }) + if ($matches.Count -ne 1) { + throw "Expected exactly one finding for '$WorkerId'; found $($matches.Count)" + } + + $finding = $matches[0] + $finding["status"] = $Status + $finding["dispositionAt"] = (Get-Date).ToString("o") + $finding["disposition"] = $data + } + "AddFix" { + Add-ManifestRecord $manifest "fixes" (New-Record "fix" ([ordered]@{ + id = $WorkerId + owner = $Owner + status = $Status + branch = $Branch + sha = $Sha + path = $Path + reportPath = $ReportPath + data = $data + })) + } + "AddVerification" { + Add-ManifestRecord $manifest "verifications" (New-Record "verification" ([ordered]@{ + id = $WorkerId + owner = $Owner + status = $Status + branch = $Branch + sha = $Sha + reportPath = $ReportPath + data = $data + })) + } + "AddMerge" { + Add-ManifestRecord $manifest "merges" (New-Record "merge" ([ordered]@{ + id = $WorkerId + owner = $Owner + status = $Status + branch = $Branch + sha = $Sha + path = $Path + reportPath = $ReportPath + data = $data + })) + } + "AddEvent" { + Add-ManifestRecord $manifest "events" (New-Record "event" ([ordered]@{ + id = $WorkerId + role = $Role + owner = $Owner + status = $Status + path = $Path + reportPath = $ReportPath + data = $data + })) + } + "Complete" { + $undisposed = @($manifest.findings | Where-Object { + -not $_.Contains("dispositionAt") -or $_.status -notin @("fixed", "open", "deferred") + }) + if ($undisposed.Count -gt 0) { + $ids = @($undisposed | ForEach-Object { + if ($_.Contains("id")) { $_.id } else { "" } + }) -join ", " + throw "Every finding requires UpdateFindingDisposition before Complete: $ids" + } + if (-not $Status) { $Status = "complete" } + if ($manifest.status -ne "running" -and $manifest.status -ne $Status) { + throw "Manifest is already '$($manifest.status)' and cannot be completed as '$Status': $ManifestPath" + } + $manifest.status = $Status + $manifest.completedAt = (Get-Date).ToString("o") + Add-ManifestRecord $manifest "events" (New-Record "event" ([ordered]@{ + id = "complete" + status = $Status + data = $data + })) + } + } + + Write-Manifest $ManifestPath $manifest +} +Write-Output $ManifestPath diff --git a/scripts/pipeline-manifest.test.ps1 b/scripts/pipeline-manifest.test.ps1 new file mode 100644 index 0000000..ae234e3 --- /dev/null +++ b/scripts/pipeline-manifest.test.ps1 @@ -0,0 +1,68 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" +$scriptPath = Join-Path $PSScriptRoot "pipeline-manifest.ps1" +$root = Join-Path ([System.IO.Path]::GetTempPath()) ("dropwheel-manifest-test-" + [guid]::NewGuid().ToString("N")) +$runRoot = Join-Path $root "run" +$manifestPath = Join-Path $runRoot "manifest.json" +$findingPath = Join-Path $root "finding.json" +$dispositionPath = Join-Path $root "disposition.json" + +function Invoke-ManifestProcess { + param([string[]]$Arguments) + $previousPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + $output = & powershell -NoProfile -ExecutionPolicy Bypass -File $scriptPath @Arguments 2>&1 + $exitCode = $LASTEXITCODE + $ErrorActionPreference = $previousPreference + return [pscustomobject]@{ ExitCode = $exitCode; Output = @($output) } +} + +function Assert-True { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { throw $Message } +} + +try { + New-Item -ItemType Directory -Force -Path $root | Out-Null + @{ fingerprint = "agents-harness|pipeline|manifest|disposition-required" } | + ConvertTo-Json | Set-Content -LiteralPath $findingPath -Encoding utf8 + @{ reason = "Verified and merged"; fixCommit = "abc123"; mergeCommit = "def456" } | + ConvertTo-Json | Set-Content -LiteralPath $dispositionPath -Encoding utf8 + + $result = Invoke-ManifestProcess @("-Mode", "New", "-RunRoot", $runRoot, "-RunId", "test-run") + Assert-True ($result.ExitCode -eq 0 -and (Test-Path -LiteralPath $manifestPath)) "New manifest failed" + + $result = Invoke-ManifestProcess @( + "-Mode", "AddFinding", "-ManifestPath", $manifestPath, + "-WorkerId", "finding-1", "-Owner", "agents-harness", "-Status", "pending", + "-DataJsonPath", $findingPath + ) + Assert-True ($result.ExitCode -eq 0) "AddFinding failed" + + $result = Invoke-ManifestProcess @("-Mode", "Complete", "-ManifestPath", $manifestPath, "-Status", "complete") + Assert-True ($result.ExitCode -ne 0 -and ($result.Output -join "`n") -match "UpdateFindingDisposition") ` + "Complete accepted an undisposed finding" + + $result = Invoke-ManifestProcess @( + "-Mode", "UpdateFindingDisposition", "-ManifestPath", $manifestPath, + "-WorkerId", "finding-1", "-Status", "fixed", "-DataJsonPath", $dispositionPath + ) + Assert-True ($result.ExitCode -eq 0) "UpdateFindingDisposition failed" + + $result = Invoke-ManifestProcess @("-Mode", "Complete", "-ManifestPath", $manifestPath, "-Status", "complete") + Assert-True ($result.ExitCode -eq 0) "Complete failed after disposition" + + $manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json + Assert-True ($manifest.status -eq "complete") "Manifest status is not complete" + Assert-True ($manifest.findings[0].status -eq "fixed") "Finding disposition status was not persisted" + Assert-True (-not [string]::IsNullOrWhiteSpace($manifest.findings[0].dispositionAt)) ` + "Finding disposition timestamp was not persisted" + Assert-True ($manifest.findings[0].disposition.mergeCommit -eq "def456") ` + "Finding disposition evidence was not persisted" + + Write-Output "PASS: pipeline manifest disposition contract" +} finally { + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/src/Dropwheel/Models/AppConfig.cs b/src/Dropwheel/Models/AppConfig.cs index ead741c..cb1ff5d 100644 --- a/src/Dropwheel/Models/AppConfig.cs +++ b/src/Dropwheel/Models/AppConfig.cs @@ -13,6 +13,13 @@ public class AppConfig public int HoverDelayMs { get; set; } = 250; public string Hotkey { get; set; } = "Ctrl+Alt+Space"; + /// Maximum pause between digits of a group shortcut. + public int GroupShortcutDelayMs { get; set; } = 400; + + /// Migration marker: existing groups receive stable codes once, while a code the + /// user later clears remains disabled. + public bool GroupShortcutsInitialized { get; set; } + /// Seconds of inactivity before the orb dims (0 = off). public int IdleFadeSeconds { get; set; } = 0; diff --git a/src/Dropwheel/Models/TargetItem.cs b/src/Dropwheel/Models/TargetItem.cs index 86394dc..8713f87 100644 --- a/src/Dropwheel/Models/TargetItem.cs +++ b/src/Dropwheel/Models/TargetItem.cs @@ -22,6 +22,10 @@ public class TargetItem [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? TilePosition { get; set; } + /// Optional one- or two-digit shortcut used while the pointer is over the orb. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? GroupCode { get; set; } + /// null — regular target; otherwise a group (one nesting level). [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? Children { get; set; } diff --git a/src/Dropwheel/Services/GroupShortcutActivation.cs b/src/Dropwheel/Services/GroupShortcutActivation.cs new file mode 100644 index 0000000..d2c91de --- /dev/null +++ b/src/Dropwheel/Services/GroupShortcutActivation.cs @@ -0,0 +1,32 @@ +namespace Dropwheel.Services; + +/// Tracks the activation started by hovering the orb. Once armed, shortcuts remain +/// available throughout the open wheel, including after navigating back from a group. +public sealed class GroupShortcutActivation +{ + public bool IsArmed { get; private set; } + public bool PointerOverOrb { get; private set; } + + public void PointerEntered() => PointerOverOrb = true; + + public void PointerLeft(bool wheelOpen, bool inputPending) + { + PointerOverOrb = false; + if (!wheelOpen && !inputPending) IsArmed = false; + } + + public void Refresh(bool hasCodes) => IsArmed = PointerOverOrb && hasCodes; + + public bool CanAcceptDigit(bool wheelOpen, bool inputPending) + { + if (!IsArmed) return false; + if (PointerOverOrb || wheelOpen || inputPending) return true; + IsArmed = false; + return false; + } + + public void ResetInput(bool preserveActivation, bool wheelOpen, bool hasCodes) + { + IsArmed = preserveActivation && hasCodes && (PointerOverOrb || wheelOpen); + } +} diff --git a/src/Dropwheel/Services/GroupShortcutSequence.cs b/src/Dropwheel/Services/GroupShortcutSequence.cs new file mode 100644 index 0000000..3772400 --- /dev/null +++ b/src/Dropwheel/Services/GroupShortcutSequence.cs @@ -0,0 +1,72 @@ +namespace Dropwheel.Services; + +public enum GroupShortcutMatchKind +{ + NoMatch, + Partial, + Exact, + ExactWithLongerMatches, +} + +public readonly record struct GroupShortcutMatch(GroupShortcutMatchKind Kind, string Input); + +/// Pure state machine for one- and two-digit group codes. The UI owns the timer; this +/// type only determines whether an input is complete, ambiguous, or invalid. +public sealed class GroupShortcutSequence +{ + private string[] _codes = []; + + public string Input { get; private set; } = ""; + + public static bool IsValidCode(string? code) => + code is { Length: >= 1 and <= 2 } && code.All(char.IsAsciiDigit); + + public void SetCodes(IEnumerable codes) + { + _codes = codes + .Where(IsValidCode) + .Select(code => code!) + .Distinct(StringComparer.Ordinal) + .ToArray(); + Reset(); + } + + public GroupShortcutMatch Push(char digit) + { + if (!char.IsAsciiDigit(digit)) + throw new ArgumentOutOfRangeException(nameof(digit), "A shortcut digit must be 0-9."); + + Input += digit; + return Evaluate(); + } + + public GroupShortcutMatch Timeout() + { + var exact = _codes.Contains(Input, StringComparer.Ordinal); + return new GroupShortcutMatch( + exact ? GroupShortcutMatchKind.Exact : GroupShortcutMatchKind.NoMatch, + Input); + } + + public void Reset() => Input = ""; + + private GroupShortcutMatch Evaluate() + { + var exact = false; + var longer = false; + foreach (var code in _codes) + { + if (code == Input) exact = true; + else if (code.StartsWith(Input, StringComparison.Ordinal)) longer = true; + } + + var kind = (exact, longer) switch + { + (true, true) => GroupShortcutMatchKind.ExactWithLongerMatches, + (true, false) => GroupShortcutMatchKind.Exact, + (false, true) => GroupShortcutMatchKind.Partial, + _ => GroupShortcutMatchKind.NoMatch, + }; + return new GroupShortcutMatch(kind, Input); + } +} diff --git a/src/Dropwheel/Services/KeyboardHook.cs b/src/Dropwheel/Services/KeyboardHook.cs new file mode 100644 index 0000000..4d169da --- /dev/null +++ b/src/Dropwheel/Services/KeyboardHook.cs @@ -0,0 +1,115 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace Dropwheel.Services; + +/// Low-level keyboard hook used only to observe bare digits while the orb has armed group +/// navigation. All other input is passed through untouched. +public sealed class KeyboardHook : IDisposable +{ + private delegate IntPtr HookProc(int code, IntPtr wParam, IntPtr lParam); + + [StructLayout(LayoutKind.Sequential)] + private struct KbdLlHookStruct + { + public uint VkCode; + public uint ScanCode; + public uint Flags; + public uint Time; + public IntPtr ExtraInfo; + } + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr SetWindowsHookExW(int id, HookProc proc, IntPtr hMod, uint tid); + + [DllImport("user32.dll")] + private static extern bool UnhookWindowsHookEx(IntPtr hook); + + [DllImport("user32.dll")] + private static extern IntPtr CallNextHookEx(IntPtr hook, int code, IntPtr wParam, IntPtr lParam); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr GetModuleHandleW(string? name); + + private const int WhKeyboardLl = 13; + private const int WmKeyDown = 0x0100; + private const int WmKeyUp = 0x0101; + private const int WmSysKeyDown = 0x0104; + private const int WmSysKeyUp = 0x0105; + private readonly Func _onDigit; + private readonly HashSet _capturedKeys = []; + private HookProc? _proc; + private IntPtr _hook; + + public KeyboardHook(Func onDigit) => _onDigit = onDigit; + + public bool Start() + { + if (_hook != IntPtr.Zero) return true; + _proc = Callback; + _hook = SetWindowsHookExW(WhKeyboardLl, _proc, GetModuleHandleW(null), 0); + if (_hook != IntPtr.Zero) return true; + + ErrorLog.Write("Failed to install the group shortcut keyboard hook", + new Win32Exception(Marshal.GetLastWin32Error())); + _proc = null; + return false; + } + + internal static bool TryGetDigit(uint virtualKey, out char digit) + { + if (virtualKey is >= 0x30 and <= 0x39) + { + digit = (char)('0' + virtualKey - 0x30); + return true; + } + if (virtualKey is >= 0x60 and <= 0x69) + { + digit = (char)('0' + virtualKey - 0x60); + return true; + } + digit = default; + return false; + } + + private IntPtr Callback(int code, IntPtr wParam, IntPtr lParam) + { + if (code >= 0) + { + var message = (int)wParam; + var data = Marshal.PtrToStructure(lParam); + if (TryGetDigit(data.VkCode, out var digit)) + { + if (message is WmKeyDown or WmSysKeyDown) + { + if (_capturedKeys.Contains(data.VkCode)) return new IntPtr(1); + try + { + if (_onDigit(digit)) + { + _capturedKeys.Add(data.VkCode); + return new IntPtr(1); + } + } + catch (Exception ex) + { + ErrorLog.Write("Failed to process a group shortcut digit", ex); + } + } + else if ((message is WmKeyUp or WmSysKeyUp) && _capturedKeys.Remove(data.VkCode)) + { + return new IntPtr(1); + } + } + } + return CallNextHookEx(_hook, code, wParam, lParam); + } + + public void Dispose() + { + if (_hook != IntPtr.Zero) UnhookWindowsHookEx(_hook); + _hook = IntPtr.Zero; + _capturedKeys.Clear(); + _proc = null; + } +} diff --git a/src/Dropwheel/Services/TargetStore.cs b/src/Dropwheel/Services/TargetStore.cs index 5e248d5..928ab7a 100644 --- a/src/Dropwheel/Services/TargetStore.cs +++ b/src/Dropwheel/Services/TargetStore.cs @@ -41,8 +41,10 @@ public static void Load() { var configText = File.ReadAllText(FilePath); Config = DeserializeConfig(configText, out var sanitizedInvalidEnums) ?? new(); - if (sanitizedInvalidEnums) Save(); - if (Config.Presets == null) { Config.Presets = PresetService.Defaults(); Save(); } + var needsSave = sanitizedInvalidEnums; + if (Config.Presets == null) { Config.Presets = PresetService.Defaults(); needsSave = true; } + if (InitializeGroupShortcuts()) needsSave = true; + if (needsSave) Save(); return; } catch (JsonException ex) { ErrorLog.Write("Config is corrupted; backing it up and recreating defaults", ex); shouldBackup = true; } @@ -104,6 +106,37 @@ public static void Save() public static IEnumerable Groups => Config.Targets.Where(t => t.IsGroup); + public static string? NextAvailableGroupCode(IEnumerable? reserved = null) + { + var used = (reserved ?? Groups.Select(group => group.GroupCode)) + .Where(GroupShortcutSequence.IsValidCode) + .Select(code => code!) + .ToHashSet(StringComparer.Ordinal); + for (int code = 1; code <= 99; code++) + { + var candidate = code.ToString(); + if (!used.Contains(candidate)) return candidate; + } + return used.Contains("0") ? null : "0"; + } + + private static bool InitializeGroupShortcuts() + { + if (Config.GroupShortcutsInitialized) return false; + + var used = new HashSet(StringComparer.Ordinal); + foreach (var group in Groups) + { + if (GroupShortcutSequence.IsValidCode(group.GroupCode) && used.Add(group.GroupCode!)) + continue; + + group.GroupCode = NextAvailableGroupCode(used); + if (group.GroupCode != null) used.Add(group.GroupCode); + } + Config.GroupShortcutsInitialized = true; + return true; + } + public static IReadOnlyList OrderedForDisplay(IList targets) { var indexed = targets.Select((target, index) => new { target, index }).ToArray(); @@ -289,6 +322,7 @@ private static AppConfig Defaults() static string P(Environment.SpecialFolder f) => Environment.GetFolderPath(f); return new AppConfig { + GroupShortcutsInitialized = true, Presets = PresetService.Defaults(), Targets = { new() { Name = "Downloads", Path = Path.Combine(P(Environment.SpecialFolder.UserProfile), "Downloads"), Pinned = true }, diff --git a/src/Dropwheel/UI/OverlayWindow.Bubble.cs b/src/Dropwheel/UI/OverlayWindow.Bubble.cs index fa5a540..322f601 100644 --- a/src/Dropwheel/UI/OverlayWindow.Bubble.cs +++ b/src/Dropwheel/UI/OverlayWindow.Bubble.cs @@ -56,6 +56,27 @@ private FrameworkElement MakeBubble(TargetItem t) var top = new Grid { Width = 70, Height = 66 }; top.Children.Add(sq); top.Children.Add(badge); + if (t.IsGroup && GroupShortcutSequence.IsValidCode(t.GroupCode)) + { + top.Children.Add(new Border + { + Background = new SolidColorBrush(th.Accent), + CornerRadius = new CornerRadius(9), + MinWidth = 20, + Padding = new Thickness(4, 1, 4, 1), + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Top, + Child = new TextBlock + { + Text = t.GroupCode, + Foreground = Brushes.Black, + FontFamily = new FontFamily("Consolas"), + FontWeight = FontWeights.Bold, + FontSize = 11, + TextAlignment = TextAlignment.Center, + }, + }); + } return WireBubble(t, badge, MakeLabel(t.Name), top, sq); } diff --git a/src/Dropwheel/UI/OverlayWindow.Cloud.cs b/src/Dropwheel/UI/OverlayWindow.Cloud.cs index 791956f..a2d89bc 100644 --- a/src/Dropwheel/UI/OverlayWindow.Cloud.cs +++ b/src/Dropwheel/UI/OverlayWindow.Cloud.cs @@ -36,8 +36,13 @@ private void OpenCloud() private void CloseCloud() { - if (!_open) return; + if (!_open) + { + ResetGroupShortcutInput(); + return; + } _open = false; + ResetGroupShortcutInput(); _currentGroup = null; _groupHover?.Stop(); Cloud.Children.Clear(); diff --git a/src/Dropwheel/UI/OverlayWindow.GroupShortcuts.cs b/src/Dropwheel/UI/OverlayWindow.GroupShortcuts.cs new file mode 100644 index 0000000..5d049f3 --- /dev/null +++ b/src/Dropwheel/UI/OverlayWindow.GroupShortcuts.cs @@ -0,0 +1,172 @@ +using System.Windows; +using System.Windows.Input; +using System.Windows.Threading; +using Dropwheel.Models; +using Dropwheel.Services; + +namespace Dropwheel.UI; + +public partial class OverlayWindow +{ + private readonly GroupShortcutSequence _groupShortcutSequence = new(); + private readonly GroupShortcutActivation _groupShortcutActivation = new(); + private DispatcherTimer? _groupShortcutTimer; + private KeyboardHook? _groupKeyboardHook; + + private static TimeSpan GroupShortcutInterval() => TimeSpan.FromMilliseconds( + Math.Clamp(TargetStore.Config.GroupShortcutDelayMs, 150, 1500)); + + private void InitGroupShortcuts() + { + _groupShortcutTimer = new DispatcherTimer { Interval = GroupShortcutInterval() }; + _groupShortcutTimer.Tick += (_, _) => OnGroupShortcutTimeout(); + _groupKeyboardHook = new KeyboardHook(OnGroupShortcutDigit); + _groupKeyboardHook.Start(); + RefreshGroupShortcuts(); + Closed += (_, _) => + { + _groupShortcutTimer?.Stop(); + _groupKeyboardHook?.Dispose(); + }; + } + + private void ArmGroupShortcuts() + { + _groupShortcutActivation.PointerEntered(); + RefreshGroupShortcuts(); + } + + private void OnOrbGroupShortcutLeave() + { + _groupShortcutActivation.PointerLeft( + wheelOpen: _open, + inputPending: _groupShortcutSequence.Input.Length > 0); + } + + private void RefreshGroupShortcuts() + { + var codes = TargetStore.Groups.Select(group => group.GroupCode).ToArray(); + _groupShortcutSequence.SetCodes(codes); + _groupShortcutActivation.Refresh(codes.Any(GroupShortcutSequence.IsValidCode)); + HideGroupShortcutInput(); + } + + private void ApplyGroupShortcutSettings() + { + if (_groupShortcutTimer != null) _groupShortcutTimer.Interval = GroupShortcutInterval(); + RefreshGroupShortcuts(); + } + + private bool OnGroupShortcutDigit(char digit) + { + var inputPending = _groupShortcutSequence.Input.Length > 0; + if (!_open && !inputPending && !Orb.IsMouseOver) + _groupShortcutActivation.PointerLeft(wheelOpen: false, inputPending: false); + if (!_groupShortcutActivation.CanAcceptDigit( + wheelOpen: _open, + inputPending: inputPending) + || !IsVisible || !IsEnabled || _hiddenByFullscreen || _movingOrb) + return false; + if (Orb.ContextMenu?.IsOpen == true) return false; + if (Keyboard.Modifiers != ModifierKeys.None || Mouse.LeftButton == MouseButtonState.Pressed) + return false; + + _hoverTimer.Stop(); + _closeTimer.Stop(); + _groupShortcutTimer?.Stop(); + + var match = _groupShortcutSequence.Push(digit); + switch (match.Kind) + { + case GroupShortcutMatchKind.Exact: + OpenGroupByShortcut(match.Input); + break; + case GroupShortcutMatchKind.Partial: + case GroupShortcutMatchKind.ExactWithLongerMatches: + ShowGroupShortcutCandidates(match.Input); + _groupShortcutTimer?.Start(); + break; + case GroupShortcutMatchKind.NoMatch: + ResetGroupShortcutInput(); + ShowToast($"No group shortcut {match.Input}"); + break; + } + return true; + } + + private void OnGroupShortcutTimeout() + { + _groupShortcutTimer?.Stop(); + var match = _groupShortcutSequence.Timeout(); + if (match.Kind == GroupShortcutMatchKind.Exact) + { + OpenGroupByShortcut(match.Input); + return; + } + + ResetGroupShortcutInput(); + if (match.Input.Length > 0) ShowToast($"No group shortcut {match.Input}"); + } + + private void OpenGroupByShortcut(string code) + { + var group = TargetStore.Groups.FirstOrDefault(candidate => candidate.GroupCode == code); + ResetGroupShortcutInput(); + if (group == null) + { + ShowToast($"No group shortcut {code}"); + return; + } + + if (_open) EnterGroup(group); + else + { + EnterGroup(group); + OpenCloud(); + } + } + + private void ShowGroupShortcutCandidates(string input) + { + if (!_open) + { + EnterGroup(null); + OpenCloud(); + } + else if (_currentGroup != null) + { + EnterGroup(null); + } + + ShortcutIndicatorText.Text = input + "…"; + ShortcutIndicator.Visibility = Visibility.Visible; + foreach (var element in Cloud.Children.OfType()) + { + if (element.Tag is not TargetItem target) continue; + element.Opacity = target.IsGroup + && target.GroupCode?.StartsWith(input, StringComparison.Ordinal) == true + ? 1.0 + : 0.25; + } + } + + private void ResetGroupShortcutInput(bool preserveActivation = true) + { + _groupShortcutTimer?.Stop(); + _groupShortcutSequence.Reset(); + HideGroupShortcutInput(); + _groupShortcutActivation.ResetInput( + preserveActivation, + wheelOpen: _open, + hasCodes: TargetStore.Groups.Any(group => GroupShortcutSequence.IsValidCode(group.GroupCode))); + } + + private void HideGroupShortcutInput() + { + ShortcutIndicator.Visibility = Visibility.Collapsed; + foreach (var element in Cloud.Children.OfType()) + { + if (element.Tag is TargetItem target) element.Opacity = target.Exists ? 1.0 : 0.4; + } + } +} diff --git a/src/Dropwheel/UI/OverlayWindow.IdleFade.cs b/src/Dropwheel/UI/OverlayWindow.IdleFade.cs index 6a8f807..53d183f 100644 --- a/src/Dropwheel/UI/OverlayWindow.IdleFade.cs +++ b/src/Dropwheel/UI/OverlayWindow.IdleFade.cs @@ -43,6 +43,7 @@ public void ApplySettings() _idleTimer?.Stop(); _idleTimer = null; InitIdleFade(); + ApplyGroupShortcutSettings(); // The string was already validated for parsing in settings, but being taken by another // process is only checked here: on failure the previous working combo stays. ApplyHotkey(TargetStore.Config.Hotkey, notify: true); @@ -50,6 +51,7 @@ public void ApplySettings() public void OpenSettings() { + ResetGroupShortcutInput(preserveActivation: false); new SettingsWindow { Owner = this }.ShowDialog(); } } diff --git a/src/Dropwheel/UI/OverlayWindow.Layout.cs b/src/Dropwheel/UI/OverlayWindow.Layout.cs index 3b73fa7..a4d3d8a 100644 --- a/src/Dropwheel/UI/OverlayWindow.Layout.cs +++ b/src/Dropwheel/UI/OverlayWindow.Layout.cs @@ -60,20 +60,29 @@ private void ShowToast(string msg, bool canUndo = false) private void CreateGroup() { + ResetGroupShortcutInput(preserveActivation: false); var p = new PromptWindow("New group", "Group name:") { Owner = this }; if (p.ShowDialog() == true && p.Value.Trim() is { Length: > 0 } name) { - TargetStore.Config.Targets.Add(new TargetItem { Name = name, Children = new() }); + TargetStore.Config.Targets.Add(new TargetItem + { + Name = name, + Children = new(), + GroupCode = TargetStore.NextAvailableGroupCode(), + }); TargetStore.Save(); + RefreshGroupShortcuts(); if (_open) BuildCloud(); } } private void OpenEditor(TargetItem t, TargetItem? preselectGroup = null) { + ResetGroupShortcutInput(preserveActivation: false); var dlg = new TargetEditorWindow(t, preselectGroup) { Owner = this }; dlg.ShowDialog(); TargetStore.Save(); + RefreshGroupShortcuts(); if (_open) BuildCloud(); } diff --git a/src/Dropwheel/UI/OverlayWindow.xaml b/src/Dropwheel/UI/OverlayWindow.xaml index 98a86c4..7d08e80 100644 --- a/src/Dropwheel/UI/OverlayWindow.xaml +++ b/src/Dropwheel/UI/OverlayWindow.xaml @@ -37,6 +37,12 @@ VerticalAlignment="Center" Margin="7,0,0,0"/> + + + { + ArmGroupShortcuts(); if (!_open && !Keyboard.Modifiers.HasFlag(ModifierKeys.Alt)) _hoverTimer.Start(); }; - Orb.MouseLeave += (_, _) => _hoverTimer.Stop(); + Orb.MouseLeave += (_, _) => { _hoverTimer.Stop(); OnOrbGroupShortcutLeave(); }; Orb.MouseLeftButtonDown += OnOrbMouseDown; Orb.DragEnter += (_, _) => { _closeTimer.Stop(); OpenCloud(); }; Orb.DragOver += OnAddTargetDragOver; @@ -58,7 +59,11 @@ public OverlayWindow() Orb.ContextMenu = orbMenu; Root.MouseEnter += (_, _) => _closeTimer.Stop(); - Root.MouseLeave += (_, _) => { if (_open) _closeTimer.Start(); }; + Root.MouseLeave += (_, _) => + { + ResetGroupShortcutInput(preserveActivation: false); + if (_open) _closeTimer.Start(); + }; AllowDrop = true; PreviewDragOver += OnTileReorderPreviewDragOver; PreviewDrop += OnTileReorderPreviewDrop; @@ -67,7 +72,7 @@ public OverlayWindow() Deactivated += (_, _) => CloseCloud(); Loaded += (_, _) => - { PlaceWindow(); PaintHub(); InitProximity(); InitHotkeyAndFullscreen(); InitIdleFade(); }; + { PlaceWindow(); PaintHub(); InitProximity(); InitHotkeyAndFullscreen(); InitGroupShortcuts(); InitIdleFade(); }; LocationChanged += (_, _) => UpdateOrbScreenPos(); } } diff --git a/src/Dropwheel/UI/SettingsWindow.xaml b/src/Dropwheel/UI/SettingsWindow.xaml index e9a413b..952b1ab 100644 --- a/src/Dropwheel/UI/SettingsWindow.xaml +++ b/src/Dropwheel/UI/SettingsWindow.xaml @@ -50,6 +50,9 @@ + +