From f6781a836d176dc566a794fec0db1e5f50c5b704 Mon Sep 17 00:00:00 2001 From: modusensus Date: Tue, 10 Nov 2026 01:21:29 +0800 Subject: [PATCH 1/3] feat(skills): put the mode words where the slash picker can see them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker renders name and description and nothing else, so the six mode words (a094a98) were invisible at the one surface where they are typed: the word-to-stage mapping lived in the body, reachable only after the skill was already loaded, and every bare /euthyna therefore defaulted to the full audit — the longest path. Both editions' description now names the words as a menu and points at the table; the mapping itself stays owned by the body, so the word list still has exactly one authoritative home. Mirror guard green: 339 tests, 338 pass + the GNU-tar skip. --- .agents/skills/euthyna-en/SKILL.md | 2 +- .agents/skills/euthyna/SKILL.md | 2 +- AGENTS.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/skills/euthyna-en/SKILL.md b/.agents/skills/euthyna-en/SKILL.md index 90b850e..fb583d7 100644 --- a/.agents/skills/euthyna-en/SKILL.md +++ b/.agents/skills/euthyna-en/SKILL.md @@ -1,6 +1,6 @@ --- name: euthyna-en -description: 'The adjudication discipline and delivery gate for code security audits. Use when the user asks to audit the security of a piece of code, a pull request, a change, or to verify an existing vulnerability claim. It does three things: supply the deterministic facts a model cannot compute (callers, test coverage, git history origins), judge every claim against six gates, and downgrade any claim that cannot produce evidence to "observation" rather than a finding. Not for: asking about the details of a single CVE, running an existing scanner once, pure documentation or formatting changes, or when the user explicitly wants a quick summary and accepts the risk.' +description: 'The adjudication discipline and delivery gate for code security audits. Use when the user asks to audit the security of a piece of code, a pull request, a change, or to verify an existing vulnerability claim. It does three things: supply the deterministic facts a model cannot compute (callers, test coverage, git history origins), judge every claim against six gates, and downgrade any claim that cannot produce evidence to "observation" rather than a finding. The slash command may carry a mode word — pr / graph / repo / claim / gate / help — to narrow the run to a single entry (the mapping lives in the mode table in the body); with no mode word the full audit runs. Not for: asking about the details of a single CVE, running an existing scanner once, pure documentation or formatting changes, or when the user explicitly wants a quick summary and accepts the risk.' whenToUse: Use when the user asks to audit the security surface of code or a repository, verify whether a suspected vulnerability is real, decide whether a finding is exploitable, review whether a change introduces a security regression, or asks to "audit before delivery". For single-topic checks such as only searching for secrets or only checking dependency versions, use the dedicated specialist skill instead and do not trigger this one. disable-model-invocation: true user-invocable: true diff --git a/.agents/skills/euthyna/SKILL.md b/.agents/skills/euthyna/SKILL.md index af2f962..8d6d0ab 100644 --- a/.agents/skills/euthyna/SKILL.md +++ b/.agents/skills/euthyna/SKILL.md @@ -1,6 +1,6 @@ --- name: euthyna -description: 代码安全审计的判定纪律与交付门禁。当用户要求对一段代码、一个 PR、一次变更或一条已有的漏洞结论做安全审查与验证时使用。它做三件事:补上模型算不准的确定性事实(调用方、测试覆盖、git 历史来源),用 6 道门禁判定每条结论真假,拿不出证据的一律降级为「观察」而不是「发现」。不适用于:仅询问某个 CVE 的详情、仅要求跑一次现成扫描器、纯文档或格式化改动、以及用户只要一句快速摘要且明确接受风险时。 +description: 代码安全审计的判定纪律与交付门禁。当用户要求对一段代码、一个 PR、一次变更或一条已有的漏洞结论做安全审查与验证时使用。它做三件事:补上模型算不准的确定性事实(调用方、测试覆盖、git 历史来源),用 6 道门禁判定每条结论真假,拿不出证据的一律降级为「观察」而不是「发现」。斜杠命令可带模式词 pr / graph / repo / claim / gate / help,把本次运行收窄到单个入口(映射见正文「模式词」表);不带模式词则走完整审计流程。不适用于:仅询问某个 CVE 的详情、仅要求跑一次现成扫描器、纯文档或格式化改动、以及用户只要一句快速摘要且明确接受风险时。 whenToUse: 用户要求审计代码或仓库的安全面、验证一条疑似漏洞结论真假、判定某个发现是否可利用、审查一次变更是否引入安全回归,或要求「审完再交付」时使用。只查密钥、只查依赖版本这类单一主题,直接用对应专项技能,不要触发本技能。 disable-model-invocation: true user-invocable: true diff --git a/AGENTS.md b/AGENTS.md index 9701e57..c387080 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -778,7 +778,7 @@ is faithful — that is a reading job, and a mechanical stand-in would only manu |---|---| | `bin/` + `src/` | **Fact producers** (zero-dependency Node CLI). `src/contract.js` is the contract in code; `src/facts/history.js`, `src/facts/coverage.js` and `src/facts/deps.js` are the three measurements (`history` has `--origins` to chase the first introducer and classifies by message + deleted-line + diff, **on by default since `euthyna audit`**; `coverage.js` reads **five** formats — c8/V8 JSON, classic istanbul JSON (jest's default provider, nyc: the same `fnMap`/`f` shape, so the symbol locator shares the c8 code path), coverage.py JSON (format 3), Go's text `-coverprofile` **and lcov tracefiles** (the Go profile needs `--source `: the profile carries no function names, so the producer parses top-level `func` ranges from the gofmt'd source; a file absent from the profile is notEvaluated, never "never executed"; the lcov path needs no `--source` because `FN:` carries the names — they are mangled, and the producer matches a `module::function` query against the `` tail segments of Rust v0 mangling (what `-C instrument-coverage` implies), since no demangler exists on this machine — each segment is anchored at a boundary and never inside a longer `` pair, so a query for `get` does not answer for `cfg_3get`; see `docs/fact-contract.md` §6.2 for the measured record, including that inlining was measured **not** to flatten a called function to 0); `deps.js` reads package-lock.json / Cargo.lock / go.mod, **located from the repository root only** — a monorepo sub-package lock is measured only by `deps --lockfile `, and `audit` does not notice its absence, which `docs/fact-contract.md` §6.3 records with the numbers from the probe). `audit` compares the manifest it measured with the one at `--head` and records `manifest-not-head` when the content differs or the file is absent there. `history` and `audit` also accept **no `--base` at all**: the range is then inferred as `merge-base(, origin/main)` — against the head that was asked for, not the checked-out commit — and declared three times over — `(推断…)` on the range line, a `range-inferred` entry in `notEvaluated`, and its `code` on the JSON channel — because an inferred range reaches the fork point only and would otherwise read as a smaller, cleaner history; when neither `origin/main` nor `origin/HEAD` resolves the run stops with a usage error rather than guessing `HEAD~1`. `euthyna audit` (0.4.0, `src/cli.js`) runs history + the full dependency surface in one command — the single entry point the CI recipe and the skill both describe. `src/gate.js` is the six-gate report validator behind `euthyna gate <报告> [--verify] [--allow-exec]` — it parses the skill's 裁定格式, downgrades findings that lack evidence/reproduce or contradict their verdict, and executes nothing but `git` unless the caller consents to interpreters. `src/graph.js` is the Mermaid view behind `--graph` on `euthyna history` and `euthyna audit`: it draws the attribution `history` already emits (the range → the commit that last modified each deleted line → the commit that first introduced its content, plus the `notEvaluated` surface as dotted edges), adds no measurement, and carries states only — colouring a node by risk would make §3.3 of the contract false. Every command takes `--out ` (added 2026-11-05): the run's artifact goes to a UTF-8-no-BOM file instead of stdout, the exit code stays the measurement's, and a write failure is a usage error rather than a clean run — it exists because a PowerShell `>` redirect writes UTF-16 (pitfall table above). `blameRanges` batches its `-L` flags, one process per ~400 ranges (2026-11-05; was one process per range at ~48 ms/行, now ~1.9 ms/行 sublinear — bench/perf.js), and the porcelain header accepts sha256's 64-hex ids the way `mergeBase` does. `deps` builds its name index and line table once per manifest (2026-11-05; was O(deps × manifest) — bench/perf-deps.js measured 4000 deps at 15.0s → 0.36s) | | `test/` | 336 tests via `node --test` (measured 2026-11-05: +10 over the 326 that `0.9.1` shipped — six `--out` cases in `test/cli.test.js`, a sha256-repo attribution case and the `blameRanges`/`mergeRanges` batching cases in `test/history.test.js`; 0.9.1 was +2 over the 324 that `0.9.0` shipped — `test/hermes-plugin.test.js` gained the case that pins the usage text naming every dispatched command, `test/i18n.test.js` the one that pins the producer name printed once in the header; that 324 was +28 over the 0.8.0 count — `test/graph.test.js` is 15, `--graph` adds 5 to `test/cli.test.js` including one that drives a real three-commit repository and one that runs `audit --graph` over a merged report, `--base` inference adds 6 more including a real clone with an `origin` and one that anchors the inference on `--head`, and `git.test.js` gained a `merge-base` case run against a **sha256** repository), no third-party framework. The pass/skip split is keyed on **tar flavour, not the OS**: `test/bench.test.js` skips `every exploit expectation holds` unless `tar --version` reports bsdtar. Measured on `cdece0f`: ubuntu 245 pass + 1 skip (its log line: *the exploit ground truth is defined against bsdtar, not "tar (GNU tar) 1.35"*), windows 246 pass + 0 skip — and this machine's Git Bash PATH puts `D:\Git\usr\bin\tar.exe` (GNU tar 1.35) ahead of System32's `tar.exe`, so a local run **on `cdece0f`** reported 245 + 1 even though it is Windows (measured again at `4d620cd` for 0.8.0: pwsh 296 pass / 0 skip, Git Bash 295 pass / 1 skip, the difference being that one case — the 245/246 pair above is the `cdece0f` record and 296/295 was the count at `4d620cd`, so read that scope with the number). **The history tests build real git repositories rather than mocking**; the coverage tests carry fixtures shaped like c8, coverage.py, Go profile and lcov tracefile output (`coverage.test.js` + `coverage-go.test.js` + `coverage-lcov.test.js`, the lcov fixtures using mangled names copied from a real `llvm-cov export --format=lcov` run); the deps tests carry fixture lockfiles (npm v1/v2/v3, Cargo.lock, go.mod); the contract tests carry the shell-quoting and render-boundary regressions from PR #1; `git.test.js` carries the error-path quoting and stderr-sanitization regressions from issue #2; `gate.test.js` pins the six-gate validator (including that `--verify` does not run an interpreter command without `--allow-exec`); `cli.test.js` pins the `audit` command, its exit codes, and the manifest-version declaration — content against `--head`, so a PR merge commit whose lockfile is byte-identical to `--head`'s declares nothing while a gitignored lockfile declares itself; the decision itself is an exported pure function so the fail-closed branch (`manifest-unverified`) is reachable from a test; `skill.test.js` pins the written discipline (the six gates, the three verdicts, the `euthyna gate` reference) so weakening the skill turns CI red; `plugin.test.js`, `claude-plugin.test.js` and `hermes-plugin.test.js` pin the three manifest surfaces; `manifest-version.test.js` pins `package.json` against the three plugin manifests (0.5.0 shipped them a release behind and nothing compared them); `i18n.test.js` pins language rendering and report parsing (zh/en); `skill-mirror.test.js` compares the two skill editions against each other — file set, heading sequence (ATX and Setext), fenced blocks, table shapes, and the `euthyna ` / `--flag` identifier sets — so an unmirrored discipline change goes red instead of relying on memory, `docs-pair.test.js` holds `docs/` to the same rule (every document paired, and each pair equal on structure and identifiers — not on prose fidelity), and `markdown-structure.test.js` pins the shared scanner itself with seven crafted-document cases, because a legal Markdown form it cannot read makes any comparison pass on that form (neither guard compares word counts, which a translation legitimately needs room for). **The suite count is written down once, in this row**: README's quickstart carried its own number (207) and was 39 tests stale, so it now says only what `npm test` does, and `package.json` defines that as `node --test` | -| `.agents/skills/euthyna/` | **The skill.** Doubles as source and as a project skill root (rank 200), so it is live in this workspace without a restart | +| `.agents/skills/euthyna/` | **The skill.** Doubles as source and as a project skill root (rank 200), so it is live in this workspace without a restart. Both editions' `description` carries the six mode words as a menu since 2026-11-10 (`pr / graph / repo / claim / gate / help`): the slash picker shows name+description only, so a word list that lived only in the body was invisible at the one surface where it is typed — the complaint that read as "只有 /euthyna 一种方式" was really "the modes exist but nothing advertises them, and the default path is the full audit". The menu names the words and points at the table; the word→stage→command mapping stays owned by the body's 模式词 table | | `bench/` | **The recall benchmark.** `exploits.js` establishes ground truth by execution (18 cases, 8 near-neighbour pairs); `prepare-blind.js` produces answer-free copies with per-round shuffled ids; `adjudicate.js` closes the loop end to end (blind tree → one adjudicator process per case → `collect-verdicts.js` machine validation → `score.js`), with a deterministic `golden` mode (ground truth written into reports — plumbing self-check only, never a real round) for CI; `collect-verdicts.js` machine-validates reports and extracts verdicts without the orchestrator reading report bodies; `score.js` computes the confusion matrix and the pair view; `perf.js` is the `history` scaling baseline, `perf-deps.js` the deps whole-manifest (`--all`) one. The verified-platform matrix (bsdtar vs GNU tar, p6 skip) lives in `bench/README.md`. Results: `bench/RESULTS.md` (round 1), `bench/RESULTS-round2.md` (round 2: three independent runs per case, zero flips), `bench/RESULTS-round3.md` (round 3: 18 cases, 54 adjudications, two fixture defects caught by adjudicators), `bench/RESULTS-round4.md` (round 4: 54/54, p6b v3 confirmed by first blind adjudication, cross-model run executed — zero flips across two model families); protocols in `bench/README.md`, design pre-registrations in `bench/DESIGN-round3.md` and `bench/DESIGN-round4.md` | | `docs/positioning.md` + `-zh` | Competitive analysis across the DSH catalog, including three claims that were tested and refuted | | `docs/fact-contract.md` + `-zh` | The measurement ↔ adjudication interface. The project's core design artefact | From 0932e5a39892633bc4b8efd743796a4e3acf8adc Mon Sep 17 00:00:00 2001 From: modusensus Date: Tue, 10 Nov 2026 01:21:59 +0800 Subject: [PATCH 2/3] perf(history): run the per-file blame loop 8 files in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git blame takes one file per invocation, so the real floor on a diff is the (file, commit) pair count, not the line count — and the 0.10.0 batching, benchmarked on a single-file fixture, could never see it. A vendored tree deletion (nektos/act HEAD~40..HEAD: 161,982 lines across 610 files) paid ~1220 serial spawns and took 2m04s; with BLAME_CONCURRENCY=8 and aggregation strictly in file order it takes 1m11s and the report is byte-identical (cmp against the pre-change run). bench/perf.js grows --files to measure this axis (200 files x 100 lines: 65.2s serial, 54.9s concurrent on the cold pass); the pool is unit-tested for input order, cap and failure propagation. Also recorded: the cold/warm split. First blame over freshly committed objects runs ~250 ms/文件, an immediate second pass ~13 ms/文件 — 20x of pure cache, which is why the win is 1.75x on the cold real diff and 20x warm. A blame before/after that does not pin the cache state measures the cache, not the change. --- AGENTS.md | 5 ++- bench/perf.js | 101 +++++++++++++++++++++++++++++++++++++++++++ src/facts/history.js | 61 +++++++++++++++++++++++--- test/history.test.js | 44 ++++++++++++++++++- 4 files changed, 202 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c387080..db11371 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -769,6 +769,7 @@ is faithful — that is a reading job, and a mechanical stand-in would only manu | **A computed README badge can publish a claim with nothing behind it, and no check will notice** | 🧪 Measured 2026-10-11: the `Agent skills installs` badge pointed at `skills.sh/b/slow-stack/euthyna.svg`, which renders `resource not found` in red (`fill="#dd4343"`) while its own link is a `404` and the site's search API answers `{"skills":[],"count":0}` for `euthyna`. The listing never existed, and the badge sat in the README for 19 days from `eafd69d`. When touching the badge block, read each SVG's `aria-label` and each link's status: a badge with a hard-coded `color=` is decoration, not evidence. Full measurement: `### skills.sh` | | **The registry refuses a second slug's submission of the version the first slug just took — and names a version the refused slug does not have** | 🧪 Measured 2026-10-21 on 0.9.0: `euthyna` landed, then `euthyna-en` was refused `Version 0.9.0 already exists`, while `verify euthyna-en --version 0.9.0` answered `Version not found` minutes later. Under `bash -e` that refusal ended the publish step, so the second edition was never submitted and the verify step — the only reader of a missing half — never ran. Walk the editions in one loop, treat a duplicate refusal as already-registered and continue, and let the verify step decide whether the listing arrived. Full measurement: `### ClawHub registry` | | **A global npm command can be a shim with nothing behind it, and then block its own reinstall** | 🧪 Measured 2026-10-21: `euthyna --version` threw `MODULE_NOT_FOUND` because `%APPDATA%\npm\node_modules\euthyna` had been removed while its three shims (dated 2026-09-27) survived; `npm install -g` then fails `EEXIST` rather than overwrite files it holds no record of. Move the shims aside — do not delete — reinstall, and test the command rather than its presence: `euthyna help` must exit 0, and read the version out of the installed `package.json` | +| **A single-file scaling bench cannot see a per-file cost floor, and blame timings have a ~20× cold/warm split** | 🧪 Measured 2026-11-10 while parallelizing the per-file loop: `bench/perf.js`'s fixture was one file, so the ~1.9 ms/行 baseline never saw that `git blame` pays one process per (file, commit) pair — a 161,982-line deletion across 610 files cost ~2 minutes even with perfect per-line batching (`--no-origins` did not help: 2m12s, the origins cap already bounds that half). First blame pass over freshly committed objects ≈ 250 ms/文件; an immediate second pass ≈ 13 ms/文件 (12-core box, warm objects) — the same code, 20× apart. Concurrency-8 measured 20× on the warm pass (8.77s → 431ms for 32 blames) but only 1.2× on the bench's cold pass and 1.75× on the real 610-file diff — so a before/after that doesn't pin the cache state measures the cache, not the change. `bench/perf.js --files` measures this axis now | --- @@ -776,8 +777,8 @@ is faithful — that is a reading job, and a mechanical stand-in would only manu | Path | What it is | |---|---| -| `bin/` + `src/` | **Fact producers** (zero-dependency Node CLI). `src/contract.js` is the contract in code; `src/facts/history.js`, `src/facts/coverage.js` and `src/facts/deps.js` are the three measurements (`history` has `--origins` to chase the first introducer and classifies by message + deleted-line + diff, **on by default since `euthyna audit`**; `coverage.js` reads **five** formats — c8/V8 JSON, classic istanbul JSON (jest's default provider, nyc: the same `fnMap`/`f` shape, so the symbol locator shares the c8 code path), coverage.py JSON (format 3), Go's text `-coverprofile` **and lcov tracefiles** (the Go profile needs `--source `: the profile carries no function names, so the producer parses top-level `func` ranges from the gofmt'd source; a file absent from the profile is notEvaluated, never "never executed"; the lcov path needs no `--source` because `FN:` carries the names — they are mangled, and the producer matches a `module::function` query against the `` tail segments of Rust v0 mangling (what `-C instrument-coverage` implies), since no demangler exists on this machine — each segment is anchored at a boundary and never inside a longer `` pair, so a query for `get` does not answer for `cfg_3get`; see `docs/fact-contract.md` §6.2 for the measured record, including that inlining was measured **not** to flatten a called function to 0); `deps.js` reads package-lock.json / Cargo.lock / go.mod, **located from the repository root only** — a monorepo sub-package lock is measured only by `deps --lockfile `, and `audit` does not notice its absence, which `docs/fact-contract.md` §6.3 records with the numbers from the probe). `audit` compares the manifest it measured with the one at `--head` and records `manifest-not-head` when the content differs or the file is absent there. `history` and `audit` also accept **no `--base` at all**: the range is then inferred as `merge-base(, origin/main)` — against the head that was asked for, not the checked-out commit — and declared three times over — `(推断…)` on the range line, a `range-inferred` entry in `notEvaluated`, and its `code` on the JSON channel — because an inferred range reaches the fork point only and would otherwise read as a smaller, cleaner history; when neither `origin/main` nor `origin/HEAD` resolves the run stops with a usage error rather than guessing `HEAD~1`. `euthyna audit` (0.4.0, `src/cli.js`) runs history + the full dependency surface in one command — the single entry point the CI recipe and the skill both describe. `src/gate.js` is the six-gate report validator behind `euthyna gate <报告> [--verify] [--allow-exec]` — it parses the skill's 裁定格式, downgrades findings that lack evidence/reproduce or contradict their verdict, and executes nothing but `git` unless the caller consents to interpreters. `src/graph.js` is the Mermaid view behind `--graph` on `euthyna history` and `euthyna audit`: it draws the attribution `history` already emits (the range → the commit that last modified each deleted line → the commit that first introduced its content, plus the `notEvaluated` surface as dotted edges), adds no measurement, and carries states only — colouring a node by risk would make §3.3 of the contract false. Every command takes `--out ` (added 2026-11-05): the run's artifact goes to a UTF-8-no-BOM file instead of stdout, the exit code stays the measurement's, and a write failure is a usage error rather than a clean run — it exists because a PowerShell `>` redirect writes UTF-16 (pitfall table above). `blameRanges` batches its `-L` flags, one process per ~400 ranges (2026-11-05; was one process per range at ~48 ms/行, now ~1.9 ms/行 sublinear — bench/perf.js), and the porcelain header accepts sha256's 64-hex ids the way `mergeBase` does. `deps` builds its name index and line table once per manifest (2026-11-05; was O(deps × manifest) — bench/perf-deps.js measured 4000 deps at 15.0s → 0.36s) | -| `test/` | 336 tests via `node --test` (measured 2026-11-05: +10 over the 326 that `0.9.1` shipped — six `--out` cases in `test/cli.test.js`, a sha256-repo attribution case and the `blameRanges`/`mergeRanges` batching cases in `test/history.test.js`; 0.9.1 was +2 over the 324 that `0.9.0` shipped — `test/hermes-plugin.test.js` gained the case that pins the usage text naming every dispatched command, `test/i18n.test.js` the one that pins the producer name printed once in the header; that 324 was +28 over the 0.8.0 count — `test/graph.test.js` is 15, `--graph` adds 5 to `test/cli.test.js` including one that drives a real three-commit repository and one that runs `audit --graph` over a merged report, `--base` inference adds 6 more including a real clone with an `origin` and one that anchors the inference on `--head`, and `git.test.js` gained a `merge-base` case run against a **sha256** repository), no third-party framework. The pass/skip split is keyed on **tar flavour, not the OS**: `test/bench.test.js` skips `every exploit expectation holds` unless `tar --version` reports bsdtar. Measured on `cdece0f`: ubuntu 245 pass + 1 skip (its log line: *the exploit ground truth is defined against bsdtar, not "tar (GNU tar) 1.35"*), windows 246 pass + 0 skip — and this machine's Git Bash PATH puts `D:\Git\usr\bin\tar.exe` (GNU tar 1.35) ahead of System32's `tar.exe`, so a local run **on `cdece0f`** reported 245 + 1 even though it is Windows (measured again at `4d620cd` for 0.8.0: pwsh 296 pass / 0 skip, Git Bash 295 pass / 1 skip, the difference being that one case — the 245/246 pair above is the `cdece0f` record and 296/295 was the count at `4d620cd`, so read that scope with the number). **The history tests build real git repositories rather than mocking**; the coverage tests carry fixtures shaped like c8, coverage.py, Go profile and lcov tracefile output (`coverage.test.js` + `coverage-go.test.js` + `coverage-lcov.test.js`, the lcov fixtures using mangled names copied from a real `llvm-cov export --format=lcov` run); the deps tests carry fixture lockfiles (npm v1/v2/v3, Cargo.lock, go.mod); the contract tests carry the shell-quoting and render-boundary regressions from PR #1; `git.test.js` carries the error-path quoting and stderr-sanitization regressions from issue #2; `gate.test.js` pins the six-gate validator (including that `--verify` does not run an interpreter command without `--allow-exec`); `cli.test.js` pins the `audit` command, its exit codes, and the manifest-version declaration — content against `--head`, so a PR merge commit whose lockfile is byte-identical to `--head`'s declares nothing while a gitignored lockfile declares itself; the decision itself is an exported pure function so the fail-closed branch (`manifest-unverified`) is reachable from a test; `skill.test.js` pins the written discipline (the six gates, the three verdicts, the `euthyna gate` reference) so weakening the skill turns CI red; `plugin.test.js`, `claude-plugin.test.js` and `hermes-plugin.test.js` pin the three manifest surfaces; `manifest-version.test.js` pins `package.json` against the three plugin manifests (0.5.0 shipped them a release behind and nothing compared them); `i18n.test.js` pins language rendering and report parsing (zh/en); `skill-mirror.test.js` compares the two skill editions against each other — file set, heading sequence (ATX and Setext), fenced blocks, table shapes, and the `euthyna ` / `--flag` identifier sets — so an unmirrored discipline change goes red instead of relying on memory, `docs-pair.test.js` holds `docs/` to the same rule (every document paired, and each pair equal on structure and identifiers — not on prose fidelity), and `markdown-structure.test.js` pins the shared scanner itself with seven crafted-document cases, because a legal Markdown form it cannot read makes any comparison pass on that form (neither guard compares word counts, which a translation legitimately needs room for). **The suite count is written down once, in this row**: README's quickstart carried its own number (207) and was 39 tests stale, so it now says only what `npm test` does, and `package.json` defines that as `node --test` | +| `bin/` + `src/` | **Fact producers** (zero-dependency Node CLI). `src/contract.js` is the contract in code; `src/facts/history.js`, `src/facts/coverage.js` and `src/facts/deps.js` are the three measurements (`history` has `--origins` to chase the first introducer and classifies by message + deleted-line + diff, **on by default since `euthyna audit`**; `coverage.js` reads **five** formats — c8/V8 JSON, classic istanbul JSON (jest's default provider, nyc: the same `fnMap`/`f` shape, so the symbol locator shares the c8 code path), coverage.py JSON (format 3), Go's text `-coverprofile` **and lcov tracefiles** (the Go profile needs `--source `: the profile carries no function names, so the producer parses top-level `func` ranges from the gofmt'd source; a file absent from the profile is notEvaluated, never "never executed"; the lcov path needs no `--source` because `FN:` carries the names — they are mangled, and the producer matches a `module::function` query against the `` tail segments of Rust v0 mangling (what `-C instrument-coverage` implies), since no demangler exists on this machine — each segment is anchored at a boundary and never inside a longer `` pair, so a query for `get` does not answer for `cfg_3get`; see `docs/fact-contract.md` §6.2 for the measured record, including that inlining was measured **not** to flatten a called function to 0); `deps.js` reads package-lock.json / Cargo.lock / go.mod, **located from the repository root only** — a monorepo sub-package lock is measured only by `deps --lockfile `, and `audit` does not notice its absence, which `docs/fact-contract.md` §6.3 records with the numbers from the probe). `audit` compares the manifest it measured with the one at `--head` and records `manifest-not-head` when the content differs or the file is absent there. `history` and `audit` also accept **no `--base` at all**: the range is then inferred as `merge-base(, origin/main)` — against the head that was asked for, not the checked-out commit — and declared three times over — `(推断…)` on the range line, a `range-inferred` entry in `notEvaluated`, and its `code` on the JSON channel — because an inferred range reaches the fork point only and would otherwise read as a smaller, cleaner history; when neither `origin/main` nor `origin/HEAD` resolves the run stops with a usage error rather than guessing `HEAD~1`. `euthyna audit` (0.4.0, `src/cli.js`) runs history + the full dependency surface in one command — the single entry point the CI recipe and the skill both describe. `src/gate.js` is the six-gate report validator behind `euthyna gate <报告> [--verify] [--allow-exec]` — it parses the skill's 裁定格式, downgrades findings that lack evidence/reproduce or contradict their verdict, and executes nothing but `git` unless the caller consents to interpreters. `src/graph.js` is the Mermaid view behind `--graph` on `euthyna history` and `euthyna audit`: it draws the attribution `history` already emits (the range → the commit that last modified each deleted line → the commit that first introduced its content, plus the `notEvaluated` surface as dotted edges), adds no measurement, and carries states only — colouring a node by risk would make §3.3 of the contract false. Every command takes `--out ` (added 2026-11-05): the run's artifact goes to a UTF-8-no-BOM file instead of stdout, the exit code stays the measurement's, and a write failure is a usage error rather than a clean run — it exists because a PowerShell `>` redirect writes UTF-16 (pitfall table above). `blameRanges` batches its `-L` flags, one process per ~400 ranges (2026-11-05; was one process per range at ~48 ms/行, now ~1.9 ms/行 sublinear — bench/perf.js), and the porcelain header accepts sha256's 64-hex ids the way `mergeBase` does. Since 2026-11-10 the per-file loop also runs `BLAME_CONCURRENCY = 8` files in flight (`mapWithLimit` in `history.js`, aggregation strictly in file order — output verified byte-identical on the 610-file diff below): **cost scales with file count, not line count** — `git blame` takes one file per invocation, so a diff that deletes 161,982 lines across 610 files (nektos/act HEAD~40..HEAD, a vendored `node_modules` tree) pays ~1220 spawns no matter how well ranges batch inside a file; measured 2m04s → 1m11s on that diff, and `bench/perf.js --files 200` 65.2s → 54.9s. The residual bound is the **first cold pass** over freshly committed objects (~250 ms/文件 measured; an immediate second pass runs ~13 ms/文件, ~20×, on this 12-core box), so any blame before/after must pin the cache state or it measures the cache. The single-file sweep cannot see the per-file floor — its fixture is one file — which is why `--files` exists. `deps` builds its name index and line table once per manifest (2026-11-05; was O(deps × manifest) — bench/perf-deps.js measured 4000 deps at 15.0s → 0.36s) | +| `test/` | 339 tests via `node --test` (measured 2026-11-10: +3 over the 336 that `0.10.0` shipped — three `mapWithLimit` pool cases in `test/history.test.js`: results land in input order regardless of completion order, the cap is the peak concurrency not a suggestion, and a first failure stops new tasks instead of running all of them; 0.10.0's 336 were +10 over the 326 that `0.9.1` shipped — six `--out` cases in `test/cli.test.js`, a sha256-repo attribution case and the `blameRanges`/`mergeRanges` batching cases in `test/history.test.js`; 0.9.1 was +2 over the 324 that `0.9.0` shipped — `test/hermes-plugin.test.js` gained the case that pins the usage text naming every dispatched command, `test/i18n.test.js` the one that pins the producer name printed once in the header; that 324 was +28 over the 0.8.0 count — `test/graph.test.js` is 15, `--graph` adds 5 to `test/cli.test.js` including one that drives a real three-commit repository and one that runs `audit --graph` over a merged report, `--base` inference adds 6 more including a real clone with an `origin` and one that anchors the inference on `--head`, and `git.test.js` gained a `merge-base` case run against a **sha256** repository), no third-party framework. The pass/skip split is keyed on **tar flavour, not the OS**: `test/bench.test.js` skips `every exploit expectation holds` unless `tar --version` reports bsdtar. Measured on `cdece0f`: ubuntu 245 pass + 1 skip (its log line: *the exploit ground truth is defined against bsdtar, not "tar (GNU tar) 1.35"*), windows 246 pass + 0 skip — and this machine's Git Bash PATH puts `D:\Git\usr\bin\tar.exe` (GNU tar 1.35) ahead of System32's `tar.exe`, so a local run **on `cdece0f`** reported 245 + 1 even though it is Windows (measured again at `4d620cd` for 0.8.0: pwsh 296 pass / 0 skip, Git Bash 295 pass / 1 skip, the difference being that one case — the 245/246 pair above is the `cdece0f` record and 296/295 was the count at `4d620cd`, so read that scope with the number). **The history tests build real git repositories rather than mocking**; the coverage tests carry fixtures shaped like c8, coverage.py, Go profile and lcov tracefile output (`coverage.test.js` + `coverage-go.test.js` + `coverage-lcov.test.js`, the lcov fixtures using mangled names copied from a real `llvm-cov export --format=lcov` run); the deps tests carry fixture lockfiles (npm v1/v2/v3, Cargo.lock, go.mod); the contract tests carry the shell-quoting and render-boundary regressions from PR #1; `git.test.js` carries the error-path quoting and stderr-sanitization regressions from issue #2; `gate.test.js` pins the six-gate validator (including that `--verify` does not run an interpreter command without `--allow-exec`); `cli.test.js` pins the `audit` command, its exit codes, and the manifest-version declaration — content against `--head`, so a PR merge commit whose lockfile is byte-identical to `--head`'s declares nothing while a gitignored lockfile declares itself; the decision itself is an exported pure function so the fail-closed branch (`manifest-unverified`) is reachable from a test; `skill.test.js` pins the written discipline (the six gates, the three verdicts, the `euthyna gate` reference) so weakening the skill turns CI red; `plugin.test.js`, `claude-plugin.test.js` and `hermes-plugin.test.js` pin the three manifest surfaces; `manifest-version.test.js` pins `package.json` against the three plugin manifests (0.5.0 shipped them a release behind and nothing compared them); `i18n.test.js` pins language rendering and report parsing (zh/en); `skill-mirror.test.js` compares the two skill editions against each other — file set, heading sequence (ATX and Setext), fenced blocks, table shapes, and the `euthyna ` / `--flag` identifier sets — so an unmirrored discipline change goes red instead of relying on memory, `docs-pair.test.js` holds `docs/` to the same rule (every document paired, and each pair equal on structure and identifiers — not on prose fidelity), and `markdown-structure.test.js` pins the shared scanner itself with seven crafted-document cases, because a legal Markdown form it cannot read makes any comparison pass on that form (neither guard compares word counts, which a translation legitimately needs room for). **The suite count is written down once, in this row**: README's quickstart carried its own number (207) and was 39 tests stale, so it now says only what `npm test` does, and `package.json` defines that as `node --test` | | `.agents/skills/euthyna/` | **The skill.** Doubles as source and as a project skill root (rank 200), so it is live in this workspace without a restart. Both editions' `description` carries the six mode words as a menu since 2026-11-10 (`pr / graph / repo / claim / gate / help`): the slash picker shows name+description only, so a word list that lived only in the body was invisible at the one surface where it is typed — the complaint that read as "只有 /euthyna 一种方式" was really "the modes exist but nothing advertises them, and the default path is the full audit". The menu names the words and points at the table; the word→stage→command mapping stays owned by the body's 模式词 table | | `bench/` | **The recall benchmark.** `exploits.js` establishes ground truth by execution (18 cases, 8 near-neighbour pairs); `prepare-blind.js` produces answer-free copies with per-round shuffled ids; `adjudicate.js` closes the loop end to end (blind tree → one adjudicator process per case → `collect-verdicts.js` machine validation → `score.js`), with a deterministic `golden` mode (ground truth written into reports — plumbing self-check only, never a real round) for CI; `collect-verdicts.js` machine-validates reports and extracts verdicts without the orchestrator reading report bodies; `score.js` computes the confusion matrix and the pair view; `perf.js` is the `history` scaling baseline, `perf-deps.js` the deps whole-manifest (`--all`) one. The verified-platform matrix (bsdtar vs GNU tar, p6 skip) lives in `bench/README.md`. Results: `bench/RESULTS.md` (round 1), `bench/RESULTS-round2.md` (round 2: three independent runs per case, zero flips), `bench/RESULTS-round3.md` (round 3: 18 cases, 54 adjudications, two fixture defects caught by adjudicators), `bench/RESULTS-round4.md` (round 4: 54/54, p6b v3 confirmed by first blind adjudication, cross-model run executed — zero flips across two model families); protocols in `bench/README.md`, design pre-registrations in `bench/DESIGN-round3.md` and `bench/DESIGN-round4.md` | | `docs/positioning.md` + `-zh` | Competitive analysis across the DSH catalog, including three claims that were tested and refuted | diff --git a/bench/perf.js b/bench/perf.js index 3567e64..63f6b9b 100644 --- a/bench/perf.js +++ b/bench/perf.js @@ -17,6 +17,19 @@ * is the number that matters. * * node bench/perf.js [--total 12000] [--min 250] [--max 4000] [--steps 5] + * node bench/perf.js --files 200 [--lines 100] + * + * --files 测另一条成本轴:`git blame` 一次只收一个文件,所以每 (文件, 提交) + * 对至少一个进程,`-L` 批处理只在文件内合并——进程数的下限是文件数,不是 + * 行数。上面的单文件扫档看不到这条轴。真实世界里的对应形状是「一次依赖 + * 升级把打进仓库的 vendored 树整片删掉」(数百个文件、每文件几十行)。 + * 要横向比较的量是 ms/文件。 + * + * 实测(--files 200 --lines 100):串行 65.2s(326 ms/文件)→ 8 路并发 + * 54.9s(275 ms/文件);并发在热对象上拿到 ~20×(同一仓库 32 个 blame + * 8.77s → 431ms),但首次冷遍历 ~250 ms/文件是并发救不了的下限——对比 + * blame 相关改动时必须钉住缓存状态,否则测的是缓存不是改动。完整记录见 + * AGENTS.md 的 pitfalls 表。 * * 分批后实测(--total 8000 --max 2000 --steps 4):2000 个删除区间约 3.7s * (~1.9ms/行,次线性)。默认全程 5 档 + 造仓约 2-3 分钟(估算,未逐档实测)。 @@ -37,6 +50,8 @@ const TOTAL = opt('--total', 12000); const MIN = opt('--min', 250); const MAX = opt('--max', 4000); const STEPS = opt('--steps', 5); +const FILES = opt('--files', 0); +const LINES_PER_FILE = opt('--lines', 100); function git(cwd, argv) { return execFileSync('git', argv, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); @@ -80,7 +95,93 @@ async function buildRepo({ totalLines, deletedLines }) { return { dir, base, head, deletedLines }; } +/** + * The file path for a generated module name, bounded to the repo directory. + * The names are build-generated (`modNNNN.js`), but the bound is asserted + * rather than assumed — same fail-loudly principle as the option validation. + */ +function boundedFile(dir, name) { + const target = path.join(dir, name); + if (path.dirname(target) !== dir) { + throw new Error(`bench/perf.js: generated name escapes the repo dir: ${name}`); + } + return target; +} + +/** + * Build a repo with `fileCount` files whose last commit deletes `linesPerFile` + * interleaved lines from every file — the vendored-tree shape. Files are + * written across COMMIT_CHUNKS commits so blame has more than one candidate + * owner; each file's lines all come from one commit, which is realistic for + * vendored content and keeps the repo build cheap. + */ +async function buildMultiFileRepo({ fileCount, linesPerFile }) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'euthyna-perf-files-')); + git(dir, ['init', '-q', '-b', 'main']); + git(dir, ['config', 'user.email', 'bench@example.com']); + git(dir, ['config', 'user.name', 'Perf Bench']); + + const COMMIT_CHUNKS = 3; + const names = []; + for (let f = 0; f < fileCount; f++) names.push(`mod${String(f).padStart(4, '0')}.js`); + const perChunk = Math.ceil(fileCount / COMMIT_CHUNKS); + + let written = 0; + for (let c = 0; c < COMMIT_CHUNKS; c++) { + for (let i = 0; i < perChunk && written < fileCount; i++, written++) { + const lines = []; + for (let l = 0; l < linesPerFile * 2; l++) { + lines.push(`export const f${written}v${l} = ${l}; // chunk ${c}`); + } + fs.writeFileSync(boundedFile(dir, names[written]), lines.join('\n') + '\n'); + } + git(dir, ['add', '-A']); + git(dir, ['commit', '-q', '-m', `feat: chunk ${c}`]); + } + const base = git(dir, ['rev-parse', 'HEAD']); + + for (const name of names) { + const file = boundedFile(dir, name); + const lines = fs.readFileSync(file, 'utf8').split('\n').filter((l) => l !== ''); + fs.writeFileSync(file, lines.filter((_, i) => i % 2 === 0).join('\n') + '\n'); + } + git(dir, ['add', '-A']); + git(dir, ['commit', '-q', '-m', 'refactor: drop interleaved lines in every file']); + const head = git(dir, ['rev-parse', 'HEAD']); + + return { dir, base, head, deletedLines: fileCount * linesPerFile }; +} + +/** The multi-file scenario: one measurement, ms/file is the number to compare. */ +async function filesMode() { + const { collectHistoryFacts } = await import('../src/facts/history.js'); + if ( + !Number.isInteger(FILES) || + FILES < 1 || + !Number.isInteger(LINES_PER_FILE) || + LINES_PER_FILE < 1 + ) { + throw new Error('--files/--lines 必须是正整数'); + } + + const repo = await buildMultiFileRepo({ fileCount: FILES, linesPerFile: LINES_PER_FILE }); + try { + const start = process.hrtime.bigint(); + await collectHistoryFacts({ cwd: repo.dir, base: repo.base, head: repo.head }); + const ms = Number(process.hrtime.bigint() - start) / 1e6; + process.stdout.write( + ` ${FILES} 文件 × ${LINES_PER_FILE} 删除行/文件 ${ms.toFixed(0).padStart(6)} ms ` + + `(${(ms / FILES).toFixed(1)} ms/文件)\n` + ); + console.log(' 绝对数值依赖机器;这一场景横向比较 ms/文件,进程下限是文件数。'); + } finally { + fs.rmSync(repo.dir, { recursive: true, force: true }); + } +} + async function main() { + if (FILES > 0) return filesMode(); + const { collectHistoryFacts } = await import('../src/facts/history.js'); // A misconfigured run must fail loudly, not generate a NaN-size benchmark that diff --git a/src/facts/history.js b/src/facts/history.js index 4df4038..4fc1b40 100644 --- a/src/facts/history.js +++ b/src/facts/history.js @@ -86,6 +86,42 @@ export function mergeRanges(ranges) { */ const BLAME_BATCH = 400; +/** + * Files processed concurrently while attributing deleted lines. `git blame` + * accepts one file per invocation, so a change that touches many files pays + * one diff process and at least one blame batch process per file, and on the + * many-small-files shape those spawns are the whole cost — the shape a + * vendored tree deleted in one change has (bench/perf.js --files measures it). + * 8 in flight keeps the win without holding every diff open at once. + * + * Concurrency changes only scheduling, never results: the aggregation after + * the map stays strictly in file order, so reports are byte-identical to the + * serial run's. + */ +const BLAME_CONCURRENCY = 8; + +/** + * Map `fn` over `items` with at most `limit` calls in flight, results in input + * order. A rejected task rejects the whole map — the first failure stops new + * tasks but does not cancel ones already running — which matches what the + * sequential await loop it replaced did. Hand-rolled because the project + * carries no dependencies. Exported for tests: input-order results and the + * cap itself are properties a regression would otherwise hide inside + * perfectly-shaped reports. + */ +export async function mapWithLimit(items, limit, fn) { + const results = new Array(items.length); + let next = 0; + const worker = async () => { + while (next < items.length) { + const i = next++; + results[i] = await fn(items[i], i); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return results; +} + /** * Attribute every line in `ranges` to the commit that last touched it, as of `rev`. * @@ -306,16 +342,29 @@ export async function collectHistoryFacts({ const linesByFile = new Map(); const deletedLinesByFile = new Map(); - for (const file of files) { + // Per-file work (one diff, then that file's blame batches) is independent, + // so files run with BLAME_CONCURRENCY in flight; results are collected by + // position and aggregated strictly in file order below, which keeps the + // facts — and the report — byte-identical to the serial run's. + const perFile = await mapWithLimit(files, BLAME_CONCURRENCY, async (file) => { const diff = await git(['diff', '--unified=0', `${base}..${head}`, '--', file], { cwd }); const ranges = parseDeletedRanges(diff); - if (ranges.length === 0) continue; + if (ranges.length === 0) return null; + + return { + file, + ranges, + deletedLines: ranges.reduce((sum, r) => sum + r.count, 0), + deletedLinesList: parseDeletedLines(diff), + byCommit: await blameRanges({ cwd, rev: base, file, ranges }) + }; + }); - const deletedLines = ranges.reduce((sum, r) => sum + r.count, 0); + for (const result of perFile) { + if (!result) continue; + const { file, ranges, deletedLines, deletedLinesList, byCommit } = result; linesByFile.set(file, { ranges, deletedLines }); - deletedLinesByFile.set(file, parseDeletedLines(diff)); - - const byCommit = await blameRanges({ cwd, rev: base, file, ranges }); + deletedLinesByFile.set(file, deletedLinesList); for (const [hash, lineNumbers] of byCommit) { const entry = blameByCommit.get(hash) ?? { lines: 0, byFile: new Map() }; const existing = entry.byFile.get(file) ?? []; diff --git a/test/history.test.js b/test/history.test.js index 3d70a5c..dc5ec92 100644 --- a/test/history.test.js +++ b/test/history.test.js @@ -20,7 +20,8 @@ import { classifyCommit, collectHistoryFacts, blameRanges, - mergeRanges + mergeRanges, + mapWithLimit } from '../src/facts/history.js'; import { STATUS, KIND } from '../src/contract.js'; @@ -897,3 +898,44 @@ describe('reintroduction detection', () => { ); }); }); + +describe('mapWithLimit — the concurrency pool behind the per-file loop', () => { + test('results land in input order regardless of completion order', async () => { + // Completion order is the reverse of input order; the aggregation after + // the map is only correct if the map itself never reorders. + const out = await mapWithLimit([40, 30, 20, 10, 0], 2, async (delay) => { + await new Promise((resolve) => setTimeout(resolve, delay)); + return delay; + }); + assert.deepEqual(out, [40, 30, 20, 10, 0]); + }); + + test('no more than `limit` tasks are in flight at once', async () => { + let inFlight = 0; + let peak = 0; + await mapWithLimit(Array.from({ length: 24 }, (_, i) => i), 4, async () => { + inFlight++; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => setTimeout(resolve, 5)); + inFlight--; + }); + assert.equal(peak, 4, 'the cap is the peak, not a suggestion'); + }); + + test('a rejected task rejects the map and stops new tasks', async () => { + let started = 0; + await assert.rejects( + mapWithLimit( + Array.from({ length: 8 }, (_, i) => i), + 1, + async (i) => { + started++; + if (i === 2) throw new Error('task 2 failed'); + return i; + } + ), + /task 2 failed/ + ); + assert.equal(started, 3, 'the failure stops new tasks instead of running all 8'); + }); +}); From b2e2c680059e2ce0004b89e3df428007aca2dc71 Mon Sep 17 00:00:00 2001 From: modusensus Date: Tue, 10 Nov 2026 02:27:21 +0800 Subject: [PATCH 3/3] docs(agents): the global CLI lagging the registry is the skill-copy trap one layer down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The machine's global euthyna sat at 0.9.1 with a healthy shim while the registry read 0.10.0 — nothing looked wrong, and audits quietly ran the pre-batching producer. Same act diff: 0.9.1 still running at 18 minutes (aborted), 0.10.0 2m04s, the per-file-concurrency branch 1m11s. The check is now the first step of any 'audit is slow' report: registry dist-tags against the installed package.json. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index db11371..432c46a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -763,7 +763,7 @@ is faithful — that is a reading job, and a mechanical stand-in would only manu | **`euthyna audit`'s two halves do not read the same version** | 🧪 Measured 2026-10-09 on the rustls run: `history` reads the commits named by `--base`/`--head`, while `deps` reads the manifest *currently checked out at the repository root* `--repo` points into. A clone parked on `main` yielded 284 dependency facts for a range whose tag holds 366. The CLI now declares it (`code: manifest-not-head`) by comparing **content** with `--head`, not by comparing commit shas — measured wrong in both directions first: a sha comparison fired on a PR merge commit whose lockfile was byte-identical to `--head`'s (silent noise that forces every dependent gate to `not_evaluated`) and stayed silent on a gitignored lockfile that belongs to no commit. A declaration is not a measurement: still check out one end of the range and confirm with `git rev-parse HEAD` | | **A control byte in a git argv does not survive the Windows command-line round trip** | Measured while writing the issue #2 regressions: a BEL reached git as `?`, and an ESC-bearing argument got different semantics entirely (exit 0, no error). A hostile-argv test against real git therefore cannot assert the escape form cross-platform — assert the portable property (no raw control byte in the output) against git, and the exact escape form against the pure message builder (`gitFailureMessage`) | | **euthyna 的 CLI 在沙箱 pwsh 里跑必失败** | 沙箱拒绝 Node 起 git 子进程(piped stdio 的 EPERM,见上)后,`repoToplevel` 静默返回空,CLI 报 exit 2「不在任何 git 仓库」——看起来像目标仓库问题,其实是沙箱问题。一律用侧边栏嵌入式终端跑 `node D:\euthyna\bin\euthyna.js ...` | -| **A user-level installed skill silently lags the published version** | 🧪 Measured 2026-10-09: `~/.agents/skills/euthyna/` was still pre-0.6.0 (1 `--source` mention where the shipped tree has 5, no `coverprofile`, missing the rule-5 wording from PR #21), while npm and ClawHub both read 0.6.0. Inside this repository it is invisible — the project root (rank 200) wins — but **outside it the old discipline is what actually runs**. Check with `git diff --no-index --stat ~/.agents/skills/euthyna .agents/skills/euthyna` (empty output = in sync) rather than assuming a release propagated. Use `git`, not `diff`: in PowerShell `diff` is the `Compare-Object` alias and refuses `-rq`. | +| **A user-level installed skill silently lags the published version** | 🧪 Measured 2026-10-09: `~/.agents/skills/euthyna/` was still pre-0.6.0 (1 `--source` mention where the shipped tree has 5, no `coverprofile`, missing the rule-5 wording from PR #21), while npm and ClawHub both read 0.6.0. Inside this repository it is invisible — the project root (rank 200) wins — but **outside it the old discipline is what actually runs**. Check with `git diff --no-index --stat ~/.agents/skills/euthyna .agents/skills/euthyna` (empty output = in sync) rather than assuming a release propagated. Use `git`, not `diff`: in PowerShell `diff` is the `Compare-Object` alias and refuses `-rq`. **The global CLI is the same trap one layer down, and the cost there is minutes, not drift**: measured 2026-11-10, the machine's global `euthyna` was 0.9.1 (shim healthy, so nothing looked wrong) while the registry read 0.10.0 — a "audit takes forever" report traced to audits running the pre-batching producer. Same act diff (161,982 deleted lines / 610 files): 0.9.1 still running at 18 min (aborted; no completion time recorded), 0.10.0 2m04s, the per-file-concurrency branch 1m11s. When a user says an audit is slow, check `npm view euthyna dist-tags` against the installed `package.json` version **first** | | **A workflow that only runs on release day can be broken for days and show nothing but ~0-second red runs** | 🧪 Measured 2026-10-10: `publish.yml` did not parse from `b795569` (PR #23) until the 0.7.0 rehearsal. The cause was `${{ runner.temp }}` in a **job-level `env:`**, where only the `secrets` and `vars` contexts are legal — one illegal reference invalidates the whole file. Every push produced a 0-second failure that no PR check reported, and the reason surfaced only as `HTTP 422 … Unrecognized named-value: 'runner'` from `gh workflow run`. So run the non-publishing rehearsal (`gh workflow run publish.yml --ref -f publish=false`, or on `main` before the tag) as a standing first step of any release: it costs a minute and is the only exercise that path gets between releases | | **Deleting `TEMP` from the env handed to a child does not remove it in the child** | 🧪 Measured while writing the issue #7 regressions: after the parent deleted `TEMP`/`TMP`/`TMPDIR` from the env object passed to `execFileSync`, the child still reported `'TEMP' in process.env === true` and `os.tmpdir()` equal to the real user temp (`TMP`/`TMPDIR` did stay absent). Setting them to `''` **does** propagate, and `os.tmpdir()` skips an empty value and falls back to an absolute default — so a test that needs "no temp variable" must set them empty rather than delete them, or assert the property on a pure helper instead (see `bench/results-dir.js` and its unit test in `test/bench.test.js`) | | **A computed README badge can publish a claim with nothing behind it, and no check will notice** | 🧪 Measured 2026-10-11: the `Agent skills installs` badge pointed at `skills.sh/b/slow-stack/euthyna.svg`, which renders `resource not found` in red (`fill="#dd4343"`) while its own link is a `404` and the site's search API answers `{"skills":[],"count":0}` for `euthyna`. The listing never existed, and the badge sat in the README for 19 days from `eafd69d`. When touching the badge block, read each SVG's `aria-label` and each link's status: a badge with a hard-coded `color=` is decoration, not evidence. Full measurement: `### skills.sh` |