optbrew 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,5 +2,5 @@
2
2
  "name": "optbrew-local",
3
3
  "description": "Local optbrew plugin source",
4
4
  "owner": { "name": "optbrew" },
5
- "plugins": [{ "name": "optbrew", "source": "./", "version": "0.1.1" }]
5
+ "plugins": [{ "name": "optbrew", "source": "./", "version": "0.1.2" }]
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "optbrew",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Pinned search audit and improvement skill bundle",
5
5
  "skills": ["./skills/optbrew"]
6
6
  }
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "optbrew",
4
- "version": "0.1.1",
4
+ "version": "0.1.2",
5
5
  "description": "Pinned search audit and improvement skill bundle",
6
6
  "license": "MIT",
7
7
  "extensions": {
8
8
  "com.openai": {
9
9
  "skills": ["./skills/optbrew"],
10
- "release_version": "0.1.1"
10
+ "release_version": "0.1.2"
11
11
  }
12
12
  }
13
13
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2
4
+
5
+ - The report headline no longer lets a measured score read as a complete one. Each headline score carries its coverage; below the 80% coverage rule the single number is withheld and the report shows `insufficient coverage` with the measured-subset value and the missing-data bounds (`report.json` `headline[]`).
6
+ - The audit now observes from collected responses: duplicate titles (`document-title`), canonical target status and redirect-chain length (`canonical-consistency`), link and declared-sitemap discovery (`discovery`), JSON-LD parse errors (`structured-data-truth`), Naver markup moved from the unreachable direct-fetch path into the collected-evidence path (`naver-markup`: Open Graph, description, canonical, viewport, title repetition, CSS or JS blocked for Yeti), `search-crawl-policy` for Googlebot, Yeti and the AI search bots OAI-SearchBot, Claude-SearchBot and PerplexityBot (`references/ai-crawlers.md`; training bots stay unscored), and cross-page name, address, phone and founding-year comparison with an entity-identity check (`fact-consistency`, `entity-identity`).
7
+ - `search-crawl-policy` now records a pass as well as a fail for every engine that has a robots token. The collector fetches in-scope canonical targets while the request count is under the limit.
8
+ - Report replay derives every collector verdict again from the hashed raw evidence with the same code the audit uses, and rejects a derived observation that is missing, extra or different.
9
+ - Evaluation version 0.2.0 (was 0.1.0). The set of observations the audit and report produce changed, so 0.1.1 and 0.1.2 scores are not comparable: `compare()` returns `not_comparable` (`evaluation_version_changed`) for a 0.1.1 baseline against 0.1.2 inputs, and the current bundle refuses to score a 0.1.1 input. Run the baseline audit again with 0.1.2 before comparing. Scoring contracts, profile weights, the check registry and the protocol version (0.1.0, protocol sources unchanged) are unchanged.
10
+ - A managed install of 0.1.1 cannot be updated to 0.1.2. The updater guard is unchanged, so `optbrew update` stops with `migration_required` and changes nothing. The message now lists the exact paths to delete and the install and doctor commands for PowerShell and POSIX shells. To replace the install by hand, use the copy-paste blocks for user and project scope in the README and `docs/platforms.md`, "Reinstall after a migration_required update". Each block first runs the new read-only `optbrew reinstall-plan`, which resolves the home directory the way the CLI does and refuses a tampered install record (a target that is not exactly the agent's `skills/optbrew` path, a relative or `..` path, a link that leads elsewhere) before anything is deleted; the shell then deletes only the printed paths, targets first and the managed root last, so the block can be run again after a failure, and never touches `.optbrew/runs`. Updates and rollbacks between releases that keep evaluation version 0.2.0 are unaffected.
11
+ - Public clones: the four maintainer-only vendor tests skip with a reason when the upstream source mirror is absent, so `npm test` passes without it.
12
+ - Round-two review fixes. Replay also binds `skipped`, `limit_reached` and the request counts to a hashed request ledger (`ledger.json`) and the response records, requires every module the audit lists to have its output, derives every collector family from the collector evidence even when a module did not run, and compares the reason and evaluator of each observation. Fact comparison reads only Organization or LocalBusiness JSON-LD, `tel:` links and labelled contact fields, normalises addresses, and treats branch pages as separate entities. Discovery decodes entity-encoded links, treats `index.html` as its directory, and stays unknown for sitemap indexes and script-built navigation. JSON-LD inside comments is ignored. The headline also needs a verdict on every core check, and 79.95% coverage is never shown as 80%.
13
+ - Audit evidence from 0.1.1 has no request ledger or module list and is refused by 0.1.2 report replay with an "older optbrew release" error. Run the audit again with 0.1.2.
14
+ - Round-four review fixes. The request ledger rejects a skipped entry that carries requests and binds a failed fetch to the requests it made (`requests_made` in `collection.json`) and its redirect chain, so a forger cannot pad the total to a claimed request limit. Fact comparison: a phone number no longer continues across a line break; a `tel:` link is its own evidence class and counts only on a page with no labelled or JSON-LD phone; addresses compare as whole tokens (`10 Main St` is not `110 Main St`), and a postal code is dropped only in its own position, so a five-digit street number stays part of the address.
15
+ - Discovery: while any collected page has scripts and no anchors, an unlinked page is unknown (that page may link it from the browser) unless a fetched sitemap that lists other pages leaves it out; without such a page the link graph decides as before. `build-bundle` writes `bundle-manifest.json` through a temporary file and a rename, so a concurrent reader never sees an empty file.
16
+ - Round-five review fixes. Request accounting: a failed fetch has no hashed response record, so it always counts as one request in the ledger and in `request_count`, whatever its redirect chain says; report replay requires exactly that, so a forger cannot pad the total to a claimed request limit. The request limit (`max_urls`, at most 50) now decides only whether a fetch may start, and a redirect chain that has started is completed. A chain follows at most min(10, `max_urls`) redirects, so the requests beyond the limit are at most `concurrency` x min(10, `max_urls`) (20 with the defaults, 10 for `max_urls` 5); report replay applies the same bound. A run that hit the limit while a failed fetch had followed redirects can fall short of the limit in the ledger; replay then rejects the report as unprovable, and the audit has to be run again.
17
+ - Phone extraction reads from text in which every tag ends the text before it, so a number in the next span, table cell or list item is never absorbed; a spaced number that is followed by more digits or a bracketed digit is read only up to its dashed or dotted part, and a national number with spaces between its groups is not read at all. The same address words in another order (27 Wausan-ro, Mapo-gu, Seoul and Seoul, Mapo-gu, Wausan-ro 27) are one address.
18
+ - `optbrew reinstall-plan` also refuses a scope, target or agent name that contains a line break, tab or other control character, because one printed path per line is what the documented blocks delete.
19
+ - A failed fetch or a request-limit skip is only accepted for a URL with provenance: a selected or auxiliary URL of the run, or a URL that a hashed page of the collection links to (anchors and canonical). Report replay rejects fabricated entries for never-linked URLs, which could otherwise pad the request total to a false limit claim and so suppress discovery failures. Duplicate entries for linked URLs are not yet rejected; see Known limitations.
20
+ - `build-release` writes `bundle-manifest.json` through the same temporary file and rename as `build-bundle`, and the release tests build on a private copy of the tree.
21
+ - Not scripted yet: the `robots.txt` Sitemap line, og:image size, hreflang, JavaScript-only navigation, answer structure, claim support, authorship and freshness.
22
+
23
+ ### Known limitations
24
+
25
+ - Report replay does not reject a URL that appears in more than one ledger entry, so adding failed-fetch duplicates of already fetched linked URLs to `collection.json`, `ledger.json` and the audit index can prove a false request limit and turn a discovery failure into `unknown`; this needs deliberate edits to local evidence files.
26
+ - A run that reaches the request limit while a failed fetch was following redirects can record fewer requests than the limit, and report replay then refuses it as unprovable; run the audit again.
27
+ - While any collected page has scripts and no anchors, an unlinked page stays `unknown` unless a declared sitemap that lists other pages leaves it out, so an orphan page on such a site is not reported.
28
+ - Phone extraction skips national numbers written with spaces only (for example `02 555 0142`) and numbers shorter than nine digits (for example `1588-1234`), so a wrong number in those forms is not detected.
29
+ - A page whose title or first heading contains "branch", 지점 or 지사 is treated as a separate entity, so a wrong phone or address on such a page stays `unknown`.
30
+ - Requests can exceed `max_urls` by up to concurrency × min(10, `max_urls`) because a redirect chain that has started is completed (20 with the defaults, 10 for `max_urls` 5).
31
+ - The Naver crawler token `Yeti` was not re-read from Naver's official page during the 0.1.2 review (the page could not be fetched); the other AI and search crawler tokens match the vendors' official pages as checked on 2026-10-15.
32
+
3
33
  ## 0.1.1 — local release candidate
4
34
 
5
35
  - Verified a real localhost noindex/title baseline, backed-up local edit, same-condition rescan and evidence-bound JSON/Markdown report. The collector emits pass or fail observations for completed index checks, a fail for a missing title and unknown for a present one (uniqueness and descriptiveness need separate evidence), and the report replays title, index and crawl-policy verdicts from the preserved raw bytes.
package/README.ko.md CHANGED
@@ -24,7 +24,7 @@ checkout은 `https://github.com/nowwcastle-sudo/optbrew`를 clone해서 받고,
24
24
 
25
25
  ## 첫 실행
26
26
 
27
- 호스트에 optbrew를 사용하라고 요청할 때 대상 URL, 목표, 사이트 소유 또는 접근 권한, 허용할 로컬 변경, 보유한 근거를 전달합니다. 로컬 감사 전에 URL 범위를 선언합니다. 기본 수집 상한은 URL 50개, 깊이 3, 동시성 2입니다. 사설 주소는 명시한 로컬 대상에서만 허용합니다. 작업은 원시 근거, 준비도 관찰, 로컬 변경, 후속 확인을 기록합니다. 로컬 검증 통과를 운영 성과로 해석하지 않습니다.
27
+ 호스트에 optbrew를 사용하라고 요청할 때 대상 URL, 목표, 사이트 소유 또는 접근 권한, 허용할 로컬 변경, 보유한 근거를 전달합니다. 로컬 감사 전에 URL 범위를 선언합니다. 기본 수집 상한은 URL 50개, 깊이 3, 동시성 2입니다. 새 요청은 한도 아래에서만 시작합니다. 이미 시작한 리다이렉트 체인은 끝까지 갑니다. 체인은 리다이렉트 min(10, `max_urls`)번까지라서 한도를 넘는 요청은 동시성 × min(10, `max_urls`)건 이하입니다(기본값은 20건). 사설 주소는 명시한 로컬 대상에서만 허용합니다. 작업은 원시 근거, 준비도 관찰, 로컬 변경, 후속 확인을 기록합니다. 로컬 검증 통과를 운영 성과로 해석하지 않습니다.
28
28
 
29
29
  다음 명령은 포함된 합성 fixture를 재생하며 제공자 호출을 하지 않습니다. 저장소 루트에서 새 출력 경로를 골라 실행합니다.
30
30
 
@@ -42,6 +42,10 @@ node bin/optbrew.mjs report --input ./remote-only.json --output-dir ./report-out
42
42
 
43
43
  자체 준비도 프로필은 SEO, 네이버가 대상일 때 NEO, AEO, GEO, LLMO입니다. 고유 검사군 22개는 여러 가이드가 같은 문제를 찾아도 한 번만 셉니다. 확인하지 못한 적용 검사는 분모에 남기고, 근거 있는 `not_applicable` 검사만 제외합니다. 결과에는 점수, 측정 범위, 미측정 구간의 상·하한, 차단 요소, 미확인 핵심 검사가 들어갑니다. 측정 범위 80% 기준은 optbrew 자체 규칙이며 검색엔진 기준이 아닙니다. 높은 준비도 점수는 색인, 유입, 인용, 사실 정확성, 전환, 매출을 증명하지 않습니다.
44
44
 
45
+ 감사 스크립트가 수집한 응답에서 스스로 관찰하는 항목은 다음과 같습니다. 응답 상태, meta 태그와 `X-Robots-Tag`의 색인 지시자(대상 엔진별), 제목 없음·중복, 선언한 엔진별 robots.txt 접근(Googlebot, Yeti, [`ai-crawlers.md`](skills/optbrew/references/ai-crawlers.md)의 AI 검색 봇. 학습 봇은 점수에 넣지 않음), canonical 대상과 리다이렉트 체인 길이, 링크 또는 선언한 사이트맵을 통한 발견성, JSON-LD 문법, 네이버 마크업(Open Graph, 설명, canonical, viewport, 제목 키워드 반복, 막힌 CSS·JS), 이름·주소·전화·설립연도의 페이지 간 일치입니다. 따라서 SEO와 NEO는 접근·색인·마크업 검사를 스크립트로 합니다. AEO와 GEO는 준비도 감사로 남습니다. 답변 구조, 주장 근거, 저자, 최신성은 가이드와 에이전트 판단이 필요합니다. LLMO는 페이지 간 불일치를 보여 주는 검사를 얻지만 사실이 맞다는 것을 증명하지는 않습니다. 심은 결함 시험과 한계는 저장소의 `docs/validation/detection-0.1.2.md`에 있습니다.
46
+
47
+ 측정 범위가 80% 미만이면 보고서는 하나의 대표 점수를 내지 않고 측정된 부분의 값과 상·하한을 보여 줍니다. 모든 대표 점수는 측정 범위와 함께 표시됩니다.
48
+
45
49
  보고서는 준비도, 해결 방안, 실제 성과, 남은 일을 별도 표로 보여 줍니다. 실제 성과에는 소유자 내보내기 자료 또는 고정된 AI 질문 패널이 필요합니다. 일반 성과 비교에는 정의와 필터가 같은 인접한 완결 28일 기간 두 개를 사용합니다. 로컬 수정은 배포와 운영 관찰의 근거가 생기기 전까지 로컬 수정입니다.
46
50
 
47
51
  ## API와 비용
@@ -64,7 +68,79 @@ node bin/optbrew.mjs update --scope project --project ./site --json
64
68
  node bin/optbrew.mjs rollback --scope project --project ./site --release-version 0.1.0 --json
65
69
  ```
66
70
 
67
- 확인된 registry 패키지가 없으면 `check-updates`는 `local_only`를 반환합니다. `update`는 현재 CLI 번들을 검증한 뒤 관리 기록의 모든 대상에 적용합니다. 로컬 편집은 자동으로 덮어쓰지 않습니다. `rollback`은 관리 기록에 해당 이전 버전이 남아 있어야 합니다. 새 0.1.1 설치에서는 위 복원 예시가 실패하며, 로컬 0.1.0을 보존한 관리 설치에 적용됩니다. 출시 후 `npm update -g optbrew`는 npm 패키지를 갱신하지만 이전에 agent 스킬 폴더로 복사한 파일까지 바꾸지는 않습니다. CLI를 갱신한 다음 관리 `update`를 다시 실행합니다. 호스트 고유 갱신은 [플랫폼 안내](docs/platforms.md)를 보십시오.
71
+ `check-updates --package optbrew`는 npm 레지스트리에서 최신 버전을 읽습니다. `--package`를 빼면 `local_only`를 반환합니다. `update`는 현재 CLI 번들을 검증한 뒤 관리 기록의 모든 대상에 적용합니다. 로컬 편집은 자동으로 덮어쓰지 않습니다. `rollback`은 관리 기록에 해당 이전 버전이 남아 있어야 합니다. 새 0.1.2 설치에서는 위 복원 예시가 실패하며, 로컬 0.1.0을 보존한 관리 설치에 적용됩니다. 출시 후 `npm update -g optbrew`는 npm 패키지를 갱신하지만 이전에 agent 스킬 폴더로 복사한 파일까지 바꾸지는 않습니다. CLI를 갱신한 다음 관리 `update`를 다시 실행합니다. 관리 중인 0.1.1 설치는 0.1.2로 갱신할 수 없고 `update`가 `migration_required`로 멈춥니다(아래 재설치 절차). 호스트 고유 갱신은 [플랫폼 안내](docs/platforms.md)를 보십시오.
72
+
73
+ ### migration_required 뒤 재설치
74
+
75
+ 0.1.2는 평가 버전 0.2.0을 사용합니다(0.1.1은 0.1.0). 그래서 0.1.1로 만든 점수와 기준선은 0.1.2와 비교할 수 없고 `compare`는 `not_comparable`을 반환합니다. 기준선 감사는 0.1.2로 다시 실행하십시오. 같은 이유로 관리 중인 0.1.1 설치에서 `optbrew update`를 실행하면 `migration_required`로 멈추고 아무것도 바꾸지 않으며, 메시지에 경로와 명령이 나옵니다. `uninstall` 명령은 없습니다. 0.1.2 저장소 루트에서 아래 블록으로 교체합니다. 각 블록은 먼저 읽기 전용 `reinstall-plan`을 실행해 설치 기록을 검증하고(모든 대상이 정확히 해당 에이전트의 `skills/optbrew` 경로여야 합니다) 변조된 기록이면 아무것도 지우기 전에 거절합니다. 이후 셸은 그 명령이 출력한 경로만, 대상을 먼저 지우고 관리 루트를 마지막에 지우므로 실패한 뒤 같은 블록을 다시 실행할 수 있습니다. 이 명령은 `.optbrew/runs`(감사 증거)를 건드리지 않습니다. `.optbrew` 폴더 전체를 지우지 마십시오. 자세한 설명은 [플랫폼 안내](docs/platforms.md)를 보십시오.
76
+
77
+ 사용자 범위:
78
+
79
+ ```powershell user
80
+ $ErrorActionPreference = 'Stop'
81
+ [Console]::OutputEncoding = [Text.Encoding]::UTF8
82
+ $plan = @(node bin/optbrew.mjs reinstall-plan --scope user)
83
+ if ($LASTEXITCODE) { exit $LASTEXITCODE }
84
+ foreach ($line in $plan) {
85
+ $f = $line -split "`t"
86
+ if ($f[0] -eq 'delete-link') { [IO.Directory]::Delete($f[1]) }
87
+ elseif ($f[0] -eq 'delete-dir') { Remove-Item -Recurse -Force -LiteralPath $f[1] }
88
+ }
89
+ foreach ($line in $plan) {
90
+ $f = $line -split "`t"
91
+ if ($f[0] -eq 'install') { node bin/optbrew.mjs install --agents $f[1] --scope user --strategy $f[2]; if ($LASTEXITCODE) { exit $LASTEXITCODE } }
92
+ elseif ($f[0] -eq 'agents') { node bin/optbrew.mjs doctor --agents $f[1] --scope user; exit $LASTEXITCODE }
93
+ }
94
+ ```
95
+
96
+ ```bash user
97
+ set -e
98
+ plan=$(node bin/optbrew.mjs reinstall-plan --scope user)
99
+ while IFS=$'\t' read -r kind a b; do
100
+ case "$kind" in delete-link | delete-dir) rm -r -- "$a" ;; esac
101
+ done <<< "$plan"
102
+ while IFS=$'\t' read -r kind a b; do
103
+ case "$kind" in
104
+ install) node bin/optbrew.mjs install --agents "$a" --scope user --strategy "$b" ;;
105
+ agents) node bin/optbrew.mjs doctor --agents "$a" --scope user ;;
106
+ esac
107
+ done <<< "$plan"
108
+ ```
109
+
110
+ 프로젝트 범위(`./site`를 프로젝트 폴더로 바꾸십시오. 한 곳에만 나옵니다):
111
+
112
+ ```powershell project
113
+ $ErrorActionPreference = 'Stop'
114
+ [Console]::OutputEncoding = [Text.Encoding]::UTF8
115
+ $project = './site'
116
+ $plan = @(node bin/optbrew.mjs reinstall-plan --scope project --project $project)
117
+ if ($LASTEXITCODE) { exit $LASTEXITCODE }
118
+ foreach ($line in $plan) {
119
+ $f = $line -split "`t"
120
+ if ($f[0] -eq 'delete-link') { [IO.Directory]::Delete($f[1]) }
121
+ elseif ($f[0] -eq 'delete-dir') { Remove-Item -Recurse -Force -LiteralPath $f[1] }
122
+ }
123
+ foreach ($line in $plan) {
124
+ $f = $line -split "`t"
125
+ if ($f[0] -eq 'install') { node bin/optbrew.mjs install --agents $f[1] --scope project --project $project --strategy $f[2]; if ($LASTEXITCODE) { exit $LASTEXITCODE } }
126
+ elseif ($f[0] -eq 'agents') { node bin/optbrew.mjs doctor --agents $f[1] --scope project --project $project; exit $LASTEXITCODE }
127
+ }
128
+ ```
129
+
130
+ ```bash project
131
+ set -e
132
+ project='./site'
133
+ plan=$(node bin/optbrew.mjs reinstall-plan --scope project --project "$project")
134
+ while IFS=$'\t' read -r kind a b; do
135
+ case "$kind" in delete-link | delete-dir) rm -r -- "$a" ;; esac
136
+ done <<< "$plan"
137
+ while IFS=$'\t' read -r kind a b; do
138
+ case "$kind" in
139
+ install) node bin/optbrew.mjs install --agents "$a" --scope project --project "$project" --strategy "$b" ;;
140
+ agents) node bin/optbrew.mjs doctor --agents "$a" --scope project --project "$project" ;;
141
+ esac
142
+ done <<< "$plan"
143
+ ```
68
144
 
69
145
  ## 지원 범위와 라이선스
70
146
 
package/README.md CHANGED
@@ -24,7 +24,7 @@ To get the checkout, clone `https://github.com/nowwcastle-sudo/optbrew` and run
24
24
 
