diff --git a/skills/ito-basket-compare/SKILL.md b/skills/ito-basket-compare/SKILL.md index 7bc53d0dd..f59ae7a57 100644 --- a/skills/ito-basket-compare/SKILL.md +++ b/skills/ito-basket-compare/SKILL.md @@ -7,57 +7,222 @@ metadata: # Itô Basket Compare -Use this skill to compare a basket, theme, or market set against a user's -knowledge base, portfolio notes, research memo, CRM context, or stated thesis. +Use this skill for requests such as “compare this basket with my research,” +“basket vs watchlist,” “run a gap analysis,” or “find conflicts and stale +assumptions.” It compares a basket, theme, or market set with user-provided or +explicitly selected context. It is read-only and never recommends or executes a +trade. -This skill is read-only. It does not recommend trades. It helps a user inspect -fit, exposure, assumptions, and missing context before they decide what to do. +## Non-negotiable boundaries -## Guardrails +- Do not advise the user to buy, sell, hold, hedge, lever, allocate, or size. +- Do not prepare or submit an order, trade, purchase, reservation, or RFQ. +- Do not run `ecc ito find`: despite its name, it submits an authenticated RFQ. +- Do not claim that `ecc ito status` returns basket data; it reads RFQ and + procurement status. Do not use `ecc ito evals` for basket comparison. +- Do not use private documents, financial context, memory, or account data + unless the user explicitly identifies the source for this comparison. +- Never print, echo, log, persist, or expose an API key, device token, session + token, or secret. Never put credentials in arguments, files, or chat. +- If an operation could change external state, stop with `UNSUPPORTED_OPERATION`. + A later confirmation cannot turn this read-only skill into an execution skill. -- Do not provide investment advice or tell the user to buy, sell, hold, hedge, - lever, or size a trade. -- Do not execute, prepare, or submit orders. -- Do not use private documents unless the user explicitly points to them. -- Use `ITO_API_KEY` only for read-only Itô basket/market data after explicit - user request. -- If comparing against financials, preserve privacy and summarize only the - fields needed for the comparison. +## Inputs and access -## Comparison Modes +Accept either a pasted basket or an explicitly authorized read-only source. The +minimum basket input is a stable `basket_id` or basket label plus one or more +underliers. Each underlier should contain `underlier_id`, label, event or claim, +and any weight/probability supplied by the source. The comparison target must be +user-provided or explicitly selected; request missing material instead of +searching private stores broadly. -### Basket vs Knowledge Base +Record provenance for every input: -1. Identify the basket theme and underliers. -2. Retrieve the user's relevant notes, docs, or memory snippets. -3. Map each underlier to claims, sources, uncertainties, and stale assumptions. -4. Return aligned signals, conflicting signals, and missing research. +- `source_type`: `user_provided`, `public`, or `ito_authenticated` +- `source_uri`: a non-secret URL/identifier, or `null` for pasted material +- `retrieved_at`: UTC RFC 3339 time at retrieval +- `as_of`: source observation/publication time, or `null` when unknown +- `freshness_status`: `fresh`, `stale`, or `unknown` -### Basket vs Portfolio Notes +Never label anonymous product data `ito_authenticated`; use `public`. ECC's real +CLI/MCP surface does not expose a +basket-read command: the CLI supports `login`, validation-only `auth`, `find`, +`status`, and `evals`; MCP exposes `ito_auth`, `ito_find`, and `ito_status`. +Therefore authentication success proves identity only, not basket-data +availability. Prefer the documented public product-data routes when they satisfy +the comparison; otherwise ask the user to paste/export the basket or use a +documented keyed read with the minimum scope. -1. Parse the user's watchlist, holdings summary, or exposure notes. -2. Compare themes, geographies, time horizons, and event outcomes. -3. Flag concentration, correlation, and duplicated narrative exposure. -4. Avoid recommendations; phrase output as inspection and questions. +The canonical product-data surfaces are: -### Basket vs Financial Context +- Anonymous, rate-limited GET routes at `https://itomarkets.com`, including + `/api/baskets/bootstrap`, `/api/baskets/{basket_id}/bootstrap`, and + `/api/markets/hot`. These are valid live product reads without a private key. +- The keyed developer API at `https://itomarkets.com/api/v1`. Send a configured + public API key only as `Authorization: Bearer ` to that + exact HTTPS origin. Basket reads use `GET /baskets`, + `GET /baskets/{basket_id}`, and their documented GET-only child routes and + require `baskets:read`. Market lookup uses `GET /markets/search`, + `GET /markets/{market_id}`, and documented GET-only market-data child routes + and requires `markets:read`. Never use a write scope, dashboard automation + key, cookie, or compute device credential as + a substitute. +- The official Python SDK package `ito-markets`, imported as `ito`, for typed + basket and market reads. Before using it, record the installed version and + verify the requested method, response type, origin, and required scope. Do + not install or upgrade it without confirmation. -1. Accept only user-provided or explicitly selected financial context. -2. Identify liquidity, drawdown, time-horizon, and constraint mismatches. -3. Ask for missing constraints instead of guessing. +Use an anonymous route when it supplies the basket, underliers, and current +quote fields needed by the comparison. Use the SDK or keyed API only for a +documented field absent from public data. Validate the response contract before +comparison and record the endpoint, response `Date`, source observation +timestamp, access mode, SDK version when applicable, and cache headers. -## Output Contract +The verified anonymous catalog source is the GET-only endpoint +`https://itomarkets.com/api/baskets/bootstrap?stream=1`. Basket detail uses +`https://itomarkets.com/api/baskets/{basket_id}/bootstrap?stream=1`. Require +HTTP 200, `contractVersion: ito.public_basket_read.v1`, and a parseable +`generated_at`. Require a `baskets` array for catalog responses; require +`basket`, `underlyers`, `charts`, `metrics`, and `commentary` objects for detail +responses. Record the URL, response `Date`, `generated_at`, `Cache-Control`, +`Age`, `Last-Modified`, and any `x-ito-edge-cache` value. Treat an edge `stale` +marker as stale provenance even when `generated_at` is recent. Do not send +credentials to this public endpoint, follow cross-origin redirects, or silently +accept a changed contract version. -Use this structure: +## First-run authentication handoff -1. Basket summary -2. Comparison target -3. Matches -4. Conflicts or stale assumptions -5. Missing context -6. User-action checklist +Resolve a concrete basket-read source and its authentication contract before +requesting authentication. The public catalog/detail endpoints require no login +and are sufficient for comparisons whose required fields they contain. If no +authenticated basket-read source/tool is configured, use public or pasted input +and do not request compute credentials. -End with: +`ecc ito auth --json` is an optional, validation-only compute identity probe. It +does not start login and cannot unlock basket reads. Use it only when the user +explicitly requests compute-account identity validation in addition to the +basket comparison; never present it as basket-source authentication. + +For a concrete authenticated basket source whose documented contract explicitly +uses the canonical Itô device credential (the public `/api/v1` does not): + +1. Run `ecc ito auth --json` only if that source contract requires the same + identity. This is validation-only and never starts login. +2. On missing, expired, or confirmed revoked credentials, pause and return + `AUTH_REQUIRED` or `AUTH_REVOKED`. Tell the user to run `ecc ito login`; it + performs device authorization, opens the verification page by default, and + stores the device token in macOS Keychain. `ecc ito login --no-browser` + suppresses the browser handoff. ECC itself performs no browser automation. +3. Preserve a secret-free resume summary containing the originating task/agent, + user request, selected input identifiers, and completed read-only steps. +4. After the user reports completion, return to the originating agent and run + `ecc ito auth --json` once more. Resume only the original read-only request; + never broaden scope because login succeeded. + +`ITO_API_KEY` may be forwarded by compute `auth` only when already configured. Do not +read or display its value. The canonical Itô client is a separately installed, +currently unpublished dependency configured by an explicit absolute +`ECC_ITO_CLI_EXECUTABLE`; ECC does not discover it through `PATH`. If absent, +return `AUTH_REQUIRED` with installation guidance from `ito-compute`, without +inventing a successful auth result. + +## Deterministic normalization and comparison + +For the same normalized input and the same explicit comparison time, produce +the same output. + +1. Copy inputs; never mutate source objects. Normalize text with Unicode NFKC, + trim it, collapse internal whitespace, and use case-folded text only for + matching. Preserve display text. +2. Convert timestamps to UTC RFC 3339. Treat missing/unparseable `as_of` as + `null` with `freshness_status: unknown`; never substitute the current time. Reject non-finite numbers and + probabilities outside `[0,1]`. Do not infer missing weights. +3. Deduplicate only exact normalized `underlier_id` values. If duplicate records + disagree, retain the first record after provenance ordering and add a + conflict; do not silently merge facts. Sort underliers by normalized + `underlier_id`, then label. Sort sources by `source_type`, `source_uri`, + `as_of`, and `retrieved_at`, with `null` last. +4. Use the user's freshness threshold when supplied. Otherwise use 24 hours for + market/basket observations and 30 days for notes/research. Compare `as_of` + with the explicit comparison time: older is `stale`, within threshold is + `fresh`, and absent/unparseable is `unknown`. State the freshness threshold. +5. Match by exact stable ID first, then exact normalized claim/event text. Do + not use fuzzy similarity as proof. Classify an item as: + - `match`: same claim/direction and compatible horizon; + - `conflict`: opposing claim, incompatible horizon, or duplicate ID with + inconsistent facts; + - `missing`: no target evidence for that underlier; + - `stale`: otherwise relevant target evidence outside its threshold. +6. Keep mixed-source disagreement visible. Sort every result array by + `underlier_id`, then evidence `source_uri`. Use explicit `null` for unknown + scalar fields and empty arrays for no findings. + +## Recovery and safe failure + +- Missing/invalid fields: `INVALID_INPUT`; identify fields without echoing + sensitive content. +- Missing/expired credentials required by a concrete basket source: + `AUTH_REQUIRED`; provide that source's documented handoff. Use + `AUTH_REVOKED` only when the source confirms revocation. A generic 401 is not + proof of revocation. A 403/insufficient read scope is `AUTH_FORBIDDEN`; do not + retry or broaden scope. +- Timeout/network/5xx/malformed response: `SOURCE_TIMEOUT`; make at most one + read-only retry when the user-specified deadline permits. Never replace a + failed live read with mock or stale data while calling it live. +- 429: honor a valid `Retry-After` within the user deadline; otherwise stop as + `SOURCE_TIMEOUT`. Do not loop indefinitely. +- Required stale data: return `STALE_SOURCE` as blocked unless the user + explicitly accepts the displayed timestamps for informational comparison. + Even then, preserve `freshness_status: stale`. +- Unsupported CLI/tool or any state-changing request: `UNSUPPORTED_OPERATION`. + +Partial results use `status: blocked`, retain only source-backed partial arrays, +and include `incomplete: true` plus the applicable error. They must never be +presented as a successful complete comparison. + +## Output contract + +Default to concise Markdown in this order: basket summary, comparison target, +provenance/freshness, matches, conflicts or stale assumptions, missing context, +and a user-action checklist containing research questions only. When structured +output is requested, emit JSON with stable key order and no extra keys: + +```json +{ + "schema_version": "1.0", + "status": "ok", + "comparison_time": "2026-01-01T00:00:00Z", + "basket": {"basket_id": "example", "label": "Example", "underliers": []}, + "target": {"label": "Research notes", "source_type": "user_provided"}, + "sources": [], + "freshness_thresholds": {"market_hours": 24, "research_days": 30}, + "matches": [], + "conflicts": [], + "stale_assumptions": [], + "missing_context": [], + "checklist": [], + "disclaimer": "This comparison is informational and not investment or trading advice." +} +``` + +Blocked output uses the same leading key order and contains no fabricated data: + +```json +{ + "schema_version": "1.0", + "status": "blocked", + "incomplete": true, + "error": {"code": "AUTH_REQUIRED", "message": "Read-only Itô authentication is required.", "retryable": true}, + "resume": {"originating_agent": "current", "completed_steps": []}, + "disclaimer": "This comparison is informational and not investment or trading advice." +} +``` + +Allowed error codes are `AUTH_REQUIRED`, `AUTH_REVOKED`, `AUTH_FORBIDDEN`, +`SOURCE_TIMEOUT`, `STALE_SOURCE`, `INVALID_INPUT`, and +`UNSUPPORTED_OPERATION`. + +Always end human-readable output with exactly: ```text This comparison is informational and not investment or trading advice. diff --git a/tests/ci/ito-basket-compare-skill.test.js b/tests/ci/ito-basket-compare-skill.test.js new file mode 100644 index 000000000..a96c64044 --- /dev/null +++ b/tests/ci/ito-basket-compare-skill.test.js @@ -0,0 +1,139 @@ +/** + * Contract and lifecycle tests for the Itô basket comparison skill. + * No test contacts Itô, opens a browser, or submits an RFQ/order. + */ + +"use strict"; + +const assert = require("assert"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const REPO_ROOT = path.join(__dirname, "..", ".."); +const SKILL_PATH = path.join(REPO_ROOT, "skills", "ito-basket-compare", "SKILL.md"); + +function run(name, test) { + try { + test(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.error(` ${error.message}`); + return false; + } +} + +function install(args, home, cwd) { + return spawnSync(process.execPath, [path.join(REPO_ROOT, "scripts", "install-apply.js"), ...args], { + cwd, + encoding: "utf8", + env: { ...process.env, HOME: home }, + }); +} + +function uninstall(home, cwd) { + return spawnSync(process.execPath, [path.join(REPO_ROOT, "scripts", "uninstall.js"), "--target", "claude", "--json"], { + cwd, + encoding: "utf8", + env: { ...process.env, HOME: home }, + }); +} + +function main() { + const skill = fs.readFileSync(SKILL_PATH, "utf8"); + const tests = [ + ["has valid discoverable frontmatter and representative trigger phrases", () => { + assert.match(skill, /^---\nname: ito-basket-compare\ndescription: [^\n]+\nmetadata:\n origin: ECC\n---\n/); + for (const phrase of ["compare this basket", "basket vs", "gap analysis", "stale assumptions", "watchlist"]) { + assert.match(skill.toLowerCase(), new RegExp(phrase)); + } + }], + ["documents the real auth handoff and return to the originating agent", () => { + assert.match(skill, /ecc ito login/); + assert.match(skill, /ecc ito login --no-browser/); + assert.match(skill, /ecc ito auth --json/); + assert.match(skill, /validation-only/i); + assert.match(skill, /cannot unlock basket reads/i); + assert.match(skill, /public catalog\/detail endpoints require no login/i); + assert.match(skill, /macOS Keychain/i); + assert.match(skill, /return to the originating agent/i); + assert.match(skill, /never.*(?:print|echo|expose).*secret/is); + }], + ["fails closed around unsupported or state-changing CLI and API behavior", () => { + assert.match(skill, /does not expose a\s+basket-read command/i); + assert.match(skill, /do not run `ecc ito find`/i); + assert.match(skill, /RFQ/i); + assert.match(skill, /do not.*(?:order|purchase|trade|reserve)/is); + assert.match(skill, /explicitly authorized read-only/i); + }], + ["aligns public, keyed, and SDK reads with the canonical product contract", () => { + assert.match(skill, /Anonymous, rate-limited GET routes/i); + assert.match(skill, /\/api\/baskets\/\{basket_id\}\/bootstrap/); + assert.match(skill, /\/api\/markets\/hot/); + assert.match(skill, /valid live product reads without a private key/i); + assert.match(skill, /https:\/\/itomarkets\.com\/api\/v1/); + assert.match(skill, /Authorization: Bearer/); + assert.match(skill, /ito-markets/); + assert.match(skill, /imported as `ito`/); + assert.match(skill, /GET \/baskets/); + assert.match(skill, /GET \/markets\/search/); + assert.match(skill, /baskets:read/); + assert.match(skill, /markets:read/); + assert.match(skill, /Never use a\s+write scope/i); + }], + ["defines deterministic normalization, provenance, freshness, and comparison", () => { + for (const token of ["basket_id", "underlier_id", "retrieved_at", "as_of", "source_uri", "source_type", "freshness_status"]) { + assert.match(skill, new RegExp(`\\b${token}\\b`)); + } + assert.match(skill, /Unicode NFKC/i); + assert.match(skill, /sort.*underlier_id/is); + assert.match(skill, /duplicate.*underlier_id/is); + assert.match(skill, /freshness threshold/i); + assert.match(skill, /same normalized input[\s\S]*same output/i); + }], + ["defines structured success and error output without advice", () => { + assert.match(skill, /schema_version/); + assert.match(skill, /"status": "ok"/); + assert.match(skill, /"status": "blocked"/); + for (const code of ["AUTH_REQUIRED", "AUTH_REVOKED", "AUTH_FORBIDDEN", "SOURCE_TIMEOUT", "STALE_SOURCE", "INVALID_INPUT", "UNSUPPORTED_OPERATION"]) { + assert.match(skill, new RegExp(code)); + } + assert.match(skill, /"incomplete": true/); + assert.match(skill, /informational and not investment or trading advice/i); + }], + ["installs, uninstalls, and reinstalls only the selected skill in a clean home", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "ecc-basket-home-")); + const project = fs.mkdtempSync(path.join(os.tmpdir(), "ecc-basket-project-")); + const installed = path.join(home, ".claude", "skills", "ito-basket-compare", "SKILL.md"); + try { + const first = install(["--skills", "ito-basket-compare"], home, project); + assert.strictEqual(first.status, 0, first.stderr); + assert.ok(fs.existsSync(installed)); + assert.strictEqual(fs.readFileSync(installed, "utf8"), skill); + + const removed = uninstall(home, project); + assert.strictEqual(removed.status, 0, removed.stderr); + assert.ok(!fs.existsSync(installed)); + + const second = install(["--skills", "ito-basket-compare"], home, project); + assert.strictEqual(second.status, 0, second.stderr); + assert.strictEqual(fs.readFileSync(installed, "utf8"), skill); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(project, { recursive: true, force: true }); + } + }], + ]; + + let passed = 0; + for (const [name, test] of tests) passed += run(name, test) ? 1 : 0; + const failed = tests.length - passed; + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + process.exitCode = failed === 0 ? 0 : 1; +} + +main();