diff --git a/.gitattributes b/.gitattributes
index d65fbf8..54ebe78 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,13 +1,13 @@
-# Git hooks и скрипты ДОЛЖНЫ быть LF — CRLF в shebang (`#!/usr/bin/env node\r`)
-# ломает запуск на macOS/Linux ("node\r: not found"). Форсим LF независимо от autocrlf.
+# Git hooks and scripts must stay LF; CRLF in a shebang (`#!/usr/bin/env node\r`)
+# breaks macOS/Linux execution ("node\r: not found"). Force LF regardless of autocrlf.
hooks/*.js text eol=lf
hooks/**/*.js text eol=lf
install.js text eol=lf
*.sh text eol=lf
-# Конфиги — тоже LF: иначе на Windows (autocrlf=true) рабочая копия становится CRLF,
-# и `node hooks/doctor.js`, читающий рабочее дерево, даёт ложный FAIL (в индексе и на
-# Linux-CI всё равно 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
+# FAIL even when the index and Linux CI are LF. Pin these formats explicitly.
*.toml text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..0bca446
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,5 @@
+# Code owners for this target repository.
+# No owner is configured yet, so the installed ruleset keeps code-owner
+# review disabled and relies on the regular approving-review requirement.
+# Re-run install.js with --code-owner @org/team or edit this file and
+# enable require_code_owner_review in .github/rulesets/main.json.
diff --git a/.github/rulesets/main.json b/.github/rulesets/main.json
index 9486754..2bab91d 100644
--- a/.github/rulesets/main.json
+++ b/.github/rulesets/main.json
@@ -1,21 +1,28 @@
{
- "_comment": "Versioned GitHub branch ruleset — the REAL enforcement layer (server-side, cannot be skipped by `git ... --no-verify` or by editing local hooks). Apply with: node hooks/apply-ruleset.js. NOTE: private repos need GitHub Pro/Team/Enterprise for rulesets; on Free make the repo public or upgrade (BACKLOG P0-0). require_code_owner_review is FALSE on purpose: a solo maintainer cannot approve their own PR — enabling it would deadlock main. integration_id 15368 pins the required check to GitHub Actions: without it ANYONE with write access can satisfy the check by posting a fake commit status named 'verify' via the API.",
+ "_comment": "Installed GitHub branch ruleset for llm-dev-harness: the server-side gate that local hooks cannot replace. It requires PRs, the GitHub Actions verify check pinned by integration_id, and blocks force-push/delete on main. Code-owner review is disabled because install.js was run without --code-owner; the regular approving-review requirement remains enabled. Re-run install.js with --code-owner @org/team to require CODEOWNERS review.",
"name": "protect-main",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": {
- "include": ["refs/heads/main", "refs/heads/master"],
+ "include": [
+ "refs/heads/main",
+ "refs/heads/master"
+ ],
"exclude": []
}
},
"rules": [
- { "type": "deletion" },
- { "type": "non_fast_forward" },
+ {
+ "type": "deletion"
+ },
+ {
+ "type": "non_fast_forward"
+ },
{
"type": "pull_request",
"parameters": {
- "required_approving_review_count": 0,
+ "required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": false,
"require_last_push_approval": false,
@@ -27,7 +34,10 @@
"parameters": {
"strict_required_status_checks_policy": true,
"required_status_checks": [
- { "context": "verify", "integration_id": 15368 }
+ {
+ "context": "verify",
+ "integration_id": 15368
+ }
]
}
}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0ecdc2d..4898e32 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,22 +1,107 @@
-name: CI
+name: verify
+# CI mirror of the local harness - the SAME checks, server-side where local hooks and
+# `--no-verify` cannot skip them. The job id `verify` is the context referenced by the
+# branch ruleset (.github/rulesets/main.json -> required_status_checks). Renaming the job
+# means updating the ruleset too.
+#
+# On GitHub Free + private this workflow RUNS and reports status but cannot be *required*
+# (rulesets need Pro/Team or a public repo - see BACKLOG P0-0).
+#
+# Actions are pinned to full commit SHAs. The trailing comment keeps Dependabot's
+# github-actions updater able to identify the source action and desired major.
on:
+ # Push runs only on main for post-merge control. Branches are checked through PRs;
+ # otherwise every PR commit would run CI twice (push + pull_request).
push:
branches: [main]
pull_request:
+ branches: [main]
+
+permissions:
+ contents: read
jobs:
verify:
runs-on: windows-latest
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7
+ with:
+ fetch-depth: 0
+
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # actions/setup-node@v6
+ with:
+ node-version: "22"
+
+ - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # actions/setup-dotnet@v5
+ with:
+ dotnet-version: "10.0.x"
- - uses: actions/setup-dotnet@v5
+ - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # actions/setup-go@v6
with:
- dotnet-version: 10.0.x
+ go-version: "1.24.x"
+
+ # Doctor catches bootstrap drift before the required check can go green by name only.
+ - name: Doctor (harness contract)
+ run: node hooks/doctor.js
+
+ # Secrets - gitleaks. Install from the Go module instead of gitleaks-action:
+ # the action's Windows asset resolver requested a non-existent .tar.gz.
+ - name: Secret scan (gitleaks)
+ shell: bash
+ env:
+ GITLEAKS_VERSION: "v8.24.3"
+ GITLEAKS_CONFIG: ".gitleaks.toml"
+ run: |
+ go install github.com/zricethezav/gitleaks/v8@$GITLEAKS_VERSION
+ gitleaks detect --source . --config "$GITLEAKS_CONFIG" --redact --no-banner
+
+ # Conventional Commits across the PR's commits - cocogitto (`cog check`).
+ # cocogitto-action is Linux-only; install the Windows binary explicitly.
+ - name: Install cocogitto
+ if: github.event_name == 'pull_request'
+ shell: bash
+ env:
+ COG_VERSION: "7.0.0"
+ COG_SHA256: "074f68f05d270da5c0d69d3e234ec362bec4c6e3189c21d1c948d038603655d7"
+ run: |
+ curl -fsSL -o cog.tar.gz "https://github.com/cocogitto/cocogitto/releases/download/$COG_VERSION/cocogitto-$COG_VERSION-x86_64-pc-windows-msvc.tar.gz"
+ echo "$COG_SHA256 cog.tar.gz" | sha256sum -c -
+ tar -xzf cog.tar.gz
+ "$PWD/x86_64-pc-windows-msvc/cog.exe" --version
+
+ # `cog.toml` is release-tag aware, but PR checks must also work before the
+ # first release tag exists. Use the PR base SHA explicitly instead of
+ # relying on from_latest_tag.
+ - name: Conventional commit range
+ if: github.event_name == 'pull_request'
+ shell: bash
+ run: ./x86_64-pc-windows-msvc/cog.exe check "${{ github.event.pull_request.base.sha }}..HEAD" --ignore-merge-commits
+
+ # Executable VERIFY: auto-detects stacks and runs lint/build/test fail-fast.
+ # For this repo that runs the harness self-test suite (node hooks/test.js).
+ - name: VERIFY (verify.js)
+ run: node hooks/verify.js
- - name: Build
- run: dotnet build src/Dropwheel -c Release
+ # AgentShield is an offline security scan of agent configuration: secrets,
+ # broad tool/MCP permissions, hook injections, and unsafe CLAUDE.md/AGENTS.md
+ # patterns. The version is pinned rather than using a floating tag/action, in
+ # the same supply-chain spirit as action SHA pinning. We do not use `--opus`;
+ # the baseline scan runs offline without a key. It is advisory for now
+ # (continue-on-error) while we collect false-positive data on our configs.
+ # Later: remove continue-on-error and add this as a second required check.
+ - name: AgentShield (agent-config security scan, advisory)
+ continue-on-error: true
+ shell: bash
+ env:
+ AGENTSHIELD_VERSION: "1.4.0"
+ AGENTSHIELD_INTEGRITY: "sha512-R98OO1Ujyk2lezDLb+iQmMhF6FwTJCHajy3G4FCB6x7wkSTqR9f8+eAelC5KDzYDsGSbc0sOZvjXOOPRBtMpDg=="
+ NPM_CONFIG_IGNORE_SCRIPTS: "true"
+ run: |
+ node -e "const {execFileSync}=require('child_process'); const got=execFileSync('npm',['view','ecc-agentshield@'+process.env.AGENTSHIELD_VERSION,'dist.integrity'],{encoding:'utf8'}).trim(); if(got!==process.env.AGENTSHIELD_INTEGRITY){ console.error('ecc-agentshield integrity mismatch: '+got); process.exit(1); }"
+ npx --yes ecc-agentshield@$AGENTSHIELD_VERSION scan --path .
- - name: Test
- run: dotnet test tests/Dropwheel.Tests -c Release
+ # DESIGN-gate: UI changes require an approved mockup set touched in the same diff.
+ - name: DESIGN-gate
+ if: github.event_name == 'pull_request'
+ run: node hooks/design-gate.js --strict --base ${{ github.event.pull_request.base.sha }}
diff --git a/.gitleaks.toml b/.gitleaks.toml
index 2d1813c..c119ec0 100644
--- a/.gitleaks.toml
+++ b/.gitleaks.toml
@@ -1,4 +1,4 @@
-# .gitleaks.toml — gitleaks config (replaces hooks/secret-scan.js).
+# .gitleaks.toml - gitleaks config (replaces hooks/secret-scan.js).
# Uses the full default ruleset (100+ high-precision detectors) and layers a small allowlist.
# Inline exception on a line: gitleaks:allow (legacy `secret-scan:allow` also honored below).
diff --git a/AGENTS.md b/AGENTS.md
index 6379b73..8dde87f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,164 +1,235 @@
-# AGENTS.md — Dev Loop
+# AGENTS.md - Dev Loop
-> Каждый заход агента в код идёт через один цикл. Всё, что можно проверить кодом,
-> гарантируют хуки (см. «Слои харнесса»); здесь остаётся только то, что хуками не покрыть.
+Every agent pass through code follows one loop. Anything that can be checked by
+code belongs in hooks; this document keeps the behavior that hooks cannot fully
+enforce.
## Loop
-```
-1. EXPLORE — изучить кодбазу, паттерны, риски
-2. PLAN — план → ⏸ APPROVAL пользователя
-2.5 DESIGN (GUI) — ≥4 мокапа → ⏸ APPROVAL → файл APPROVED
-3. IMPLEMENT+TEST — код + тесты вместе (edge cases как тесты)
-4. VERIFY — node hooks/verify.js + чтение вывода + git diff self-review
- ├─ провал/новые warnings → вернуться к 3
- └─ зелёное → шаг 5
-5. COMMIT on branch → PR (не в main)
-6. REPORT — изменено / проверено / осталось / как тестировать
-7. ⏸ USER DECISION — принять = DONE; доработать → к 2/3; отклонить → откат
+```text
+1. EXPLORE - read the codebase, patterns, and risks
+2. PLAN - plan -> user approval
+2.5 DESIGN (GUI) - >=4 mockups -> user approval -> APPROVED file
+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
+6. REPORT - changed / verified / remaining / manual test notes
+7. USER DECISION - accept = DONE; revise -> 2 or 3; reject -> revert
```
-**Сокращение:** trivial-фикс (опечатка/однострочник) — можно без plan mode;
-VERIFY и feature-ветка обязательны всегда.
+Shortcut: a trivial typo or one-line fix can skip plan mode, but VERIFY and a
+feature branch are always required.
## Bootstrap
-Перед тем как требовать этот loop от целевого репозитория, харнесс должен быть
-закоммичен в `main` отдельным bootstrap PR. Минимальный набор: `hooks/`, `AGENTS.md`,
+Before requiring this loop from a target repository, commit the harness to
+`main` through a separate bootstrap PR. Minimum set: `hooks/`, `AGENTS.md`,
`harness.config.json`, `lefthook.yml`, `cog.toml`, `.gitleaks.toml`,
-`settings.example.json` и, если включён CI/ruleset, `.github/`.
+`settings.example.json`, and `.github/` when CI/rulesets are enabled.
+
+`node hooks/doctor.js` checks not only that these files exist, but also that they
+are tracked in git. If harness files are untracked, a clean worktree from
+`origin/main` cannot run `node hooks/verify.js`, `design-gate.js`, or release
+through `cog bump --auto`. In that state, create the bootstrap PR first; release
+flow is not fully enforceable.
-`node hooks/doctor.js` проверяет не только наличие этих файлов, но и то, что они
-tracked в git. Если файлы лежат локально untracked, clean worktree от `origin/main`
-не сможет выполнить `node hooks/verify.js`, `design-gate.js` или release через
-`cog bump --auto`. В таком состоянии сначала делается bootstrap PR; release flow
-считается не fully enforceable.
+## Stage Rules
-## Правила по этапам
+**1. EXPLORE.** Do not assume structure. Verify it with `rg`, file reads, and
+existing tests. Changes across more than two or three files, or behavior changes,
+need a plan.
-**1. EXPLORE.** Не предполагать структуру — проверять (`grep`/`find`/Read).
-Задача на >2–3 файла или смену поведения ⇒ нужен план.
+**2. PLAN.** Non-trivial work goes through plan mode and user approval before
+implementation. The plan names the files, rationale, tests, risks, and edge
+cases.
-**2. PLAN.** Нетривиальное — только через plan mode (`EnterPlanMode` → `ExitPlanMode`,
-реализация после approval). В плане: что меняем, почему, как тестируем, риски/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).** UI-работа (глобы в `harness.config.json → ui`) до кода:
-новый GUI → ≥4 стилистически разных мокапа (`node hooks/new-mockups.js `),
-показать пользователю, выбрать направление; правка GUI → мокап нового состояния.
-После approval — файл `design/mockups//APPROVED`.
-Гейт `hooks/design-gate.js` (pre-push + CI) пропускает UI-изменения только если
-одобренный набор **затронут в diff этой же ветки**; повторное использование старого
-набора — допиши строку в его APPROVED (дата/ветка), чтобы он попал в diff.
+**3. IMPLEMENT+TEST.** Code and tests move together. If a target project has no
+test runner, report that and propose a minimal one.
-**3. IMPLEMENT+TEST.** Код и тесты вместе. Нет тест-раннера — сказать в отчёте
-и предложить минимальный.
+**4. VERIFY before commit.** `node hooks/verify.js` auto-detects common stacks:
+Python uses ruff and pytest; C# uses dotnet format, build with warnings as
+errors, and test; Rust uses fmt, clippy with warnings as errors, and test; Node
+uses npm lint/build/test. Overrides live in `harness.config.json -> verify`.
+`--list` prints the plan without running it.
-**4. VERIFY (до коммита).** `node hooks/verify.js` — авто-детект стеков
-(Python→ruff+pytest; C#→dotnet format/build -warnaserror/test; Rust→fmt+clippy -D warnings+test;
-Node→npm lint/build/test), fail-fast, warnings-as-errors зашиты в шаги; переопределение —
-`harness.config.json → verify`; `--list` — план без запуска. Параллельно — **debug-аудит**
-изменённых файлов: hard-маркеры (`debugger;`/`breakpoint()`/`pdb.set_trace()`/`dbg!()`)
-валят VERIFY, soft (`console.log`/`print`) — заметка (`debugAudit` в конфиге). Сверх exit-кода: прочитать
-вывод билда (новые warnings, deprecation, «falling back to …» — чинить или явно отметить
-в отчёте) и сделать `git diff` self-review (debug-логи, закомментированный код, мусор).
+VERIFY also runs a debug audit on changed files. Hard markers such as
+`debugger;`, `breakpoint()`, `pdb.set_trace()`, and `dbg!()` fail VERIFY. Soft
+markers such as `console.log` and `print` are notes when configured. Beyond the
+exit code, read build output for new warnings, deprecations, and fallbacks, and
+run a git diff self-review for debug logs, commented-out code, and debris.
-**5. COMMIT → PR.** Только feature-ветка (`feat/…`, `fix/…`, `docs/…`), не main.
-Conventional Commits: `(): `; `feat:`→MINOR, `fix:`→PATCH,
-`!`/`BREAKING CHANGE:`→MAJOR. Без соавторства (`Co-Authored-By`, «Generated with …») —
-lefthook отклонит. `git push` / `--force` / `reset --hard` — только по явному запросу
-пользователя.
+**5. COMMIT -> PR.** Work on a feature branch such as `feat/...`, `fix/...`, or
+`docs/...`, never directly on `main`. Use Conventional Commits:
+`(): `. `feat:` means MINOR, `fix:` means PATCH, and `!`
+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.
-**6. REPORT.** Что изменено / чем проверено (команды + результат) / что осталось /
-как пользователю проверить руками.
+**6. REPORT.** Report what changed, what was verified with commands and results,
+what remains, and how the user can test manually.
-**7. USER DECISION.** Loop завершён только когда пользователь принял результат.
+**7. USER DECISION.** The loop is complete only when the user accepts the result.
-## Release flow (только по явной просьбе, после merge в main)
+## Release Flow
-| Шаг | Действие | Гейт |
+Run this only on explicit request and after merge to `main`.
+
+| Step | Action | Gate |
|---|---|---|
-| R1 | clean worktree от `origin/main`, `node hooks/doctor.js` зелёный; версия: `git describe --tags --abbrev=0` | |
-| R2 | SemVer из conventional-commits (`cog bump --auto`): annotated tag + CHANGELOG | ⏸ показать тэг/diff/notes → approval |
-| R3 | `git push origin && git push origin vX.Y.Z` | ⏸ только после явного «да» |
-| R4 | `gh run watch` — релизный workflow зелёный, без skipped-шагов | |
-| R5 | `gh release view vX.Y.Z` — Published, артефакты на месте | |
-| R6 | скачать артефакт, smoke-тест, версия в бинарнике = тэг | |
-| R7 | знать откат: до публикации — пересоздать тэг; после — `gh release delete` + revert (с approval) | |
-
-Hotfix: ветка от тэга (`git checkout -b hotfix/x.y.z vX.Y.Z-1`) → fix → PR в main + тэг по R2–R6.
-Легитимный коммит на main (CHANGELOG релиза): `HARNESS_ALLOW_MAIN=1 git commit …`.
-
-После `gh pr merge --delete-branch` GitHub может успешно смержить PR server-side,
-а локальный post-merge pull/rebase всё равно упадёт из-за dirty worktree. В этом
-случае отдельно проверь `gh pr view --json state,mergedAt,mergeCommit`; если
-state = `MERGED`, merge состоялся. Локальную синхронизацию делай только из чистого
-дерева: `git fetch origin` → `git merge --ff-only origin/main`. Для release лучше
-создать новый clean worktree от `origin/main`, а не продолжать из грязной рабочей
-папки с bootstrap/untracked файлами.
-
-## Слои харнесса
-
-**Слой 0 — серверный ruleset (единственный настоящий enforcement).**
-`.github/rulesets/main.json`, ставится `node hooks/apply-ruleset.js`: require PR,
-required check `verify`, блок force-push/delete main. Не обходится локально.
-(Free+private: ruleset требует Pro или публичный репо — BACKLOG P0-0.)
-
-**Слой 1 — lefthook (гигиена, для любого агента/человека).** `lefthook install`:
-commit-msg → `cog verify` + запрет соавторства; pre-commit → gitleaks + запрет коммита
-на main; pre-push → `verify.js` + `design-gate.js`.
-На Windows диагностику отдельных команд запускай через `.cmd`, чтобы не упереться
-в PowerShell ExecutionPolicy: `lefthook.cmd run pre-commit --command branch-guard --force --verbose`.
-Для `commit-msg` передавай файл сообщения позиционным аргументом:
-`$msg = Join-Path $env:TEMP "commit-msg.txt"; Set-Content $msg "fix(hooks): test"; lefthook.cmd run commit-msg $msg --command no-coauthor --force --verbose`.
-Флаг singular: `--command`, не `--commands`.
-
-**Слой 2 — agent-adapter (опционально, per-runtime).** Один хук `hooks/agent/guard.js`
-на PreToolUse + `stop-reminder.js` на Stop (молчит при чистом дереве и при
-`stop_hook_active`). Вход — нормализованный JSON (`hooks/agent/_input.js`), поэтому
-подходит любому раннеру. Логика guard — в экспортируемой `run(ctx, env) →
-{exitCode, stdout, stderr}` без побочных эффектов: тесты и диспетчеры зовут её
-in-process (без ~50-100мс спавна), CLI-обёртка — для раннеров.
-Stop-reminder — напоминание, не enforcement: первый Stop при dirty tree блокирует
-и показывает статус, повторный Stop с тем же `git status` пропускает осознанно
-оставленные uncommitted/bootstrap/local файлы.
-Строгость: `HARNESS_PROFILE=minimal|standard|strict` (minimal — только анти-обход
-и защита файлов харнесса; strict — пороги циклов вдвое ниже) и
-`HARNESS_DISABLED_CHECKS=` для точечного отключения — это ручки ЧЕЛОВЕКА
-в env раннера, команды агента на env хуков не влияют. Контракт: exit 0 = allow, exit 2 = block; заметка —
-`{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"…"}}`
-(+ top-level дубль для простых раннеров, stderr-зеркало для человека); Stop-хук
-доносит текст ТОЛЬКО через `{"decision":"block","reason":"…"}`.
-Подключение: скопировать блок `hooks` из `settings.example.json` в `.claude/settings.json`.
-
-| guard.js ловит | Тип |
+| 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.
+
+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
+`gh pr view --json state,mergedAt,mergeCommit`; if `state` is `MERGED`, the
+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`.
+
+## Harness Layers
+
+**Layer 0 - server ruleset.** The only real enforcement layer. Versioned in
+`.github/rulesets/main.json` and applied with `node hooks/apply-ruleset.js`.
+It requires PRs, the `verify` required check, and blocks force-push/delete on
+main. It cannot be bypassed locally. Private repositories need a plan that
+supports rulesets, or the repository must be public.
+This source repository uses a solo-maintainer variant: approving/code-owner
+review is advisory, while target installs keep regular approving review by
+default through `install.js`.
+
+**Layer 1 - lefthook.** Local hygiene for humans and agents:
+commit-msg runs `cog verify` and rejects co-author trailers; pre-commit runs
+gitleaks and branch guard; pre-push runs `verify.js` and `design-gate.js`.
+
+On Windows, diagnose commands through `.cmd` to avoid PowerShell ExecutionPolicy:
+
+```powershell
+lefthook.cmd run pre-commit --command branch-guard --force --verbose
+$msg = Join-Path $env:TEMP "commit-msg.txt"; Set-Content $msg "fix(hooks): test"; lefthook.cmd run commit-msg $msg --command no-coauthor --force --verbose
+```
+
+Use `--command`, not `--commands`.
+
+**Layer 2 - agent adapter.** Optional per-runtime hooks. `hooks/agent/guard.js`
+runs on PreToolUse and `hooks/agent/stop-reminder.js` runs on Stop. Input is
+normalized JSON through `hooks/agent/_input.js`. Guard logic is exported as
+`run(ctx, env) -> {exitCode, stdout, stderr}` so tests and dispatchers can call
+it in-process; the CLI wrapper is for runners.
+
+Stop-reminder is a reminder, not hard enforcement. First Stop with a dirty tree
+blocks and shows status; a second Stop with the same status passes so intentional
+uncommitted bootstrap/local files do not trap the user.
+
+Strictness:
+
+- `HARNESS_PROFILE=minimal|standard|strict`; minimal keeps only anti-bypass and
+ harness-file protection, strict halves loop thresholds.
+- `HARNESS_DISABLED_CHECKS=` disables targeted checks. These are human
+ runner env knobs; agent shell commands should not change hook env.
+
+Contract: exit 0 allows, exit 2 blocks. Notes are emitted through
+`hookSpecificOutput.additionalContext` plus a top-level duplicate for simpler
+runners. Stop communicates only through `{"decision":"block","reason":"..."}`.
+Install by copying the `hooks` block from `settings.example.json` to
+`.claude/settings.json`.
+
+| guard.js catches | Type |
|---|---|
-| Обход харнесса: `--no-verify`/`commit -n`, `core.hooksPath` (config и `-c`), `LEFTHOOK=0`, `lefthook uninstall`, запись в `.git/hooks` | блок |
-| Правку файлов харнесса (`hooks/`, `lefthook.yml`, конфиги, workflows, `.claude/settings.json`) — file-tools **и** shell (POSIX `sed -i`/`rm`/`mv`/`tee`/редирект + cmd/PowerShell `del`/`move`/`Remove-Item`/`Set-Content`…); пути нормализуются (`./`, `..`, регистр, `/` и `\`) | блок |
-| Правку **существующего** lint/format-конфига проекта (`.eslintrc*`, `ruff.toml`, `biome.json`, `clippy.toml`, `pytest.ini`…) — красный VERIFY чинится кодом, а не ослаблением конфига; создание нового конфига с нуля разрешено; смешанные файлы (`pyproject.toml`, `package.json`, `tsconfig.json`) намеренно не в списке | блок |
-| Дегенеративные циклы: серия тривиальных команд; N× одно действие подряд; чередование A-B-A-B — на shell **и** на file-tools (Read/Write/Edit) | блок |
-| Мусор tool-разметки / низкоэнтропийную команду (признак сбоя стриминга/парсинга) | блок |
-| Обрезанный или нечитаемый входной payload (fail-closed, всегда включён) | блок |
-| git commit/merge/push или правку файлов на `main`/`master` | note |
-| Правку UI-файла — напоминание про DESIGN-стадию (≥N мокапов + `APPROVED`) | note |
-| fact-force: правку существующего файла, не читанного в этой сессии (EXPLORE → IMPLEMENT) | note |
-
-Осознанный, одобренный пользователем обход блока: `HARNESS_ACK_BYPASS=1` (аудит-заметка в контекст).
-Что guard **не** ловит: «тонкое» зацикливание из внешне осмысленных шагов и состязательный обход
-(подстановка переменных, конкатенация кавычек) — это закрывают TodoWrite + этап 7 (пользователь
-видит, что todo не двигаются) и настоящий серверный ruleset.
+| Harness bypass: `--no-verify`, `commit -n`, `core.hooksPath`, `LEFTHOOK=0`, `lefthook uninstall`, writes under `.git/hooks`. | block |
+| Edits to harness files: `hooks/`, `lefthook.yml`, configs, workflows, `.claude/settings.json`; both file tools and shell writes are covered, including quoted paths. | block |
+| Edits to existing lint/format configs such as `.eslintrc*`, `ruff.toml`, `biome.json`, `clippy.toml`, `pytest.ini`. Mixed files such as `pyproject.toml`, `package.json`, and `tsconfig.json` are intentionally excluded. | block |
+| Degenerate loops: trivial command streaks, same action repeated N times, A-B-A-B alternation, for shell and file tools. | block |
+| Tool markup debris, low-entropy commands, and unreadable non-empty hook payloads. | block |
+| git commit/merge/push or edits while on `main`/`master`. | note |
+| UI file edit requiring DESIGN stage. | note |
+| fact-force: editing an existing file before reading it in the session. | note |
+
+Approved bypass of a guard block: `HARNESS_ACK_BYPASS=1`, with an audit note in
+context. Guard does not catch subtle meaningful-looking loops or adversarial
+string construction; Todo progress, user decision, and the server ruleset cover
+that boundary.
## Env
-Ручки для ЧЕЛОВЕКА — задаются в env раннера; команды агента на env хуков не влияют.
+Human runner knobs:
-| Переменная | Назначение | Default |
+| Variable | Purpose | Default |
|---|---|---|
-| `HARNESS_ALLOW_MAIN=1` | легитимный коммит на `main` (релиз/hotfix/bootstrap); снимает branch-guard в pre-commit | — |
-| `HARNESS_ACK_BYPASS=1` | одобренный пользователем обход guard.js (блок → аудит-заметка) | — |
-| `HARNESS_PROFILE` | строгость guard: `minimal` (только анти-обход + защита файлов харнесса), `standard`, `strict` (пороги циклов вдвое ниже) | `standard` |
-| `HARNESS_DISABLED_CHECKS` | точечно выключить проверки: `loops,entropy,lintconfig,design-note,fact-force,…` | — |
-| `HARNESS_LOOP_THRESHOLD` | порог циклов shell-команд | 5 |
-| `HARNESS_TOOLLOOP_THRESHOLD` | порог циклов file-tools (Read/Write/Edit) | 12 |
-| `HARNESS_SESSION_ID` / `HARNESS_PROJECT_DIR` | ключ состояния guard, если раннер не задал своих (`CLAUDE_*`/`ZCODE_*` тоже читаются) | — |
-| `HARNESS_ROOT` | корень для `new-mockups.js` при scaffolding мокапов | корень репо |
-| `LEFTHOOK=0` | пропуск lefthook-хуков (только человек; агенту блокирует guard) | — |
+| `HARNESS_ALLOW_MAIN=1` | Legitimate release/hotfix/bootstrap commit on `main`; bypasses branch guard in pre-commit. | unset |
+| `HARNESS_ACK_BYPASS=1` | User-approved bypass of a guard block; emits an audit note. | unset |
+| `HARNESS_PROFILE` | Guard strictness: `minimal`, `standard`, `strict`. | `standard` |
+| `HARNESS_DISABLED_CHECKS` | Disable targeted checks: `loops,entropy,lintconfig,design-note,fact-force,...`. | unset |
+| `HARNESS_LOOP_THRESHOLD` | Shell loop threshold. | `5` |
+| `HARNESS_TOOLLOOP_THRESHOLD` | File-tool loop threshold. | `12` |
+| `HARNESS_SESSION_ID` / `HARNESS_PROJECT_DIR` | Guard state key when the runner did not provide one. | unset |
+| `HARNESS_ROOT` | Root for `new-mockups.js` when scaffolding mockups. | repo root |
+| `LEFTHOOK=0` | Skip lefthook for humans only; guard blocks agent use. | unset |
+
+## Dropwheel Canary Inbox
+
+Dropwheel at `C:\Users\poweruser\projects\csharp\dropwheel` is the canary
+target for this harness.
+
+When a report arrives under `inbox/dropwheel` or from a Dropwheel Codex thread,
+use `$harness-triage` and follow `docs/dropwheel-harness-inbox.md`.
+
+Routing rule:
+
+- installer, doctor, harness syntax, guard, design gate, verify runner, or
+ generated harness file failure caused by source harness behavior belongs in
+ this repo;
+- installer-created files that only need to be accepted/tracked in Dropwheel are
+ `dropwheel-harness-update` and should be routed back to Dropwheel auto-fix;
+- Dropwheel build/test failure after a green install and doctor belongs in
+ Dropwheel unless the report proves a bad harness contract;
+- unclear owner starts here as triage, then routes to the right project.
+
+For valid harness bugs, reproduce against a disposable Dropwheel canary
+worktree, fix the smallest harness behavior, add regression coverage, run
+`node hooks\verify.js`, and rerun:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File C:\Users\poweruser\projects\csharp\dropwheel\scripts\harness-canary.ps1
+```
+
+The Dropwheel inbox automation is allowed to perform these harness fixes
+automatically. If the main checkout is dirty, make code changes in an isolated
+local worktree under `.codex\auto-fix-worktrees` and keep inbox bookkeeping in
+the main checkout. After `node hooks\verify.js` and the Dropwheel canary pass,
+create a local feature-branch commit with a Conventional Commit message. Do not
+push, merge, release, reset, force-push, or bypass hooks without an explicit
+user request.
+
+## Color Team Review
+
+When the user asks for Color Team Review, use `$color-team-review` instead of
+expanding a long prompt inline. Keep the compact format: verdict, evidence-based
+findings, what is good, priority fixes, minimal safe plan, useful tests, and
+verdict-changing questions only.
+
+For Dropwheel handoffs, read `docs/dropwheel-harness-inbox.md` and process
+`inbox/dropwheel/review-handoff-*` files as first-class harness triage inputs,
+even when the related canary report is green.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9b31053..a587092 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,44 @@
# Changelog
- - -
+## [v0.12.0](https://github.com/IvanLarinDev/dropwheel/compare/647c2ca2c4eb3981b191d0f87277f725d693e1a2..v0.12.0) - 2026-07-10
+#### Features
+- (**overlay**) enrich browser URL targets - ([b387999](https://github.com/IvanLarinDev/dropwheel/commit/b3879996c89fef78c32f6dffe94a84848cebe121)) - Ivan Larin
+- (**overlay**) stage telegram drops on clipboard - ([75947d7](https://github.com/IvanLarinDev/dropwheel/commit/75947d79dfc2d3fe319c8d6ab9643687401d23ee)) - Ivan Larin
+- (**overlay**) add link quick-access targets - ([b60c4fc](https://github.com/IvanLarinDev/dropwheel/commit/b60c4fc0d0543c88bbee7509fdfc1ad9314104d4)) - Ivan Larin
+#### Bug Fixes
+- (**config**) merge unknown enum config load - ([0aa30ee](https://github.com/IvanLarinDev/dropwheel/commit/0aa30eeb3352c64beb9d6c937d44bab4a60c608e)) - Ivan Larin
+- (**config**) preserve config on unknown enum load - ([fe9fad2](https://github.com/IvanLarinDev/dropwheel/commit/fe9fad2f2a4a1b21cd702c50ea5cb48a3580238b)) - Ivan Larin
+- (**overlay**) accept delayed telegram text drops - ([a32c791](https://github.com/IvanLarinDev/dropwheel/commit/a32c7918b73e0160fcab218715182557f8a7de4e)) - Ivan Larin
+- (**overlay**) accept move-only telegram text drops - ([a247351](https://github.com/IvanLarinDev/dropwheel/commit/a247351e92916d6c80f992508ae5f839d3a4ffdc)) - Ivan Larin
+- (**overlay**) paste telegram drops into topic - ([c3e3b05](https://github.com/IvanLarinDev/dropwheel/commit/c3e3b05bc1b34240150604ea29b9b23165076907)) - Ivan Larin
+- (**overlay**) open telegram links in desktop app - ([599a833](https://github.com/IvanLarinDev/dropwheel/commit/599a833f1ef07869a27105e56f6e08aada15cbf5)) - Ivan Larin
+- (**overlay**) use compatible add-target drag effect - ([1f888da](https://github.com/IvanLarinDev/dropwheel/commit/1f888dab7ce575311ce1a9b2a47eae59ff219ccd)) - Ivan Larin
+- (**overlay**) accept bare telegram links - ([0f80897](https://github.com/IvanLarinDev/dropwheel/commit/0f808971a0c97a3993a814e0d6dc2c3a07973860)) - Ivan Larin
+- (**overlay**) handle saved messages chat drops - ([82c26fb](https://github.com/IvanLarinDev/dropwheel/commit/82c26fb650a0b8ee894d4b98e7b5793f8c18cdf3)) - Ivan Larin
+- (**overlay**) prioritize link drops over text saves - ([401b20d](https://github.com/IvanLarinDev/dropwheel/commit/401b20d2762591f29a4b979da9ff3aadc368b19d)) - Ivan Larin
+- (**overlay**) normalize virtual sorter roots - ([cee2d28](https://github.com/IvanLarinDev/dropwheel/commit/cee2d282dca3dd696536873882fe488f05210c47)) - Ivan Larin
+- (**sorter**) skip same-folder drop operations - ([c0e10b1](https://github.com/IvanLarinDev/dropwheel/commit/c0e10b1ee11d8f3d255f4b503356bca106df55d1)) - Ivan Larin
+- (**text**) avoid directory-name drop collisions - ([a5571a1](https://github.com/IvanLarinDev/dropwheel/commit/a5571a1f646e5a975963cf087c5a1250ce204ad2)) - Ivan Larin
+- (**watcher**) gate queued sort after stop - ([98f7985](https://github.com/IvanLarinDev/dropwheel/commit/98f7985fb7eacefc66a6edf861ac7c853fad67a4)) - Ivan Larin
+- (**watcher**) cancel queued work on stop - ([9eceb44](https://github.com/IvanLarinDev/dropwheel/commit/9eceb44134b457582fac717c11a87dd469b6049a)) - Ivan Larin
+#### Documentation
+- (**readme**) document link and telegram targets - ([b939ed2](https://github.com/IvanLarinDev/dropwheel/commit/b939ed2e02146a37c97192ffe516ee76506b4143)) - Ivan Larin
+#### Tests
+- (**watcher**) add autosort collision regression - ([87768e3](https://github.com/IvanLarinDev/dropwheel/commit/87768e362170c0bb5d0b63159be91610a8871c3f)) - Ivan Larin
+#### Miscellaneous Chores
+- (**dropwheel**) merge telegram quick access target - ([32724a2](https://github.com/IvanLarinDev/dropwheel/commit/32724a29a90136c71fb216be7bebce76b2c7341e)) - Ivan Larin
+- (**dropwheel**) merge watcher collision test - ([afa0552](https://github.com/IvanLarinDev/dropwheel/commit/afa0552c6b31a2a41e1f7c7aa85c2bac5d4b3e4d)) - Ivan Larin
+- (**dropwheel**) merge overlay root fix - ([32e6373](https://github.com/IvanLarinDev/dropwheel/commit/32e637324d1ad0c5560fdbee6d191896d9f8d559)) - Ivan Larin
+- (**dropwheel**) merge watcher stop race guard - ([f9d1735](https://github.com/IvanLarinDev/dropwheel/commit/f9d17355e50e4f3db348fd6105ff5132d14e56cf)) - Ivan Larin
+- (**dropwheel**) merge watcher stop fix - ([9595985](https://github.com/IvanLarinDev/dropwheel/commit/9595985328b4f85593e76ee90d248ece32b82093)) - Ivan Larin
+- (**dropwheel**) merge text drop collision fix - ([f279424](https://github.com/IvanLarinDev/dropwheel/commit/f279424c87b0bb71821913347c87e7a7a244e173)) - Ivan Larin
+- (**dropwheel**) merge verified harness and sorter fixes - ([e57dc51](https://github.com/IvanLarinDev/dropwheel/commit/e57dc512cd7d4754aeb6f87bb5db3b699c3190f3)) - Ivan Larin
+- (**harness**) update generated harness - ([647c2ca](https://github.com/IvanLarinDev/dropwheel/commit/647c2ca2c4eb3981b191d0f87277f725d693e1a2)) - Ivan Larin
+- (**version**) set project version v0.12.0 - ([d3c2e92](https://github.com/IvanLarinDev/dropwheel/commit/d3c2e92e58e4f30d3ce2cac986d591874659f402)) - Ivan Larin
+
+- - -
+
## [v0.11.0](https://github.com/IvanLarinDev/dropwheel/compare/d6ab8bfd1380a84d043c5da4e76865ca14a07456..v0.11.0) - 2026-07-09
#### Features
- (**overlay**) animate tile reorder - ([84916d1](https://github.com/IvanLarinDev/dropwheel/commit/84916d16712bba456b5ae042f32d4b9d456180f5)) - Ivan Larin
diff --git a/README.md b/README.md
index e55ec59..1087e81 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,7 @@ common loops: `run.cmd [run|build|publish|stop]`. Run the tests with
| Force copy / move | hold Ctrl / Shift while dropping |
| Undo last drop | click “Undo” in the toast (6 s) |
| Edit a target | right-click its tile |
-| Add a target | drop a folder/exe onto the “+” tile or the orb |
+| 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 |
| Sort a sorter now | middle-click a sorter tile |
@@ -151,6 +151,20 @@ behaviour, shown with a ▶ badge. Scripts the shell would only open in an edito
(`.ps1`, `.py`, `.jar`) are launched through their interpreter. This is a launch,
not a file operation, so it isn't undoable.
+## Link targets
+
+Drop a link such as `https://example.com`, `tg://resolve?domain=telegram`, or
+`https://t.me/c/4379453334/1` onto the orb or the “+” tile to create a
+quick-access target. Browser URL drags keep the page title when the drag payload
+includes one, and Dropwheel fetches a favicon in the background when the page
+exposes a PNG/JPG/ICO/WEBP icon.
+
+Telegram web links are converted to desktop deep links where possible, so
+clicking a `t.me` tile opens Telegram Desktop when the `tg://` protocol is
+registered. Dropping files or selected text onto a Telegram tile copies the
+payload to the clipboard, opens the chat or topic, and pastes it once Telegram is
+foreground; review and press Send in Telegram.
+
## Themes
Four themes — chosen in Settings. Each carries a full palette: the wheel, the
@@ -176,12 +190,14 @@ are tuned per theme.
VirtualFileService, TextDropService, SortService, SortMigration,
WatcherService (auto-sort watched folders), FileMeta, PresetService,
ShortcutResolver, MouseHook, HotkeyService, LaunchService,
- IconService, StartupService, FullscreenDetector
+ IconService, LinkTargetService, LinkMetadataService,
+ TelegramDropService, StartupService, FullscreenDetector
UI/ OverlayWindow (hub + rim + spokes wheel, partial classes),
TargetEditorWindow (+ .Rules master-detail), SettingsWindow,
Themes, Palette (per-theme widget colours), MenuTheme.xaml
tests/ Dropwheel.Tests (xUnit: SortService, SortMigration, FileMeta,
- TextDropService, WatcherService, HotkeyService, VirtualFileService)
+ TextDropService, WatcherService, HotkeyService, VirtualFileService,
+ LinkTargetService, LinkMetadataService, TelegramDropService)
docs/media/ screenshots and gifs used by this README
## Known limitations
diff --git a/cog.toml b/cog.toml
index 6eb4716..b1b5615 100644
--- a/cog.toml
+++ b/cog.toml
@@ -11,7 +11,7 @@
from_latest_tag = true
ignore_merge_commits = true
tag_prefix = "v"
-branch_whitelist = ["main", "master", "feat/**", "fix/**", "docs/**", "hotfix/**", "chore/**", "refactor/**"]
+branch_whitelist = ["main", "master", "feat/**", "fix/**", "docs/**", "hotfix/**", "chore/**", "refactor/**", "release/**"]
[changelog]
path = "CHANGELOG.md"
diff --git a/design/mockups/drop-safety-review/APPROVED b/design/mockups/drop-safety-review/APPROVED
index 0a017b6..fcfc582 100644
--- a/design/mockups/drop-safety-review/APPROVED
+++ b/design/mockups/drop-safety-review/APPROVED
@@ -1 +1,2 @@
2026-07-09 fix/product-safety-review approved 01-minimal-light.html for drop safety and incomplete undo states
+2026-07-10 release/v0.12.0 reuses drop-safety-review approval for quick-access drag/drop states
diff --git a/harness.config.json b/harness.config.json
index e14aadf..8cb4174 100644
--- a/harness.config.json
+++ b/harness.config.json
@@ -27,8 +27,7 @@
"enabled": true,
"base": "main",
"soft": false,
- "exclude": [
- "hooks/**"
- ]
+ "exclude": [],
+ "strict": true
}
}
diff --git a/hooks/_lib.js b/hooks/_lib.js
index e5684c5..0137167 100644
--- a/hooks/_lib.js
+++ b/hooks/_lib.js
@@ -1,5 +1,5 @@
-// _lib.js — shared helpers for the harness hooks (guard.js, design-gate.js).
-// Single source of truth for: glob→regex, harness.config.json loading, path
+// _lib.js - shared helpers for the harness hooks (guard.js, design-gate.js).
+// Single source of truth for: glob-to-regex, harness.config.json loading, path
// normalization. No CLI, no side effects.
const fs = require("fs");
@@ -18,10 +18,11 @@ const DEFAULT_PROTECTED = [
".github/rulesets/", ".github/workflows/", ".claude/settings.json", ".git/",
];
-// Линт/формат-конфиги ЦЕЛЕВОГО проекта (паттерн ECC config-protection): агенты
-// «чинят» красный VERIFY, ослабляя конфиг вместо кода. Блокируем правку
-// СУЩЕСТВУЮЩЕГО конфига; создание с нуля — легитимный bootstrap. Смешанные файлы
-// (pyproject.toml, package.json, tsconfig.json) намеренно НЕ в списке.
+// Lint/format configs of the target project (ECC config-protection pattern):
+// agents sometimes "fix" a red VERIFY by weakening config instead of fixing code.
+// Block edits to existing configs; creating a config from scratch is legitimate
+// bootstrap work. Mixed-purpose files (pyproject.toml, package.json, tsconfig.json)
+// are intentionally not listed.
const DEFAULT_LINT_CONFIGS = [
".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yml", ".eslintrc.yaml",
"eslint.config.js", "eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts", "eslint.config.mts", "eslint.config.cts",
@@ -36,7 +37,7 @@ const DEFAULT_LINT_CONFIGS = [
".markdownlint.json", ".markdownlint.yaml", ".markdownlintrc", ".shellcheckrc",
];
-// glob → RegExp (supports **, *, literal)
+// glob to RegExp (supports **, *, literal)
function globToRe(g) {
const re = g.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*\*\//g, "@@DS@@").replace(/\*\*/g, "@@SS@@").replace(/\*/g, "[^/]*")
@@ -44,7 +45,7 @@ function globToRe(g) {
return new RegExp("^" + re + "$", "i");
}
-// harness.config.json (missing/broken file → defaults; hooks stay fail-open)
+// harness.config.json (missing/broken file -> defaults; hooks stay fail-open)
function loadConfig(root) {
let c = {};
try { c = JSON.parse(fs.readFileSync(path.join(root, "harness.config.json"), "utf8")); } catch {}
@@ -57,8 +58,8 @@ function loadConfig(root) {
};
}
-// Absolute/relative path → normalized repo-relative posix path.
-// Collapses ./ and ..; strips the project prefix case-insensitively — WITHOUT
+// Absolute/relative path -> normalized repo-relative posix path.
+// Collapses ./ and ..; strips the project prefix case-insensitively - WITHOUT
// normalization "./lefthook.yml" or "design/../hooks/x" would dodge prefix checks.
function normRel(fp, projectDir) {
let f = String(fp).replace(/\\/g, "/");
@@ -80,12 +81,12 @@ function isProtectedPath(rel, protectedList) {
}
// ---------- shell-write detection ----------
-// Covers write/delete/move verbs across POSIX, cmd и PowerShell + sed/perl -i и
-// перенаправление > / >>. Windows-глаголы (del/move/Remove-Item/Set-Content…) —
-// потому что здесь основная оболочка PowerShell/cmd, а не bash: без них
-// `del hooks\x.js` или `Remove-Item lefthook.yml` проходили мимо защиты.
-// Все глаголы срабатывают ТОЛЬКО когда цель — защищённый путь (${target}),
-// поэтому обычные команды не задеваются. Read-only (cat/ls/node hooks/x.js) — мимо.
+// Covers write/delete/move verbs across POSIX, cmd, and PowerShell, plus sed/perl
+// in-place edits and > / >> redirection. Windows verbs matter because this repo is
+// commonly driven from PowerShell/cmd; without them, commands such as
+// `del hooks\x.js` or `Remove-Item lefthook.yml` would bypass protection.
+// Verbs match only when the target is a protected path, so ordinary commands and
+// read-only calls (cat/ls/node hooks/x.js) pass through.
const WRITE_VERBS =
"rm|mv|cp|tee|chmod|ln|truncate|touch|" + // POSIX
"del|erase|rmdir|rd|move|ren|rename|copy|" + // cmd.exe
@@ -103,66 +104,82 @@ function shellWriteHit(scrubbed, alt, lb, dirPrefixInRedirect) {
return res.some((re) => re.test(scrubbed));
}
-// SEP — разделитель пути: `/` ИЛИ `\` (в PowerShell/cmd путь пишут через backslash,
-// поэтому `del hooks\agent\guard.js` должен матчиться так же, как `rm hooks/...`).
+function normalizePathFragments(text) {
+ return String(text || "").replace(
+ /[A-Za-z0-9_.-]+(?:[\/\\]+[A-Za-z0-9_.-]+)+/g,
+ (frag) => path.posix.normalize(frag.replace(/\\/g, "/"))
+ );
+}
+
+// SEP is a path separator: `/` or `\`. PowerShell/cmd often use backslashes, so
+// `del hooks\agent\guard.js` must match the same way as `rm hooks/...`.
const SEP = "[\\/\\\\]";
-// Запись в защищённые пути харнесса (префиксы от корня репо).
+// Writes to protected harness paths, expressed as repo-root prefixes.
function isProtectedShellWrite(scrubbed, protectedList) {
- // Порядок важен: сначала слэши → SEP, потом точки → `\.`. Иначе экранирование
- // точки вставит `\`, который замена слэшей затрёт (lefthook.yml → lefthook[\/\\]yml).
+ const scanned = normalizePathFragments(scrubbed);
+ // Order matters: replace slashes with SEP before escaping dots. Otherwise dot
+ // escaping inserts backslashes that the slash replacement would later corrupt.
const esc = (str) => str.replace(/[\/\\]/g, SEP).replace(/\./g, "\\.");
- // "hooks/" защищает и "hooks/x", и голое "rm -rf hooks"; файлы — по границе слова.
+ // "hooks/" protects both "hooks/x" and a bare "rm -rf hooks"; files use word boundaries.
const alt = "(?:\\." + SEP + ")?(?:" + protectedList.map((p) =>
p.endsWith("/") ? esc(p.slice(0, -1)) + "(?:" + SEP + "|[\\s;&|]|$)" : esc(p) + "\\b"
).join("|") + ")";
- // Lookbehind БЕЗ разделителя: src/hooks/useAuth.ts (React) — не файл харнесса.
- return shellWriteHit(scrubbed, alt, "(?<=^|[\\s=:'\"(])", false);
+ // Lookbehind without a path separator keeps src/hooks/useAuth.ts from matching.
+ return shellWriteHit(scanned, alt, "(?<=^|[\\s=:'\"(])", false);
}
-// Запись в lint/format-конфиг (по basename, в любом каталоге — через / или \).
+// Writes to lint/format config files by basename, in any directory, with / or \.
function isLintConfigShellWrite(scrubbed, lintConfigs) {
+ const scanned = normalizePathFragments(scrubbed);
const names = lintConfigs.map((n) => n.replace(/\./g, "\\.")).join("|");
const alt = `(?:[^\\s;|&<>]*${SEP})?(?:${names})\\b`;
- return shellWriteHit(scrubbed, alt, "(?<=^|[\\s=:'\"(/\\\\])", true);
+ return shellWriteHit(scanned, alt, "(?<=^|[\\s=:'\"(/\\\\])", true);
}
-// rel — нормализованный путь; сравнение по basename, регистронезависимо.
+// rel is normalized; compare by basename, case-insensitively.
function isLintConfigPath(rel, lintConfigs) {
const base = String(rel).toLowerCase().split("/").pop();
return lintConfigs.some((n) => n.toLowerCase() === base);
}
-// ---------- запись в защищённый путь через инлайн-eval интерпретатора ----------
-// `node -e "fs.writeFileSync('hooks/x')"`, `python -c "open('lefthook.yml','w')"`,
-// `bash -c "rm -rf hooks/"` обходят write-verb-детекцию: глагол/путь спрятаны в
-// строке, а scrubQuotes её обнуляет. Работаем по СЫРОЙ команде. Это НОТА, не блок:
-// в -e путь может быть безобидной строкой, жёстко блокировать нельзя, но напомнить
-// про обход стоит. Триггерим только при совпадении трёх условий: интерпретатор с
-// eval-флагом + индикатор записи + литерал защищённого пути (минимум ложных).
+// ---------- protected-path writes through inline interpreter eval ----------
+// Commands such as `node -e "fs.writeFileSync('hooks/x')"`,
+// `python -c "open('lefthook.yml','w')"`, or `bash -c "rm -rf hooks/"`
+// hide the write verb/path inside a quoted program, so scrubbed shell-write
+// detection cannot see them. Inspect the raw command and block only when the
+// command is eval-like, has a write indicator, and references a protected path.
+// Encoded PowerShell is intentionally blocked as opaque eval: the write/path
+// cannot be inspected before execution.
const INTERP_EVAL_RE = /\b(?:node|nodejs|deno|bun|python|python3|py|perl|ruby|php|pwsh|powershell|bash|sh|zsh)\b[^\n]*?(?:\s-e\b|\s--eval\b|\s-c\b|\seval\b|\s-Command\b|\s-EncodedCommand\b)/i;
-const INTERP_WRITE_RE = /writefile|writefilesync|appendfile|createwritestream|fs\.write|\.write\s*\(|open\s*\([^)]*['"][aw]|set-content|add-content|out-file|>{1,2}|\b(?:rm|del|erase|move|mv|remove-item|ren|rename)\b/i;
+const INTERP_ENCODED_RE = /\b(?:pwsh|powershell)\b[^\n]*\s-EncodedCommand\b/i;
+const INTERP_WRITE_RE = /writefile|writefilesync|appendfile|createwritestream|write_text|write_bytes|unlink\s*\(|rmtree\s*\(|\brm(?:sync)?\s*\(|remove\s*\(|replace\s*\(|rename\s*\(|shutil\.(?:rmtree|move)|os\.(?:remove|unlink|replace|rename)|path\([^)]*\)\.(?:write_text|write_bytes|unlink)|fs\.write|\.write\s*\(|\[\s*['"]write|['"]write['"]\s*\+|\+\s*['"]filesync['"]|open\s*\([^)]*['"][aw]|set-content|add-content|out-file|>{1,2}|\b(?:rm|del|erase|move|mv|remove-item|ren|rename)\b/i;
function interpreterProtectedHint(rawCmd, protectedList) {
const s = String(rawCmd);
- if (!INTERP_EVAL_RE.test(s) || !INTERP_WRITE_RE.test(s)) return null;
- const low = s.replace(/\\/g, "/").toLowerCase();
+ if (!INTERP_EVAL_RE.test(s)) return null;
+ if (INTERP_ENCODED_RE.test(s)) return "encoded-command";
+ if (!INTERP_WRITE_RE.test(s)) return null;
+ const low = normalizePathFragments(s.replace(/\\/g, "/").toLowerCase());
for (const p of protectedList) {
const pref = p.toLowerCase().replace(/\/$/, "").replace(/[.]/g, "\\.");
- if (new RegExp("(?:^|[\\s'\"(/=:])" + pref + "(?:/|\\b)").test(low)) return p;
+ if (new RegExp("(?:^|[\\s'\"(=:,])(?:\\.\\/)?" + pref + "(?:/|\\b)").test(low)) return p;
}
return null;
}
// ---------- changed files (branch/worktree diff) ----------
-// Изменённые файлы ветки относительно базы. Возвращает {files, base} при успешном
-// diff (пусть даже ПУСТОМ) или {error} если ни одна база не доступна — это РАЗНЫЕ
-// исходы: пустой diff = «изменений нет», ошибка = «не смогли проверить». Молчаливый
-// fail-open при ошибке означал бы, что в репо без ожидаемой базы гейт/фильтр просто
-// никогда не работает. explicitFiles (тесты/CI) возвращается как есть, без git.
-// По умолчанию это branch-only контракт для CI/design-gate. Локальный verify может
-// явно добавить dirty/staged/untracked файлы через includeDirty.
+// Changed files in the branch relative to a base. Returns {files, base} when diff
+// succeeds, even if the diff is empty, or {error} when no base can be used. Those
+// outcomes are intentionally distinct: empty diff means "no changes", while error
+// means "could not check". Silent fail-open would make gates ineffective in repos
+// without the expected base. explicitFiles is normalized and returned without git.
+// By default this is a branch-only contract for CI/design-gate; local verify can
+// explicitly include dirty/staged/untracked files via includeDirty.
function changedFiles(base, root, explicitFiles, opts = {}) {
- if (explicitFiles) return { files: explicitFiles };
+ if (explicitFiles) {
+ const files = normalizeChangedFiles(root, explicitFiles);
+ return { files, explicit: true, branchFiles: files, dirtyFiles: [], includeDirty: false };
+ }
const remoteFirst = /^origin\//.test(String(base || ""));
const fallbacks = remoteFirst
? [base, "origin/HEAD", "main", "master"]
@@ -172,20 +189,39 @@ function changedFiles(base, root, explicitFiles, opts = {}) {
for (const args of [["diff", "--name-only", `${b}...HEAD`], ["diff", "--name-only", b]]) {
try {
const out = execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000, killSignal: "SIGKILL" });
- const branchFiles = parseFiles(out);
- return { files: opts.includeDirty ? mergeFiles(branchFiles, dirtyFiles(root)) : branchFiles, base: b };
+ const branchFiles = parseFiles(out, root);
+ const worktreeFiles = opts.includeDirty ? dirtyFiles(root) : [];
+ return {
+ files: opts.includeDirty ? mergeFiles(root, branchFiles, worktreeFiles) : branchFiles,
+ base: b,
+ explicit: false,
+ branchFiles,
+ dirtyFiles: worktreeFiles,
+ includeDirty: !!opts.includeDirty,
+ };
} catch {}
}
}
- return { error: `git diff не удался ни для одной базы (${bases.join(", ")})` };
+ return { error: `git diff failed for every base (${bases.join(", ")})` };
}
function workingTreeChangedFiles(base, root, explicitFiles) {
return changedFiles(base, root, explicitFiles, { includeDirty: true });
}
-function parseFiles(out) {
- return String(out || "").split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
+function normalizeChangedFile(root, fp) {
+ const rel = normRel(fp, root);
+ if (!rel || rel === "." || rel === ".." || rel.startsWith("../")) return "";
+ if (/^(?:[A-Za-z]:)?\//.test(rel) || /^[A-Za-z]:\//.test(rel)) return "";
+ return rel;
+}
+
+function normalizeChangedFiles(root, files) {
+ return mergeFiles(root, files);
+}
+
+function parseFiles(out, root) {
+ return normalizeChangedFiles(root, String(out || "").split(/\r?\n/));
}
function gitFiles(root, args) {
@@ -193,26 +229,26 @@ function gitFiles(root, args) {
return parseFiles(execFileSync("git", args, {
cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
timeout: 5000, killSignal: "SIGKILL",
- }));
+ }), root);
} catch {
return [];
}
}
function dirtyFiles(root) {
- return mergeFiles(
+ return mergeFiles(root,
gitFiles(root, ["diff", "--name-only"]),
gitFiles(root, ["diff", "--name-only", "--cached"]),
gitFiles(root, ["ls-files", "--others", "--exclude-standard"])
);
}
-function mergeFiles(...lists) {
+function mergeFiles(root, ...lists) {
const out = [];
const seen = new Set();
for (const list of lists) {
for (const f of list || []) {
- const rel = String(f).replace(/\\/g, "/");
+ const rel = normalizeChangedFile(root, f);
if (!rel || seen.has(rel)) continue;
seen.add(rel);
out.push(rel);
diff --git a/hooks/agent/_input.js b/hooks/agent/_input.js
index 09b74de..6630762 100644
--- a/hooks/agent/_input.js
+++ b/hooks/agent/_input.js
@@ -1,4 +1,4 @@
-// _input.js — runtime-agnostic input normalizer for the agent-adapter hooks.
+// _input.js - runtime-agnostic input normalizer for the agent-adapter hooks.
//
// Different LLM harnesses describe a pending tool call with different field names.
// These hooks read a JSON payload on stdin and normalize it, so the SAME hook works
@@ -14,17 +14,15 @@
// stderr text = human-readable reason (shown by most runners)
//
// Non-blocking note (see note()): Claude Code reads additionalContext ONLY from
-// {"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"…"}}
-// — a bare top-level {"additionalContext": …} is ignored. We emit BOTH shapes
+// {"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"..."}}
+// - a bare top-level {"additionalContext": "..."} is ignored. We emit BOTH shapes
// (top-level for simpler runners, hookSpecificOutput for Claude Code) + stderr mirror.
function readStdin() {
- // Чтение stdin без жёсткого дедлайна. Основной сигнал конца — событие `end`.
- // На паузе потока (idle) завершаемся ТОЛЬКО если буфер уже парсится как целый
- // JSON: иначе это середина рывкового payload'а (GC-пауза, медленный пайп) —
- // ждём остаток до общего капа, а не мис-фреймим на неполных данных (иначе
- // легитимный вызов ловил ложный fail-closed блок). Лимит размера → truncated,
- // по нему guard решает fail-closed. Пустой idle держим до капа (ручной запуск).
+ // Read stdin without a hard idle deadline. The main completion signal is `end`.
+ // On idle, finish only if the buffer already parses as complete JSON; otherwise
+ // wait for the rest of a bursty payload until the cap. Size cap sets truncated,
+ // and guard.js decides fail-closed. Empty idle waits until the cap for manual runs.
const IDLE_MS = 300, CAP_MS = 5000, MAX_BYTES = 2 * 1024 * 1024;
return new Promise((resolve) => {
let data = "", done = false, idle = null, truncated = false;
@@ -35,10 +33,10 @@ function readStdin() {
};
const cap = setTimeout(finish, CAP_MS);
const onIdle = () => {
- if (truncated) return finish(); // обрезано по лимиту — дальше не ждём
- if (!data) return arm(); // ничего не пришло — ждём (до капа)
- try { JSON.parse(data); finish(); } // целый JSON — можно завершать
- catch { arm(); } // ещё неполный — ждём остаток
+ if (truncated) return finish(); // size cap hit; do not wait longer
+ if (!data) return arm(); // no data yet; wait until cap
+ try { JSON.parse(data); finish(); } // complete JSON; finish
+ catch { arm(); } // incomplete JSON; wait for more
};
const arm = () => { clearTimeout(idle); idle = setTimeout(onIdle, IDLE_MS); };
process.stdin.setEncoding("utf8");
@@ -64,12 +62,31 @@ async function parse() {
let raw = {};
let parseError = false;
try { raw = JSON.parse(data || "{}"); }
- catch { parseError = data.trim().length > 0; } // пустой stdin = ручной запуск, не ошибка
+ catch { parseError = data.trim().length > 0; } // empty stdin = manual run, not an error
const ti = raw.tool_input || raw.toolInput || raw.input || raw.arguments || {};
return {
tool: firstString(raw.tool_name, raw.toolName, raw.tool, raw.name),
command: firstString(ti.command, raw.command),
- filePath: firstString(ti.file_path, ti.path, ti.filePath, raw.file_path, raw.path),
+ filePath: firstString(
+ ti.file_path,
+ ti.path,
+ ti.filePath,
+ ti.filename,
+ ti.file,
+ ti.target_file,
+ ti.targetFile,
+ ti.notebook_path,
+ ti.notebookPath,
+ raw.file_path,
+ raw.path,
+ raw.filePath,
+ raw.filename,
+ raw.file,
+ raw.target_file,
+ raw.targetFile,
+ raw.notebook_path,
+ raw.notebookPath
+ ),
sessionId: firstString(
process.env.HARNESS_SESSION_ID,
process.env.CLAUDE_SESSION_ID,
@@ -91,7 +108,7 @@ async function parse() {
};
}
-// Non-blocking note injected into agent context. eventName: "PreToolUse" | "PostToolUse" | …
+// Non-blocking note injected into agent context. eventName: "PreToolUse" | "PostToolUse" | ...
function note(text, eventName = "PreToolUse") {
try {
process.stdout.write(JSON.stringify({
diff --git a/hooks/agent/guard.js b/hooks/agent/guard.js
index f1dd37c..a312395 100644
--- a/hooks/agent/guard.js
+++ b/hooks/agent/guard.js
@@ -1,33 +1,26 @@
#!/usr/bin/env node
-// guard.js — единый agent-adapter хук (PreToolUse на Bash/Write/Edit/Read и т.п.).
+// guard.js - unified agent-adapter hook for PreToolUse shell/file/read calls.
//
-// Архитектура (паттерн ECC bash-hook-dispatcher): вся логика — в экспортируемой
-// синхронной run(ctx, env) -> { exitCode, stdout, stderr }, БЕЗ process.exit и
-// побочных эффектов вывода. CLI-обёртка внизу читает stdin и применяет результат.
-// Это даёт in-process запуск из тестов/диспетчеров без ~50-100мс спавна на вызов.
+// Architecture: all policy lives in exported synchronous
+// run(ctx, env) -> { exitCode, stdout, stderr }, with no process.exit and no
+// output side effects. The CLI wrapper at the bottom only reads stdin and applies
+// the result. Tests and dispatchers can call run() in-process without spawn cost.
//
-// БЛОК (exit 2):
-// • обход харнесса: --no-verify / git commit -n, core.hooksPath (config и -c),
-// LEFTHOOK=0, lefthook uninstall, запись в .git/hooks;
-// • правка файлов харнесса (hooks/, lefthook.yml, конфиги, workflows) — file-tools
-// И shell (POSIX rm/mv/sed -i/tee/редирект + cmd/PowerShell del/move/Remove-Item/
-// Set-Content…); пути нормализуются, разделитель / и \;
-// • правка СУЩЕСТВУЮЩЕГО lint/format-конфига проекта (создание нового — можно);
-// • дегенеративные циклы; мусор tool-разметки; низкоэнтропийная команда;
-// • обрезанный/нечитаемый входной payload (fail-closed, всегда включён).
+// BLOCK (exit 2):
+// - harness bypass: --no-verify / git commit -n, core.hooksPath, LEFTHOOK=0,
+// lefthook uninstall, and writes to .git/hooks;
+// - writes to harness files through file tools and shell commands;
+// - edits to existing lint/format config files;
+// - degenerate loops, leaked tool markup, low-entropy commands;
+// - truncated or unreadable payloads.
// NOTE (exit 0 + additionalContext):
-// • git commit/merge/push или правка файлов на main/master;
-// • правка UI-файла — напоминание о DESIGN-стадии;
-// • fact-force (паттерн ECC GateGuard): правка существующего файла, который
-// в этой сессии ни разу не читали — EXPLORE прежде IMPLEMENT (1 note на файл).
+// - git commit/merge/push or file writes on main/master;
+// - UI file edits that need the DESIGN stage;
+// - fact-force: editing an existing file that was not read in this session.
//
-// Профили строгости (для ЧЕЛОВЕКА; env задаёт раннер, не команды агента):
-// HARNESS_PROFILE=minimal — только анти-обход + защита файлов харнесса;
-// HARNESS_PROFILE=standard — всё (default);
-// HARNESS_PROFILE=strict — всё + пороги циклов вдвое ниже.
-// HARNESS_DISABLED_CHECKS=loops,entropy — точечное отключение проверок.
-// Escape одобренного обхода в block-сообщениях агенту не называется (см. AGENTS.md → Env).
-// Ошибка самого хука никогда не блокирует работу (fail-open → exit 0).
+// Strictness profiles are runner/user environment controls, not agent commands.
+// Approved bypass details are not printed in block messages; see AGENTS.md.
+// Internal hook errors fail open, but are made visible on stderr.
const fs = require("fs");
const os = require("os");
@@ -40,9 +33,9 @@ const { globToRe, loadConfig, normRel, isProtectedPath, isProtectedShellWrite,
const TTL_MS = 2 * 60 * 60 * 1000;
const SEEN_MAX = 200;
-const BYPASS_HINT = "Обход возможен только по явному одобрению пользователя — попроси его (escape описан в AGENTS.md → Env).";
+const BYPASS_HINT = "Bypass requires explicit user approval; ask the user first. The escape hatch is documented in AGENTS.md.";
-// ---------- профили ----------
+// ---------- profiles ----------
const ALL_CHECKS = ["bypass", "protected", "lintconfig", "corruption", "entropy", "loops", "main-note", "design-note", "fact-force"];
const PROFILES = {
minimal: new Set(["bypass", "protected"]),
@@ -62,14 +55,14 @@ function envAllow(env, name) {
return ["1", "true", "yes", "on"].includes(String(env[name] || "").trim().toLowerCase());
}
-// ---------- результат (вместо process.exit / stdout из логики) ----------
+// ---------- result object, no process.exit inside policy logic ----------
function allowRes(notes) {
if (!notes || !notes.length) return { exitCode: 0, stdout: "", stderr: "" };
const text = notes.join("\n");
return {
exitCode: 0,
stdout: JSON.stringify({
- additionalContext: text, // простые раннеры
+ additionalContext: text, // simple runners
hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: text }, // Claude Code
}) + "\n",
stderr: text + "\n",
@@ -79,7 +72,7 @@ function blockRes(text) {
return { exitCode: 2, stdout: "", stderr: text + "\n" };
}
-// ---------- state (per session, в tmpdir) ----------
+// ---------- state (per session, in tmpdir) ----------
function stateFile(sessionId, projectDir) {
const id = sessionId || "proj-" + crypto.createHash("sha1").update(String(projectDir)).digest("hex").slice(0, 12);
return path.join(os.tmpdir(), `harness-guard-${id}.json`);
@@ -91,9 +84,8 @@ function readState(p) {
return { hist: s.hist || [], streak: s.streak || 0, seen: s.seen || [] };
} catch { return { hist: [], streak: 0, seen: [] }; }
}
-// Атомарная запись: temp-файл в том же каталоге (tmpdir) + rename. Иначе
-// параллельные PreToolUse-хуки могли оставить оборванный JSON или затереть
-// историю на полузаписи (гонка read-modify-write). rename атомарен в пределах ФС.
+// Atomic write: temp file in the same directory plus rename. Parallel hooks can
+// otherwise leave truncated JSON or lose history during read-modify-write races.
function writeState(p, s) {
const tmp = p + "." + process.pid + ".tmp";
try {
@@ -115,9 +107,8 @@ function currentBranch(cwd) {
} catch { return ""; }
}
-// ---------- детекторы ----------
-// Узкий паттерн разметки tool-call'ов: , , это легитимная запись HTML).
+// ---------- detectors ----------
+// Narrow tool-call markup pattern; generic HTML such as stays valid.
const CORRUPTION_RE = /<\/?(?:tool_?(?:call|use|result)|function_call|invoke|antml)[\s>_:/]|^\s*["']\s*,?\s*\d*\s*= T) {
writeState(p, { hist: [], streak: 0, seen: st.seen });
- return blockRes(`🛑 guard: ${rep}× подряд одно и то же действие — похоже на зацикливание.\n` +
+ return blockRes(`guard: same action repeated ${rep} times; this looks like a loop.\n` +
` ${st.hist[st.hist.length - 1].slice(0, 120)}\n` +
- ` Остановись, сверься с планом/TodoWrite, смени подход. Порог: ${T}.`);
+ ` Stop, compare with the plan/TodoWrite, and change approach. Threshold: ${T}.`);
}
const alt = tailAlt(st.hist);
if (alt >= 2 * T) {
writeState(p, { hist: [], streak: 0, seen: st.seen });
- return blockRes(`🛑 guard: чередование двух действий ${alt} шагов подряд (A-B-A-B…) — цикл без прогресса.\n` +
- ` Остановись, сверься с планом/TodoWrite, смени подход. Порог: ${2 * T}.`);
+ return blockRes(`guard: two actions alternated for ${alt} steps (A-B-A-B); this is a no-progress loop.\n` +
+ ` Stop, compare with the plan/TodoWrite, and change approach. Threshold: ${2 * T}.`);
}
return null;
}
-// ---------- обход харнесса (shell) ----------
-// GIT: на Windows `git.exe`/`git.cmd` — валидные вызовы; без вариантов имени
-// `git.exe commit --no-verify` проходил мимо блока. Замечание про -n: у git commit
-// это --no-verify (обход), у merge/revert — безобидное.
+// ---------- harness bypass through shell ----------
+// Windows can call git.exe/git.cmd. For git commit, -n means --no-verify.
const GIT = "git(?:\\.exe|\\.cmd)?";
const BYPASS = [
{ re: new RegExp(`\\b${GIT}\\s+commit\\b[^\\n]*(?:\\s--no-verify\\b|\\s-[a-z]*n[a-z]*\\b)`, "i"),
- why: "--no-verify / -n на git commit — пропускает pre-commit и commit-msg" },
+ why: "--no-verify / -n on git commit skips pre-commit and commit-msg hooks" },
{ re: new RegExp(`\\b${GIT}\\s+(merge|push)\\b[^\\n]*\\s--no-verify\\b`, "i"),
- why: "--no-verify на git merge/push — пропускает хуки" },
+ why: "--no-verify on git merge/push skips hooks" },
{ re: new RegExp(`\\b${GIT}\\b[^\\n]*\\bcore\\.hookspath\\b`, "i"),
- why: "core.hooksPath (git config или git -c) — отключение/подмена git-хуков" },
+ why: "core.hooksPath disables or replaces git hooks" },
{ re: /\blefthook\s+uninstall\b/i,
- why: "lefthook uninstall — снятие всех git-хуков" },
+ why: "lefthook uninstall removes git hooks" },
{ re: /(^|[\s;&|])LEFTHOOK\s*=\s*(0|false)\b/i,
- why: "LEFTHOOK=0 — отключение lefthook-хуков (это escape для человека, не для агента)" },
+ why: "LEFTHOOK=0 disables lefthook hooks; this is a human escape hatch, not an agent escape hatch" },
];
function isGitHooksWrite(scrubbed) {
return /\.git[\/\\]hooks\b/i.test(scrubbed) &&
/(^|[\s;&|])(rm|mv|cp|tee|chmod|ln|truncate|sed|del|erase|rmdir|rd|move|ren|rename|copy|Remove-Item|Move-Item|Rename-Item|Copy-Item|Set-Content|Add-Content|Clear-Content|Out-File|New-Item)\b|>/i.test(scrubbed);
}
-// Обнулить строки в кавычках, чтобы commit -m "про -n флаг" не считался обходом.
+// Blank quoted strings so commit messages mentioning -n do not look like bypasses.
function scrubQuotes(cmd) {
return cmd.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '""');
}
+function scrubGitMessageArgs(cmd) {
+ return String(cmd).replace(
+ new RegExp(`(\\b${GIT}\\s+commit\\b[^\\n]*?\\s(?:-m|--message)\\s+)(?:"(?:[^"\\\\]|\\\\.)*"|'(?:[^'\\\\]|\\\\.)*')`, "gi"),
+ "$1\"\""
+ );
+}
+function unquoteShellPaths(cmd) {
+ return String(cmd).replace(/"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'/g, (m, dq, sq) =>
+ (dq !== undefined ? dq : sq).replace(/\\(["'\\])/g, "$1")
+ );
+}
-// ---------- основная логика ----------
+// ---------- main policy ----------
function run(ctx, env = process.env) {
try {
- // Fail-closed (всегда включён): обрезанный или нечитаемый payload — отказ
- // решать вслепую. Пустой stdin (ручной запуск) остаётся fail-open в parse().
+ // Fail closed on truncated or unreadable payloads. Empty stdin for manual
+ // runs remains fail-open in parse().
if (ctx.truncated || ctx.parseError)
- return blockRes("🛑 guard: входной payload обрезан или нечитаем — защитные проверки не решают вслепую.\n" +
- " Повтори вызов меньшим изменением (или доставь ввод целиком).");
+ return blockRes("guard: input payload is truncated or unreadable; policy cannot decide blind.\n" +
+ " Retry with a smaller change or provide the full payload.");
const { tool, command, filePath, sessionId, projectDir } = ctx;
const isShell = /^(bash|shell|sh|exec|run|terminal|execute)/i.test(tool) || (!tool && command);
const isFile = /^(write|edit|multiedit|applypatch|create|str_replace|notebookedit)/i.test(tool);
const isRead = /^read/i.test(tool);
- // Пороги циклов; strict — вдвое строже.
+ // Loop thresholds; strict mode halves them.
let T_SH = Number(env.HARNESS_LOOP_THRESHOLD) || 5;
let T_FT = Number(env.HARNESS_TOOLLOOP_THRESHOLD) || 12;
if (getProfile(env) === "strict") { T_SH = Math.max(2, Math.ceil(T_SH / 2)); T_FT = Math.max(3, Math.ceil(T_FT / 2)); }
@@ -220,67 +220,70 @@ function run(ctx, env = process.env) {
if (isShell && typeof command === "string" && command) {
const scrubbed = scrubQuotes(command);
+ const pathScan = unquoteShellPaths(scrubGitMessageArgs(command));
- // 1) обход харнесса / запись в защищённые пути и lint-конфиги через shell
+ // 1) harness bypass or shell writes to protected paths and lint configs
const hit =
(checkEnabled("bypass", env) &&
(BYPASS.find((b) => b.re.test(scrubbed)) ||
- (isGitHooksWrite(scrubbed) ? { why: "прямое вмешательство в .git/hooks" } : null))) ||
- (checkEnabled("protected", env) && isProtectedShellWrite(scrubbed, cfg.protected)
- ? { why: "shell-запись в файлы харнесса (hooks/, конфиги, workflows) — их агент не меняет сам" } : null) ||
- (checkEnabled("lintconfig", env) && isLintConfigShellWrite(scrubbed, cfg.lintConfigs)
- ? { why: "shell-запись в lint/format-конфиг — красный гейт чинится кодом, а не ослаблением конфига" } : null);
+ (isGitHooksWrite(pathScan) ? { why: "direct write/delete under .git/hooks" } : null))) ||
+ (checkEnabled("protected", env) && isProtectedShellWrite(pathScan, cfg.protected)
+ ? { why: "shell write to harness files (hooks/, configs, workflows)" } : null) ||
+ (checkEnabled("lintconfig", env) && isLintConfigShellWrite(pathScan, cfg.lintConfigs)
+ ? { why: "shell write to lint/format config; fix code instead of weakening config" } : null);
if (hit) {
if (envAllow(env, "HARNESS_ACK_BYPASS")) {
- notes.push(`⚠️ guard: обход харнесса разрешён явно пользователем: ${hit.why}. Обоснуй в отчёте.`);
+ notes.push(`guard: harness bypass was explicitly approved by the user: ${hit.why}. Explain this in the report.`);
} else {
- return blockRes(`🛑 guard: команда обходит harness — заблокировано.\n Причина: ${hit.why}.\n ${BYPASS_HINT}`);
+ return blockRes(`guard: command bypasses the harness; blocked.\n Reason: ${hit.why}.\n ${BYPASS_HINT}`);
}
}
- // 1c) запись в файл харнесса через инлайн-eval интерпретатора (node -e/
- // python -c/bash -c…). Write-verb-детекция это не ловит (глагол/путь в
- // строке, scrubQuotes их обнулил), поэтому проверяем СЫРУЮ команду и только
- // напоминаем — жёстко блокировать нельзя (путь в -e может быть безобиден).
+ // 1c) writes to harness files through inline interpreter eval.
if (checkEnabled("protected", env)) {
const ip = interpreterProtectedHint(command, cfg.protected);
- if (ip) notes.push(`⚠️ guard: похоже на запись в файл харнесса (${ip}) через инлайн-eval интерпретатора ` +
- `(node -e / python -c / bash -c …). Такой обход не ловится жёстким блоком — не меняй файлы харнесса так. ` +
- `Если это не файл харнесса, игнорируй.`);
+ if (ip) {
+ if (envAllow(env, "HARNESS_ACK_BYPASS")) {
+ notes.push(`guard: inline-eval write to harness file (${ip}) was explicitly approved by the user. Explain this in the report.`);
+ } else {
+ return blockRes(`guard: command looks like an inline-eval write to a harness file (${ip}).\n` +
+ ` Reason: node -e / python -c / bash -c hides the write from normal shell detection.\n ${BYPASS_HINT}`);
+ }
+ }
}
- // 2) сбой стриминга / мусор
+ // 2) stream corruption / junk
if (checkEnabled("corruption", env) && CORRUPTION_RE.test(scrubbed)) {
writeState(p, { hist: [], streak: 0, seen: st.seen });
- return blockRes(`🛑 guard: мусор tool-разметки в команде — верный признак сбоя стриминга/парсинга.\n ${JSON.stringify(command.slice(0, 120))}`);
+ return blockRes(`guard: leaked tool markup in command; this usually means streaming/parsing corruption.\n ${JSON.stringify(command.slice(0, 120))}`);
}
if (checkEnabled("entropy", env) && isLowEntropy(command)) {
writeState(p, { hist: [], streak: 0, seen: st.seen });
- return blockRes(`🛑 guard: аномально низкая энтропия токенов команды (паттерн «echo a echo a …»).\n ${JSON.stringify(command.slice(0, 120))}`);
+ return blockRes(`guard: command token entropy is abnormally low.\n ${JSON.stringify(command.slice(0, 120))}`);
}
- // 3) циклы
+ // 3) loops
if (checkEnabled("loops", env)) {
st.streak = isTrivial(command) ? st.streak + 1 : 0;
st.hist.push("sh::" + command.trim());
if (st.hist.length > HIST_MAX) st.hist.shift();
if (st.streak >= T_SH) {
writeState(p, { hist: [], streak: 0, seen: st.seen });
- return blockRes(`🛑 guard: ${st.streak} тривиальных команд подряд (echo/ls/pwd/…) — дегенеративный паттерн.\n` +
- ` Остановись и реши задачу одним осмысленным шагом. Порог: HARNESS_LOOP_THRESHOLD=${T_SH}.`);
+ return blockRes(`guard: ${st.streak} trivial commands in a row; this is a degenerate pattern.\n` +
+ ` Stop and make one meaningful step. Threshold: HARNESS_LOOP_THRESHOLD=${T_SH}.`);
}
const lr = loopCheck(st, p, T_SH);
if (lr) return lr;
writeState(p, st);
}
- // 4) ранние подсказки про main
+ // 4) early notes about protected branches
if (checkEnabled("main-note", env)) {
const branch = currentBranch(projectDir);
if (["main", "master"].includes(branch) && new RegExp(`\\b${GIT}\\s+(commit|merge)\\b`).test(scrubbed))
- notes.push(`⚠️ guard: git commit/merge на «${branch}» — pre-commit отклонит. Перейди на feature-ветку (релиз: HARNESS_ALLOW_MAIN=1).`);
+ notes.push(`guard: git commit/merge on ${branch} will be rejected by pre-commit. Switch to a feature branch; releases use HARNESS_ALLOW_MAIN=1.`);
if (new RegExp(`\\b${GIT}\\s+push\\b`).test(scrubbed) && /\b(main|master)\b/.test(scrubbed) && !/refs\/tags|v\d/.test(scrubbed))
- notes.push(`⚠️ guard: прямой push в main/master отклонит серверный ruleset. main обновляется через PR.`);
+ notes.push(`guard: direct push to main/master will be rejected by the server ruleset. Update main through a PR.`);
}
return allowRes(notes);
}
@@ -289,47 +292,45 @@ function run(ctx, env = process.env) {
const rel = normRel(filePath, projectDir);
const abs = path.isAbsolute(String(filePath)) ? String(filePath) : path.join(String(projectDir), rel);
- // 1) файлы самого харнесса — только с явного разрешения
+ // 1) harness files require explicit approval
if (isFile && checkEnabled("protected", env) && isProtectedPath(rel, cfg.protected)) {
if (envAllow(env, "HARNESS_ACK_BYPASS")) {
- notes.push(`⚠️ guard: правка файла харнесса (${rel}) разрешена явно пользователем.`);
+ notes.push(`guard: harness file edit (${rel}) was explicitly approved by the user.`);
} else {
- return blockRes(`🛑 guard: правка файла харнесса заблокирована: ${rel}\n` +
- ` Хуки/конфиги харнесса агент не меняет сам по себе.\n ${BYPASS_HINT}`);
+ return blockRes(`guard: harness file edit blocked: ${rel}\n` +
+ ` Agents must not change harness hooks/configs without approval.\n ${BYPASS_HINT}`);
}
}
- // 1b) lint/format-конфиги ЦЕЛЕВОГО проекта: правка существующего — блок
- // (агент «чинит» красный VERIFY, ослабляя конфиг); создание нового — можно.
+ // 1b) existing target lint/format configs: block edits, allow new files.
if (isFile && checkEnabled("lintconfig", env) &&
!isProtectedPath(rel, cfg.protected) && isLintConfigPath(rel, cfg.lintConfigs)) {
let exists = true;
try { fs.lstatSync(abs); }
- catch (e) { if (e && e.code === "ENOENT") exists = false; } // иные ошибки = fail-closed
+ catch (e) { if (e && e.code === "ENOENT") exists = false; } // other errors fail closed
if (exists) {
if (envAllow(env, "HARNESS_ACK_BYPASS")) {
- notes.push(`⚠️ guard: правка lint-конфига (${rel}) разрешена явно пользователем.`);
+ notes.push(`guard: lint config edit (${rel}) was explicitly approved by the user.`);
} else {
- return blockRes(`🛑 guard: правка существующего lint/format-конфига заблокирована: ${rel}\n` +
- ` Красный гейт чинится исправлением кода, а не ослаблением конфига.\n` +
- ` Создание нового конфига с нуля разрешено. ${BYPASS_HINT}`);
+ return blockRes(`guard: existing lint/format config edit blocked: ${rel}\n` +
+ ` Fix failing gates in code instead of weakening config.\n` +
+ ` Creating a new config file from scratch is allowed. ${BYPASS_HINT}`);
}
}
}
- // 2) fact-force (ECC GateGuard, note-only): правка существующего файла,
- // который в этой сессии не читали — одна заметка на файл, без спама.
+ // 2) fact-force note: editing an existing file before reading it.
if (isRead) markSeen(st, rel);
if (isFile && checkEnabled("fact-force", env) && !st.seen.includes(rel)) {
let exists = false;
try { fs.lstatSync(abs); exists = true; } catch {}
if (exists)
- notes.push(`⚠️ guard: правишь ${rel}, не читав его в этой сессии (EXPLORE → IMPLEMENT). ` +
- `Прочитай файл или места его использования перед правкой.`);
- markSeen(st, rel); // независимо от exists: новый файл дальше «знаком»
+ notes.push(`guard: editing ${rel} before reading it in this session (EXPLORE -> IMPLEMENT). ` +
+ `Read the file or its call sites before editing.`);
+ markSeen(st, rel); // new files are considered known after this point
}
- // 3) циклы (Read/Write/Edit одного и того же объекта)
+ // 3) file-tool loops
if (checkEnabled("loops", env)) {
st.streak = 0;
st.hist.push(tool.toLowerCase() + "::" + rel);
@@ -339,17 +340,17 @@ function run(ctx, env = process.env) {
}
writeState(p, st);
- // 4) подсказки: DESIGN-стадия и main
+ // 4) notes: DESIGN stage and protected branches
if (isFile) {
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-файла (${rel}). DESIGN-стадия: ≥${cfg.mockups.min} мокапа + APPROVED до кода ` +
- `(node hooks/new-mockups.js ). Жёсткий гейт — design-gate.js в pre-push/CI.`);
+ 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.`);
if (checkEnabled("main-note", env)) {
const branch = currentBranch(projectDir);
if (["main", "master"].includes(branch))
- notes.push(`⚠️ guard: правишь файлы на «${branch}». Нужна feature-ветка: git checkout -b feat/…`);
+ notes.push(`guard: editing files on ${branch}. Create a feature branch first: git checkout -b feat/...`);
}
}
return allowRes(notes);
@@ -357,15 +358,14 @@ function run(ctx, env = process.env) {
return { exitCode: 0, stdout: "", stderr: "" };
} catch (e) {
- // fail-open: хук не должен вешать сессию. НО делаем поломку видимой в stderr —
- // иначе исключение в детекторе тихо отключило бы всю защиту для этого вызова.
- return { exitCode: 0, stdout: "", stderr: "⚠️ guard: внутренняя ошибка проверки, пропускаю (fail-open): " + (e && e.message) + "\n" };
+ // Fail open so the hook cannot wedge the session, but make the failure visible.
+ return { exitCode: 0, stdout: "", stderr: "guard: internal policy error, allowing call (fail-open): " + (e && e.message) + "\n" };
}
}
module.exports = { run };
-// ---------- CLI-обёртка ----------
+// ---------- CLI wrapper ----------
if (require.main === module) {
(async () => {
const ctx = await parse();
diff --git a/hooks/agent/stop-reminder.js b/hooks/agent/stop-reminder.js
index 0d76741..efd09d9 100644
--- a/hooks/agent/stop-reminder.js
+++ b/hooks/agent/stop-reminder.js
@@ -1,11 +1,10 @@
#!/usr/bin/env node
-// stop-reminder.js — Stop-хук. Напоминает про VERIFY / COMMIT / REPORT ТОЛЬКО если
-// в рабочем дереве есть незакоммиченные изменения; чистое дерево → молчит.
+// stop-reminder.js - Stop hook. Reminds about VERIFY / COMMIT / REPORT only when
+// the working tree has uncommitted changes; clean tree stays quiet.
//
-// Контракт Stop-хука Claude Code: additionalContext на Stop НЕ поддерживается —
-// единственный способ донести текст до модели: {"decision":"block","reason":"…"}.
-// Защита от зацикливания: если раннер прислал stop_hook_active=true (мы уже
-// блокировали этот Stop), выходим молча — иначе агент никогда не остановится.
+// Claude Code Stop hooks do not support additionalContext. The only way to
+// deliver text is {"decision":"block","reason":"..."}. If the runner sends
+// stop_hook_active=true, this hook stays quiet to avoid an infinite stop loop.
const { execSync } = require("child_process");
const crypto = require("crypto");
@@ -61,10 +60,10 @@ function lastAssistantTextFromTranscript(file) {
function explainedIntentionalDirty(text) {
const s = String(text || "").toLowerCase();
if (!s) return false;
- const mentionsDirty = /dirty tree|uncommitted|незакоммич|некоммич|рабоч(ем|ее) дерев|оставш/.test(s);
- const intentional = /intentional|intentionally|намеренн|осознанн|не трогал|не трогала|оставил|оставила|оставлены/.test(s);
- const reportsLoop = /verify|проверен|проверено|self-review|diff|commit|коммит|report|отч[её]т/.test(s);
- const reviewOnly = /review-only|только review|только ревью|повторн(ый|ое) review|повторн(ый|ое) ревью|изменения я не правил|изменения не правил|не коммитил|commit\/pr не делал|коммит не делал/.test(s);
+ const mentionsDirty = /dirty tree|uncommitted|working tree|left over|left intentionally/.test(s);
+ const intentional = /intentional|intentionally|left on purpose|not touched|left unchanged/.test(s);
+ const reportsLoop = /verify|verified|self-review|diff|commit|report/.test(s);
+ const reviewOnly = /review-only|review only|did not edit|did not commit|no commit\/pr|commit\/pr not created/.test(s);
return (mentionsDirty && intentional && reportsLoop) || (reviewOnly && reportsLoop);
}
function isHarnessOrLocalStatus(line) {
@@ -83,7 +82,7 @@ function isHarnessOrLocalStatus(line) {
stopHookActive = ctx.stopHookActive;
raw = ctx.raw || {};
} catch {}
- if (stopHookActive) process.exit(0); // уже напоминали в этом же Stop — не зацикливаемся
+ if (stopHookActive) process.exit(0); // already reminded during this Stop; avoid loops
let status = "";
try {
@@ -107,16 +106,16 @@ function isHarnessOrLocalStatus(line) {
process.exit(0);
}
}
- const shown = lines.slice(0, 20).join("\n") + (lines.length > 20 ? `\n... ещё ${lines.length - 20}` : "");
+ const shown = lines.slice(0, 20).join("\n") + (lines.length > 20 ? `\n... ${lines.length - 20} more` : "");
const harnessNote = harnessLike.length
- ? "\nПохоже, часть dirty tree — bootstrap/harness/local files. Если они оставлены намеренно, повторный Stop с тем же git status будет разрешён.\n"
- : "\nЕсли dirty tree оставлено намеренно и отчёт уже объясняет почему, повторный Stop с тем же git status будет разрешён.\n";
+ ? "\nSome dirty files look like bootstrap/harness/local files. If they are intentional, the next Stop with the same git status will be allowed.\n"
+ : "\nIf the dirty tree is intentional and the report explains why, the next Stop with the same git status will be allowed.\n";
const reason =
- "stop-reminder: есть незакоммиченные изменения — закрыты ли шаги loop?\n" +
- " 4. VERIFY (node hooks/verify.js + git diff review) -> 5. COMMIT на feature-ветке -> 6. REPORT.\n" +
- "Коммит не всегда нужен: можно явно отчитаться, почему изменения остаются uncommitted.\n" +
+ "stop-reminder: uncommitted changes remain; are the loop steps complete?\n" +
+ " 4. VERIFY (node hooks/verify.js + git diff review) -> 5. COMMIT on a feature branch -> 6. REPORT.\n" +
+ "A commit is not always required: explicitly report why changes remain uncommitted.\n" +
harnessNote +
- "git status (первые строки):\n" + shown;
+ "git status (first lines):\n" + shown;
try { process.stdout.write(JSON.stringify({ decision: "block", reason }) + "\n"); } catch {}
process.stderr.write(reason + "\n");
process.exit(0);
diff --git a/hooks/apply-ruleset.js b/hooks/apply-ruleset.js
index 2d5484e..b70915e 100644
--- a/hooks/apply-ruleset.js
+++ b/hooks/apply-ruleset.js
@@ -1,5 +1,5 @@
#!/usr/bin/env node
-// apply-ruleset.js — install the versioned branch ruleset (the REAL, server-side gate).
+// apply-ruleset.js - install the versioned branch ruleset (the REAL, server-side gate).
//
// Local hooks (lefthook + gitleaks + cocogitto) are HYGIENE: fast, but an agent with
// write access can bypass them. Only a GitHub ruleset with a required status check
@@ -17,45 +17,112 @@ const fs = require("fs");
const path = require("path");
const { execFileSync } = require("child_process");
-const dryRun = process.argv.includes("--dry-run");
const rulesetPath = path.join(__dirname, "..", ".github", "rulesets", "main.json");
function gh(args, opts = {}) {
return execFileSync("gh", args, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], ...opts }).trim();
}
+function stable(value) {
+ if (Array.isArray(value)) return value.map(stable);
+ if (!value || typeof value !== "object") return value;
+ return Object.fromEntries(Object.keys(value).sort().map((k) => [k, stable(value[k])]));
+}
+function same(a, b) {
+ return JSON.stringify(stable(a)) === JSON.stringify(stable(b));
+}
+function parseRulesetList(text) {
+ const raw = String(text || "").trim();
+ if (!raw) return [];
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed.flat() : [parsed];
+ } catch {}
+ const out = [];
+ for (const line of raw.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)) {
+ const parsed = JSON.parse(line);
+ if (Array.isArray(parsed)) out.push(...parsed);
+ else out.push(parsed);
+ }
+ return out;
+}
+function ruleByType(ruleset, type) {
+ return ((ruleset && ruleset.rules) || []).find((r) => r.type === type) || null;
+}
+function requiredCheckKey(check) {
+ return `${check.context || ""}\u0000${check.integration_id === undefined ? "" : check.integration_id}`;
+}
+function compareRuleset(expected, actual) {
+ const mismatches = [];
+ for (const key of ["name", "target", "enforcement"]) {
+ if (expected[key] !== actual[key]) mismatches.push(`${key}: expected ${expected[key]}, got ${actual[key]}`);
+ }
+ if (!same(expected.conditions || {}, actual.conditions || {})) mismatches.push("conditions differ");
+ for (const expRule of expected.rules || []) {
+ const gotRule = ruleByType(actual, expRule.type);
+ if (!gotRule) { mismatches.push(`missing rule: ${expRule.type}`); continue; }
+ if (expRule.type === "required_status_checks") {
+ const expParams = expRule.parameters || {};
+ const gotParams = gotRule.parameters || {};
+ if (expParams.strict_required_status_checks_policy !== gotParams.strict_required_status_checks_policy)
+ mismatches.push("required_status_checks.strict_required_status_checks_policy differs");
+ const gotChecks = new Set((gotParams.required_status_checks || []).map(requiredCheckKey));
+ for (const check of expParams.required_status_checks || []) {
+ if (!gotChecks.has(requiredCheckKey(check)))
+ mismatches.push(`required status check missing: ${check.context} / ${check.integration_id}`);
+ }
+ continue;
+ }
+ for (const [key, value] of Object.entries(expRule.parameters || {})) {
+ if (!same(value, ((gotRule.parameters || {})[key])))
+ mismatches.push(`${expRule.type}.${key} differs`);
+ }
+ }
+ return mismatches;
+}
-try {
- const raw = JSON.parse(fs.readFileSync(rulesetPath, "utf8"));
- delete raw._comment; // strip the doc comment before sending
- const body = JSON.stringify(raw);
+function main() {
+ const dryRun = process.argv.includes("--dry-run");
+ try {
+ const raw = JSON.parse(fs.readFileSync(rulesetPath, "utf8"));
+ delete raw._comment; // strip the doc comment before sending
+ const body = JSON.stringify(raw);
- if (dryRun) {
- console.log("would POST this ruleset to repos///rulesets:\n");
- console.log(JSON.stringify(raw, null, 2));
- process.exit(0);
- }
+ if (dryRun) {
+ console.log("would apply this ruleset to repos///rulesets:\n");
+ console.log(JSON.stringify(raw, null, 2));
+ process.exit(0);
+ }
- const repo = gh(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]);
+ const repo = gh(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]);
- // Replace an existing "protect-main" ruleset if present (idempotent).
- let existingId = "";
- try {
- const list = JSON.parse(gh(["api", `repos/${repo}/rulesets`]));
- const found = (Array.isArray(list) ? list : []).find((r) => r.name === raw.name);
- if (found) existingId = String(found.id);
- } catch {}
+ // Replace an existing "protect-main" ruleset if present (idempotent).
+ const list = parseRulesetList(gh(["api", "--paginate", `repos/${repo}/rulesets`]));
+ const found = list.find((r) => r.name === raw.name);
+ const existingId = found ? String(found.id) : "";
- const apiArgs = existingId
- ? ["api", "-X", "PUT", `repos/${repo}/rulesets/${existingId}`, "--input", "-"]
- : ["api", "-X", "POST", `repos/${repo}/rulesets`, "--input", "-"];
+ const apiArgs = existingId
+ ? ["api", "-X", "PUT", `repos/${repo}/rulesets/${existingId}`, "--input", "-"]
+ : ["api", "-X", "POST", `repos/${repo}/rulesets`, "--input", "-"];
- gh(apiArgs, { input: body });
- console.log(`✅ ruleset "${raw.name}" ${existingId ? "updated" : "created"} on ${repo} (branches: main/master).`);
- console.log(" Enforced: require PR, required check «verify», block force-push & deletion.");
- process.exit(0);
-} catch (e) {
- console.error("❌ apply-ruleset failed:", e.message);
- console.error(" Need: gh CLI (authenticated, repo admin) and a plan that supports rulesets");
- console.error(" (private repos → Pro/Team/Enterprise; or make the repo public). See BACKLOG P0-0.");
- process.exit(1);
+ const written = JSON.parse(gh(apiArgs, { input: body }));
+ const id = String(written.id || existingId || "");
+ const applied = id ? JSON.parse(gh(["api", `repos/${repo}/rulesets/${id}`])) : written;
+ const mismatches = compareRuleset(raw, applied);
+ if (mismatches.length) {
+ console.error(`FAIL ruleset "${raw.name}" was written but readback does not match .github/rulesets/main.json:`);
+ for (const m of mismatches) console.error(` - ${m}`);
+ process.exit(1);
+ }
+ console.log(`OK ruleset "${raw.name}" ${existingId ? "updated" : "created"} on ${repo} (branches: main/master).`);
+ console.log(" Readback verified: PR policy, required check \"verify\", force-push/delete protection.");
+ process.exit(0);
+ } catch (e) {
+ console.error("FAIL apply-ruleset failed:", e.message);
+ console.error(" Need: gh CLI (authenticated, repo admin) and a plan that supports rulesets");
+ console.error(" (private repos need Pro/Team/Enterprise; or make the repo public). See BACKLOG P0-0.");
+ process.exit(1);
+ }
}
+
+module.exports = { parseRulesetList, compareRuleset };
+if (require.main === module) main();
diff --git a/hooks/design-gate.js b/hooks/design-gate.js
index 2465c28..063426a 100644
--- a/hooks/design-gate.js
+++ b/hooks/design-gate.js
@@ -1,8 +1,8 @@
#!/usr/bin/env node
-// design-gate.js — DESIGN-stage gate.
+// 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 —
+// 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.
//
// Usage:
@@ -13,7 +13,7 @@
// --strict diff errors fail closed (CI/server enforcement)
//
// Exit 0 = gate satisfied (or no UI change), exit 1 = UI changed without approved mockups.
-// Internal error → exit 0 (never wedge unrelated work), но с ГРОМКИМ warning.
+// Internal error -> exit 0 locally (never wedge unrelated work), with a loud warning.
const fs = require("fs");
const path = require("path");
@@ -50,21 +50,18 @@ function defaultBase(root) {
return "origin/main";
}
-// ---------- config (общая с guard.js: hooks/_lib.js) ----------
-// changedFiles — общий с verify.js (--changed): единый источник git-diff логики.
+// ---------- config shared with guard.js and verify.js ----------
const { globToRe, loadConfig, changedFiles } = require(path.join(__dirname, "_lib.js"));
// ---------- mockups scan ----------
-// Одобренный набор засчитывается ТОЛЬКО если он затронут в diff этой же ветки —
-// иначе один старый approval навсегда открывал бы гейт для любых будущих UI-правок.
-// Повторное использование уже одобренного набора: допиши строку в его APPROVED
-// (дата/ветка) — файл попадёт в diff, и связь «этот набор ↔ это изменение» явная.
+// 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) {
const base = path.join(root, m.dir);
const mockRoot = m.dir.replace(/\\/g, "/").replace(/\/$/, "");
let dirs;
try { dirs = fs.readdirSync(base, { withFileTypes: true }).filter((d) => d.isDirectory()); }
- catch { return { ok: false, reason: `нет каталога ${m.dir}/` }; }
+ catch { return { ok: false, reason: `missing directory ${m.dir}/` }; }
const stale = [];
for (const d of dirs) {
@@ -81,9 +78,9 @@ function hasApprovedMockups(root, m, changed) {
return {
ok: false,
reason: stale.length
- ? `одобренные наборы (${stale.join(", ")}) не затронуты в diff этой ветки — ` +
- `привяжи набор к изменению: допиши строку в ${m.dir}//${m.approvalFile}`
- : `нет ${m.dir}// с >=${m.min} мокапами и файлом ${m.approvalFile}, затронутого в этой ветке`,
+ ? `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`,
};
}
@@ -99,8 +96,8 @@ function hasApprovedMockups(root, m, changed) {
const cf = changedFiles(a.base, a.root, a.files);
if (cf.base) res.base = cf.base;
if (cf.error) {
- // local default = fail-open, но ГРОМКО; strict/CI = fail-closed.
- const warn = `⚠️ design-gate: ${cf.error} — ${a.strict ? "гейт НЕ МОЖЕТ ПРОВЕРИТЬ UI-изменения" : "гейт ПРОПУЩЕН, UI-изменения не проверены"}. Укажи базу явно: --base [.`;
+ // Local default = fail-open loudly; strict/CI = fail-closed.
+ const warn = `design-gate: ${cf.error}; ${a.strict ? "gate cannot verify UI changes" : "gate skipped and UI changes were not checked"}. Pass --base ][.`;
if (a.json) console.log(JSON.stringify({ ...res, ok: !a.strict, skipped: true, warn }));
else console.error(warn);
process.exit(a.strict ? 1 : 0);
@@ -112,8 +109,8 @@ function hasApprovedMockups(root, m, changed) {
);
if (res.uiChanged.length === 0) {
- if (a.json) console.log(JSON.stringify({ ...res, note: "нет изменений в UI-путях" }));
- else console.log("OK design-gate: изменений в UI-путях нет — гейт не требуется.");
+ if (a.json) console.log(JSON.stringify({ ...res, note: "no UI-path changes" }));
+ else console.log("OK design-gate: no UI-path changes; gate not required.");
process.exit(0);
}
@@ -121,18 +118,18 @@ function hasApprovedMockups(root, m, changed) {
res.mockups = mk;
if (mk.ok) {
if (a.json) console.log(JSON.stringify(res));
- else console.log(`OK design-gate: UI-изменения есть, одобренный набор мокапов затронут в ветке (${mk.feature}, ${mk.count} шт.).`);
+ else console.log(`OK design-gate: UI changes have an approved mockup set touched in this branch (${mk.feature}, ${mk.count}).`);
process.exit(0);
}
if (a.json) { console.log(JSON.stringify({ ...res, ok: false })); process.exit(1); }
console.error(
- `BLOCK design-gate: изменения затрагивают GUI, но DESIGN-стадия не выполнена.\n` +
- ` UI-файлы: ${res.uiChanged.slice(0, 8).join(", ")}${res.uiChanged.length > 8 ? " ..." : ""}\n` +
- ` Требуется: ${mk.reason}.\n` +
- ` Новый набор: node hooks/new-mockups.js → approval → создай ${cfg.mockups.dir}//${cfg.mockups.approvalFile}.\n` +
- ` Уже одобренный набор: допиши строку в его ${cfg.mockups.approvalFile}, чтобы он попал в diff ветки.\n` +
- ` Политика: для нового/изменяемого GUI — >=${cfg.mockups.min} стилистически разных мокапа + approval.`
+ `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.`
);
process.exit(1);
})();
diff --git a/hooks/doctor.js b/hooks/doctor.js
index 2c17fa2..76e3ffb 100644
--- a/hooks/doctor.js
+++ b/hooks/doctor.js
@@ -1,5 +1,5 @@
#!/usr/bin/env node
-// doctor.js — environment self-check (BACKLOG P2-12). Catches the classes of problem we
+// doctor.js - environment self-check (BACKLOG P2-12). Catches the classes of problem we
// hit in development: hooks not wired, CRLF, NUL bytes, bad config, missing git identity.
// Checks the migrated stack (lefthook + gitleaks + cocogitto). Run: node hooks/doctor.js
//
@@ -20,28 +20,73 @@ function gitSafe(args) { try { return git(args); } catch { return null; } }
function inPath(bin) { try { execFileSync(process.platform === "win32" ? "where" : "which", [bin], { stdio: ["ignore", "pipe", "ignore"], timeout: 5000, killSignal: "SIGKILL" }); return true; } catch { return false; } }
function tracked(rel) { return gitSafe(["ls-files", "--error-unmatch", rel]) !== null; }
function readText(rel) { try { return fs.readFileSync(path.join(ROOT, rel), "utf8"); } catch { return ""; } }
+function escapeRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
+function tomlSection(text, name) {
+ const re = new RegExp(`^\\s*\\[${escapeRe(name)}\\]\\s*$`, "m");
+ const m = re.exec(text);
+ if (!m) return "";
+ const rest = text.slice(m.index + m[0].length);
+ const next = rest.search(/^\s*\[[^\]]+\]\s*$/m);
+ return next >= 0 ? rest.slice(0, next) : rest;
+}
+function tomlString(section, key) {
+ const re = new RegExp(`^\\s*${escapeRe(key)}\\s*=\\s*"([^"]*)"\\s*$`, "m");
+ const m = re.exec(section);
+ return m ? m[1] : null;
+}
+function githubRepoFromUrl(url) {
+ const value = String(url || "").trim();
+ const m = value.match(/^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/:]+)\/([^/]+?)(?:\.git)?\/?$/i);
+ return m ? { owner: m[1], repository: m[2] } : null;
+}
function checkTextFile(rel) {
const p = path.join(ROOT, rel);
let buf;
- try { buf = fs.readFileSync(p); } catch { fail(rel + " отсутствует"); return; }
+ try { buf = fs.readFileSync(p); } catch { fail(rel + " is missing"); return; }
const text = buf.toString("utf8");
- if (!Buffer.from(text, "utf8").equals(buf)) fail(rel + ": невалидный UTF-8 или обрезанный многобайтный символ");
- else if (buf.includes(0)) fail(rel + " содержит NUL-байты");
- else if (buf.includes(13)) fail(rel + ": CRLF/CR line endings (нужен LF)");
- else ok(rel + ": LF, UTF-8, без NUL");
+ if (!Buffer.from(text, "utf8").equals(buf)) fail(rel + ": invalid UTF-8 or truncated multibyte character");
+ else if (buf.includes(0)) fail(rel + " contains NUL bytes");
+ else if (buf.includes(13)) fail(rel + ": CRLF/CR line endings (LF required)");
+ else ok(rel + ": LF, UTF-8, no NUL");
}
-function workflowJobIds(rel) {
+function yamlKeyLine(line) {
+ const m = String(line).match(/^(\s*)(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))\s*:.*$/);
+ if (!m) return null;
+ return { indent: m[1].length, key: m[2] || m[3] || m[4] };
+}
+function workflowJobs(rel) {
const text = readText(rel);
const lines = text.split(/\r?\n/);
- const ids = [];
- let inJobs = false;
+ const jobs = [];
+ let jobsIndent = null, jobIndent = null;
+ let current = null;
for (const line of lines) {
- if (/^jobs:\s*$/.test(line)) { inJobs = true; continue; }
- if (inJobs && /^\S/.test(line) && !/^jobs:\s*$/.test(line)) break;
- const m = inJobs && line.match(/^ ([A-Za-z0-9_-]+):\s*(?:#.*)?$/);
- if (m) ids.push(m[1]);
+ if (/^\s*(?:#.*)?$/.test(line)) {
+ if (current) current.body.push(line);
+ continue;
+ }
+ const key = yamlKeyLine(line);
+ if (jobsIndent === null) {
+ if (key && key.key === "jobs") jobsIndent = key.indent;
+ continue;
+ }
+ if (key && key.indent <= jobsIndent) break;
+ if (key && (jobIndent === null || key.indent === jobIndent)) {
+ if (jobIndent === null) jobIndent = key.indent;
+ current = { id: key.key, body: [line] };
+ jobs.push(current);
+ continue;
+ }
+ if (current) current.body.push(line);
}
- return ids;
+ return jobs;
+}
+function workflowJobIds(rel) {
+ return workflowJobs(rel).map((j) => j.id);
+}
+function workflowJobBody(rel, id) {
+ const job = workflowJobs(rel).find((j) => j.id === id);
+ return job ? job.body.join("\n") : "";
}
function rulesetRequiredChecks(rel) {
let ruleset = {};
@@ -49,20 +94,81 @@ function rulesetRequiredChecks(rel) {
const rsc = (ruleset.rules || []).find((r) => r.type === "required_status_checks");
return (((rsc || {}).parameters || {}).required_status_checks || []).map((c) => c.context).filter(Boolean);
}
+function codeownersInfo(rel = ".github/CODEOWNERS") {
+ const abs = path.join(ROOT, rel);
+ const exists = fs.existsSync(abs);
+ const text = exists ? readText(rel) : "";
+ const ownerLines = text.split(/\r?\n/)
+ .map((line) => line.replace(/\s+#.*$/, "").trim())
+ .filter((line) => line && !line.startsWith("#") && /\s@[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)?\b/.test(line));
+ return { rel, exists, hasOwner: ownerLines.length > 0, tracked: !inRepo || tracked(rel) };
+}
+function checkRulesetPrReview(rel) {
+ let ruleset = {};
+ try { ruleset = JSON.parse(readText(rel)); } catch { return; }
+ const pr = (ruleset.rules || []).find((r) => r.type === "pull_request");
+ const p = (pr && pr.parameters) || {};
+ const codeowners = codeownersInfo();
+ if (Number(p.required_approving_review_count || 0) < 1)
+ warn("ruleset: pull_request does not require approving review; this is only appropriate for a solo-maintainer source repo");
+ else ok("ruleset: pull_request requires approving review");
+ if (p.require_code_owner_review === true) {
+ ok("ruleset: code-owner review required");
+ if (!codeowners.exists) fail("ruleset: code-owner review requires .github/CODEOWNERS");
+ else if (!codeowners.tracked) fail("ruleset: .github/CODEOWNERS must be tracked");
+ else if (!codeowners.hasOwner) fail("ruleset: code-owner review is enabled but .github/CODEOWNERS has no owner entries");
+ else ok("ruleset: CODEOWNERS has owner entries and is tracked");
+ } else {
+ if (codeowners.exists && codeowners.hasOwner)
+ warn("ruleset: CODEOWNERS has owner entries but required code-owner review is disabled");
+ else
+ ok("ruleset: code-owner review disabled and CODEOWNERS has no required owner configured");
+ }
+}
+function checkVerifyJobContract(workflowPath, required) {
+ if (!required.includes("verify")) return;
+ const body = workflowJobBody(workflowPath, "verify");
+ if (!body) { fail("CI job verify is required by ruleset but its workflow body was not found"); return; }
+ const checks = [
+ { name: "doctor", re: /node\s+hooks\/doctor\.js\b/ },
+ { name: "verify", re: /node\s+hooks\/verify\.js\b/ },
+ { name: "design-gate strict", re: /node\s+hooks\/design-gate\.js\b[^\n]*--strict\b/ },
+ { name: "secret scan", re: /gitleaks/i },
+ ];
+ const missing = checks.filter((c) => !c.re.test(body)).map((c) => c.name);
+ if (missing.length) fail(`CI job verify does not run required harness step(s): ${missing.join(", ")}`);
+ else ok("CI job verify runs doctor, verify.js, design-gate --strict and secret scan");
+}
+function checkWorkflowSupplyChain(workflowPath) {
+ const text = readText(workflowPath);
+ const unpinned = [];
+ for (const m of text.matchAll(/uses:\s*([^\s#]+)/g)) {
+ const spec = m[1];
+ if (!/@[0-9a-f]{40}$/i.test(spec)) unpinned.push(spec);
+ }
+ if (unpinned.length) fail(`CI action(s) not pinned to full SHA: ${unpinned.join(", ")}`);
+ else ok("CI actions are pinned to full commit SHAs");
+
+ if (/ecc-agentshield@/.test(text)) {
+ /AGENTSHIELD_INTEGRITY:\s*["']sha512-/.test(text) && /NPM_CONFIG_IGNORE_SCRIPTS:\s*["']true["']/.test(text)
+ ? ok("CI AgentShield npm package has integrity pin and install scripts disabled")
+ : fail("CI AgentShield npm package must pin dist.integrity and set NPM_CONFIG_IGNORE_SCRIPTS=true");
+ }
+}
// node / git
ok("node " + process.version);
const gv = gitSafe(["--version"]);
-gv ? ok(gv) : fail("git не найден в PATH");
+gv ? ok(gv) : fail("git not found in PATH");
// repo
const inRepo = gitSafe(["rev-parse", "--is-inside-work-tree"]) === "true";
if (!inRepo) {
- fail("не git-репозиторий (запусти внутри репо)");
+ fail("not a git repository; run inside a repository");
} else {
const name = gitSafe(["config", "--get", "user.name"]);
const email = gitSafe(["config", "--get", "user.email"]);
- (name && email) ? ok("git identity: " + name + " <" + email + ">") : warn("git user.name/email не заданы");
+ (name && email) ? ok("git identity: " + name + " <" + email + ">") : warn("git user.name/email are not configured");
// lefthook wired into .git/hooks? (lefthook install writes a stub referencing lefthook)
const hooksDir = gitSafe(["rev-parse", "--git-path", "hooks"]) || ".git/hooks";
@@ -72,13 +178,12 @@ if (!inRepo) {
if (/lefthook/i.test(fs.readFileSync(path.join(ROOT, hooksDir, h), "utf8"))) { wired = true; break; }
} catch {}
}
- wired ? ok("lefthook wired into .git/hooks") : warn("хуки не установлены — запусти: lefthook install");
+ wired ? ok("lefthook wired into .git/hooks") : warn("hooks are not installed; run: lefthook install");
- // .git должна допускать полный жизненный цикл lock-файла (write + unlink): git
- // обновляет index и refs через .lock -> rename/unlink. На FS без удаления
- // (некоторые сетевые/контейнерные/FUSE mount'ы) commit/checkout/rebase падают на
- // "index.lock: File exists". Проверяем реальной пробой, а не предположением —
- // именно этот отказ среды раньше не ловился.
+ // .git must support the full lock-file lifecycle (write + unlink): git updates
+ // the index and refs through .lock -> rename/unlink. On filesystems that
+ // cannot delete files (some network/container/FUSE mounts), commit/checkout/rebase
+ // fails with "index.lock: File exists". Probe the real behavior instead of guessing.
const gitDir = gitSafe(["rev-parse", "--git-dir"]) || ".git";
const gitDirAbs = path.isAbsolute(gitDir) ? gitDir : path.join(ROOT, gitDir);
const probe = path.join(gitDirAbs, ".doctor-lock-probe-" + process.pid);
@@ -86,32 +191,34 @@ if (!inRepo) {
fs.writeFileSync(probe, "x");
try {
fs.unlinkSync(probe);
- ok(".git допускает атомарные lock-операции (write + unlink)");
+ ok(".git supports atomic lock operations (write + unlink)");
} catch {
- fail(".git запрещает удаление файлов — git не уберёт *.lock (index.lock/ref.lock); commit/checkout/rebase упадут. Проверь mount (read-delete/FUSE) или права.");
+ fail(".git cannot delete files; git cannot clean up lock files and commit/checkout/rebase will fail. Check mount or permissions.");
}
} catch {
- fail(".git недоступна для записи — git add/commit/checkout работать не будут. Проверь права/mount.");
+ fail(".git is not writable; git add/commit/checkout will not work. Check permissions or mount.");
}
try {
if (fs.existsSync(path.join(gitDirAbs, "index.lock")))
- warn("залипший .git/index.lock — удали, если ни один git-процесс не запущен (иначе add/commit блокируются)");
+ warn("stale .git/index.lock detected; remove it only if no git process is running");
} catch {}
}
-// runner + delegated tools in PATH (WARN, not FAIL — CI provides them)
+// runner + delegated tools in PATH (WARN, not FAIL; CI provides them)
const tools = [
- ["lefthook", "git-hook раннер (lefthook install)"],
+ ["lefthook", "git hook runner (lefthook install)"],
["gitleaks", "secret scanning (pre-commit + CI)"],
["cog", "cocogitto: conventional commits + release"],
];
for (const t of tools) {
- inPath(t[0]) ? ok(t[0] + " найден") : warn(t[0] + " не в PATH — " + t[1]);
+ inPath(t[0]) ? ok(t[0] + " found") : warn(t[0] + " not in PATH - " + t[1]);
}
const requiredHarnessFiles = [
"hooks/verify.js",
+ "hooks/verify-core.js",
"hooks/design-gate.js",
+ "hooks/release-preflight.js",
"hooks/new-mockups.js",
"hooks/doctor.js",
"hooks/apply-ruleset.js",
@@ -124,10 +231,13 @@ const requiredHarnessFiles = [
"harness.config.json",
"lefthook.yml",
"cog.toml",
+ "CHANGELOG.md",
".gitleaks.toml",
"AGENTS.md",
"settings.example.json",
".github/rulesets/main.json",
+ ".github/workflows/ci.yml",
+ ".github/CODEOWNERS",
];
const missingHarness = [];
const untrackedHarness = [];
@@ -139,8 +249,8 @@ if (missingHarness.length || untrackedHarness.length) {
const parts = [];
if (missingHarness.length) parts.push("missing: " + missingHarness.join(", "));
if (untrackedHarness.length) parts.push("untracked: " + untrackedHarness.join(", "));
- fail("harness not bootstrapped into repository main — " + parts.join("; ") +
- ". Создай bootstrap PR и закоммить эти файлы перед dev/release loop.");
+ fail("harness not bootstrapped into repository main - " + parts.join("; ") +
+ ". Create a bootstrap PR and commit these files before the dev/release loop.");
} else {
ok("harness bootstrap files present and tracked");
}
@@ -163,26 +273,87 @@ for (const f of textCritical) checkTextFile(f);
// harness.config.json valid JSON
const cfgPath = path.join(ROOT, "harness.config.json");
if (fs.existsSync(cfgPath)) {
- try { JSON.parse(fs.readFileSync(cfgPath, "utf8")); ok("harness.config.json — валидный JSON"); }
- catch (e) { fail("harness.config.json невалиден: " + e.message); }
+ try {
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
+ 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;
+ if (hasSelfTest && stacks) {
+ const harness = stacks.find((s) => s && s.id === "harness");
+ const steps = (harness && Array.isArray(harness.steps)) ? harness.steps : [];
+ const runsSelfTest = steps.some((s) => /node\s+test\.js\b/.test(String(s && s.run || "")));
+ runsSelfTest ? ok("harness.config.json: VERIFY includes harness self-test") :
+ fail("harness.config.json: verify.stacks is set but required harness self-test (node test.js) is missing");
+ }
+ }
+ catch (e) { fail("harness.config.json is invalid: " + e.message); }
}
const cogPath = path.join(ROOT, "cog.toml");
if (fs.existsSync(cogPath)) {
const cog = fs.readFileSync(cogPath, "utf8");
- /from_latest_tag\s*=\s*true/.test(cog) ? ok("cog.toml: from_latest_tag=true") : fail("cog.toml: нужен from_latest_tag=true для release bump от последнего v* tag");
- /ignore_merge_commits\s*=\s*true/.test(cog) ? ok("cog.toml: ignore_merge_commits=true") : fail("cog.toml: нужен ignore_merge_commits=true");
- /tag_prefix\s*=\s*"v"/.test(cog) ? ok("cog.toml: tag_prefix=\"v\"") : fail("cog.toml: нужен tag_prefix=\"v\"");
+ const changelogSection = tomlSection(cog, "changelog");
+ const changelogTemplate = tomlString(changelogSection, "template");
+ const changelogOwner = tomlString(changelogSection, "owner");
+ const changelogRepository = tomlString(changelogSection, "repository");
+ /from_latest_tag\s*=\s*true/.test(cog) ? ok("cog.toml: from_latest_tag=true") : fail("cog.toml: from_latest_tag=true is required for release bumps from the latest v* tag");
+ /ignore_merge_commits\s*=\s*true/.test(cog) ? ok("cog.toml: ignore_merge_commits=true") : fail("cog.toml: ignore_merge_commits=true is required");
+ /tag_prefix\s*=\s*"v"/.test(cog) ? ok("cog.toml: tag_prefix=\"v\"") : fail("cog.toml: tag_prefix=\"v\" is required");
+ /branch_whitelist\s*=\s*\[[^\]]*"release\/\*\*"/s.test(cog)
+ ? ok("cog.toml: branch_whitelist includes release/**")
+ : fail("cog.toml: branch_whitelist must include release/** for release worktrees");
+ changelogTemplate === "remote"
+ ? ok("cog.toml: changelog.template=\"remote\"")
+ : fail("cog.toml: changelog.template=\"remote\" is required for github.com remote changelog generation");
+ changelogOwner
+ ? ok("cog.toml: changelog.owner set")
+ : fail("cog.toml: changelog.owner is required for remote changelog generation");
+ changelogRepository
+ ? ok("cog.toml: changelog.repository set")
+ : fail("cog.toml: changelog.repository is required for remote changelog generation");
+ if (changelogTemplate === "remote" && (changelogOwner || changelogRepository)) {
+ const origin = githubRepoFromUrl(gitSafe(["remote", "get-url", "origin"]));
+ if (!origin) {
+ fail("cog.toml: remote changelog generation requires a GitHub origin remote");
+ } else {
+ const ownerMatches = changelogOwner && changelogOwner.toLowerCase() === origin.owner.toLowerCase();
+ const repoMatches = changelogRepository && changelogRepository.toLowerCase() === origin.repository.toLowerCase();
+ ownerMatches
+ ? ok("cog.toml: changelog.owner matches origin")
+ : fail(`cog.toml: changelog.owner must match origin owner (${origin.owner})`);
+ repoMatches
+ ? ok("cog.toml: changelog.repository matches origin")
+ : fail(`cog.toml: changelog.repository must match origin repository (${origin.repository})`);
+ }
+ }
+ const changelogPath = path.join(ROOT, "CHANGELOG.md");
+ let changelog = "";
+ try { changelog = fs.readFileSync(changelogPath, "utf8"); } catch {}
+ changelog
+ ? ok("CHANGELOG.md: present")
+ : fail("CHANGELOG.md is required for cog bump changelog generation");
+ /^- - -\s*$/m.test(changelog)
+ ? ok("CHANGELOG.md: contains Cocogitto separator - - -")
+ : fail("CHANGELOG.md must contain Cocogitto separator line - - -");
}
const workflowPath = ".github/workflows/ci.yml";
const rulesetPath = ".github/rulesets/main.json";
-if (fs.existsSync(path.join(ROOT, workflowPath)) && fs.existsSync(path.join(ROOT, rulesetPath))) {
+if (fs.existsSync(path.join(ROOT, rulesetPath))) {
const jobs = workflowJobIds(workflowPath);
const required = rulesetRequiredChecks(rulesetPath);
- const missing = required.filter((ctx) => !jobs.includes(ctx));
- if (missing.length) fail(`ruleset required check(s) not published by CI workflow: ${missing.join(", ")} (jobs: ${jobs.join(", ") || "none"})`);
- else if (required.length) ok("ruleset required checks match CI workflow job ids");
+ if (required.length && !fs.existsSync(path.join(ROOT, workflowPath))) {
+ fail(`ruleset required check(s) cannot run because ${workflowPath} is missing: ${required.join(", ")}`);
+ } else if (fs.existsSync(path.join(ROOT, workflowPath))) {
+ const missing = required.filter((ctx) => !jobs.includes(ctx));
+ if (missing.length) fail(`ruleset required check(s) not published by CI workflow: ${missing.join(", ")} (jobs: ${jobs.join(", ") || "none"})`);
+ else if (required.length) {
+ ok("ruleset required checks match CI workflow job ids");
+ checkVerifyJobContract(workflowPath, required);
+ }
+ checkWorkflowSupplyChain(workflowPath);
+ }
+ checkRulesetPrReview(rulesetPath);
}
// report
@@ -191,8 +362,8 @@ if (process.argv.includes("--json")) {
console.log(JSON.stringify({ ok: fails === 0, results }));
} else {
console.log("harness doctor:");
- const icon = { PASS: "✓", WARN: "⚠", FAIL: "✗" };
+ const icon = { PASS: "OK", WARN: "WARN", FAIL: "FAIL" };
for (const r of results) console.log(" " + icon[r.level] + " " + r.msg);
- console.log(fails ? "\n❌ doctor: " + fails + " FAIL — почини перед работой." : "\n✅ doctor: окружение в порядке.");
+ console.log(fails ? "\ndoctor: " + fails + " FAIL - fix before work." : "\ndoctor: environment is ready.");
}
process.exit(fails ? 1 : 0);
diff --git a/hooks/new-mockups.js b/hooks/new-mockups.js
index e2784df..f758305 100644
--- a/hooks/new-mockups.js
+++ b/hooks/new-mockups.js
@@ -1,10 +1,10 @@
#!/usr/bin/env node
-// new-mockups.js — scaffold N stylistically-distinct single-file HTML mockups for a
+// new-mockups.js - scaffold N stylistically-distinct single-file HTML mockups for a
// GUI feature, satisfying the DESIGN stage (BACKLOG P1-5).
//
// 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
+// directions) + NOTES.md. Does NOT create APPROVED; you add that after picking a
// direction, which is what the gate checks.
const fs = require("fs");
@@ -22,7 +22,7 @@ const STYLES = [
function mockupHtml(feature, s) {
return `
-${feature} — ${s.name}
+${feature} - ${s.name}
-${feature}${s.name}MOCKUP · ${s.id}
+${feature}${s.name}MOCKUP - ${s.id}
]Panel A
-
Замени этот скелет реальным макетом экрана «${feature}» в стиле «${s.name}».
+
Replace this skeleton with the real ${feature} screen in the ${s.name} direction.
Panel B
-
Разные стили нужны, чтобы сравнить направления до кода, а не переписывать GUI потом.
+ Use distinct directions to compare the design before implementation.
`;
}
-const NOTES = (feature, styles) => `# Mockups — ${feature}
+const NOTES = (feature, styles) => `# Mockups - ${feature}
-DESIGN-стадия для GUI-фичи «${feature}». Правило: >=${styles.length} стилистически разных
-мокапа + approval **до** написания GUI-кода (BACKLOG P1-5).
+DESIGN stage for the GUI feature "${feature}". Rule: >=${styles.length} stylistically distinct
+mockups plus approval before GUI implementation.
-## Варианты
-${styles.map((s) => `- \`${s.id}.html\` — ${s.name}`).join("\n")}
+## Variants
+${styles.map((s) => `- \`${s.id}.html\` - ${s.name}`).join("\n")}
-## Как закрыть гейт
-1. Доведи мокапы до реальных экранов (это скелеты-заглушки).
-2. Обсуди/выбери направление с ревьюером.
-3. Создай пустой файл \`APPROVED\` в этом каталоге (его проверяет hooks/design-gate.js).
-4. Только после этого — реализация GUI.
+## 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.
`;
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(`❌ уже существует: design/mockups/${feature}/ — не перезаписываю.`); process.exit(1); }
+ if (fs.existsSync(dir)) { console.error(`already exists: design/mockups/${feature}/; not overwriting.`); process.exit(1); }
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(`✅ создано ${STYLES.length} мокапа: design/mockups/${feature}/`);
+ console.log(`created ${STYLES.length} mockups: design/mockups/${feature}/`);
STYLES.forEach((s) => console.log(` - ${s.id}.html (${s.name})`));
- console.log(`Дальше: доведи макеты → approval → создай design/mockups/${feature}/APPROVED`);
+ console.log(`Next: refine mockups, get approval, then create design/mockups/${feature}/APPROVED`);
}
main();
diff --git a/hooks/release-preflight.js b/hooks/release-preflight.js
new file mode 100644
index 0000000..bdeb547
--- /dev/null
+++ b/hooks/release-preflight.js
@@ -0,0 +1,272 @@
+#!/usr/bin/env node
+// release-preflight.js - executable release gate for the harness release loop.
+//
+// Checks the risky parts that are easy to miss by prose:
+// - clean worktree;
+// - release HEAD is based on the configured 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.
+//
+// Usage:
+// node hooks/release-preflight.js --tag v1.2.3 [--root ] [--base origin/main] [--json]
+// --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
+
+const fs = require("fs");
+const path = require("path");
+const { execFileSync } = require("child_process");
+
+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(),
+ base: "origin/main",
+ tag: "",
+ json: false,
+ allowDirty: false,
+ allowMissingTag: false,
+ allowRemoteTag: false,
+ };
+ 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] === "--tag") a.tag = argv[++i];
+ else if (argv[i] === "--json") a.json = true;
+ 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;
+ }
+ a.root = path.resolve(a.root);
+ return a;
+}
+
+function git(root, args) {
+ return execFileSync("git", args, {
+ cwd: root,
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ timeout: args[0] === "ls-remote" ? 60000 : 10000,
+ killSignal: "SIGKILL",
+ }).trim();
+}
+function gitOk(root, args) {
+ try { git(root, args); return true; } catch { return false; }
+}
+function gitOut(root, args) {
+ try { return git(root, args); } catch { return ""; }
+}
+function gitResult(root, args) {
+ try { return { ok: true, out: git(root, args) }; }
+ catch (e) { return { ok: false, out: String((e && e.stderr) || (e && e.message) || "") }; }
+}
+
+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 semverFromTag(tag) {
+ const m = String(tag || "").match(/^v(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)$/);
+ return m ? m[1] : "";
+}
+
+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 firstMatch(text, re) {
+ const m = re.exec(text);
+ return m ? m[1] : "";
+}
+
+function tomlSection(text, name) {
+ const lines = String(text || "").split(/\r?\n/);
+ const out = [];
+ let inSection = false;
+ for (const line of lines) {
+ const sec = line.match(/^\s*\[([^\]]+)\]\s*$/);
+ if (sec) {
+ if (inSection) break;
+ inSection = sec[1] === name;
+ continue;
+ }
+ if (inSection) out.push(line);
+ }
+ return out.join("\n");
+}
+
+function packageJsonVersion(abs, rel) {
+ if (path.basename(rel) !== "package.json") return null;
+ try {
+ const j = JSON.parse(fs.readFileSync(abs, "utf8"));
+ return typeof j.version === "string" && j.version ? { rel, kind: "package.json", version: j.version } : null;
+ } catch { return null; }
+}
+
+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);
+ return version ? { rel, kind: "csproj", version } : null;
+}
+
+function cargoVersion(abs, rel) {
+ if (path.basename(rel) !== "Cargo.toml") return null;
+ let text = "";
+ try { text = fs.readFileSync(abs, "utf8"); } catch { return null; }
+ const version = firstMatch(tomlSection(text, "package"), /^\s*version\s*=\s*"([^"]+)"/m);
+ return version ? { rel, kind: "Cargo.toml", version } : null;
+}
+
+function pyprojectVersion(abs, rel) {
+ if (path.basename(rel) !== "pyproject.toml") return null;
+ let text = "";
+ try { text = fs.readFileSync(abs, "utf8"); } catch { return null; }
+ const version = firstMatch(tomlSection(text, "project"), /^\s*version\s*=\s*"([^"]+)"/m);
+ return version ? { rel, kind: "pyproject.toml", version } : null;
+}
+
+function collectVersions(root) {
+ const versions = [];
+ walk(root, (abs, rel) => {
+ const hit = csprojVersion(abs, rel) || packageJsonVersion(abs, rel) || cargoVersion(abs, rel) || pyprojectVersion(abs, rel);
+ if (hit) versions.push(hit);
+ });
+ return versions;
+}
+
+function checkGitState(a, res) {
+ if (!gitOk(a.root, ["rev-parse", "--is-inside-work-tree"])) {
+ fail(res, "not a git repository");
+ return;
+ }
+ pass(res, "git repository detected");
+
+ const dirty = gitOut(a.root, ["status", "--porcelain"]);
+ if (dirty && !a.allowDirty) fail(res, "worktree is dirty", { details: dirty.split(/\r?\n/).filter(Boolean).slice(0, 20) });
+ else if (dirty) warn(res, "worktree is dirty but allowed", { details: dirty.split(/\r?\n/).filter(Boolean).slice(0, 20) });
+ else pass(res, "worktree is clean");
+
+ if (a.base) {
+ if (!gitOk(a.root, ["rev-parse", "--verify", "--quiet", a.base])) {
+ fail(res, `base ref not found: ${a.base}`);
+ } else if (gitOk(a.root, ["merge-base", "--is-ancestor", a.base, "HEAD"])) {
+ pass(res, `HEAD is based on ${a.base}`);
+ } else {
+ fail(res, `HEAD is not based on ${a.base}`);
+ }
+ }
+}
+
+function checkTag(a, res, version) {
+ if (!a.tag) {
+ fail(res, "release tag is required; pass --tag vX.Y.Z");
+ return;
+ }
+ if (!version) {
+ fail(res, `tag must look like vX.Y.Z: ${a.tag}`);
+ return;
+ }
+ pass(res, `tag format ok: ${a.tag}`);
+
+ const head = gitOut(a.root, ["rev-parse", "HEAD"]);
+ const localTag = gitOut(a.root, ["rev-parse", "-q", "--verify", `refs/tags/${a.tag}^{}`]);
+ if (!localTag) {
+ if (a.allowMissingTag) warn(res, `local tag is missing but allowed: ${a.tag}`);
+ else fail(res, `local tag is missing: ${a.tag}`);
+ } else if (localTag !== head) {
+ fail(res, `local tag ${a.tag} does not point at HEAD`, { tag: localTag, head });
+ } else {
+ pass(res, `local tag ${a.tag} points at HEAD`);
+ }
+ const tagType = gitOut(a.root, ["cat-file", "-t", `refs/tags/${a.tag}`]);
+ if (!tagType) {
+ if (!a.allowMissingTag) fail(res, `cannot inspect local tag object: ${a.tag}`);
+ } else if (tagType !== "tag") {
+ fail(res, `local tag ${a.tag} must be annotated`, { type: tagType });
+ } else {
+ pass(res, `local tag ${a.tag} is annotated`);
+ }
+
+ const remote = gitResult(a.root, ["remote", "get-url", "origin"]);
+ if (!remote.ok || !remote.out) {
+ fail(res, "origin remote is not configured; cannot verify remote tag state");
+ return;
+ }
+ const remoteTag = gitResult(a.root, ["ls-remote", "--tags", "origin", `refs/tags/${a.tag}`]);
+ if (!remoteTag.ok) fail(res, `cannot verify remote tag state for ${a.tag}`);
+ else if (remoteTag.out && !a.allowRemoteTag) fail(res, `remote tag already exists: ${a.tag}`);
+ else if (remoteTag.out) warn(res, `remote tag already exists but allowed: ${a.tag}`);
+ else pass(res, `remote tag does not exist yet: ${a.tag}`);
+}
+
+function checkVersions(a, res, version) {
+ if (!version) return;
+ const versions = collectVersions(a.root);
+ if (!versions.length) {
+ warn(res, "no project version manifests found");
+ return;
+ }
+ const mismatches = versions.filter((v) => v.version !== version);
+ if (mismatches.length) {
+ fail(res, `project version(s) do not match ${a.tag}`, { expected: version, mismatches });
+ } else {
+ pass(res, `project version manifests match ${a.tag}`, { count: versions.length });
+ }
+}
+
+function checkChangelog(a, res) {
+ const p = path.join(a.root, "CHANGELOG.md");
+ let text = "";
+ try { text = fs.readFileSync(p, "utf8"); } catch { fail(res, "CHANGELOG.md not found"); return; }
+ if (text.includes(a.tag) || text.includes(semverFromTag(a.tag))) pass(res, "CHANGELOG.md mentions the release version");
+ else fail(res, "CHANGELOG.md does not mention the release version");
+}
+
+function main() {
+ const a = parseArgs(process.argv.slice(2));
+ const res = { ok: true, tag: a.tag, root: a.root, results: [] };
+ const version = semverFromTag(a.tag);
+
+ checkGitState(a, res);
+ checkTag(a, res, version);
+ checkVersions(a, res, version);
+ checkChangelog(a, res);
+
+ 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 preflight: ${a.tag || "(no tag)"}`);
+ for (const r of res.results) {
+ console.log(` ${icon[r.level]} ${r.msg}`);
+ if (r.mismatches) for (const m of r.mismatches) console.log(` ${m.rel}: ${m.version} (${m.kind})`);
+ if (r.details) for (const d of r.details) console.log(` ${d}`);
+ }
+ console.log(res.ok ? "\nrelease preflight passed." : "\nrelease preflight failed.");
+ }
+ process.exit(res.ok ? 0 : 1);
+}
+
+if (require.main === module) main();
diff --git a/hooks/verify-core.js b/hooks/verify-core.js
new file mode 100644
index 0000000..f1209d0
--- /dev/null
+++ b/hooks/verify-core.js
@@ -0,0 +1,244 @@
+// verify-core.js - side-effect-light VERIFY planning and audit policy.
+//
+// verify.js owns the CLI, process.exit calls, and command execution. This module
+// owns stack detection, changed-file target selection, and debug-audit policy so
+// those rules can be tested or reused without running external commands.
+
+const fs = require("fs");
+const path = require("path");
+const { spawnSync } = require("child_process");
+const { workingTreeChangedFiles, globToRe } = require(path.join(__dirname, "_lib.js"));
+
+function fileUnder(rel, file) {
+ const r = String(rel).replace(/\\/g, "/");
+ const f = String(file).replace(/\\/g, "/");
+ if (r === "." || r === "") return true;
+ return f === r || f.startsWith(r + "/");
+}
+
+const DEBUG_HARD = [
+ { ext: [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"], re: /(?:^|[^.\w])debugger\s*;/, what: "debugger statement" },
+ { ext: [".py"], re: /(?:^|[^.\w])breakpoint\s*\(\s*\)/, what: "breakpoint call" },
+ { ext: [".py"], re: /(?:^|[^.\w])i?pdb\.set_trace\s*\(/, what: "pdb set_trace" },
+ { ext: [".rs"], re: /(?:^|[^.\w])dbg!\s*\(/, what: "dbg macro" },
+ { ext: [".cs"], re: /Debugger\.Break\s*\(/, what: "Debugger Break" },
+];
+const DEBUG_SOFT = [
+ { ext: [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"], re: /(?:^|[^.\w])console\.(?:log|debug)\s*\(/, what: "console.log/debug" },
+ { ext: [".py"], re: /(?:^|[^.\w])print\s*\(/, what: "print()" },
+];
+
+function loadDebugAudit(root) {
+ try {
+ const cfg = JSON.parse(fs.readFileSync(path.join(root, "harness.config.json"), "utf8"));
+ const d = cfg.debugAudit || {};
+ return {
+ enabled: d.enabled !== false,
+ base: d.base || "main",
+ soft: d.soft === true,
+ exclude: Array.isArray(d.exclude) ? d.exclude : [],
+ strict: d.strict === true,
+ };
+ } catch {
+ return { enabled: true, base: "main", soft: false, exclude: [], strict: false };
+ }
+}
+
+function maskQuotedSegments(line) {
+ let out = "", quote = null, esc = false;
+ for (let i = 0; i < line.length; i++) {
+ const ch = line[i];
+ if (quote) {
+ if (esc) { esc = false; out += " "; continue; }
+ if (ch === "\\") { esc = true; out += " "; continue; }
+ if (ch === quote) { quote = null; out += ch; continue; }
+ out += " ";
+ continue;
+ }
+ if (ch === "\"" || ch === "'" || ch === "`") { quote = ch; out += ch; continue; }
+ out += ch;
+ }
+ return out;
+}
+
+function stripCommentTail(line, ext) {
+ if ([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".rs", ".cs"].includes(ext)) return line.replace(/\/\/.*$/, "");
+ if (ext === ".py") return line.replace(/#.*$/, "");
+ return line;
+}
+
+function scanFileForDebug(abs, rel, soft) {
+ let text;
+ try {
+ const st = fs.statSync(abs);
+ if (!st.isFile() || st.size > 1024 * 1024) return [];
+ text = fs.readFileSync(abs, "utf8");
+ } catch { return []; }
+ if (text.includes(String.fromCharCode(0))) return [];
+ const ext = path.extname(rel).toLowerCase();
+ const softSet = new Set(DEBUG_SOFT);
+ const markers = (soft ? DEBUG_HARD.concat(DEBUG_SOFT) : DEBUG_HARD).filter((m) => m.ext.includes(ext));
+ if (!markers.length) return [];
+ const hits = [];
+ const lines = text.split(/\r?\n/);
+ for (let i = 0; i < lines.length; i++) {
+ const line = stripCommentTail(maskQuotedSegments(lines[i]), ext);
+ for (const m of markers) if (m.re.test(line)) hits.push({ rel, line: i + 1, what: m.what, soft: softSet.has(m) });
+ }
+ return hits;
+}
+
+function debugAudit(root, opts, base, explicitFiles) {
+ if (!opts.enabled) return { hard: [], soft: [], skipped: "disabled in harness.config.json" };
+ const cf = workingTreeChangedFiles(base || opts.base, root, explicitFiles);
+ if (cf.error) return { hard: [], soft: [], skipped: cf.error };
+ const excludeRe = (opts.exclude || []).map(globToRe);
+ const hard = [], soft = [];
+ for (const f of cf.files) {
+ const rel = String(f).replace(/\\/g, "/");
+ if (excludeRe.some((re) => re.test(rel))) continue;
+ for (const h of scanFileForDebug(path.join(root, rel), rel, opts.soft)) (h.soft ? soft : hard).push(h);
+ }
+ return { hard, soft, skipped: null };
+}
+
+const DEFAULT_STACKS = [
+ { id: "rust", markers: ["Cargo.toml"], steps: [
+ { name: "fmt", run: "cargo fmt --all --check" },
+ { name: "clippy", run: "cargo clippy --all-targets --all-features -- -D warnings" },
+ { name: "test", run: "cargo test --all" },
+ ] },
+ { id: "dotnet", markers: ["*.sln", "*.csproj"], steps: [
+ { name: "format", run: "dotnet format --verify-no-changes", optional: true },
+ { name: "build", run: "dotnet build --nologo -warnaserror" },
+ { name: "test", run: "dotnet test --nologo" },
+ ] },
+ { id: "python", markers: ["pyproject.toml", "requirements.txt", "setup.py"], steps: [
+ { name: "lint", run: "ruff check ." },
+ { name: "format", run: "ruff format --check .", optional: true },
+ { name: "test", run: "pytest -q", okCodes: { 5: "pytest: no tests collected; add at least a smoke test" } },
+ ] },
+ { id: "node", markers: ["package.json"], steps: [
+ { name: "lint", run: "npm run lint --if-present" },
+ { name: "build", run: "npm run build --if-present" },
+ { name: "test", run: "npm test --if-present" },
+ ] },
+];
+
+const SKIP_DIRS = new Set([".git", ".codex", "node_modules", "target", "bin", "obj", "dist", "build", ".venv", "venv", "__pycache__", ".next", ".idea", ".vscode"]);
+const MAX_DEPTH = 6;
+const HARNESS_CHANGED = [
+ "hooks/", "lefthook.yml", "harness.config.json", ".gitleaks.toml", "cog.toml",
+ ".github/rulesets/", ".github/workflows/", "settings.example.json", "AGENTS.md",
+];
+
+function loadStacks(root) {
+ try {
+ const cfg = JSON.parse(fs.readFileSync(path.join(root, "harness.config.json"), "utf8"));
+ if (cfg.verify && Array.isArray(cfg.verify.stacks) && cfg.verify.stacks.length) {
+ return { stacks: cfg.verify.stacks, failFast: cfg.verify.failFast !== false, explicit: true };
+ }
+ return { stacks: DEFAULT_STACKS, failFast: !(cfg.verify && cfg.verify.failFast === false), explicit: false };
+ } catch {
+ return { stacks: DEFAULT_STACKS, failFast: true, explicit: false };
+ }
+}
+
+function isHarnessChangedFile(file) {
+ const f = String(file || "").replace(/\\/g, "/");
+ return HARNESS_CHANGED.some((p) => p.endsWith("/") ? f.startsWith(p) : f === p);
+}
+
+function harnessTarget(root) {
+ const steps = [];
+ if (fs.existsSync(path.join(root, "hooks", "test.js"))) steps.push({ name: "self-test", run: "node test.js", cwdRel: "hooks" });
+ return { stack: { id: "harness", steps }, dir: root, rel: "." };
+}
+
+function harnessSyntaxTarget(root) {
+ return { stack: { id: "harness-syntax", steps: [{ name: "syntax", run: "node hooks/verify.js --check-harness-syntax" }] }, dir: root, rel: "." };
+}
+
+function ensureHarnessSyntaxTarget(root, targets) {
+ if (!fs.existsSync(path.join(root, "hooks", "verify.js"))) return targets;
+ if (targets.some((t) => t.stack && t.stack.id === "harness-syntax")) return targets;
+ return targets.concat([harnessSyntaxTarget(root)]);
+}
+
+function isGitRepo(root) {
+ const r = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
+ cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
+ timeout: 5000, killSignal: "SIGKILL",
+ });
+ return r.status === 0 && String(r.stdout || "").trim() === "true";
+}
+
+function ensureGitHygieneTarget(root, targets) {
+ if (!isGitRepo(root)) return targets;
+ if (targets.some((t) => t.stack && t.stack.id === "git-hygiene")) return targets;
+ return targets.concat([{ stack: { id: "git-hygiene", steps: [
+ { name: "diff-check", run: "git diff --check && git diff --cached --check" },
+ ] }, dir: root, rel: "." }]);
+}
+
+function maybeAddHarnessTarget(root, targets, files) {
+ if (!files.some(isHarnessChangedFile)) return targets;
+ if (!fs.existsSync(path.join(root, "hooks", "verify.js"))) return targets;
+ targets = ensureHarnessSyntaxTarget(root, targets);
+ if (!fs.existsSync(path.join(root, "hooks", "test.js"))) return targets;
+ if (targets.some((t) => t.stack && t.stack.id === "harness")) return targets;
+ return targets.concat([harnessTarget(root)]);
+}
+
+function fnMatch(pattern, name) {
+ if (pattern === name) return true;
+ const re = "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*") + "$";
+ return new RegExp(re).test(name);
+}
+
+function detect(root, stacks) {
+ const found = [];
+ function walk(dir, depth) {
+ let entries;
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
+ const names = entries.filter((e) => e.isFile()).map((e) => e.name);
+ for (const s of stacks) {
+ const markers = s.markers || [];
+ if (markers.some((m) => names.some((n) => fnMatch(m, n)))) {
+ found.push({ stack: s, dir, rel: (path.relative(root, dir) || ".").replace(/\\/g, "/") });
+ }
+ }
+ if (depth >= MAX_DEPTH) return;
+ for (const e of entries) if (e.isDirectory() && !SKIP_DIRS.has(e.name)) walk(path.join(dir, e.name), depth + 1);
+ }
+ walk(root, 0);
+ return found;
+}
+
+function planVerifyTargets(root, stacks, opts = {}) {
+ let targets = detect(root, stacks);
+ if (opts.changed) {
+ const cf = workingTreeChangedFiles(opts.base, root, opts.files);
+ if (cf.error) {
+ return {
+ targets: maybeAddHarnessTarget(root, targets, ["hooks/verify.js"]),
+ scope: cf,
+ warning: `verify --changed: ${cf.error}; filter not applied, checking all detected stacks. Pass --base [ to choose a base.`,
+ };
+ }
+ const files = cf.files.map((f) => String(f).replace(/\\/g, "/"));
+ targets = files.length ? targets.filter((t) => files.some((f) => fileUnder(t.rel, f))) : [];
+ targets = maybeAddHarnessTarget(root, targets, files);
+ return { targets, scope: cf, files };
+ }
+ targets = ensureHarnessSyntaxTarget(root, targets);
+ targets = ensureGitHygieneTarget(root, targets);
+ return { targets, scope: null, files: null };
+}
+
+module.exports = {
+ DEBUG_HARD, DEBUG_SOFT, DEFAULT_STACKS,
+ fileUnder, loadDebugAudit, scanFileForDebug, debugAudit,
+ loadStacks, detect, planVerifyTargets,
+ ensureHarnessSyntaxTarget, ensureGitHygieneTarget, maybeAddHarnessTarget,
+};
diff --git a/hooks/verify.js b/hooks/verify.js
index d40b8a1..5e6fdaf 100644
--- a/hooks/verify.js
+++ b/hooks/verify.js
@@ -1,5 +1,5 @@
#!/usr/bin/env node
-// verify.js — executable, multi-stack VERIFY runner (BACKLOG P1-8).
+// verify.js - executable, multi-stack VERIFY runner (BACKLOG P1-8).
//
// Makes AGENTS.md step 4 (VERIFY) an actual command instead of prose. Auto-detects
// which stacks live in the repo by marker files and runs lint -> build -> test for
@@ -9,7 +9,7 @@
// Node (npm lint/build/test).
//
// Usage:
-// node hooks/verify.js [--root ] [--stack ] [--changed [--base ][]] [--list] [--json]
+// node hooks/verify.js [--root ] [--stack ] [--changed [--base ][]] [--list] [--json] [--strict-audit]
// --list detect + print the plan, do not run
// --stack run only the named stack
// --root repo root (default: cwd)
@@ -17,150 +17,32 @@
// --base git ref to diff against for --changed (default: main; fallback master/origin/HEAD)
// --files explicit comma-separated changed files (tests/CI; bypasses git)
// --check-harness-syntax internal lightweight JS syntax check for harness files
+// --strict-audit fail VERIFY if the debug-audit cannot compute its changed-file scope
// --changed fail-safe: if the diff can't be computed, verify ALL stacks (loud warn),
// never silently skip verification; empty diff = nothing to verify.
//
// Config: harness.config.json -> "verify". If "verify.stacks" is present it REPLACES
// the auto-detected defaults (explicit control); otherwise DEFAULT_STACKS are used.
-// Per-step: optional (missing tool → skip), okCodes { "": "note" } — non-zero
-// exits that are warnings, not failures (e.g. pytest 5 = no tests collected).
+// Per-step: optional (missing tool -> skip only), okCodes { "": "note" }
+// - explicit non-zero exits that are warnings, not failures (e.g. pytest 5 = no tests collected).
//
-// Debug-аудит: параллельно со стеками verify сканирует ТОЛЬКО изменённые в diff
-// файлы на забытые отладочные строки. hard-маркеры (debugger; / breakpoint() /
-// pdb.set_trace() / dbg!()) валят VERIFY; soft (console.log / print) — заметка,
-// включается harness.config.json -> debugAudit.soft. Область — diff (база = --base
-// или debugAudit.base); без diff аудит пропускается (не сканируем весь репо, иначе
-// массовые легитимные console.log/print дали бы шум). exclude-глобы — пропуск путей.
+// Debug audit: runs alongside stack checks and scans only changed files for
+// leftover debug statements. Hard markers fail VERIFY; soft markers are notes
+// only when harness.config.json -> debugAudit.soft is true.
//
// Exit 0 = all steps passed (or nothing to verify), exit 1 = a required step failed
-// ИЛИ debug-аудит нашёл hard-находку.
+// or the debug audit found a hard marker.
const fs = require("fs");
const os = require("os");
const path = require("path");
const { spawnSync } = require("child_process");
-const { workingTreeChangedFiles, globToRe } = require(path.join(__dirname, "_lib.js"));
-
-// Файл `file` лежит под каталогом стека `rel`? Корневой стек (rel ".") владеет всем
-// деревом → матчит любой изменённый файл; глубокий стек — только свой подкаталог.
-function fileUnder(rel, file) {
- const r = String(rel).replace(/\\/g, "/");
- const f = String(file).replace(/\\/g, "/");
- if (r === "." || r === "") return true;
- return f === r || f.startsWith(r + "/");
-}
-
-// ---------- debug-leftover audit (только изменённые файлы) ----------
-// Маркеры «никогда не коммить». Точные по синтаксису (требуют `;`/`()`), поэтому
-// строковые определения ниже и regex-литералы НЕ матчат сами себя (self-FP).
-// `what` намеренно без реального синтаксиса (без точки/скобок) — та же причина.
-// Привязка к расширениям: `breakpoint()`/`set_trace` — только .py, `dbg!` — только
-// .rs; иначе слово-омоним в чужом языке дал бы ложную тревогу.
-const DEBUG_HARD = [
- { ext: [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"], re: /(?:^|[^.\w])debugger\s*;/, what: "debugger statement" },
- { ext: [".py"], re: /(?:^|[^.\w])breakpoint\s*\(\s*\)/, what: "breakpoint call" },
- { ext: [".py"], re: /(?:^|[^.\w])i?pdb\.set_trace\s*\(/, what: "pdb set_trace" },
- { ext: [".rs"], re: /(?:^|[^.\w])dbg!\s*\(/, what: "dbg macro" },
- { ext: [".cs"], re: /Debugger\.Break\s*\(/, what: "Debugger Break" },
-];
-const DEBUG_SOFT = [
- { ext: [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"], re: /(?:^|[^.\w])console\.(?:log|debug)\s*\(/, what: "console.log/debug" },
- { ext: [".py"], re: /(?:^|[^.\w])print\s*\(/, what: "print()" },
-];
-
-// harness.config.json → debugAudit (дефолты: включён, база main, soft off, без exclude).
-function loadDebugAudit(root) {
- try {
- const cfg = JSON.parse(fs.readFileSync(path.join(root, "harness.config.json"), "utf8"));
- const d = cfg.debugAudit || {};
- return {
- enabled: d.enabled !== false,
- base: d.base || "main",
- soft: d.soft === true,
- exclude: Array.isArray(d.exclude) ? d.exclude : [],
- };
- } catch {
- return { enabled: true, base: "main", soft: false, exclude: [] };
- }
-}
-
-// Один файл → массив находок. Бинарь/крупный/нечитаемый → пропуск (не ошибка).
-function scanFileForDebug(abs, rel, soft) {
- let text;
- try {
- const st = fs.statSync(abs);
- if (!st.isFile() || st.size > 1024 * 1024) return [];
- text = fs.readFileSync(abs, "utf8");
- } catch { return []; }
- if (text.includes(String.fromCharCode(0))) return [];
- const ext = path.extname(rel).toLowerCase();
- const softSet = new Set(DEBUG_SOFT);
- const markers = (soft ? DEBUG_HARD.concat(DEBUG_SOFT) : DEBUG_HARD).filter((m) => m.ext.includes(ext));
- if (!markers.length) return [];
- const hits = [];
- const lines = text.split(/\r?\n/);
- for (let i = 0; i < lines.length; i++) {
- for (const m of markers) if (m.re.test(lines[i])) hits.push({ rel, line: i + 1, what: m.what, soft: softSet.has(m) });
- }
- return hits;
-}
-
-// Аудит изменённых файлов. Область — ТОЛЬКО diff (в отличие от стеков, где fail-safe
-// = проверить всё): без diff НЕ сканируем весь репозиторий, т.к. console.log/print
-// массово легитимны и дали бы шум. → { hard:[], soft:[], skipped:<причина>|null }.
-function debugAudit(root, opts, base, explicitFiles) {
- if (!opts.enabled) return { hard: [], soft: [], skipped: "отключён в harness.config.json" };
- const cf = workingTreeChangedFiles(base || opts.base, root, explicitFiles);
- if (cf.error) return { hard: [], soft: [], skipped: cf.error };
- const excludeRe = (opts.exclude || []).map(globToRe);
- const hard = [], soft = [];
- for (const f of cf.files) {
- const rel = String(f).replace(/\\/g, "/");
- if (excludeRe.some((re) => re.test(rel))) continue;
- for (const h of scanFileForDebug(path.join(root, rel), rel, opts.soft)) (h.soft ? soft : hard).push(h);
- }
- return { hard, soft, skipped: null };
-}
-
-// ---------- defaults (warnings-as-errors baked in) ----------
-const DEFAULT_STACKS = [
- { id: "rust", markers: ["Cargo.toml"], steps: [
- { name: "fmt", run: "cargo fmt --all --check" },
- { name: "clippy", run: "cargo clippy --all-targets --all-features -- -D warnings" },
- { name: "test", run: "cargo test --all" },
- ] },
- { id: "dotnet", markers: ["*.sln", "*.csproj"], steps: [
- // Existing C# repos often need a one-time formatting migration. Keep the
- // default bootstrap gate warning-only; target repos can make this required
- // by overriding verify.stacks after the format baseline lands.
- { name: "format", run: "dotnet format --verify-no-changes", optional: true },
- { name: "build", run: "dotnet build --nologo -warnaserror" },
- { name: "test", run: "dotnet test --nologo" },
- ] },
- { id: "python", markers: ["pyproject.toml", "requirements.txt", "setup.py"], steps: [
- { name: "lint", run: "ruff check ." },
- { name: "format", run: "ruff format --check .", optional: true },
- // pytest exit 5 = «тесты не собраны»: проект без тестов — warning, не вечно-красный VERIFY
- { name: "test", run: "pytest -q", okCodes: { 5: "pytest: тесты не найдены — добавь хотя бы smoke-тест" } },
- ] },
- { id: "node", markers: ["package.json"], steps: [
- { name: "lint", run: "npm run lint --if-present" },
- { name: "build", run: "npm run build --if-present" },
- { name: "test", run: "npm test --if-present" },
- ] },
-];
-
-const SKIP_DIRS = new Set([".git", "node_modules", "target", "bin", "obj", "dist", "build", ".venv", "venv", "__pycache__", ".next", ".idea", ".vscode"]);
-const MAX_DEPTH = 6;
+const { loadDebugAudit, debugAudit, loadStacks, planVerifyTargets } = require(path.join(__dirname, "verify-core.js"));
const DEFAULT_STEP_TIMEOUT_MS = 15 * 60 * 1000;
-const HARNESS_CHANGED = [
- "hooks/", "lefthook.yml", "harness.config.json", ".gitleaks.toml", "cog.toml",
- ".github/rulesets/", ".github/workflows/", "settings.example.json", "AGENTS.md",
-];
// ---------- args ----------
function parseArgs(argv) {
- const a = { root: process.cwd(), stack: null, list: false, json: false, changed: false, base: "main", files: null, checkHarnessSyntax: false };
+ const a = { root: process.cwd(), stack: null, list: false, json: false, changed: false, base: "main", files: null, checkHarnessSyntax: false, strictAudit: false };
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--root") a.root = argv[++i];
else if (argv[i] === "--stack") a.stack = argv[++i];
@@ -170,41 +52,11 @@ function parseArgs(argv) {
else if (argv[i] === "--base") a.base = argv[++i];
else if (argv[i] === "--files") a.files = (argv[++i] || "").split(",").map((s) => s.trim()).filter(Boolean);
else if (argv[i] === "--check-harness-syntax") a.checkHarnessSyntax = true;
+ else if (argv[i] === "--strict-audit") a.strictAudit = true;
}
return a;
}
-// ---------- config ----------
-function loadStacks(root) {
- try {
- const cfg = JSON.parse(fs.readFileSync(path.join(root, "harness.config.json"), "utf8"));
- if (cfg.verify && Array.isArray(cfg.verify.stacks) && cfg.verify.stacks.length) {
- return { stacks: cfg.verify.stacks, failFast: cfg.verify.failFast !== false, explicit: true };
- }
- return { stacks: DEFAULT_STACKS, failFast: !(cfg.verify && cfg.verify.failFast === false), explicit: false };
- } catch {
- return { stacks: DEFAULT_STACKS, failFast: true, explicit: false };
- }
-}
-
-function isHarnessChangedFile(file) {
- const f = String(file || "").replace(/\\/g, "/");
- return HARNESS_CHANGED.some((p) => p.endsWith("/") ? f.startsWith(p) : f === p);
-}
-
-function harnessTarget(root) {
- const steps = [{ name: "syntax", run: "node hooks/verify.js --check-harness-syntax" }];
- if (fs.existsSync(path.join(root, "hooks", "test.js"))) steps.push({ name: "self-test", run: "node test.js", cwdRel: "hooks" });
- return { stack: { id: "harness", steps }, dir: root, rel: "." };
-}
-
-function maybeAddHarnessTarget(root, targets, files) {
- if (!files.some(isHarnessChangedFile)) return targets;
- if (!fs.existsSync(path.join(root, "hooks", "verify.js"))) return targets;
- if (targets.some((t) => t.stack && t.stack.id === "harness")) return targets;
- return targets.concat([harnessTarget(root)]);
-}
-
function listHarnessJs(root) {
const out = [];
function walk(dir) {
@@ -243,33 +95,6 @@ function checkHarnessSyntax(root) {
process.exit(failed ? 1 : 0);
}
-// ---------- filename glob (within a directory) ----------
-function fnMatch(pattern, name) {
- if (pattern === name) return true;
- const re = "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*") + "$";
- return new RegExp(re).test(name);
-}
-
-// ---------- detect: dirs that contain a stack's marker ----------
-function detect(root, stacks) {
- const found = []; // {stack, dir(abs), rel}
- function walk(dir, depth) {
- let entries;
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
- const names = entries.filter((e) => e.isFile()).map((e) => e.name);
- for (const s of stacks) {
- const markers = s.markers || [];
- if (markers.some((m) => names.some((n) => fnMatch(m, n)))) {
- found.push({ stack: s, dir, rel: path.relative(root, dir) || "." });
- }
- }
- if (depth >= MAX_DEPTH) return;
- for (const e of entries) if (e.isDirectory() && !SKIP_DIRS.has(e.name)) walk(path.join(dir, e.name), depth + 1);
- }
- walk(root, 0);
- return found;
-}
-
// ---------- run ----------
function stepTimeoutMs(step) {
const raw = step.timeoutMs !== undefined ? step.timeoutMs : step.timeout;
@@ -326,6 +151,14 @@ function diagnosticExcerpt(res, maxLines = 8) {
const lines = text.split(/\r?\n/).map((s) => s.trimEnd()).filter((s) => s.trim()).slice(0, maxLines);
return lines.join("\n");
}
+function commandNotFound(res) {
+ if (res.notFound || res.code === 9009 || res.code === 127) return true;
+ const text = diagnosticExcerpt(res, 8).toLowerCase();
+ return /is not recognized as an internal or external command/.test(text) ||
+ /is not recognized as the name of (?:a )?(?:cmdlet|function|script file|operable program)/.test(text) ||
+ /command not found/.test(text) ||
+ /not found:/.test(text);
+}
function readSmallOutputFile(file, maxBytes = 64 * 1024) {
if (!file) return "";
try {
@@ -356,53 +189,41 @@ function cleanupStepOutput(dir) {
let { stacks, failFast, explicit } = loadStacks(a.root);
if (a.stack) stacks = stacks.filter((s) => s.id === a.stack);
- let targets = detect(a.root, stacks);
-
- // --changed: сузить до стеков, чьи каталоги затронуты в diff ветки. Fail-safe —
- // при ошибке diff проверяем ВСЕ стеки (громкий warn), а не молча пропускаем.
- if (a.changed) {
- const cf = workingTreeChangedFiles(a.base, a.root, a.files);
- if (cf.error) {
- console.error(`⚠️ verify --changed: ${cf.error} — фильтр не применён, проверяю все обнаруженные стеки. Задай базу: --base ][.`);
- targets = maybeAddHarnessTarget(a.root, targets, ["hooks/verify.js"]);
- } else {
- const files = cf.files.map((f) => f.replace(/\\/g, "/"));
- targets = files.length ? targets.filter((t) => files.some((f) => fileUnder(t.rel, f))) : [];
- targets = maybeAddHarnessTarget(a.root, targets, files);
- }
- }
+ const targetPlan = planVerifyTargets(a.root, stacks, { changed: a.changed, base: a.base, files: a.files });
+ if (targetPlan.warning) console.error(targetPlan.warning);
+ const targets = targetPlan.targets;
if (a.list) {
const plan = targets.map((t) => ({ stack: t.stack.id, dir: t.rel, steps: (t.stack.steps || []).map((s) => s.name) }));
if (a.json) console.log(JSON.stringify({ explicit, plan }));
else {
console.log(`design of VERIFY (${explicit ? "config" : "auto-detect"}):`);
- if (!plan.length) console.log(" (стеки не обнаружены)");
- for (const p of plan) console.log(` • ${p.stack} @ ${p.dir}: ${p.steps.join(" → ")}`);
+ if (!plan.length) console.log(" (no stacks detected)");
+ for (const p of plan) console.log(` - ${p.stack} @ ${p.dir}: ${p.steps.join(" -> ")}`);
}
process.exit(0);
}
- // debug-аудит изменённых файлов: до раннего выхода — работает даже без стеков.
- // hard-находки валят VERIFY; soft (только при soft=true) — заметка без падения.
+ // Debug audit runs before the early no-target exit, so it still works without stacks.
const da = loadDebugAudit(a.root);
const audit = debugAudit(a.root, da, a.base, a.files);
let auditFailed = null;
if (audit.skipped) {
- if (da.enabled) console.log(`\n· debug-аудит пропущен: ${audit.skipped}`);
+ if (da.enabled) console.log(`\n- debug audit skipped: ${audit.skipped}`);
+ if (da.enabled && (a.strictAudit || da.strict)) auditFailed = `debug audit strict: ${audit.skipped}`;
} else {
- for (const h of audit.soft) console.log(` ⚠ debug-строка (soft): ${h.rel}:${h.line} — ${h.what}`);
+ for (const h of audit.soft) console.log(` debug line (soft): ${h.rel}:${h.line} - ${h.what}`);
if (audit.hard.length) {
- console.error("\n❌ debug-аудит — забытые отладочные строки в изменённых файлах:");
- for (const h of audit.hard) console.error(` ✗ ${h.rel}:${h.line} — ${h.what}`);
- auditFailed = `debug-аудит: ${audit.hard.length} hard-находок (debugger/breakpoint/set_trace/dbg!)`;
+ console.error("\ndebug audit: leftover debug statements in changed files:");
+ for (const h of audit.hard) console.error(` - ${h.rel}:${h.line} - ${h.what}`);
+ auditFailed = `debug audit: ${audit.hard.length} hard finding(s)`;
}
}
if (!targets.length && !auditFailed) {
console.log(a.changed
- ? "✅ verify --changed: изменённые файлы не затрагивают ни один стек — проверять нечего."
- : "✅ verify: стеки не обнаружены — проверять нечего.");
+ ? "verify --changed: changed files do not touch any detected stack; nothing to check."
+ : "verify: no stacks detected; nothing to check.");
process.exit(0);
}
@@ -413,42 +234,38 @@ function cleanupStepOutput(dir) {
for (const t of targets) {
for (const step of t.stack.steps || []) {
const label = `${t.stack.id}/${step.name} @ ${t.rel}`;
- console.log(`\n▶ ${label}: ${step.run}`);
+ console.log(`\n> ${label}: ${step.run}`);
const res = runStep(step, t.dir);
try {
- if (res.ok) { emitStepOutput(res); summary.push(`✓ ${label}`); continue; }
+ if (res.ok) { emitStepOutput(res); summary.push(`OK ${label}`); continue; }
if (res.timedOut) {
- summary.push(`✗ ${label} (timeout after ${stepTimeoutMs(step)}ms)`);
+ summary.push(`FAIL ${label} (timeout after ${stepTimeoutMs(step)}ms)`);
emitStepOutput(res);
failed = `${label}: timeout after ${stepTimeoutMs(step)}ms`;
if (failFast) break outer; else continue;
}
- if (res.notFound || res.code === 9009 || res.code === 127) {
+ if (commandNotFound(res)) {
if (step.optional) {
- warnings.push(`${label}: optional tool not found — step skipped`);
- summary.push(`⚠ ${label} (инструмент не найден — пропущено)`);
+ warnings.push(`${label}: optional tool not found - step skipped`);
+ summary.push(`${label} (optional tool not found; skipped)`);
continue;
}
- summary.push(`✗ ${label} (инструмент не найден)`);
+ summary.push(`${label} (tool not found)`);
emitStepOutput(res);
- failed = `${label}: команда не найдена — установи инструмент или переопредели шаг в harness.config.json`;
+ failed = `${label}: command not found; install the tool or override the step in harness.config.json`;
if (failFast) break outer; else continue;
}
if (step.okCodes && step.okCodes[res.code] !== undefined) {
const detail = diagnosticExcerpt(res);
- warnings.push(`${label}: exit ${res.code}: ${step.okCodes[res.code] || "допустимый код"}${detail ? "\n" + detail : ""}`);
- summary.push(`⚠ ${label} (exit ${res.code}: ${step.okCodes[res.code] || "допустимый код"})`);
- continue;
- }
- if (step.optional) {
- const detail = diagnosticExcerpt(res);
- warnings.push(`${label}: optional step exited ${res.code}${detail ? "\n" + detail : "\n(no diagnostics captured)"}`);
- summary.push(`⚠ ${label} (optional, exit ${res.code})`);
+ warnings.push(`${label}: exit ${res.code}: ${step.okCodes[res.code] || "allowed code"}${detail ? "\n" + detail : ""}`);
+ summary.push(`${label} (exit ${res.code}: ${step.okCodes[res.code] || "allowed code"})`);
continue;
}
- summary.push(`✗ ${label} (exit ${res.code})`);
+ summary.push(`FAIL ${label} (exit ${res.code})`);
emitStepOutput(res);
- failed = `${label}: exit ${res.code}`;
+ failed = step.optional
+ ? `${label}: optional step ran but failed with exit ${res.code}`
+ : `${label}: exit ${res.code}`;
if (failFast) break outer;
} finally {
if (res.cleanup) res.cleanup();
@@ -457,17 +274,17 @@ function cleanupStepOutput(dir) {
}
if (warnings.length) {
- console.log("\n— optional warnings —");
- for (const w of warnings) console.log(" ⚠ " + w.replace(/\n/g, "\n "));
+ console.log("\n-- optional warnings --");
+ for (const w of warnings) console.log(" WARN " + w.replace(/\n/g, "\n "));
}
if (summary.length) {
- console.log("\n— verify summary —");
+ console.log("\n-- verify summary --");
for (const s of summary) console.log(" " + s);
}
if (failed || auditFailed) {
- console.error(`\n❌ VERIFY failed: ${[failed, auditFailed].filter(Boolean).join(" | ")}`);
+ console.error(`\nVERIFY failed: ${[failed, auditFailed].filter(Boolean).join(" | ")}`);
process.exit(1);
}
- console.log("\n✅ VERIFY passed.");
+ console.log("\nVERIFY passed.");
process.exit(0);
})();
diff --git a/settings.example.json b/settings.example.json
index 7a30395..8472b87 100644
--- a/settings.example.json
+++ b/settings.example.json
@@ -4,7 +4,7 @@
"Any runtime that can run a command before a tool call and honor its exit code",
"can wire the same scripts: exit 2 = block, exit 0 = allow,",
"stdout {\"additionalContext\":\"...\"} = non-blocking note.",
- "The git layer is NOT wired here — install it once with `lefthook install`.",
+ "The git layer is NOT wired here - install it once with `lefthook install`.",
"Copy the `hooks` block into .claude/settings.json (project) to activate."
],
"hooks": {
diff --git a/src/Dropwheel/Dropwheel.csproj b/src/Dropwheel/Dropwheel.csproj
index 8ef8fdb..b30f66f 100644
--- a/src/Dropwheel/Dropwheel.csproj
+++ b/src/Dropwheel/Dropwheel.csproj
@@ -11,7 +11,7 @@
Dropwheel
app.manifest
dropwheel.ico
- 0.11.0
+ 0.12.0
$(NoWarn);WPF0001
diff --git a/src/Dropwheel/Models/TargetItem.cs b/src/Dropwheel/Models/TargetItem.cs
index 76815d3..86394dc 100644
--- a/src/Dropwheel/Models/TargetItem.cs
+++ b/src/Dropwheel/Models/TargetItem.cs
@@ -9,6 +9,13 @@ public class TargetItem
{
public string Name { get; set; } = "";
public string Path { get; set; } = "";
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? SourceUrl { get; set; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? IconPath { get; set; }
+
public DropAction Override { get; set; } = DropAction.Inherit;
public bool Pinned { get; set; }
@@ -47,6 +54,12 @@ public class TargetItem
public static bool IsExeExtension(string path) =>
ExeExtensions.Contains(System.IO.Path.GetExtension(path).ToLowerInvariant());
+ public static bool IsLaunchUri(string path) =>
+ Uri.TryCreate(path, UriKind.Absolute, out var uri)
+ && (uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
+ || uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)
+ || uri.Scheme.Equals("tg", StringComparison.OrdinalIgnoreCase));
+
[JsonIgnore] public bool IsGroup => Children != null;
[JsonIgnore] public bool IsSorter => SortRules is { Count: > 0 } || Rules is { Count: > 0 };
[JsonIgnore] public bool IsFolder => !IsGroup && Directory.Exists(Path);
@@ -54,8 +67,9 @@ public static bool IsExeExtension(string path) =>
/// An executable or script by its own extension. A .lnk that points at an executable
/// is handled by LaunchService.IsRunTarget, which resolves the shortcut first.
[JsonIgnore] public bool IsExecutable => !IsGroup && IsExeExtension(Path);
+ [JsonIgnore] public bool IsUri => !IsGroup && IsLaunchUri(Path);
- [JsonIgnore] public bool Exists => IsGroup || IsFolder || File.Exists(Path);
+ [JsonIgnore] public bool Exists => IsGroup || IsFolder || File.Exists(Path) || IsUri;
}
public sealed class LaunchOptions
diff --git a/src/Dropwheel/Services/IconService.cs b/src/Dropwheel/Services/IconService.cs
index 7b6c873..ef64b4f 100644
--- a/src/Dropwheel/Services/IconService.cs
+++ b/src/Dropwheel/Services/IconService.cs
@@ -3,6 +3,7 @@
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Imaging;
+using Dropwheel.Models;
namespace Dropwheel.Services;
@@ -28,15 +29,40 @@ private struct SHFILEINFO
private static readonly Dictionary _cache = new();
+ public static ImageSource? GetIcon(TargetItem target) =>
+ !string.IsNullOrWhiteSpace(target.IconPath) && System.IO.File.Exists(target.IconPath)
+ ? GetIcon(target.IconPath)
+ : GetIcon(target.Path);
+
public static ImageSource? GetIcon(string path)
{
if (_cache.TryGetValue(path, out var cached)) return cached;
- var icon = Extract(path);
+ var icon = ExtractBitmap(path) ?? ExtractShellIcon(path);
_cache[path] = icon;
return icon;
}
- private static ImageSource? Extract(string path)
+ private static ImageSource? ExtractBitmap(string path)
+ {
+ if (!System.IO.File.Exists(path)) return null;
+
+ try
+ {
+ var bitmap = new BitmapImage();
+ bitmap.BeginInit();
+ bitmap.CacheOption = BitmapCacheOption.OnLoad;
+ bitmap.UriSource = new Uri(path, UriKind.Absolute);
+ bitmap.EndInit();
+ bitmap.Freeze();
+ return bitmap;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static ImageSource? ExtractShellIcon(string path)
{
var info = new SHFILEINFO();
SHGetFileInfo(path, 0, ref info, (uint)Marshal.SizeOf(info), SHGFI_ICON | SHGFI_LARGEICON);
diff --git a/src/Dropwheel/Services/LinkMetadataService.cs b/src/Dropwheel/Services/LinkMetadataService.cs
new file mode 100644
index 0000000..ab24f66
--- /dev/null
+++ b/src/Dropwheel/Services/LinkMetadataService.cs
@@ -0,0 +1,189 @@
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.RegularExpressions;
+using Dropwheel.Models;
+
+namespace Dropwheel.Services;
+
+public sealed record LinkMetadataUpdate(string? Title, string? IconPath);
+
+public static class LinkMetadataService
+{
+ private const int MaxHtmlBytes = 512 * 1024;
+ private const int MaxIconBytes = 1024 * 1024;
+
+ private static readonly HttpClient Client = CreateClient();
+
+ private static HttpClient CreateClient()
+ {
+ var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("Dropwheel/1.0");
+ return client;
+ }
+
+ public static async Task FetchAsync(TargetItem target, CancellationToken ct = default)
+ {
+ if (SourceUri(target) is not { } pageUri) return null;
+
+ string? html = null;
+ try { html = await FetchHtmlAsync(pageUri, ct); }
+ catch (Exception ex) { ErrorLog.Write($"Could not fetch link metadata for '{pageUri}'", ex); }
+
+ var title = html == null ? null : ExtractTitle(html);
+ var iconUri = html == null ? new Uri(pageUri, "/favicon.ico") : ExtractIconUri(pageUri, html);
+ var iconPath = iconUri == null ? null : await TryDownloadIconAsync(iconUri, ct);
+
+ return string.IsNullOrWhiteSpace(title) && string.IsNullOrWhiteSpace(iconPath)
+ ? null
+ : new LinkMetadataUpdate(title, iconPath);
+ }
+
+ internal static Uri? SourceUri(TargetItem target)
+ {
+ var source = string.IsNullOrWhiteSpace(target.SourceUrl) ? target.Path : target.SourceUrl;
+ return Uri.TryCreate(source, UriKind.Absolute, out var uri) && IsWebUri(uri) ? uri : null;
+ }
+
+ internal static string? ExtractTitle(string html)
+ {
+ var match = Regex.Match(html, @"(?is)]*>(.*?)");
+ if (!match.Success) return null;
+
+ var title = Regex.Replace(match.Groups[1].Value, "<[^>]+>", "");
+ title = WebUtility.HtmlDecode(title);
+ title = Regex.Replace(title, @"\s+", " ").Trim();
+ return title.Length == 0 ? null : title;
+ }
+
+ internal static Uri? ExtractIconUri(Uri pageUri, string html)
+ {
+ foreach (Match match in Regex.Matches(html, @"(?is)]*>"))
+ {
+ var tag = match.Value;
+ var rel = AttributeValue(tag, "rel");
+ if (rel == null || !rel.Split(' ', StringSplitOptions.RemoveEmptyEntries)
+ .Any(part => part.Contains("icon", StringComparison.OrdinalIgnoreCase)))
+ continue;
+
+ var href = AttributeValue(tag, "href");
+ if (string.IsNullOrWhiteSpace(href)) continue;
+
+ href = WebUtility.HtmlDecode(href);
+ if (!Uri.TryCreate(pageUri, href, out var iconUri)) continue;
+ if (IsUnsupportedIcon(iconUri, contentType: null)) continue;
+ return iconUri;
+ }
+
+ return new Uri(pageUri, "/favicon.ico");
+ }
+
+ private static async Task FetchHtmlAsync(Uri uri, CancellationToken ct)
+ {
+ using var response = await Client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, ct);
+ response.EnsureSuccessStatusCode();
+
+ await using var stream = await response.Content.ReadAsStreamAsync(ct);
+ using var copy = new MemoryStream();
+ var buffer = new byte[16 * 1024];
+ while (copy.Length <= MaxHtmlBytes)
+ {
+ var read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), ct);
+ if (read == 0) break;
+ copy.Write(buffer, 0, read);
+ }
+
+ return Encoding.UTF8.GetString(copy.ToArray());
+ }
+
+ private static async Task TryDownloadIconAsync(Uri iconUri, CancellationToken ct)
+ {
+ try
+ {
+ using var response = await Client.GetAsync(iconUri, HttpCompletionOption.ResponseHeadersRead, ct);
+ if (!response.IsSuccessStatusCode) return null;
+
+ var contentType = response.Content.Headers.ContentType?.MediaType;
+ if (IsUnsupportedIcon(iconUri, contentType)) return null;
+
+ var ext = IconExtension(iconUri, contentType);
+ if (ext == null) return null;
+
+ var path = IconPathFor(iconUri, ext);
+ if (File.Exists(path)) return path;
+
+ await using var stream = await response.Content.ReadAsStreamAsync(ct);
+ var bytes = await ReadLimitedAsync(stream, MaxIconBytes, ct);
+ if (bytes.Length == 0) return null;
+
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ var tmp = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
+ await File.WriteAllBytesAsync(tmp, bytes, ct);
+ File.Move(tmp, path, overwrite: true);
+ return path;
+ }
+ catch (Exception ex)
+ {
+ ErrorLog.Write($"Could not download favicon '{iconUri}'", ex);
+ return null;
+ }
+ }
+
+ private static async Task ReadLimitedAsync(Stream stream, int maxBytes, CancellationToken ct)
+ {
+ using var copy = new MemoryStream();
+ var buffer = new byte[16 * 1024];
+ while (copy.Length <= maxBytes)
+ {
+ var read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), ct);
+ if (read == 0) break;
+ copy.Write(buffer, 0, read);
+ }
+
+ return copy.Length > maxBytes ? Array.Empty() : copy.ToArray();
+ }
+
+ private static string? AttributeValue(string tag, string name)
+ {
+ var quoted = Regex.Match(tag, $@"(?is)\b{name}\s*=\s*(['""])(.*?)\1");
+ if (quoted.Success) return quoted.Groups[2].Value;
+
+ var unquoted = Regex.Match(tag, $@"(?is)\b{name}\s*=\s*([^\s>]+)");
+ return unquoted.Success ? unquoted.Groups[1].Value : null;
+ }
+
+ private static string IconPathFor(Uri iconUri, string extension)
+ {
+ var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(iconUri.AbsoluteUri)))[..24]
+ .ToLowerInvariant();
+ return Path.Combine(TargetStore.Dir, "icons", hash + extension);
+ }
+
+ private static string? IconExtension(Uri iconUri, string? contentType)
+ {
+ var byType = contentType?.ToLowerInvariant() switch
+ {
+ "image/png" => ".png",
+ "image/jpeg" => ".jpg",
+ "image/x-icon" or "image/vnd.microsoft.icon" => ".ico",
+ "image/webp" => ".webp",
+ _ => null,
+ };
+ if (byType != null) return byType;
+
+ var ext = Path.GetExtension(iconUri.AbsolutePath).ToLowerInvariant();
+ return ext is ".png" or ".jpg" or ".jpeg" or ".ico" or ".webp" ? ext : ".ico";
+ }
+
+ private static bool IsUnsupportedIcon(Uri iconUri, string? contentType)
+ {
+ if (contentType?.Contains("svg", StringComparison.OrdinalIgnoreCase) == true) return true;
+ return Path.GetExtension(iconUri.AbsolutePath).Equals(".svg", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsWebUri(Uri uri) =>
+ uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
+ || uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
+}
diff --git a/src/Dropwheel/Services/LinkTargetService.cs b/src/Dropwheel/Services/LinkTargetService.cs
new file mode 100644
index 0000000..11c84da
--- /dev/null
+++ b/src/Dropwheel/Services/LinkTargetService.cs
@@ -0,0 +1,321 @@
+using System.IO;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Windows;
+using Dropwheel.Models;
+
+namespace Dropwheel.Services;
+
+/// Creates quick-access targets from dragged links such as tg:// and https://t.me/...
+public static class LinkTargetService
+{
+ private sealed record LinkDropCandidate(string Text, string? Title = null);
+
+ private static readonly Regex LaunchUri =
+ new(@"\b(?:(?:https?://|tg://)[^\s<>'""]+|(?:t\.me|telegram\.me|telegram\.dog)/[^\s<>'""]+|[a-z0-9_]{2,64}\.t\.me(?:/[^\s<>'""]*)?)",
+ RegexOptions.Compiled | RegexOptions.IgnoreCase);
+ private static readonly Regex SavedMessagesLabel =
+ new(@"^\s*(?:saved messages?|избранное)\s*$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ public static bool HasLaunchUri(IDataObject data) => TryGetLaunchUri(data, out _);
+
+ public static bool HasPotentialLaunchUriData(IDataObject data) =>
+ data.GetDataPresent("UniformResourceLocatorW")
+ || data.GetDataPresent("UniformResourceLocator")
+ || data.GetDataPresent("text/x-moz-url")
+ || data.GetDataPresent(DataFormats.Html)
+ || TextDropService.HasPotentialText(data);
+
+ public static TargetItem? CreateTarget(IDataObject data) =>
+ TryGetLaunchUri(data, out var candidate) ? CreateTarget(candidate.Text, candidate.Title) : null;
+
+ public static bool HasSavedMessagesLabel(IDataObject data) =>
+ TextCandidates(data).Any(IsSavedMessagesText);
+
+ public static TargetItem? CreateSavedMessagesTarget(string account)
+ {
+ account = account.Trim();
+ if (account.Length == 0) return null;
+
+ if (CreateTarget(account) is { } linkTarget)
+ return new TargetItem { Name = "Saved Messages", Path = linkTarget.Path };
+
+ account = account.TrimStart('@');
+ if (account.Length == 0) return null;
+
+ var parameter = account.StartsWith('+') || account.Any(char.IsDigit) && account.All(c => char.IsDigit(c) || c is '+' or '-' or '(' or ')' or ' ')
+ ? "phone"
+ : "domain";
+ var value = parameter == "phone"
+ ? new string(account.Where(c => char.IsDigit(c) || c == '+').ToArray())
+ : account;
+ if (value.Length == 0) return null;
+
+ return new TargetItem
+ {
+ Name = "Saved Messages",
+ Path = $"tg://resolve?{parameter}={Uri.EscapeDataString(value)}",
+ };
+ }
+
+ internal static TargetItem? CreateTarget(string text)
+ {
+ if (!TryExtractLaunchUri(text, out var uriText)) return null;
+ return CreateTarget(uriText, titleHint: null);
+ }
+
+ internal static bool TryExtractLaunchUri(string? text, out string uriText)
+ {
+ uriText = "";
+ if (string.IsNullOrWhiteSpace(text)) return false;
+
+ var match = LaunchUri.Match(text);
+ if (!match.Success) return false;
+
+ var candidate = NormalizeUri(TrimUri(match.Value));
+ if (!TargetItem.IsLaunchUri(candidate)) return false;
+
+ uriText = candidate;
+ return true;
+ }
+
+ internal static bool IsSavedMessagesText(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text)) return false;
+ return text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(line => line.Trim())
+ .Any(line => SavedMessagesLabel.IsMatch(line));
+ }
+
+ private static bool TryGetLaunchUri(IDataObject data, out LinkDropCandidate candidate)
+ {
+ LinkDropCandidate? fallback = null;
+ foreach (var text in LinkDropCandidates(data))
+ {
+ if (!TryExtractLaunchUri(text.Text, out var uriText)) continue;
+ var current = text with { Text = uriText };
+ if (!string.IsNullOrWhiteSpace(current.Title))
+ {
+ candidate = current;
+ return true;
+ }
+
+ fallback ??= current;
+ }
+
+ candidate = fallback ?? new LinkDropCandidate("");
+ return fallback != null;
+ }
+
+ private static IEnumerable TextCandidates(IDataObject data)
+ => LinkDropCandidates(data).Select(candidate => candidate.Text);
+
+ private static IEnumerable LinkDropCandidates(IDataObject data)
+ {
+ if (ReadData(data, "UniformResourceLocatorW", Encoding.Unicode) is { } urlW)
+ yield return new LinkDropCandidate(urlW);
+ if (ReadData(data, "UniformResourceLocator", Encoding.Default) is { } url)
+ yield return new LinkDropCandidate(url);
+ if (ReadData(data, "text/x-moz-url", Encoding.Unicode) is { } moz)
+ yield return new LinkDropCandidate(FirstLine(moz), SecondLine(moz));
+ if (data.GetData(DataFormats.Html) is string html)
+ yield return new LinkDropCandidate(html, TitleFromHtml(html));
+ if (TextDropService.GetText(data) is { } text)
+ yield return new LinkDropCandidate(text);
+ }
+
+ private static string? ReadData(IDataObject data, string format, Encoding encoding)
+ {
+ if (!data.GetDataPresent(format)) return null;
+ return data.GetData(format) switch
+ {
+ string text => text,
+ byte[] bytes => encoding.GetString(bytes),
+ MemoryStream stream => encoding.GetString(stream.ToArray()),
+ Stream stream => ReadStream(stream, encoding),
+ _ => null,
+ };
+ }
+
+ private static string ReadStream(Stream stream, Encoding encoding)
+ {
+ if (stream.CanSeek) stream.Position = 0;
+ using var copy = new MemoryStream();
+ stream.CopyTo(copy);
+ return encoding.GetString(copy.ToArray());
+ }
+
+ private static string FirstLine(string text) =>
+ text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)[0];
+
+ private static string? SecondLine(string text) =>
+ text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None).Skip(1).FirstOrDefault()?.Trim();
+
+ private static string TrimUri(string value) =>
+ value.Trim().TrimEnd('\0', '.', ',', ';', '!', ')', ']', '}');
+
+ private static string NormalizeUri(string value) =>
+ IsBareTelegramLink(value) ? "https://" + value : value;
+
+ private static bool IsBareTelegramLink(string value) =>
+ value.StartsWith("t.me/", StringComparison.OrdinalIgnoreCase)
+ || value.StartsWith("telegram.me/", StringComparison.OrdinalIgnoreCase)
+ || value.StartsWith("telegram.dog/", StringComparison.OrdinalIgnoreCase)
+ || Regex.IsMatch(value, @"^[a-z0-9_]{2,64}\.t\.me(?:/|$)", RegexOptions.IgnoreCase);
+
+ private static TargetItem CreateTarget(string uriText, string? titleHint)
+ {
+ var path = TargetPathFor(uriText);
+ return new TargetItem
+ {
+ Name = NameFor(uriText, titleHint),
+ Path = path,
+ SourceUrl = IsWebUri(uriText) ? uriText : null,
+ };
+ }
+
+ private static string NameFor(string uriText, string? titleHint)
+ {
+ if (!Uri.TryCreate(uriText, UriKind.Absolute, out var uri)) return uriText;
+ if (CleanTitle(titleHint, uriText) is { } title) return title;
+ if (IsTelegramUri(uri)) return TelegramName(uri);
+ return string.IsNullOrWhiteSpace(uri.Host) ? uriText : uri.Host;
+ }
+
+ private static string? CleanTitle(string? title, string uriText)
+ {
+ if (string.IsNullOrWhiteSpace(title)) return null;
+ title = Regex.Replace(title, @"\s+", " ").Trim();
+ return title.Length > 0 && !title.Equals(uriText, StringComparison.OrdinalIgnoreCase)
+ ? title
+ : null;
+ }
+
+ private static string? TitleFromHtml(string html)
+ {
+ var title = Regex.Match(html, @"(?is)]*>(.*?)");
+ if (title.Success) return DecodeHtmlText(title.Groups[1].Value);
+
+ var anchor = Regex.Match(HtmlFragment(html), @"(?is)]*>(.*?)");
+ return anchor.Success ? DecodeHtmlText(anchor.Groups[1].Value) : null;
+ }
+
+ private static string HtmlFragment(string html)
+ {
+ const string startMarker = "";
+ const string endMarker = "";
+ var markerStart = html.IndexOf(startMarker, StringComparison.OrdinalIgnoreCase);
+ var markerEnd = html.IndexOf(endMarker, StringComparison.OrdinalIgnoreCase);
+ if (markerStart >= 0 && markerEnd > markerStart)
+ return html[(markerStart + startMarker.Length)..markerEnd];
+
+ return html;
+ }
+
+ private static string DecodeHtmlText(string value)
+ {
+ value = Regex.Replace(value, "<[^>]+>", "");
+ return System.Net.WebUtility.HtmlDecode(value).Trim();
+ }
+
+ private static string TargetPathFor(string uriText)
+ {
+ if (!Uri.TryCreate(uriText, UriKind.Absolute, out var uri) || !IsTelegramWebUri(uri))
+ return uriText;
+
+ return TelegramWebDeepLink(uri) ?? uriText;
+ }
+
+ private static bool IsTelegramWebUri(Uri uri) =>
+ uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
+ || uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
+
+ private static bool IsWebUri(string uriText) =>
+ Uri.TryCreate(uriText, UriKind.Absolute, out var uri)
+ && IsTelegramWebUri(uri);
+
+ private static string? TelegramWebDeepLink(Uri uri)
+ {
+ if (!IsTelegramUri(uri)) return null;
+
+ if (uri.Host.EndsWith(".t.me", StringComparison.OrdinalIgnoreCase)
+ && !uri.Host.Equals("t.me", StringComparison.OrdinalIgnoreCase))
+ {
+ var domain = uri.Host[..^".t.me".Length];
+ return $"tg://resolve?domain={Uri.EscapeDataString(domain)}";
+ }
+
+ var segments = uri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries);
+ if (segments.Length == 0) return null;
+
+ var first = Uri.UnescapeDataString(segments[0]);
+ if (first.Equals("c", StringComparison.OrdinalIgnoreCase) && segments.Length >= 3)
+ {
+ if (segments.Length >= 4)
+ return $"tg://privatepost?channel={Uri.EscapeDataString(segments[1])}&topic={Uri.EscapeDataString(segments[2])}&post={Uri.EscapeDataString(segments[3])}";
+
+ return $"tg://privatepost?channel={Uri.EscapeDataString(segments[1])}&post={Uri.EscapeDataString(segments[2])}";
+ }
+
+ if (first.StartsWith('+') && first.Length > 1)
+ return $"tg://join?invite={Uri.EscapeDataString(first[1..])}";
+
+ if (first.Equals("joinchat", StringComparison.OrdinalIgnoreCase) && segments.Length >= 2)
+ return $"tg://join?invite={Uri.EscapeDataString(segments[1])}";
+
+ var resolvedDomain = Uri.EscapeDataString(first.TrimStart('@'));
+ if (segments.Length >= 3 && int.TryParse(segments[1], out _) && int.TryParse(segments[2], out _))
+ return $"tg://resolve?domain={resolvedDomain}&topic={Uri.EscapeDataString(segments[1])}&post={Uri.EscapeDataString(segments[2])}";
+
+ if (segments.Length >= 2 && int.TryParse(segments[1], out _))
+ return $"tg://resolve?domain={resolvedDomain}&post={Uri.EscapeDataString(segments[1])}";
+
+ return $"tg://resolve?domain={resolvedDomain}";
+ }
+
+ private static bool IsTelegramUri(Uri uri) =>
+ uri.Scheme.Equals("tg", StringComparison.OrdinalIgnoreCase)
+ || (uri.Host.Equals("t.me", StringComparison.OrdinalIgnoreCase)
+ || uri.Host.Equals("telegram.me", StringComparison.OrdinalIgnoreCase)
+ || uri.Host.Equals("telegram.dog", StringComparison.OrdinalIgnoreCase)
+ || uri.Host.EndsWith(".t.me", StringComparison.OrdinalIgnoreCase));
+
+ private static string TelegramName(Uri uri)
+ {
+ if (uri.Scheme.Equals("tg", StringComparison.OrdinalIgnoreCase))
+ {
+ var domain = QueryValue(uri, "domain");
+ if (!string.IsNullOrWhiteSpace(domain)) return "Telegram: " + domain.TrimStart('@');
+
+ var id = QueryValue(uri, "id") ?? QueryValue(uri, "user_id");
+ if (!string.IsNullOrWhiteSpace(id)) return "Telegram: " + id;
+
+ return "Telegram";
+ }
+
+ var segments = uri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries);
+ var firstSegment = segments.FirstOrDefault();
+ if (string.IsNullOrWhiteSpace(firstSegment)) return "Telegram";
+
+ firstSegment = Uri.UnescapeDataString(firstSegment);
+ if (firstSegment.StartsWith('+') || firstSegment.Equals("joinchat", StringComparison.OrdinalIgnoreCase))
+ return "Telegram invite";
+ if (firstSegment.Equals("c", StringComparison.OrdinalIgnoreCase))
+ return segments.Length >= 3 ? "Telegram topic" : "Telegram chat";
+
+ return "Telegram: " + firstSegment.TrimStart('@');
+ }
+
+ private static string? QueryValue(Uri uri, string name)
+ {
+ foreach (var part in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var pieces = part.Split('=', 2);
+ var key = Uri.UnescapeDataString(pieces[0]);
+ if (!key.Equals(name, StringComparison.OrdinalIgnoreCase)) continue;
+ return pieces.Length == 2 ? Uri.UnescapeDataString(pieces[1]) : "";
+ }
+
+ return null;
+ }
+}
diff --git a/src/Dropwheel/Services/TargetStore.cs b/src/Dropwheel/Services/TargetStore.cs
index a58876c..5e248d5 100644
--- a/src/Dropwheel/Services/TargetStore.cs
+++ b/src/Dropwheel/Services/TargetStore.cs
@@ -1,6 +1,7 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
+using System.Text.Json.Nodes;
using Dropwheel.Models;
namespace Dropwheel.Services;
@@ -38,7 +39,9 @@ public static void Load()
{
try
{
- Config = JsonSerializer.Deserialize(File.ReadAllText(FilePath), Opts) ?? new();
+ var configText = File.ReadAllText(FilePath);
+ Config = DeserializeConfig(configText, out var sanitizedInvalidEnums) ?? new();
+ if (sanitizedInvalidEnums) Save();
if (Config.Presets == null) { Config.Presets = PresetService.Defaults(); Save(); }
return;
}
@@ -194,6 +197,93 @@ public static void MoveToGroup(TargetItem item, TargetItem? group)
return Groups.Select(g => (IList)g.Children!).FirstOrDefault(children => children.Contains(item));
}
+ private static AppConfig? DeserializeConfig(string json, out bool sanitizedInvalidEnums)
+ {
+ try
+ {
+ sanitizedInvalidEnums = false;
+ return JsonSerializer.Deserialize(json, Opts);
+ }
+ catch (JsonException ex) when (TrySanitizeInvalidEnums(json, out var sanitizedJson))
+ {
+ ErrorLog.Write("Config contains unknown enum values; falling back only for those fields.", ex);
+ sanitizedInvalidEnums = true;
+ return JsonSerializer.Deserialize(sanitizedJson, Opts);
+ }
+ }
+
+ private static bool TrySanitizeInvalidEnums(string json, out string sanitizedJson)
+ {
+ sanitizedJson = json;
+ JsonNode? root;
+ try
+ {
+ root = JsonNode.Parse(json);
+ }
+ catch (JsonException)
+ {
+ return false;
+ }
+
+ if (root is not JsonObject rootObject)
+ return false;
+
+ var changed = false;
+ changed |= RemoveInvalidEnum(rootObject, nameof(AppConfig.GlobalAction));
+ changed |= RemoveInvalidEnum(rootObject, nameof(AppConfig.OpenAnimation));
+
+ if (rootObject[nameof(AppConfig.Targets)] is JsonArray targets)
+ changed |= SanitizeTargets(targets);
+
+ if (!changed)
+ return false;
+
+ sanitizedJson = rootObject.ToJsonString();
+ return true;
+ }
+
+ private static bool SanitizeTargets(JsonArray targets)
+ {
+ var changed = false;
+ foreach (var targetNode in targets)
+ {
+ if (targetNode is not JsonObject targetObject)
+ continue;
+
+ changed |= RemoveInvalidEnum(targetObject, nameof(TargetItem.Override));
+
+ if (targetObject[nameof(TargetItem.Children)] is JsonArray children)
+ changed |= SanitizeTargets(children);
+ }
+
+ return changed;
+ }
+
+ private static bool RemoveInvalidEnum(JsonObject obj, string propertyName)
+ where TEnum : struct, Enum
+ {
+ if (!obj.TryGetPropertyValue(propertyName, out var valueNode) || valueNode is null)
+ return false;
+
+ if (valueNode is not JsonValue value)
+ return false;
+
+ if (value.TryGetValue(out var enumToken))
+ {
+ if (Enum.TryParse(enumToken, ignoreCase: true, out _))
+ return false;
+
+ obj.Remove(propertyName);
+ return true;
+ }
+
+ if (value.TryGetValue(out var enumValue) && Enum.IsDefined(typeof(TEnum), enumValue))
+ return false;
+
+ obj.Remove(propertyName);
+ return true;
+ }
+
private static AppConfig Defaults()
{
static string P(Environment.SpecialFolder f) => Environment.GetFolderPath(f);
diff --git a/src/Dropwheel/Services/TelegramDropService.cs b/src/Dropwheel/Services/TelegramDropService.cs
new file mode 100644
index 0000000..12dfe43
--- /dev/null
+++ b/src/Dropwheel/Services/TelegramDropService.cs
@@ -0,0 +1,179 @@
+using System.Collections.Specialized;
+using System.Diagnostics;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Windows;
+using Dropwheel.Models;
+
+namespace Dropwheel.Services;
+
+public enum TelegramDropKind { Files, Text }
+
+public sealed class TelegramDropResult
+{
+ public TelegramDropKind Kind { get; init; }
+ public int Count { get; init; }
+}
+
+internal sealed class TelegramClipboardPayload
+{
+ public required TelegramDropKind Kind { get; init; }
+ public string[] Files { get; init; } = Array.Empty();
+ public string? Text { get; init; }
+
+ public int Count => Kind == TelegramDropKind.Files ? Files.Length : 1;
+
+ public void Copy()
+ {
+ if (Kind == TelegramDropKind.Files)
+ {
+ var list = new StringCollection();
+ list.AddRange(Files);
+ SetClipboard(() => Clipboard.SetFileDropList(list));
+ return;
+ }
+
+ SetClipboard(() => Clipboard.SetText(Text ?? "", TextDataFormat.UnicodeText));
+ }
+
+ private static void SetClipboard(Action set)
+ {
+ for (int attempt = 0; ; attempt++)
+ {
+ try
+ {
+ set();
+ return;
+ }
+ catch (ExternalException) when (attempt < 2)
+ {
+ Thread.Sleep(50);
+ }
+ }
+ }
+}
+
+public static class TelegramDropService
+{
+ private static readonly TimeSpan PasteTimeout = TimeSpan.FromSeconds(4);
+ private static readonly TimeSpan PastePoll = TimeSpan.FromMilliseconds(100);
+
+ [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow();
+ [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out int processId);
+
+ public static bool IsTelegramTarget(TargetItem target)
+ {
+ if (target.IsGroup || !Uri.TryCreate(target.Path, UriKind.Absolute, out var uri))
+ return false;
+
+ if (uri.Scheme.Equals("tg", StringComparison.OrdinalIgnoreCase))
+ return true;
+
+ if (!uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
+ && !uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ return uri.Host.Equals("t.me", StringComparison.OrdinalIgnoreCase)
+ || uri.Host.Equals("telegram.me", StringComparison.OrdinalIgnoreCase)
+ || uri.Host.Equals("telegram.dog", StringComparison.OrdinalIgnoreCase)
+ || uri.Host.EndsWith(".t.me", StringComparison.OrdinalIgnoreCase);
+ }
+
+ public static bool CanAccept(TargetItem target, IDataObject data) =>
+ IsTelegramTarget(target) && HasSendablePayload(data);
+
+ public static string LaunchPathFor(TargetItem target) =>
+ IsTelegramTarget(target) && LinkTargetService.CreateTarget(target.Path) is { } linkTarget
+ ? linkTarget.Path
+ : target.Path;
+
+ public static void PasteIntoTelegramWhenReady()
+ {
+ _ = Task.Run(async () =>
+ {
+ try { await PasteIntoTelegramWhenReady(PasteTimeout, PastePoll); }
+ catch (Exception ex) { ErrorLog.Write("Could not paste Telegram drop payload", ex); }
+ });
+ }
+
+ public static TelegramDropResult? CopyToClipboard(IDataObject data, string stagingFolder)
+ {
+ var payload = CreatePayload(data, stagingFolder);
+ if (payload == null) return null;
+
+ payload.Copy();
+ return new TelegramDropResult { Kind = payload.Kind, Count = payload.Count };
+ }
+
+ private static bool HasSendablePayload(IDataObject data) =>
+ RealFiles(data).Length > 0
+ || VirtualFileService.HasVirtualFiles(data)
+ || TextDropService.HasPotentialText(data);
+
+ internal static TelegramClipboardPayload? CreatePayload(IDataObject data, string stagingFolder)
+ {
+ var realFiles = RealFiles(data);
+ if (realFiles.Length > 0)
+ return new TelegramClipboardPayload { Kind = TelegramDropKind.Files, Files = realFiles };
+
+ if (VirtualFileService.HasVirtualFiles(data))
+ {
+ Directory.CreateDirectory(stagingFolder);
+ var saved = VirtualFileService.Extract(data, stagingFolder);
+ if (saved.Length > 0)
+ return new TelegramClipboardPayload { Kind = TelegramDropKind.Files, Files = saved };
+ }
+
+ var text = TextDropService.GetText(data);
+ return string.IsNullOrEmpty(text)
+ ? null
+ : new TelegramClipboardPayload { Kind = TelegramDropKind.Text, Text = text };
+ }
+
+ internal static async Task PasteIntoTelegramWhenReady(
+ TimeSpan timeout,
+ TimeSpan poll,
+ Action? paste = null,
+ Func? foregroundProcessName = null)
+ {
+ var deadline = DateTime.UtcNow + timeout;
+ do
+ {
+ var processName = foregroundProcessName?.Invoke() ?? ForegroundProcessName();
+ if (IsTelegramProcessName(processName))
+ {
+ await Task.Delay(250);
+ if (paste != null) paste();
+ else await Application.Current.Dispatcher.InvokeAsync(() => System.Windows.Forms.SendKeys.SendWait("^v"));
+ return true;
+ }
+
+ await Task.Delay(poll);
+ }
+ while (DateTime.UtcNow <= deadline);
+
+ return false;
+ }
+
+ internal static bool IsTelegramProcessName(string? processName) =>
+ processName != null
+ && (processName.Equals("Telegram", StringComparison.OrdinalIgnoreCase)
+ || processName.Equals("TelegramDesktop", StringComparison.OrdinalIgnoreCase));
+
+ private static string? ForegroundProcessName()
+ {
+ var hWnd = GetForegroundWindow();
+ if (hWnd == IntPtr.Zero) return null;
+
+ GetWindowThreadProcessId(hWnd, out var processId);
+ if (processId == 0) return null;
+
+ try { return Process.GetProcessById(processId).ProcessName; }
+ catch { return null; }
+ }
+
+ private static string[] RealFiles(IDataObject data) =>
+ data.GetData(DataFormats.FileDrop) is string[] files
+ ? files.Where(path => File.Exists(path) || Directory.Exists(path)).ToArray()
+ : Array.Empty();
+}
diff --git a/src/Dropwheel/Services/TextDropService.cs b/src/Dropwheel/Services/TextDropService.cs
index 556602d..58802de 100644
--- a/src/Dropwheel/Services/TextDropService.cs
+++ b/src/Dropwheel/Services/TextDropService.cs
@@ -1,4 +1,5 @@
using System.IO;
+using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using System.Windows;
@@ -9,11 +10,37 @@ namespace Dropwheel.Services;
/// The extension is picked from the content: markdown-looking text becomes .md, otherwise .txt.
public static class TextDropService
{
- public static bool HasText(IDataObject data) =>
- data.GetDataPresent(DataFormats.UnicodeText) || data.GetDataPresent(DataFormats.Text);
+ private static readonly string[] PlainTextFormats =
+ {
+ DataFormats.UnicodeText,
+ DataFormats.Text,
+ DataFormats.StringFormat,
+ DataFormats.OemText,
+ "UTF8_STRING",
+ "text/plain",
+ "text/plain;charset=utf-8",
+ };
+
+ public static bool HasPotentialText(IDataObject data) =>
+ TextFormatCandidates(data).Any();
- public static string? GetText(IDataObject data) =>
- data.GetData(DataFormats.UnicodeText) as string ?? data.GetData(DataFormats.Text) as string;
+ public static bool HasText(IDataObject data) => !string.IsNullOrEmpty(GetText(data));
+
+ public static string? GetText(IDataObject data)
+ {
+ foreach (var format in TextFormatCandidates(data))
+ {
+ if (TryGetText(data, format) is { Length: > 0 } text) return text;
+ }
+
+ return null;
+ }
+
+ public static string DescribeFormats(IDataObject data)
+ {
+ try { return string.Join(", ", data.GetFormats()); }
+ catch (Exception ex) { return $""; }
+ }
/// Saves dragged text into the folder, or null when the drop carries no text.
public static string? SaveFrom(IDataObject data, string folder, DateTime now)
@@ -42,16 +69,169 @@ public static string Save(string text, string folder, DateTime now)
public static bool LooksLikeMarkdown(string text) =>
text.Contains("```") || Heading.IsMatch(text) || Link.IsMatch(text);
+ private static string? TryGetText(IDataObject data, string format)
+ {
+ foreach (var autoConvert in new[] { false, true })
+ {
+ try
+ {
+ if (!data.GetDataPresent(format, autoConvert)) continue;
+ if (TextFromFormat(format, data.GetData(format, autoConvert)) is { Length: > 0 } text)
+ return text;
+ }
+ catch
+ {
+ // Some delayed-rendered drag formats throw until the final Drop. Try the next mode.
+ }
+ }
+
+ return null;
+ }
+
+ private static string? TextFromFormat(string format, object? data)
+ {
+ var text = TextFromData(data);
+ if (string.IsNullOrEmpty(text)) return null;
+ if (IsHtmlFormat(format)) return TextFromHtml(text);
+ if (IsRtfFormat(format)) return TextFromRtf(text);
+ return text.TrimEnd('\0');
+ }
+
+ private static string? TextFromData(object? data)
+ {
+ if (data is string text) return text;
+ if (data is byte[] bytes) return TextFromBytes(bytes);
+ if (data is not Stream stream) return null;
+
+ if (stream.CanSeek) stream.Position = 0;
+ using var copy = new MemoryStream();
+ stream.CopyTo(copy);
+ return TextFromBytes(copy.ToArray());
+ }
+
+ private static string? TextFromBytes(byte[] bytes)
+ {
+ bytes = bytes.TrimTrailingZeros();
+ if (bytes.Length == 0) return null;
+ return LooksLikeUtf16(bytes)
+ ? Encoding.Unicode.GetString(bytes)
+ : Encoding.UTF8.GetString(bytes);
+ }
+
+ private static IEnumerable TextFormatCandidates(IDataObject data)
+ {
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var formats = SafeGetFormats(data);
+
+ foreach (var format in PlainTextFormats)
+ {
+ if ((formats.Contains(format) || SafeGetDataPresent(data, format)) && seen.Add(format))
+ yield return format;
+ }
+
+ foreach (var format in formats)
+ {
+ if (LooksLikeTextFormat(format) && seen.Add(format))
+ yield return format;
+ }
+ }
+
+ private static string[] SafeGetFormats(IDataObject data)
+ {
+ try { return data.GetFormats(autoConvert: true); }
+ catch { return Array.Empty(); }
+ }
+
+ private static bool SafeGetDataPresent(IDataObject data, string format)
+ {
+ try { return data.GetDataPresent(format, autoConvert: true); }
+ catch { return false; }
+ }
+
+ private static bool LooksLikeTextFormat(string format)
+ {
+ var normalized = format.ToLowerInvariant();
+ return normalized.Contains("text")
+ || normalized.Contains("string")
+ || normalized.Contains("utf8")
+ || normalized.Contains("unicode")
+ || normalized.Contains("html")
+ || normalized.Contains("rtf")
+ || normalized.Contains("rich text");
+ }
+
+ private static bool IsHtmlFormat(string format) =>
+ format.Contains("html", StringComparison.OrdinalIgnoreCase);
+
+ private static bool IsRtfFormat(string format) =>
+ format.Contains("rtf", StringComparison.OrdinalIgnoreCase)
+ || format.Contains("rich text", StringComparison.OrdinalIgnoreCase);
+
+ private static string? TextFromHtml(string html)
+ {
+ var fragment = HtmlFragment(html);
+ fragment = Regex.Replace(fragment, @"(?i)]
", "\n");
+ fragment = Regex.Replace(fragment, @"(?i)
", "\n");
+ fragment = Regex.Replace(fragment, "<[^>]+>", "");
+ return WebUtility.HtmlDecode(fragment).Trim();
+ }
+
+ private static string HtmlFragment(string html)
+ {
+ const string startMarker = "";
+ const string endMarker = "";
+ var markerStart = html.IndexOf(startMarker, StringComparison.OrdinalIgnoreCase);
+ var markerEnd = html.IndexOf(endMarker, StringComparison.OrdinalIgnoreCase);
+ if (markerStart >= 0 && markerEnd > markerStart)
+ return html[(markerStart + startMarker.Length)..markerEnd];
+
+ var start = Regex.Match(html, @"StartFragment:(\d+)");
+ var end = Regex.Match(html, @"EndFragment:(\d+)");
+ if (start.Success
+ && end.Success
+ && int.TryParse(start.Groups[1].Value, out var startIndex)
+ && int.TryParse(end.Groups[1].Value, out var endIndex)
+ && startIndex >= 0
+ && endIndex > startIndex
+ && endIndex <= html.Length)
+ return html[startIndex..endIndex];
+
+ return html;
+ }
+
+ private static string? TextFromRtf(string rtf)
+ {
+ var text = Regex.Replace(rtf, @"\\'[0-9a-fA-F]{2}", "");
+ text = Regex.Replace(text, @"\\[a-zA-Z]+\d* ?", "");
+ text = text.Replace("{", "").Replace("}", "").Trim();
+ return text.Length == 0 ? null : text;
+ }
+
+ private static bool LooksLikeUtf16(byte[] bytes) =>
+ bytes.Length >= 2 && bytes.Where((_, i) => i % 2 == 1).Take(16).Count(b => b == 0) >= 4;
+
private static string Unique(string folder, string name)
{
var path = Path.Combine(folder, name);
- if (!File.Exists(path)) return path;
+ if (!PathExists(path)) return path;
var stem = Path.GetFileNameWithoutExtension(name);
var ext = Path.GetExtension(name);
for (int i = 2; ; i++)
{
path = Path.Combine(folder, $"{stem} ({i}){ext}");
- if (!File.Exists(path)) return path;
+ if (!PathExists(path)) return path;
}
}
+
+ private static bool PathExists(string path) => File.Exists(path) || Directory.Exists(path);
+}
+
+internal static class ByteArrayExtensions
+{
+ public static byte[] TrimTrailingZeros(this byte[] bytes)
+ {
+ var length = bytes.Length;
+ while (length > 0 && bytes[length - 1] == 0) length--;
+ return length == bytes.Length ? bytes : bytes[..length];
+ }
}
diff --git a/src/Dropwheel/Services/WatcherService.cs b/src/Dropwheel/Services/WatcherService.cs
index bb7d649..099e87e 100644
--- a/src/Dropwheel/Services/WatcherService.cs
+++ b/src/Dropwheel/Services/WatcherService.cs
@@ -17,7 +17,70 @@ public sealed class WatcherService
private sealed class Entry
{
public required FileSystemWatcher Watcher { get; init; }
+ public required CancellationTokenSource Lifetime { get; init; }
public TargetItem Target { get; set; } = null!;
+
+ private readonly object _gate = new();
+ private int _queuedWork;
+ private bool _stopping;
+ private bool _lifetimeDisposed;
+
+ public CancellationToken Token => Lifetime.Token;
+
+ public bool TryQueueWork()
+ {
+ lock (_gate)
+ {
+ if (_stopping) return false;
+ _queuedWork++;
+ return true;
+ }
+ }
+
+ public void CompleteWork()
+ {
+ CancellationTokenSource? dispose = null;
+ lock (_gate)
+ {
+ _queuedWork--;
+ if (_queuedWork == 0 && _stopping && !_lifetimeDisposed)
+ {
+ _lifetimeDisposed = true;
+ dispose = Lifetime;
+ }
+ }
+ dispose?.Dispose();
+ }
+
+ public void Cancel()
+ {
+ CancellationTokenSource? dispose = null;
+ lock (_gate)
+ {
+ if (!_stopping)
+ {
+ _stopping = true;
+ Lifetime.Cancel();
+ Watcher.Dispose();
+ }
+ if (_queuedWork == 0 && !_lifetimeDisposed)
+ {
+ _lifetimeDisposed = true;
+ dispose = Lifetime;
+ }
+ }
+ dispose?.Dispose();
+ }
+
+ public bool TryRunSort(CancellationToken cancellationToken, Action sort)
+ {
+ lock (_gate)
+ {
+ if (_stopping || cancellationToken.IsCancellationRequested) return false;
+ sort();
+ return true;
+ }
+ }
}
private readonly Dispatcher _ui;
@@ -50,12 +113,15 @@ public void Start()
public void Stop()
{
TargetStore.Saved -= Rebuild;
- foreach (var e in _entries.Values) e.Watcher.Dispose();
+ foreach (var e in _entries.Values)
+ {
+ e.Cancel();
+ }
_entries.Clear();
}
/// Re-syncs watchers to the current set of watched sorter folders: adds new folders,
- /// drops removed ones, and refreshes the target reference for folders that stay — so rule edits
+ /// drops removed ones, and refreshes the target reference for folders that stay so rule edits
/// take effect without recreating the watcher.
private void Rebuild()
{
@@ -65,25 +131,29 @@ private void Rebuild()
if (!t.Watch || !t.IsSorter) continue;
string path;
try { path = Path.GetFullPath(t.Path); }
- catch { continue; } // invalid path in config — skip, do not throw
+ catch { continue; } // invalid path in config - skip, do not throw
if (!Directory.Exists(path)) continue;
- desired[path] = t; // rare duplicate (two targets on one folder) — keep the last
+ desired[path] = t; // rare duplicate (two targets on one folder) - keep the last
}
foreach (var path in _entries.Keys.Where(p => !desired.ContainsKey(p)).ToList())
{
- _entries[path].Watcher.Dispose();
+ _entries[path].Cancel();
_entries.Remove(path);
}
foreach (var (path, target) in desired)
{
if (_entries.TryGetValue(path, out var existing)) { existing.Target = target; continue; }
- // One bad folder (path too long, network glitch) must not break the whole rebuild — it
+ // One bad folder (path too long, network glitch) must not break the whole rebuild - it
// runs from the config-save handler.
try
{
- var entry = new Entry { Watcher = CreateWatcher(path) };
+ var entry = new Entry
+ {
+ Watcher = CreateWatcher(path),
+ Lifetime = new CancellationTokenSource(),
+ };
entry.Target = target;
entry.Watcher.Created += (_, e) => OnAppeared(entry, e.FullPath);
entry.Watcher.Renamed += (_, e) => OnAppeared(entry, e.FullPath);
@@ -100,7 +170,7 @@ private void Rebuild()
{
IncludeSubdirectories = false,
NotifyFilter = NotifyFilters.FileName,
- // Larger buffer — fewer lost events when many files are dropped at once (64 KB is the max).
+ // Larger buffer - fewer lost events when many files are dropped at once (64 KB is the max).
InternalBufferSize = 64 * 1024,
};
@@ -109,7 +179,7 @@ private void Rebuild()
/// it now. The _inFlight dedup keeps a file from being processed twice.
private void OnError(Entry entry, string path, Exception ex)
{
- ErrorLog.Write($"Watch buffer overflow for folder '{path}' — rescanning", ex);
+ ErrorLog.Write($"Watch buffer overflow for folder '{path}' - rescanning", ex);
Sweep(entry);
}
@@ -127,32 +197,67 @@ private void Sweep(Entry entry)
/// Renamed can both fire for one file); then we wait off-thread for it to be released.
private void OnAppeared(Entry entry, string fullPath)
{
- if (!_inFlight.TryAdd(fullPath, 0)) return;
- _ = ProcessWhenReady(entry, fullPath);
+ if (!entry.TryQueueWork()) return;
+ if (!_inFlight.TryAdd(fullPath, 0))
+ {
+ entry.CompleteWork();
+ return;
+ }
+ _ = ProcessWhenReady(entry, fullPath, entry.Token);
}
- private async Task ProcessWhenReady(Entry entry, string file)
+ private async Task ProcessWhenReady(Entry entry, string file, CancellationToken cancellationToken)
{
try
{
- for (int i = 0; i < MaxWaitTicks; i++)
+ if (!await WaitUntilReadyAsync(file, IsReady, PollMs, MaxWaitTicks, cancellationToken)) return;
+ await _moveGate.WaitAsync(cancellationToken);
+ try
{
- if (Directory.Exists(file)) return; // a folder appeared, not a file
- if (!File.Exists(file)) return; // the file vanished while we waited
- if (IsReady(file)) break; // the writing process released it — fully written
- if (i == MaxWaitTicks - 1)
- {
- ErrorLog.Write($"File '{file}' stays locked — auto-sort skipped");
- return;
- }
- await Task.Delay(PollMs);
+ entry.TryRunSort(
+ cancellationToken,
+ () => SortOne(entry, file, cancellationToken)); // off-thread: planning and the silent move never touch the UI
}
- await _moveGate.WaitAsync();
- try { SortOne(entry, file); } // off-thread: planning and the silent move never touch the UI
finally { _moveGate.Release(); }
}
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
catch (Exception ex) { ErrorLog.Write($"Error waiting for file '{file}'", ex); }
- finally { _inFlight.TryRemove(file, out _); }
+ finally
+ {
+ _inFlight.TryRemove(file, out _);
+ entry.CompleteWork();
+ }
+ }
+
+ internal static async Task WaitUntilReadyAsync(
+ string file,
+ Func isReady,
+ int pollMs,
+ int maxWaitTicks,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ for (int i = 0; i < maxWaitTicks; i++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (Directory.Exists(file)) return false; // a folder appeared, not a file
+ if (!File.Exists(file)) return false; // the file vanished while we waited
+ if (isReady(file)) return true; // the writing process released it - fully written
+ if (i == maxWaitTicks - 1)
+ {
+ ErrorLog.Write($"File '{file}' stays locked - auto-sort skipped");
+ return false;
+ }
+ await Task.Delay(pollMs, cancellationToken);
+ }
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return false;
+ }
+
+ return false;
}
/// A file counts as fully written once it can be opened exclusively: while a copy or
@@ -168,16 +273,20 @@ private static bool IsReady(string file)
catch (UnauthorizedAccessException) { return false; }
}
- private void SortOne(Entry entry, string file)
+ private void SortOne(Entry entry, string file, CancellationToken cancellationToken)
{
try
{
+ cancellationToken.ThrowIfCancellationRequested();
if (!File.Exists(file)) return;
var plan = SortService.MovePlan(entry.Target, new[] { file });
foreach (var (folder, files) in plan)
{
+ cancellationToken.ThrowIfCancellationRequested();
+ if (SameFolder(folder, file)) continue; // file stays in its own folder - no move, no loop
// Create the destination folder first: otherwise SHFileOperation moving a single file
// to a non-existent path treats the last segment as a new file name, not a folder.
+ cancellationToken.ThrowIfCancellationRequested();
Directory.CreateDirectory(folder);
var conflicts = FileOps.DestinationConflicts(files, folder);
if (conflicts.Length > 0)
@@ -185,12 +294,17 @@ private void SortOne(Entry entry, string file)
ErrorLog.Write($"Auto-sort skipped '{file}' because destination already exists: '{conflicts[0]}'");
continue;
}
+ cancellationToken.ThrowIfCancellationRequested();
if (FileOps.Execute(files, folder, DropAction.Move, silent: true))
- _ui.InvokeAsync(() => QueueToast(files.Length)); // coalesce the toast on the UI thread
+ {
+ if (!cancellationToken.IsCancellationRequested)
+ _ui.InvokeAsync(() => QueueToast(files.Length)); // coalesce the toast on the UI thread
+ }
else
ErrorLog.Write($"Failed to move '{file}' to '{folder}'");
}
}
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
catch (Exception ex) { ErrorLog.Write($"Auto-sort of '{file}' failed", ex); }
}
diff --git a/src/Dropwheel/UI/OverlayWindow.Bubble.Wire.cs b/src/Dropwheel/UI/OverlayWindow.Bubble.Wire.cs
index 005b9fd..c20d03c 100644
--- a/src/Dropwheel/UI/OverlayWindow.Bubble.Wire.cs
+++ b/src/Dropwheel/UI/OverlayWindow.Bubble.Wire.cs
@@ -78,7 +78,7 @@ private StackPanel WireBubble(TargetItem t, Border badge, FrameworkElement label
if (t.IsGroup)
{
StartGroupHover(t, back: false);
- e.Effects = DragDropEffects.Link;
+ e.Effects = CanAddTarget(e.Data) ? AddTargetDropEffect(e) : DragDropEffects.None;
e.Handled = true;
}
else OnBubbleDragOver(t, badge, e);
diff --git a/src/Dropwheel/UI/OverlayWindow.Bubble.cs b/src/Dropwheel/UI/OverlayWindow.Bubble.cs
index 5e3c364..fa5a540 100644
--- a/src/Dropwheel/UI/OverlayWindow.Bubble.cs
+++ b/src/Dropwheel/UI/OverlayWindow.Bubble.cs
@@ -27,7 +27,7 @@ private FrameworkElement MakeBubble(TargetItem t)
{
Width = 32,
Height = 32,
- Source = IconService.GetIcon(t.Path),
+ Source = IconService.GetIcon(t),
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
diff --git a/src/Dropwheel/UI/OverlayWindow.Dnd.cs b/src/Dropwheel/UI/OverlayWindow.Dnd.cs
index 02f7d92..aca81d3 100644
--- a/src/Dropwheel/UI/OverlayWindow.Dnd.cs
+++ b/src/Dropwheel/UI/OverlayWindow.Dnd.cs
@@ -21,7 +21,27 @@ private void OnBubbleDragOver(TargetItem t, Border badge, DragEventArgs e)
{
bool real = e.Data.GetDataPresent(DataFormats.FileDrop);
bool virt = !real && VirtualFileService.HasVirtualFiles(e.Data);
- bool text = !real && !virt && TextDropService.HasText(e.Data);
+ bool link = !real && !virt && LinkTargetService.HasLaunchUri(e.Data);
+ bool text = !real && !virt && !link && TextDropService.HasText(e.Data);
+
+ if (TelegramDropService.CanAccept(t, e.Data))
+ {
+ var effect = TelegramDropEffect(e);
+ if (effect == DragDropEffects.None)
+ {
+ e.Effects = DragDropEffects.None;
+ e.Handled = true;
+ return;
+ }
+
+ var telegramText = !real && !virt && TextDropService.HasText(e.Data);
+ e.Effects = effect;
+ ((TextBlock)badge.Child).Text = telegramText ? "≡" : "⧉";
+ badge.Background = Brushes.CornflowerBlue;
+ badge.Visibility = Visibility.Visible;
+ e.Handled = true;
+ return;
+ }
if (real && LaunchService.IsRunTarget(t)) // drop files on an exe/script → run it (open with)
{
@@ -32,13 +52,14 @@ private void OnBubbleDragOver(TargetItem t, Border badge, DragEventArgs e)
e.Handled = true;
return;
}
- if ((!real && !virt && !text) || !LaunchService.IsFolderTarget(t))
+ if ((!real && !virt && !link && !text) || !LaunchService.IsFolderTarget(t))
{ e.Effects = DragDropEffects.None; e.Handled = true; return; }
var act = virt || text ? DropAction.Copy : Resolve(t, e); // virtual files and text: copy only
- e.Effects = act == DropAction.Move ? DragDropEffects.Move : DragDropEffects.Copy;
+ e.Effects = link ? AddTargetDropEffect(e) : act == DropAction.Move ? DragDropEffects.Move : DragDropEffects.Copy;
((TextBlock)badge.Child).Text = t.IsSorter ? "⇅" : text ? "≡" : act == DropAction.Move ? "➜" : "⧉";
- badge.Background = act == DropAction.Move ? Brushes.Orange : Brushes.MediumSpringGreen;
+ if (link) ((TextBlock)badge.Child).Text = "+";
+ badge.Background = link ? Brushes.CornflowerBlue : act == DropAction.Move ? Brushes.Orange : Brushes.MediumSpringGreen;
badge.Visibility = Visibility.Visible;
e.Handled = true;
}
@@ -58,6 +79,31 @@ private void OnBubbleDrop(TargetItem t, Border badge, DragEventArgs e)
private void OnBubbleDropCore(TargetItem t, DragEventArgs e)
{
+ if (TelegramDropService.CanAccept(t, e.Data))
+ {
+ var result = TelegramDropService.CopyToClipboard(
+ e.Data,
+ System.IO.Path.Combine(TargetStore.Dir, "telegram-drop"));
+
+ if (result == null)
+ {
+ ErrorLog.Write(
+ $"Telegram drop had no extractable payload. AllowedEffects={e.AllowedEffects}; Formats={TextDropService.DescribeFormats(e.Data)}");
+ ShowToast("Nothing to send");
+ return;
+ }
+
+ LaunchService.Launch(new TargetItem { Name = t.Name, Path = TelegramDropService.LaunchPathFor(t) });
+ TelegramDropService.PasteIntoTelegramWhenReady();
+ e.Effects = e.AllowedEffects.HasFlag(DragDropEffects.Copy)
+ ? DragDropEffects.Copy
+ : DragDropEffects.None;
+ ShowToast(result.Kind == TelegramDropKind.Files
+ ? $"⧉ Copied {result.Count} file(s); pasting in Telegram"
+ : "≡ Copied text; pasting in Telegram");
+ return;
+ }
+
// A shortcut target (.lnk) to a folder stores the shortcut's own path — resolve it so files
// land in the target folder, not next to the .lnk.
var dest = LaunchService.DestPath(t);
@@ -96,6 +142,13 @@ private void OnBubbleDropCore(TargetItem t, DragEventArgs e)
? $"⧉ Saved: {saved.Length} item(s) → {t.Name}"
: "Nothing to save", saved.Length > 0);
}
+ else if (AddTargetsFromDrop(e.Data, _currentGroup))
+ {
+ }
+ else if (LinkTargetService.HasSavedMessagesLabel(e.Data))
+ {
+ ShowToast("Saved Messages target was not added");
+ }
else if (TextDropService.HasText(e.Data))
{
var saved = TextDropService.SaveFrom(e.Data, dest, DateTime.Now);
@@ -109,4 +162,12 @@ private void OnBubbleDropCore(TargetItem t, DragEventArgs e)
: "No text to save", saved != null);
}
}
+
+ private static DragDropEffects TelegramDropEffect(DragEventArgs e)
+ {
+ if (e.AllowedEffects.HasFlag(DragDropEffects.Copy)) return DragDropEffects.Copy;
+ if (e.AllowedEffects.HasFlag(DragDropEffects.Move)) return DragDropEffects.Move;
+ if (e.AllowedEffects.HasFlag(DragDropEffects.Link)) return DragDropEffects.Link;
+ return DragDropEffects.None;
+ }
}
diff --git a/src/Dropwheel/UI/OverlayWindow.OrbDrop.cs b/src/Dropwheel/UI/OverlayWindow.OrbDrop.cs
index 035cc9b..9049aad 100644
--- a/src/Dropwheel/UI/OverlayWindow.OrbDrop.cs
+++ b/src/Dropwheel/UI/OverlayWindow.OrbDrop.cs
@@ -11,9 +11,7 @@ public partial class OverlayWindow
/// the target goes into it; otherwise into the root.
private void OnOrbDrop(object sender, DragEventArgs e)
{
- if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths || paths.Length == 0) return;
- AddTargets(paths, _currentGroup);
- e.Handled = true;
+ if (AddTargetsFromDrop(e.Data, _currentGroup)) e.Handled = true;
}
/// A quick drop on a group bubble (before hover-expand fires)
@@ -21,28 +19,146 @@ private void OnOrbDrop(object sender, DragEventArgs e)
private void OnGroupDrop(TargetItem group, DragEventArgs e)
{
_groupHover?.Stop();
- if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths || paths.Length == 0) return;
- AddTargets(paths, group);
+ if (AddTargetsFromDrop(e.Data, group)) e.Handled = true;
+ }
+
+ private void OnAddTargetDragOver(object sender, DragEventArgs e)
+ {
+ e.Effects = CanAddTarget(e.Data) ? AddTargetDropEffect(e) : DragDropEffects.None;
e.Handled = true;
}
- private void AddTargets(string[] paths, TargetItem? group)
+ private static bool CanAddTarget(IDataObject data) =>
+ data.GetDataPresent(DataFormats.FileDrop) || LinkTargetService.HasPotentialLaunchUriData(data);
+
+ private static DragDropEffects AddTargetDropEffect(DragEventArgs e)
{
- var list = group?.Children ?? TargetStore.Config.Targets;
- foreach (var p in paths)
+ if (e.AllowedEffects.HasFlag(DragDropEffects.Copy)) return DragDropEffects.Copy;
+ if (e.AllowedEffects.HasFlag(DragDropEffects.Link)) return DragDropEffects.Link;
+ return DragDropEffects.None;
+ }
+
+ private bool AddTargetsFromDrop(IDataObject data, TargetItem? group)
+ {
+ if (data.GetData(DataFormats.FileDrop) is string[] paths && paths.Length > 0)
{
- // A dropped .lnk becomes a target for what it points at, not the shortcut file.
- var target = ShortcutResolver.Resolve(p);
- // Keep the shortcut's friendly label (e.g. "Visual Studio Code") over the raw target name.
- var name = IOPath.GetFileNameWithoutExtension(p);
- if (string.IsNullOrEmpty(name)) name = IOPath.GetFileNameWithoutExtension(target);
- if (string.IsNullOrEmpty(name)) name = target;
- list.Add(new TargetItem { Name = name, Path = target });
+ AddTargets(paths.Select(TargetFromPath), group);
+ return true;
}
+
+ if (LinkTargetService.CreateTarget(data) is { } linkTarget)
+ {
+ AddTargets(new[] { linkTarget }, group);
+ return true;
+ }
+
+ if (LinkTargetService.HasSavedMessagesLabel(data)
+ && PromptSavedMessagesTarget() is { } savedMessagesTarget)
+ {
+ AddTargets(new[] { savedMessagesTarget }, group);
+ return true;
+ }
+
+ return false;
+ }
+
+ private TargetItem? PromptSavedMessagesTarget()
+ {
+ var prompt = new PromptWindow(
+ "Telegram Saved Messages",
+ "Enter your Telegram username or phone number:")
+ { Owner = this };
+
+ if (prompt.ShowDialog() != true) return null;
+ if (LinkTargetService.CreateSavedMessagesTarget(prompt.Value) is { } target) return target;
+
+ ShowToast("Saved Messages target needs a username or phone");
+ return null;
+ }
+
+ private static TargetItem TargetFromPath(string path)
+ {
+ // A dropped .lnk becomes a target for what it points at, not the shortcut file.
+ var target = ShortcutResolver.Resolve(path);
+ // Keep the shortcut's friendly label (e.g. "Visual Studio Code") over the raw target name.
+ var name = IOPath.GetFileNameWithoutExtension(path);
+ if (string.IsNullOrEmpty(name)) name = IOPath.GetFileNameWithoutExtension(target);
+ if (string.IsNullOrEmpty(name)) name = target;
+ return new TargetItem { Name = name, Path = target };
+ }
+
+ private void AddTargets(IEnumerable targets, TargetItem? group)
+ {
+ var items = targets.ToArray();
+ if (items.Length == 0) return;
+
+ var list = group?.Children ?? TargetStore.Config.Targets;
+ foreach (var item in items) list.Add(item);
+
TargetStore.Save();
ShowToast(group == null
- ? $"Targets added: {paths.Length}"
- : $"Added to {group.Name}: {paths.Length}");
+ ? $"Targets added: {items.Length}"
+ : $"Added to {group.Name}: {items.Length}");
if (_open) BuildCloud();
+ RefreshLinkMetadata(items);
+ }
+
+ private void RefreshLinkMetadata(TargetItem[] items)
+ {
+ var pending = items
+ .Where(item => LinkMetadataService.SourceUri(item) != null)
+ .Select(item => new PendingLinkMetadata(item, item.Name))
+ .ToArray();
+ if (pending.Length == 0) return;
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ var updates = new List<(PendingLinkMetadata Pending, LinkMetadataUpdate Update)>();
+ foreach (var item in pending)
+ {
+ if (await LinkMetadataService.FetchAsync(item.Target) is { } update)
+ updates.Add((item, update));
+ }
+
+ if (updates.Count == 0) return;
+
+ await Dispatcher.InvokeAsync(() =>
+ {
+ var changed = false;
+ foreach (var (pendingItem, update) in updates)
+ {
+ var target = pendingItem.Target;
+ if (!TargetStore.AllTargets.Any(item => ReferenceEquals(item, target))) continue;
+
+ if (!string.IsNullOrWhiteSpace(update.Title)
+ && target.Name == pendingItem.OriginalName
+ && target.Name != update.Title)
+ {
+ target.Name = update.Title;
+ changed = true;
+ }
+
+ if (!string.IsNullOrWhiteSpace(update.IconPath)
+ && target.IconPath != update.IconPath)
+ {
+ target.IconPath = update.IconPath;
+ changed = true;
+ }
+ }
+
+ if (!changed) return;
+ TargetStore.Save();
+ if (_open) BuildCloud();
+ });
+ }
+ catch (Exception ex)
+ {
+ ErrorLog.Write("Could not refresh link metadata", ex);
+ }
+ });
}
+
+ private sealed record PendingLinkMetadata(TargetItem Target, string OriginalName);
}
diff --git a/src/Dropwheel/UI/OverlayWindow.Plus.cs b/src/Dropwheel/UI/OverlayWindow.Plus.cs
index ad92bf8..dec75f5 100644
--- a/src/Dropwheel/UI/OverlayWindow.Plus.cs
+++ b/src/Dropwheel/UI/OverlayWindow.Plus.cs
@@ -52,7 +52,7 @@ private FrameworkElement MakePlusTile()
e.Handled = true;
return;
}
- e.Effects = DragDropEffects.Link;
+ e.Effects = CanAddTarget(e.Data) ? AddTargetDropEffect(e) : DragDropEffects.None;
e.Handled = true;
};
panel.Drop += (_, e) =>
diff --git a/src/Dropwheel/UI/OverlayWindow.Sort.cs b/src/Dropwheel/UI/OverlayWindow.Sort.cs
index c9dbd00..bd00838 100644
--- a/src/Dropwheel/UI/OverlayWindow.Sort.cs
+++ b/src/Dropwheel/UI/OverlayWindow.Sort.cs
@@ -7,18 +7,39 @@ namespace Dropwheel.UI;
public partial class OverlayWindow
{
+ internal readonly record struct SorterExecutionGroup(string Folder, string[] Sources);
+
+ internal static bool SameNormalizedFolder(string left, string right) =>
+ string.Equals(
+ IOPath.TrimEndingDirectorySeparator(IOPath.GetFullPath(left)),
+ IOPath.TrimEndingDirectorySeparator(IOPath.GetFullPath(right)),
+ StringComparison.OrdinalIgnoreCase);
+
+ internal static IReadOnlyList ExecutableSorterGroups(
+ Dictionary> plan)
+ {
+ var groups = new List();
+ foreach (var (folder, group) in plan)
+ {
+ var sources = group
+ .Where(source => !WatcherService.SameFolder(folder, source))
+ .ToArray();
+ if (sources.Length > 0) groups.Add(new SorterExecutionGroup(folder, sources));
+ }
+ return groups;
+ }
+
/// Real files dropped on a sorter target: distribute by the rules.
private void DropSorted(TargetItem t, string[] files, DropAction act)
{
var plan = SortService.Plan(t, files);
bool ok = true;
var ops = new List();
- foreach (var (folder, group) in plan)
+ foreach (var group in ExecutableSorterGroups(plan))
{
- Directory.CreateDirectory(folder);
- var sources = group.ToArray();
- var op = BuildOpBefore(act, sources, folder);
- if (FileOps.Execute(sources, folder, act)) ops.Add(op);
+ Directory.CreateDirectory(group.Folder);
+ var op = BuildOpBefore(act, group.Sources, group.Folder);
+ if (FileOps.Execute(group.Sources, group.Folder, act)) ops.Add(op);
else ok = false;
}
if (ops.Count > 0) RememberOps(ops);
@@ -33,10 +54,10 @@ private void SortSavedVirtuals(TargetItem t, string[] saved)
{
var plan = SortService.Plan(t, saved);
var ops = new List();
- string root = IOPath.GetFullPath(t.Path).TrimEnd('\\');
+ string root = t.Path;
foreach (var (folder, group) in plan)
{
- if (IOPath.GetFullPath(folder).TrimEnd('\\') == root)
+ if (SameNormalizedFolder(folder, root))
{ ops.Add(BuildCreatedCopyOp(group.ToArray(), folder)); continue; }
Directory.CreateDirectory(folder);
var sources = group.ToArray();
diff --git a/src/Dropwheel/UI/OverlayWindow.xaml.cs b/src/Dropwheel/UI/OverlayWindow.xaml.cs
index bf1bb5b..0853ad6 100644
--- a/src/Dropwheel/UI/OverlayWindow.xaml.cs
+++ b/src/Dropwheel/UI/OverlayWindow.xaml.cs
@@ -44,6 +44,7 @@ public OverlayWindow()
Orb.MouseLeave += (_, _) => _hoverTimer.Stop();
Orb.MouseLeftButtonDown += OnOrbMouseDown;
Orb.DragEnter += (_, _) => { _closeTimer.Stop(); OpenCloud(); };
+ Orb.DragOver += OnAddTargetDragOver;
Orb.Drop += OnOrbDrop; // dropping on the orb adds a target
var orbMenu = new System.Windows.Controls.ContextMenu();
diff --git a/tests/Dropwheel.Tests/AppConfigTests.cs b/tests/Dropwheel.Tests/AppConfigTests.cs
index eea79a2..12d6f7c 100644
--- a/tests/Dropwheel.Tests/AppConfigTests.cs
+++ b/tests/Dropwheel.Tests/AppConfigTests.cs
@@ -213,4 +213,74 @@ public void Load_does_not_overwrite_config_when_backup_fails()
Assert.Equal(original, File.ReadAllText(configPath));
Assert.Empty(Directory.GetFiles(_root, "config.bad.*.json"));
}
+
+ [Fact]
+ public void Load_preserves_config_when_top_level_enum_token_is_unknown()
+ {
+ var configPath = Path.Combine(_root, "config.json");
+ File.WriteAllText(configPath,
+ """
+ {
+ "GlobalAction": "Move",
+ "OpenAnimation": "FutureSpin",
+ "HoverDelayMs": 900,
+ "Targets": [
+ {
+ "Name": "Inbox",
+ "Path": "C:\\Temp\\Inbox",
+ "Override": "Copy",
+ "Pinned": true
+ }
+ ]
+ }
+ """);
+
+ TargetStore.Load();
+
+ Assert.Equal(DropAction.Move, TargetStore.Config.GlobalAction);
+ Assert.Equal(OpenAnimation.Pop, TargetStore.Config.OpenAnimation);
+ Assert.Equal(900, TargetStore.Config.HoverDelayMs);
+
+ var target = Assert.Single(TargetStore.Config.Targets);
+ Assert.Equal("Inbox", target.Name);
+ Assert.Equal("C:\\Temp\\Inbox", target.Path);
+ Assert.Equal(DropAction.Copy, target.Override);
+ Assert.True(target.Pinned);
+
+ var saved = File.ReadAllText(configPath);
+ Assert.Contains("\"OpenAnimation\": \"Pop\"", saved);
+ Assert.Contains("\"HoverDelayMs\": 900", saved);
+ Assert.Contains("\"Name\": \"Inbox\"", saved);
+ }
+
+ [Fact]
+ public void Load_preserves_targets_when_target_override_enum_token_is_unknown()
+ {
+ var configPath = Path.Combine(_root, "config.json");
+ File.WriteAllText(configPath,
+ """
+ {
+ "Targets": [
+ {
+ "Name": "Archive",
+ "Path": "C:\\Temp\\Archive",
+ "Override": "Teleport",
+ "Pinned": true
+ }
+ ]
+ }
+ """);
+
+ TargetStore.Load();
+
+ var target = Assert.Single(TargetStore.Config.Targets);
+ Assert.Equal("Archive", target.Name);
+ Assert.Equal("C:\\Temp\\Archive", target.Path);
+ Assert.Equal(DropAction.Inherit, target.Override);
+ Assert.True(target.Pinned);
+
+ var saved = File.ReadAllText(configPath);
+ Assert.Contains("\"Override\": \"Inherit\"", saved);
+ Assert.Contains("\"Name\": \"Archive\"", saved);
+ }
}
diff --git a/tests/Dropwheel.Tests/ExecutableTargetTests.cs b/tests/Dropwheel.Tests/ExecutableTargetTests.cs
index 4d6d0f6..bd464af 100644
--- a/tests/Dropwheel.Tests/ExecutableTargetTests.cs
+++ b/tests/Dropwheel.Tests/ExecutableTargetTests.cs
@@ -31,6 +31,19 @@ public void Group_is_never_executable()
Assert.False(t.IsExecutable);
}
+ [Theory]
+ [InlineData("tg://resolve?domain=telegram")]
+ [InlineData("https://t.me/telegram")]
+ [InlineData("https://example.com/docs")]
+ public void Uri_targets_count_as_existing_quick_access_targets(string path)
+ {
+ var t = new TargetItem { Name = "link", Path = path };
+
+ Assert.True(t.IsUri);
+ Assert.True(t.Exists);
+ Assert.False(t.IsExecutable);
+ }
+
[Fact]
public void BuildArgs_quotes_and_joins_paths()
{
diff --git a/tests/Dropwheel.Tests/LinkMetadataServiceTests.cs b/tests/Dropwheel.Tests/LinkMetadataServiceTests.cs
new file mode 100644
index 0000000..38a0d5c
--- /dev/null
+++ b/tests/Dropwheel.Tests/LinkMetadataServiceTests.cs
@@ -0,0 +1,53 @@
+using Dropwheel.Models;
+using Dropwheel.Services;
+
+namespace Dropwheel.Tests;
+
+public sealed class LinkMetadataServiceTests
+{
+ [Fact]
+ public void SourceUri_prefers_original_browser_url()
+ {
+ var target = new TargetItem
+ {
+ Path = "tg://privatepost?channel=1&post=2",
+ SourceUrl = "https://t.me/c/1/2",
+ };
+
+ Assert.Equal("https://t.me/c/1/2", LinkMetadataService.SourceUri(target)?.AbsoluteUri.TrimEnd('/'));
+ }
+
+ [Fact]
+ public void ExtractTitle_decodes_and_compacts_page_title()
+ {
+ const string html = " Example &\n Docs ";
+
+ Assert.Equal("Example & Docs", LinkMetadataService.ExtractTitle(html));
+ }
+
+ [Fact]
+ public void ExtractIconUri_reads_relative_icon_link()
+ {
+ var page = new Uri("https://example.com/docs/page");
+ const string html = "";
+
+ Assert.Equal("https://example.com/assets/favicon.png", LinkMetadataService.ExtractIconUri(page, html)?.AbsoluteUri);
+ }
+
+ [Fact]
+ public void ExtractIconUri_falls_back_to_favicon_ico()
+ {
+ var page = new Uri("https://example.com/docs/page");
+
+ Assert.Equal("https://example.com/favicon.ico", LinkMetadataService.ExtractIconUri(page, "")?.AbsoluteUri);
+ }
+
+ [Fact]
+ public void ExtractIconUri_skips_svg_icons()
+ {
+ var page = new Uri("https://example.com/docs/page");
+ const string html = "";
+
+ Assert.Equal("https://example.com/icon.png", LinkMetadataService.ExtractIconUri(page, html)?.AbsoluteUri);
+ }
+}
diff --git a/tests/Dropwheel.Tests/LinkTargetServiceTests.cs b/tests/Dropwheel.Tests/LinkTargetServiceTests.cs
new file mode 100644
index 0000000..4bc2d1b
--- /dev/null
+++ b/tests/Dropwheel.Tests/LinkTargetServiceTests.cs
@@ -0,0 +1,146 @@
+using System.Windows;
+using Dropwheel.Services;
+using WpfDataFormats = System.Windows.DataFormats;
+using WpfDataObject = System.Windows.DataObject;
+
+namespace Dropwheel.Tests;
+
+public sealed class LinkTargetServiceTests
+{
+ [Theory]
+ [InlineData("tg://resolve?domain=telegram", "tg://resolve?domain=telegram", "Telegram: telegram")]
+ [InlineData("Open https://t.me/telegram.", "tg://resolve?domain=telegram", "Telegram: telegram")]
+ [InlineData("t.me/c/2669588230/1", "tg://privatepost?channel=2669588230&post=1", "Telegram topic")]
+ [InlineData("t.me/c/2669588230/1/2", "tg://privatepost?channel=2669588230&topic=1&post=2", "Telegram topic")]
+ [InlineData("https://telegram.me/durov", "tg://resolve?domain=durov", "Telegram: durov")]
+ [InlineData("https://t.me/group/1/2", "tg://resolve?domain=group&topic=1&post=2", "Telegram: group")]
+ [InlineData("durov.t.me", "tg://resolve?domain=durov", "Telegram")]
+ [InlineData("https://t.me/+abcdef", "tg://join?invite=abcdef", "Telegram invite")]
+ public void CreateTarget_extracts_telegram_links(string text, string expectedPath, string expectedName)
+ {
+ var target = LinkTargetService.CreateTarget(text);
+
+ Assert.NotNull(target);
+ Assert.Equal(expectedPath, target.Path);
+ Assert.Equal(expectedName, target.Name);
+ }
+
+ [Fact]
+ public void CreateTarget_ignores_plain_text()
+ {
+ Assert.Null(LinkTargetService.CreateTarget("not a link"));
+ }
+
+ [Theory]
+ [InlineData("Saved Messages")]
+ [InlineData("Saved message")]
+ [InlineData("Избранное")]
+ public void HasSavedMessagesLabel_detects_saved_messages_chat(string text)
+ {
+ var data = new WpfDataObject();
+ data.SetData(WpfDataFormats.UnicodeText, text);
+
+ Assert.True(LinkTargetService.HasSavedMessagesLabel(data));
+ }
+
+ [Theory]
+ [InlineData("@durov", "tg://resolve?domain=durov")]
+ [InlineData("durov", "tg://resolve?domain=durov")]
+ [InlineData("+15555550123", "tg://resolve?phone=%2B15555550123")]
+ [InlineData("https://t.me/telegram", "tg://resolve?domain=telegram")]
+ public void CreateSavedMessagesTarget_builds_self_chat_target(string account, string expectedPath)
+ {
+ var target = LinkTargetService.CreateSavedMessagesTarget(account);
+
+ Assert.NotNull(target);
+ Assert.Equal("Saved Messages", target.Name);
+ Assert.Equal(expectedPath, target.Path);
+ }
+
+ [Fact]
+ public void CreateTarget_accepts_unicode_text_drop_data()
+ {
+ var data = new WpfDataObject();
+ data.SetData(WpfDataFormats.UnicodeText, "t.me/c/2669588230/1");
+
+ Assert.True(LinkTargetService.HasPotentialLaunchUriData(data));
+
+ var target = LinkTargetService.CreateTarget(data);
+
+ Assert.NotNull(target);
+ Assert.Equal("tg://privatepost?channel=2669588230&post=1", target.Path);
+ Assert.Equal("Telegram topic", target.Name);
+ }
+
+ [Fact]
+ public void CreateTarget_accepts_browser_url_drop_format()
+ {
+ var data = new WpfDataObject();
+ data.SetData("UniformResourceLocatorW", "https://t.me/telegram");
+
+ Assert.True(LinkTargetService.HasPotentialLaunchUriData(data));
+
+ var target = LinkTargetService.CreateTarget(data);
+
+ Assert.NotNull(target);
+ Assert.Equal("tg://resolve?domain=telegram", target.Path);
+ Assert.Equal("Telegram: telegram", target.Name);
+ }
+
+ [Fact]
+ public void CreateTarget_uses_browser_drop_title_when_available()
+ {
+ var data = new WpfDataObject();
+ data.SetData("text/x-moz-url", "https://example.com/article\r\nReadable Article");
+
+ var target = LinkTargetService.CreateTarget(data);
+
+ Assert.NotNull(target);
+ Assert.Equal("https://example.com/article", target.Path);
+ Assert.Equal("https://example.com/article", target.SourceUrl);
+ Assert.Equal("Readable Article", target.Name);
+ }
+
+ [Fact]
+ public void CreateTarget_keeps_source_url_for_telegram_web_links()
+ {
+ var data = new WpfDataObject();
+ data.SetData("text/x-moz-url", "https://t.me/c/2669588230/1\r\nGeneral");
+
+ var target = LinkTargetService.CreateTarget(data);
+
+ Assert.NotNull(target);
+ Assert.Equal("tg://privatepost?channel=2669588230&post=1", target.Path);
+ Assert.Equal("https://t.me/c/2669588230/1", target.SourceUrl);
+ Assert.Equal("General", target.Name);
+ }
+
+ [Fact]
+ public void CreateTarget_uses_html_anchor_text_when_title_is_missing()
+ {
+ const string html = "Version:0.9\r\nExample & Docs";
+ var data = new WpfDataObject();
+ data.SetData(WpfDataFormats.Html, html);
+
+ var target = LinkTargetService.CreateTarget(data);
+
+ Assert.NotNull(target);
+ Assert.Equal("https://example.com/x", target.Path);
+ Assert.Equal("Example & Docs", target.Name);
+ }
+
+ [Fact]
+ public void CreateTarget_prefers_titled_browser_candidate_over_url_only_candidate()
+ {
+ const string html = "Version:0.9\r\nExample Docs";
+ var data = new WpfDataObject();
+ data.SetData("UniformResourceLocatorW", "https://example.com/x");
+ data.SetData(WpfDataFormats.Html, html);
+
+ var target = LinkTargetService.CreateTarget(data);
+
+ Assert.NotNull(target);
+ Assert.Equal("https://example.com/x", target.Path);
+ Assert.Equal("Example Docs", target.Name);
+ }
+}
diff --git a/tests/Dropwheel.Tests/OverlaySortTests.cs b/tests/Dropwheel.Tests/OverlaySortTests.cs
new file mode 100644
index 0000000..08b0b0e
--- /dev/null
+++ b/tests/Dropwheel.Tests/OverlaySortTests.cs
@@ -0,0 +1,42 @@
+using System.IO;
+using Dropwheel.UI;
+
+namespace Dropwheel.Tests;
+
+public sealed class OverlaySortTests : IDisposable
+{
+ private readonly string _root = Path.Combine(Path.GetTempPath(), "dw_sort_overlay_" + Guid.NewGuid().ToString("N"));
+
+ public OverlaySortTests() => Directory.CreateDirectory(_root);
+ public void Dispose() { try { Directory.Delete(_root, true); } catch (DirectoryNotFoundException) { } }
+
+ [Fact]
+ public void ExecutableSorterGroups_skips_sources_already_in_destination_folder()
+ {
+ var inRoot = Path.Combine(_root, "already-here.txt");
+ var incomingDir = Path.Combine(_root, "incoming");
+ var incoming = Path.Combine(incomingDir, "move-me.txt");
+ Directory.CreateDirectory(incomingDir);
+ File.WriteAllText(inRoot, "same-folder");
+ File.WriteAllText(incoming, "incoming");
+ var plan = new Dictionary>(StringComparer.OrdinalIgnoreCase)
+ {
+ [_root] = new() { inRoot, incoming },
+ };
+
+ var groups = OverlayWindow.ExecutableSorterGroups(plan);
+
+ var group = Assert.Single(groups);
+ Assert.Equal(_root, group.Folder);
+ Assert.Equal(new[] { incoming }, group.Sources);
+ }
+
+ [Fact]
+ public void SameNormalizedFolder_ignores_trailing_separator_and_case()
+ {
+ var canonical = _root;
+ var variant = canonical.ToUpperInvariant() + Path.DirectorySeparatorChar;
+
+ Assert.True(OverlayWindow.SameNormalizedFolder(canonical, variant));
+ }
+}
diff --git a/tests/Dropwheel.Tests/TelegramDropServiceTests.cs b/tests/Dropwheel.Tests/TelegramDropServiceTests.cs
new file mode 100644
index 0000000..53d31a5
--- /dev/null
+++ b/tests/Dropwheel.Tests/TelegramDropServiceTests.cs
@@ -0,0 +1,141 @@
+using System.IO;
+using System.Windows;
+using Dropwheel.Models;
+using Dropwheel.Services;
+using WpfDataObject = System.Windows.DataObject;
+using WpfDataFormats = System.Windows.DataFormats;
+
+namespace Dropwheel.Tests;
+
+public sealed class TelegramDropServiceTests : IDisposable
+{
+ private readonly string _root = Path.Combine(Path.GetTempPath(), "dw_tgdrop_" + Guid.NewGuid().ToString("N"));
+
+ public TelegramDropServiceTests() => Directory.CreateDirectory(_root);
+ public void Dispose() { try { Directory.Delete(_root, true); } catch (DirectoryNotFoundException) { } }
+
+ [Theory]
+ [InlineData("tg://privatepost?channel=4379453334&post=1", true)]
+ [InlineData("tg://resolve?domain=telegram", true)]
+ [InlineData("https://t.me/c/4379453334/1", true)]
+ [InlineData("https://durov.t.me", true)]
+ [InlineData("https://example.com/file", false)]
+ [InlineData("C:\\Temp\\file.txt", false)]
+ public void IsTelegramTarget_detects_telegram_uri_targets(string path, bool expected)
+ {
+ var target = new TargetItem { Name = "target", Path = path };
+
+ Assert.Equal(expected, TelegramDropService.IsTelegramTarget(target));
+ }
+
+ [Fact]
+ public void IsTelegramTarget_rejects_groups()
+ {
+ var target = new TargetItem
+ {
+ Name = "group",
+ Path = "tg://resolve?domain=telegram",
+ Children = new(),
+ };
+
+ Assert.False(TelegramDropService.IsTelegramTarget(target));
+ }
+
+ [Fact]
+ public void CanAccept_requires_telegram_target_and_payload()
+ {
+ var data = new WpfDataObject();
+ data.SetData(WpfDataFormats.UnicodeText, "hello");
+
+ Assert.True(TelegramDropService.CanAccept(
+ new TargetItem { Name = "topic", Path = "tg://privatepost?channel=4379453334&post=1" },
+ data));
+ Assert.False(TelegramDropService.CanAccept(
+ new TargetItem { Name = "web", Path = "https://example.com" },
+ data));
+ }
+
+ [Fact]
+ public void LaunchPathFor_converts_web_topic_link_to_desktop_deep_link()
+ {
+ var target = new TargetItem { Name = "topic", Path = "https://t.me/c/4379453334/1" };
+
+ Assert.Equal("tg://privatepost?channel=4379453334&post=1", TelegramDropService.LaunchPathFor(target));
+ }
+
+ [Theory]
+ [InlineData("Telegram", true)]
+ [InlineData("telegramdesktop", true)]
+ [InlineData("Dropwheel", false)]
+ [InlineData(null, false)]
+ public void IsTelegramProcessName_matches_only_telegram_processes(string? processName, bool expected) =>
+ Assert.Equal(expected, TelegramDropService.IsTelegramProcessName(processName));
+
+ [Fact]
+ public async Task PasteIntoTelegramWhenReady_does_not_paste_when_telegram_is_not_foreground()
+ {
+ var pasted = false;
+
+ var result = await TelegramDropService.PasteIntoTelegramWhenReady(
+ TimeSpan.Zero,
+ TimeSpan.Zero,
+ () => pasted = true,
+ () => "Dropwheel");
+
+ Assert.False(result);
+ Assert.False(pasted);
+ }
+
+ [Fact]
+ public async Task PasteIntoTelegramWhenReady_pastes_when_telegram_is_foreground()
+ {
+ var pasted = false;
+
+ var result = await TelegramDropService.PasteIntoTelegramWhenReady(
+ TimeSpan.Zero,
+ TimeSpan.Zero,
+ () => pasted = true,
+ () => "Telegram");
+
+ Assert.True(result);
+ Assert.True(pasted);
+ }
+
+ [Fact]
+ public void CreatePayload_prefers_file_drop_list_over_text()
+ {
+ var file = Path.Combine(_root, "note.txt");
+ File.WriteAllText(file, "hello");
+ var data = new WpfDataObject();
+ data.SetData(WpfDataFormats.FileDrop, new[] { file });
+ data.SetData(WpfDataFormats.UnicodeText, "fallback text");
+
+ var payload = TelegramDropService.CreatePayload(data, Path.Combine(_root, "staging"));
+
+ Assert.NotNull(payload);
+ Assert.Equal(TelegramDropKind.Files, payload.Kind);
+ Assert.Equal(new[] { file }, payload.Files);
+ }
+
+ [Fact]
+ public void CreatePayload_uses_text_when_no_files_are_present()
+ {
+ var data = new WpfDataObject();
+ data.SetData(WpfDataFormats.UnicodeText, "message");
+
+ var payload = TelegramDropService.CreatePayload(data, Path.Combine(_root, "staging"));
+
+ Assert.NotNull(payload);
+ Assert.Equal(TelegramDropKind.Text, payload.Kind);
+ Assert.Equal("message", payload.Text);
+ }
+
+ [Fact]
+ public void CreatePayload_ignores_missing_file_paths()
+ {
+ var data = new WpfDataObject();
+ data.SetData(WpfDataFormats.FileDrop, new[] { Path.Combine(_root, "missing.txt") });
+
+ Assert.Null(TelegramDropService.CreatePayload(data, Path.Combine(_root, "staging")));
+ }
+}
diff --git a/tests/Dropwheel.Tests/TextDropServiceTests.cs b/tests/Dropwheel.Tests/TextDropServiceTests.cs
index c4591bc..075505f 100644
--- a/tests/Dropwheel.Tests/TextDropServiceTests.cs
+++ b/tests/Dropwheel.Tests/TextDropServiceTests.cs
@@ -1,5 +1,8 @@
using System.IO;
+using System.Text;
using Dropwheel.Services;
+using WpfDataObject = System.Windows.DataObject;
+using WpfDataFormats = System.Windows.DataFormats;
namespace Dropwheel.Tests;
@@ -28,6 +31,75 @@ public void Hash_in_the_middle_is_not_a_heading()
Assert.Equal("txt", TextDropService.ExtensionFor("issue #42 was fixed"));
}
+ [Fact]
+ public void GetText_reads_string_format()
+ {
+ var data = new WpfDataObject();
+ data.SetData(WpfDataFormats.StringFormat, "from editor");
+
+ Assert.True(TextDropService.HasText(data));
+ Assert.Equal("from editor", TextDropService.GetText(data));
+ }
+
+ [Fact]
+ public void GetText_reads_oem_text_format()
+ {
+ var data = new WpfDataObject();
+ data.SetData(WpfDataFormats.OemText, "from editor");
+
+ Assert.True(TextDropService.HasText(data));
+ Assert.Equal("from editor", TextDropService.GetText(data));
+ }
+
+ [Fact]
+ public void GetText_reads_utf8_memory_stream_plain_text()
+ {
+ var data = new WpfDataObject();
+ data.SetData("text/plain", new MemoryStream(Encoding.UTF8.GetBytes("stream text\0")));
+
+ Assert.Equal("stream text", TextDropService.GetText(data));
+ }
+
+ [Fact]
+ public void GetText_reads_case_variant_text_plain_byte_array()
+ {
+ var data = new TestDataObject(
+ "text/plain;charset=UTF-8",
+ Encoding.UTF8.GetBytes("byte text\0"));
+
+ Assert.Equal("byte text", TextDropService.GetText(data));
+ }
+
+ [Fact]
+ public void GetText_reads_qt_wrapped_text_plain_stream()
+ {
+ var data = new WpfDataObject();
+ data.SetData(
+ "application/x-qt-windows-mime;value=\"text/plain\"",
+ new MemoryStream(Encoding.UTF8.GetBytes("wrapped text\0")));
+
+ Assert.Equal("wrapped text", TextDropService.GetText(data));
+ }
+
+ [Fact]
+ public void HasPotentialText_accepts_delayed_text_format()
+ {
+ var data = new TestDataObject("text/plain;charset=UTF-8");
+
+ Assert.True(TextDropService.HasPotentialText(data));
+ Assert.False(TextDropService.HasText(data));
+ }
+
+ [Fact]
+ public void GetText_reads_html_fragment_when_plain_text_is_missing()
+ {
+ const string html = "Version:0.9\r\nStartHTML:00000097\r\nEndHTML:00000165\r\nStartFragment:00000129\r\nEndFragment:00000133\r\nhi
there";
+ var data = new WpfDataObject();
+ data.SetData(WpfDataFormats.Html, html);
+
+ Assert.Equal("hi\nthere", TextDropService.GetText(data));
+ }
+
[Fact]
public void Save_writes_content_with_timestamped_name()
{
@@ -53,4 +125,37 @@ public void Save_avoids_collisions_within_the_same_second()
Assert.Equal("a", File.ReadAllText(first));
Assert.Equal("b", File.ReadAllText(second));
}
+
+ [Fact]
+ public void Save_avoids_collisions_with_existing_directory()
+ {
+ var occupied = Path.Combine(_root, "text_2026-07-06_23-15-04.txt");
+ Directory.CreateDirectory(occupied);
+
+ var path = TextDropService.Save("directory collision", _root, When);
+
+ Assert.Equal("text_2026-07-06_23-15-04 (2).txt", Path.GetFileName(path));
+ Assert.True(Directory.Exists(occupied));
+ Assert.Equal("directory collision", File.ReadAllText(path));
+ }
+
+ private sealed class TestDataObject(string format, object? value = null) : System.Windows.IDataObject
+ {
+ public object? GetData(string requestedFormat) =>
+ GetDataPresent(requestedFormat) ? value : null;
+
+ public object? GetData(Type format) => null;
+ public object? GetData(string requestedFormat, bool autoConvert) => GetData(requestedFormat);
+ public bool GetDataPresent(string requestedFormat) =>
+ string.Equals(format, requestedFormat, StringComparison.OrdinalIgnoreCase);
+
+ public bool GetDataPresent(Type format) => false;
+ public bool GetDataPresent(string format, bool autoConvert) => GetDataPresent(format);
+ public string[] GetFormats() => new[] { format };
+ public string[] GetFormats(bool autoConvert) => GetFormats();
+ public void SetData(string format, object data) => throw new NotSupportedException();
+ public void SetData(Type format, object data) => throw new NotSupportedException();
+ public void SetData(string format, object data, bool autoConvert) => throw new NotSupportedException();
+ public void SetData(object data) => throw new NotSupportedException();
+ }
}
diff --git a/tests/Dropwheel.Tests/WatcherServiceTests.cs b/tests/Dropwheel.Tests/WatcherServiceTests.cs
index 6c8e100..4a69d5b 100644
--- a/tests/Dropwheel.Tests/WatcherServiceTests.cs
+++ b/tests/Dropwheel.Tests/WatcherServiceTests.cs
@@ -1,4 +1,6 @@
using System.IO;
+using System.Reflection;
+using System.Windows.Threading;
using Dropwheel.Models;
using Dropwheel.Services;
@@ -48,7 +50,7 @@ public void No_match_resolves_to_own_folder_so_the_file_is_left_in_place()
{ Field = ConditionField.Extension, Op = CompareOp.In, Value = "jpg" } } } },
};
var folder = SortService.Plan(t, new[] { file }).Keys.Single();
- Assert.True(WatcherService.SameFolder(folder, file)); // stays in its own folder — don't move
+ Assert.True(WatcherService.SameFolder(folder, file)); // stays in its own folder - don't move
}
[Fact]
@@ -63,7 +65,82 @@ public void Matching_rule_routes_into_a_subfolder_so_the_file_moves()
{ Field = ConditionField.Extension, Op = CompareOp.In, Value = "jpg" } } } },
};
var folder = SortService.Plan(t, new[] { file }).Keys.Single();
- Assert.False(WatcherService.SameFolder(folder, file)); // routed into a subfolder — move it
+ Assert.False(WatcherService.SameFolder(folder, file)); // routed into a subfolder - move it
+ }
+
+ [Fact]
+ public void Auto_sort_skips_move_when_destination_already_has_a_conflicting_file()
+ {
+ var file = Path.Combine(_root, "a.jpg");
+ var destFolder = Path.Combine(_root, "Images");
+ Directory.CreateDirectory(destFolder);
+ File.WriteAllText(file, "source");
+ File.WriteAllText(Path.Combine(destFolder, "a.jpg"), "existing");
+
+ var target = new TargetItem
+ {
+ Path = _root,
+ Rules = new() { new SortRule { Dest = "Images", All = { new RuleCondition
+ { Field = ConditionField.Extension, Op = CompareOp.In, Value = "jpg" } } } },
+ };
+
+ var service = new WatcherService(Dispatcher.CurrentDispatcher, _ => { });
+ InvokeSortOne(service, target, file);
+
+ Assert.True(File.Exists(file));
+ Assert.Equal("source", File.ReadAllText(file));
+ Assert.Equal("existing", File.ReadAllText(Path.Combine(destFolder, "a.jpg")));
+ }
+
+ [Fact]
+ public async Task Wait_until_ready_returns_false_when_cancelled_before_file_is_ready()
+ {
+ var file = Path.Combine(_root, "locked.mov");
+ File.WriteAllBytes(file, Array.Empty());
+
+ using var cts = new CancellationTokenSource();
+ var waitTask = WatcherService.WaitUntilReadyAsync(
+ file,
+ _ => false,
+ pollMs: 1,
+ maxWaitTicks: 1000,
+ cts.Token);
+
+ cts.CancelAfter(TimeSpan.FromMilliseconds(10));
+
+ Assert.False(await waitTask);
+ }
+
+ [Fact]
+ public async Task Wait_until_ready_checks_cancellation_before_readiness()
+ {
+ var file = Path.Combine(_root, "ready.mov");
+ File.WriteAllBytes(file, Array.Empty());
+
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ var ready = await WatcherService.WaitUntilReadyAsync(
+ file,
+ _ => true,
+ pollMs: 1,
+ maxWaitTicks: 1,
+ cts.Token);
+
+ Assert.False(ready);
+ }
+
+ private static void InvokeSortOne(WatcherService service, TargetItem target, string file)
+ {
+ var entryType = typeof(WatcherService).GetNestedType("Entry", BindingFlags.NonPublic)
+ ?? throw new InvalidOperationException("WatcherService.Entry not found.");
+ var entry = Activator.CreateInstance(entryType, nonPublic: true)
+ ?? throw new InvalidOperationException("WatcherService.Entry could not be created.");
+ entryType.GetProperty("Target", BindingFlags.Instance | BindingFlags.Public)?.SetValue(entry, target);
+
+ var sortOne = typeof(WatcherService).GetMethod("SortOne", BindingFlags.Instance | BindingFlags.NonPublic)
+ ?? throw new InvalidOperationException("WatcherService.SortOne not found.");
+ sortOne.Invoke(service, new[] { entry, file, CancellationToken.None });
}
[Fact]