25
25
  ## First use
26
26
 
27
- Ask the host to use optbrew with the target URL, goal, site ownership or access, allowed local changes, and available evidence. For a local audit, declare the URL scope before collection. The default collection ceilings are 50 URLs, depth 3, and concurrency 2; private addresses need an explicit local target. The workflow records raw evidence, readiness observations, local changes, and follow-up checks. It does not infer a site's production result from a passing local test.
27
+ Ask the host to use optbrew with the target URL, goal, site ownership or access, allowed local changes, and available evidence. For a local audit, declare the URL scope before collection. The default collection ceilings are 50 URLs, depth 3, and concurrency 2. A new fetch starts only while fewer requests than the limit have been made, and a redirect chain that has started is completed; a chain follows at most min(10, `max_urls`) redirects, so the requests beyond the limit are at most concurrency x min(10, `max_urls`), which is 20 with the defaults; private addresses need an explicit local target. The workflow records raw evidence, readiness observations, local changes, and follow-up checks. It does not infer a site's production result from a passing local test.
28
28
 
29
29
  The examples below replay bundled synthetic fixtures. They make no provider call. Run them from the checkout root and choose fresh output paths:
30
30
 
@@ -42,6 +42,10 @@ For those literal commands, copy `tests/fixtures/scoring/before.json` and `after
42
42
 
43
43
  The five optbrew readiness profiles are SEO, NEO when Naver is a target, AEO, GEO, and LLMO. The 22 unique check families are counted once even when several guides find the same issue. An unknown applicable check remains in the denominator; an evidenced `not_applicable` check is excluded. The result includes score, measured coverage, missing-data bounds, blockers, and unresolved core checks. The 80% coverage threshold is an optbrew rule, not a search engine standard. A high readiness score does not prove indexing, traffic, citations, accuracy, conversions, or revenue.
44
44
 
45
+ What the audit script observes by itself, from the responses it collected: fetch status; indexing directives in meta tags and `X-Robots-Tag`, scoped to each target engine; missing or duplicated titles; robots.txt access per declared engine (Googlebot, Yeti and the AI search bots in [`ai-crawlers.md`](skills/optbrew/references/ai-crawlers.md); training bots are never scored); canonical target and redirect-chain length; discovery through links or a declared sitemap; JSON-LD syntax; Naver markup (Open Graph, description, canonical, viewport, title repetition, blocked CSS or JS); and cross-page consistency of name, address, phone and founding year. SEO and NEO therefore have scripted access, indexing and markup checks. AEO and GEO stay readiness audits: answer structure, claims, authorship and freshness still need the guides and agent judgment. LLMO gets a cross-page consistency check, which shows disagreement between pages but does not prove a fact is true. A seeded-defect test and its limits are in `docs/validation/detection-0.1.2.md` (repository only).
46
+
47
+ When measured coverage is below 80%, the report withholds the single headline number and shows the measured subset with its bounds. Each headline score is printed with its coverage.
48
+
45
49
  The report keeps readiness, remedies, actual outcomes, and remaining work in separate tables. Actual outcomes need owner exports or a fixed AI question panel. A normal outcome comparison uses two complete, adjacent 28-day windows with matching definitions and filters. A local edit remains local until deployment and a production observation have their own evidence.
46
50
 
47
51
  ## APIs and cost
@@ -64,7 +68,79 @@ node bin/optbrew.mjs update --scope project --project ./site --json
64
68
  node bin/optbrew.mjs rollback --scope project --project ./site --release-version 0.1.0 --json
65
69
  ```
