opencode-skills-collection 4.1.17 → 4.1.19
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.
- package/bundled-skills/.antigravity-install-manifest.json +16 -1
- package/bundled-skills/agentic-test-audit/SKILL.md +143 -0
- package/bundled-skills/agentic-verify/SKILL.md +153 -0
- package/bundled-skills/altorank/SKILL.md +155 -0
- package/bundled-skills/apify-reddit-scraper/SKILL.md +208 -0
- package/bundled-skills/apify-reddit-scraper/references/gotchas.md +62 -0
- package/bundled-skills/apify-reddit-scraper/references/input-mapping.md +118 -0
- package/bundled-skills/chord-onchain-trading/SKILL.md +98 -0
- package/bundled-skills/healthcare-job-search/SKILL.md +77 -0
- package/bundled-skills/logo-to-stickers/LICENSE +21 -0
- package/bundled-skills/logo-to-stickers/SKILL.md +111 -0
- package/bundled-skills/logo-to-stickers/references/walkthrough.md +9 -0
- package/bundled-skills/mmx-cli/SKILL.md +7 -4
- package/bundled-skills/moltnode-chain-reader/SKILL.md +84 -0
- package/bundled-skills/movie-coupon/SKILL.md +95 -0
- package/bundled-skills/movie-coupon/assets/movie-sample1.png +0 -0
- package/bundled-skills/movie-coupon/references/api_documentation.md +66 -0
- package/bundled-skills/movie-coupon/scripts/fetch_coupons.py +154 -0
- package/bundled-skills/obsess/LICENSE +21 -0
- package/bundled-skills/obsess/SKILL.md +125 -0
- package/bundled-skills/obsess/assets/baseline.example.json +19 -0
- package/bundled-skills/obsess/assets/candidate.example.json +19 -0
- package/bundled-skills/obsess/assets/contract.example.json +29 -0
- package/bundled-skills/obsess/assets/experiment.template.json +21 -0
- package/bundled-skills/obsess/assets/report.template.md +17 -0
- package/bundled-skills/obsess/assets/run.template.md +32 -0
- package/bundled-skills/obsess/assets/state.template.json +26 -0
- package/bundled-skills/obsess/references/discovery.md +13 -0
- package/bundled-skills/obsess/references/evaluators.md +54 -0
- package/bundled-skills/obsess/references/host.md +73 -0
- package/bundled-skills/obsess/references/integrations/cline/obsess.md +9 -0
- package/bundled-skills/obsess/references/integrations/codex/obsess.md +13 -0
- package/bundled-skills/obsess/references/integrations/copilot/obsess.prompt.md +12 -0
- package/bundled-skills/obsess/references/integrations/cursor/obsess.md +9 -0
- package/bundled-skills/obsess/references/integrations/gemini/obsess-command.md +11 -0
- package/bundled-skills/obsess/references/integrations/opencode/obsess.md +10 -0
- package/bundled-skills/obsess/references/integrations/rules/obsess.md +23 -0
- package/bundled-skills/obsess/references/integrations/windsurf/obsess.md +11 -0
- package/bundled-skills/obsess/references/polish.md +59 -0
- package/bundled-skills/obsess/references/protocol.md +55 -0
- package/bundled-skills/obsess/references/recipes.md +51 -0
- package/bundled-skills/obsess/references/recovery.md +50 -0
- package/bundled-skills/obsess/scripts/gate.py +167 -0
- package/bundled-skills/obsess/scripts/install.py +241 -0
- package/bundled-skills/obtain-coupons-all-in-one/SKILL.md +95 -0
- package/bundled-skills/obtain-coupons-all-in-one/references/api_documentation.md +321 -0
- package/bundled-skills/obtain-coupons-all-in-one/scripts/fetch_coupons.py +451 -0
- package/bundled-skills/openhook/LICENSE +21 -0
- package/bundled-skills/openhook/SKILL.md +119 -0
- package/bundled-skills/openhook/references/evaluations.md +19 -0
- package/bundled-skills/parcel-coupon/SKILL.md +95 -0
- package/bundled-skills/parcel-coupon/references/api_documentation.md +68 -0
- package/bundled-skills/parcel-coupon/scripts/fetch_coupons.py +154 -0
- package/bundled-skills/takeout-coupon/SKILL.md +95 -0
- package/bundled-skills/takeout-coupon/assets/eleme-sample1.png +0 -0
- package/bundled-skills/takeout-coupon/assets/meituan-sample1.png +0 -0
- package/bundled-skills/takeout-coupon/references/api_documentation.md +173 -0
- package/bundled-skills/takeout-coupon/scripts/fetch_coupons.py +221 -0
- package/bundled-skills/trip-coupon/SKILL.md +95 -0
- package/bundled-skills/trip-coupon/assets/trip-sample1.png +0 -0
- package/bundled-skills/trip-coupon/assets/trip-sample2.png +0 -0
- package/bundled-skills/trip-coupon/references/api_documentation.md +73 -0
- package/bundled-skills/trip-coupon/scripts/fetch_coupons.py +194 -0
- package/package.json +1 -1
- package/skills_index.json +489 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Measured optimisation protocol
|
|
2
|
+
|
|
3
|
+
Use this only when the primary objective has a reproducible scalar evaluator. For non-scalar work use [polish](polish.md), even when the target is code. Defaults are engineering choices, not research-established optima.
|
|
4
|
+
|
|
5
|
+
## Freeze a compact contract
|
|
6
|
+
|
|
7
|
+
Use [contract.example.json](../assets/contract.example.json) as a field guide. Keep the primary objective separate from hard constraints. Record the target and authorised paths, then the baseline snapshot and comparison conditions. Choose the evaluator revision and minimum meaningful improvement before seeing candidate results.
|
|
8
|
+
|
|
9
|
+
Default ceiling: 12 candidate attempts and 30 active-session minutes. Explicit limits replace those defaults. `--lite` lowers either mode to at most 2 attempts and 5 active-session minutes, with no extra reviewers. Read [recovery](recovery.md) for cumulative accounting.
|
|
10
|
+
|
|
11
|
+
## Establish the baseline
|
|
12
|
+
|
|
13
|
+
Run the untouched version under the chosen conditions. Keep raw results and the evaluator's identity. A new candidate mustn't change the benchmark workload or weaken expected outputs.
|
|
14
|
+
|
|
15
|
+
If required baseline gates fail, don't use the scalar helper to optimise an ineligible baseline. First establish a bounded repair contract, or explicitly distinguish pre-existing unrelated failures with the user's approval. A repair target may legitimately start out failing; the original failure must remain visible in the record.
|
|
16
|
+
|
|
17
|
+
For noisy metrics, measure baseline variability and choose the repetition procedure in advance. Alternate or randomise baseline/candidate runs under comparable conditions. Retain all relevant observations, not the single fastest run. The helper's scalar threshold isn't a statistical-significance test.
|
|
18
|
+
|
|
19
|
+
Tests produced by the proposing agent aren't independent evidence just because they pass. Anchor expected behaviour to the specification or an existing trusted reference. A changed evaluator requires a new comparison epoch and re-evaluation of both versions; don't mix incompatible receipts.
|
|
20
|
+
|
|
21
|
+
## Protect each candidate
|
|
22
|
+
|
|
23
|
+
Prefer an isolated copy that includes relevant user work. Don't assume a clean Git worktree contains uncommitted inputs. Never reset, stash or clean the user's working tree. A worktree isn't a security sandbox.
|
|
24
|
+
|
|
25
|
+
Where a small supervised in-place change is appropriate, apply the snapshot and conflict checks from [polish](polish.md). Preserve the original and incumbent separately. Restore only run-owned changes; external edits cause a stop, not an overwrite.
|
|
26
|
+
|
|
27
|
+
Before editing, increment `experiments_started`. Save a candidate ID and parent ID. Record a falsifiable hypothesis and one observation that would count against it. A coherent multi-file edit is allowed when the same hypothesis requires it.
|
|
28
|
+
|
|
29
|
+
## Evaluate before promoting
|
|
30
|
+
|
|
31
|
+
Run cheap validity checks before the costly evaluator. All fixed constraints must pass and the primary improvement must be positive and meet the agreed threshold. A tie or an inconclusive result leaves the incumbent unchanged. Reject regressions even if another metric improves.
|
|
32
|
+
|
|
33
|
+
The [scalar gate](../scripts/gate.py) compares supplied receipts. It checks matching contract/evaluator identifiers and mandatory results, then a scalar delta. It doesn't execute tests, read evidence files or authenticate measurements. Only a subset of the contract is machine-checked. Never invent favourable receipts and describe the helper as verification.
|
|
34
|
+
|
|
35
|
+
A fresh-context review is optional when supported and worth the cost; it isn't required for lite. Give a reviewer the contract and evidence rather than the author's sales pitch. The same model in another role isn't an independent truth source.
|
|
36
|
+
|
|
37
|
+
Save a recoverable checkpoint before promoting the candidate. Append one [attempt record](../assets/experiment.template.json), including its lesson. Public decision summaries are enough; don't record private chain-of-thought. Keep raw logs on disk and only relevant summaries in the working context.
|
|
38
|
+
|
|
39
|
+
## Exploration and stopping
|
|
40
|
+
|
|
41
|
+
Consult previous lessons before generating another candidate. At most 4 recoverable alternatives may be retained outside lite mode. They remain separate from accepted output and mustn't violate mandatory constraints. Combining alternatives is a new attempt that needs fresh evaluation. Lite keeps only the active candidate and best checkpoint.
|
|
42
|
+
|
|
43
|
+
After 4 consecutive evaluated candidates without acceptance, use one changed approach for up to 2 remaining attempts. If neither produces a gain, stop with `plateau`. This pivot never increases the total budget. Clear the no-gain count after an acceptance.
|
|
44
|
+
|
|
45
|
+
Stop earlier for a verified terminal goal or when no testable improvement remains. Stop for cancellation, missing evaluation or exhausted resources. An environment failure permits one diagnosis and one retry within the original allowance, not repeated retries or another provider used to bypass a limit. Crashed and invalid attempts still consume the attempt budget.
|
|
46
|
+
|
|
47
|
+
## Final verification
|
|
48
|
+
|
|
49
|
+
Reserve roughly one fifth of the time allowance for final checks and reporting; this is a heuristic. Use host timeouts where available, but don't imply that Markdown can enforce process termination.
|
|
50
|
+
|
|
51
|
+
Compare the selected version with the original baseline under the unchanged contract. Use genuinely held-out data when available. A holdout consumed during selection is no longer held out. Don't try several finalists against it and call the winner an unbiased confirmation.
|
|
52
|
+
|
|
53
|
+
If a holdout fails, report that failure. Return an eligible checkpoint with its actual verification level; don't label a fallback as holdout-verified unless it was. A later development run needs a new untouched holdout. Without a holdout, label the result as checked on the available development cases only.
|
|
54
|
+
|
|
55
|
+
The final report names the actual stop reason and distinguishes observed gains from unsupported claims. See [evaluators](evaluators.md) for further evidence limits.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# UI recipes
|
|
2
|
+
|
|
3
|
+
These are risk-based starting points, not mandatory exhaustive audits. Use only the recipe relevant to the request. Test the actual product rather than building an unrelated demo. The checks below don't certify WCAG compliance.
|
|
4
|
+
|
|
5
|
+
## Toggle: “make the transition super smooth”
|
|
6
|
+
|
|
7
|
+
**Goal:** improve continuity during switching while preserving correct state changes.
|
|
8
|
+
|
|
9
|
+
Inspect the current interaction before choosing a duration. Use the product's motion conventions rather than a universal “perfect” easing curve. Slower isn't automatically smoother.
|
|
10
|
+
|
|
11
|
+
| Risk to inspect | Candidate check |
|
|
12
|
+
|---|---|
|
|
13
|
+
| Motion appears to snap when interrupted | Switch on and off, then reverse while motion is still running; inspect a recording when available |
|
|
14
|
+
| Visual state lags behind actual state | Assert the underlying state after repeated input and inspect the endpoint |
|
|
15
|
+
| Cosmetic changes damage interaction | Test keyboard activation and disabled behaviour; confirm the accessible state and label |
|
|
16
|
+
| Motion ignores a user preference | Exercise the reduced-motion path separately and confirm state is still clear |
|
|
17
|
+
|
|
18
|
+
Prefer focused CSS transitions where they fit. Consider transform/opacity rather than layout-affecting animation, but measure actual behaviour before claiming a performance gain. Avoid adding an animation library for one toggle without a clear need. The rendering guidance comes from Google's [animation performance guide](https://web.dev/articles/animations-guide).
|
|
19
|
+
|
|
20
|
+
Keep the widget's established semantics. A switch should remain keyboard-operable with the appropriate checked state; Space is the required switch-pattern activation key, while Enter is optional. Don't accidentally fire an action twice by duplicating native keyboard handling. Refer to the [WAI-ARIA switch pattern](https://www.w3.org/WAI/ARIA/apg/patterns/switch/).
|
|
21
|
+
|
|
22
|
+
Respect the user's motion preference instead of equating “super smooth” with “more animation”. The [`prefers-reduced-motion` media feature](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion) provides that signal. Reduced-motion support still needs a behaviour check.
|
|
23
|
+
|
|
24
|
+
**Honest result:** “The captured reversal no longer visibly snaps in the tested browser; state assertions passed.” Only say this if that evidence was actually inspected. With source-only feedback, say “The patch changes the transition mechanism; rendered smoothness wasn't verified.”
|
|
25
|
+
|
|
26
|
+
## Button: “obsess over the responsive design”
|
|
27
|
+
|
|
28
|
+
**Goal:** retain a usable action when its available space changes.
|
|
29
|
+
|
|
30
|
+
Identify the component's real parent layout. A narrow grid cell can fail even in a wide viewport. Don't fix overflow with page-wide clipping or by hiding meaningful label text.
|
|
31
|
+
|
|
32
|
+
| Risk to inspect | Candidate check |
|
|
33
|
+
|---|---|
|
|
34
|
+
| Narrow layouts clip or overflow | Use a constrained parent and the smallest supported viewport; inspect the label and boundaries |
|
|
35
|
+
| Content changes break sizing | Try a long label and existing icon variants where present |
|
|
36
|
+
| Text enlargement loses content | Exercise supported text enlargement or zoom and confirm that the action remains available |
|
|
37
|
+
| Responsive rules change behaviour | Check keyboard focus and activation; confirm the existing disabled/loading states where present |
|
|
38
|
+
|
|
39
|
+
Begin with the project's documented support range. Without one, use 320 and 390 CSS-pixel viewports as narrow examples, plus an existing desktop layout and a constrained parent. These are starting checks, not complete device coverage. Also inspect around a changed breakpoint when relevant.
|
|
40
|
+
|
|
41
|
+
The 320 CSS-pixel case is informed by W3C's [reflow explanation](https://www.w3.org/WAI/WCAG21/Understanding/reflow.html). Passing these local checks isn't a full accessibility assessment. Record browser zoom separately from viewport emulation; they aren't interchangeable tests.
|
|
42
|
+
|
|
43
|
+
Don't shrink text or remove focus visibility merely to make the button fit. Preserve the existing semantic button rather than replacing it with a clickable generic element. See the [WAI-ARIA button pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/) when custom behaviour needs review.
|
|
44
|
+
|
|
45
|
+
**Honest result:** name the exact layouts checked and what stopped clipping. Don't claim “responsive on every device”. A unit test that finds a CSS class doesn't prove that the rendered label fits.
|
|
46
|
+
|
|
47
|
+
## Spacing or other visual detail
|
|
48
|
+
|
|
49
|
+
Use the existing design tokens and compare the affected region under matching content. Preserve nearby layout behaviour. A visually preferred alternative may be useful without being objectively “more correct”.
|
|
50
|
+
|
|
51
|
+
Stop when the requested discrepancy is resolved and the relevant checks pass. Don't expand a spacing adjustment into a new component system.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Run state and recovery
|
|
2
|
+
|
|
3
|
+
These conventions make a session recoverable. They don't provide an autonomous runner or tamper-proof accounting.
|
|
4
|
+
|
|
5
|
+
## Minimal run directory
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
.obsess/<run-id>/
|
|
9
|
+
run.md
|
|
10
|
+
state.json
|
|
11
|
+
experiments.jsonl
|
|
12
|
+
snapshots/
|
|
13
|
+
evidence/
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Use a unique, filesystem-safe run ID and an authorised alternate directory when required. Resolve the location safely; never accept a run ID that escapes the run directory. Don't store secrets or commit run data by default. Keep paths in notes relative when practical.
|
|
17
|
+
|
|
18
|
+
A small polish run doesn't need a JSON contract in addition to `run.md`. The note still freezes the target and acceptance checks. Scalar optimisation can use `contract.json` because the receipt helper consumes it.
|
|
19
|
+
|
|
20
|
+
The working tree may hold a provisional candidate while the accepted snapshot remains elsewhere. Record `working_tree_status` and the active candidate explicitly so a later session doesn't confuse them.
|
|
21
|
+
|
|
22
|
+
## Cumulative accounting
|
|
23
|
+
|
|
24
|
+
Increment `experiments_started` before the first candidate edit. An interrupted or invalid candidate consumes that attempt. Increment `experiments_evaluated` when evaluation completes, including a rejection. Don't double-count an existing candidate when finishing an interrupted evaluation. A materially new candidate consumes another attempt.
|
|
25
|
+
|
|
26
|
+
Record cumulative active-session elapsed time. Setup and tool waits count, as do final checks. A deliberately closed/paused session doesn't consume inference time, but mustn't reset used minutes when resumed. If elapsed time can't be measured, store null with `elapsed_time_measured: false`, disclose the limitation and enforce the finite attempt cap as an instruction.
|
|
27
|
+
|
|
28
|
+
A wall-clock timeout cannot be guaranteed by an instruction-only skill. Host process limits are separate. A requested AUD budget can only be treated as enforced when the host actually provides current pricing and hard accounting; otherwise say the limit can't be guaranteed.
|
|
29
|
+
|
|
30
|
+
`remaining_experiments` is derived from the authorised cap minus started attempts. Don't trust a stale saved value. Only one writer should own a run. Save `state.json` atomically when possible and keep the attempt log append-only.
|
|
31
|
+
|
|
32
|
+
## Status
|
|
33
|
+
|
|
34
|
+
`status <run-id>` reads the stored state and evidence without editing target files or starting candidates. Report the last observed status, recorded usage and known remaining budget. State that the data is saved progress, not a live process. Don't fabricate missing evidence from notes that merely claim it exists.
|
|
35
|
+
|
|
36
|
+
## Resume
|
|
37
|
+
|
|
38
|
+
Read the note and state, then the relevant attempt summaries. Check actual contents against the saved baseline and incumbent. Reconcile interrupted work rather than assuming it completed. Checkpoint references and evidence must exist before relying on them.
|
|
39
|
+
|
|
40
|
+
If files have changed externally, stop and explain the conflict. Don't roll the user's work backwards. Reuse the frozen goal and checks. A changed objective requires a new run, not an unrecorded extension of the old one.
|
|
41
|
+
|
|
42
|
+
Retain all spent attempts and measured time. An exhausted allowance needs new explicit budget authorisation. Don't launch another run automatically to evade a cap. If persistence is missing, offer an in-session review but no durable resume promise.
|
|
43
|
+
|
|
44
|
+
## v0.1 migration
|
|
45
|
+
|
|
46
|
+
A v0.1 run remains an optimise-mode run under its original contract. Don't retrofit new polish limits or silently enable lite. Preserve the original snapshots and receipts.
|
|
47
|
+
|
|
48
|
+
v0.1 state recorded `experiments_evaluated` rather than `experiments_started`. Reconstruct started attempts from its append-only records, including an unfinished candidate. If the count is uncertain, stop for reconciliation rather than assuming unrecorded work was free. Retain measured elapsed time; a false measurement flag means a numeric placeholder isn't trustworthy.
|
|
49
|
+
|
|
50
|
+
Existing v0.1 scalar receipt schema remains supported. The v0.2 state schema is a record format, not an automatic migration tool.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Compare evaluator-produced scalar receipts. This does not authenticate evidence.
|
|
3
|
+
|
|
4
|
+
Exit codes: 0 accept, 1 reject, 2 invalid input, 3 inconclusive.
|
|
5
|
+
Requires Python 3.10+ and only the standard library. Makes no external calls.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import sys
|
|
13
|
+
from decimal import Decimal
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
MAX_INPUT_BYTES = 1_000_000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def nonempty(value: Any, label: str) -> str:
|
|
21
|
+
if not isinstance(value, str) or not value.strip():
|
|
22
|
+
raise ValueError(f"{label} must be a non-empty string")
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def number(value: Any, label: str) -> Decimal:
|
|
27
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
28
|
+
raise ValueError(f"{label} must be a finite JSON number")
|
|
29
|
+
try:
|
|
30
|
+
finite = math.isfinite(value)
|
|
31
|
+
except (OverflowError, TypeError):
|
|
32
|
+
finite = False
|
|
33
|
+
if not finite:
|
|
34
|
+
raise ValueError(f"{label} must be finite")
|
|
35
|
+
return Decimal(str(value))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def object_value(value: Any, label: str) -> dict[str, Any]:
|
|
39
|
+
if not isinstance(value, dict):
|
|
40
|
+
raise ValueError(f"{label} must be a JSON object")
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def verdict(status: str, reason: str, delta: Decimal | None = None) -> dict[str, Any]:
|
|
45
|
+
result: dict[str, Any] = {
|
|
46
|
+
"status": status,
|
|
47
|
+
"reason": reason,
|
|
48
|
+
"basis": "reported_measurements_only",
|
|
49
|
+
"evidence_authenticated": False,
|
|
50
|
+
}
|
|
51
|
+
if delta is not None:
|
|
52
|
+
# String preserves decimal subtraction without binary floating-point noise.
|
|
53
|
+
result["improvement"] = str(delta)
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def decide(contract: dict[str, Any], baseline: dict[str, Any],
|
|
58
|
+
candidate: dict[str, Any]) -> dict[str, Any]:
|
|
59
|
+
"""Fail closed on incompatible receipts; no mutation or file operations."""
|
|
60
|
+
contract = object_value(contract, "contract")
|
|
61
|
+
if type(contract.get("schema_version")) is not int or contract["schema_version"] != 1:
|
|
62
|
+
raise ValueError("contract.schema_version must be integer 1")
|
|
63
|
+
|
|
64
|
+
for field in ("contract_id", "evaluator_revision", "comparison_key"):
|
|
65
|
+
nonempty(contract.get(field), f"contract.{field}")
|
|
66
|
+
|
|
67
|
+
metric = object_value(contract.get("primary_metric"), "primary_metric")
|
|
68
|
+
metric_name = nonempty(metric.get("name"), "primary_metric.name")
|
|
69
|
+
direction = metric.get("direction")
|
|
70
|
+
if direction not in ("minimise", "maximise"):
|
|
71
|
+
raise ValueError("direction must be 'minimise' or 'maximise'")
|
|
72
|
+
minimum = number(metric.get("min_delta"), "primary_metric.min_delta")
|
|
73
|
+
if minimum < 0:
|
|
74
|
+
raise ValueError("min_delta must not be negative")
|
|
75
|
+
|
|
76
|
+
gates = contract.get("required_gates")
|
|
77
|
+
if not isinstance(gates, list) or not gates:
|
|
78
|
+
raise ValueError("required_gates must be a non-empty list")
|
|
79
|
+
for gate in gates:
|
|
80
|
+
nonempty(gate, "required_gates item")
|
|
81
|
+
if len(set(gates)) != len(gates):
|
|
82
|
+
raise ValueError("required_gates must not contain duplicates")
|
|
83
|
+
|
|
84
|
+
values: dict[str, Decimal] = {}
|
|
85
|
+
receipts = {"baseline": baseline, "candidate": candidate}
|
|
86
|
+
for label, raw in receipts.items():
|
|
87
|
+
receipt = object_value(raw, label)
|
|
88
|
+
for field in ("contract_id", "evaluator_revision", "comparison_key"):
|
|
89
|
+
if receipt.get(field) != contract[field]:
|
|
90
|
+
raise ValueError(f"{label}.{field} doesn't match the frozen contract")
|
|
91
|
+
nonempty(receipt.get("artifact_id"), f"{label}.artifact_id")
|
|
92
|
+
if receipt.get("metric_name") != metric_name:
|
|
93
|
+
raise ValueError(f"{label}.metric_name doesn't match the contract")
|
|
94
|
+
if receipt.get("measurement_valid") is not True:
|
|
95
|
+
raise ValueError(f"{label}.measurement_valid must be true")
|
|
96
|
+
refs = receipt.get("evidence_refs")
|
|
97
|
+
if not isinstance(refs, list) or not refs:
|
|
98
|
+
raise ValueError(f"{label}.evidence_refs must be a non-empty list")
|
|
99
|
+
for ref in refs:
|
|
100
|
+
nonempty(ref, f"{label}.evidence_refs item")
|
|
101
|
+
results = object_value(receipt.get("gates"), f"{label}.gates")
|
|
102
|
+
for gate in gates:
|
|
103
|
+
if gate not in results or type(results[gate]) is not bool:
|
|
104
|
+
raise ValueError(f"{label}.gates.{gate} must be an explicit boolean")
|
|
105
|
+
values[label] = number(receipt.get("value"), f"{label}.value")
|
|
106
|
+
|
|
107
|
+
failed_baseline = [gate for gate in gates if not baseline["gates"][gate]]
|
|
108
|
+
if failed_baseline:
|
|
109
|
+
raise ValueError("baseline isn't eligible; resolve its contract failures before optimisation")
|
|
110
|
+
|
|
111
|
+
failed = [gate for gate in gates if not candidate["gates"][gate]]
|
|
112
|
+
if failed:
|
|
113
|
+
return verdict("reject", "Candidate failed mandatory gates: " + ", ".join(failed))
|
|
114
|
+
|
|
115
|
+
if baseline["artifact_id"] == candidate["artifact_id"]:
|
|
116
|
+
return verdict("inconclusive", "The candidate identifies the same artefact as the baseline")
|
|
117
|
+
|
|
118
|
+
delta = values["candidate"] - values["baseline"]
|
|
119
|
+
if direction == "minimise":
|
|
120
|
+
delta = -delta
|
|
121
|
+
if delta < 0:
|
|
122
|
+
return verdict("reject", "Candidate regressed on the primary metric", delta)
|
|
123
|
+
if delta == 0:
|
|
124
|
+
return verdict("inconclusive", "No improvement; retain the incumbent", delta)
|
|
125
|
+
if delta < minimum:
|
|
126
|
+
return verdict("inconclusive", "Improvement is below the agreed meaningful threshold", delta)
|
|
127
|
+
return verdict("accept", "Reported gates pass and the reported improvement meets the threshold", delta)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def reject_constant(value: str) -> None:
|
|
131
|
+
raise ValueError(f"Non-finite JSON constant is forbidden: {value}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
135
|
+
result: dict[str, Any] = {}
|
|
136
|
+
for key, value in pairs:
|
|
137
|
+
if key in result:
|
|
138
|
+
raise ValueError(f"Duplicate JSON key: {key}")
|
|
139
|
+
result[key] = value
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def read_json(path: Path) -> dict[str, Any]:
|
|
144
|
+
with path.open("rb") as stream:
|
|
145
|
+
data = stream.read(MAX_INPUT_BYTES + 1)
|
|
146
|
+
if len(data) > MAX_INPUT_BYTES:
|
|
147
|
+
raise ValueError(f"Input exceeds {MAX_INPUT_BYTES} bytes: {path}")
|
|
148
|
+
return object_value(json.loads(data.decode("utf-8"), parse_constant=reject_constant,
|
|
149
|
+
object_pairs_hook=unique_object), str(path))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def main(argv: list[str] | None = None) -> int:
|
|
153
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
154
|
+
parser.add_argument("--contract", type=Path, required=True)
|
|
155
|
+
parser.add_argument("--baseline", type=Path, required=True)
|
|
156
|
+
parser.add_argument("--candidate", type=Path, required=True)
|
|
157
|
+
args = parser.parse_args(argv)
|
|
158
|
+
try:
|
|
159
|
+
result = decide(read_json(args.contract), read_json(args.baseline), read_json(args.candidate))
|
|
160
|
+
except (OSError, ValueError, TypeError, OverflowError, RecursionError) as exc:
|
|
161
|
+
result = verdict("invalid", str(exc))
|
|
162
|
+
print(json.dumps(result, ensure_ascii=False, allow_nan=False))
|
|
163
|
+
return {"accept": 0, "reject": 1, "invalid": 2, "inconclusive": 3}[result["status"]]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
sys.exit(main())
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Install /obsess locally without network access or unapproved overwrites.
|
|
3
|
+
|
|
4
|
+
Requires Python 3.10+. Install into an explicit project or the user's home.
|
|
5
|
+
--upgrade preserves the prior installation outside the skills search path.
|
|
6
|
+
Concurrent installers or external edits during installation aren't supported.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import shutil
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InstallError(RuntimeError):
|
|
22
|
+
"""A validation failure or a recoverable installation failure."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Plan:
|
|
27
|
+
source: Path
|
|
28
|
+
config_root: Path
|
|
29
|
+
skill: Path
|
|
30
|
+
command: Path | None
|
|
31
|
+
duplicate_paths: tuple[Path, ...]
|
|
32
|
+
legacy_command: bool
|
|
33
|
+
upgrade: bool
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def check_path(path: Path, *, leaf_directory: bool | None = None) -> None:
|
|
37
|
+
"""Refuse symlinks and files where a directory is required."""
|
|
38
|
+
if path.is_symlink():
|
|
39
|
+
raise InstallError(f"Refusing a symlinked path: {path}")
|
|
40
|
+
for parent in path.parents:
|
|
41
|
+
if parent.is_symlink():
|
|
42
|
+
raise InstallError(f"Refusing a symlinked parent: {parent}")
|
|
43
|
+
if parent.exists() and not parent.is_dir():
|
|
44
|
+
raise InstallError(f"A parent isn't a directory: {parent}")
|
|
45
|
+
if path.exists() and leaf_directory is not None:
|
|
46
|
+
expected = path.is_dir() if leaf_directory else path.is_file()
|
|
47
|
+
if not expected:
|
|
48
|
+
kind = "directory" if leaf_directory else "file"
|
|
49
|
+
raise InstallError(f"Expected a {kind}: {path}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def validate(plan: Plan) -> None:
|
|
53
|
+
if not plan.source.is_dir() or not (plan.source / "SKILL.md").is_file():
|
|
54
|
+
raise InstallError("Source must be the obsess skill directory containing SKILL.md")
|
|
55
|
+
for entry in plan.source.rglob("*"):
|
|
56
|
+
if entry.is_symlink():
|
|
57
|
+
raise InstallError(f"The package contains a symlink: {entry}")
|
|
58
|
+
if not entry.is_file() and not entry.is_dir():
|
|
59
|
+
raise InstallError(f"The package contains an unsupported file type: {entry}")
|
|
60
|
+
if plan.skill.is_relative_to(plan.source) or plan.source.is_relative_to(plan.skill):
|
|
61
|
+
raise InstallError("Source and installation directories mustn't overlap")
|
|
62
|
+
check_path(plan.config_root, leaf_directory=True)
|
|
63
|
+
check_path(plan.config_root / "obsess-backups", leaf_directory=True)
|
|
64
|
+
check_path(plan.skill, leaf_directory=True)
|
|
65
|
+
for competing in plan.duplicate_paths:
|
|
66
|
+
if competing.exists() or competing.is_symlink():
|
|
67
|
+
raise InstallError(
|
|
68
|
+
f"A same-name skill already exists at {competing}. "
|
|
69
|
+
"Upgrade that copy instead of creating a duplicate installation."
|
|
70
|
+
)
|
|
71
|
+
command_path = plan.config_root / "commands" / "obsess.md"
|
|
72
|
+
check_path(command_path, leaf_directory=False)
|
|
73
|
+
if plan.command is None and command_path.exists():
|
|
74
|
+
raise InstallError(
|
|
75
|
+
f"An /obsess command already exists at {command_path}. "
|
|
76
|
+
"Move it aside before native installation, or use --legacy-command for OpenCode."
|
|
77
|
+
)
|
|
78
|
+
destinations = [plan.skill] + ([plan.command] if plan.command is not None else [])
|
|
79
|
+
for destination in destinations:
|
|
80
|
+
if destination.exists() and not plan.upgrade:
|
|
81
|
+
raise InstallError(
|
|
82
|
+
f"Installation already exists: {destination}. "
|
|
83
|
+
"Use --upgrade to preserve a backup before replacing it."
|
|
84
|
+
)
|
|
85
|
+
if plan.legacy_command:
|
|
86
|
+
wrapper = plan.source / "references" / "integrations" / "opencode" / "obsess.md"
|
|
87
|
+
if not wrapper.is_file():
|
|
88
|
+
raise InstallError("The package is missing its OpenCode command wrapper")
|
|
89
|
+
skill_text = (plan.source / "SKILL.md").read_text(encoding="utf-8")
|
|
90
|
+
if skill_text.count(' opencode/slash: "true"') != 1:
|
|
91
|
+
raise InstallError("Expected exactly one native OpenCode slash flag in the source skill")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def make_plan(source: Path, host: str, *, project: Path | None = None,
|
|
95
|
+
global_install: bool = False, home: Path | None = None,
|
|
96
|
+
legacy_command: bool = False, upgrade: bool = False) -> Plan:
|
|
97
|
+
if host not in {"claude", "opencode"}:
|
|
98
|
+
raise InstallError("host must be claude or opencode")
|
|
99
|
+
if global_install == (project is not None):
|
|
100
|
+
raise InstallError("Choose exactly one of --project and --global")
|
|
101
|
+
if legacy_command and host != "opencode":
|
|
102
|
+
raise InstallError("--legacy-command is only supported for OpenCode")
|
|
103
|
+
source = source.expanduser().resolve(strict=True)
|
|
104
|
+
if global_install:
|
|
105
|
+
base = (home or Path.home()).expanduser().resolve(strict=True)
|
|
106
|
+
roots = (base / ".claude", base / ".config" / "opencode", base / ".agents")
|
|
107
|
+
else:
|
|
108
|
+
assert project is not None
|
|
109
|
+
base = project.expanduser().resolve(strict=True)
|
|
110
|
+
roots = (base / ".claude", base / ".opencode", base / ".agents")
|
|
111
|
+
if not base.is_dir():
|
|
112
|
+
raise InstallError(f"Installation base isn't a directory: {base}")
|
|
113
|
+
config_root = roots[0] if host == "claude" else roots[1]
|
|
114
|
+
skill = config_root / "skills" / "obsess"
|
|
115
|
+
duplicates = tuple(root / "skills" / "obsess" for root in roots if root != config_root)
|
|
116
|
+
plan = Plan(
|
|
117
|
+
source=source, config_root=config_root, skill=skill,
|
|
118
|
+
command=config_root / "commands" / "obsess.md" if legacy_command else None,
|
|
119
|
+
duplicate_paths=duplicates, legacy_command=legacy_command, upgrade=upgrade,
|
|
120
|
+
)
|
|
121
|
+
validate(plan)
|
|
122
|
+
return plan
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def describe(plan: Plan) -> dict[str, Any]:
|
|
126
|
+
return {
|
|
127
|
+
"skill": str(plan.skill),
|
|
128
|
+
"command": str(plan.command) if plan.command else None,
|
|
129
|
+
"upgrade_requested": plan.upgrade,
|
|
130
|
+
"native_opencode_slash": not plan.legacy_command,
|
|
131
|
+
"network_access": False,
|
|
132
|
+
"permissions_changed": False,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _promote(staged: Path, destination: Path) -> None:
|
|
137
|
+
staged.rename(destination)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def install(plan: Plan, *, dry_run: bool = False) -> dict[str, Any]:
|
|
141
|
+
validate(plan)
|
|
142
|
+
result = describe(plan)
|
|
143
|
+
if dry_run:
|
|
144
|
+
return {**result, "status": "dry_run", "files_written": False}
|
|
145
|
+
|
|
146
|
+
plan.config_root.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
stage = Path(tempfile.mkdtemp(prefix=".obsess-stage-", dir=plan.config_root))
|
|
148
|
+
promoted: list[Path] = []
|
|
149
|
+
backups: list[tuple[Path, Path]] = []
|
|
150
|
+
backup_root: Path | None = None
|
|
151
|
+
try:
|
|
152
|
+
staged_skill = stage / "skill"
|
|
153
|
+
shutil.copytree(
|
|
154
|
+
plan.source, staged_skill,
|
|
155
|
+
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".git", ".DS_Store", ".pytest_cache"),
|
|
156
|
+
)
|
|
157
|
+
items: list[tuple[Path, Path, str]] = [(staged_skill, plan.skill, "skill")]
|
|
158
|
+
if plan.legacy_command:
|
|
159
|
+
installed_skill = staged_skill / "SKILL.md"
|
|
160
|
+
text = installed_skill.read_text(encoding="utf-8")
|
|
161
|
+
installed_skill.write_text(
|
|
162
|
+
text.replace(' opencode/slash: "true"', ' opencode/slash: "false"', 1),
|
|
163
|
+
encoding="utf-8",
|
|
164
|
+
)
|
|
165
|
+
staged_command = stage / "command.md"
|
|
166
|
+
shutil.copy2(plan.source / "references" / "integrations" / "opencode" / "obsess.md", staged_command)
|
|
167
|
+
assert plan.command is not None
|
|
168
|
+
items.append((staged_command, plan.command, "command.md"))
|
|
169
|
+
|
|
170
|
+
# Revalidate before touching existing installations. This isn't a concurrency lock.
|
|
171
|
+
validate(plan)
|
|
172
|
+
if any(destination.exists() for _, destination, _ in items):
|
|
173
|
+
backup_parent = plan.config_root / "obsess-backups"
|
|
174
|
+
backup_parent.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ-")
|
|
176
|
+
backup_root = Path(tempfile.mkdtemp(prefix=stamp, dir=backup_parent))
|
|
177
|
+
for staged, destination, backup_name in items:
|
|
178
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
if destination.exists():
|
|
180
|
+
assert backup_root is not None
|
|
181
|
+
backup = backup_root / backup_name
|
|
182
|
+
destination.rename(backup)
|
|
183
|
+
backups.append((destination, backup))
|
|
184
|
+
_promote(staged, destination)
|
|
185
|
+
promoted.append(destination)
|
|
186
|
+
return {**result, "status": "installed", "files_written": True,
|
|
187
|
+
"backup": str(backup_root) if backup_root else None}
|
|
188
|
+
except Exception as exc:
|
|
189
|
+
rollback_errors: list[str] = []
|
|
190
|
+
for destination in reversed(promoted):
|
|
191
|
+
try:
|
|
192
|
+
if destination.is_dir():
|
|
193
|
+
shutil.rmtree(destination)
|
|
194
|
+
elif destination.exists():
|
|
195
|
+
destination.unlink()
|
|
196
|
+
except OSError as error:
|
|
197
|
+
rollback_errors.append(str(error))
|
|
198
|
+
for destination, backup in reversed(backups):
|
|
199
|
+
try:
|
|
200
|
+
if destination.exists() or destination.is_symlink():
|
|
201
|
+
raise InstallError(f"Can't restore backup over an existing path: {destination}")
|
|
202
|
+
backup.rename(destination)
|
|
203
|
+
except (OSError, InstallError) as error:
|
|
204
|
+
rollback_errors.append(str(error))
|
|
205
|
+
if backup_root is not None:
|
|
206
|
+
try:
|
|
207
|
+
backup_root.rmdir() # Remove only an empty, successfully restored backup.
|
|
208
|
+
except OSError:
|
|
209
|
+
pass
|
|
210
|
+
message = f"Installation failed: {exc}. Existing files were restored where possible."
|
|
211
|
+
if rollback_errors:
|
|
212
|
+
message += f" Backup location: {backup_root}. Recovery errors: " + "; ".join(rollback_errors)
|
|
213
|
+
raise InstallError(message) from exc
|
|
214
|
+
finally:
|
|
215
|
+
shutil.rmtree(stage, ignore_errors=True)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def main(argv: list[str] | None = None) -> int:
|
|
219
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
220
|
+
parser.add_argument("--host", required=True, choices=("claude", "opencode"))
|
|
221
|
+
target = parser.add_mutually_exclusive_group(required=True)
|
|
222
|
+
target.add_argument("--project", type=Path, help="Existing project directory")
|
|
223
|
+
target.add_argument("--global", dest="global_install", action="store_true", help="Install for this user")
|
|
224
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
225
|
+
parser.add_argument("--upgrade", action="store_true", help="Back up an existing copy before replacement")
|
|
226
|
+
parser.add_argument("--legacy-command", action="store_true", help="Use an OpenCode command wrapper instead of native slash registration")
|
|
227
|
+
args = parser.parse_args(argv)
|
|
228
|
+
try:
|
|
229
|
+
plan = make_plan(Path(__file__).resolve().parents[1], args.host,
|
|
230
|
+
project=args.project, global_install=args.global_install,
|
|
231
|
+
legacy_command=args.legacy_command, upgrade=args.upgrade)
|
|
232
|
+
result = install(plan, dry_run=args.dry_run)
|
|
233
|
+
except (InstallError, OSError, ValueError) as exc:
|
|
234
|
+
print(json.dumps({"status": "error", "reason": str(exc)}, ensure_ascii=False), file=sys.stderr)
|
|
235
|
+
return 2
|
|
236
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
237
|
+
return 0
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
if __name__ == "__main__":
|
|
241
|
+
sys.exit(main())
|