66
70
 
67
- Without a confirmed registry package, `check-updates` reports `local_only`. `update` uses the current CLI bundle, verifies it, and updates all targets in that install record; it does not silently replace local edits. `rollback` needs that version in the record's retained prior releases. The shown rollback will therefore fail on a fresh 0.1.1 install and applies to a managed installation that retains local 0.1.0. `npm update -g optbrew` updates npm's package files after release; it does not rewrite a copy already placed in an agent skills directory. Run the managed `update` command again after updating the CLI. Native plugin and Pi package update paths are in [platform support](docs/platforms.md).
71
+ `check-updates --package optbrew` reads the latest version from the npm registry; without `--package` it reports `local_only`. `update` uses the current CLI bundle, verifies it, and updates all targets in that install record; it does not silently replace local edits. `rollback` needs that version in the record's retained prior releases. The shown rollback will therefore fail on a fresh 0.1.2 install and applies to a managed installation that retains local 0.1.0. `npm update -g optbrew` updates npm's package files after release; it does not rewrite a copy already placed in an agent skills directory. Run the managed `update` command again after updating the CLI. A managed 0.1.1 install cannot be updated to 0.1.2: `update` stops with `migration_required` (see the reinstall steps below). Native plugin and Pi package update paths are in [platform support](docs/platforms.md).
72
+
73
+ ### Reinstall after a migration_required update
74
+
75
+ Release 0.1.2 uses evaluation version 0.2.0 (0.1.1 used 0.1.0), so scores and baselines made with 0.1.1 are not comparable with 0.1.2: `compare` returns `not_comparable`, and you run the baseline audit again with 0.1.2. For the same reason `optbrew update` on a managed 0.1.1 install stops with `migration_required` and changes nothing; the message lists the paths and commands. There is no `uninstall` command. Replace the install with the blocks below, from the 0.1.2 checkout root. Each block first runs the read-only `reinstall-plan`, which validates the install record (every target must be exactly the agent's `skills/optbrew` path) and refuses a tampered one before anything is deleted. The shell then deletes only the paths it printed, targets first and the managed root last, so the block can be run again after a failure. The commands never touch `.optbrew/runs` (your audit evidence); do not delete the whole `.optbrew` folder. Full explanation: [platform support](docs/platforms.md).
76
+
77
+ User scope:
78
+
79
+ ```powershell user
80
+ $ErrorActionPreference = 'Stop'
81
+ [Console]::OutputEncoding = [Text.Encoding]::UTF8
82
+ $plan = @(node bin/optbrew.mjs reinstall-plan --scope user)
83
+ if ($LASTEXITCODE) { exit $LASTEXITCODE }
84
+ foreach ($line in $plan) {
85
+ $f = $line -split "`t"
86
+ if ($f[0] -eq 'delete-link') { [IO.Directory]::Delete($f[1]) }
87
+ elseif ($f[0] -eq 'delete-dir') { Remove-Item -Recurse -Force -LiteralPath $f[1] }
88
+ }
89
+ foreach ($line in $plan) {
90
+ $f = $line -split "`t"
91
+ if ($f[0] -eq 'install') { node bin/optbrew.mjs install --agents $f[1] --scope user --strategy $f[2]; if ($LASTEXITCODE) { exit $LASTEXITCODE } }
92
+ elseif ($f[0] -eq 'agents') { node bin/optbrew.mjs doctor --agents $f[1] --scope user; exit $LASTEXITCODE }
93
+ }
94
+ ```
95
+
96
+ ```bash user
97
+ set -e
98
+ plan=$(node bin/optbrew.mjs reinstall-plan --scope user)
99
+ while IFS=$'\t' read -r kind a b; do
100
+ case "$kind" in delete-link | delete-dir) rm -r -- "$a" ;; esac
101
+ done <<< "$plan"
102
+ while IFS=$'\t' read -r kind a b; do
103
+ case "$kind" in
104
+ install) node bin/optbrew.mjs install --agents "$a" --scope user --strategy "$b" ;;
105
+ agents) node bin/optbrew.mjs doctor --agents "$a" --scope user ;;
106
+ esac
107
+ done <<< "$plan"
108
+ ```
109
+
110
+ Project scope (replace `./site` with your project folder; it appears once):
111
+
112
+ ```powershell project
113
+ $ErrorActionPreference = 'Stop'
114
+ [Console]::OutputEncoding = [Text.Encoding]::UTF8
115
+ $project = './site'
116
+ $plan = @(node bin/optbrew.mjs reinstall-plan --scope project --project $project)
117
+ if ($LASTEXITCODE) { exit $LASTEXITCODE }
118
+ foreach ($line in $plan) {
119
+ $f = $line -split "`t"
120
+ if ($f[0] -eq 'delete-link') { [IO.Directory]::Delete($f[1]) }
121
+ elseif ($f[0] -eq 'delete-dir') { Remove-Item -Recurse -Force -LiteralPath $f[1] }
122
+ }
123
+ foreach ($line in $plan) {
124
+ $f = $line -split "`t"
125
+ if ($f[0] -eq 'install') { node bin/optbrew.mjs install --agents $f[1] --scope project --project $project --strategy $f[2]; if ($LASTEXITCODE) { exit $LASTEXITCODE } }
126
+ elseif ($f[0] -eq 'agents') { node bin/optbrew.mjs doctor --agents $f[1] --scope project --project $project; exit $LASTEXITCODE }
127
+ }
128
+ ```
129
+
130
+ ```bash project
131
+ set -e
132
+ project='./site'
133
+ plan=$(node bin/optbrew.mjs reinstall-plan --scope project --project "$project")
134
+ while IFS=$'\t' read -r kind a b; do
135
+ case "$kind" in delete-link | delete-dir) rm -r -- "$a" ;; esac
136
+ done <<< "$plan"
137
+ while IFS=$'\t' read -r kind a b; do
138
+ case "$kind" in
139
+ install) node bin/optbrew.mjs install --agents "$a" --scope project --project "$project" --strategy "$b" ;;
140
+ agents) node bin/optbrew.mjs doctor --agents "$a" --scope project --project "$project" ;;
141
+ esac
142
+ done <<< "$plan"
143
+ ```
68
144
 
69
145
  ## Support and license
70
146
 
package/docs/platforms.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Platform installation and update paths
2
2
 
3
- Optbrew v0.1.1 contains one skill source at `skills/optbrew`. The managed CLI can place it at five hosts' documented default paths. A file in a host path proves placement, not that the host discovered, loaded, or invoked the skill. Confirm those three steps in a fresh host session before reporting host support as exercised.
3
+ Optbrew v0.1.2 contains one skill source at `skills/optbrew`. The managed CLI can place it at five hosts' documented default paths. A file in a host path proves placement, not that the host discovered, loaded, or invoked the skill. Confirm those three steps in a fresh host session before reporting host support as exercised.
4
4
 
5
5
  | Host | Managed project target | Managed user target | Discovery and invocation boundary |
6
6
  |---|---|---|---|
@@ -16,15 +16,91 @@ The CLI maps only these default targets. Custom roots, host policies, remote age
16
16
 
17
17
  The [README](../README.md) shows parser-checked checkout commands for install, doctor, update, and rollback. The CLI validates bundle hashes before an install or update. `update` replaces every target in one managed record as a unit. An edited target makes the default update stop; the explicit `--local-changes backup-and-replace` policy retains a readable backup. `rollback --release-version VERSION` works only when that version is in the record's retained prior releases. A fresh installation has no older release.
18
18
 
19
- After a public npm release, `npm install -g optbrew` and `npm update -g optbrew` manage the package in npm. Neither command rewrites an old copied skill target. Run the installed CLI's managed `update` after the package update, then `doctor` and a new-session invocation check. `check-updates --json` is read-only; without a confirmed registry name it returns `local_only`. The package is not public merely because `package.json` names it.
19
+ After a public npm release, `npm install -g optbrew` and `npm update -g optbrew` manage the package in npm. Neither command rewrites an old copied skill target. Run the installed CLI's managed `update` after the package update, then `doctor` and a new-session invocation check. `check-updates --package optbrew --json` is read-only and compares the installed release with the npm registry; without `--package` it returns `local_only`.
20
+
21
+ ## Reinstall after a migration_required update
22
+
23
+ Release 0.1.2 uses evaluation version 0.2.0; 0.1.1 used 0.1.0. The managed updater refuses a change of evaluation version, so `optbrew update` on a managed 0.1.1 install stops with `migration_required` and changes nothing. There is no `uninstall` command. Replace the install by hand: the blocks below delete the install's targets and then its managed root, and run `install` again from the 0.1.2 checkout. Scores and baselines made with 0.1.1 are not comparable with 0.1.2; `compare` returns `not_comparable` (`evaluation_version_changed`). Run the baseline audit again with 0.1.2.
24
+
25
+ The blocks do not trust the install record. Each one first runs the read-only `node bin/optbrew.mjs reinstall-plan`, which resolves the home directory the way the CLI does (`HOME`, else the operating system's home directory), checks that the record belongs to this scope, that every recorded target is exactly the agent's `skills/optbrew` path (absolute, no `..`, no linked folder on the way, inside the scope or project), and that an existing target is a link to the managed bundle or a real folder holding a bundle manifest. If any check fails it prints the reason, exits with a nonzero code, and the block deletes nothing. Otherwise the shell deletes only the paths it printed: the targets first, then `<scope root>/.optbrew/installs/user` (user scope) or `<project>/.optbrew/install` (project scope), which holds the install record. Because the record goes last, you can run the same block again after a failure. The commands never touch `<scope root>/.optbrew/runs`, where your audit evidence lives; do not delete the whole `.optbrew` folder. Each agent is installed again with the strategy (`link` or `copy`) the record had, then `doctor` runs. Run the blocks from the 0.1.2 checkout root. If `reinstall-plan` refuses the record, fix or remove the named path by hand and look at it first; the CLI also refuses to overwrite a target it does not own.
26
+
27
+ User scope:
28
+
29
+ ```powershell user
30
+ $ErrorActionPreference = 'Stop'
31
+ [Console]::OutputEncoding = [Text.Encoding]::UTF8
32
+ $plan = @(node bin/optbrew.mjs reinstall-plan --scope user)
33
+ if ($LASTEXITCODE) { exit $LASTEXITCODE }
34
+ foreach ($line in $plan) {
35
+ $f = $line -split "`t"
36
+ if ($f[0] -eq 'delete-link') { [IO.Directory]::Delete($f[1]) }
37
+ elseif ($f[0] -eq 'delete-dir') { Remove-Item -Recurse -Force -LiteralPath $f[1] }
38
+ }
39
+ foreach ($line in $plan) {
40
+ $f = $line -split "`t"
41
+ if ($f[0] -eq 'install') { node bin/optbrew.mjs install --agents $f[1] --scope user --strategy $f[2]; if ($LASTEXITCODE) { exit $LASTEXITCODE } }
42
+ elseif ($f[0] -eq 'agents') { node bin/optbrew.mjs doctor --agents $f[1] --scope user; exit $LASTEXITCODE }
43
+ }
44
+ ```
45
+
46
+ ```bash user
47
+ set -e
48
+ plan=$(node bin/optbrew.mjs reinstall-plan --scope user)
49
+ while IFS=$'\t' read -r kind a b; do
50
+ case "$kind" in delete-link | delete-dir) rm -r -- "$a" ;; esac
51
+ done <<< "$plan"
52
+ while IFS=$'\t' read -r kind a b; do
53
+ case "$kind" in
54
+ install) node bin/optbrew.mjs install --agents "$a" --scope user --strategy "$b" ;;
55
+ agents) node bin/optbrew.mjs doctor --agents "$a" --scope user ;;
56
+ esac
57
+ done <<< "$plan"
58
+ ```
59
+
60
+ Project scope (replace `./site` with your project folder; it appears once):
61
+
62
+ ```powershell project
63
+ $ErrorActionPreference = 'Stop'
64
+ [Console]::OutputEncoding = [Text.Encoding]::UTF8
65
+ $project = './site'
66
+ $plan = @(node bin/optbrew.mjs reinstall-plan --scope project --project $project)
67
+ if ($LASTEXITCODE) { exit $LASTEXITCODE }
68
+ foreach ($line in $plan) {
69
+ $f = $line -split "`t"
70
+ if ($f[0] -eq 'delete-link') { [IO.Directory]::Delete($f[1]) }
71
+ elseif ($f[0] -eq 'delete-dir') { Remove-Item -Recurse -Force -LiteralPath $f[1] }
72
+ }
73
+ foreach ($line in $plan) {
74
+ $f = $line -split "`t"
75
+ if ($f[0] -eq 'install') { node bin/optbrew.mjs install --agents $f[1] --scope project --project $project --strategy $f[2]; if ($LASTEXITCODE) { exit $LASTEXITCODE } }
76
+ elseif ($f[0] -eq 'agents') { node bin/optbrew.mjs doctor --agents $f[1] --scope project --project $project; exit $LASTEXITCODE }
77
+ }
78
+ ```
79
+
80
+ ```bash project
81
+ set -e
82
+ project='./site'
83
+ plan=$(node bin/optbrew.mjs reinstall-plan --scope project --project "$project")
84
+ while IFS=$'\t' read -r kind a b; do
85
+ case "$kind" in delete-link | delete-dir) rm -r -- "$a" ;; esac
86
+ done <<< "$plan"
87
+ while IFS=$'\t' read -r kind a b; do
88
+ case "$kind" in
89
+ install) node bin/optbrew.mjs install --agents "$a" --scope project --project "$project" --strategy "$b" ;;
90
+ agents) node bin/optbrew.mjs doctor --agents "$a" --scope project --project "$project" ;;
91
+ esac
92
+ done <<< "$plan"
93
+ ```
94
+
95
+ Inside 0.1.2 and later releases that keep evaluation version 0.2.0, `update` and `rollback` work as before. The same refusal and steps apply to any future release that changes the evaluation version.
20
96
 
21
97
  ## Native host routes
22
98
 
23
99
  These are alternatives to the managed CLI. Do not mix their ownership records for the same target path.
24
100
 
25
101
  - **Agent Skills tool:** Current [Vercel Skills CLI](https://github.com/vercel-labs/skills) documents `npx skills add ./` for a local source and `npx skills update optbrew` for an installation it owns. Run from a checkout containing the skill. Confirm its host selection and destination before writing. The public repository is `https://github.com/nowwcastle-sudo/optbrew`.
26
- - **Claude Code plugin:** The local `.claude-plugin/marketplace.json` and `plugin.json` name `optbrew@optbrew-local` at version 0.1.1. The installed Claude CLI help accepts `claude plugin marketplace add --scope project .`, then `claude plugin install --scope project optbrew@optbrew-local` from this checkout. Later, `claude plugin update --scope project optbrew@optbrew-local` applies the marketplace's newer version and requires a restart. These commands change Claude settings, so documentation checks inspect `--help` without registering a marketplace. [Claude Code plugin instructions](https://code.claude.com/docs/en/plugins) also describe the interactive `/plugin` path. Marketplace auto-update is a user setting; the marketplace file does not enable it. Verify the selected release version and a fresh-session invocation after a native update.
27
- - **Pi package:** Current [Pi package instructions](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md) document `pi install ./local-package`, `pi install npm:package-name@version`, `pi list`, and `pi update --extensions`. `pi install ./` installs from a local checkout when Pi is installed; `pi install npm:optbrew@0.1.1` installs the published npm package. The package's `pi.skills` entry points to `./skills/optbrew`. A project-local package declaration uses Pi's `--local` option and is subject to project trust. Check the installed Pi version's help and package list before updating.
102
+ - **Claude Code plugin:** The local `.claude-plugin/marketplace.json` and `plugin.json` name `optbrew@optbrew-local` at version 0.1.2. The installed Claude CLI help accepts `claude plugin marketplace add --scope project .`, then `claude plugin install --scope project optbrew@optbrew-local` from this checkout. Later, `claude plugin update --scope project optbrew@optbrew-local` applies the marketplace's newer version and requires a restart. These commands change Claude settings, so documentation checks inspect `--help` without registering a marketplace. [Claude Code plugin instructions](https://code.claude.com/docs/en/plugins) also describe the interactive `/plugin` path. Marketplace auto-update is a user setting; the marketplace file does not enable it. Verify the selected release version and a fresh-session invocation after a native update.
103
+ - **Pi package:** Current [Pi package instructions](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md) document `pi install ./local-package`, `pi install npm:package-name@version`, `pi list`, and `pi update --extensions`. `pi install ./` installs from a local checkout when Pi is installed; `pi install npm:optbrew@0.1.2` installs the published npm package. The package's `pi.skills` entry points to `./skills/optbrew`. A project-local package declaration uses Pi's `--local` option and is subject to project trust. Check the installed Pi version's help and package list before updating.
28
104
  - **Claude web:** Upload the built skill ZIP manually in the supported web interface. Upload a new ZIP again for each update. There is no verified automatic update of that upload.
29
105
  - **Ordinary ChatGPT chat:** Attach the relevant skill files or ZIP and give the assistant the target and scope. This is attachment-guided use. It is not the Codex installation path and does not establish Work/desktop plugin discovery.
30
106
 
package/docs/release.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Release procedure
2
2
 
3
- This checkout is version 0.1.1. A package name, local manifest, or generated ZIP does not establish a public release. Publication, a Git tag, a push, and account changes require their own authorization. This page describes the reviewable local release artifacts and the checks to run before that decision.
3
+ This checkout is version 0.1.2. A package name, local manifest, or generated ZIP does not establish a public release. Publication, a Git tag, a push, and account changes require their own authorization. This page describes the reviewable local release artifacts and the checks to run before that decision.
4
4
 
5
5
  1. Confirm the source commit and clean intended diff. Match `package.json`, the portable plugin, Codex plugin, Claude plugin, marketplace entry, and ZIP name to one release version. Keep `evaluation_version` and `protocol_version` separate from the release version.
6
6
  2. Run `npm test` and `python -m unittest discover -s tests/python` on Python 3.10 or later. Run the focused `node --test tests/docs-commands.test.mjs` check when changing examples. The command check runs optbrew against isolated local fixtures; it makes no provider call.
7
- 3. Run `npm run build-bundle`, `node scripts/build-release.mjs --version 0.1.1 --output-dir dist`, `npm pack --pack-destination dist`, `node scripts/verify-release.mjs --dist dist`, and `npm pack --dry-run --json` from the repository root. Rebuild after changing the README or packaged files. Do not treat an older tarball as containing a later documentation edit.
7
+ 3. Run `npm run build-bundle`, `node scripts/build-release.mjs --version 0.1.2 --output-dir dist`, `npm pack --pack-destination dist`, `node scripts/verify-release.mjs --dist dist`, and `npm pack --dry-run --json` from the repository root. Rebuild after changing the README or packaged files. Do not treat an older tarball as containing a later documentation edit.
8
8
  4. Inspect the npm file list, the ZIP file list, `bundle-manifest.json`, `source-lock.json`, licenses, and secret scan output. The same skill bytes and version must be traceable across the npm package and ZIP. Source notices belong in both. User run evidence, account configuration, keys, private cards, and authoring materials must stay outside both.
9
9
  5. Test a fresh isolated managed install, `doctor`, update from a retained earlier release, rollback, and fresh host discovery or invocation where a host is available. A dry run or manifest check does not prove invocation. Record the untested hosts and capabilities.
10
10
  6. Only after a reviewed release decision, publish or upload the approved artifacts and verify the public npm and repository readback. Do not infer publication from successful local builds.
package/lib/cli.mjs CHANGED
@@ -8,10 +8,11 @@ import { checkUpdates, update, rollback } from './update.mjs';
8
8
  import { readFile } from 'node:fs/promises';
9
9
  import { readInstallRecord } from './records.mjs';
10
10
  import { runCli } from '../skills/optbrew/scripts/cli.mjs';
11
+ import { reinstallPlan } from './reinstall.mjs';
11
12
 
12
13
  const defaultBundle = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../skills/optbrew');
13
14
  class UsageError extends Error {}
14
- const usage = 'usage: optbrew <install|doctor|check-updates|update|rollback|score|compare|report> [options]';
15
+ const usage = 'usage: optbrew <install|doctor|check-updates|update|rollback|reinstall-plan|score|compare|report> [options]';
15
16
 
16
17
  function parseOptions(argv, allowed) {
17
18
  const options = {};
@@ -61,7 +62,19 @@ export async function main(argv, { stdout = process.stdout, stderr = process.std
61
62
  stdout.write(`${command} complete\n`);
62
63
  return 0;
63
64
  }
64
- if (!['install', 'doctor', 'check-updates', 'update', 'rollback'].includes(command)) throw new UsageError(usage);
65
+ if (!['install', 'doctor', 'check-updates', 'update', 'rollback', 'reinstall-plan'].includes(command)) throw new UsageError(usage);
66
+ if (command === 'reinstall-plan') {
67
+ // Read-only: validates the install record and prints what the documented manual reinstall may delete.
68
+ const parsed = parseOptions(rest, ['--scope', '--home', '--project']);
69
+ const scope = parsed['--scope'] ?? 'user';
70
+ if (!['user', 'project'].includes(scope)) throw new UsageError('--scope must be user or project');
71
+ if (scope === 'project' && !parsed['--project']) throw new UsageError('project scope requires --project');
72
+ if (scope === 'user' && parsed['--project']) throw new UsageError('--project requires project scope');
73
+ const rows = await reinstallPlan({ scope, home: parsed['--home'] ? path.resolve(cwd, parsed['--home']) : env?.HOME ?? os.homedir(),
74
+ projectRoot: parsed['--project'] ? path.resolve(cwd, parsed['--project']) : undefined });
75
+ stdout.write(`${rows.map(row => row.join('\t')).join('\n')}\n`);
76
+ return 0;
77
+ }
65
78
  if (command === 'check-updates') {
66
79
  const parsed = parseOptions(rest, ['--package', '--json']);
67
80
  const manifest = JSON.parse(await readFile(path.join(bundleRoot, 'bundle-manifest.json'), 'utf8'));
package/lib/doctor.mjs CHANGED
@@ -30,6 +30,7 @@ export async function doctor({ agents, scope, home, projectRoot }) {
30
30
  if (saved.strategy === 'copy' && (await lstat(target.path)).isSymbolicLink()) throw new Error('copy replaced by link');
31
31
  const manifest = JSON.parse(await readFile(path.join(target.path, 'bundle-manifest.json'), 'utf8'));
32
32
  const check = await verifyBundle({ bundleRoot: target.path, manifest });
33
+ if (check.errors.some(e => e.code === 'migration_required')) warnings.push(`${target.agent}: installed release ${manifest.release_version} was built for another evaluation version; update is refused (migration_required). See docs/platforms.md, "Reinstall after a migration_required update".`);
33
34
  hashOk = !recoveryPending && record.transaction.state === 'committed' && check.status === 'valid' && check.bundle_hash === record.bundle_hash && manifest.release_version === record.release_version;
34
35
  } catch (error) { warnings.push(`${target.agent}: ${error.message}`); }
35
36
  }
@@ -0,0 +1,92 @@
1
+ import { lstat, realpath } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { readInstallRecord } from './records.mjs';
4
+ import { scopePaths } from './paths.mjs';
5
+ import { validateScope, statOrNull } from './transaction.mjs';
6
+
7
+ const powershell = value => `'${String(value).replaceAll("'", "''")}'`;
8
+ const posix = value => `'${String(value).replaceAll("'", "'\\''")}'`;
9
+
10
+ /**
11
+ * Read-only plan for the manual reinstall of a managed scope. It validates the install record before anything is
12
+ * deleted: the record belongs to this scope, every recorded target is exactly the expected skills/optbrew path of its
13
+ * agent (absolute, no "..", no linked ancestor, inside the scope), and every target that exists is what the installer
14
+ * created (a link to the managed bundle, or a real directory holding a bundle manifest). Any failed check throws, so a
15
+ * caller that deletes only what this returns never deletes anything for a tampered record.
16
+ *
17
+ * Rows: `delete-link|delete-dir <path>` (recorded targets first, the managed root last), then `install <agent> <strategy>`
18
+ * per recorded agent in record order, then `agents <csv>` for the final doctor call.
19
+ */
20
+ export async function reinstallPlan({ scope, home, projectRoot }) {
21
+ const refuse = message => { throw new Error(`reinstall plan refused: ${message}`); };
22
+ // One output line per path: a line break, tab or other control character would split a row or inject another one.
23
+ const plain = value => typeof value === 'string' && [...value].every(ch => { const c = ch.codePointAt(0); return c > 31 && (c < 127 || c > 159) && c !== 8232 && c !== 8233; });
24
+ const { scopeRoot, managedRoot } = scopePaths({ scope, home, projectRoot });
25
+ if (!plain(scopeRoot) || !plain(managedRoot)) refuse('the scope path contains a control character (a line break, tab or similar)');
26
+ const record = await readInstallRecord(managedRoot).catch(error => refuse(`install record unreadable (${error.message})`));
27
+ if (!record) refuse(`no install record in ${managedRoot}`);
28
+ if (record.scope_id !== `${scope}:${scopeRoot}` || record.managed_root !== managedRoot) refuse('the install record belongs to another scope');
29
+ if (!record.targets.length) refuse('the install record lists no target');
30
+ for (const target of record.targets) {
31
+ if (!plain(target.path) && typeof target.path === 'string') refuse('a recorded target path contains a control character (a line break, tab or similar)');
32
+ if (typeof target.path !== 'string' || !path.isAbsolute(target.path) || target.path.split(/[\\/]/).includes('..'))
33
+ refuse(`recorded target is not a plain absolute path: ${JSON.stringify(target.path)}`);
34
+ }
35
+ try { await validateScope(managedRoot, record); }
36
+ catch (error) { refuse(`${error.message} (expected exactly the agent's skills/optbrew path inside ${scopeRoot})`); }
37
+ const managedBundle = path.join(managedRoot, 'bundle');
38
+ const rows = [];
39
+ for (const target of record.targets) {
40
+ const found = await statOrNull(target.path);
41
+ if (!found) continue;
42
+ if (found.isSymbolicLink()) {
43
+ if (target.strategy !== 'link' || await realpath(target.path) !== await realpath(managedBundle))
44
+ refuse(`${target.path} is a link that does not lead to the managed bundle`);
45
+ rows.push(['delete-link', target.path]);
46
+ } else if (found.isDirectory()) {
47
+ if (target.strategy !== 'copy') refuse(`${target.path} was recorded as a link but is a directory`);
48
+ try { await lstat(path.join(target.path, 'bundle-manifest.json')); }
49
+ catch { refuse(`${target.path} holds no optbrew bundle manifest`); }
50
+ rows.push(['delete-dir', target.path]);
51
+ } else refuse(`${target.path} is neither a link nor a directory`);
52
+ }
53
+ rows.push(['delete-dir', managedRoot]);
54
+ for (const target of record.targets) rows.push(['install', target.agent, target.strategy]);
55
+ rows.push(['agents', record.targets.map(target => target.agent).join(',')]);
56
+ if (!rows.every(row => row.every(plain))) refuse('a path or name in the plan contains a control character (a line break, tab or similar)');
57
+ return rows;
58
+ }
59
+
60
+ /**
61
+ * Exact manual reinstall steps for a managed scope whose update was refused with migration_required.
62
+ * Targets are deleted first and the managed root (which holds the install record) last, so the steps can be repeated
63
+ * after a partial failure. `.optbrew/runs` is never deleted.
64
+ */
65
+ export function reinstallGuidance({ record, recordRoot, from, to }) {
66
+ const separator = record.scope_id.indexOf(':');
67
+ const scope = record.scope_id.slice(0, separator), scopeRoot = record.scope_id.slice(separator + 1);
68
+ const agents = record.targets.map(target => target.agent).join(',');
69
+ const place = quote => scope === 'user' ? `--scope user --home ${quote(scopeRoot)}` : `--scope project --project ${quote(scopeRoot)}`;
70
+ const removePowershell = record.targets.map(target => target.strategy === 'link' ?
71
+ `[IO.Directory]::Delete(${powershell(target.path)})` : `Remove-Item -Recurse -Force -LiteralPath ${powershell(target.path)}`);
72
+ removePowershell.push(`Remove-Item -Recurse -Force -LiteralPath ${powershell(recordRoot)}`);
73
+ const removePosix = `rm -r -- ${[...record.targets.map(target => target.path), recordRoot].map(posix).join(' ')}`;
74
+ const install = quote => record.targets.map(target =>
75
+ `optbrew install --agents ${target.agent} ${place(quote)} --strategy ${target.strategy}`);
76
+ const doctor = quote => `optbrew doctor --agents ${agents} ${place(quote)}`;
77
+ return [
78
+ `Release ${to} uses a new evaluation contract, so a managed update from ${from} cannot carry this install over, and scores from the two releases are not comparable. Reinstall instead; your runs folder (${scopeRoot}/.optbrew/runs) is not touched, so never delete it.`,
79
+ '1. Check the install record first. This read-only command refuses a tampered record and prints exactly what will be deleted:',
80
+ ` optbrew reinstall-plan ${place(posix)}`,
81
+ '2. Delete only those paths, the targets first and the managed root last (repeat the same steps if one fails):',
82
+ ' PowerShell:',
83
+ ...removePowershell.map(line => ` ${line}`),
84
+ ` POSIX shell: ${removePosix}`,
85
+ '3. Install again, one command per recorded agent, from the checkout of the new release:',
86
+ ...['PowerShell', 'POSIX shell'].flatMap((label, i) => [` ${label}:`, ...install(i ? posix : powershell).map(line => ` ${line}`)]),
87
+ '4. Verify:',
88
+ ` PowerShell: ${doctor(powershell)}`,
89
+ ` POSIX shell: ${doctor(posix)}`,
90
+ 'The same steps as copy-paste blocks that do these checks for you: docs/platforms.md, "Reinstall after a migration_required update".',
91
+ ].join('\n');
92
+ }
package/lib/update.mjs CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { createBundleManifest } from '../skills/optbrew/scripts/core/bundle.mjs';
5
5
  import { readInstallRecord, writeDurableJson, writeInstallRecord } from './records.mjs';
6
+ import { reinstallGuidance } from './reinstall.mjs';
6
7
  import { assertManagedPath, copyBundle, fingerprint, finishJournal, managedRename, operationPaths,
7
8
  readJournal, recoverTransaction, statOrNull, validateScope, verifiedBundle, withScopeLock } from './transaction.mjs';
8
9
 
@@ -110,9 +111,16 @@ async function applyRelease({ bundleRoot, recordRoot, localChanges, faultHook, k
110
111
  const managed = path.join(recordRoot, 'bundle');
111
112
  let previousRoot = managed;
112
113
  if (conflicts.some(c => c.path === managed)) previousRoot = before.source;
113
- const previous = await verifiedBundle(previousRoot);
114
+ // The guard stays as is. A refusal for another evaluation version carries the exact manual reinstall steps.
115
+ const refuse = error => {
116
+ if (!/migration_required/.test(error.message)) throw error;
117
+ const reason = error.message.startsWith('migration_required') ? error.message
118
+ : 'migration_required: evaluation_version changed (the installed bundle was built for another evaluation version)';
119
+ throw new Error(`${reason}\n${reinstallGuidance({ record: before, recordRoot, from: before.release_version, to: source.manifest.release_version })}`);
120
+ };
121
+ const previous = await verifiedBundle(previousRoot).catch(refuse);
114
122
  if (!matchesRecord(previous.manifest, before)) throw new Error('previous verified bundle unavailable; preserve edits and recover original release first');
115
- await compatible(previous.manifest, source.manifest);
123
+ await compatible(previous.manifest, source.manifest).catch(refuse);
116
124
  const id = randomUUID();
117
125
  const snapshotRoot = path.join(recordRoot, 'releases', id, 'bundle');
118
126
  await assertManagedPath(scopeRoot, snapshotRoot);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "optbrew",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Pinned search audit and improvement skill bundle",
6
6
  "license": "MIT",
@@ -34,7 +34,7 @@
34
34
  ],
35
35
  "scripts": {
36
36
  "test": "node --test",
37
- "build-bundle": "node scripts/build-bundle.mjs --bundle skills/optbrew --version 0.1.1",
37
+ "build-bundle": "node scripts/build-bundle.mjs --bundle skills/optbrew --version 0.1.2",
38
38
  "vendor": "node scripts/vendor.mjs",
39
39
  "scan-secrets": "node scripts/scan-secrets.mjs --tracked-and-staged"
40
40
  }