diff --git a/.adal/README.md b/.adal/README.md index 95fb30b5b..0c01a51c1 100644 --- a/.adal/README.md +++ b/.adal/README.md @@ -1,6 +1,6 @@ # ECC for AdaL CLI -This directory contains the ECC (Everything Claude Code) configuration for the AdaL CLI harness. +This directory contains the ECC configuration for the AdaL CLI harness. ## What is installed @@ -20,3 +20,4 @@ bash ./install.sh --target adal --profile minimal - The `adal` target installs into the project-level `./.adal/` directory. - AdaL's own config (`~/.adal/settings.json`, MCP servers, plugins) is **not** touched by ECC install. - Use `npx ecc-universal doctor --target adal` to check install health. +- use an installed diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index e730e4eed..a69ad3709 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -6,7 +6,7 @@ "plugins": [ { "name": "ecc", - "version": "2.2.2", + "version": "2.2.3", "source": { "source": "local", "path": "./" diff --git a/.agents/skills/bun-runtime/SKILL.md b/.agents/skills/bun-runtime/SKILL.md index ab748e26a..5bd36c241 100644 --- a/.agents/skills/bun-runtime/SKILL.md +++ b/.agents/skills/bun-runtime/SKILL.md @@ -17,21 +17,28 @@ Use when: adopting Bun, migrating from Node, writing or debugging Bun scripts/te ## How It Works -- **Runtime**: Drop-in Node-compatible runtime (built on JavaScriptCore, implemented in Zig). -- **Package manager**: `bun install` is significantly faster than npm/yarn. Lockfile is `bun.lock` (text) by default in current Bun; older versions used `bun.lockb` (binary). +- **Version baseline**: guidance below targets Bun 1.4.x. Check the actual version with `bun --version` before relying on flag names, since CLI flags can change between releases. +- **Runtime**: Drop-in Node-compatible runtime, built on JavaScriptCore. As of Bun 1.4 the runtime itself is implemented in Rust (migrated from Zig). Not 100% Node-compatible — some native addons, less-common `node:` internal APIs, and packages that depend on Node-specific internals can still fail; verify before depending on it in production. +- **Package manager**: Bun includes a package manager; installation performance depends on the project, network conditions, and cache state. Lockfile is `bun.lock` (text) by default in current Bun; older versions used `bun.lockb` (binary) — Bun still reads that format for migration, but new projects should use `bun.lock`. - **Bundler**: Built-in bundler and transpiler for apps and libraries. -- **Test runner**: Built-in `bun test` with Jest-like API. +- **Test runner**: Built-in `bun test` with a Jest-like API. **Migration from Node**: Replace `node script.js` with `bun run script.js` or `bun script.js`. Run `bun install` in place of `npm install`; most packages work. Use `bun run` for npm scripts; `bun x` for npx-style one-off runs. Node built-ins are supported; prefer Bun APIs where they exist for better performance. -**Vercel**: Set runtime to Bun in project settings. Build: `bun run build` or `bun build ./src/index.ts --outdir=dist`. Install: `bun install --frozen-lockfile` for reproducible deploys. +**Package and workspace commands**: `bun add ` / `bun remove ` / `bun update [pkg]` manage dependencies; `bun outdated` lists stale deps. `bun pm ls` lists project dependencies and resolved versions using the lockfile; `--all` includes transitive dependencies. It is not an integrity check of installed file contents. [`bun audit`](https://bun.com/docs/pm/cli/audit) queries registry advisories using package names and versions from `bun.lock`; it does not modify `package.json`, `bun.lock`, or `node_modules`. Review approved destinations before sending private package metadata. Packages whose scoped registry lacks an advisory endpoint are skipped and do not affect the exit code. `bun audit fix` upgrades and installs dependencies, changing the lockfile and installed packages and sometimes direct version pins; review it as a modifying install operation, including applicable lifecycle scripts. `bun dedupe` collapses duplicate installs, and `bun prune` removes packages no longer referenced. Monorepos use a root `workspaces` array in `package.json` (same convention as npm/yarn); run a script in one workspace with `bun run --filter ', body: 'Tom & \"Jerry\" \\'s', website: 'https://ex.com/' });\nassert.ok(a.startsWith('
  • '));\nassert.ok(!a.includes(']*>a<\\/a>/.test(q) && /

    b<\\/p>/.test(q));\n" + }, + { + "id": "list-pagination", + "category": "api", + "manualIds": [ + "skill:api-design" + ], + "query": "src/listProducts.js exports listProducts(query, store) for GET /products. query holds raw query-string values (strings or undefined); store.all() returns the full array. Implement offset pagination: limit defaults to 20 and must be an integer 1..100, offset defaults to 0 and must be an integer >= 0. Success returns { status: 200, body: { data, meta: { total, limit, offset, hasMore } } }. Invalid values return { status: 400, body: { error: { code: \"VALIDATION_ERROR\", message, details: [{ field, message }] } } } with one details entry per invalid field (\"limit\" or \"offset\"). Do not mutate the store array. Do not add dependencies.", + "files": { + "src/listProducts.js": "'use strict';\n\n// GET /products?limit=&offset=\nfunction listProducts(query, store) {\n const items = store.all();\n const page = items.slice(query.offset, query.offset + query.limit);\n return { status: 200, body: page };\n}\n\nmodule.exports = { listProducts };\n", + "src/store.js": "'use strict';\n\nfunction createStore(items) {\n return { all: () => items };\n}\n\nmodule.exports = { createStore };\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst path = require('node:path');\nconst { listProducts } = require(path.join(process.cwd(), 'src/listProducts.js'));\nconst items = Array.from({ length: 45 }, (_, i) => ({ id: i + 1 }));\nconst copy = JSON.stringify(items);\nconst store = { all: () => items };\nlet r = listProducts({}, store);\nassert.equal(r.status, 200);\nassert.equal(r.body.data.length, 20);\nassert.deepEqual(r.body.meta, { total: 45, limit: 20, offset: 0, hasMore: true });\nr = listProducts({ limit: '10', offset: '40' }, store);\nassert.deepEqual(r.body.data.map(x => x.id), [41, 42, 43, 44, 45]);\nassert.deepEqual(r.body.meta, { total: 45, limit: 10, offset: 40, hasMore: false });\nr = listProducts({ limit: '5', offset: '35' }, store);\nassert.equal(r.body.meta.hasMore, true);\nr = listProducts({ limit: '100', offset: '100' }, store);\nassert.equal(r.status, 200);\nassert.deepEqual(r.body.data, []);\nassert.equal(r.body.meta.hasMore, false);\nfor (const [q, fields] of [[{ limit: '0' }, ['limit']], [{ limit: '101' }, ['limit']], [{ limit: 'abc' }, ['limit']],\n [{ limit: '2.5' }, ['limit']], [{ offset: '-1' }, ['offset']], [{ limit: '-3', offset: 'x' }, ['limit', 'offset']]]) {\n const bad = listProducts(q, store);\n assert.equal(bad.status, 400, JSON.stringify(q));\n assert.equal(bad.body.error.code, 'VALIDATION_ERROR');\n assert.equal(typeof bad.body.error.message, 'string');\n assert.deepEqual(bad.body.error.details.map(d => d.field).sort(), fields);\n assert.ok(bad.body.error.details.every(d => typeof d.message === 'string'));\n}\nassert.equal(JSON.stringify(items), copy);\n" + }, + { + "id": "create-user-status-codes", + "category": "api", + "manualIds": [ + "skill:api-design" + ], + "query": "src/usersRoute.js exports async createUser(req, repo) for POST /users and async getUser(req, repo) for GET /users/:id. Both return { status, headers?, body }. They currently return 200 for everything and 500 on duplicates. Fix them to use proper REST semantics. createUser: body { email, name }; email must be a string containing \"@\" and name a non-empty trimmed string, otherwise 400 with body { error: { code: \"VALIDATION_ERROR\", message, details: [{ field, message }] } } listing each bad field; if repo.findByEmail(email) returns a user, 409 with error code \"CONFLICT\"; otherwise call repo.create({ email, name }) and return 201 with headers { Location: \"/users/\" } and body { data: user }. getUser: req.params.id; missing user gives 404 with error code \"NOT_FOUND\", found user gives 200 { data: user }. Do not add dependencies.", + "files": { + "src/usersRoute.js": "'use strict';\n\nasync function createUser(req, repo) {\n try {\n const { email, name } = req.body || {};\n const existing = await repo.findByEmail(email);\n if (existing) throw new Error('duplicate');\n const user = await repo.create({ email, name });\n return { status: 200, body: user };\n } catch (err) {\n return { status: 500, body: { message: err.message } };\n }\n}\n\nasync function getUser(req, repo) {\n const user = await repo.findById(req.params.id);\n return { status: 200, body: user };\n}\n\nmodule.exports = { createUser, getUser };\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst path = require('node:path');\nconst { createUser, getUser } = require(path.join(process.cwd(), 'src/usersRoute.js'));\nfunction repo() {\n const users = [{ id: 1, email: 'ada@example.com', name: 'Ada' }];\n return { created: 0, async findByEmail(e) { return users.find(u => u.email === e) || null; },\n async findById(id) { return users.find(u => String(u.id) === String(id)) || null; },\n async create(u) { this.created++; const user = { id: users.length + 1, ...u }; users.push(user); return user; } };\n}\n(async () => {\n const r = repo();\n let res = await createUser({ body: { email: 'lin@example.com', name: 'Lin' } }, r);\n assert.equal(res.status, 201);\n assert.equal(res.headers.Location, '/users/2');\n assert.deepEqual(res.body.data, { id: 2, email: 'lin@example.com', name: 'Lin' });\n res = await createUser({ body: { email: 'ada@example.com', name: 'Ada2' } }, r);\n assert.equal(res.status, 409);\n assert.equal(res.body.error.code, 'CONFLICT');\n res = await createUser({ body: { email: 'nope', name: ' ' } }, r);\n assert.equal(res.status, 400);\n assert.equal(res.body.error.code, 'VALIDATION_ERROR');\n assert.deepEqual(res.body.error.details.map(d => d.field).sort(), ['email', 'name']);\n res = await createUser({ body: { email: 'x@y.z' } }, r);\n assert.equal(res.status, 400);\n assert.deepEqual(res.body.error.details.map(d => d.field), ['name']);\n assert.equal(r.created, 1);\n res = await getUser({ params: { id: '99' } }, r);\n assert.equal(res.status, 404);\n assert.equal(res.body.error.code, 'NOT_FOUND');\n res = await getUser({ params: { id: '1' } }, r);\n assert.equal(res.status, 200);\n assert.equal(res.body.data.email, 'ada@example.com');\n})().catch(err => { console.error(err); process.exitCode = 1; });\n" + }, + { + "id": "retry-with-backoff", + "category": "errors", + "manualIds": [ + "skill:error-handling" + ], + "query": "src/retry.js exports async withRetry(fn, options) used around calls to a flaky payments API. It currently retries every error immediately and throws a generic Error(\"failed\"), losing the cause. Rewrite it: options are { retries = 3, baseDelayMs = 100, maxDelayMs = 2000, sleep } where sleep(ms) returns a promise (default: a real setTimeout sleep). Call fn(attempt) with attempt starting at 1, for at most retries + 1 attempts. Only retry when the error is retryable: err.retryable === true, or err.status is 429 or >= 500. Non-retryable errors must be rethrown immediately (the same error object). Before retry n (n = 1, 2, ...) await sleep(d) where d is between half and all of min(baseDelayMs * 2^(n-1), maxDelayMs) (jitter optional). When retries are exhausted, rethrow the last error object. Return fn's resolved value on success. Do not add dependencies.", + "files": { + "src/retry.js": "'use strict';\n\nasync function withRetry(fn, options = {}) {\n const retries = options.retries || 3;\n for (let i = 0; i < retries; i++) {\n try {\n return await fn(i);\n } catch (err) {\n // try again\n }\n }\n throw new Error('failed');\n}\n\nmodule.exports = { withRetry };\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst path = require('node:path');\nconst { withRetry } = require(path.join(process.cwd(), 'src/retry.js'));\nconst mk = (status, extra = {}) => Object.assign(new Error('e' + status), { status }, extra);\n(async () => {\n let delays = [];\n const sleep = ms => { delays.push(ms); return Promise.resolve(); };\n let calls = [];\n const out = await withRetry(async a => { calls.push(a); if (a < 3) throw mk(503); return 'ok'; }, { sleep });\n assert.equal(out, 'ok');\n assert.deepEqual(calls, [1, 2, 3]);\n assert.equal(delays.length, 2);\n assert.ok(delays[0] >= 50 && delays[0] <= 100 && delays[1] >= 100 && delays[1] <= 200, String(delays));\n delays = []; calls = [];\n const last = mk(500);\n let n = 0;\n await assert.rejects(withRetry(async a => { calls.push(a); n++; throw n === 5 ? last : mk(502); },\n { retries: 4, baseDelayMs: 1000, maxDelayMs: 3000, sleep }), e => e === last);\n assert.deepEqual(calls, [1, 2, 3, 4, 5]);\n const caps = [1000, 2000, 3000, 3000];\n assert.equal(delays.length, 4);\n delays.forEach((d, i) => assert.ok(d >= caps[i] / 2 && d <= caps[i], 'delay ' + i + '=' + d));\n delays = []; calls = [];\n const bad = mk(400);\n await assert.rejects(withRetry(async a => { calls.push(a); throw bad; }, { sleep }), e => e === bad);\n assert.deepEqual(calls, [1]);\n assert.equal(delays.length, 0);\n calls = [];\n const plain = new Error('boom');\n await assert.rejects(withRetry(async a => { calls.push(a); throw plain; }, { sleep }), e => e === plain);\n assert.equal(calls.length, 1);\n calls = [];\n await withRetry(async a => { calls.push(a); if (a === 1) throw mk(429); if (a === 2) throw Object.assign(new Error('r'), { retryable: true }); return 1; }, { sleep });\n assert.deepEqual(calls, [1, 2, 3]);\n calls = [];\n await assert.rejects(withRetry(async a => { calls.push(a); throw mk(503); }, { retries: 0, sleep }));\n assert.deepEqual(calls, [1]);\n})().catch(err => { console.error(err); process.exitCode = 1; });\n" + }, + { + "id": "typed-config-errors", + "category": "errors", + "manualIds": [ + "skill:error-handling" + ], + "query": "src/config.js exports loadConfig(text), which parses a JSON config string. Today it silently returns {} on bad JSON and accepts missing fields. Add and export a ConfigError class (extends Error, name \"ConfigError\") with a code property, and make loadConfig throw it: code \"CONFIG_PARSE\" for invalid JSON (with the original SyntaxError as error.cause); code \"CONFIG_MISSING\" with error.field set when a required field is missing (required: apiUrl, then timeoutMs, checked in that order); code \"CONFIG_INVALID\" with error.field = \"timeoutMs\" when timeoutMs is not a positive integer. On success return { apiUrl, timeoutMs, retries } where retries defaults to 2. Messages should be human readable. Do not add dependencies.", + "files": { + "src/config.js": "'use strict';\n\nfunction loadConfig(text) {\n let raw;\n try {\n raw = JSON.parse(text);\n } catch (e) {\n return {};\n }\n return { apiUrl: raw.apiUrl, timeoutMs: raw.timeoutMs, retries: raw.retries };\n}\n\nmodule.exports = { loadConfig };\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst path = require('node:path');\nconst { loadConfig, ConfigError } = require(path.join(process.cwd(), 'src/config.js'));\nassert.equal(typeof ConfigError, 'function');\nassert.deepEqual(loadConfig('{\"apiUrl\":\"https://x\",\"timeoutMs\":500}'), { apiUrl: 'https://x', timeoutMs: 500, retries: 2 });\nassert.deepEqual(loadConfig('{\"apiUrl\":\"https://x\",\"timeoutMs\":5,\"retries\":0}'), { apiUrl: 'https://x', timeoutMs: 5, retries: 0 });\nfunction thrown(text) { try { loadConfig(text); } catch (e) { return e; } assert.fail('expected throw for ' + text); }\nlet e = thrown('{bad json');\nassert.ok(e instanceof ConfigError && e instanceof Error);\nassert.equal(e.name, 'ConfigError');\nassert.equal(e.code, 'CONFIG_PARSE');\nassert.ok(e.cause instanceof SyntaxError);\nassert.ok(e.message.length > 0);\ne = thrown('{\"timeoutMs\":1}');\nassert.equal(e.code, 'CONFIG_MISSING');\nassert.equal(e.field, 'apiUrl');\ne = thrown('{\"apiUrl\":\"u\"}');\nassert.equal(e.code, 'CONFIG_MISSING');\nassert.equal(e.field, 'timeoutMs');\nfor (const t of ['0', '-5', '1.5', '\"100\"']) {\n e = thrown('{\"apiUrl\":\"u\",\"timeoutMs\":' + t + '}');\n assert.ok(e instanceof ConfigError);\n assert.equal(e.code, 'CONFIG_INVALID');\n assert.equal(e.field, 'timeoutMs');\n}\n" + }, + { + "id": "batch-partial-failures", + "category": "errors", + "manualIds": [ + "skill:error-handling" + ], + "query": "src/batch.js exports async processAll(items, worker). items are objects with an id; worker(item) returns a promise. The current version swallows errors inside an empty catch and returns only a count, so failed webhook deliveries vanish. Change it to process every item (a failure must not stop the others) and resolve to { succeeded: [{ id, result }], failed: [{ id, error }] }, both in input order, where error is the thrown error's message (or String(value) if a non-Error was thrown). It must never reject because of a worker failure, and a worker that throws synchronously must be treated like a rejection. Do not add dependencies.", + "files": { + "src/batch.js": "'use strict';\n\nasync function processAll(items, worker) {\n let done = 0;\n for (const item of items) {\n try {\n await worker(item);\n done++;\n } catch (e) {}\n }\n return done;\n}\n\nmodule.exports = { processAll };\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst path = require('node:path');\nconst { processAll } = require(path.join(process.cwd(), 'src/batch.js'));\n(async () => {\n const seen = [];\n const items = [1, 2, 3, 4, 5].map(id => ({ id }));\n const out = await processAll(items, item => {\n seen.push(item.id);\n if (item.id === 2) throw new Error('sync boom');\n if (item.id === 4) return Promise.reject('plain string');\n if (item.id === 5) return Promise.reject(new TypeError('bad payload'));\n return Promise.resolve(item.id * 10);\n });\n assert.deepEqual(seen.slice().sort(), [1, 2, 3, 4, 5]);\n assert.deepEqual(out.succeeded, [{ id: 1, result: 10 }, { id: 3, result: 30 }]);\n assert.deepEqual(out.failed, [{ id: 2, error: 'sync boom' }, { id: 4, error: 'plain string' }, { id: 5, error: 'bad payload' }]);\n assert.deepEqual(await processAll([], () => 1), { succeeded: [], failed: [] });\n})().catch(err => { console.error(err); process.exitCode = 1; });\n" + }, + { + "id": "access-log-parser", + "category": "parsing", + "manualIds": [ + "skill:regex-vs-llm-structured-text" + ], + "query": "src/parseLog.js parses web server access logs in Common Log Format, optionally extended to Combined Log Format with a quoted referrer and a quoted user agent. The current parseLine(line) splits on spaces and breaks on user agents and timestamps that contain spaces. Rewrite parseLine(line) to return { ip, user, time, method, path, protocol, status, bytes, referrer, userAgent } or null for any line that does not match the format. user, referrer and userAgent are null when the field is \"-\" or absent; time is the text inside the square brackets; status is a number (three digits); bytes is a number and \"-\" means 0. Also export parseLog(text) returning { entries, invalid } where blank lines (LF or CRLF endings) are skipped and invalid counts non-matching lines. See README.md for examples. Do not add dependencies.", + "files": { + "src/parseLog.js": "'use strict';\n\nfunction parseLine(line) {\n const parts = line.split(' ');\n return {\n ip: parts[0],\n user: parts[2],\n time: parts[3],\n method: parts[5],\n path: parts[6],\n protocol: parts[7],\n status: Number(parts[8]),\n bytes: Number(parts[9]),\n };\n}\n\nmodule.exports = { parseLine };\n", + "README.md": "# log-stats\n\nAccess log examples we must support:\n\n 127.0.0.1 - frank [10/Oct/2000:13:55:36 -0700] \"GET /apache_pb.gif HTTP/1.0\" 200 2326 \"http://www.example.com/start.html\" \"Mozilla/4.08 [en] (Win98; I ;Nav)\"\n 10.0.0.2 - - [11/Oct/2000:08:00:01 +0000] \"POST /api/login HTTP/1.1\" 401 -\n\nThe first is Combined Log Format, the second plain Common Log Format.\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst path = require('node:path');\nconst { parseLine, parseLog } = require(path.join(process.cwd(), 'src/parseLog.js'));\nconst a = '127.0.0.1 - frank [10/Oct/2000:13:55:36 -0700] \"GET /apache_pb.gif HTTP/1.0\" 200 2326 \"http://www.example.com/start.html\" \"Mozilla/4.08 [en] (Win98; I ;Nav)\"';\nassert.deepEqual(parseLine(a), { ip: '127.0.0.1', user: 'frank', time: '10/Oct/2000:13:55:36 -0700', method: 'GET',\n path: '/apache_pb.gif', protocol: 'HTTP/1.0', status: 200, bytes: 2326,\n referrer: 'http://www.example.com/start.html', userAgent: 'Mozilla/4.08 [en] (Win98; I ;Nav)' });\nconst b = '10.0.0.2 - - [11/Oct/2000:08:00:01 +0000] \"POST /api/login HTTP/1.1\" 401 -';\nassert.deepEqual(parseLine(b), { ip: '10.0.0.2', user: null, time: '11/Oct/2000:08:00:01 +0000', method: 'POST',\n path: '/api/login', protocol: 'HTTP/1.1', status: 401, bytes: 0, referrer: null, userAgent: null });\nconst c = '::1 - - [01/Jan/2024:00:00:00 +0000] \"DELETE /items/9?force=1 HTTP/2.0\" 204 0 \"-\" \"curl/8.4.0\"';\nconst pc = parseLine(c);\nassert.equal(pc.ip, '::1');\nassert.equal(pc.path, '/items/9?force=1');\nassert.equal(pc.referrer, null);\nassert.equal(pc.userAgent, 'curl/8.4.0');\nassert.equal(pc.status, 204);\nfor (const bad of ['garbage line', '', '10.0.0.2 - - 11/Oct/2000:08:00:01 +0000 \"GET / HTTP/1.1\" 200 5',\n '10.0.0.2 - - [11/Oct/2000:08:00:01 +0000] \"GET / HTTP/1.1\" 2000 5', '10.0.0.2 - - [x] \"GET / HTTP/1.1\" 200 abc',\n '\"GET / HTTP/1.1\" 200 12']) {\n assert.equal(parseLine(bad), null, bad);\n}\nconst log = [a, '', 'nonsense', b + '\\r', ' ', c, ''].join('\\n');\nconst out = parseLog(log);\nassert.equal(out.entries.length, 3);\nassert.equal(out.invalid, 1);\nassert.equal(out.entries[1].bytes, 0);\n" + }, + { + "id": "invoice-field-extraction", + "category": "parsing", + "manualIds": [ + "skill:regex-vs-llm-structured-text" + ], + "query": "src/extract.js exports extractInvoice(text), which pulls fields out of plain-text invoices from several vendors. It only handles one vendor today. Make it return { invoiceNumber, date, total, currency } for all layouts documented in FORMATS.md: invoiceNumber is the identifier string; date is normalized to YYYY-MM-DD; total is a number (thousands separators removed) taken from the grand total line, never from Subtotal or Tax lines; currency is a three-letter code (\"$\" means USD). Any field that cannot be found is null. Labels are case-insensitive. Keep it deterministic and offline. Do not add dependencies.", + "files": { + "src/extract.js": "'use strict';\n\nfunction extractInvoice(text) {\n const num = /Invoice #: (\\S+)/.exec(text);\n const date = /Date: (\\d{4}-\\d{2}-\\d{2})/.exec(text);\n const total = /Total: \\$([\\d.]+)/.exec(text);\n return {\n invoiceNumber: num ? num[1] : null,\n date: date ? date[1] : null,\n total: total ? Number(total[1]) : null,\n currency: total ? 'USD' : null,\n };\n}\n\nmodule.exports = { extractInvoice };\n", + "FORMATS.md": "# Invoice layouts\n\nInvoice number labels: \"Invoice #:\", \"Invoice No.\", \"Invoice Number:\".\nIdentifiers use letters, digits and hyphens, for example INV-2024-0042, INV-7, A-19.\n\nDate labels: \"Date:\", \"Invoice Date:\", \"Issued:\". Values appear as\n2024-03-05 (ISO), 05/03/2024 (DD/MM/YYYY, day first) or 7 November 2023\n(day, full English month name, year).\n\nGrand total labels: \"Total:\", \"Total due:\", \"Amount due:\". Amounts look like\n$1,234.50 or EUR 99.00 (code before) or 1,000.00 GBP (code after).\nInvoices may also contain \"Subtotal:\" and \"Tax:\" lines, which are not totals.\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst path = require('node:path');\nconst { extractInvoice } = require(path.join(process.cwd(), 'src/extract.js'));\nassert.deepEqual(extractInvoice(['ACME Corp', 'Invoice #: INV-2024-0042', 'Date: 2024-03-05', 'Subtotal: $1,100.00',\n 'Tax: $134.50', 'Total: $1,234.50'].join('\\n')), { invoiceNumber: 'INV-2024-0042', date: '2024-03-05', total: 1234.5, currency: 'USD' });\nassert.deepEqual(extractInvoice(['Globex GmbH', 'invoice no. INV-7', 'Invoice Date: 05/03/2024', 'Subtotal: EUR 90.00',\n 'TOTAL DUE: EUR 99.00'].join('\\r\\n')), { invoiceNumber: 'INV-7', date: '2024-03-05', total: 99, currency: 'EUR' });\nassert.deepEqual(extractInvoice(['Initech Ltd', 'Invoice Number: A-19', 'Issued: 7 November 2023', 'Tax: 0.00 GBP',\n 'Amount due: 1,000.00 GBP'].join('\\n')), { invoiceNumber: 'A-19', date: '2023-11-07', total: 1000, currency: 'GBP' });\nassert.deepEqual(extractInvoice('Thanks for your business!'), { invoiceNumber: null, date: null, total: null, currency: null });\nconst partial = extractInvoice('Invoice #: Z-1\\nSubtotal: $5.00');\nassert.equal(partial.invoiceNumber, 'Z-1');\nassert.equal(partial.total, null);\nassert.equal(partial.date, null);\n" + }, + { + "id": "add-column-migration", + "category": "database", + "manualIds": [ + "skill:database-migrations" + ], + "query": "This repo keeps PostgreSQL migrations in migrations/ as NNN_name.up.sql plus NNN_name.down.sql (see README.md). Add migration 002 (one .up.sql and one .down.sql with the same NNN_name stem) that adds users.email_verified as a boolean that is NOT NULL with default false, and a unique index named users_email_lower_key on lower(email). The users table is large and takes writes constantly, so the index must be built without blocking writes, and the runner does not wrap files in a transaction. The down migration must fully reverse 002 and nothing else. Do not modify migration 001. Do not add dependencies.", + "files": { + "migrations/001_create_users.up.sql": "CREATE TABLE users (\n id bigserial PRIMARY KEY,\n email text NOT NULL,\n name text NOT NULL,\n created_at timestamptz NOT NULL DEFAULT now()\n);\n", + "migrations/001_create_users.down.sql": "DROP TABLE users;\n", + "README.md": "# accounts-db\n\nPostgreSQL 15. Migrations live in migrations/ and are applied in filename order.\nEach migration is a pair: NNN_name.up.sql and NNN_name.down.sql.\nThe runner sends each file as-is (no implicit BEGIN/COMMIT).\nProduction: users has about 40 million rows and receives writes all day.\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst path = require('node:path');\nconst dir = path.join(process.cwd(), 'migrations');\nconst names = fs.readdirSync(dir);\nconst ups = names.filter(n => /^002_[A-Za-z0-9_-]+\\.up\\.sql$/.test(n));\nassert.equal(ups.length, 1, 'expected one 002 up migration');\nconst stem = ups[0].slice(0, -'.up.sql'.length);\nassert.ok(names.includes(stem + '.down.sql'), 'matching down migration missing');\nconst strip = s => s.replace(/--[^\\n]*/g, '').replace(/\\/\\*[\\s\\S]*?\\*\\//g, '');\nconst up = strip(fs.readFileSync(path.join(dir, ups[0]), 'utf8'));\nconst down = strip(fs.readFileSync(path.join(dir, stem + '.down.sql'), 'utf8'));\nconst add = /ALTER\\s+TABLE\\s+(?:IF\\s+EXISTS\\s+)?(?:ONLY\\s+)?\"?users\"?\\s+ADD\\s+(?:COLUMN\\s+)?(?:IF\\s+NOT\\s+EXISTS\\s+)?\"?email_verified\"?\\s+(?:boolean|bool)\\b([^;]*)/i.exec(up);\nassert.ok(add, 'ADD COLUMN email_verified boolean missing');\nconst col = '\"?email_verified\"?';\nassert.ok(/NOT\\s+NULL/i.test(add[1]) || new RegExp('ALTER\\\\s+COLUMN\\\\s+' + col + '\\\\s+SET\\\\s+NOT\\\\s+NULL', 'i').test(up), 'NOT NULL missing');\nassert.ok(/DEFAULT\\s+(?:false|'f'|'false')/i.test(add[1]) || new RegExp('ALTER\\\\s+COLUMN\\\\s+' + col + '\\\\s+SET\\\\s+DEFAULT\\\\s+false', 'i').test(up), 'DEFAULT false missing');\nassert.match(up, /CREATE\\s+UNIQUE\\s+INDEX\\s+CONCURRENTLY\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?\"?users_email_lower_key\"?\\s+ON\\s+(?:ONLY\\s+)?\"?users\"?\\s*(?:USING\\s+btree\\s*)?\\(\\s*lower\\s*\\(\\s*\"?email\"?\\s*\\)\\s*\\)/i);\nconst idx = up.search(/CREATE\\s+UNIQUE\\s+INDEX\\s+CONCURRENTLY/i);\nconst opened = [...up.slice(0, idx).matchAll(/\\b(BEGIN|START\\s+TRANSACTION|COMMIT|END|ROLLBACK)\\b\\s*;/gi)].map(x => x[1].toUpperCase());\nassert.ok(!opened.length || !/^(BEGIN|START)/.test(opened[opened.length - 1]), 'concurrent index inside a transaction');\nassert.doesNotMatch(up, /DROP\\s+(?:COLUMN|TABLE|INDEX)/i);\nassert.match(down, /DROP\\s+INDEX\\s+(?:CONCURRENTLY\\s+)?(?:IF\\s+EXISTS\\s+)?\"?users_email_lower_key\"?/i);\nassert.match(down, /ALTER\\s+TABLE\\s+(?:IF\\s+EXISTS\\s+)?\"?users\"?\\s+DROP\\s+(?:COLUMN\\s+)?(?:IF\\s+EXISTS\\s+)?\"?email_verified\"?/i);\nassert.doesNotMatch(down, /DROP\\s+TABLE/i);\nconst original = \"CREATE TABLE users (\\n id bigserial PRIMARY KEY,\\n email text NOT NULL,\\n name text NOT NULL,\\n created_at timestamptz NOT NULL DEFAULT now()\\n);\\n\";\nassert.equal(fs.readFileSync(path.join(dir, '001_create_users.up.sql'), 'utf8'), original);\nassert.equal(fs.readFileSync(path.join(dir, '001_create_users.down.sql'), 'utf8'), 'DROP TABLE users;\\n');\n" + }, + { + "id": "rename-column-expand", + "category": "database", + "manualIds": [ + "skill:database-migrations" + ], + "query": "We want PostgreSQL column customers.full_name renamed to display_name, but old app instances keep reading and writing full_name for hours during the rolling deploy (see README.md). Do only the zero-downtime expand step. 1) Add migrations/002_.up.sql and matching .down.sql: the up adds a nullable display_name text column and backfills it from full_name; it must not rename or drop full_name. The down removes display_name only. 2) Update src/customerRepo.js: buildInsert(customer) and buildUpdateName(id, name) must write the name to both full_name and display_name (still parameterized { text, values } with $n placeholders), and mapRow(row) must return name from display_name, falling back to full_name when display_name is null. Keep all exports. Do not add dependencies.", + "files": { + "migrations/001_create_customers.up.sql": "CREATE TABLE customers (\n id bigserial PRIMARY KEY,\n email text NOT NULL,\n full_name text NOT NULL\n);\n", + "migrations/001_create_customers.down.sql": "DROP TABLE customers;\n", + "src/customerRepo.js": "'use strict';\n\nfunction buildInsert(customer) {\n return { text: 'INSERT INTO customers (email, full_name) VALUES ($1, $2) RETURNING id', values: [customer.email, customer.name] };\n}\n\nfunction buildUpdateName(id, name) {\n return { text: 'UPDATE customers SET full_name = $1 WHERE id = $2', values: [name, id] };\n}\n\nfunction mapRow(row) {\n return { id: row.id, email: row.email, name: row.full_name };\n}\n\nmodule.exports = { buildInsert, buildUpdateName, mapRow };\n", + "README.md": "# customers-service\n\nPostgreSQL 15. Migrations: migrations/NNN_name.up.sql and NNN_name.down.sql.\nDeploys are rolling: the previous app version keeps serving traffic (reading\nand writing full_name) until every instance is replaced.\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst path = require('node:path');\nconst dir = path.join(process.cwd(), 'migrations');\nconst names = fs.readdirSync(dir);\nconst ups = names.filter(n => /^002_[A-Za-z0-9_-]+\\.up\\.sql$/.test(n));\nassert.equal(ups.length, 1);\nconst stem = ups[0].slice(0, -'.up.sql'.length);\nconst strip = s => s.replace(/--[^\\n]*/g, '').replace(/\\/\\*[\\s\\S]*?\\*\\//g, '');\nconst up = strip(fs.readFileSync(path.join(dir, ups[0]), 'utf8'));\nconst down = strip(fs.readFileSync(path.join(dir, stem + '.down.sql'), 'utf8'));\nconst add = /ALTER\\s+TABLE\\s+(?:IF\\s+EXISTS\\s+)?\"?customers\"?\\s+ADD\\s+(?:COLUMN\\s+)?(?:IF\\s+NOT\\s+EXISTS\\s+)?\"?display_name\"?\\s+(?:text|varchar|character\\s+varying)\\b([^;]*)/i.exec(up);\nassert.ok(add, 'ADD COLUMN display_name missing');\nassert.doesNotMatch(add[1], /NOT\\s+NULL/i);\nassert.match(up, /UPDATE\\s+\"?customers\"?\\s+SET\\s+\"?display_name\"?\\s*=\\s*\"?full_name\"?/i);\nassert.doesNotMatch(up, /RENAME\\s+(?:COLUMN\\s+)?\"?full_name/i);\nassert.doesNotMatch(up, /DROP\\s+(?:COLUMN|TABLE)|DROP\\s+\"?full_name/i);\nassert.match(down, /DROP\\s+(?:COLUMN\\s+)?(?:IF\\s+EXISTS\\s+)?\"?display_name\"?/i);\nassert.doesNotMatch(down, /full_name|DROP\\s+TABLE/i);\nconst repo = require(path.join(process.cwd(), 'src/customerRepo.js'));\nconst maxParam = t => Math.max(0, ...[...t.matchAll(/\\$(\\d+)/g)].map(x => Number(x[1])));\nconst ins = repo.buildInsert({ email: 'a@x.io', name: \"O'Hara\" });\nassert.match(ins.text, /INSERT\\s+INTO\\s+\"?customers\"?/i);\nassert.match(ins.text, /full_name/);\nassert.match(ins.text, /display_name/);\nassert.ok(!ins.text.includes(\"O'Hara\"));\nassert.ok(ins.values.includes(\"O'Hara\") && ins.values.includes('a@x.io'));\nassert.equal(maxParam(ins.text), ins.values.length);\nconst upd = repo.buildUpdateName(7, 'Bo');\nassert.match(upd.text, /UPDATE\\s+\"?customers\"?\\s+SET/i);\nassert.match(upd.text, /full_name\\s*=\\s*\\$\\d+/);\nassert.match(upd.text, /display_name\\s*=\\s*\\$\\d+/);\nassert.match(upd.text, /WHERE\\s+\"?id\"?\\s*=\\s*\\$\\d+/i);\nassert.ok(upd.values.includes('Bo') && upd.values.includes(7));\nassert.equal(maxParam(upd.text), upd.values.length);\nassert.equal(repo.mapRow({ id: 1, email: 'e', full_name: 'Old', display_name: null }).name, 'Old');\nassert.equal(repo.mapRow({ id: 1, email: 'e', full_name: 'Old' }).name, 'Old');\nassert.equal(repo.mapRow({ id: 1, email: 'e', full_name: 'Old', display_name: 'New' }).name, 'New');\nassert.equal(repo.mapRow({ id: 2, email: 'e', full_name: 'Old', display_name: 'New' }).id, 2);\n" + }, + { + "id": "keyset-feed-query", + "category": "database", + "manualIds": [ + "skill:postgres-patterns" + ], + "query": "src/feedQuery.js builds the PostgreSQL query for a user's post feed using OFFSET, which gets slow and skips rows on deep pages. Switch to keyset (cursor) pagination ordered by created_at DESC, id DESC. Export encodeCursor(row) (row has created_at as an ISO string and id) returning an opaque string, and buildFeedQuery({ userId, limit, cursor }) returning { text, values } for node-postgres ($n placeholders; no caller value inlined into text). cursor is undefined for the first page; otherwise it comes from encodeCursor and the query must return only rows strictly after that row in the sort order. Throw an Error for a malformed cursor and a RangeError unless limit is an integer 1..50. Also add migrations/002_.sql creating a composite index on posts that supports this query (single-file migrations, see 001). Do not add dependencies.", + "files": { + "src/feedQuery.js": "'use strict';\n\n// page is 0-based\nfunction buildFeedQuery({ userId, limit, page = 0 }) {\n return {\n text: 'SELECT id, user_id, body, created_at FROM posts WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3',\n values: [userId, limit, page * limit],\n };\n}\n\nmodule.exports = { buildFeedQuery };\n", + "migrations/001_create_posts.sql": "CREATE TABLE posts (\n id bigserial PRIMARY KEY,\n user_id bigint NOT NULL,\n body text NOT NULL,\n created_at timestamptz NOT NULL DEFAULT now()\n);\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst path = require('node:path');\nconst { buildFeedQuery, encodeCursor } = require(path.join(process.cwd(), 'src/feedQuery.js'));\nconst maxParam = t => Math.max(0, ...[...t.matchAll(/\\$(\\d+)/g)].map(x => Number(x[1])));\nconst ORDER = /ORDER\\s+BY\\s+\"?created_at\"?\\s+DESC\\s*,\\s*\"?id\"?\\s+DESC/i;\nconst first = buildFeedQuery({ userId: 7, limit: 20 });\nassert.doesNotMatch(first.text, /OFFSET/i);\nassert.match(first.text, ORDER);\nassert.match(first.text, /user_id\\s*=\\s*\\$\\d+/i);\nassert.ok(first.values.includes(7));\nassert.match(first.text, /LIMIT\\s+(\\$\\d+|20)\\b/i);\nassert.equal(maxParam(first.text), first.values.length);\nconst cur = encodeCursor({ id: 42, user_id: 7, body: 'hi', created_at: '2024-05-01T10:00:00.000Z' });\nassert.equal(typeof cur, 'string');\nconst next = buildFeedQuery({ userId: 7, limit: 20, cursor: cur });\nassert.doesNotMatch(next.text, /OFFSET/i);\nassert.match(next.text, ORDER);\nassert.ok(!next.text.includes('2024-05-01') && !/\\b42\\b/.test(next.text));\nconst row = /\\(\\s*\"?created_at\"?\\s*,\\s*\"?id\"?\\s*\\)\\s*<\\s*\\(\\s*\\$(\\d+)(?:::\\w+)?\\s*,\\s*\\$(\\d+)(?:::\\w+)?\\s*\\)/i.exec(next.text);\nconst expanded = /\"?created_at\"?\\s*<\\s*\\$(\\d+)[\\s\\S]*\"?created_at\"?\\s*=\\s*\\$(\\d+)[\\s\\S]*\"?id\"?\\s*<\\s*\\$(\\d+)/i.exec(next.text);\nassert.ok(row || expanded, 'keyset predicate missing: ' + next.text);\nconst vals = next.values.map(v => (v instanceof Date ? v.toISOString() : String(v)));\nassert.ok(vals.includes('2024-05-01T10:00:00.000Z'));\nassert.ok(vals.includes('42'));\nassert.ok(next.values.includes(7));\nassert.equal(maxParam(next.text), next.values.length);\nassert.throws(() => buildFeedQuery({ userId: 7, limit: 20, cursor: 'not-a-cursor' }));\nfor (const bad of [0, 51, '20', 1.5]) assert.throws(() => buildFeedQuery({ userId: 7, limit: bad }), RangeError);\nconst dir = path.join(process.cwd(), 'migrations');\nconst mig = fs.readdirSync(dir).filter(n => /^002_[A-Za-z0-9_-]+\\.sql$/.test(n));\nassert.equal(mig.length, 1);\nconst sql = fs.readFileSync(path.join(dir, mig[0]), 'utf8').replace(/--[^\\n]*/g, '');\nassert.match(sql, /CREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+[\\s\\S]*?ON\\s+(?:ONLY\\s+)?\"?posts\"?\\s*(?:USING\\s+btree\\s*)?\\(\\s*\"?user_id\"?\\s*,\\s*\"?created_at\"?(?:\\s+DESC)?\\s*,\\s*\"?id\"?(?:\\s+DESC)?\\s*\\)/i);\n" + }, + { + "id": "upsert-inventory-sql", + "category": "database", + "manualIds": [ + "skill:postgres-patterns" + ], + "query": "src/inventory.js exports async syncStock(db, items), where items are { sku, quantity } and db.query(text, values) runs a parameterized PostgreSQL statement (node-postgres style, $n placeholders). It currently does a SELECT and then an UPDATE or INSERT per item, which is slow and races with concurrent syncs. Replace it with a single INSERT INTO inventory (sku, quantity, updated_at) ... ON CONFLICT (sku) DO UPDATE statement for the whole batch that sets quantity from the incoming row and updated_at to now(). Exactly one db.query call per non-empty batch and none for an empty batch. If the same sku appears more than once in items, the last occurrence wins (PostgreSQL rejects affecting a row twice in one statement). No caller value may be inlined into the SQL text. Resolve to the number of distinct skus written. Do not add dependencies.", + "files": { + "src/inventory.js": "'use strict';\n\nasync function syncStock(db, items) {\n let count = 0;\n for (const item of items) {\n const found = await db.query('SELECT sku FROM inventory WHERE sku = $1', [item.sku]);\n if (found.rows.length) {\n await db.query('UPDATE inventory SET quantity = $1, updated_at = now() WHERE sku = $2', [item.quantity, item.sku]);\n } else {\n await db.query('INSERT INTO inventory (sku, quantity, updated_at) VALUES ($1, $2, now())', [item.sku, item.quantity]);\n }\n count++;\n }\n return count;\n}\n\nmodule.exports = { syncStock };\n", + "schema.sql": "CREATE TABLE inventory (\n sku text PRIMARY KEY,\n quantity integer NOT NULL,\n updated_at timestamptz NOT NULL\n);\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst path = require('node:path');\nconst { syncStock } = require(path.join(process.cwd(), 'src/inventory.js'));\nfunction fakeDb() {\n const calls = [];\n return { calls, async query(text, values) { calls.push({ text, values }); return { rows: [], rowCount: 0 }; } };\n}\n(async () => {\n let db = fakeDb();\n assert.equal(await syncStock(db, []), 0);\n assert.equal(db.calls.length, 0);\n db = fakeDb();\n const n = await syncStock(db, [{ sku: 'SKU-A', quantity: 11 }, { sku: \"SKU-'B\", quantity: 55 }, { sku: 'SKU-A', quantity: 7 }]);\n assert.equal(n, 2);\n assert.equal(db.calls.length, 1);\n const { text, values } = db.calls[0];\n assert.match(text, /INSERT\\s+INTO\\s+\"?inventory\"?/i);\n assert.match(text, /ON\\s+CONFLICT\\s*\\(\\s*\"?sku\"?\\s*\\)\\s*DO\\s+UPDATE\\s+SET/i);\n assert.match(text, /\"?quantity\"?\\s*=\\s*EXCLUDED\\.\"?quantity\"?/i);\n assert.match(text, /\"?updated_at\"?\\s*=\\s*(?:now\\(\\)|CURRENT_TIMESTAMP|EXCLUDED\\.\"?updated_at\"?)/i);\n assert.ok(!text.includes('SKU-'), 'sku inlined into SQL');\n const flat = values.flat(Infinity).map(v => (typeof v === 'string' && /^\\d+$/.test(v) ? Number(v) : v));\n assert.equal(flat.filter(v => v === 'SKU-A').length, 1);\n assert.equal(flat.filter(v => v === \"SKU-'B\").length, 1);\n assert.ok(flat.includes(7) && flat.includes(55));\n assert.ok(!flat.includes(11), 'stale duplicate quantity sent');\n const maxParam = Math.max(0, ...[...text.matchAll(/\\$(\\d+)/g)].map(x => Number(x[1])));\n assert.equal(maxParam, values.length);\n db = fakeDb();\n assert.equal(await syncStock(db, [{ sku: 'X', quantity: 1 }]), 1);\n assert.equal(db.calls.length, 1);\n})().catch(err => { console.error(err); process.exitCode = 1; });\n" + }, + { + "id": "slugify-regression-tests", + "category": "testing", + "manualIds": [ + "skill:tdd-workflow" + ], + "query": "Bug report in BUGS.md: src/slugify.js produces leading and trailing hyphens and mangles accented letters. Work test-first: add test/slugify.test.js using the built-in node:test runner and node:assert, requiring ../src/slugify, with at least three separate test cases that reproduce the reported bugs and cover edge cases (empty input, repeated separators), then fix slugify(input) so they pass. Expected behavior: lowercase ASCII output; accented Latin letters lose their accents (e with grave becomes e); every run of non-alphanumeric characters becomes a single hyphen; no leading or trailing hyphens; empty or separator-only input returns an empty string. Do not add dependencies.", + "files": { + "src/slugify.js": "'use strict';\n\nfunction slugify(input) {\n return String(input).toLowerCase().replace(/[^a-z0-9]+/g, '-');\n}\n\nmodule.exports = { slugify };\n", + "BUGS.md": "# Open bugs\n\n1. slugify(' Hello, World! ') returns '-hello-world-' (expected 'hello-world').\n2. slugify('Cr\\u00e8me Br\\u00fbl\\u00e9e') (accented) returns 'cr-me-br-l-e' (expected 'creme-brulee').\n", + "package.json": "{\n \"name\": \"slugs\",\n \"version\": \"1.0.0\",\n \"private\": true,\n \"scripts\": { \"test\": \"node --test test/\" }\n}\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst path = require('node:path');\nconst { slugify } = require(path.join(process.cwd(), 'src/slugify.js'));\nassert.equal(slugify(' Hello, World! '), 'hello-world');\nassert.equal(slugify('Cr\\u00e8me Br\\u00fbl\\u00e9e'), 'creme-brulee');\nassert.equal(slugify('D\\u00e9j\\u00e0 Vu 2024'), 'deja-vu-2024');\nassert.equal(slugify('a--b__c'), 'a-b-c');\nassert.equal(slugify(''), '');\nassert.equal(slugify(' -- !! '), '');\nassert.equal(slugify('already-slugged'), 'already-slugged');\nconst testFile = path.join(process.cwd(), 'test', 'slugify.test.js');\nassert.ok(fs.existsSync(testFile), 'test/slugify.test.js missing');\nconst src = fs.readFileSync(testFile, 'utf8');\nassert.match(src, /node:test/);\nassert.match(src, /require\\(\\s*['\"]\\.\\.\\/src\\/slugify(?:\\.js)?['\"]\\s*\\)/);\nassert.ok((src.match(/\\b(?:test|it)\\s*\\(/g) || []).length >= 3, 'expected at least three test cases');\n" + }, + { + "id": "content-hash-cache", + "category": "performance", + "manualIds": [ + "skill:content-hash-cache-pattern" + ], + "query": "src/extractor.js exports createExtractor({ readFile, parse }). readFile(filePath) returns a Buffer and parse(text) is an expensive document parser. The cache is keyed by file path, so edited files return stale results and renamed or copied files are parsed again. Re-key the cache by the SHA-256 hex digest of the file bytes (use node:crypto) so identical content at any path is parsed once and changed content is re-parsed. Also export cacheKeyFor(buffer) returning that hex digest. extract(filePath) must still return the parse result, and stats() must return { hits, misses } counting cache hits and parses. Do not add dependencies.", + "files": { + "src/extractor.js": "'use strict';\n\nfunction createExtractor({ readFile, parse }) {\n const cache = new Map();\n let hits = 0;\n let misses = 0;\n return {\n extract(filePath) {\n if (cache.has(filePath)) {\n hits++;\n return cache.get(filePath);\n }\n misses++;\n const result = parse(readFile(filePath).toString('utf8'));\n cache.set(filePath, result);\n return result;\n },\n stats: () => ({ hits, misses }),\n };\n}\n\nmodule.exports = { createExtractor };\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst path = require('node:path');\nconst { createExtractor, cacheKeyFor } = require(path.join(process.cwd(), 'src/extractor.js'));\nassert.equal(cacheKeyFor(Buffer.from('hello')), '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824');\nassert.notEqual(cacheKeyFor(Buffer.from('a')), cacheKeyFor(Buffer.from('b')));\nconst disk = { 'a.txt': Buffer.from('report one'), 'b.txt': Buffer.from('report one') };\nlet parses = 0;\nconst ex = createExtractor({ readFile: p => Buffer.from(disk[p]), parse: t => { parses++; return { words: t.split(' ').length, text: t }; } });\nassert.deepEqual(ex.extract('a.txt'), { words: 2, text: 'report one' });\nassert.deepEqual(ex.extract('b.txt'), { words: 2, text: 'report one' });\nassert.equal(parses, 1);\ndisk['a.txt'] = Buffer.from('report one edited');\nassert.deepEqual(ex.extract('a.txt'), { words: 3, text: 'report one edited' });\nassert.equal(parses, 2);\nex.extract('a.txt');\nex.extract('b.txt');\nassert.equal(parses, 2);\nassert.deepEqual(ex.stats(), { hits: 3, misses: 2 });\n" + }, + { + "id": "batch-customer-lookup", + "category": "performance", + "manualIds": [ + "skill:backend-patterns" + ], + "query": "src/orders.js exports async getOrdersWithCustomers(repo) for the orders dashboard endpoint. It calls repo.findCustomerById once per order, which is an N+1 query pattern and times out for large accounts. The repo (see src/repo.js for the interface) also offers findCustomersByIds(ids), which resolves to the matching customers in any order and omits unknown ids. Rewrite the function to load all customers with a single findCustomersByIds call using the distinct customer ids (and no call at all when there are no orders), never calling findCustomerById. Return the orders in their original order, each as a new object with a customer property (null when the customer does not exist). Do not add dependencies.", + "files": { + "src/orders.js": "'use strict';\n\nasync function getOrdersWithCustomers(repo) {\n const orders = await repo.listOrders();\n const result = [];\n for (const order of orders) {\n const customer = await repo.findCustomerById(order.customerId);\n result.push({ ...order, customer });\n }\n return result;\n}\n\nmodule.exports = { getOrdersWithCustomers };\n", + "src/repo.js": "'use strict';\n\n// Interface implemented by the SQL repository in production.\n// listOrders(): Promise>\n// findCustomerById(id): Promise<{ id, name } | null> -- one query per call\n// findCustomersByIds(ids): Promise> -- one query, WHERE id = ANY($1)\nmodule.exports = {};\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst path = require('node:path');\nconst { getOrdersWithCustomers } = require(path.join(process.cwd(), 'src/orders.js'));\nfunction repo(orders) {\n const customers = [{ id: 'c1', name: 'Ada' }, { id: 'c2', name: 'Lin' }, { id: 'c3', name: 'Bo' }];\n const r = { single: 0, batch: [], async listOrders() { return orders; },\n async findCustomerById(id) { r.single++; return customers.find(c => c.id === id) || null; },\n async findCustomersByIds(ids) { r.batch.push([...ids]); return customers.filter(c => ids.includes(c.id)).reverse(); } };\n return r;\n}\n(async () => {\n const orders = [{ id: 1, customerId: 'c2', total: 5 }, { id: 2, customerId: 'c1', total: 7 },\n { id: 3, customerId: 'c2', total: 1 }, { id: 4, customerId: 'gone', total: 2 }];\n const snapshot = JSON.stringify(orders);\n const r = repo(orders);\n const out = await getOrdersWithCustomers(r);\n assert.equal(r.single, 0);\n assert.equal(r.batch.length, 1);\n assert.deepEqual(r.batch[0].slice().sort(), ['c1', 'c2', 'gone']);\n assert.deepEqual(out.map(o => o.id), [1, 2, 3, 4]);\n assert.deepEqual(out.map(o => o.customer && o.customer.name), ['Lin', 'Ada', 'Lin', null]);\n assert.equal(out[0].total, 5);\n assert.equal(JSON.stringify(orders), snapshot);\n const empty = repo([]);\n assert.deepEqual(await getOrdersWithCustomers(empty), []);\n assert.equal(empty.batch.length + empty.single, 0);\n})().catch(err => { console.error(err); process.exitCode = 1; });\n" + }, + { + "id": "rbac-middleware", + "category": "auth", + "manualIds": [ + "skill:backend-patterns" + ], + "query": "src/auth.js exports requirePermission(permission), an Express-style middleware factory, and ROLE_PERMISSIONS. It only checks that req.user exists and never checks the role. Implement role-based access control: calling requirePermission with a permission that no role grants must throw immediately. The returned middleware (req, res, next) must respond res.status(401).json({ error: { code: \"UNAUTHENTICATED\", message } }) when req.user is missing; res.status(403).json({ error: { code: \"FORBIDDEN\", message } }) when req.user.role is unknown or lacks the permission (role names must be looked up safely, so values such as \"constructor\" or \"__proto__\" are simply unknown roles); otherwise call next() exactly once without responding. Do not change ROLE_PERMISSIONS. Do not add dependencies.", + "files": { + "src/auth.js": "'use strict';\n\nconst ROLE_PERMISSIONS = {\n admin: ['read', 'write', 'delete'],\n editor: ['read', 'write'],\n viewer: ['read'],\n};\n\nfunction requirePermission(permission) {\n return (req, res, next) => {\n if (!req.user) return res.status(401).json({ error: 'unauthorized' });\n return next();\n };\n}\n\nmodule.exports = { requirePermission, ROLE_PERMISSIONS };\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst path = require('node:path');\nconst { requirePermission } = require(path.join(process.cwd(), 'src/auth.js'));\nfunction run(permission, user) {\n const res = { code: null, body: null, status(c) { this.code = c; return this; }, json(b) { this.body = b; return this; } };\n let nexts = 0;\n requirePermission(permission)(user === undefined ? {} : { user }, res, () => { nexts++; });\n return { res, nexts };\n}\nlet r = run('read');\nassert.equal(r.res.code, 401);\nassert.equal(r.res.body.error.code, 'UNAUTHENTICATED');\nassert.equal(typeof r.res.body.error.message, 'string');\nassert.equal(r.nexts, 0);\nr = run('write', { id: 1, role: 'viewer' });\nassert.equal(r.res.code, 403);\nassert.equal(r.res.body.error.code, 'FORBIDDEN');\nassert.equal(r.nexts, 0);\nfor (const role of ['root', 'constructor', '__proto__', 'toString', undefined, 'hasOwnProperty']) {\n let out;\n assert.doesNotThrow(() => { out = run('read', { id: 2, role }); }, String(role));\n assert.equal(out.res.code, 403, String(role));\n assert.equal(out.nexts, 0);\n}\nr = run('write', { id: 3, role: 'editor' });\nassert.equal(r.nexts, 1);\nassert.equal(r.res.code, null);\nr = run('delete', { id: 4, role: 'admin' });\nassert.equal(r.nexts, 1);\nr = run('delete', { id: 5, role: 'editor' });\nassert.equal(r.res.code, 403);\nassert.throws(() => requirePermission('fly'));\nassert.throws(() => requirePermission('constructor'));\n" + }, + { + "id": "immutable-cart-update", + "category": "refactor", + "manualIds": [ + "skill:coding-standards" + ], + "query": "src/cart.js exports addItem(cart, item), removeItem(cart, sku), applyDiscount(cart, pct) and total(cart). A cart is { items: [{ sku, price, quantity }], discountPct }. The update functions mutate their arguments, which causes stale UI state bugs. Refactor them to be pure: never mutate the cart, its items array, any item object, or the item argument; always return a new cart object. Keep the behavior: addItem adds the item, or increases quantity when the sku already exists; removeItem drops the sku; applyDiscount sets discountPct and must throw a RangeError unless pct is a number from 0 to 100; total returns the discounted sum rounded to 2 decimal places. Do not add dependencies.", + "files": { + "src/cart.js": "'use strict';\n\nfunction addItem(cart, item) {\n const existing = cart.items.find(i => i.sku === item.sku);\n if (existing) existing.quantity += item.quantity;\n else cart.items.push(item);\n return cart;\n}\n\nfunction removeItem(cart, sku) {\n cart.items = cart.items.filter(i => i.sku !== sku);\n return cart;\n}\n\nfunction applyDiscount(cart, pct) {\n cart.discountPct = pct;\n return cart;\n}\n\nfunction total(cart) {\n const sum = cart.items.reduce((acc, i) => acc + i.price * i.quantity, 0);\n return Math.round(sum * (1 - (cart.discountPct || 0) / 100) * 100) / 100;\n}\n\nmodule.exports = { addItem, removeItem, applyDiscount, total };\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst path = require('node:path');\nconst cart = require(path.join(process.cwd(), 'src/cart.js'));\nconst deepFreeze = o => { Object.values(o).forEach(v => { if (v && typeof v === 'object') deepFreeze(v); }); return Object.freeze(o); };\nconst base = deepFreeze({ items: [{ sku: 'a', price: 10, quantity: 1 }, { sku: 'b', price: 2.5, quantity: 2 }], discountPct: 0 });\nconst snap = JSON.stringify(base);\nconst item = deepFreeze({ sku: 'a', price: 10, quantity: 2 });\nconst c1 = cart.addItem(base, item);\nassert.notEqual(c1, base);\nassert.deepEqual(c1.items.find(i => i.sku === 'a').quantity, 3);\nassert.equal(c1.items.length, 2);\nconst newItem = deepFreeze({ sku: 'c', price: 1, quantity: 1 });\nconst c2 = cart.addItem(c1, newItem);\nassert.equal(c2.items.length, 3);\nassert.equal(c1.items.length, 2);\nconst c3 = cart.removeItem(c2, 'b');\nassert.deepEqual(c3.items.map(i => i.sku), ['a', 'c']);\nassert.equal(c2.items.length, 3);\nconst c4 = cart.applyDiscount(c3, 10);\nassert.equal(c4.discountPct, 10);\nassert.equal(c3.discountPct, 0);\nassert.equal(cart.total(c4), 27.9);\nassert.equal(cart.total(base), 15);\nfor (const bad of [-1, 101, '10', NaN]) assert.throws(() => cart.applyDiscount(base, bad), RangeError);\nassert.equal(JSON.stringify(base), snap);\nconst m = { items: [{ sku: 'z', price: 1, quantity: 1 }], discountPct: 0 };\nconst m2 = cart.addItem(m, { sku: 'z', price: 1, quantity: 4 });\nassert.equal(m.items[0].quantity, 1);\nassert.equal(m2.items[0].quantity, 5);\nconst added = { sku: 'y', price: 3, quantity: 1 };\nconst m3 = cart.addItem(m, added);\ncart.addItem(m3, { sku: 'y', price: 3, quantity: 5 });\nassert.equal(added.quantity, 1);\n" + }, + { + "id": "inject-signup-deps", + "category": "refactor", + "manualIds": [ + "skill:hexagonal-architecture" + ], + "query": "src/signup.js hard-requires the Postgres and SMTP adapters in src/adapters/, which fail at import time without infrastructure, so the sign-up use case cannot be unit tested. Refactor to ports and adapters. src/signup.js must export createSignupService({ userRepository, mailer, clock }) returning { signUp({ email, name }) } and must not import anything from src/adapters or read environment variables. Ports: userRepository.findByEmail(email) and userRepository.save(user) (resolves to the stored user including id), mailer.sendWelcome({ to, name }), clock.now() returning a Date. signUp trims and lowercases the email; rejects with an error whose code is \"INVALID_EMAIL\" if it lacks \"@\", or \"EMAIL_TAKEN\" if findByEmail finds a user (without saving or mailing); otherwise saves { email, name, createdAt: clock.now().toISOString() }, sends the welcome email to the saved user, and resolves to the saved user. Add src/main.js as the composition root that wires the real adapters. Keep the adapters as they are. Do not add dependencies.", + "files": { + "src/signup.js": "'use strict';\nconst store = require('./adapters/pgUserStore');\nconst mailer = require('./adapters/smtpMailer');\n\nasync function signUp({ email, name }) {\n const normalized = email.trim().toLowerCase();\n if (await store.findByEmail(normalized)) throw new Error('taken');\n const user = await store.insert({ email: normalized, name, createdAt: new Date().toISOString() });\n await mailer.sendWelcome(user.email, user.name);\n return user;\n}\n\nmodule.exports = { signUp };\n", + "src/adapters/pgUserStore.js": "'use strict';\n// Connects at import time, like our real pool module.\nif (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not configured');\n\nmodule.exports = {\n async findByEmail(email) { throw new Error('not implemented in this repo snapshot: ' + email); },\n async insert(user) { throw new Error('not implemented in this repo snapshot: ' + user.email); },\n};\n", + "src/adapters/smtpMailer.js": "'use strict';\nif (!process.env.SMTP_URL) throw new Error('SMTP_URL is not configured');\n\nmodule.exports = {\n async sendWelcome(to, name) { throw new Error('not implemented in this repo snapshot: ' + to + name); },\n};\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst path = require('node:path');\ndelete process.env.DATABASE_URL;\ndelete process.env.SMTP_URL;\nconst file = path.join(process.cwd(), 'src/signup.js');\nconst source = fs.readFileSync(file, 'utf8');\nassert.doesNotMatch(source, /require\\([^)]*adapters|from\\s+['\"][^'\"]*adapters/, 'domain imports an adapter');\nassert.doesNotMatch(source, /process\\.env/, 'domain reads the environment');\nassert.ok(fs.existsSync(path.join(process.cwd(), 'src/main.js')), 'composition root missing');\nconst { createSignupService } = require(file);\nfunction setup(existing = []) {\n const users = [...existing];\n const log = { saved: [], mails: [] };\n const svc = createSignupService({\n userRepository: { async findByEmail(e) { return users.find(u => u.email === e) || null; },\n async save(u) { const s = { id: 'u' + (users.length + 1), ...u }; users.push(s); log.saved.push(u); return s; } },\n mailer: { async sendWelcome(msg) { log.mails.push(msg); } },\n clock: { now: () => new Date(Date.UTC(2024, 0, 2, 3, 4, 5)) },\n });\n return { svc, log };\n}\n(async () => {\n let { svc, log } = setup();\n const user = await svc.signUp({ email: ' Ada@Example.COM ', name: 'Ada' });\n assert.deepEqual(user, { id: 'u1', email: 'ada@example.com', name: 'Ada', createdAt: '2024-01-02T03:04:05.000Z' });\n assert.deepEqual(log.saved, [{ email: 'ada@example.com', name: 'Ada', createdAt: '2024-01-02T03:04:05.000Z' }]);\n assert.deepEqual(log.mails, [{ to: 'ada@example.com', name: 'Ada' }]);\n ({ svc, log } = setup([{ id: 'x', email: 'lin@example.com', name: 'Lin' }]));\n await assert.rejects(svc.signUp({ email: 'LIN@example.com', name: 'Lin 2' }), e => e.code === 'EMAIL_TAKEN');\n await assert.rejects(svc.signUp({ email: 'nope', name: 'N' }), e => e.code === 'INVALID_EMAIL');\n assert.equal(log.saved.length, 0);\n assert.equal(log.mails.length, 0);\n})().catch(err => { console.error(err); process.exitCode = 1; });\n" + }, + { + "id": "cache-aside-user", + "category": "caching", + "manualIds": [ + "skill:redis-patterns" + ], + "query": "src/userCache.js exports createUserCache({ redis, db, ttlSeconds = 300 }). redis is a node-redis v4 style client (async get(key), set(key, value, { EX }), del(key)) and db has async findUser(id) and updateUser(id, patch). Profile reads are hammering the database. Implement cache-aside: getUser(id) uses key \"user:\" + id, returns the parsed cached JSON on a hit without touching db, and on a miss loads from db and caches JSON with an expiry of ttlSeconds (do not cache a missing user; return null). updateUser(id, patch) writes to db first, then deletes the cache key, and resolves to the updated user. Redis is an optimization, not a dependency: if any redis call rejects, getUser and updateUser must still return the correct db result. Do not add dependencies.", + "files": { + "src/userCache.js": "'use strict';\n\nfunction createUserCache({ redis, db, ttlSeconds = 300 }) {\n return {\n async getUser(id) {\n return db.findUser(id);\n },\n async updateUser(id, patch) {\n return db.updateUser(id, patch);\n },\n };\n}\n\nmodule.exports = { createUserCache };\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst path = require('node:path');\nconst { createUserCache } = require(path.join(process.cwd(), 'src/userCache.js'));\nfunction fakes(broken = false) {\n const store = new Map();\n const log = [];\n const redis = {\n async get(k) { log.push(['get', k]); if (broken) throw new Error('ECONNREFUSED'); return store.has(k) ? store.get(k) : null; },\n async set(k, v, opts) { log.push(['set', k, opts]); if (broken) throw new Error('ECONNREFUSED'); store.set(k, v); return 'OK'; },\n async del(k) { log.push(['del', k]); if (broken) throw new Error('ECONNREFUSED'); return store.delete(k) ? 1 : 0; },\n };\n const rows = { 1: { id: 1, name: 'Ada' } };\n const db = { reads: 0, async findUser(id) { db.reads++; return rows[id] ? { ...rows[id] } : null; },\n async updateUser(id, patch) { log.push(['db-update', id]); rows[id] = { ...rows[id], ...patch }; return { ...rows[id] }; } };\n return { store, log, redis, db };\n}\n(async () => {\n let f = fakes();\n const cache = createUserCache({ redis: f.redis, db: f.db, ttlSeconds: 60 });\n assert.deepEqual(await cache.getUser(1), { id: 1, name: 'Ada' });\n assert.equal(f.db.reads, 1);\n const set = f.log.find(e => e[0] === 'set');\n assert.equal(set[1], 'user:1');\n assert.deepEqual(set[2], { EX: 60 });\n assert.deepEqual(JSON.parse(f.store.get('user:1')), { id: 1, name: 'Ada' });\n assert.deepEqual(await cache.getUser(1), { id: 1, name: 'Ada' });\n assert.equal(f.db.reads, 1);\n assert.equal(await cache.getUser(2), null);\n assert.ok(!f.store.has('user:2'));\n const updated = await cache.updateUser(1, { name: 'Ada L' });\n assert.deepEqual(updated, { id: 1, name: 'Ada L' });\n const iUpd = f.log.findIndex(e => e[0] === 'db-update');\n const iDel = f.log.findIndex(e => e[0] === 'del' && e[1] === 'user:1');\n assert.ok(iUpd >= 0 && iDel > iUpd, 'must invalidate after the db write');\n assert.deepEqual(await cache.getUser(1), { id: 1, name: 'Ada L' });\n f = fakes();\n const dflt = createUserCache({ redis: f.redis, db: f.db });\n await dflt.getUser(1);\n assert.deepEqual(f.log.find(e => e[0] === 'set')[2], { EX: 300 });\n f = fakes(true);\n const broken = createUserCache({ redis: f.redis, db: f.db });\n assert.deepEqual(await broken.getUser(1), { id: 1, name: 'Ada' });\n assert.deepEqual(await broken.updateUser(1, { name: 'X' }), { id: 1, name: 'X' });\n})().catch(err => { console.error(err); process.exitCode = 1; });\n" + }, + { + "id": "token-units-bigint", + "category": "data", + "manualIds": [ + "skill:evm-token-decimals" + ], + "query": "src/units.js converts ERC-20 token amounts for our portfolio dashboard, but it uses floating point, so 18-decimal balances lose precision. Rewrite it with exact BigInt math. formatUnits(raw, decimals): raw is a bigint or an integer string in base units; return a decimal string with no trailing fractional zeros and no trailing \".\", keeping a leading \"-\" for negatives. parseUnits(value, decimals): value is a decimal string such as \"1.5\" or \"-0.25\"; return a bigint in base units; throw a RangeError if it has more fractional digits than decimals, and throw an Error for anything that is not a plain decimal number (e.g. \"\", \"abc\", \"1e5\", \"1.2.3\"). Also export normalizeAmount(raw, fromDecimals, toDecimals) returning a bigint rescaled between token precisions, truncating toward zero when precision is reduced. Do not add dependencies.", + "files": { + "src/units.js": "'use strict';\n\nfunction formatUnits(raw, decimals) {\n return String(Number(raw) / 10 ** decimals);\n}\n\nfunction parseUnits(value, decimals) {\n return BigInt(Math.round(parseFloat(value) * 10 ** decimals));\n}\n\nmodule.exports = { formatUnits, parseUnits };\n", + "README.md": "# portfolio-units\n\nToken decimals differ per token and per chain: USDC uses 6 on Ethereum mainnet,\nWETH uses 18, and some bridged tokens differ from their native versions.\nAlways pass the decimals value read from the token contract.\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst path = require('node:path');\nconst { formatUnits, parseUnits, normalizeAmount } = require(path.join(process.cwd(), 'src/units.js'));\nassert.equal(formatUnits(123456789012345678901234567n, 18), '123456789.012345678901234567');\nassert.equal(formatUnits('1000000', 6), '1');\nassert.equal(formatUnits(1500000n, 6), '1.5');\nassert.equal(formatUnits(0n, 18), '0');\nassert.equal(formatUnits(-1n, 18), '-0.000000000000000001');\nassert.equal(formatUnits(-1500000n, 6), '-1.5');\nassert.equal(formatUnits(5n, 0), '5');\nassert.equal(parseUnits('1.5', 6), 1500000n);\nassert.equal(parseUnits('0.000000000000000001', 18), 1n);\nassert.equal(parseUnits('123456789.012345678901234567', 18), 123456789012345678901234567n);\nassert.equal(parseUnits('-0.25', 6), -250000n);\nassert.equal(parseUnits('100', 0), 100n);\nassert.throws(() => parseUnits('1.1234567', 6), RangeError);\nfor (const bad of ['', 'abc', '1e5', '1.2.3', '0x10', ' 1']) assert.throws(() => parseUnits(bad, 6), Error, bad);\nassert.equal(normalizeAmount(1234567n, 6, 18), 1234567000000000000n);\nassert.equal(normalizeAmount(1234567890123456789n, 18, 6), 1234567n);\nassert.equal(normalizeAmount(-1234567890123456789n, 18, 6), -1234567n);\nassert.equal(normalizeAmount(42n, 8, 8), 42n);\nassert.equal(typeof normalizeAmount(1n, 6, 6), 'bigint');\n" + }, + { + "id": "inclusive-range", + "category": "no-workflow", + "manualIds": [], + "query": "range(start, end) in src/range.js is documented as inclusive of end, but it stops one short. Fix it so range(1, 5) returns [1, 2, 3, 4, 5]; when start > end it must return an empty array. Do not add dependencies.", + "files": { + "src/range.js": "'use strict';\n\n/** Returns the integers from start to end, inclusive. */\nfunction range(start, end) {\n const out = [];\n for (let i = start; i < end; i++) out.push(i);\n return out;\n}\n\nmodule.exports = { range };\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst path = require('node:path');\nconst { range } = require(path.join(process.cwd(), 'src/range.js'));\nassert.deepEqual(range(1, 5), [1, 2, 3, 4, 5]);\nassert.deepEqual(range(3, 3), [3]);\nassert.deepEqual(range(-2, 0), [-2, -1, 0]);\nassert.deepEqual(range(5, 1), []);\n" + }, + { + "id": "export-name-typo", + "category": "no-workflow", + "manualIds": [], + "query": "src/report.js crashes with \"formatDate is not a function\" because src/dates.js exports its formatter under a misspelled name. Export it as formatDate, and keep the misspelled export as an alias of the same function so older callers keep working. Do not add dependencies.", + "files": { + "src/dates.js": "'use strict';\n\nfunction formatDate(date) {\n const pad = n => String(n).padStart(2, '0');\n return date.getUTCFullYear() + '-' + pad(date.getUTCMonth() + 1) + '-' + pad(date.getUTCDate());\n}\n\nmodule.exports = { fromatDate: formatDate };\n", + "src/report.js": "'use strict';\nconst { formatDate } = require('./dates');\n\nfunction reportHeader(title, date) {\n return title + ' (' + formatDate(date) + ')';\n}\n\nmodule.exports = { reportHeader };\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst path = require('node:path');\nconst dates = require(path.join(process.cwd(), 'src/dates.js'));\nconst { reportHeader } = require(path.join(process.cwd(), 'src/report.js'));\nconst d = new Date(Date.UTC(2024, 0, 5, 12));\nassert.equal(dates.formatDate(d), '2024-01-05');\nassert.equal(dates.fromatDate, dates.formatDate);\nassert.equal(reportHeader('Weekly', d), 'Weekly (2024-01-05)');\n" + }, + { + "id": "default-greeting", + "category": "no-workflow", + "manualIds": [], + "noWorkflow": true, + "query": "Small fix, no workflow needed. greet(name) in src/greet.js returns \"Hello, undefined!\" when called without a name. Make it trim the name and fall back to \"world\" when the name is missing, null, empty or only whitespace, so greet() returns \"Hello, world!\" and greet(\" Ada \") returns \"Hello, Ada!\". Do not add dependencies.", + "files": { + "src/greet.js": "'use strict';\n\nfunction greet(name) {\n return 'Hello, ' + name + '!';\n}\n\nmodule.exports = { greet };\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst path = require('node:path');\nconst { greet } = require(path.join(process.cwd(), 'src/greet.js'));\nassert.equal(greet(), 'Hello, world!');\nassert.equal(greet(null), 'Hello, world!');\nassert.equal(greet(''), 'Hello, world!');\nassert.equal(greet(' '), 'Hello, world!');\nassert.equal(greet(' Ada '), 'Hello, Ada!');\nassert.equal(greet('Lin'), 'Hello, Lin!');\n" + }, + { + "id": "sum-form-values", + "category": "no-workflow", + "manualIds": [], + "query": "total(values) in src/total.js sums amounts typed into a form, but the inputs arrive as strings so it returns \"0123.5\" for [\"1\", \"2\", \"3.5\"]. Make it return the numeric sum (6.5 in that example). Empty strings count as 0, plain numbers must still work, and an empty array returns 0. Do not add dependencies.", + "files": { + "src/total.js": "'use strict';\n\nfunction total(values) {\n return values.reduce((sum, v) => sum + v, 0);\n}\n\nmodule.exports = { total };\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst path = require('node:path');\nconst { total } = require(path.join(process.cwd(), 'src/total.js'));\nassert.equal(total(['1', '2', '3.5']), 6.5);\nassert.equal(total([]), 0);\nassert.equal(total(['', '4']), 4);\nassert.equal(total([2, '3']), 5);\n" + }, + { + "id": "changelog-capitalize", + "category": "no-workflow", + "manualIds": [], + "query": "The security team's release-notes script imports src/changelog.js, and it crashes when a changelog entry has an empty title because capitalize(\"\") throws. Fix capitalize so an empty string returns \"\", while other strings still get only their first character uppercased with the rest unchanged. formatEntry must keep its current output format. Do not add dependencies.", + "files": { + "src/changelog.js": "'use strict';\n\nfunction capitalize(text) {\n return text[0].toUpperCase() + text.slice(1);\n}\n\nfunction formatEntry(entry) {\n return '- ' + capitalize(entry.title) + ' (' + entry.type + ')';\n}\n\nmodule.exports = { capitalize, formatEntry };\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst path = require('node:path');\nconst { capitalize, formatEntry } = require(path.join(process.cwd(), 'src/changelog.js'));\nassert.equal(capitalize(''), '');\nassert.equal(capitalize('x'), 'X');\nassert.equal(capitalize('hello World'), 'Hello World');\nassert.equal(formatEntry({ title: 'fix xss in footer', type: 'security' }), '- Fix xss in footer (security)');\nassert.equal(formatEntry({ title: '', type: 'chore' }), '- (chore)');\n" + }, + { + "id": "test-summary-plural", + "category": "no-workflow", + "manualIds": [], + "query": "Our test runner prints \"1 tests passed, 1 tests failed\". In src/summary.js, fix formatSummary(passed, failed) to use \"test\" when a count is exactly 1 and \"tests\" otherwise, e.g. \"1 test passed, 0 tests failed\". Keep the rest of the wording identical. Do not add dependencies.", + "files": { + "src/summary.js": "'use strict';\n\nfunction formatSummary(passed, failed) {\n return passed + ' tests passed, ' + failed + ' tests failed';\n}\n\nmodule.exports = { formatSummary };\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst path = require('node:path');\nconst { formatSummary } = require(path.join(process.cwd(), 'src/summary.js'));\nassert.equal(formatSummary(1, 0), '1 test passed, 0 tests failed');\nassert.equal(formatSummary(2, 1), '2 tests passed, 1 test failed');\nassert.equal(formatSummary(0, 0), '0 tests passed, 0 tests failed');\nassert.equal(formatSummary(12, 3), '12 tests passed, 3 tests failed');\n" + }, + { + "id": "database-label-typo", + "category": "no-workflow", + "manualIds": [], + "noWorkflow": true, + "query": "No workflow needed. In src/options.js the settings dropdown shows \"Databse\" for the database option; correct the label to \"Database\". Also make labelFor(value) return the value itself when no option matches, instead of throwing. Do not change the option values or their order. Do not add dependencies.", + "files": { + "src/options.js": "'use strict';\n\nconst OPTIONS = [\n { value: 'database', label: 'Databse' },\n { value: 'api', label: 'API' },\n { value: 'cache', label: 'Cache' },\n];\n\nfunction labelFor(value) {\n return OPTIONS.find(o => o.value === value).label;\n}\n\nmodule.exports = { OPTIONS, labelFor };\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst path = require('node:path');\nconst { OPTIONS, labelFor } = require(path.join(process.cwd(), 'src/options.js'));\nassert.deepEqual(OPTIONS, [{ value: 'database', label: 'Database' }, { value: 'api', label: 'API' }, { value: 'cache', label: 'Cache' }]);\nassert.equal(labelFor('database'), 'Database');\nassert.equal(labelFor('api'), 'API');\nassert.equal(labelFor('queue'), 'queue');\n" + }, + { + "id": "port-from-env", + "category": "no-workflow", + "manualIds": [], + "noWorkflow": true, + "query": "Do not select a workflow for this one-line style fix. getPort(env) in src/server-config.js returns env.PORT as a string or 3000. Make it return a number: the integer value of env.PORT when it consists only of decimal digits and is between 1 and 65535, otherwise 3000. Do not add dependencies.", + "files": { + "src/server-config.js": "'use strict';\n\nfunction getPort(env = process.env) {\n return env.PORT || 3000;\n}\n\nmodule.exports = { getPort };\n" + }, + "check": "'use strict';\nconst assert = require('node:assert/strict');\nconst path = require('node:path');\nconst { getPort } = require(path.join(process.cwd(), 'src/server-config.js'));\nassert.equal(getPort({ PORT: '8080' }), 8080);\nassert.equal(getPort({}), 3000);\nassert.equal(getPort({ PORT: '' }), 3000);\nassert.equal(getPort({ PORT: 'abc' }), 3000);\nassert.equal(getPort({ PORT: '70000' }), 3000);\nassert.equal(getPort({ PORT: '0' }), 3000);\nassert.equal(getPort({ PORT: '80.5' }), 3000);\nassert.equal(getPort({ PORT: '65535' }), 65535);\n" + } + ] +} diff --git a/docker/context-profiles/ai-eval-lib.js b/docker/context-profiles/ai-eval-lib.js new file mode 100644 index 000000000..c90793cf2 --- /dev/null +++ b/docker/context-profiles/ai-eval-lib.js @@ -0,0 +1,847 @@ +'use strict'; + +// Development-only evaluator. It lives under docker/ so the npm package never ships it. +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { isDeepStrictEqual } = require('node:util'); +const LIB = path.join(__dirname, '../../scripts/lib'); +const { loadContextRegistry } = require(path.join(LIB, 'context-pack-registry')); +const { compileContextProfile } = require(path.join(LIB, 'context-profiles')); +const { resolveTaskContext, resolveDeclinedFallback } = require(path.join(LIB, 'context-selection')); +const { proposeTaskContext } = require(path.join(LIB, 'context-profile-proposal')); +const { resolveExecutable, fingerprintExecutable } = require(path.join(LIB, 'context-profile-native-executable')); +const { launchTaskContext } = require(path.join(LIB, 'context-profile-launch')); +const { applyStore } = require(path.join(LIB, 'context-profile-store')); +const { prepareNativeProfile, getNativeProfileStatus } = require(path.join(LIB, 'context-profile-native')); +const { DEFAULT_REPO_ROOT, digestObject, createSourceReader } = require(path.join(LIB, 'context-profile-support')); +const io = require(path.join(LIB, 'context-profile-store-fs')); + +const ARMS = Object.freeze(['full', 'manual-lean', 'auto-lean', 'ecc-legacy', 'baseline']); +const CORPUS_PATH = path.join(__dirname, 'ai-corpus.json'); +const LEGACY_PIN_PATH = path.join(__dirname, 'legacy-source.json'); +const CHECK_FILE = '.ecc-eval-check.cjs'; +const IMPLEMENTATION = ['docker/context-profiles/ai-eval-lib.js', 'docker/context-profiles/ai-eval.js', + 'docker/context-profiles/legacy-source.json', + 'manifests/context-packs/skill-triggers@1.json', + 'scripts/lib/context-profile-launch.js', 'scripts/lib/context-selection.js', + 'scripts/lib/context-retrieval.js', + 'scripts/lib/context-profile-proposal.js', 'scripts/lib/context-profiles.js', + 'scripts/lib/context-profile-support.js', 'scripts/lib/context-pack-registry.js', + 'scripts/lib/context-profile-native-executable.js', 'scripts/lib/context-profile-native.js', + 'scripts/lib/context-profile-store.js', 'scripts/lib/context-profile-store-fs.js']; +const BLOCKS = Object.freeze({ excluded: /Context ID is excluded:/, + 'native-authority': /requires native authority or dynamic-content review/, + 'manual-only': /Context ID is manual-only:/, 'opt-out-conflict': /noWorkflow conflicts/, 'unknown-id': /Unknown context ID:/ }); +const ENV_KEYS = ['PATH', 'HOME', 'USERPROFILE', 'CODEX_HOME', 'TMPDIR', 'LANG', 'SystemRoot']; +const CLAUDE_ENV_KEYS = ['PATH', 'HOME', 'USERPROFILE', 'CLAUDE_CONFIG_DIR', 'TMPDIR', 'LANG', 'SystemRoot']; +const bounded = (value, min, max) => Number.isSafeInteger(value) && value >= min && value <= max; +const exists = file => Boolean(fs.lstatSync(file, { throwIfNoEntry: false })); + +function loadCorpus(file = CORPUS_PATH) { return JSON.parse(fs.readFileSync(file, 'utf8')); } + +function safeRelative(file) { + return typeof file === 'string' && file.length > 0 && file.length <= 200 && !path.isAbsolute(file) + && !file.startsWith('.') && !file.includes('\\') && file.split('/').every(part => part && part !== '..' && part !== '.'); +} + +function validateCorpus(corpus) { + if (corpus?.schemaVersion === 'ecc.context-eval-complex-corpus.v1') return validateComplexCorpus(corpus); + if (corpus?.schemaVersion !== 'ecc.context-eval-corpus.v2' + || !Array.isArray(corpus.selection) || !Array.isArray(corpus.tasks) + || !bounded(corpus.selection.length, 1, 200) || !bounded(corpus.tasks.length, 1, 200) + || corpus.minimumDistinctTasks !== 30 || corpus.nonInferiorityMargin !== 0.05) { + throw new Error('Invalid preregistered corpus'); + } + for (const cases of [corpus.selection, corpus.tasks]) validateCorpusIds(cases); + for (const task of corpus.tasks) { + const files = Object.entries(task.files || {}); + if (!Array.isArray(task.manualIds) || task.manualIds.length > 1 || !bounded(files.length, 1, 8) + || files.some(([file, content]) => !safeRelative(file) || typeof content !== 'string' || Buffer.byteLength(content) > 16384) + || typeof task.check !== 'string' || !bounded(Buffer.byteLength(task.check), 1, 16384)) { + throw new Error('Invalid corpus task'); + } + } +} + +function validateCorpusIds(cases) { + if (new Set(cases.map(c => c.id)).size !== cases.length) throw new Error('Duplicate corpus ID'); + for (const item of cases) { + if (!/^[a-z][a-z0-9-]{0,63}$/.test(item.id) || typeof item.query !== 'string' + || !bounded(Buffer.byteLength(item.query), 1, 8192)) throw new Error('Invalid corpus case'); + } +} + +// Complex corpora hold a few realistic multi-file tasks with scored hidden graders. Sample gates +// are descriptive at this size, so the distinct-task minimum relaxes to the corpus itself. +function validateComplexCorpus(corpus) { + if (!Array.isArray(corpus.selection) || !Array.isArray(corpus.tasks) + || !bounded(corpus.selection.length, 0, 50) || !bounded(corpus.tasks.length, 1, 10) + || corpus.minimumDistinctTasks !== corpus.tasks.length || corpus.nonInferiorityMargin !== 0.05) { + throw new Error('Invalid preregistered corpus'); + } + validateCorpusIds(corpus.selection); + if (new Set(corpus.tasks.map(c => c.id)).size !== corpus.tasks.length) throw new Error('Duplicate corpus ID'); + for (const task of corpus.tasks) { + if (!/^[a-z][a-z0-9-]{0,63}$/.test(task.id)) throw new Error('Invalid corpus case'); + if (task.steps === undefined + && (typeof task.query !== 'string' || !bounded(Buffer.byteLength(task.query), 1, 8192))) throw new Error('Invalid corpus case'); + const files = Object.entries(task.files || {}); + if (!Array.isArray(task.manualIds) || task.manualIds.length > 3 || !bounded(files.length, 1, 24) + || files.some(([file, content]) => !safeRelative(file) || typeof content !== 'string' || Buffer.byteLength(content) > 65536)) { + throw new Error('Invalid corpus task'); + } + if (task.steps !== undefined) { + // Stepped (chained) task: sequential tickets graded in one accumulating workspace. + if (!Array.isArray(task.steps) || !bounded(task.steps.length, 2, 8) + || task.steps.some(step => typeof step.query !== 'string' || !bounded(Buffer.byteLength(step.query), 1, 8192) + || typeof step.check !== 'string' || !bounded(Buffer.byteLength(step.check), 1, 65536) + || (step.checkTimeoutMs !== undefined && !bounded(step.checkTimeoutMs, 1, 120000)) + || (step.manualIds !== undefined && (!Array.isArray(step.manualIds) || step.manualIds.length > 3)))) { + throw new Error('Invalid corpus task'); + } + } else if (typeof task.check !== 'string' || !bounded(Buffer.byteLength(task.check), 1, 65536) + || (task.checkTimeoutMs !== undefined && !bounded(task.checkTimeoutMs, 1, 120000))) { + throw new Error('Invalid corpus task'); + } + } +} + +function sourceSnapshot(repoRoot) { + const registry = loadContextRegistry({ repoRoot }); + const profiles = ['full@1', 'lean@1'].map(profileId => compileContextProfile({ repoRoot, profileId })); + // Implementation modules are loaded from this evaluator's checkout; repoRoot may be a fixture registry. + const reader = createSourceReader(DEFAULT_REPO_ROOT); + const implementation = IMPLEMENTATION.map(file => ({ path: file, digest: reader.read(file).digest })); + const packageJson = JSON.parse(reader.read('package.json').content.toString('utf8')); + const runtime = { node: process.versions.node, dependencies: { + ajv: packageJson.dependencies.ajv, 'js-yaml': packageJson.dependencies['js-yaml'] } }; + return { registry, profiles, sourceDigest: digestObject({ registryDigest: registry.registryDigest, + planDigests: profiles.map(p => p.planDigest), implementation, runtime }), runtime }; +} + +const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultra']; + +function providerFamily(executable) { + const base = path.basename(String(executable || '')).toLowerCase(); + if (base.includes('claude')) return 'claude'; + if (base.includes('codex')) return 'codex'; + throw new Error('Provider executable must name a Claude or Codex CLI'); +} + +function resolveFamily(provider, executable) { + if (provider !== undefined && provider !== null) { + if (!['claude', 'codex'].includes(provider)) throw new Error('Provider must be claude or codex'); + return provider; + } + if (executable) return providerFamily(executable); + return 'codex'; +} + +function providerPin(model, executable, effort) { + if (model === undefined && executable === undefined && effort === undefined) return null; + if (typeof model !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,99}$/.test(model) + || !path.isAbsolute(executable || '')) throw new Error('Provider pin requires model and absolute executable'); + if (effort !== undefined && !EFFORTS.includes(effort)) throw new Error('Invalid reasoning effort'); + return { modelDigest: digestObject(model), executableDigest: resolveExecutable(executable).digest, + ...(effort === undefined ? {} : { effort }) }; +} + +function preregister({ repoRoot = DEFAULT_REPO_ROOT, corpus = loadCorpus(), repeats = 1, model, executable, effort, arms } = {}) { + validateCorpus(corpus); + if (!bounded(repeats, 1, 20)) throw new Error('Invalid repeat count'); + const armList = arms === undefined ? [...ARMS] : arms; + if (!Array.isArray(armList) || !armList.length || new Set(armList).size !== armList.length + || armList.some(arm => !ARMS.includes(arm))) throw new Error('Invalid arm subset'); + const source = sourceSnapshot(repoRoot); + const value = { schemaVersion: 'ecc.context-eval-registration.v2', corpusDigest: digestObject(corpus), + sourceDigest: source.sourceDigest, registryDigest: source.registry.registryDigest, + providerPin: providerPin(model, executable, effort), runtime: source.runtime, + arms: armList, repeats, minimumDistinctTasks: corpus.minimumDistinctTasks, nonInferiorityMargin: 0.05, + confidence: 0.95, sampling: 'fixed-purposive-pilot', + design: corpus.schemaVersion === 'ecc.context-eval-complex-corpus.v1' + ? 'paired-native-installs-hidden-scored-complex-tasks' + : 'paired-native-installs-hidden-graded-coding-tasks', + order: corpus.tasks.flatMap((task, index) => Array.from({ length: repeats }, (_, repeat) => ({ + id: task.id, repeat, arms: armList.map((_, offset) => armList[(index + repeat + offset) % armList.length]), + }))), selectionIds: corpus.selection.map(c => c.id) }; + return { ...value, registrationDigest: digestObject(value) }; +} + +// Parse in memory only. No event objects, paths, provider messages or error text enter reports. +function parseCodexJsonl(stdout) { + const invalid = { valid: false, text: '', usage: null }; + if (typeof stdout !== 'string' || Buffer.byteLength(stdout) > 1024 * 1024) return invalid; + let text = ''; + let completions = 0; + let usage = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }; + try { + for (const line of stdout.split('\n').filter(line => line.trim())) { + const event = JSON.parse(line); + if (!event || typeof event !== 'object' || ['error', 'turn.failed'].includes(event.type)) return invalid; + if (event.type === 'item.completed' && event.item?.type === 'agent_message') { + if (typeof event.item.text !== 'string') return invalid; + text = event.item.text; + } + if (event.type !== 'turn.completed') continue; + const u = event.usage; + if (!u || ![u.input_tokens, u.cached_input_tokens, u.output_tokens].every(v => bounded(v, 0, 1e9)) + || u.cached_input_tokens > u.input_tokens) return invalid; + completions++; + usage = { inputTokens: usage.inputTokens + u.input_tokens, + cachedInputTokens: usage.cachedInputTokens + u.cached_input_tokens, + outputTokens: usage.outputTokens + u.output_tokens }; + } + } catch { return invalid; } + return completions === 1 ? { valid: true, text, usage } : invalid; +} + +// Claude print-mode emits exactly one result JSON object. Fresh input folds cache creations; +// cache reads are reported separately. is_error results are provider failures, not parse failures. +function parseClaudeJson(stdout) { + const invalid = { valid: false, text: '', usage: null }; + if (typeof stdout !== 'string' || Buffer.byteLength(stdout) > 1024 * 1024) return invalid; + let result = null; + let results = 0; + try { + for (const line of stdout.split('\n').filter(line => line.trim())) { + const event = JSON.parse(line); + if (!event || typeof event !== 'object' || Array.isArray(event)) return invalid; + if (event.type !== 'result') continue; + results++; + result = event; + } + } catch { return invalid; } + if (results !== 1) return invalid; + if (result.is_error !== false || typeof result.result !== 'string') return { ...invalid, error: true }; + const u = result.usage; + if (!u || ![u.input_tokens, u.cache_creation_input_tokens, u.cache_read_input_tokens, u.output_tokens] + .every(value => bounded(value, 0, 1e9))) return { ...invalid, error: true }; + return { valid: true, text: result.result, + usage: { inputTokens: u.input_tokens + u.cache_creation_input_tokens, + cachedInputTokens: u.cache_read_input_tokens, outputTokens: u.output_tokens } }; +} + +function privateEntry(file, directory) { + const stat = fs.lstatSync(file, { throwIfNoEntry: false }); + return Boolean(stat) && !stat.isSymbolicLink() && (directory ? stat.isDirectory() : stat.isFile()) + && (process.platform === 'win32' || ((stat.mode & 0o077) === 0 && (!process.getuid || stat.uid === process.getuid()))); +} + +/** + * Subscription credentials stay in a dedicated evaluator login home. Each call leases auth.json into the + * isolated CODEX_HOME, returns refreshed tokens afterwards and always removes the leased copy. + */ +function createAuthLease(authHome) { + if (typeof authHome !== 'string' || !path.isAbsolute(authHome)) throw new Error('Auth home must be an absolute path'); + const real = fs.realpathSync(authHome); + const forbidden = [path.join(os.homedir(), '.codex'), process.env.CODEX_HOME].filter(Boolean) + .map(file => (exists(file) ? fs.realpathSync(file) : path.resolve(file))); + if (forbidden.includes(real)) throw new Error('Auth home must be a dedicated evaluator login home, not your Codex home'); + const source = path.join(real, 'auth.json'); + if (!privateEntry(real, true) || !privateEntry(source, false)) { + throw new Error('Auth home must be a private directory containing a private auth.json; see the evaluation guide'); + } + return { + mode: 'subscription-lease', + run(codexHome, work) { + const leased = path.join(codexHome, 'auth.json'); + const original = fs.readFileSync(source); + fs.writeFileSync(leased, original, { flag: 'wx', mode: 0o600 }); + try { return work(); } finally { + try { + const after = fs.readFileSync(leased); + if (!after.equals(original)) { + JSON.parse(after.toString('utf8')); + const temp = `${source}.${process.pid}.tmp`; + try { + fs.writeFileSync(temp, after, { flag: 'wx', mode: 0o600 }); + fs.renameSync(temp, source); + } finally { fs.rmSync(temp, { force: true }); } + } + } catch { /* An unreadable refresh keeps the previous login; the next call reports any auth failure. */ } + fs.rmSync(leased, { force: true }); + } + }, + }; +} + +/** + * Claude subscription logins live in the macOS Keychain as a JSON wrapper. The lease reads the + * current access token per call into the child environment only; it is never persisted or reported. + */ +function readClaudeKeychainToken() { + if (process.platform !== 'darwin') throw new Error('Claude Keychain login requires macOS; provide CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY'); + const result = spawnSync('security', ['find-generic-password', '-s', 'Claude Code-credentials', '-w'], + { encoding: 'utf8', shell: false, timeout: 15000, killSignal: 'SIGKILL', maxBuffer: 65536 }); + if (result.status !== 0 || result.error) throw new Error('Claude Keychain login is unavailable; provide CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY'); + let parsed; + try { parsed = JSON.parse(result.stdout); } + catch { throw new Error('Claude Keychain login is unreadable; provide CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY'); } + const token = parsed?.claudeAiOauth?.accessToken; + if (typeof token !== 'string' || !token) throw new Error('Claude Keychain login is unrecognized; provide CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY'); + return token; +} + +function createClaudeProvider({ allowRealProvider = false, allowCredentialedTools = false, executable, model, + apiKey = process.env.ANTHROPIC_API_KEY, oauthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN, + tokenSource = readClaudeKeychainToken, persistSessions = false, execute = spawnSync } = {}) { + if (allowRealProvider !== true) throw new Error('Real provider requires explicit opt-in'); + if (!model || !executable) throw new Error('Real provider requires a model and absolute executable'); + let lease = null; + let authentication; + if (oauthToken) authentication = 'oauth-env'; + else if (apiKey) authentication = 'api-key'; + else if (typeof tokenSource === 'function') { + lease = { mode: 'subscription-keychain-lease', + run(env, work) { env.CLAUDE_CODE_OAUTH_TOKEN = tokenSource(); return work(); } }; + authentication = lease.mode; + } else throw new Error('Real provider requires CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, or the Claude Keychain login'); + const pin = providerPin(model, executable, undefined); + const binary = resolveExecutable(executable); + const provider = request => { + if (fingerprintExecutable(binary.path).digest !== pin.executableDigest) fail('source-drift'); + const selection = request.phase === 'selection'; + if (!selection && !allowCredentialedTools) { + throw new Error('Claude task tools can read provider credentials; explicit credentialed-tool opt-in is required'); + } + // Selection is tool-free and read-only; task execution may edit and run commands in the workspace. + // Claude has no cwd-write sandbox flag, so containment relies on the isolated home and temp workspace. + const args = ['--print', '--output-format', 'json', + ...(persistSessions ? [] : ['--no-session-persistence']), + ...(selection ? ['--tools', ''] : ['--permission-mode', 'bypassPermissions']), + '--model', model]; + const env = Object.fromEntries(CLAUDE_ENV_KEYS.filter(key => typeof request.env?.[key] === 'string') + .map(key => [key, request.env[key]])); + env.DISABLE_NON_ESSENTIAL_MODEL_CALLS = '1'; + if (authentication === 'oauth-env') env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken; + if (authentication === 'api-key') env.ANTHROPIC_API_KEY = apiKey; + const call = () => execute(binary.path, args, { input: request.input, cwd: request.cwd, env, + encoding: 'utf8', shell: false, timeout: request.timeoutMs, killSignal: 'SIGKILL', + maxBuffer: request.maxBuffer }); + return lease ? lease.run(env, call) : call(); + }; + provider.authentication = authentication; + return provider; +} + +function createCodexProvider({ allowRealProvider = false, executable, model, effort, authHome, + apiKey = process.env.CODEX_API_KEY, execute = spawnSync } = {}) { + if (allowRealProvider !== true) throw new Error('Real provider requires explicit opt-in'); + if (!model || !executable) throw new Error('Real provider requires a model and absolute executable'); + if (!authHome && !apiKey) throw new Error('Real provider requires --auth-home (subscription login) or CODEX_API_KEY'); + const lease = authHome ? createAuthLease(authHome) : null; + const pin = providerPin(model, executable, effort); + const binary = resolveExecutable(executable); + const provider = request => { + if (fingerprintExecutable(binary.path).digest !== pin.executableDigest) fail('source-drift'); + const args = ['exec', '--json', '--ephemeral', '--skip-git-repo-check', + '--sandbox', request.phase === 'selection' ? 'read-only' : 'workspace-write', + // Connected ChatGPT apps and account plugin installs stay out of every arm. + '--disable', 'apps', '--disable', 'remote_plugin', + '-c', 'approval_policy="never"', ...(effort ? ['-c', `model_reasoning_effort="${effort}"`] : []), + '--model', model, '-']; + const env = Object.fromEntries(ENV_KEYS.filter(key => typeof request.env?.[key] === 'string') + .map(key => [key, request.env[key]])); + if (!lease) env.CODEX_API_KEY = apiKey; + const call = () => execute(binary.path, args, { input: request.input, cwd: request.cwd, env, + encoding: 'utf8', shell: false, timeout: request.timeoutMs, killSignal: 'SIGKILL', + maxBuffer: request.maxBuffer }); + return lease ? lease.run(env.CODEX_HOME, call) : call(); + }; + provider.authentication = lease ? lease.mode : 'api-key'; + return provider; +} + +/** Real Lean and Full installs, prepared through the same isolated native adapter users get. */ +function prepareEnvironments({ repoRoot, executable, root }) { + const binary = resolveExecutable(executable); + const environments = {}; + for (const [name, profileId, selectionMode] of [['full', 'full@1', 'manual'], ['lean', 'lean@1', 'auto']]) { + const options = { stateRoot: path.join(root, name, 'managed'), nativeRoot: path.join(root, name, 'native') }; + fs.mkdirSync(path.join(root, name), { mode: 0o700 }); + applyStore({ repoRoot, stateRoot: options.stateRoot, target: 'codex', selectionMode, profileId }); + const status = prepareNativeProfile({ ...options, codexPath: executable }); + if (!status.ready) throw new Error(`Native ${name} install is not ready`); + // A signed-in Codex records task-directory trust in config.toml and downloads account-provided + // plugins into plugins/. Restoring the prepared state after every call keeps trials identical; + // any other change still fails verification as drift. + const config = path.join(status.codexHome, 'config.toml'); + const prepared = fs.readFileSync(config); + const plugins = path.join(status.codexHome, 'plugins'); + const listing = directory => (exists(directory) ? fs.readdirSync(directory) : []); + const preparedPlugins = new Set(listing(plugins)); + const preparedCache = new Set(listing(path.join(plugins, 'cache'))); + environments[name] = { profileId, skills: status.selectedIds.length, + launch: { home: status.home, codexHome: status.codexHome, codexPath: status.codexPath, + executableDigest: status.executableDigest }, + restore() { + fs.writeFileSync(config, prepared); + for (const entry of listing(plugins)) if (!preparedPlugins.has(entry)) fs.rmSync(path.join(plugins, entry), { recursive: true, force: true }); + for (const entry of listing(path.join(plugins, 'cache'))) { + if (!preparedCache.has(entry)) fs.rmSync(path.join(plugins, 'cache', entry), { recursive: true, force: true }); + } + }, + verify() { + let ready = false; + try { ready = getNativeProfileStatus(options).ready; } catch { ready = false; } + if (!ready) fail('environment-drift'); + } }; + } + // Baseline arm: an empty native home with no ECC install, for provider-overhead subtraction. + const home = path.join(root, 'baseline', 'home'); + fs.mkdirSync(path.join(home, '.codex'), { recursive: true, mode: 0o700 }); + environments.baseline = { profileId: null, skills: 0, restore() {}, + launch: { home, codexHome: path.join(home, '.codex'), codexPath: binary.path, executableDigest: binary.digest }, + verify() { if (fingerprintExecutable(binary.path).digest !== binary.digest) fail('environment-drift'); } }; + return environments; +} + +function installClaudeSkills({ payload, home }) { + const config = path.join(home, '.claude'); + const installed = path.join(config, 'skills'); + fs.mkdirSync(installed, { recursive: true, mode: 0o700 }); + for (const entry of fs.readdirSync(payload)) { + fs.cpSync(path.join(payload, entry), path.join(installed, entry), { recursive: true, errorOnExist: true, force: false }); + } + return { config, installed }; +} + +function claudeEnvironment({ name, binary, home, config, installed, profileId, skills, sourceSha = null }) { + const managed = () => digestObject(io.inventory(installed)); + const prepared = managed(); + return [name, { profileId, skills, sourceSha, + launch: { home, claudeConfigDir: config, claudePath: binary.path, executableDigest: binary.digest }, + restore() {}, + verify() { + if (fingerprintExecutable(binary.path).digest !== binary.digest) fail('environment-drift'); + let observed = null; + try { observed = managed(); } catch { observed = null; } + if (observed !== prepared) fail('environment-drift'); + } }]; +} + +/** The pre-scoping ECC source, pinned by commit so the ecc-legacy arm is reproducible. */ +function exportLegacySource({ repoRoot = DEFAULT_REPO_ROOT, destination, + pin = JSON.parse(fs.readFileSync(LEGACY_PIN_PATH, 'utf8')) } = {}) { + if (!/^[a-f0-9]{40}$/.test(pin?.sha || '')) throw new Error('Invalid legacy source pin'); + if (!path.isAbsolute(destination || '')) throw new Error('Legacy destination must be absolute'); + const resolved = spawnSync('git', ['-C', repoRoot, 'rev-parse', '--verify', `${pin.sha}^{commit}`], + { encoding: 'utf8', shell: false, timeout: 30000, killSignal: 'SIGKILL' }); + if (resolved.status !== 0 || resolved.error || resolved.stdout.trim() !== pin.sha) { + throw new Error('Legacy source pin is unavailable in this repository'); + } + fs.mkdirSync(destination, { recursive: true, mode: 0o700 }); + const tar = path.join(destination, 'legacy.tar'); + const archive = spawnSync('git', ['-C', repoRoot, 'archive', '--format=tar', '-o', tar, pin.sha, 'skills'], + { encoding: 'utf8', shell: false, timeout: 60000, killSignal: 'SIGKILL' }); + const extract = archive.status === 0 && !archive.error + ? spawnSync('tar', ['-xf', tar, '-C', destination], { encoding: 'utf8', shell: false, timeout: 60000, killSignal: 'SIGKILL' }) + : archive; + fs.rmSync(tar, { force: true }); + const payload = path.join(destination, 'skills'); + if (extract.status !== 0 || extract.error || !exists(payload) || !fs.readdirSync(payload).length) { + throw new Error('Legacy source export failed'); + } + return { root: destination, sha: pin.sha }; +} + +/** Real Claude installs in isolated config homes. Managed-skill drift aborts; there is no + * provider bookkeeping to restore because isolated Claude runs do not mutate the managed tree. */ +function prepareClaudeEnvironments({ repoRoot, executable, root, legacySource = null }) { + const binary = resolveExecutable(executable); + const environments = {}; + for (const [name, profileId, selectionMode] of [['full', 'full@1', 'manual'], ['lean', 'lean@1', 'auto']]) { + const stateRoot = path.join(root, name, 'managed'); + fs.mkdirSync(path.join(root, name), { mode: 0o700 }); + const status = applyStore({ repoRoot, stateRoot, target: 'claude', selectionMode, profileId }); + const home = path.join(root, name, 'home'); + const { config, installed } = installClaudeSkills({ payload: path.join(status.generationRoot, 'skills'), home }); + const [key, env] = claudeEnvironment({ name, binary, home, config, installed, profileId, skills: status.selectedIds.length }); + environments[key] = env; + } + if (legacySource) { + // ecc-legacy: the typical pre-scoping install — the full skill library from the pinned + // pre-ECC-029 commit, launched bare with no ECC context block. + const home = path.join(root, 'ecc-legacy', 'home'); + const { config, installed } = installClaudeSkills({ payload: path.join(legacySource.root, 'skills'), home }); + const [key, env] = claudeEnvironment({ name: 'ecc-legacy', binary, home, config, installed, + profileId: null, skills: fs.readdirSync(installed).length, sourceSha: legacySource.sha }); + environments[key] = env; + } + // Baseline arm: an empty config home with no ECC install, for provider-overhead subtraction. + const baselineHome = path.join(root, 'baseline', 'home'); + const baselineConfig = path.join(baselineHome, '.claude'); + fs.mkdirSync(baselineConfig, { recursive: true, mode: 0o700 }); + environments.baseline = { profileId: null, skills: 0, sourceSha: null, restore() {}, + launch: { home: baselineHome, claudeConfigDir: baselineConfig, claudePath: binary.path, executableDigest: binary.digest }, + verify() { if (fingerprintExecutable(binary.path).digest !== binary.digest) fail('environment-drift'); } }; + return environments; +} + +function syntheticEnvironments(root) { + const executable = resolveExecutable(process.execPath); + return Object.fromEntries(['full', 'lean', 'ecc-legacy', 'baseline'].map(name => { + const home = path.join(root, name, 'home'); + fs.mkdirSync(path.join(home, '.codex'), { recursive: true, mode: 0o700 }); + return [name, { profileId: ['baseline', 'ecc-legacy'].includes(name) ? null : `${name}@1`, skills: null, sourceSha: null, + verify() {}, restore() {}, + launch: { home, codexHome: path.join(home, '.codex'), codexPath: executable.path, executableDigest: executable.digest } }]; + })); +} + +function checkArguments(cwd, file = CHECK_FILE, writable = false) { + const major = Number(process.versions.node.split('.')[0]); + const flag = major >= 22 ? '--permission' : major >= 20 ? '--experimental-permission' : null; + // A directory grant covers its children. Node 20.20.2 can abort in its native + // permission radix tree when the same directory is also granted as "cwd/*". + return flag ? [flag, `--allow-fs-read=${cwd}`, + // Stepped graders exercise stateful apps (persistence); single-step graders stay read-only. + ...(writable ? [`--allow-fs-write=${cwd}`] : []), file] : [file]; +} + +// The hidden grader enters the workspace only after the agent exits, and runs read-only where Node supports it. +// A grader may print one `ECC_EVAL_SCORE {"score":0..1}` line for partial credit; without it the exit +// status alone decides (exit 0 scores 1). Outcome success still requires a full score. Stepped tasks +// grade each step with a distinct grader file so earlier graders stay readable in the workspace. +const SCORE_LINE = /^\s*ECC_EVAL_SCORE\s+(\{[^\n]*\})\s*$/m; +function runScoredCheck(cwd, source, timeoutMs = 10000, step = null) { + const name = step === null ? CHECK_FILE : `.ecc-eval-check-${step}.cjs`; + const file = path.join(cwd, name); + if (exists(file)) return { passed: false, score: 0 }; + fs.writeFileSync(file, source, { flag: 'wx' }); + const result = spawnSync(process.execPath, checkArguments(fs.realpathSync(cwd), name, step !== null), { cwd, encoding: 'utf8', + env: { LANG: 'C.UTF-8' }, shell: false, timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: 65536 }); + // Grader files never linger: in stepped tasks the workspace accumulates, and a later ticket's + // agent could read or replay an earlier grader. The planted-grader guard above still applies. + fs.rmSync(file, { force: true }); + const passed = result.status === 0 && !result.error; + let score = passed ? 1 : 0; + const match = SCORE_LINE.exec(result.stdout || ''); + // A grader that advertises ECC_EVAL_SCORE but never printed it died mid-run (e.g. the graded + // server crashed the process): that is a zero, never a silent pass. A printed but malformed + // line keeps the exit-status score. + const graderDied = passed && !match && source.includes('ECC_EVAL_SCORE') + && !(result.stdout || '').includes('ECC_EVAL_SCORE'); + if (passed && match) { + try { + const parsed = JSON.parse(match[1]); + if (typeof parsed?.score === 'number' && parsed.score >= 0 && parsed.score <= 1) score = parsed.score; + } catch { /* A malformed score line keeps the exit-status score. */ } + } + if (graderDied) score = 0; + return { passed, score }; +} + +function runCheck(cwd, source) { return runScoredCheck(cwd, source).passed; } + +function writeWorkspace(cwd, files) { + for (const [relative, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(cwd, relative)), { recursive: true }); + fs.writeFileSync(path.join(cwd, relative), content, { flag: 'wx' }); + } +} + +function wilson(successes, n) { + if (!n) return [0, 1]; + const z = 1.959963984540054; + const p = successes / n; + const denominator = 1 + z * z / n; + const center = (p + z * z / (2 * n)) / denominator; + const radius = z * Math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denominator; + return [Math.max(0, center - radius), Math.min(1, center + radius)]; +} + +function summarize(outcomes, arms = ARMS) { + const ids = [...new Set(outcomes.map(row => row.id))]; + // Reference arm: full when present (all-arms runs), otherwise the last registered arm (baseline in subset runs). + const reference = arms.includes('full') ? 'full' : arms[arms.length - 1]; + const rates = arms.map(arm => { + const rows = outcomes.filter(row => row.arm === arm); + return { arm, attempts: rows.length, successes: rows.filter(row => row.passed).length, + rate: rows.length ? rows.filter(row => row.passed).length / rows.length : null, + meanScore: rows.length ? rows.reduce((sum, row) => sum + (typeof row.score === 'number' ? row.score : Number(row.passed)), 0) / rows.length : null }; + }); + const pairs = arms.filter(arm => arm !== reference).map(arm => { + const differences = ids.map(id => { + const rows = outcomes.filter(row => row.id === id); + const baseline = rows.filter(row => row.arm === reference); + const delta = baseline.map(row => Number(rows.find(r => r.arm === arm && r.repeat === row.repeat)?.passed === true) + - Number(row.passed === true)); + return delta.length ? delta.reduce((a, b) => a + b, 0) / delta.length : null; + }).filter(value => value !== null); + const n = differences.length; + const delta = n ? differences.reduce((a, b) => a + b, 0) / n : null; + // Paired task-cluster means in [-1,1]. Hoeffding with Bonferroni for the arm comparisons. + const radius = n ? Math.sqrt(2 * Math.log(80) / n) : 2; + return { arm, reference, n, delta, interval: [Math.max(-1, (delta || 0) - radius), Math.min(1, (delta || 0) + radius)], + method: 'paired-task-cluster-hoeffding-familywise-95' }; + }); + return { distinctTasks: ids.length, rates, pairs }; +} + +function selectionTask(item) { + return { sessionId: 'ecc-eval', taskId: item.id, revision: 1, phase: 'evaluate', query: item.query, + ...(item.noWorkflow === undefined ? {} : { noWorkflow: item.noWorkflow }), + ...(item.explicitIds ? { explicitIds: item.explicitIds } : {}) }; +} + +function failureCode(error) { + if (['call-budget', 'deadline', 'source-drift', 'environment-drift', 'provider-failed', 'invalid-jsonl'].includes(error?.code)) return error.code; + for (const [code, pattern] of Object.entries(BLOCKS)) if (pattern.test(error?.message || '')) return code; + return 'evaluation-failed'; +} +function fail(code) { const error = new Error(code); error.code = code; throw error; } + +function launchEnvironment(launch) { + return { PATH: process.env.PATH, HOME: launch.home, + ...(launch.codexHome ? { CODEX_HOME: launch.codexHome } : {}), + ...(launch.claudeConfigDir ? { CLAUDE_CONFIG_DIR: launch.claudeConfigDir } : {}), + TMPDIR: launch.home, LANG: 'C.UTF-8' }; +} + +function executeAdapter(state, cwd, environment) { + return (_command, args, options) => { + if (state.calls >= state.maxCalls) fail('call-budget'); + state.assertCurrent(); + environment.verify(); + const remaining = state.deadline - Date.now(); + if (remaining <= 0) fail('deadline'); + const phase = options.phase || (args.includes('read-only') ? 'selection' : 'task'); + state.calls++; + const started = Date.now(); + let raw; + // Coding tasks outgrow the launcher's interactive default, so the evaluator's own call bound governs them. + const timeoutMs = Math.min(phase === 'task' ? state.callTimeoutMs : options.timeout, state.callTimeoutMs, remaining); + const env = options.env || launchEnvironment(environment.launch); + try { + raw = state.provider({ phase, input: options.input, cwd, env, timeoutMs, maxBuffer: 1024 * 1024 }); + } catch (error) { + state.metrics.push({ phase, elapsedMs: Date.now() - started, usage: null }); + if (error?.code === 'source-drift') throw error; + fail('provider-failed'); + } finally { environment.restore(); } + const elapsedMs = Date.now() - started; + const parsed = state.family === 'claude' ? parseClaudeJson(raw?.stdout) : parseCodexJsonl(raw?.stdout); + state.metrics.push({ phase, elapsedMs, usage: parsed.valid && raw?.status === 0 && !raw?.error ? parsed.usage : null }); + if (Date.now() >= state.deadline || elapsedMs > timeoutMs) fail('deadline'); + state.assertCurrent(); + if (raw?.status !== 0 || raw?.error) fail('provider-failed'); + if (!parsed.valid) fail(parsed.error ? 'provider-failed' : 'invalid-jsonl'); + return { status: 0, stdout: parsed.text }; + }; +} + +function selectionProbe(item, repoRoot, execute, environment, target) { + const options = { repoRoot, task: selectionTask(item), exclude: item.exclude || [], load: true }; + try { + let selection = resolveTaskContext(options); + if (selection.reason === 'agent-selection-required') { + const proposedIds = proposeTaskContext({ target, query: item.query, candidates: selection.candidates, execute, + executable: environment.launch.codexPath || environment.launch.claudePath }); + // An empty proposal is an explicit decline: honor it (inject nothing). + // The tier-2 fallback only applies when a non-empty proposal admitted + // nothing — never to override a decline. + const declined = proposedIds.length === 0; + const next = resolveTaskContext({ ...options, task: { ...options.task, proposedIds, noWorkflow: declined } }); + if (next.selectedIds.length) selection = next; + else if (declined) selection = { ...next, reason: 'agent-declined-selection' }; + else selection = resolveDeclinedFallback(options, selection); + } + return { id: item.id, category: item.category, passed: !item.expectedBlock + && isDeepStrictEqual(selection.selectedIds, item.expectedIds), selectedIds: selection.selectedIds, failure: null }; + } catch (error) { + const failure = failureCode(error); + return { id: item.id, category: item.category, passed: Boolean(item.expectedBlock && failure === item.expectedBlock), + selectedIds: [], failure }; + } +} + +// Full relies on native discovery of the whole install; the Lean arms receive ECC-selected skill bodies; +// ecc-legacy runs bare against the pinned pre-scoping skill library; Baseline runs the bare task query. +// Stepped tasks run each ticket in the same accumulating workspace, grading after every step. +function outcomeTrial(item, arm, repeat, repoRoot, execute, cwd, environment, target, harvest, metrics = null) { + const launchStep = (query, manualIds) => { + const task = { sessionId: 'ecc-eval', taskId: item.id, revision: 1, phase: 'evaluate', query }; + return launchTaskContext({ repoRoot, execute, nativeEnvironment: environment.launch, target, + bare: arm === 'baseline' || arm === 'ecc-legacy', + task: { ...task, ...(arm === 'manual-lean' && manualIds?.length ? { explicitIds: manualIds } : {}) }, + profileId: arm === 'full' ? 'full@1' : 'lean@1', selectionMode: arm === 'auto-lean' ? 'auto' : 'manual' }); + }; + try { + if (!item.steps) { + const result = launchStep(item.query, item.manualIds); + if (harvest) harvest(arm, item.id, repeat, environment); + const verdict = runScoredCheck(cwd, item.check, item.checkTimeoutMs); + const passed = result.status === 'completed' && verdict.passed && verdict.score >= 0.999; + return { id: item.id, arm, repeat, passed, score: result.status === 'completed' ? verdict.score : 0, + selectedIds: result.selection.selectedIds, failure: passed ? null : 'hidden-check' }; + } + const steps = []; + const selectedIds = []; + for (let index = 0; index < item.steps.length; index++) { + const step = item.steps[index]; + const start = metrics ? metrics.length : 0; + const result = launchStep(step.query, step.manualIds || item.manualIds); + if (harvest) harvest(arm, `${item.id}--step${index + 1}`, repeat, environment); + if (result.status !== 'completed') { + // A failed ticket ends the chain; remaining tickets are unscored. + steps.push({ score: 0, ...(metrics ? metricsSince(metrics, start) : {}) }); + for (let rest = index + 1; rest < item.steps.length; rest++) { + steps.push({ score: 0, ...(metrics ? metricsSince(metrics, metrics.length) : {}) }); + } + break; + } + selectedIds.push(...result.selection.selectedIds); + const verdict = runScoredCheck(cwd, step.check, step.checkTimeoutMs, index + 1); + steps.push({ score: verdict.passed ? verdict.score : 0, ...(metrics ? metricsSince(metrics, start) : {}) }); + } + const score = steps.reduce((sum, step) => sum + step.score, 0) / item.steps.length; + const passed = steps.length === item.steps.length && steps.every(step => step.score >= 0.999); + return { id: item.id, arm, repeat, passed, score, selectedIds: [...new Set(selectedIds)], steps, + failure: passed ? null : 'hidden-check' }; + } catch (error) { + if (harvest) harvest(arm, item.id, repeat, environment); + return { id: item.id, arm, repeat, passed: false, score: 0, selectedIds: [], failure: failureCode(error) }; + } +} + +function metricsSince(metrics, start) { + const calls = metrics.slice(start); + const complete = calls.length > 0 && calls.every(call => call.usage !== null); + return { calls: calls.length, elapsedMs: calls.reduce((sum, c) => sum + c.elapsedMs, 0), + usage: complete ? calls.reduce((sum, c) => ({ inputTokens: sum.inputTokens + c.usage.inputTokens, + cachedInputTokens: sum.cachedInputTokens + c.usage.cachedInputTokens, + outputTokens: sum.outputTokens + c.usage.outputTokens }), { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }) : null }; +} + +// Transcript retention is opt-in (--artifact-dir) and file-only: reports never embed session content or paths. +function createHarvester(artifactDir, envs) { + if (typeof artifactDir !== 'string' || !path.isAbsolute(artifactDir)) throw new Error('Artifact directory must be absolute'); + fs.mkdirSync(artifactDir, { recursive: true }); + const sessionsOf = env => { + const config = env.launch.claudeConfigDir; + const projects = config ? path.join(config, 'projects') : null; + if (!projects || !exists(projects)) return new Set(); + const found = new Set(); + const walk = directory => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const item = path.join(directory, entry.name); + if (entry.isDirectory()) walk(item); + else if (entry.name.endsWith('.jsonl')) found.add(item); + } + }; + walk(projects); + return found; + }; + const seen = new Map(Object.entries(envs).map(([name, env]) => [name, sessionsOf(env)])); + const index = []; + return { + record(arm, id, repeat, env) { + const before = seen.get(arm) || new Set(); + const now = sessionsOf(env); + seen.set(arm, now); + const fresh = [...now].filter(file => !before.has(file)); + if (!fresh.length) return; + const directory = path.join(artifactDir, `${id}--${arm}--${repeat}`); + fs.mkdirSync(directory, { recursive: true }); + for (const file of fresh) fs.copyFileSync(file, path.join(directory, path.basename(file))); + index.push({ id, arm, repeat, files: fresh.map(file => path.basename(file)) }); + }, + writeIndex() { fs.writeFileSync(path.join(artifactDir, 'artifact-index.json'), `${JSON.stringify(index, null, 1)}\n`); }, + }; +} + +function runEvaluation({ repoRoot = DEFAULT_REPO_ROOT, corpus = loadCorpus(), registration, + repeats = 1, provider, family, allowRealProvider = false, allowCredentialedTools = false, + executable, model, effort, authHome, environments, + arms = undefined, artifactDir = null, maxCalls = 300, deadlineMs = 3600000, callTimeoutMs = 300000 } = {}) { + if (!provider && !allowRealProvider) throw new Error('Evaluation requires an injected provider or explicit opt-in'); + if (!bounded(maxCalls, 1, 2000) || !bounded(deadlineMs, 1, 8 * 3600000) + || !bounded(callTimeoutMs, 1, 600000)) throw new Error('Invalid call or deadline bound'); + if (!provider && !registration) throw new Error('Real evaluation requires prior registration'); + const resolvedFamily = provider ? (family || 'codex') : resolveFamily(family, executable); + if (resolvedFamily === 'claude' && effort !== undefined) throw new Error('Reasoning effort applies only to the Codex provider'); + if (!provider && resolvedFamily === 'claude' && !allowCredentialedTools) { + throw new Error('Claude task tools can read provider credentials; explicit credentialed-tool opt-in is required'); + } + const pin = preregister({ repoRoot, corpus, repeats, model, executable, effort, arms }); + if (!provider && resolvedFamily === 'codex' && pin.arms.includes('ecc-legacy')) { + throw new Error('Codex real evaluation requires --arms without ecc-legacy; the pinned legacy skills arm is Claude-only'); + } + if (registration && !isDeepStrictEqual(registration, pin)) throw new Error('Registration pin mismatch'); + const injected = Boolean(provider); + const liveProvider = provider || (resolvedFamily === 'claude' + ? createClaudeProvider({ allowRealProvider, allowCredentialedTools, executable, model, + persistSessions: Boolean(artifactDir) }) + : createCodexProvider({ allowRealProvider, executable, model, effort, authHome })); + const state = { calls: 0, metrics: [], maxCalls, callTimeoutMs, family: resolvedFamily, + deadline: Date.now() + deadlineMs, provider: liveProvider, + assertCurrent() { + if (digestObject(corpus) !== pin.corpusDigest || sourceSnapshot(repoRoot).sourceDigest !== pin.sourceDigest) fail('source-drift'); + } }; + const temp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-ai-eval-'))); + const selection = []; + const outcomes = []; + let installs = null; + let harvester = null; + try { + const installRoot = path.join(temp, 'installs'); + fs.mkdirSync(installRoot, { mode: 0o700 }); + const envs = environments || (injected ? syntheticEnvironments(installRoot) + : resolvedFamily === 'claude' + ? prepareClaudeEnvironments({ repoRoot, executable, root: installRoot, + ...(pin.arms.includes('ecc-legacy') + ? { legacySource: exportLegacySource({ repoRoot, destination: path.join(installRoot, 'legacy-source') }) } + : {}) }) + : prepareEnvironments({ repoRoot, executable, root: installRoot })); + installs = Object.fromEntries(Object.entries(envs).map(([name, env]) => [name, + { profileId: env.profileId, skills: env.skills, ...(env.sourceSha ? { sourceSha: env.sourceSha } : {}) }])); + harvester = artifactDir && resolvedFamily === 'claude' && !injected ? createHarvester(artifactDir, envs) : null; + const harvest = harvester ? (arm, id, repeat, env) => harvester.record(arm, id, repeat, env) : null; + for (const item of corpus.selection) { + const cwd = path.join(temp, `${item.id}--selection`); + fs.mkdirSync(cwd); + const start = state.metrics.length; + selection.push({ ...selectionProbe(item, repoRoot, executeAdapter(state, cwd, envs.lean), envs.lean, resolvedFamily), + ...metricsSince(state.metrics, start) }); + } + for (const scheduled of pin.order) { + const item = corpus.tasks.find(c => c.id === scheduled.id); + for (const arm of scheduled.arms) { + const cwd = path.join(temp, `${item.id}--${arm}--${scheduled.repeat}`); + const environment = envs[['full', 'baseline', 'ecc-legacy'].includes(arm) ? arm : 'lean']; + fs.mkdirSync(cwd); + writeWorkspace(cwd, item.files); + const start = state.metrics.length; + outcomes.push({ ...outcomeTrial(item, arm, scheduled.repeat, repoRoot, + executeAdapter(state, cwd, environment), cwd, environment, resolvedFamily, harvest, state.metrics), + ...metricsSince(state.metrics, start) }); + fs.rmSync(cwd, { recursive: true, force: true }); + } + } + if (harvester) harvester.writeIndex(); + } finally { if (harvester) harvester.writeIndex(); fs.rmSync(temp, { recursive: true, force: true }); } + const summary = summarize(outcomes, pin.arms); + const insufficient = summary.distinctTasks < pin.minimumDistinctTasks || selection.length < pin.minimumDistinctTasks; + const selectionSuccesses = selection.filter(row => row.passed).length; + return { schemaVersion: 'ecc.context-eval.v2', registration: pin, + evidence: injected ? 'injected-provider' : resolvedFamily === 'claude' ? 'claude-json' : 'codex-jsonl', installs, + authentication: injected ? 'injected' : liveProvider.authentication, credentialsRetained: false, + calls: state.calls, bounds: { maxCalls, deadlineMs, callTimeoutMs }, selection, outcomes, summary, + selectionSummary: { n: selection.length, successes: selectionSuccesses, + categories: [...new Set(selection.map(row => row.category))].map(category => ({ category, + n: selection.filter(row => row.category === category).length, + successes: selection.filter(row => row.category === category && row.passed).length })), + interval: wilson(selectionSuccesses, selection.length), method: 'wilson-95-descriptive-purposive-sample' }, + gate: { status: insufficient ? 'insufficient-sample' : injected ? 'synthetic-only' : 'review-required', + nonInferioritySupported: !insufficient && !injected && summary.pairs.every(p => p.interval[0] >= -pin.nonInferiorityMargin), + releaseApproved: false }, nativeInvocation: 'unobserved', + measurementScope: 'native-install-hidden-graded-coding-tasks', + artifactRetention: harvester ? 'session-jsonl-per-task-trial' : 'none', ...metricsSince(state.metrics, 0) }; +} + +module.exports = { loadCorpus, preregister, runEvaluation, parseCodexJsonl, parseClaudeJson, summarize, wilson, + runCheck, runScoredCheck, createAuthLease, createCodexProvider, createClaudeProvider, prepareEnvironments, + prepareClaudeEnvironments, exportLegacySource, providerFamily, resolveFamily, readClaudeKeychainToken }; diff --git a/docker/context-profiles/ai-eval.js b/docker/context-profiles/ai-eval.js new file mode 100644 index 000000000..92903d6c8 --- /dev/null +++ b/docker/context-profiles/ai-eval.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node +'use strict'; +const fs = require('node:fs'); +const { preregister, runEvaluation, loadCorpus } = require('./ai-eval-lib'); + +function main(argv = process.argv.slice(2), injected = {}) { + const flags = new Map(); + const switches = new Set(['--plan', '--allow-real-provider', '--allow-credentialed-tools', '--help']); + const values = new Set(['--registration', '--model', '--executable', '--provider', '--auth-home', '--effort', '--repeats', '--max-calls', '--deadline-ms', '--artifact-dir', '--corpus', '--call-timeout-ms', '--arms']); + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + if (flags.has(flag) || (!switches.has(flag) && !values.has(flag))) throw new Error('Invalid evaluation arguments'); + if (values.has(flag) && (!argv[i + 1] || argv[i + 1].startsWith('--'))) throw new Error('Missing evaluation argument'); + flags.set(flag, switches.has(flag) ? true : argv[++i]); + } + if (flags.has('--help')) { + return { usage: 'ai-eval.js --plan [--corpus FILE] [--arms a,b] [--repeats N] [--model MODEL --executable ABSOLUTE_PATH [--provider claude|codex] [--effort LEVEL]] | --allow-real-provider --registration FILE --model MODEL --executable ABSOLUTE_PATH [--provider claude|codex] [--allow-credentialed-tools (Claude only)] [--effort LEVEL (Codex only)] [--auth-home ABSOLUTE_DIR (Codex only)] [--corpus FILE] [--arms a,b] [--repeats N] [--max-calls N] [--deadline-ms N] [--call-timeout-ms N]. Claude auth: CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, or the macOS Keychain login.' }; + } + if (flags.get('--provider') !== undefined && !['claude', 'codex'].includes(flags.get('--provider'))) throw new Error('Provider must be claude or codex'); + if (flags.get('--provider') === 'claude' && flags.has('--effort')) throw new Error('Reasoning effort applies only to the Codex provider'); + if (flags.has('--allow-credentialed-tools') && (!flags.has('--allow-real-provider') || flags.get('--provider') !== 'claude')) { + throw new Error('Credentialed-tool opt-in requires a real Claude evaluation'); + } + const repeats = flags.has('--repeats') ? Number(flags.get('--repeats')) : 1; + const corpus = flags.has('--corpus') ? loadCorpus(flags.get('--corpus')) : undefined; + const arms = flags.has('--arms') ? flags.get('--arms').split(',').map(a => a.trim()).filter(Boolean) : undefined; + if (flags.has('--plan')) { + if (flags.has('--allow-real-provider')) throw new Error('Plan and provider execution are separate actions'); + return preregister({ repeats, model: flags.get('--model'), executable: flags.get('--executable'), effort: flags.get('--effort'), + ...(corpus ? { corpus } : {}), ...(arms ? { arms } : {}) }); + } + if (!flags.has('--allow-real-provider') && !injected.provider) throw new Error('Real evaluation requires explicit opt-in'); + if (!flags.has('--registration')) throw new Error('Evaluation requires a preregistration file'); + const registration = JSON.parse(fs.readFileSync(flags.get('--registration'), 'utf8')); + return runEvaluation({ ...injected, registration, repeats, allowRealProvider: flags.has('--allow-real-provider'), + allowCredentialedTools: flags.has('--allow-credentialed-tools'), + executable: flags.get('--executable'), model: flags.get('--model'), family: flags.get('--provider'), effort: flags.get('--effort'), authHome: flags.get('--auth-home'), + artifactDir: flags.get('--artifact-dir'), ...(corpus ? { corpus } : {}), ...(arms ? { arms } : {}), + ...(flags.has('--max-calls') ? { maxCalls: Number(flags.get('--max-calls')) } : {}), + ...(flags.has('--deadline-ms') ? { deadlineMs: Number(flags.get('--deadline-ms')) } : {}), + ...(flags.has('--call-timeout-ms') ? { callTimeoutMs: Number(flags.get('--call-timeout-ms')) } : {}) }); +} +if (require.main === module) { + try { process.stdout.write(`${JSON.stringify(main())}\n`); } + catch (error) { + // Only fixed messages from this evaluator are shown; provider output and paths never reach stderr. + const known = /^(Invalid|Missing|Real|Evaluation|Plan|Registration|Provider|Auth home|Native Codex version|Reasoning effort|Claude Keychain login|Claude)[^/\\]*$/.test(error?.message || ''); + process.stderr.write(`Evaluation stopped: ${known ? error.message : 'invalid arguments, registration, source, or provider configuration'}. Use --help.\n`); + process.exitCode = 1; + } +} +module.exports = { main }; diff --git a/docker/context-profiles/complex-corpus-v2.json b/docker/context-profiles/complex-corpus-v2.json new file mode 100644 index 000000000..63ec2c8cb --- /dev/null +++ b/docker/context-profiles/complex-corpus-v2.json @@ -0,0 +1,85 @@ +{ + "schemaVersion": "ecc.context-eval-complex-corpus.v1", + "id": "complex-tasks@2", + "sampling": "Realistic multi-file engineering tasks, fixed before any provider call, with deterministic hidden graders scoring partial credit (ECC_EVAL_SCORE). Descriptive pilot: no population-representativeness claim. See complex-eval/DESIGN.md for the preregistered methodology.", + "minimumDistinctTasks": 3, + "nonInferiorityMargin": 0.05, + "selection": [ + { + "id": "complex-event-stats-api", + "category": "complex-correctness-performance", + "query": "The /stats endpoint in this repo is wrong on edge cases and too slow — customers on big dashboards are timing out. It currently rescans and resorts the whole 300k-event log on every request, and the analytics team says the numbers don't match the documented semantics (nearest-rank percentiles, half-up two-decimal averages, null fields when nothing matches, proper 400s). Make it correct per the README and fast enough to meet the documented performance budget, without changing the API shape. `npm test` must stay green.", + "expectedIds": [ + "skill:backend-patterns" + ] + }, + { + "id": "complex-forge-cli", + "category": "complex-spec-thoroughness", + "query": "Build the snippet manager CLI per the README — all twelve numbered behaviors are contractual, including exact messages, exit codes, sorting, and the never-throw guarantee. `npm test` must pass, and add tests for the tricky edges (duplicates, invalid names, bad imports) so we don't regress them.", + "expectedIds": [ + "skill:tdd-workflow" + ] + }, + { + "id": "complex-keccak-selector", + "category": "complex-domain-knowledge-trap", + "query": "We're building contract ABI tooling and need Ethereum function selectors. Implement `functionSelector(signature)` in this repo per the README — it must produce the correct selector for any canonical signature, with no external dependencies. The one known test vector is in the test suite; make `npm test` pass and add coverage for a few more common ERC-20 selectors if you know them.", + "expectedIds": [ + "skill:nodejs-keccak256" + ] + } + ], + "tasks": [ + { + "id": "event-stats-api", + "category": "correctness-and-performance", + "manualIds": [ + "skill:backend-patterns" + ], + "checkTimeoutMs": 120000, + "query": "The /stats endpoint in this repo is wrong on edge cases and too slow — customers on big dashboards are timing out. It currently rescans and resorts the whole 300k-event log on every request, and the analytics team says the numbers don't match the documented semantics (nearest-rank percentiles, half-up two-decimal averages, null fields when nothing matches, proper 400s). Make it correct per the README and fast enough to meet the documented performance budget, without changing the API shape. `npm test` must stay green.", + "files": { + "package.json": "{\n \"name\": \"event-stats\",\n \"private\": true,\n \"type\": \"commonjs\",\n \"scripts\": { \"test\": \"node --test test/\" }\n}\n", + "README.md": "# event-stats\n\nAnalytics endpoint over an in-memory event log (300,000 events, generated\ndeterministically by `src/data.js`).\n\n## API\n\n`GET /stats?type=&from=&to=` returns JSON:\n\n```json\n{ \"type\": \"click\", \"from\": 1754000000000, \"to\": 1756592000000,\n \"count\": 1234, \"sum\": 56789, \"avg\": 46.02,\n \"p50\": 123, \"p95\": 456, \"p99\": 789, \"min\": 1, \"max\": 50000 }\n```\n\nSemantics (all pinned; follow them exactly):\n\n- `from`/`to` are millisecond timestamps, **inclusive**, and optional\n (absent means unbounded). Non-numeric bounds, or `from > to`, are `400`.\n- Only events of the given `type` within `[from, to]` are included.\n- `sum` is the exact integer sum of `value`s.\n- `avg` is `sum / count` rounded **half-up to two decimals**.\n- Percentiles use the **nearest-rank** method: sort values ascending, take the\n value at 1-based rank `ceil(p / 100 * count)`. No interpolation.\n- If no events match (including an unknown `type`), return `200` with\n `count: 0, sum: 0` and `avg`, `p50`, `p95`, `p99`, `min`, `max` all `null`.\n- The response echoes the effective `from`/`to` (`null` when unbounded).\n\n## Performance requirement\n\nThe endpoint must stay fast at this data size: **2,000 mixed queries complete\nin under 6 seconds** on this machine (the reference does it in ~1.5s).\nPrecompute whatever you need at startup; per-query work must not scan the\nwhole log.\n\n## Module contract\n\n- `src/app.js` is CommonJS and exports `createApp()` returning an\n `http.Server` that is not yet listening.\n- `node src/index.js ` starts the service.\n- No external dependencies. Run the tests with `npm test`.\n", + "src/app.js": "'use strict';\nconst http = require('node:http');\nconst { events } = require('./data');\n\n// Current implementation: scan and sort per query. Known slow, and the\n// analytics team says edge cases don't match the README semantics.\nfunction summarize(type, from, to) {\n const rows = events\n .filter(e => e.type === type && (from === null || e.ts >= from) && (to === null || e.ts <= to))\n .map(e => e.value)\n .sort((a, b) => a - b);\n const count = rows.length;\n const sum = rows.reduce((a, b) => a + b, 0);\n const interpolate = p => {\n if (!count) return 0;\n const rank = (p / 100) * (count - 1);\n const low = Math.floor(rank);\n const high = Math.ceil(rank);\n return rows[low] + (rows[high] - rows[low]) * (rank - low);\n };\n return { count, sum, avg: count ? sum / count : 0,\n p50: interpolate(50), p95: interpolate(95), p99: interpolate(99),\n min: count ? rows[0] : 0, max: count ? rows[count - 1] : 0 };\n}\n\nfunction createApp() {\n return http.createServer((req, res) => {\n const url = new URL(req.url, 'http://localhost');\n if (req.method === 'GET' && url.pathname === '/stats') {\n const type = url.searchParams.get('type');\n const from = url.searchParams.has('from') ? Number(url.searchParams.get('from')) : null;\n const to = url.searchParams.has('to') ? Number(url.searchParams.get('to')) : null;\n const body = summarize(type, from, to);\n res.writeHead(200, { 'content-type': 'application/json' });\n res.end(JSON.stringify({ type, from, to, ...body }));\n return;\n }\n res.writeHead(404, { 'content-type': 'application/json' });\n res.end(JSON.stringify({ error: 'not found' }));\n });\n}\n\nmodule.exports = { createApp };\n", + "src/data.js": "'use strict';\n// Deterministic event log: 300,000 events from a seeded LCG so every run,\n// grader, and reference sees identical data. Do not change the generator.\nconst TYPES = ['click', 'view', 'signup', 'purchase', 'refund', 'login',\n 'logout', 'share', 'comment', 'like', 'search', 'export'];\nconst DAY_MS = 86400000;\nconst EPOCH_MS = 1754000000000;\nconst SPAN_MS = 90 * DAY_MS;\n\nfunction lcg(seed) {\n let state = seed >>> 0;\n return () => {\n state = (Math.imul(state, 1664525) + 1013904223) >>> 0;\n return state / 2 ** 32;\n };\n}\n\nconst rand = lcg(20260925);\nconst events = new Array(300000);\nfor (let i = 0; i < events.length; i++) {\n events[i] = {\n type: TYPES[Math.floor(rand() * TYPES.length)],\n ts: EPOCH_MS + Math.floor(rand() * SPAN_MS),\n value: Math.floor(rand() * 50000) + 1,\n };\n}\n\nmodule.exports = { events, TYPES, EPOCH_MS, SPAN_MS };\n", + "src/index.js": "'use strict';\nconst { createApp } = require('./app');\n\nconst port = Number(process.argv[2] || 8080);\ncreateApp().listen(port, () => {\n console.log(`event-stats listening on ${port}`);\n});\n", + "test/stats.test.js": "'use strict';\nconst test = require('node:test');\nconst assert = require('node:assert/strict');\nconst { createApp } = require('../src/app');\nconst { EPOCH_MS } = require('../src/data');\n\ntest('stats endpoint answers a broad query', async () => {\n const server = createApp();\n await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));\n try {\n const port = server.address().port;\n const response = await fetch(`http://127.0.0.1:${port}/stats?type=click&from=${EPOCH_MS}&to=${EPOCH_MS + 30 * 86400000}`);\n assert.equal(response.status, 200);\n const body = await response.json();\n assert.equal(body.type, 'click');\n assert.ok(body.count > 0);\n } finally {\n server.close();\n }\n});\n" + }, + "check": "'use strict';\n// Hidden grader for event-stats-api: independent spec-conformant aggregation\n// over the deterministic event log, plus a measured 2,000-query performance\n// probe (threshold calibrated on the grading machine: shipped naive ~7.7s,\n// reference ~1.5s). Prints ECC_EVAL_SCORE and always exits 0.\nconst fs = require('node:fs');\nconst path = require('node:path');\n\nconst checks = [];\nconst record = (name, ok) => checks.push({ name, ok: Boolean(ok) });\nlet finished = false;\nfunction finish() {\n if (finished) return;\n finished = true;\n const ok = checks.filter(c => c.ok).length;\n for (const c of checks) console.log(`${c.ok ? 'ok' : 'not ok'} - ${c.name}`);\n console.log(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / checks.length, passed: ok, total: checks.length })}`);\n process.exit(0);\n}\nsetTimeout(finish, 110000).unref();\n\nconst PERF_THRESHOLD_MS = 6000;\nconst PERF_QUERIES = 2000;\n\nfunction lcg(seed) {\n let state = seed >>> 0;\n return () => {\n state = (Math.imul(state, 1664525) + 1013904223) >>> 0;\n return state / 2 ** 32;\n };\n}\n\nconst root = process.cwd();\nconst { events, TYPES, EPOCH_MS, SPAN_MS } = require(path.join(root, 'src', 'data.js'));\n\n// Independent reference semantics per the README: inclusive bounds,\n// nearest-rank percentiles, half-up two-decimal average via exact integer math.\nfunction expected(type, from, to) {\n const rows = events\n .filter(e => e.type === type && (from === null || e.ts >= from) && (to === null || e.ts <= to))\n .map(e => e.value)\n .sort((a, b) => a - b);\n const count = rows.length;\n if (!count) return { count: 0, sum: 0, avg: null, p50: null, p95: null, p99: null, min: null, max: null };\n const sum = rows.reduce((a, b) => a + b, 0);\n const rank = p => rows[Math.ceil((p / 100) * count) - 1];\n const avgCents = Math.floor((sum * 200 + count) / (count * 2));\n return { count, sum, avg: avgCents / 100,\n p50: rank(50), p95: rank(95), p99: rank(99), min: rows[0], max: rows[count - 1] };\n}\n\nconst same = (a, b) => JSON.stringify(a) === JSON.stringify(b);\n\nasync function query(port, params) {\n const qs = Object.entries(params).map(([k, v]) => `${k}=${v}`).join('&');\n const response = await fetch(`http://127.0.0.1:${port}/stats?${qs}`);\n return { status: response.status, body: await response.json().catch(() => null) };\n}\n\n(async () => {\n let createApp;\n try { ({ createApp } = require(path.join(root, 'src', 'app.js'))); } catch { finish(); return; }\n if (typeof createApp !== 'function') { finish(); return; }\n\n try {\n const app = createApp();\n await new Promise(resolve => app.listen(0, '127.0.0.1', resolve));\n const port = app.address().port;\n\n // 1-2: broad and full-range queries with independently computed expectations.\n const broadFrom = EPOCH_MS;\n const broadTo = EPOCH_MS + 30 * 86400000;\n const broad = await query(port, { type: 'click', from: broadFrom, to: broadTo });\n record('broad-window-exact', broad.status === 200\n && same(broad.body, { type: 'click', from: broadFrom, to: broadTo, ...expected('click', broadFrom, broadTo) }));\n const full = await query(port, { type: 'purchase' });\n record('full-range-exact', full.status === 200\n && same(full.body, { type: 'purchase', from: null, to: null, ...expected('purchase', null, null) }));\n\n // 3: nearest-rank vs interpolation is distinguishable on a tiny window.\n const exportEvents = events.filter(e => e.type === 'export').map(e => e.ts).sort((a, b) => a - b);\n const pivot = exportEvents[Math.floor(exportEvents.length / 2)];\n const narrowFrom = pivot - 1;\n const narrowTo = pivot + 1;\n const narrow = await query(port, { type: 'export', from: narrowFrom, to: narrowTo });\n record('narrow-window-nearest-rank', narrow.status === 200\n && same(narrow.body, { type: 'export', from: narrowFrom, to: narrowTo, ...expected('export', narrowFrom, narrowTo) }));\n\n // 4-5: empty range and unknown type return nulls, not zeros or errors.\n const beyond = await query(port, { type: 'click', from: EPOCH_MS + 200 * 86400000, to: EPOCH_MS + 201 * 86400000 });\n record('empty-range-nulls', beyond.status === 200 && same(beyond.body,\n { type: 'click', from: EPOCH_MS + 200 * 86400000, to: EPOCH_MS + 201 * 86400000, ...expected('click', EPOCH_MS + 200 * 86400000, EPOCH_MS + 201 * 86400000) }));\n const unknown = await query(port, { type: 'nope' });\n record('unknown-type-nulls', unknown.status === 200\n && same(unknown.body, { type: 'nope', from: null, to: null, ...expected('nope', null, null) }));\n\n // 6: inclusive bounds — a zero-width window on a real timestamp includes it.\n const likeTs = events.filter(e => e.type === 'like').map(e => e.ts).sort((a, b) => a - b)[100];\n const inclusive = await query(port, { type: 'like', from: likeTs, to: likeTs });\n record('bounds-inclusive', inclusive.status === 200 && inclusive.body.count === expected('like', likeTs, likeTs).count && inclusive.body.count >= 1);\n\n // 7: average rounding follows half-up two decimals exactly.\n const rounding = expected('view', EPOCH_MS, EPOCH_MS + 86400000);\n const rounded = await query(port, { type: 'view', from: EPOCH_MS, to: EPOCH_MS + 86400000 });\n record('avg-half-up-2dp', rounded.status === 200 && rounded.body.avg === rounding.avg);\n\n // 8-9: invalid parameters are 400.\n const inverted = await query(port, { type: 'click', from: 10, to: 5 });\n record('inverted-bounds-400', inverted.status === 400);\n const garbage = await query(port, { type: 'click', from: 'abc' });\n record('non-numeric-bounds-400', garbage.status === 400);\n\n // 10: performance budget.\n const rand = lcg(777);\n const queries = [];\n for (let i = 0; i < PERF_QUERIES; i++) {\n const type = TYPES[Math.floor(rand() * TYPES.length)];\n const start = EPOCH_MS + Math.floor(rand() * SPAN_MS * 0.7);\n queries.push({ type, from: start, to: start + Math.floor(rand() * SPAN_MS * 0.5) });\n }\n const started = Date.now();\n for (let i = 0; i < queries.length; i += 20) {\n await Promise.all(queries.slice(i, i + 20).map(q => query(port, q)));\n }\n const elapsed = Date.now() - started;\n console.log(`perf: ${elapsed}ms for ${PERF_QUERIES} queries (threshold ${PERF_THRESHOLD_MS}ms)`);\n record('performance-budget', elapsed < PERF_THRESHOLD_MS);\n\n app.close();\n } catch { /* grader-side failure leaves remaining checks unscored */ }\n\n // 11: no external dependencies.\n try {\n const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));\n const sources = [];\n const walk = directory => {\n for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {\n const item = path.join(directory, entry.name);\n if (entry.isDirectory()) walk(item);\n else if (entry.name.endsWith('.js')) sources.push(fs.readFileSync(item, 'utf8'));\n }\n };\n walk(path.join(root, 'src'));\n const bareImport = sources.some(source => /require\\(\\s*['\"](?!node:)[a-z@][^'./]*['\"]\\s*\\)/.test(source));\n record('no-external-dependencies', !bareImport && !pkg.dependencies && !pkg.devDependencies);\n } catch { record('no-external-dependencies', false); }\n\n finish();\n})();\n" + }, + { + "id": "forge-cli", + "category": "spec-thoroughness", + "manualIds": [ + "skill:tdd-workflow" + ], + "checkTimeoutMs": 30000, + "query": "Build the snippet manager CLI per the README — all twelve numbered behaviors are contractual, including exact messages, exit codes, sorting, and the never-throw guarantee. `npm test` must pass, and add tests for the tricky edges (duplicates, invalid names, bad imports) so we don't regress them.", + "files": { + "package.json": "{\n \"name\": \"snippet-cli\",\n \"private\": true,\n \"type\": \"commonjs\",\n \"scripts\": { \"test\": \"node --test test/\" }\n}\n", + "README.md": "# snippet-cli\n\nA small in-process snippet manager. No external dependencies; Node.js standard\nlibrary only.\n\n## Contract\n\n`src/cli.js` is CommonJS and exports `run(argv, state)`:\n\n- `argv`: array of command-line words (already split, no program name).\n- `state`: any plain object, created by the caller as `{}`. The CLI keeps its\n data in it and mutates it in place; it survives across calls.\n- Returns synchronously: `{ code, stdout, stderr }` — a number and two strings\n (empty string when there is nothing to print). `run` must **never throw**,\n on any input.\n- All printed lines end with `\\n`.\n\n## Commands (all behavior below is contractual)\n\n1. `add [--tags a,b] ` — creates a snippet from the remaining\n words joined by single spaces. Prints `created `, code 0.\n2. Adding an existing name: code 1, stderr `error: snippet '' already exists`,\n state unchanged.\n3. `add` with a missing name or missing text: code 2, stderr\n `usage: add [--tags t1,t2] `.\n4. Names must match `^[a-z0-9][a-z0-9-]*$`; otherwise code 2, stderr\n `error: invalid snippet name ''`.\n5. `get ` — prints the exact text, code 0. Unknown name: code 2, stderr\n `error: no snippet named ''`.\n6. `remove ` — prints `removed `, code 0. Unknown name: same as `get`.\n7. `list` — every snippet name, sorted ascending, one per line. With no\n snippets: prints `no snippets`. Always code 0.\n8. `list --tag ` — only snippets whose tags include `t`.\n9. `search ` — case-insensitive substring match over name **and** text;\n prints matching names sorted, one per line; prints `no matches` when empty.\n Code 0.\n10. `export` — prints `JSON.stringify` of `{ snippets: { : { text, tags } } }`\n with names sorted and each `tags` array sorted. Code 0.\n11. `import ` — merges an exported document: names not already present\n are added, existing names are skipped. Prints `imported , skipped `,\n code 0. Malformed JSON: code 1, stderr `error: invalid JSON`, state\n unchanged.\n12. No command or an unknown command: code 2, stderr\n `usage: snippet `.\n\nRun the tests with `npm test`.\n", + "src/cli.js": "'use strict';\n\n// TODO: implement per README. The contract is run(argv, state) -> { code, stdout, stderr }.\nfunction run(argv, state) {\n throw new Error('not implemented');\n}\n\nmodule.exports = { run };\n", + "test/cli.test.js": "'use strict';\nconst test = require('node:test');\nconst assert = require('node:assert/strict');\nconst { run } = require('../src/cli');\n\ntest('add then get round-trips a snippet', () => {\n const state = {};\n const added = run(['add', 'hello', 'hello', 'world'], state);\n assert.equal(added.code, 0);\n assert.equal(added.stdout, 'created hello\\n');\n const got = run(['get', 'hello'], state);\n assert.equal(got.code, 0);\n assert.equal(got.stdout, 'hello world\\n');\n});\n\ntest('list on empty state', () => {\n const result = run(['list'], {});\n assert.equal(result.code, 0);\n assert.equal(result.stdout, 'no snippets\\n');\n});\n" + }, + "check": "'use strict';\n// Hidden grader for forge-cli: drives run(argv, state) through the twelve\n// contractual behaviors plus never-throw fuzzing and static hygiene. Prints\n// ECC_EVAL_SCORE and always exits 0.\nconst fs = require('node:fs');\nconst path = require('node:path');\n\nconst checks = [];\nconst record = (name, ok) => checks.push({ name, ok: Boolean(ok) });\n\nconst root = process.cwd();\nlet run;\ntry { ({ run } = require(path.join(root, 'src', 'cli.js'))); } catch { /* scored below */ }\n\nconst USAGE = 'usage: snippet \\n';\nconst ADD_USAGE = 'usage: add [--tags t1,t2] \\n';\n\nif (typeof run !== 'function') {\n for (let i = 0; i < 26; i++) record(`check-${i + 1}`, false);\n} else {\n const call = (argv, state) => {\n try {\n const result = run(argv, state);\n if (!result || typeof result.code !== 'number'\n || typeof result.stdout !== 'string' || typeof result.stderr !== 'string') return null;\n return result;\n } catch { return null; }\n };\n\n // Basic lifecycle.\n let s = {};\n let r = call(['add', 'hello', 'hello', 'world'], s);\n record('add-happy', r && r.code === 0 && r.stdout === 'created hello\\n' && r.stderr === '');\n r = call(['add', 'hello', 'different', 'text'], s);\n const afterDup = call(['get', 'hello'], s);\n record('add-duplicate-rejected', r && r.code === 1 && r.stderr === \"error: snippet 'hello' already exists\\n\"\n && afterDup && afterDup.stdout === 'hello world\\n');\n const m1 = call(['add'], s);\n const m2 = call(['add', 'justname'], s);\n record('add-missing-args-usage', m1 && m1.code === 2 && m1.stderr === ADD_USAGE\n && m2 && m2.code === 2 && m2.stderr === ADD_USAGE);\n r = call(['add', 'Bad_Name', 'text'], s);\n record('invalid-name-rejected', r && r.code === 2 && r.stderr === \"error: invalid snippet name 'Bad_Name'\\n\");\n r = call(['get', 'hello'], s);\n record('get-happy', r && r.code === 0 && r.stdout === 'hello world\\n');\n r = call(['get', 'ghost'], s);\n record('get-unknown', r && r.code === 2 && r.stderr === \"error: no snippet named 'ghost'\\n\");\n\n // Listing and tags.\n s = {};\n call(['add', 'bravo', 'second'], s);\n call(['add', 'alpha', '--tags', 'x,y', 'first'], s);\n call(['add', 'charlie', '--tags', 'y', 'third'], s);\n r = call(['list'], s);\n record('list-sorted', r && r.code === 0 && r.stdout === 'alpha\\nbravo\\ncharlie\\n');\n r = call(['list'], {});\n record('list-empty', r && r.code === 0 && r.stdout === 'no snippets\\n');\n r = call(['list', '--tag', 'y'], s);\n record('list-tag-filter', r && r.code === 0 && r.stdout === 'alpha\\ncharlie\\n');\n\n // Removal.\n r = call(['remove', 'bravo'], s);\n const gone = call(['get', 'bravo'], s);\n record('remove-happy', r && r.code === 0 && r.stdout === 'removed bravo\\n' && gone && gone.code === 2);\n r = call(['remove', 'bravo'], s);\n record('remove-unknown', r && r.code === 2 && r.stderr === \"error: no snippet named 'bravo'\\n\");\n\n // Search over name and text, case-insensitive, sorted.\n r = call(['search', 'FIRST'], s);\n record('search-text-case-insensitive', r && r.code === 0 && r.stdout === 'alpha\\n');\n r = call(['search', 'char'], s);\n record('search-name-match', r && r.code === 0 && r.stdout === 'charlie\\n');\n r = call(['search', 'zzz'], s);\n record('search-no-matches', r && r.code === 0 && r.stdout === 'no matches\\n');\n\n // Export/import round-trip with stable ordering.\n r = call(['export'], s);\n let doc = null;\n try { doc = r && JSON.parse(r.stdout); } catch { /* wrong */ }\n record('export-json-sorted', doc && r.code === 0 && sameDoc(doc, {\n snippets: { alpha: { text: 'first', tags: ['x', 'y'] }, charlie: { text: 'third', tags: ['y'] } } })\n && r.stdout.indexOf('alpha') < r.stdout.indexOf('charlie'));\n const importedState = { snippets: { alpha: { text: 'preexisting', tags: [] } } };\n r = call(['import', JSON.stringify({ snippets: {\n alpha: { text: 'first', tags: ['x', 'y'] }, delta: { text: 'fourth', tags: ['z'] } } })], importedState);\n const delta = call(['get', 'delta'], importedState);\n const alpha = call(['get', 'alpha'], importedState);\n record('import-merge-skip-existing', r && r.code === 0 && r.stdout === 'imported 1, skipped 1\\n'\n && delta && delta.stdout === 'fourth\\n' && alpha && alpha.stdout === 'preexisting\\n');\n const beforeExport = call(['export'], s);\n r = call(['import', '{not json'], s);\n const afterExport = call(['export'], s);\n record('import-malformed-atomic', r && r.code === 1 && r.stderr === 'error: invalid JSON\\n'\n && beforeExport && afterExport && beforeExport.stdout === afterExport.stdout);\n\n // Usage fallbacks.\n r = call(['bogus'], {});\n record('unknown-command-usage', r && r.code === 2 && r.stderr === USAGE);\n r = call([], {});\n record('no-command-usage', r && r.code === 2 && r.stderr === USAGE);\n\n // Never-throw fuzzing on junk input.\n const fuzz = [['--help', 'x'], ['get'], ['add', 'x', 'y', '--tags'], ['import']];\n fuzz.forEach((argv, index) => {\n record(`fuzz-never-throws-${index + 1}`, call(argv, {}) !== null);\n });\n}\n\nfunction sameDoc(a, b) { return JSON.stringify(a) === JSON.stringify(b); }\n\n// Static hygiene.\ntry {\n const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));\n record('no-external-dependencies', !pkg.dependencies && !pkg.devDependencies);\n} catch { record('no-external-dependencies', false); }\ntry {\n const sources = [];\n const walk = directory => {\n for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {\n const item = path.join(directory, entry.name);\n if (entry.isDirectory()) walk(item);\n else if (entry.name.endsWith('.js')) sources.push(fs.readFileSync(item, 'utf8'));\n }\n };\n walk(path.join(root, 'src'));\n record('no-leftover-todos', sources.every(source => !/TODO|FIXME/.test(source)));\n} catch { record('no-leftover-todos', false); }\n\nconst okCount = checks.filter(c => c.ok).length;\nfor (const c of checks) console.log(`${c.ok ? 'ok' : 'not ok'} - ${c.name}`);\nconsole.log(`ECC_EVAL_SCORE ${JSON.stringify({ score: okCount / checks.length, passed: okCount, total: checks.length })}`);\nprocess.exit(0);\n" + }, + { + "id": "keccak-selector", + "category": "domain-knowledge-trap", + "manualIds": [ + "skill:nodejs-keccak256" + ], + "checkTimeoutMs": 30000, + "query": "We're building contract ABI tooling and need Ethereum function selectors. Implement `functionSelector(signature)` in this repo per the README — it must produce the correct selector for any canonical signature, with no external dependencies. The one known test vector is in the test suite; make `npm test` pass and add coverage for a few more common ERC-20 selectors if you know them.", + "files": { + "package.json": "{\n \"name\": \"abi-selectors\",\n \"private\": true,\n \"type\": \"commonjs\",\n \"scripts\": { \"test\": \"node --test test/\" }\n}\n", + "README.md": "# abi-selectors\n\nContract ABI tooling: compute Ethereum function selectors.\n\n## Contract\n\n`src/selector.js` is CommonJS and exports `functionSelector(signature)`:\n\n- `signature` is the canonical function signature string, e.g.\n `\"transfer(address,uint256)\"` — no spaces, no argument names.\n- Returns `\"0x\"` plus the first 4 bytes of the Keccak-256 hash of the UTF-8\n signature, as 8 lowercase hex characters.\n- Throws `TypeError` for a non-string argument.\n- Node.js standard library only; no external dependencies. Whatever hashing\n you need, implement it in this repo.\n- Run the tests with `npm test`.\n\n## Note\n\nEthereum uses **Keccak-256**, the original Keccak submission, which predates\nthe finalized NIST SHA3-256 standard. Mind that distinction.\n", + "src/selector.js": "'use strict';\n\n// TODO: implement per README. Known vector: name() -> 0x06fdde03.\nfunction functionSelector(signature) {\n throw new Error('not implemented');\n}\n\nmodule.exports = { functionSelector };\n", + "test/selector.test.js": "'use strict';\nconst test = require('node:test');\nconst assert = require('node:assert/strict');\nconst { functionSelector } = require('../src/selector');\n\ntest('name() selector matches the published ERC-20 value', () => {\n assert.equal(functionSelector('name()'), '0x06fdde03');\n});\n\ntest('output format', () => {\n assert.match(functionSelector('totalSupply()'), /^0x[0-9a-f]{8}$/);\n});\n" + }, + "check": "'use strict';\n// Hidden grader for keccak-selector. Every vector is independently cross-checked:\n// the implementation is validated against Node's SHA3-256 (same Keccak-f[1600]\n// permutation, different padding suffix) including multi-block and q=1 padding\n// edge inputs. Prints ECC_EVAL_SCORE and always exits 0.\nconst fs = require('node:fs');\nconst path = require('node:path');\n\nconst checks = [];\nconst record = (name, ok) => checks.push({ name, ok: Boolean(ok) });\n\nconst VECTORS = [\n ['name()', '0x06fdde03'],\n ['symbol()', '0x95d89b41'],\n ['decimals()', '0x313ce567'],\n ['totalSupply()', '0x18160ddd'],\n ['balanceOf(address)', '0x70a08231'],\n ['transfer(address,uint256)', '0xa9059cbb'],\n ['approve(address,uint256)', '0x095ea7b3'],\n ['transferFrom(address,address,uint256)', '0x23b872dd'],\n // 135-byte signature: padding lands on the q=1 edge case.\n ['someVeryLongFunctionNameForTestingMultiBlockHashingBehavior(address,uint256,string,bytes32,bool,uint8[],int128,(address,uint256),bytes)', '0x2add16ac'],\n];\n\nlet functionSelector;\ntry { ({ functionSelector } = require(path.join(process.cwd(), 'src', 'selector.js'))); } catch { /* scored below */ }\n\nif (typeof functionSelector === 'function') {\n VECTORS.forEach(([signature, expected], index) => {\n let actual = null;\n try { actual = functionSelector(signature); } catch { /* wrong */ }\n record(`selector-vector-${index + 1}`, actual === expected);\n });\n try { record('output-format', /^0x[0-9a-f]{8}$/.test(functionSelector('name()'))); }\n catch { record('output-format', false); }\n let threw = false;\n try { functionSelector(42); } catch (error) { threw = error instanceof TypeError; }\n record('typeerror-on-non-string', threw);\n} else {\n for (const [,] of VECTORS) checks.push({ name: `selector-vector-${checks.length + 1}`, ok: false });\n record('output-format', false);\n record('typeerror-on-non-string', false);\n}\n\n// No external code: every import under src/ must be relative or node:-prefixed.\nconst sources = [];\nconst walk = directory => {\n for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {\n const item = path.join(directory, entry.name);\n if (entry.isDirectory()) walk(item);\n else if (entry.name.endsWith('.js')) sources.push(fs.readFileSync(item, 'utf8'));\n }\n};\ntry { walk(path.join(process.cwd(), 'src')); } catch { /* none */ }\nconst bareImport = sources.some(source => /require\\(\\s*['\"](?!node:)[a-z@][^'./]*['\"]\\s*\\)/.test(source)\n || /^\\s*import\\s/m.test(source) && /from\\s*['\"](?!node:|\\.)[^'\"]+['\"]/.test(source));\nconst pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));\nrecord('no-external-dependencies', !bareImport && !pkg.dependencies && !pkg.devDependencies);\n\nconst ok = checks.filter(c => c.ok).length;\nfor (const c of checks) console.log(`${c.ok ? 'ok' : 'not ok'} - ${c.name}`);\nconsole.log(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / checks.length, passed: ok, total: checks.length })}`);\nprocess.exit(0);\n" + } + ] +} diff --git a/docker/context-profiles/complex-corpus-v3.json b/docker/context-profiles/complex-corpus-v3.json new file mode 100644 index 000000000..7e895a153 --- /dev/null +++ b/docker/context-profiles/complex-corpus-v3.json @@ -0,0 +1,117 @@ +{ + "schemaVersion": "ecc.context-eval-complex-corpus.v1", + "id": "complex-tasks@3", + "sampling": "Realistic multi-file engineering tasks, fixed before any provider call, with deterministic hidden graders scoring partial credit (ECC_EVAL_SCORE). Descriptive pilot: no population-representativeness claim. See complex-eval/DESIGN.md for the preregistered methodology.", + "minimumDistinctTasks": 3, + "nonInferiorityMargin": 0.05, + "selection": [ + { + "id": "complex-chained-tickets", + "category": "complex-long-horizon", + "query": "Implement the link shortener described in API.md. Follow CONTRIBUTING.md — every convention applies. Links need to survive a service restart. Persist them to the JSON file named by the DATA_FILE environment variable (read at startup). Take care of it. We're seeing abuse — people hammering the API and submitting junk URLs. Deal with it. Track redirect hits per link and expose them at GET /links/:code/stats, consistent with the existing API.", + "expectedIds": [ + "skill:backend-patterns" + ] + }, + { + "id": "complex-idempotent-webhooks", + "category": "complex-concurrency-trap", + "query": "The payments provider retries aggressively and sometimes delivers the same webhook many times concurrently. Our receiver double-charges customers when that happens — see the incident note in SPEC.md. Rework the receiver so the full contract in SPEC.md holds: duplicates must never double-apply under any interleaving, and the documented API and the store contract stay intact. `npm test` must pass, and add regression coverage for the concurrent-duplicate case so INC-104 can't come back.", + "expectedIds": [ + "skill:error-handling" + ] + }, + { + "id": "complex-production-ready", + "category": "complex-vague-hardening", + "query": "This goes to production Monday morning. Get it ready. The bar we hold production services to is in docs/production-bar.md — meet all of it, keep the documented API working, and leave the repo in a state you'd be comfortable being on-call for.", + "expectedIds": [ + "skill:backend-patterns" + ] + } + ], + "tasks": [ + { + "id": "chained-tickets", + "category": "long-horizon-chain", + "manualIds": [], + "files": { + "API.md": "# Shortlink API\n\n- `POST /links` — body `{ \"url\": string, \"ttlSeconds\"?: number }`.\n - `201` → `{ \"code\", \"shortUrl\", \"expiresAt\" }`. `code` is 6–10\n alphanumeric characters; `shortUrl` is `/`; `expiresAt` is an ISO\n timestamp. Default TTL is 7 days; `ttlSeconds` must be an integer between\n 1 and 2592000 (30 days).\n - Missing/invalid `url` or out-of-range `ttlSeconds` → `400`.\n- `GET /` — `302` with `Location` set to the original URL.\n Unknown code → `404`. Expired link → `410`.\n- `DELETE /links/` — `204`. Unknown code → `404`.\n\nAll error responses follow the envelope in `CONTRIBUTING.md`.\n", + "CONTRIBUTING.md": "# Engineering conventions\n\nThese conventions apply to every ticket, every route, every change:\n\n- **Errors**: every error response is JSON with the envelope\n `{ \"error\": { \"code\": \"\", \"message\": \"\" } }`\n and the matching HTTP status. No HTML error pages, no stack traces.\n- **Layering**: HTTP handling in `src/routes.js`, business logic in\n `src/service.js`, storage in `src/store.js`. `src/app.js` wires them.\n- **Runtime config** comes from environment variables, read at startup.\n- **Every ticket**: add tests under `test/`, add a `CHANGELOG.md` entry\n describing what shipped, and keep `README.md` accurate.\n- No external dependencies.\n", + "package.json": "{\n \"name\": \"shortlink\",\n \"private\": true,\n \"type\": \"commonjs\",\n \"scripts\": { \"test\": \"node --test test/*.test.js\" }\n}\n", + "README.md": "# shortlink\n\nInternal link shortener service. Node.js standard library only, CommonJS.\n\n- `API.md` — the HTTP contract.\n- `CONTRIBUTING.md` — engineering conventions. Every ticket follows them.\n- `src/app.js` exports `createApp()` returning an `http.Server` that is not yet\n listening; `node src/index.js ` starts the service.\n- Run the tests with `npm test`.\n" + }, + "steps": [ + { + "query": "Implement the link shortener described in API.md. Follow CONTRIBUTING.md — every convention applies.", + "check": "'use strict';\n// Step 1 grader: core API contract + conventions (envelope, layering, changelog, tests).\nconst fs = require('node:fs');\nconst path = require('node:path');\n\nconst checks = [];\nconst record = (name, ok) => checks.push({ name, ok: Boolean(ok) });\nlet finished = false;\nfunction finish() {\n if (finished) return;\n finished = true;\n for (let i = checks.length; i < 10; i++) record(`unreached-${i + 1}`, false);\n const ok = checks.filter(c => c.ok).length;\n for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\\n`);\n process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 10, passed: ok, total: 10 })}\\n`);\n process.exit(0);\n}\n// A crashing agent server must not kill the grader: score what completed.\nprocess.on('uncaughtException', finish);\nprocess.on('unhandledRejection', finish);\nconst sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\nconst root = process.cwd();\nconst hasEnvelope = body => body && body.error && typeof body.error.code === 'string'\n && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string';\n\n(async () => {\n let createApp;\n try { ({ createApp } = require(path.join(root, 'src', 'app.js'))); } catch { /* scored below */ }\n if (typeof createApp === 'function') {\n try {\n const app = createApp();\n await new Promise(resolve => app.listen(0, '127.0.0.1', resolve));\n const port = app.address().port;\n const post = (body) => fetch(`http://127.0.0.1:${port}/links`, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });\n const get = (p) => fetch(`http://127.0.0.1:${port}${p}`, { redirect: 'manual' });\n\n const created = await post({ url: 'https://example.com/landing' });\n const createdBody = await created.json().catch(() => null);\n record('create-happy-201', created.status === 201 && createdBody\n && /^[A-Za-z0-9]{6,10}$/.test(createdBody.code || '') && typeof createdBody.shortUrl === 'string'\n && typeof createdBody.expiresAt === 'string' && !Number.isNaN(Date.parse(createdBody.expiresAt)));\n\n let code = createdBody && createdBody.code;\n if (code) {\n const redirect = await get(`/${code}`);\n record('redirect-302-location', redirect.status === 302\n && redirect.headers.get('location') === 'https://example.com/landing');\n } else record('redirect-302-location', false);\n\n const unknown = await get('/nope00');\n record('unknown-code-404-envelope', unknown.status === 404 && hasEnvelope(await unknown.json().catch(() => null)));\n\n const badUrl = await post({ url: 'notaurl' });\n record('invalid-url-400-envelope', badUrl.status === 400 && hasEnvelope(await badUrl.json().catch(() => null)));\n const noBody = await post({});\n record('missing-url-400-envelope', noBody.status === 400 && hasEnvelope(await noBody.json().catch(() => null)));\n const badTtl = await post({ url: 'https://example.com', ttlSeconds: 99999999 });\n record('ttl-bounds-400-envelope', badTtl.status === 400 && hasEnvelope(await badTtl.json().catch(() => null)));\n\n const expiring = await post({ url: 'https://example.com/gone', ttlSeconds: 1 });\n const expiringBody = await expiring.json().catch(() => null);\n if (expiringBody && expiringBody.code) {\n await sleep(1300);\n const gone = await get(`/${expiringBody.code}`);\n record('expired-link-410-envelope', gone.status === 410 && hasEnvelope(await gone.json().catch(() => null)));\n } else record('expired-link-410-envelope', false);\n\n if (code) {\n const del = await fetch(`http://127.0.0.1:${port}/links/${code}`, { method: 'DELETE' });\n const after = await get(`/${code}`);\n record('delete-flow-204-then-404', del.status === 204 && after.status === 404);\n } else record('delete-flow-204-then-404', false);\n app.close();\n } catch { /* remaining checks unscored */ }\n } else {\n for (const name of ['create-happy-201', 'redirect-302-location', 'unknown-code-404-envelope',\n 'invalid-url-400-envelope', 'missing-url-400-envelope', 'ttl-bounds-400-envelope',\n 'expired-link-410-envelope', 'delete-flow-204-then-404']) record(name, false);\n }\n\n // Conventions.\n let changelog = '';\n try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ }\n let tests = '';\n try {\n for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8');\n } catch { /* missing */ }\n const testCount = (tests.match(/\\btest\\(/g) || []).length;\n record('changelog-and-tests', changelog.length > 20 && testCount >= 3);\n record('layering-files', ['routes.js', 'service.js', 'store.js']\n .every(f => fs.existsSync(path.join(root, 'src', f))));\n\n finish();\n})();\n", + "manualIds": [ + "skill:backend-patterns" + ], + "checkTimeoutMs": 60000 + }, + { + "query": "Links need to survive a service restart. Persist them to the JSON file named by the DATA_FILE environment variable (read at startup). Take care of it.", + "check": "'use strict';\n// Step 2 grader: persistence across a simulated restart (fresh module state,\n// same DATA_FILE), expiry state survives, fresh/corrupt-start tolerance, conventions.\nconst fs = require('node:fs');\nconst path = require('node:path');\n\nconst checks = [];\nconst record = (name, ok) => checks.push({ name, ok: Boolean(ok) });\nlet finished = false;\nfunction finish() {\n if (finished) return;\n finished = true;\n for (let i = checks.length; i < 7; i++) record(`unreached-${i + 1}`, false);\n const ok = checks.filter(c => c.ok).length;\n for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\\n`);\n process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 7, passed: ok, total: 7 })}\\n`);\n process.exit(0);\n}\n// A crashing agent server must not kill the grader: score what completed.\nprocess.on('uncaughtException', finish);\nprocess.on('unhandledRejection', finish);\nconst sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\nconst root = process.cwd();\nconst DATA_FILE = path.join(root, '.ecc-data', 'links.json');\nconst hasEnvelope = body => body && body.error && typeof body.error.code === 'string'\n && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string';\n\nfunction purgeApp() {\n for (const key of Object.keys(require.cache)) {\n if (key.startsWith(path.join(root, 'src') + path.sep)) delete require.cache[key];\n }\n}\n\nasync function start() {\n purgeApp();\n const { createApp } = require(path.join(root, 'src', 'app.js'));\n const app = createApp();\n await new Promise((resolve, reject) => { app.once('error', reject); app.listen(0, '127.0.0.1', resolve); });\n return app;\n}\n\n(async () => {\n process.env.DATA_FILE = DATA_FILE;\n try {\n // First boot: create a durable link and a 1s-expiring link.\n let app = await start();\n let port = app.address().port;\n const post = body => fetch(`http://127.0.0.1:${port}/links`, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });\n const durable = await (await post({ url: 'https://example.com/durable' })).json().catch(() => null);\n const short = await (await post({ url: 'https://example.com/short', ttlSeconds: 1 })).json().catch(() => null);\n await new Promise(resolve => app.close(resolve));\n\n // Restart: fresh modules, same DATA_FILE.\n app = await start();\n port = app.address().port;\n const get = p => fetch(`http://127.0.0.1:${port}${p}`, { redirect: 'manual' });\n\n const after = durable && durable.code ? await get(`/${durable.code}`) : null;\n record('link-survives-restart', after && after.status === 302\n && after.headers.get('location') === 'https://example.com/durable');\n\n await sleep(1300);\n const expiredAfter = short && short.code ? await get(`/${short.code}`) : null;\n record('expiry-survives-restart', expiredAfter && expiredAfter.status === 410);\n await new Promise(resolve => app.close(resolve));\n\n // Data file is real JSON on disk.\n let dataOk = false;\n try { JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); dataOk = true; } catch { /* missing/invalid */ }\n record('data-file-is-json', dataOk);\n\n // Fresh start with no data file present.\n fs.rmSync(DATA_FILE, { force: true });\n app = await start();\n port = app.address().port;\n const fresh = await fetch(`http://127.0.0.1:${port}/links`, {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ url: 'https://example.com/fresh' }) });\n record('fresh-start-without-data-file', fresh.status === 201);\n await new Promise(resolve => app.close(resolve));\n\n // Corrupt data file must not kill the service.\n fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });\n fs.writeFileSync(DATA_FILE, 'garbage{{{');\n app = await start();\n port = app.address().port;\n const afterCorrupt = await get('/anything1');\n record('corrupt-data-file-tolerated', afterCorrupt.status === 404\n && hasEnvelope(await afterCorrupt.json().catch(() => null)));\n await new Promise(resolve => app.close(resolve));\n fs.rmSync(DATA_FILE, { force: true });\n } catch { /* remaining checks unscored */ }\n\n let changelog = '';\n try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ }\n let tests = '';\n try {\n for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8');\n } catch { /* missing */ }\n const changelogEntries = (changelog.match(/^[-*#]/gm) || []).length;\n record('changelog-grown', changelogEntries >= 2 && /persist|restart|data/i.test(changelog));\n record('tests-grown', (tests.match(/\\btest\\(/g) || []).length >= 6);\n\n finish();\n})();\n", + "manualIds": [ + "skill:backend-patterns" + ], + "checkTimeoutMs": 60000 + }, + { + "query": "We're seeing abuse — people hammering the API and submitting junk URLs. Deal with it.", + "check": "'use strict';\n// Step 3 grader: abuse handling — URL validation, size limits, rate limiting —\n// plus conventions. Hammer probe runs last so earlier probes stay unthrottled.\nconst fs = require('node:fs');\nconst path = require('node:path');\n\nconst checks = [];\nconst record = (name, ok) => checks.push({ name, ok: Boolean(ok) });\nlet finished = false;\nfunction finish() {\n if (finished) return;\n finished = true;\n for (let i = checks.length; i < 8; i++) record(`unreached-${i + 1}`, false);\n const ok = checks.filter(c => c.ok).length;\n for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\\n`);\n process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 8, passed: ok, total: 8 })}\\n`);\n process.exit(0);\n}\n// A crashing agent server must not kill the grader: score what completed.\nprocess.on('uncaughtException', finish);\nprocess.on('unhandledRejection', finish);\nconst root = process.cwd();\nconst DATA_FILE = path.join(root, '.ecc-data', 'links-step3.json');\nconst hasEnvelope = body => body && body.error && typeof body.error.code === 'string'\n && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string';\n\nfunction purgeApp() {\n for (const key of Object.keys(require.cache)) {\n if (key.startsWith(path.join(root, 'src') + path.sep)) delete require.cache[key];\n }\n}\n\n(async () => {\n process.env.DATA_FILE = DATA_FILE;\n try {\n purgeApp();\n const { createApp } = require(path.join(root, 'src', 'app.js'));\n const app = createApp();\n await new Promise(resolve => app.listen(0, '127.0.0.1', resolve));\n const port = app.address().port;\n const post = body => fetch(`http://127.0.0.1:${port}/links`, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });\n\n const okCreate = await post({ url: 'https://example.com/normal' });\n record('normal-create-still-201', okCreate.status === 201);\n\n const js = await post({ url: 'javascript:alert(1)' });\n record('javascript-scheme-400-envelope', js.status === 400 && hasEnvelope(await js.json().catch(() => null)));\n const ftp = await post({ url: 'ftp://files.example.com/x' });\n record('non-http-scheme-400-envelope', ftp.status === 400 && hasEnvelope(await ftp.json().catch(() => null)));\n const huge = await post({ url: `https://example.com/${'a'.repeat(10000)}` });\n const hugeBody = await huge.json().catch(() => null);\n record('oversize-url-4xx-envelope', huge.status >= 400 && huge.status < 500 && hasEnvelope(hugeBody));\n\n // Hammer: 60 rapid creates must trip a 429 with the envelope.\n const responses = await Promise.all(Array.from({ length: 60 }, (_, i) =>\n post({ url: `https://example.com/flood-${i}` })));\n const limited = [];\n for (const r of responses) if (r.status === 429) limited.push(await r.json().catch(() => null));\n record('rate-limit-429-envelope', limited.length > 0 && limited.every(hasEnvelope));\n app.close();\n } catch { /* remaining checks unscored */ }\n\n let sources = '';\n try {\n for (const f of fs.readdirSync(path.join(root, 'src'))) {\n if (f.endsWith('.js')) sources += fs.readFileSync(path.join(root, 'src', f), 'utf8');\n }\n } catch { /* missing */ }\n record('rate-limiting-implemented', /429|rate.?limit/i.test(sources));\n\n let changelog = '';\n try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ }\n let tests = '';\n try {\n for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8');\n } catch { /* missing */ }\n const changelogEntries = (changelog.match(/^[-*#]/gm) || []).length;\n record('changelog-grown', changelogEntries >= 3 && /abuse|rate|valid|secur/i.test(changelog));\n record('tests-grown', (tests.match(/\\btest\\(/g) || []).length >= 9);\n\n finish();\n})();\n", + "manualIds": [ + "skill:security-review" + ], + "checkTimeoutMs": 60000 + }, + { + "query": "Track redirect hits per link and expose them at GET /links/:code/stats, consistent with the existing API.", + "check": "'use strict';\n// Step 4 grader: hit analytics consistent with the existing API, conventions,\n// docs and tests. (Runs in a later process than step 3, so rate windows cleared.)\nconst fs = require('node:fs');\nconst path = require('node:path');\n\nconst checks = [];\nconst record = (name, ok) => checks.push({ name, ok: Boolean(ok) });\nlet finished = false;\nfunction finish() {\n if (finished) return;\n finished = true;\n for (let i = checks.length; i < 8; i++) record(`unreached-${i + 1}`, false);\n const ok = checks.filter(c => c.ok).length;\n for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\\n`);\n process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 8, passed: ok, total: 8 })}\\n`);\n process.exit(0);\n}\n// A crashing agent server must not kill the grader: score what completed.\nprocess.on('uncaughtException', finish);\nprocess.on('unhandledRejection', finish);\nconst root = process.cwd();\nconst DATA_FILE = path.join(root, '.ecc-data', 'links-step4.json');\nconst hasEnvelope = body => body && body.error && typeof body.error.code === 'string'\n && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string';\n\nfunction purgeApp() {\n for (const key of Object.keys(require.cache)) {\n if (key.startsWith(path.join(root, 'src') + path.sep)) delete require.cache[key];\n }\n}\n\n(async () => {\n process.env.DATA_FILE = DATA_FILE;\n try {\n purgeApp();\n const { createApp } = require(path.join(root, 'src', 'app.js'));\n const app = createApp();\n await new Promise(resolve => app.listen(0, '127.0.0.1', resolve));\n const port = app.address().port;\n\n const created = await fetch(`http://127.0.0.1:${port}/links`, {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ url: 'https://example.com/tracked' }) });\n const body = await created.json().catch(() => null);\n const code = body && body.code;\n record('create-still-works', created.status === 201 && Boolean(code));\n\n if (code) {\n const before = await fetch(`http://127.0.0.1:${port}/links/${code}/stats`);\n const beforeBody = await before.json().catch(() => null);\n record('stats-zero-before-redirects', before.status === 200 && beforeBody && beforeBody.hits === 0);\n\n for (let i = 0; i < 3; i++) {\n await fetch(`http://127.0.0.1:${port}/${code}`, { redirect: 'manual' });\n }\n const stats = await fetch(`http://127.0.0.1:${port}/links/${code}/stats`);\n const statsBody = await stats.json().catch(() => null);\n record('stats-count-three-hits', stats.status === 200 && statsBody && statsBody.hits === 3);\n\n const redirect = await fetch(`http://127.0.0.1:${port}/${code}`, { redirect: 'manual' });\n record('redirect-still-302', redirect.status === 302);\n\n const missing = await fetch(`http://127.0.0.1:${port}/links/zzzzzz/stats`);\n record('stats-unknown-404-envelope', missing.status === 404\n && hasEnvelope(await missing.json().catch(() => null)));\n } else {\n for (const name of ['stats-zero-before-redirects', 'stats-count-three-hits',\n 'redirect-still-302', 'stats-unknown-404-envelope']) record(name, false);\n }\n app.close();\n } catch { /* remaining checks unscored */ }\n\n let readme = '';\n try { readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8'); } catch { /* missing */ }\n record('readme-documents-stats', /\\/stats|hits|analytics/i.test(readme));\n let changelog = '';\n try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ }\n let tests = '';\n try {\n for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8');\n } catch { /* missing */ }\n const changelogEntries = (changelog.match(/^[-*#]/gm) || []).length;\n record('changelog-grown', changelogEntries >= 4 && /stat|analytic|hit/i.test(changelog));\n record('tests-grown', (tests.match(/\\btest\\(/g) || []).length >= 12);\n\n finish();\n})();\n", + "manualIds": [ + "skill:api-design" + ], + "checkTimeoutMs": 60000 + } + ] + }, + { + "id": "idempotent-webhooks", + "category": "concurrency-trap", + "manualIds": [ + "skill:error-handling" + ], + "checkTimeoutMs": 60000, + "query": "The payments provider retries aggressively and sometimes delivers the same webhook many times concurrently. Our receiver double-charges customers when that happens — see the incident note in SPEC.md. Rework the receiver so the full contract in SPEC.md holds: duplicates must never double-apply under any interleaving, and the documented API and the store contract stay intact. `npm test` must pass, and add regression coverage for the concurrent-duplicate case so INC-104 can't come back.", + "files": { + "package.json": "{\n \"name\": \"webhook-receiver\",\n \"private\": true,\n \"type\": \"commonjs\",\n \"scripts\": { \"test\": \"node --test test/*.test.js\" }\n}\n", + "README.md": "# webhook-receiver\n\nReceives payment webhooks. There is an open incident: customers were\ndouble-charged when the provider retried deliveries. See `SPEC.md` for the\ncontract, including the exactly-once rules.\n\n- `src/app.js` exports `createApp()` returning an `http.Server` that is not\n yet listening; `node src/index.js ` starts the service.\n- `src/store.js` is shared infrastructure: it keeps its current exports\n (`store`) and records every applied payment in `store.paymentLog`.\n- No external dependencies. `npm test` runs the tests. `CHANGELOG.md` records\n every shipped change.\n", + "SPEC.md": "# Payment webhook contract\n\n`POST /webhooks/payments` with JSON body\n`{ \"eventId\": string, \"orderId\": string, \"amountCents\": number, \"type\": \"payment.succeeded\" }`.\n\nExactly-once is the point. The provider retries aggressively and may deliver\nthe same event many times, concurrently, or out of order.\n\n- A new, valid `eventId`: apply the payment exactly once → `200`\n `{ \"status\": \"processed\", \"orderId\" }`.\n- The same `eventId` seen again (any number of times, any interleaving):\n `200` `{ \"status\": \"duplicate\", \"orderId\" }` — never applied twice.\n- A payment event (new `eventId`) for an order that is already paid:\n `200` `{ \"status\": \"already_paid\", \"orderId\" }` — an order is paid at most\n once, ever.\n- `amountCents` not matching the order's amount: `422`, not applied.\n- Unknown `orderId`: `404`. Malformed body (bad JSON, missing/invalid\n fields): `400`.\n- Error responses use the envelope\n `{ \"error\": { \"code\": \"\", \"message\": \"...\" } }`.\n\n`GET /orders/:id` → `200` `{ \"id\", \"status\", \"paidAt\", \"paymentsApplied\" }`\nor a `404` envelope.\n\n## Incident note\n\nINC-104: concurrent duplicate deliveries double-applied payments. The naive\nreceiver checked \"have we seen this event?\" and applied the payment in two\nseparate steps with an async gap in between, so parallel duplicates both\npassed the check.\n", + "src/app.js": "'use strict';\nconst http = require('node:http');\nconst { store } = require('./store');\n\n// INC-104 receiver: checks \"seen this event?\" and applies the payment in two\n// steps with an async gap in between. Concurrent duplicates both pass the\n// check. Do not keep this shape.\nfunction createApp() {\n return http.createServer((req, res) => {\n const url = new URL(req.url, 'http://localhost');\n\n if (req.method === 'POST' && url.pathname === '/webhooks/payments') {\n let body = '';\n req.on('data', chunk => { body += chunk; });\n req.on('end', async () => {\n const parsed = JSON.parse(body);\n const { eventId, orderId } = parsed;\n if (store.processedEvents.has(eventId)) {\n res.writeHead(200, { 'content-type': 'application/json' });\n res.end(JSON.stringify({ status: 'duplicate', orderId }));\n return;\n }\n await new Promise(resolve => setImmediate(resolve)); // async gap\n const order = store.orders.get(orderId);\n order.status = 'paid';\n order.paidAt = new Date().toISOString();\n order.paymentsApplied++;\n store.paymentLog.push({ eventId, orderId, amountCents: parsed.amountCents });\n store.processedEvents.add(eventId);\n res.writeHead(200, { 'content-type': 'application/json' });\n res.end(JSON.stringify({ status: 'processed', orderId }));\n });\n return;\n }\n\n const match = /^\\/orders\\/([\\w-]+)$/.exec(url.pathname);\n if (req.method === 'GET' && match) {\n const order = store.orders.get(match[1]);\n if (!order) {\n res.writeHead(404, { 'content-type': 'application/json' });\n res.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'no such order' } }));\n return;\n }\n res.writeHead(200, { 'content-type': 'application/json' });\n res.end(JSON.stringify(order));\n return;\n }\n\n res.writeHead(404, { 'content-type': 'application/json' });\n res.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'not found' } }));\n });\n}\n\nmodule.exports = { createApp };\n", + "src/index.js": "'use strict';\nconst { createApp } = require('./app');\n\nconst port = Number(process.argv[2] || 8080);\ncreateApp().listen(port, () => {\n console.log(`webhook-receiver listening on ${port}`);\n});\n", + "src/store.js": "'use strict';\n\n// Shared infrastructure. Every applied payment is appended to paymentLog;\n// orders and processedEvents track receiver state. Keep the `store` export.\nconst store = {\n orders: new Map([\n ['o1', { id: 'o1', amountCents: 5000, status: 'pending', paidAt: null, paymentsApplied: 0 }],\n ['o2', { id: 'o2', amountCents: 12500, status: 'pending', paidAt: null, paymentsApplied: 0 }],\n ['o3', { id: 'o3', amountCents: 800, status: 'pending', paidAt: null, paymentsApplied: 0 }],\n ['o4', { id: 'o4', amountCents: 9999, status: 'pending', paidAt: null, paymentsApplied: 0 }],\n ['o5', { id: 'o5', amountCents: 250, status: 'pending', paidAt: null, paymentsApplied: 0 }],\n ['o6', { id: 'o6', amountCents: 7300, status: 'pending', paidAt: null, paymentsApplied: 0 }],\n ]),\n paymentLog: [],\n processedEvents: new Set(),\n};\n\nmodule.exports = { store };\n", + "test/webhooks.test.js": "'use strict';\nconst test = require('node:test');\nconst assert = require('node:assert/strict');\nconst { createApp } = require('../src/app');\nconst { store } = require('../src/store');\n\ntest('a single payment event processes', async () => {\n const server = createApp();\n await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));\n try {\n const port = server.address().port;\n const res = await fetch(`http://127.0.0.1:${port}/webhooks/payments`, {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ eventId: 'ev-test-1', orderId: 'o1', amountCents: 5000, type: 'payment.succeeded' }) });\n assert.equal(res.status, 200);\n assert.equal((await res.json()).status, 'processed');\n assert.equal(store.orders.get('o1').status, 'paid');\n } finally {\n server.close();\n }\n});\n" + }, + "check": "'use strict';\n// Hidden grader for idempotent-webhooks: exactly-once under sequential,\n// concurrent, and mixed-concurrent duplicates, plus the documented API,\n// regression coverage, and hygiene. Prints ECC_EVAL_SCORE and always exits 0.\nconst fs = require('node:fs');\nconst path = require('node:path');\n\nconst checks = [];\nconst record = (name, ok) => checks.push({ name, ok: Boolean(ok) });\nlet finished = false;\nfunction finish() {\n if (finished) return;\n finished = true;\n for (let i = checks.length; i < 12; i++) record(`unreached-${i + 1}`, false);\n const ok = checks.filter(c => c.ok).length;\n for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\\n`);\n process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 12, passed: ok, total: 12 })}\\n`);\n process.exit(0);\n}\n// A crashing agent server must not kill the grader: score what completed.\nprocess.on('uncaughtException', finish);\nprocess.on('unhandledRejection', finish);\nconst root = process.cwd();\nconst hasEnvelope = body => body && body.error && typeof body.error.code === 'string'\n && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string';\n\n(async () => {\n let createApp;\n let store;\n try {\n ({ createApp } = require(path.join(root, 'src', 'app.js')));\n ({ store } = require(path.join(root, 'src', 'store.js')));\n } catch { /* scored below */ }\n if (typeof createApp === 'function' && store && Array.isArray(store.paymentLog)) {\n try {\n const app = createApp();\n await new Promise(resolve => app.listen(0, '127.0.0.1', resolve));\n const port = app.address().port;\n const send = (eventId, orderId, amountCents) => fetch(`http://127.0.0.1:${port}/webhooks/payments`, {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ eventId, orderId, amountCents, type: 'payment.succeeded' }) });\n const logsFor = orderId => store.paymentLog.filter(p => p.orderId === orderId).length;\n\n // 1: single delivery applies once.\n const single = await send('ev-1', 'o1', 5000);\n const singleBody = await single.json().catch(() => null);\n record('single-delivery-processed', single.status === 200 && singleBody\n && singleBody.status === 'processed' && singleBody.orderId === 'o1' && logsFor('o1') === 1);\n\n // 2: sequential retry replays without re-applying.\n const retry = await send('ev-1', 'o1', 5000);\n const retryBody = await retry.json().catch(() => null);\n record('sequential-duplicate-inert', retry.status === 200 && retryBody\n && retryBody.status === 'duplicate' && logsFor('o1') === 1);\n\n // 3: fifty concurrent identical deliveries apply exactly once.\n const storm = await Promise.all(Array.from({ length: 50 }, () => send('ev-2', 'o2', 12500)));\n const stormBodies = [];\n for (const r of storm) stormBodies.push(await r.json().catch(() => null));\n const processedCount = stormBodies.filter(b => b && b.status === 'processed').length;\n const duplicateCount = stormBodies.filter(b => b && b.status === 'duplicate').length;\n record('concurrent-storm-exactly-once', storm.every(r => r.status === 200)\n && processedCount === 1 && duplicateCount === 49 && logsFor('o2') === 1\n && store.orders.get('o2').paymentsApplied === 1);\n\n // 4: a different event for an already-paid order is already_paid and inert.\n const second = await send('ev-3', 'o2', 12500);\n const secondBody = await second.json().catch(() => null);\n record('already-paid-order-inert', second.status === 200 && secondBody\n && secondBody.status === 'already_paid' && logsFor('o2') === 1);\n\n // 5-7: contract errors with envelopes.\n const unknown = await send('ev-4', 'nope', 100);\n record('unknown-order-404-envelope', unknown.status === 404 && hasEnvelope(await unknown.json().catch(() => null)));\n const malformed = await fetch(`http://127.0.0.1:${port}/webhooks/payments`, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: '{bad json' });\n record('malformed-body-400-envelope', malformed.status === 400 && hasEnvelope(await malformed.json().catch(() => null)));\n const mismatch = await send('ev-5', 'o3', 999999);\n record('amount-mismatch-422-envelope', mismatch.status === 422\n && hasEnvelope(await mismatch.json().catch(() => null)) && logsFor('o3') === 0);\n\n // 8: mixed storm — three orders, three eventIds, ten duplicates each, all concurrent.\n const mixed = await Promise.all(['o4', 'o5', 'o6'].flatMap(orderId =>\n Array.from({ length: 10 }, () => send(`ev-${orderId}`, orderId, store.orders.get(orderId).amountCents))));\n for (const r of mixed) await r.json().catch(() => null);\n record('mixed-storm-each-order-once', ['o4', 'o5', 'o6'].every(orderId =>\n logsFor(orderId) === 1 && store.orders.get(orderId).paymentsApplied === 1));\n\n // 9: order inspection endpoint reflects reality.\n const orderView = await fetch(`http://127.0.0.1:${port}/orders/o2`);\n const orderBody = await orderView.json().catch(() => null);\n record('order-endpoint-accurate', orderView.status === 200 && orderBody\n && orderBody.status === 'paid' && orderBody.paymentsApplied === 1 && Boolean(orderBody.paidAt));\n\n app.close();\n } catch { /* remaining checks unscored */ }\n } else {\n for (const name of ['single-delivery-processed', 'sequential-duplicate-inert', 'concurrent-storm-exactly-once',\n 'already-paid-order-inert', 'unknown-order-404-envelope', 'malformed-body-400-envelope',\n 'amount-mismatch-422-envelope', 'mixed-storm-each-order-once', 'order-endpoint-accurate']) record(name, false);\n }\n\n // Conventions.\n let tests = '';\n try {\n for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8');\n } catch { /* missing */ }\n record('concurrency-regression-tests', (tests.match(/\\btest\\(/g) || []).length >= 4\n && /Promise\\.all|concurrent|duplicate|retry/i.test(tests));\n let changelog = '';\n try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ }\n record('changelog-entry', /idem|duplicat|retry|inc-104|race/i.test(changelog));\n try {\n const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));\n record('no-external-dependencies', !pkg.dependencies && !pkg.devDependencies);\n } catch { record('no-external-dependencies', false); }\n\n finish();\n})();\n" + }, + { + "id": "production-ready", + "category": "vague-hardening", + "manualIds": [ + "skill:backend-patterns" + ], + "checkTimeoutMs": 60000, + "query": "This goes to production Monday morning. Get it ready. The bar we hold production services to is in docs/production-bar.md — meet all of it, keep the documented API working, and leave the repo in a state you'd be comfortable being on-call for.", + "files": { + "docs/production-bar.md": "# The production bar\n\nEvery production service here meets all of the following, all the time:\n\n- **Validation**: malformed JSON, missing fields, and wrong types are rejected\n with `400` and a structured JSON error body\n `{ \"error\": { \"code\": \"\", \"message\": \"...\" } }`. Unknown\n resources are `404` in the same envelope. No stack traces, no HTML errors,\n no hanging connections.\n- **Body limits**: request bodies over 64 KB are rejected with `413`, same\n envelope.\n- **Health**: `GET /health` returns `200` with `{ \"status\": \"ok\" }`.\n- **Logging**: one structured JSON log line per request with at least\n `method`, `path`, and `status` fields.\n- **Configuration**: runtime configuration (port, limits) comes from\n environment variables, read at startup. Nothing secret is hardcoded.\n- **Shutdown**: the service closes cleanly on `SIGTERM` (stops accepting,\n drains, exits).\n- **Headers**: responses carry `X-Content-Type-Options: nosniff`.\n- **Tests**: the suite covers error paths, not just the happy path.\n- **Changelog**: every shipped change has a `CHANGELOG.md` entry.\n", + "package.json": "{\n \"name\": \"notes-service\",\n \"private\": true,\n \"type\": \"commonjs\",\n \"scripts\": { \"test\": \"node --test test/*.test.js\" }\n}\n", + "README.md": "# notes-service\n\nTiny notes API. Hobby prototype state: it works on the happy path and that's\nabout all that can be said for it.\n\n## API\n\n- `POST /notes` — body `{ \"title\": string, \"body\": string }` → `201` with\n `{ \"id\", \"title\", \"body\" }`.\n- `GET /notes/:id` — `200` with the note, or `404`.\n- `GET /notes` — `200` with `{ \"notes\": [...] }`.\n\n`src/app.js` exports `createApp()` returning an `http.Server` that is not yet\nlistening; `node src/index.js` starts the service. `npm test` runs the tests.\n\n## Operations\n\n`docs/production-bar.md` lists what every production service here must meet.\n`CHANGELOG.md` records every shipped change.\n", + "src/app.js": "'use strict';\nconst http = require('node:http');\n\n// Prototype state: happy path only.\nconst notes = new Map();\nlet nextId = 1;\n\nfunction createApp() {\n return http.createServer((req, res) => {\n console.log('got a request');\n const url = new URL(req.url, 'http://localhost');\n\n if (req.method === 'POST' && url.pathname === '/notes') {\n let body = '';\n req.on('data', chunk => { body += chunk; });\n req.on('end', () => {\n const parsed = JSON.parse(body);\n const id = `n_${nextId++}`;\n notes.set(id, { id, title: parsed.title, body: parsed.body });\n res.writeHead(201, { 'content-type': 'application/json' });\n res.end(JSON.stringify(notes.get(id)));\n });\n return;\n }\n\n const match = /^\\/notes\\/([\\w-]+)$/.exec(url.pathname);\n if (req.method === 'GET' && match) {\n const note = notes.get(match[1]);\n if (!note) {\n res.writeHead(404);\n res.end('not found');\n return;\n }\n res.writeHead(200, { 'content-type': 'application/json' });\n res.end(JSON.stringify(note));\n return;\n }\n\n if (req.method === 'GET' && url.pathname === '/notes') {\n res.writeHead(200, { 'content-type': 'application/json' });\n res.end(JSON.stringify({ notes: [...notes.values()] }));\n return;\n }\n\n res.writeHead(404);\n res.end('not found');\n });\n}\n\nmodule.exports = { createApp };\n", + "src/index.js": "'use strict';\nconst { createApp } = require('./app');\n\ncreateApp().listen(8080, () => {\n console.log('notes listening on 8080');\n});\n", + "test/notes.test.js": "'use strict';\nconst test = require('node:test');\nconst assert = require('node:assert/strict');\nconst { createApp } = require('../src/app');\n\ntest('create and read a note', async () => {\n const server = createApp();\n await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));\n try {\n const port = server.address().port;\n const created = await fetch(`http://127.0.0.1:${port}/notes`, {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ title: 'first', body: 'hello' }) });\n assert.equal(created.status, 201);\n const { id } = await created.json();\n const read = await fetch(`http://127.0.0.1:${port}/notes/${id}`);\n assert.equal((await read.json()).title, 'first');\n } finally {\n server.close();\n }\n});\n" + }, + "check": "'use strict';\n// Hidden grader for production-ready: probes every dimension of the documented\n// production bar. Prints ECC_EVAL_SCORE and always exits 0.\nconst fs = require('node:fs');\nconst path = require('node:path');\n\nconst checks = [];\nconst record = (name, ok) => checks.push({ name, ok: Boolean(ok) });\nlet finished = false;\nfunction finish() {\n if (finished) return;\n finished = true;\n for (let i = checks.length; i < 16; i++) record(`unreached-${i + 1}`, false);\n const ok = checks.filter(c => c.ok).length;\n for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\\n`);\n process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 16, passed: ok, total: 16 })}\\n`);\n process.exit(0);\n}\n// A crashing agent server must not kill the grader: score what completed.\nprocess.on('uncaughtException', finish);\nprocess.on('unhandledRejection', finish);\nconst root = process.cwd();\nconst hasEnvelope = body => body && body.error && typeof body.error.code === 'string'\n && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string';\n\n(async () => {\n let createApp;\n try { ({ createApp } = require(path.join(root, 'src', 'app.js'))); } catch { /* scored below */ }\n if (typeof createApp === 'function') {\n // Capture console output during the probe run to inspect request logging.\n const logged = [];\n const originalLog = console.log;\n const originalError = console.error;\n console.log = (...args) => { logged.push(args.join(' ')); };\n console.error = (...args) => { logged.push(args.join(' ')); };\n try {\n const app = createApp();\n await new Promise(resolve => app.listen(0, '127.0.0.1', resolve));\n const port = app.address().port;\n const api = (p, options) => fetch(`http://127.0.0.1:${port}${p}`, options);\n const post = body => api('/notes', { method: 'POST', headers: { 'content-type': 'application/json' }, body });\n\n // Documented API still works.\n const created = await post(JSON.stringify({ title: 'deploy', body: 'checklist' }));\n const createdBody = await created.json().catch(() => null);\n record('api-roundtrip-preserved', created.status === 201 && createdBody && createdBody.id\n && (await (await api(`/notes/${createdBody.id}`)).json().catch(() => ({}))).title === 'deploy'\n && Array.isArray((await (await api('/notes')).json().catch(() => ({}))).notes));\n\n // Validation and envelope discipline.\n const badJson = await post('{not json');\n record('malformed-json-400-envelope', badJson.status === 400 && hasEnvelope(await badJson.json().catch(() => null)));\n const missing = await post(JSON.stringify({ body: 'no title' }));\n record('missing-field-400-envelope', missing.status === 400 && hasEnvelope(await missing.json().catch(() => null)));\n const wrongType = await post(JSON.stringify({ title: 42, body: 'x' }));\n record('wrong-type-400-envelope', wrongType.status === 400 && hasEnvelope(await wrongType.json().catch(() => null)));\n const unknown = await api('/notes/n_999999');\n const unknownBody = await unknown.text();\n let unknownParsed = null;\n try { unknownParsed = JSON.parse(unknownBody); } catch { /* html or text */ }\n record('unknown-404-json-envelope', unknown.status === 404 && hasEnvelope(unknownParsed));\n\n // Body limit.\n const big = await post(JSON.stringify({ title: 'big', body: 'x'.repeat(100 * 1024) }));\n record('oversize-body-413-envelope', big.status === 413 && hasEnvelope(await big.json().catch(() => null)));\n\n // Health endpoint.\n const health = await api('/health');\n const healthBody = await health.json().catch(() => null);\n record('health-endpoint', health.status === 200 && healthBody && healthBody.status === 'ok');\n\n // Security header on a normal response.\n const headers = await api('/notes');\n record('nosniff-header', headers.headers.get('x-content-type-options') === 'nosniff');\n\n // Error responses carry JSON content type.\n record('errors-are-json', /application\\/json/.test(unknown.headers.get('content-type') || ''));\n\n app.close();\n } catch { /* remaining checks unscored */ } finally {\n console.log = originalLog;\n console.error = originalError;\n }\n\n // Structured request logging: at least one JSON line with method/path/status-ish fields.\n const structured = logged.some(line => {\n try {\n const parsed = JSON.parse(line);\n return parsed && typeof parsed === 'object'\n && /method/i.test(Object.keys(parsed).join(' '))\n && /path|url/i.test(Object.keys(parsed).join(' '))\n && /status/i.test(Object.keys(parsed).join(' '));\n } catch { return false; }\n });\n record('structured-request-logs', structured);\n } else {\n for (const name of ['api-roundtrip-preserved', 'malformed-json-400-envelope', 'missing-field-400-envelope',\n 'wrong-type-400-envelope', 'unknown-404-json-envelope', 'oversize-body-413-envelope', 'health-endpoint',\n 'nosniff-header', 'errors-are-json', 'structured-request-logs']) record(name, false);\n }\n\n // Static dimensions.\n let sources = '';\n const walk = directory => {\n for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {\n const item = path.join(directory, entry.name);\n if (entry.isDirectory()) walk(item);\n else if (entry.name.endsWith('.js')) sources += fs.readFileSync(item, 'utf8');\n }\n };\n try { walk(path.join(root, 'src')); } catch { /* none */ }\n record('sigterm-graceful-shutdown', /SIGTERM/.test(sources));\n record('env-config-port', /process\\.env\\.[A-Z_]*PORT/.test(sources));\n\n let tests = '';\n try {\n for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8');\n } catch { /* missing */ }\n const testCount = (tests.match(/\\btest\\(/g) || []).length;\n record('tests-cover-error-paths', testCount >= 4 && /400|404|413|invalid|error/i.test(tests));\n\n let changelog = '';\n try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ }\n record('changelog-entry', changelog.length > 20 && /product|harden|valid|health|log/i.test(changelog));\n\n record('no-leftover-todos', !/TODO|FIXME/.test(sources));\n try {\n const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));\n record('no-external-dependencies', !pkg.dependencies && !pkg.devDependencies);\n } catch { record('no-external-dependencies', false); }\n\n finish();\n})();\n" + } + ] +} diff --git a/docker/context-profiles/complex-corpus-v4.json b/docker/context-profiles/complex-corpus-v4.json new file mode 100644 index 000000000..615eb16d0 --- /dev/null +++ b/docker/context-profiles/complex-corpus-v4.json @@ -0,0 +1,167 @@ +{ + "schemaVersion": "ecc.context-eval-complex-corpus.v1", + "id": "complex-tasks@4", + "sampling": "Realistic multi-file engineering tasks, fixed before any provider call, with deterministic hidden graders scoring partial credit (ECC_EVAL_SCORE). Descriptive pilot: no population-representativeness claim. See complex-eval/DESIGN.md for the preregistered methodology.", + "minimumDistinctTasks": 4, + "nonInferiorityMargin": 0.05, + "selection": [ + { + "id": "complex-chained-tickets", + "category": "complex-long-horizon", + "query": "Implement the link shortener described in API.md. Follow CONTRIBUTING.md — every convention applies. Links need to survive a service restart. Persist them to the JSON file named by the DATA_FILE environment variable (read at startup). Take care of it. We're seeing abuse — people hammering the API and submitting junk URLs. Deal with it. Track redirect hits per link and expose them at GET /links/:code/stats, consistent with the existing API.", + "expectedIds": [ + "skill:backend-patterns" + ] + }, + { + "id": "complex-idempotent-webhooks", + "category": "complex-concurrency-trap", + "query": "The payments provider retries aggressively and sometimes delivers the same webhook many times concurrently. Our receiver double-charges customers when that happens — see the incident note in SPEC.md. Rework the receiver so the full contract in SPEC.md holds: duplicates must never double-apply under any interleaving, and the documented API and the store contract stay intact. `npm test` must pass, and add regression coverage for the concurrent-duplicate case so INC-104 can't come back.", + "expectedIds": [ + "skill:error-handling" + ] + }, + { + "id": "complex-production-ready", + "category": "complex-vague-hardening", + "query": "This goes to production Monday morning. Get it ready. The bar we hold production services to is in docs/production-bar.md — meet all of it, keep the documented API working, and leave the repo in a state you'd be comfortable being on-call for.", + "expectedIds": [ + "skill:backend-patterns" + ] + }, + { + "id": "complex-recurring-incident", + "category": "complex-learning-loop", + "query": "Customers are reporting duplicate refunds again — see docs/incidents.md. When a refund request comes in without an idempotency key and the caller retries, we refund the customer twice. Fix it, and make sure this stops being a recurring incident — it's the third time this quarter. Finance just flagged that their payout batch job is about to start retrying on timeouts, and payout retries can double-pay vendors. Same family of problem as the refunds — handle it. One hard requirement: a retried payout must never pay a vendor twice, even if the service restarts between the attempts. You're rolling off this area. Write the handoff note for whoever picks this up next.", + "expectedIds": [ + "skill:continuous-learning" + ] + } + ], + "tasks": [ + { + "id": "chained-tickets", + "category": "long-horizon-chain", + "manualIds": [], + "files": { + "API.md": "# Shortlink API\n\n- `POST /links` — body `{ \"url\": string, \"ttlSeconds\"?: number }`.\n - `201` → `{ \"code\", \"shortUrl\", \"expiresAt\" }`. `code` is 6–10\n alphanumeric characters; `shortUrl` is `/`; `expiresAt` is an ISO\n timestamp. Default TTL is 7 days; `ttlSeconds` must be an integer between\n 1 and 2592000 (30 days).\n - Missing/invalid `url` or out-of-range `ttlSeconds` → `400`.\n- `GET /` — `302` with `Location` set to the original URL.\n Unknown code → `404`. Expired link → `410`.\n- `DELETE /links/` — `204`. Unknown code → `404`.\n\nAll error responses follow the envelope in `CONTRIBUTING.md`.\n", + "CONTRIBUTING.md": "# Engineering conventions\n\nThese conventions apply to every ticket, every route, every change:\n\n- **Errors**: every error response is JSON with the envelope\n `{ \"error\": { \"code\": \"\", \"message\": \"\" } }`\n and the matching HTTP status. No HTML error pages, no stack traces.\n- **Layering**: HTTP handling in `src/routes.js`, business logic in\n `src/service.js`, storage in `src/store.js`. `src/app.js` wires them.\n- **Runtime config** comes from environment variables, read at startup.\n- **Every ticket**: add tests under `test/`, add a `CHANGELOG.md` entry\n describing what shipped, and keep `README.md` accurate.\n- No external dependencies.\n", + "package.json": "{\n \"name\": \"shortlink\",\n \"private\": true,\n \"type\": \"commonjs\",\n \"scripts\": { \"test\": \"node --test test/*.test.js\" }\n}\n", + "README.md": "# shortlink\n\nInternal link shortener service. Node.js standard library only, CommonJS.\n\n- `API.md` — the HTTP contract.\n- `CONTRIBUTING.md` — engineering conventions. Every ticket follows them.\n- `src/app.js` exports `createApp()` returning an `http.Server` that is not yet\n listening; `node src/index.js ` starts the service.\n- Run the tests with `npm test`.\n" + }, + "steps": [ + { + "query": "Implement the link shortener described in API.md. Follow CONTRIBUTING.md — every convention applies.", + "check": "'use strict';\n// Step 1 grader: core API contract + conventions (envelope, layering, changelog, tests).\nconst fs = require('node:fs');\nconst path = require('node:path');\n\nconst checks = [];\nconst record = (name, ok) => checks.push({ name, ok: Boolean(ok) });\nlet finished = false;\nfunction finish() {\n if (finished) return;\n finished = true;\n for (let i = checks.length; i < 10; i++) record(`unreached-${i + 1}`, false);\n const ok = checks.filter(c => c.ok).length;\n for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\\n`);\n process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 10, passed: ok, total: 10 })}\\n`);\n process.exit(0);\n}\n// A crashing agent server must not kill the grader: score what completed.\nprocess.on('uncaughtException', finish);\nprocess.on('unhandledRejection', finish);\nconst sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\nconst root = process.cwd();\nconst hasEnvelope = body => body && body.error && typeof body.error.code === 'string'\n && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string';\n\n(async () => {\n let createApp;\n try { ({ createApp } = require(path.join(root, 'src', 'app.js'))); } catch { /* scored below */ }\n if (typeof createApp === 'function') {\n try {\n const app = createApp();\n await new Promise(resolve => app.listen(0, '127.0.0.1', resolve));\n const port = app.address().port;\n const post = (body) => fetch(`http://127.0.0.1:${port}/links`, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });\n const get = (p) => fetch(`http://127.0.0.1:${port}${p}`, { redirect: 'manual' });\n\n const created = await post({ url: 'https://example.com/landing' });\n const createdBody = await created.json().catch(() => null);\n record('create-happy-201', created.status === 201 && createdBody\n && /^[A-Za-z0-9]{6,10}$/.test(createdBody.code || '') && typeof createdBody.shortUrl === 'string'\n && typeof createdBody.expiresAt === 'string' && !Number.isNaN(Date.parse(createdBody.expiresAt)));\n\n let code = createdBody && createdBody.code;\n if (code) {\n const redirect = await get(`/${code}`);\n record('redirect-302-location', redirect.status === 302\n && redirect.headers.get('location') === 'https://example.com/landing');\n } else record('redirect-302-location', false);\n\n const unknown = await get('/nope00');\n record('unknown-code-404-envelope', unknown.status === 404 && hasEnvelope(await unknown.json().catch(() => null)));\n\n const badUrl = await post({ url: 'notaurl' });\n record('invalid-url-400-envelope', badUrl.status === 400 && hasEnvelope(await badUrl.json().catch(() => null)));\n const noBody = await post({});\n record('missing-url-400-envelope', noBody.status === 400 && hasEnvelope(await noBody.json().catch(() => null)));\n const badTtl = await post({ url: 'https://example.com', ttlSeconds: 99999999 });\n record('ttl-bounds-400-envelope', badTtl.status === 400 && hasEnvelope(await badTtl.json().catch(() => null)));\n\n const expiring = await post({ url: 'https://example.com/gone', ttlSeconds: 1 });\n const expiringBody = await expiring.json().catch(() => null);\n if (expiringBody && expiringBody.code) {\n await sleep(1300);\n const gone = await get(`/${expiringBody.code}`);\n record('expired-link-410-envelope', gone.status === 410 && hasEnvelope(await gone.json().catch(() => null)));\n } else record('expired-link-410-envelope', false);\n\n if (code) {\n const del = await fetch(`http://127.0.0.1:${port}/links/${code}`, { method: 'DELETE' });\n const after = await get(`/${code}`);\n record('delete-flow-204-then-404', del.status === 204 && after.status === 404);\n } else record('delete-flow-204-then-404', false);\n app.close();\n } catch { /* remaining checks unscored */ }\n } else {\n for (const name of ['create-happy-201', 'redirect-302-location', 'unknown-code-404-envelope',\n 'invalid-url-400-envelope', 'missing-url-400-envelope', 'ttl-bounds-400-envelope',\n 'expired-link-410-envelope', 'delete-flow-204-then-404']) record(name, false);\n }\n\n // Conventions.\n let changelog = '';\n try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ }\n let tests = '';\n try {\n for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8');\n } catch { /* missing */ }\n const testCount = (tests.match(/\\btest\\(/g) || []).length;\n record('changelog-and-tests', changelog.length > 20 && testCount >= 3);\n record('layering-files', ['routes.js', 'service.js', 'store.js']\n .every(f => fs.existsSync(path.join(root, 'src', f))));\n\n finish();\n})();\n", + "manualIds": [ + "skill:backend-patterns" + ], + "checkTimeoutMs": 60000 + }, + { + "query": "Links need to survive a service restart. Persist them to the JSON file named by the DATA_FILE environment variable (read at startup). Take care of it.", + "check": "'use strict';\n// Step 2 grader: persistence across a simulated restart (fresh module state,\n// same DATA_FILE), expiry state survives, fresh/corrupt-start tolerance, conventions.\nconst fs = require('node:fs');\nconst path = require('node:path');\n\nconst checks = [];\nconst record = (name, ok) => checks.push({ name, ok: Boolean(ok) });\nlet finished = false;\nfunction finish() {\n if (finished) return;\n finished = true;\n for (let i = checks.length; i < 7; i++) record(`unreached-${i + 1}`, false);\n const ok = checks.filter(c => c.ok).length;\n for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\\n`);\n process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 7, passed: ok, total: 7 })}\\n`);\n process.exit(0);\n}\n// A crashing agent server must not kill the grader: score what completed.\nprocess.on('uncaughtException', finish);\nprocess.on('unhandledRejection', finish);\nconst sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\nconst root = process.cwd();\nconst DATA_FILE = path.join(root, '.ecc-data', 'links.json');\nconst hasEnvelope = body => body && body.error && typeof body.error.code === 'string'\n && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string';\n\nfunction purgeApp() {\n for (const key of Object.keys(require.cache)) {\n if (key.startsWith(path.join(root, 'src') + path.sep)) delete require.cache[key];\n }\n}\n\nasync function start() {\n purgeApp();\n const { createApp } = require(path.join(root, 'src', 'app.js'));\n const app = createApp();\n await new Promise((resolve, reject) => { app.once('error', reject); app.listen(0, '127.0.0.1', resolve); });\n return app;\n}\n\n(async () => {\n process.env.DATA_FILE = DATA_FILE;\n try {\n // First boot: create a durable link and a 1s-expiring link.\n let app = await start();\n let port = app.address().port;\n const post = body => fetch(`http://127.0.0.1:${port}/links`, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });\n const durable = await (await post({ url: 'https://example.com/durable' })).json().catch(() => null);\n const short = await (await post({ url: 'https://example.com/short', ttlSeconds: 1 })).json().catch(() => null);\n await new Promise(resolve => app.close(resolve));\n\n // Restart: fresh modules, same DATA_FILE.\n app = await start();\n port = app.address().port;\n const get = p => fetch(`http://127.0.0.1:${port}${p}`, { redirect: 'manual' });\n\n const after = durable && durable.code ? await get(`/${durable.code}`) : null;\n record('link-survives-restart', after && after.status === 302\n && after.headers.get('location') === 'https://example.com/durable');\n\n await sleep(1300);\n const expiredAfter = short && short.code ? await get(`/${short.code}`) : null;\n record('expiry-survives-restart', expiredAfter && expiredAfter.status === 410);\n await new Promise(resolve => app.close(resolve));\n\n // Data file is real JSON on disk.\n let dataOk = false;\n try { JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); dataOk = true; } catch { /* missing/invalid */ }\n record('data-file-is-json', dataOk);\n\n // Fresh start with no data file present.\n fs.rmSync(DATA_FILE, { force: true });\n app = await start();\n port = app.address().port;\n const fresh = await fetch(`http://127.0.0.1:${port}/links`, {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ url: 'https://example.com/fresh' }) });\n record('fresh-start-without-data-file', fresh.status === 201);\n await new Promise(resolve => app.close(resolve));\n\n // Corrupt data file must not kill the service.\n fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });\n fs.writeFileSync(DATA_FILE, 'garbage{{{');\n app = await start();\n port = app.address().port;\n const afterCorrupt = await get('/anything1');\n record('corrupt-data-file-tolerated', afterCorrupt.status === 404\n && hasEnvelope(await afterCorrupt.json().catch(() => null)));\n await new Promise(resolve => app.close(resolve));\n fs.rmSync(DATA_FILE, { force: true });\n } catch { /* remaining checks unscored */ }\n\n let changelog = '';\n try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ }\n let tests = '';\n try {\n for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8');\n } catch { /* missing */ }\n const changelogEntries = (changelog.match(/^[-*#]/gm) || []).length;\n record('changelog-grown', changelogEntries >= 2 && /persist|restart|data/i.test(changelog));\n record('tests-grown', (tests.match(/\\btest\\(/g) || []).length >= 6);\n\n finish();\n})();\n", + "manualIds": [ + "skill:backend-patterns" + ], + "checkTimeoutMs": 60000 + }, + { + "query": "We're seeing abuse — people hammering the API and submitting junk URLs. Deal with it.", + "check": "'use strict';\n// Step 3 grader: abuse handling — URL validation, size limits, rate limiting —\n// plus conventions. Hammer probe runs last so earlier probes stay unthrottled.\nconst fs = require('node:fs');\nconst path = require('node:path');\n\nconst checks = [];\nconst record = (name, ok) => checks.push({ name, ok: Boolean(ok) });\nlet finished = false;\nfunction finish() {\n if (finished) return;\n finished = true;\n for (let i = checks.length; i < 8; i++) record(`unreached-${i + 1}`, false);\n const ok = checks.filter(c => c.ok).length;\n for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\\n`);\n process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 8, passed: ok, total: 8 })}\\n`);\n process.exit(0);\n}\n// A crashing agent server must not kill the grader: score what completed.\nprocess.on('uncaughtException', finish);\nprocess.on('unhandledRejection', finish);\nconst root = process.cwd();\nconst DATA_FILE = path.join(root, '.ecc-data', 'links-step3.json');\nconst hasEnvelope = body => body && body.error && typeof body.error.code === 'string'\n && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string';\n\nfunction purgeApp() {\n for (const key of Object.keys(require.cache)) {\n if (key.startsWith(path.join(root, 'src') + path.sep)) delete require.cache[key];\n }\n}\n\n(async () => {\n process.env.DATA_FILE = DATA_FILE;\n try {\n purgeApp();\n const { createApp } = require(path.join(root, 'src', 'app.js'));\n const app = createApp();\n await new Promise(resolve => app.listen(0, '127.0.0.1', resolve));\n const port = app.address().port;\n const post = body => fetch(`http://127.0.0.1:${port}/links`, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });\n\n const okCreate = await post({ url: 'https://example.com/normal' });\n record('normal-create-still-201', okCreate.status === 201);\n\n const js = await post({ url: 'javascript:alert(1)' });\n record('javascript-scheme-400-envelope', js.status === 400 && hasEnvelope(await js.json().catch(() => null)));\n const ftp = await post({ url: 'ftp://files.example.com/x' });\n record('non-http-scheme-400-envelope', ftp.status === 400 && hasEnvelope(await ftp.json().catch(() => null)));\n const huge = await post({ url: `https://example.com/${'a'.repeat(10000)}` });\n const hugeBody = await huge.json().catch(() => null);\n record('oversize-url-4xx-envelope', huge.status >= 400 && huge.status < 500 && hasEnvelope(hugeBody));\n\n // Hammer: 60 rapid creates must trip a 429 with the envelope.\n const responses = await Promise.all(Array.from({ length: 60 }, (_, i) =>\n post({ url: `https://example.com/flood-${i}` })));\n const limited = [];\n for (const r of responses) if (r.status === 429) limited.push(await r.json().catch(() => null));\n record('rate-limit-429-envelope', limited.length > 0 && limited.every(hasEnvelope));\n app.close();\n } catch { /* remaining checks unscored */ }\n\n let sources = '';\n try {\n for (const f of fs.readdirSync(path.join(root, 'src'))) {\n if (f.endsWith('.js')) sources += fs.readFileSync(path.join(root, 'src', f), 'utf8');\n }\n } catch { /* missing */ }\n record('rate-limiting-implemented', /429|rate.?limit/i.test(sources));\n\n let changelog = '';\n try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ }\n let tests = '';\n try {\n for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8');\n } catch { /* missing */ }\n const changelogEntries = (changelog.match(/^[-*#]/gm) || []).length;\n record('changelog-grown', changelogEntries >= 3 && /abuse|rate|valid|secur/i.test(changelog));\n record('tests-grown', (tests.match(/\\btest\\(/g) || []).length >= 9);\n\n finish();\n})();\n", + "manualIds": [ + "skill:security-review" + ], + "checkTimeoutMs": 60000 + }, + { + "query": "Track redirect hits per link and expose them at GET /links/:code/stats, consistent with the existing API.", + "check": "'use strict';\n// Step 4 grader: hit analytics consistent with the existing API, conventions,\n// docs and tests. (Runs in a later process than step 3, so rate windows cleared.)\nconst fs = require('node:fs');\nconst path = require('node:path');\n\nconst checks = [];\nconst record = (name, ok) => checks.push({ name, ok: Boolean(ok) });\nlet finished = false;\nfunction finish() {\n if (finished) return;\n finished = true;\n for (let i = checks.length; i < 8; i++) record(`unreached-${i + 1}`, false);\n const ok = checks.filter(c => c.ok).length;\n for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\\n`);\n process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 8, passed: ok, total: 8 })}\\n`);\n process.exit(0);\n}\n// A crashing agent server must not kill the grader: score what completed.\nprocess.on('uncaughtException', finish);\nprocess.on('unhandledRejection', finish);\nconst root = process.cwd();\nconst DATA_FILE = path.join(root, '.ecc-data', 'links-step4.json');\nconst hasEnvelope = body => body && body.error && typeof body.error.code === 'string'\n && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string';\n\nfunction purgeApp() {\n for (const key of Object.keys(require.cache)) {\n if (key.startsWith(path.join(root, 'src') + path.sep)) delete require.cache[key];\n }\n}\n\n(async () => {\n process.env.DATA_FILE = DATA_FILE;\n try {\n purgeApp();\n const { createApp } = require(path.join(root, 'src', 'app.js'));\n const app = createApp();\n await new Promise(resolve => app.listen(0, '127.0.0.1', resolve));\n const port = app.address().port;\n\n const created = await fetch(`http://127.0.0.1:${port}/links`, {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ url: 'https://example.com/tracked' }) });\n const body = await created.json().catch(() => null);\n const code = body && body.code;\n record('create-still-works', created.status === 201 && Boolean(code));\n\n if (code) {\n const before = await fetch(`http://127.0.0.1:${port}/links/${code}/stats`);\n const beforeBody = await before.json().catch(() => null);\n record('stats-zero-before-redirects', before.status === 200 && beforeBody && beforeBody.hits === 0);\n\n for (let i = 0; i < 3; i++) {\n await fetch(`http://127.0.0.1:${port}/${code}`, { redirect: 'manual' });\n }\n const stats = await fetch(`http://127.0.0.1:${port}/links/${code}/stats`);\n const statsBody = await stats.json().catch(() => null);\n record('stats-count-three-hits', stats.status === 200 && statsBody && statsBody.hits === 3);\n\n const redirect = await fetch(`http://127.0.0.1:${port}/${code}`, { redirect: 'manual' });\n record('redirect-still-302', redirect.status === 302);\n\n const missing = await fetch(`http://127.0.0.1:${port}/links/zzzzzz/stats`);\n record('stats-unknown-404-envelope', missing.status === 404\n && hasEnvelope(await missing.json().catch(() => null)));\n } else {\n for (const name of ['stats-zero-before-redirects', 'stats-count-three-hits',\n 'redirect-still-302', 'stats-unknown-404-envelope']) record(name, false);\n }\n app.close();\n } catch { /* remaining checks unscored */ }\n\n let readme = '';\n try { readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8'); } catch { /* missing */ }\n record('readme-documents-stats', /\\/stats|hits|analytics/i.test(readme));\n let changelog = '';\n try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ }\n let tests = '';\n try {\n for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8');\n } catch { /* missing */ }\n const changelogEntries = (changelog.match(/^[-*#]/gm) || []).length;\n record('changelog-grown', changelogEntries >= 4 && /stat|analytic|hit/i.test(changelog));\n record('tests-grown', (tests.match(/\\btest\\(/g) || []).length >= 12);\n\n finish();\n})();\n", + "manualIds": [ + "skill:api-design" + ], + "checkTimeoutMs": 60000 + } + ] + }, + { + "id": "idempotent-webhooks", + "category": "concurrency-trap", + "manualIds": [ + "skill:error-handling" + ], + "checkTimeoutMs": 60000, + "query": "The payments provider retries aggressively and sometimes delivers the same webhook many times concurrently. Our receiver double-charges customers when that happens — see the incident note in SPEC.md. Rework the receiver so the full contract in SPEC.md holds: duplicates must never double-apply under any interleaving, and the documented API and the store contract stay intact. `npm test` must pass, and add regression coverage for the concurrent-duplicate case so INC-104 can't come back.", + "files": { + "package.json": "{\n \"name\": \"webhook-receiver\",\n \"private\": true,\n \"type\": \"commonjs\",\n \"scripts\": { \"test\": \"node --test test/*.test.js\" }\n}\n", + "README.md": "# webhook-receiver\n\nReceives payment webhooks. There is an open incident: customers were\ndouble-charged when the provider retried deliveries. See `SPEC.md` for the\ncontract, including the exactly-once rules.\n\n- `src/app.js` exports `createApp()` returning an `http.Server` that is not\n yet listening; `node src/index.js ` starts the service.\n- `src/store.js` is shared infrastructure: it keeps its current exports\n (`store`) and records every applied payment in `store.paymentLog`.\n- No external dependencies. `npm test` runs the tests. `CHANGELOG.md` records\n every shipped change.\n", + "SPEC.md": "# Payment webhook contract\n\n`POST /webhooks/payments` with JSON body\n`{ \"eventId\": string, \"orderId\": string, \"amountCents\": number, \"type\": \"payment.succeeded\" }`.\n\nExactly-once is the point. The provider retries aggressively and may deliver\nthe same event many times, concurrently, or out of order.\n\n- A new, valid `eventId`: apply the payment exactly once → `200`\n `{ \"status\": \"processed\", \"orderId\" }`.\n- The same `eventId` seen again (any number of times, any interleaving):\n `200` `{ \"status\": \"duplicate\", \"orderId\" }` — never applied twice.\n- A payment event (new `eventId`) for an order that is already paid:\n `200` `{ \"status\": \"already_paid\", \"orderId\" }` — an order is paid at most\n once, ever.\n- `amountCents` not matching the order's amount: `422`, not applied.\n- Unknown `orderId`: `404`. Malformed body (bad JSON, missing/invalid\n fields): `400`.\n- Error responses use the envelope\n `{ \"error\": { \"code\": \"\", \"message\": \"...\" } }`.\n\n`GET /orders/:id` → `200` `{ \"id\", \"status\", \"paidAt\", \"paymentsApplied\" }`\nor a `404` envelope.\n\n## Incident note\n\nINC-104: concurrent duplicate deliveries double-applied payments. The naive\nreceiver checked \"have we seen this event?\" and applied the payment in two\nseparate steps with an async gap in between, so parallel duplicates both\npassed the check.\n", + "src/app.js": "'use strict';\nconst http = require('node:http');\nconst { store } = require('./store');\n\n// INC-104 receiver: checks \"seen this event?\" and applies the payment in two\n// steps with an async gap in between. Concurrent duplicates both pass the\n// check. Do not keep this shape.\nfunction createApp() {\n return http.createServer((req, res) => {\n const url = new URL(req.url, 'http://localhost');\n\n if (req.method === 'POST' && url.pathname === '/webhooks/payments') {\n let body = '';\n req.on('data', chunk => { body += chunk; });\n req.on('end', async () => {\n const parsed = JSON.parse(body);\n const { eventId, orderId } = parsed;\n if (store.processedEvents.has(eventId)) {\n res.writeHead(200, { 'content-type': 'application/json' });\n res.end(JSON.stringify({ status: 'duplicate', orderId }));\n return;\n }\n await new Promise(resolve => setImmediate(resolve)); // async gap\n const order = store.orders.get(orderId);\n order.status = 'paid';\n order.paidAt = new Date().toISOString();\n order.paymentsApplied++;\n store.paymentLog.push({ eventId, orderId, amountCents: parsed.amountCents });\n store.processedEvents.add(eventId);\n res.writeHead(200, { 'content-type': 'application/json' });\n res.end(JSON.stringify({ status: 'processed', orderId }));\n });\n return;\n }\n\n const match = /^\\/orders\\/([\\w-]+)$/.exec(url.pathname);\n if (req.method === 'GET' && match) {\n const order = store.orders.get(match[1]);\n if (!order) {\n res.writeHead(404, { 'content-type': 'application/json' });\n res.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'no such order' } }));\n return;\n }\n res.writeHead(200, { 'content-type': 'application/json' });\n res.end(JSON.stringify(order));\n return;\n }\n\n res.writeHead(404, { 'content-type': 'application/json' });\n res.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'not found' } }));\n });\n}\n\nmodule.exports = { createApp };\n", + "src/index.js": "'use strict';\nconst { createApp } = require('./app');\n\nconst port = Number(process.argv[2] || 8080);\ncreateApp().listen(port, () => {\n console.log(`webhook-receiver listening on ${port}`);\n});\n", + "src/store.js": "'use strict';\n\n// Shared infrastructure. Every applied payment is appended to paymentLog;\n// orders and processedEvents track receiver state. Keep the `store` export.\nconst store = {\n orders: new Map([\n ['o1', { id: 'o1', amountCents: 5000, status: 'pending', paidAt: null, paymentsApplied: 0 }],\n ['o2', { id: 'o2', amountCents: 12500, status: 'pending', paidAt: null, paymentsApplied: 0 }],\n ['o3', { id: 'o3', amountCents: 800, status: 'pending', paidAt: null, paymentsApplied: 0 }],\n ['o4', { id: 'o4', amountCents: 9999, status: 'pending', paidAt: null, paymentsApplied: 0 }],\n ['o5', { id: 'o5', amountCents: 250, status: 'pending', paidAt: null, paymentsApplied: 0 }],\n ['o6', { id: 'o6', amountCents: 7300, status: 'pending', paidAt: null, paymentsApplied: 0 }],\n ]),\n paymentLog: [],\n processedEvents: new Set(),\n};\n\nmodule.exports = { store };\n", + "test/webhooks.test.js": "'use strict';\nconst test = require('node:test');\nconst assert = require('node:assert/strict');\nconst { createApp } = require('../src/app');\nconst { store } = require('../src/store');\n\ntest('a single payment event processes', async () => {\n const server = createApp();\n await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));\n try {\n const port = server.address().port;\n const res = await fetch(`http://127.0.0.1:${port}/webhooks/payments`, {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ eventId: 'ev-test-1', orderId: 'o1', amountCents: 5000, type: 'payment.succeeded' }) });\n assert.equal(res.status, 200);\n assert.equal((await res.json()).status, 'processed');\n assert.equal(store.orders.get('o1').status, 'paid');\n } finally {\n server.close();\n }\n});\n" + }, + "check": "'use strict';\n// Hidden grader for idempotent-webhooks: exactly-once under sequential,\n// concurrent, and mixed-concurrent duplicates, plus the documented API,\n// regression coverage, and hygiene. Prints ECC_EVAL_SCORE and always exits 0.\nconst fs = require('node:fs');\nconst path = require('node:path');\n\nconst checks = [];\nconst record = (name, ok) => checks.push({ name, ok: Boolean(ok) });\nlet finished = false;\nfunction finish() {\n if (finished) return;\n finished = true;\n for (let i = checks.length; i < 12; i++) record(`unreached-${i + 1}`, false);\n const ok = checks.filter(c => c.ok).length;\n for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\\n`);\n process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 12, passed: ok, total: 12 })}\\n`);\n process.exit(0);\n}\n// A crashing agent server must not kill the grader: score what completed.\nprocess.on('uncaughtException', finish);\nprocess.on('unhandledRejection', finish);\nconst root = process.cwd();\nconst hasEnvelope = body => body && body.error && typeof body.error.code === 'string'\n && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string';\n\n(async () => {\n let createApp;\n let store;\n try {\n ({ createApp } = require(path.join(root, 'src', 'app.js')));\n ({ store } = require(path.join(root, 'src', 'store.js')));\n } catch { /* scored below */ }\n if (typeof createApp === 'function' && store && Array.isArray(store.paymentLog)) {\n try {\n const app = createApp();\n await new Promise(resolve => app.listen(0, '127.0.0.1', resolve));\n const port = app.address().port;\n const send = (eventId, orderId, amountCents) => fetch(`http://127.0.0.1:${port}/webhooks/payments`, {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ eventId, orderId, amountCents, type: 'payment.succeeded' }) });\n const logsFor = orderId => store.paymentLog.filter(p => p.orderId === orderId).length;\n\n // 1: single delivery applies once.\n const single = await send('ev-1', 'o1', 5000);\n const singleBody = await single.json().catch(() => null);\n record('single-delivery-processed', single.status === 200 && singleBody\n && singleBody.status === 'processed' && singleBody.orderId === 'o1' && logsFor('o1') === 1);\n\n // 2: sequential retry replays without re-applying.\n const retry = await send('ev-1', 'o1', 5000);\n const retryBody = await retry.json().catch(() => null);\n record('sequential-duplicate-inert', retry.status === 200 && retryBody\n && retryBody.status === 'duplicate' && logsFor('o1') === 1);\n\n // 3: fifty concurrent identical deliveries apply exactly once.\n const storm = await Promise.all(Array.from({ length: 50 }, () => send('ev-2', 'o2', 12500)));\n const stormBodies = [];\n for (const r of storm) stormBodies.push(await r.json().catch(() => null));\n const processedCount = stormBodies.filter(b => b && b.status === 'processed').length;\n const duplicateCount = stormBodies.filter(b => b && b.status === 'duplicate').length;\n record('concurrent-storm-exactly-once', storm.every(r => r.status === 200)\n && processedCount === 1 && duplicateCount === 49 && logsFor('o2') === 1\n && store.orders.get('o2').paymentsApplied === 1);\n\n // 4: a different event for an already-paid order is already_paid and inert.\n const second = await send('ev-3', 'o2', 12500);\n const secondBody = await second.json().catch(() => null);\n record('already-paid-order-inert', second.status === 200 && secondBody\n && secondBody.status === 'already_paid' && logsFor('o2') === 1);\n\n // 5-7: contract errors with envelopes.\n const unknown = await send('ev-4', 'nope', 100);\n record('unknown-order-404-envelope', unknown.status === 404 && hasEnvelope(await unknown.json().catch(() => null)));\n const malformed = await fetch(`http://127.0.0.1:${port}/webhooks/payments`, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: '{bad json' });\n record('malformed-body-400-envelope', malformed.status === 400 && hasEnvelope(await malformed.json().catch(() => null)));\n const mismatch = await send('ev-5', 'o3', 999999);\n record('amount-mismatch-422-envelope', mismatch.status === 422\n && hasEnvelope(await mismatch.json().catch(() => null)) && logsFor('o3') === 0);\n\n // 8: mixed storm — three orders, three eventIds, ten duplicates each, all concurrent.\n const mixed = await Promise.all(['o4', 'o5', 'o6'].flatMap(orderId =>\n Array.from({ length: 10 }, () => send(`ev-${orderId}`, orderId, store.orders.get(orderId).amountCents))));\n for (const r of mixed) await r.json().catch(() => null);\n record('mixed-storm-each-order-once', ['o4', 'o5', 'o6'].every(orderId =>\n logsFor(orderId) === 1 && store.orders.get(orderId).paymentsApplied === 1));\n\n // 9: order inspection endpoint reflects reality.\n const orderView = await fetch(`http://127.0.0.1:${port}/orders/o2`);\n const orderBody = await orderView.json().catch(() => null);\n record('order-endpoint-accurate', orderView.status === 200 && orderBody\n && orderBody.status === 'paid' && orderBody.paymentsApplied === 1 && Boolean(orderBody.paidAt));\n\n app.close();\n } catch { /* remaining checks unscored */ }\n } else {\n for (const name of ['single-delivery-processed', 'sequential-duplicate-inert', 'concurrent-storm-exactly-once',\n 'already-paid-order-inert', 'unknown-order-404-envelope', 'malformed-body-400-envelope',\n 'amount-mismatch-422-envelope', 'mixed-storm-each-order-once', 'order-endpoint-accurate']) record(name, false);\n }\n\n // Conventions.\n let tests = '';\n try {\n for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8');\n } catch { /* missing */ }\n record('concurrency-regression-tests', (tests.match(/\\btest\\(/g) || []).length >= 4\n && /Promise\\.all|concurrent|duplicate|retry/i.test(tests));\n let changelog = '';\n try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ }\n record('changelog-entry', /idem|duplicat|retry|inc-104|race/i.test(changelog));\n try {\n const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));\n record('no-external-dependencies', !pkg.dependencies && !pkg.devDependencies);\n } catch { record('no-external-dependencies', false); }\n\n finish();\n})();\n" + }, + { + "id": "production-ready", + "category": "vague-hardening", + "manualIds": [ + "skill:backend-patterns" + ], + "checkTimeoutMs": 60000, + "query": "This goes to production Monday morning. Get it ready. The bar we hold production services to is in docs/production-bar.md — meet all of it, keep the documented API working, and leave the repo in a state you'd be comfortable being on-call for.", + "files": { + "docs/production-bar.md": "# The production bar\n\nEvery production service here meets all of the following, all the time:\n\n- **Validation**: malformed JSON, missing fields, and wrong types are rejected\n with `400` and a structured JSON error body\n `{ \"error\": { \"code\": \"\", \"message\": \"...\" } }`. Unknown\n resources are `404` in the same envelope. No stack traces, no HTML errors,\n no hanging connections.\n- **Body limits**: request bodies over 64 KB are rejected with `413`, same\n envelope.\n- **Health**: `GET /health` returns `200` with `{ \"status\": \"ok\" }`.\n- **Logging**: one structured JSON log line per request with at least\n `method`, `path`, and `status` fields.\n- **Configuration**: runtime configuration (port, limits) comes from\n environment variables, read at startup. Nothing secret is hardcoded.\n- **Shutdown**: the service closes cleanly on `SIGTERM` (stops accepting,\n drains, exits).\n- **Headers**: responses carry `X-Content-Type-Options: nosniff`.\n- **Tests**: the suite covers error paths, not just the happy path.\n- **Changelog**: every shipped change has a `CHANGELOG.md` entry.\n", + "package.json": "{\n \"name\": \"notes-service\",\n \"private\": true,\n \"type\": \"commonjs\",\n \"scripts\": { \"test\": \"node --test test/*.test.js\" }\n}\n", + "README.md": "# notes-service\n\nTiny notes API. Hobby prototype state: it works on the happy path and that's\nabout all that can be said for it.\n\n## API\n\n- `POST /notes` — body `{ \"title\": string, \"body\": string }` → `201` with\n `{ \"id\", \"title\", \"body\" }`.\n- `GET /notes/:id` — `200` with the note, or `404`.\n- `GET /notes` — `200` with `{ \"notes\": [...] }`.\n\n`src/app.js` exports `createApp()` returning an `http.Server` that is not yet\nlistening; `node src/index.js` starts the service. `npm test` runs the tests.\n\n## Operations\n\n`docs/production-bar.md` lists what every production service here must meet.\n`CHANGELOG.md` records every shipped change.\n", + "src/app.js": "'use strict';\nconst http = require('node:http');\n\n// Prototype state: happy path only.\nconst notes = new Map();\nlet nextId = 1;\n\nfunction createApp() {\n return http.createServer((req, res) => {\n console.log('got a request');\n const url = new URL(req.url, 'http://localhost');\n\n if (req.method === 'POST' && url.pathname === '/notes') {\n let body = '';\n req.on('data', chunk => { body += chunk; });\n req.on('end', () => {\n const parsed = JSON.parse(body);\n const id = `n_${nextId++}`;\n notes.set(id, { id, title: parsed.title, body: parsed.body });\n res.writeHead(201, { 'content-type': 'application/json' });\n res.end(JSON.stringify(notes.get(id)));\n });\n return;\n }\n\n const match = /^\\/notes\\/([\\w-]+)$/.exec(url.pathname);\n if (req.method === 'GET' && match) {\n const note = notes.get(match[1]);\n if (!note) {\n res.writeHead(404);\n res.end('not found');\n return;\n }\n res.writeHead(200, { 'content-type': 'application/json' });\n res.end(JSON.stringify(note));\n return;\n }\n\n if (req.method === 'GET' && url.pathname === '/notes') {\n res.writeHead(200, { 'content-type': 'application/json' });\n res.end(JSON.stringify({ notes: [...notes.values()] }));\n return;\n }\n\n res.writeHead(404);\n res.end('not found');\n });\n}\n\nmodule.exports = { createApp };\n", + "src/index.js": "'use strict';\nconst { createApp } = require('./app');\n\ncreateApp().listen(8080, () => {\n console.log('notes listening on 8080');\n});\n", + "test/notes.test.js": "'use strict';\nconst test = require('node:test');\nconst assert = require('node:assert/strict');\nconst { createApp } = require('../src/app');\n\ntest('create and read a note', async () => {\n const server = createApp();\n await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));\n try {\n const port = server.address().port;\n const created = await fetch(`http://127.0.0.1:${port}/notes`, {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ title: 'first', body: 'hello' }) });\n assert.equal(created.status, 201);\n const { id } = await created.json();\n const read = await fetch(`http://127.0.0.1:${port}/notes/${id}`);\n assert.equal((await read.json()).title, 'first');\n } finally {\n server.close();\n }\n});\n" + }, + "check": "'use strict';\n// Hidden grader for production-ready: probes every dimension of the documented\n// production bar. Prints ECC_EVAL_SCORE and always exits 0.\nconst fs = require('node:fs');\nconst path = require('node:path');\n\nconst checks = [];\nconst record = (name, ok) => checks.push({ name, ok: Boolean(ok) });\nlet finished = false;\nfunction finish() {\n if (finished) return;\n finished = true;\n for (let i = checks.length; i < 16; i++) record(`unreached-${i + 1}`, false);\n const ok = checks.filter(c => c.ok).length;\n for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\\n`);\n process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 16, passed: ok, total: 16 })}\\n`);\n process.exit(0);\n}\n// A crashing agent server must not kill the grader: score what completed.\nprocess.on('uncaughtException', finish);\nprocess.on('unhandledRejection', finish);\nconst root = process.cwd();\nconst hasEnvelope = body => body && body.error && typeof body.error.code === 'string'\n && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string';\n\n(async () => {\n let createApp;\n try { ({ createApp } = require(path.join(root, 'src', 'app.js'))); } catch { /* scored below */ }\n if (typeof createApp === 'function') {\n // Capture console output during the probe run to inspect request logging.\n const logged = [];\n const originalLog = console.log;\n const originalError = console.error;\n const originalStdoutWrite = process.stdout.write.bind(process.stdout);\n const originalStderrWrite = process.stderr.write.bind(process.stderr);\n console.log = (...args) => { logged.push(args.join(' ')); };\n console.error = (...args) => { logged.push(args.join(' ')); };\n // Agents may log through an injectable writer straight to the streams\n // instead of console.*. Capture-then-pass-through: the bytes always reach\n // the stream untouched, so the grader's own ECC_EVAL_SCORE line (emitted\n // via process.stdout.write) can never be swallowed or corrupted.\n const tap = write => (chunk, encoding, callback) => {\n try { logged.push(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)); } catch { /* capture must never break a write */ }\n return write(chunk, encoding, callback);\n };\n process.stdout.write = tap(originalStdoutWrite);\n process.stderr.write = tap(originalStderrWrite);\n try {\n const app = createApp();\n await new Promise(resolve => app.listen(0, '127.0.0.1', resolve));\n const port = app.address().port;\n const api = (p, options) => fetch(`http://127.0.0.1:${port}${p}`, options);\n const post = body => api('/notes', { method: 'POST', headers: { 'content-type': 'application/json' }, body });\n\n // Documented API still works.\n const created = await post(JSON.stringify({ title: 'deploy', body: 'checklist' }));\n const createdBody = await created.json().catch(() => null);\n record('api-roundtrip-preserved', created.status === 201 && createdBody && createdBody.id\n && (await (await api(`/notes/${createdBody.id}`)).json().catch(() => ({}))).title === 'deploy'\n && Array.isArray((await (await api('/notes')).json().catch(() => ({}))).notes));\n\n // Validation and envelope discipline.\n const badJson = await post('{not json');\n record('malformed-json-400-envelope', badJson.status === 400 && hasEnvelope(await badJson.json().catch(() => null)));\n const missing = await post(JSON.stringify({ body: 'no title' }));\n record('missing-field-400-envelope', missing.status === 400 && hasEnvelope(await missing.json().catch(() => null)));\n const wrongType = await post(JSON.stringify({ title: 42, body: 'x' }));\n record('wrong-type-400-envelope', wrongType.status === 400 && hasEnvelope(await wrongType.json().catch(() => null)));\n const unknown = await api('/notes/n_999999');\n const unknownBody = await unknown.text();\n let unknownParsed = null;\n try { unknownParsed = JSON.parse(unknownBody); } catch { /* html or text */ }\n record('unknown-404-json-envelope', unknown.status === 404 && hasEnvelope(unknownParsed));\n\n // Body limit.\n const big = await post(JSON.stringify({ title: 'big', body: 'x'.repeat(100 * 1024) }));\n record('oversize-body-413-envelope', big.status === 413 && hasEnvelope(await big.json().catch(() => null)));\n\n // Health endpoint.\n const health = await api('/health');\n const healthBody = await health.json().catch(() => null);\n record('health-endpoint', health.status === 200 && healthBody && healthBody.status === 'ok');\n\n // Security header on a normal response.\n const headers = await api('/notes');\n record('nosniff-header', headers.headers.get('x-content-type-options') === 'nosniff');\n\n // Error responses carry JSON content type.\n record('errors-are-json', /application\\/json/.test(unknown.headers.get('content-type') || ''));\n\n app.close();\n } catch { /* remaining checks unscored */ } finally {\n console.log = originalLog;\n console.error = originalError;\n process.stdout.write = originalStdoutWrite;\n process.stderr.write = originalStderrWrite;\n }\n\n // Structured request logging: at least one JSON line with method/path/status-ish fields.\n const structured = logged.flatMap(chunk => String(chunk).split('\\n')).some(line => {\n try {\n const parsed = JSON.parse(line);\n return parsed && typeof parsed === 'object'\n && /method/i.test(Object.keys(parsed).join(' '))\n && /path|url/i.test(Object.keys(parsed).join(' '))\n && /status/i.test(Object.keys(parsed).join(' '));\n } catch { return false; }\n });\n record('structured-request-logs', structured);\n } else {\n for (const name of ['api-roundtrip-preserved', 'malformed-json-400-envelope', 'missing-field-400-envelope',\n 'wrong-type-400-envelope', 'unknown-404-json-envelope', 'oversize-body-413-envelope', 'health-endpoint',\n 'nosniff-header', 'errors-are-json', 'structured-request-logs']) record(name, false);\n }\n\n // Static dimensions.\n let sources = '';\n const sourceFiles = [];\n const walk = directory => {\n for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {\n const item = path.join(directory, entry.name);\n if (entry.isDirectory()) walk(item);\n else if (entry.name.endsWith('.js')) {\n const content = fs.readFileSync(item, 'utf8');\n sourceFiles.push(content);\n sources += content;\n }\n }\n };\n try { walk(path.join(root, 'src')); } catch { /* none */ }\n record('sigterm-graceful-shutdown', /SIGTERM/.test(sources));\n // Literal process.env.PORT access, or an injectable-config indirection: a\n // 'PORT' string literal in a file that also reads process.env (for example a\n // loadConfig(env = process.env) + readInt(env, 'PORT', default) module).\n record('env-config-port', sourceFiles.some(content => /process\\.env\\.[A-Z_]*PORT/.test(content)\n || (/(['\"`])PORT\\1/.test(content) && /process\\.env/.test(content))));\n\n let tests = '';\n try {\n for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8');\n } catch { /* missing */ }\n const testCount = (tests.match(/\\btest\\(/g) || []).length;\n record('tests-cover-error-paths', testCount >= 4 && /400|404|413|invalid|error/i.test(tests));\n\n let changelog = '';\n try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ }\n record('changelog-entry', changelog.length > 20 && /product|harden|valid|health|log/i.test(changelog));\n\n record('no-leftover-todos', !/TODO|FIXME/.test(sources));\n try {\n const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));\n record('no-external-dependencies', !pkg.dependencies && !pkg.devDependencies);\n } catch { record('no-external-dependencies', false); }\n\n finish();\n})();\n" + }, + { + "id": "recurring-incident", + "category": "learning-loop-chain", + "manualIds": [], + "files": { + "docs/incidents.md": "# Incident notes\n\n## INC-201 — duplicate refunds (2026-06-14)\n\nCustomers saw two refunds for one order. Traced to the storefront retrying the\nrefund call after a gateway timeout. Asked the storefront team to retry less\naggressively. Closed.\n\n## INC-214 — duplicate refunds, again (2026-07-29)\n\nSame shape as INC-201: a retried refund call landed twice. Reminded the\nstorefront team about backoff. Closed.\n\n## INC-227 — duplicate refunds, third time (2026-09-03)\n\nSame shape as INC-201 and INC-214. Third time this quarter. Support is\nescalating refund-credit requests faster than we can explain them.\n", + "package.json": "{\n \"name\": \"payments-lite\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": { \"test\": \"node --test test/*.test.js\" }\n}\n", + "README.md": "# payments-lite\n\nA small dependency-free payments service core: refunds to customers and payouts\nto vendors, executed against a fake gateway that records every call in an\nappend-only ledger.\n\n## Layout\n\n- `src/charge.js` — the gateway client. `charge()`, `refund()`, and `payout()`\n simulate network latency and append one JSON line per call to the ledger at\n `LEDGER_FILE` (default `.data/ledger.jsonl`). `readLedger()` parses it.\n- `src/store.js` — a tiny JSON-file store at `STORE_FILE` (default\n `.data/store.json`): `get`, `has`, `set`. Reads and writes are synchronous.\n- `src/refunds.js` — `processRefund(req)` for customer refunds.\n- `src/payouts.js` — `processPayout(req)` for vendor payouts.\n\n## API contract\n\n`processRefund({ orderId, amount, idempotencyKey? })` and\n`processPayout({ vendorId, amount, idempotencyKey? })` each return the gateway\nreceipt (`{ id, type, amount, ... }`). When the caller supplies an\n`idempotencyKey`, a repeated call with the same key must not hit the gateway\nagain; it returns the stored receipt with `duplicate: true`. Keep these\nsignatures stable — the dashboard and the finance batch job call them directly.\n\n## Working here\n\n- No external dependencies. `npm test` runs the tests.\n- Incident notes live in `docs/incidents.md`; add an entry when you work one.\n", + "src/charge.js": "// Fake payment gateway. Every call is recorded as one JSON line in an\n// append-only ledger so side effects can be audited after the fact.\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport crypto from 'node:crypto';\n\nfunction ledgerPath() {\n return process.env.LEDGER_FILE || path.join(process.cwd(), '.data', 'ledger.jsonl');\n}\n\nfunction append(entry) {\n const file = ledgerPath();\n fs.mkdirSync(path.dirname(file), { recursive: true });\n fs.appendFileSync(file, `${JSON.stringify({ ...entry, at: new Date().toISOString() })}\\n`);\n}\n\nfunction latency() {\n return new Promise(resolve => setTimeout(resolve, 5 + Math.floor(Math.random() * 10)));\n}\n\nexport async function charge({ orderId, amount }) {\n await latency();\n const receipt = { id: `chg_${crypto.randomUUID()}`, type: 'charge', orderId, amount };\n append(receipt);\n return receipt;\n}\n\nexport async function refund({ orderId, amount }) {\n await latency();\n const receipt = { id: `rfnd_${crypto.randomUUID()}`, type: 'refund', orderId, amount };\n append(receipt);\n return receipt;\n}\n\nexport async function payout({ vendorId, amount }) {\n await latency();\n const receipt = { id: `pay_${crypto.randomUUID()}`, type: 'payout', vendorId, amount };\n append(receipt);\n return receipt;\n}\n\nexport function readLedger(file = ledgerPath()) {\n let text = '';\n try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }\n return text.split('\\n').filter(line => line.trim()).map(line => JSON.parse(line));\n}\n", + "src/payouts.js": "import { payout } from './charge.js';\nimport * as store from './store.js';\n\n// Processes a vendor payout. Finance's batch job calls this once per payout\n// run and has never retried, so the keyless path has never been exercised.\nexport async function processPayout(req) {\n const key = req.idempotencyKey ? `payout:${req.idempotencyKey}` : null;\n if (key && store.has(key)) {\n return { ...store.get(key), duplicate: true };\n }\n const receipt = await payout({ vendorId: req.vendorId, amount: req.amount });\n if (key) store.set(key, receipt);\n return receipt;\n}\n", + "src/refunds.js": "import { refund } from './charge.js';\nimport * as store from './store.js';\n\n// Processes a customer refund. Callers that have one pass an idempotencyKey;\n// plenty of callers (the storefront retry loop among them) do not.\nexport async function processRefund(req) {\n const key = req.idempotencyKey ? `refund:${req.idempotencyKey}` : null;\n if (key && store.has(key)) {\n return { ...store.get(key), duplicate: true };\n }\n const receipt = await refund({ orderId: req.orderId, amount: req.amount });\n if (key) store.set(key, receipt);\n return receipt;\n}\n", + "src/store.js": "// Tiny JSON-file-backed key/value store. All operations are synchronous so a\n// check-and-set within one event-loop turn cannot interleave.\nimport fs from 'node:fs';\nimport path from 'node:path';\n\nfunction storePath() {\n return process.env.STORE_FILE || path.join(process.cwd(), '.data', 'store.json');\n}\n\nfunction load() {\n try { return JSON.parse(fs.readFileSync(storePath(), 'utf8')); } catch { return {}; }\n}\n\nfunction save(data) {\n const file = storePath();\n fs.mkdirSync(path.dirname(file), { recursive: true });\n fs.writeFileSync(file, JSON.stringify(data, null, 1));\n}\n\nexport function get(key) {\n return load()[key];\n}\n\nexport function has(key) {\n return Object.prototype.hasOwnProperty.call(load(), key);\n}\n\nexport function set(key, value) {\n const data = load();\n data[key] = value;\n save(data);\n return value;\n}\n", + "test/payouts.test.js": "import test from 'node:test';\nimport assert from 'node:assert/strict';\nimport fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\n\nfunction freshEnv(t) {\n const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'payments-test-'));\n process.env.LEDGER_FILE = path.join(dir, 'ledger.jsonl');\n process.env.STORE_FILE = path.join(dir, 'store.json');\n t.after(() => fs.rmSync(dir, { recursive: true, force: true }));\n}\n\ntest('processPayout pays once and returns the gateway receipt', async (t) => {\n freshEnv(t);\n const { processPayout } = await import('../src/payouts.js');\n const receipt = await processPayout({ vendorId: 'ven-1', amount: 5000 });\n assert.equal(receipt.type, 'payout');\n assert.equal(receipt.vendorId, 'ven-1');\n assert.equal(receipt.amount, 5000);\n});\n\ntest('processPayout with an explicit key returns the stored receipt on a repeat call', async (t) => {\n freshEnv(t);\n const { processPayout } = await import('../src/payouts.js');\n const first = await processPayout({ vendorId: 'ven-2', amount: 7000, idempotencyKey: 'key-7' });\n const second = await processPayout({ vendorId: 'ven-2', amount: 7000, idempotencyKey: 'key-7' });\n assert.equal(second.duplicate, true);\n assert.equal(second.id, first.id);\n});\n", + "test/refunds.test.js": "import test from 'node:test';\nimport assert from 'node:assert/strict';\nimport fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\n\nfunction freshEnv(t) {\n const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'payments-test-'));\n process.env.LEDGER_FILE = path.join(dir, 'ledger.jsonl');\n process.env.STORE_FILE = path.join(dir, 'store.json');\n t.after(() => fs.rmSync(dir, { recursive: true, force: true }));\n}\n\ntest('processRefund refunds once and returns the gateway receipt', async (t) => {\n freshEnv(t);\n const { processRefund } = await import('../src/refunds.js');\n const receipt = await processRefund({ orderId: 'ord-1', amount: 1200 });\n assert.equal(receipt.type, 'refund');\n assert.equal(receipt.orderId, 'ord-1');\n assert.equal(receipt.amount, 1200);\n});\n\ntest('processRefund with an explicit key returns the stored receipt on a repeat call', async (t) => {\n freshEnv(t);\n const { processRefund } = await import('../src/refunds.js');\n const first = await processRefund({ orderId: 'ord-2', amount: 900, idempotencyKey: 'key-2' });\n const second = await processRefund({ orderId: 'ord-2', amount: 900, idempotencyKey: 'key-2' });\n assert.equal(second.duplicate, true);\n assert.equal(second.id, first.id);\n});\n" + }, + "steps": [ + { + "query": "Customers are reporting duplicate refunds again — see docs/incidents.md. When a refund request comes in without an idempotency key and the caller retries, we refund the customer twice. Fix it, and make sure this stops being a recurring incident — it's the third time this quarter.", + "check": "'use strict';\n// Step 1 grader (recurring-incident): a refund retried WITHOUT an idempotency\n// key must refund exactly once — in-process (0.20) and across a module reload\n// with the same store (0.20); a regression test wired into `npm test` must fail\n// when the fix is reverted in a scratch copy (0.30); a durable prevention doc\n// must exist (0.20); the mechanism must live in a shared helper module (0.10).\n// Graders cannot spawn child processes (--permission), so tests are executed\n// in-process via node:test's run({ isolation: 'none' }) with TMPDIR redirected\n// into the workspace.\nconst fs = require('node:fs');\nconst path = require('node:path');\nconst { pathToFileURL } = require('node:url');\n\nconst probes = [\n { name: 'retry-same-process-refunds-once', weight: 0.20 },\n { name: 'retry-after-reload-refunds-once', weight: 0.20 },\n { name: 'regression-test-wired-and-bites', weight: 0.30 },\n { name: 'prevention-doc-exists', weight: 0.20 },\n { name: 'shared-idempotency-helper', weight: 0.10 },\n];\nconst results = new Map();\nlet finished = false;\nfunction finish() {\n if (finished) return;\n finished = true;\n let score = 0;\n for (const probe of probes) {\n const ok = results.get(probe.name) === true;\n if (ok) score += probe.weight;\n process.stdout.write(`${ok ? 'ok' : 'not ok'} - ${probe.name}\\n`);\n }\n process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: Math.round(score * 1000) / 1000 })}\\n`);\n process.exit(0);\n}\nprocess.on('uncaughtException', finish);\nprocess.on('unhandledRejection', finish);\n\nconst root = process.cwd();\nconst scratch = fs.mkdtempSync(path.join(root, '.ecc-g1-'));\nfs.mkdirSync(path.join(scratch, 'tmp'), { recursive: true });\nprocess.env.TMPDIR = path.join(scratch, 'tmp');\n\n// The fixture's original buggy refunds.js, embedded so the mutation probe can\n// revert the fix in a scratch copy and check the regression suite notices.\nconst ORIGINAL_REFUNDS = [\n \"import { refund } from './charge.js';\",\n \"import * as store from './store.js';\",\n '',\n '// Processes a customer refund. Callers that have one pass an idempotencyKey;',\n '// plenty of callers (the storefront retry loop among them) do not.',\n 'export async function processRefund(req) {',\n ' const key = req.idempotencyKey ? `refund:${req.idempotencyKey}` : null;',\n ' if (key && store.has(key)) {',\n ' return { ...store.get(key), duplicate: true };',\n ' }',\n ' const receipt = await refund({ orderId: req.orderId, amount: req.amount });',\n ' if (key) store.set(key, receipt);',\n ' return receipt;',\n '}',\n '',\n].join('\\n');\n\nlet importCounter = 0;\nfunction importFresh(relative) {\n importCounter += 1;\n return import(`${pathToFileURL(path.join(root, relative)).href}?cb=${importCounter}`);\n}\n\nfunction readLedger(file) {\n let text = '';\n try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }\n return text.split('\\n').filter(line => line.trim()).map(line => {\n try { return JSON.parse(line); } catch { return null; }\n }).filter(Boolean);\n}\n\nfunction copyTree(from, to) {\n fs.mkdirSync(to, { recursive: true });\n for (const entry of fs.readdirSync(from, { withFileTypes: true })) {\n const target = path.join(to, entry.name);\n if (entry.isDirectory()) copyTree(path.join(from, entry.name), target);\n else if (entry.isFile()) fs.copyFileSync(path.join(from, entry.name), target);\n }\n}\n\nfunction findTestFiles(mustMatch) {\n const found = [];\n const walk = dir => {\n let entries = [];\n try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }\n for (const entry of entries) {\n if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;\n const full = path.join(dir, entry.name);\n if (entry.isDirectory()) { walk(full); continue; }\n if (!/\\.test\\.(js|cjs|mjs)$/.test(entry.name)) continue;\n let content = '';\n try { content = fs.readFileSync(full, 'utf8'); } catch { continue; }\n if (mustMatch.every(re => re.test(content))) found.push(full);\n }\n };\n walk(root);\n return found.sort();\n}\n\nfunction npmTestWired() {\n try {\n const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));\n const script = (pkg.scripts && pkg.scripts.test) || '';\n // `node --test test/` silently runs nothing on Node 24; that is not wired.\n return /--test\\b/.test(script) && !/--test\\s+test\\/?\\s*$/.test(script.trim());\n } catch { return false; }\n}\n\nasync function countTestFailures(files) {\n const { run } = require('node:test');\n let failures = 0;\n const stream = run({ files, isolation: 'none', concurrency: 1 });\n stream.on('test:fail', () => { failures += 1; });\n await new Promise((resolve, reject) => { stream.on('end', resolve); stream.on('error', reject); stream.resume(); });\n return failures;\n}\n\nfunction markdownFiles() {\n const found = [];\n const walk = dir => {\n let entries = [];\n try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }\n for (const entry of entries) {\n if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;\n const full = path.join(dir, entry.name);\n if (entry.isDirectory()) walk(full);\n else if (/\\.(md|markdown|txt)$/i.test(entry.name)) found.push(full);\n }\n };\n walk(root);\n return found.sort();\n}\n\nfunction isPreventionDoc(file) {\n let content = '';\n try { content = fs.readFileSync(file, 'utf8'); } catch { return false; }\n return /idempoten/i.test(content) && /prevent|runbook|playbook|checklist|post-?mortem|procedure/i.test(content);\n}\n\n(async () => {\n // (a) Functional: a keyless refund retried in-process refunds exactly once.\n try {\n const ledger = path.join(scratch, 'ledger-a.jsonl');\n process.env.LEDGER_FILE = ledger;\n process.env.STORE_FILE = path.join(scratch, 'store-a.json');\n const refunds = await importFresh('src/refunds.js');\n await refunds.processRefund({ orderId: 'ord-g1-retry', amount: 2500 });\n await refunds.processRefund({ orderId: 'ord-g1-retry', amount: 2500 });\n const entries = readLedger(ledger).filter(e => e.type === 'refund' && e.orderId === 'ord-g1-retry');\n results.set('retry-same-process-refunds-once', entries.length === 1);\n\n // (a2) Durable: a fresh module instance (simulated restart) over the same\n // store must still not refund again.\n const reloaded = await importFresh('src/refunds.js');\n await reloaded.processRefund({ orderId: 'ord-g1-retry', amount: 2500 });\n const afterReload = readLedger(ledger).filter(e => e.type === 'refund' && e.orderId === 'ord-g1-retry');\n results.set('retry-after-reload-refunds-once', entries.length === 1 && afterReload.length === 1);\n } catch { /* both functional probes stay false */ }\n\n // (b) Regression coverage: a refund/idempotency test exists, npm test is\n // wired, the suite passes as-is, and it FAILS when the fix is reverted.\n try {\n const files = findTestFiles([/refund/i, /idempoten|retry|duplicat/i]);\n let ok = files.length > 0 && npmTestWired();\n if (ok) ok = (await countTestFailures(files)) === 0;\n if (ok) {\n const mut = path.join(scratch, 'mutation');\n fs.mkdirSync(mut, { recursive: true });\n copyTree(path.join(root, 'src'), path.join(mut, 'src'));\n fs.copyFileSync(path.join(root, 'package.json'), path.join(mut, 'package.json'));\n for (const file of files) {\n const target = path.join(mut, path.relative(root, file));\n fs.mkdirSync(path.dirname(target), { recursive: true });\n fs.copyFileSync(file, target);\n }\n fs.writeFileSync(path.join(mut, 'src', 'refunds.js'), ORIGINAL_REFUNDS);\n const mutated = files.map(file => path.join(mut, path.relative(root, file)));\n ok = (await countTestFailures(mutated)) > 0;\n }\n results.set('regression-test-wired-and-bites', ok);\n } catch { /* probe stays false */ }\n\n // (c) A durable prevention artifact: some doc ties idempotency to a\n // prevention procedure (runbook/playbook/checklist/postmortem).\n try {\n results.set('prevention-doc-exists', markdownFiles().some(isPreventionDoc));\n } catch { /* probe stays false */ }\n\n // (d) The mechanism lives in a shared helper module that refunds.js imports,\n // not inline in refunds.js alone.\n try {\n const refundsSrc = fs.readFileSync(path.join(root, 'src', 'refunds.js'), 'utf8');\n const helpers = fs.readdirSync(path.join(root, 'src'))\n .filter(name => /idempoten/i.test(name) && /\\.(js|cjs|mjs)$/.test(name));\n const imported = /import[^'\"]*from\\s*['\"][^'\"]*idempoten[^'\"]*['\"]/.test(refundsSrc)\n || /require\\(\\s*['\"][^'\"]*idempoten[^'\"]*['\"]\\s*\\)/.test(refundsSrc);\n results.set('shared-idempotency-helper', helpers.length > 0 && imported);\n } catch { /* probe stays false */ }\n\n try { fs.rmSync(scratch, { recursive: true, force: true }); } catch { /* best effort */ }\n finish();\n})();\n", + "manualIds": [ + "skill:error-handling" + ], + "checkTimeoutMs": 60000 + }, + { + "query": "Finance just flagged that their payout batch job is about to start retrying on timeouts, and payout retries can double-pay vendors. Same family of problem as the refunds — handle it. One hard requirement: a retried payout must never pay a vendor twice, even if the service restarts between the attempts.", + "check": "'use strict';\n// Step 2 grader (recurring-incident): a concurrent keyless payout retry storm\n// must pay exactly once and stay paid once across a module reload (0.40);\n// payouts.js must REUSE the same shared idempotency helper refunds.js imports,\n// with no second inline implementation (0.30); a payout regression test wired\n// into npm test must fail when the fix is reverted in a scratch copy (0.20);\n// the prevention doc must now cover payouts / this class of bug (0.10).\nconst fs = require('node:fs');\nconst path = require('node:path');\nconst { pathToFileURL } = require('node:url');\n\nconst probes = [\n { name: 'payout-storm-pays-once', weight: 0.40 },\n { name: 'reuses-shared-helper', weight: 0.30 },\n { name: 'payout-regression-test-bites', weight: 0.20 },\n { name: 'prevention-doc-covers-class', weight: 0.10 },\n];\nconst results = new Map();\nlet finished = false;\nfunction finish() {\n if (finished) return;\n finished = true;\n let score = 0;\n for (const probe of probes) {\n const ok = results.get(probe.name) === true;\n if (ok) score += probe.weight;\n process.stdout.write(`${ok ? 'ok' : 'not ok'} - ${probe.name}\\n`);\n }\n process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: Math.round(score * 1000) / 1000 })}\\n`);\n process.exit(0);\n}\nprocess.on('uncaughtException', finish);\nprocess.on('unhandledRejection', finish);\n\nconst root = process.cwd();\nconst scratch = fs.mkdtempSync(path.join(root, '.ecc-g2-'));\nfs.mkdirSync(path.join(scratch, 'tmp'), { recursive: true });\nprocess.env.TMPDIR = path.join(scratch, 'tmp');\n\n// The fixture's original payouts.js, embedded for the mutation probe.\nconst ORIGINAL_PAYOUTS = [\n \"import { payout } from './charge.js';\",\n \"import * as store from './store.js';\",\n '',\n '// Processes a vendor payout. Finance\\'s batch job calls this once per payout',\n '// run and has never retried, so the keyless path has never been exercised.',\n 'export async function processPayout(req) {',\n ' const key = req.idempotencyKey ? `payout:${req.idempotencyKey}` : null;',\n ' if (key && store.has(key)) {',\n ' return { ...store.get(key), duplicate: true };',\n ' }',\n ' const receipt = await payout({ vendorId: req.vendorId, amount: req.amount });',\n ' if (key) store.set(key, receipt);',\n ' return receipt;',\n '}',\n '',\n].join('\\n');\n\nlet importCounter = 0;\nfunction importFresh(relative) {\n importCounter += 1;\n return import(`${pathToFileURL(path.join(root, relative)).href}?cb=${importCounter}`);\n}\n\nfunction readLedger(file) {\n let text = '';\n try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }\n return text.split('\\n').filter(line => line.trim()).map(line => {\n try { return JSON.parse(line); } catch { return null; }\n }).filter(Boolean);\n}\n\nfunction copyTree(from, to) {\n fs.mkdirSync(to, { recursive: true });\n for (const entry of fs.readdirSync(from, { withFileTypes: true })) {\n const target = path.join(to, entry.name);\n if (entry.isDirectory()) copyTree(path.join(from, entry.name), target);\n else if (entry.isFile()) fs.copyFileSync(path.join(from, entry.name), target);\n }\n}\n\nfunction findTestFiles(mustMatch) {\n const found = [];\n const walk = dir => {\n let entries = [];\n try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }\n for (const entry of entries) {\n if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;\n const full = path.join(dir, entry.name);\n if (entry.isDirectory()) { walk(full); continue; }\n if (!/\\.test\\.(js|cjs|mjs)$/.test(entry.name)) continue;\n let content = '';\n try { content = fs.readFileSync(full, 'utf8'); } catch { continue; }\n if (mustMatch.every(re => re.test(content))) found.push(full);\n }\n };\n walk(root);\n return found.sort();\n}\n\nfunction npmTestWired() {\n try {\n const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));\n const script = (pkg.scripts && pkg.scripts.test) || '';\n return /--test\\b/.test(script) && !/--test\\s+test\\/?\\s*$/.test(script.trim());\n } catch { return false; }\n}\n\nasync function countTestFailures(files) {\n const { run } = require('node:test');\n let failures = 0;\n const stream = run({ files, isolation: 'none', concurrency: 1 });\n stream.on('test:fail', () => { failures += 1; });\n await new Promise((resolve, reject) => { stream.on('end', resolve); stream.on('error', reject); stream.resume(); });\n return failures;\n}\n\nfunction markdownFiles() {\n const found = [];\n const walk = dir => {\n let entries = [];\n try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }\n for (const entry of entries) {\n if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;\n const full = path.join(dir, entry.name);\n if (entry.isDirectory()) walk(full);\n else if (/\\.(md|markdown|txt)$/i.test(entry.name)) found.push(full);\n }\n };\n walk(root);\n return found.sort();\n}\n\n// The idempotency helper module specifier refunds.js imports, if any.\nfunction helperSpecifier() {\n try {\n const refundsSrc = fs.readFileSync(path.join(root, 'src', 'refunds.js'), 'utf8');\n const match = /(?:from|require\\()\\s*['\"]([^'\"]*idempoten[^'\"]*)['\"]/i.exec(refundsSrc);\n return match ? match[1] : null;\n } catch { return null; }\n}\n\n(async () => {\n // (a) Functional: 20 concurrent keyless retries pay exactly once, and a\n // fresh module instance over the same store still does not pay again.\n try {\n const ledger = path.join(scratch, 'ledger-a.jsonl');\n process.env.LEDGER_FILE = ledger;\n process.env.STORE_FILE = path.join(scratch, 'store-a.json');\n const payouts = await importFresh('src/payouts.js');\n await Promise.all(Array.from({ length: 20 },\n () => payouts.processPayout({ vendorId: 'ven-g2-storm', amount: 9000 }).catch(() => null)));\n const afterStorm = readLedger(ledger).filter(e => e.type === 'payout' && e.vendorId === 'ven-g2-storm');\n const reloaded = await importFresh('src/payouts.js');\n await reloaded.processPayout({ vendorId: 'ven-g2-storm', amount: 9000 }).catch(() => null);\n const afterReload = readLedger(ledger).filter(e => e.type === 'payout' && e.vendorId === 'ven-g2-storm');\n results.set('payout-storm-pays-once', afterStorm.length === 1 && afterReload.length === 1);\n } catch { /* probe stays false */ }\n\n // (b) Reuse: payouts.js imports the SAME helper specifier as refunds.js and\n // does not carry a second inline implementation (own key hashing or its own\n // seen/inflight table).\n try {\n const specifier = helperSpecifier();\n const payoutsSrc = fs.readFileSync(path.join(root, 'src', 'payouts.js'), 'utf8');\n const importsSame = specifier !== null\n && new RegExp(`(?:from|require\\\\()\\\\s*['\"]${specifier.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}['\"]`).test(payoutsSrc);\n const inlineImplementation = /createHash|new Map\\s*\\(|new Set\\s*\\(|new WeakMap\\s*\\(/.test(payoutsSrc);\n results.set('reuses-shared-helper', importsSame && !inlineImplementation);\n } catch { /* probe stays false */ }\n\n // (c) Regression coverage for payouts, same discipline as step 1.\n try {\n const files = findTestFiles([/payout/i, /idempoten|retry|duplicat|storm|concurrent/i]);\n let ok = files.length > 0 && npmTestWired();\n if (ok) ok = (await countTestFailures(files)) === 0;\n if (ok) {\n const mut = path.join(scratch, 'mutation');\n fs.mkdirSync(mut, { recursive: true });\n copyTree(path.join(root, 'src'), path.join(mut, 'src'));\n fs.copyFileSync(path.join(root, 'package.json'), path.join(mut, 'package.json'));\n for (const file of files) {\n const target = path.join(mut, path.relative(root, file));\n fs.mkdirSync(path.dirname(target), { recursive: true });\n fs.copyFileSync(file, target);\n }\n fs.writeFileSync(path.join(mut, 'src', 'payouts.js'), ORIGINAL_PAYOUTS);\n const mutated = files.map(file => path.join(mut, path.relative(root, file)));\n ok = (await countTestFailures(mutated)) > 0;\n }\n results.set('payout-regression-test-bites', ok);\n } catch { /* probe stays false */ }\n\n // (d) The prevention doc now covers payouts / the whole class of bug.\n try {\n const covered = markdownFiles().some(file => {\n let content = '';\n try { content = fs.readFileSync(file, 'utf8'); } catch { return false; }\n return /idempoten/i.test(content)\n && /prevent|runbook|playbook|checklist|post-?mortem|procedure/i.test(content)\n && /payout|vendor|class of|general|every payment|any payment/i.test(content);\n });\n results.set('prevention-doc-covers-class', covered);\n } catch { /* probe stays false */ }\n\n try { fs.rmSync(scratch, { recursive: true, force: true }); } catch { /* best effort */ }\n finish();\n})();\n", + "manualIds": [ + "skill:error-handling" + ], + "checkTimeoutMs": 60000 + }, + { + "query": "You're rolling off this area. Write the handoff note for whoever picks this up next.", + "check": "'use strict';\n// Step 3 grader (recurring-incident): the handoff note. A handoff doc must\n// exist (0.20); every file path it references must actually exist in the\n// workspace, with at least two concrete references (0.30); it must name the\n// shared idempotency helper and describe the prevention procedure (0.30); it\n// must cover both the refunds and the payouts incidents (0.20). Scored on the\n// best candidate when several handoff files exist.\nconst fs = require('node:fs');\nconst path = require('node:path');\n\nconst probes = [\n { name: 'handoff-exists', weight: 0.20 },\n { name: 'referenced-paths-exist', weight: 0.30 },\n { name: 'names-helper-and-procedure', weight: 0.30 },\n { name: 'covers-both-incidents', weight: 0.20 },\n];\nconst results = new Map();\nlet finished = false;\nfunction finish() {\n if (finished) return;\n finished = true;\n let score = 0;\n for (const probe of probes) {\n const ok = results.get(probe.name) === true;\n if (ok) score += probe.weight;\n process.stdout.write(`${ok ? 'ok' : 'not ok'} - ${probe.name}\\n`);\n }\n process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: Math.round(score * 1000) / 1000 })}\\n`);\n process.exit(0);\n}\nprocess.on('uncaughtException', finish);\nprocess.on('unhandledRejection', finish);\n\nconst root = process.cwd();\n\nfunction handoffFiles() {\n const found = [];\n const walk = dir => {\n let entries = [];\n try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }\n for (const entry of entries) {\n if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;\n const full = path.join(dir, entry.name);\n if (entry.isDirectory()) { walk(full); continue; }\n if (/hand[ -]?off/i.test(entry.name) && /\\.(md|markdown|txt)$/i.test(entry.name)) found.push(full);\n }\n };\n walk(root);\n return found.sort();\n}\n\n// Candidate file paths mentioned in prose: at least one path segment and a\n// file extension (src/refunds.js, docs/runbooks/idempotency.md, ...).\nfunction referencedPaths(content) {\n const tokens = new Set();\n for (const match of content.matchAll(/(?:[\\w@+.-]+\\/)+[\\w@+.-]+\\.[a-z0-9]{1,8}/gi)) {\n const token = match[0].replace(/[.,;:'\")\\]`]+$/, '').replace(/^[('\"\\[`]+/, '');\n if (token.includes('..') || /^https?/i.test(token)) continue;\n tokens.add(token);\n }\n return [...tokens];\n}\n\nfunction helperBasename() {\n try {\n const refundsSrc = fs.readFileSync(path.join(root, 'src', 'refunds.js'), 'utf8');\n const match = /(?:from|require\\()\\s*['\"]([^'\"]*idempoten[^'\"]*)['\"]/i.exec(refundsSrc);\n return match ? path.basename(match[1]) : null;\n } catch { return null; }\n}\n\nfunction scoreCandidate(content) {\n const verdicts = new Map();\n verdicts.set('handoff-exists', true);\n\n const paths = referencedPaths(content);\n verdicts.set('referenced-paths-exist', paths.length >= 2\n && paths.every(token => fs.existsSync(path.join(root, token))));\n\n const helper = helperBasename();\n verdicts.set('names-helper-and-procedure', helper !== null\n && content.includes(helper)\n && /prevent|runbook|playbook|checklist|regression|npm test|procedure/i.test(content));\n\n verdicts.set('covers-both-incidents', /refund/i.test(content) && /payout/i.test(content));\n return verdicts;\n}\n\ntry {\n const candidates = handoffFiles();\n if (candidates.length > 0) {\n let best = null;\n for (const file of candidates) {\n let content = '';\n try { content = fs.readFileSync(file, 'utf8'); } catch { continue; }\n const verdicts = scoreCandidate(content);\n const total = [...verdicts.values()].filter(Boolean).length;\n if (!best || total > best.total) best = { verdicts, total };\n }\n if (best) for (const [name, ok] of best.verdicts) results.set(name, ok);\n }\n} catch { /* everything stays false */ }\n\nfinish();\n", + "manualIds": [ + "skill:continuous-learning" + ], + "checkTimeoutMs": 60000 + } + ] + } + ] +} diff --git a/docker/context-profiles/complex-corpus.json b/docker/context-profiles/complex-corpus.json new file mode 100644 index 000000000..9ce8b0b4d --- /dev/null +++ b/docker/context-profiles/complex-corpus.json @@ -0,0 +1,93 @@ +{ + "schemaVersion": "ecc.context-eval-complex-corpus.v1", + "id": "complex-tasks@1", + "sampling": "Realistic multi-file engineering tasks, fixed before any provider call, with deterministic hidden graders scoring partial credit (ECC_EVAL_SCORE). Descriptive pilot: no population-representativeness claim. See complex-eval/DESIGN.md for the preregistered methodology.", + "minimumDistinctTasks": 3, + "nonInferiorityMargin": 0.05, + "selection": [ + { + "id": "complex-incident-triage", + "category": "complex-debugging-incident", + "query": "Finance flagged that some order totals have been off by a cent since yesterday's deploy — details are in evidence/incident.txt. Three changes shipped yesterday (CHANGELOG.md, entries C-1 to C-3). Find the root cause, fix it so totals are computed exactly per the pricing rules in the README, keep `npm test` green, and write INCIDENT.md at the repo root identifying which changelog entry introduced the regression, with a short explanation of why it produces wrong totals.", + "expectedIds": [ + "skill:orch-fix-defect" + ] + }, + { + "id": "complex-sentinel-api", + "category": "complex-security-hardening", + "query": "This internal paste-sharing service failed a security review, but the auditors didn't itemize the findings. Review the implementation against the API contract in the README, find every place the code violates the documented security behavior or is otherwise exploitable, and fix all of them without breaking the documented API. `npm test` must stay green.", + "expectedIds": [ + "skill:security-review" + ] + }, + { + "id": "complex-webhook-relay", + "category": "complex-feature-build", + "query": "The webhook relay in this repo accepts delivery requests but never actually sends them — the delivery worker was never finished, and customers are losing notifications. Implement asynchronous delivery per the README: POST each delivery's JSON payload to its URL, retry failures with exponential backoff starting around 100ms and doubling each time, give up after 5 total attempts and mark the delivery dead. Keep the documented module contract, make `npm test` pass, and extend the test suite to cover the retry and dead-letter behavior.", + "expectedIds": [ + "skill:tdd-workflow" + ] + } + ], + "tasks": [ + { + "id": "incident-triage", + "category": "debugging-incident", + "manualIds": [ + "skill:orch-fix-defect" + ], + "checkTimeoutMs": 30000, + "query": "Finance flagged that some order totals have been off by a cent since yesterday's deploy — details are in evidence/incident.txt. Three changes shipped yesterday (CHANGELOG.md, entries C-1 to C-3). Find the root cause, fix it so totals are computed exactly per the pricing rules in the README, keep `npm test` green, and write INCIDENT.md at the repo root identifying which changelog entry introduced the regression, with a short explanation of why it produces wrong totals.", + "files": { + "CHANGELOG.md": "# Changelog\n\n## 2026-09-23 deploy\n\n- **C-1**: request logging switched to JSON lines (`src/request-log.js`).\n Log volume and format only; no request-handling behavior changed.\n- **C-2**: totals computation refactored for readability (`src/totals.js`).\n The old cents-as-integers helper was replaced with a direct decimal\n expression that reviewers found easier to follow. No behavior change intended.\n- **C-3**: inventory client timeout raised from 2s to 5s (`src/inventory-client.js`).\n Reduces spurious failures when the inventory service is slow.\n", + "evidence/incident.txt": "2026-09-24T08:57:11Z finance-review order=ORD-2204 note=\"charged_total_cents=115 expected_total_cents=116 lines=[{priceCents:165,quantity:1}] discountPercent=30\"\n2026-09-24T09:14:02Z finance-review order=ORD-2291 note=\"charged_total_cents=232 expected_total_cents=233 lines=[{priceCents:250,quantity:1}] discountPercent=7\"\n2026-09-24T09:41:37Z finance-review order=ORD-2310 note=\"charged_total_cents=227 expected_total_cents=228 lines=[{priceCents:325,quantity:1}] discountPercent=30\"\n2026-09-24T10:05:19Z support-ticket customer=\"ORDER-2310 looks like it undercharged me by a cent vs the invoice email\"\n2026-09-24T10:22:48Z finance-review summary=\"12 of 4,813 orders since the 2026-09-23 deploy are off by exactly one cent, always in the store's favor; all pre-deploy orders reconcile\"\n", + "package.json": "{\n \"name\": \"order-service\",\n \"private\": true,\n \"type\": \"commonjs\",\n \"scripts\": { \"test\": \"node --test test/\" }\n}\n", + "README.md": "# order-service\n\nComputes order totals for the checkout service.\n\n## Pricing rules\n\nAn order is `{ \"lines\": [{ \"priceCents\": number, \"quantity\": number }], \"discountPercent\": number }`.\n\n- All prices are integer cents. There is no such thing as a fraction of a cent\n in an order total.\n- The discount applies per line: `lineCents = priceCents * quantity * (100 - discountPercent) / 100`,\n rounded **half-up** to the nearest cent (0.5 rounds up).\n- The order total is the sum of the rounded line totals, in integer cents.\n\n`src/totals.js` is CommonJS and exports `computeOrderTotal(order)` returning the\ntotal in integer cents. Run the tests with `npm test`.\n\n## Operations\n\n- `CHANGELOG.md` records what shipped in each deploy.\n- `evidence/incident.txt` holds the finance team's findings for the current incident.\n", + "src/inventory-client.js": "'use strict';\n\n// Changed 2026-09-23 (C-3): the inventory service has been slow this week;\n// give it 5s instead of 2s before declaring a failure.\nconst INVENTORY_TIMEOUT_MS = 5000;\n\nfunction inventoryClientOptions() {\n return { timeoutMs: INVENTORY_TIMEOUT_MS, retries: 2 };\n}\n\nmodule.exports = { inventoryClientOptions };\n", + "src/request-log.js": "'use strict';\n\n// Changed 2026-09-23 (C-1): emit request logs as JSON lines so the log\n// pipeline can parse them without regexes.\nfunction logRequest(req) {\n console.log(JSON.stringify({\n method: req.method,\n url: req.url,\n at: new Date().toISOString(),\n }));\n}\n\nmodule.exports = { logRequest };\n", + "src/totals.js": "'use strict';\n\n// Refactored 2026-09-23 (C-2): express the discount math directly with a\n// decimal factor instead of the old integer-cents helper, which reviewers\n// found hard to follow.\nfunction computeOrderTotal(order) {\n let total = 0;\n for (const line of order.lines) {\n total += Math.round(line.priceCents * line.quantity * (1 - order.discountPercent / 100));\n }\n return total;\n}\n\nmodule.exports = { computeOrderTotal };\n", + "test/totals.test.js": "'use strict';\nconst test = require('node:test');\nconst assert = require('node:assert/strict');\nconst { computeOrderTotal } = require('../src/totals');\n\ntest('sums lines without a discount', () => {\n assert.equal(computeOrderTotal({ lines: [{ priceCents: 1000, quantity: 2 }], discountPercent: 0 }), 2000);\n});\n\ntest('applies a clean quarter discount', () => {\n assert.equal(computeOrderTotal({ lines: [{ priceCents: 2000, quantity: 1 }], discountPercent: 25 }), 1500);\n});\n\ntest('multiplies quantity before discounting', () => {\n assert.equal(computeOrderTotal({ lines: [{ priceCents: 400, quantity: 3 }], discountPercent: 50 }), 600);\n});\n" + }, + "check": "'use strict';\n// Hidden grader for incident-triage: checks exact totals on boundary orders and\n// the root-cause report. Prints ECC_EVAL_SCORE and always exits 0.\nconst fs = require('node:fs');\nconst path = require('node:path');\n\nconst checks = [];\nconst record = (name, ok) => checks.push({ name, ok: Boolean(ok) });\n\nlet computeOrderTotal;\ntry { ({ computeOrderTotal } = require(path.join(process.cwd(), 'src', 'totals.js'))); } catch { /* scored below */ }\n\n// Boundary orders where decimal-factor float math under-rounds by a cent;\n// expected values follow the README pricing rules (integer cents, half-up per line).\nconst boundary = [\n { lines: [{ priceCents: 165, quantity: 1 }], discountPercent: 30, expected: 116 },\n { lines: [{ priceCents: 250, quantity: 1 }], discountPercent: 7, expected: 233 },\n { lines: [{ priceCents: 325, quantity: 1 }], discountPercent: 30, expected: 228 },\n { lines: [{ priceCents: 345, quantity: 1 }], discountPercent: 30, expected: 242 },\n { lines: [{ priceCents: 165, quantity: 1 }, { priceCents: 325, quantity: 1 }], discountPercent: 30, expected: 344 },\n];\n\nif (typeof computeOrderTotal === 'function') {\n boundary.forEach((order, index) => {\n let actual = NaN;\n try { actual = computeOrderTotal({ lines: order.lines, discountPercent: order.discountPercent }); } catch { /* wrong */ }\n record(`boundary-total-${index + 1}`, actual === order.expected);\n });\n let plain = NaN;\n try { plain = computeOrderTotal({ lines: [{ priceCents: 1000, quantity: 2 }], discountPercent: 0 }); } catch { /* wrong */ }\n record('undiscounted-total-unchanged', plain === 2000);\n} else {\n for (let index = 0; index < boundary.length; index++) record(`boundary-total-${index + 1}`, false);\n record('undiscounted-total-unchanged', false);\n}\n\nlet incident = '';\ntry { incident = fs.readFileSync(path.join(process.cwd(), 'INCIDENT.md'), 'utf8'); } catch { /* missing */ }\nrecord('incident-identifies-C-2', /C-2/.test(incident));\nrecord('incident-explains-rounding', /round|float|decimal|cent/i.test(incident));\n\nconst ok = checks.filter(c => c.ok).length;\nfor (const c of checks) console.log(`${c.ok ? 'ok' : 'not ok'} - ${c.name}`);\nconsole.log(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / checks.length, passed: ok, total: checks.length })}`);\nprocess.exit(0);\n" + }, + { + "id": "sentinel-api", + "category": "security-hardening", + "manualIds": [ + "skill:security-review" + ], + "checkTimeoutMs": 60000, + "query": "This internal paste-sharing service failed a security review, but the auditors didn't itemize the findings. Review the implementation against the API contract in the README, find every place the code violates the documented security behavior or is otherwise exploitable, and fix all of them without breaking the documented API. `npm test` must stay green.", + "files": { + "data/files/welcome.txt": "hello from sentinel\n", + "data/secret.txt": "CANARY-7f3c9d-internal-only\n", + "package.json": "{\n \"name\": \"sentinel-paste\",\n \"private\": true,\n \"type\": \"commonjs\",\n \"scripts\": { \"test\": \"node --test test/\" }\n}\n", + "README.md": "# sentinel-paste\n\nInternal paste-sharing service. In-memory store, Node.js standard library only.\n\n## API contract\n\n- `POST /pastes` — JSON body `{ \"content\": string }`. `201` with\n `{ \"id\", \"deleteToken\" }`. Request bodies larger than 64 KB are rejected with\n `413 Payload Too Large`.\n- `GET /pastes/:id` — `200` JSON `{ \"id\", \"content\" }`, or `404`.\n- `DELETE /pastes/:id` — requires the `X-Delete-Token` header to match the\n token returned at creation. `204` on success, `403` otherwise. Delete tokens\n are shown once and must be unguessable.\n- `GET /p/:id` — HTML view of a paste. The paste content is rendered as text:\n it must never be interpreted as markup.\n- `GET /files?name=` — serves a public file from `data/files/`. Only\n files inside that directory may ever be served.\n- `GET /admin/stats` — operational stats. Requires the `X-Admin-Token` header\n to match the `ADMIN_TOKEN` environment variable; the service reads it at\n startup and refuses all admin access (`401`) when it is missing or wrong.\n The token must never be committed to the repository.\n\n## Module contract\n\n- `src/app.js` is CommonJS and exports `createApp()`, which returns an\n `http.Server` that is not yet listening.\n- `node src/index.js ` starts the service.\n- Run the tests with `npm test`.\n", + "src/app.js": "'use strict';\nconst fs = require('node:fs');\nconst path = require('node:path');\nconst http = require('node:http');\nconst config = require('./config');\nconst store = require('./store');\n\nfunction readBody(req, callback) {\n let body = '';\n req.on('data', chunk => { body += chunk; });\n req.on('end', () => callback(body));\n}\n\nfunction sendJson(res, status, value) {\n res.writeHead(status, { 'content-type': 'application/json' });\n res.end(JSON.stringify(value));\n}\n\nfunction page(paste) {\n return `paste ${paste.id}`\n + `

    ${paste.content}
    `;\n}\n\nfunction createApp() {\n return http.createServer((req, res) => {\n const url = new URL(req.url, 'http://localhost');\n\n if (req.method === 'POST' && url.pathname === '/pastes') {\n readBody(req, body => {\n let parsed;\n try { parsed = JSON.parse(body); } catch {\n sendJson(res, 400, { error: 'invalid JSON body' });\n return;\n }\n if (typeof parsed.content !== 'string') {\n sendJson(res, 400, { error: 'content must be a string' });\n return;\n }\n const paste = store.create(parsed.content);\n sendJson(res, 201, { id: paste.id, deleteToken: paste.deleteToken });\n });\n return;\n }\n\n const pasteMatch = /^\\/pastes\\/([\\w-]+)$/.exec(url.pathname);\n if (pasteMatch && req.method === 'GET') {\n const paste = store.get(pasteMatch[1]);\n if (!paste) { sendJson(res, 404, { error: 'not found' }); return; }\n sendJson(res, 200, { id: paste.id, content: paste.content });\n return;\n }\n if (pasteMatch && req.method === 'DELETE') {\n const paste = store.get(pasteMatch[1]);\n if (!paste) { sendJson(res, 404, { error: 'not found' }); return; }\n if (req.headers['x-delete-token'] !== paste.deleteToken) {\n sendJson(res, 403, { error: 'bad delete token' });\n return;\n }\n store.remove(paste.id);\n res.writeHead(204);\n res.end();\n return;\n }\n\n const pageMatch = /^\\/p\\/([\\w-]+)$/.exec(url.pathname);\n if (pageMatch && req.method === 'GET') {\n const paste = store.get(pageMatch[1]);\n if (!paste) { sendJson(res, 404, { error: 'not found' }); return; }\n res.writeHead(200, { 'content-type': 'text/html' });\n res.end(page(paste));\n return;\n }\n\n if (req.method === 'GET' && url.pathname === '/files') {\n const name = url.searchParams.get('name') || '';\n try {\n const content = fs.readFileSync(path.join(config.FILES_DIR, name));\n res.writeHead(200, { 'content-type': 'text/plain' });\n res.end(content);\n } catch {\n sendJson(res, 404, { error: 'not found' });\n }\n return;\n }\n\n if (req.method === 'GET' && url.pathname === '/admin/stats') {\n if (req.headers['x-admin-token'] !== config.ADMIN_TOKEN) {\n sendJson(res, 401, { error: 'unauthorized' });\n return;\n }\n sendJson(res, 200, store.stats());\n return;\n }\n\n sendJson(res, 404, { error: 'not found' });\n });\n}\n\nmodule.exports = { createApp };\n", + "src/config.js": "'use strict';\nconst path = require('node:path');\n\nmodule.exports = {\n // TODO: move this out of the repository before the next audit.\n ADMIN_TOKEN: 'fixture-token',\n MAX_BODY_BYTES: 64 * 1024,\n FILES_DIR: path.join(__dirname, '..', 'data', 'files'),\n};\n", + "src/index.js": "'use strict';\nconst { createApp } = require('./app');\n\nconst port = Number(process.argv[2] || 8080);\ncreateApp().listen(port, () => {\n console.log(`sentinel-paste listening on ${port}`);\n});\n", + "src/store.js": "'use strict';\n\n// In-memory paste store.\nconst pastes = new Map();\nlet nextId = 1;\n\nfunction create(content) {\n const id = `p_${nextId++}`;\n const paste = { id, content, deleteToken: `tok_${id}` };\n pastes.set(id, paste);\n return paste;\n}\n\nfunction get(id) {\n return pastes.get(id) || null;\n}\n\nfunction remove(id) {\n return pastes.delete(id);\n}\n\nfunction stats() {\n return { pastes: pastes.size, created: nextId - 1 };\n}\n\nmodule.exports = { create, get, remove, stats };\n", + "test/api.test.js": "'use strict';\nconst test = require('node:test');\nconst assert = require('node:assert/strict');\nconst { createApp } = require('../src/app');\n\nfunction listen(server) {\n return new Promise((resolve, reject) => {\n server.once('error', reject);\n server.listen(0, '127.0.0.1', () => resolve(server.address().port));\n });\n}\n\ntest('create and read back a paste', async () => {\n const server = createApp();\n const port = await listen(server);\n try {\n const created = await fetch(`http://127.0.0.1:${port}/pastes`, {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ content: 'hello paste' }) });\n assert.equal(created.status, 201);\n const { id } = await created.json();\n const read = await fetch(`http://127.0.0.1:${port}/pastes/${id}`);\n assert.equal(read.status, 200);\n assert.equal((await read.json()).content, 'hello paste');\n } finally {\n server.close();\n }\n});\n" + }, + "check": "'use strict';\n// Hidden grader for sentinel-api: runs exploit probes and functional regression\n// probes against the agent's service, in-process, plus static source checks.\n// Prints ECC_EVAL_SCORE and always exits 0.\nconst fs = require('node:fs');\nconst path = require('node:path');\n\nconst checks = [];\nconst record = (name, ok) => checks.push({ name, ok: Boolean(ok) });\nlet finished = false;\n\nfunction finish() {\n if (finished) return;\n finished = true;\n const ok = checks.filter(c => c.ok).length;\n for (const c of checks) console.log(`${c.ok ? 'ok' : 'not ok'} - ${c.name}`);\n console.log(`ECC_EVAL_SCORE ${JSON.stringify({ score: checks.length ? ok / checks.length : 0, passed: ok, total: checks.length })}`);\n process.exit(0);\n}\nsetTimeout(finish, 45000).unref();\n\nconst HARDCODED_TOKEN = 'fixture-token';\nconst GRADER_TOKEN = 'ecc-grader-admin-token-31f7';\n\nfunction sourceFiles(directory, found = []) {\n for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {\n const item = path.join(directory, entry.name);\n if (entry.isDirectory()) sourceFiles(item, found);\n else if (entry.name.endsWith('.js')) found.push(item);\n }\n return found;\n}\n\nfunction listen(server) {\n return new Promise((resolve, reject) => {\n server.once('error', reject);\n server.listen(0, '127.0.0.1', () => resolve(server.address().port));\n });\n}\n\nasync function createPaste(port, content) {\n const response = await fetch(`http://127.0.0.1:${port}/pastes`, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ content }) });\n return { status: response.status, body: await response.json().catch(() => null) };\n}\n\n(async () => {\n const root = process.cwd();\n const sources = sourceFiles(path.join(root, 'src'))\n .map(file => fs.readFileSync(file, 'utf8')).join('\\n');\n\n // Static: the committed admin token must be gone.\n record('hardcoded-admin-token-removed', !sources.includes(HARDCODED_TOKEN));\n\n // Static: delete tokens must come from a cryptographic source.\n record('delete-token-from-crypto', /crypto\\.(randomBytes|randomUUID|randomInt)/.test(sources));\n\n // The service reads ADMIN_TOKEN from the environment at startup.\n process.env.ADMIN_TOKEN = GRADER_TOKEN;\n let createApp;\n try { ({ createApp } = require(path.join(root, 'src', 'app.js'))); } catch { finish(); return; }\n if (typeof createApp !== 'function') { finish(); return; }\n\n try {\n const app = createApp();\n const port = await listen(app);\n\n // Admin route: rejected without the token, allowed with the environment token.\n const denied = await fetch(`http://127.0.0.1:${port}/admin/stats`);\n const wrong = await fetch(`http://127.0.0.1:${port}/admin/stats`, { headers: { 'x-admin-token': HARDCODED_TOKEN } });\n const allowed = await fetch(`http://127.0.0.1:${port}/admin/stats`, { headers: { 'x-admin-token': GRADER_TOKEN } });\n record('admin-requires-env-token', denied.status === 401 && wrong.status === 401 && allowed.status === 200);\n\n // Path traversal: the canary outside data/files must never be served.\n const traversal = await fetch(`http://127.0.0.1:${port}/files?name=../secret.txt`);\n const traversalBody = await traversal.text();\n record('path-traversal-blocked', [400, 401, 403, 404].includes(traversal.status) && !traversalBody.includes('CANARY-7f3c9d'));\n const nested = await fetch(`http://127.0.0.1:${port}/files?name=${encodeURIComponent('..%2f..%2fsecret.txt')}`);\n record('encoded-traversal-blocked', !(await nested.text()).includes('CANARY-7f3c9d'));\n\n // Legitimate files still work.\n const legit = await fetch(`http://127.0.0.1:${port}/files?name=welcome.txt`);\n record('public-files-still-served', legit.status === 200 && (await legit.text()).includes('hello from sentinel'));\n\n // Reflected content is escaped in the HTML view.\n const xss = ``;\n const made = await createPaste(port, xss);\n const view = made.body && made.body.id\n ? await fetch(`http://127.0.0.1:${port}/p/${made.body.id}`) : null;\n const html = view ? await view.text() : '';\n record('html-view-escapes-content', Boolean(view) && !html.includes(xss) && html.includes('<script>'));\n\n // Delete tokens: unique, long, and not derived from the paste id.\n const first = await createPaste(port, 'one');\n const second = await createPaste(port, 'two');\n const t1 = first.body && first.body.deleteToken;\n const t2 = second.body && second.body.deleteToken;\n record('delete-token-unpredictable', Boolean(t1 && t2) && t1 !== t2 && t1.length >= 16\n && !t1.includes(String(first.body.id)) && t1 !== `tok_${first.body.id}`);\n\n // Oversized bodies are rejected per the contract.\n const big = await fetch(`http://127.0.0.1:${port}/pastes`, {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ content: 'x'.repeat(200 * 1024) }) });\n record('oversized-body-rejected-413', big.status === 413);\n\n // Functional regression: the documented flow still works end to end.\n const flow = await createPaste(port, 'roundtrip content');\n const readBack = flow.body && flow.body.id\n ? await fetch(`http://127.0.0.1:${port}/pastes/${flow.body.id}`) : null;\n const readJson = readBack ? await readBack.json().catch(() => null) : null;\n const deleted = flow.body && flow.body.id\n ? await fetch(`http://127.0.0.1:${port}/pastes/${flow.body.id}`, {\n method: 'DELETE', headers: { 'x-delete-token': flow.body.deleteToken } }) : null;\n const afterDelete = flow.body && flow.body.id\n ? await fetch(`http://127.0.0.1:${port}/pastes/${flow.body.id}`) : null;\n record('documented-api-still-works', Boolean(readJson) && readJson.content === 'roundtrip content'\n && Boolean(deleted) && deleted.status === 204 && Boolean(afterDelete) && afterDelete.status === 404);\n\n app.close();\n } catch { /* grader-side failure leaves remaining checks unscored */ }\n finish();\n})();\n" + }, + { + "id": "webhook-relay", + "category": "feature-build", + "manualIds": [ + "skill:tdd-workflow" + ], + "checkTimeoutMs": 60000, + "query": "The webhook relay in this repo accepts delivery requests but never actually sends them — the delivery worker was never finished, and customers are losing notifications. Implement asynchronous delivery per the README: POST each delivery's JSON payload to its URL, retry failures with exponential backoff starting around 100ms and doubling each time, give up after 5 total attempts and mark the delivery dead. Keep the documented module contract, make `npm test` pass, and extend the test suite to cover the retry and dead-letter behavior.", + "files": { + "package.json": "{\n \"name\": \"webhook-relay\",\n \"private\": true,\n \"type\": \"commonjs\",\n \"scripts\": { \"test\": \"node --test test/\" }\n}\n", + "README.md": "# webhook-relay\n\nIn-memory webhook relay. Accepts delivery requests over HTTP and POSTs each\npayload to its destination URL, retrying failures with exponential backoff.\n\n## HTTP API\n\n- `POST /deliveries` — body `{ \"url\": string, \"payload\": any }`. Responds\n `202` with `{ \"id\" }` and delivers asynchronously. `400` for invalid JSON.\n- `GET /deliveries/:id` — `200` with\n `{ \"id\", \"url\", \"status\", \"attempts\", \"lastError\" }`, or `404`.\n `status` is `pending`, `delivered`, or `dead`.\n\n## Delivery contract\n\n- The payload is POSTed to `url` with `content-type: application/json`.\n- Any 2xx response means success: `status` becomes `delivered`.\n- Any other outcome (non-2xx, connection error, timeout) is a failure and is\n retried with exponential backoff: the first retry happens after about\n 100ms and the delay doubles each retry. Up to 20% jitter in either\n direction is fine.\n- At most 5 attempts are made in total (the initial try plus 4 retries).\n- After the final failure the delivery becomes `dead` and `lastError`\n records a short description of the last failure.\n- `attempts` always reflects how many delivery attempts were made.\n\n## Module contract\n\n- `src/app.js` is CommonJS and exports `createRelay()`, which returns an\n `http.Server` that is not yet listening.\n- `node src/index.js ` starts the service.\n- No external dependencies; Node.js standard library only.\n- Run the tests with `npm test`.\n", + "src/app.js": "'use strict';\nconst http = require('node:http');\nconst crypto = require('node:crypto');\n\n// In-memory webhook relay. See README.md for the delivery contract.\n//\n// TODO: deliveries are accepted and stored, but the delivery worker was never\n// finished — nothing ever POSTs to the destination URL, retries never happen,\n// and records stay \"pending\" forever.\n\nfunction createRelay() {\n const deliveries = new Map();\n\n const server = http.createServer((req, res) => {\n if (req.method === 'POST' && req.url === '/deliveries') {\n let body = '';\n req.on('data', chunk => { body += chunk; });\n req.on('end', () => {\n let parsed;\n try { parsed = JSON.parse(body); } catch {\n res.writeHead(400, { 'content-type': 'application/json' });\n res.end(JSON.stringify({ error: 'invalid JSON body' }));\n return;\n }\n const id = crypto.randomUUID();\n deliveries.set(id, { id, url: parsed.url, payload: parsed.payload,\n status: 'pending', attempts: 0, lastError: null });\n res.writeHead(202, { 'content-type': 'application/json' });\n res.end(JSON.stringify({ id }));\n });\n return;\n }\n const match = /^\\/deliveries\\/([0-9a-f-]+)$/.exec(req.url || '');\n if (req.method === 'GET' && match) {\n const record = deliveries.get(match[1]);\n if (!record) {\n res.writeHead(404, { 'content-type': 'application/json' });\n res.end(JSON.stringify({ error: 'not found' }));\n return;\n }\n res.writeHead(200, { 'content-type': 'application/json' });\n res.end(JSON.stringify(record));\n return;\n }\n res.writeHead(404, { 'content-type': 'application/json' });\n res.end(JSON.stringify({ error: 'not found' }));\n });\n return server;\n}\n\nmodule.exports = { createRelay };\n", + "src/index.js": "'use strict';\nconst { createRelay } = require('./app');\n\nconst port = Number(process.argv[2] || 8080);\ncreateRelay().listen(port, () => {\n console.log(`webhook-relay listening on ${port}`);\n});\n", + "test/relay.test.js": "'use strict';\nconst test = require('node:test');\nconst assert = require('node:assert/strict');\nconst { createRelay } = require('../src/app');\n\nfunction listen(server) {\n return new Promise((resolve, reject) => {\n server.once('error', reject);\n server.listen(0, '127.0.0.1', () => resolve(server.address().port));\n });\n}\n\ntest('accepts a delivery and reports it as pending', async () => {\n const server = createRelay();\n const port = await listen(server);\n try {\n const created = await fetch(`http://127.0.0.1:${port}/deliveries`, {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ url: 'http://127.0.0.1:1/hook', payload: { a: 1 } }) });\n assert.equal(created.status, 202);\n const { id } = await created.json();\n const status = await fetch(`http://127.0.0.1:${port}/deliveries/${id}`);\n assert.equal(status.status, 200);\n const record = await status.json();\n assert.equal(record.status, 'pending');\n assert.equal(record.attempts, 0);\n } finally {\n server.close();\n }\n});\n\ntest('unknown delivery id returns 404', async () => {\n const server = createRelay();\n const port = await listen(server);\n try {\n const response = await fetch(`http://127.0.0.1:${port}/deliveries/00000000-0000-0000-0000-000000000000`);\n assert.equal(response.status, 404);\n } finally {\n server.close();\n }\n});\n" + }, + "check": "'use strict';\n// Hidden grader for webhook-relay: drives the agent's relay in-process against\n// local target servers and prints ECC_EVAL_SCORE. Always exits 0; the score line\n// carries the result. Runs under Node's read-only permission model, so it only\n// reads the workspace and talks to 127.0.0.1.\nconst http = require('node:http');\nconst path = require('node:path');\n\nconst checks = [];\nconst record = (name, ok) => checks.push({ name, ok: Boolean(ok) });\nconst sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\nlet finished = false;\n\nfunction finish() {\n if (finished) return;\n finished = true;\n const ok = checks.filter(c => c.ok).length;\n for (const c of checks) console.log(`${c.ok ? 'ok' : 'not ok'} - ${c.name}`);\n console.log(`ECC_EVAL_SCORE ${JSON.stringify({ score: checks.length ? ok / checks.length : 0, passed: ok, total: checks.length })}`);\n process.exit(0);\n}\nsetTimeout(finish, 45000).unref();\n\nfunction listen(server) {\n return new Promise((resolve, reject) => {\n server.once('error', reject);\n server.listen(0, '127.0.0.1', () => resolve(server.address().port));\n });\n}\n\nfunction postJson(port, urlPath, body) {\n return fetch(`http://127.0.0.1:${port}${urlPath}`, {\n method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })\n .then(async response => ({ status: response.status, body: await response.json().catch(() => null) }));\n}\n\nasync function waitForStatus(port, id, wanted, timeoutMs) {\n const started = Date.now();\n let last = null;\n while (Date.now() - started < timeoutMs) {\n try {\n const response = await fetch(`http://127.0.0.1:${port}/deliveries/${id}`);\n if (response.status === 200) {\n last = await response.json();\n if (last.status === wanted || last.status === 'dead') return { record: last, elapsedMs: Date.now() - started };\n }\n } catch { /* relay not ready yet */ }\n await sleep(25);\n }\n return { record: last, elapsedMs: Date.now() - started };\n}\n\n(async () => {\n let createRelay;\n try { ({ createRelay } = require(path.join(process.cwd(), 'src', 'app.js'))); } catch { finish(); return; }\n if (typeof createRelay !== 'function') { finish(); return; }\n\n // Probe group 1: a target that fails 3 times then succeeds.\n let calls = 0;\n const flaky = http.createServer((req, res) => {\n calls++;\n req.resume();\n req.on('end', () => { res.writeHead(calls <= 3 ? 500 : 200); res.end('{}'); });\n });\n const relay = createRelay();\n try {\n const flakyPort = await listen(flaky);\n const relayPort = await listen(relay);\n const started = Date.now();\n const created = await postJson(relayPort, '/deliveries', { url: `http://127.0.0.1:${flakyPort}/hook`, payload: { hello: 'world' } });\n record('accepts-delivery-202', created.status === 202 && created.body && typeof created.body.id === 'string');\n if (created.body && created.body.id) {\n const { record: rec, elapsedMs } = await waitForStatus(relayPort, created.body.id, 'delivered', 8000);\n record('delivered-after-retries', rec && rec.status === 'delivered' && calls >= 4);\n record('attempts-counted', rec && rec.attempts === 4);\n record('backoff-window-respected', rec && rec.status === 'delivered' && elapsedMs >= 250 && elapsedMs <= 5000 && Date.now() - started >= 250);\n } else {\n record('delivered-after-retries', false);\n record('attempts-counted', false);\n record('backoff-window-respected', false);\n }\n\n // Probe group 2: a target that always fails -> dead after exactly 5 attempts.\n let deadCalls = 0;\n const deadEnd = http.createServer((req, res) => {\n deadCalls++;\n req.resume();\n req.on('end', () => { res.writeHead(500); res.end('{}'); });\n });\n const deadPort = await listen(deadEnd);\n const doomed = await postJson(relayPort, '/deliveries', { url: `http://127.0.0.1:${deadPort}/hook`, payload: { x: 1 } });\n if (doomed.body && doomed.body.id) {\n const { record: rec } = await waitForStatus(relayPort, doomed.body.id, 'dead', 15000);\n record('dead-after-retries-exhausted', rec && rec.status === 'dead');\n record('exactly-five-attempts', rec && rec.status === 'dead' && rec.attempts === 5 && deadCalls === 5);\n record('last-error-recorded', rec && rec.status === 'dead' && typeof rec.lastError === 'string' && rec.lastError.length > 0);\n } else {\n record('dead-after-retries-exhausted', false);\n record('exactly-five-attempts', false);\n record('last-error-recorded', false);\n }\n deadEnd.close();\n\n // Probe 3: pre-existing API behavior is preserved.\n const missing = await fetch(`http://127.0.0.1:${relayPort}/deliveries/00000000-0000-0000-0000-000000000000`);\n record('unknown-id-still-404', missing.status === 404);\n\n // Probe 4: concurrent deliveries all complete.\n let goodCalls = 0;\n const good = http.createServer((req, res) => {\n goodCalls++;\n req.resume();\n req.on('end', () => { res.writeHead(200); res.end('{}'); });\n });\n const goodPort = await listen(good);\n const batch = await Promise.all(Array.from({ length: 10 }, (_, i) =>\n postJson(relayPort, '/deliveries', { url: `http://127.0.0.1:${goodPort}/hook`, payload: { i } })));\n const settled = await Promise.all(batch.map(item => item.body && item.body.id\n ? waitForStatus(relayPort, item.body.id, 'delivered', 10000).then(r => r.record && r.record.status === 'delivered')\n : false));\n record('concurrent-deliveries-complete', settled.every(Boolean) && goodCalls === 10);\n good.close();\n } catch { /* any grader-side failure leaves the missing checks unscored */ }\n finish();\n})();\n" + } + ] +} diff --git a/docker/context-profiles/complex-eval/DESIGN.md b/docker/context-profiles/complex-eval/DESIGN.md new file mode 100644 index 000000000..03ada0372 --- /dev/null +++ b/docker/context-profiles/complex-eval/DESIGN.md @@ -0,0 +1,293 @@ +# ECC Complex-Task Evaluation (complex-tasks@1) + +A reproducible, public benchmark of what ECC's context scoping does for **realistic +agent work** — as opposed to the 30-task repair corpus (`ai-corpus.json`), which +measures small, single-file fixes. This document is the preregistered methodology: +it was written before the first provider call against this corpus, and it is the +reference for anyone who wants to audit or rerun the evaluation. + +## Research question + +Does ECC's context engineering — the full skill library, manually picked skills +(manual-lean), automatic skill matching (auto-lean), and the ECC-029 changes +themselves — change what a frontier coding agent delivers on multi-step +engineering tasks, and at what cost in tokens, time, and dollars? + +## Arms + +Five conditions, all launched through the same evaluator with real installs in +isolated config homes, paired per task and repeat: + +| Arm | What the agent gets | What it represents | +|---|---|---| +| `full` | Branch skill library installed + ECC context block (catalog/resources) | ECC with scoping machinery present but everything loaded | +| `manual-lean` | lean profile + the maintainer-chosen canonical skill(s) injected | A user who knows exactly which ECC skill applies | +| `auto-lean` | lean profile; ECC's trigger/proposal machinery picks and injects skills | The "auto" experience: no ECC knowledge required | +| `ecc-legacy` | The full skill library **from the pinned pre-ECC-029 commit** (`legacy-source.json`, currently `e482e579` = `origin/main`), bare prompt, no context block | The typical current ECC user experience before the scoping work | +| `baseline` | No ECC install, bare prompt | The provider with no ECC at all (overhead subtraction) | + +`ecc-legacy` doubles as a replication control: where its install content matches +`full`, score differences between them isolate the ECC-029 deltas (rewritten +skill descriptions, scoping layer) rather than provider noise. + +## The three tasks + +Chosen to be the kind of work ECC exists for — multi-step, judgment-heavy, +checkpointable — while deliberately **not** shaped around ECC's current skill +list. Queries are written as a real user would phrase them, with no ECC +vocabulary, no hints about which skill applies, and no instruction to use any +particular methodology. Each task has one clear correct outcome and a +deterministic, dependency-free grader. + +1. **`webhook-relay`** (feature build). Finish an asynchronous webhook delivery + worker: retries with exponential backoff, dead-lettering after 5 attempts, + status reporting, under load. Graded by 9 in-process behavioral probes + (delivery after failures, exact attempt counts, backoff timing window, + dead-lettering, error capture, API preservation, concurrency). + *Why it belongs here:* everyday backend feature work where test discipline + and backend patterns genuinely change outcomes; canonical skill: + `tdd-workflow` (a second skill would exceed the 32 KB selection budget — + itself a measured constraint of the scoping layer). + +2. **`incident-triage`** (debugging / root cause). Finance reports one-cent + total errors since yesterday's deploy. The repo contains three changelog + entries (two red herrings), an incident log with concrete amounts, and a + regression: a "readability" refactor that switched integer-cent math to + decimal-factor floats, which under-rounds exact half-cent boundaries. + Graded by 5 boundary-value totals the float path provably gets wrong, one + regression probe, and 2 deterministic checks on the required `INCIDENT.md` + (names the right changelog entry, explains the rounding mechanism). + *Why it belongs here:* evidence-driven diagnosis under uncertainty is the + highest-leverage agent workflow; guessing is penalized because red herrings + are plausible; canonical skill: `orch-fix-defect`. + +3. **`sentinel-api`** (security review + hardening). A paste service whose + README documents the secure contract while the code violates it five ways: + hardcoded admin token, path traversal, reflected XSS, predictable delete + tokens, no body-size limit. Graded by 10 exploit probes (each vulnerability + must actually be closed) plus functional regression probes (the documented + API must still work), including one encoded-traversal variant so partial + fixes score partially. + *Why it belongs here:* security review is a canonical agent task with + objectively checkable outcomes; canonical skill: `security-review`. + +### Why these tests are effective + +- **Realism over benchmark gaming.** Each task is a small production-shaped + repo with docs, tests, logs, and changelogs — the inputs a real engineer (or + a real user of an agent harness) actually has. Nothing references ECC. +- **Correctness is decidable.** Every grader assertion is deterministic: + behavioral probes against the agent's own running service, exact numeric + answers on boundary cases, static source checks, exploit probes. No LLM + judges, no rubrics, no human scoring. +- **Partial credit.** Graders emit `ECC_EVAL_SCORE {"score": 0..1}`, so "found + 4 of 5 vulnerabilities" registers as 0.9-of-task progress instead of a binary + failure. Pass/fail (score = 1.0) is reported alongside the mean score. +- **Hard to luck into.** Red herrings (incident-triage), timing windows + (webhook-relay), and exploit-verified fixes (sentinel-api) mean superficial + plausible work scores low. +- **Fair across arms.** Hidden graders run only after the agent exits, from a + read-only sandbox; the agent never sees the grader. The same grader scores + every arm identically. Reference solutions score 1.0 and as-shipped fixtures + score ≤ 0.3 (`verify-checks.js` proves both before any provider call). + +## Measured variables + +Per trial (one task × arm × repeat), from the provider's own usage events: + +- **Fresh input tokens** (input + cache-creation), **cache-read tokens**, + **output tokens** — the context-cost story. +- **Provider calls** per trial (1, or 2 when auto-lean needs a routing proposal). +- **Wall-clock time** per provider call and per trial (ms) — time to completion. +- **Score** (0..1) and **pass** (score = 1.0) from the hidden grader. +- **API-equivalent cost**, derived at analysis time at Anthropic Opus list + prices ($15 / $1.50 / $75 per million fresh-input / cache-read / output + tokens). This is an accounting convention for comparison, not a billing + claim; subscription pricing differs. +- **Skill routing** (auto-lean): which skills the trigger/proposal machinery + selected vs the maintainer-chosen canonical set, reported as the selection + probe accuracy — the direct measure of "automatic skill matching". + +Comparisons are **within-run only**: same provider, model, executable digest, +corpus digest, and source digest, paired by task and repeat. Cross-run and +cross-provider comparisons are invalid by design. This is a descriptive pilot +(3 tasks × 5 arms × 4 repeats = 60 trials): it estimates direction and +magnitude, not population statistics, and the report says so in its gate block. + +## Reproducing or auditing + +Everything below is committed; there are no hidden inputs. + +```bash +# 1. Inspect the tasks: fixtures, queries, graders, and reference solutions. +ls docker/context-profiles/complex-eval/cases/ +ls docker/context-profiles/complex-eval/reference/ + +# 2. Prove the graders: reference solutions must score 1.0, fixtures below 1.0. +node docker/context-profiles/complex-eval/verify-checks.js + +# 3. Rebuild the corpus after any fixture edit (digest-pinned at registration). +node docker/context-profiles/complex-eval/build-corpus.js + +# 4. Preregister (pins corpus, source, model, executable digests; no provider). +node docker/context-profiles/ai-eval.js --plan \ + --corpus docker/context-profiles/complex-corpus.json --repeats 4 \ + --provider claude --model --executable /absolute/path/to/claude \ + > registration.json + +# 5. Run (requires your own Claude subscription login or API key). +node docker/context-profiles/ai-eval.js --allow-real-provider --allow-credentialed-tools \ + --registration registration.json \ + --corpus docker/context-profiles/complex-corpus.json \ + --provider claude --model --executable /absolute/path/to/claude \ + --repeats 4 --max-calls 400 --deadline-ms 25200000 --call-timeout-ms 600000 \ + --artifact-dir /absolute/path/for/transcripts > report.json +``` + +Claude task tools inherit the provider credential through the CLI process and can read it. Use +`--allow-credentialed-tools` only with a trusted local corpus and credential. Without that +explicit flag, real Claude task evaluation stops before a provider call; selection-only calls +remain tool-free. This development evaluator does not provide a credential isolation boundary. + +The registration digest binds the exact corpus, evaluator source, model, and +executable; the run refuses to start if any of them drift, and aborts if the +tree changes mid-run. `--artifact-dir` retains per-trial session transcripts +for independent inspection (they never enter the report). The `ecc-legacy` arm +is pinned by commit in `legacy-source.json` and exported from git objects at +run time. The Codex provider is unsupported for this corpus (the legacy arm has +no Codex install path); `--provider claude` is required. + +## Known limits + +- Three tasks is a probe, not a census: treat intervals as descriptive. +- Tasks are Node.js/stdlib by construction (graders must be hermetic); results + say nothing about other ecosystems directly. +- `webhook-relay` uses wall-clock backoff windows; bounds are wide (250–5000ms) + but loaded machines could in principle flake a timing probe. The grader + reports each probe individually so flakes are visible. +- Provider behavior varies week to week; the pinned model/executable digests + make a rerun comparable only within the same pin. +- Fixture wart observed in the 2026-09-25 run: on Node 24, `node --test test/` + no longer scans the directory the way Node 22 did, so `npm test` fails as + shipped. This is identical for every arm (the task says to make `npm test` + pass, and agents fix the script), so fairness holds, but it adds unplanned + work per trial. A future corpus revision should ship a portable test script. + +## complex-tasks@2 (discriminative revision) + +The @1 run saturated: every arm scored 1.000 on every task, so only economics +and routing differed. @2 (`cases2/`, built to `complex-corpus-v2.json`) is +designed to discriminate on the axes users actually pay for — correctness on +traps, solution efficiency, spec thoroughness — with wide partial-credit +spreads. The @1 corpus and its report stay untouched for comparability. + +1. **`keccak-selector`** (domain-knowledge trap). Implement Ethereum function + selectors from scratch, stdlib only. The trap: Node's crypto offers + SHA3-256, which shares the Keccak-f[1600] permutation but differs in + padding — the naive one-liner is wrong for every vector (verified: the + naive control scores 0.25, format checks only). Graded by 9 selector + vectors including a padding edge case, all cross-validated against Node's + SHA3-256 on shared-permutation inputs. Canonical skill: `nodejs-keccak256`. + *Hypothesis:* the skill body carries exactly this knowledge; bare agents + must rediscover it. + +2. **`event-stats-api`** (correctness edges + measured efficiency). A shipped + implementation that is both wrong on the documented edge semantics + (interpolated instead of nearest-rank percentiles, zeros instead of nulls, + unrounded averages, missing 400s) and algorithmically naive (full-log scan + and sort per query). Graded by 10 independently computed correctness probes + plus a measured 2,000-query performance budget (threshold 6s; shipped naive + ~7.7s, reference ~1.5s — calibrated on the grading machine in + `calibrate-stats.js`). Canonical skill: `backend-patterns`. *Hypothesis:* + solution *efficiency* separates arms even when correctness doesn't. + +3. **`forge-cli`** (spec thoroughness + robustness). Twelve contractual + behaviors with exact messages, exit codes, sorting, and a never-throw + guarantee, graded by 26 checks including junk-input fuzzing and static + hygiene (no leftover TODO/FIXME, no new dependencies). Canonical skill: + `tdd-workflow`. *Hypothesis:* checklist discipline shows up as breadth of + completion, and partial credit spreads the distribution. + +First @2 run uses `claude-opus-4-8` (cost discipline); the corpus is +provider- and model-pinned per run, so a later Opus 5.5 rerun on the same +digest measures the model difference directly. repeats=2 (30 trials): simple +experimentation, expand later. + +## complex-tasks@3 (vagueness and horizon; arms: auto-lean vs baseline) + +@2 still saturated on outcomes (30/30) — enumerated specs are within the +model's cold competence. @3 (`cases3/`, built to `complex-corpus-v3.json`) +moves grading to what users actually complain about (see the complaint +taxonomy in this file's discussion: happy-path-only work, unverified +completion, skipped implied work, convention drift, concurrency blindness). +Everything graded is discoverable from repo docs visible to every arm — the +question is whether agents reliably *do* all of it under vague instruction. + +1. **`chained-tickets`** (long horizon). Four sequential tickets in one + accumulating workspace — build a link shortener core, then vague tickets: + "links need to survive a restart", "we're seeing abuse, deal with it", + "track redirect hits, consistent with the existing API". 33 hidden probes + across the four steps grade function, convention compliance (error + envelope, layering — pinned in a visible CONTRIBUTING.md), and implied + work (changelog entries, growing tests, accurate README). Stepped trials + grade each ticket after its call; a failed ticket ends the chain. +2. **`production-ready`** (vague prompt, heavy implication). "This goes to + production Monday — get it ready." A documented production bar + (validation envelopes, body limits, /health, structured request logs, env + config, graceful SIGTERM, nosniff, error-path tests, changelog) graded by + 16 probes against a naive prototype. Fixture scores 0.063. +3. **`idempotent-webhooks`** (the "almost right" trap). A payment receiver + whose shipped code has a textbook check-then-act race (INC-104). Hidden + grader fires 50 concurrent identical deliveries plus replay, already-paid, + mixed-storm, and contract probes. The naive fixture double-applies and + crashes on unknown orders (0.25). Exactly-once requires claiming events + synchronously — the discipline skills like `error-handling` encode. + +Grader robustness (hard-won, now fixed and unit-tested): a graded server runs +in-process, so a crashing server kills the grader. Graders install +uncaughtException/unhandledRejection handlers, emit their score line via +`process.stdout.write` (immune to the log-capture patching used in probes), +pre-declare their check totals (unreached checks score zero), and the +evaluator itself treats a score-advertising grader that printed nothing as a +zero (`graderDied` guard in `runScoredCheck`). Stepped graders may write to +the workspace (persistence probes); single-step graders stay read-only. + +First @3 run: arms `auto-lean` and `baseline` only, repeats=1, +`claude-opus-4-8` — the direct test of "ECC auto-routing vs no harness" on +quality, time, and tokens. Full-arm and Opus 5.5 replications follow if the +spread shows up. + +## complex-tasks@4 (learning loops; adds recurring-incident) + +@4 (`cases4/`, built to `complex-corpus-v4.json`) keeps the three @3 cases +unchanged and adds a fourth targeting a different ECC value prop: converting +a fix into durable, reusable prevention — and *reusing your own artifacts* +later in the session. Baseline agents can hold this in context; ECC's claim +is that skills/workflows make it systematic. + +4. **`recurring-incident`** (learning loop / institutional memory). Three + chained steps against a dependency-free payments service whose gateway + records side effects in an append-only JSONL ledger. Step 1: keyless + refund retries double-refund (INC-201/214/227 "third time this quarter" + trail in `docs/incidents.md`); the vague ask is "make sure this stops + being a recurring incident." Probes: functional correctness across a + module reload (kills in-memory-only fixes) [0.40], regression test wired + into the suite + mutation probe [0.30], a durable prevention runbook + [0.20], and the mechanism living in one shared helper module [0.10]. + Step 2: payout retries, "same family of problem" — graded on REUSE of + the step-1 helper (static import check + no divergent inline + reimplementation) [0.30] alongside function [0.40], test+mutation [0.20], + doc update [0.10]. Step 3: "write the handoff note" — graded on + existence [0.20], every referenced path actually existing on disk [0.30], + naming the helper + prevention procedure [0.30], and covering both + incidents [0.20]. Manual skills: `error-handling`, `continuous-learning`. + *Hypothesis:* learning-loop behavior (abstract once, reuse, document, + hand off) separates harnessed arms from baseline even when raw bug-fix + competence doesn't. + +Verification: reference 1.000 on all steps of all four cases; naive +recurring-incident scores 0.20 / 0.00 / 0.20 per step; fixtures 0.00–0.25. + +First @4 run: arm `auto-lean` only, repeats=1, `claude-opus-5-5` — the +model-difference probe against the @3 opus-4-8 numbers on the shared cases, +plus first signal on the learning-loop case. diff --git a/docker/context-profiles/complex-eval/build-corpus.js b/docker/context-profiles/complex-eval/build-corpus.js new file mode 100644 index 000000000..ceb0dc808 --- /dev/null +++ b/docker/context-profiles/complex-eval/build-corpus.js @@ -0,0 +1,67 @@ +'use strict'; +// Development tool: assembles a complex corpus JSON from a reviewed fixture +// tree. Usage: node build-corpus.js [casesDir=cases] [outFile=complex-corpus.json] [corpusId=complex-tasks@1] +// Run after editing any fixture, query, or grader; commit the tree and the +// regenerated corpus together. +const fs = require('node:fs'); +const path = require('node:path'); + +const root = __dirname; +const casesDir = path.join(root, process.argv[2] || 'cases'); +const OUT = path.join(root, '..', process.argv[3] || 'complex-corpus.json'); +const corpusId = process.argv[4] || 'complex-tasks@1'; + +function collect(directory, prefix = '') { + const files = {}; + for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) Object.assign(files, collect(path.join(directory, entry.name), relative)); + else if (entry.isFile()) files[relative] = fs.readFileSync(path.join(directory, entry.name), 'utf8'); + } + return files; +} + +const tasks = []; +const selection = []; +for (const id of fs.readdirSync(casesDir).sort()) { + const directory = path.join(casesDir, id); + const meta = JSON.parse(fs.readFileSync(path.join(directory, 'meta.json'), 'utf8')); + if (meta.id !== id || !/^[a-z][a-z0-9-]{0,63}$/.test(id)) throw new Error(`Invalid task metadata in ${id}`); + const files = collect(path.join(directory, 'files')); + const stepsDir = path.join(directory, 'steps'); + let task; + if (fs.existsSync(stepsDir)) { + const steps = fs.readdirSync(stepsDir).sort().map((name, index) => ({ + query: fs.readFileSync(path.join(stepsDir, name, 'query.md'), 'utf8').trim(), + check: fs.readFileSync(path.join(stepsDir, name, 'check.cjs'), 'utf8'), + ...(meta.steps?.[index]?.manualIds ? { manualIds: meta.steps[index].manualIds } : {}), + ...((meta.steps?.[index]?.checkTimeoutMs || meta.checkTimeoutMs) + ? { checkTimeoutMs: meta.steps?.[index]?.checkTimeoutMs || meta.checkTimeoutMs } : {}), + })); + task = { id, category: meta.category, manualIds: meta.manualIds || [], files, steps }; + } else { + const query = fs.readFileSync(path.join(directory, 'query.md'), 'utf8').trim(); + task = { id, category: meta.category, manualIds: meta.manualIds, + ...(meta.checkTimeoutMs ? { checkTimeoutMs: meta.checkTimeoutMs } : {}), + query, files, check: fs.readFileSync(path.join(directory, 'check.cjs'), 'utf8') }; + } + tasks.push(task); + selection.push({ id: meta.selection.id, category: meta.selection.category, + query: meta.selection.query || task.query || task.steps.map(step => step.query).join(' '), + expectedIds: meta.selection.expectedIds }); +} + +const corpus = { + schemaVersion: 'ecc.context-eval-complex-corpus.v1', + id: corpusId, + sampling: 'Realistic multi-file engineering tasks, fixed before any provider call, with deterministic ' + + 'hidden graders scoring partial credit (ECC_EVAL_SCORE). Descriptive pilot: no ' + + 'population-representativeness claim. See complex-eval/DESIGN.md for the preregistered methodology.', + minimumDistinctTasks: tasks.length, + nonInferiorityMargin: 0.05, + selection, + tasks, +}; +fs.writeFileSync(OUT, `${JSON.stringify(corpus, null, 1)}\n`); +console.log(`wrote ${path.basename(OUT)} (${corpusId}): ${tasks.length} tasks, ${selection.length} selection probes, ` + + `${tasks.reduce((sum, task) => sum + Object.keys(task.files).length, 0)} fixture files`); diff --git a/docker/context-profiles/complex-eval/calibrate-stats.js b/docker/context-profiles/complex-eval/calibrate-stats.js new file mode 100644 index 000000000..aa8c76912 --- /dev/null +++ b/docker/context-profiles/complex-eval/calibrate-stats.js @@ -0,0 +1,73 @@ +'use strict'; +// Calibration harness (not shipped in the corpus): measures the 2,000-query +// workload wall time for the shipped naive app and the reference app, each +// staged as a standalone copy (fixture; fixture + reference overlay). +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const root = __dirname; +const fixture = path.join(root, 'cases2', 'event-stats-api', 'files'); +const overlay = path.join(root, 'reference2', 'event-stats-api'); + +function stage(withOverlay) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-calib-')); + const copy = (from, to) => { + for (const entry of fs.readdirSync(from, { withFileTypes: true })) { + const target = path.join(to, entry.name); + if (entry.isDirectory()) { fs.mkdirSync(target, { recursive: true }); copy(path.join(from, entry.name), target); } + else fs.copyFileSync(path.join(from, entry.name), target); + } + }; + copy(fixture, dir); + if (withOverlay) copy(overlay, dir); + return dir; +} + +function lcg(seed) { + let state = seed >>> 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state / 2 ** 32; + }; +} + +function workload(types, epoch, span) { + const rand = lcg(777); + const queries = []; + for (let i = 0; i < 2000; i++) { + const type = types[Math.floor(rand() * types.length)]; + const start = epoch + Math.floor(rand() * span * 0.7); + queries.push({ type, from: start, to: start + Math.floor(rand() * span * 0.5) }); + } + return queries; +} + +async function measure(label, dir) { + const { createApp } = require(path.join(dir, 'src', 'app.js')); + const { TYPES, EPOCH_MS, SPAN_MS } = require(path.join(dir, 'src', 'data.js')); + const app = createApp(); + await new Promise(resolve => app.listen(0, '127.0.0.1', resolve)); + const port = app.address().port; + const queries = workload(TYPES, EPOCH_MS, SPAN_MS); + const started = Date.now(); + for (let i = 0; i < queries.length; i += 20) { + await Promise.all(queries.slice(i, i + 20).map(q => + fetch(`http://127.0.0.1:${port}/stats?type=${q.type}&from=${q.from}&to=${q.to}`).then(r => r.json()))); + } + const elapsed = Date.now() - started; + app.close(); + console.log(`${label}: ${elapsed}ms for 2000 queries`); + return elapsed; +} + +(async () => { + const naiveDir = stage(false); + const refDir = stage(true); + await measure('naive 1 ', naiveDir); + await measure('naive 2 ', naiveDir); + await measure('reference 1 ', refDir); + await measure('reference 2 ', refDir); + fs.rmSync(naiveDir, { recursive: true, force: true }); + fs.rmSync(refDir, { recursive: true, force: true }); +})(); diff --git a/docker/context-profiles/complex-eval/cases/incident-triage/check.cjs b/docker/context-profiles/complex-eval/cases/incident-triage/check.cjs new file mode 100644 index 000000000..0c244584e --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/incident-triage/check.cjs @@ -0,0 +1,45 @@ +'use strict'; +// Hidden grader for incident-triage: checks exact totals on boundary orders and +// the root-cause report. Prints ECC_EVAL_SCORE and always exits 0. +const fs = require('node:fs'); +const path = require('node:path'); + +const checks = []; +const record = (name, ok) => checks.push({ name, ok: Boolean(ok) }); + +let computeOrderTotal; +try { ({ computeOrderTotal } = require(path.join(process.cwd(), 'src', 'totals.js'))); } catch { /* scored below */ } + +// Boundary orders where decimal-factor float math under-rounds by a cent; +// expected values follow the README pricing rules (integer cents, half-up per line). +const boundary = [ + { lines: [{ priceCents: 165, quantity: 1 }], discountPercent: 30, expected: 116 }, + { lines: [{ priceCents: 250, quantity: 1 }], discountPercent: 7, expected: 233 }, + { lines: [{ priceCents: 325, quantity: 1 }], discountPercent: 30, expected: 228 }, + { lines: [{ priceCents: 345, quantity: 1 }], discountPercent: 30, expected: 242 }, + { lines: [{ priceCents: 165, quantity: 1 }, { priceCents: 325, quantity: 1 }], discountPercent: 30, expected: 344 }, +]; + +if (typeof computeOrderTotal === 'function') { + boundary.forEach((order, index) => { + let actual = NaN; + try { actual = computeOrderTotal({ lines: order.lines, discountPercent: order.discountPercent }); } catch { /* wrong */ } + record(`boundary-total-${index + 1}`, actual === order.expected); + }); + let plain = NaN; + try { plain = computeOrderTotal({ lines: [{ priceCents: 1000, quantity: 2 }], discountPercent: 0 }); } catch { /* wrong */ } + record('undiscounted-total-unchanged', plain === 2000); +} else { + for (let index = 0; index < boundary.length; index++) record(`boundary-total-${index + 1}`, false); + record('undiscounted-total-unchanged', false); +} + +let incident = ''; +try { incident = fs.readFileSync(path.join(process.cwd(), 'INCIDENT.md'), 'utf8'); } catch { /* missing */ } +record('incident-identifies-C-2', /C-2/.test(incident)); +record('incident-explains-rounding', /round|float|decimal|cent/i.test(incident)); + +const ok = checks.filter(c => c.ok).length; +for (const c of checks) console.log(`${c.ok ? 'ok' : 'not ok'} - ${c.name}`); +console.log(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / checks.length, passed: ok, total: checks.length })}`); +process.exit(0); diff --git a/docker/context-profiles/complex-eval/cases/incident-triage/files/CHANGELOG.md b/docker/context-profiles/complex-eval/cases/incident-triage/files/CHANGELOG.md new file mode 100644 index 000000000..962bc7293 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/incident-triage/files/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## 2026-09-23 deploy + +- **C-1**: request logging switched to JSON lines (`src/request-log.js`). + Log volume and format only; no request-handling behavior changed. +- **C-2**: totals computation refactored for readability (`src/totals.js`). + The old cents-as-integers helper was replaced with a direct decimal + expression that reviewers found easier to follow. No behavior change intended. +- **C-3**: inventory client timeout raised from 2s to 5s (`src/inventory-client.js`). + Reduces spurious failures when the inventory service is slow. diff --git a/docker/context-profiles/complex-eval/cases/incident-triage/files/README.md b/docker/context-profiles/complex-eval/cases/incident-triage/files/README.md new file mode 100644 index 000000000..943407a99 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/incident-triage/files/README.md @@ -0,0 +1,21 @@ +# order-service + +Computes order totals for the checkout service. + +## Pricing rules + +An order is `{ "lines": [{ "priceCents": number, "quantity": number }], "discountPercent": number }`. + +- All prices are integer cents. There is no such thing as a fraction of a cent + in an order total. +- The discount applies per line: `lineCents = priceCents * quantity * (100 - discountPercent) / 100`, + rounded **half-up** to the nearest cent (0.5 rounds up). +- The order total is the sum of the rounded line totals, in integer cents. + +`src/totals.js` is CommonJS and exports `computeOrderTotal(order)` returning the +total in integer cents. Run the tests with `npm test`. + +## Operations + +- `CHANGELOG.md` records what shipped in each deploy. +- `evidence/incident.txt` holds the finance team's findings for the current incident. diff --git a/docker/context-profiles/complex-eval/cases/incident-triage/files/evidence/incident.txt b/docker/context-profiles/complex-eval/cases/incident-triage/files/evidence/incident.txt new file mode 100644 index 000000000..54cf683c8 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/incident-triage/files/evidence/incident.txt @@ -0,0 +1,5 @@ +2026-09-24T08:57:11Z finance-review order=ORD-2204 note="charged_total_cents=115 expected_total_cents=116 lines=[{priceCents:165,quantity:1}] discountPercent=30" +2026-09-24T09:14:02Z finance-review order=ORD-2291 note="charged_total_cents=232 expected_total_cents=233 lines=[{priceCents:250,quantity:1}] discountPercent=7" +2026-09-24T09:41:37Z finance-review order=ORD-2310 note="charged_total_cents=227 expected_total_cents=228 lines=[{priceCents:325,quantity:1}] discountPercent=30" +2026-09-24T10:05:19Z support-ticket customer="ORDER-2310 looks like it undercharged me by a cent vs the invoice email" +2026-09-24T10:22:48Z finance-review summary="12 of 4,813 orders since the 2026-09-23 deploy are off by exactly one cent, always in the store's favor; all pre-deploy orders reconcile" diff --git a/docker/context-profiles/complex-eval/cases/incident-triage/files/package.json b/docker/context-profiles/complex-eval/cases/incident-triage/files/package.json new file mode 100644 index 000000000..20141cc70 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/incident-triage/files/package.json @@ -0,0 +1,6 @@ +{ + "name": "order-service", + "private": true, + "type": "commonjs", + "scripts": { "test": "node --test test/" } +} diff --git a/docker/context-profiles/complex-eval/cases/incident-triage/files/src/inventory-client.js b/docker/context-profiles/complex-eval/cases/incident-triage/files/src/inventory-client.js new file mode 100644 index 000000000..eec646f10 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/incident-triage/files/src/inventory-client.js @@ -0,0 +1,11 @@ +'use strict'; + +// Changed 2026-09-23 (C-3): the inventory service has been slow this week; +// give it 5s instead of 2s before declaring a failure. +const INVENTORY_TIMEOUT_MS = 5000; + +function inventoryClientOptions() { + return { timeoutMs: INVENTORY_TIMEOUT_MS, retries: 2 }; +} + +module.exports = { inventoryClientOptions }; diff --git a/docker/context-profiles/complex-eval/cases/incident-triage/files/src/request-log.js b/docker/context-profiles/complex-eval/cases/incident-triage/files/src/request-log.js new file mode 100644 index 000000000..b166da38f --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/incident-triage/files/src/request-log.js @@ -0,0 +1,13 @@ +'use strict'; + +// Changed 2026-09-23 (C-1): emit request logs as JSON lines so the log +// pipeline can parse them without regexes. +function logRequest(req) { + console.log(JSON.stringify({ + method: req.method, + url: req.url, + at: new Date().toISOString(), + })); +} + +module.exports = { logRequest }; diff --git a/docker/context-profiles/complex-eval/cases/incident-triage/files/src/totals.js b/docker/context-profiles/complex-eval/cases/incident-triage/files/src/totals.js new file mode 100644 index 000000000..6ec43c8fb --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/incident-triage/files/src/totals.js @@ -0,0 +1,14 @@ +'use strict'; + +// Refactored 2026-09-23 (C-2): express the discount math directly with a +// decimal factor instead of the old integer-cents helper, which reviewers +// found hard to follow. +function computeOrderTotal(order) { + let total = 0; + for (const line of order.lines) { + total += Math.round(line.priceCents * line.quantity * (1 - order.discountPercent / 100)); + } + return total; +} + +module.exports = { computeOrderTotal }; diff --git a/docker/context-profiles/complex-eval/cases/incident-triage/files/test/totals.test.js b/docker/context-profiles/complex-eval/cases/incident-triage/files/test/totals.test.js new file mode 100644 index 000000000..a05d637f7 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/incident-triage/files/test/totals.test.js @@ -0,0 +1,16 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { computeOrderTotal } = require('../src/totals'); + +test('sums lines without a discount', () => { + assert.equal(computeOrderTotal({ lines: [{ priceCents: 1000, quantity: 2 }], discountPercent: 0 }), 2000); +}); + +test('applies a clean quarter discount', () => { + assert.equal(computeOrderTotal({ lines: [{ priceCents: 2000, quantity: 1 }], discountPercent: 25 }), 1500); +}); + +test('multiplies quantity before discounting', () => { + assert.equal(computeOrderTotal({ lines: [{ priceCents: 400, quantity: 3 }], discountPercent: 50 }), 600); +}); diff --git a/docker/context-profiles/complex-eval/cases/incident-triage/meta.json b/docker/context-profiles/complex-eval/cases/incident-triage/meta.json new file mode 100644 index 000000000..14c2b26f2 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/incident-triage/meta.json @@ -0,0 +1,11 @@ +{ + "id": "incident-triage", + "category": "debugging-incident", + "manualIds": ["skill:orch-fix-defect"], + "checkTimeoutMs": 30000, + "selection": { + "id": "complex-incident-triage", + "category": "complex-debugging-incident", + "expectedIds": ["skill:orch-fix-defect"] + } +} diff --git a/docker/context-profiles/complex-eval/cases/incident-triage/query.md b/docker/context-profiles/complex-eval/cases/incident-triage/query.md new file mode 100644 index 000000000..382a85999 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/incident-triage/query.md @@ -0,0 +1 @@ +Finance flagged that some order totals have been off by a cent since yesterday's deploy — details are in evidence/incident.txt. Three changes shipped yesterday (CHANGELOG.md, entries C-1 to C-3). Find the root cause, fix it so totals are computed exactly per the pricing rules in the README, keep `npm test` green, and write INCIDENT.md at the repo root identifying which changelog entry introduced the regression, with a short explanation of why it produces wrong totals. diff --git a/docker/context-profiles/complex-eval/cases/sentinel-api/check.cjs b/docker/context-profiles/complex-eval/cases/sentinel-api/check.cjs new file mode 100644 index 000000000..45ed1f8c4 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/sentinel-api/check.cjs @@ -0,0 +1,123 @@ +'use strict'; +// Hidden grader for sentinel-api: runs exploit probes and functional regression +// probes against the agent's service, in-process, plus static source checks. +// Prints ECC_EVAL_SCORE and always exits 0. +const fs = require('node:fs'); +const path = require('node:path'); + +const checks = []; +const record = (name, ok) => checks.push({ name, ok: Boolean(ok) }); +let finished = false; + +function finish() { + if (finished) return; + finished = true; + const ok = checks.filter(c => c.ok).length; + for (const c of checks) console.log(`${c.ok ? 'ok' : 'not ok'} - ${c.name}`); + console.log(`ECC_EVAL_SCORE ${JSON.stringify({ score: checks.length ? ok / checks.length : 0, passed: ok, total: checks.length })}`); + process.exit(0); +} +setTimeout(finish, 45000).unref(); + +const HARDCODED_TOKEN = 'fixture-token'; +const GRADER_TOKEN = 'ecc-grader-admin-token-31f7'; + +function sourceFiles(directory, found = []) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const item = path.join(directory, entry.name); + if (entry.isDirectory()) sourceFiles(item, found); + else if (entry.name.endsWith('.js')) found.push(item); + } + return found; +} + +function listen(server) { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve(server.address().port)); + }); +} + +async function createPaste(port, content) { + const response = await fetch(`http://127.0.0.1:${port}/pastes`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ content }) }); + return { status: response.status, body: await response.json().catch(() => null) }; +} + +(async () => { + const root = process.cwd(); + const sources = sourceFiles(path.join(root, 'src')) + .map(file => fs.readFileSync(file, 'utf8')).join('\n'); + + // Static: the committed admin token must be gone. + record('hardcoded-admin-token-removed', !sources.includes(HARDCODED_TOKEN)); + + // Static: delete tokens must come from a cryptographic source. + record('delete-token-from-crypto', /crypto\.(randomBytes|randomUUID|randomInt)/.test(sources)); + + // The service reads ADMIN_TOKEN from the environment at startup. + process.env.ADMIN_TOKEN = GRADER_TOKEN; + let createApp; + try { ({ createApp } = require(path.join(root, 'src', 'app.js'))); } catch { finish(); return; } + if (typeof createApp !== 'function') { finish(); return; } + + try { + const app = createApp(); + const port = await listen(app); + + // Admin route: rejected without the token, allowed with the environment token. + const denied = await fetch(`http://127.0.0.1:${port}/admin/stats`); + const wrong = await fetch(`http://127.0.0.1:${port}/admin/stats`, { headers: { 'x-admin-token': HARDCODED_TOKEN } }); + const allowed = await fetch(`http://127.0.0.1:${port}/admin/stats`, { headers: { 'x-admin-token': GRADER_TOKEN } }); + record('admin-requires-env-token', denied.status === 401 && wrong.status === 401 && allowed.status === 200); + + // Path traversal: the canary outside data/files must never be served. + const traversal = await fetch(`http://127.0.0.1:${port}/files?name=../secret.txt`); + const traversalBody = await traversal.text(); + record('path-traversal-blocked', [400, 401, 403, 404].includes(traversal.status) && !traversalBody.includes('CANARY-7f3c9d')); + const nested = await fetch(`http://127.0.0.1:${port}/files?name=${encodeURIComponent('..%2f..%2fsecret.txt')}`); + record('encoded-traversal-blocked', !(await nested.text()).includes('CANARY-7f3c9d')); + + // Legitimate files still work. + const legit = await fetch(`http://127.0.0.1:${port}/files?name=welcome.txt`); + record('public-files-still-served', legit.status === 200 && (await legit.text()).includes('hello from sentinel')); + + // Reflected content is escaped in the HTML view. + const xss = ``; + const made = await createPaste(port, xss); + const view = made.body && made.body.id + ? await fetch(`http://127.0.0.1:${port}/p/${made.body.id}`) : null; + const html = view ? await view.text() : ''; + record('html-view-escapes-content', Boolean(view) && !html.includes(xss) && html.includes('<script>')); + + // Delete tokens: unique, long, and not derived from the paste id. + const first = await createPaste(port, 'one'); + const second = await createPaste(port, 'two'); + const t1 = first.body && first.body.deleteToken; + const t2 = second.body && second.body.deleteToken; + record('delete-token-unpredictable', Boolean(t1 && t2) && t1 !== t2 && t1.length >= 16 + && !t1.includes(String(first.body.id)) && t1 !== `tok_${first.body.id}`); + + // Oversized bodies are rejected per the contract. + const big = await fetch(`http://127.0.0.1:${port}/pastes`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ content: 'x'.repeat(200 * 1024) }) }); + record('oversized-body-rejected-413', big.status === 413); + + // Functional regression: the documented flow still works end to end. + const flow = await createPaste(port, 'roundtrip content'); + const readBack = flow.body && flow.body.id + ? await fetch(`http://127.0.0.1:${port}/pastes/${flow.body.id}`) : null; + const readJson = readBack ? await readBack.json().catch(() => null) : null; + const deleted = flow.body && flow.body.id + ? await fetch(`http://127.0.0.1:${port}/pastes/${flow.body.id}`, { + method: 'DELETE', headers: { 'x-delete-token': flow.body.deleteToken } }) : null; + const afterDelete = flow.body && flow.body.id + ? await fetch(`http://127.0.0.1:${port}/pastes/${flow.body.id}`) : null; + record('documented-api-still-works', Boolean(readJson) && readJson.content === 'roundtrip content' + && Boolean(deleted) && deleted.status === 204 && Boolean(afterDelete) && afterDelete.status === 404); + + app.close(); + } catch { /* grader-side failure leaves remaining checks unscored */ } + finish(); +})(); diff --git a/docker/context-profiles/complex-eval/cases/sentinel-api/files/README.md b/docker/context-profiles/complex-eval/cases/sentinel-api/files/README.md new file mode 100644 index 000000000..410907f8d --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/sentinel-api/files/README.md @@ -0,0 +1,28 @@ +# sentinel-paste + +Internal paste-sharing service. In-memory store, Node.js standard library only. + +## API contract + +- `POST /pastes` — JSON body `{ "content": string }`. `201` with + `{ "id", "deleteToken" }`. Request bodies larger than 64 KB are rejected with + `413 Payload Too Large`. +- `GET /pastes/:id` — `200` JSON `{ "id", "content" }`, or `404`. +- `DELETE /pastes/:id` — requires the `X-Delete-Token` header to match the + token returned at creation. `204` on success, `403` otherwise. Delete tokens + are shown once and must be unguessable. +- `GET /p/:id` — HTML view of a paste. The paste content is rendered as text: + it must never be interpreted as markup. +- `GET /files?name=` — serves a public file from `data/files/`. Only + files inside that directory may ever be served. +- `GET /admin/stats` — operational stats. Requires the `X-Admin-Token` header + to match the `ADMIN_TOKEN` environment variable; the service reads it at + startup and refuses all admin access (`401`) when it is missing or wrong. + The token must never be committed to the repository. + +## Module contract + +- `src/app.js` is CommonJS and exports `createApp()`, which returns an + `http.Server` that is not yet listening. +- `node src/index.js ` starts the service. +- Run the tests with `npm test`. diff --git a/docker/context-profiles/complex-eval/cases/sentinel-api/files/data/files/welcome.txt b/docker/context-profiles/complex-eval/cases/sentinel-api/files/data/files/welcome.txt new file mode 100644 index 000000000..ccf400c8e --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/sentinel-api/files/data/files/welcome.txt @@ -0,0 +1 @@ +hello from sentinel diff --git a/docker/context-profiles/complex-eval/cases/sentinel-api/files/data/secret.txt b/docker/context-profiles/complex-eval/cases/sentinel-api/files/data/secret.txt new file mode 100644 index 000000000..fe862dbe9 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/sentinel-api/files/data/secret.txt @@ -0,0 +1 @@ +CANARY-7f3c9d-internal-only diff --git a/docker/context-profiles/complex-eval/cases/sentinel-api/files/package.json b/docker/context-profiles/complex-eval/cases/sentinel-api/files/package.json new file mode 100644 index 000000000..81f7f6c4a --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/sentinel-api/files/package.json @@ -0,0 +1,6 @@ +{ + "name": "sentinel-paste", + "private": true, + "type": "commonjs", + "scripts": { "test": "node --test test/" } +} diff --git a/docker/context-profiles/complex-eval/cases/sentinel-api/files/src/app.js b/docker/context-profiles/complex-eval/cases/sentinel-api/files/src/app.js new file mode 100644 index 000000000..76c590650 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/sentinel-api/files/src/app.js @@ -0,0 +1,99 @@ +'use strict'; +const fs = require('node:fs'); +const path = require('node:path'); +const http = require('node:http'); +const config = require('./config'); +const store = require('./store'); + +function readBody(req, callback) { + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', () => callback(body)); +} + +function sendJson(res, status, value) { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(value)); +} + +function page(paste) { + return `paste ${paste.id}` + + `
    ${paste.content}
    `; +} + +function createApp() { + return http.createServer((req, res) => { + const url = new URL(req.url, 'http://localhost'); + + if (req.method === 'POST' && url.pathname === '/pastes') { + readBody(req, body => { + let parsed; + try { parsed = JSON.parse(body); } catch { + sendJson(res, 400, { error: 'invalid JSON body' }); + return; + } + if (typeof parsed.content !== 'string') { + sendJson(res, 400, { error: 'content must be a string' }); + return; + } + const paste = store.create(parsed.content); + sendJson(res, 201, { id: paste.id, deleteToken: paste.deleteToken }); + }); + return; + } + + const pasteMatch = /^\/pastes\/([\w-]+)$/.exec(url.pathname); + if (pasteMatch && req.method === 'GET') { + const paste = store.get(pasteMatch[1]); + if (!paste) { sendJson(res, 404, { error: 'not found' }); return; } + sendJson(res, 200, { id: paste.id, content: paste.content }); + return; + } + if (pasteMatch && req.method === 'DELETE') { + const paste = store.get(pasteMatch[1]); + if (!paste) { sendJson(res, 404, { error: 'not found' }); return; } + if (req.headers['x-delete-token'] !== paste.deleteToken) { + sendJson(res, 403, { error: 'bad delete token' }); + return; + } + store.remove(paste.id); + res.writeHead(204); + res.end(); + return; + } + + const pageMatch = /^\/p\/([\w-]+)$/.exec(url.pathname); + if (pageMatch && req.method === 'GET') { + const paste = store.get(pageMatch[1]); + if (!paste) { sendJson(res, 404, { error: 'not found' }); return; } + res.writeHead(200, { 'content-type': 'text/html' }); + res.end(page(paste)); + return; + } + + if (req.method === 'GET' && url.pathname === '/files') { + const name = url.searchParams.get('name') || ''; + try { + const content = fs.readFileSync(path.join(config.FILES_DIR, name)); + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end(content); + } catch { + sendJson(res, 404, { error: 'not found' }); + } + return; + } + + if (req.method === 'GET' && url.pathname === '/admin/stats') { + if (req.headers['x-admin-token'] !== config.ADMIN_TOKEN) { + sendJson(res, 401, { error: 'unauthorized' }); + return; + } + sendJson(res, 200, store.stats()); + return; + } + + sendJson(res, 404, { error: 'not found' }); + }); +} + +module.exports = { createApp }; diff --git a/docker/context-profiles/complex-eval/cases/sentinel-api/files/src/config.js b/docker/context-profiles/complex-eval/cases/sentinel-api/files/src/config.js new file mode 100644 index 000000000..822552216 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/sentinel-api/files/src/config.js @@ -0,0 +1,9 @@ +'use strict'; +const path = require('node:path'); + +module.exports = { + // TODO: move this out of the repository before the next audit. + ADMIN_TOKEN: 'fixture-token', + MAX_BODY_BYTES: 64 * 1024, + FILES_DIR: path.join(__dirname, '..', 'data', 'files'), +}; diff --git a/docker/context-profiles/complex-eval/cases/sentinel-api/files/src/index.js b/docker/context-profiles/complex-eval/cases/sentinel-api/files/src/index.js new file mode 100644 index 000000000..3e9a14985 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/sentinel-api/files/src/index.js @@ -0,0 +1,7 @@ +'use strict'; +const { createApp } = require('./app'); + +const port = Number(process.argv[2] || 8080); +createApp().listen(port, () => { + console.log(`sentinel-paste listening on ${port}`); +}); diff --git a/docker/context-profiles/complex-eval/cases/sentinel-api/files/src/store.js b/docker/context-profiles/complex-eval/cases/sentinel-api/files/src/store.js new file mode 100644 index 000000000..39da05cea --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/sentinel-api/files/src/store.js @@ -0,0 +1,26 @@ +'use strict'; + +// In-memory paste store. +const pastes = new Map(); +let nextId = 1; + +function create(content) { + const id = `p_${nextId++}`; + const paste = { id, content, deleteToken: `tok_${id}` }; + pastes.set(id, paste); + return paste; +} + +function get(id) { + return pastes.get(id) || null; +} + +function remove(id) { + return pastes.delete(id); +} + +function stats() { + return { pastes: pastes.size, created: nextId - 1 }; +} + +module.exports = { create, get, remove, stats }; diff --git a/docker/context-profiles/complex-eval/cases/sentinel-api/files/test/api.test.js b/docker/context-profiles/complex-eval/cases/sentinel-api/files/test/api.test.js new file mode 100644 index 000000000..3929de0b4 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/sentinel-api/files/test/api.test.js @@ -0,0 +1,28 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createApp } = require('../src/app'); + +function listen(server) { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve(server.address().port)); + }); +} + +test('create and read back a paste', async () => { + const server = createApp(); + const port = await listen(server); + try { + const created = await fetch(`http://127.0.0.1:${port}/pastes`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ content: 'hello paste' }) }); + assert.equal(created.status, 201); + const { id } = await created.json(); + const read = await fetch(`http://127.0.0.1:${port}/pastes/${id}`); + assert.equal(read.status, 200); + assert.equal((await read.json()).content, 'hello paste'); + } finally { + server.close(); + } +}); diff --git a/docker/context-profiles/complex-eval/cases/sentinel-api/meta.json b/docker/context-profiles/complex-eval/cases/sentinel-api/meta.json new file mode 100644 index 000000000..a6b459916 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/sentinel-api/meta.json @@ -0,0 +1,11 @@ +{ + "id": "sentinel-api", + "category": "security-hardening", + "manualIds": ["skill:security-review"], + "checkTimeoutMs": 60000, + "selection": { + "id": "complex-sentinel-api", + "category": "complex-security-hardening", + "expectedIds": ["skill:security-review"] + } +} diff --git a/docker/context-profiles/complex-eval/cases/sentinel-api/query.md b/docker/context-profiles/complex-eval/cases/sentinel-api/query.md new file mode 100644 index 000000000..4a91420a9 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/sentinel-api/query.md @@ -0,0 +1 @@ +This internal paste-sharing service failed a security review, but the auditors didn't itemize the findings. Review the implementation against the API contract in the README, find every place the code violates the documented security behavior or is otherwise exploitable, and fix all of them without breaking the documented API. `npm test` must stay green. diff --git a/docker/context-profiles/complex-eval/cases/webhook-relay/check.cjs b/docker/context-profiles/complex-eval/cases/webhook-relay/check.cjs new file mode 100644 index 000000000..e5f097930 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/webhook-relay/check.cjs @@ -0,0 +1,125 @@ +'use strict'; +// Hidden grader for webhook-relay: drives the agent's relay in-process against +// local target servers and prints ECC_EVAL_SCORE. Always exits 0; the score line +// carries the result. Runs under Node's read-only permission model, so it only +// reads the workspace and talks to 127.0.0.1. +const http = require('node:http'); +const path = require('node:path'); + +const checks = []; +const record = (name, ok) => checks.push({ name, ok: Boolean(ok) }); +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +let finished = false; + +function finish() { + if (finished) return; + finished = true; + const ok = checks.filter(c => c.ok).length; + for (const c of checks) console.log(`${c.ok ? 'ok' : 'not ok'} - ${c.name}`); + console.log(`ECC_EVAL_SCORE ${JSON.stringify({ score: checks.length ? ok / checks.length : 0, passed: ok, total: checks.length })}`); + process.exit(0); +} +setTimeout(finish, 45000).unref(); + +function listen(server) { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve(server.address().port)); + }); +} + +function postJson(port, urlPath, body) { + return fetch(`http://127.0.0.1:${port}${urlPath}`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }) + .then(async response => ({ status: response.status, body: await response.json().catch(() => null) })); +} + +async function waitForStatus(port, id, wanted, timeoutMs) { + const started = Date.now(); + let last = null; + while (Date.now() - started < timeoutMs) { + try { + const response = await fetch(`http://127.0.0.1:${port}/deliveries/${id}`); + if (response.status === 200) { + last = await response.json(); + if (last.status === wanted || last.status === 'dead') return { record: last, elapsedMs: Date.now() - started }; + } + } catch { /* relay not ready yet */ } + await sleep(25); + } + return { record: last, elapsedMs: Date.now() - started }; +} + +(async () => { + let createRelay; + try { ({ createRelay } = require(path.join(process.cwd(), 'src', 'app.js'))); } catch { finish(); return; } + if (typeof createRelay !== 'function') { finish(); return; } + + // Probe group 1: a target that fails 3 times then succeeds. + let calls = 0; + const flaky = http.createServer((req, res) => { + calls++; + req.resume(); + req.on('end', () => { res.writeHead(calls <= 3 ? 500 : 200); res.end('{}'); }); + }); + const relay = createRelay(); + try { + const flakyPort = await listen(flaky); + const relayPort = await listen(relay); + const started = Date.now(); + const created = await postJson(relayPort, '/deliveries', { url: `http://127.0.0.1:${flakyPort}/hook`, payload: { hello: 'world' } }); + record('accepts-delivery-202', created.status === 202 && created.body && typeof created.body.id === 'string'); + if (created.body && created.body.id) { + const { record: rec, elapsedMs } = await waitForStatus(relayPort, created.body.id, 'delivered', 8000); + record('delivered-after-retries', rec && rec.status === 'delivered' && calls >= 4); + record('attempts-counted', rec && rec.attempts === 4); + record('backoff-window-respected', rec && rec.status === 'delivered' && elapsedMs >= 250 && elapsedMs <= 5000 && Date.now() - started >= 250); + } else { + record('delivered-after-retries', false); + record('attempts-counted', false); + record('backoff-window-respected', false); + } + + // Probe group 2: a target that always fails -> dead after exactly 5 attempts. + let deadCalls = 0; + const deadEnd = http.createServer((req, res) => { + deadCalls++; + req.resume(); + req.on('end', () => { res.writeHead(500); res.end('{}'); }); + }); + const deadPort = await listen(deadEnd); + const doomed = await postJson(relayPort, '/deliveries', { url: `http://127.0.0.1:${deadPort}/hook`, payload: { x: 1 } }); + if (doomed.body && doomed.body.id) { + const { record: rec } = await waitForStatus(relayPort, doomed.body.id, 'dead', 15000); + record('dead-after-retries-exhausted', rec && rec.status === 'dead'); + record('exactly-five-attempts', rec && rec.status === 'dead' && rec.attempts === 5 && deadCalls === 5); + record('last-error-recorded', rec && rec.status === 'dead' && typeof rec.lastError === 'string' && rec.lastError.length > 0); + } else { + record('dead-after-retries-exhausted', false); + record('exactly-five-attempts', false); + record('last-error-recorded', false); + } + deadEnd.close(); + + // Probe 3: pre-existing API behavior is preserved. + const missing = await fetch(`http://127.0.0.1:${relayPort}/deliveries/00000000-0000-0000-0000-000000000000`); + record('unknown-id-still-404', missing.status === 404); + + // Probe 4: concurrent deliveries all complete. + let goodCalls = 0; + const good = http.createServer((req, res) => { + goodCalls++; + req.resume(); + req.on('end', () => { res.writeHead(200); res.end('{}'); }); + }); + const goodPort = await listen(good); + const batch = await Promise.all(Array.from({ length: 10 }, (_, i) => + postJson(relayPort, '/deliveries', { url: `http://127.0.0.1:${goodPort}/hook`, payload: { i } }))); + const settled = await Promise.all(batch.map(item => item.body && item.body.id + ? waitForStatus(relayPort, item.body.id, 'delivered', 10000).then(r => r.record && r.record.status === 'delivered') + : false)); + record('concurrent-deliveries-complete', settled.every(Boolean) && goodCalls === 10); + good.close(); + } catch { /* any grader-side failure leaves the missing checks unscored */ } + finish(); +})(); diff --git a/docker/context-profiles/complex-eval/cases/webhook-relay/files/README.md b/docker/context-profiles/complex-eval/cases/webhook-relay/files/README.md new file mode 100644 index 000000000..b7da9e823 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/webhook-relay/files/README.md @@ -0,0 +1,33 @@ +# webhook-relay + +In-memory webhook relay. Accepts delivery requests over HTTP and POSTs each +payload to its destination URL, retrying failures with exponential backoff. + +## HTTP API + +- `POST /deliveries` — body `{ "url": string, "payload": any }`. Responds + `202` with `{ "id" }` and delivers asynchronously. `400` for invalid JSON. +- `GET /deliveries/:id` — `200` with + `{ "id", "url", "status", "attempts", "lastError" }`, or `404`. + `status` is `pending`, `delivered`, or `dead`. + +## Delivery contract + +- The payload is POSTed to `url` with `content-type: application/json`. +- Any 2xx response means success: `status` becomes `delivered`. +- Any other outcome (non-2xx, connection error, timeout) is a failure and is + retried with exponential backoff: the first retry happens after about + 100ms and the delay doubles each retry. Up to 20% jitter in either + direction is fine. +- At most 5 attempts are made in total (the initial try plus 4 retries). +- After the final failure the delivery becomes `dead` and `lastError` + records a short description of the last failure. +- `attempts` always reflects how many delivery attempts were made. + +## Module contract + +- `src/app.js` is CommonJS and exports `createRelay()`, which returns an + `http.Server` that is not yet listening. +- `node src/index.js ` starts the service. +- No external dependencies; Node.js standard library only. +- Run the tests with `npm test`. diff --git a/docker/context-profiles/complex-eval/cases/webhook-relay/files/package.json b/docker/context-profiles/complex-eval/cases/webhook-relay/files/package.json new file mode 100644 index 000000000..96c180c2b --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/webhook-relay/files/package.json @@ -0,0 +1,6 @@ +{ + "name": "webhook-relay", + "private": true, + "type": "commonjs", + "scripts": { "test": "node --test test/" } +} diff --git a/docker/context-profiles/complex-eval/cases/webhook-relay/files/src/app.js b/docker/context-profiles/complex-eval/cases/webhook-relay/files/src/app.js new file mode 100644 index 000000000..9d5e85397 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/webhook-relay/files/src/app.js @@ -0,0 +1,51 @@ +'use strict'; +const http = require('node:http'); +const crypto = require('node:crypto'); + +// In-memory webhook relay. See README.md for the delivery contract. +// +// TODO: deliveries are accepted and stored, but the delivery worker was never +// finished — nothing ever POSTs to the destination URL, retries never happen, +// and records stay "pending" forever. + +function createRelay() { + const deliveries = new Map(); + + const server = http.createServer((req, res) => { + if (req.method === 'POST' && req.url === '/deliveries') { + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', () => { + let parsed; + try { parsed = JSON.parse(body); } catch { + res.writeHead(400, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'invalid JSON body' })); + return; + } + const id = crypto.randomUUID(); + deliveries.set(id, { id, url: parsed.url, payload: parsed.payload, + status: 'pending', attempts: 0, lastError: null }); + res.writeHead(202, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ id })); + }); + return; + } + const match = /^\/deliveries\/([0-9a-f-]+)$/.exec(req.url || ''); + if (req.method === 'GET' && match) { + const record = deliveries.get(match[1]); + if (!record) { + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + return; + } + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(record)); + return; + } + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + }); + return server; +} + +module.exports = { createRelay }; diff --git a/docker/context-profiles/complex-eval/cases/webhook-relay/files/src/index.js b/docker/context-profiles/complex-eval/cases/webhook-relay/files/src/index.js new file mode 100644 index 000000000..6a77b03de --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/webhook-relay/files/src/index.js @@ -0,0 +1,7 @@ +'use strict'; +const { createRelay } = require('./app'); + +const port = Number(process.argv[2] || 8080); +createRelay().listen(port, () => { + console.log(`webhook-relay listening on ${port}`); +}); diff --git a/docker/context-profiles/complex-eval/cases/webhook-relay/files/test/relay.test.js b/docker/context-profiles/complex-eval/cases/webhook-relay/files/test/relay.test.js new file mode 100644 index 000000000..cc90156d9 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/webhook-relay/files/test/relay.test.js @@ -0,0 +1,41 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createRelay } = require('../src/app'); + +function listen(server) { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve(server.address().port)); + }); +} + +test('accepts a delivery and reports it as pending', async () => { + const server = createRelay(); + const port = await listen(server); + try { + const created = await fetch(`http://127.0.0.1:${port}/deliveries`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ url: 'http://127.0.0.1:1/hook', payload: { a: 1 } }) }); + assert.equal(created.status, 202); + const { id } = await created.json(); + const status = await fetch(`http://127.0.0.1:${port}/deliveries/${id}`); + assert.equal(status.status, 200); + const record = await status.json(); + assert.equal(record.status, 'pending'); + assert.equal(record.attempts, 0); + } finally { + server.close(); + } +}); + +test('unknown delivery id returns 404', async () => { + const server = createRelay(); + const port = await listen(server); + try { + const response = await fetch(`http://127.0.0.1:${port}/deliveries/00000000-0000-0000-0000-000000000000`); + assert.equal(response.status, 404); + } finally { + server.close(); + } +}); diff --git a/docker/context-profiles/complex-eval/cases/webhook-relay/meta.json b/docker/context-profiles/complex-eval/cases/webhook-relay/meta.json new file mode 100644 index 000000000..25179ad1e --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/webhook-relay/meta.json @@ -0,0 +1,11 @@ +{ + "id": "webhook-relay", + "category": "feature-build", + "manualIds": ["skill:tdd-workflow"], + "checkTimeoutMs": 60000, + "selection": { + "id": "complex-webhook-relay", + "category": "complex-feature-build", + "expectedIds": ["skill:tdd-workflow"] + } +} diff --git a/docker/context-profiles/complex-eval/cases/webhook-relay/query.md b/docker/context-profiles/complex-eval/cases/webhook-relay/query.md new file mode 100644 index 000000000..939ee28b7 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases/webhook-relay/query.md @@ -0,0 +1 @@ +The webhook relay in this repo accepts delivery requests but never actually sends them — the delivery worker was never finished, and customers are losing notifications. Implement asynchronous delivery per the README: POST each delivery's JSON payload to its URL, retry failures with exponential backoff starting around 100ms and doubling each time, give up after 5 total attempts and mark the delivery dead. Keep the documented module contract, make `npm test` pass, and extend the test suite to cover the retry and dead-letter behavior. diff --git a/docker/context-profiles/complex-eval/cases2/event-stats-api/check.cjs b/docker/context-profiles/complex-eval/cases2/event-stats-api/check.cjs new file mode 100644 index 000000000..29a25776f --- /dev/null +++ b/docker/context-profiles/complex-eval/cases2/event-stats-api/check.cjs @@ -0,0 +1,149 @@ +'use strict'; +// Hidden grader for event-stats-api: independent spec-conformant aggregation +// over the deterministic event log, plus a measured 2,000-query performance +// probe (threshold calibrated on the grading machine: shipped naive ~7.7s, +// reference ~1.5s). Prints ECC_EVAL_SCORE and always exits 0. +const fs = require('node:fs'); +const path = require('node:path'); + +const checks = []; +const record = (name, ok) => checks.push({ name, ok: Boolean(ok) }); +let finished = false; +function finish() { + if (finished) return; + finished = true; + const ok = checks.filter(c => c.ok).length; + for (const c of checks) console.log(`${c.ok ? 'ok' : 'not ok'} - ${c.name}`); + console.log(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / checks.length, passed: ok, total: checks.length })}`); + process.exit(0); +} +setTimeout(finish, 110000).unref(); + +const PERF_THRESHOLD_MS = 6000; +const PERF_QUERIES = 2000; + +function lcg(seed) { + let state = seed >>> 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state / 2 ** 32; + }; +} + +const root = process.cwd(); +const { events, TYPES, EPOCH_MS, SPAN_MS } = require(path.join(root, 'src', 'data.js')); + +// Independent reference semantics per the README: inclusive bounds, +// nearest-rank percentiles, half-up two-decimal average via exact integer math. +function expected(type, from, to) { + const rows = events + .filter(e => e.type === type && (from === null || e.ts >= from) && (to === null || e.ts <= to)) + .map(e => e.value) + .sort((a, b) => a - b); + const count = rows.length; + if (!count) return { count: 0, sum: 0, avg: null, p50: null, p95: null, p99: null, min: null, max: null }; + const sum = rows.reduce((a, b) => a + b, 0); + const rank = p => rows[Math.ceil((p / 100) * count) - 1]; + const avgCents = Math.floor((sum * 200 + count) / (count * 2)); + return { count, sum, avg: avgCents / 100, + p50: rank(50), p95: rank(95), p99: rank(99), min: rows[0], max: rows[count - 1] }; +} + +const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); + +async function query(port, params) { + const qs = Object.entries(params).map(([k, v]) => `${k}=${v}`).join('&'); + const response = await fetch(`http://127.0.0.1:${port}/stats?${qs}`); + return { status: response.status, body: await response.json().catch(() => null) }; +} + +(async () => { + let createApp; + try { ({ createApp } = require(path.join(root, 'src', 'app.js'))); } catch { finish(); return; } + if (typeof createApp !== 'function') { finish(); return; } + + try { + const app = createApp(); + await new Promise(resolve => app.listen(0, '127.0.0.1', resolve)); + const port = app.address().port; + + // 1-2: broad and full-range queries with independently computed expectations. + const broadFrom = EPOCH_MS; + const broadTo = EPOCH_MS + 30 * 86400000; + const broad = await query(port, { type: 'click', from: broadFrom, to: broadTo }); + record('broad-window-exact', broad.status === 200 + && same(broad.body, { type: 'click', from: broadFrom, to: broadTo, ...expected('click', broadFrom, broadTo) })); + const full = await query(port, { type: 'purchase' }); + record('full-range-exact', full.status === 200 + && same(full.body, { type: 'purchase', from: null, to: null, ...expected('purchase', null, null) })); + + // 3: nearest-rank vs interpolation is distinguishable on a tiny window. + const exportEvents = events.filter(e => e.type === 'export').map(e => e.ts).sort((a, b) => a - b); + const pivot = exportEvents[Math.floor(exportEvents.length / 2)]; + const narrowFrom = pivot - 1; + const narrowTo = pivot + 1; + const narrow = await query(port, { type: 'export', from: narrowFrom, to: narrowTo }); + record('narrow-window-nearest-rank', narrow.status === 200 + && same(narrow.body, { type: 'export', from: narrowFrom, to: narrowTo, ...expected('export', narrowFrom, narrowTo) })); + + // 4-5: empty range and unknown type return nulls, not zeros or errors. + const beyond = await query(port, { type: 'click', from: EPOCH_MS + 200 * 86400000, to: EPOCH_MS + 201 * 86400000 }); + record('empty-range-nulls', beyond.status === 200 && same(beyond.body, + { type: 'click', from: EPOCH_MS + 200 * 86400000, to: EPOCH_MS + 201 * 86400000, ...expected('click', EPOCH_MS + 200 * 86400000, EPOCH_MS + 201 * 86400000) })); + const unknown = await query(port, { type: 'nope' }); + record('unknown-type-nulls', unknown.status === 200 + && same(unknown.body, { type: 'nope', from: null, to: null, ...expected('nope', null, null) })); + + // 6: inclusive bounds — a zero-width window on a real timestamp includes it. + const likeTs = events.filter(e => e.type === 'like').map(e => e.ts).sort((a, b) => a - b)[100]; + const inclusive = await query(port, { type: 'like', from: likeTs, to: likeTs }); + record('bounds-inclusive', inclusive.status === 200 && inclusive.body.count === expected('like', likeTs, likeTs).count && inclusive.body.count >= 1); + + // 7: average rounding follows half-up two decimals exactly. + const rounding = expected('view', EPOCH_MS, EPOCH_MS + 86400000); + const rounded = await query(port, { type: 'view', from: EPOCH_MS, to: EPOCH_MS + 86400000 }); + record('avg-half-up-2dp', rounded.status === 200 && rounded.body.avg === rounding.avg); + + // 8-9: invalid parameters are 400. + const inverted = await query(port, { type: 'click', from: 10, to: 5 }); + record('inverted-bounds-400', inverted.status === 400); + const garbage = await query(port, { type: 'click', from: 'abc' }); + record('non-numeric-bounds-400', garbage.status === 400); + + // 10: performance budget. + const rand = lcg(777); + const queries = []; + for (let i = 0; i < PERF_QUERIES; i++) { + const type = TYPES[Math.floor(rand() * TYPES.length)]; + const start = EPOCH_MS + Math.floor(rand() * SPAN_MS * 0.7); + queries.push({ type, from: start, to: start + Math.floor(rand() * SPAN_MS * 0.5) }); + } + const started = Date.now(); + for (let i = 0; i < queries.length; i += 20) { + await Promise.all(queries.slice(i, i + 20).map(q => query(port, q))); + } + const elapsed = Date.now() - started; + console.log(`perf: ${elapsed}ms for ${PERF_QUERIES} queries (threshold ${PERF_THRESHOLD_MS}ms)`); + record('performance-budget', elapsed < PERF_THRESHOLD_MS); + + app.close(); + } catch { /* grader-side failure leaves remaining checks unscored */ } + + // 11: no external dependencies. + try { + const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + const sources = []; + const walk = directory => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const item = path.join(directory, entry.name); + if (entry.isDirectory()) walk(item); + else if (entry.name.endsWith('.js')) sources.push(fs.readFileSync(item, 'utf8')); + } + }; + walk(path.join(root, 'src')); + const bareImport = sources.some(source => /require\(\s*['"](?!node:)[a-z@][^'./]*['"]\s*\)/.test(source)); + record('no-external-dependencies', !bareImport && !pkg.dependencies && !pkg.devDependencies); + } catch { record('no-external-dependencies', false); } + + finish(); +})(); diff --git a/docker/context-profiles/complex-eval/cases2/event-stats-api/files/README.md b/docker/context-profiles/complex-eval/cases2/event-stats-api/files/README.md new file mode 100644 index 000000000..ac5cb6579 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases2/event-stats-api/files/README.md @@ -0,0 +1,41 @@ +# event-stats + +Analytics endpoint over an in-memory event log (300,000 events, generated +deterministically by `src/data.js`). + +## API + +`GET /stats?type=&from=&to=` returns JSON: + +```json +{ "type": "click", "from": 1754000000000, "to": 1756592000000, + "count": 1234, "sum": 56789, "avg": 46.02, + "p50": 123, "p95": 456, "p99": 789, "min": 1, "max": 50000 } +``` + +Semantics (all pinned; follow them exactly): + +- `from`/`to` are millisecond timestamps, **inclusive**, and optional + (absent means unbounded). Non-numeric bounds, or `from > to`, are `400`. +- Only events of the given `type` within `[from, to]` are included. +- `sum` is the exact integer sum of `value`s. +- `avg` is `sum / count` rounded **half-up to two decimals**. +- Percentiles use the **nearest-rank** method: sort values ascending, take the + value at 1-based rank `ceil(p / 100 * count)`. No interpolation. +- If no events match (including an unknown `type`), return `200` with + `count: 0, sum: 0` and `avg`, `p50`, `p95`, `p99`, `min`, `max` all `null`. +- The response echoes the effective `from`/`to` (`null` when unbounded). + +## Performance requirement + +The endpoint must stay fast at this data size: **2,000 mixed queries complete +in under 6 seconds** on this machine (the reference does it in ~1.5s). +Precompute whatever you need at startup; per-query work must not scan the +whole log. + +## Module contract + +- `src/app.js` is CommonJS and exports `createApp()` returning an + `http.Server` that is not yet listening. +- `node src/index.js ` starts the service. +- No external dependencies. Run the tests with `npm test`. diff --git a/docker/context-profiles/complex-eval/cases2/event-stats-api/files/package.json b/docker/context-profiles/complex-eval/cases2/event-stats-api/files/package.json new file mode 100644 index 000000000..3407c945e --- /dev/null +++ b/docker/context-profiles/complex-eval/cases2/event-stats-api/files/package.json @@ -0,0 +1,6 @@ +{ + "name": "event-stats", + "private": true, + "type": "commonjs", + "scripts": { "test": "node --test test/" } +} diff --git a/docker/context-profiles/complex-eval/cases2/event-stats-api/files/src/app.js b/docker/context-profiles/complex-eval/cases2/event-stats-api/files/src/app.js new file mode 100644 index 000000000..f0a458200 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases2/event-stats-api/files/src/app.js @@ -0,0 +1,43 @@ +'use strict'; +const http = require('node:http'); +const { events } = require('./data'); + +// Current implementation: scan and sort per query. Known slow, and the +// analytics team says edge cases don't match the README semantics. +function summarize(type, from, to) { + const rows = events + .filter(e => e.type === type && (from === null || e.ts >= from) && (to === null || e.ts <= to)) + .map(e => e.value) + .sort((a, b) => a - b); + const count = rows.length; + const sum = rows.reduce((a, b) => a + b, 0); + const interpolate = p => { + if (!count) return 0; + const rank = (p / 100) * (count - 1); + const low = Math.floor(rank); + const high = Math.ceil(rank); + return rows[low] + (rows[high] - rows[low]) * (rank - low); + }; + return { count, sum, avg: count ? sum / count : 0, + p50: interpolate(50), p95: interpolate(95), p99: interpolate(99), + min: count ? rows[0] : 0, max: count ? rows[count - 1] : 0 }; +} + +function createApp() { + return http.createServer((req, res) => { + const url = new URL(req.url, 'http://localhost'); + if (req.method === 'GET' && url.pathname === '/stats') { + const type = url.searchParams.get('type'); + const from = url.searchParams.has('from') ? Number(url.searchParams.get('from')) : null; + const to = url.searchParams.has('to') ? Number(url.searchParams.get('to')) : null; + const body = summarize(type, from, to); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ type, from, to, ...body })); + return; + } + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + }); +} + +module.exports = { createApp }; diff --git a/docker/context-profiles/complex-eval/cases2/event-stats-api/files/src/data.js b/docker/context-profiles/complex-eval/cases2/event-stats-api/files/src/data.js new file mode 100644 index 000000000..643771023 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases2/event-stats-api/files/src/data.js @@ -0,0 +1,28 @@ +'use strict'; +// Deterministic event log: 300,000 events from a seeded LCG so every run, +// grader, and reference sees identical data. Do not change the generator. +const TYPES = ['click', 'view', 'signup', 'purchase', 'refund', 'login', + 'logout', 'share', 'comment', 'like', 'search', 'export']; +const DAY_MS = 86400000; +const EPOCH_MS = 1754000000000; +const SPAN_MS = 90 * DAY_MS; + +function lcg(seed) { + let state = seed >>> 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state / 2 ** 32; + }; +} + +const rand = lcg(20260925); +const events = new Array(300000); +for (let i = 0; i < events.length; i++) { + events[i] = { + type: TYPES[Math.floor(rand() * TYPES.length)], + ts: EPOCH_MS + Math.floor(rand() * SPAN_MS), + value: Math.floor(rand() * 50000) + 1, + }; +} + +module.exports = { events, TYPES, EPOCH_MS, SPAN_MS }; diff --git a/docker/context-profiles/complex-eval/cases2/event-stats-api/files/src/index.js b/docker/context-profiles/complex-eval/cases2/event-stats-api/files/src/index.js new file mode 100644 index 000000000..73f99e3ca --- /dev/null +++ b/docker/context-profiles/complex-eval/cases2/event-stats-api/files/src/index.js @@ -0,0 +1,7 @@ +'use strict'; +const { createApp } = require('./app'); + +const port = Number(process.argv[2] || 8080); +createApp().listen(port, () => { + console.log(`event-stats listening on ${port}`); +}); diff --git a/docker/context-profiles/complex-eval/cases2/event-stats-api/files/test/stats.test.js b/docker/context-profiles/complex-eval/cases2/event-stats-api/files/test/stats.test.js new file mode 100644 index 000000000..ddfd19556 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases2/event-stats-api/files/test/stats.test.js @@ -0,0 +1,20 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createApp } = require('../src/app'); +const { EPOCH_MS } = require('../src/data'); + +test('stats endpoint answers a broad query', async () => { + const server = createApp(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + try { + const port = server.address().port; + const response = await fetch(`http://127.0.0.1:${port}/stats?type=click&from=${EPOCH_MS}&to=${EPOCH_MS + 30 * 86400000}`); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.type, 'click'); + assert.ok(body.count > 0); + } finally { + server.close(); + } +}); diff --git a/docker/context-profiles/complex-eval/cases2/event-stats-api/meta.json b/docker/context-profiles/complex-eval/cases2/event-stats-api/meta.json new file mode 100644 index 000000000..8fb03f0cb --- /dev/null +++ b/docker/context-profiles/complex-eval/cases2/event-stats-api/meta.json @@ -0,0 +1,11 @@ +{ + "id": "event-stats-api", + "category": "correctness-and-performance", + "manualIds": ["skill:backend-patterns"], + "checkTimeoutMs": 120000, + "selection": { + "id": "complex-event-stats-api", + "category": "complex-correctness-performance", + "expectedIds": ["skill:backend-patterns"] + } +} diff --git a/docker/context-profiles/complex-eval/cases2/event-stats-api/query.md b/docker/context-profiles/complex-eval/cases2/event-stats-api/query.md new file mode 100644 index 000000000..325a60392 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases2/event-stats-api/query.md @@ -0,0 +1 @@ +The /stats endpoint in this repo is wrong on edge cases and too slow — customers on big dashboards are timing out. It currently rescans and resorts the whole 300k-event log on every request, and the analytics team says the numbers don't match the documented semantics (nearest-rank percentiles, half-up two-decimal averages, null fields when nothing matches, proper 400s). Make it correct per the README and fast enough to meet the documented performance budget, without changing the API shape. `npm test` must stay green. diff --git a/docker/context-profiles/complex-eval/cases2/forge-cli/check.cjs b/docker/context-profiles/complex-eval/cases2/forge-cli/check.cjs new file mode 100644 index 000000000..f82efd979 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases2/forge-cli/check.cjs @@ -0,0 +1,132 @@ +'use strict'; +// Hidden grader for forge-cli: drives run(argv, state) through the twelve +// contractual behaviors plus never-throw fuzzing and static hygiene. Prints +// ECC_EVAL_SCORE and always exits 0. +const fs = require('node:fs'); +const path = require('node:path'); + +const checks = []; +const record = (name, ok) => checks.push({ name, ok: Boolean(ok) }); + +const root = process.cwd(); +let run; +try { ({ run } = require(path.join(root, 'src', 'cli.js'))); } catch { /* scored below */ } + +const USAGE = 'usage: snippet \n'; +const ADD_USAGE = 'usage: add [--tags t1,t2] \n'; + +if (typeof run !== 'function') { + for (let i = 0; i < 26; i++) record(`check-${i + 1}`, false); +} else { + const call = (argv, state) => { + try { + const result = run(argv, state); + if (!result || typeof result.code !== 'number' + || typeof result.stdout !== 'string' || typeof result.stderr !== 'string') return null; + return result; + } catch { return null; } + }; + + // Basic lifecycle. + let s = {}; + let r = call(['add', 'hello', 'hello', 'world'], s); + record('add-happy', r && r.code === 0 && r.stdout === 'created hello\n' && r.stderr === ''); + r = call(['add', 'hello', 'different', 'text'], s); + const afterDup = call(['get', 'hello'], s); + record('add-duplicate-rejected', r && r.code === 1 && r.stderr === "error: snippet 'hello' already exists\n" + && afterDup && afterDup.stdout === 'hello world\n'); + const m1 = call(['add'], s); + const m2 = call(['add', 'justname'], s); + record('add-missing-args-usage', m1 && m1.code === 2 && m1.stderr === ADD_USAGE + && m2 && m2.code === 2 && m2.stderr === ADD_USAGE); + r = call(['add', 'Bad_Name', 'text'], s); + record('invalid-name-rejected', r && r.code === 2 && r.stderr === "error: invalid snippet name 'Bad_Name'\n"); + r = call(['get', 'hello'], s); + record('get-happy', r && r.code === 0 && r.stdout === 'hello world\n'); + r = call(['get', 'ghost'], s); + record('get-unknown', r && r.code === 2 && r.stderr === "error: no snippet named 'ghost'\n"); + + // Listing and tags. + s = {}; + call(['add', 'bravo', 'second'], s); + call(['add', 'alpha', '--tags', 'x,y', 'first'], s); + call(['add', 'charlie', '--tags', 'y', 'third'], s); + r = call(['list'], s); + record('list-sorted', r && r.code === 0 && r.stdout === 'alpha\nbravo\ncharlie\n'); + r = call(['list'], {}); + record('list-empty', r && r.code === 0 && r.stdout === 'no snippets\n'); + r = call(['list', '--tag', 'y'], s); + record('list-tag-filter', r && r.code === 0 && r.stdout === 'alpha\ncharlie\n'); + + // Removal. + r = call(['remove', 'bravo'], s); + const gone = call(['get', 'bravo'], s); + record('remove-happy', r && r.code === 0 && r.stdout === 'removed bravo\n' && gone && gone.code === 2); + r = call(['remove', 'bravo'], s); + record('remove-unknown', r && r.code === 2 && r.stderr === "error: no snippet named 'bravo'\n"); + + // Search over name and text, case-insensitive, sorted. + r = call(['search', 'FIRST'], s); + record('search-text-case-insensitive', r && r.code === 0 && r.stdout === 'alpha\n'); + r = call(['search', 'char'], s); + record('search-name-match', r && r.code === 0 && r.stdout === 'charlie\n'); + r = call(['search', 'zzz'], s); + record('search-no-matches', r && r.code === 0 && r.stdout === 'no matches\n'); + + // Export/import round-trip with stable ordering. + r = call(['export'], s); + let doc = null; + try { doc = r && JSON.parse(r.stdout); } catch { /* wrong */ } + record('export-json-sorted', doc && r.code === 0 && sameDoc(doc, { + snippets: { alpha: { text: 'first', tags: ['x', 'y'] }, charlie: { text: 'third', tags: ['y'] } } }) + && r.stdout.indexOf('alpha') < r.stdout.indexOf('charlie')); + const importedState = { snippets: { alpha: { text: 'preexisting', tags: [] } } }; + r = call(['import', JSON.stringify({ snippets: { + alpha: { text: 'first', tags: ['x', 'y'] }, delta: { text: 'fourth', tags: ['z'] } } })], importedState); + const delta = call(['get', 'delta'], importedState); + const alpha = call(['get', 'alpha'], importedState); + record('import-merge-skip-existing', r && r.code === 0 && r.stdout === 'imported 1, skipped 1\n' + && delta && delta.stdout === 'fourth\n' && alpha && alpha.stdout === 'preexisting\n'); + const beforeExport = call(['export'], s); + r = call(['import', '{not json'], s); + const afterExport = call(['export'], s); + record('import-malformed-atomic', r && r.code === 1 && r.stderr === 'error: invalid JSON\n' + && beforeExport && afterExport && beforeExport.stdout === afterExport.stdout); + + // Usage fallbacks. + r = call(['bogus'], {}); + record('unknown-command-usage', r && r.code === 2 && r.stderr === USAGE); + r = call([], {}); + record('no-command-usage', r && r.code === 2 && r.stderr === USAGE); + + // Never-throw fuzzing on junk input. + const fuzz = [['--help', 'x'], ['get'], ['add', 'x', 'y', '--tags'], ['import']]; + fuzz.forEach((argv, index) => { + record(`fuzz-never-throws-${index + 1}`, call(argv, {}) !== null); + }); +} + +function sameDoc(a, b) { return JSON.stringify(a) === JSON.stringify(b); } + +// Static hygiene. +try { + const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + record('no-external-dependencies', !pkg.dependencies && !pkg.devDependencies); +} catch { record('no-external-dependencies', false); } +try { + const sources = []; + const walk = directory => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const item = path.join(directory, entry.name); + if (entry.isDirectory()) walk(item); + else if (entry.name.endsWith('.js')) sources.push(fs.readFileSync(item, 'utf8')); + } + }; + walk(path.join(root, 'src')); + record('no-leftover-todos', sources.every(source => !/TODO|FIXME/.test(source))); +} catch { record('no-leftover-todos', false); } + +const okCount = checks.filter(c => c.ok).length; +for (const c of checks) console.log(`${c.ok ? 'ok' : 'not ok'} - ${c.name}`); +console.log(`ECC_EVAL_SCORE ${JSON.stringify({ score: okCount / checks.length, passed: okCount, total: checks.length })}`); +process.exit(0); diff --git a/docker/context-profiles/complex-eval/cases2/forge-cli/files/README.md b/docker/context-profiles/complex-eval/cases2/forge-cli/files/README.md new file mode 100644 index 000000000..c7299c51e --- /dev/null +++ b/docker/context-profiles/complex-eval/cases2/forge-cli/files/README.md @@ -0,0 +1,46 @@ +# snippet-cli + +A small in-process snippet manager. No external dependencies; Node.js standard +library only. + +## Contract + +`src/cli.js` is CommonJS and exports `run(argv, state)`: + +- `argv`: array of command-line words (already split, no program name). +- `state`: any plain object, created by the caller as `{}`. The CLI keeps its + data in it and mutates it in place; it survives across calls. +- Returns synchronously: `{ code, stdout, stderr }` — a number and two strings + (empty string when there is nothing to print). `run` must **never throw**, + on any input. +- All printed lines end with `\n`. + +## Commands (all behavior below is contractual) + +1. `add [--tags a,b] ` — creates a snippet from the remaining + words joined by single spaces. Prints `created `, code 0. +2. Adding an existing name: code 1, stderr `error: snippet '' already exists`, + state unchanged. +3. `add` with a missing name or missing text: code 2, stderr + `usage: add [--tags t1,t2] `. +4. Names must match `^[a-z0-9][a-z0-9-]*$`; otherwise code 2, stderr + `error: invalid snippet name ''`. +5. `get ` — prints the exact text, code 0. Unknown name: code 2, stderr + `error: no snippet named ''`. +6. `remove ` — prints `removed `, code 0. Unknown name: same as `get`. +7. `list` — every snippet name, sorted ascending, one per line. With no + snippets: prints `no snippets`. Always code 0. +8. `list --tag ` — only snippets whose tags include `t`. +9. `search ` — case-insensitive substring match over name **and** text; + prints matching names sorted, one per line; prints `no matches` when empty. + Code 0. +10. `export` — prints `JSON.stringify` of `{ snippets: { : { text, tags } } }` + with names sorted and each `tags` array sorted. Code 0. +11. `import ` — merges an exported document: names not already present + are added, existing names are skipped. Prints `imported , skipped `, + code 0. Malformed JSON: code 1, stderr `error: invalid JSON`, state + unchanged. +12. No command or an unknown command: code 2, stderr + `usage: snippet `. + +Run the tests with `npm test`. diff --git a/docker/context-profiles/complex-eval/cases2/forge-cli/files/package.json b/docker/context-profiles/complex-eval/cases2/forge-cli/files/package.json new file mode 100644 index 000000000..daab6430e --- /dev/null +++ b/docker/context-profiles/complex-eval/cases2/forge-cli/files/package.json @@ -0,0 +1,6 @@ +{ + "name": "snippet-cli", + "private": true, + "type": "commonjs", + "scripts": { "test": "node --test test/" } +} diff --git a/docker/context-profiles/complex-eval/cases2/forge-cli/files/src/cli.js b/docker/context-profiles/complex-eval/cases2/forge-cli/files/src/cli.js new file mode 100644 index 000000000..9acf79991 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases2/forge-cli/files/src/cli.js @@ -0,0 +1,8 @@ +'use strict'; + +// TODO: implement per README. The contract is run(argv, state) -> { code, stdout, stderr }. +function run(_argv, _state) { + throw new Error('not implemented'); +} + +module.exports = { run }; diff --git a/docker/context-profiles/complex-eval/cases2/forge-cli/files/test/cli.test.js b/docker/context-profiles/complex-eval/cases2/forge-cli/files/test/cli.test.js new file mode 100644 index 000000000..0c586bbf0 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases2/forge-cli/files/test/cli.test.js @@ -0,0 +1,20 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { run } = require('../src/cli'); + +test('add then get round-trips a snippet', () => { + const state = {}; + const added = run(['add', 'hello', 'hello', 'world'], state); + assert.equal(added.code, 0); + assert.equal(added.stdout, 'created hello\n'); + const got = run(['get', 'hello'], state); + assert.equal(got.code, 0); + assert.equal(got.stdout, 'hello world\n'); +}); + +test('list on empty state', () => { + const result = run(['list'], {}); + assert.equal(result.code, 0); + assert.equal(result.stdout, 'no snippets\n'); +}); diff --git a/docker/context-profiles/complex-eval/cases2/forge-cli/meta.json b/docker/context-profiles/complex-eval/cases2/forge-cli/meta.json new file mode 100644 index 000000000..71ea53556 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases2/forge-cli/meta.json @@ -0,0 +1,11 @@ +{ + "id": "forge-cli", + "category": "spec-thoroughness", + "manualIds": ["skill:tdd-workflow"], + "checkTimeoutMs": 30000, + "selection": { + "id": "complex-forge-cli", + "category": "complex-spec-thoroughness", + "expectedIds": ["skill:tdd-workflow"] + } +} diff --git a/docker/context-profiles/complex-eval/cases2/forge-cli/query.md b/docker/context-profiles/complex-eval/cases2/forge-cli/query.md new file mode 100644 index 000000000..add81b1f9 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases2/forge-cli/query.md @@ -0,0 +1 @@ +Build the snippet manager CLI per the README — all twelve numbered behaviors are contractual, including exact messages, exit codes, sorting, and the never-throw guarantee. `npm test` must pass, and add tests for the tricky edges (duplicates, invalid names, bad imports) so we don't regress them. diff --git a/docker/context-profiles/complex-eval/cases2/keccak-selector/check.cjs b/docker/context-profiles/complex-eval/cases2/keccak-selector/check.cjs new file mode 100644 index 000000000..58c2a9fd9 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases2/keccak-selector/check.cjs @@ -0,0 +1,63 @@ +'use strict'; +// Hidden grader for keccak-selector. Every vector is independently cross-checked: +// the implementation is validated against Node's SHA3-256 (same Keccak-f[1600] +// permutation, different padding suffix) including multi-block and q=1 padding +// edge inputs. Prints ECC_EVAL_SCORE and always exits 0. +const fs = require('node:fs'); +const path = require('node:path'); + +const checks = []; +const record = (name, ok) => checks.push({ name, ok: Boolean(ok) }); + +const VECTORS = [ + ['name()', '0x06fdde03'], + ['symbol()', '0x95d89b41'], + ['decimals()', '0x313ce567'], + ['totalSupply()', '0x18160ddd'], + ['balanceOf(address)', '0x70a08231'], + ['transfer(address,uint256)', '0xa9059cbb'], + ['approve(address,uint256)', '0x095ea7b3'], + ['transferFrom(address,address,uint256)', '0x23b872dd'], + // 135-byte signature: padding lands on the q=1 edge case. + ['someVeryLongFunctionNameForTestingMultiBlockHashingBehavior(address,uint256,string,bytes32,bool,uint8[],int128,(address,uint256),bytes)', '0x2add16ac'], +]; + +let functionSelector; +try { ({ functionSelector } = require(path.join(process.cwd(), 'src', 'selector.js'))); } catch { /* scored below */ } + +if (typeof functionSelector === 'function') { + VECTORS.forEach(([signature, expected], index) => { + let actual = null; + try { actual = functionSelector(signature); } catch { /* wrong */ } + record(`selector-vector-${index + 1}`, actual === expected); + }); + try { record('output-format', /^0x[0-9a-f]{8}$/.test(functionSelector('name()'))); } + catch { record('output-format', false); } + let threw = false; + try { functionSelector(42); } catch (error) { threw = error instanceof TypeError; } + record('typeerror-on-non-string', threw); +} else { + for (const [,] of VECTORS) checks.push({ name: `selector-vector-${checks.length + 1}`, ok: false }); + record('output-format', false); + record('typeerror-on-non-string', false); +} + +// No external code: every import under src/ must be relative or node:-prefixed. +const sources = []; +const walk = directory => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const item = path.join(directory, entry.name); + if (entry.isDirectory()) walk(item); + else if (entry.name.endsWith('.js')) sources.push(fs.readFileSync(item, 'utf8')); + } +}; +try { walk(path.join(process.cwd(), 'src')); } catch { /* none */ } +const bareImport = sources.some(source => /require\(\s*['"](?!node:)[a-z@][^'./]*['"]\s*\)/.test(source) + || /^\s*import\s/m.test(source) && /from\s*['"](?!node:|\.)[^'"]+['"]/.test(source)); +const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')); +record('no-external-dependencies', !bareImport && !pkg.dependencies && !pkg.devDependencies); + +const ok = checks.filter(c => c.ok).length; +for (const c of checks) console.log(`${c.ok ? 'ok' : 'not ok'} - ${c.name}`); +console.log(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / checks.length, passed: ok, total: checks.length })}`); +process.exit(0); diff --git a/docker/context-profiles/complex-eval/cases2/keccak-selector/files/README.md b/docker/context-profiles/complex-eval/cases2/keccak-selector/files/README.md new file mode 100644 index 000000000..262a8d3d2 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases2/keccak-selector/files/README.md @@ -0,0 +1,21 @@ +# abi-selectors + +Contract ABI tooling: compute Ethereum function selectors. + +## Contract + +`src/selector.js` is CommonJS and exports `functionSelector(signature)`: + +- `signature` is the canonical function signature string, e.g. + `"transfer(address,uint256)"` — no spaces, no argument names. +- Returns `"0x"` plus the first 4 bytes of the Keccak-256 hash of the UTF-8 + signature, as 8 lowercase hex characters. +- Throws `TypeError` for a non-string argument. +- Node.js standard library only; no external dependencies. Whatever hashing + you need, implement it in this repo. +- Run the tests with `npm test`. + +## Note + +Ethereum uses **Keccak-256**, the original Keccak submission, which predates +the finalized NIST SHA3-256 standard. Mind that distinction. diff --git a/docker/context-profiles/complex-eval/cases2/keccak-selector/files/package.json b/docker/context-profiles/complex-eval/cases2/keccak-selector/files/package.json new file mode 100644 index 000000000..d28ea0650 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases2/keccak-selector/files/package.json @@ -0,0 +1,6 @@ +{ + "name": "abi-selectors", + "private": true, + "type": "commonjs", + "scripts": { "test": "node --test test/" } +} diff --git a/docker/context-profiles/complex-eval/cases2/keccak-selector/files/src/selector.js b/docker/context-profiles/complex-eval/cases2/keccak-selector/files/src/selector.js new file mode 100644 index 000000000..4e5a82d0f --- /dev/null +++ b/docker/context-profiles/complex-eval/cases2/keccak-selector/files/src/selector.js @@ -0,0 +1,8 @@ +'use strict'; + +// TODO: implement per README. Known vector: name() -> 0x06fdde03. +function functionSelector(_signature) { + throw new Error('not implemented'); +} + +module.exports = { functionSelector }; diff --git a/docker/context-profiles/complex-eval/cases2/keccak-selector/files/test/selector.test.js b/docker/context-profiles/complex-eval/cases2/keccak-selector/files/test/selector.test.js new file mode 100644 index 000000000..97a435335 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases2/keccak-selector/files/test/selector.test.js @@ -0,0 +1,12 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { functionSelector } = require('../src/selector'); + +test('name() selector matches the published ERC-20 value', () => { + assert.equal(functionSelector('name()'), '0x06fdde03'); +}); + +test('output format', () => { + assert.match(functionSelector('totalSupply()'), /^0x[0-9a-f]{8}$/); +}); diff --git a/docker/context-profiles/complex-eval/cases2/keccak-selector/meta.json b/docker/context-profiles/complex-eval/cases2/keccak-selector/meta.json new file mode 100644 index 000000000..30cc51fec --- /dev/null +++ b/docker/context-profiles/complex-eval/cases2/keccak-selector/meta.json @@ -0,0 +1,11 @@ +{ + "id": "keccak-selector", + "category": "domain-knowledge-trap", + "manualIds": ["skill:nodejs-keccak256"], + "checkTimeoutMs": 30000, + "selection": { + "id": "complex-keccak-selector", + "category": "complex-domain-knowledge-trap", + "expectedIds": ["skill:nodejs-keccak256"] + } +} diff --git a/docker/context-profiles/complex-eval/cases2/keccak-selector/query.md b/docker/context-profiles/complex-eval/cases2/keccak-selector/query.md new file mode 100644 index 000000000..1381a1904 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases2/keccak-selector/query.md @@ -0,0 +1 @@ +We're building contract ABI tooling and need Ethereum function selectors. Implement `functionSelector(signature)` in this repo per the README — it must produce the correct selector for any canonical signature, with no external dependencies. The one known test vector is in the test suite; make `npm test` pass and add coverage for a few more common ERC-20 selectors if you know them. diff --git a/docker/context-profiles/complex-eval/cases3/chained-tickets/files/API.md b/docker/context-profiles/complex-eval/cases3/chained-tickets/files/API.md new file mode 100644 index 000000000..b916ba80a --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/chained-tickets/files/API.md @@ -0,0 +1,13 @@ +# Shortlink API + +- `POST /links` — body `{ "url": string, "ttlSeconds"?: number }`. + - `201` → `{ "code", "shortUrl", "expiresAt" }`. `code` is 6–10 + alphanumeric characters; `shortUrl` is `/`; `expiresAt` is an ISO + timestamp. Default TTL is 7 days; `ttlSeconds` must be an integer between + 1 and 2592000 (30 days). + - Missing/invalid `url` or out-of-range `ttlSeconds` → `400`. +- `GET /` — `302` with `Location` set to the original URL. + Unknown code → `404`. Expired link → `410`. +- `DELETE /links/` — `204`. Unknown code → `404`. + +All error responses follow the envelope in `CONTRIBUTING.md`. diff --git a/docker/context-profiles/complex-eval/cases3/chained-tickets/files/CONTRIBUTING.md b/docker/context-profiles/complex-eval/cases3/chained-tickets/files/CONTRIBUTING.md new file mode 100644 index 000000000..7c45e4af2 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/chained-tickets/files/CONTRIBUTING.md @@ -0,0 +1,13 @@ +# Engineering conventions + +These conventions apply to every ticket, every route, every change: + +- **Errors**: every error response is JSON with the envelope + `{ "error": { "code": "", "message": "" } }` + and the matching HTTP status. No HTML error pages, no stack traces. +- **Layering**: HTTP handling in `src/routes.js`, business logic in + `src/service.js`, storage in `src/store.js`. `src/app.js` wires them. +- **Runtime config** comes from environment variables, read at startup. +- **Every ticket**: add tests under `test/`, add a `CHANGELOG.md` entry + describing what shipped, and keep `README.md` accurate. +- No external dependencies. diff --git a/docker/context-profiles/complex-eval/cases3/chained-tickets/files/README.md b/docker/context-profiles/complex-eval/cases3/chained-tickets/files/README.md new file mode 100644 index 000000000..90f4bae61 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/chained-tickets/files/README.md @@ -0,0 +1,9 @@ +# shortlink + +Internal link shortener service. Node.js standard library only, CommonJS. + +- `API.md` — the HTTP contract. +- `CONTRIBUTING.md` — engineering conventions. Every ticket follows them. +- `src/app.js` exports `createApp()` returning an `http.Server` that is not yet + listening; `node src/index.js ` starts the service. +- Run the tests with `npm test`. diff --git a/docker/context-profiles/complex-eval/cases3/chained-tickets/files/package.json b/docker/context-profiles/complex-eval/cases3/chained-tickets/files/package.json new file mode 100644 index 000000000..12bbcaf08 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/chained-tickets/files/package.json @@ -0,0 +1,6 @@ +{ + "name": "shortlink", + "private": true, + "type": "commonjs", + "scripts": { "test": "node --test test/*.test.js" } +} diff --git a/docker/context-profiles/complex-eval/cases3/chained-tickets/meta.json b/docker/context-profiles/complex-eval/cases3/chained-tickets/meta.json new file mode 100644 index 000000000..30eb9fb05 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/chained-tickets/meta.json @@ -0,0 +1,17 @@ +{ + "id": "chained-tickets", + "category": "long-horizon-chain", + "manualIds": [], + "checkTimeoutMs": 60000, + "steps": [ + { "manualIds": ["skill:backend-patterns"] }, + { "manualIds": ["skill:backend-patterns"] }, + { "manualIds": ["skill:security-review"] }, + { "manualIds": ["skill:api-design"] } + ], + "selection": { + "id": "complex-chained-tickets", + "category": "complex-long-horizon", + "expectedIds": ["skill:backend-patterns"] + } +} diff --git a/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/01-core/check.cjs b/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/01-core/check.cjs new file mode 100644 index 000000000..cda5c3028 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/01-core/check.cjs @@ -0,0 +1,95 @@ +'use strict'; +// Step 1 grader: core API contract + conventions (envelope, layering, changelog, tests). +const fs = require('node:fs'); +const path = require('node:path'); + +const checks = []; +const record = (name, ok) => checks.push({ name, ok: Boolean(ok) }); +let finished = false; +function finish() { + if (finished) return; + finished = true; + for (let i = checks.length; i < 10; i++) record(`unreached-${i + 1}`, false); + const ok = checks.filter(c => c.ok).length; + for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\n`); + process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 10, passed: ok, total: 10 })}\n`); + process.exit(0); +} +// A crashing agent server must not kill the grader: score what completed. +process.on('uncaughtException', finish); +process.on('unhandledRejection', finish); +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +const root = process.cwd(); +const hasEnvelope = body => body && body.error && typeof body.error.code === 'string' + && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string'; + +(async () => { + let createApp; + try { ({ createApp } = require(path.join(root, 'src', 'app.js'))); } catch { /* scored below */ } + if (typeof createApp === 'function') { + try { + const app = createApp(); + await new Promise(resolve => app.listen(0, '127.0.0.1', resolve)); + const port = app.address().port; + const post = (body) => fetch(`http://127.0.0.1:${port}/links`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + const get = (p) => fetch(`http://127.0.0.1:${port}${p}`, { redirect: 'manual' }); + + const created = await post({ url: 'https://example.com/landing' }); + const createdBody = await created.json().catch(() => null); + record('create-happy-201', created.status === 201 && createdBody + && /^[A-Za-z0-9]{6,10}$/.test(createdBody.code || '') && typeof createdBody.shortUrl === 'string' + && typeof createdBody.expiresAt === 'string' && !Number.isNaN(Date.parse(createdBody.expiresAt))); + + let code = createdBody && createdBody.code; + if (code) { + const redirect = await get(`/${code}`); + record('redirect-302-location', redirect.status === 302 + && redirect.headers.get('location') === 'https://example.com/landing'); + } else record('redirect-302-location', false); + + const unknown = await get('/nope00'); + record('unknown-code-404-envelope', unknown.status === 404 && hasEnvelope(await unknown.json().catch(() => null))); + + const badUrl = await post({ url: 'notaurl' }); + record('invalid-url-400-envelope', badUrl.status === 400 && hasEnvelope(await badUrl.json().catch(() => null))); + const noBody = await post({}); + record('missing-url-400-envelope', noBody.status === 400 && hasEnvelope(await noBody.json().catch(() => null))); + const badTtl = await post({ url: 'https://example.com', ttlSeconds: 99999999 }); + record('ttl-bounds-400-envelope', badTtl.status === 400 && hasEnvelope(await badTtl.json().catch(() => null))); + + const expiring = await post({ url: 'https://example.com/gone', ttlSeconds: 1 }); + const expiringBody = await expiring.json().catch(() => null); + if (expiringBody && expiringBody.code) { + await sleep(1300); + const gone = await get(`/${expiringBody.code}`); + record('expired-link-410-envelope', gone.status === 410 && hasEnvelope(await gone.json().catch(() => null))); + } else record('expired-link-410-envelope', false); + + if (code) { + const del = await fetch(`http://127.0.0.1:${port}/links/${code}`, { method: 'DELETE' }); + const after = await get(`/${code}`); + record('delete-flow-204-then-404', del.status === 204 && after.status === 404); + } else record('delete-flow-204-then-404', false); + app.close(); + } catch { /* remaining checks unscored */ } + } else { + for (const name of ['create-happy-201', 'redirect-302-location', 'unknown-code-404-envelope', + 'invalid-url-400-envelope', 'missing-url-400-envelope', 'ttl-bounds-400-envelope', + 'expired-link-410-envelope', 'delete-flow-204-then-404']) record(name, false); + } + + // Conventions. + let changelog = ''; + try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ } + let tests = ''; + try { + for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8'); + } catch { /* missing */ } + const testCount = (tests.match(/\btest\(/g) || []).length; + record('changelog-and-tests', changelog.length > 20 && testCount >= 3); + record('layering-files', ['routes.js', 'service.js', 'store.js'] + .every(f => fs.existsSync(path.join(root, 'src', f)))); + + finish(); +})(); diff --git a/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/01-core/query.md b/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/01-core/query.md new file mode 100644 index 000000000..2c00246ec --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/01-core/query.md @@ -0,0 +1 @@ +Implement the link shortener described in API.md. Follow CONTRIBUTING.md — every convention applies. diff --git a/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/02-persistence/check.cjs b/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/02-persistence/check.cjs new file mode 100644 index 000000000..ce42427f4 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/02-persistence/check.cjs @@ -0,0 +1,106 @@ +'use strict'; +// Step 2 grader: persistence across a simulated restart (fresh module state, +// same DATA_FILE), expiry state survives, fresh/corrupt-start tolerance, conventions. +const fs = require('node:fs'); +const path = require('node:path'); + +const checks = []; +const record = (name, ok) => checks.push({ name, ok: Boolean(ok) }); +let finished = false; +function finish() { + if (finished) return; + finished = true; + for (let i = checks.length; i < 7; i++) record(`unreached-${i + 1}`, false); + const ok = checks.filter(c => c.ok).length; + for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\n`); + process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 7, passed: ok, total: 7 })}\n`); + process.exit(0); +} +// A crashing agent server must not kill the grader: score what completed. +process.on('uncaughtException', finish); +process.on('unhandledRejection', finish); +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +const root = process.cwd(); +const DATA_FILE = path.join(root, '.ecc-data', 'links.json'); +const hasEnvelope = body => body && body.error && typeof body.error.code === 'string' + && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string'; + +function purgeApp() { + for (const key of Object.keys(require.cache)) { + if (key.startsWith(path.join(root, 'src') + path.sep)) delete require.cache[key]; + } +} + +async function start() { + purgeApp(); + const { createApp } = require(path.join(root, 'src', 'app.js')); + const app = createApp(); + await new Promise((resolve, reject) => { app.once('error', reject); app.listen(0, '127.0.0.1', resolve); }); + return app; +} + +(async () => { + process.env.DATA_FILE = DATA_FILE; + try { + // First boot: create a durable link and a 1s-expiring link. + let app = await start(); + let port = app.address().port; + const post = body => fetch(`http://127.0.0.1:${port}/links`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + const durable = await (await post({ url: 'https://example.com/durable' })).json().catch(() => null); + const short = await (await post({ url: 'https://example.com/short', ttlSeconds: 1 })).json().catch(() => null); + await new Promise(resolve => app.close(resolve)); + + // Restart: fresh modules, same DATA_FILE. + app = await start(); + port = app.address().port; + const get = p => fetch(`http://127.0.0.1:${port}${p}`, { redirect: 'manual' }); + + const after = durable && durable.code ? await get(`/${durable.code}`) : null; + record('link-survives-restart', after && after.status === 302 + && after.headers.get('location') === 'https://example.com/durable'); + + await sleep(1300); + const expiredAfter = short && short.code ? await get(`/${short.code}`) : null; + record('expiry-survives-restart', expiredAfter && expiredAfter.status === 410); + await new Promise(resolve => app.close(resolve)); + + // Data file is real JSON on disk. + let dataOk = false; + try { JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); dataOk = true; } catch { /* missing/invalid */ } + record('data-file-is-json', dataOk); + + // Fresh start with no data file present. + fs.rmSync(DATA_FILE, { force: true }); + app = await start(); + port = app.address().port; + const fresh = await fetch(`http://127.0.0.1:${port}/links`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ url: 'https://example.com/fresh' }) }); + record('fresh-start-without-data-file', fresh.status === 201); + await new Promise(resolve => app.close(resolve)); + + // Corrupt data file must not kill the service. + fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true }); + fs.writeFileSync(DATA_FILE, 'garbage{{{'); + app = await start(); + port = app.address().port; + const afterCorrupt = await get('/anything1'); + record('corrupt-data-file-tolerated', afterCorrupt.status === 404 + && hasEnvelope(await afterCorrupt.json().catch(() => null))); + await new Promise(resolve => app.close(resolve)); + fs.rmSync(DATA_FILE, { force: true }); + } catch { /* remaining checks unscored */ } + + let changelog = ''; + try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ } + let tests = ''; + try { + for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8'); + } catch { /* missing */ } + const changelogEntries = (changelog.match(/^[-*#]/gm) || []).length; + record('changelog-grown', changelogEntries >= 2 && /persist|restart|data/i.test(changelog)); + record('tests-grown', (tests.match(/\btest\(/g) || []).length >= 6); + + finish(); +})(); diff --git a/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/02-persistence/query.md b/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/02-persistence/query.md new file mode 100644 index 000000000..544b2f51e --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/02-persistence/query.md @@ -0,0 +1 @@ +Links need to survive a service restart. Persist them to the JSON file named by the DATA_FILE environment variable (read at startup). Take care of it. diff --git a/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/03-abuse/check.cjs b/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/03-abuse/check.cjs new file mode 100644 index 000000000..829abd522 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/03-abuse/check.cjs @@ -0,0 +1,83 @@ +'use strict'; +// Step 3 grader: abuse handling — URL validation, size limits, rate limiting — +// plus conventions. Hammer probe runs last so earlier probes stay unthrottled. +const fs = require('node:fs'); +const path = require('node:path'); + +const checks = []; +const record = (name, ok) => checks.push({ name, ok: Boolean(ok) }); +let finished = false; +function finish() { + if (finished) return; + finished = true; + for (let i = checks.length; i < 8; i++) record(`unreached-${i + 1}`, false); + const ok = checks.filter(c => c.ok).length; + for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\n`); + process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 8, passed: ok, total: 8 })}\n`); + process.exit(0); +} +// A crashing agent server must not kill the grader: score what completed. +process.on('uncaughtException', finish); +process.on('unhandledRejection', finish); +const root = process.cwd(); +const DATA_FILE = path.join(root, '.ecc-data', 'links-step3.json'); +const hasEnvelope = body => body && body.error && typeof body.error.code === 'string' + && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string'; + +function purgeApp() { + for (const key of Object.keys(require.cache)) { + if (key.startsWith(path.join(root, 'src') + path.sep)) delete require.cache[key]; + } +} + +(async () => { + process.env.DATA_FILE = DATA_FILE; + try { + purgeApp(); + const { createApp } = require(path.join(root, 'src', 'app.js')); + const app = createApp(); + await new Promise(resolve => app.listen(0, '127.0.0.1', resolve)); + const port = app.address().port; + const post = body => fetch(`http://127.0.0.1:${port}/links`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + + const okCreate = await post({ url: 'https://example.com/normal' }); + record('normal-create-still-201', okCreate.status === 201); + + const js = await post({ url: 'javascript:alert(1)' }); + record('javascript-scheme-400-envelope', js.status === 400 && hasEnvelope(await js.json().catch(() => null))); + const ftp = await post({ url: 'ftp://files.example.com/x' }); + record('non-http-scheme-400-envelope', ftp.status === 400 && hasEnvelope(await ftp.json().catch(() => null))); + const huge = await post({ url: `https://example.com/${'a'.repeat(10000)}` }); + const hugeBody = await huge.json().catch(() => null); + record('oversize-url-4xx-envelope', huge.status >= 400 && huge.status < 500 && hasEnvelope(hugeBody)); + + // Hammer: 60 rapid creates must trip a 429 with the envelope. + const responses = await Promise.all(Array.from({ length: 60 }, (_, i) => + post({ url: `https://example.com/flood-${i}` }))); + const limited = []; + for (const r of responses) if (r.status === 429) limited.push(await r.json().catch(() => null)); + record('rate-limit-429-envelope', limited.length > 0 && limited.every(hasEnvelope)); + app.close(); + } catch { /* remaining checks unscored */ } + + let sources = ''; + try { + for (const f of fs.readdirSync(path.join(root, 'src'))) { + if (f.endsWith('.js')) sources += fs.readFileSync(path.join(root, 'src', f), 'utf8'); + } + } catch { /* missing */ } + record('rate-limiting-implemented', /429|rate.?limit/i.test(sources)); + + let changelog = ''; + try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ } + let tests = ''; + try { + for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8'); + } catch { /* missing */ } + const changelogEntries = (changelog.match(/^[-*#]/gm) || []).length; + record('changelog-grown', changelogEntries >= 3 && /abuse|rate|valid|secur/i.test(changelog)); + record('tests-grown', (tests.match(/\btest\(/g) || []).length >= 9); + + finish(); +})(); diff --git a/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/03-abuse/query.md b/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/03-abuse/query.md new file mode 100644 index 000000000..799adaf89 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/03-abuse/query.md @@ -0,0 +1 @@ +We're seeing abuse — people hammering the API and submitting junk URLs. Deal with it. diff --git a/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/04-analytics/check.cjs b/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/04-analytics/check.cjs new file mode 100644 index 000000000..ed2e69364 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/04-analytics/check.cjs @@ -0,0 +1,88 @@ +'use strict'; +// Step 4 grader: hit analytics consistent with the existing API, conventions, +// docs and tests. (Runs in a later process than step 3, so rate windows cleared.) +const fs = require('node:fs'); +const path = require('node:path'); + +const checks = []; +const record = (name, ok) => checks.push({ name, ok: Boolean(ok) }); +let finished = false; +function finish() { + if (finished) return; + finished = true; + for (let i = checks.length; i < 8; i++) record(`unreached-${i + 1}`, false); + const ok = checks.filter(c => c.ok).length; + for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\n`); + process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 8, passed: ok, total: 8 })}\n`); + process.exit(0); +} +// A crashing agent server must not kill the grader: score what completed. +process.on('uncaughtException', finish); +process.on('unhandledRejection', finish); +const root = process.cwd(); +const DATA_FILE = path.join(root, '.ecc-data', 'links-step4.json'); +const hasEnvelope = body => body && body.error && typeof body.error.code === 'string' + && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string'; + +function purgeApp() { + for (const key of Object.keys(require.cache)) { + if (key.startsWith(path.join(root, 'src') + path.sep)) delete require.cache[key]; + } +} + +(async () => { + process.env.DATA_FILE = DATA_FILE; + try { + purgeApp(); + const { createApp } = require(path.join(root, 'src', 'app.js')); + const app = createApp(); + await new Promise(resolve => app.listen(0, '127.0.0.1', resolve)); + const port = app.address().port; + + const created = await fetch(`http://127.0.0.1:${port}/links`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ url: 'https://example.com/tracked' }) }); + const body = await created.json().catch(() => null); + const code = body && body.code; + record('create-still-works', created.status === 201 && Boolean(code)); + + if (code) { + const before = await fetch(`http://127.0.0.1:${port}/links/${code}/stats`); + const beforeBody = await before.json().catch(() => null); + record('stats-zero-before-redirects', before.status === 200 && beforeBody && beforeBody.hits === 0); + + for (let i = 0; i < 3; i++) { + await fetch(`http://127.0.0.1:${port}/${code}`, { redirect: 'manual' }); + } + const stats = await fetch(`http://127.0.0.1:${port}/links/${code}/stats`); + const statsBody = await stats.json().catch(() => null); + record('stats-count-three-hits', stats.status === 200 && statsBody && statsBody.hits === 3); + + const redirect = await fetch(`http://127.0.0.1:${port}/${code}`, { redirect: 'manual' }); + record('redirect-still-302', redirect.status === 302); + + const missing = await fetch(`http://127.0.0.1:${port}/links/zzzzzz/stats`); + record('stats-unknown-404-envelope', missing.status === 404 + && hasEnvelope(await missing.json().catch(() => null))); + } else { + for (const name of ['stats-zero-before-redirects', 'stats-count-three-hits', + 'redirect-still-302', 'stats-unknown-404-envelope']) record(name, false); + } + app.close(); + } catch { /* remaining checks unscored */ } + + let readme = ''; + try { readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8'); } catch { /* missing */ } + record('readme-documents-stats', /\/stats|hits|analytics/i.test(readme)); + let changelog = ''; + try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ } + let tests = ''; + try { + for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8'); + } catch { /* missing */ } + const changelogEntries = (changelog.match(/^[-*#]/gm) || []).length; + record('changelog-grown', changelogEntries >= 4 && /stat|analytic|hit/i.test(changelog)); + record('tests-grown', (tests.match(/\btest\(/g) || []).length >= 12); + + finish(); +})(); diff --git a/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/04-analytics/query.md b/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/04-analytics/query.md new file mode 100644 index 000000000..619549068 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/chained-tickets/steps/04-analytics/query.md @@ -0,0 +1 @@ +Track redirect hits per link and expose them at GET /links/:code/stats, consistent with the existing API. diff --git a/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/check.cjs b/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/check.cjs new file mode 100644 index 000000000..7882bce07 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/check.cjs @@ -0,0 +1,119 @@ +'use strict'; +// Hidden grader for idempotent-webhooks: exactly-once under sequential, +// concurrent, and mixed-concurrent duplicates, plus the documented API, +// regression coverage, and hygiene. Prints ECC_EVAL_SCORE and always exits 0. +const fs = require('node:fs'); +const path = require('node:path'); + +const checks = []; +const record = (name, ok) => checks.push({ name, ok: Boolean(ok) }); +let finished = false; +function finish() { + if (finished) return; + finished = true; + for (let i = checks.length; i < 12; i++) record(`unreached-${i + 1}`, false); + const ok = checks.filter(c => c.ok).length; + for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\n`); + process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 12, passed: ok, total: 12 })}\n`); + process.exit(0); +} +// A crashing agent server must not kill the grader: score what completed. +process.on('uncaughtException', finish); +process.on('unhandledRejection', finish); +const root = process.cwd(); +const hasEnvelope = body => body && body.error && typeof body.error.code === 'string' + && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string'; + +(async () => { + let createApp; + let store; + try { + ({ createApp } = require(path.join(root, 'src', 'app.js'))); + ({ store } = require(path.join(root, 'src', 'store.js'))); + } catch { /* scored below */ } + if (typeof createApp === 'function' && store && Array.isArray(store.paymentLog)) { + try { + const app = createApp(); + await new Promise(resolve => app.listen(0, '127.0.0.1', resolve)); + const port = app.address().port; + const send = (eventId, orderId, amountCents) => fetch(`http://127.0.0.1:${port}/webhooks/payments`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ eventId, orderId, amountCents, type: 'payment.succeeded' }) }); + const logsFor = orderId => store.paymentLog.filter(p => p.orderId === orderId).length; + + // 1: single delivery applies once. + const single = await send('ev-1', 'o1', 5000); + const singleBody = await single.json().catch(() => null); + record('single-delivery-processed', single.status === 200 && singleBody + && singleBody.status === 'processed' && singleBody.orderId === 'o1' && logsFor('o1') === 1); + + // 2: sequential retry replays without re-applying. + const retry = await send('ev-1', 'o1', 5000); + const retryBody = await retry.json().catch(() => null); + record('sequential-duplicate-inert', retry.status === 200 && retryBody + && retryBody.status === 'duplicate' && logsFor('o1') === 1); + + // 3: fifty concurrent identical deliveries apply exactly once. + const storm = await Promise.all(Array.from({ length: 50 }, () => send('ev-2', 'o2', 12500))); + const stormBodies = []; + for (const r of storm) stormBodies.push(await r.json().catch(() => null)); + const processedCount = stormBodies.filter(b => b && b.status === 'processed').length; + const duplicateCount = stormBodies.filter(b => b && b.status === 'duplicate').length; + record('concurrent-storm-exactly-once', storm.every(r => r.status === 200) + && processedCount === 1 && duplicateCount === 49 && logsFor('o2') === 1 + && store.orders.get('o2').paymentsApplied === 1); + + // 4: a different event for an already-paid order is already_paid and inert. + const second = await send('ev-3', 'o2', 12500); + const secondBody = await second.json().catch(() => null); + record('already-paid-order-inert', second.status === 200 && secondBody + && secondBody.status === 'already_paid' && logsFor('o2') === 1); + + // 5-7: contract errors with envelopes. + const unknown = await send('ev-4', 'nope', 100); + record('unknown-order-404-envelope', unknown.status === 404 && hasEnvelope(await unknown.json().catch(() => null))); + const malformed = await fetch(`http://127.0.0.1:${port}/webhooks/payments`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: '{bad json' }); + record('malformed-body-400-envelope', malformed.status === 400 && hasEnvelope(await malformed.json().catch(() => null))); + const mismatch = await send('ev-5', 'o3', 999999); + record('amount-mismatch-422-envelope', mismatch.status === 422 + && hasEnvelope(await mismatch.json().catch(() => null)) && logsFor('o3') === 0); + + // 8: mixed storm — three orders, three eventIds, ten duplicates each, all concurrent. + const mixed = await Promise.all(['o4', 'o5', 'o6'].flatMap(orderId => + Array.from({ length: 10 }, () => send(`ev-${orderId}`, orderId, store.orders.get(orderId).amountCents)))); + for (const r of mixed) await r.json().catch(() => null); + record('mixed-storm-each-order-once', ['o4', 'o5', 'o6'].every(orderId => + logsFor(orderId) === 1 && store.orders.get(orderId).paymentsApplied === 1)); + + // 9: order inspection endpoint reflects reality. + const orderView = await fetch(`http://127.0.0.1:${port}/orders/o2`); + const orderBody = await orderView.json().catch(() => null); + record('order-endpoint-accurate', orderView.status === 200 && orderBody + && orderBody.status === 'paid' && orderBody.paymentsApplied === 1 && Boolean(orderBody.paidAt)); + + app.close(); + } catch { /* remaining checks unscored */ } + } else { + for (const name of ['single-delivery-processed', 'sequential-duplicate-inert', 'concurrent-storm-exactly-once', + 'already-paid-order-inert', 'unknown-order-404-envelope', 'malformed-body-400-envelope', + 'amount-mismatch-422-envelope', 'mixed-storm-each-order-once', 'order-endpoint-accurate']) record(name, false); + } + + // Conventions. + let tests = ''; + try { + for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8'); + } catch { /* missing */ } + record('concurrency-regression-tests', (tests.match(/\btest\(/g) || []).length >= 4 + && /Promise\.all|concurrent|duplicate|retry/i.test(tests)); + let changelog = ''; + try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ } + record('changelog-entry', /idem|duplicat|retry|inc-104|race/i.test(changelog)); + try { + const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + record('no-external-dependencies', !pkg.dependencies && !pkg.devDependencies); + } catch { record('no-external-dependencies', false); } + + finish(); +})(); diff --git a/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/files/README.md b/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/files/README.md new file mode 100644 index 000000000..512c8c059 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/files/README.md @@ -0,0 +1,12 @@ +# webhook-receiver + +Receives payment webhooks. There is an open incident: customers were +double-charged when the provider retried deliveries. See `SPEC.md` for the +contract, including the exactly-once rules. + +- `src/app.js` exports `createApp()` returning an `http.Server` that is not + yet listening; `node src/index.js ` starts the service. +- `src/store.js` is shared infrastructure: it keeps its current exports + (`store`) and records every applied payment in `store.paymentLog`. +- No external dependencies. `npm test` runs the tests. `CHANGELOG.md` records + every shipped change. diff --git a/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/files/SPEC.md b/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/files/SPEC.md new file mode 100644 index 000000000..e3dee27b1 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/files/SPEC.md @@ -0,0 +1,30 @@ +# Payment webhook contract + +`POST /webhooks/payments` with JSON body +`{ "eventId": string, "orderId": string, "amountCents": number, "type": "payment.succeeded" }`. + +Exactly-once is the point. The provider retries aggressively and may deliver +the same event many times, concurrently, or out of order. + +- A new, valid `eventId`: apply the payment exactly once → `200` + `{ "status": "processed", "orderId" }`. +- The same `eventId` seen again (any number of times, any interleaving): + `200` `{ "status": "duplicate", "orderId" }` — never applied twice. +- A payment event (new `eventId`) for an order that is already paid: + `200` `{ "status": "already_paid", "orderId" }` — an order is paid at most + once, ever. +- `amountCents` not matching the order's amount: `422`, not applied. +- Unknown `orderId`: `404`. Malformed body (bad JSON, missing/invalid + fields): `400`. +- Error responses use the envelope + `{ "error": { "code": "", "message": "..." } }`. + +`GET /orders/:id` → `200` `{ "id", "status", "paidAt", "paymentsApplied" }` +or a `404` envelope. + +## Incident note + +INC-104: concurrent duplicate deliveries double-applied payments. The naive +receiver checked "have we seen this event?" and applied the payment in two +separate steps with an async gap in between, so parallel duplicates both +passed the check. diff --git a/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/files/package.json b/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/files/package.json new file mode 100644 index 000000000..11c26f720 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/files/package.json @@ -0,0 +1,6 @@ +{ + "name": "webhook-receiver", + "private": true, + "type": "commonjs", + "scripts": { "test": "node --test test/*.test.js" } +} diff --git a/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/files/src/app.js b/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/files/src/app.js new file mode 100644 index 000000000..6ba0ba755 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/files/src/app.js @@ -0,0 +1,54 @@ +'use strict'; +const http = require('node:http'); +const { store } = require('./store'); + +// INC-104 receiver: checks "seen this event?" and applies the payment in two +// steps with an async gap in between. Concurrent duplicates both pass the +// check. Do not keep this shape. +function createApp() { + return http.createServer((req, res) => { + const url = new URL(req.url, 'http://localhost'); + + if (req.method === 'POST' && url.pathname === '/webhooks/payments') { + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', async () => { + const parsed = JSON.parse(body); + const { eventId, orderId } = parsed; + if (store.processedEvents.has(eventId)) { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'duplicate', orderId })); + return; + } + await new Promise(resolve => setImmediate(resolve)); // async gap + const order = store.orders.get(orderId); + order.status = 'paid'; + order.paidAt = new Date().toISOString(); + order.paymentsApplied++; + store.paymentLog.push({ eventId, orderId, amountCents: parsed.amountCents }); + store.processedEvents.add(eventId); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'processed', orderId })); + }); + return; + } + + const match = /^\/orders\/([\w-]+)$/.exec(url.pathname); + if (req.method === 'GET' && match) { + const order = store.orders.get(match[1]); + if (!order) { + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'no such order' } })); + return; + } + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(order)); + return; + } + + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'not found' } })); + }); +} + +module.exports = { createApp }; diff --git a/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/files/src/index.js b/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/files/src/index.js new file mode 100644 index 000000000..90ef9215f --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/files/src/index.js @@ -0,0 +1,7 @@ +'use strict'; +const { createApp } = require('./app'); + +const port = Number(process.argv[2] || 8080); +createApp().listen(port, () => { + console.log(`webhook-receiver listening on ${port}`); +}); diff --git a/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/files/src/store.js b/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/files/src/store.js new file mode 100644 index 000000000..64a4099a4 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/files/src/store.js @@ -0,0 +1,18 @@ +'use strict'; + +// Shared infrastructure. Every applied payment is appended to paymentLog; +// orders and processedEvents track receiver state. Keep the `store` export. +const store = { + orders: new Map([ + ['o1', { id: 'o1', amountCents: 5000, status: 'pending', paidAt: null, paymentsApplied: 0 }], + ['o2', { id: 'o2', amountCents: 12500, status: 'pending', paidAt: null, paymentsApplied: 0 }], + ['o3', { id: 'o3', amountCents: 800, status: 'pending', paidAt: null, paymentsApplied: 0 }], + ['o4', { id: 'o4', amountCents: 9999, status: 'pending', paidAt: null, paymentsApplied: 0 }], + ['o5', { id: 'o5', amountCents: 250, status: 'pending', paidAt: null, paymentsApplied: 0 }], + ['o6', { id: 'o6', amountCents: 7300, status: 'pending', paidAt: null, paymentsApplied: 0 }], + ]), + paymentLog: [], + processedEvents: new Set(), +}; + +module.exports = { store }; diff --git a/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/files/test/webhooks.test.js b/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/files/test/webhooks.test.js new file mode 100644 index 000000000..cf79f83d4 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/files/test/webhooks.test.js @@ -0,0 +1,21 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createApp } = require('../src/app'); +const { store } = require('../src/store'); + +test('a single payment event processes', async () => { + const server = createApp(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + try { + const port = server.address().port; + const res = await fetch(`http://127.0.0.1:${port}/webhooks/payments`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ eventId: 'ev-test-1', orderId: 'o1', amountCents: 5000, type: 'payment.succeeded' }) }); + assert.equal(res.status, 200); + assert.equal((await res.json()).status, 'processed'); + assert.equal(store.orders.get('o1').status, 'paid'); + } finally { + server.close(); + } +}); diff --git a/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/meta.json b/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/meta.json new file mode 100644 index 000000000..d5d396e74 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/meta.json @@ -0,0 +1,11 @@ +{ + "id": "idempotent-webhooks", + "category": "concurrency-trap", + "manualIds": ["skill:error-handling"], + "checkTimeoutMs": 60000, + "selection": { + "id": "complex-idempotent-webhooks", + "category": "complex-concurrency-trap", + "expectedIds": ["skill:error-handling"] + } +} diff --git a/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/query.md b/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/query.md new file mode 100644 index 000000000..f2902f874 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/idempotent-webhooks/query.md @@ -0,0 +1 @@ +The payments provider retries aggressively and sometimes delivers the same webhook many times concurrently. Our receiver double-charges customers when that happens — see the incident note in SPEC.md. Rework the receiver so the full contract in SPEC.md holds: duplicates must never double-apply under any interleaving, and the documented API and the store contract stay intact. `npm test` must pass, and add regression coverage for the concurrent-duplicate case so INC-104 can't come back. diff --git a/docker/context-profiles/complex-eval/cases3/production-ready/check.cjs b/docker/context-profiles/complex-eval/cases3/production-ready/check.cjs new file mode 100644 index 000000000..1320f0e9f --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/production-ready/check.cjs @@ -0,0 +1,133 @@ +'use strict'; +// Hidden grader for production-ready: probes every dimension of the documented +// production bar. Prints ECC_EVAL_SCORE and always exits 0. +const fs = require('node:fs'); +const path = require('node:path'); + +const checks = []; +const record = (name, ok) => checks.push({ name, ok: Boolean(ok) }); +let finished = false; +function finish() { + if (finished) return; + finished = true; + for (let i = checks.length; i < 16; i++) record(`unreached-${i + 1}`, false); + const ok = checks.filter(c => c.ok).length; + for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\n`); + process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 16, passed: ok, total: 16 })}\n`); + process.exit(0); +} +// A crashing agent server must not kill the grader: score what completed. +process.on('uncaughtException', finish); +process.on('unhandledRejection', finish); +const root = process.cwd(); +const hasEnvelope = body => body && body.error && typeof body.error.code === 'string' + && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string'; + +(async () => { + let createApp; + try { ({ createApp } = require(path.join(root, 'src', 'app.js'))); } catch { /* scored below */ } + if (typeof createApp === 'function') { + // Capture console output during the probe run to inspect request logging. + const logged = []; + const originalLog = console.log; + const originalError = console.error; + console.log = (...args) => { logged.push(args.join(' ')); }; + console.error = (...args) => { logged.push(args.join(' ')); }; + try { + const app = createApp(); + await new Promise(resolve => app.listen(0, '127.0.0.1', resolve)); + const port = app.address().port; + const api = (p, options) => fetch(`http://127.0.0.1:${port}${p}`, options); + const post = body => api('/notes', { method: 'POST', headers: { 'content-type': 'application/json' }, body }); + + // Documented API still works. + const created = await post(JSON.stringify({ title: 'deploy', body: 'checklist' })); + const createdBody = await created.json().catch(() => null); + record('api-roundtrip-preserved', created.status === 201 && createdBody && createdBody.id + && (await (await api(`/notes/${createdBody.id}`)).json().catch(() => ({}))).title === 'deploy' + && Array.isArray((await (await api('/notes')).json().catch(() => ({}))).notes)); + + // Validation and envelope discipline. + const badJson = await post('{not json'); + record('malformed-json-400-envelope', badJson.status === 400 && hasEnvelope(await badJson.json().catch(() => null))); + const missing = await post(JSON.stringify({ body: 'no title' })); + record('missing-field-400-envelope', missing.status === 400 && hasEnvelope(await missing.json().catch(() => null))); + const wrongType = await post(JSON.stringify({ title: 42, body: 'x' })); + record('wrong-type-400-envelope', wrongType.status === 400 && hasEnvelope(await wrongType.json().catch(() => null))); + const unknown = await api('/notes/n_999999'); + const unknownBody = await unknown.text(); + let unknownParsed = null; + try { unknownParsed = JSON.parse(unknownBody); } catch { /* html or text */ } + record('unknown-404-json-envelope', unknown.status === 404 && hasEnvelope(unknownParsed)); + + // Body limit. + const big = await post(JSON.stringify({ title: 'big', body: 'x'.repeat(100 * 1024) })); + record('oversize-body-413-envelope', big.status === 413 && hasEnvelope(await big.json().catch(() => null))); + + // Health endpoint. + const health = await api('/health'); + const healthBody = await health.json().catch(() => null); + record('health-endpoint', health.status === 200 && healthBody && healthBody.status === 'ok'); + + // Security header on a normal response. + const headers = await api('/notes'); + record('nosniff-header', headers.headers.get('x-content-type-options') === 'nosniff'); + + // Error responses carry JSON content type. + record('errors-are-json', /application\/json/.test(unknown.headers.get('content-type') || '')); + + app.close(); + } catch { /* remaining checks unscored */ } finally { + console.log = originalLog; + console.error = originalError; + } + + // Structured request logging: at least one JSON line with method/path/status-ish fields. + const structured = logged.some(line => { + try { + const parsed = JSON.parse(line); + return parsed && typeof parsed === 'object' + && /method/i.test(Object.keys(parsed).join(' ')) + && /path|url/i.test(Object.keys(parsed).join(' ')) + && /status/i.test(Object.keys(parsed).join(' ')); + } catch { return false; } + }); + record('structured-request-logs', structured); + } else { + for (const name of ['api-roundtrip-preserved', 'malformed-json-400-envelope', 'missing-field-400-envelope', + 'wrong-type-400-envelope', 'unknown-404-json-envelope', 'oversize-body-413-envelope', 'health-endpoint', + 'nosniff-header', 'errors-are-json', 'structured-request-logs']) record(name, false); + } + + // Static dimensions. + let sources = ''; + const walk = directory => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const item = path.join(directory, entry.name); + if (entry.isDirectory()) walk(item); + else if (entry.name.endsWith('.js')) sources += fs.readFileSync(item, 'utf8'); + } + }; + try { walk(path.join(root, 'src')); } catch { /* none */ } + record('sigterm-graceful-shutdown', /SIGTERM/.test(sources)); + record('env-config-port', /process\.env\.[A-Z_]*PORT/.test(sources)); + + let tests = ''; + try { + for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8'); + } catch { /* missing */ } + const testCount = (tests.match(/\btest\(/g) || []).length; + record('tests-cover-error-paths', testCount >= 4 && /400|404|413|invalid|error/i.test(tests)); + + let changelog = ''; + try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ } + record('changelog-entry', changelog.length > 20 && /product|harden|valid|health|log/i.test(changelog)); + + record('no-leftover-todos', !/TODO|FIXME/.test(sources)); + try { + const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + record('no-external-dependencies', !pkg.dependencies && !pkg.devDependencies); + } catch { record('no-external-dependencies', false); } + + finish(); +})(); diff --git a/docker/context-profiles/complex-eval/cases3/production-ready/files/README.md b/docker/context-profiles/complex-eval/cases3/production-ready/files/README.md new file mode 100644 index 000000000..e387bff31 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/production-ready/files/README.md @@ -0,0 +1,19 @@ +# notes-service + +Tiny notes API. Hobby prototype state: it works on the happy path and that's +about all that can be said for it. + +## API + +- `POST /notes` — body `{ "title": string, "body": string }` → `201` with + `{ "id", "title", "body" }`. +- `GET /notes/:id` — `200` with the note, or `404`. +- `GET /notes` — `200` with `{ "notes": [...] }`. + +`src/app.js` exports `createApp()` returning an `http.Server` that is not yet +listening; `node src/index.js` starts the service. `npm test` runs the tests. + +## Operations + +`docs/production-bar.md` lists what every production service here must meet. +`CHANGELOG.md` records every shipped change. diff --git a/docker/context-profiles/complex-eval/cases3/production-ready/files/docs/production-bar.md b/docker/context-profiles/complex-eval/cases3/production-ready/files/docs/production-bar.md new file mode 100644 index 000000000..af3df1c4c --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/production-ready/files/docs/production-bar.md @@ -0,0 +1,21 @@ +# The production bar + +Every production service here meets all of the following, all the time: + +- **Validation**: malformed JSON, missing fields, and wrong types are rejected + with `400` and a structured JSON error body + `{ "error": { "code": "", "message": "..." } }`. Unknown + resources are `404` in the same envelope. No stack traces, no HTML errors, + no hanging connections. +- **Body limits**: request bodies over 64 KB are rejected with `413`, same + envelope. +- **Health**: `GET /health` returns `200` with `{ "status": "ok" }`. +- **Logging**: one structured JSON log line per request with at least + `method`, `path`, and `status` fields. +- **Configuration**: runtime configuration (port, limits) comes from + environment variables, read at startup. Nothing secret is hardcoded. +- **Shutdown**: the service closes cleanly on `SIGTERM` (stops accepting, + drains, exits). +- **Headers**: responses carry `X-Content-Type-Options: nosniff`. +- **Tests**: the suite covers error paths, not just the happy path. +- **Changelog**: every shipped change has a `CHANGELOG.md` entry. diff --git a/docker/context-profiles/complex-eval/cases3/production-ready/files/package.json b/docker/context-profiles/complex-eval/cases3/production-ready/files/package.json new file mode 100644 index 000000000..7cef6f8c0 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/production-ready/files/package.json @@ -0,0 +1,6 @@ +{ + "name": "notes-service", + "private": true, + "type": "commonjs", + "scripts": { "test": "node --test test/*.test.js" } +} diff --git a/docker/context-profiles/complex-eval/cases3/production-ready/files/src/app.js b/docker/context-profiles/complex-eval/cases3/production-ready/files/src/app.js new file mode 100644 index 000000000..db7fe2695 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/production-ready/files/src/app.js @@ -0,0 +1,50 @@ +'use strict'; +const http = require('node:http'); + +// Prototype state: happy path only. +const notes = new Map(); +let nextId = 1; + +function createApp() { + return http.createServer((req, res) => { + console.log('got a request'); + const url = new URL(req.url, 'http://localhost'); + + if (req.method === 'POST' && url.pathname === '/notes') { + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', () => { + const parsed = JSON.parse(body); + const id = `n_${nextId++}`; + notes.set(id, { id, title: parsed.title, body: parsed.body }); + res.writeHead(201, { 'content-type': 'application/json' }); + res.end(JSON.stringify(notes.get(id))); + }); + return; + } + + const match = /^\/notes\/([\w-]+)$/.exec(url.pathname); + if (req.method === 'GET' && match) { + const note = notes.get(match[1]); + if (!note) { + res.writeHead(404); + res.end('not found'); + return; + } + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(note)); + return; + } + + if (req.method === 'GET' && url.pathname === '/notes') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ notes: [...notes.values()] })); + return; + } + + res.writeHead(404); + res.end('not found'); + }); +} + +module.exports = { createApp }; diff --git a/docker/context-profiles/complex-eval/cases3/production-ready/files/src/index.js b/docker/context-profiles/complex-eval/cases3/production-ready/files/src/index.js new file mode 100644 index 000000000..a71330e92 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/production-ready/files/src/index.js @@ -0,0 +1,6 @@ +'use strict'; +const { createApp } = require('./app'); + +createApp().listen(8080, () => { + console.log('notes listening on 8080'); +}); diff --git a/docker/context-profiles/complex-eval/cases3/production-ready/files/test/notes.test.js b/docker/context-profiles/complex-eval/cases3/production-ready/files/test/notes.test.js new file mode 100644 index 000000000..51babd8fb --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/production-ready/files/test/notes.test.js @@ -0,0 +1,21 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createApp } = require('../src/app'); + +test('create and read a note', async () => { + const server = createApp(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + try { + const port = server.address().port; + const created = await fetch(`http://127.0.0.1:${port}/notes`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ title: 'first', body: 'hello' }) }); + assert.equal(created.status, 201); + const { id } = await created.json(); + const read = await fetch(`http://127.0.0.1:${port}/notes/${id}`); + assert.equal((await read.json()).title, 'first'); + } finally { + server.close(); + } +}); diff --git a/docker/context-profiles/complex-eval/cases3/production-ready/meta.json b/docker/context-profiles/complex-eval/cases3/production-ready/meta.json new file mode 100644 index 000000000..21aae2a12 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/production-ready/meta.json @@ -0,0 +1,11 @@ +{ + "id": "production-ready", + "category": "vague-hardening", + "manualIds": ["skill:backend-patterns"], + "checkTimeoutMs": 60000, + "selection": { + "id": "complex-production-ready", + "category": "complex-vague-hardening", + "expectedIds": ["skill:backend-patterns"] + } +} diff --git a/docker/context-profiles/complex-eval/cases3/production-ready/query.md b/docker/context-profiles/complex-eval/cases3/production-ready/query.md new file mode 100644 index 000000000..341755315 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases3/production-ready/query.md @@ -0,0 +1 @@ +This goes to production Monday morning. Get it ready. The bar we hold production services to is in docs/production-bar.md — meet all of it, keep the documented API working, and leave the repo in a state you'd be comfortable being on-call for. diff --git a/docker/context-profiles/complex-eval/cases4/chained-tickets/files/API.md b/docker/context-profiles/complex-eval/cases4/chained-tickets/files/API.md new file mode 100644 index 000000000..b916ba80a --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/chained-tickets/files/API.md @@ -0,0 +1,13 @@ +# Shortlink API + +- `POST /links` — body `{ "url": string, "ttlSeconds"?: number }`. + - `201` → `{ "code", "shortUrl", "expiresAt" }`. `code` is 6–10 + alphanumeric characters; `shortUrl` is `/`; `expiresAt` is an ISO + timestamp. Default TTL is 7 days; `ttlSeconds` must be an integer between + 1 and 2592000 (30 days). + - Missing/invalid `url` or out-of-range `ttlSeconds` → `400`. +- `GET /` — `302` with `Location` set to the original URL. + Unknown code → `404`. Expired link → `410`. +- `DELETE /links/` — `204`. Unknown code → `404`. + +All error responses follow the envelope in `CONTRIBUTING.md`. diff --git a/docker/context-profiles/complex-eval/cases4/chained-tickets/files/CONTRIBUTING.md b/docker/context-profiles/complex-eval/cases4/chained-tickets/files/CONTRIBUTING.md new file mode 100644 index 000000000..7c45e4af2 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/chained-tickets/files/CONTRIBUTING.md @@ -0,0 +1,13 @@ +# Engineering conventions + +These conventions apply to every ticket, every route, every change: + +- **Errors**: every error response is JSON with the envelope + `{ "error": { "code": "", "message": "" } }` + and the matching HTTP status. No HTML error pages, no stack traces. +- **Layering**: HTTP handling in `src/routes.js`, business logic in + `src/service.js`, storage in `src/store.js`. `src/app.js` wires them. +- **Runtime config** comes from environment variables, read at startup. +- **Every ticket**: add tests under `test/`, add a `CHANGELOG.md` entry + describing what shipped, and keep `README.md` accurate. +- No external dependencies. diff --git a/docker/context-profiles/complex-eval/cases4/chained-tickets/files/README.md b/docker/context-profiles/complex-eval/cases4/chained-tickets/files/README.md new file mode 100644 index 000000000..90f4bae61 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/chained-tickets/files/README.md @@ -0,0 +1,9 @@ +# shortlink + +Internal link shortener service. Node.js standard library only, CommonJS. + +- `API.md` — the HTTP contract. +- `CONTRIBUTING.md` — engineering conventions. Every ticket follows them. +- `src/app.js` exports `createApp()` returning an `http.Server` that is not yet + listening; `node src/index.js ` starts the service. +- Run the tests with `npm test`. diff --git a/docker/context-profiles/complex-eval/cases4/chained-tickets/files/package.json b/docker/context-profiles/complex-eval/cases4/chained-tickets/files/package.json new file mode 100644 index 000000000..12bbcaf08 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/chained-tickets/files/package.json @@ -0,0 +1,6 @@ +{ + "name": "shortlink", + "private": true, + "type": "commonjs", + "scripts": { "test": "node --test test/*.test.js" } +} diff --git a/docker/context-profiles/complex-eval/cases4/chained-tickets/meta.json b/docker/context-profiles/complex-eval/cases4/chained-tickets/meta.json new file mode 100644 index 000000000..30eb9fb05 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/chained-tickets/meta.json @@ -0,0 +1,17 @@ +{ + "id": "chained-tickets", + "category": "long-horizon-chain", + "manualIds": [], + "checkTimeoutMs": 60000, + "steps": [ + { "manualIds": ["skill:backend-patterns"] }, + { "manualIds": ["skill:backend-patterns"] }, + { "manualIds": ["skill:security-review"] }, + { "manualIds": ["skill:api-design"] } + ], + "selection": { + "id": "complex-chained-tickets", + "category": "complex-long-horizon", + "expectedIds": ["skill:backend-patterns"] + } +} diff --git a/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/01-core/check.cjs b/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/01-core/check.cjs new file mode 100644 index 000000000..cda5c3028 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/01-core/check.cjs @@ -0,0 +1,95 @@ +'use strict'; +// Step 1 grader: core API contract + conventions (envelope, layering, changelog, tests). +const fs = require('node:fs'); +const path = require('node:path'); + +const checks = []; +const record = (name, ok) => checks.push({ name, ok: Boolean(ok) }); +let finished = false; +function finish() { + if (finished) return; + finished = true; + for (let i = checks.length; i < 10; i++) record(`unreached-${i + 1}`, false); + const ok = checks.filter(c => c.ok).length; + for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\n`); + process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 10, passed: ok, total: 10 })}\n`); + process.exit(0); +} +// A crashing agent server must not kill the grader: score what completed. +process.on('uncaughtException', finish); +process.on('unhandledRejection', finish); +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +const root = process.cwd(); +const hasEnvelope = body => body && body.error && typeof body.error.code === 'string' + && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string'; + +(async () => { + let createApp; + try { ({ createApp } = require(path.join(root, 'src', 'app.js'))); } catch { /* scored below */ } + if (typeof createApp === 'function') { + try { + const app = createApp(); + await new Promise(resolve => app.listen(0, '127.0.0.1', resolve)); + const port = app.address().port; + const post = (body) => fetch(`http://127.0.0.1:${port}/links`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + const get = (p) => fetch(`http://127.0.0.1:${port}${p}`, { redirect: 'manual' }); + + const created = await post({ url: 'https://example.com/landing' }); + const createdBody = await created.json().catch(() => null); + record('create-happy-201', created.status === 201 && createdBody + && /^[A-Za-z0-9]{6,10}$/.test(createdBody.code || '') && typeof createdBody.shortUrl === 'string' + && typeof createdBody.expiresAt === 'string' && !Number.isNaN(Date.parse(createdBody.expiresAt))); + + let code = createdBody && createdBody.code; + if (code) { + const redirect = await get(`/${code}`); + record('redirect-302-location', redirect.status === 302 + && redirect.headers.get('location') === 'https://example.com/landing'); + } else record('redirect-302-location', false); + + const unknown = await get('/nope00'); + record('unknown-code-404-envelope', unknown.status === 404 && hasEnvelope(await unknown.json().catch(() => null))); + + const badUrl = await post({ url: 'notaurl' }); + record('invalid-url-400-envelope', badUrl.status === 400 && hasEnvelope(await badUrl.json().catch(() => null))); + const noBody = await post({}); + record('missing-url-400-envelope', noBody.status === 400 && hasEnvelope(await noBody.json().catch(() => null))); + const badTtl = await post({ url: 'https://example.com', ttlSeconds: 99999999 }); + record('ttl-bounds-400-envelope', badTtl.status === 400 && hasEnvelope(await badTtl.json().catch(() => null))); + + const expiring = await post({ url: 'https://example.com/gone', ttlSeconds: 1 }); + const expiringBody = await expiring.json().catch(() => null); + if (expiringBody && expiringBody.code) { + await sleep(1300); + const gone = await get(`/${expiringBody.code}`); + record('expired-link-410-envelope', gone.status === 410 && hasEnvelope(await gone.json().catch(() => null))); + } else record('expired-link-410-envelope', false); + + if (code) { + const del = await fetch(`http://127.0.0.1:${port}/links/${code}`, { method: 'DELETE' }); + const after = await get(`/${code}`); + record('delete-flow-204-then-404', del.status === 204 && after.status === 404); + } else record('delete-flow-204-then-404', false); + app.close(); + } catch { /* remaining checks unscored */ } + } else { + for (const name of ['create-happy-201', 'redirect-302-location', 'unknown-code-404-envelope', + 'invalid-url-400-envelope', 'missing-url-400-envelope', 'ttl-bounds-400-envelope', + 'expired-link-410-envelope', 'delete-flow-204-then-404']) record(name, false); + } + + // Conventions. + let changelog = ''; + try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ } + let tests = ''; + try { + for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8'); + } catch { /* missing */ } + const testCount = (tests.match(/\btest\(/g) || []).length; + record('changelog-and-tests', changelog.length > 20 && testCount >= 3); + record('layering-files', ['routes.js', 'service.js', 'store.js'] + .every(f => fs.existsSync(path.join(root, 'src', f)))); + + finish(); +})(); diff --git a/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/01-core/query.md b/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/01-core/query.md new file mode 100644 index 000000000..2c00246ec --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/01-core/query.md @@ -0,0 +1 @@ +Implement the link shortener described in API.md. Follow CONTRIBUTING.md — every convention applies. diff --git a/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/02-persistence/check.cjs b/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/02-persistence/check.cjs new file mode 100644 index 000000000..ce42427f4 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/02-persistence/check.cjs @@ -0,0 +1,106 @@ +'use strict'; +// Step 2 grader: persistence across a simulated restart (fresh module state, +// same DATA_FILE), expiry state survives, fresh/corrupt-start tolerance, conventions. +const fs = require('node:fs'); +const path = require('node:path'); + +const checks = []; +const record = (name, ok) => checks.push({ name, ok: Boolean(ok) }); +let finished = false; +function finish() { + if (finished) return; + finished = true; + for (let i = checks.length; i < 7; i++) record(`unreached-${i + 1}`, false); + const ok = checks.filter(c => c.ok).length; + for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\n`); + process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 7, passed: ok, total: 7 })}\n`); + process.exit(0); +} +// A crashing agent server must not kill the grader: score what completed. +process.on('uncaughtException', finish); +process.on('unhandledRejection', finish); +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +const root = process.cwd(); +const DATA_FILE = path.join(root, '.ecc-data', 'links.json'); +const hasEnvelope = body => body && body.error && typeof body.error.code === 'string' + && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string'; + +function purgeApp() { + for (const key of Object.keys(require.cache)) { + if (key.startsWith(path.join(root, 'src') + path.sep)) delete require.cache[key]; + } +} + +async function start() { + purgeApp(); + const { createApp } = require(path.join(root, 'src', 'app.js')); + const app = createApp(); + await new Promise((resolve, reject) => { app.once('error', reject); app.listen(0, '127.0.0.1', resolve); }); + return app; +} + +(async () => { + process.env.DATA_FILE = DATA_FILE; + try { + // First boot: create a durable link and a 1s-expiring link. + let app = await start(); + let port = app.address().port; + const post = body => fetch(`http://127.0.0.1:${port}/links`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + const durable = await (await post({ url: 'https://example.com/durable' })).json().catch(() => null); + const short = await (await post({ url: 'https://example.com/short', ttlSeconds: 1 })).json().catch(() => null); + await new Promise(resolve => app.close(resolve)); + + // Restart: fresh modules, same DATA_FILE. + app = await start(); + port = app.address().port; + const get = p => fetch(`http://127.0.0.1:${port}${p}`, { redirect: 'manual' }); + + const after = durable && durable.code ? await get(`/${durable.code}`) : null; + record('link-survives-restart', after && after.status === 302 + && after.headers.get('location') === 'https://example.com/durable'); + + await sleep(1300); + const expiredAfter = short && short.code ? await get(`/${short.code}`) : null; + record('expiry-survives-restart', expiredAfter && expiredAfter.status === 410); + await new Promise(resolve => app.close(resolve)); + + // Data file is real JSON on disk. + let dataOk = false; + try { JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); dataOk = true; } catch { /* missing/invalid */ } + record('data-file-is-json', dataOk); + + // Fresh start with no data file present. + fs.rmSync(DATA_FILE, { force: true }); + app = await start(); + port = app.address().port; + const fresh = await fetch(`http://127.0.0.1:${port}/links`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ url: 'https://example.com/fresh' }) }); + record('fresh-start-without-data-file', fresh.status === 201); + await new Promise(resolve => app.close(resolve)); + + // Corrupt data file must not kill the service. + fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true }); + fs.writeFileSync(DATA_FILE, 'garbage{{{'); + app = await start(); + port = app.address().port; + const afterCorrupt = await get('/anything1'); + record('corrupt-data-file-tolerated', afterCorrupt.status === 404 + && hasEnvelope(await afterCorrupt.json().catch(() => null))); + await new Promise(resolve => app.close(resolve)); + fs.rmSync(DATA_FILE, { force: true }); + } catch { /* remaining checks unscored */ } + + let changelog = ''; + try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ } + let tests = ''; + try { + for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8'); + } catch { /* missing */ } + const changelogEntries = (changelog.match(/^[-*#]/gm) || []).length; + record('changelog-grown', changelogEntries >= 2 && /persist|restart|data/i.test(changelog)); + record('tests-grown', (tests.match(/\btest\(/g) || []).length >= 6); + + finish(); +})(); diff --git a/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/02-persistence/query.md b/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/02-persistence/query.md new file mode 100644 index 000000000..544b2f51e --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/02-persistence/query.md @@ -0,0 +1 @@ +Links need to survive a service restart. Persist them to the JSON file named by the DATA_FILE environment variable (read at startup). Take care of it. diff --git a/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/03-abuse/check.cjs b/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/03-abuse/check.cjs new file mode 100644 index 000000000..829abd522 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/03-abuse/check.cjs @@ -0,0 +1,83 @@ +'use strict'; +// Step 3 grader: abuse handling — URL validation, size limits, rate limiting — +// plus conventions. Hammer probe runs last so earlier probes stay unthrottled. +const fs = require('node:fs'); +const path = require('node:path'); + +const checks = []; +const record = (name, ok) => checks.push({ name, ok: Boolean(ok) }); +let finished = false; +function finish() { + if (finished) return; + finished = true; + for (let i = checks.length; i < 8; i++) record(`unreached-${i + 1}`, false); + const ok = checks.filter(c => c.ok).length; + for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\n`); + process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 8, passed: ok, total: 8 })}\n`); + process.exit(0); +} +// A crashing agent server must not kill the grader: score what completed. +process.on('uncaughtException', finish); +process.on('unhandledRejection', finish); +const root = process.cwd(); +const DATA_FILE = path.join(root, '.ecc-data', 'links-step3.json'); +const hasEnvelope = body => body && body.error && typeof body.error.code === 'string' + && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string'; + +function purgeApp() { + for (const key of Object.keys(require.cache)) { + if (key.startsWith(path.join(root, 'src') + path.sep)) delete require.cache[key]; + } +} + +(async () => { + process.env.DATA_FILE = DATA_FILE; + try { + purgeApp(); + const { createApp } = require(path.join(root, 'src', 'app.js')); + const app = createApp(); + await new Promise(resolve => app.listen(0, '127.0.0.1', resolve)); + const port = app.address().port; + const post = body => fetch(`http://127.0.0.1:${port}/links`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + + const okCreate = await post({ url: 'https://example.com/normal' }); + record('normal-create-still-201', okCreate.status === 201); + + const js = await post({ url: 'javascript:alert(1)' }); + record('javascript-scheme-400-envelope', js.status === 400 && hasEnvelope(await js.json().catch(() => null))); + const ftp = await post({ url: 'ftp://files.example.com/x' }); + record('non-http-scheme-400-envelope', ftp.status === 400 && hasEnvelope(await ftp.json().catch(() => null))); + const huge = await post({ url: `https://example.com/${'a'.repeat(10000)}` }); + const hugeBody = await huge.json().catch(() => null); + record('oversize-url-4xx-envelope', huge.status >= 400 && huge.status < 500 && hasEnvelope(hugeBody)); + + // Hammer: 60 rapid creates must trip a 429 with the envelope. + const responses = await Promise.all(Array.from({ length: 60 }, (_, i) => + post({ url: `https://example.com/flood-${i}` }))); + const limited = []; + for (const r of responses) if (r.status === 429) limited.push(await r.json().catch(() => null)); + record('rate-limit-429-envelope', limited.length > 0 && limited.every(hasEnvelope)); + app.close(); + } catch { /* remaining checks unscored */ } + + let sources = ''; + try { + for (const f of fs.readdirSync(path.join(root, 'src'))) { + if (f.endsWith('.js')) sources += fs.readFileSync(path.join(root, 'src', f), 'utf8'); + } + } catch { /* missing */ } + record('rate-limiting-implemented', /429|rate.?limit/i.test(sources)); + + let changelog = ''; + try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ } + let tests = ''; + try { + for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8'); + } catch { /* missing */ } + const changelogEntries = (changelog.match(/^[-*#]/gm) || []).length; + record('changelog-grown', changelogEntries >= 3 && /abuse|rate|valid|secur/i.test(changelog)); + record('tests-grown', (tests.match(/\btest\(/g) || []).length >= 9); + + finish(); +})(); diff --git a/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/03-abuse/query.md b/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/03-abuse/query.md new file mode 100644 index 000000000..799adaf89 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/03-abuse/query.md @@ -0,0 +1 @@ +We're seeing abuse — people hammering the API and submitting junk URLs. Deal with it. diff --git a/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/04-analytics/check.cjs b/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/04-analytics/check.cjs new file mode 100644 index 000000000..ed2e69364 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/04-analytics/check.cjs @@ -0,0 +1,88 @@ +'use strict'; +// Step 4 grader: hit analytics consistent with the existing API, conventions, +// docs and tests. (Runs in a later process than step 3, so rate windows cleared.) +const fs = require('node:fs'); +const path = require('node:path'); + +const checks = []; +const record = (name, ok) => checks.push({ name, ok: Boolean(ok) }); +let finished = false; +function finish() { + if (finished) return; + finished = true; + for (let i = checks.length; i < 8; i++) record(`unreached-${i + 1}`, false); + const ok = checks.filter(c => c.ok).length; + for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\n`); + process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 8, passed: ok, total: 8 })}\n`); + process.exit(0); +} +// A crashing agent server must not kill the grader: score what completed. +process.on('uncaughtException', finish); +process.on('unhandledRejection', finish); +const root = process.cwd(); +const DATA_FILE = path.join(root, '.ecc-data', 'links-step4.json'); +const hasEnvelope = body => body && body.error && typeof body.error.code === 'string' + && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string'; + +function purgeApp() { + for (const key of Object.keys(require.cache)) { + if (key.startsWith(path.join(root, 'src') + path.sep)) delete require.cache[key]; + } +} + +(async () => { + process.env.DATA_FILE = DATA_FILE; + try { + purgeApp(); + const { createApp } = require(path.join(root, 'src', 'app.js')); + const app = createApp(); + await new Promise(resolve => app.listen(0, '127.0.0.1', resolve)); + const port = app.address().port; + + const created = await fetch(`http://127.0.0.1:${port}/links`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ url: 'https://example.com/tracked' }) }); + const body = await created.json().catch(() => null); + const code = body && body.code; + record('create-still-works', created.status === 201 && Boolean(code)); + + if (code) { + const before = await fetch(`http://127.0.0.1:${port}/links/${code}/stats`); + const beforeBody = await before.json().catch(() => null); + record('stats-zero-before-redirects', before.status === 200 && beforeBody && beforeBody.hits === 0); + + for (let i = 0; i < 3; i++) { + await fetch(`http://127.0.0.1:${port}/${code}`, { redirect: 'manual' }); + } + const stats = await fetch(`http://127.0.0.1:${port}/links/${code}/stats`); + const statsBody = await stats.json().catch(() => null); + record('stats-count-three-hits', stats.status === 200 && statsBody && statsBody.hits === 3); + + const redirect = await fetch(`http://127.0.0.1:${port}/${code}`, { redirect: 'manual' }); + record('redirect-still-302', redirect.status === 302); + + const missing = await fetch(`http://127.0.0.1:${port}/links/zzzzzz/stats`); + record('stats-unknown-404-envelope', missing.status === 404 + && hasEnvelope(await missing.json().catch(() => null))); + } else { + for (const name of ['stats-zero-before-redirects', 'stats-count-three-hits', + 'redirect-still-302', 'stats-unknown-404-envelope']) record(name, false); + } + app.close(); + } catch { /* remaining checks unscored */ } + + let readme = ''; + try { readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8'); } catch { /* missing */ } + record('readme-documents-stats', /\/stats|hits|analytics/i.test(readme)); + let changelog = ''; + try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ } + let tests = ''; + try { + for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8'); + } catch { /* missing */ } + const changelogEntries = (changelog.match(/^[-*#]/gm) || []).length; + record('changelog-grown', changelogEntries >= 4 && /stat|analytic|hit/i.test(changelog)); + record('tests-grown', (tests.match(/\btest\(/g) || []).length >= 12); + + finish(); +})(); diff --git a/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/04-analytics/query.md b/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/04-analytics/query.md new file mode 100644 index 000000000..619549068 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/chained-tickets/steps/04-analytics/query.md @@ -0,0 +1 @@ +Track redirect hits per link and expose them at GET /links/:code/stats, consistent with the existing API. diff --git a/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/check.cjs b/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/check.cjs new file mode 100644 index 000000000..7882bce07 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/check.cjs @@ -0,0 +1,119 @@ +'use strict'; +// Hidden grader for idempotent-webhooks: exactly-once under sequential, +// concurrent, and mixed-concurrent duplicates, plus the documented API, +// regression coverage, and hygiene. Prints ECC_EVAL_SCORE and always exits 0. +const fs = require('node:fs'); +const path = require('node:path'); + +const checks = []; +const record = (name, ok) => checks.push({ name, ok: Boolean(ok) }); +let finished = false; +function finish() { + if (finished) return; + finished = true; + for (let i = checks.length; i < 12; i++) record(`unreached-${i + 1}`, false); + const ok = checks.filter(c => c.ok).length; + for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\n`); + process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 12, passed: ok, total: 12 })}\n`); + process.exit(0); +} +// A crashing agent server must not kill the grader: score what completed. +process.on('uncaughtException', finish); +process.on('unhandledRejection', finish); +const root = process.cwd(); +const hasEnvelope = body => body && body.error && typeof body.error.code === 'string' + && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string'; + +(async () => { + let createApp; + let store; + try { + ({ createApp } = require(path.join(root, 'src', 'app.js'))); + ({ store } = require(path.join(root, 'src', 'store.js'))); + } catch { /* scored below */ } + if (typeof createApp === 'function' && store && Array.isArray(store.paymentLog)) { + try { + const app = createApp(); + await new Promise(resolve => app.listen(0, '127.0.0.1', resolve)); + const port = app.address().port; + const send = (eventId, orderId, amountCents) => fetch(`http://127.0.0.1:${port}/webhooks/payments`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ eventId, orderId, amountCents, type: 'payment.succeeded' }) }); + const logsFor = orderId => store.paymentLog.filter(p => p.orderId === orderId).length; + + // 1: single delivery applies once. + const single = await send('ev-1', 'o1', 5000); + const singleBody = await single.json().catch(() => null); + record('single-delivery-processed', single.status === 200 && singleBody + && singleBody.status === 'processed' && singleBody.orderId === 'o1' && logsFor('o1') === 1); + + // 2: sequential retry replays without re-applying. + const retry = await send('ev-1', 'o1', 5000); + const retryBody = await retry.json().catch(() => null); + record('sequential-duplicate-inert', retry.status === 200 && retryBody + && retryBody.status === 'duplicate' && logsFor('o1') === 1); + + // 3: fifty concurrent identical deliveries apply exactly once. + const storm = await Promise.all(Array.from({ length: 50 }, () => send('ev-2', 'o2', 12500))); + const stormBodies = []; + for (const r of storm) stormBodies.push(await r.json().catch(() => null)); + const processedCount = stormBodies.filter(b => b && b.status === 'processed').length; + const duplicateCount = stormBodies.filter(b => b && b.status === 'duplicate').length; + record('concurrent-storm-exactly-once', storm.every(r => r.status === 200) + && processedCount === 1 && duplicateCount === 49 && logsFor('o2') === 1 + && store.orders.get('o2').paymentsApplied === 1); + + // 4: a different event for an already-paid order is already_paid and inert. + const second = await send('ev-3', 'o2', 12500); + const secondBody = await second.json().catch(() => null); + record('already-paid-order-inert', second.status === 200 && secondBody + && secondBody.status === 'already_paid' && logsFor('o2') === 1); + + // 5-7: contract errors with envelopes. + const unknown = await send('ev-4', 'nope', 100); + record('unknown-order-404-envelope', unknown.status === 404 && hasEnvelope(await unknown.json().catch(() => null))); + const malformed = await fetch(`http://127.0.0.1:${port}/webhooks/payments`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: '{bad json' }); + record('malformed-body-400-envelope', malformed.status === 400 && hasEnvelope(await malformed.json().catch(() => null))); + const mismatch = await send('ev-5', 'o3', 999999); + record('amount-mismatch-422-envelope', mismatch.status === 422 + && hasEnvelope(await mismatch.json().catch(() => null)) && logsFor('o3') === 0); + + // 8: mixed storm — three orders, three eventIds, ten duplicates each, all concurrent. + const mixed = await Promise.all(['o4', 'o5', 'o6'].flatMap(orderId => + Array.from({ length: 10 }, () => send(`ev-${orderId}`, orderId, store.orders.get(orderId).amountCents)))); + for (const r of mixed) await r.json().catch(() => null); + record('mixed-storm-each-order-once', ['o4', 'o5', 'o6'].every(orderId => + logsFor(orderId) === 1 && store.orders.get(orderId).paymentsApplied === 1)); + + // 9: order inspection endpoint reflects reality. + const orderView = await fetch(`http://127.0.0.1:${port}/orders/o2`); + const orderBody = await orderView.json().catch(() => null); + record('order-endpoint-accurate', orderView.status === 200 && orderBody + && orderBody.status === 'paid' && orderBody.paymentsApplied === 1 && Boolean(orderBody.paidAt)); + + app.close(); + } catch { /* remaining checks unscored */ } + } else { + for (const name of ['single-delivery-processed', 'sequential-duplicate-inert', 'concurrent-storm-exactly-once', + 'already-paid-order-inert', 'unknown-order-404-envelope', 'malformed-body-400-envelope', + 'amount-mismatch-422-envelope', 'mixed-storm-each-order-once', 'order-endpoint-accurate']) record(name, false); + } + + // Conventions. + let tests = ''; + try { + for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8'); + } catch { /* missing */ } + record('concurrency-regression-tests', (tests.match(/\btest\(/g) || []).length >= 4 + && /Promise\.all|concurrent|duplicate|retry/i.test(tests)); + let changelog = ''; + try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ } + record('changelog-entry', /idem|duplicat|retry|inc-104|race/i.test(changelog)); + try { + const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + record('no-external-dependencies', !pkg.dependencies && !pkg.devDependencies); + } catch { record('no-external-dependencies', false); } + + finish(); +})(); diff --git a/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/files/README.md b/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/files/README.md new file mode 100644 index 000000000..512c8c059 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/files/README.md @@ -0,0 +1,12 @@ +# webhook-receiver + +Receives payment webhooks. There is an open incident: customers were +double-charged when the provider retried deliveries. See `SPEC.md` for the +contract, including the exactly-once rules. + +- `src/app.js` exports `createApp()` returning an `http.Server` that is not + yet listening; `node src/index.js ` starts the service. +- `src/store.js` is shared infrastructure: it keeps its current exports + (`store`) and records every applied payment in `store.paymentLog`. +- No external dependencies. `npm test` runs the tests. `CHANGELOG.md` records + every shipped change. diff --git a/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/files/SPEC.md b/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/files/SPEC.md new file mode 100644 index 000000000..e3dee27b1 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/files/SPEC.md @@ -0,0 +1,30 @@ +# Payment webhook contract + +`POST /webhooks/payments` with JSON body +`{ "eventId": string, "orderId": string, "amountCents": number, "type": "payment.succeeded" }`. + +Exactly-once is the point. The provider retries aggressively and may deliver +the same event many times, concurrently, or out of order. + +- A new, valid `eventId`: apply the payment exactly once → `200` + `{ "status": "processed", "orderId" }`. +- The same `eventId` seen again (any number of times, any interleaving): + `200` `{ "status": "duplicate", "orderId" }` — never applied twice. +- A payment event (new `eventId`) for an order that is already paid: + `200` `{ "status": "already_paid", "orderId" }` — an order is paid at most + once, ever. +- `amountCents` not matching the order's amount: `422`, not applied. +- Unknown `orderId`: `404`. Malformed body (bad JSON, missing/invalid + fields): `400`. +- Error responses use the envelope + `{ "error": { "code": "", "message": "..." } }`. + +`GET /orders/:id` → `200` `{ "id", "status", "paidAt", "paymentsApplied" }` +or a `404` envelope. + +## Incident note + +INC-104: concurrent duplicate deliveries double-applied payments. The naive +receiver checked "have we seen this event?" and applied the payment in two +separate steps with an async gap in between, so parallel duplicates both +passed the check. diff --git a/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/files/package.json b/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/files/package.json new file mode 100644 index 000000000..11c26f720 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/files/package.json @@ -0,0 +1,6 @@ +{ + "name": "webhook-receiver", + "private": true, + "type": "commonjs", + "scripts": { "test": "node --test test/*.test.js" } +} diff --git a/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/files/src/app.js b/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/files/src/app.js new file mode 100644 index 000000000..6ba0ba755 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/files/src/app.js @@ -0,0 +1,54 @@ +'use strict'; +const http = require('node:http'); +const { store } = require('./store'); + +// INC-104 receiver: checks "seen this event?" and applies the payment in two +// steps with an async gap in between. Concurrent duplicates both pass the +// check. Do not keep this shape. +function createApp() { + return http.createServer((req, res) => { + const url = new URL(req.url, 'http://localhost'); + + if (req.method === 'POST' && url.pathname === '/webhooks/payments') { + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', async () => { + const parsed = JSON.parse(body); + const { eventId, orderId } = parsed; + if (store.processedEvents.has(eventId)) { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'duplicate', orderId })); + return; + } + await new Promise(resolve => setImmediate(resolve)); // async gap + const order = store.orders.get(orderId); + order.status = 'paid'; + order.paidAt = new Date().toISOString(); + order.paymentsApplied++; + store.paymentLog.push({ eventId, orderId, amountCents: parsed.amountCents }); + store.processedEvents.add(eventId); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'processed', orderId })); + }); + return; + } + + const match = /^\/orders\/([\w-]+)$/.exec(url.pathname); + if (req.method === 'GET' && match) { + const order = store.orders.get(match[1]); + if (!order) { + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'no such order' } })); + return; + } + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(order)); + return; + } + + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'not found' } })); + }); +} + +module.exports = { createApp }; diff --git a/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/files/src/index.js b/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/files/src/index.js new file mode 100644 index 000000000..90ef9215f --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/files/src/index.js @@ -0,0 +1,7 @@ +'use strict'; +const { createApp } = require('./app'); + +const port = Number(process.argv[2] || 8080); +createApp().listen(port, () => { + console.log(`webhook-receiver listening on ${port}`); +}); diff --git a/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/files/src/store.js b/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/files/src/store.js new file mode 100644 index 000000000..64a4099a4 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/files/src/store.js @@ -0,0 +1,18 @@ +'use strict'; + +// Shared infrastructure. Every applied payment is appended to paymentLog; +// orders and processedEvents track receiver state. Keep the `store` export. +const store = { + orders: new Map([ + ['o1', { id: 'o1', amountCents: 5000, status: 'pending', paidAt: null, paymentsApplied: 0 }], + ['o2', { id: 'o2', amountCents: 12500, status: 'pending', paidAt: null, paymentsApplied: 0 }], + ['o3', { id: 'o3', amountCents: 800, status: 'pending', paidAt: null, paymentsApplied: 0 }], + ['o4', { id: 'o4', amountCents: 9999, status: 'pending', paidAt: null, paymentsApplied: 0 }], + ['o5', { id: 'o5', amountCents: 250, status: 'pending', paidAt: null, paymentsApplied: 0 }], + ['o6', { id: 'o6', amountCents: 7300, status: 'pending', paidAt: null, paymentsApplied: 0 }], + ]), + paymentLog: [], + processedEvents: new Set(), +}; + +module.exports = { store }; diff --git a/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/files/test/webhooks.test.js b/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/files/test/webhooks.test.js new file mode 100644 index 000000000..cf79f83d4 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/files/test/webhooks.test.js @@ -0,0 +1,21 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createApp } = require('../src/app'); +const { store } = require('../src/store'); + +test('a single payment event processes', async () => { + const server = createApp(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + try { + const port = server.address().port; + const res = await fetch(`http://127.0.0.1:${port}/webhooks/payments`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ eventId: 'ev-test-1', orderId: 'o1', amountCents: 5000, type: 'payment.succeeded' }) }); + assert.equal(res.status, 200); + assert.equal((await res.json()).status, 'processed'); + assert.equal(store.orders.get('o1').status, 'paid'); + } finally { + server.close(); + } +}); diff --git a/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/meta.json b/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/meta.json new file mode 100644 index 000000000..d5d396e74 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/meta.json @@ -0,0 +1,11 @@ +{ + "id": "idempotent-webhooks", + "category": "concurrency-trap", + "manualIds": ["skill:error-handling"], + "checkTimeoutMs": 60000, + "selection": { + "id": "complex-idempotent-webhooks", + "category": "complex-concurrency-trap", + "expectedIds": ["skill:error-handling"] + } +} diff --git a/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/query.md b/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/query.md new file mode 100644 index 000000000..f2902f874 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/idempotent-webhooks/query.md @@ -0,0 +1 @@ +The payments provider retries aggressively and sometimes delivers the same webhook many times concurrently. Our receiver double-charges customers when that happens — see the incident note in SPEC.md. Rework the receiver so the full contract in SPEC.md holds: duplicates must never double-apply under any interleaving, and the documented API and the store contract stay intact. `npm test` must pass, and add regression coverage for the concurrent-duplicate case so INC-104 can't come back. diff --git a/docker/context-profiles/complex-eval/cases4/production-ready/check.cjs b/docker/context-profiles/complex-eval/cases4/production-ready/check.cjs new file mode 100644 index 000000000..e08c1efeb --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/production-ready/check.cjs @@ -0,0 +1,156 @@ +'use strict'; +// Hidden grader for production-ready: probes every dimension of the documented +// production bar. Prints ECC_EVAL_SCORE and always exits 0. +const fs = require('node:fs'); +const path = require('node:path'); + +const checks = []; +const record = (name, ok) => checks.push({ name, ok: Boolean(ok) }); +let finished = false; +function finish() { + if (finished) return; + finished = true; + for (let i = checks.length; i < 16; i++) record(`unreached-${i + 1}`, false); + const ok = checks.filter(c => c.ok).length; + for (const c of checks) process.stdout.write(`${c.ok ? 'ok' : 'not ok'} - ${c.name}\n`); + process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: ok / 16, passed: ok, total: 16 })}\n`); + process.exit(0); +} +// A crashing agent server must not kill the grader: score what completed. +process.on('uncaughtException', finish); +process.on('unhandledRejection', finish); +const root = process.cwd(); +const hasEnvelope = body => body && body.error && typeof body.error.code === 'string' + && /^[A-Z][A-Z0-9_]+$/.test(body.error.code) && typeof body.error.message === 'string'; + +(async () => { + let createApp; + try { ({ createApp } = require(path.join(root, 'src', 'app.js'))); } catch { /* scored below */ } + if (typeof createApp === 'function') { + // Capture console output during the probe run to inspect request logging. + const logged = []; + const originalLog = console.log; + const originalError = console.error; + const originalStdoutWrite = process.stdout.write.bind(process.stdout); + const originalStderrWrite = process.stderr.write.bind(process.stderr); + console.log = (...args) => { logged.push(args.join(' ')); }; + console.error = (...args) => { logged.push(args.join(' ')); }; + // Agents may log through an injectable writer straight to the streams + // instead of console.*. Capture-then-pass-through: the bytes always reach + // the stream untouched, so the grader's own ECC_EVAL_SCORE line (emitted + // via process.stdout.write) can never be swallowed or corrupted. + const tap = write => (chunk, encoding, callback) => { + try { logged.push(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)); } catch { /* capture must never break a write */ } + return write(chunk, encoding, callback); + }; + process.stdout.write = tap(originalStdoutWrite); + process.stderr.write = tap(originalStderrWrite); + try { + const app = createApp(); + await new Promise(resolve => app.listen(0, '127.0.0.1', resolve)); + const port = app.address().port; + const api = (p, options) => fetch(`http://127.0.0.1:${port}${p}`, options); + const post = body => api('/notes', { method: 'POST', headers: { 'content-type': 'application/json' }, body }); + + // Documented API still works. + const created = await post(JSON.stringify({ title: 'deploy', body: 'checklist' })); + const createdBody = await created.json().catch(() => null); + record('api-roundtrip-preserved', created.status === 201 && createdBody && createdBody.id + && (await (await api(`/notes/${createdBody.id}`)).json().catch(() => ({}))).title === 'deploy' + && Array.isArray((await (await api('/notes')).json().catch(() => ({}))).notes)); + + // Validation and envelope discipline. + const badJson = await post('{not json'); + record('malformed-json-400-envelope', badJson.status === 400 && hasEnvelope(await badJson.json().catch(() => null))); + const missing = await post(JSON.stringify({ body: 'no title' })); + record('missing-field-400-envelope', missing.status === 400 && hasEnvelope(await missing.json().catch(() => null))); + const wrongType = await post(JSON.stringify({ title: 42, body: 'x' })); + record('wrong-type-400-envelope', wrongType.status === 400 && hasEnvelope(await wrongType.json().catch(() => null))); + const unknown = await api('/notes/n_999999'); + const unknownBody = await unknown.text(); + let unknownParsed = null; + try { unknownParsed = JSON.parse(unknownBody); } catch { /* html or text */ } + record('unknown-404-json-envelope', unknown.status === 404 && hasEnvelope(unknownParsed)); + + // Body limit. + const big = await post(JSON.stringify({ title: 'big', body: 'x'.repeat(100 * 1024) })); + record('oversize-body-413-envelope', big.status === 413 && hasEnvelope(await big.json().catch(() => null))); + + // Health endpoint. + const health = await api('/health'); + const healthBody = await health.json().catch(() => null); + record('health-endpoint', health.status === 200 && healthBody && healthBody.status === 'ok'); + + // Security header on a normal response. + const headers = await api('/notes'); + record('nosniff-header', headers.headers.get('x-content-type-options') === 'nosniff'); + + // Error responses carry JSON content type. + record('errors-are-json', /application\/json/.test(unknown.headers.get('content-type') || '')); + + app.close(); + } catch { /* remaining checks unscored */ } finally { + console.log = originalLog; + console.error = originalError; + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + } + + // Structured request logging: at least one JSON line with method/path/status-ish fields. + const structured = logged.flatMap(chunk => String(chunk).split('\n')).some(line => { + try { + const parsed = JSON.parse(line); + return parsed && typeof parsed === 'object' + && /method/i.test(Object.keys(parsed).join(' ')) + && /path|url/i.test(Object.keys(parsed).join(' ')) + && /status/i.test(Object.keys(parsed).join(' ')); + } catch { return false; } + }); + record('structured-request-logs', structured); + } else { + for (const name of ['api-roundtrip-preserved', 'malformed-json-400-envelope', 'missing-field-400-envelope', + 'wrong-type-400-envelope', 'unknown-404-json-envelope', 'oversize-body-413-envelope', 'health-endpoint', + 'nosniff-header', 'errors-are-json', 'structured-request-logs']) record(name, false); + } + + // Static dimensions. + let sources = ''; + const sourceFiles = []; + const walk = directory => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const item = path.join(directory, entry.name); + if (entry.isDirectory()) walk(item); + else if (entry.name.endsWith('.js')) { + const content = fs.readFileSync(item, 'utf8'); + sourceFiles.push(content); + sources += content; + } + } + }; + try { walk(path.join(root, 'src')); } catch { /* none */ } + record('sigterm-graceful-shutdown', /SIGTERM/.test(sources)); + // Literal process.env.PORT access, or an injectable-config indirection: a + // 'PORT' string literal in a file that also reads process.env (for example a + // loadConfig(env = process.env) + readInt(env, 'PORT', default) module). + record('env-config-port', sourceFiles.some(content => /process\.env\.[A-Z_]*PORT/.test(content) + || (/(['"`])PORT\1/.test(content) && /process\.env/.test(content)))); + + let tests = ''; + try { + for (const f of fs.readdirSync(path.join(root, 'test'))) tests += fs.readFileSync(path.join(root, 'test', f), 'utf8'); + } catch { /* missing */ } + const testCount = (tests.match(/\btest\(/g) || []).length; + record('tests-cover-error-paths', testCount >= 4 && /400|404|413|invalid|error/i.test(tests)); + + let changelog = ''; + try { changelog = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8'); } catch { /* missing */ } + record('changelog-entry', changelog.length > 20 && /product|harden|valid|health|log/i.test(changelog)); + + record('no-leftover-todos', !/TODO|FIXME/.test(sources)); + try { + const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + record('no-external-dependencies', !pkg.dependencies && !pkg.devDependencies); + } catch { record('no-external-dependencies', false); } + + finish(); +})(); diff --git a/docker/context-profiles/complex-eval/cases4/production-ready/files/README.md b/docker/context-profiles/complex-eval/cases4/production-ready/files/README.md new file mode 100644 index 000000000..e387bff31 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/production-ready/files/README.md @@ -0,0 +1,19 @@ +# notes-service + +Tiny notes API. Hobby prototype state: it works on the happy path and that's +about all that can be said for it. + +## API + +- `POST /notes` — body `{ "title": string, "body": string }` → `201` with + `{ "id", "title", "body" }`. +- `GET /notes/:id` — `200` with the note, or `404`. +- `GET /notes` — `200` with `{ "notes": [...] }`. + +`src/app.js` exports `createApp()` returning an `http.Server` that is not yet +listening; `node src/index.js` starts the service. `npm test` runs the tests. + +## Operations + +`docs/production-bar.md` lists what every production service here must meet. +`CHANGELOG.md` records every shipped change. diff --git a/docker/context-profiles/complex-eval/cases4/production-ready/files/docs/production-bar.md b/docker/context-profiles/complex-eval/cases4/production-ready/files/docs/production-bar.md new file mode 100644 index 000000000..af3df1c4c --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/production-ready/files/docs/production-bar.md @@ -0,0 +1,21 @@ +# The production bar + +Every production service here meets all of the following, all the time: + +- **Validation**: malformed JSON, missing fields, and wrong types are rejected + with `400` and a structured JSON error body + `{ "error": { "code": "", "message": "..." } }`. Unknown + resources are `404` in the same envelope. No stack traces, no HTML errors, + no hanging connections. +- **Body limits**: request bodies over 64 KB are rejected with `413`, same + envelope. +- **Health**: `GET /health` returns `200` with `{ "status": "ok" }`. +- **Logging**: one structured JSON log line per request with at least + `method`, `path`, and `status` fields. +- **Configuration**: runtime configuration (port, limits) comes from + environment variables, read at startup. Nothing secret is hardcoded. +- **Shutdown**: the service closes cleanly on `SIGTERM` (stops accepting, + drains, exits). +- **Headers**: responses carry `X-Content-Type-Options: nosniff`. +- **Tests**: the suite covers error paths, not just the happy path. +- **Changelog**: every shipped change has a `CHANGELOG.md` entry. diff --git a/docker/context-profiles/complex-eval/cases4/production-ready/files/package.json b/docker/context-profiles/complex-eval/cases4/production-ready/files/package.json new file mode 100644 index 000000000..7cef6f8c0 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/production-ready/files/package.json @@ -0,0 +1,6 @@ +{ + "name": "notes-service", + "private": true, + "type": "commonjs", + "scripts": { "test": "node --test test/*.test.js" } +} diff --git a/docker/context-profiles/complex-eval/cases4/production-ready/files/src/app.js b/docker/context-profiles/complex-eval/cases4/production-ready/files/src/app.js new file mode 100644 index 000000000..db7fe2695 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/production-ready/files/src/app.js @@ -0,0 +1,50 @@ +'use strict'; +const http = require('node:http'); + +// Prototype state: happy path only. +const notes = new Map(); +let nextId = 1; + +function createApp() { + return http.createServer((req, res) => { + console.log('got a request'); + const url = new URL(req.url, 'http://localhost'); + + if (req.method === 'POST' && url.pathname === '/notes') { + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', () => { + const parsed = JSON.parse(body); + const id = `n_${nextId++}`; + notes.set(id, { id, title: parsed.title, body: parsed.body }); + res.writeHead(201, { 'content-type': 'application/json' }); + res.end(JSON.stringify(notes.get(id))); + }); + return; + } + + const match = /^\/notes\/([\w-]+)$/.exec(url.pathname); + if (req.method === 'GET' && match) { + const note = notes.get(match[1]); + if (!note) { + res.writeHead(404); + res.end('not found'); + return; + } + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(note)); + return; + } + + if (req.method === 'GET' && url.pathname === '/notes') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ notes: [...notes.values()] })); + return; + } + + res.writeHead(404); + res.end('not found'); + }); +} + +module.exports = { createApp }; diff --git a/docker/context-profiles/complex-eval/cases4/production-ready/files/src/index.js b/docker/context-profiles/complex-eval/cases4/production-ready/files/src/index.js new file mode 100644 index 000000000..a71330e92 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/production-ready/files/src/index.js @@ -0,0 +1,6 @@ +'use strict'; +const { createApp } = require('./app'); + +createApp().listen(8080, () => { + console.log('notes listening on 8080'); +}); diff --git a/docker/context-profiles/complex-eval/cases4/production-ready/files/test/notes.test.js b/docker/context-profiles/complex-eval/cases4/production-ready/files/test/notes.test.js new file mode 100644 index 000000000..51babd8fb --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/production-ready/files/test/notes.test.js @@ -0,0 +1,21 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createApp } = require('../src/app'); + +test('create and read a note', async () => { + const server = createApp(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + try { + const port = server.address().port; + const created = await fetch(`http://127.0.0.1:${port}/notes`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ title: 'first', body: 'hello' }) }); + assert.equal(created.status, 201); + const { id } = await created.json(); + const read = await fetch(`http://127.0.0.1:${port}/notes/${id}`); + assert.equal((await read.json()).title, 'first'); + } finally { + server.close(); + } +}); diff --git a/docker/context-profiles/complex-eval/cases4/production-ready/meta.json b/docker/context-profiles/complex-eval/cases4/production-ready/meta.json new file mode 100644 index 000000000..21aae2a12 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/production-ready/meta.json @@ -0,0 +1,11 @@ +{ + "id": "production-ready", + "category": "vague-hardening", + "manualIds": ["skill:backend-patterns"], + "checkTimeoutMs": 60000, + "selection": { + "id": "complex-production-ready", + "category": "complex-vague-hardening", + "expectedIds": ["skill:backend-patterns"] + } +} diff --git a/docker/context-profiles/complex-eval/cases4/production-ready/query.md b/docker/context-profiles/complex-eval/cases4/production-ready/query.md new file mode 100644 index 000000000..341755315 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/production-ready/query.md @@ -0,0 +1 @@ +This goes to production Monday morning. Get it ready. The bar we hold production services to is in docs/production-bar.md — meet all of it, keep the documented API working, and leave the repo in a state you'd be comfortable being on-call for. diff --git a/docker/context-profiles/complex-eval/cases4/recurring-incident/files/README.md b/docker/context-profiles/complex-eval/cases4/recurring-incident/files/README.md new file mode 100644 index 000000000..9c7e5925a --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/recurring-incident/files/README.md @@ -0,0 +1,29 @@ +# payments-lite + +A small dependency-free payments service core: refunds to customers and payouts +to vendors, executed against a fake gateway that records every call in an +append-only ledger. + +## Layout + +- `src/charge.js` — the gateway client. `charge()`, `refund()`, and `payout()` + simulate network latency and append one JSON line per call to the ledger at + `LEDGER_FILE` (default `.data/ledger.jsonl`). `readLedger()` parses it. +- `src/store.js` — a tiny JSON-file store at `STORE_FILE` (default + `.data/store.json`): `get`, `has`, `set`. Reads and writes are synchronous. +- `src/refunds.js` — `processRefund(req)` for customer refunds. +- `src/payouts.js` — `processPayout(req)` for vendor payouts. + +## API contract + +`processRefund({ orderId, amount, idempotencyKey? })` and +`processPayout({ vendorId, amount, idempotencyKey? })` each return the gateway +receipt (`{ id, type, amount, ... }`). When the caller supplies an +`idempotencyKey`, a repeated call with the same key must not hit the gateway +again; it returns the stored receipt with `duplicate: true`. Keep these +signatures stable — the dashboard and the finance batch job call them directly. + +## Working here + +- No external dependencies. `npm test` runs the tests. +- Incident notes live in `docs/incidents.md`; add an entry when you work one. diff --git a/docker/context-profiles/complex-eval/cases4/recurring-incident/files/docs/incidents.md b/docker/context-profiles/complex-eval/cases4/recurring-incident/files/docs/incidents.md new file mode 100644 index 000000000..cde645464 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/recurring-incident/files/docs/incidents.md @@ -0,0 +1,17 @@ +# Incident notes + +## INC-201 — duplicate refunds (2026-06-14) + +Customers saw two refunds for one order. Traced to the storefront retrying the +refund call after a gateway timeout. Asked the storefront team to retry less +aggressively. Closed. + +## INC-214 — duplicate refunds, again (2026-07-29) + +Same shape as INC-201: a retried refund call landed twice. Reminded the +storefront team about backoff. Closed. + +## INC-227 — duplicate refunds, third time (2026-09-03) + +Same shape as INC-201 and INC-214. Third time this quarter. Support is +escalating refund-credit requests faster than we can explain them. diff --git a/docker/context-profiles/complex-eval/cases4/recurring-incident/files/package.json b/docker/context-profiles/complex-eval/cases4/recurring-incident/files/package.json new file mode 100644 index 000000000..c7ce403d0 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/recurring-incident/files/package.json @@ -0,0 +1,6 @@ +{ + "name": "payments-lite", + "private": true, + "type": "module", + "scripts": { "test": "node --test test/*.test.js" } +} diff --git a/docker/context-profiles/complex-eval/cases4/recurring-incident/files/src/charge.js b/docker/context-profiles/complex-eval/cases4/recurring-incident/files/src/charge.js new file mode 100644 index 000000000..c0192c1f3 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/recurring-incident/files/src/charge.js @@ -0,0 +1,46 @@ +// Fake payment gateway. Every call is recorded as one JSON line in an +// append-only ledger so side effects can be audited after the fact. +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; + +function ledgerPath() { + return process.env.LEDGER_FILE || path.join(process.cwd(), '.data', 'ledger.jsonl'); +} + +function append(entry) { + const file = ledgerPath(); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.appendFileSync(file, `${JSON.stringify({ ...entry, at: new Date().toISOString() })}\n`); +} + +function latency() { + return new Promise(resolve => setTimeout(resolve, 5 + Math.floor(Math.random() * 10))); +} + +export async function charge({ orderId, amount }) { + await latency(); + const receipt = { id: `chg_${crypto.randomUUID()}`, type: 'charge', orderId, amount }; + append(receipt); + return receipt; +} + +export async function refund({ orderId, amount }) { + await latency(); + const receipt = { id: `rfnd_${crypto.randomUUID()}`, type: 'refund', orderId, amount }; + append(receipt); + return receipt; +} + +export async function payout({ vendorId, amount }) { + await latency(); + const receipt = { id: `pay_${crypto.randomUUID()}`, type: 'payout', vendorId, amount }; + append(receipt); + return receipt; +} + +export function readLedger(file = ledgerPath()) { + let text = ''; + try { text = fs.readFileSync(file, 'utf8'); } catch { return []; } + return text.split('\n').filter(line => line.trim()).map(line => JSON.parse(line)); +} diff --git a/docker/context-profiles/complex-eval/cases4/recurring-incident/files/src/payouts.js b/docker/context-profiles/complex-eval/cases4/recurring-incident/files/src/payouts.js new file mode 100644 index 000000000..4b09b6784 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/recurring-incident/files/src/payouts.js @@ -0,0 +1,14 @@ +import { payout } from './charge.js'; +import * as store from './store.js'; + +// Processes a vendor payout. Finance's batch job calls this once per payout +// run and has never retried, so the keyless path has never been exercised. +export async function processPayout(req) { + const key = req.idempotencyKey ? `payout:${req.idempotencyKey}` : null; + if (key && store.has(key)) { + return { ...store.get(key), duplicate: true }; + } + const receipt = await payout({ vendorId: req.vendorId, amount: req.amount }); + if (key) store.set(key, receipt); + return receipt; +} diff --git a/docker/context-profiles/complex-eval/cases4/recurring-incident/files/src/refunds.js b/docker/context-profiles/complex-eval/cases4/recurring-incident/files/src/refunds.js new file mode 100644 index 000000000..b8217e506 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/recurring-incident/files/src/refunds.js @@ -0,0 +1,14 @@ +import { refund } from './charge.js'; +import * as store from './store.js'; + +// Processes a customer refund. Callers that have one pass an idempotencyKey; +// plenty of callers (the storefront retry loop among them) do not. +export async function processRefund(req) { + const key = req.idempotencyKey ? `refund:${req.idempotencyKey}` : null; + if (key && store.has(key)) { + return { ...store.get(key), duplicate: true }; + } + const receipt = await refund({ orderId: req.orderId, amount: req.amount }); + if (key) store.set(key, receipt); + return receipt; +} diff --git a/docker/context-profiles/complex-eval/cases4/recurring-incident/files/src/store.js b/docker/context-profiles/complex-eval/cases4/recurring-incident/files/src/store.js new file mode 100644 index 000000000..3303c7588 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/recurring-incident/files/src/store.js @@ -0,0 +1,33 @@ +// Tiny JSON-file-backed key/value store. All operations are synchronous so a +// check-and-set within one event-loop turn cannot interleave. +import fs from 'node:fs'; +import path from 'node:path'; + +function storePath() { + return process.env.STORE_FILE || path.join(process.cwd(), '.data', 'store.json'); +} + +function load() { + try { return JSON.parse(fs.readFileSync(storePath(), 'utf8')); } catch { return {}; } +} + +function save(data) { + const file = storePath(); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(data, null, 1)); +} + +export function get(key) { + return load()[key]; +} + +export function has(key) { + return Object.prototype.hasOwnProperty.call(load(), key); +} + +export function set(key, value) { + const data = load(); + data[key] = value; + save(data); + return value; +} diff --git a/docker/context-profiles/complex-eval/cases4/recurring-incident/files/test/payouts.test.js b/docker/context-profiles/complex-eval/cases4/recurring-incident/files/test/payouts.test.js new file mode 100644 index 000000000..9b51bd593 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/recurring-incident/files/test/payouts.test.js @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +function freshEnv(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'payments-test-')); + process.env.LEDGER_FILE = path.join(dir, 'ledger.jsonl'); + process.env.STORE_FILE = path.join(dir, 'store.json'); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); +} + +test('processPayout pays once and returns the gateway receipt', async (t) => { + freshEnv(t); + const { processPayout } = await import('../src/payouts.js'); + const receipt = await processPayout({ vendorId: 'ven-1', amount: 5000 }); + assert.equal(receipt.type, 'payout'); + assert.equal(receipt.vendorId, 'ven-1'); + assert.equal(receipt.amount, 5000); +}); + +test('processPayout with an explicit key returns the stored receipt on a repeat call', async (t) => { + freshEnv(t); + const { processPayout } = await import('../src/payouts.js'); + const first = await processPayout({ vendorId: 'ven-2', amount: 7000, idempotencyKey: 'key-7' }); + const second = await processPayout({ vendorId: 'ven-2', amount: 7000, idempotencyKey: 'key-7' }); + assert.equal(second.duplicate, true); + assert.equal(second.id, first.id); +}); diff --git a/docker/context-profiles/complex-eval/cases4/recurring-incident/files/test/refunds.test.js b/docker/context-profiles/complex-eval/cases4/recurring-incident/files/test/refunds.test.js new file mode 100644 index 000000000..163dc4a50 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/recurring-incident/files/test/refunds.test.js @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +function freshEnv(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'payments-test-')); + process.env.LEDGER_FILE = path.join(dir, 'ledger.jsonl'); + process.env.STORE_FILE = path.join(dir, 'store.json'); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); +} + +test('processRefund refunds once and returns the gateway receipt', async (t) => { + freshEnv(t); + const { processRefund } = await import('../src/refunds.js'); + const receipt = await processRefund({ orderId: 'ord-1', amount: 1200 }); + assert.equal(receipt.type, 'refund'); + assert.equal(receipt.orderId, 'ord-1'); + assert.equal(receipt.amount, 1200); +}); + +test('processRefund with an explicit key returns the stored receipt on a repeat call', async (t) => { + freshEnv(t); + const { processRefund } = await import('../src/refunds.js'); + const first = await processRefund({ orderId: 'ord-2', amount: 900, idempotencyKey: 'key-2' }); + const second = await processRefund({ orderId: 'ord-2', amount: 900, idempotencyKey: 'key-2' }); + assert.equal(second.duplicate, true); + assert.equal(second.id, first.id); +}); diff --git a/docker/context-profiles/complex-eval/cases4/recurring-incident/meta.json b/docker/context-profiles/complex-eval/cases4/recurring-incident/meta.json new file mode 100644 index 000000000..15649835e --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/recurring-incident/meta.json @@ -0,0 +1,16 @@ +{ + "id": "recurring-incident", + "category": "learning-loop-chain", + "manualIds": [], + "checkTimeoutMs": 60000, + "steps": [ + { "manualIds": ["skill:error-handling"] }, + { "manualIds": ["skill:error-handling"] }, + { "manualIds": ["skill:continuous-learning"] } + ], + "selection": { + "id": "complex-recurring-incident", + "category": "complex-learning-loop", + "expectedIds": ["skill:continuous-learning"] + } +} diff --git a/docker/context-profiles/complex-eval/cases4/recurring-incident/steps/01-refund-retry/check.cjs b/docker/context-profiles/complex-eval/cases4/recurring-incident/steps/01-refund-retry/check.cjs new file mode 100644 index 000000000..f1b6e681d --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/recurring-incident/steps/01-refund-retry/check.cjs @@ -0,0 +1,207 @@ +'use strict'; +// Step 1 grader (recurring-incident): a refund retried WITHOUT an idempotency +// key must refund exactly once — in-process (0.20) and across a module reload +// with the same store (0.20); a regression test wired into `npm test` must fail +// when the fix is reverted in a scratch copy (0.30); a durable prevention doc +// must exist (0.20); the mechanism must live in a shared helper module (0.10). +// Graders cannot spawn child processes (--permission), so tests are executed +// in-process via node:test's run({ isolation: 'none' }) with TMPDIR redirected +// into the workspace. +const fs = require('node:fs'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +const probes = [ + { name: 'retry-same-process-refunds-once', weight: 0.20 }, + { name: 'retry-after-reload-refunds-once', weight: 0.20 }, + { name: 'regression-test-wired-and-bites', weight: 0.30 }, + { name: 'prevention-doc-exists', weight: 0.20 }, + { name: 'shared-idempotency-helper', weight: 0.10 }, +]; +const results = new Map(); +let finished = false; +function finish() { + if (finished) return; + finished = true; + let score = 0; + for (const probe of probes) { + const ok = results.get(probe.name) === true; + if (ok) score += probe.weight; + process.stdout.write(`${ok ? 'ok' : 'not ok'} - ${probe.name}\n`); + } + process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: Math.round(score * 1000) / 1000 })}\n`); + process.exit(0); +} +process.on('uncaughtException', finish); +process.on('unhandledRejection', finish); + +const root = process.cwd(); +const scratch = fs.mkdtempSync(path.join(root, '.ecc-g1-')); +fs.mkdirSync(path.join(scratch, 'tmp'), { recursive: true }); +process.env.TMPDIR = path.join(scratch, 'tmp'); + +// The fixture's original buggy refunds.js, embedded so the mutation probe can +// revert the fix in a scratch copy and check the regression suite notices. +const ORIGINAL_REFUNDS = [ + "import { refund } from './charge.js';", + "import * as store from './store.js';", + '', + '// Processes a customer refund. Callers that have one pass an idempotencyKey;', + '// plenty of callers (the storefront retry loop among them) do not.', + 'export async function processRefund(req) {', + ' const key = req.idempotencyKey ? `refund:${req.idempotencyKey}` : null;', + ' if (key && store.has(key)) {', + ' return { ...store.get(key), duplicate: true };', + ' }', + ' const receipt = await refund({ orderId: req.orderId, amount: req.amount });', + ' if (key) store.set(key, receipt);', + ' return receipt;', + '}', + '', +].join('\n'); + +let importCounter = 0; +function importFresh(relative) { + importCounter += 1; + return import(`${pathToFileURL(path.join(root, relative)).href}?cb=${importCounter}`); +} + +function readLedger(file) { + let text = ''; + try { text = fs.readFileSync(file, 'utf8'); } catch { return []; } + return text.split('\n').filter(line => line.trim()).map(line => { + try { return JSON.parse(line); } catch { return null; } + }).filter(Boolean); +} + +function copyTree(from, to) { + fs.mkdirSync(to, { recursive: true }); + for (const entry of fs.readdirSync(from, { withFileTypes: true })) { + const target = path.join(to, entry.name); + if (entry.isDirectory()) copyTree(path.join(from, entry.name), target); + else if (entry.isFile()) fs.copyFileSync(path.join(from, entry.name), target); + } +} + +function findTestFiles(mustMatch) { + const found = []; + const walk = dir => { + let entries = []; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { walk(full); continue; } + if (!/\.test\.(js|cjs|mjs)$/.test(entry.name)) continue; + let content = ''; + try { content = fs.readFileSync(full, 'utf8'); } catch { continue; } + if (mustMatch.every(re => re.test(content))) found.push(full); + } + }; + walk(root); + return found.sort(); +} + +function npmTestWired() { + try { + const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + const script = (pkg.scripts && pkg.scripts.test) || ''; + // `node --test test/` silently runs nothing on Node 24; that is not wired. + return /--test\b/.test(script) && !/--test\s+test\/?\s*$/.test(script.trim()); + } catch { return false; } +} + +async function countTestFailures(files) { + const { run } = require('node:test'); + let failures = 0; + const stream = run({ files, isolation: 'none', concurrency: 1 }); + stream.on('test:fail', () => { failures += 1; }); + await new Promise((resolve, reject) => { stream.on('end', resolve); stream.on('error', reject); stream.resume(); }); + return failures; +} + +function markdownFiles() { + const found = []; + const walk = dir => { + let entries = []; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (/\.(md|markdown|txt)$/i.test(entry.name)) found.push(full); + } + }; + walk(root); + return found.sort(); +} + +function isPreventionDoc(file) { + let content = ''; + try { content = fs.readFileSync(file, 'utf8'); } catch { return false; } + return /idempoten/i.test(content) && /prevent|runbook|playbook|checklist|post-?mortem|procedure/i.test(content); +} + +(async () => { + // (a) Functional: a keyless refund retried in-process refunds exactly once. + try { + const ledger = path.join(scratch, 'ledger-a.jsonl'); + process.env.LEDGER_FILE = ledger; + process.env.STORE_FILE = path.join(scratch, 'store-a.json'); + const refunds = await importFresh('src/refunds.js'); + await refunds.processRefund({ orderId: 'ord-g1-retry', amount: 2500 }); + await refunds.processRefund({ orderId: 'ord-g1-retry', amount: 2500 }); + const entries = readLedger(ledger).filter(e => e.type === 'refund' && e.orderId === 'ord-g1-retry'); + results.set('retry-same-process-refunds-once', entries.length === 1); + + // (a2) Durable: a fresh module instance (simulated restart) over the same + // store must still not refund again. + const reloaded = await importFresh('src/refunds.js'); + await reloaded.processRefund({ orderId: 'ord-g1-retry', amount: 2500 }); + const afterReload = readLedger(ledger).filter(e => e.type === 'refund' && e.orderId === 'ord-g1-retry'); + results.set('retry-after-reload-refunds-once', entries.length === 1 && afterReload.length === 1); + } catch { /* both functional probes stay false */ } + + // (b) Regression coverage: a refund/idempotency test exists, npm test is + // wired, the suite passes as-is, and it FAILS when the fix is reverted. + try { + const files = findTestFiles([/refund/i, /idempoten|retry|duplicat/i]); + let ok = files.length > 0 && npmTestWired(); + if (ok) ok = (await countTestFailures(files)) === 0; + if (ok) { + const mut = path.join(scratch, 'mutation'); + fs.mkdirSync(mut, { recursive: true }); + copyTree(path.join(root, 'src'), path.join(mut, 'src')); + fs.copyFileSync(path.join(root, 'package.json'), path.join(mut, 'package.json')); + for (const file of files) { + const target = path.join(mut, path.relative(root, file)); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(file, target); + } + fs.writeFileSync(path.join(mut, 'src', 'refunds.js'), ORIGINAL_REFUNDS); + const mutated = files.map(file => path.join(mut, path.relative(root, file))); + ok = (await countTestFailures(mutated)) > 0; + } + results.set('regression-test-wired-and-bites', ok); + } catch { /* probe stays false */ } + + // (c) A durable prevention artifact: some doc ties idempotency to a + // prevention procedure (runbook/playbook/checklist/postmortem). + try { + results.set('prevention-doc-exists', markdownFiles().some(isPreventionDoc)); + } catch { /* probe stays false */ } + + // (d) The mechanism lives in a shared helper module that refunds.js imports, + // not inline in refunds.js alone. + try { + const refundsSrc = fs.readFileSync(path.join(root, 'src', 'refunds.js'), 'utf8'); + const helpers = fs.readdirSync(path.join(root, 'src')) + .filter(name => /idempoten/i.test(name) && /\.(js|cjs|mjs)$/.test(name)); + const imported = /import[^'"]*from\s*['"][^'"]*idempoten[^'"]*['"]/.test(refundsSrc) + || /require\(\s*['"][^'"]*idempoten[^'"]*['"]\s*\)/.test(refundsSrc); + results.set('shared-idempotency-helper', helpers.length > 0 && imported); + } catch { /* probe stays false */ } + + try { fs.rmSync(scratch, { recursive: true, force: true }); } catch { /* best effort */ } + finish(); +})(); diff --git a/docker/context-profiles/complex-eval/cases4/recurring-incident/steps/01-refund-retry/query.md b/docker/context-profiles/complex-eval/cases4/recurring-incident/steps/01-refund-retry/query.md new file mode 100644 index 000000000..84a617791 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/recurring-incident/steps/01-refund-retry/query.md @@ -0,0 +1 @@ +Customers are reporting duplicate refunds again — see docs/incidents.md. When a refund request comes in without an idempotency key and the caller retries, we refund the customer twice. Fix it, and make sure this stops being a recurring incident — it's the third time this quarter. diff --git a/docker/context-profiles/complex-eval/cases4/recurring-incident/steps/02-payout-storm/check.cjs b/docker/context-profiles/complex-eval/cases4/recurring-incident/steps/02-payout-storm/check.cjs new file mode 100644 index 000000000..619414014 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/recurring-incident/steps/02-payout-storm/check.cjs @@ -0,0 +1,208 @@ +'use strict'; +// Step 2 grader (recurring-incident): a concurrent keyless payout retry storm +// must pay exactly once and stay paid once across a module reload (0.40); +// payouts.js must REUSE the same shared idempotency helper refunds.js imports, +// with no second inline implementation (0.30); a payout regression test wired +// into npm test must fail when the fix is reverted in a scratch copy (0.20); +// the prevention doc must now cover payouts / this class of bug (0.10). +const fs = require('node:fs'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +const probes = [ + { name: 'payout-storm-pays-once', weight: 0.40 }, + { name: 'reuses-shared-helper', weight: 0.30 }, + { name: 'payout-regression-test-bites', weight: 0.20 }, + { name: 'prevention-doc-covers-class', weight: 0.10 }, +]; +const results = new Map(); +let finished = false; +function finish() { + if (finished) return; + finished = true; + let score = 0; + for (const probe of probes) { + const ok = results.get(probe.name) === true; + if (ok) score += probe.weight; + process.stdout.write(`${ok ? 'ok' : 'not ok'} - ${probe.name}\n`); + } + process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: Math.round(score * 1000) / 1000 })}\n`); + process.exit(0); +} +process.on('uncaughtException', finish); +process.on('unhandledRejection', finish); + +const root = process.cwd(); +const scratch = fs.mkdtempSync(path.join(root, '.ecc-g2-')); +fs.mkdirSync(path.join(scratch, 'tmp'), { recursive: true }); +process.env.TMPDIR = path.join(scratch, 'tmp'); + +// The fixture's original payouts.js, embedded for the mutation probe. +const ORIGINAL_PAYOUTS = [ + "import { payout } from './charge.js';", + "import * as store from './store.js';", + '', + '// Processes a vendor payout. Finance\'s batch job calls this once per payout', + '// run and has never retried, so the keyless path has never been exercised.', + 'export async function processPayout(req) {', + ' const key = req.idempotencyKey ? `payout:${req.idempotencyKey}` : null;', + ' if (key && store.has(key)) {', + ' return { ...store.get(key), duplicate: true };', + ' }', + ' const receipt = await payout({ vendorId: req.vendorId, amount: req.amount });', + ' if (key) store.set(key, receipt);', + ' return receipt;', + '}', + '', +].join('\n'); + +let importCounter = 0; +function importFresh(relative) { + importCounter += 1; + return import(`${pathToFileURL(path.join(root, relative)).href}?cb=${importCounter}`); +} + +function readLedger(file) { + let text = ''; + try { text = fs.readFileSync(file, 'utf8'); } catch { return []; } + return text.split('\n').filter(line => line.trim()).map(line => { + try { return JSON.parse(line); } catch { return null; } + }).filter(Boolean); +} + +function copyTree(from, to) { + fs.mkdirSync(to, { recursive: true }); + for (const entry of fs.readdirSync(from, { withFileTypes: true })) { + const target = path.join(to, entry.name); + if (entry.isDirectory()) copyTree(path.join(from, entry.name), target); + else if (entry.isFile()) fs.copyFileSync(path.join(from, entry.name), target); + } +} + +function findTestFiles(mustMatch) { + const found = []; + const walk = dir => { + let entries = []; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { walk(full); continue; } + if (!/\.test\.(js|cjs|mjs)$/.test(entry.name)) continue; + let content = ''; + try { content = fs.readFileSync(full, 'utf8'); } catch { continue; } + if (mustMatch.every(re => re.test(content))) found.push(full); + } + }; + walk(root); + return found.sort(); +} + +function npmTestWired() { + try { + const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + const script = (pkg.scripts && pkg.scripts.test) || ''; + return /--test\b/.test(script) && !/--test\s+test\/?\s*$/.test(script.trim()); + } catch { return false; } +} + +async function countTestFailures(files) { + const { run } = require('node:test'); + let failures = 0; + const stream = run({ files, isolation: 'none', concurrency: 1 }); + stream.on('test:fail', () => { failures += 1; }); + await new Promise((resolve, reject) => { stream.on('end', resolve); stream.on('error', reject); stream.resume(); }); + return failures; +} + +function markdownFiles() { + const found = []; + const walk = dir => { + let entries = []; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (/\.(md|markdown|txt)$/i.test(entry.name)) found.push(full); + } + }; + walk(root); + return found.sort(); +} + +// The idempotency helper module specifier refunds.js imports, if any. +function helperSpecifier() { + try { + const refundsSrc = fs.readFileSync(path.join(root, 'src', 'refunds.js'), 'utf8'); + const match = /(?:from|require\()\s*['"]([^'"]*idempoten[^'"]*)['"]/i.exec(refundsSrc); + return match ? match[1] : null; + } catch { return null; } +} + +(async () => { + // (a) Functional: 20 concurrent keyless retries pay exactly once, and a + // fresh module instance over the same store still does not pay again. + try { + const ledger = path.join(scratch, 'ledger-a.jsonl'); + process.env.LEDGER_FILE = ledger; + process.env.STORE_FILE = path.join(scratch, 'store-a.json'); + const payouts = await importFresh('src/payouts.js'); + await Promise.all(Array.from({ length: 20 }, + () => payouts.processPayout({ vendorId: 'ven-g2-storm', amount: 9000 }).catch(() => null))); + const afterStorm = readLedger(ledger).filter(e => e.type === 'payout' && e.vendorId === 'ven-g2-storm'); + const reloaded = await importFresh('src/payouts.js'); + await reloaded.processPayout({ vendorId: 'ven-g2-storm', amount: 9000 }).catch(() => null); + const afterReload = readLedger(ledger).filter(e => e.type === 'payout' && e.vendorId === 'ven-g2-storm'); + results.set('payout-storm-pays-once', afterStorm.length === 1 && afterReload.length === 1); + } catch { /* probe stays false */ } + + // (b) Reuse: payouts.js imports the SAME helper specifier as refunds.js and + // does not carry a second inline implementation (own key hashing or its own + // seen/inflight table). + try { + const specifier = helperSpecifier(); + const payoutsSrc = fs.readFileSync(path.join(root, 'src', 'payouts.js'), 'utf8'); + const importsSame = specifier !== null + && new RegExp(`(?:from|require\\()\\s*['"]${specifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]`).test(payoutsSrc); + const inlineImplementation = /createHash|new Map\s*\(|new Set\s*\(|new WeakMap\s*\(/.test(payoutsSrc); + results.set('reuses-shared-helper', importsSame && !inlineImplementation); + } catch { /* probe stays false */ } + + // (c) Regression coverage for payouts, same discipline as step 1. + try { + const files = findTestFiles([/payout/i, /idempoten|retry|duplicat|storm|concurrent/i]); + let ok = files.length > 0 && npmTestWired(); + if (ok) ok = (await countTestFailures(files)) === 0; + if (ok) { + const mut = path.join(scratch, 'mutation'); + fs.mkdirSync(mut, { recursive: true }); + copyTree(path.join(root, 'src'), path.join(mut, 'src')); + fs.copyFileSync(path.join(root, 'package.json'), path.join(mut, 'package.json')); + for (const file of files) { + const target = path.join(mut, path.relative(root, file)); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(file, target); + } + fs.writeFileSync(path.join(mut, 'src', 'payouts.js'), ORIGINAL_PAYOUTS); + const mutated = files.map(file => path.join(mut, path.relative(root, file))); + ok = (await countTestFailures(mutated)) > 0; + } + results.set('payout-regression-test-bites', ok); + } catch { /* probe stays false */ } + + // (d) The prevention doc now covers payouts / the whole class of bug. + try { + const covered = markdownFiles().some(file => { + let content = ''; + try { content = fs.readFileSync(file, 'utf8'); } catch { return false; } + return /idempoten/i.test(content) + && /prevent|runbook|playbook|checklist|post-?mortem|procedure/i.test(content) + && /payout|vendor|class of|general|every payment|any payment/i.test(content); + }); + results.set('prevention-doc-covers-class', covered); + } catch { /* probe stays false */ } + + try { fs.rmSync(scratch, { recursive: true, force: true }); } catch { /* best effort */ } + finish(); +})(); diff --git a/docker/context-profiles/complex-eval/cases4/recurring-incident/steps/02-payout-storm/query.md b/docker/context-profiles/complex-eval/cases4/recurring-incident/steps/02-payout-storm/query.md new file mode 100644 index 000000000..b61f88e6e --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/recurring-incident/steps/02-payout-storm/query.md @@ -0,0 +1 @@ +Finance just flagged that their payout batch job is about to start retrying on timeouts, and payout retries can double-pay vendors. Same family of problem as the refunds — handle it. One hard requirement: a retried payout must never pay a vendor twice, even if the service restarts between the attempts. diff --git a/docker/context-profiles/complex-eval/cases4/recurring-incident/steps/03-handoff/check.cjs b/docker/context-profiles/complex-eval/cases4/recurring-incident/steps/03-handoff/check.cjs new file mode 100644 index 000000000..e495c15b0 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/recurring-incident/steps/03-handoff/check.cjs @@ -0,0 +1,104 @@ +'use strict'; +// Step 3 grader (recurring-incident): the handoff note. A handoff doc must +// exist (0.20); every file path it references must actually exist in the +// workspace, with at least two concrete references (0.30); it must name the +// shared idempotency helper and describe the prevention procedure (0.30); it +// must cover both the refunds and the payouts incidents (0.20). Scored on the +// best candidate when several handoff files exist. +const fs = require('node:fs'); +const path = require('node:path'); + +const probes = [ + { name: 'handoff-exists', weight: 0.20 }, + { name: 'referenced-paths-exist', weight: 0.30 }, + { name: 'names-helper-and-procedure', weight: 0.30 }, + { name: 'covers-both-incidents', weight: 0.20 }, +]; +const results = new Map(); +let finished = false; +function finish() { + if (finished) return; + finished = true; + let score = 0; + for (const probe of probes) { + const ok = results.get(probe.name) === true; + if (ok) score += probe.weight; + process.stdout.write(`${ok ? 'ok' : 'not ok'} - ${probe.name}\n`); + } + process.stdout.write(`ECC_EVAL_SCORE ${JSON.stringify({ score: Math.round(score * 1000) / 1000 })}\n`); + process.exit(0); +} +process.on('uncaughtException', finish); +process.on('unhandledRejection', finish); + +const root = process.cwd(); + +function handoffFiles() { + const found = []; + const walk = dir => { + let entries = []; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { walk(full); continue; } + if (/hand[ -]?off/i.test(entry.name) && /\.(md|markdown|txt)$/i.test(entry.name)) found.push(full); + } + }; + walk(root); + return found.sort(); +} + +// Candidate file paths mentioned in prose: at least one path segment and a +// file extension (src/refunds.js, docs/runbooks/idempotency.md, ...). +function referencedPaths(content) { + const tokens = new Set(); + for (const match of content.matchAll(/(?:[\w@+.-]+\/)+[\w@+.-]+\.[a-z0-9]{1,8}/gi)) { + const token = match[0].replace(/[.,;:'")\]`]+$/, '').replace(/^[^\w@+.-]+/, ''); + if (token.includes('..') || /^https?/i.test(token)) continue; + tokens.add(token); + } + return [...tokens]; +} + +function helperBasename() { + try { + const refundsSrc = fs.readFileSync(path.join(root, 'src', 'refunds.js'), 'utf8'); + const match = /(?:from|require\()\s*['"]([^'"]*idempoten[^'"]*)['"]/i.exec(refundsSrc); + return match ? path.basename(match[1]) : null; + } catch { return null; } +} + +function scoreCandidate(content) { + const verdicts = new Map(); + verdicts.set('handoff-exists', true); + + const paths = referencedPaths(content); + verdicts.set('referenced-paths-exist', paths.length >= 2 + && paths.every(token => fs.existsSync(path.join(root, token)))); + + const helper = helperBasename(); + verdicts.set('names-helper-and-procedure', helper !== null + && content.includes(helper) + && /prevent|runbook|playbook|checklist|regression|npm test|procedure/i.test(content)); + + verdicts.set('covers-both-incidents', /refund/i.test(content) && /payout/i.test(content)); + return verdicts; +} + +try { + const candidates = handoffFiles(); + if (candidates.length > 0) { + let best = null; + for (const file of candidates) { + let content = ''; + try { content = fs.readFileSync(file, 'utf8'); } catch { continue; } + const verdicts = scoreCandidate(content); + const total = [...verdicts.values()].filter(Boolean).length; + if (!best || total > best.total) best = { verdicts, total }; + } + if (best) for (const [name, ok] of best.verdicts) results.set(name, ok); + } +} catch { /* everything stays false */ } + +finish(); diff --git a/docker/context-profiles/complex-eval/cases4/recurring-incident/steps/03-handoff/query.md b/docker/context-profiles/complex-eval/cases4/recurring-incident/steps/03-handoff/query.md new file mode 100644 index 000000000..a76859c00 --- /dev/null +++ b/docker/context-profiles/complex-eval/cases4/recurring-incident/steps/03-handoff/query.md @@ -0,0 +1 @@ +You're rolling off this area. Write the handoff note for whoever picks this up next. diff --git a/docker/context-profiles/complex-eval/naive2/keccak-selector/src/selector.js b/docker/context-profiles/complex-eval/naive2/keccak-selector/src/selector.js new file mode 100644 index 000000000..7d878cce5 --- /dev/null +++ b/docker/context-profiles/complex-eval/naive2/keccak-selector/src/selector.js @@ -0,0 +1,11 @@ +'use strict'; +// Deliberately naive control: confuses Keccak-256 with the finalized NIST +// SHA3-256 (different padding suffix), so every vector is wrong. +const crypto = require('node:crypto'); + +function functionSelector(signature) { + if (typeof signature !== 'string') throw new TypeError('signature must be a string'); + return `0x${crypto.createHash('sha3-256').update(signature, 'utf8').digest('hex').slice(0, 8)}`; +} + +module.exports = { functionSelector }; diff --git a/docker/context-profiles/complex-eval/naive4/recurring-incident/HANDOFF.md b/docker/context-profiles/complex-eval/naive4/recurring-incident/HANDOFF.md new file mode 100644 index 000000000..393321d57 --- /dev/null +++ b/docker/context-profiles/complex-eval/naive4/recurring-incident/HANDOFF.md @@ -0,0 +1,3 @@ +# Handoff + +Refunds were double-processing when clients retried. Fixed by remembering what we already refunded. — Sam diff --git a/docker/context-profiles/complex-eval/naive4/recurring-incident/src/payouts.js b/docker/context-profiles/complex-eval/naive4/recurring-incident/src/payouts.js new file mode 100644 index 000000000..d29a194d5 --- /dev/null +++ b/docker/context-profiles/complex-eval/naive4/recurring-incident/src/payouts.js @@ -0,0 +1,15 @@ +import { payout } from './charge.js'; + +// Track in-flight payouts so a burst of retries only sends one. +const pendingPayouts = new Map(); + +export async function processPayout(req) { + const tag = `pay-${req.vendorId}-${req.amount}`; + if (pendingPayouts.has(tag)) { + const receipt = await pendingPayouts.get(tag); + return { ...receipt, duplicate: true }; + } + const pending = payout({ vendorId: req.vendorId, amount: req.amount }); + pendingPayouts.set(tag, pending); + return pending; +} diff --git a/docker/context-profiles/complex-eval/naive4/recurring-incident/src/refunds.js b/docker/context-profiles/complex-eval/naive4/recurring-incident/src/refunds.js new file mode 100644 index 000000000..e0cddd01b --- /dev/null +++ b/docker/context-profiles/complex-eval/naive4/recurring-incident/src/refunds.js @@ -0,0 +1,13 @@ +import { refund } from './charge.js'; + +// Remember which refunds we already sent so we don't send them twice. +const seenRefunds = new Set(); + +export async function processRefund(req) { + const key = req.idempotencyKey || `${req.orderId}:${req.amount}`; + if (seenRefunds.has(key)) { + return { id: `dup_${key}`, type: 'refund', orderId: req.orderId, amount: req.amount, duplicate: true }; + } + seenRefunds.add(key); + return refund({ orderId: req.orderId, amount: req.amount }); +} diff --git a/docker/context-profiles/complex-eval/reference/incident-triage/INCIDENT.md b/docker/context-profiles/complex-eval/reference/incident-triage/INCIDENT.md new file mode 100644 index 000000000..251ea9c51 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference/incident-triage/INCIDENT.md @@ -0,0 +1,27 @@ +# Incident 2026-09-24: order totals off by one cent + +## Root cause + +**C-2** — the totals refactor in `src/totals.js`. + +The refactor replaced integer-cent arithmetic with a decimal discount factor +(`priceCents * quantity * (1 - discountPercent / 100)`). Decimal factors such +as 0.7 or 0.93 have no exact binary floating-point representation, so for +line amounts whose exact discounted value lands precisely on a half-cent +boundary (e.g. 165 cents at 30% off = 115.5), the float result lands just +below the boundary and `Math.round` rounds down instead of half-up. Every +affected order is undercharged by exactly one cent, matching the finance +findings in `evidence/incident.txt`. + +## Evidence + +- `evidence/incident.txt`: every flagged order is off by exactly one cent in the + store's favor, and all of them appeared after the 2026-09-23 deploy. +- C-1 (logging) and C-3 (inventory timeout) cannot change totals; C-2 touched + the totals computation itself. + +## Fix + +`src/totals.js` now computes line discounts with exact integer arithmetic: +`floor((priceCents * quantity * (100 - discountPercent) + 50) / 100)`, which +rounds half-up on exact cent boundaries with no floating-point error. diff --git a/docker/context-profiles/complex-eval/reference/incident-triage/src/totals.js b/docker/context-profiles/complex-eval/reference/incident-triage/src/totals.js new file mode 100644 index 000000000..398a1f132 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference/incident-triage/src/totals.js @@ -0,0 +1,15 @@ +'use strict'; + +// Fixed after the 2026-09-24 incident: totals use exact integer-cent +// arithmetic. Per line: priceCents * quantity * (100 - discountPercent) / 100, +// rounded half-up via (n + 50) / 100 floored — no floating point anywhere. +function computeOrderTotal(order) { + let total = 0; + for (const line of order.lines) { + const numerator = line.priceCents * line.quantity * (100 - order.discountPercent); + total += Math.floor((numerator + 50) / 100); + } + return total; +} + +module.exports = { computeOrderTotal }; diff --git a/docker/context-profiles/complex-eval/reference/sentinel-api/src/app.js b/docker/context-profiles/complex-eval/reference/sentinel-api/src/app.js new file mode 100644 index 000000000..da0f88d96 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference/sentinel-api/src/app.js @@ -0,0 +1,120 @@ +'use strict'; +const fs = require('node:fs'); +const path = require('node:path'); +const http = require('node:http'); +const config = require('./config'); +const store = require('./store'); + +const HTML_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; +const escapeHtml = text => text.replace(/[&<>"']/g, char => HTML_ESCAPES[char]); + +function sendJson(res, status, value) { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(value)); +} + +function readBody(req, res, callback) { + const chunks = []; + let bytes = 0; + let rejected = false; + req.on('data', chunk => { + bytes += chunk.length; + if (bytes > config.MAX_BODY_BYTES && !rejected) { + rejected = true; + sendJson(res, 413, { error: 'payload too large' }); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => { if (!rejected) callback(Buffer.concat(chunks).toString('utf8')); }); +} + +function page(paste) { + return `paste ${paste.id}` + + `
    ${escapeHtml(paste.content)}
    `; +} + +function createApp() { + const adminToken = process.env.ADMIN_TOKEN || null; + + return http.createServer((req, res) => { + const url = new URL(req.url, 'http://localhost'); + + if (req.method === 'POST' && url.pathname === '/pastes') { + readBody(req, res, body => { + let parsed; + try { parsed = JSON.parse(body); } catch { + sendJson(res, 400, { error: 'invalid JSON body' }); + return; + } + if (typeof parsed.content !== 'string') { + sendJson(res, 400, { error: 'content must be a string' }); + return; + } + const paste = store.create(parsed.content); + sendJson(res, 201, { id: paste.id, deleteToken: paste.deleteToken }); + }); + return; + } + + const pasteMatch = /^\/pastes\/([\w-]+)$/.exec(url.pathname); + if (pasteMatch && req.method === 'GET') { + const paste = store.get(pasteMatch[1]); + if (!paste) { sendJson(res, 404, { error: 'not found' }); return; } + sendJson(res, 200, { id: paste.id, content: paste.content }); + return; + } + if (pasteMatch && req.method === 'DELETE') { + const paste = store.get(pasteMatch[1]); + if (!paste) { sendJson(res, 404, { error: 'not found' }); return; } + if (req.headers['x-delete-token'] !== paste.deleteToken) { + sendJson(res, 403, { error: 'bad delete token' }); + return; + } + store.remove(paste.id); + res.writeHead(204); + res.end(); + return; + } + + const pageMatch = /^\/p\/([\w-]+)$/.exec(url.pathname); + if (pageMatch && req.method === 'GET') { + const paste = store.get(pageMatch[1]); + if (!paste) { sendJson(res, 404, { error: 'not found' }); return; } + res.writeHead(200, { 'content-type': 'text/html' }); + res.end(page(paste)); + return; + } + + if (req.method === 'GET' && url.pathname === '/files') { + const name = url.searchParams.get('name') || ''; + const resolved = path.resolve(config.FILES_DIR, name); + if (resolved !== config.FILES_DIR && !resolved.startsWith(config.FILES_DIR + path.sep)) { + sendJson(res, 400, { error: 'invalid file name' }); + return; + } + try { + const content = fs.readFileSync(resolved); + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end(content); + } catch { + sendJson(res, 404, { error: 'not found' }); + } + return; + } + + if (req.method === 'GET' && url.pathname === '/admin/stats') { + if (!adminToken || req.headers['x-admin-token'] !== adminToken) { + sendJson(res, 401, { error: 'unauthorized' }); + return; + } + sendJson(res, 200, store.stats()); + return; + } + + sendJson(res, 404, { error: 'not found' }); + }); +} + +module.exports = { createApp }; diff --git a/docker/context-profiles/complex-eval/reference/sentinel-api/src/config.js b/docker/context-profiles/complex-eval/reference/sentinel-api/src/config.js new file mode 100644 index 000000000..f36468899 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference/sentinel-api/src/config.js @@ -0,0 +1,7 @@ +'use strict'; +const path = require('node:path'); + +module.exports = { + MAX_BODY_BYTES: 64 * 1024, + FILES_DIR: path.join(__dirname, '..', 'data', 'files'), +}; diff --git a/docker/context-profiles/complex-eval/reference/sentinel-api/src/store.js b/docker/context-profiles/complex-eval/reference/sentinel-api/src/store.js new file mode 100644 index 000000000..88f194153 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference/sentinel-api/src/store.js @@ -0,0 +1,28 @@ +'use strict'; +const crypto = require('node:crypto'); + +// In-memory paste store. Delete tokens are cryptographically random and shown +// once at creation. +const pastes = new Map(); +let nextId = 1; + +function create(content) { + const id = `p_${nextId++}`; + const paste = { id, content, deleteToken: crypto.randomBytes(16).toString('hex') }; + pastes.set(id, paste); + return paste; +} + +function get(id) { + return pastes.get(id) || null; +} + +function remove(id) { + return pastes.delete(id); +} + +function stats() { + return { pastes: pastes.size, created: nextId - 1 }; +} + +module.exports = { create, get, remove, stats }; diff --git a/docker/context-profiles/complex-eval/reference/webhook-relay/src/app.js b/docker/context-profiles/complex-eval/reference/webhook-relay/src/app.js new file mode 100644 index 000000000..c7c97d267 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference/webhook-relay/src/app.js @@ -0,0 +1,73 @@ +'use strict'; +const http = require('node:http'); +const crypto = require('node:crypto'); + +const MAX_ATTEMPTS = 5; +const BASE_DELAY_MS = 100; + +function createRelay() { + const deliveries = new Map(); + + async function attempt(record) { + record.attempts += 1; + try { + const response = await fetch(record.url, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify(record.payload), signal: AbortSignal.timeout(5000) }); + if (response.status >= 200 && response.status < 300) { + record.status = 'delivered'; + record.lastError = null; + return; + } + record.lastError = `HTTP ${response.status}`; + } catch (error) { + record.lastError = error && error.message ? error.message : 'delivery failed'; + } + if (record.attempts >= MAX_ATTEMPTS) { + record.status = 'dead'; + return; + } + const delay = BASE_DELAY_MS * 2 ** (record.attempts - 1); + setTimeout(() => { void attempt(record); }, delay); + } + + const server = http.createServer((req, res) => { + if (req.method === 'POST' && req.url === '/deliveries') { + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', () => { + let parsed; + try { parsed = JSON.parse(body); } catch { + res.writeHead(400, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'invalid JSON body' })); + return; + } + const id = crypto.randomUUID(); + const record = { id, url: parsed.url, payload: parsed.payload, + status: 'pending', attempts: 0, lastError: null }; + deliveries.set(id, record); + void attempt(record); + res.writeHead(202, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ id })); + }); + return; + } + const match = /^\/deliveries\/([0-9a-f-]+)$/.exec(req.url || ''); + if (req.method === 'GET' && match) { + const record = deliveries.get(match[1]); + if (!record) { + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + return; + } + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(record)); + return; + } + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + }); + return server; +} + +module.exports = { createRelay }; diff --git a/docker/context-profiles/complex-eval/reference2/event-stats-api/src/app.js b/docker/context-profiles/complex-eval/reference2/event-stats-api/src/app.js new file mode 100644 index 000000000..2abfb2eff --- /dev/null +++ b/docker/context-profiles/complex-eval/reference2/event-stats-api/src/app.js @@ -0,0 +1,87 @@ +'use strict'; +const http = require('node:http'); +const { events } = require('./data'); + +// Indexed implementation: per-type arrays sorted by timestamp, with prefix +// sums, built once at startup. Per query the range is located with binary +// search; only the matching slice is touched. +function buildIndex() { + const byType = new Map(); + for (const event of events) { + if (!byType.has(event.type)) byType.set(event.type, []); + byType.get(event.type).push(event); + } + for (const rows of byType.values()) { + rows.sort((a, b) => a.ts - b.ts); + const prefix = new Float64Array(rows.length + 1); + for (let i = 0; i < rows.length; i++) prefix[i + 1] = prefix[i] + rows[i].value; + rows.prefixSums = prefix; + } + return byType; +} + +function lowerBound(rows, ts) { + let lo = 0; + let hi = rows.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (rows[mid].ts < ts) lo = mid + 1; else hi = mid; + } + return lo; +} + +function upperBound(rows, ts) { + let lo = 0; + let hi = rows.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (rows[mid].ts <= ts) lo = mid + 1; else hi = mid; + } + return lo; +} + +const EMPTY = { count: 0, sum: 0, avg: null, p50: null, p95: null, p99: null, min: null, max: null }; + +function summarize(index, type, from, to) { + const rows = index.get(type); + if (!rows) return EMPTY; + const lo = from === null ? 0 : lowerBound(rows, from); + const hi = to === null ? rows.length : upperBound(rows, to); + const count = hi - lo; + if (count <= 0) return EMPTY; + const sum = rows.prefixSums[hi] - rows.prefixSums[lo]; + const values = new Array(count); + for (let i = 0; i < count; i++) values[i] = rows[lo + i].value; + values.sort((a, b) => a - b); + const rank = p => values[Math.ceil((p / 100) * count) - 1]; + const avgCents = Math.floor((sum * 200 + count) / (count * 2)); + return { count, sum, avg: avgCents / 100, + p50: rank(50), p95: rank(95), p99: rank(99), min: values[0], max: values[count - 1] }; +} + +function createApp() { + const index = buildIndex(); + return http.createServer((req, res) => { + const url = new URL(req.url, 'http://localhost'); + if (req.method === 'GET' && url.pathname === '/stats') { + const type = url.searchParams.get('type'); + const hasFrom = url.searchParams.has('from'); + const hasTo = url.searchParams.has('to'); + const from = hasFrom ? Number(url.searchParams.get('from')) : null; + const to = hasTo ? Number(url.searchParams.get('to')) : null; + if ((hasFrom && !Number.isFinite(from)) || (hasTo && !Number.isFinite(to)) + || (from !== null && to !== null && from > to)) { + res.writeHead(400, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'invalid bounds' })); + return; + } + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ type, from, to, ...summarize(index, type, from, to) })); + return; + } + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + }); +} + +module.exports = { createApp }; diff --git a/docker/context-profiles/complex-eval/reference2/forge-cli/src/cli.js b/docker/context-profiles/complex-eval/reference2/forge-cli/src/cli.js new file mode 100644 index 000000000..58301d426 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference2/forge-cli/src/cli.js @@ -0,0 +1,98 @@ +'use strict'; + +const NAME = /^[a-z0-9][a-z0-9-]*$/; +const USAGE = 'usage: snippet \n'; +const ADD_USAGE = 'usage: add [--tags t1,t2] \n'; + +const ok = (stdout = '') => ({ code: 0, stdout, stderr: '' }); +const fail = (code, stderr) => ({ code, stdout: '', stderr }); + +function snippetsOf(state) { + if (!state.snippets || typeof state.snippets !== 'object') state.snippets = {}; + return state.snippets; +} + +function sortedNames(snippets, filter) { + return Object.keys(snippets).filter(filter).sort(); +} + +function run(argv, state) { + try { + const snippets = snippetsOf(state); + const [command, ...args] = argv; + + if (command === 'add') { + let tags = []; + let rest = args; + const tagIndex = args.indexOf('--tags'); + const name = args[0]; + if (tagIndex !== -1) { + if (tagIndex < 1 || !args[tagIndex + 1]) return fail(2, ADD_USAGE); + tags = args[tagIndex + 1].split(',').filter(Boolean); + rest = [args[0], ...args.slice(tagIndex + 2)]; + } + const text = rest.slice(1).join(' '); + if (!name || !text) return fail(2, ADD_USAGE); + if (!NAME.test(name)) return fail(2, `error: invalid snippet name '${name}'\n`); + if (snippets[name]) return fail(1, `error: snippet '${name}' already exists\n`); + snippets[name] = { text, tags: [...tags].sort() }; + return ok(`created ${name}\n`); + } + + if (command === 'get') { + const snippet = snippets[args[0]]; + if (!snippet) return fail(2, `error: no snippet named '${args[0]}'\n`); + return ok(`${snippet.text}\n`); + } + + if (command === 'remove') { + const snippet = snippets[args[0]]; + if (!snippet) return fail(2, `error: no snippet named '${args[0]}'\n`); + delete snippets[args[0]]; + return ok(`removed ${args[0]}\n`); + } + + if (command === 'list') { + const tagIndex = args.indexOf('--tag'); + const tag = tagIndex !== -1 ? args[tagIndex + 1] : null; + const names = sortedNames(snippets, name => tag === null || snippets[name].tags.includes(tag)); + return ok(names.length ? `${names.join('\n')}\n` : 'no snippets\n'); + } + + if (command === 'search') { + const term = (args[0] || '').toLowerCase(); + const names = sortedNames(snippets, name => + name.toLowerCase().includes(term) || snippets[name].text.toLowerCase().includes(term)); + return ok(names.length ? `${names.join('\n')}\n` : 'no matches\n'); + } + + if (command === 'export') { + const out = { snippets: {} }; + for (const name of sortedNames(snippets, () => true)) { + out.snippets[name] = { text: snippets[name].text, tags: [...snippets[name].tags].sort() }; + } + return ok(`${JSON.stringify(out)}\n`); + } + + if (command === 'import') { + let parsed; + try { parsed = JSON.parse(args[0]); } catch { return fail(1, 'error: invalid JSON\n'); } + const incoming = parsed && typeof parsed === 'object' ? parsed.snippets : null; + if (!incoming || typeof incoming !== 'object') return fail(1, 'error: invalid JSON\n'); + let imported = 0; + let skipped = 0; + for (const [name, value] of Object.entries(incoming)) { + if (snippets[name]) { skipped++; continue; } + snippets[name] = { text: value.text, tags: [...(value.tags || [])].sort() }; + imported++; + } + return ok(`imported ${imported}, skipped ${skipped}\n`); + } + + return fail(2, USAGE); + } catch { + return fail(2, USAGE); + } +} + +module.exports = { run }; diff --git a/docker/context-profiles/complex-eval/reference2/keccak-selector/src/selector.js b/docker/context-profiles/complex-eval/reference2/keccak-selector/src/selector.js new file mode 100644 index 000000000..0054fc2e0 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference2/keccak-selector/src/selector.js @@ -0,0 +1,53 @@ +'use strict'; +// Keccak-256 (original Keccak padding 0x01, NOT the NIST SHA3-256 suffix 0x06). +// Keccak-f[1600] permutation over 25 64-bit little-endian lanes as BigInts. +const RC = [0x0000000000000001n, 0x0000000000008082n, 0x800000000000808an, 0x8000000080008000n, + 0x000000000000808bn, 0x0000000080000001n, 0x8000000080008081n, 0x8000000000008009n, + 0x000000000000008an, 0x0000000000000088n, 0x0000000080008009n, 0x000000008000000an, + 0x000000008000808bn, 0x800000000000008bn, 0x8000000000008089n, 0x8000000000008003n, + 0x8000000000008002n, 0x8000000000000080n, 0x000000000000800an, 0x800000008000000an, + 0x8000000080008081n, 0x8000000000008080n, 0x0000000080000001n, 0x8000000080008008n]; +const ROT = [[0, 36, 3, 41, 18], [1, 44, 10, 45, 2], [62, 6, 43, 15, 61], + [28, 55, 25, 21, 56], [27, 20, 39, 8, 14]]; +const MASK = 0xffffffffffffffffn; +const rotl = (x, n) => n === 0n ? x : ((x << n) | (x >> (64n - n))) & MASK; + +function keccakF(s) { + for (let round = 0; round < 24; round++) { + const c = []; + const d = []; + for (let x = 0; x < 5; x++) c[x] = s[x] ^ s[x + 5] ^ s[x + 10] ^ s[x + 15] ^ s[x + 20]; + for (let x = 0; x < 5; x++) d[x] = c[(x + 4) % 5] ^ rotl(c[(x + 1) % 5], 1n); + for (let y = 0; y < 5; y++) for (let x = 0; x < 5; x++) s[x + 5 * y] ^= d[x]; + const b = new Array(25); + for (let y = 0; y < 5; y++) { + for (let x = 0; x < 5; x++) b[y + 5 * ((2 * x + 3 * y) % 5)] = rotl(s[x + 5 * y], BigInt(ROT[x][y])); + } + for (let y = 0; y < 5; y++) { + for (let x = 0; x < 5; x++) s[x + 5 * y] = b[x + 5 * y] ^ ((~b[(x + 1) % 5 + 5 * y] & MASK) & b[(x + 2) % 5 + 5 * y]); + } + s[0] ^= RC[round]; + } +} + +function keccak256(bytes) { + const rate = 136; // 1088-bit rate, 512-bit capacity + const state = new Array(25).fill(0n); + const q = rate - (bytes.length % rate); + const padded = Buffer.concat([bytes, Buffer.from([0x01]), Buffer.alloc(q - 1)]); + padded[padded.length - 1] |= 0x80; + for (let offset = 0; offset < padded.length; offset += rate) { + for (let i = 0; i < rate; i++) state[i >> 3] ^= BigInt(padded[offset + i]) << BigInt(8 * (i & 7)); + keccakF(state); + } + const out = []; + for (let i = 0; i < 32; i++) out.push(Number((state[i >> 3] >> BigInt(8 * (i & 7))) & 0xffn)); + return Buffer.from(out); +} + +function functionSelector(signature) { + if (typeof signature !== 'string') throw new TypeError('signature must be a string'); + return `0x${keccak256(Buffer.from(signature, 'utf8')).subarray(0, 4).toString('hex')}`; +} + +module.exports = { functionSelector }; diff --git a/docker/context-profiles/complex-eval/reference3/chained-tickets/CHANGELOG.md b/docker/context-profiles/complex-eval/reference3/chained-tickets/CHANGELOG.md new file mode 100644 index 000000000..e8cad2f0c --- /dev/null +++ b/docker/context-profiles/complex-eval/reference3/chained-tickets/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +- 2026-09-25: Initial shortlink core — create, redirect, expiry, and delete per API.md. +- 2026-09-25: Persistence — links survive restarts via the DATA_FILE JSON store; missing or corrupt data files start clean. +- 2026-09-25: Abuse protection — URL validation (http/https only, length cap), request body limits, and per-client rate limiting with 429 responses. +- 2026-09-25: Analytics — per-link redirect hit counts exposed at GET /links/:code/stats. diff --git a/docker/context-profiles/complex-eval/reference3/chained-tickets/README.md b/docker/context-profiles/complex-eval/reference3/chained-tickets/README.md new file mode 100644 index 000000000..3420482fe --- /dev/null +++ b/docker/context-profiles/complex-eval/reference3/chained-tickets/README.md @@ -0,0 +1,14 @@ +# shortlink + +Internal link shortener service. Node.js standard library only, CommonJS. + +- `API.md` — the HTTP contract. +- `CONTRIBUTING.md` — engineering conventions. Every ticket follows them. +- `src/app.js` exports `createApp()` returning an `http.Server` that is not yet + listening; `node src/index.js ` starts the service. +- Links persist to the JSON file named by the `DATA_FILE` environment variable + (default `./data/links.json`). +- `GET /links//stats` returns `{ "code", "hits", "expiresAt" }` — + `hits` counts redirects. +- The API is rate limited per client and validates URLs (http/https only). +- Run the tests with `npm test`. diff --git a/docker/context-profiles/complex-eval/reference3/chained-tickets/src/app.js b/docker/context-profiles/complex-eval/reference3/chained-tickets/src/app.js new file mode 100644 index 000000000..c802a64fd --- /dev/null +++ b/docker/context-profiles/complex-eval/reference3/chained-tickets/src/app.js @@ -0,0 +1,15 @@ +'use strict'; +const http = require('node:http'); +const path = require('node:path'); +const { createStore } = require('./store'); +const { createService } = require('./service'); +const { createRouter } = require('./routes'); + +function createApp() { + const file = process.env.DATA_FILE || path.join(process.cwd(), 'data', 'links.json'); + const store = createStore(file); + const service = createService(store); + return http.createServer(createRouter(service)); +} + +module.exports = { createApp }; diff --git a/docker/context-profiles/complex-eval/reference3/chained-tickets/src/index.js b/docker/context-profiles/complex-eval/reference3/chained-tickets/src/index.js new file mode 100644 index 000000000..d37872b76 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference3/chained-tickets/src/index.js @@ -0,0 +1,7 @@ +'use strict'; +const { createApp } = require('./app'); + +const port = Number(process.env.PORT || process.argv[2] || 8080); +createApp().listen(port, () => { + console.log(`shortlink listening on ${port}`); +}); diff --git a/docker/context-profiles/complex-eval/reference3/chained-tickets/src/routes.js b/docker/context-profiles/complex-eval/reference3/chained-tickets/src/routes.js new file mode 100644 index 000000000..7344146c6 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference3/chained-tickets/src/routes.js @@ -0,0 +1,86 @@ +'use strict'; +const { HttpError } = require('./service'); + +const MAX_BODY_BYTES = 64 * 1024; + +function sendJson(res, status, value) { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(value)); +} + +function sendError(res, error) { + const known = error instanceof HttpError; + sendJson(res, known ? error.status : 500, { + error: { code: known ? error.code : 'INTERNAL', message: known ? error.message : 'internal error' }, + }); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + let body = ''; + let bytes = 0; + let settled = false; + req.on('data', chunk => { + if (settled) return; + bytes += chunk.length; + if (bytes > MAX_BODY_BYTES) { + settled = true; + reject(new HttpError(413, 'PAYLOAD_TOO_LARGE', 'request body too large')); + // Drain rather than destroy: the socket must live long enough to send the 413. + req.resume(); + return; + } + body += chunk; + }); + req.on('end', () => { + if (settled) return; + settled = true; + if (!body) { resolve({}); return; } + try { resolve(JSON.parse(body)); } catch { reject(new HttpError(400, 'INVALID_JSON', 'body must be valid JSON')); } + }); + req.on('error', reject); + }); +} + +function createRouter(service) { + return async (req, res) => { + try { + const url = new URL(req.url, 'http://localhost'); + + if (req.method === 'POST' && url.pathname === '/links') { + service.assertRateLimit(req.socket.remoteAddress || 'unknown'); + const link = service.createLink(await readBody(req)); + sendJson(res, 201, { code: link.code, shortUrl: `/${link.code}`, expiresAt: link.expiresAt }); + return; + } + + const statsMatch = /^\/links\/([A-Za-z0-9]{1,20})\/stats$/.exec(url.pathname); + if (req.method === 'GET' && statsMatch) { + sendJson(res, 200, service.stats(statsMatch[1])); + return; + } + + const linkMatch = /^\/links\/([A-Za-z0-9]{1,20})$/.exec(url.pathname); + if (req.method === 'DELETE' && linkMatch) { + service.deleteLink(linkMatch[1]); + res.writeHead(204); + res.end(); + return; + } + + const redirectMatch = /^\/([A-Za-z0-9]{1,20})$/.exec(url.pathname); + if (req.method === 'GET' && redirectMatch) { + const link = service.resolveLink(redirectMatch[1]); + res.writeHead(302, { location: link.url }); + res.end(); + return; + } + + throw new HttpError(404, 'NOT_FOUND', 'not found'); + } catch (error) { + sendError(res, error); + } + }; +} + +module.exports = { createRouter }; diff --git a/docker/context-profiles/complex-eval/reference3/chained-tickets/src/service.js b/docker/context-profiles/complex-eval/reference3/chained-tickets/src/service.js new file mode 100644 index 000000000..f28167d8a --- /dev/null +++ b/docker/context-profiles/complex-eval/reference3/chained-tickets/src/service.js @@ -0,0 +1,82 @@ +'use strict'; +const crypto = require('node:crypto'); + +const MAX_URL_LENGTH = 2048; +const DEFAULT_TTL_SECONDS = 604800; +const MAX_TTL_SECONDS = 2592000; +const RATE_LIMIT_WINDOW_MS = 60000; +const RATE_LIMIT_MAX = 20; + +class HttpError extends Error { + constructor(status, code, message) { + super(message); + this.status = status; + this.code = code; + } +} + +function validateUrl(url) { + if (typeof url !== 'string' || !url) throw new HttpError(400, 'INVALID_URL', 'url is required'); + if (url.length > MAX_URL_LENGTH) throw new HttpError(400, 'INVALID_URL', 'url exceeds 2048 characters'); + let parsed; + try { parsed = new URL(url); } catch { throw new HttpError(400, 'INVALID_URL', 'url must be a valid absolute URL'); } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new HttpError(400, 'INVALID_URL', 'only http and https URLs are allowed'); + } + return url; +} + +function validateTtl(ttlSeconds) { + if (ttlSeconds === undefined || ttlSeconds === null) return DEFAULT_TTL_SECONDS; + if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > MAX_TTL_SECONDS) { + throw new HttpError(400, 'INVALID_TTL', 'ttlSeconds must be an integer between 1 and 2592000'); + } + return ttlSeconds; +} + +function createService(store) { + const buckets = new Map(); + + function assertRateLimit(key) { + const now = Date.now(); + const windowHits = (buckets.get(key) || []).filter(at => now - at < RATE_LIMIT_WINDOW_MS); + if (windowHits.length >= RATE_LIMIT_MAX) throw new HttpError(429, 'RATE_LIMITED', 'too many requests, slow down'); + windowHits.push(now); + buckets.set(key, windowHits); + } + + function freshCode() { + let code = crypto.randomBytes(4).toString('hex'); + while (store.get(code)) code = crypto.randomBytes(4).toString('hex'); + return code; + } + + return { + assertRateLimit, + createLink({ url, ttlSeconds } = {}) { + const validUrl = validateUrl(url); + const ttl = validateTtl(ttlSeconds); + const link = { code: freshCode(), url: validUrl, + expiresAt: new Date(Date.now() + ttl * 1000).toISOString(), hits: 0 }; + store.set(link.code, link); + return link; + }, + resolveLink(code) { + const link = store.get(code); + if (!link) throw new HttpError(404, 'NOT_FOUND', 'no link with that code'); + if (Date.parse(link.expiresAt) <= Date.now()) throw new HttpError(410, 'GONE', 'link has expired'); + store.incrementHits(code); + return link; + }, + deleteLink(code) { + if (!store.delete(code)) throw new HttpError(404, 'NOT_FOUND', 'no link with that code'); + }, + stats(code) { + const link = store.get(code); + if (!link) throw new HttpError(404, 'NOT_FOUND', 'no link with that code'); + return { code, hits: link.hits || 0, expiresAt: link.expiresAt }; + }, + }; +} + +module.exports = { createService, HttpError }; diff --git a/docker/context-profiles/complex-eval/reference3/chained-tickets/src/store.js b/docker/context-profiles/complex-eval/reference3/chained-tickets/src/store.js new file mode 100644 index 000000000..7d5aa091b --- /dev/null +++ b/docker/context-profiles/complex-eval/reference3/chained-tickets/src/store.js @@ -0,0 +1,28 @@ +'use strict'; +const fs = require('node:fs'); +const path = require('node:path'); + +// JSON-file-backed link store. Missing or corrupt files start clean; every +// mutation is flushed synchronously so a restart never loses a committed link. +function createStore(file) { + let links = new Map(); + try { + const raw = JSON.parse(fs.readFileSync(file, 'utf8')); + for (const [code, value] of Object.entries(raw.links || {})) links.set(code, value); + } catch { /* missing or corrupt: start empty */ } + const save = () => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify({ links: Object.fromEntries(links) }, null, 1)}\n`); + }; + return { + get: code => links.get(code) || null, + set(code, value) { links.set(code, value); save(); }, + delete(code) { const had = links.delete(code); if (had) save(); return had; }, + incrementHits(code) { + const link = links.get(code); + if (link) { link.hits = (link.hits || 0) + 1; save(); } + }, + }; +} + +module.exports = { createStore }; diff --git a/docker/context-profiles/complex-eval/reference3/chained-tickets/test/links.test.js b/docker/context-profiles/complex-eval/reference3/chained-tickets/test/links.test.js new file mode 100644 index 000000000..1a358d43d --- /dev/null +++ b/docker/context-profiles/complex-eval/reference3/chained-tickets/test/links.test.js @@ -0,0 +1,106 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createApp } = require('../src/app'); + +process.env.DATA_FILE = require('node:path').join(require('node:os').tmpdir(), + `shortlink-test-${process.pid}.json`); + +let server; +let port; +test.before(async () => { + server = createApp(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + port = server.address().port; +}); +test.after(() => server.close()); + +const post = body => fetch(`http://127.0.0.1:${port}/links`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); +const get = p => fetch(`http://127.0.0.1:${port}${p}`, { redirect: 'manual' }); + +test('creates a link with default expiry', async () => { + const res = await post({ url: 'https://example.com/a' }); + assert.equal(res.status, 201); + const body = await res.json(); + assert.match(body.code, /^[A-Za-z0-9]{6,10}$/); + assert.ok(Date.parse(body.expiresAt) > Date.now()); +}); + +test('redirects with 302 and location', async () => { + const { code } = await (await post({ url: 'https://example.com/b' })).json(); + const res = await get(`/${code}`); + assert.equal(res.status, 302); + assert.equal(res.headers.get('location'), 'https://example.com/b'); +}); + +test('unknown code is a 404 envelope', async () => { + const res = await get('/zzzzzz'); + assert.equal(res.status, 404); + assert.equal((await res.json()).error.code, 'NOT_FOUND'); +}); + +test('invalid url is a 400 envelope', async () => { + const res = await post({ url: 'notaurl' }); + assert.equal(res.status, 400); + assert.equal((await res.json()).error.code, 'INVALID_URL'); +}); + +test('javascript scheme rejected', async () => { + const res = await post({ url: 'javascript:alert(1)' }); + assert.equal(res.status, 400); +}); + +test('ttl bounds enforced', async () => { + const res = await post({ url: 'https://example.com', ttlSeconds: 99999999 }); + assert.equal(res.status, 400); + assert.equal((await res.json()).error.code, 'INVALID_TTL'); +}); + +test('delete flow', async () => { + const { code } = await (await post({ url: 'https://example.com/c' })).json(); + const del = await fetch(`http://127.0.0.1:${port}/links/${code}`, { method: 'DELETE' }); + assert.equal(del.status, 204); + assert.equal((await get(`/${code}`)).status, 404); +}); + +test('stats start at zero and count redirects', async () => { + const { code } = await (await post({ url: 'https://example.com/d' })).json(); + const zero = await (await fetch(`http://127.0.0.1:${port}/links/${code}/stats`)).json(); + assert.equal(zero.hits, 0); + await get(`/${code}`); + await get(`/${code}`); + const two = await (await fetch(`http://127.0.0.1:${port}/links/${code}/stats`)).json(); + assert.equal(two.hits, 2); +}); + +test('stats for unknown code are a 404 envelope', async () => { + const res = await fetch(`http://127.0.0.1:${port}/links/zzzzzz/stats`); + assert.equal(res.status, 404); + assert.equal((await res.json()).error.code, 'NOT_FOUND'); +}); + +test('expired links are 410', async () => { + const { code } = await (await post({ url: 'https://example.com/e', ttlSeconds: 1 })).json(); + await new Promise(resolve => setTimeout(resolve, 1200)); + assert.equal((await get(`/${code}`)).status, 410); +}); + +test('malformed json is a 400 envelope', async () => { + const res = await fetch(`http://127.0.0.1:${port}/links`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: '{nope' }); + assert.equal(res.status, 400); + assert.equal((await res.json()).error.code, 'INVALID_JSON'); +}); + +test('error responses never leak html', async () => { + const res = await get('/zzzzzz'); + assert.match(res.headers.get('content-type'), /application\/json/); +}); + +// Last: the flood exhausts the per-client rate-limit bucket. +test('rate limiting kicks in under a flood', async () => { + const responses = await Promise.all(Array.from({ length: 30 }, (_, i) => + post({ url: `https://example.com/flood-${i}` }))); + assert.ok(responses.some(r => r.status === 429)); +}); diff --git a/docker/context-profiles/complex-eval/reference3/idempotent-webhooks/CHANGELOG.md b/docker/context-profiles/complex-eval/reference3/idempotent-webhooks/CHANGELOG.md new file mode 100644 index 000000000..e0560c4a5 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference3/idempotent-webhooks/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +- 2026-09-25: Fixed INC-104 — the receiver now claims each event id and applies + the payment synchronously in one event-loop turn, so concurrent duplicate + deliveries can never both pass the seen-check. Added idempotency regression + tests for concurrent duplicates, retries, and already-paid orders. diff --git a/docker/context-profiles/complex-eval/reference3/idempotent-webhooks/src/app.js b/docker/context-profiles/complex-eval/reference3/idempotent-webhooks/src/app.js new file mode 100644 index 000000000..57f29c250 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference3/idempotent-webhooks/src/app.js @@ -0,0 +1,87 @@ +'use strict'; +const http = require('node:http'); +const { store } = require('./store'); + +// Fixed after INC-104: all state checks and mutations happen synchronously in +// one turn of the event loop — an event is claimed the instant its body is +// parsed, before any await, so concurrent duplicates can never both pass. +class HttpError extends Error { + constructor(status, code, message) { + super(message); + this.status = status; + this.code = code; + } +} + +function sendJson(res, status, value) { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(value)); +} + +function sendError(res, error) { + const known = error instanceof HttpError; + sendJson(res, known ? error.status : 500, { + error: { code: known ? error.code : 'INTERNAL', message: known ? error.message : 'internal error' }, + }); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', () => { + try { resolve(JSON.parse(body)); } catch { reject(new HttpError(400, 'INVALID_JSON', 'body must be valid JSON')); } + }); + req.on('error', reject); + }); +} + +function validateEvent(parsed) { + if (!parsed || typeof parsed.eventId !== 'string' || !parsed.eventId + || typeof parsed.orderId !== 'string' || !parsed.orderId + || !Number.isInteger(parsed.amountCents) || parsed.amountCents <= 0 + || parsed.type !== 'payment.succeeded') { + throw new HttpError(400, 'INVALID_EVENT', 'body must be a valid payment.succeeded event'); + } + return parsed; +} + +// Synchronous claim-and-apply: no awaits inside, so it is atomic. +function applyEvent({ eventId, orderId, amountCents }) { + if (store.processedEvents.has(eventId)) return { status: 'duplicate', orderId }; + const order = store.orders.get(orderId); + if (!order) throw new HttpError(404, 'NOT_FOUND', 'no such order'); + if (order.amountCents !== amountCents) throw new HttpError(422, 'AMOUNT_MISMATCH', 'amountCents does not match the order'); + if (order.status === 'paid') return { status: 'already_paid', orderId }; + store.processedEvents.add(eventId); + order.status = 'paid'; + order.paidAt = new Date().toISOString(); + order.paymentsApplied++; + store.paymentLog.push({ eventId, orderId, amountCents }); + return { status: 'processed', orderId }; +} + +function createApp() { + return http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + try { + if (req.method === 'POST' && url.pathname === '/webhooks/payments') { + const parsed = validateEvent(await readBody(req)); + sendJson(res, 200, applyEvent(parsed)); + return; + } + const match = /^\/orders\/([\w-]+)$/.exec(url.pathname); + if (req.method === 'GET' && match) { + const order = store.orders.get(match[1]); + if (!order) throw new HttpError(404, 'NOT_FOUND', 'no such order'); + sendJson(res, 200, order); + return; + } + throw new HttpError(404, 'NOT_FOUND', 'not found'); + } catch (error) { + sendError(res, error); + } + }); +} + +module.exports = { createApp }; diff --git a/docker/context-profiles/complex-eval/reference3/idempotent-webhooks/test/webhooks.test.js b/docker/context-profiles/complex-eval/reference3/idempotent-webhooks/test/webhooks.test.js new file mode 100644 index 000000000..cdd102f49 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference3/idempotent-webhooks/test/webhooks.test.js @@ -0,0 +1,60 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createApp } = require('../src/app'); +const { store } = require('../src/store'); + +let server; +let port; +test.before(async () => { + server = createApp(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + port = server.address().port; +}); +test.after(() => server.close()); + +const send = (eventId, orderId, amountCents) => fetch(`http://127.0.0.1:${port}/webhooks/payments`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ eventId, orderId, amountCents, type: 'payment.succeeded' }) }); + +test('a single payment event processes', async () => { + const res = await send('ev-t-1', 'o1', 5000); + assert.equal(res.status, 200); + assert.equal((await res.json()).status, 'processed'); + assert.equal(store.orders.get('o1').status, 'paid'); +}); + +test('a sequential retry is an inert duplicate', async () => { + await send('ev-t-2', 'o3', 800); + const before = store.paymentLog.filter(p => p.orderId === 'o3').length; + const res = await send('ev-t-2', 'o3', 800); + assert.equal((await res.json()).status, 'duplicate'); + assert.equal(store.paymentLog.filter(p => p.orderId === 'o3').length, before); +}); + +test('fifty concurrent duplicates apply exactly once (INC-104 regression)', async () => { + const storm = await Promise.all(Array.from({ length: 50 }, () => send('ev-t-storm', 'o4', 9999))); + const bodies = []; + for (const r of storm) bodies.push(await r.json()); + assert.equal(bodies.filter(b => b.status === 'processed').length, 1); + assert.equal(bodies.filter(b => b.status === 'duplicate').length, 49); + assert.equal(store.orders.get('o4').paymentsApplied, 1); +}); + +test('a second event for a paid order is already_paid', async () => { + const res = await send('ev-t-3', 'o4', 9999); + assert.equal((await res.json()).status, 'already_paid'); + assert.equal(store.orders.get('o4').paymentsApplied, 1); +}); + +test('amount mismatch is 422 and inert', async () => { + const res = await send('ev-t-4', 'o5', 1); + assert.equal(res.status, 422); + assert.equal(store.orders.get('o5').status, 'pending'); +}); + +test('unknown order is a 404 envelope', async () => { + const res = await send('ev-t-5', 'nope', 100); + assert.equal(res.status, 404); + assert.equal((await res.json()).error.code, 'NOT_FOUND'); +}); diff --git a/docker/context-profiles/complex-eval/reference3/production-ready/CHANGELOG.md b/docker/context-profiles/complex-eval/reference3/production-ready/CHANGELOG.md new file mode 100644 index 000000000..e0b0f3c6a --- /dev/null +++ b/docker/context-profiles/complex-eval/reference3/production-ready/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +- 2026-09-25: Production hardening — request validation with structured JSON + error envelopes, 64 KB body limit with 413, /health endpoint, structured + JSON request logging, PORT from the environment, graceful SIGTERM shutdown, + nosniff headers, and error-path test coverage. diff --git a/docker/context-profiles/complex-eval/reference3/production-ready/src/app.js b/docker/context-profiles/complex-eval/reference3/production-ready/src/app.js new file mode 100644 index 000000000..ccdecd16e --- /dev/null +++ b/docker/context-profiles/complex-eval/reference3/production-ready/src/app.js @@ -0,0 +1,100 @@ +'use strict'; +const http = require('node:http'); + +const MAX_BODY_BYTES = Number(process.env.MAX_BODY_BYTES || 64 * 1024); + +class HttpError extends Error { + constructor(status, code, message) { + super(message); + this.status = status; + this.code = code; + } +} + +function sendJson(res, status, value) { + res.writeHead(status, { 'content-type': 'application/json', 'x-content-type-options': 'nosniff' }); + res.end(JSON.stringify(value)); +} + +function sendError(res, error) { + const known = error instanceof HttpError; + sendJson(res, known ? error.status : 500, { + error: { code: known ? error.code : 'INTERNAL', message: known ? error.message : 'internal error' }, + }); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + let body = ''; + let bytes = 0; + let settled = false; + req.on('data', chunk => { + if (settled) return; + bytes += chunk.length; + if (bytes > MAX_BODY_BYTES) { + settled = true; + reject(new HttpError(413, 'PAYLOAD_TOO_LARGE', 'request body exceeds 64 KB')); + // Drain rather than destroy: the socket must live long enough to send the 413. + req.resume(); + return; + } + body += chunk; + }); + req.on('end', () => { + if (settled) return; + settled = true; + try { resolve(JSON.parse(body)); } catch { reject(new HttpError(400, 'INVALID_JSON', 'body must be valid JSON')); } + }); + req.on('error', reject); + }); +} + +function validateNote(input) { + if (!input || typeof input.title !== 'string' || !input.title.trim()) { + throw new HttpError(400, 'INVALID_TITLE', 'title must be a non-empty string'); + } + if (typeof input.body !== 'string') throw new HttpError(400, 'INVALID_BODY', 'body must be a string'); + return { title: input.title, body: input.body }; +} + +function createApp() { + const notes = new Map(); + let nextId = 1; + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + try { + if (req.method === 'GET' && url.pathname === '/health') { + sendJson(res, 200, { status: 'ok' }); + return; + } + if (req.method === 'POST' && url.pathname === '/notes') { + const fields = validateNote(await readBody(req)); + const id = `n_${nextId++}`; + notes.set(id, { id, ...fields }); + sendJson(res, 201, notes.get(id)); + return; + } + const match = /^\/notes\/([\w-]+)$/.exec(url.pathname); + if (req.method === 'GET' && match) { + const note = notes.get(match[1]); + if (!note) throw new HttpError(404, 'NOT_FOUND', 'no note with that id'); + sendJson(res, 200, note); + return; + } + if (req.method === 'GET' && url.pathname === '/notes') { + sendJson(res, 200, { notes: [...notes.values()] }); + return; + } + throw new HttpError(404, 'NOT_FOUND', 'not found'); + } catch (error) { + sendError(res, error); + } finally { + console.log(JSON.stringify({ method: req.method, path: url.pathname, + status: res.statusCode, at: new Date().toISOString() })); + } + }); + return server; +} + +module.exports = { createApp }; diff --git a/docker/context-profiles/complex-eval/reference3/production-ready/src/index.js b/docker/context-profiles/complex-eval/reference3/production-ready/src/index.js new file mode 100644 index 000000000..9b1d0a0d7 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference3/production-ready/src/index.js @@ -0,0 +1,13 @@ +'use strict'; +const { createApp } = require('./app'); + +const port = Number(process.env.PORT || 8080); +const server = createApp(); +server.listen(port, () => { + console.log(JSON.stringify({ event: 'listening', port })); +}); + +process.on('SIGTERM', () => { + server.close(() => process.exit(0)); + setTimeout(() => process.exit(1), 5000).unref(); +}); diff --git a/docker/context-profiles/complex-eval/reference3/production-ready/test/notes.test.js b/docker/context-profiles/complex-eval/reference3/production-ready/test/notes.test.js new file mode 100644 index 000000000..65e4ca200 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference3/production-ready/test/notes.test.js @@ -0,0 +1,58 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createApp } = require('../src/app'); + +let server; +let port; +test.before(async () => { + server = createApp(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + port = server.address().port; +}); +test.after(() => server.close()); + +const post = body => fetch(`http://127.0.0.1:${port}/notes`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body }); + +test('create and read a note', async () => { + const created = await post(JSON.stringify({ title: 'first', body: 'hello' })); + assert.equal(created.status, 201); + const { id } = await created.json(); + const read = await fetch(`http://127.0.0.1:${port}/notes/${id}`); + assert.equal((await read.json()).title, 'first'); +}); + +test('malformed json is a 400 envelope', async () => { + const res = await post('{oops'); + assert.equal(res.status, 400); + assert.equal((await res.json()).error.code, 'INVALID_JSON'); +}); + +test('missing title is a 400 envelope', async () => { + const res = await post(JSON.stringify({ body: 'x' })); + assert.equal(res.status, 400); + assert.equal((await res.json()).error.code, 'INVALID_TITLE'); +}); + +test('unknown note is a 404 envelope', async () => { + const res = await fetch(`http://127.0.0.1:${port}/notes/n_9999`); + assert.equal(res.status, 404); + assert.equal((await res.json()).error.code, 'NOT_FOUND'); +}); + +test('oversize body is a 413 envelope', async () => { + const res = await post(JSON.stringify({ title: 'x', body: 'y'.repeat(100 * 1024) })); + assert.equal(res.status, 413); +}); + +test('health endpoint', async () => { + const res = await fetch(`http://127.0.0.1:${port}/health`); + assert.equal(res.status, 200); + assert.equal((await res.json()).status, 'ok'); +}); + +test('nosniff header present', async () => { + const res = await fetch(`http://127.0.0.1:${port}/notes`); + assert.equal(res.headers.get('x-content-type-options'), 'nosniff'); +}); diff --git a/docker/context-profiles/complex-eval/reference4/chained-tickets/CHANGELOG.md b/docker/context-profiles/complex-eval/reference4/chained-tickets/CHANGELOG.md new file mode 100644 index 000000000..e8cad2f0c --- /dev/null +++ b/docker/context-profiles/complex-eval/reference4/chained-tickets/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +- 2026-09-25: Initial shortlink core — create, redirect, expiry, and delete per API.md. +- 2026-09-25: Persistence — links survive restarts via the DATA_FILE JSON store; missing or corrupt data files start clean. +- 2026-09-25: Abuse protection — URL validation (http/https only, length cap), request body limits, and per-client rate limiting with 429 responses. +- 2026-09-25: Analytics — per-link redirect hit counts exposed at GET /links/:code/stats. diff --git a/docker/context-profiles/complex-eval/reference4/chained-tickets/README.md b/docker/context-profiles/complex-eval/reference4/chained-tickets/README.md new file mode 100644 index 000000000..3420482fe --- /dev/null +++ b/docker/context-profiles/complex-eval/reference4/chained-tickets/README.md @@ -0,0 +1,14 @@ +# shortlink + +Internal link shortener service. Node.js standard library only, CommonJS. + +- `API.md` — the HTTP contract. +- `CONTRIBUTING.md` — engineering conventions. Every ticket follows them. +- `src/app.js` exports `createApp()` returning an `http.Server` that is not yet + listening; `node src/index.js ` starts the service. +- Links persist to the JSON file named by the `DATA_FILE` environment variable + (default `./data/links.json`). +- `GET /links//stats` returns `{ "code", "hits", "expiresAt" }` — + `hits` counts redirects. +- The API is rate limited per client and validates URLs (http/https only). +- Run the tests with `npm test`. diff --git a/docker/context-profiles/complex-eval/reference4/chained-tickets/src/app.js b/docker/context-profiles/complex-eval/reference4/chained-tickets/src/app.js new file mode 100644 index 000000000..c802a64fd --- /dev/null +++ b/docker/context-profiles/complex-eval/reference4/chained-tickets/src/app.js @@ -0,0 +1,15 @@ +'use strict'; +const http = require('node:http'); +const path = require('node:path'); +const { createStore } = require('./store'); +const { createService } = require('./service'); +const { createRouter } = require('./routes'); + +function createApp() { + const file = process.env.DATA_FILE || path.join(process.cwd(), 'data', 'links.json'); + const store = createStore(file); + const service = createService(store); + return http.createServer(createRouter(service)); +} + +module.exports = { createApp }; diff --git a/docker/context-profiles/complex-eval/reference4/chained-tickets/src/index.js b/docker/context-profiles/complex-eval/reference4/chained-tickets/src/index.js new file mode 100644 index 000000000..d37872b76 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference4/chained-tickets/src/index.js @@ -0,0 +1,7 @@ +'use strict'; +const { createApp } = require('./app'); + +const port = Number(process.env.PORT || process.argv[2] || 8080); +createApp().listen(port, () => { + console.log(`shortlink listening on ${port}`); +}); diff --git a/docker/context-profiles/complex-eval/reference4/chained-tickets/src/routes.js b/docker/context-profiles/complex-eval/reference4/chained-tickets/src/routes.js new file mode 100644 index 000000000..7344146c6 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference4/chained-tickets/src/routes.js @@ -0,0 +1,86 @@ +'use strict'; +const { HttpError } = require('./service'); + +const MAX_BODY_BYTES = 64 * 1024; + +function sendJson(res, status, value) { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(value)); +} + +function sendError(res, error) { + const known = error instanceof HttpError; + sendJson(res, known ? error.status : 500, { + error: { code: known ? error.code : 'INTERNAL', message: known ? error.message : 'internal error' }, + }); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + let body = ''; + let bytes = 0; + let settled = false; + req.on('data', chunk => { + if (settled) return; + bytes += chunk.length; + if (bytes > MAX_BODY_BYTES) { + settled = true; + reject(new HttpError(413, 'PAYLOAD_TOO_LARGE', 'request body too large')); + // Drain rather than destroy: the socket must live long enough to send the 413. + req.resume(); + return; + } + body += chunk; + }); + req.on('end', () => { + if (settled) return; + settled = true; + if (!body) { resolve({}); return; } + try { resolve(JSON.parse(body)); } catch { reject(new HttpError(400, 'INVALID_JSON', 'body must be valid JSON')); } + }); + req.on('error', reject); + }); +} + +function createRouter(service) { + return async (req, res) => { + try { + const url = new URL(req.url, 'http://localhost'); + + if (req.method === 'POST' && url.pathname === '/links') { + service.assertRateLimit(req.socket.remoteAddress || 'unknown'); + const link = service.createLink(await readBody(req)); + sendJson(res, 201, { code: link.code, shortUrl: `/${link.code}`, expiresAt: link.expiresAt }); + return; + } + + const statsMatch = /^\/links\/([A-Za-z0-9]{1,20})\/stats$/.exec(url.pathname); + if (req.method === 'GET' && statsMatch) { + sendJson(res, 200, service.stats(statsMatch[1])); + return; + } + + const linkMatch = /^\/links\/([A-Za-z0-9]{1,20})$/.exec(url.pathname); + if (req.method === 'DELETE' && linkMatch) { + service.deleteLink(linkMatch[1]); + res.writeHead(204); + res.end(); + return; + } + + const redirectMatch = /^\/([A-Za-z0-9]{1,20})$/.exec(url.pathname); + if (req.method === 'GET' && redirectMatch) { + const link = service.resolveLink(redirectMatch[1]); + res.writeHead(302, { location: link.url }); + res.end(); + return; + } + + throw new HttpError(404, 'NOT_FOUND', 'not found'); + } catch (error) { + sendError(res, error); + } + }; +} + +module.exports = { createRouter }; diff --git a/docker/context-profiles/complex-eval/reference4/chained-tickets/src/service.js b/docker/context-profiles/complex-eval/reference4/chained-tickets/src/service.js new file mode 100644 index 000000000..f28167d8a --- /dev/null +++ b/docker/context-profiles/complex-eval/reference4/chained-tickets/src/service.js @@ -0,0 +1,82 @@ +'use strict'; +const crypto = require('node:crypto'); + +const MAX_URL_LENGTH = 2048; +const DEFAULT_TTL_SECONDS = 604800; +const MAX_TTL_SECONDS = 2592000; +const RATE_LIMIT_WINDOW_MS = 60000; +const RATE_LIMIT_MAX = 20; + +class HttpError extends Error { + constructor(status, code, message) { + super(message); + this.status = status; + this.code = code; + } +} + +function validateUrl(url) { + if (typeof url !== 'string' || !url) throw new HttpError(400, 'INVALID_URL', 'url is required'); + if (url.length > MAX_URL_LENGTH) throw new HttpError(400, 'INVALID_URL', 'url exceeds 2048 characters'); + let parsed; + try { parsed = new URL(url); } catch { throw new HttpError(400, 'INVALID_URL', 'url must be a valid absolute URL'); } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new HttpError(400, 'INVALID_URL', 'only http and https URLs are allowed'); + } + return url; +} + +function validateTtl(ttlSeconds) { + if (ttlSeconds === undefined || ttlSeconds === null) return DEFAULT_TTL_SECONDS; + if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > MAX_TTL_SECONDS) { + throw new HttpError(400, 'INVALID_TTL', 'ttlSeconds must be an integer between 1 and 2592000'); + } + return ttlSeconds; +} + +function createService(store) { + const buckets = new Map(); + + function assertRateLimit(key) { + const now = Date.now(); + const windowHits = (buckets.get(key) || []).filter(at => now - at < RATE_LIMIT_WINDOW_MS); + if (windowHits.length >= RATE_LIMIT_MAX) throw new HttpError(429, 'RATE_LIMITED', 'too many requests, slow down'); + windowHits.push(now); + buckets.set(key, windowHits); + } + + function freshCode() { + let code = crypto.randomBytes(4).toString('hex'); + while (store.get(code)) code = crypto.randomBytes(4).toString('hex'); + return code; + } + + return { + assertRateLimit, + createLink({ url, ttlSeconds } = {}) { + const validUrl = validateUrl(url); + const ttl = validateTtl(ttlSeconds); + const link = { code: freshCode(), url: validUrl, + expiresAt: new Date(Date.now() + ttl * 1000).toISOString(), hits: 0 }; + store.set(link.code, link); + return link; + }, + resolveLink(code) { + const link = store.get(code); + if (!link) throw new HttpError(404, 'NOT_FOUND', 'no link with that code'); + if (Date.parse(link.expiresAt) <= Date.now()) throw new HttpError(410, 'GONE', 'link has expired'); + store.incrementHits(code); + return link; + }, + deleteLink(code) { + if (!store.delete(code)) throw new HttpError(404, 'NOT_FOUND', 'no link with that code'); + }, + stats(code) { + const link = store.get(code); + if (!link) throw new HttpError(404, 'NOT_FOUND', 'no link with that code'); + return { code, hits: link.hits || 0, expiresAt: link.expiresAt }; + }, + }; +} + +module.exports = { createService, HttpError }; diff --git a/docker/context-profiles/complex-eval/reference4/chained-tickets/src/store.js b/docker/context-profiles/complex-eval/reference4/chained-tickets/src/store.js new file mode 100644 index 000000000..7d5aa091b --- /dev/null +++ b/docker/context-profiles/complex-eval/reference4/chained-tickets/src/store.js @@ -0,0 +1,28 @@ +'use strict'; +const fs = require('node:fs'); +const path = require('node:path'); + +// JSON-file-backed link store. Missing or corrupt files start clean; every +// mutation is flushed synchronously so a restart never loses a committed link. +function createStore(file) { + let links = new Map(); + try { + const raw = JSON.parse(fs.readFileSync(file, 'utf8')); + for (const [code, value] of Object.entries(raw.links || {})) links.set(code, value); + } catch { /* missing or corrupt: start empty */ } + const save = () => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify({ links: Object.fromEntries(links) }, null, 1)}\n`); + }; + return { + get: code => links.get(code) || null, + set(code, value) { links.set(code, value); save(); }, + delete(code) { const had = links.delete(code); if (had) save(); return had; }, + incrementHits(code) { + const link = links.get(code); + if (link) { link.hits = (link.hits || 0) + 1; save(); } + }, + }; +} + +module.exports = { createStore }; diff --git a/docker/context-profiles/complex-eval/reference4/chained-tickets/test/links.test.js b/docker/context-profiles/complex-eval/reference4/chained-tickets/test/links.test.js new file mode 100644 index 000000000..1a358d43d --- /dev/null +++ b/docker/context-profiles/complex-eval/reference4/chained-tickets/test/links.test.js @@ -0,0 +1,106 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createApp } = require('../src/app'); + +process.env.DATA_FILE = require('node:path').join(require('node:os').tmpdir(), + `shortlink-test-${process.pid}.json`); + +let server; +let port; +test.before(async () => { + server = createApp(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + port = server.address().port; +}); +test.after(() => server.close()); + +const post = body => fetch(`http://127.0.0.1:${port}/links`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); +const get = p => fetch(`http://127.0.0.1:${port}${p}`, { redirect: 'manual' }); + +test('creates a link with default expiry', async () => { + const res = await post({ url: 'https://example.com/a' }); + assert.equal(res.status, 201); + const body = await res.json(); + assert.match(body.code, /^[A-Za-z0-9]{6,10}$/); + assert.ok(Date.parse(body.expiresAt) > Date.now()); +}); + +test('redirects with 302 and location', async () => { + const { code } = await (await post({ url: 'https://example.com/b' })).json(); + const res = await get(`/${code}`); + assert.equal(res.status, 302); + assert.equal(res.headers.get('location'), 'https://example.com/b'); +}); + +test('unknown code is a 404 envelope', async () => { + const res = await get('/zzzzzz'); + assert.equal(res.status, 404); + assert.equal((await res.json()).error.code, 'NOT_FOUND'); +}); + +test('invalid url is a 400 envelope', async () => { + const res = await post({ url: 'notaurl' }); + assert.equal(res.status, 400); + assert.equal((await res.json()).error.code, 'INVALID_URL'); +}); + +test('javascript scheme rejected', async () => { + const res = await post({ url: 'javascript:alert(1)' }); + assert.equal(res.status, 400); +}); + +test('ttl bounds enforced', async () => { + const res = await post({ url: 'https://example.com', ttlSeconds: 99999999 }); + assert.equal(res.status, 400); + assert.equal((await res.json()).error.code, 'INVALID_TTL'); +}); + +test('delete flow', async () => { + const { code } = await (await post({ url: 'https://example.com/c' })).json(); + const del = await fetch(`http://127.0.0.1:${port}/links/${code}`, { method: 'DELETE' }); + assert.equal(del.status, 204); + assert.equal((await get(`/${code}`)).status, 404); +}); + +test('stats start at zero and count redirects', async () => { + const { code } = await (await post({ url: 'https://example.com/d' })).json(); + const zero = await (await fetch(`http://127.0.0.1:${port}/links/${code}/stats`)).json(); + assert.equal(zero.hits, 0); + await get(`/${code}`); + await get(`/${code}`); + const two = await (await fetch(`http://127.0.0.1:${port}/links/${code}/stats`)).json(); + assert.equal(two.hits, 2); +}); + +test('stats for unknown code are a 404 envelope', async () => { + const res = await fetch(`http://127.0.0.1:${port}/links/zzzzzz/stats`); + assert.equal(res.status, 404); + assert.equal((await res.json()).error.code, 'NOT_FOUND'); +}); + +test('expired links are 410', async () => { + const { code } = await (await post({ url: 'https://example.com/e', ttlSeconds: 1 })).json(); + await new Promise(resolve => setTimeout(resolve, 1200)); + assert.equal((await get(`/${code}`)).status, 410); +}); + +test('malformed json is a 400 envelope', async () => { + const res = await fetch(`http://127.0.0.1:${port}/links`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: '{nope' }); + assert.equal(res.status, 400); + assert.equal((await res.json()).error.code, 'INVALID_JSON'); +}); + +test('error responses never leak html', async () => { + const res = await get('/zzzzzz'); + assert.match(res.headers.get('content-type'), /application\/json/); +}); + +// Last: the flood exhausts the per-client rate-limit bucket. +test('rate limiting kicks in under a flood', async () => { + const responses = await Promise.all(Array.from({ length: 30 }, (_, i) => + post({ url: `https://example.com/flood-${i}` }))); + assert.ok(responses.some(r => r.status === 429)); +}); diff --git a/docker/context-profiles/complex-eval/reference4/idempotent-webhooks/CHANGELOG.md b/docker/context-profiles/complex-eval/reference4/idempotent-webhooks/CHANGELOG.md new file mode 100644 index 000000000..e0560c4a5 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference4/idempotent-webhooks/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +- 2026-09-25: Fixed INC-104 — the receiver now claims each event id and applies + the payment synchronously in one event-loop turn, so concurrent duplicate + deliveries can never both pass the seen-check. Added idempotency regression + tests for concurrent duplicates, retries, and already-paid orders. diff --git a/docker/context-profiles/complex-eval/reference4/idempotent-webhooks/src/app.js b/docker/context-profiles/complex-eval/reference4/idempotent-webhooks/src/app.js new file mode 100644 index 000000000..57f29c250 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference4/idempotent-webhooks/src/app.js @@ -0,0 +1,87 @@ +'use strict'; +const http = require('node:http'); +const { store } = require('./store'); + +// Fixed after INC-104: all state checks and mutations happen synchronously in +// one turn of the event loop — an event is claimed the instant its body is +// parsed, before any await, so concurrent duplicates can never both pass. +class HttpError extends Error { + constructor(status, code, message) { + super(message); + this.status = status; + this.code = code; + } +} + +function sendJson(res, status, value) { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(value)); +} + +function sendError(res, error) { + const known = error instanceof HttpError; + sendJson(res, known ? error.status : 500, { + error: { code: known ? error.code : 'INTERNAL', message: known ? error.message : 'internal error' }, + }); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', () => { + try { resolve(JSON.parse(body)); } catch { reject(new HttpError(400, 'INVALID_JSON', 'body must be valid JSON')); } + }); + req.on('error', reject); + }); +} + +function validateEvent(parsed) { + if (!parsed || typeof parsed.eventId !== 'string' || !parsed.eventId + || typeof parsed.orderId !== 'string' || !parsed.orderId + || !Number.isInteger(parsed.amountCents) || parsed.amountCents <= 0 + || parsed.type !== 'payment.succeeded') { + throw new HttpError(400, 'INVALID_EVENT', 'body must be a valid payment.succeeded event'); + } + return parsed; +} + +// Synchronous claim-and-apply: no awaits inside, so it is atomic. +function applyEvent({ eventId, orderId, amountCents }) { + if (store.processedEvents.has(eventId)) return { status: 'duplicate', orderId }; + const order = store.orders.get(orderId); + if (!order) throw new HttpError(404, 'NOT_FOUND', 'no such order'); + if (order.amountCents !== amountCents) throw new HttpError(422, 'AMOUNT_MISMATCH', 'amountCents does not match the order'); + if (order.status === 'paid') return { status: 'already_paid', orderId }; + store.processedEvents.add(eventId); + order.status = 'paid'; + order.paidAt = new Date().toISOString(); + order.paymentsApplied++; + store.paymentLog.push({ eventId, orderId, amountCents }); + return { status: 'processed', orderId }; +} + +function createApp() { + return http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + try { + if (req.method === 'POST' && url.pathname === '/webhooks/payments') { + const parsed = validateEvent(await readBody(req)); + sendJson(res, 200, applyEvent(parsed)); + return; + } + const match = /^\/orders\/([\w-]+)$/.exec(url.pathname); + if (req.method === 'GET' && match) { + const order = store.orders.get(match[1]); + if (!order) throw new HttpError(404, 'NOT_FOUND', 'no such order'); + sendJson(res, 200, order); + return; + } + throw new HttpError(404, 'NOT_FOUND', 'not found'); + } catch (error) { + sendError(res, error); + } + }); +} + +module.exports = { createApp }; diff --git a/docker/context-profiles/complex-eval/reference4/idempotent-webhooks/test/webhooks.test.js b/docker/context-profiles/complex-eval/reference4/idempotent-webhooks/test/webhooks.test.js new file mode 100644 index 000000000..cdd102f49 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference4/idempotent-webhooks/test/webhooks.test.js @@ -0,0 +1,60 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createApp } = require('../src/app'); +const { store } = require('../src/store'); + +let server; +let port; +test.before(async () => { + server = createApp(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + port = server.address().port; +}); +test.after(() => server.close()); + +const send = (eventId, orderId, amountCents) => fetch(`http://127.0.0.1:${port}/webhooks/payments`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ eventId, orderId, amountCents, type: 'payment.succeeded' }) }); + +test('a single payment event processes', async () => { + const res = await send('ev-t-1', 'o1', 5000); + assert.equal(res.status, 200); + assert.equal((await res.json()).status, 'processed'); + assert.equal(store.orders.get('o1').status, 'paid'); +}); + +test('a sequential retry is an inert duplicate', async () => { + await send('ev-t-2', 'o3', 800); + const before = store.paymentLog.filter(p => p.orderId === 'o3').length; + const res = await send('ev-t-2', 'o3', 800); + assert.equal((await res.json()).status, 'duplicate'); + assert.equal(store.paymentLog.filter(p => p.orderId === 'o3').length, before); +}); + +test('fifty concurrent duplicates apply exactly once (INC-104 regression)', async () => { + const storm = await Promise.all(Array.from({ length: 50 }, () => send('ev-t-storm', 'o4', 9999))); + const bodies = []; + for (const r of storm) bodies.push(await r.json()); + assert.equal(bodies.filter(b => b.status === 'processed').length, 1); + assert.equal(bodies.filter(b => b.status === 'duplicate').length, 49); + assert.equal(store.orders.get('o4').paymentsApplied, 1); +}); + +test('a second event for a paid order is already_paid', async () => { + const res = await send('ev-t-3', 'o4', 9999); + assert.equal((await res.json()).status, 'already_paid'); + assert.equal(store.orders.get('o4').paymentsApplied, 1); +}); + +test('amount mismatch is 422 and inert', async () => { + const res = await send('ev-t-4', 'o5', 1); + assert.equal(res.status, 422); + assert.equal(store.orders.get('o5').status, 'pending'); +}); + +test('unknown order is a 404 envelope', async () => { + const res = await send('ev-t-5', 'nope', 100); + assert.equal(res.status, 404); + assert.equal((await res.json()).error.code, 'NOT_FOUND'); +}); diff --git a/docker/context-profiles/complex-eval/reference4/production-ready/CHANGELOG.md b/docker/context-profiles/complex-eval/reference4/production-ready/CHANGELOG.md new file mode 100644 index 000000000..e0b0f3c6a --- /dev/null +++ b/docker/context-profiles/complex-eval/reference4/production-ready/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +- 2026-09-25: Production hardening — request validation with structured JSON + error envelopes, 64 KB body limit with 413, /health endpoint, structured + JSON request logging, PORT from the environment, graceful SIGTERM shutdown, + nosniff headers, and error-path test coverage. diff --git a/docker/context-profiles/complex-eval/reference4/production-ready/src/app.js b/docker/context-profiles/complex-eval/reference4/production-ready/src/app.js new file mode 100644 index 000000000..ccdecd16e --- /dev/null +++ b/docker/context-profiles/complex-eval/reference4/production-ready/src/app.js @@ -0,0 +1,100 @@ +'use strict'; +const http = require('node:http'); + +const MAX_BODY_BYTES = Number(process.env.MAX_BODY_BYTES || 64 * 1024); + +class HttpError extends Error { + constructor(status, code, message) { + super(message); + this.status = status; + this.code = code; + } +} + +function sendJson(res, status, value) { + res.writeHead(status, { 'content-type': 'application/json', 'x-content-type-options': 'nosniff' }); + res.end(JSON.stringify(value)); +} + +function sendError(res, error) { + const known = error instanceof HttpError; + sendJson(res, known ? error.status : 500, { + error: { code: known ? error.code : 'INTERNAL', message: known ? error.message : 'internal error' }, + }); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + let body = ''; + let bytes = 0; + let settled = false; + req.on('data', chunk => { + if (settled) return; + bytes += chunk.length; + if (bytes > MAX_BODY_BYTES) { + settled = true; + reject(new HttpError(413, 'PAYLOAD_TOO_LARGE', 'request body exceeds 64 KB')); + // Drain rather than destroy: the socket must live long enough to send the 413. + req.resume(); + return; + } + body += chunk; + }); + req.on('end', () => { + if (settled) return; + settled = true; + try { resolve(JSON.parse(body)); } catch { reject(new HttpError(400, 'INVALID_JSON', 'body must be valid JSON')); } + }); + req.on('error', reject); + }); +} + +function validateNote(input) { + if (!input || typeof input.title !== 'string' || !input.title.trim()) { + throw new HttpError(400, 'INVALID_TITLE', 'title must be a non-empty string'); + } + if (typeof input.body !== 'string') throw new HttpError(400, 'INVALID_BODY', 'body must be a string'); + return { title: input.title, body: input.body }; +} + +function createApp() { + const notes = new Map(); + let nextId = 1; + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + try { + if (req.method === 'GET' && url.pathname === '/health') { + sendJson(res, 200, { status: 'ok' }); + return; + } + if (req.method === 'POST' && url.pathname === '/notes') { + const fields = validateNote(await readBody(req)); + const id = `n_${nextId++}`; + notes.set(id, { id, ...fields }); + sendJson(res, 201, notes.get(id)); + return; + } + const match = /^\/notes\/([\w-]+)$/.exec(url.pathname); + if (req.method === 'GET' && match) { + const note = notes.get(match[1]); + if (!note) throw new HttpError(404, 'NOT_FOUND', 'no note with that id'); + sendJson(res, 200, note); + return; + } + if (req.method === 'GET' && url.pathname === '/notes') { + sendJson(res, 200, { notes: [...notes.values()] }); + return; + } + throw new HttpError(404, 'NOT_FOUND', 'not found'); + } catch (error) { + sendError(res, error); + } finally { + console.log(JSON.stringify({ method: req.method, path: url.pathname, + status: res.statusCode, at: new Date().toISOString() })); + } + }); + return server; +} + +module.exports = { createApp }; diff --git a/docker/context-profiles/complex-eval/reference4/production-ready/src/index.js b/docker/context-profiles/complex-eval/reference4/production-ready/src/index.js new file mode 100644 index 000000000..9b1d0a0d7 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference4/production-ready/src/index.js @@ -0,0 +1,13 @@ +'use strict'; +const { createApp } = require('./app'); + +const port = Number(process.env.PORT || 8080); +const server = createApp(); +server.listen(port, () => { + console.log(JSON.stringify({ event: 'listening', port })); +}); + +process.on('SIGTERM', () => { + server.close(() => process.exit(0)); + setTimeout(() => process.exit(1), 5000).unref(); +}); diff --git a/docker/context-profiles/complex-eval/reference4/production-ready/test/notes.test.js b/docker/context-profiles/complex-eval/reference4/production-ready/test/notes.test.js new file mode 100644 index 000000000..65e4ca200 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference4/production-ready/test/notes.test.js @@ -0,0 +1,58 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createApp } = require('../src/app'); + +let server; +let port; +test.before(async () => { + server = createApp(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + port = server.address().port; +}); +test.after(() => server.close()); + +const post = body => fetch(`http://127.0.0.1:${port}/notes`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body }); + +test('create and read a note', async () => { + const created = await post(JSON.stringify({ title: 'first', body: 'hello' })); + assert.equal(created.status, 201); + const { id } = await created.json(); + const read = await fetch(`http://127.0.0.1:${port}/notes/${id}`); + assert.equal((await read.json()).title, 'first'); +}); + +test('malformed json is a 400 envelope', async () => { + const res = await post('{oops'); + assert.equal(res.status, 400); + assert.equal((await res.json()).error.code, 'INVALID_JSON'); +}); + +test('missing title is a 400 envelope', async () => { + const res = await post(JSON.stringify({ body: 'x' })); + assert.equal(res.status, 400); + assert.equal((await res.json()).error.code, 'INVALID_TITLE'); +}); + +test('unknown note is a 404 envelope', async () => { + const res = await fetch(`http://127.0.0.1:${port}/notes/n_9999`); + assert.equal(res.status, 404); + assert.equal((await res.json()).error.code, 'NOT_FOUND'); +}); + +test('oversize body is a 413 envelope', async () => { + const res = await post(JSON.stringify({ title: 'x', body: 'y'.repeat(100 * 1024) })); + assert.equal(res.status, 413); +}); + +test('health endpoint', async () => { + const res = await fetch(`http://127.0.0.1:${port}/health`); + assert.equal(res.status, 200); + assert.equal((await res.json()).status, 'ok'); +}); + +test('nosniff header present', async () => { + const res = await fetch(`http://127.0.0.1:${port}/notes`); + assert.equal(res.headers.get('x-content-type-options'), 'nosniff'); +}); diff --git a/docker/context-profiles/complex-eval/reference4/recurring-incident/docs/handoff.md b/docker/context-profiles/complex-eval/reference4/recurring-incident/docs/handoff.md new file mode 100644 index 000000000..91d68d69f --- /dev/null +++ b/docker/context-profiles/complex-eval/reference4/recurring-incident/docs/handoff.md @@ -0,0 +1,35 @@ +# Handoff: refunds & payouts idempotency + +## What happened + +Two incidents, one root cause family: + +- **Refunds** (INC-201, INC-214, INC-227 in docs/incidents.md): refund requests + arriving without an idempotency key were double-processed whenever the + storefront retried, refunding customers twice. +- **Payouts**: finance's batch job is about to start retrying on timeouts, and + keyless payout retries would double-pay vendors the same way. + +## The fix + +Both entry points now route through a single shared helper, +`src/idempotency.js` (`deriveKey` + `once`). `src/refunds.js` and +`src/payouts.js` derive a stable key from the request payload when the caller +sends none, claim it synchronously so concurrent retries share one execution, +and persist the receipt in `src/store.js` so retries after a restart return the +stored receipt. Gateway side effects all go through `src/charge.js`, so the +ledger is the source of truth for "did this actually happen". + +## Regression coverage + +`test/idempotency.test.js` covers keyless refund retries, restart durability, +and a 20-way concurrent payout storm. The pre-existing `test/refunds.test.js` +and `test/payouts.test.js` still cover the keyed contract. Everything is wired +into `npm test`; run it before touching any of this. + +## Prevention + +`docs/runbooks/idempotency.md` is the runbook: any new money-moving operation +must go through `src/idempotency.js`, ship with a retry regression test, and +log recurrences in `docs/incidents.md`. Do not bolt a second inline key-check +into a new module — extend the helper instead. diff --git a/docker/context-profiles/complex-eval/reference4/recurring-incident/docs/runbooks/idempotency.md b/docker/context-profiles/complex-eval/reference4/recurring-incident/docs/runbooks/idempotency.md new file mode 100644 index 000000000..00c32cfa9 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference4/recurring-incident/docs/runbooks/idempotency.md @@ -0,0 +1,35 @@ +# Runbook: idempotency for money-moving operations + +## The incident class + +INC-201, INC-214, INC-227 (refunds) and the payout double-pay risk flagged by +finance are one class of bug: a caller retries a money-moving request that +carries no idempotency key, and the service executes it again. Asking clients +to retry less has failed three times; prevention must live in the service. + +## The pattern + +Every money-moving entry point routes through the shared helper in +`src/idempotency.js`: + +- `deriveKey(scope, parts)` builds a stable key from the request payload when + the caller did not supply one. +- `once(store, key, produce)` claims the key synchronously (concurrent retries + share one execution) and persists the receipt (retries after a restart get + the stored receipt back). + +`src/refunds.js` and `src/payouts.js` both use it. Do not add a second inline +implementation of key derivation or seen-tracking in another module. + +## Prevention procedure + +For any new operation that moves money (charges, refunds, payouts, credits, +adjustments): + +1. Route the side effect through `once()` from `src/idempotency.js` — never + call the gateway directly from the entry point. +2. Add a regression test that retries the operation without a key (including + a concurrent retry storm) and asserts the ledger shows exactly one effect. +3. Run `npm test` before merging. +4. If this class of bug recurs anywhere, log it in `docs/incidents.md` and + extend this runbook instead of fixing silently. diff --git a/docker/context-profiles/complex-eval/reference4/recurring-incident/src/idempotency.js b/docker/context-profiles/complex-eval/reference4/recurring-incident/src/idempotency.js new file mode 100644 index 000000000..7f5eb0fc8 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference4/recurring-incident/src/idempotency.js @@ -0,0 +1,31 @@ +// Shared idempotency helper for money-moving entry points. Any operation that +// must not happen twice derives a stable key (from the caller's idempotencyKey +// or from the request payload) and routes through once(). +import crypto from 'node:crypto'; + +const inflight = new Map(); + +export function deriveKey(scope, parts) { + const hash = crypto.createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 24); + return `${scope}:${hash}`; +} + +// Runs produce() at most once per key. The key is claimed synchronously, so +// concurrent callers share one execution, and the receipt is persisted, so a +// retry after a restart returns the stored receipt instead of re-running. +export async function once(store, key, produce) { + const existing = store.get(key); + if (existing) return { ...existing, duplicate: true }; + if (inflight.has(key)) return { ...(await inflight.get(key)), duplicate: true }; + const pending = (async () => { + const receipt = await produce(); + store.set(key, receipt); + return receipt; + })(); + inflight.set(key, pending); + try { + return await pending; + } finally { + inflight.delete(key); + } +} diff --git a/docker/context-profiles/complex-eval/reference4/recurring-incident/src/payouts.js b/docker/context-profiles/complex-eval/reference4/recurring-incident/src/payouts.js new file mode 100644 index 000000000..fffb428ae --- /dev/null +++ b/docker/context-profiles/complex-eval/reference4/recurring-incident/src/payouts.js @@ -0,0 +1,12 @@ +import { payout } from './charge.js'; +import * as store from './store.js'; +import { deriveKey, once } from './idempotency.js'; + +// Processes a vendor payout through the same shared idempotency helper as +// refunds, so a retry storm can never double-pay a vendor. +export async function processPayout(req) { + const key = req.idempotencyKey + ? `payout:${req.idempotencyKey}` + : deriveKey('payout', { vendorId: req.vendorId, amount: req.amount }); + return once(store, key, () => payout({ vendorId: req.vendorId, amount: req.amount })); +} diff --git a/docker/context-profiles/complex-eval/reference4/recurring-incident/src/refunds.js b/docker/context-profiles/complex-eval/reference4/recurring-incident/src/refunds.js new file mode 100644 index 000000000..a756f09bc --- /dev/null +++ b/docker/context-profiles/complex-eval/reference4/recurring-incident/src/refunds.js @@ -0,0 +1,13 @@ +import { refund } from './charge.js'; +import * as store from './store.js'; +import { deriveKey, once } from './idempotency.js'; + +// Processes a customer refund. Requests without an idempotencyKey get a key +// derived from the payload, so a retried call can never refund twice — see +// docs/runbooks/idempotency.md. +export async function processRefund(req) { + const key = req.idempotencyKey + ? `refund:${req.idempotencyKey}` + : deriveKey('refund', { orderId: req.orderId, amount: req.amount }); + return once(store, key, () => refund({ orderId: req.orderId, amount: req.amount })); +} diff --git a/docker/context-profiles/complex-eval/reference4/recurring-incident/test/idempotency.test.js b/docker/context-profiles/complex-eval/reference4/recurring-incident/test/idempotency.test.js new file mode 100644 index 000000000..20135eb69 --- /dev/null +++ b/docker/context-profiles/complex-eval/reference4/recurring-incident/test/idempotency.test.js @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { readLedger } from '../src/charge.js'; + +function freshEnv(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'payments-idem-')); + process.env.LEDGER_FILE = path.join(dir, 'ledger.jsonl'); + process.env.STORE_FILE = path.join(dir, 'store.json'); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +test('a refund retried without an idempotency key refunds exactly once', async (t) => { + const dir = freshEnv(t); + const { processRefund } = await import('../src/refunds.js'); + await processRefund({ orderId: 'ord-retry', amount: 2500 }); + await processRefund({ orderId: 'ord-retry', amount: 2500 }); + const refunds = readLedger().filter(e => e.type === 'refund' && e.orderId === 'ord-retry'); + assert.equal(refunds.length, 1); + assert.equal(fs.readdirSync(dir).includes('ledger.jsonl'), true); +}); + +test('refund idempotency survives a restart (fresh module, same store)', async (t) => { + freshEnv(t); + const first = await import('../src/refunds.js'); + await first.processRefund({ orderId: 'ord-restart', amount: 3100 }); + const reloaded = await import(`../src/refunds.js?restart=${Date.now()}`); + await reloaded.processRefund({ orderId: 'ord-restart', amount: 3100 }); + const refunds = readLedger().filter(e => e.type === 'refund' && e.orderId === 'ord-restart'); + assert.equal(refunds.length, 1); +}); + +test('a concurrent keyless payout retry storm pays exactly once', async (t) => { + freshEnv(t); + const { processPayout } = await import('../src/payouts.js'); + await Promise.all(Array.from({ length: 20 }, + () => processPayout({ vendorId: 'ven-storm', amount: 9000 }))); + const payouts = readLedger().filter(e => e.type === 'payout' && e.vendorId === 'ven-storm'); + assert.equal(payouts.length, 1); +}); + +test('payout idempotency survives a restart (fresh module, same store)', async (t) => { + freshEnv(t); + const first = await import('../src/payouts.js'); + await first.processPayout({ vendorId: 'ven-restart', amount: 4000 }); + const reloaded = await import(`../src/payouts.js?restart=${Date.now()}`); + await reloaded.processPayout({ vendorId: 'ven-restart', amount: 4000 }); + const payouts = readLedger().filter(e => e.type === 'payout' && e.vendorId === 'ven-restart'); + assert.equal(payouts.length, 1); +}); diff --git a/docker/context-profiles/complex-eval/verify-checks.js b/docker/context-profiles/complex-eval/verify-checks.js new file mode 100644 index 000000000..8c69d8d5e --- /dev/null +++ b/docker/context-profiles/complex-eval/verify-checks.js @@ -0,0 +1,64 @@ +'use strict'; +// Development tool: validates the hidden graders end to end. For every task the +// reference solution (referenceDir/ overlaid on the fixture) must score +// 1.0; the as-shipped fixture and the optional naive control (naiveDir/) +// must score strictly below 1.0. Uses the evaluator's own sandboxed grader +// runner, so this exercises the real grading path. +// Usage: node verify-checks.js [casesDir=cases] [referenceDir=reference] [naiveDir=naive] +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { runScoredCheck } = require('../ai-eval-lib'); + +const root = __dirname; +const casesDir = path.join(root, process.argv[2] || 'cases'); +const referenceDir = path.join(root, process.argv[3] || 'reference'); +const naiveDir = path.join(root, process.argv[4] || 'naive'); + +function stage(task, overlayDir) { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), `ecc-complex-${task}-`)); + const copy = (from, to) => { + for (const entry of fs.readdirSync(from, { withFileTypes: true })) { + const target = path.join(to, entry.name); + if (entry.isDirectory()) { fs.mkdirSync(target, { recursive: true }); copy(path.join(from, entry.name), target); } + else fs.copyFileSync(path.join(from, entry.name), target); + } + }; + copy(path.join(casesDir, task, 'files'), cwd); + if (overlayDir && fs.existsSync(path.join(overlayDir, task))) copy(path.join(overlayDir, task), cwd); + return cwd; +} + +let failed = false; +for (const task of fs.readdirSync(casesDir).sort()) { + const meta = JSON.parse(fs.readFileSync(path.join(casesDir, task, 'meta.json'), 'utf8')); + const stepsDir = path.join(casesDir, task, 'steps'); + if (fs.existsSync(stepsDir)) { + // Stepped task: graders run in order against one accumulating workspace. + const steps = fs.readdirSync(stepsDir).sort().map((name, index) => ({ + check: fs.readFileSync(path.join(stepsDir, name, 'check.cjs'), 'utf8'), + timeoutMs: meta.steps?.[index]?.checkTimeoutMs || meta.checkTimeoutMs || 30000, + })); + const runChain = overlayDir => { + const cwd = stage(task, overlayDir); + return steps.map((step, index) => runScoredCheck(cwd, step.check, step.timeoutMs, index + 1).score); + }; + const bare = runChain(null); + const solved = runChain(referenceDir); + const ok = solved.every(score => score === 1) && bare.some(score => score < 1); + if (!ok) failed = true; + console.log(`${ok ? 'ok' : 'FAIL'} - ${task}: fixture=[${bare.map(s => s.toFixed(2))}] reference=[${solved.map(s => s.toFixed(2))}]`); + continue; + } + const check = fs.readFileSync(path.join(casesDir, task, 'check.cjs'), 'utf8'); + const timeoutMs = meta.checkTimeoutMs || 30000; + const bare = runScoredCheck(stage(task, null), check, timeoutMs); + const naive = fs.existsSync(path.join(naiveDir, task)) + ? runScoredCheck(stage(task, naiveDir), check, timeoutMs) : null; + const solved = runScoredCheck(stage(task, referenceDir), check, timeoutMs); + const ok = solved.passed && solved.score === 1 && bare.score < 1 && (!naive || naive.score < 1); + if (!ok) failed = true; + console.log(`${ok ? 'ok' : 'FAIL'} - ${task}: fixture=${bare.score.toFixed(3)}` + + `${naive ? ` naive=${naive.score.toFixed(3)}` : ''} reference=${solved.score.toFixed(3)}`); +} +process.exit(failed ? 1 : 0); diff --git a/docker/context-profiles/example-task.json b/docker/context-profiles/example-task.json new file mode 100644 index 000000000..f45512f85 --- /dev/null +++ b/docker/context-profiles/example-task.json @@ -0,0 +1,7 @@ +{ + "sessionId": "local-auto-canary", + "taskId": "python-patterns-explanation", + "revision": 1, + "phase": "explain", + "query": "Explain Python patterns for a short, readable list comprehension. Give one example and describe when a plain loop is clearer. Do not modify files or run commands." +} diff --git a/docker/context-profiles/legacy-source.json b/docker/context-profiles/legacy-source.json new file mode 100644 index 000000000..096fe759a --- /dev/null +++ b/docker/context-profiles/legacy-source.json @@ -0,0 +1,5 @@ +{ + "ref": "origin/main", + "sha": "e482e579415fde18357cafce70f177ae19fd7f03", + "note": "Pre-ECC-029 ECC source for the ecc-legacy evaluation arm: the typical current user install (full skill library, no scoping layer). Pinned so runs are reproducible; advance deliberately." +} diff --git a/docker/context-profiles/native-probe.js b/docker/context-profiles/native-probe.js new file mode 100644 index 000000000..ea74ea3ac --- /dev/null +++ b/docker/context-profiles/native-probe.js @@ -0,0 +1,168 @@ +#!/usr/bin/env node +'use strict'; + +// Opt-in, credential-free native discovery. Never starts a thread or model turn. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn, spawnSync } = require('node:child_process'); + +function run(command, args, options) { + const result = spawnSync(command, args, { ...options, encoding: 'utf8', timeout: 60000, + maxBuffer: 16 * 1024 * 1024 }); + assert.equal(result.status, 0, `${command}: ${result.error || result.stderr || result.stdout}`); + return result.stdout.trim(); +} + +async function listSkills() { + const server = spawn(process.env.ECC_NATIVE_CODEX || 'codex', ['app-server', '--stdio'], { + cwd: process.cwd(), env: process.env, stdio: ['pipe', 'pipe', 'pipe'], + }); + let buffer = ''; + let stderr = ''; + const pending = new Map(); + let nextId = 0; + server.stderr.on('data', chunk => { stderr += chunk; }); + server.stdout.on('data', chunk => { + buffer += chunk; + let end; + while ((end = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, end); + buffer = buffer.slice(end + 1); + if (!line.trim()) continue; + const message = JSON.parse(line); + const handler = pending.get(message.id); + if (handler) { + pending.delete(message.id); + if (message.error) handler.reject(new Error(JSON.stringify(message.error))); + else handler.resolve(message.result); + } + } + }); + const fail = error => { for (const handler of pending.values()) handler.reject(error); }; + server.on('error', fail); + server.on('exit', code => fail(new Error(`App server exited ${code}: ${stderr}`))); + const timer = setTimeout(() => { fail(new Error('Native discovery timed out')); server.kill(); }, 45000); + const request = (method, params) => new Promise((resolve, reject) => { + const id = ++nextId; + pending.set(id, { resolve, reject }); + server.stdin.write(`${JSON.stringify({ id, method, params })}\n`); + }); + try { + const initialized = await request('initialize', { + clientInfo: { name: 'ecc-context-native-probe', version: '1.0.0' }, + capabilities: { experimentalApi: true }, + }); + server.stdin.write(`${JSON.stringify({ method: 'initialized' })}\n`); + const skills = await request('skills/list', { cwds: [process.cwd()], forceReload: true }); + process.stdout.write(`${JSON.stringify({ initialized, skills })}\n`); + } finally { + clearTimeout(timer); + server.kill(); + } +} + +function probe(options) { + const repoRoot = path.resolve(process.env.ECC_NATIVE_PACKAGE_ROOT || path.join(__dirname, '../..')); + const { planContextCarrier } = require(path.join(repoRoot, 'scripts/lib/context-carriers')); + const { compileContextProfile } = require(path.join(repoRoot, 'scripts/lib/context-profiles')); + // The independent structural oracle remains source-only test infrastructure. + const { withCarrierFixture } = require('../../tests/lib/helpers/context-carrier-fixture'); + const artifact = planContextCarrier({ repoRoot, ...options }); + const expectedPlan = compileContextProfile({ repoRoot, ...options }); + return withCarrierFixture({ repoRoot, artifact, expectedPlan }, ({ root, verify }) => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-context-native-')); + try { + const home = path.join(temp, 'home'); + const codexHome = path.join(home, '.codex'); + const cwd = path.join(temp, 'project'); + const marketplace = path.join(temp, 'marketplace'); + for (const dir of [codexHome, cwd, path.join(marketplace, '.agents/plugins')]) { + fs.mkdirSync(dir, { recursive: true }); + } + const env = { PATH: process.env.PATH, HOME: home, CODEX_HOME: codexHome, + CLAUDE_CONFIG_DIR: path.join(home, '.claude'), LANG: 'C.UTF-8', + DISABLE_TELEMETRY: '1', DISABLE_AUTOUPDATER: '1', + ECC_NATIVE_CODEX: process.env.ECC_NATIVE_CODEX || 'codex' }; + const commandOptions = { cwd, env }; + if (options.target === 'claude') { + const version = run('claude', ['--version'], commandOptions); + const validation = run('claude', ['plugin', 'validate', root], commandOptions); + const details = run('claude', ['--setting-sources', '', '--plugin-dir', root, + 'plugin', 'details', 'ecc-context-carrier'], commandOptions); + const names = details.match(/Skills \(\d+\)\s+([^\n]+)/); + assert.ok(names, 'Claude did not report the skill inventory'); + const nativeNames = names[1].split(', ').sort(); + assert.deepEqual(nativeNames, artifact.entries.map(skill => skill.name).sort()); + for (const component of ['Agents', 'Hooks', 'MCP servers', 'LSP servers']) { + assert.ok(details.includes(`${component} (0)`), `Unexpected native ${component}`); + } + verify(); + return { provider: version, profileId: artifact.profileId, + selectedIds: artifact.selectedIds, excludedIds: artifact.excludedIds, + nativeNames, discovery: 'verified-component-inventory', + validation, projectedTokens: details.match(/Always-on:\s+([^\n]+)/)?.[1], + carrierDigest: artifact.carrierDigest, + invocation: 'unobserved', modelCalls: 0, credentialsCopied: false }; + } + const codex = env.ECC_NATIVE_CODEX; + const version = run(codex, ['--version'], commandOptions); + fs.cpSync(root, path.join(marketplace, 'carrier'), { recursive: true }); + fs.writeFileSync(path.join(marketplace, '.agents/plugins/marketplace.json'), JSON.stringify({ + name: 'ecc-context-probe', plugins: [{ name: 'ecc-context-carrier', + source: { source: 'local', path: './carrier' }, + policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' } }], + })); + const added = JSON.parse(run(codex, ['plugin', 'marketplace', 'add', marketplace, '--json'], commandOptions)); + const installed = JSON.parse(run(codex, ['plugin', 'add', 'ecc-context-carrier@ecc-context-probe', '--json'], commandOptions)); + // Discovery must survive removal of the marketplace's source skill tree. + fs.rmSync(path.join(marketplace, 'carrier'), { recursive: true }); + const observed = JSON.parse(run(process.execPath, [__filename, '--list-skills'], commandOptions)); + assert.equal(observed.skills.data.length, 1); + const entry = observed.skills.data[0]; + assert.deepEqual(entry.errors, [], 'Native parser rejected a selected skill'); + const nativeSkills = entry.skills.filter(skill => skill.pluginId === 'ecc-context-carrier@ecc-context-probe'); + const expectedNames = artifact.entries.map(skill => `ecc-context-carrier:${skill.name}`).sort(); + const actualNames = nativeSkills.map(skill => skill.name).sort(); + assert.deepEqual(actualNames, expectedNames, `Native skill selection mismatch: ${JSON.stringify(entry)}`); + let resourceCount = 0; + for (const skill of nativeSkills) { + assert.equal(skill.enabled, true); + assert.ok(skill.path.startsWith(`${fs.realpathSync(codexHome)}${path.sep}`), 'Skill escaped isolated Codex home'); + const expected = artifact.entries.find(item => `ecc-context-carrier:${item.name}` === skill.name); + for (const file of artifact.files.filter(item => item.skillId === expected.id)) { + const relative = file.destinationPath.slice(`skills/${expected.name}/`.length); + const bytes = fs.readFileSync(path.join(path.dirname(skill.path), relative)); + const digest = require('node:crypto').createHash('sha256').update(bytes).digest('hex'); + assert.equal(digest, file.digest, 'Installed resource bytes changed'); + resourceCount++; + } + } + verify(); + assert.equal(fs.existsSync(path.join(codexHome, 'auth.json')), false); + return { provider: version, profileId: artifact.profileId, selectedIds: artifact.selectedIds, + excludedIds: artifact.excludedIds, discovery: 'verified', resources: resourceCount, + relocation: 'verified-after-source-removal', carrierDigest: artifact.carrierDigest, + nativeNames: actualNames, systemSkills: entry.skills.filter(skill => !skill.pluginId).map(skill => skill.name), + marketplaceAdded: !!added, installed: !!installed, invocation: 'unobserved', + modelCalls: 0, credentialsCopied: false }; + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } + }); +} + +if (process.argv.includes('--list-skills')) { + listSkills().catch(error => { console.error(error); process.exitCode = 1; }); +} else { + const cases = process.argv.includes('--claude') ? [ + { profileId: 'lean@1', target: 'claude' }, + { profileId: 'full@1', target: 'claude', exclude: ['skill:python-patterns'] }, + ] : [ + { profileId: 'lean@1', target: 'codex' }, + { profileId: 'lean@1', target: 'codex', include: ['skill:angular-developer'] }, + { profileId: 'full@1', target: 'codex', exclude: ['skill:python-patterns'] }, + ]; + for (const options of cases) process.stdout.write(`${JSON.stringify(probe(options))}\n`); +} diff --git a/docker/context-profiles/native-switch-probe.js b/docker/context-profiles/native-switch-probe.js new file mode 100644 index 000000000..47b21810f --- /dev/null +++ b/docker/context-profiles/native-switch-probe.js @@ -0,0 +1,43 @@ +#!/usr/bin/env node +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { applyStore, rollbackStore } = require('../../scripts/lib/context-profile-store'); +const { prepareNativeProfile, rollbackNativeProfile, getNativeProfileStatus, recoverNativeProfile } = require('../../scripts/lib/context-profile-native'); + +const repoRoot = path.resolve(__dirname, '../..'); +const temp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-native-switch-'))); +const options = { stateRoot: path.join(temp, 'managed'), nativeRoot: path.join(temp, 'native'), + codexPath: process.env.ECC_NATIVE_CODEX || 'codex' }; +try { + const cases = []; let full; + for (const [index, profileId] of ['full@1', 'lean@1', 'full@1'].entries()) { + const managed = index === 2 ? rollbackStore({ stateRoot: options.stateRoot }) + : applyStore({ repoRoot, stateRoot: options.stateRoot, target: 'codex', selectionMode: 'auto', + profileId, exclude: profileId === 'full@1' ? ['skill:python-patterns'] : [] }); + const native = index === 2 ? rollbackNativeProfile(options) : prepareNativeProfile(options); + assert.equal(native.ready, true); + assert.equal(native.carrierDigest, managed.carrierDigest); + assert.equal(native.storeRevision, managed.revision); + assert.equal(native.active, false); + assert.equal(getNativeProfileStatus(options).ready, true); + if (index === 0) { + full = native; + fs.writeFileSync(path.join(full.home, 'unrelated.txt'), 'Unrelated user bytes'); + } + if (index === 1) assert.notEqual(native.home, full.home); + if (index === 2) assert.equal(native.home, full.home); + assert.equal(fs.readFileSync(path.join(full.home, 'unrelated.txt'), 'utf8'), 'Unrelated user bytes'); + cases.push({ profileId, storeRevision: native.storeRevision, nativeRevision: native.revision, + skills: native.selectedIds.length, carrierDigest: native.carrierDigest }); + } + assert.equal(recoverNativeProfile(options).ready, true); + process.stdout.write(`${JSON.stringify({ kind: 'native-managed-switch', provider: 'codex-cli 0.154.0', + productAdapter: 'isolated-native-generations', cases, unrelatedBytesPreserved: true, + discovery: 'verified', modelCalls: 0, credentialsCopied: false, invocation: 'unobserved' })}\n`); +} finally { + fs.rmSync(temp, { recursive: true, force: true }); +} diff --git a/docker/context-profiles/packed-smoke.js b/docker/context-profiles/packed-smoke.js new file mode 100644 index 000000000..2cc959374 --- /dev/null +++ b/docker/context-profiles/packed-smoke.js @@ -0,0 +1,143 @@ +#!/usr/bin/env node +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { planContextCarrier } = require('../../scripts/lib/context-carriers'); +const { compileContextProfile } = require('../../scripts/lib/context-profiles'); +const { withCarrierFixture } = require('../../tests/lib/helpers/context-carrier-fixture'); + +const repoRoot = path.resolve(__dirname, '../..'); +const temp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-packed-context-'))); +const expectedSource = process.env.ECC_EXPECTED_CARRIERS + ? JSON.parse(fs.readFileSync(process.env.ECC_EXPECTED_CARRIERS, 'utf8')) : null; + +function profileCommand(args, temp, env, expectedStatus = 0) { + const result = spawnSync(process.execPath, [path.join(repoRoot, 'scripts/ecc.js'), 'profile', ...args, '--json'], { + cwd: temp, env, encoding: 'utf8', timeout: 60000, maxBuffer: 16 * 1024 * 1024, + }); + assert.equal(result.status, expectedStatus, result.stderr || result.stdout); + return JSON.parse(result.stdout); +} + +function managedJourney(temp, env) { + const stateRoot = path.join(temp, 'managed'); + const command = (args, status) => profileCommand(args, temp, env, status); + const store = (args, status) => command([...args, '--state-root', stateRoot], status); + assert.equal(store(['status']).store.status, 'unconfigured'); + const preview = store(['set', 'full', '--dry-run']); + assert.equal(preview.store.proposedProfileId, 'full@1'); + assert.equal(fs.existsSync(stateRoot), false); + const full = store(['set', 'full', '--exclude', 'skill:python-patterns', '--expected-revision', '0']).store; + assert.equal(full.profileId, 'full@1'); + assert.equal(full.active, false); + assert.equal(full.revision, 1); + assert.equal(full.selectedIds.includes('skill:python-patterns'), false); + const lean = store(['set', 'lean', '--selection', 'auto', '--expected-revision', '1']).store; + assert.equal(lean.revision, 2); + assert.equal(lean.profileId, 'lean@1'); + assert.equal(lean.selectedIds.length, 3); + assert.ok(fs.existsSync(path.join(lean.generationRoot, '.codex-plugin/plugin.json'))); + const restored = store(['rollback', '--expected-revision', '2']).store; + assert.equal(restored.revision, 3); + assert.equal(restored.carrierDigest, full.carrierDigest); + const repeated = store(['set', 'full', '--exclude', 'skill:python-patterns']).store; + assert.equal(repeated.revision, 3, 'Repeated configuration should be idempotent'); + store(['set', 'lean', '--expected-revision', '1'], 1); + assert.equal(store(['status']).store.revision, 3); + assert.equal(store(['recover']).store.revision, 3); + + const taskPath = path.join(temp, 'task.json'); + const task = { sessionId: 'packed-probe', taskId: 'python-step', revision: 1, phase: 'implement', + query: 'python-patterns', proposedIds: ['skill:python-patterns'] }; + fs.writeFileSync(taskPath, JSON.stringify(task)); + const resolve = args => command(['resolve', 'lean', '--task-input', taskPath, ...args]).selection; + const selected = resolve(['--selection', 'auto']); + assert.deepEqual(selected.selectedIds, ['skill:python-patterns']); + assert.deepEqual(selected.loadedIds, []); + const loaded = resolve(['--selection', 'auto', '--load', '--expected-digest', selected.receipt.selectionDigest]); + assert.deepEqual(loaded.loadedIds, ['skill:python-patterns']); + assert.ok(loaded.resources.every(resource => resource.content.length > 0)); + assert.deepEqual(resolve(['--selection', 'suggest', '--load']).loadedIds, []); + assert.deepEqual(resolve(['--selection', 'manual', '--load']).loadedIds, []); + assert.deepEqual(resolve(['--selection', 'auto', '--load', '--dry-run']).loadedIds, []); + const launch = profileCommand(['run', 'lean', '--task-input', taskPath, '--dry-run'], temp, + { ...env, PATH: temp }).launch; + assert.equal(launch.status, 'proposed'); + assert.equal(launch.exitCode, null); + assert.deepEqual(launch.selection.loadedIds, []); + fs.writeFileSync(taskPath, JSON.stringify({ ...task, explicitIds: ['skill:python-patterns'] })); + const excluded = command(['resolve', '--state-root', stateRoot, '--task-input', taskPath, '--load'], 1); + assert.match(excluded.summary, /excluded/); + fs.writeFileSync(taskPath, JSON.stringify(task)); + const receiptPath = path.join(temp, 'receipt.json'); + fs.writeFileSync(receiptPath, JSON.stringify(loaded.receipt)); + fs.writeFileSync(taskPath, JSON.stringify({ ...task, proposedIds: [], query: 'unrelated wording' })); + assert.equal(resolve(['--previous', receiptPath, '--load']).reused, true); + fs.writeFileSync(taskPath, JSON.stringify({ ...task, revision: 2, noWorkflow: true })); + const reset = resolve(['--previous', receiptPath, '--load']); + assert.equal(reset.reason, 'no-workflow-needed'); + assert.deepEqual(reset.loadedIds, []); + const nativeRoot = path.join(temp, 'native-cli'); + const nativeArgs = ['--state-root', stateRoot, '--native-root', nativeRoot]; + const proposedNative = command(['prepare-native', ...nativeArgs, '--dry-run']).native; + assert.equal(proposedNative.ready, false); + assert.equal(fs.existsSync(nativeRoot), false); + const preparedNative = command(['prepare-native', ...nativeArgs]).native; + assert.equal(preparedNative.ready, true); + const nativeStatus = command(['native-status', ...nativeArgs]).native; + assert.equal(nativeStatus.ready, true); + assert.equal(nativeStatus.storeRevision, 3); + const nativeLaunch = profileCommand(['run', '--task-input', taskPath, ...nativeArgs, '--dry-run'], temp, + { ...env, PATH: temp }).launch; + assert.equal(nativeLaunch.status, 'proposed'); + assert.equal(nativeLaunch.command, preparedNative.executable); + assert.equal(nativeLaunch.providerConfiguration, 'isolated-native-generation'); + assert.equal(command(['native-recover', ...nativeArgs]).native.ready, true); + assert.equal(fs.existsSync(env.HOME), false, 'Managed commands changed the caller home'); + return { kind: 'packed-managed-and-auto', transitions: ['full', 'lean', 'rollback-full'], + finalRevision: 3, idempotency: 'verified', staleRevision: 'rejected', + autoLoaded: loaded.loadedIds, suggestLoaded: [], manualLoaded: [], + dryRunLoaded: [], launcherDryRun: 'verified-with-no-provider-on-PATH', savedExclusions: 'enforced', + pinnedReuse: 'verified', noWorkflowReset: 'verified', nativeCliPreparation: 'verified', + nativePinnedLaunchDryRun: 'verified', existingSessionActivation: 'unchanged' }; +} + +try { + const env = { PATH: process.env.PATH, HOME: path.join(temp, 'home'), LANG: 'C.UTF-8' }; + const results = []; + for (const target of ['claude', 'codex', 'pi', 'opencode', 'cursor']) { + for (const profileId of ['lean@1', 'full@1']) { + const options = { repoRoot, profileId, target, selectionMode: 'auto' }; + const expectedPlan = compileContextProfile(options); + const artifact = planContextCarrier(options); + if (expectedSource) { + assert.deepEqual(artifact, expectedSource.find(item => item.target === target && item.profileId === profileId), + 'Packed carrier differs from source artifact'); + } + const cli = spawnSync(process.execPath, [path.join(repoRoot, 'scripts/ecc.js'), + 'profile', 'carrier', profileId, '--target', target, '--json'], + { cwd: temp, env, encoding: 'utf8', timeout: 60000, maxBuffer: 16 * 1024 * 1024 }); + assert.equal(cli.status, 0, cli.stderr); + assert.deepEqual(JSON.parse(cli.stdout).carrier, artifact); + const evidence = withCarrierFixture({ repoRoot, artifact, expectedPlan }, ({ verify }) => verify()); + results.push({ target, profileId, selected: artifact.selectedIds.length, files: evidence.fileCount }); + } + } + assert.deepEqual(fs.readdirSync(temp), [], 'Preview changed the disposable caller home'); + process.stdout.write(`${JSON.stringify({ kind: 'packed-cli-and-structural', node: process.version, + platform: `${process.platform}/${process.arch}`, cases: results })}\n`); + process.stdout.write(`${JSON.stringify(managedJourney(temp, env))}\n`); + for (const script of ['native-probe.js', 'native-switch-probe.js']) { + const native = spawnSync(process.execPath, [path.join(__dirname, script)], { + cwd: temp, env, encoding: 'utf8', timeout: 180000, maxBuffer: 16 * 1024 * 1024, + }); + assert.equal(native.status, 0, native.stderr || native.stdout); + process.stdout.write(native.stdout); + } +} finally { + fs.rmSync(temp, { recursive: true, force: true }); +} diff --git a/docker/context-profiles/run-podman.js b/docker/context-profiles/run-podman.js new file mode 100644 index 000000000..c58cca450 --- /dev/null +++ b/docker/context-profiles/run-podman.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node +'use strict'; + +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const repoRoot = path.resolve(__dirname, '../..'); +const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-context-podman-')); +const image = `localhost/ecc-context-profiles:${process.pid}-${Date.now()}`; +function run(command, args, capture = false) { + const result = spawnSync(command, args, { cwd: repoRoot, encoding: 'utf8', + timeout: 600000, maxBuffer: 32 * 1024 * 1024, stdio: capture ? 'pipe' : 'inherit' }); + assert.equal(result.status, 0, `${command}: ${result.error || result.stderr || result.stdout}`); + return result.stdout; +} +try { + const packed = JSON.parse(run('npm', ['pack', '--json', '--pack-destination', temp], true)); + const { planContextCarrier } = require('../../scripts/lib/context-carriers'); + const expected = []; + for (const target of ['claude', 'codex', 'pi', 'opencode', 'cursor']) { + for (const profileId of ['lean@1', 'full@1']) { + expected.push(planContextCarrier({ repoRoot, target, profileId, selectionMode: 'auto' })); + } + } + const archivePaths = new Set(packed[0].files.map(file => file.path)); + const missing = expected[1].files.filter(file => file.kind === 'copy' && !archivePaths.has(file.sourcePath)); + assert.deepEqual(missing, [], 'Packed archive omitted canonical skill resources'); + fs.writeFileSync(path.join(temp, 'expected-carriers.json'), JSON.stringify(expected)); + fs.renameSync(path.join(temp, packed[0].filename), path.join(temp, 'package.tgz')); + for (const file of ['Dockerfile', 'native-probe.js', 'native-switch-probe.js', 'packed-smoke.js']) { + fs.copyFileSync(path.join(__dirname, file), path.join(temp, file)); + } + fs.copyFileSync(path.join(repoRoot, 'tests/lib/helpers/context-carrier-fixture.js'), + path.join(temp, 'context-carrier-fixture.js')); + const packageDigest = crypto.createHash('sha256').update(fs.readFileSync(path.join(temp, 'package.tgz'))).digest('hex'); + process.stdout.write(`${JSON.stringify({ packageDigest, image })}\n`); + const args = ['build', '--tag', image]; + if (process.env.ECC_CONTEXT_NODE_IMAGE) args.push('--build-arg', `NODE_IMAGE=${process.env.ECC_CONTEXT_NODE_IMAGE}`); + args.push(temp); + run('podman', args); + run('podman', ['run', '--rm', '--network=none', '--cap-drop=all', '--security-opt=no-new-privileges', image]); +} finally { + // Only the image and temporary directory created by this invocation are removed. + spawnSync('podman', ['image', 'rm', image], { stdio: 'ignore', timeout: 60000 }); + fs.rmSync(temp, { recursive: true, force: true }); +} diff --git a/docker/context-profiles/run-sandbox.js b/docker/context-profiles/run-sandbox.js new file mode 100644 index 000000000..c429f6c81 --- /dev/null +++ b/docker/context-profiles/run-sandbox.js @@ -0,0 +1,284 @@ +#!/usr/bin/env node +'use strict'; + +// The installed tier router owns provisioning and cleanup. This acceptance +// driver transfers only an npm archive and a fixed verifier into the VM. +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const http = require('node:http'); +const net = require('node:net'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const NODE_VERSION = '22.18.0'; +const NODE_SHA = '2c12913cba67af77ded8a399df3fd91c2e7f8628c7079da40bb9ff33bf00dfc0'; +const digest = bytes => crypto.createHash('sha256').update(bytes).digest('hex'); +const quote = text => `'${String(text).replace(/'/g, `'"'"'`)}'`; + +function command(executable, args, cwd, timeout = 900000) { + return new Promise((resolve, reject) => { + const child = spawn(executable, args, { cwd, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], shell: false }); + let stdout = ''; let stderr = ''; let size = 0; let termination = null; let settled = false; + const stop = reason => { + if (!termination) termination = reason; + child.kill('SIGKILL'); + }; + const timer = setTimeout(() => stop('timeout'), timeout); + const collect = key => chunk => { + size += chunk.length; + if (size > 24 * 1024 * 1024) { stop('output-limit'); return; } + if (key === 'stdout') stdout += chunk; else stderr += chunk; + }; + child.stdout.on('data', collect('stdout')); child.stderr.on('data', collect('stderr')); + child.once('error', error => { + if (settled) return; + settled = true; clearTimeout(timer); reject(error); + }); + child.once('close', (code, signal) => { + if (settled) return; + settled = true; clearTimeout(timer); resolve({ code, signal, stdout, stderr, termination }); + }); + }); +} + +function fingerprintSandboxCli(executable) { + const resolved = fs.realpathSync(executable); + fs.accessSync(resolved, fs.constants.X_OK); + const before = fs.statSync(resolved); + assert.ok(before.isFile() && before.size > 0 && before.size <= 64 * 1024 * 1024, + 'Sandbox CLI must be a bounded executable file'); + const bytes = fs.readFileSync(resolved); + const after = fs.statSync(resolved); + assert.equal(after.dev, before.dev, 'Sandbox CLI changed during fingerprinting'); + assert.equal(after.ino, before.ino, 'Sandbox CLI changed during fingerprinting'); + assert.equal(after.size, before.size, 'Sandbox CLI changed during fingerprinting'); + assert.equal(after.mtimeMs, before.mtimeMs, 'Sandbox CLI changed during fingerprinting'); + const executableDigest = digest(bytes); + const sourceRoot = path.basename(path.dirname(resolved)) === 'sandbox' ? path.dirname(resolved) : null; + if (!sourceRoot) return { path: resolved, bytes: bytes.length, digest: executableDigest, + implementation: { root: null, files: 1, bytes: bytes.length, digest: executableDigest } }; + const files = []; + function visit(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const file = path.join(directory, entry.name); + assert.equal(entry.isSymbolicLink(), false, 'Sandbox CLI implementation must not contain symbolic links'); + if (entry.isDirectory()) visit(file); + else { + assert.equal(entry.isFile(), true, 'Sandbox CLI implementation must contain regular files only'); + files.push(file); + assert.ok(files.length <= 512, 'Sandbox CLI implementation exceeds the file bound'); + } + } + } + visit(sourceRoot); + const hash = crypto.createHash('sha256'); let total = 0; + for (const file of files) { + const content = fs.readFileSync(file); + total += content.length; + assert.ok(total <= 32 * 1024 * 1024, 'Sandbox CLI implementation exceeds the byte bound'); + hash.update(path.relative(sourceRoot, file).split(path.sep).join('/')).update('\0').update(content); + } + return { path: resolved, bytes: bytes.length, digest: executableDigest, + implementation: { root: sourceRoot, files: files.length, bytes: total, digest: hash.digest('hex') } }; +} + +function resolveSandboxCli(commandName = 'ecc-sandbox') { + const candidates = path.isAbsolute(commandName) ? [commandName] + : (process.env.PATH || '').split(path.delimiter).filter(directory => path.isAbsolute(directory)) + .map(directory => path.join(directory, commandName)); + const executable = candidates.find(candidate => { + try { fs.accessSync(candidate, fs.constants.X_OK); return true; } catch { return false; } + }); + assert.ok(executable, 'Sandbox CLI executable was not found'); + return fingerprintSandboxCli(executable); +} + +function verifySandboxCli(binding) { + const current = fingerprintSandboxCli(binding.path); + assert.deepEqual(current, binding, 'Sandbox CLI changed after acceptance was staged'); + return current; +} + +function validateReport(stdout, { tier, manifest }) { + try { + const report = JSON.parse(stdout); + assert.ok(report && typeof report === 'object' && !Array.isArray(report)); + assert.equal(report.result, 'pass'); + assert.equal(report.backend, tier === 1 ? 'podman' : 'lume'); + assert.equal(report.tier, tier); + assert.equal(report.execution_mode, 'real'); + const installDiff = report.install_diff; + assert.ok(installDiff && typeof installDiff === 'object' && !Array.isArray(installDiff)); + for (const key of ['files_added', 'files_changed', 'files_deleted', 'path_changes', + 'services_registered', 'dotfiles_touched']) assert.ok(Array.isArray(installDiff[key])); + if (tier === 1) assert.equal(installDiff.complete, true); + else { + assert.equal(installDiff.method, 'scan'); + assert.equal(installDiff.complete, false); + assert.ok(report.notes?.includes('VM install diff is a bounded best-effort path scan, not a complete disk diff')); + } + assert.equal(report.assertions?.length, manifest.steps.assert.length); + for (let index = 0; index < manifest.steps.assert.length; index++) { + assert.deepEqual(report.assertions[index], { cmd: manifest.steps.assert[index], pass: true }); + } + const assertion = manifest.steps.assert.at(-1); + const step = report.steps?.findLast(item => item?.cmd === assertion); + assert.equal(step?.exit, 0); + assert.equal(typeof step.stdout_tail, 'string'); + const smoke = JSON.parse(step.stdout_tail.trim()); + assert.equal(smoke?.schemaVersion, 'ecc.context-sandbox-smoke.v1'); + assert.equal(smoke.passed, true); + assert.equal(smoke.os, tier === 1 ? 'linux' : 'darwin'); + assert.equal(smoke.arch, 'arm64'); + assert.equal(smoke.authenticated, false); + assert.equal(smoke.taskOutcomes, 'unobserved'); + assert.equal(smoke.matrix?.length, 10); + const layouts = smoke.matrix.map(item => `${item.target}/${item.profile}`).sort(); + assert.deepEqual(layouts, ['claude/full', 'claude/lean', 'codex/full', 'codex/lean', + 'cursor/full', 'cursor/lean', 'opencode/full', 'opencode/lean', 'pi/full', 'pi/lean']); + return { report, smoke }; + } catch { + throw new Error('Sandbox acceptance report or final smoke payload is invalid'); + } +} + +function manifestFor({ tier, archiveDigest, verifierDigest, url, runName }) { + assert.ok([1, 2].includes(tier)); + for (const value of [archiveDigest, verifierDigest]) assert.match(value, /^[a-f0-9]{64}$/); + assert.match(runName, /^[a-z0-9-]+$/); + const guestRoot = tier === 1 ? `/home/ecc/${runName}` : `/tmp/${runName}`; + const setup = [`mkdir -m 700 ${quote(guestRoot)}`]; + let runtime = ''; + if (tier === 2) { + const parsed = new URL(url); + assert.equal(parsed.protocol, 'http:'); + assert.equal(parsed.username, ''); assert.equal(parsed.password, ''); + assert.equal(net.isIP(parsed.hostname), 4, 'Artifact URL requires an IPv4 address'); + setup.push(`curl -fsS --max-time 120 https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-darwin-arm64.tar.gz -o ${quote(`${guestRoot}/node.tgz`)} && test "$(shasum -a 256 ${quote(`${guestRoot}/node.tgz`)} | cut -d ' ' -f 1)" = ${NODE_SHA} && tar -xzf ${quote(`${guestRoot}/node.tgz`)} -C ${quote(guestRoot)}`); + runtime = `export PATH=${quote(`${guestRoot}/node-v${NODE_VERSION}-darwin-arm64/bin`)}:$PATH; `; + for (const file of ['package.tgz', 'sandbox-smoke.js']) { + setup.push(`curl -fsS --max-time 120 ${quote(`${url}/${file}`)} -o ${quote(`${guestRoot}/${file}`)}`); + } + } else { + setup.push(`cp /workspace/source/package.tgz /workspace/source/sandbox-smoke.js ${quote(guestRoot)}/`); + } + const check = `const fs=require('fs'),c=require('crypto'); for(const [f,h] of ${JSON.stringify([['package.tgz', archiveDigest], ['sandbox-smoke.js', verifierDigest]])}) {if(c.createHash('sha256').update(fs.readFileSync(f)).digest('hex')!==h)throw Error('Input digest mismatch')}`; + setup.push(`${runtime}cd ${quote(guestRoot)} && node -e ${quote(check)} && npm install --ignore-scripts --omit=dev --no-audit --no-fund --fetch-timeout=30000 --fetch-retries=1 --prefix consumer ./package.tgz && npm install --ignore-scripts --no-audit --no-fund --fetch-timeout=30000 --fetch-retries=1 --prefix tools @openai/codex@0.154.0 ${quote(`@openai/codex-${tier === 2 ? 'darwin' : 'linux'}-arm64@npm:@openai/codex@0.154.0-${tier === 2 ? 'darwin' : 'linux'}-arm64`)}`); + const assertion = `${runtime}export PATH=${quote(`${guestRoot}/tools/node_modules/.bin`)}:$PATH; node ${quote(`${guestRoot}/sandbox-smoke.js`)} ${quote(`${guestRoot}/consumer/node_modules/ecc-universal`)} ${quote(guestRoot)}`; + const manifest = { name: runName, needs: { os: [tier === 1 ? 'linux' : 'macos'], arch: ['arm64'], + capabilities: ['clean-home', 'pkg-install', 'network:*'], trust: 'first-party', native: tier === 2 }, + resources: { cpu: 2, memory: tier === 1 ? '1GB' : '2GB', timeout: 900 }, + steps: { setup, assert: [assertion] }, report: 'install-diff' }; + for (const step of [...setup, assertion]) assert.ok(step.length <= 8192); + return manifest; +} + +async function serveInputs(files, host) { + assert.equal(net.isIP(host), 4, 'Artifact host must be an explicit IPv4 address'); + const token = crypto.randomBytes(24).toString('hex'); + const requests = []; + const server = http.createServer((request, response) => { + const file = request.url?.startsWith(`/${token}/`) ? request.url.slice(token.length + 2) : ''; + if (request.method !== 'GET' || !Object.hasOwn(files, file) || requests.length >= 12) { + response.writeHead(404).end(); return; + } + const bytes = files[file]; requests.push({ file, bytes: bytes.length, digest: digest(bytes) }); + response.writeHead(200, { 'Content-Length': bytes.length, 'Content-Type': 'application/octet-stream', 'Cache-Control': 'no-store' }); + response.end(bytes); + }); + server.requestTimeout = 150000; server.headersTimeout = 10000; + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, host, resolve); }); + return { url: `http://${host}:${server.address().port}/${token}`, requests, + close: () => new Promise(resolve => { server.close(resolve); server.closeAllConnections(); }) }; +} + +async function run(options) { + assert.ok([1, 2].includes(options.tier), 'Choose --tier 1 or --tier 2'); + assert.equal(process.arch, 'arm64', 'This acceptance currently certifies arm64 only'); + const repoRoot = path.resolve(__dirname, '../..'); + if (options.sandboxCli) assert.ok(path.isAbsolute(options.sandboxCli), '--sandbox-cli must be an absolute trusted executable'); + const sandboxBinding = resolveSandboxCli(options.sandboxCli || 'ecc-sandbox'); + const sandboxCli = sandboxBinding.path; + const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-profile-sandbox-')); + const resultRoot = path.resolve(options.output); + fs.mkdirSync(resultRoot, { recursive: true, mode: 0o700 }); + const runName = `ecc-profile-tier${options.tier}-${crypto.randomUUID()}`; + let server; + const receipt = { schemaVersion: 'ecc.context-sandbox-acceptance.v1', runName, tier: options.tier, + sourceRevision: (await command('git', ['rev-parse', 'HEAD'], repoRoot, 10000)).stdout.trim(), + sourceDirty: (await command('git', ['status', '--porcelain'], repoRoot, 10000)).stdout.length > 0, + sandboxCli, sandboxCliDigest: sandboxBinding.digest, + sandboxImplementationDigest: sandboxBinding.implementation.digest, reportValidated: false, + credentialsTransferred: false, artifactServerClosed: false, stageRemoved: false }; + try { + const packed = await command('npm', ['pack', '--json', '--pack-destination', stage], repoRoot); + assert.equal(packed.code, 0, packed.stderr); + const pack = JSON.parse(packed.stdout)[0]; + const archive = fs.readFileSync(path.join(stage, pack.filename)); + assert.ok(archive.length < 64 * 1024 * 1024, 'Package exceeds transfer bound'); + const verifier = fs.readFileSync(path.join(__dirname, 'sandbox-smoke.js')); + assert.ok(verifier.length < 65536); + const files = { 'package.tgz': archive, 'sandbox-smoke.js': verifier }; + fs.writeFileSync(path.join(stage, 'package.tgz'), archive, { mode: 0o600 }); + fs.writeFileSync(path.join(stage, 'sandbox-smoke.js'), verifier, { mode: 0o600 }); + receipt.packageDigest = digest(archive); receipt.verifierDigest = digest(verifier); + if (options.tier === 2) { + const host = options.artifactHost || Object.values(os.networkInterfaces()).flat() + .find(address => address.address === '192.168.64.1')?.address; + assert.ok(host, 'Specify --artifact-host with a host IP reachable from the guest'); + server = await serveInputs(files, host); + } + const manifest = manifestFor({ tier: options.tier, archiveDigest: receipt.packageDigest, + verifierDigest: receipt.verifierDigest, url: server?.url, runName }); + receipt.manifestDigest = digest(Buffer.from(JSON.stringify(manifest))); + const manifestPath = path.join(stage, 'sandbox.json'); + fs.writeFileSync(manifestPath, JSON.stringify(manifest), { mode: 0o600 }); + fs.copyFileSync(manifestPath, path.join(resultRoot, `${runName}.manifest.json`)); + verifySandboxCli(sandboxBinding); + const preview = await command(sandboxCli, ['run', manifestPath, '--local-only', '--dry-run'], stage, 30000); + fs.writeFileSync(path.join(resultRoot, `${runName}.preview.json`), preview.stdout, { mode: 0o600 }); + assert.equal(preview.code, 0, preview.stdout || preview.stderr); + const routes = JSON.parse(preview.stdout).routes; + assert.equal(routes?.length, 1, 'Expected exactly one admitted sandbox route'); + assert.equal(routes[0].result, 'routable'); + assert.equal(routes[0].tier, options.tier, 'Router chose a different tier'); + assert.equal(routes[0].backend, options.tier === 1 ? 'podman' : 'lume', 'Router chose a different backend'); + process.stderr.write(`Starting ${runName}; package ${receipt.packageDigest}\n`); + verifySandboxCli(sandboxBinding); + const result = await command(sandboxCli, ['run', manifestPath, '--local-only'], stage, 960000); + receipt.exitCode = result.code; receipt.signal = result.signal; + fs.writeFileSync(path.join(resultRoot, `${runName}.report.json`), result.stdout, { mode: 0o600 }); + fs.writeFileSync(path.join(resultRoot, `${runName}.stderr.log`), result.stderr, { mode: 0o600 }); + receipt.reportPath = path.join(resultRoot, `${runName}.report.json`); + assert.equal(result.code, 0, result.stdout || result.stderr); + verifySandboxCli(sandboxBinding); + const validated = validateReport(result.stdout, { tier: options.tier, manifest }); + receipt.reportValidated = true; + receipt.smokeDigest = digest(Buffer.from(JSON.stringify(validated.smoke))); + if (server) receipt.transfers = server.requests; + return receipt; + } finally { + if (server) { await server.close(); receipt.artifactServerClosed = true; } + else receipt.artifactServerClosed = true; + fs.rmSync(stage, { recursive: true, force: true }); receipt.stageRemoved = !fs.existsSync(stage); + fs.writeFileSync(path.join(resultRoot, `${runName}.driver.json`), JSON.stringify(receipt, null, 2), { mode: 0o600 }); + } +} + +if (require.main === module) { + const args = process.argv.slice(2); const options = {}; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--tier') options.tier = Number(args[++i]); + else if (args[i] === '--output') options.output = args[++i]; + else if (args[i] === '--artifact-host') options.artifactHost = args[++i]; + else if (args[i] === '--sandbox-cli') options.sandboxCli = args[++i]; + else throw new Error(`Unknown option: ${args[i]}`); + } + if (!options.output) throw new Error('--output is required'); + run(options).then(receipt => { process.stdout.write(`${JSON.stringify(receipt, null, 2)}\n`); process.exitCode = receipt.exitCode === 0 ? 0 : 1; }) + .catch(error => { process.stderr.write(`${error.stack}\n`); process.exitCode = 1; }); +} +module.exports = { command, manifestFor, resolveSandboxCli, serveInputs, validateReport, + verifySandboxCli, run }; diff --git a/docker/context-profiles/sandbox-smoke.js b/docker/context-profiles/sandbox-smoke.js new file mode 100644 index 000000000..ed68fe694 --- /dev/null +++ b/docker/context-profiles/sandbox-smoke.js @@ -0,0 +1,180 @@ +#!/usr/bin/env node +'use strict'; + +// Runs only inside the disposable acceptance environment. The supervisor owns +// the verdict and resource cleanup; this script supplies independently checked +// file and public-CLI assertions, not a production-readiness assertion. +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +function discoverPublishedSkills(packageRoot) { + const skillsRoot = path.join(packageRoot, 'skills'); + const nativeNames = new Set(); + return fs.readdirSync(skillsRoot, { withFileTypes: true }).filter(entry => { + if (!entry.isDirectory()) return false; + assert.equal(entry.isSymbolicLink(), false, 'Published skill directory must not be a symlink'); + return fs.existsSync(path.join(skillsRoot, entry.name, 'SKILL.md')); + }).map(entry => { + assert.match(entry.name, NAME, 'Canonical skill directory has an invalid name'); + const source = fs.readFileSync(path.join(skillsRoot, entry.name, 'SKILL.md'), 'utf8') + .replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n'); + const frontmatter = source.match(/^---\n([\s\S]*?)\n---(?:\n|$)/); + assert.ok(frontmatter, `Missing skill metadata: ${entry.name}`); + const names = frontmatter[1].split('\n').map(line => line.match(/^name:[ \t]*([a-z0-9]+(?:-[a-z0-9]+)*)[ \t]*$/)) + .filter(Boolean).map(match => match[1]); + assert.equal(names.length, 1, `Skill requires one plain native name: ${entry.name}`); + assert.equal(nativeNames.has(names[0]), false, `Duplicate native skill name: ${names[0]}`); + nativeNames.add(names[0]); + return { id: `skill:${entry.name}`, sourceName: entry.name, nativeName: names[0] }; + }).sort((left, right) => left.id.localeCompare(right.id)); +} + +function smoke(packageRoot, workspace) { + const cli = path.join(packageRoot, 'scripts/ecc.js'); + // macOS exposes /tmp as a system symlink to /private/tmp. Canonicalize the + // newly created directory so the production store can keep rejecting + // symlinked managed paths without rejecting this isolated acceptance root. + const root = fs.realpathSync(fs.mkdtempSync(path.join(workspace, 'lifecycle-'))); + const stateRoot = path.join(root, 'store'); + const nativeRoot = path.join(root, 'native'); + const sentinel = path.join(root, 'user-owned.txt'); + fs.writeFileSync(sentinel, 'preserve unrelated user content\n'); + const checks = []; + function invoke(args, expected = 0) { + const child = spawnSync(process.execPath, [cli, 'profile', ...args, '--json'], { + cwd: root, encoding: 'utf8', timeout: 90000, maxBuffer: 16 * 1024 * 1024, + }); + assert.equal(child.error, undefined, child.error?.message); + assert.equal(child.status, expected, child.stderr || child.stdout); + return JSON.parse(child.stdout); + } + function profile(args, expected) { return invoke([...args, '--state-root', stateRoot], expected); } + const preview = profile(['set', 'lean', '--dry-run']); + assert.equal(preview.status, 'success'); + assert.equal(fs.existsSync(stateRoot), false); + checks.push('dry-run-does-not-create-state'); + + const full = profile(['set', 'full', '--exclude', 'skill:python-testing']).store; + assert.ok(full.selectedIds.length > 200); + assert.ok(!full.selectedIds.includes('skill:python-testing')); + const verify = value => { + const carrier = JSON.parse(fs.readFileSync(path.join(path.dirname(value.generationRoot), 'carrier.json'))); + for (const file of carrier.files) { + const bytes = fs.readFileSync(path.join(value.generationRoot, file.destinationPath)); + assert.equal(bytes.length, file.bytes); + assert.equal(crypto.createHash('sha256').update(bytes).digest('hex'), file.digest); + } + return carrier.files.length; + }; + const fullFiles = verify(full); + const repeated = profile(['set', 'full', '--exclude', 'skill:python-testing']).store; + assert.equal(repeated.revision, full.revision); + profile(['set', 'lean', '--expected-revision', '0'], 1); + assert.equal(profile(['status']).store.revision, full.revision); + checks.push('idempotent-install-and-stale-revision-rejection'); + + const lean = profile(['set', 'lean']).store; + assert.equal(lean.selectedIds.length, 3); + const leanFiles = verify(lean); + assert.equal(profile(['status']).store.carrierDigest, lean.carrierDigest); + const restored = profile(['rollback']).store; + assert.equal(restored.carrierDigest, full.carrierDigest); + assert.deepEqual(restored.selectedIds, full.selectedIds); + checks.push('full-lean-full-byte-verified-rollback'); + + // Independent layout oracle: do not import the carrier generator or its tests. + const allSkills = discoverPublishedSkills(packageRoot); + const kernel = new Set(['skill:configure-ecc', 'skill:context-budget', 'skill:ecc-guide']); + const layouts = { claude: 'skills', codex: 'skills', pi: 'skills', + opencode: '.opencode/skills', cursor: '.cursor/skills' }; + const manifests = { claude: ['.claude-plugin/plugin.json', { name: 'ecc-context-carrier', skills: ['./skills/'] }], + codex: ['.codex-plugin/plugin.json', { name: 'ecc-context-carrier', skills: './skills/' }], + pi: ['package.json', { name: 'ecc-context-carrier', private: true, pi: { skills: ['./skills'] } }] }; + const walk = (directory, prefix = '') => fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => { + assert.equal(entry.isSymbolicLink(), false, 'Carrier resource must not be a symlink'); + const relative = path.posix.join(prefix, entry.name); + return entry.isDirectory() ? walk(path.join(directory, entry.name), relative) : [relative]; + }).sort(); + const matrix = []; + for (const [target, skillRoot] of Object.entries(layouts)) { + for (const base of ['lean', 'full']) { + const value = invoke(['set', base, '--target', target, + '--state-root', path.join(root, `matrix-${target}-${base}`)]).store; + const expected = base === 'lean' ? allSkills.filter(skill => kernel.has(skill.id)) : allSkills; + assert.deepEqual(value.selectedIds, expected.map(skill => skill.id)); + const expectedFiles = []; + for (const skill of expected) { + const source = path.join(packageRoot, 'skills', skill.sourceName); + for (const relative of walk(source)) { + const destination = path.posix.join(skillRoot, skill.nativeName, relative); + expectedFiles.push(destination); + assert.deepEqual(fs.readFileSync(path.join(value.generationRoot, destination)), fs.readFileSync(path.join(source, relative))); + } + } + if (manifests[target]) { + const [filename, expectedManifest] = manifests[target]; + expectedFiles.push(filename); + assert.deepEqual(JSON.parse(fs.readFileSync(path.join(value.generationRoot, filename))), expectedManifest); + } + assert.deepEqual(walk(value.generationRoot), expectedFiles.sort(), 'Unexpected, missing, or authority-bearing carrier file'); + matrix.push({ target, profile: base, skills: expected.length, files: verify(value), nativeInvocation: 'unobserved' }); + } + } + checks.push('ten-packed-carrier-layouts-exact-resource-bytes-and-file-set'); + + profile(['set', 'lean', '--selection', 'auto']); + const taskFile = path.join(root, 'task.json'); + const task = { sessionId: 'acceptance', taskId: 'task', revision: 1, phase: 'implement', + query: 'Use Python patterns to explain a list comprehension.', explicitIds: ['skill:python-patterns'] }; + fs.writeFileSync(taskFile, JSON.stringify(task)); + const loaded = profile(['resolve', '--task-input', taskFile, '--load']).selection; + assert.deepEqual(loaded.loadedIds, ['skill:python-patterns']); + assert.ok(loaded.resources.length > 0); + profile(['mode', 'suggest']); + assert.deepEqual(profile(['resolve', '--task-input', taskFile, '--load']).selection.loadedIds, []); + profile(['mode', 'manual']); + fs.writeFileSync(taskFile, JSON.stringify({ ...task, explicitIds: [] })); + assert.deepEqual(profile(['resolve', '--task-input', taskFile, '--load']).selection.loadedIds, []); + profile(['mode', 'auto']); + const pending = profile(['resolve', '--task-input', taskFile]).selection; + assert.equal(pending.receipt.decision, 'pending'); + assert.deepEqual(pending.loadedIds, []); + checks.push('auto-manual-suggest-and-pending-admission'); + + const native = profile(['prepare-native', '--native-root', nativeRoot]).native; + assert.equal(native.ready, true); + assert.equal(native.credentialsCopied, false); + assert.equal(native.selectedIds.length, 3); + const nativeDry = profile(['run', '--native-root', nativeRoot, '--task-input', taskFile, '--dry-run']).launch; + assert.equal(nativeDry.status, 'proposed'); + assert.deepEqual(nativeDry.selection.loadedIds, []); + checks.push('isolated-native-discovery-and-pinned-launch-preview'); + const interactive = profile(['start', '--native-root', nativeRoot, '--dry-run']).interactive; + assert.equal(interactive.status, 'proposed'); + assert.equal(interactive.launched, false); + checks.push('interactive-start-preview-without-authentication'); + + // A user edit inside managed content must block a switch, preserving bytes. + const current = profile(['status']).store; + const ownedFile = path.join(current.generationRoot, 'skills/ecc-guide/SKILL.md'); + fs.appendFileSync(ownedFile, '\nUser customization\n'); + profile(['set', 'full'], 1); + assert.match(fs.readFileSync(ownedFile, 'utf8'), /User customization/); + assert.equal(fs.readFileSync(sentinel, 'utf8'), 'preserve unrelated user content\n'); + checks.push('modified-managed-and-unrelated-files-preserved'); + return { schemaVersion: 'ecc.context-sandbox-smoke.v1', passed: true, os: process.platform, + arch: process.arch, node: process.version, packageVersion: require(path.join(packageRoot, 'package.json')).version, + fullSkills: full.selectedIds.length, fullFiles, leanSkills: lean.selectedIds.length, leanFiles, + nativeVersion: native.providerVersion, matrix, checks, authenticated: false, taskOutcomes: 'unobserved' }; +} + +if (require.main === module) { + try { process.stdout.write(`${JSON.stringify(smoke(path.resolve(process.argv[2]), path.resolve(process.argv[3])))}\n`); } + catch (error) { process.stderr.write(`${error.stack}\n`); process.exitCode = 1; } +} +module.exports = { discoverPublishedSkills, smoke }; diff --git a/docs/ATLAS-CLOUD-GUIDE.md b/docs/ATLAS-CLOUD-GUIDE.md index 83163c5b7..1700f16a1 100644 --- a/docs/ATLAS-CLOUD-GUIDE.md +++ b/docs/ATLAS-CLOUD-GUIDE.md @@ -1,6 +1,8 @@ # Atlas Cloud — LLM Provider Guide -[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=everything-claude-code) is a full-modal AI inference platform providing an OpenAI-compatible API for 59+ LLM models, image generation, and video generation. +> Sponsor status: Atlas Cloud is a past sponsor. This status change leaves the documented integration unchanged. + +[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=ECC) is a full-modal AI inference platform providing an OpenAI-compatible API for 59+ LLM models, image generation, and video generation. > Run or self-host any open-source model instead of using a managed API. Itô is ECC's preferred compute sponsor: [open the Itô dashboard to sign in and rent or manage GPUs](https://compute.itomarkets.com). Any GPU provider works. That sponsorship link is passive: it does not invoke an RFQ, reserve capacity, provision compute, or configure serving. Separately, the opt-in `ecc ito find` bridge invokes the explicitly configured canonical Itô CLI and submits a live authenticated RFQ; it does not reserve capacity. Managed inference through Itô is not live yet. diff --git a/docs/COMMAND-AGENT-MAP.md b/docs/COMMAND-AGENT-MAP.md index e2202d78c..3d618244f 100644 --- a/docs/COMMAND-AGENT-MAP.md +++ b/docs/COMMAND-AGENT-MAP.md @@ -76,6 +76,12 @@ This document lists each slash command and the primary agent(s) or skills it inv - **strategic-compact**: suggested at compaction points (hooks) - **unified-memory**: `ecc memory ...` and the opt-in `ecc-memory-mcp` server +## Direct-Use Skills + +| Skill | Purpose | Entry point | +|-------|---------|-------------| +| [osint-investigation](../skills/osint-investigation/SKILL.md) | Public-source investigation methodology: competing hypotheses, falsification, visual verification, and evidence tracking | Invoke the self-contained skill directly using available host capabilities. | + ## How to use this map - **Discoverability:** Find which command triggers which agent (e.g. “use `/code-review` for code-reviewer”). diff --git a/docs/COMMAND-REGISTRY.json b/docs/COMMAND-REGISTRY.json index e8b05bba5..b0f2eca31 100644 --- a/docs/COMMAND-REGISTRY.json +++ b/docs/COMMAND-REGISTRY.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "totalCommands": 95, + "totalCommands": 96, "commands": [ { "command": "aside", @@ -40,7 +40,7 @@ }, { "command": "code-review", - "description": "Code review — local uncommitted changes or GitHub PR (pass PR number/URL for PR mode)", + "description": "Code review — local uncommitted changes or GitHub PR (pass PR number/URL for PR mode). Use for a step-by-step PRP-style checklist review; for a multi-agent pass use /review-pr, and for the adversarially-verified Workflow pass use /orch-review.", "type": "testing", "primaryAgents": [], "allAgents": [], @@ -49,7 +49,7 @@ }, { "command": "cost-report", - "description": "Generate a local Claude Code cost report from the ECC cost-tracker metrics log.", + "description": "Generate a local Claude Code cost report from the ECC cost-tracker metrics log. Use for a terminal summary or CSV export of tracked spend; the cost-tracking skill covers the same metrics log for on-demand cost/budget questions asked in conversation.", "type": "testing", "primaryAgents": [], "allAgents": [], @@ -101,7 +101,7 @@ }, { "command": "ecc-guide", - "description": "Navigate ECC's current agents, skills, commands, hooks, install profiles, and docs from the live repository surface.", + "description": "Navigate ECC's current agents, skills, commands, hooks, install profiles, and docs from the live repository surface. This is the slash-command entrypoint for that navigation; the ecc-guide skill covers the same map for on-demand use in conversation.", "type": "review", "primaryAgents": [], "allAgents": [], @@ -325,7 +325,7 @@ }, { "command": "harness-audit", - "description": "Run a deterministic repository harness audit and return a prioritized scorecard.", + "description": "Run a deterministic repository harness audit and return a prioritized scorecard. Use for a deterministic overall repo-readiness scorecard; for a security-specific audit use the security-scan command.", "type": "testing", "primaryAgents": [], "allAgents": [], @@ -361,7 +361,7 @@ }, { "command": "hookify", - "description": "Create hooks to prevent unwanted behaviors from conversation analysis or explicit instructions", + "description": "Create hooks to prevent unwanted behaviors from conversation analysis or explicit instructions. Use to generate a new hook rule file from conversation analysis or a described behavior; the hookify-rules skill covers hookify rule syntax and patterns for authoring or editing rules directly.", "type": "general", "primaryAgents": [], "allAgents": [], @@ -464,7 +464,7 @@ }, { "command": "learn", - "description": "Extract reusable patterns from the current session and save them as candidate skills or guidance.", + "description": "Extract reusable patterns from the current session and save them as candidate skills or guidance. Use to review a session on demand and persist an approved skill file; continuous-learning-v2 is a separate, configurable Stop/PreToolUse/PostToolUse hook-observation and instinct-evolution system (background observer disabled by default) and is not an automatic equivalent of this command.", "type": "review", "primaryAgents": [], "allAgents": [], @@ -473,7 +473,7 @@ }, { "command": "loop-start", - "description": "Start a managed autonomous loop pattern with safety defaults and explicit stop conditions.", + "description": "Prepare a managed autonomous loop pattern with safety defaults and explicit stop conditions, then print the commands to launch and monitor it. Use to set up sequential, continuous-pr, rfc-dag, or infinite loop patterns with safety gates before starting one; the continuous-agent-loop skill covers the same pattern selection and quality-gate guidance for in-conversation use (supersedes the deprecated autonomous-loops skill).", "type": "testing", "primaryAgents": [], "allAgents": [], @@ -491,7 +491,7 @@ }, { "command": "marketing-campaign", - "description": "Plan and execute a full marketing campaign. Accepts a product brief and returns positioning, landing page copy, email sequence, social posts, ad variants, video scripts, and a content calendar. Can also review existing copy for conversion quality.", + "description": "Plan and execute a full marketing campaign. Accepts a product brief and returns positioning, landing page copy, email sequence, social posts, ad variants, video scripts, and a content calendar. Can also review existing copy for conversion quality. This is the slash-command entrypoint that delegates to the marketing-agent; prefer the marketing-campaign skill for the same end-to-end workflow in conversation.", "type": "testing", "primaryAgents": [], "allAgents": [], @@ -538,7 +538,7 @@ }, { "command": "multi-plan", - "description": "Create a multi-model implementation plan without modifying production code.", + "description": "Create a multi-model (Codex + Antigravity) implementation plan without modifying production code. Requires the external ccg-workflow runtime, not part of the base ECC install (see Prerequisite below). Use when the user explicitly wants dual-model plan drafts; for a single-model plan with no extra runtime use /plan.", "type": "orchestration", "primaryAgents": [], "allAgents": [], @@ -619,7 +619,7 @@ }, { "command": "orch-review", - "description": "Run the orch-review native Workflow over a diff (local changes or a GitHub PR) and report blocking vs advisory findings. Surface for the orch-review workflow.", + "description": "Run the orch-review native Workflow over a diff (local changes or a GitHub PR) and report blocking vs advisory findings. Surface for the orch-review workflow. Use for a native-Workflow, adversarially-verified multi-dimension review with fan-out and dedup; for the step-by-step checklist pass use /code-review, and for the multi-agent pass use /review-pr.", "type": "review", "primaryAgents": [], "allAgents": [], @@ -628,7 +628,7 @@ }, { "command": "plan-canvas", - "description": "Open a plan or HTML artifact in the browser Plan Canvas for annotate-and-approve review", + "description": "Open a plan or HTML artifact in the browser Plan Canvas for annotate-and-approve review. This is a thin slash-command entrypoint over the plan-canvas skill, which covers the full workflow and rules.", "type": "review", "primaryAgents": [], "allAgents": [], @@ -648,7 +648,7 @@ }, { "command": "plan", - "description": "Restate requirements, assess risks, and create step-by-step implementation plan. WAIT for user CONFIRM before touching any code.", + "description": "Restate requirements, assess risks, and create step-by-step implementation plan. WAIT for user CONFIRM before touching any code. Use for a single-model inline or PRD-driven implementation plan; for a dual-model (Codex/Antigravity) plan use /multi-plan, and for visual annotate-and-approve review of the resulting plan use /plan-canvas.", "type": "testing", "primaryAgents": [ "planner" @@ -681,7 +681,7 @@ }, { "command": "project-init", - "description": "Detect a project's stack and produce a dry-run ECC onboarding plan using the repository's install manifests and stack mappings.", + "description": "Detect a project's stack and produce a dry-run ECC onboarding plan using the repository's install manifests and stack mappings. Use to onboard ECC into a target project via a reviewable dry-run plan; for general feature discovery and navigation use the ecc-guide skill or /ecc-guide command instead.", "type": "testing", "primaryAgents": [], "allAgents": [], @@ -759,7 +759,7 @@ }, { "command": "prune", - "description": "Delete pending instincts older than 30 days that were never promoted", + "description": "Delete pending instincts older than 30 days that were never promoted. Thin CLI wrapper for continuous-learning-v2's instinct-cli.py prune command; use to clean up stale pending instincts that were never reviewed or promoted.", "type": "review", "primaryAgents": [], "allAgents": [], @@ -862,7 +862,7 @@ }, { "command": "review-pr", - "description": "Comprehensive PR review using specialized agents", + "description": "Comprehensive PR review using specialized agents (code-reviewer, comment-analyzer, pr-test-analyzer, silent-failure-hunter, type-design-analyzer, code-simplifier). Use for a multi-agent PR review pass; for the adversarially-verified Workflow pass use /orch-review, and for the standalone step-by-step checklist review use /code-review.", "type": "testing", "primaryAgents": [], "allAgents": [], @@ -914,7 +914,7 @@ }, { "command": "santa-loop", - "description": "Adversarial dual-review convergence loop — two independent model reviewers must both approve before code ships.", + "description": "Adversarial dual-review convergence loop — two independent model reviewers must both approve before code ships. Use for the CLI-driven version of this loop; wraps the santa-method skill.", "type": "review", "primaryAgents": [], "allAgents": [], @@ -932,7 +932,7 @@ }, { "command": "security-scan", - "description": "Run AgentShield against agent, hook, MCP, permission, and secret surfaces.", + "description": "Run AgentShield against agent, hook, MCP, permission, and secret surfaces. This is the slash-command entrypoint for that audit; prefer the security-scan skill for the same AgentShield audit in conversation.", "type": "review", "primaryAgents": [ "security-reviewer" @@ -965,7 +965,7 @@ }, { "command": "skill-create", - "description": "Analyze local git history to extract coding patterns and generate SKILL.md files. Local version of the Skill Creator GitHub App.", + "description": "Analyze local git history to extract coding patterns and generate SKILL.md files. Local version of the Skill Creator GitHub App. Use to generate new skills from local git history on demand; check the skill-scout skill first to avoid duplicating an existing local, marketplace, or GitHub skill.", "type": "testing", "primaryAgents": [], "allAgents": [], @@ -974,7 +974,7 @@ }, { "command": "skill-health", - "description": "Show skill portfolio health dashboard with charts and analytics", + "description": "Show skill portfolio health dashboard with charts and analytics. Use for the quantitative usage/success-rate dashboard; for a qualitative compliance or quality audit use the skill-stocktake skill.", "type": "review", "primaryAgents": [], "allAgents": [], @@ -1001,6 +1001,17 @@ "skills": [], "path": "commands/test-coverage.md" }, + { + "command": "token-card", + "description": "Render a token-usage stat card from local agent session logs and commit it to the repo. Invokes the token-card skill.", + "type": "general", + "primaryAgents": [], + "allAgents": [], + "skills": [ + "token-card" + ], + "path": "commands/token-card.md" + }, { "command": "update-codemaps", "description": "Scan project structure and generate token-lean architecture codemaps.", @@ -1040,7 +1051,7 @@ "statistics": { "byType": { "build": 2, - "general": 11, + "general": 12, "orchestration": 11, "planning": 2, "refactoring": 1, diff --git a/docs/ECC-2.0-REFERENCE-ARCHITECTURE.md b/docs/ECC-2.0-REFERENCE-ARCHITECTURE.md index fdd919762..c2c02a25c 100644 --- a/docs/ECC-2.0-REFERENCE-ARCHITECTURE.md +++ b/docs/ECC-2.0-REFERENCE-ARCHITECTURE.md @@ -71,7 +71,7 @@ Adopt from Orca, Superset, dmux, and Ghast: Repo work: -- `everything-claude-code`: extend the adapter compliance matrix and public +- `ECC`: extend the adapter compliance matrix and public scorecard onramp. - `ecc2`: surface session/worktree state through a stable local payload before adding hosted telemetry. @@ -129,7 +129,7 @@ Adopt from Meta-Harness, Autocontext, and Hermes Agent: Repo work: -- `everything-claude-code`: document scenario specs, verifier contracts, and +- `ECC`: document scenario specs, verifier contracts, and playbook promotion rules. - `ECC-Tools`: map analyzer findings to PR comments, check runs, and Linear tasks without flooding the workspace. diff --git a/docs/HERMES-HOOKS.md b/docs/HERMES-HOOKS.md new file mode 100644 index 000000000..d9d8c701a --- /dev/null +++ b/docs/HERMES-HOOKS.md @@ -0,0 +1,103 @@ +# Hermes Hooks — Native Guards Without the Hook Runtime + +Hermes ships its own shell-hook system: a `hooks:` block in `~/.hermes/config.yaml`, +Claude-Code-compatible JSON payloads on stdin, per-tool regex matchers, a +first-use consent allowlist, and `hermes hooks list | test | doctor` for +inspection. ECC's `hooks/hooks.json` is Claude Code format, and the Hermes +install target (`--target hermes`) deliberately does not install the hook +runtime — so Hermes operators get ECC's rules, skills, and commands with no +hooks at all. + +This directory ports the two ECC hooks that matter outside Claude Code as +self-contained Hermes-native hooks. No ECC runtime, no `run-with-flags.js`, no +`.pi` extension — just two Python scripts and a config block. + +## Why only these two + +ECC's hook graph is 24 commands. Most of it is Claude Code-specific +(plugin bootstrap, plan canvas, Claude session transcripts, cost tracking +against Claude's usage API) or duplicates the target harness's own gates. +These two are harness-independent policy: + +1. **config-protection** — blocks edits to existing linter/formatter configs + (`.eslintrc*`, `eslint.config.*`, `.prettierrc*`, `biome.json`, `ruff.toml`, + `.stylelintrc*`, markdownlint) and lint ratchet baselines. An agent under + pressure weakens the check instead of fixing the code; this hook refuses + that path. Creating a config from scratch and reading configs stay allowed. +2. **check-console-log** — warns (stderr, never blocks) when an edited + TS/JS file now contains `console.log()`. Tests, `.d.ts`, and scripts are + excluded. + +## Install + +Preferred — through the ECC installer (module `hermes-hooks`): + +```bash +./install.sh --target hermes --modules hermes-hooks --enable-hooks +# or: node scripts/install-apply.js --target hermes --modules hermes-hooks --enable-hooks +``` + +This lands `hooks/hermes/*.py` under `~/.hermes/hooks/hermes/` and this guide +under `~/.hermes/docs/`. Then register the hooks (the installer deliberately +never edits your `config.yaml`): + +```bash +# Scripts are already at ~/.hermes/hooks/hermes/. Point the commands at them — +# Hermes resolves hook commands verbatim, without ~ expansion. +HOOK_DIR="$HOME/.hermes/hooks/hermes" + +# Append to ~/.hermes/config.yaml (Hermes wants the mapping keyed by event): +cat >> ~/.hermes/config.yaml < /tmp/ecc-context.md printf '\n\n---\n\n' >> /tmp/ecc-context.md @@ -208,7 +208,7 @@ So the rule is simple: ## Related Work -- [Issue #1186](https://github.com/affaan-m/everything-claude-code/issues/1186) -- [Discussion #1077](https://github.com/affaan-m/everything-claude-code/discussions/1077) +- [Issue #1186](https://github.com/affaan-m/ECC/issues/1186) +- [Discussion #1077](https://github.com/affaan-m/ECC/discussions/1077) - [Antigravity Guide](./ANTIGRAVITY-GUIDE.md) - [Troubleshooting](./TROUBLESHOOTING.md) diff --git a/docs/MIGRATION-1X-TO-2.0.md b/docs/MIGRATION-1X-TO-2.0.md index 768b27e34..24e3f1e4d 100644 --- a/docs/MIGRATION-1X-TO-2.0.md +++ b/docs/MIGRATION-1X-TO-2.0.md @@ -1,4 +1,4 @@ -# Migrating From ECC 1.x (everything-claude-code) To 2.0 +# Migrating From ECC 1.x To 2.0 ECC 2.0 renamed the repo (`affaan-m/everything-claude-code` → `affaan-m/ECC`) and the plugin identifier (`everything-claude-code@everything-claude-code` → `ecc@ecc`). If you installed 1.x, follow this guide to upgrade cleanly. See also the [Naming + Migration Note](../README.md#naming--migration-note) in the README. diff --git a/docs/SELECTIVE-INSTALL-ARCHITECTURE.md b/docs/SELECTIVE-INSTALL-ARCHITECTURE.md index cd5e2226d..9f6ee1879 100644 --- a/docs/SELECTIVE-INSTALL-ARCHITECTURE.md +++ b/docs/SELECTIVE-INSTALL-ARCHITECTURE.md @@ -478,7 +478,7 @@ target-specific semantics. ## Design Constraints -1. Keep `everything-claude-code` as the canonical source repo. +1. Keep `ECC` as the canonical source repo. 2. Preserve existing `install.sh` flows during migration. 3. Support home-scoped and project-scoped targets from the same planner. 4. Make uninstall/repair/doctor possible without guessing. @@ -703,7 +703,7 @@ Suggested payload: "skippedModules": [] }, "source": { - "repoVersion": "2.2.2", + "repoVersion": "2.2.3", "repoCommit": "git-sha", "manifestVersion": 1 }, diff --git a/docs/SKILL-DEVELOPMENT-GUIDE.md b/docs/SKILL-DEVELOPMENT-GUIDE.md index fc1fb0690..ff05f7712 100644 --- a/docs/SKILL-DEVELOPMENT-GUIDE.md +++ b/docs/SKILL-DEVELOPMENT-GUIDE.md @@ -1,6 +1,6 @@ # Skill Development Guide -A comprehensive guide to creating effective skills for Everything Claude Code (ECC). +A comprehensive guide to creating effective skills for ECC. ## Table of Contents @@ -632,8 +632,8 @@ go build ./examples/... ### 1. Fork and Clone ```bash -gh repo fork affaan-m/everything-claude-code --clone -cd everything-claude-code +gh repo fork affaan-m/ECC --clone +cd ECC ``` ### 2. Create Branch @@ -908,6 +908,7 @@ npm run test:e2e ## Additional Resources +- [DevScratchpad AI Skill Studio](https://www.devscratchpad.tech/ai-skill-studio/claude-skills) - Third-party editor for drafting Claude Code `SKILL.md` files. - [CONTRIBUTING.md](../CONTRIBUTING.md) - General contribution guidelines - [project-guidelines-template](./examples/project-guidelines-template.md) - Project-specific skill template - [coding-standards](../skills/coding-standards/SKILL.md) - Example of standards skill diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 608a11d27..7c23b0a54 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -2,7 +2,7 @@ Community-reported workarounds for current Claude Code bugs that can affect ECC users. -These are upstream Claude Code behaviors, not ECC bugs. The entries below summarize the production-tested workarounds collected in [issue #644](https://github.com/affaan-m/everything-claude-code/issues/644) on Claude Code `v2.1.79` (macOS, heavy hook usage, MCP connectors enabled). Treat them as pragmatic stopgaps until upstream fixes land. +These are upstream Claude Code behaviors, not ECC bugs. The entries below summarize the production-tested workarounds collected in [issue #644](https://github.com/affaan-m/ECC/issues/644) on Claude Code `v2.1.79` (macOS, heavy hook usage, MCP connectors enabled). Treat them as pragmatic stopgaps until upstream fixes land. ## Community Workarounds For Open Claude Code Bugs @@ -97,4 +97,4 @@ exit 2 - [hook-bug-workarounds.md](./hook-bug-workarounds.md) for the shorter hook/compaction/MCP recovery checklist. - [hooks/README.md](../hooks/README.md) for ECC's documented hook lifecycle and exit-code behavior. - [token-optimization.md](./token-optimization.md) for cost and context management settings. -- [issue #644](https://github.com/affaan-m/everything-claude-code/issues/644) for the original report and tested environment. +- [issue #644](https://github.com/affaan-m/ECC/issues/644) for the original report and tested environment. diff --git a/docs/architecture/hud-status-session-control.md b/docs/architecture/hud-status-session-control.md index 8b773e20c..f8f1c269d 100644 --- a/docs/architecture/hud-status-session-control.md +++ b/docs/architecture/hud-status-session-control.md @@ -78,3 +78,43 @@ handoffs. The `ecc.hud-status.v1` payload is the common outer contract these surfaces can project into before ECC grows a dedicated full-screen HUD. + +## State Store Concurrency And Recovery + +ECC's file-backed state store serializes each synchronous query or transaction +with a sibling `.ecc-state.lock` file. It checks the current database +under that lock and publishes successful writes before releasing it. A handle +can reuse its last successful read snapshot when the file identity, size, and +modification/change timestamps match. Fixed SELECTs in the query API avoid +reloading or exporting an unchanged database; query results are still computed +afresh. Generic SQL, transactions, exports, and failed operations invalidate +reuse. Changes from another writer trigger a reload on the next operation. +Queries use one snapshot; closing a handle never writes an older snapshot back. +All concurrent writers must use this adapter; older ECC versions and external +SQLite writers do not participate in this locking protocol. + +An operation waits up to five seconds before reporting `STATE_STORE_BUSY`. +Retry when the other operation finishes. Locks are never stolen based on age: +a paused process may still be writing. After an abnormal exit, stop all ECC +processes using that database, inspect the PID and hostname in the lock file, +and remove only the leftover `.ecc-state.lock` file before retrying. Do not +remove the database itself. In-memory stores do not create lock files. + +The control-pane HTTP server runs board claims and moves in one-shot workers, +so waiting for another writer does not stall health checks or snapshots. A +mutation that times out while lock creation reports `EEXIST` returns HTTP 503 +with `code: STATE_STORE_BUSY` and `Retry-After: 1`. On Windows, lock creation +also retries `EPERM` within the same deadline because deletion may still be +pending. If the final attempt still reports `EPERM`, the original permission +error is preserved rather than replaced with busy or leftover-lock recovery +advice. Invalid mutations continue to return HTTP 400. Each worker +closes its store before reporting the result and exits naturally, including +when the requesting browser disconnects, to avoid interrupting a write. + +At most two board-mutation workers may exist in a server process, shared +across database paths and server instances. Admission happens before worker +creation. When both slots are occupied, extra requests receive the same +retryable HTTP 503 immediately; they are not queued or executed later. +A slot remains occupied until the worker actually exits, including after a +result, error, or browser disconnect. Construction failure releases the slot +immediately. This bounds waiting workers even while the database is locked. diff --git a/docs/bn/README.md b/docs/bn/README.md new file mode 100644 index 000000000..93418c0b3 --- /dev/null +++ b/docs/bn/README.md @@ -0,0 +1,256 @@ +**ভাষা:** [English](../../README.md) | [Português (Brasil)](../pt-BR/README.md) | [简体中文](../../README.zh-CN.md) | [繁體中文](../zh-TW/README.md) | [日本語](../ja-JP/README.md) | [한국어](../ko-KR/README.md) | [Türkçe](../tr/README.md) | [Русский](../ru/README.md) | [Tiếng Việt](../vi-VN/README.md) | [ไทย](../th/README.md) | [Deutsch](../de-DE/README.md) | [Español](../es/README.md) | [Українська](../uk-UA/README.md) | [اردو](../ur/README.md) | [Polski](../pl/README.md) | বাংলা + +# ECC + +![ECC - এজেন্টিক কাজের জন্য হার্নেস-নেটিভ অপারেটর সিস্টেম](../../assets/hero.png) + +[![Stars](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Fstars&style=flat)](https://github.com/affaan-m/ECC/stargazers) +[![Forks](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Fforks&style=flat)](https://github.com/affaan-m/ECC/network/members) +[![Contributors](https://img.shields.io/github/contributors/affaan-m/ECC?style=flat)](https://github.com/affaan-m/ECC/graphs/contributors) +[![npm ecc-universal](https://img.shields.io/npm/dw/ecc-universal?label=ecc-universal%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-universal) +[![npm ecc-agentshield](https://img.shields.io/npm/dw/ecc-agentshield?label=ecc-agentshield%20weekly%20downloads&logo=npm)](https://www.npmjs.com/package/ecc-agentshield) +[![GitHub App Install](https://img.shields.io/endpoint?url=https%3A%2F%2Fapi.ecc.tools%2Fbadge%2Finstalls&logo=github)](https://github.com/marketplace/ecc-tools) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](../../LICENSE) + +> **182K+ স্টার** | **28K+ ফর্ক** | **170+ অবদানকারী** | **12+ ভাষা ইকোসিস্টেম** | **Anthropic হ্যাকাথন বিজয়ী** + +--- + +
    + +**Language / ভাষা / 语言 / 語言 / 언어 / Dil / Язык / Ngôn ngữ** + +[**English**](../../README.md) | [Português (Brasil)](../pt-BR/README.md) | [简体中文](../../README.zh-CN.md) | [繁體中文](../zh-TW/README.md) | [日本語](../ja-JP/README.md) | [한국어](../ko-KR/README.md) | [Türkçe](../tr/README.md) | [Русский](../ru/README.md) | [Tiếng Việt](../vi-VN/README.md) | [ไทย](../th/README.md) | [Deutsch](../de-DE/README.md) | [Español](../es/README.md) | [Українська](../uk-UA/README.md) | [اردو](../ur/README.md) | [Polski](../pl/README.md) | [বাংলা](README.md) + +
    + +--- + +**AI এজেন্ট হার্নেসের জন্য পারফরম্যান্স অপ্টিমাইজেশন সিস্টেম। Anthropic হ্যাকাথন বিজয়ীর তৈরি।** + +এটি শুধু কনফিগারেশন ফাইলের সংকলন নয়। এটি একটি সম্পূর্ণ সিস্টেম: স্কিল, ইনস্টিংক্ট, মেমোরি অপ্টিমাইজেশন, ক্রমাগত শেখা, সিকিউরিটি স্ক্যানিং, এবং রিসার্চ-ফার্স্ট ডেভেলপমেন্ট। ১০ মাসেরও বেশি সময় ধরে প্রতিদিন নিবিড়ভাবে ব্যবহার ও বাস্তব পণ্য তৈরির মধ্য দিয়ে উন্নত করা প্রোডাকশন-লেভেল এজেন্ট, হুক, কমান্ড, রুল ও MCP কনফিগারেশন অন্তর্ভুক্ত। + +**Claude Code**, **Codex**, **Cursor**, **OpenCode**, **Gemini** এবং অন্যান্য AI এজেন্ট হার্নেসে ব্যবহারযোগ্য। + +--- + +## গাইড + +এই রিপোজিটরিতে শুধু কোড আছে। গাইডে সবকিছু ব্যাখ্যা করা হয়েছে। + + + + + + + + + + +
    + +The Shorthand Guide to Everything Claude Code + + + +The Longform Guide to Everything Claude Code + +
    সংক্ষিপ্ত গাইড
    সেটআপ, মৌলিক বিষয়, দর্শন। এটি আগে পড়ুন।
    বিস্তারিত গাইড
    টোকেন অপ্টিমাইজেশন, মেমোরি পার্সিস্টেন্স, মূল্যায়ন, প্যারালেল প্রসেসিং।
    + +| বিষয় | যা শিখবেন | +|-------|-----------| +| টোকেন অপ্টিমাইজেশন | মডেল নির্বাচন, সিস্টেম প্রম্পট অপ্টিমাইজেশন, ব্যাকগ্রাউন্ড প্রসেস | +| মেমোরি পার্সিস্টেন্স | সেশনের মধ্যে কনটেক্সট স্বয়ংক্রিয়ভাবে সংরক্ষণ/লোড করার হুক | +| ক্রমাগত শেখা | সেশন থেকে প্যাটার্ন স্বয়ংক্রিয়ভাবে বের করে পুনঃব্যবহারযোগ্য স্কিলে রূপান্তর | +| যাচাই লুপ | চেকপয়েন্ট বনাম ক্রমাগত মূল্যায়ন, গ্রেডিং ধরন, pass@k মেট্রিক | +| প্যারালেল প্রসেসিং | Git worktree, ক্যাসকেড পদ্ধতি, ইনস্ট্যান্স স্কেলিং | +| সাবএজেন্ট অর্কেস্ট্রেশন | কনটেক্সট সমস্যা, পুনরাবৃত্তি অনুসন্ধান প্যাটার্ন | + +--- + +## নতুন কী আছে + +### v2.2.2 — গাইডেড সেটআপ ও রিলিজ গেট (২০২৬ সেপ্টেম্বর) + +বর্তমান রিলিজ: **2.2.2** (২০২৬-০৯-২৯)। 2.2 ধারার প্রধান বৈশিষ্ট্য: + +- Claude Code, Codex ও Kimi Code জুড়ে ম্যানিফেস্ট-চালিত গাইডেড সেটআপ, সেই সঙ্গে ইনস্টল-স্টেট মালিকানা, `doctor`, `repair` ও `uninstall`। +- নেটিভ Antigravity ইনস্টল, পাতলা Pi অ্যাডাপ্টার, এবং Linux, macOS ও Windows-এ পরীক্ষিত প্যাকড-আর্টিফ্যাক্ট রিলিজ গেট। +- Plan Canvas ব্রাউজার রিভিউ, ইউনিফায়েড মেমরি ভল্ট (`ecc memory`) ও Itô কম্পিউট স্কিল পরিবার। + +### v2.1 — Plan Canvas, Kimi হার্নেস (২০২৬ জুলাই) + +- **Plan Canvas** — আপনার এজেন্ট একটি প্ল্যান লেখে, তারপর একটি ব্রাউজার ক্যানভাসে খোলে। ক্লিক করুন, টীকা যোগ করুন, সাইড রেইল থেকে চ্যাট করুন। +- **Kimi Code ইনস্টল টার্গেট** (`--target kimi`) — ECC সরাসরি Moonshot AI-এর Kimi Code CLI-তে ইনস্টল হয়। +- **GPU-তে সেলফ-হোস্ট** — Itô কম্পিউট স্পনসরের সাথে যাচাইকৃত পথ। +- **Hermes + OpenClaw ইনস্টল টার্গেট**, Codex নেভিগেশন গাইড। + +### v2.0 — এজেন্ট হার্নেস অপারেটিং সিস্টেম (২০২৬ জুন) + +- **কন্ট্রোল-প্লেন সাবস্ট্রেট** — সেশন অ্যাডাপ্টার + MCP ইনভেন্টরি। +- **`orch-*` অর্কেস্ট্রেটর পরিবার** — ক্রস-হার্নেস অর্কেস্ট্রেশন। +- **ECC Discord কমিউনিটি** চালু। + +সম্পূর্ণ পরিবর্তনের তালিকা [Releases](https://github.com/affaan-m/ECC/releases)-এ দেখুন। + +--- + +## দ্রুত শুরু + +২ মিনিটে সেটআপ সম্পন্ন করুন: + +### ১ম ধাপ: প্লাগইন ইনস্টল + +```bash +# মার্কেটপ্লেস যোগ করুন +/plugin marketplace add https://github.com/affaan-m/ECC + +# প্লাগইন ইনস্টল করুন +/plugin install ecc@ecc +``` + +### ২য় ধাপ: রুল ইনস্টল (আবশ্যক) + +> **গুরুত্বপূর্ণ:** Claude Code প্লাগইন `rules` স্বয়ংক্রিয়ভাবে ডেপ্লয় করতে পারে না। ম্যানুয়ালি ইনস্টল করতে হবে: + +```bash +git clone https://github.com/affaan-m/ECC.git +cd ECC +mkdir -p ~/.claude/rules/ecc +cp -R rules/common ~/.claude/rules/ecc/ +cp -R rules/typescript ~/.claude/rules/ecc/ # অথবা python, golang +``` + +### ৩য় ধাপ: ব্যবহার শুরু করুন + +```bash +/ecc:plan "ব্যবহারকারী অথেনটিকেশন যোগ করুন" +/plugin list ecc@ecc +``` + +**হয়ে গেছে!** এখন আপনি 69 এজেন্ট, 295 স্কিল এবং 94 কমান্ড ব্যবহার করতে পারবেন। + +--- + +## ক্রস-প্ল্যাটফর্ম সমর্থন + +এই প্লাগইন **Windows, macOS, Linux** সম্পূর্ণভাবে সমর্থন করে। সর্বোচ্চ সামঞ্জস্যের জন্য সমস্ত হুক ও স্ক্রিপ্ট Node.js-এ লেখা। + +### প্যাকেজ ম্যানেজার সনাক্তকরণ + +প্লাগইন আপনার পছন্দের প্যাকেজ ম্যানেজার (npm, pnpm, yarn, bun) স্বয়ংক্রিয়ভাবে সনাক্ত করে: + +1. **এনভায়রনমেন্ট ভেরিয়েবল**: `CLAUDE_PACKAGE_MANAGER` +2. **প্রজেক্ট কনফিগ**: `.claude/package-manager.json` +3. **package.json**: `packageManager` ফিল্ড +4. **লক ফাইল**: package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb +5. **গ্লোবাল কনফিগ**: `~/.claude/package-manager.json` +6. **ফলব্যাক**: `npm` + +--- + +## উপাদানসমূহ + +এই রিপোজিটরি একটি **Claude Code প্লাগইন** — সরাসরি ইনস্টল করুন অথবা কম্পোনেন্ট ম্যানুয়ালি কপি করুন। + +```text +ECC/ +|-- agents/ # বিশেষায়িত সাবএজেন্ট (69) +| |-- planner.md # ফিচার বাস্তবায়ন পরিকল্পনা +| |-- code-reviewer.md # মান ও নিরাপত্তা পর্যালোচনা +| |-- bengali-reviewer.md # বাংলা টেক্সট হ্যান্ডলিং রিভিউ +| +|-- skills/ # ওয়ার্কফ্লো ও ডোমেইন জ্ঞান (295) +| |-- bengali-nlp/ # বাংলা টেক্সট প্রসেসিং প্যাটার্ন +| |-- tdd-workflow/ # TDD পদ্ধতি +| |-- security-review/ # নিরাপত্তা চেকলিস্ট +| +|-- commands/ # স্ল্যাশ কমান্ড (94) +|-- rules/ # কোডিং নির্দেশনা +| |-- common/ # ভাষা-নিরপেক্ষ নীতি +| |-- typescript/ # TypeScript নির্দিষ্ট +| |-- python/ # Python নির্দিষ্ট +| +|-- hooks/ # ট্রিগার-ভিত্তিক অটোমেশন +|-- scripts/ # ক্রস-প্ল্যাটফর্ম Node.js স্ক্রিপ্ট +``` + +--- + +## কোন এজেন্ট ব্যবহার করবেন? + +| যা করতে চান | কমান্ড | এজেন্ট | +|-------------|--------|--------| +| নতুন ফিচার পরিকল্পনা | `/ecc:plan "ফিচার বর্ণনা"` | planner | +| টেস্ট আগে লিখে কোডিং | `tdd-workflow` স্কিল | tdd-guide | +| কোড রিভিউ | `/code-review` | code-reviewer | +| বিল্ড ব্যর্থতা সমাধান | `/build-fix` | build-error-resolver | +| নিরাপত্তা দুর্বলতা খোঁজা | `/security-scan` | security-reviewer | +| বাংলা টেক্সট কোড রিভিউ | bengali-reviewer এজেন্ট | bengali-reviewer | + +### সাধারণ ওয়ার্কফ্লো + +**নতুন ফিচার:** + +```text +/ecc:plan "OAuth দিয়ে অথেনটিকেশন যোগ" -> পরিকল্পনা তৈরি +tdd-workflow স্কিল -> টেস্ট-ফার্স্ট ডেভেলপমেন্ট +/code-review -> কোড পর্যালোচনা +``` + +**বাগ সমাধান:** + +```text +tdd-workflow স্কিল -> বাগ পুনরুৎপাদনকারী ব্যর্থ টেস্ট লেখা + -> সমাধান বাস্তবায়ন, টেস্ট পাস নিশ্চিত +/code-review -> রিগ্রেশন পরীক্ষা +``` + +--- + +## প্রায়শই জিজ্ঞাসিত প্রশ্ন + +
    +ইনস্টল করা এজেন্ট/কমান্ড কীভাবে দেখব? + +```bash +/plugin list ecc@ecc +``` + +
    + +
    +Cursor / OpenCode / Codex-এও কাজ করে? + +হ্যাঁ। ECC ক্রস-প্ল্যাটফর্ম — Claude Code, Cursor, OpenCode, Codex, Antigravity এবং আরও অনেক হার্নেসে কাজ করে। +
    + +
    +নতুন স্কিল বা এজেন্ট অবদান রাখতে চাই + +[CONTRIBUTING.md](../../CONTRIBUTING.md) দেখুন। সংক্ষেপে: ফর্ক করুন, ব্রাঞ্চ তৈরি করুন, স্কিল/এজেন্ট তৈরি করুন, PR জমা দিন। +
    + +--- + +## অবদান রাখুন + +**অবদান স্বাগত।** নির্দেশনার জন্য [CONTRIBUTING.md](../../CONTRIBUTING.md) দেখুন। + +--- + +## স্পনসর + +এই প্রজেক্ট বিনামূল্যে ওপেন সোর্স। স্পনসরদের সহায়তায় রক্ষণাবেক্ষণ ও উন্নয়ন চলে। + +[**স্পনসর হন**](https://github.com/sponsors/affaan-m) | [স্পনসর টায়ার](../../SPONSORS.md) + +--- + +## লাইসেন্স + +MIT - স্বাধীনভাবে ব্যবহার করুন, প্রয়োজনমতো পরিবর্তন করুন, এবং সম্ভব হলে অবদান রাখুন। + +--- + +**এই রিপোজিটরি সহায়ক হলে Star দিন। দারুণ কিছু তৈরি করুন।** diff --git a/docs/de-DE/README.md b/docs/de-DE/README.md index 07542e977..2d2ff4840 100644 --- a/docs/de-DE/README.md +++ b/docs/de-DE/README.md @@ -1,4 +1,4 @@ -**Sprache:** [English](../../README.md) | [Deutsch](README.md) | [Português (Brasil)](../pt-BR/README.md) | [简体中文](../../README.zh-CN.md) | [繁體中文](../zh-TW/README.md) | [日本語](../ja-JP/README.md) | [한국어](../ko-KR/README.md) | [Türkçe](../tr/README.md) | [Русский](../ru/README.md) | [Tiếng Việt](../vi-VN/README.md) | [ไทย](../th/README.md) | [Українська](../uk-UA/README.md) +**Sprache:** [English](../../README.md) | [Deutsch](README.md) | [Português (Brasil)](../pt-BR/README.md) | [简体中文](../../README.zh-CN.md) | [繁體中文](../zh-TW/README.md) | [日本語](../ja-JP/README.md) | [한국어](../ko-KR/README.md) | [Türkçe](../tr/README.md) | [Русский](../ru/README.md) | [Tiếng Việt](../vi-VN/README.md) | [ไทย](../th/README.md) | [Українська](../uk-UA/README.md) | [Polski](../pl/README.md) | [বাংলা](../bn/README.md) # ECC @@ -28,7 +28,7 @@ **Language / 语言 / 語言 / Dil / Язык / Ngôn ngữ** [English](../../README.md) | [**Deutsch**](README.md) | [Português (Brasil)](../pt-BR/README.md) | [简体中文](../../README.zh-CN.md) | [繁體中文](../zh-TW/README.md) | [日本語](../ja-JP/README.md) | [한국어](../ko-KR/README.md) - | [Türkçe](../tr/README.md) | [Русский](../ru/README.md) | [Tiếng Việt](../vi-VN/README.md) | [ไทย](../th/README.md) | [Українська](../uk-UA/README.md) + | [Türkçe](../tr/README.md) | [Русский](../ru/README.md) | [Tiếng Việt](../vi-VN/README.md) | [ไทย](../th/README.md) | [Українська](../uk-UA/README.md) | [Polski](../pl/README.md) | [বাংলা](../bn/README.md) @@ -86,12 +86,12 @@ Dieses Repo enthält ausschließlich den rohen Code. Die Leitfäden erklären al -The Shorthand Guide to Everything Claude Code +The Shorthand Guide to ECC -The Longform Guide to Everything Claude Code +The Longform Guide to ECC @@ -1348,7 +1348,7 @@ Kanonische Anthropic-Skills wie `claude-api`, `frontend-design` und `skill-creat | documentation-lookup | Aktuelle Bibliotheks- und Framework-Dokumentation über Context7 MCP | | e2e-testing | Playwright-E2E-Tests | | eval-harness | Eval-getriebene Entwicklung | -| everything-claude-code | Entwicklungskonventionen und -Patterns für das Projekt | +| ecc-conventions | Entwicklungskonventionen und -Patterns für das Projekt | | exa-search | Neural Search über Exa MCP für Web-, Code-, Unternehmensrecherche | | fal-ai-media | Vereinheitlichte Mediengenerierung für Bilder, Video und Audio | | frontend-patterns | React-/Next.js-Patterns | @@ -1747,8 +1747,8 @@ Dieses Projekt ist kostenlos und Open Source. Sponsoren helfen, es gepflegt und ## Links -- **Kurzleitfaden (Hier starten):** [The Shorthand Guide to Everything Claude Code](https://x.com/affaanmustafa/status/2012378465664745795) -- **Langleitfaden (fortgeschritten):** [The Longform Guide to Everything Claude Code](https://x.com/affaanmustafa/status/2014040193557471352) +- **Kurzleitfaden (Hier starten):** [The Shorthand Guide to ECC](https://x.com/affaanmustafa/status/2012378465664745795) +- **Langleitfaden (fortgeschritten):** [The Longform Guide to ECC](https://x.com/affaanmustafa/status/2014040193557471352) - **Security-Leitfaden:** [Security-Leitfaden](../../the-security-guide.md) | [Thread](https://x.com/affaanmustafa/status/2033263813387223421) - **Folgen:** [@affaanmustafa](https://x.com/affaanmustafa) diff --git a/docs/design/context-carriers.md b/docs/design/context-carriers.md new file mode 100644 index 000000000..ee4f61212 --- /dev/null +++ b/docs/design/context-carriers.md @@ -0,0 +1,79 @@ +# Skill-only context carriers + +Status: P2a/P2b/P2c implemented and focused checks passed, following the read-only foundation in [PR #3037](https://github.com/affaan-m/ECC/pull/3037). This is a source implementation contract, not an installation, activation, or native discovery certificate. + +M1 context profiles determine proposed discovery. Carrier layouts map that proposal into a portable file inventory. Sandbox authority, hooks, tool permissions, task routing, and user settings remain separate. See the [profile contract](context-profiles.md) for Lean/Full and selection semantics. + +## Three bounded slices + +| Slice | Contract | Boundary | +| --- | --- | --- | +| P2a resource declarations | Registry and plan entries preserve sorted explicit `requiredResources` | `sourcePath` is the mandatory entrypoint; empty declarations do not prove resource or workflow closure | +| P2b carrier planning | `planContextCarrier(options)` emits `ecc.context-carrier.v1` | Pure read-only file projection; no output destination, installed-state probe, or native activation | +| P2c acceptance fixtures | An independently checked disposable tree demonstrates structural materialization | Test-only writer owns its temporary parent; observed file equality does not prove native discovery or invocation | + +The generated registry/plan v1 shapes gain an additive `requiredResources` field. Existing profile IDs and declaration schemas retain their meanings. Inspection consumers should tolerate additional output fields. A new carrier consumer must reject an older object missing declaration metadata instead of interpreting it as an empty declaration. + +`sourcePath` remains required even when absent from the explicit declaration list. An explicit declaration of `SKILL.md` remains visible. The effective required set is their union, while `resources` inventories all included bundled files. Resource-content digests retain their exact byte semantics; registry and plan provenance also bind declaration changes. + +## User-facing preview + +```sh +node scripts/ecc.js profile carrier lean@1 --target codex --json +node scripts/ecc.js profile carrier lean@1 --target claude --include skill:security-review --json +node scripts/ecc.js profile carrier full@1 --target pi --exclude skill:python-patterns --selection manual --json +``` + +The packaged command uses `ecc profile carrier` with the same arguments. Defaults match profile preview: Lean, Codex, and Auto selection intent. Auto remains recorded intent only. The JSON inspection envelope reports a warning and unobserved activation; its `carrier` object lists exact proposed files and source bindings. No files are written. Destination and hook flags are rejected. + +The [carrier library](../../scripts/lib/context-carriers.js) accepts the same source/profile/target/selection options as compilation. It compiles from canonical sources, verifies the loaded registry matches the compiled plan, and rejects externally supplied replacement plans or unknown options. Its output is checked against the [carrier schema](../../schemas/context-carrier.schema.json). + +The schema validates output shape and rejects unknown fields. Semantic relationships such as exact target/layout agreement and resource completeness are enforced by the generator and independent fixture verifier. Schema validation alone cannot certify a supplied artifact. + +## Layouts preserve the exact selection + +| Target | Skill root within a future isolated carrier | Generated discovery manifest | +| --- | --- | --- | +| Claude | `skills/` | `.claude-plugin/plugin.json` | +| Codex | `skills/` | `.codex-plugin/plugin.json` | +| Pi | `skills/` | `package.json` with the narrow Pi skills declaration | +| OpenCode | `.opencode/skills/` | None; use the native project skills convention | +| Cursor | `.cursor/skills/` | None; use the native project skills convention | + +These are implemented layout proposals, not five certified runtime integrations. Other recognized target IDs return `status: unsupported` with an empty file list and retained proposal inventory; unknown target IDs fail. A legacy install-module declaration gap remains visible independently of layout availability. + +Every selected skill contributes its complete bundled tree. Canonical IDs remain stable; destination directories use validated native metadata names, which can differ from canonical directory IDs. Full honors explicit exclusions. Routed and excluded skills contribute no carrier files; routed retrieval remains future work rather than an extra undisclosed bootstrap skill. Generated manifests use a narrow field allowlist and never inherit ECC's monolithic hooks, MCP configuration, agents, commands, or broad instruction lists. + +Copy operations retain binary byte digests and sizes rather than embedding decoded bodies. Generated manifests bind exact UTF-8 bytes. Required resources must exist in the selected inventory. Duplicate native names, case-colliding paths, unsafe paths, nested case-insensitive skill entrypoints, or source-plan drift fail before a carrier can be returned. + +Preserved skill files can contain their own authority-related metadata, including `allowed-tools`. Planning treats those bytes as data and grants no authority. Before native activation, resolve skill-level metadata against retained user consent and trusted policy; omitting hook and MCP manifest fields is insufficient for that gate. + +The artifact binds the source registry, profile, compiler, plan, and adapter implementation/schema digests. `carrierDigest` binds the full proposed artifact before adding its own digest. Hashes are content bindings, not signatures or attestations. No runtime execution or executable-mode preservation is certified. + +## Acceptance evidence has a narrow meaning + +The source-only fixture helper creates its own temporary parent, stages pinned source bytes, and compares an independently expected tree with observed files. It does not accept a user destination. Tests cover resource omission, extra or changed bytes, binary preservation, source drift, symlink substitution, failed-write cleanup, and unrelated sentinel preservation. Generated content must match its independently compiled expectation; a carrier's self-reported digest cannot redefine acceptance. + +Structural evidence and native evidence are distinct: + +| Claim | Required evidence | +| --- | --- | +| Materialized file set and byte integrity | Fixture comparison against independent expected source and generated content | +| Bundled resource completeness and relocation | All selected resources present; verification still works after source removal | +| Native visible IDs and exclusions | Future fresh-session probe for a named provider version and install path | +| Skill loading and useful workflow execution | Future native invocation and task-outcome checks | +| Activation, reload, rollback, hooks, whole-context cost | Later dedicated lifecycle, consent, and measurement gates | + +No structural result may set native discovery, invocation, activation, or token usage to verified. Whole bundled trees also do not prove complete cross-skill or external runtime dependency closure. + +## Contributor and provider provenance + +The architecture reuses Jeffrey Montoya's [#2788](https://github.com/affaan-m/ECC/pull/2788) ideas of whole-skill copying and one preview/build inventory. Ownership receipts and staging/rollback mechanics remain queued for P3. Its extra catalog bootstrap and copying of all unselected skills are not carried forward because they would change the approved selection or leak exclusions. + +LovePlayCode's [#2844](https://github.com/affaan-m/ECC/pull/2844) grouping and deterministic selection ideas inform the shared inventory. Its broad Full directory projection cannot preserve explicit exclusions, so the carrier uses the canonical selected IDs instead. These source contributions remain independently reviewable with attribution; this work does not merge or close their PRs. + +Codex and Pi layout fields are grounded in ECC's existing native manifests; provider mirrors are not used as canonical resources. Claude's [documented path rules](https://code.claude.com/docs/en/plugins-reference#path-behavior-rules) require install-path-specific exclusion tests because default discovery can be additive. OpenCode's [skill-name rules](https://opencode.ai/docs/skills/#validate-names) require the native directory name to match metadata. These constraints inform projection fixtures and do not substitute for fresh-session observations. + +## Next gate + +Earn native discovery and exclusion evidence using isolated homes and exact provider versions. Then implement transactional activation and recovery using the accepted ownership/receipt contract. Task routing, automatic switching, hook consent integration, and release-default changes remain behind their later gates. diff --git a/docs/design/context-carriers.tdd.md b/docs/design/context-carriers.tdd.md new file mode 100644 index 000000000..8655fee37 --- /dev/null +++ b/docs/design/context-carriers.tdd.md @@ -0,0 +1,78 @@ +# ECC-029 carrier slice evidence + +Date: September 8, 2026. Milestone: M1 canonical context profiles. The P2a/P2b/P2c stack follows [PR #3037](https://github.com/affaan-m/ECC/pull/3037), based on main `5064474d4d762dc9640234a41617cccb79185cec`. Environment: macOS 26.6.2 arm64, Node 24.9.0, ECC 2.2.1. This source-only report records local development evidence. The packed [carrier contract](context-carriers.md) defines the public boundaries. + +## Test-first slices and review regressions + +| Slice or regression | RED checkpoint | GREEN checkpoint and evidence | +| --- | --- | --- | +| P2a explicit required-resource output | `3b3a7c72`: 3 resource cases passed, 10 failed for missing declarations | `935861ac`: 13 resource cases pass; resource byte digests retain their meaning, while declaration changes affect provenance | +| P2b pure five-layout file planner | `09ec70d9`: 20 cases fail for the intended missing public module | `bdb317eb`: 22 planner cases pass, including subsequent path-alias regressions | +| Read-only carrier CLI journey | `3b3a7c72`: 1 CLI case passed, 6 failed for missing command behavior | `bdb317eb`: 7 cases pass; deterministic JSON, five layouts, exclusions, unsupported targets, argument rejection, unchanged temporary caller state | +| Packed public surface | `a2963136`: both publish-surface cases fail for the missing carrier contract | `bdb317eb`: 2 cases pass with the library, schema and public contract included | +| Portable path collision rejection | `337c560c`: 20 planner cases passed, 2 failed for case/NFC-equivalent directory prefixes | `bdb317eb`: all 22 pass; aliases with different child names fail before returning an artifact | +| P2c disposable acceptance fixture | `09ec70d9`: the intended helper entry point is absent | `fccadba2`: 19 fixture cases pass, including independent expected-plan and manifest checks, source removal, binary bytes, tampering, symlinks and cleanup | +| Fixture aliases fail before writes | `9454a0d5`: 17 cases passed, 2 failed because staging performed 6 writes before rejection | `fccadba2`: both adversarial cases reject with zero writes | + +Preserve the RED/GREEN commits. Independent security/code review checked the file planner and CLI, reproduced the portable ancestor collision, and approved the corrected implementation. The acceptance helper received separate review and remains test-only. Source files and skill bodies are data during these checks; scripts are copied but never executed. Narrow manifests omit hooks and MCP settings, while preserved authority-related skill metadata remains a separate pre-activation policy gate. + +## Focused checks and coverage + +```sh +./node_modules/.bin/c8 --all \ + --include='scripts/lib/context*.js' \ + --include='scripts/profile.js' \ + --include='scripts/ci/validate-context-profiles.js' \ + --reporter=text --reporter=json-summary \ + --reports-dir=/tmp/ecc-029-carrier-coverage \ + --check-coverage --lines=80 --functions=80 --branches=80 --statements=80 \ + node --test tests/lib/context-pack-registry.test.js \ + tests/lib/context-profiles.test.js tests/lib/context-resources.test.js \ + tests/lib/context-carriers.test.js tests/lib/context-carrier-fixture.test.js \ + tests/scripts/profile.test.js tests/scripts/profile-carrier.test.js \ + tests/ci/context-profiles.test.js +node tests/scripts/npm-publish-surface.test.js +npm run lint +npm test +git diff --check +``` + +Focused results: 119 logical cases passed, zero failed or skipped. The breakdown is 18 registry, 12 compiler, 13 resource, 22 carrier, 19 fixture, 25 original CLI, 7 carrier CLI and 3 CI cases. Node's outer TAP summary reports 93 because the original CLI and CI files each wrap their own cases. + +Runtime coverage: 98.33% statements/lines, 91.16% branches and 100% functions. All thresholds pass. A separate test-helper-inclusive review run reports 100% statements/lines/functions and 90.54% branches for that helper. Runtime coverage excludes test infrastructure. + +## Real inventory and package verification + +All ten source-tree Lean/Full combinations across Claude, Codex, Pi, OpenCode and Cursor passed disposable structural verification against the actual canonical inventory. Full contains 286 skills and 464 bundled files. Claude, Codex and Pi add one narrow manifest, giving 465 files; OpenCode and Cursor retain 464. Lean contains 3 skills and 3 source files, plus a manifest where applicable. + +At implementation head `d52d3430`, the full `npm test` exited 0 and its legacy aggregate reported 4,423 passed and zero failed. That aggregate does not separately count the new node:test cases, which are reported explicitly above. Full ESLint/Markdown lint and whitespace checks passed before this source-only evidence update. + +A real `npm pack` ran the normal prepack build. The archive SHA-256 was `dd0577889bfa09071cbd87b430b200f8d0eaf036c6b0fb583dc71ae2f855fd78`. A disposable consumer installed it with `npm install --offline --ignore-scripts --omit=dev --no-audit --no-fund --userconfig=/dev/null`, using a task-local cache explicitly primed online during the preceding PR-readiness check. This proves an offline cached install, not a dependency-free install. + +The installed public dispatcher produced all ten Lean/Full carrier objects with deep equality to the checkout, including their complete digests. Each installed artifact then passed structural materialization using the installed package's own canonical skill resources and an independently compiled expected plan. Full's 464 bundled resources were verified in every layout. The isolated subprocess environment was allowlisted and its disposable home remained absent. Packed runtime resolution confirmed js-yaml 4.3.2. + +A separate policy simulation denying Windows file symlinks passed all 54 new resource/carrier/fixture cases with zero skips. Directory links use junctions on Windows. This simulation supplies no native Windows filesystem or provider evidence. + +Hosted review of the prerequisite PR subsequently identified dry-run argument ordering and directory-enumeration bounds. Fixes and their dependent-stack revalidation follow; the `d52d3430` results remain a pinned earlier checkpoint. + +## September 9 review hardening and final verification + +The stack inherits the prerequisite PR's global dry-run fix `9b5e3934` and bounded-reader fix `5f9503e6`. Their RED checkpoints are `c373b7fe` (27 CLI passes, 4 failures) and `ea00894d` (7 support-test failures). The reader keeps all file-byte and identity protections and now limits incremental directory enumeration. Public context-profile documentation describes the exact limits. Source-reader extraction received independent security review; its largest function is 20 lines. + +Carrier checkpoint `ebd43bef` independently reproduced the global flag failure: 6 CLI cases passed and 1 failed. Merging the prerequisite fixes in `072a3160` makes all 7 carrier CLI cases pass, including a leading global flag and a flag between an option and its value. + +The first merged focused run passed 98 outer tests and failed 2 alias regressions because their old `readdirSync` mocks no longer supplied synthetic alias names to the incremental reader. Test-only correction `46924366` models those same source directories through `opendirSync` instead. Both case/NFC spellings and the mandatory zero-staging-write assertions remain unchanged; independent review reran all 19 fixture cases successfully. No runtime change was needed. + +Final focused execution uses the coverage command above plus `tests/lib/context-profile-support.test.js`. It passes 132 logical cases, zero failures or skips: 18 registry, 7 support, 12 compiler, 13 resource, 22 carrier, 19 fixture, 31 original CLI, 7 carrier CLI and 3 CI. Outer TAP reports 100 passes. Runtime coverage is 98.37% statements/lines, 91.43% branches and 100% functions, with every threshold passing. + +Both prerequisite and carrier full-suite commands exited 0 with legacy aggregates of 4,429 passed and zero failed. The carrier run began at `072a3160`; its test-only mock correction was applied before the runner reached that fixture file, whose final 19/19 result was observed in the complete run. Runtime and packed files remained unchanged throughout. The final focused run independently exercised the corrected tests. Later changes update source-only evidence. + +The rebuilt carrier archive at runtime revision `072a3160` has SHA-256 `45ef651dfab1a9da9af7b7b4b4546c84bc6b325a31a95dac47d52def060649e6`. Its offline cached install and all ten installed-provider-layout Lean/Full parity and structural checks passed again. The archive has 2,628 entries; none of these checks launches a provider. A Git diff verifies final runtime, schemas, manifests, package declarations, lockfiles and packed contracts are byte-identical to that revision. + +The prerequisite runtime at `e54fd44c` separately passes 71 focused cases, 98.49% statements/lines, 90.46% branches and 100% functions, plus the full 4,429 aggregate. Its rebuilt offline-consumer archive has SHA-256 `e96826df9b336e180408c7765dcd4e09fca2fb7eb7252cbf84f2ff99d036b1a7`. Later prerequisite commit `be393cb0` only reconciles the source-only dependency evidence. Hosted CI is still pending for the latest PR revision. + +Lower-priority review suggestions remain explicit follow-ups: failing projection labels, one exported supported-profile list, richer budget-failure inspection and preserving dual CLI/snapshot diagnostics. Process-lifetime compiler caching is deferred until an immutable snapshot and invalidation contract exists. The current schema fixes the budget at 8,000; alternate ceilings are rejected. Private fixtures currently have only synchronous callers, and noncanonical skill-root directories remain rejected under the existing inventory policy. + +## Claims deliberately left unobserved + +Native discovery, exact native exclusions, invocation, executable-mode needs, workflow outcomes, activation, hook consent, rollback, automatic routing and actual token savings still require their own gates. Schema validation checks shape; it cannot certify supplied artifact semantics. The independently compiled fixture checks exact layout, selection, file set and bytes. It uses a trusted private temporary parent and does not certify an arbitrary-destination transaction writer against hostile concurrent mutation. No native provider, model, container or VM was launched, and no package was published. diff --git a/docs/design/context-profile-ai-evaluation.md b/docs/design/context-profile-ai-evaluation.md new file mode 100644 index 000000000..f9d790df7 --- /dev/null +++ b/docs/design/context-profile-ai-evaluation.md @@ -0,0 +1,128 @@ +# Context profile AI evaluation + +This development-only evaluator measures whether Lean with Auto selection completes real +coding tasks as well as Full. It lives in `docker/context-profiles/` and is not part of +the published npm package. No provider call occurs without an injected test provider or +the explicit `--allow-real-provider` flag. Reports never approve a release on their own. + +## What it compares + +`docker/context-profiles/ai-corpus.json` fixes 30 small coding tasks and at least 30 +selection probes before execution. Each task is a tiny CommonJS workspace with a bug or +missing behavior; about two thirds benefit from a specific ECC skill and the rest need +none, including tasks with misleading workflow vocabulary. Each task carries a hidden +grader that the agent never sees. + +Every task runs in all three arms, in separate fresh workspaces with identical files. +Arm order rotates by task and repeat to reduce fixed ordering effects. + +| Arm | Codex install | ECC task context | +| --- | --- | --- | +| Full | Real Full install: every skill natively discoverable | None; the host chooses from its own catalog | +| manual Lean | Real Lean install: three-entry core | The task's preregistered skill, loaded by the launcher | +| Auto Lean | Same Lean install | The resolver's shortlist plus one bounded agent proposal | + +Both installs are prepared through the isolated native adapter (`applyStore` then +`prepareNativeProfile`), the same path users get. Before every call the evaluator +re-verifies the install's recorded inventory and stops with `environment-drift` if +Codex changed discovery configuration or skill bytes. Full therefore measures today's +native experience, including its real startup context, rather than a simulated catalog. + +## Hidden grading + +After the agent exits, the evaluator writes the grader into the workspace and runs it +with Node. Exit zero passes. An agent that plants its own grader file fails. On Node 20 +and later the grader runs under Node's permission model with read access limited to the +workspace, so it cannot write files, spawn processes or start workers. Network access is +not restricted by that model; run live evaluations inside the Tier 1 sandbox when that +matters. Provider exit status and claimed success alone never pass a task. + +`tests/lib/context-profile-eval-corpus.test.js` proves every grader fails on the initial +files and passes on an independent reference solution kept in +`tests/fixtures/context-eval-references.json`, which is never shown to the agent. + +## Setup with a ChatGPT subscription + +The Codex adapter supports exactly Codex 0.154.0 and 0.155.1. Install a pinned copy +next to, not over, your everyday Codex: + +```sh +npm install --prefix ~/.ecc-eval/codex @openai/codex@0.155.1 +``` + +Create a dedicated login home and sign in once. The file credential store keeps the +login in `auth.json`, which the evaluator can lease: + +```sh +mkdir -m 700 -p ~/.ecc-eval/auth +CODEX_HOME=~/.ecc-eval/auth ~/.ecc-eval/codex/node_modules/.bin/codex login \ + -c 'cli_auth_credentials_store="file"' +chmod 600 ~/.ecc-eval/auth/auth.json +``` + +For each call, the evaluator copies `auth.json` into the isolated install's +`CODEX_HOME`, runs Codex, writes any refreshed tokens back to the login home, and always +deletes the copy. It refuses a login home that is your own `~/.codex` or `CODEX_HOME`, +or that other users can read. It never reads your everyday Codex home. Calls run +sequentially, so refreshed tokens cannot race. Usage counts against your subscription's +rate limits. `CODEX_API_KEY` remains an alternative when no `--auth-home` is given. + +## Running + +Register first, then execute against the retained registration: + +```sh +CODEX=$(realpath ~/.ecc-eval/codex/node_modules/@openai/codex/bin/codex.js) +node docker/context-profiles/ai-eval.js --plan \ + --executable "$CODEX" --model YOUR_PINNED_MODEL > /tmp/ecc-ai-registration.json +node docker/context-profiles/ai-eval.js --allow-real-provider \ + --registration /tmp/ecc-ai-registration.json \ + --executable "$CODEX" --model YOUR_PINNED_MODEL \ + --auth-home ~/.ecc-eval/auth > /tmp/ecc-ai-metrics.json +``` + +The registration binds corpus bytes, registry resource digests, both profile plans, +evaluator, launcher, resolver and native adapter digests, model and executable +fingerprints, case order, repeats and analysis thresholds. A changed source stops +execution. Repeated sampling requires the same `--repeats N` at registration and +execution. A changed corpus is a new experiment, never a silent replacement for failed +cases. + +Defaults are 300 provider calls, a one-hour overall deadline and five minutes per task +call. Hard limits are 2,000 calls, four hours and ten minutes per call. Proposal calls +retain the launcher's tighter timeout. A single pass of the bundled corpus makes about +90 task calls plus up to one proposal call per Auto task and selection probe. Every +scheduled outcome remains in the denominator after a budget, deadline, provider, drift +or grading failure. Workspaces and installs are removed in `finally`. + +## Metrics and statistical limits + +The JSON report is built from an allowlist: case IDs, arm, repeat, pass/fail, controlled +failure codes, selected skill IDs, digests, call counts, elapsed time, numeric usage, +install skill counts and the authentication mode. Transcripts, prompts, paths, stderr +and credentials are never emitted or persisted. Valid usage requires one +`turn.completed` record with nonnegative integer input, cached-input and output +counters. Missing or malformed usage is unknown, never zero. + +Selection accuracy includes a descriptive 95% Wilson interval. Paired pass-rate +differences against Full use a conservative bounded Hoeffding interval with Bonferroni +correction across the two comparisons. Repeats are averaged within distinct task IDs +first, so repeating tasks never creates new independent tasks. The corpus is purposive, +so no production population generalization is justified. + +The preregistered minimum is 30 distinct tasks and 30 selection cases, with a +five-percentage-point noninferiority margin. With 30 tasks the Hoeffding interval is +still wide, so a first live run is expected to report `review-required` without +supporting noninferiority. Use its observed variance to size the next corpus. + +## Deterministic verification + +```sh +node --test tests/lib/context-profile-eval.test.js tests/lib/context-profile-eval-corpus.test.js +node docker/context-profiles/ai-eval.js --plan +``` + +Injected providers validate the measurement path, isolation, grading, lease handling +and sanitization. A passing synthetic run validates the framework, never model quality. +A valid CLI report exits zero even when cases fail or the sample is insufficient; +consumers must inspect case results and the gate. diff --git a/docs/design/context-profile-delivery.md b/docs/design/context-profile-delivery.md new file mode 100644 index 000000000..82d6151fe --- /dev/null +++ b/docs/design/context-profile-delivery.md @@ -0,0 +1,91 @@ +# Lean, Full, and task selection delivery + +ECC-029 advances M1: a canonical `lean@1` / `full@1` context contract. This development branch adds managed generations, experimental task selection, an opt-in isolated Codex session, and a preregistered outcome-evaluation pilot. Public release defaults remain governed by the M1 release gate. + +## Development sequence and acceptance + +| Stage | Deliverable | Acceptance | +| --- | --- | --- | +| Registry and compiler | One source-backed registry, Lean/Full plans, exact exclusions | Deterministic digests, resource closure, invalid-input fixtures | +| Native carriers | Complete skill trees and allowlisted native manifests | Fresh Claude/Codex inventory, exclusion and relocated resource readback | +| Managed state | Explicit private store, immutable generations, receipts, rollback and recovery | Full to Lean to Full, injected interruption, source drift, ownership and concurrency checks | +| Task selection | Manual, suggest and Auto over a stable base | Explicit IDs, bounded agent proposals, exclusions, manual-only rules, source-bound decisions, output budget | +| Interactive session | Receipt-bound bootstrap in an isolated native Codex home | Exact source and executable identity, bounded stdin resolution, refresh after binary or source drift | +| Disposable acceptance | Packed install in tiered clean environments | All ten layout/profile combinations, native Codex discovery, functional store and resolver | +| Release promotion | Certified activation adapters and outcome evidence | Provider invocation, measured whole-context budget, paired task quality, upgrade/uninstall matrix, reviewed PRs | + +The first five stages are the local development target. Release promotion requires its own evidence and must retain explicit unsupported or unobserved states. + +## User interface + +```text +ecc profile preview lean --target codex --json +ecc profile set lean --state-root /absolute/dedicated/profile-store --selection auto --dry-run --json +ecc profile set lean --state-root /absolute/dedicated/profile-store --selection auto --json +ecc profile status --state-root /absolute/dedicated/profile-store --json +ecc profile mode suggest --state-root /absolute/dedicated/profile-store --json +ecc profile rollback --state-root /absolute/dedicated/profile-store --expected-revision 2 --json +ecc profile recover --state-root /absolute/dedicated/profile-store --json +ecc profile resolve lean --task-input task.json|- --json +ecc profile resolve lean --task-input task.json|- --load --json +ecc profile resolve --state-root /absolute/dedicated/profile-store --task-input task.json --load --json +ecc profile run --state-root /absolute/dedicated/profile-store --task-input task.json --dry-run --json +ecc profile prepare-native --state-root /absolute/dedicated/profile-store --native-root /absolute/dedicated/native-store --json +ecc profile native-status --state-root /absolute/dedicated/profile-store --native-root /absolute/dedicated/native-store --json +ecc profile run --state-root /absolute/dedicated/profile-store --native-root /absolute/dedicated/native-store --task-input task.json --dry-run --json +ecc profile start --state-root /absolute/dedicated/profile-store --native-root /absolute/dedicated/native-store +``` + +`set` materializes a verified generation and records the configured choice. `generationRoot` identifies the provider-shaped payload. A configured generation does not claim a running provider loaded it. Provider-owned skills can remain visible alongside ECC skills. + +`resolve --state-root` uses the saved base, mode and exclusions. It rejects overrides and stale source generations. `mode` preserves the configured profile and explicit selections while recording the new mode transactionally. + +A task input contains caller-assigned `sessionId`, `taskId`, positive integer `revision`, and `phase`. Optional fields are `query`, `explicitIds`, `proposedIds`, and `noWorkflow`. Increment revision for material task changes. A changed query, including rewording, also invalidates selection reuse. Task prose is consumed locally and omitted from returned receipts. + +```json +{ + "sessionId": "session-1", + "taskId": "feature-1", + "revision": 1, + "phase": "implement", + "explicitIds": ["skill:python-patterns"] +} +``` + +Auto uses explicit user IDs first, then a completed pinned decision, an unambiguous ranked match, one cited skill name, or admitted agent-proposed IDs. Ambiguous free text shortlists up to five candidates for a bounded proposal. Manual uses explicit IDs; suggest emits a proposal without bodies. `--load` returns selected UTF-8 instructions and declared required resources, capped at 32,000 bytes across at most eight skills. `--task-input -` accepts one UTF-8 JSON object on standard input, capped at 65,536 bytes. These byte caps are output and transport bounds, not native tokenizer results. + +Save the returned `selection.receipt` as a separate JSON document to use `--previous receipt.json`. `--expected-digest` can bind a load to a prior selection digest. Source, trigger content, routing-policy version, profile, mode, exclusions, session, task revision, phase, and a digest of the query invalidate stale reuse. A pending proposal cannot be reused as a completed decision. Receipts are integrity checks for local operation, not an authorization signature. + +An agent can call the resolver at task boundaries and read the returned context. This integration is prompt-advisory. Returning a body never grants tools, invokes shell interpolation, starts a native skill, changes hooks or installs dependencies. Native manual-only flags and authority-bearing metadata are checked before selection. Base profiles remain stable during task routing. + +`run` is the explicit task-launch boundary. Ambiguous Auto routing makes one provider proposal call over candidate IDs and descriptions. It accepts zero or one known candidate, then rechecks source bindings, saved state, exclusions and admission policy before loading bodies. Invalid or stale proposals stop before task execution. The proposal has a 30-second timeout and 64 KiB output bound. Codex uses an ephemeral, filesystem-read-only agent session with inherited tools and configuration; the prompt's request to avoid tools is advisory, not enforced tool isolation. Claude disables tools and session persistence for this proposal. Task text is sent to the configured provider, so its normal authentication and data-handling policy apply. + +The task call sends the query and selected reference content on standard input to `codex exec -` or `claude --print`, with no added task permissions or hook overrides. Current-provider launches inherit the provider process environment. An isolated native launch passes only the pinned home paths, `PATH`, a fixed locale, a private temporary directory, and required Windows system root; caller credentials, proxy settings, runtime injection and unrelated secrets are excluded. Its timeout is 90 seconds after a proposal or 120 seconds without one, uses an uncatchable termination signal, and captures at most 1 MiB. Dry run reports the pending proposal without a provider call. A zero provider exit code records process completion; task success and native skill invocation remain unverified. Routine interactive turns outside this launcher do not gain automatic routing. + +## Isolated native Codex generations + +`prepare-native` registers the managed carrier in a fresh ECC-owned home, verifies exact discovery through the allowlisted Codex 0.154.0 or 0.155.1 binary, and only then selects that native generation. It writes a bounded `AGENTS.md` bootstrap bound to the installed CLI source, managed roots, carrier, executable and receipt. It copies no credentials or user configuration and never rewrites the user's provider home. `native-status` checks the recorded generation, executable fingerprint, bootstrap source identity and managed-store binding. A launch pins that verified binary instead of resolving a different executable from PATH. Explicit preparation can refresh a changed executable or installed-source binding while preserving the prior generation and receipts. + +`profile start` is an explicit terminal-only boundary. It revalidates the store and native generation, then launches the pinned Codex binary with inherited terminal capabilities and the isolated home. The bootstrap tells the active agent to resolve context at material task boundaries through bounded structured stdin. It remains prompt-advisory, grants no tools or permissions, and persists no task prose or selected skill bodies. Authentication must be completed separately inside the isolated home; the start path does not inherit or copy provider credentials. + +Switching the managed profile makes the old native generation stale until `prepare-native` succeeds. To undo a switch, first `rollback` the managed store, then use `native-rollback` with both roots. `native-recover` handles a retained interruption journal without deleting provider data. Existing sessions retain their original context. These commands support isolated Codex generations, not migration of an existing global installation or native activation for other providers. + +Discovery evidence comes from the generation's empty project. Task launch inherits the caller's task working directory, whose repository instructions and native configuration may add context or affect policy. Native readiness attests the isolated home's recorded inventory and integrity, not the complete context or permissions of every possible task directory. + +## Outcome-evaluation pilot + +`docker/context-profiles/ai-eval.js` is a development-only evaluator; it lives outside the published package. It preregisters a fixed corpus before any provider call, binding the corpus, profile plans, registry, implementation, Node runtime, dependency versions, model and executable digests. It supports isolated Claude skill installs for five arms, including a pinned legacy skill-library comparator, and isolated Codex Lean/Full installs without that legacy arm. A hidden grader enters each workspace only after the agent exits and runs read-only where Node supports its permission model. + +Real execution requires an explicit flag and provider authentication. Codex uses a dedicated subscription login home (`--auth-home`) or `CODEX_API_KEY`; Claude uses its configured token or Keychain login. A Codex subscription login is leased into each isolated call home, refreshed tokens are returned to the login home, and the leased copy is always removed. The evaluator never reads or copies the user's own Codex home. Results contain allowlisted metrics and hidden-check verdicts, not prompts, transcripts, paths or credentials. See `context-profile-ai-evaluation.md` for the setup, measurement contract and statistical limits. + +## Community integration + +Jeffrey Montoya's [#2788](https://github.com/affaan-m/ECC/pull/2788) informed whole-tree staging, ownership receipts and reversible generations. LovePlayCode's [#2844](https://github.com/affaan-m/ECC/pull/2844) informed deterministic grouping and explicit exclusion. Jeffrey's [#2945](https://github.com/affaan-m/ECC/pull/2945) informed bounded ID/description ranking and deterministic ties. Canonical source digests replace independent routing-cache authority. [#2740](https://github.com/affaan-m/ECC/pull/2740) remains aligned with native context meters and truthful measurement labels. + +These are attributed adaptations of concepts; contributor commits have not been silently relabeled as our implementation. Source PR disposition remains separate. + +## Remaining release gates + +The store recovers actual process exits at five durable boundaries: prepared journal, file publication, generation publication, receipt publication and state publication. An interruption before the initial ownership marker is published, or a corrupted partial kernel write, is preserved for inspection. These cases do not receive an automatic recovery claim. + +Small authenticated Claude pilots now provide task and token observations, but they are descriptive and the evaluation gate remains `review-required`. Adequately powered task-quality canaries and whole-context measurements need additional evidence. The opt-in interactive bootstrap has local source, discovery and terminal-start evidence, but authenticated task behavior and native skill invocation remain unobserved. Isolated Codex registration, switching, refresh and rollback have local native evidence; changing a live user installation still requires its own ownership and recovery contract. Fresh-install default changes, existing-user migration, other-provider activation, hook plans, ECC Tools compatibility, hosted rollout and package publication remain outside this local preview. diff --git a/docs/design/context-profile-delivery.tdd.md b/docs/design/context-profile-delivery.tdd.md new file mode 100644 index 000000000..725eb94a2 --- /dev/null +++ b/docs/design/context-profile-delivery.tdd.md @@ -0,0 +1,91 @@ +# ECC-029 verification ledger + +September 13 baseline branch: `feat/ecc-029-profile-delivery`, incorporating upstream main `8321021c` and the previous carrier branch. The September 21 continuation is recorded below. This report describes local development and packed evidence, not a public release. + +## Reproduced failures and fixes + +| Failure | RED evidence | Fix and GREEN evidence | +| --- | --- | --- | +| Windows profile CI identity fixtures | Synthetic inode `2 ** 60` reproduces missing-exception assertions because adding one does not change the Number | Guaranteed distinct test inode; host and large-inode fixtures pass | +| npm resource mismatch | Source inventory contains nested `.gitignore` omitted by npm | Publication-control files excluded from canonical resources; ten packed plans match source | +| Implicit-invocation policy race | Change `agents/openai.yaml` after compile and before policy read | Policy bytes revalidated against registry digests; preview/load reject drift | +| Windows managed-root parsing | Drive/UNC decomposition loses root separator | Platform-aware root preservation; drive/UNC tests pass | +| Interactive setup fixture race | Delayed startup sends blank answers and EOF before prompt | Prompt-driven PTY and final input closure; 30 tests and 36 existing-install combinations pass | +| Overconfident keyword Auto | Realistic JS review, RAG research and npm release queries select unrelated top scores | Names and generic scores only shortlist; loading requires explicit IDs or a separately admitted agent proposal | +| Native state and executable drift | Reviewed receipt resealing, stale revision, symlink/FIFO and binary replacement cases | Immutable transition binding, bounded regular-file reads, prepublication checks and pinned binary checks | +| Packaged native binary layout | Linux npm wrapper differs from assumed vendor path | Resolve and fingerprint the actual pinned platform binary; regression and real Podman pass | + +New feature tests were introduced before their implementations. Independent review covered ownership, source races, exclusion/dependency policy, Windows paths, command validation, inherited authority, native provenance and failure propagation. + +## Final focused verification + +```sh +node --experimental-test-coverage --test \ + --test-coverage-include='scripts/lib/context-profile-*.js' \ + --test-coverage-include='scripts/lib/context-selection.js' \ + tests/lib/context-profile-*.test.js tests/lib/context-selection.test.js \ + tests/scripts/profile-selection.test.js +``` + +140 tests pass, zero failures. Aggregate coverage for the listed runtime files: 92.73% lines, 81.74% branches, 96.00% functions. This includes the lightly unit-instrumented native discovery subprocess adapter, which also has real-provider conformance below. These percentages are aggregate, not per-file or repository-wide guarantees. Native unit tests account for 25 cases; launcher/proposal/CLI review accounts for 35. + +Final `npm test`, `npm run lint` and `git diff --check` all exit zero. The full runner reports 4,726 legacy-format passes and zero failures, and also executes the new native `node:test` files successfully. Its summary parser counts only `Passed:` output, so the separately measured 140-case focused result above is the precise native-runner count, not a claim that the full-suite summary includes every test format. + +## Final fresh packed consumer + +Command: `node docker/context-profiles/run-podman.js`. Final frozen run exits zero. + +Tested npm archive SHA-256: + +```text +34346621a1062358f96b1a3ce2f07ac6fe72067cd735771e30d06e1dc202335e +``` + +Linux arm64, Node 22.23.1, Codex 0.154.0. Normal packed installation completed during image build. The runtime container used the unprivileged node user, networking disabled, all capabilities dropped, no privilege escalation, no host mounts and no copied credentials. Task containers, image and temporary build directory were removed. The exact archive and acceptance log were retained separately; ordinary dependency build caches may remain. + +- All ten Lean/Full target combinations match source plans and independent resource expectations. Lean has three skills. Full has 292 skills and 583 source resource files, plus one generated manifest for Claude, Codex and Pi. +- The packed managed CLI verifies Full to Lean to rollback Full, revision checks, idempotency, exclusions, Auto loading, suggest/manual/dry-run boundaries, receipt reuse and no-workflow reset. +- Packed `prepare-native`, `native-status` and `native-recover` pass. Isolated launch dry-run uses the pinned executable even with no provider on PATH. +- Native Codex discovery matches Lean, Lean plus Angular and Full excluding Python patterns. Resource digests survive marketplace carrier source removal. Six provider-owned system skills are reported separately. +- Actual managed/native product APIs switch 291 ECC skills to three and roll back to 291, preserving the Full exclusion and unrelated prior-home bytes. Every native preparation and rollback uses a fresh app-server and verifies discovery before pointer publication. +- Earlier isolated Claude Code 2.1.247 conformance validates and lists exact Lean/Full-with-exclusion inventory with zero hooks, agents, MCP and LSP components. Its projected token counter is not provider usage. + +## Evidence boundaries + +No authenticated model calls were made. Auto proposal and task transport, admission failures, executable pinning and state drift are tested with injected executable fixtures. Dry-run and native discovery are tested through actual packed provider executables. Model-driven task success, native skill invocation and token savings remain unobserved; there is no certified routing-quality percentage. + +Native readiness attests the isolated generation and discovery in its empty project. Task launch inherits the actual working directory and its repository controls, so complete task-context equivalence is unverified. Codex proposal execution is filesystem-read-only but inherits provider tools; tool avoidance in its prompt is advisory. Claude proposal tools are disabled. Task execution inherits provider policy and requires normal authentication. + +The store recovers actual process exits at five durable boundaries. Initial creation interrupted before its ownership marker, corrupted partial writes and numeric filesystem identity precision retain explicit limitations. Live installer migration, other-provider activation, interactive Auto bootstrap, whole-context outcome evaluation and default/release changes remain delivery gates. Native status never claims that an existing session changed context. + +## September 21 production-acceptance continuation + +Branch: `feat/ecc-029-production-acceptance`, with the working integration snapshot updated to upstream main `43b3a01e`. The writer session stopped at its provider usage limit after integrating the interactive and evaluation slices. A replacement session recovered the exact tmux transcript, process state, task log and worktree before continuing. No test process was still running and no conflicting writer remained active. + +Additional RED/GREEN cases cover gaps found during review: + +- Complete skill names in questions, quoted data or negated requests previously triggered implicit loading. Names now create candidates only; a user explicit ID or admitted agent proposal is required. +- A pending receipt could previously be reused and skip the provider decision. Receipts now bind routing-policy version and `selected`, `none` or `pending` decision state; only completed decisions can be reused. +- A changed or removed pinned Codex executable could leave native preparation unable to refresh. Explicit preparation may create a newly verified generation while preserving the old receipt and pointer until publication. Ordinary status and start remain fail-closed. +- Isolated native task launch previously inherited every caller environment variable. It now passes only pinned home paths, `PATH`, a fixed locale, a private temporary directory and the required Windows system root. Regression coverage proves unrelated cloud credentials, API keys, proxy settings and `NODE_OPTIONS` are absent. +- The Auto authority check previously missed the shipped `tools` frontmatter field. Scalar and array forms now require manual selection. Malformed task JSON now returns a fixed error without echoing task bytes. +- Provider and sandbox timeouts previously used a catchable termination signal. Launch, proposal, native discovery and sandbox supervision now use `SIGKILL`; a real subprocess that ignores `SIGTERM` verifies the sandbox bound. +- The acceptance driver previously trusted only the sandbox exit code. It now binds the executable and its complete implementation tree, rechecks both identities across preview and execution, and validates backend, tier, real execution, assertion commands, final smoke payload, architecture, layout matrix and evidence boundaries. + +The opt-in interactive slice adds bounded UTF-8 task JSON on stdin, receipt-bound bootstrap instructions, installed-source and executable identity checks, exact Codex 0.154.0/0.155.1 version admission, safe refresh, and `profile start`. A real macOS arm64 Codex 0.155.1 run verified Lean, an explicit include, Full with an exclusion, relocated resource digests, stdin resolution, bootstrap visibility, sign-in-screen startup and removed-binary refresh. No credential was copied and no authenticated task turn was made. + +The source-only AI pilot fixes 13 selection probes and eight paired artifact tasks before execution. Registration binds corpus, registry, plans, implementation, Node runtime, pinned parser and validator dependency versions, model and binary. The provider adapter uses disposable homes, explicit opt-in, `CODEX_API_KEY`, bounded JSONL, deadlines and call counts. Independent artifact assertions and sanitized metrics are implemented. Synthetic tests validate the measurement path; they do not establish model quality. The 13/8 pilot remains below the 30/30 gate and therefore reports `insufficient-sample` even if every case passes. + +Current combined verification after recovery: + +- Focused registry, carrier, store, native, interactive, resolver, admission, evaluation, sandbox and CLI suites pass, including the review regressions above. +- The final focused `node:test` run passes 182/182. Claude migration and setup compatibility suites pass 16/16 and 30/30. The complete repository runner passes 4,940/4,940; lint, diff checks and the production dependency audit all pass with zero vulnerabilities. +- The integration snapshot is current with upstream main `43b3a01e`. The latest-main Claude setup change removed obsolete install flags; migration dry-run and setup expectations now match the shipped command while retaining separate settings preservation. +- Clean commit `cda9c4bf` produced package SHA-256 `2ebc804ffc4f4c89fcf4b5ea0a9f644613618c1508292ef9199928157aa228d1`; both final driver receipts record that exact revision with `sourceDirty: false`. +- Real Tier 1 run `ecc-profile-tier1-89ead327-f193-4959-aff4-67cf8d381df3` passes on rootless Podman with a validated final smoke payload, a complete 10,758-added/4-changed layer diff, no credentials and exact cleanup. +- Real Tier 2 run `ecc-profile-tier2-fd4654a2-18b2-45f4-ba87-b8d0cd8bc488` passes on a disposable native macOS arm64 Lume clone with the same package digest. It validates all ten layouts, isolated Codex discovery, no credential transfer, stopped-guest cleanup and artifact-server cleanup. Lume v1 reports a bounded path scan with 49 added and nine changed files; it explicitly does not claim a complete disk diff. +- The initial Tier 2 attempt exposed `/tmp` as the standard macOS symlink to `/private/tmp`. The acceptance verifier now canonicalizes its newly created private directory while the production managed-store guard continues to reject symlinked roots. A second guest run proved the corrected path. +- The default sandbox checkout's 5,000-path capture limit truncated a real Tier 1 install diff and failed closed. The reviewed ECC-029 sandbox implementation raises the bounded cap to 50,000, passes its 26-case boundary suite, and produced both final reports. The driver receipt binds its 51-file implementation digest `a84e09ab848b8cd05f33792c13734f7aabe16bfe16d50d8f8292eb5261a93c3a`. +- No real AI outcome call ran because `CODEX_API_KEY` was absent. Host ChatGPT authentication was neither copied nor exposed to the disposable evaluator. + +These boundaries keep the shipped behavior distinct from the M1 release gate. Authenticated outcome observations, a complete Tier 2 disk diff, live-install migration, other-provider activation, whole-context token truth and release defaults remain unverified until their explicit prerequisites are available. diff --git a/docs/design/context-profiles.md b/docs/design/context-profiles.md new file mode 100644 index 000000000..5c67246eb --- /dev/null +++ b/docs/design/context-profiles.md @@ -0,0 +1,153 @@ +# Context profiles: read-only foundation + +Status: accepted first development slice, P0/P1, September 8, 2026. This document describes the source implementation and its contributor contract. It does not announce a released runtime capability or a change to installation defaults. + +ECC context profiles separate the skill-discovery proposal from installation, runtime authority, and measurement. The first slice inventories canonical skills, validates versioned declarations, and produces deterministic read-only plans. It does not yet scope the complete host system prompt. + +## Keep the controls separate + +| Control | Meaning | Compatibility rule | +| --- | --- | --- | +| Existing install `--profile` | Selects install modules using [install profiles](../../manifests/install-profiles.json) | `minimal`, `opencode`, `core`, `developer`, `security`, `research`, and `full` keep their existing meanings | +| Context profile `lean@1` or `full@1` | Proposes which canonical skill metadata is selected for discovery | No automatic mapping from an install profile; `full@1` is a skill projection, not the complete ECC installation | +| Selection `manual`, `suggest`, or `auto` | Records selection intent in a proposed context plan | No task classifier, agent-directed switching, or automatic application exists in this slice | +| Existing hook profile | Controls existing hook policy through [hook flags](../../scripts/lib/hook-flags.js) | `minimal`, `standard`, and `strict` remain separate; preview never changes hook consent | +| Runtime and capabilities | Execution isolation, tool permissions, secrets, and side effects | A context selection grants no authority and chooses no sandbox | + +There is no new `use`, `apply`, or `mode` mutation command. The existing install interface is preserved rather than repurposed. + +## Inspect the proposal + +From a source checkout, use the existing [ECC dispatcher](../../scripts/ecc.js): + +```sh +node scripts/ecc.js profile show --json +node scripts/ecc.js profile show lean@1 --json +node scripts/ecc.js profile preview lean@1 --target codex --selection auto --json +node scripts/ecc.js profile preview full@1 --target claude --selection manual --json +node scripts/ecc.js profile preview lean@1 --target codex --include skill:security-review --exclude skill:python-patterns --json +node scripts/ecc.js profile explain skill:security-review --target codex --json +``` + +The packaged CLI uses the same `ecc profile ...` arguments. `show` reads profile definitions; `preview` compiles a proposal; `explain` looks up one exact canonical skill ID and reports its source, resources, ownership, and target declarations. These commands neither invoke skills nor write installed settings. The CLI reads its own package sources, independently of the caller's working directory. + +CLI preview defaults are `lean@1`, target `codex`, and selection intent `auto`. These are preview defaults, not detected user preferences. The library compiler defaults selection intent to `manual`; consumers should pass the intended value explicitly. Both `lean` and `full` are accepted aliases for the versioned profile IDs. + +JSON responses use `ecc.profile-inspection.v1`, including `status`, `summary`, `activation`, `next_actions`, and `artifacts`. A successful preview deliberately reports `status: "warning"` with exit code 0 because runtime activation remains `unobserved`. Invalid requests return an error and exit code 1. A plan reports `active: false` and `disposition: "proposed"`; these fields must survive downstream presentation. + +## Public sources and APIs + +The source manifests have numeric `schemaVersion: 1`. Generated registry and plan objects identify their output shapes as `ecc.context-registry.v1` and `ecc.context-plan.v1` respectively. + +| Source | Responsibility | +| --- | --- | +| [Profile schema](../../schemas/context-profile.schema.json) | Versioned profile ID, registry binding, eager and required selection, and metadata budget | +| [Registry declaration schema](../../schemas/context-pack-registry.schema.json) | Canonical inventory source and explicit per-skill dependency/resource overrides | +| [Lean manifest](../../manifests/context-profiles/lean@1.json) and [Full manifest](../../manifests/context-profiles/full@1.json) | Reviewable selection and budget policy | +| [Skill registry declaration](../../manifests/context-packs/skill-registry@1.json) | Binds the inventory to existing install-module ownership and the canonical skills directory | +| [Registry library](../../scripts/lib/context-pack-registry.js) | Inventory, metadata validation, source hashing, dependency validation, and exact explanation | +| [Profile library](../../scripts/lib/context-profiles.js) | Profile loading, deterministic selection, target projection, and metadata estimation | +| [Shared support](../../scripts/lib/context-profile-support.js) | Bounded source reads, portable paths, schema validation, canonical serialization, and compiler digest | +| [Profile CLI](../../scripts/profile.js) | Read-only inspection envelope and argument validation | + +Contributor entry points are: + +```js +loadContextRegistry({ repoRoot }); +explainContextEntry({ repoRoot, id: 'skill:security-review', target: 'codex' }); +loadContextProfile('lean@1', { repoRoot }); +compileContextProfile({ + repoRoot, + profileId: 'lean@1', + target: 'codex', + selectionMode: 'auto', + include: ['skill:security-review'], + exclude: ['skill:python-patterns'], +}); +``` + +The first two functions are exported by the registry library; the profile library exports the last two and re-exports `explainContextEntry`. The registry also exports `projectionFor(entry, target)` for already validated entries and targets. Consumers should use the loading and compilation APIs instead of duplicating source parsing or building another profile authority. + +## Inventory and selection semantics + +Each canonical `skills//SKILL.md` becomes `skill:`. Its skill directory must have exactly one owner in [install modules](../../manifests/install-modules.json). The owning module supplies `ownerModuleId`, the initial `packId`, and `declaredInstallTargets`. This reuses existing ownership without treating installer module dependencies as skill workflow dependencies. + +Lean currently selects three required candidate entries: `skill:configure-ecc`, `skill:context-budget`, and `skill:ecc-guide`. Other canonical skills remain labeled `routed` unless explicitly included or excluded. Here, `routed` means available in the catalog for future discovery integration; it does not mean a router has run or a native host can already retrieve the skill. + +Full derives `all` from the current canonical inventory. The September 8 baseline contains 286 skills, but 286 is a snapshot, not a hardcoded profile limit. Explicit exclusions can narrow a Full proposal, except for required entries and dependencies needed by retained selections. + +Includes add exact IDs and their transitively declared dependencies. Exclusions cannot remove required profile entries or break that declared closure. Unknown IDs, duplicate selectors, overlapping include/exclude requests, unknown targets, and invalid selection modes fail. Profiles must include their declared required entries in the eager selection. + +Dependencies come only from `overrides[].dependencies` in the registry declaration. The current manifest has no overrides, and entries report `dependencyCoverage: "declared-only-unreviewed"`. An empty dependency array means no declaration exists; it does not prove that a workflow is self-contained. References in skill prose are not followed, interpreted, or promoted into dependency edges. + +`overrides[].requiredResources` can assert that files exist within that skill's own directory. Unknown override IDs, duplicate ownership, missing resources, unknown dependencies, cycles, malformed metadata, unsafe paths, and symbolic links within the source tree are rejected. Reads are bounded at 4 MiB per file, 16 MiB per source reader, 10,000 files, and 32 levels of recursive directory depth. Directory enumeration is incremental, with at most 10,000 accepted names per directory and 20,000 traversal operations per reader. Every directory open and enumerated entry consumes that shared budget, including empty directories and excluded names; detecting overflow may inspect one extra entry. Generated Python caches, `.git`, and `node_modules` are excluded; an explicitly required excluded resource is rejected. + +P2a adds sorted explicit `requiredResources` to registry and plan entries. The mandatory `sourcePath` entrypoint remains distinct; effective required paths are their union. Empty declarations do not establish resource closure, and carriers must not infer that arbitrary subsets are sufficient. The first carrier implementation projects all bundled files for selected skills; see the [P2 carrier contract](context-carriers.md). + +Source reads revalidate ancestor and file identities before consuming bytes and after reading. These consistency checks reject the tested concurrent symlink substitution; they do not provide an atomic repository snapshot. Use immutable source artifacts for downstream execution. Skill and profile metadata reject terminal controls; CLI text also renders controls inert in error paths. + +## Provenance without eager instruction loading + +The registry reads and hashes skill bodies and bundled resource bytes to bind source identity. It does not evaluate scripts, follow instructions in prose, or emit those bodies as model context. Discovery metadata and resource descriptors are separate from instruction loading. Future native carriers must preserve on-demand loading of selected skill bodies and required resources; this first slice implements no native loader. + +| Digest | What it binds | +| --- | --- | +| Resource `digest` | Exact bytes of one source file | +| Entry `contentDigest` | Ordered resource descriptors, including paths, byte counts, and resource digests | +| `registryDigest` | Portable registry output, including inventory-source digests, ownership, metadata, and resource descriptors | +| `profileDigest` | Normalized profile manifest, with selection arrays sorted | +| `compilerDigest` | Source digests for the three compiler library files, two declaration schemas, and the existing install-manifest module supplying target IDs | +| `planDigest` | Complete portable proposed-plan object before adding `planDigest` itself | + +These are SHA-256 content bindings, not signatures, runtime attestations, or a complete execution-environment identity. Digests deliberately exclude caller-specific absolute paths and timestamps. Equivalent selector ordering produces identical plans; changing a skill body changes provenance even when its discovery-metadata estimate stays constant. + +## The 8K check is a metadata fixture gate + +`estimate.surface` is `skill-discovery-metadata`. Method `utf8-bytes-div-4@1` renders each selected entry as canonical JSON containing `harness`, `type`, `name`, and `description`, adds a newline, divides UTF-8 bytes by four, rounds each entry up, and sums the results. The ledger exposes per-entry costs. + +Lean rejects estimates above 8,000 using `CONTEXT_PROFILE_BUDGET_EXCEEDED`; a library caller can inspect the rejected proposal on `error.plan`. Exactly 8,000 passes the estimator check; 8,001 fails. Full uses the same reference budget in report-only mode. + +This heuristic is an early rejection and regression fixture, not a tokenizer, measured lower bound, or whole-prompt certification. Passing cannot establish the production Lean startup ceiling. `nativeTokens`, `wrapperTokens`, and `wholeScopeTokens` remain `null` until appropriate observation exists. + +The registry explicitly excludes agents, commands, rules, hooks, MCP schemas, harness wrappers, and learned skills. Skill bodies and bundled resources are hashed but excluded from the discovery estimate. Other plugin context, host overhead, repeated prompts, and task execution costs are also unmeasured. Report observed native counters separately and avoid deriving savings claims from this ledger alone. + +## Target declarations are not runtime certification + +The registry recognizes the current 15 install target IDs plus Pi. For a requested target, `projection.installSupport` reports `declared` or `not-declared` according to the owning module. `projection.nativeSupport` remains `unobserved` in both cases. + +Target selection does not silently drop skills lacking an installer declaration. The same explicit skill selection is projected for every recognized target, so consumers can inspect gaps rather than mistake them for successful installation. Native discovery, invocation, resource access, reload behavior, exclusion enforcement, and whole-context cost require adapter-specific evidence in later slices. + +## Rationale and alternatives + +The read-only boundary makes the selection contract reviewable before it can alter user state. Versioned manifests and source digests provide shared inputs for adapters, grouping work, routing, and measurement. Keeping existing install ownership avoids a second independently maintained inventory. + +Alternatives considered: + +- Reuse install profile names for runtime scope. Rejected because installed files, visible context, hooks, and permissions are separate controls with existing compatibility obligations. +- Start by rewriting plugin caches or installed discovery files. Deferred until carrier ownership, fresh-session behavior, receipts, rollback, and user-edit preservation have evidence. +- Treat a task classifier or system prompt as the enforcement boundary. Rejected. Future agent proposals must be validated against deterministic contracts and retained consent. +- Infer complete workflow closure from Markdown prose. Rejected as an unreviewed authority source. Explicit declarations are auditable; the current dependency coverage remains incomplete. +- Declare 8K compliance from a character or byte estimate. Rejected. Metadata fixtures help catch regressions while native host measurements remain a separate gate. + +## Contributor integration lanes + +These related PRs are integration inputs, not claims that their proposed behavior has shipped. Preserve contributor attribution and verify each change against the shared contract before adoption. + +| Contribution | Intended integration | Boundary | +| --- | --- | --- | +| [#2788](https://github.com/affaan-m/ECC/pull/2788) | Native discovery carriers and associated ownership/receipt work | Consume this registry and plan; carrier generation and activation belong to later slices | +| [#2844](https://github.com/affaan-m/ECC/pull/2844) | Catalog grouping, deterministic selection fixtures, and listing projection | Reuse canonical IDs and pack ownership instead of introducing competing profile authority | +| [#2945](https://github.com/affaan-m/ECC/pull/2945) | Task routing and automatic-selection proposals | Future structured task resolver; `selectionMode: "auto"` alone implements none of this | +| [#2740](https://github.com/affaan-m/ECC/pull/2740) | Native context counters and bounded diagnostics | Keep observed measurements separate from fixture estimates and scan assumptions | +| [#3030](https://github.com/affaan-m/ECC/pull/3030) | Contributor skill-quality validation | Content-quality checks complement inventory validation; they do not prove runtime activation or workflow outcomes | +| [#3032](https://github.com/affaan-m/ECC/pull/3032) | Existing js-yaml dependency security update | Verify contributor integration before release; retain both lockfiles and rerun dependency and regression checks | + +The original September 8 dependency baseline pinned js-yaml 4.3.1, affected by [GHSA-2883-xcg3-v3hh](https://github.com/nodeca/js-yaml/security/advisories/GHSA-2883-xcg3-v3hh). PR preparation exposed that existing finding in hosted CI. This branch now includes Myles Agnew's exact 4.3.2 upgrade from #3032 as an attributed prerequisite commit, updating the runtime pin, overrides, resolutions, and both lockfiles. Runtime audit reports zero vulnerabilities after installation. The original contributor PR remains independently reviewable. This registry's `JSON_SCHEMA` excludes the advisory's merge behavior, but upgrading also protects existing default-schema parsers. + +## Follow-on gates and verification + +P2 now has resource-complete read-only carrier projections and disposable structural acceptance fixtures. Native fresh-session discovery and invocation remain unobserved. P3 adds transactional activation, receipts, ownership, migration, recovery, and rollback. P4 adds structured task selection, agent proposals, and bounded automatic routing. P5 integrates hook plans with explicit, separately retained consent. P6 earns release-default changes through package, operating-system, harness, compatibility, and recovery tests. None of those later stages is implied by a successful preview. + +The first-slice checks live in [registry tests](../../tests/lib/context-pack-registry.test.js), [profile tests](../../tests/lib/context-profiles.test.js), [CLI tests](../../tests/scripts/profile.test.js), and the [context-profile validator](../../scripts/ci/validate-context-profiles.js). They cover source and selection validation, deterministic provenance, metadata boundaries, and read-only behavior. Those fixtures do not replace native fresh-session, activation, workflow, or whole-system measurement evidence. + +In a source checkout, see the [TDD evidence record](context-profiles.tdd.md) and test files linked above for executed checks, checkpoints, coverage, and known gaps. Test sources and the evidence record are intentionally outside the reduced npm runtime surface. diff --git a/docs/design/context-profiles.tdd.md b/docs/design/context-profiles.tdd.md new file mode 100644 index 000000000..01d332afa --- /dev/null +++ b/docs/design/context-profiles.tdd.md @@ -0,0 +1,89 @@ +# ECC-029 read-only context profile evidence + +Date: September 8, 2026. Scope: the first P0/P1 implementation slice for M1, canonical context profiles. Baseline: main `5064474d4d762dc9640234a41617cccb79185cec`, ECC 2.2.1. Environment: macOS 26.6.2, Apple M4 Pro, Node 24.9.0. This is local development evidence, not a release or native-host certification. + +Source intent: the accepted ECC-029 production and economics planning canvases in the maintainer workspace. Their approved first-slice journeys and boundaries are carried into the portable [implementation contract](context-profiles.md). Planning text was treated as design input; validation used reviewed local test, lint, package, and inspection commands. No activation, remote installer, publication, or credential-handling instruction was adopted. The project detector selected unavailable Bun; the actual test scripts run standalone Node, so Node and npm ran them without changing package-manager preferences. + +## Journeys and test specification + +| Approved journey and guarantee | Test target | Type | RED evidence | GREEN evidence | +| --- | --- | --- | --- | --- | +| Inspect versioned profiles and exact skill IDs without invoking skills or changing caller state | [CLI tests](../../tests/scripts/profile.test.js) | CLI journey/integration | `cd3950d3`: 24 failures for the missing command, entrypoint, and package inclusion | 25 passed, including later terminal-control regression; temporary home and workspace snapshots remain unchanged | +| Build one portable canonical skill inventory with validated ownership, explicit declarations, and resource digests | [Registry tests](../../tests/lib/context-pack-registry.test.js) | Unit/integration | `4c1b938b`: intended registry module absent | 15 passed, including source safety and repository inventory | +| Compile deterministic Lean/Full proposals with exact selectors, declared dependency closure, and honest metadata estimates | [Profile tests](../../tests/lib/context-profiles.test.js) | Unit/integration | `4c1b938b`: intended compiler module absent | 12 passed; 8,000 passes and 8,001 blocks the Lean metadata estimator, while native totals remain unknown | +| Gate every recognized target and register validation in the normal test workflow | [CI tests](../../tests/ci/context-profiles.test.js) | Integration | `5fcd9e08`: 3 failures for missing validation and registration | 3 passed; 2 profiles across 16 target IDs | +| Reject redirected source reads, unsafe metadata controls, and unstable cache-derived provenance | Registry and profile tests above | Security/regression | `f01d3366`: 23 passed and 3 expected failures during review | Same regressions pass; redirected descriptor receives zero byte reads in the substitution fixture | +| Keep user-supplied terminal controls inert in CLI error output | CLI tests above | Security/CLI | `254a6cc1`: 24 passed, 1 failed for raw OSC output | 25 passed | +| Ship the entrypoint, libraries, schemas, manifests, and contract together | [Publish-surface tests](../../tests/scripts/npm-publish-surface.test.js) | Packaging/integration | Existing explicit publish allowlist initially reported 1 pass and 1 failure | Updated expected public surface passes, plus real offline package smoke below | + +The module-absence RED runs exercised the intended new public entry points; they were not failures of an unrelated dependency installation. The initial library checkpoint contained 20 cases; boundary and security review grew the focused library suite to 27. All listed checkpoints are local commits on `plan/ecc-029-harness-scoping`, reachable from the GREEN implementation commit. Preserve this record if later integration squashes those checkpoints. No separate refactor stage was performed after final GREEN validation. + +## Executed checks + +```sh +node --test tests/lib/context-pack-registry.test.js tests/lib/context-profiles.test.js +node tests/scripts/profile.test.js +node tests/ci/context-profiles.test.js +node tests/scripts/npm-publish-surface.test.js +npm run context-profiles:check +npm test +npm run lint +git diff --check +``` + +Final focused coverage execution also runs the first four feature test targets together: + +```sh +./node_modules/.bin/c8 --all \ + --include='scripts/lib/context*.js' \ + --include='scripts/profile.js' \ + --include='scripts/ci/validate-context-profiles.js' \ + --reporter=text --reporter=json-summary \ + --reports-dir=/tmp/ecc-029-context-coverage \ + --check-coverage --lines=80 --functions=80 --branches=80 --statements=80 \ + node --test tests/lib/context-pack-registry.test.js \ + tests/lib/context-profiles.test.js tests/scripts/profile.test.js \ + tests/ci/context-profiles.test.js +``` + +Results: 27 library cases, 25 CLI cases, and 3 CI cases passed. Node's outer TAP summary reports 29 because the CLI and CI files each wrap their own cases. New-code coverage is 98.43% statements and lines, 90% branches, and 100% functions. Coverage thresholds all pass; no focused cases were skipped. Uncovered lines include a defensive source-error path and the single-profile text rendering branch. + +The complete `npm test` command exited 0 and its legacy aggregate reported `Total Tests: 4423`, `Passed: 4423`, `Failed: 0`. Its aggregate does not separately count the new node:test library cases, which have their explicit result above. Existing platform-dependent tests can skip on macOS; this run supplies no Windows or Linux execution evidence. Full ESLint/Markdown lint, catalog/command validators, and whitespace checks passed. + +## Packed offline user journey + +Ran `npm pack` with the real prepack build into a disposable directory, followed by `npm install --offline --ignore-scripts --omit=dev --no-audit --no-fund --userconfig=/dev/null` into a disposable consumer. The install succeeded using cached dependencies. No package was published or globally installed. + +The packaged dispatcher produced Lean and Full Codex previews, and the packaged direct entrypoint explained an exact skill ID. Both full proposed-plan objects were deeply equal to their checkout counterparts, including registry, profile, compiler, and plan digests. The subprocess environment used an explicit allowlist and a disposable user-home path, which remained absent after all three calls. This checks the real archive and runtime dependencies independently of the checkout's module resolution. + +At this baseline, Codex Lean selects 3 entries and leaves 283 routed; Full selects all 286. The descriptor estimator reports 221 tokens from 879 bytes for Lean and 26,145 tokens from 104,168 bytes for Full. These are reproducible fixture estimates, not observed native startup tokens or demonstrated task savings. + +## Review findings and remaining gates + +Independent review reproduced ancestor substitution and terminal-control issues before fixes, then rechecked the fixes and approved the read-only boundary. Source identity checks do not create an atomic filesystem snapshot. The initial checkpoint lacked an independent directory listing bound; the hosted-review follow-up below closes that gap. Dependency coverage remains explicit-declarations-only and unreviewed. Required-resource annotations need a distinct output contract before selective P2 carriers can safely omit resources. + +The js-yaml integration prerequisite from contributor [PR #3032](https://github.com/affaan-m/ECC/pull/3032) is satisfied on this branch by the attributed 4.3.2 upgrade, fresh install, zero-vulnerability runtime audit and packed-consumer verification described below. Its original PR remains open; final hosted CI and release qualification are separate gates. See the [contract's dependency gate](context-profiles.md#contributor-integration-lanes). + +Native carriers, active discovery, actual skill invocation, transactional activation, hook consent, automatic task routing, recovery, real-host token counters, broader context surfaces, cross-platform conformance, and default migration remain follow-on work. No provider calls, container or VM launches, or runtime profile changes were used to establish these results. + +## PR-readiness follow-up + +Independent exact-head review approved the read-only implementation and identified privilege-sensitive symlink fixtures. Review's original permission-denial injection produced 12 passes and 3 failures. Checkpoint `88f5a996` added a failing portable directory-link contract: 15 passes and 1 expected failure. The fix uses Windows junctions for directory cases, separates unconditional ownership and mocked leaf-link rejection from the real file-link integration case, and explicitly skips only that extra file-link case on Windows EPERM/EACCES. No runtime code changed. + +Final local focused checks now pass 30 library, 25 CLI, and 3 CI cases. A bounded simulation of Windows file-link denial, keeping the local temporary directory fixed and emulating directory junctions, passes 17 registry cases and explicitly skips 1 real file-link case. It is a test-policy simulation, not native Windows evidence. The source-read substitution and zero-byte-read assertions remain mandatory. + +An isolated Git archive passed `YARN_ENABLE_HARDENED_MODE=1 YARN_ENABLE_SCRIPTS=false yarn install --immutable --mode=skip-build`; both package manifest and Yarn lockfile remained byte-identical. The initially attempted immutable/update-lockfile combination was rejected by Yarn as incompatible before installation; the immutable skip-build run is the applicable successful CI check. Dependency declarations remain unchanged. Source-only evidence/test links in the shipped contract are now labeled explicitly. + +### Contributor security prerequisite + +Hosted CI for PR #3037 at `78cbd01c` reproduced the existing js-yaml high-severity advisory in its runtime audit. The branch incorporated contributor Myles Agnew's exact commit `5674661fc30ab1d3f3fcae22d72bfb4ab3059822` from #3032 using an attributed cherry-pick (`77872972`). No contributor PR was merged or closed. A fresh dependency install resolved js-yaml 4.3.2, and `npm audit --omit=dev --audit-level=high` reports zero vulnerabilities. + +The local npm 11 install unexpectedly rewrote the Yarn lock into its legacy format. Only that task-induced rewrite was restored to the committed contributor bytes before subsequent validation. This is installation-tool behavior, not an intended lockfile change. The full test run started on the preceding revision overlapped the dependency update and is excluded from exact-final-head evidence; final PR checks must bind to the updated head. + +### Hosted review regressions + +The global dry-run parser regression was reproduced before implementation in `c373b7fe`: 27 CLI cases passed and 4 failed. Fix `9b5e3934` removes exact global `--dry-run` flags before command/value parsing, without mutating caller arguments or weakening other validation. All 31 CLI cases and seven independent parser probes pass. Both public entrypoints retain unobserved activation. + +Checkpoint `ea00894d` adds seven source-reader regressions for incremental enumeration, the exact per-directory boundary, empty-directory breadth, excluded cache names, handle cleanup and directory identity changes. The corrected reader accepts at most 10,000 names per directory and charges every directory open and enumerated entry against a 20,000-operation reader budget, allowing one lookahead to detect overflow. It retains the file, cumulative-byte and depth bounds. Focused support/registry/compiler checks pass 37/37, including the mandatory ancestor-substitution test with zero redirected file-byte reads. + +The source reader was split into focused helpers below 50 lines. Directory handles close in `finally`, and identities are revalidated before and after enumeration. Independent review checked that descriptor no-follow flags, identity checks before the first file byte, post-read checks and exact byte digests survive the extraction. This remains a bounded consistency check, not an atomic filesystem snapshot. diff --git a/docs/es/AGENTS.md b/docs/es/AGENTS.md index c15bf5539..d4b2b585c 100644 --- a/docs/es/AGENTS.md +++ b/docs/es/AGENTS.md @@ -1,4 +1,4 @@ -# Everything Claude Code (ECC) — Instrucciones para Agentes +# ECC — Instrucciones para Agentes Este es un **plugin de IA para codificación listo para producción** que proporciona 63 agentes especializados, 249 skills, 79 comandos y flujos de trabajo de hooks automatizados para el desarrollo de software. diff --git a/docs/es/CONTRIBUTING.md b/docs/es/CONTRIBUTING.md index b2cb92c5a..6f7eacc88 100644 --- a/docs/es/CONTRIBUTING.md +++ b/docs/es/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contribuir a Everything Claude Code +# Contribuir a ECC ¡Gracias por querer contribuir! Este repositorio es un recurso comunitario para usuarios de Claude Code. @@ -52,8 +52,8 @@ Comandos slash que invocan flujos de trabajo útiles: ```bash # 1. Hacer fork y clonar -gh repo fork affaan-m/everything-claude-code --clone -cd everything-claude-code +gh repo fork affaan-m/ECC --clone +cd ECC # 2. Crear una rama git checkout -b feat/mi-contribucion @@ -465,7 +465,7 @@ Cómo lo probaste. ## ¿Preguntas? -- **Issues:** [github.com/affaan-m/everything-claude-code/issues](https://github.com/affaan-m/everything-claude-code/issues) +- **Issues:** [github.com/affaan-m/ECC/issues](https://github.com/affaan-m/ECC/issues) - **X/Twitter:** [@affaanmustafa](https://x.com/affaanmustafa) --- diff --git a/docs/es/README.md b/docs/es/README.md index 242adb358..9097e376b 100644 --- a/docs/es/README.md +++ b/docs/es/README.md @@ -1,4 +1,4 @@ -**Idioma:** [English](../../README.md) | [Português (Brasil)](../pt-BR/README.md) | [简体中文](../../README.zh-CN.md) | [繁體中文](../zh-TW/README.md) | [日本語](../ja-JP/README.md) | [한국어](../ko-KR/README.md) | [Türkçe](../tr/README.md) | [Русский](../ru/README.md) | [Tiếng Việt](../vi-VN/README.md) | [ไทย](../th/README.md) | [Deutsch](../de-DE/README.md) | **Español** | [Українська](../uk-UA/README.md) +**Idioma:** [English](../../README.md) | [Português (Brasil)](../pt-BR/README.md) | [简体中文](../../README.zh-CN.md) | [繁體中文](../zh-TW/README.md) | [日本語](../ja-JP/README.md) | [한국어](../ko-KR/README.md) | [Türkçe](../tr/README.md) | [Русский](../ru/README.md) | [Tiếng Việt](../vi-VN/README.md) | [ไทย](../th/README.md) | [Deutsch](../de-DE/README.md) | **Español** | [Українська](../uk-UA/README.md) | [Polski](../pl/README.md) | [বাংলা](../bn/README.md) # ECC @@ -28,7 +28,7 @@ **Language / 语言 / 語言 / Dil / Язык / Ngôn ngữ / Idioma** [**English**](../../README.md) | [Português (Brasil)](../pt-BR/README.md) | [简体中文](../../README.zh-CN.md) | [繁體中文](../zh-TW/README.md) | [日本語](../ja-JP/README.md) | [한국어](../ko-KR/README.md) - | [Türkçe](../tr/README.md) | [Русский](../ru/README.md) | [Tiếng Việt](../vi-VN/README.md) | [ไทย](../th/README.md) | [Deutsch](../de-DE/README.md) | **Español** | [Українська](../uk-UA/README.md) + | [Türkçe](../tr/README.md) | [Русский](../ru/README.md) | [Tiếng Việt](../vi-VN/README.md) | [ไทย](../th/README.md) | [Deutsch](../de-DE/README.md) | **Español** | [Українська](../uk-UA/README.md) | [Polski](../pl/README.md) | [বাংলা](../bn/README.md) @@ -1372,8 +1372,8 @@ Este proyecto es gratuito y de código abierto. Los patrocinadores ayudan a mant ## Enlaces -- **Guía Resumida (Empieza aquí):** [La Guía Resumida de Everything Claude Code](https://x.com/affaanmustafa/status/2012378465664745795) -- **Guía Extensa (Avanzado):** [La Guía Extensa de Everything Claude Code](https://x.com/affaanmustafa/status/2014040193557471352) +- **Guía Resumida (Empieza aquí):** [La Guía Resumida de ECC](https://x.com/affaanmustafa/status/2012378465664745795) +- **Guía Extensa (Avanzado):** [La Guía Extensa de ECC](https://x.com/affaanmustafa/status/2014040193557471352) - **Guía de Seguridad:** [Guía de Seguridad](../../the-security-guide.md) | [Hilo](https://x.com/affaanmustafa/status/2033263813387223421) - **Seguir:** [@affaanmustafa](https://x.com/affaanmustafa) diff --git a/docs/es/SPONSORS.md b/docs/es/SPONSORS.md index 54a9e0acc..51f2f4bbd 100644 --- a/docs/es/SPONSORS.md +++ b/docs/es/SPONSORS.md @@ -18,10 +18,15 @@ Gracias a todos los que financian el trabajo de código abierto de ECC. Tu patro | Patrocinador | Desde | |---------|-------| -| [Mike Morgan](https://github.com/mikejmorgan-ai) | 2026 | *[Conviértete en patrocinador Team](https://github.com/sponsors/affaan-m) para obtener un logo pequeño y 5 asientos de ECC Pro.* +## Patrocinadores anteriores + +| Patrocinador | Período activo | +|---------|---------------| +| [Mike Morgan](https://github.com/mikejmorgan-ai) (inactivo) | 2026 | + ## Patrocinadores Pro — $50/mes *[Conviértete en patrocinador Pro](https://github.com/sponsors/affaan-m) para aparecer aquí con tu nombre en la fila de patrocinadores del README principal.* diff --git a/docs/es/TROUBLESHOOTING.md b/docs/es/TROUBLESHOOTING.md index 614d36a8f..f4778692d 100644 --- a/docs/es/TROUBLESHOOTING.md +++ b/docs/es/TROUBLESHOOTING.md @@ -1,6 +1,6 @@ # Guía de Resolución de Problemas -Problemas comunes y soluciones para el plugin Everything Claude Code (ECC). +Problemas comunes y soluciones para el plugin ECC. ## Tabla de Contenidos @@ -264,7 +264,7 @@ mv ~/.claude/plugins/cache ~/.claude/plugins/cache.backup.$(date +%Y%m%d-%H%M%S) mkdir -p ~/.claude/plugins/cache # Reinstalar desde el marketplace -# Claude Code → Extensions → Everything Claude Code → Uninstall +# Claude Code → Extensions → ECC → Uninstall # Luego reinstalar desde el marketplace # Si el problema es el acceso al marketplace/cuenta, usa la recuperación de cuenta/facturación de ECC Tools por separado; no uses la reinstalación como sustituto de la recuperación de cuenta @@ -274,8 +274,8 @@ claude --version # Requiere Claude Code 2.0+ # Instalación manual (si el marketplace falla) -git clone https://github.com/affaan-m/everything-claude-code.git -cp -r everything-claude-code ~/.claude/plugins/ecc +git clone https://github.com/affaan-m/ECC.git +cp -r ECC ~/.claude/plugins/ecc ``` ### Falla la Detección del Gestor de Paquetes @@ -406,7 +406,7 @@ find ~/.claude/plugins -name "*.sh" -exec dos2unix {} \; Si sigues experimentando problemas: -1. **Revisa los Issues de GitHub**: [github.com/affaan-m/everything-claude-code/issues](https://github.com/affaan-m/everything-claude-code/issues) +1. **Revisa los Issues de GitHub**: [github.com/affaan-m/ECC/issues](https://github.com/affaan-m/ECC/issues) 2. **Habilita el Registro de Depuración**: ```bash export CLAUDE_DEBUG=1 diff --git a/docs/es/agents/chief-of-staff.md b/docs/es/agents/chief-of-staff.md index 6963978c2..1e12ac3e4 100644 --- a/docs/es/agents/chief-of-staff.md +++ b/docs/es/agents/chief-of-staff.md @@ -2,7 +2,7 @@ name: chief-of-staff description: Jefe de comunicaciones personal que gestiona el correo electrónico, Slack, LINE y Messenger. Clasifica mensajes en 4 niveles (skip/info_only/meeting_info/action_required), genera borradores de respuesta y refuerza el seguimiento post-envío mediante hooks. Usar para gestionar flujos de trabajo de comunicación multi-canal. tools: ["Read", "Grep", "Glob", "Bash", "Edit", "Write"] -model: opus +model: sonnet --- ## Línea de Base de Defensa de Prompts diff --git a/docs/es/agents/database-reviewer.md b/docs/es/agents/database-reviewer.md index 808b4d706..02ec9bce7 100644 --- a/docs/es/agents/database-reviewer.md +++ b/docs/es/agents/database-reviewer.md @@ -1,7 +1,7 @@ --- name: database-reviewer description: Especialista en bases de datos PostgreSQL para optimización de consultas, diseño de esquemas, seguridad y rendimiento. Usar PROACTIVAMENTE al escribir SQL, crear migraciones, diseñar esquemas o solucionar problemas de rendimiento de base de datos. Incorpora mejores prácticas de Supabase. -tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"] +tools: ["Read", "Grep", "Glob", "Bash"] model: sonnet --- diff --git a/docs/es/agents/docs-lookup.md b/docs/es/agents/docs-lookup.md index 7a8625278..8625de573 100644 --- a/docs/es/agents/docs-lookup.md +++ b/docs/es/agents/docs-lookup.md @@ -2,7 +2,7 @@ name: docs-lookup description: Cuando el usuario pregunta cómo usar una biblioteca, framework o API, o necesita ejemplos de código actualizados, usar Context7 MCP para obtener documentación actual y devolver respuestas con ejemplos. Invocar para preguntas sobre docs/API/configuración. tools: ["Read", "Grep", "mcp__context7__resolve-library-id", "mcp__context7__query-docs"] -model: sonnet +model: haiku --- ## Línea de Base de Defensa de Prompts diff --git a/docs/es/agents/security-reviewer.md b/docs/es/agents/security-reviewer.md index 13a893a8d..cb99eb30d 100644 --- a/docs/es/agents/security-reviewer.md +++ b/docs/es/agents/security-reviewer.md @@ -1,7 +1,7 @@ --- name: security-reviewer description: Especialista en detección y remediación de vulnerabilidades de seguridad. Usar PROACTIVAMENTE después de escribir código que maneja entrada de usuarios, autenticación, endpoints de API o datos sensibles. Detecta secretos, SSRF, inyección, criptografía insegura y vulnerabilidades del OWASP Top 10. -tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"] +tools: ["Read", "Grep", "Glob", "Bash"] model: sonnet --- diff --git a/docs/es/the-longform-guide.md b/docs/es/the-longform-guide.md index 75d0f92c6..93ecece49 100644 --- a/docs/es/the-longform-guide.md +++ b/docs/es/the-longform-guide.md @@ -1,10 +1,10 @@ -# La Guía Extendida de Everything Claude Code +# La Guía Extendida de ECC -![Encabezado: La Guía Extendida de Everything Claude Code](../../assets/images/longform/01-header.png) +![Encabezado: La Guía Extendida de ECC](../../assets/images/longform/01-header.png) --- -> **Prerequisito**: Esta guía se basa en [La Guía Breve de Everything Claude Code](./the-shortform-guide.md). Lee esa primero si aún no has configurado skills, hooks, subagentes, MCPs y plugins. +> **Prerequisito**: Esta guía se basa en [La Guía Breve de ECC](./the-shortform-guide.md). Lee esa primero si aún no has configurado skills, hooks, subagentes, MCPs y plugins. ![Referencia a la Guía Breve](../../assets/images/longform/02-shortform-reference.png) *La Guía Breve — léela primero* @@ -15,7 +15,7 @@ Esta guía extendida profundiza en las técnicas que separan las sesiones produc Los temas aquí: economía de tokens, persistencia de memoria, patrones de verificación, estrategias de paralelización y los efectos compuestos de construir flujos de trabajo reutilizables. Estos son los patrones que he refinado en más de 10 meses de uso diario, y que marcan la diferencia entre sufrir una degradación de contexto en la primera hora versus mantener sesiones productivas durante horas. -Todo lo cubierto en las guías breve y extendida está disponible en GitHub: `github.com/affaan-m/everything-claude-code` +Todo lo cubierto en las guías breve y extendida está disponible en GitHub: `github.com/affaan-m/ECC` --- @@ -40,7 +40,7 @@ Con la carga diferida (lazy loading), el problema de la ventana de contexto est Para compartir memoria entre sesiones, la mejor opción es una skill o comando que resuma y verifique el progreso, luego lo guarde en un archivo `.tmp` en tu carpeta `.claude` y lo vaya añadiendo hasta el final de tu sesión. Al día siguiente puede usar eso como contexto y retomar donde lo dejaste; crea un nuevo archivo para cada sesión para no contaminar el contexto antiguo en el trabajo nuevo. ![Árbol de Archivos de Almacenamiento de Sesión](../../assets/images/longform/03-session-storage.png) -*Ejemplo de almacenamiento de sesión -> * +*Ejemplo de almacenamiento de sesión -> * Claude crea un archivo resumiendo el estado actual. Revísalo, pide ediciones si es necesario, luego empieza de nuevo. Para la nueva conversación, solo proporciona la ruta del archivo. Particularmente útil cuando estás alcanzando los límites de contexto y necesitas continuar trabajo complejo. Estos archivos deben contener: - Qué enfoques funcionaron (verificablemente con evidencia) @@ -82,7 +82,7 @@ Hay hooks que la mayoría de la gente no conoce y que ayudan con la memoria: - **Hook Stop (Fin de Sesión)**: Al finalizar la sesión, persiste los aprendizajes en un archivo - **Hook SessionStart**: Al iniciar una nueva sesión, carga el contexto previo automáticamente -He construido estos hooks y están en el repositorio en `github.com/affaan-m/everything-claude-code/tree/main/hooks/memory-persistence` +He construido estos hooks y están en el repositorio en `github.com/affaan-m/ECC/tree/main/hooks/memory-persistence` --- @@ -94,7 +94,7 @@ Si has tenido que repetir un prompt varias veces y Claude se encontró con el mi **La Solución:** Cuando Claude Code descubre algo que no es trivial — una técnica de depuración, una solución alternativa, algún patrón específico del proyecto — guarda ese conocimiento como una nueva skill. La próxima vez que aparezca un problema similar, la skill se carga automáticamente. -He construido una skill de aprendizaje continuo que hace esto: `github.com/affaan-m/everything-claude-code/tree/main/skills/continuous-learning` +He construido una skill de aprendizaje continuo que hace esto: `github.com/affaan-m/ECC/tree/main/skills/continuous-learning` **Por Qué Hook Stop (No UserPromptSubmit):** @@ -351,4 +351,4 @@ alias q='cd ~/Desktop/projects' --- -*Todo lo cubierto en ambas guías está disponible en GitHub en [everything-claude-code](https://github.com/affaan-m/everything-claude-code)* +*Todo lo cubierto en ambas guías está disponible en GitHub en [ECC](https://github.com/affaan-m/ECC)* diff --git a/docs/es/the-security-guide.md b/docs/es/the-security-guide.md index 46ac94b89..c4fe80421 100644 --- a/docs/es/the-security-guide.md +++ b/docs/es/the-security-guide.md @@ -1,6 +1,6 @@ # La Guía Breve de Todo sobre Seguridad Agéntica -_everything claude code / investigación / seguridad_ +_ecc / investigación / seguridad_ --- @@ -447,10 +447,10 @@ Escanea tu configuración: [github.com/affaan-m/agentshield](https://github.com/ Si no has leído las guías anteriores, empieza aquí: -> [La Guía Breve de Everything Claude Code](./the-shortform-guide.md) +> [La Guía Breve de ECC](./the-shortform-guide.md) > -> [La Guía Extendida de Everything Claude Code](./the-longform-guide.md) +> [La Guía Extendida de ECC](./the-longform-guide.md) también guarda estos repositorios: -- [github.com/affaan-m/everything-claude-code](https://github.com/affaan-m/everything-claude-code) +- [github.com/affaan-m/ECC](https://github.com/affaan-m/ECC) - [github.com/affaan-m/agentshield](https://github.com/affaan-m/agentshield) diff --git a/docs/es/the-shortform-guide.md b/docs/es/the-shortform-guide.md index 0803f0cab..56b32c7b4 100644 --- a/docs/es/the-shortform-guide.md +++ b/docs/es/the-shortform-guide.md @@ -1,4 +1,4 @@ -# La Guía Resumida de Everything Claude Code +# La Guía Resumida de ECC ![Encabezado: Ganador del Hackathon de Anthropic - Tips y Trucos para Claude Code](../../assets/images/shortform/00-header.png) diff --git a/docs/hook-bug-workarounds.md b/docs/hook-bug-workarounds.md index 74f4dc607..ef1d4fbb1 100644 --- a/docs/hook-bug-workarounds.md +++ b/docs/hook-bug-workarounds.md @@ -71,4 +71,4 @@ What helps: - [TROUBLESHOOTING.md](./TROUBLESHOOTING.md) - [token-optimization.md](./token-optimization.md) - [hooks/README.md](../hooks/README.md) -- [issue #644](https://github.com/affaan-m/everything-claude-code/issues/644) +- [issue #644](https://github.com/affaan-m/ECC/issues/644) diff --git a/docs/ja-JP/AGENTS.md b/docs/ja-JP/AGENTS.md index f32e801b8..b97de3d4f 100644 --- a/docs/ja-JP/AGENTS.md +++ b/docs/ja-JP/AGENTS.md @@ -1,4 +1,4 @@ -# Everything Claude Code (ECC) — エージェント指示書 +# ECC — エージェント指示書 これは60の専門エージェント、228のスキル、75のコマンド、自動化フックワークフローを提供する**プロダクション対応のAIコーディングプラグイン**です。 diff --git a/docs/ja-JP/CONTRIBUTING.md b/docs/ja-JP/CONTRIBUTING.md index 63231f094..3c7888fec 100644 --- a/docs/ja-JP/CONTRIBUTING.md +++ b/docs/ja-JP/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Everything Claude Codeに貢献する +# ECCに貢献する 貢献いただきありがとうございます!このリポジトリはClaude Codeユーザーのためのコミュニティリソースです。 @@ -53,8 +53,8 @@ ```bash # 1. Fork とクローン -gh repo fork affaan-m/everything-claude-code --clone -cd everything-claude-code +gh repo fork affaan-m/ECC --clone +cd ECC # 2. ブランチを作成 git checkout -b feat/my-contribution @@ -422,7 +422,7 @@ docs: improve contributing guide ## 質問がありますか? -- **Issues:** [github.com/affaan-m/everything-claude-code/issues](https://github.com/affaan-m/everything-claude-code/issues) +- **Issues:** [github.com/affaan-m/ECC/issues](https://github.com/affaan-m/ECC/issues) - **X/Twitter:** [@affaanmustafa](https://x.com/affaanmustafa) --- diff --git a/docs/ja-JP/EVALUATION.md b/docs/ja-JP/EVALUATION.md index 5d4c9b0b2..7dadf05f8 100644 --- a/docs/ja-JP/EVALUATION.md +++ b/docs/ja-JP/EVALUATION.md @@ -28,7 +28,7 @@ --- -## このリポジトリ(`everything-claude-code` v1.9.0) +## このリポジトリ(`ECC` v1.9.0) | コンポーネント | リポジトリ | |---------------|-----------| diff --git a/docs/ja-JP/GLOSSARY.md b/docs/ja-JP/GLOSSARY.md index 594bcda58..a39a39ea7 100644 --- a/docs/ja-JP/GLOSSARY.md +++ b/docs/ja-JP/GLOSSARY.md @@ -1,6 +1,6 @@ # 用語集 / Glossary -everything-claude-code 日本語翻訳における統一用語集です。 +ECC 日本語翻訳における統一用語集です。 | English | Japanese | 注記 | |---------|----------|------| diff --git a/docs/ja-JP/README.md b/docs/ja-JP/README.md index ec59cf67a..6db24ded6 100644 --- a/docs/ja-JP/README.md +++ b/docs/ja-JP/README.md @@ -31,7 +31,9 @@ ไทย | Deutsch | Español | - Українська + Українська | + Polski | + বাংলা

    @@ -109,12 +111,14 @@

    CodeRabbit    Greptile    - Atlas Cloud    Moonshot AI - Kimi    - Itô Markets + Itô Markets    + SerpApi: Web Search API

    -コミュニティスポンサー: Mike Morgan · @jasonwu513 · @1anter · @massimotodaro · @meadmccabe +過去のスポンサー: Atlas Cloud · Mike Morgan (inactive) + +コミュニティスポンサー: @jasonwu513 · @1anter · @massimotodaro · @meadmccabe スポンサーになる · スポンサーティア · スポンサーシッププログラム @@ -136,13 +140,13 @@ plan -> test -> implement -> review -> verify -> remember -> improve ECC は MIT ライセンスのオープンソースです。現時点では Claude Code で最もよく機能し、サポート対象の Codex 同期パスを備え、Cursor、OpenCode、Gemini、Zed、GitHub Copilot、Antigravity、Qwen、その他のハーネス向けには機能が限定されたアダプターを提供しています。機能の同等性を前提にする前に、[サポート状況マトリクス](#プラットフォームサポート)を確認してください。 -68 の agents、292 の skills、95 のレガシー command シムに加えて、hooks、rules、メモリ、継続的学習、AgentShield セキュリティスキャンを利用できます。agents は計画、レビュー、ビルド修復、セキュリティ、アーキテクチャ、ドメイン作業に特化しています。 +68 の agents、293 の skills、94 のレガシー command シムに加えて、hooks、rules、メモリ、継続的学習、AgentShield セキュリティスキャンを利用できます。agents は計画、レビュー、ビルド修復、セキュリティ、アーキテクチャ、ドメイン作業に特化しています。 | 含まれるもの | 数 | 得られるもの | | ---------------- | ----------: | ------------------------------------------------------------------------------------ | | Agents | 68 agents | 計画、レビュー、ビルド修復、セキュリティ、アーキテクチャ、ドメイン作業 | -| Skills | 292 skills | TDD、リサーチ、セキュリティ、ドキュメント、フロントエンド、データ、ML、運用など | -| Commands | 95 commands | ECC が skills ファーストの構成へ移行する間の便利なエントリーポイント | +| Skills | 293 skills | TDD、リサーチ、セキュリティ、ドキュメント、フロントエンド、データ、ML、運用など | +| Commands | 94 commands | ECC が skills ファーストの構成へ移行する間の便利なエントリーポイント | | Hooks とメモリ | ランタイム | 強制、セッションサマリー、継続的学習、instincts、コンテキスト制御 | | Rules | 選択式 | 言語やプロジェクトごとに選ぶ、常時ロードされる標準 | | AgentShield | 同梱 | プロンプト、hooks、MCP 設定、パーミッション、シークレット、agent ファイルのスキャン | @@ -376,7 +380,7 @@ cd ECC | Qwen CLI | `./install.sh --profile minimal --target qwen` | [Qwen ガイド](../QWEN-GUIDE.md)を参照 | | Hermes | `./install.sh --profile minimal --target hermes` | [Hermes セットアップガイド](../HERMES-SETUP.md)を参照 | | OpenClaw | `./install.sh --profile minimal --target openclaw` | 管理されたホームディレクトリインストール | -| Kimi Code CLI | `./install.sh --profile minimal --target kimi` | プロジェクトローカルの `.kimi-code/` インストール · [Kimi Code を入手](https://www.kimi.com/code?aff=ecc) | +| Kimi Code CLI | `./install.sh --profile minimal --target kimi` | プロジェクトローカルの `.kimi-code/` インストール · [Kimi Code を入手](https://www.kimi.ai/code?aff=ecc) | | CodeBuddy | `./install.sh --profile minimal --target codebuddy` | プロジェクトローカルの `.codebuddy/` インストール | | JoyCode | `./install.sh --profile minimal --target joycode` | プロジェクトローカルの `.joycode/` インストール | @@ -794,7 +798,7 @@ Kimi Code はインストールされた `.kimi-code/AGENTS.md` の指示と `.k ```text ECC/ |-- agents/ # 委譲用の 68 の専門サブエージェント -|-- skills/ # オンデマンドで読み込まれる 292 の再利用可能なワークフロー +|-- skills/ # オンデマンドで読み込まれる 293 の再利用可能なワークフロー |-- commands/ # メンテナンスされている 94 のスラッシュコマンドシム |-- rules/ # オプトインの共通標準と言語別標準 |-- hooks/ # ランタイムの自動化と強制 @@ -817,7 +821,7 @@ ECC/ | |-- plugin.json # Plugin メタデータとコンポーネントパス | |-- marketplace.json # /plugin marketplace add 用のマーケットプレイスカタログ | -|-- agents/ # 委譲用の 67 の専門サブエージェント +|-- agents/ # 委譲用の 69 の専門サブエージェント | |-- planner.md # 機能実装の計画 | |-- architect.md # システム設計の意思決定 | |-- tdd-guide.md # テスト駆動開発 @@ -844,6 +848,7 @@ ECC/ | |-- kotlin-reviewer.md # Kotlin/Android/KMP コードレビュー | |-- kotlin-build-resolver.md # Kotlin/Gradle ビルドエラー | |-- harmonyos-app-resolver.md # HarmonyOS/ArkTS アプリ開発 +| |-- ruby-reviewer.md # Ruby/Rails コードレビュー | |-- rust-reviewer.md # Rust コードレビュー | |-- rust-build-resolver.md # Rust ビルドエラーの解決 | |-- pytorch-build-resolver.md # PyTorch/CUDA トレーニングエラー diff --git a/docs/ja-JP/SOUL.md b/docs/ja-JP/SOUL.md index 97d5d2aba..0deef8a04 100644 --- a/docs/ja-JP/SOUL.md +++ b/docs/ja-JP/SOUL.md @@ -1,7 +1,7 @@ # ソウル ## コアアイデンティティ -Everything Claude Code (ECC) は、30の専門エージェント、135のスキル、60のコマンド、ソフトウェア開発のための自動化フックワークフローを備えたプロダクション対応のAIコーディングプラグインです。 +ECC は、30の専門エージェント、135のスキル、60のコマンド、ソフトウェア開発のための自動化フックワークフローを備えたプロダクション対応のAIコーディングプラグインです。 ## コア原則 1. **エージェントファースト** — できるだけ早い段階で適切なスペシャリストに作業をルーティングする。 diff --git a/docs/ja-JP/TROUBLESHOOTING.md b/docs/ja-JP/TROUBLESHOOTING.md index ea30d2546..e81c2bbed 100644 --- a/docs/ja-JP/TROUBLESHOOTING.md +++ b/docs/ja-JP/TROUBLESHOOTING.md @@ -1,6 +1,6 @@ # トラブルシューティングガイド -Everything Claude Code (ECC) プラグインの一般的な問題と解決策。 +ECC プラグインの一般的な問題と解決策。 ## 目次 @@ -264,7 +264,7 @@ mv ~/.claude/plugins/cache ~/.claude/plugins/cache.backup.$(date +%Y%m%d-%H%M%S) mkdir -p ~/.claude/plugins/cache # Marketplaceから再インストール -# Claude Code → Extensions → Everything Claude Code → Uninstall +# Claude Code → Extensions → ECC → Uninstall # その後Marketplaceから再インストール # 問題がMarketplace/アカウントアクセスの場合、ECC Toolsのbilling/アカウントリカバリーを別途使用 @@ -275,8 +275,8 @@ claude --version # Claude Code 2.0+が必要 # 手動インストール(Marketplaceが失敗する場合) -git clone https://github.com/affaan-m/everything-claude-code.git -cp -r everything-claude-code ~/.claude/plugins/ecc +git clone https://github.com/affaan-m/ECC.git +cp -r ECC ~/.claude/plugins/ecc ``` ### パッケージマネージャー検出の失敗 @@ -407,7 +407,7 @@ find ~/.claude/plugins -name "*.sh" -exec dos2unix {} \; 問題が解決しない場合: -1. **GitHub Issuesを確認**: [github.com/affaan-m/everything-claude-code/issues](https://github.com/affaan-m/everything-claude-code/issues) +1. **GitHub Issuesを確認**: [github.com/affaan-m/ECC/issues](https://github.com/affaan-m/ECC/issues) 2. **デバッグログを有効化**: ```bash export CLAUDE_DEBUG=1 diff --git a/docs/ja-JP/agents/build-error-resolver.md b/docs/ja-JP/agents/build-error-resolver.md index 6362ac24a..5eda1ba50 100644 --- a/docs/ja-JP/agents/build-error-resolver.md +++ b/docs/ja-JP/agents/build-error-resolver.md @@ -2,7 +2,7 @@ name: build-error-resolver description: ビルドおよびTypeScriptエラー解決のスペシャリスト。ビルドが失敗した際やタイプエラーが発生した際に積極的に使用してください。最小限の差分でビルド/タイプエラーのみを修正し、アーキテクチャの変更は行いません。ビルドを迅速に成功させることに焦点を当てます。 tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"] -model: opus +model: sonnet --- # ビルドエラーリゾルバー diff --git a/docs/ja-JP/agents/chief-of-staff.md b/docs/ja-JP/agents/chief-of-staff.md index d62cc0be0..13ad1f21c 100644 --- a/docs/ja-JP/agents/chief-of-staff.md +++ b/docs/ja-JP/agents/chief-of-staff.md @@ -2,7 +2,7 @@ name: chief-of-staff description: メール、Slack、LINE、Messengerをトリアージするパーソナルコミュニケーションチーフオブスタッフ。メッセージを4つのティア(skip/info_only/meeting_info/action_required)に分類し、返信ドラフトを生成し、送信後のフォロースルーをフックで強制します。マルチチャネルコミュニケーションワークフローの管理時に使用します。 tools: ["Read", "Grep", "Glob", "Bash", "Edit", "Write"] -model: opus +model: sonnet --- ## プロンプト防御ベースライン diff --git a/docs/ja-JP/agents/code-reviewer.md b/docs/ja-JP/agents/code-reviewer.md index b5c5c5d72..bf26d6a5a 100644 --- a/docs/ja-JP/agents/code-reviewer.md +++ b/docs/ja-JP/agents/code-reviewer.md @@ -2,7 +2,7 @@ name: code-reviewer description: 専門コードレビュースペシャリスト。品質、セキュリティ、保守性のためにコードを積極的にレビューします。コードの記述または変更直後に使用してください。すべてのコード変更に対して必須です。 tools: ["Read", "Grep", "Glob", "Bash"] -model: opus +model: sonnet --- あなたはコード品質とセキュリティの高い基準を確保するシニアコードレビュアーです。 diff --git a/docs/ja-JP/agents/comment-analyzer.md b/docs/ja-JP/agents/comment-analyzer.md index 1db18900e..63255e383 100644 --- a/docs/ja-JP/agents/comment-analyzer.md +++ b/docs/ja-JP/agents/comment-analyzer.md @@ -1,7 +1,7 @@ --- name: comment-analyzer description: コードコメントの正確性、完全性、保守性、コメント劣化リスクを分析します。 -model: sonnet +model: haiku tools: [Read, Grep, Glob] --- diff --git a/docs/ja-JP/agents/conversation-analyzer.md b/docs/ja-JP/agents/conversation-analyzer.md index bc8ddb8e0..26e31f7ca 100644 --- a/docs/ja-JP/agents/conversation-analyzer.md +++ b/docs/ja-JP/agents/conversation-analyzer.md @@ -1,7 +1,7 @@ --- name: conversation-analyzer description: 会話のトランスクリプトを分析し、フックで防止すべき動作を見つけるためにこのエージェントを使用します。引数なしの/hookifyでトリガーされます。 -model: sonnet +model: haiku tools: [Read, Grep] --- diff --git a/docs/ja-JP/agents/database-reviewer.md b/docs/ja-JP/agents/database-reviewer.md index 30d814b82..f76e50e95 100644 --- a/docs/ja-JP/agents/database-reviewer.md +++ b/docs/ja-JP/agents/database-reviewer.md @@ -1,8 +1,8 @@ --- name: database-reviewer description: クエリ最適化、スキーマ設計、セキュリティ、パフォーマンスのためのPostgreSQLデータベーススペシャリスト。SQL作成、マイグレーション作成、スキーマ設計、データベースパフォーマンスのトラブルシューティング時に積極的に使用してください。Supabaseのベストプラクティスを組み込んでいます。 -tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"] -model: opus +tools: ["Read", "Grep", "Glob", "Bash"] +model: sonnet --- # データベースレビューアー diff --git a/docs/ja-JP/agents/doc-updater.md b/docs/ja-JP/agents/doc-updater.md index c54876458..adf807954 100644 --- a/docs/ja-JP/agents/doc-updater.md +++ b/docs/ja-JP/agents/doc-updater.md @@ -2,7 +2,7 @@ name: doc-updater description: ドキュメントとコードマップのスペシャリスト。コードマップとドキュメントの更新に積極的に使用してください。/update-codemapsと/update-docsを実行し、docs/CODEMAPS/*を生成し、READMEとガイドを更新します。 tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"] -model: opus +model: haiku --- # ドキュメント & コードマップスペシャリスト diff --git a/docs/ja-JP/agents/docs-lookup.md b/docs/ja-JP/agents/docs-lookup.md index e18c0e55a..721baf95c 100644 --- a/docs/ja-JP/agents/docs-lookup.md +++ b/docs/ja-JP/agents/docs-lookup.md @@ -2,7 +2,7 @@ name: docs-lookup description: ユーザーがライブラリ、フレームワーク、APIの使い方を質問したり、最新のコード例が必要な場合に、Context7 MCPを使用して最新のドキュメントを取得し、例付きの回答を返します。ドキュメント/API/セットアップの質問時に呼び出します。 tools: ["Read", "Grep", "mcp__context7__resolve-library-id", "mcp__context7__query-docs"] -model: sonnet +model: haiku --- ## プロンプト防御ベースライン diff --git a/docs/ja-JP/agents/e2e-runner.md b/docs/ja-JP/agents/e2e-runner.md index e6eb35f87..03cc2890c 100644 --- a/docs/ja-JP/agents/e2e-runner.md +++ b/docs/ja-JP/agents/e2e-runner.md @@ -2,7 +2,7 @@ name: e2e-runner description: Vercel Agent Browser(推奨)とPlaywrightフォールバックを使用するエンドツーエンドテストスペシャリスト。E2Eテストの生成、メンテナンス、実行に積極的に使用してください。テストジャーニーの管理、不安定なテストの隔離、アーティファクト(スクリーンショット、ビデオ、トレース)のアップロード、重要なユーザーフローの動作確認を行います。 tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"] -model: opus +model: sonnet --- # E2Eテストランナー diff --git a/docs/ja-JP/agents/gan-evaluator.md b/docs/ja-JP/agents/gan-evaluator.md index 8e2e1b8b6..7dd200268 100644 --- a/docs/ja-JP/agents/gan-evaluator.md +++ b/docs/ja-JP/agents/gan-evaluator.md @@ -1,8 +1,8 @@ --- name: gan-evaluator description: "GANハーネス — エバリュエーターエージェント。Playwrightを使用してライブ実行中のアプリケーションをテストし、ルーブリックに対してスコアリングし、ジェネレーターに実行可能なフィードバックを提供します。" -tools: ["Read", "Write", "Bash", "Grep", "Glob"] -model: opus +tools: ["Read", "Write", "Bash", "Grep", "Glob", "mcp__playwright__browser_navigate", "mcp__playwright__browser_click", "mcp__playwright__browser_take_screenshot", "mcp__playwright__browser_snapshot", "mcp__playwright__browser_type", "mcp__playwright__browser_fill_form", "mcp__playwright__browser_resize", "mcp__playwright__browser_press_key"] +model: sonnet color: red --- diff --git a/docs/ja-JP/agents/gan-generator.md b/docs/ja-JP/agents/gan-generator.md index f31d4c02c..9a3862608 100644 --- a/docs/ja-JP/agents/gan-generator.md +++ b/docs/ja-JP/agents/gan-generator.md @@ -2,7 +2,7 @@ name: gan-generator description: "GANハーネス — ジェネレーターエージェント。仕様に従って機能を実装し、エバリュエーターのフィードバックを読み、品質閾値を満たすまでイテレーションします。" tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"] -model: opus +model: sonnet color: green --- diff --git a/docs/ja-JP/agents/gan-planner.md b/docs/ja-JP/agents/gan-planner.md index 084bb60b4..acf2752d2 100644 --- a/docs/ja-JP/agents/gan-planner.md +++ b/docs/ja-JP/agents/gan-planner.md @@ -2,7 +2,7 @@ name: gan-planner description: "GANハーネス — プランナーエージェント。1行のプロンプトを、機能、スプリント、評価基準、デザイン方向を含む完全な製品仕様に展開します。" tools: ["Read", "Write", "Grep", "Glob"] -model: opus +model: sonnet color: purple --- diff --git a/docs/ja-JP/agents/go-build-resolver.md b/docs/ja-JP/agents/go-build-resolver.md index 4f360fce3..64f2f7df5 100644 --- a/docs/ja-JP/agents/go-build-resolver.md +++ b/docs/ja-JP/agents/go-build-resolver.md @@ -2,7 +2,7 @@ name: go-build-resolver description: Goビルド、vet、コンパイルエラー解決スペシャリスト。最小限の変更でビルドエラー、go vet問題、リンターの警告を修正します。Goビルドが失敗したときに使用してください。 tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"] -model: opus +model: sonnet --- # Goビルドエラーリゾルバー diff --git a/docs/ja-JP/agents/go-reviewer.md b/docs/ja-JP/agents/go-reviewer.md index abab6fe57..0dd66d876 100644 --- a/docs/ja-JP/agents/go-reviewer.md +++ b/docs/ja-JP/agents/go-reviewer.md @@ -4,7 +4,7 @@ description: 慣用的なGo、並行処理パターン、エラー処理、パ コード変更に使用してください。Goプロジェクトに必須です。 tools: ["Read", "Grep", "Glob", "Bash"] -model: opus +model: sonnet --- あなたは慣用的なGoとベストプラクティスの高い基準を確保するシニアGoコードレビュアーです。 diff --git a/docs/ja-JP/agents/opensource-forker.md b/docs/ja-JP/agents/opensource-forker.md index c1c21dd9a..30a81d7ae 100644 --- a/docs/ja-JP/agents/opensource-forker.md +++ b/docs/ja-JP/agents/opensource-forker.md @@ -2,7 +2,7 @@ name: opensource-forker description: あらゆるプロジェクトをオープンソース化のためにフォークします。ファイルのコピー、シークレットと認証情報の除去(20以上のパターン)、内部参照のプレースホルダー置換、.env.exampleの生成、git履歴のクリーンアップを行います。opensource-pipelineスキルの第1ステージです。 tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"] -model: sonnet +model: haiku --- ## プロンプト防御ベースライン diff --git a/docs/ja-JP/agents/opensource-packager.md b/docs/ja-JP/agents/opensource-packager.md index 6916ed6c8..916147b04 100644 --- a/docs/ja-JP/agents/opensource-packager.md +++ b/docs/ja-JP/agents/opensource-packager.md @@ -2,7 +2,7 @@ name: opensource-packager description: サニタイズ済みプロジェクトの完全なオープンソースパッケージングを生成します。CLAUDE.md、setup.sh、README.md、LICENSE、CONTRIBUTING.md、GitHubイシューテンプレートを作成します。あらゆるリポジトリをClaude Codeですぐに使えるようにします。opensource-pipelineスキルの第3ステージです。 tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"] -model: sonnet +model: haiku --- ## プロンプト防御ベースライン diff --git a/docs/ja-JP/agents/python-reviewer.md b/docs/ja-JP/agents/python-reviewer.md index 06059fea3..a8ffe16db 100644 --- a/docs/ja-JP/agents/python-reviewer.md +++ b/docs/ja-JP/agents/python-reviewer.md @@ -2,7 +2,7 @@ name: python-reviewer description: PEP 8準拠、Pythonイディオム、型ヒント、セキュリティ、パフォーマンスを専門とする専門Pythonコードレビュアー。すべてのPythonコード変更に使用してください。Pythonプロジェクトに必須です。 tools: ["Read", "Grep", "Glob", "Bash"] -model: opus +model: sonnet --- あなたはPythonicコードとベストプラクティスの高い基準を確保するシニアPythonコードレビュアーです。 diff --git a/docs/ja-JP/agents/refactor-cleaner.md b/docs/ja-JP/agents/refactor-cleaner.md index e378ba949..a6757490d 100644 --- a/docs/ja-JP/agents/refactor-cleaner.md +++ b/docs/ja-JP/agents/refactor-cleaner.md @@ -2,7 +2,7 @@ name: refactor-cleaner description: デッドコードクリーンアップと統合スペシャリスト。未使用コード、重複の削除、リファクタリングに積極的に使用してください。分析ツール(knip、depcheck、ts-prune)を実行してデッドコードを特定し、安全に削除します。 tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"] -model: opus +model: sonnet --- # リファクタ&デッドコードクリーナー diff --git a/docs/ja-JP/agents/security-reviewer.md b/docs/ja-JP/agents/security-reviewer.md index a9367460d..d2285a3e3 100644 --- a/docs/ja-JP/agents/security-reviewer.md +++ b/docs/ja-JP/agents/security-reviewer.md @@ -1,8 +1,8 @@ --- name: security-reviewer description: セキュリティ脆弱性検出および修復のスペシャリスト。ユーザー入力、認証、APIエンドポイント、機密データを扱うコードを書いた後に積極的に使用してください。シークレット、SSRF、インジェクション、安全でない暗号、OWASP Top 10の脆弱性を検出します。 -tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"] -model: opus +tools: ["Read", "Grep", "Glob", "Bash"] +model: sonnet --- # セキュリティレビューアー diff --git a/docs/ja-JP/agents/tdd-guide.md b/docs/ja-JP/agents/tdd-guide.md index 7726ce367..79bac72df 100644 --- a/docs/ja-JP/agents/tdd-guide.md +++ b/docs/ja-JP/agents/tdd-guide.md @@ -2,7 +2,7 @@ name: tdd-guide description: テスト駆動開発スペシャリストで、テストファースト方法論を強制します。新しい機能の記述、バグの修正、コードのリファクタリング時に積極的に使用してください。80%以上のテストカバレッジを確保します。 tools: ["Read", "Write", "Edit", "Bash", "Grep"] -model: opus +model: sonnet --- あなたはテスト駆動開発(TDD)スペシャリストで、すべてのコードがテストファーストの方法論で包括的なカバレッジをもって開発されることを確保します。 diff --git a/docs/ja-JP/commands/auto-update.md b/docs/ja-JP/commands/auto-update.md index b5147f502..167be9d0f 100644 --- a/docs/ja-JP/commands/auto-update.md +++ b/docs/ja-JP/commands/auto-update.md @@ -18,7 +18,7 @@ node "$ECC_ROOT/scripts/auto-update.js" --dry-run node "$ECC_ROOT/scripts/auto-update.js" --target cursor # ECCリポジトリルートを明示的に上書き -node "$ECC_ROOT/scripts/auto-update.js" --repo-root /path/to/everything-claude-code +node "$ECC_ROOT/scripts/auto-update.js" --repo-root /path/to/ECC ``` ## ノート diff --git a/docs/ja-JP/commands/ecc-guide.md b/docs/ja-JP/commands/ecc-guide.md index e1e75b5cf..055d2d4bc 100644 --- a/docs/ja-JP/commands/ecc-guide.md +++ b/docs/ja-JP/commands/ecc-guide.md @@ -4,7 +4,7 @@ description: ECCの現在のエージェント、スキル、コマンド、フ # /ecc-guide -このコマンドをEverything Claude Codeの会話型マップとして使用します。完全なREADMEや古いカタログ数をダンプすることなく、タスクに適したECCサーフェスをユーザーが発見するのを支援します。 +このコマンドをECCの会話型マップとして使用します。完全なREADMEや古いカタログ数をダンプすることなく、タスクに適したECCサーフェスをユーザーが発見するのを支援します。 ## 使い方 diff --git a/docs/ja-JP/commands/security-scan.md b/docs/ja-JP/commands/security-scan.md index f53659207..0b48895c0 100644 --- a/docs/ja-JP/commands/security-scan.md +++ b/docs/ja-JP/commands/security-scan.md @@ -1,6 +1,6 @@ --- description: エージェント、フック、MCP、パーミッション、シークレットのサーフェスに対してAgentShieldを実行します。 -agent: everything-claude-code:security-reviewer +agent: ecc:security-reviewer subtask: true --- diff --git a/docs/ja-JP/commands/skill-create.md b/docs/ja-JP/commands/skill-create.md index 6715c67d4..31ccd5679 100644 --- a/docs/ja-JP/commands/skill-create.md +++ b/docs/ja-JP/commands/skill-create.md @@ -171,4 +171,4 @@ src/ --- -*[Everything Claude Code](https://github.com/affaan-m/everything-claude-code)の一部* +*[ECC](https://github.com/affaan-m/ECC)の一部* diff --git a/docs/ja-JP/skills/agent-payment-x402/SKILL.md b/docs/ja-JP/skills/agent-payment-x402/SKILL.md index 20a09d917..84d762311 100644 --- a/docs/ja-JP/skills/agent-payment-x402/SKILL.md +++ b/docs/ja-JP/skills/agent-payment-x402/SKILL.md @@ -1,6 +1,6 @@ --- name: agent-payment-x402 -description: タスクごとのバジェット、支出コントロール、ノンカストディアルウォレットを備えた x402 決済実行を AI エージェントに追加します。agentwallet-sdk を通じて Base をサポートし、OKX Payments / OKX エージェント決済プロトコルを通じて X Layer をサポートします。 +description: タスクごとのバジェット、支出コントロール、ノンカストディアルウォレットを備えた x402 決済実行を AI エージェントに追加します。agentwallet-sdk を通じて Base を、OKX Payments / OKX エージェント決済プロトコルを通じて X Layer を、アップストリームの x402 パッケージとファシリテーターベースの決済を通じて Solana とマルチネットワーク EVM をサポートします。 origin: community --- @@ -20,6 +20,8 @@ origin: community |------|------------------| | エージェントが Base または他の agentwallet 対応チェーンの 402 ゲート API に支払う | 厳格な支出ポリシーで `agentwallet-sdk` を MCP 決済サーバーとして使用 | | エージェントが X Layer の 402 ゲート API に支払う | `okx/onchainos-skills` の OKX エージェント決済プロトコルを使用;`okx-x402-payment` は廃止されたレガシーエイリアス | +| エージェントが Solana または他の x402 v2 ネットワークの 402 ゲート API に支払う | エージェントの HTTP クライアントをアップストリームの `@x402/fetch` または `@x402/axios` パッケージでラップし、EVM/SVM スキームを登録する;リソースサーバーのファシリテーターが検証・決済する | +| API が Solana または複数ネットワークでエージェントに課金する(TypeScript、Python、または Go) | `x402-foundation/x402` のアップストリーム x402 ミドルウェアを使用 — TypeScript は `@x402/express`、`@x402/hono`、`@x402/next`、または `@x402/fastify`、Python は `x402`、Go は `github.com/x402-foundation/x402/go/v2` | | TypeScript API がエージェントに課金する | Express、Hono、Fastify、または Next.js 向け OKX Payments TypeScript セラー SDK ドキュメントを使用 | | Go API がエージェントに課金する | Gin、Echo、または `net/http` 向け OKX Payments Go セラー SDK ドキュメントを使用 | | Rust API がエージェントに課金する | Axum 向け OKX Payments Rust セラー SDK ドキュメントを使用 | @@ -30,6 +32,7 @@ origin: community - `agentwallet-sdk`: 本番使用前に現在のネットワークカバレッジをパッケージドキュメントで確認。Base Sepolia が最も安全な開発デフォルト;Base メインネットがオリジナルスキルで説明されている本番パス。 - OKX Payments / X Layer: 現在のセラードキュメントは X Layer(`eip155:196`)と USDT0 決済を対象。決済パッケージとファシリテーターの動作が迅速に変わる可能性があるため、本番コードを生成する前に現在の SDK ドキュメントを取得すること。 +- アップストリーム x402 パッケージ: 設計上マルチネットワーク — 1 つのルートで Base と Solana を同時に提示し、買い手に選ばせることができる。パッケージのデフォルトは `x402.org` ファシリテーターで、テストネット専用(Base Sepolia `eip155:84532`、Solana デブネット `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`、加えて Stellar、Aptos、Hedera、XRPL の各テストネット)であり、メインネットルート向けではない。メインネット(Solana は `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`)では、自己ファシリテートするか、自前のファシリテーターを運用するか、アップストリームのファシリテーターリストからホスト型を選ぶ — オプション C のファシリテーター比較を参照。本番前にここにハードコードするのではなく、ファシリテーターの `/supported` エンドポイントでライブカバレッジを確認すること。 ## 仕組み @@ -98,6 +101,212 @@ X Layer x402、マルチパーティ決済(MPP)、セッション決済、 現在の OKX リポジトリを確認せずに古いドキュメントの例をコピーしないこと。現在の OKX ガイダンスはディスパッチャーとして `okx-agent-payments-protocol` を使用しており、Java セラードキュメントが利用可能になっています。 +### オプション C: アップストリーム x402 パッケージ(Solana + Base/EVM) + +エージェントが Solana、Base、またはアップストリームのプロトコル実装がサポートする他のネットワークで支払う(または API が課金する)場合にこのパスを使用します。[`x402-foundation/x402`](https://github.com/x402-foundation/x402) の正規 x402 モノレポは活発にメンテナンスされており、クライアントとミドルウェアのパッケージを直接公開しています。オプション A・B と異なり、これは別個の MCP サーバーではありません — エージェント自身の HTTP クライアントをラップし、リソースサーバーが選んだファシリテーターが検証・決済します。 + +バイヤー側エージェントフローの場合: + +1. 古いドキュメントのスニペットをコピーするのではなく、メンテナンスされている [`examples/typescript/clients`](https://github.com/x402-foundation/x402/tree/main/examples/typescript/clients)(fetch、axios、MCP)の例から始める。 +2. オプション B が OKX フローに要求するのとまったく同様に、最初の有料リクエストの署名または送信の前に明示的なユーザー確認を求める。汎用ツール呼び出しの背後に決済実行を隠さない。 +3. パッケージバージョンを固定する(例:`@x402/fetch@2.22.0`);アップストリームの全パッケージはロックステップでバージョニングされる。 +4. バジェットはクライアントに登録した `PaymentPolicy` で強制し、毎回サーバーの実際のチャレンジに対して検査が走るようにする。自分で渡した数値と突き合わせるバジェットは何も証明しない — 金額・アセット・ネットワークはすべてサーバー由来なので、署名が生成される前に 3 つとも検証すること。 + +5. 受取先と予算のポリシーとは別に、許可するリソースのオリジンを正確に設定する。以下の支払い対応 fetch はすべてリダイレクトを拒否する。セッションの確認には許可オリジン、ネットワーク、資産、受取先、人が読める単位の支出上限を明示し、並行呼び出しは同じ確認結果を共有する。クライアントとポリシーはオーケストレーター内に留める。 + +```typescript +import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; +import { ExactEvmScheme } from "@x402/evm/exact/client"; +import { ExactSvmScheme } from "@x402/svm/exact/client"; +import { privateKeyToAccount } from "viem/accounts"; +import { createKeyPairSignerFromBytes } from "@solana/kit"; +import { base58 } from "@scure/base"; + +// Signer keys belong to the ORCHESTRATOR's env — never hardcoded, never agent-writable. +const evmKey = process.env.EVM_PRIVATE_KEY as `0x${string}`; +const svmKey = process.env.SVM_PRIVATE_KEY; +if (!evmKey || !svmKey) { + throw new Error("Signer keys are not set — refusing to start payment client"); +} + +// One client, both network families: the buyer pays whichever chain the 402 offers. +const client = new x402Client(); +client.register("eip155:*", new ExactEvmScheme(privateKeyToAccount(evmKey))); +client.register("solana:*", new ExactSvmScheme(await createKeyPairSignerFromBytes(base58.decode(svmKey)))); + +// A PaymentPolicy filters the SERVER's payment requirements before any +// signature is created. Returning an empty array means "nothing here is +// acceptable" and the client refuses to pay rather than falling back. +const ALLOWED_NETWORKS = new Set([ + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", // Solana mainnet + "eip155:8453", // Base +]); +const MAX_AMOUNT = 10_000n; // atomic units, 6-decimal USDC: 0.01 USDC per call +const SESSION_CAP = 50_000n; // 0.05 USDC across the whole session + +// EVM addresses are case-insensitive, so compare them lowercased. Solana +// addresses are base58 and ARE case-sensitive — never lowercase those, or a +// different account could slip through. +const normalizeAddress = (a: string) => (a.startsWith("0x") ? a.toLowerCase() : a); +const ALLOWED_ASSETS = new Set( + [ + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC, Solana mainnet + "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC, Base + ].map(normalizeAddress), +); +// Who you are willing to pay. Without this, any 402 an agent happens to hit +// can name its own recipient. +const ALLOWED_PAY_TO = new Set( + ["7pr7NCaQRz5PEhPy7BAeB3Z72TVkiShhjRyVCN5DA6yC"].map(normalizeAddress), +); + +client.registerPolicy((_x402Version, requirements) => + requirements.filter(r => { + if (!ALLOWED_NETWORKS.has(r.network)) return false; // wrong chain + if (!ALLOWED_ASSETS.has(normalizeAddress(r.asset))) return false; // wrong token + if (!ALLOWED_PAY_TO.has(normalizeAddress(r.payTo))) return false; // wrong recipient + try { + const amount = BigInt(r.amount); + return amount >= 0n && amount <= MAX_AMOUNT; // over budget / negative + } catch { + return false; // unparseable amount + } + }), +); + +// The policy sees one challenge at a time, so it cannot enforce a session +// total or ask a human anything. Keep the payment-enabled client private and +// route every paid call through the payOnce boundary below. + +// Supply this from your harness — a real prompt, never a stub that returns true. +declare function confirmWithUser(prompt: string): Promise; + +// Resource authorization is independent of recipient and budget policy. +// The ORCHESTRATOR supplies exact approved HTTPS origins before delegation; +// never populate this set from a server challenge or agent-controlled input. +const ALLOWED_ORIGINS = new Set(["https://api.example.com"]); +function requireAllowedOrigin(url: string): void { + const parsed = new URL(url); + if (!ALLOWED_ORIGINS.has(parsed.origin) || parsed.username || parsed.password) { + throw new Error("Resource origin is not authorized"); + } +} + +// Both approved assets above are 6-decimal USDC. Update this trusted formatting +// policy along with the asset allowlist if you support other assets/decimals. +const USDC_SCALE = 1_000_000n; +const humanUSDC = (amount: bigint) => + `${amount / USDC_SCALE}.${(amount % USDC_SCALE).toString().padStart(6, "0").replace(/0+$/, "") || "0"} USDC`; + +let sessionSpent = 0n; +let sessionApproval: Promise | undefined; + +async function payOnce(url: string, init?: RequestInit): Promise { + requireAllowedOrigin(url); // Before reservation, prompt, fetch, or signature. + // Reserve the worst case the policy allows. Settlement responses do not + // carry an amount, so counting MAX_AMOUNT per call is a deliberate + // over-estimate — it can stop early, never late. Reserve before any await, + // so concurrent calls cannot all pass the check. + if (sessionSpent + MAX_AMOUNT > SESSION_CAP) { + throw new Error("Session budget exhausted — blocked"); + } + sessionSpent += MAX_AMOUNT; + // Release the reservation only when no signed payment left this process: + // a declined or failed prompt, a challenge the policy rejected, or a free + // response. Once a signed request is sent it may settle, so keep it counted. + let signedRequestSent = false; + const trackingFetch: typeof fetch = async (input, reqInit) => { + // Override caller options on BOTH the challenge and signed retry. Native + // fetch refuses redirects, so an unauthorized host cannot return a 402 + // or receive a payment header via an automatic redirect. + const req = new Request(input, { ...reqInit, redirect: "error" }); + requireAllowedOrigin(req.url); + if (req.headers.has("PAYMENT-SIGNATURE") || req.headers.has("X-PAYMENT")) { + signedRequestSent = true; + } + const response = await fetch(req); + // Also fail closed for adapters that expose a redirect response instead. + if (response.redirected || (response.status >= 300 && response.status < 400)) { + throw new Error("Paid request redirects are blocked"); + } + return response; + }; + try { + // Install one promise before awaiting the prompt. Concurrent calls share + // the same decision, including a decline or failure; never auto-reprompt. + sessionApproval ??= Promise.resolve().then(() => confirmWithUser( + `Allow paid requests to origins: ${[...ALLOWED_ORIGINS].join(", ")}? ` + + `Networks: ${[...ALLOWED_NETWORKS].join(", ")}. ` + + `Assets: ${[...ALLOWED_ASSETS].join(", ")} (6-decimal USDC). ` + + `Recipients (payTo): ${[...ALLOWED_PAY_TO].join(", ")}. ` + + `Per-call cap: ${humanUSDC(MAX_AMOUNT)}; session cap: ${humanUSDC(SESSION_CAP)}.`, + )); + if (!await sessionApproval) { + throw new Error("User declined: no payment attempted"); + } + return await wrapFetchWithPayment(trackingFetch, client)(url, init); + } finally { + if (!signedRequestSent) sessionSpent -= MAX_AMOUNT; + } +} + +const res = await payOnce("https://api.example.com/data", { method: "GET" }); +``` + +ポリシーを登録すると、予算超過の金額、想定外のトークン、未登録のチェーンはすべてフェイルクローズドになる — 候補がすべて除外されるため、`createPaymentPayload` は署名せずに例外を投げる。デブネット USDC は `4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`;開発時のみ `ALLOWED_ASSETS` に追加すること。ポリシーは敵対的にテストする — `5000000` アトミック単位を要求するチャレンジ、別のミントを提示するチャレンジ、登録していないチェーンのチャレンジを与え、いずれも署名が生成されないことを確認する。exact-SVM スキームではファシリテーターがトランザクション手数料支払者となるため、買い手のウォレットは USDC のみを保有すればよい — ガス用の SOL は不要。 + +**ファシリテーターの選択。** ファシリテーターはリソースサーバーに代わって検証と決済を行うため、これは買い手ではなくリソースサーバーの判断である。委ねる信頼が小さい順に: + +| 選択肢 | 適する場面 | +|--------|--------------| +| [インプロセスで自己ファシリテート](https://github.com/x402-foundation/x402/tree/main/examples/typescript/servers/self-facilitation) | 決済経路に第三者を入れたくなく、鍵と RPC アクセスを自前で保持できる | +| 自前のファシリテーターを運用 | 同じ制御を複数サービスで共有したい | +| [`x402.org` ファシリテーター](https://x402.org/facilitator) | 開発・テストネット向け — パッケージのデフォルトでセットアップ不要。アップストリームはメインネットルート向けではないと明記している | +| ホスト型の本番ファシリテーター | インフラを運用せずにメインネット対応が欲しい | + +ホスト型を選ぶ場合は、ここに書かれた名前ではなくアップストリームドキュメントの[ファシリテーターリスト](https://docs.x402.org/dev-tools/facilitators)から選ぶこと — メンテナンスされており、網羅的ではなく、カバレッジも変わる。執筆時点では Coinbase の CDP(全トランザクションに KYT/OFAC スクリーニング)、PayAI、Corbits、Dexter、Solvador などが掲載され、複数が EVM に加えて Solana メインネットをカバーしている。どれを選ぶ場合も、本番前に `/supported` エンドポイントでライブカバレッジを確認し、ネットワークを追加したときは再確認すること。 + +> **開示**: このセクションは、掲載されているファシリテーターの 1 つである PayAI に関わる者が寄稿した。複数ある選択肢の 1 つとして記載しており、上記の自己ホストおよびアップストリームデフォルトのパスを意図的に先に挙げている。 + +**セラー側(API がエージェントに課金する)。** アップストリームのミドルウェアを使用する;1 つのルートで Base と Solana を同時に提示できる(実行可能なバージョンは [`examples/typescript/servers`](https://github.com/x402-foundation/x402/tree/main/examples/typescript/servers) を参照): + +| ランタイム | パッケージ | +|---------|---------| +| Express / Hono / Next.js / Fastify | `@x402/express@2.22.0`、`@x402/hono@2.22.0`、`@x402/next@2.22.0`、`@x402/fastify@2.22.0` | +| Python(FastAPI、Flask) | PyPI の `x402` | +| Go(Gin、Echo、`net/http`) | `github.com/x402-foundation/x402/go/v2` | + +**Solana のセラー:`payTo` アドレスには先に正規のトークンアカウントが必要。** exact-SVM クライアントは `findAssociatedTokenPda` で `payTo` の*関連トークンアカウント*(ATA)を導出してそこに送金するが、それを作成はしない。その正確なアカウントが存在しないと決済はシミュレーション段階で失敗し、402 が `transaction_simulation_failed` を返す — クライアントのバグのように見えるが、実際は受取側アカウントの欠落である。導出したアドレス自体を確認すること — オーナーのトークンアカウントを走査するのは等価ではない。同じミントの非正規な補助アカウントがあると検査は通るのに、クライアントが対象とする ATA は依然として存在しないからだ: + +```typescript +import { findAssociatedTokenPda, TOKEN_PROGRAM_ADDRESS } from "@solana-program/token"; + +const [ata] = await findAssociatedTokenPda({ + mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC mainnet + owner: payToAddress, + tokenProgram: TOKEN_PROGRAM_ADDRESS, // TOKEN_2022_PROGRAM_ADDRESS for Token-2022 mints +}); +``` + +次にそのアドレスが実在することを確認する — `value: null` なら存在せず、そこへの決済は失敗する: + +```bash +curl -s https://api.mainnet-beta.solana.com -X POST -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"getAccountInfo", + "params":["",{"encoding":"base64"}]}' \ + | jq '.result.value != null' +``` + +作成方法(作成者が少額のレントを負担し、支払者ではない): + +- コード上では `@solana-program/token` の `getCreateAssociatedTokenIdempotentInstruction` をオンボーディングフローに追加する — 冪等版なので再実行しても安全で、決定的に動作する唯一の方法。 +- spl-token CLI で `spl-token create-account --owner `。 +- そのトークンを `payTo` に送金する方法も使えるが、送金側が作成インストラクションを含む場合に限る — ウォレットや `spl-token transfer --fund-recipient` は含める。ATA が無い状態への素の `transferChecked` は、決済と同じように失敗する。 + +これは新規プロビジョニングされたウォレットやカストディアルウォレットへの支払いで最も問題になりやすい。そうしたウォレットは対象アセットの ATA をまだ持っていないことが多い。 + +**発見。** x402 バザール拡張を実装するファシリテーターは `/discovery/resources` エンドポイントを公開する — CDP カタログは `https://api.cdp.coinbase.com/platform/v2/x402/discovery/resources`、PayAI カタログは `https://facilitator.payai.network/discovery/resources` で照会できる。Solana で支払い可能なサービスには、Solana Foundation のキュレーションカタログである [pay.sh](https://pay.sh) もある。 + ## 例 ### MCP クライアントでのバジェット強制 @@ -212,7 +421,8 @@ main().catch((err) => { - **監査証跡**: タスク後のフックで `list_transactions` を使用して何が使われたかをログに記録する。 - **フェイルクローズド**: 決済ツールに到達できない場合、有料アクションをブロックする — 課金されないアクセスにフォールバックしない。 - **security-review と組み合わせる**: 決済ツールは高い権限を持つ。シェルアクセスと同じ精査を適用する。 -- **まずテストネットでテストする**: 開発には Base Sepolia を使用;本番には Base メインネットに切り替える。 +- **まずテストネットでテストする**: 開発には Base Sepolia を使用;本番には Base メインネットに切り替える。Solana では、メインネットの本番ファシリテーターに移る前に、Solana デブネット(`solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`)と無料の x402.org ファシリテーターで開発する。 +- **Solana では SOL ではなく USDC を資金として入れる**: exact-SVM スキームではファシリテーターがトランザクション手数料支払者となるため、SOL を持たないウォレットでも支払える。署名前に各チャレンジの `asset` を期待する USDC ミント(メインネット `EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`、デブネット `4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`)と照合する — 任意のアセットに支払うラップクライアントはバジェットの穴になる。 ## 本番リファレンス @@ -222,3 +432,8 @@ main().catch((err) => { - **OKX Payments SDK**: [`okx/payments`](https://github.com/okx/payments) — X Layer x402 向け TypeScript、Go、Rust、Java セラー統合 - **OKX エージェント決済プロトコルスキル**: [`okx/onchainos-skills`](https://github.com/okx/onchainos-skills/tree/main/skills/okx-agent-payments-protocol) - **OKX Payments 概要**: [web3.okx.com/onchainos/dev-docs/payments/overview](https://web3.okx.com/onchainos/dev-docs/payments/overview) +- **アップストリーム x402 モノレポ**: [`x402-foundation/x402`](https://github.com/x402-foundation/x402) — TypeScript、Python、Go の実装と、メンテナンスされているクライアント・サーバー例 +- **x402 ドキュメント**: [docs.x402.org](https://docs.x402.org);本番ファシリテーターリストは [docs.x402.org/dev-tools/facilitators](https://docs.x402.org/dev-tools/facilitators) +- **`@x402` パッケージ**: [npmjs.com/org/x402](https://www.npmjs.com/org/x402) — `@x402/fetch`、`@x402/axios`、`@x402/express`、`@x402/hono`、`@x402/next`、`@x402/fastify`、`@x402/evm`、`@x402/svm` +- **ファシリテーター**: [自己ファシリテーションの例](https://github.com/x402-foundation/x402/tree/main/examples/typescript/servers/self-facilitation)(第三者なし)、[x402.org ファシリテーター](https://x402.org/facilitator)(テストネットデフォルト)、メンテナンスされている[本番リスト](https://docs.x402.org/dev-tools/facilitators) +- **発見**: CDP と PayAI のバザールは `/discovery/resources`;Solana で支払い可能なサービスは [pay.sh](https://pay.sh) diff --git a/docs/ja-JP/skills/autonomous-agent-harness/SKILL.md b/docs/ja-JP/skills/autonomous-agent-harness/SKILL.md index 1462cb747..22ebcea8e 100644 --- a/docs/ja-JP/skills/autonomous-agent-harness/SKILL.md +++ b/docs/ja-JP/skills/autonomous-agent-harness/SKILL.md @@ -93,7 +93,7 @@ Claude Codeのスケジュール済みタスクを使用して、定期的なエ mcp__scheduled-tasks__create_scheduled_task({ name: "daily-pr-review", schedule: "0 9 * * 1-5", # 平日午前9時 - prompt: "affaan-m/everything-claude-codeのすべてのオープンPRを確認します。各について:CIステータスをチェック、変更を確認、問題にフラグを立てます。メモリに概要を投稿します。", + prompt: "affaan-m/ECCのすべてのオープンPRを確認します。各について:CIステータスをチェック、変更を確認、問題にフラグを立てます。メモリに概要を投稿します。", project_dir: "/path/to/repo" }) ``` diff --git a/docs/ja-JP/skills/blueprint/SKILL.md b/docs/ja-JP/skills/blueprint/SKILL.md index 22cfa75df..113ce966f 100644 --- a/docs/ja-JP/skills/blueprint/SKILL.md +++ b/docs/ja-JP/skills/blueprint/SKILL.md @@ -50,7 +50,7 @@ origin: community ## インストール -このスキルはEverything Claude Codeに付属します。ECCがインストールされている場合、別のインストールは必要ありません。 +このスキルはECCに付属します。ECCがインストールされている場合、別のインストールは必要ありません。 ### 完全なECCインストール diff --git a/docs/ja-JP/skills/bun-runtime/SKILL.md b/docs/ja-JP/skills/bun-runtime/SKILL.md index 9b4049022..5ff7f0464 100644 --- a/docs/ja-JP/skills/bun-runtime/SKILL.md +++ b/docs/ja-JP/skills/bun-runtime/SKILL.md @@ -17,21 +17,28 @@ Bunは高速なオールインワンJavaScriptランタイムとツールキッ ## 動作方法 -- **ランタイム**:ドロップイン互換のNodeランタイム(JavaScriptCoreで構築、Zigで実装)。 -- **パッケージマネージャー**:`bun install`はnpm/yarnよりも大幅に高速です。ロックファイルは`bun.lock`(テキスト)(デフォルト)。古いバージョンは`bun.lockb`(バイナリ)を使用しました。 +- **バージョンの基準**:以下のガイダンスはBun 1.4.xを対象としています。CLIフラグはリリース間で変更されることがあるため、フラグ名に依存する前に`bun --version`で実際のバージョンを確認してください。 +- **ランタイム**:ドロップイン互換のNodeランタイム(JavaScriptCore上に構築)。Bun 1.4以降、ランタイム自体はRustで実装されています(Zigから移行)。100%Node互換ではありません — 一部のネイティブアドオン、あまり一般的でない`node:`内部APIや、Node固有の内部動作に依存するパッケージは動作しないことがあります。本番環境で依存する前に検証してください。 +- **パッケージマネージャー**:Bunにはパッケージマネージャーが含まれています。インストール性能はプロジェクト、ネットワーク状況、キャッシュの状態によって変わります。ロックファイルは現行のBunでは`bun.lock`(テキスト)がデフォルトです。古いバージョンは`bun.lockb`(バイナリ)を使用しており、Bunは移行のため現在もこれを読み込めますが、新規プロジェクトは`bun.lock`を使うべきです。 - **バンドラー**:アプリとライブラリ用の組み込みバンドラーとトランスパイラー。 - **テストランナー**:Jest様のAPIを備えた組み込み`bun test`。 -**Nodeからの移行**:`node script.js`を`bun run script.js`または`bun script.js`に置き換えます。`npm install`の代わりに`bun install`を実行します。ほとんどのパッケージは機能します。npm スクリプトには`bun run`を使用します。`bun x`をnpxスタイルの1回限りの実行に使用します。Nodeの組み込みはサポートされています。パフォーマンスの向上のため、Bunチャネルが存在する場合は優先。 +**Nodeからの移行**:`node script.js`を`bun run script.js`または`bun script.js`に置き換えます。`npm install`の代わりに`bun install`を実行します。ほとんどのパッケージは機能します。npm スクリプトには`bun run`を使用します。`bun x`をnpxスタイルの1回限りの実行に使用します。Nodeの組み込みはサポートされています。パフォーマンスの向上のため、Bun APIが存在する場合は優先してください。 -**Vercel**:プロジェクト設定でBunに設定をランタイムに設定します。ビルド:`bun run build`または`bun build ./src/index.ts --outdir=dist`。インストール:再現可能なデプロイの場合は`bun install --frozen-lockfile`。 +**パッケージ・ワークスペースコマンド**:`bun add ` / `bun remove ` / `bun update [pkg]`で依存関係を管理し、`bun outdated`で古い依存関係を一覧表示します。`bun pm ls`はロックファイルを使ってプロジェクトの依存関係と解決済みバージョンを表示し、`--all`を付けると推移的な依存関係も含めます。インストール済みファイルの内容の整合性を検証するものではありません。[`bun audit`](https://bun.com/docs/pm/cli/audit)は`bun.lock`内のパッケージ名とバージョンを使ってレジストリの脆弱性情報を照会し、`package.json`、`bun.lock`、`node_modules`は変更しません。プライベートなパッケージ情報を送信する前に、承認済みの送信先を確認してください。スコープ付きレジストリにアドバイザリエンドポイントがないパッケージはスキップされ、終了コードに影響しません。`bun audit fix`は依存関係を更新・インストールし、ロックファイルとインストール済みパッケージ、場合によっては直接依存の固定バージョンも変更します。適用されるライフサイクルスクリプトも含む、変更を伴うインストール操作としてレビューしてください。`bun dedupe`は重複インストールを解消し、`bun prune`は参照されなくなったパッケージを削除します。モノレポはルートの`package.json`の`workspaces`配列を使用します(npm/yarnと同じ規約)。特定のワークスペースでスクリプトを実行するには`bun run --filter +``` + +### Safe String Handling + +```python +from django.utils.safestring import mark_safe +from django.utils.html import escape + +# BAD: Never mark user input as safe without escaping +def render_bad(user_input): + return mark_safe(user_input) # VULNERABLE! + +# GOOD: Escape first, then mark safe +def render_good(user_input): + return mark_safe(escape(user_input)) + +# GOOD: Use format_html for HTML with variables +from django.utils.html import format_html + +def greet_user(username): + return format_html('{}', escape(username)) +``` + +### HTTP Headers + +```python +# settings.py +SECURE_CONTENT_TYPE_NOSNIFF = True # Prevent MIME sniffing +SECURE_BROWSER_XSS_FILTER = True # Enable XSS filter +X_FRAME_OPTIONS = 'DENY' # Prevent clickjacking + +# Custom middleware +from django.conf import settings + +class SecurityHeaderMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + response['X-Content-Type-Options'] = 'nosniff' + response['X-Frame-Options'] = 'DENY' + response['X-XSS-Protection'] = '1; mode=block' + response['Content-Security-Policy'] = "default-src 'self'" + return response +``` + +## CSRF Protection + +### Default CSRF Protection + +```python +# settings.py - CSRF is enabled by default +CSRF_COOKIE_SECURE = True # Only send over HTTPS +CSRF_COOKIE_HTTPONLY = True # Prevent JavaScript access +CSRF_COOKIE_SAMESITE = 'Lax' # Prevent CSRF in some cases +CSRF_TRUSTED_ORIGINS = ['https://example.com'] # Trusted domains + +# Template usage +
    + {% csrf_token %} + {{ form.as_p }} + +
    + +# AJAX requests +function getCookie(name) { + let cookieValue = null; + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; +} + +fetch('/api/endpoint/', { + method: 'POST', + headers: { + 'X-CSRFToken': getCookie('csrftoken'), + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data) +}); +``` + +### Exempting Views (Use Carefully) + +```python +from django.views.decorators.csrf import csrf_exempt + +@csrf_exempt # Only use when absolutely necessary! +def webhook_view(request): + # Webhook from external service + pass +``` + +## File Upload Security + +### File Validation + +```python +import os +import magic # pip install python-magic +from django.core.exceptions import ValidationError + +ALLOWED_MIMES = { + 'image/jpeg', 'image/png', 'image/gif', 'application/pdf', +} + +MIME_TO_EXTENSIONS = { + 'image/jpeg': {'.jpg', '.jpeg'}, + 'image/png': {'.png'}, + 'image/gif': {'.gif'}, + 'application/pdf': {'.pdf'}, +} + +def validate_file_type(value): + """Validate file type using magic bytes and cross-check extension.""" + mime = magic.from_buffer(value.read(2048), mime=True) + value.seek(0) + + if mime not in ALLOWED_MIMES: + raise ValidationError('Unsupported file type.') + + ext = os.path.splitext(value.name)[1].lower() + if ext not in MIME_TO_EXTENSIONS.get(mime, set()): + raise ValidationError('File extension does not match file content.') + +def validate_file_size(value): + """Validate file size (max 5MB).""" + if value.size > 5 * 1024 * 1024: + raise ValidationError('File too large. Max size is 5MB.') + +# models.py +class Document(models.Model): + file = models.FileField( + upload_to='documents/', + validators=[validate_file_type, validate_file_size] + ) + +``` + +For environments where installing libmagic is difficult (e.g., minimal containers), +use the pure-Python `filetype` package as an alternative: + +```python +import os +from django.core.exceptions import ValidationError + +import filetype # pip install filetype + +ALLOWED_MIMES = { + 'image/jpeg', 'image/png', 'image/gif', 'application/pdf', +} + +MIME_TO_EXTENSIONS = { + 'image/jpeg': {'.jpg', '.jpeg'}, + 'image/png': {'.png'}, + 'image/gif': {'.gif'}, + 'application/pdf': {'.pdf'}, +} + +def validate_file_type(value): + """Validate file type using magic bytes.""" + kind = filetype.guess(value.read(2048)) + value.seek(0) + + if kind is None or kind.mime not in ALLOWED_MIMES: + raise ValidationError('Unsupported file type.') + + ext = os.path.splitext(value.name)[1].lower() + if ext not in MIME_TO_EXTENSIONS.get(kind.mime, set()): + raise ValidationError('File extension does not match file content.') +``` + +### Secure File Storage + +```python +# settings.py +MEDIA_ROOT = '/var/www/media/' +MEDIA_URL = '/media/' + +# Use a separate domain for media in production +MEDIA_DOMAIN = 'https://media.example.com' + +# Don't serve user uploads directly +# Use whitenoise or a CDN for static files +# Use a separate server or S3 for media files +``` + +## API Security + +### Rate Limiting + +```python +# settings.py +REST_FRAMEWORK = { + 'DEFAULT_THROTTLE_CLASSES': [ + 'rest_framework.throttling.AnonRateThrottle', + 'rest_framework.throttling.UserRateThrottle' + ], + 'DEFAULT_THROTTLE_RATES': { + 'anon': '100/day', + 'user': '1000/day', + 'upload': '10/hour', + } +} + +# Custom throttle +from rest_framework.throttling import UserRateThrottle + +class BurstRateThrottle(UserRateThrottle): + scope = 'burst' + rate = '60/min' + +class SustainedRateThrottle(UserRateThrottle): + scope = 'sustained' + rate = '1000/day' +``` + +### Authentication for APIs + +```python +# settings.py +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], +} + +# views.py +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def protected_view(request): + return Response({'message': 'You are authenticated'}) +``` + +## Security Headers + +### Content Security Policy + +```python +# settings.py +CSP_DEFAULT_SRC = "'self'" +CSP_SCRIPT_SRC = "'self' https://cdn.example.com" +CSP_STYLE_SRC = "'self' 'unsafe-inline'" +CSP_IMG_SRC = "'self' data: https:" +CSP_CONNECT_SRC = "'self' https://api.example.com" + +# Middleware +class CSPMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + response['Content-Security-Policy'] = ( + f"default-src {CSP_DEFAULT_SRC}; " + f"script-src {CSP_SCRIPT_SRC}; " + f"style-src {CSP_STYLE_SRC}; " + f"img-src {CSP_IMG_SRC}; " + f"connect-src {CSP_CONNECT_SRC}" + ) + return response +``` + +## Environment Variables + +### Managing Secrets + +```python +# Use python-decouple or django-environ +import environ + +env = environ.Env( + # set casting, default value + DEBUG=(bool, False) +) + +# reading .env file +environ.Env.read_env() + +SECRET_KEY = env('DJANGO_SECRET_KEY') +DATABASE_URL = env('DATABASE_URL') +ALLOWED_HOSTS = env.list('ALLOWED_HOSTS') + +# .env file (never commit this) +DEBUG=False +SECRET_KEY=your-secret-key-here +DATABASE_URL=postgresql://user:password@localhost:5432/dbname +ALLOWED_HOSTS=example.com,www.example.com +``` + +## Logging Security Events + +```python +# settings.py +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'file': { + 'level': 'WARNING', + 'class': 'logging.FileHandler', + 'filename': '/var/log/django/security.log', + }, + 'console': { + 'level': 'INFO', + 'class': 'logging.StreamHandler', + }, + }, + 'loggers': { + 'django.security': { + 'handlers': ['file', 'console'], + 'level': 'WARNING', + 'propagate': True, + }, + 'django.request': { + 'handlers': ['file'], + 'level': 'ERROR', + 'propagate': False, + }, + }, +} +``` + +## Quick Security Checklist + +| Check | Description | +|-------|-------------| +| `DEBUG = False` | Never run with DEBUG in production | +| HTTPS only | Force SSL, secure cookies | +| Strong secrets | Use environment variables for SECRET_KEY | +| Password validation | Enable all password validators | +| CSRF protection | Enabled by default, don't disable | +| XSS prevention | Django auto-escapes, don't use `|safe` with user input | +| SQL injection | Use ORM, never concatenate strings in queries | +| File uploads | Validate file type and size | +| Rate limiting | Throttle API endpoints | +| Security headers | CSP, X-Frame-Options, HSTS | +| Logging | Log security events | +| Updates | Keep Django and dependencies updated | + +Remember: Security is a process, not a product. Regularly review and update your security practices. diff --git a/pi/core/skills/django-tdd/SKILL.md b/pi/core/skills/django-tdd/SKILL.md new file mode 100644 index 000000000..aaa2cd87f --- /dev/null +++ b/pi/core/skills/django-tdd/SKILL.md @@ -0,0 +1,730 @@ +--- +name: django-tdd +description: Django testing strategies with pytest-django, TDD methodology, factory_boy, mocking, coverage, and testing Django REST Framework APIs. Use when writing Django or DRF tests with pytest-django, or driving a Django feature test-first. +metadata: + origin: ECC +--- + +# Django Testing with TDD + +Test-driven development for Django applications using pytest, factory_boy, and Django REST Framework. + +## When to Activate + +- Writing new Django applications +- Implementing Django REST Framework APIs +- Testing Django models, views, and serializers +- Setting up testing infrastructure for Django projects + +## TDD Workflow for Django + +### Red-Green-Refactor Cycle + +```python +# Step 1: RED - Write failing test +def test_user_creation(): + user = User.objects.create_user(email='test@example.com', password='testpass123') + assert user.email == 'test@example.com' + assert user.check_password('testpass123') + assert not user.is_staff + +# Step 2: GREEN - Make test pass +# Create User model or factory + +# Step 3: REFACTOR - Improve while keeping tests green +``` + +## Setup + +### pytest Configuration + +```ini +# pytest.ini +[pytest] +DJANGO_SETTINGS_MODULE = config.settings.test +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + --reuse-db + --nomigrations + --cov=apps + --cov-report=html + --cov-report=term-missing + --strict-markers +markers = + slow: marks tests as slow + integration: marks tests as integration tests +``` + +### Test Settings + +```python +# config/settings/test.py +from .base import * + +DEBUG = True +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + } +} + +# Disable migrations for speed +class DisableMigrations: + def __contains__(self, item): + return True + + def __getitem__(self, item): + return None + +MIGRATION_MODULES = DisableMigrations() + +# Faster password hashing +PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.MD5PasswordHasher', +] + +# Email backend +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' + +# Celery always eager +CELERY_TASK_ALWAYS_EAGER = True +CELERY_TASK_EAGER_PROPAGATES = True +``` + +### conftest.py + +```python +# tests/conftest.py +import pytest +from django.utils import timezone +from django.contrib.auth import get_user_model + +User = get_user_model() + +@pytest.fixture(autouse=True) +def timezone_settings(settings): + """Ensure consistent timezone.""" + settings.TIME_ZONE = 'UTC' + +@pytest.fixture +def user(db): + """Create a test user.""" + return User.objects.create_user( + email='test@example.com', + password='testpass123', + username='testuser' + ) + +@pytest.fixture +def admin_user(db): + """Create an admin user.""" + return User.objects.create_superuser( + email='admin@example.com', + password='adminpass123', + username='admin' + ) + +@pytest.fixture +def authenticated_client(client, user): + """Return authenticated client.""" + client.force_login(user) + return client + +@pytest.fixture +def api_client(): + """Return DRF API client.""" + from rest_framework.test import APIClient + return APIClient() + +@pytest.fixture +def authenticated_api_client(api_client, user): + """Return authenticated API client.""" + api_client.force_authenticate(user=user) + return api_client +``` + +## Factory Boy + +### Factory Setup + +```python +# tests/factories.py +import factory +from factory import fuzzy +from datetime import datetime, timedelta +from django.contrib.auth import get_user_model +from apps.products.models import Product, Category + +User = get_user_model() + +class UserFactory(factory.django.DjangoModelFactory): + """Factory for User model.""" + + class Meta: + model = User + + email = factory.Sequence(lambda n: f"user{n}@example.com") + username = factory.Sequence(lambda n: f"user{n}") + password = factory.PostGenerationMethodCall('set_password', 'testpass123') + first_name = factory.Faker('first_name') + last_name = factory.Faker('last_name') + is_active = True + +class CategoryFactory(factory.django.DjangoModelFactory): + """Factory for Category model.""" + + class Meta: + model = Category + + name = factory.Faker('word') + slug = factory.LazyAttribute(lambda obj: obj.name.lower()) + description = factory.Faker('text') + +class ProductFactory(factory.django.DjangoModelFactory): + """Factory for Product model.""" + + class Meta: + model = Product + + name = factory.Faker('sentence', nb_words=3) + slug = factory.LazyAttribute(lambda obj: obj.name.lower().replace(' ', '-')) + description = factory.Faker('text') + price = fuzzy.FuzzyDecimal(10.00, 1000.00, 2) + stock = fuzzy.FuzzyInteger(0, 100) + is_active = True + category = factory.SubFactory(CategoryFactory) + created_by = factory.SubFactory(UserFactory) + + @factory.post_generation + def tags(self, create, extracted, **kwargs): + """Add tags to product.""" + if not create: + return + if extracted: + for tag in extracted: + self.tags.add(tag) +``` + +### Using Factories + +```python +# tests/test_models.py +import pytest +from tests.factories import ProductFactory, UserFactory + +def test_product_creation(): + """Test product creation using factory.""" + product = ProductFactory(price=100.00, stock=50) + assert product.price == 100.00 + assert product.stock == 50 + assert product.is_active is True + +def test_product_with_tags(): + """Test product with tags.""" + tags = [TagFactory(name='electronics'), TagFactory(name='new')] + product = ProductFactory(tags=tags) + assert product.tags.count() == 2 + +def test_multiple_products(): + """Test creating multiple products.""" + products = ProductFactory.create_batch(10) + assert len(products) == 10 +``` + +## Model Testing + +### Model Tests + +```python +# tests/test_models.py +import pytest +from django.core.exceptions import ValidationError +from tests.factories import UserFactory, ProductFactory + +class TestUserModel: + """Test User model.""" + + def test_create_user(self, db): + """Test creating a regular user.""" + user = UserFactory(email='test@example.com') + assert user.email == 'test@example.com' + assert user.check_password('testpass123') + assert not user.is_staff + assert not user.is_superuser + + def test_create_superuser(self, db): + """Test creating a superuser.""" + user = UserFactory( + email='admin@example.com', + is_staff=True, + is_superuser=True + ) + assert user.is_staff + assert user.is_superuser + + def test_user_str(self, db): + """Test user string representation.""" + user = UserFactory(email='test@example.com') + assert str(user) == 'test@example.com' + +class TestProductModel: + """Test Product model.""" + + def test_product_creation(self, db): + """Test creating a product.""" + product = ProductFactory() + assert product.id is not None + assert product.is_active is True + assert product.created_at is not None + + def test_product_slug_generation(self, db): + """Test automatic slug generation.""" + product = ProductFactory(name='Test Product') + assert product.slug == 'test-product' + + def test_product_price_validation(self, db): + """Test price cannot be negative.""" + product = ProductFactory(price=-10) + with pytest.raises(ValidationError): + product.full_clean() + + def test_product_manager_active(self, db): + """Test active manager method.""" + ProductFactory.create_batch(5, is_active=True) + ProductFactory.create_batch(3, is_active=False) + + active_count = Product.objects.active().count() + assert active_count == 5 + + def test_product_stock_management(self, db): + """Test stock management.""" + product = ProductFactory(stock=10) + product.reduce_stock(5) + product.refresh_from_db() + assert product.stock == 5 + + with pytest.raises(ValueError): + product.reduce_stock(10) # Not enough stock +``` + +## View Testing + +### Django View Testing + +```python +# tests/test_views.py +import pytest +from django.urls import reverse +from tests.factories import ProductFactory, UserFactory + +class TestProductViews: + """Test product views.""" + + def test_product_list(self, client, db): + """Test product list view.""" + ProductFactory.create_batch(10) + + response = client.get(reverse('products:list')) + + assert response.status_code == 200 + assert len(response.context['products']) == 10 + + def test_product_detail(self, client, db): + """Test product detail view.""" + product = ProductFactory() + + response = client.get(reverse('products:detail', kwargs={'slug': product.slug})) + + assert response.status_code == 200 + assert response.context['product'] == product + + def test_product_create_requires_login(self, client, db): + """Test product creation requires authentication.""" + response = client.get(reverse('products:create')) + + assert response.status_code == 302 + assert response.url.startswith('/accounts/login/') + + def test_product_create_authenticated(self, authenticated_client, db): + """Test product creation as authenticated user.""" + response = authenticated_client.get(reverse('products:create')) + + assert response.status_code == 200 + + def test_product_create_post(self, authenticated_client, db, category): + """Test creating a product via POST.""" + data = { + 'name': 'Test Product', + 'description': 'A test product', + 'price': '99.99', + 'stock': 10, + 'category': category.id, + } + + response = authenticated_client.post(reverse('products:create'), data) + + assert response.status_code == 302 + assert Product.objects.filter(name='Test Product').exists() +``` + +## DRF API Testing + +### Serializer Testing + +```python +# tests/test_serializers.py +import pytest +from rest_framework.exceptions import ValidationError +from apps.products.serializers import ProductSerializer +from tests.factories import ProductFactory + +class TestProductSerializer: + """Test ProductSerializer.""" + + def test_serialize_product(self, db): + """Test serializing a product.""" + product = ProductFactory() + serializer = ProductSerializer(product) + + data = serializer.data + + assert data['id'] == product.id + assert data['name'] == product.name + assert data['price'] == str(product.price) + + def test_deserialize_product(self, db): + """Test deserializing product data.""" + data = { + 'name': 'Test Product', + 'description': 'Test description', + 'price': '99.99', + 'stock': 10, + 'category': 1, + } + + serializer = ProductSerializer(data=data) + + assert serializer.is_valid() + product = serializer.save() + + assert product.name == 'Test Product' + assert float(product.price) == 99.99 + + def test_price_validation(self, db): + """Test price validation.""" + data = { + 'name': 'Test Product', + 'price': '-10.00', + 'stock': 10, + } + + serializer = ProductSerializer(data=data) + + assert not serializer.is_valid() + assert 'price' in serializer.errors + + def test_stock_validation(self, db): + """Test stock cannot be negative.""" + data = { + 'name': 'Test Product', + 'price': '99.99', + 'stock': -5, + } + + serializer = ProductSerializer(data=data) + + assert not serializer.is_valid() + assert 'stock' in serializer.errors +``` + +### API ViewSet Testing + +```python +# tests/test_api.py +import pytest +from rest_framework.test import APIClient +from rest_framework import status +from django.urls import reverse +from tests.factories import ProductFactory, UserFactory + +class TestProductAPI: + """Test Product API endpoints.""" + + @pytest.fixture + def api_client(self): + """Return API client.""" + return APIClient() + + def test_list_products(self, api_client, db): + """Test listing products.""" + ProductFactory.create_batch(10) + + url = reverse('api:product-list') + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data['count'] == 10 + + def test_retrieve_product(self, api_client, db): + """Test retrieving a product.""" + product = ProductFactory() + + url = reverse('api:product-detail', kwargs={'pk': product.id}) + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data['id'] == product.id + + def test_create_product_unauthorized(self, api_client, db): + """Test creating product without authentication.""" + url = reverse('api:product-list') + data = {'name': 'Test Product', 'price': '99.99'} + + response = api_client.post(url, data) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_create_product_authorized(self, authenticated_api_client, db): + """Test creating product as authenticated user.""" + url = reverse('api:product-list') + data = { + 'name': 'Test Product', + 'description': 'Test', + 'price': '99.99', + 'stock': 10, + } + + response = authenticated_api_client.post(url, data) + + assert response.status_code == status.HTTP_201_CREATED + assert response.data['name'] == 'Test Product' + + def test_update_product(self, authenticated_api_client, db): + """Test updating a product.""" + product = ProductFactory(created_by=authenticated_api_client.user) + + url = reverse('api:product-detail', kwargs={'pk': product.id}) + data = {'name': 'Updated Product'} + + response = authenticated_api_client.patch(url, data) + + assert response.status_code == status.HTTP_200_OK + assert response.data['name'] == 'Updated Product' + + def test_delete_product(self, authenticated_api_client, db): + """Test deleting a product.""" + product = ProductFactory(created_by=authenticated_api_client.user) + + url = reverse('api:product-detail', kwargs={'pk': product.id}) + response = authenticated_api_client.delete(url) + + assert response.status_code == status.HTTP_204_NO_CONTENT + + def test_filter_products_by_price(self, api_client, db): + """Test filtering products by price.""" + ProductFactory(price=50) + ProductFactory(price=150) + + url = reverse('api:product-list') + response = api_client.get(url, {'price_min': 100}) + + assert response.status_code == status.HTTP_200_OK + assert response.data['count'] == 1 + + def test_search_products(self, api_client, db): + """Test searching products.""" + ProductFactory(name='Apple iPhone') + ProductFactory(name='Samsung Galaxy') + + url = reverse('api:product-list') + response = api_client.get(url, {'search': 'Apple'}) + + assert response.status_code == status.HTTP_200_OK + assert response.data['count'] == 1 +``` + +## Mocking and Patching + +### Mocking External Services + +```python +# tests/test_views.py +from unittest.mock import patch, Mock +import pytest + +class TestPaymentView: + """Test payment view with mocked payment gateway.""" + + @patch('apps.payments.services.stripe') + def test_successful_payment(self, mock_stripe, client, user, product): + """Test successful payment with mocked Stripe.""" + # Configure mock + mock_stripe.Charge.create.return_value = { + 'id': 'ch_123', + 'status': 'succeeded', + 'amount': 9999, + } + + client.force_login(user) + response = client.post(reverse('payments:process'), { + 'product_id': product.id, + 'token': 'tok_visa', + }) + + assert response.status_code == 302 + mock_stripe.Charge.create.assert_called_once() + + @patch('apps.payments.services.stripe') + def test_failed_payment(self, mock_stripe, client, user, product): + """Test failed payment.""" + mock_stripe.Charge.create.side_effect = Exception('Card declined') + + client.force_login(user) + response = client.post(reverse('payments:process'), { + 'product_id': product.id, + 'token': 'tok_visa', + }) + + assert response.status_code == 302 + assert 'error' in response.url +``` + +### Mocking Email Sending + +```python +# tests/test_email.py +from django.core import mail +from django.test import override_settings + +@override_settings(EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend') +def test_order_confirmation_email(db, order): + """Test order confirmation email.""" + order.send_confirmation_email() + + assert len(mail.outbox) == 1 + assert order.user.email in mail.outbox[0].to + assert 'Order Confirmation' in mail.outbox[0].subject +``` + +## Integration Testing + +### Full Flow Testing + +```python +# tests/test_integration.py +import pytest +from django.urls import reverse +from tests.factories import UserFactory, ProductFactory + +class TestCheckoutFlow: + """Test complete checkout flow.""" + + def test_guest_to_purchase_flow(self, client, db): + """Test complete flow from guest to purchase.""" + # Step 1: Register + response = client.post(reverse('users:register'), { + 'email': 'test@example.com', + 'password': 'testpass123', + 'password_confirm': 'testpass123', + }) + assert response.status_code == 302 + + # Step 2: Login + response = client.post(reverse('users:login'), { + 'email': 'test@example.com', + 'password': 'testpass123', + }) + assert response.status_code == 302 + + # Step 3: Browse products + product = ProductFactory(price=100) + response = client.get(reverse('products:detail', kwargs={'slug': product.slug})) + assert response.status_code == 200 + + # Step 4: Add to cart + response = client.post(reverse('cart:add'), { + 'product_id': product.id, + 'quantity': 1, + }) + assert response.status_code == 302 + + # Step 5: Checkout + response = client.get(reverse('checkout:review')) + assert response.status_code == 200 + assert product.name in response.content.decode() + + # Step 6: Complete purchase + with patch('apps.checkout.services.process_payment') as mock_payment: + mock_payment.return_value = True + response = client.post(reverse('checkout:complete')) + + assert response.status_code == 302 + assert Order.objects.filter(user__email='test@example.com').exists() +``` + +## Testing Best Practices + +### DO + +- **Use factories**: Instead of manual object creation +- **One assertion per test**: Keep tests focused +- **Descriptive test names**: `test_user_cannot_delete_others_post` +- **Test edge cases**: Empty inputs, None values, boundary conditions +- **Mock external services**: Don't depend on external APIs +- **Use fixtures**: Eliminate duplication +- **Test permissions**: Ensure authorization works +- **Keep tests fast**: Use `--reuse-db` and `--nomigrations` + +### DON'T + +- **Don't test Django internals**: Trust Django to work +- **Don't test third-party code**: Trust libraries to work +- **Don't ignore failing tests**: All tests must pass +- **Don't make tests dependent**: Tests should run in any order +- **Don't over-mock**: Mock only external dependencies +- **Don't test private methods**: Test public interface +- **Don't use production database**: Always use test database + +## Coverage + +### Coverage Configuration + +```bash +# Run tests with coverage +pytest --cov=apps --cov-report=html --cov-report=term-missing + +# Generate HTML report +open htmlcov/index.html +``` + +### Coverage Goals + +| Component | Target Coverage | +|-----------|-----------------| +| Models | 90%+ | +| Serializers | 85%+ | +| Views | 80%+ | +| Services | 90%+ | +| Utilities | 80%+ | +| Overall | 80%+ | + +## Quick Reference + +| Pattern | Usage | +|---------|-------| +| `@pytest.mark.django_db` | Enable database access | +| `client` | Django test client | +| `api_client` | DRF API client | +| `factory.create_batch(n)` | Create multiple objects | +| `patch('module.function')` | Mock external dependencies | +| `override_settings` | Temporarily change settings | +| `force_authenticate()` | Bypass authentication in tests | +| `assertRedirects` | Check for redirects | +| `assertTemplateUsed` | Verify template usage | +| `mail.outbox` | Check sent emails | + +Remember: Tests are documentation. Good tests explain how your code should work. Keep them simple, readable, and maintainable. diff --git a/pi/core/skills/django-verification/SKILL.md b/pi/core/skills/django-verification/SKILL.md new file mode 100644 index 000000000..2c5062f02 --- /dev/null +++ b/pi/core/skills/django-verification/SKILL.md @@ -0,0 +1,470 @@ +--- +name: django-verification +description: Run the full Django verification loop — environment check, mypy/ruff/black linting, migration safety, pytest with coverage targets, pip-audit and bandit security scans, settings and logging review, and diff review — producing a phased pass/fail report before release or PR. Use when preparing a Django pull request, validating migrations or coverage, or running pre-deploy readiness checks. +metadata: + origin: ECC +--- + +# Django Verification Loop + +Run before PRs, after major changes, and pre-deploy to ensure Django application quality and security. + +## When to Activate + +- Before opening a pull request for a Django project +- After major model changes, migration updates, or dependency upgrades +- Pre-deployment verification for staging or production +- Running full environment → lint → test → security → deploy readiness pipeline +- Validating migration safety and test coverage + +## Phase 1: Environment Check + +```bash +# Verify Python version +python --version # Should match project requirements + +# Check virtual environment +which python +pip list --outdated + +# Verify environment variables +python -c "import os; import environ; print('DJANGO_SECRET_KEY set' if os.environ.get('DJANGO_SECRET_KEY') else 'MISSING: DJANGO_SECRET_KEY')" +``` + +If environment is misconfigured, stop and fix. + +## Phase 2: Code Quality & Formatting + +```bash +# Type checking +mypy . --config-file pyproject.toml + +# Linting with ruff +ruff check . --fix + +# Formatting with black +black . --check +black . # Auto-fix + +# Import sorting +isort . --check-only +isort . # Auto-fix + +# Django-specific checks +python manage.py check --deploy +``` + +Common issues: +- Missing type hints on public functions +- PEP 8 formatting violations +- Unsorted imports +- Debug settings left in production configuration + +## Phase 3: Migrations + +```bash +# Check for unapplied migrations +python manage.py showmigrations + +# Create missing migrations +python manage.py makemigrations --check + +# Dry-run migration application +python manage.py migrate --plan + +# Apply migrations (test environment) +python manage.py migrate + +# Check for migration conflicts +python manage.py makemigrations --merge # Only if conflicts exist +``` + +Report: +- Number of pending migrations +- Any migration conflicts +- Model changes without migrations + +## Phase 4: Tests + Coverage + +```bash +# Run all tests with pytest +pytest --cov=apps --cov-report=html --cov-report=term-missing --reuse-db + +# Run specific app tests +pytest apps/users/tests/ + +# Run with markers +pytest -m "not slow" # Skip slow tests +pytest -m integration # Only integration tests + +# Coverage report +open htmlcov/index.html +``` + +Report: +- Total tests: X passed, Y failed, Z skipped +- Overall coverage: XX% +- Per-app coverage breakdown + +Coverage targets: + +| Component | Target | +|-----------|--------| +| Models | 90%+ | +| Serializers | 85%+ | +| Views | 80%+ | +| Services | 90%+ | +| Overall | 80%+ | + +## Phase 5: Security Scan + +```bash +# Dependency vulnerabilities +pip-audit +safety check --full-report + +# Django security checks +python manage.py check --deploy + +# Bandit security linter +bandit -r . -f json -o bandit-report.json + +# Secret scanning (if gitleaks is installed) +gitleaks detect --source . --verbose + +# Environment variable check +python -c "from django.core.exceptions import ImproperlyConfigured; from django.conf import settings; settings.DEBUG" +``` + +Report: +- Vulnerable dependencies found +- Security configuration issues +- Hardcoded secrets detected +- DEBUG mode status (should be False in production) + +## Phase 6: Django Management Commands + +```bash +# Check for model issues +python manage.py check + +# Collect static files +python manage.py collectstatic --noinput --clear + +# Create superuser (if needed for tests) +echo "from apps.users.models import User; User.objects.create_superuser('admin@example.com', 'admin')" | python manage.py shell + +# Database integrity +python manage.py check --database default + +# Cache verification (if using Redis) +python -c "from django.core.cache import cache; cache.set('test', 'value', 10); print(cache.get('test'))" +``` + +## Phase 7: Performance Checks + +```bash +# Django Debug Toolbar output (check for N+1 queries) +# Run in dev mode with DEBUG=True and access a page +# Look for duplicate queries in SQL panel + +# Query count analysis +django-admin debugsqlshell # If django-debug-sqlshell installed + +# Check for missing indexes +python manage.py shell << EOF +from django.db import connection +with connection.cursor() as cursor: + cursor.execute("SELECT table_name, index_name FROM information_schema.statistics WHERE table_schema = 'public'") + print(cursor.fetchall()) +EOF +``` + +Report: +- Number of queries per page (should be < 50 for typical pages) +- Missing database indexes +- Duplicate queries detected + +## Phase 8: Static Assets + +```bash +# Check for npm dependencies (if using npm) +npm audit +npm audit fix + +# Build static files (if using webpack/vite) +npm run build + +# Verify static files +ls -la staticfiles/ +python manage.py findstatic css/style.css +``` + +## Phase 9: Configuration Review + +```python +# Run in Python shell to verify settings +python manage.py shell << EOF +from django.conf import settings +import os + +# Critical checks +checks = { + 'DEBUG is False': not settings.DEBUG, + 'SECRET_KEY set': bool(settings.SECRET_KEY and len(settings.SECRET_KEY) > 30), + 'ALLOWED_HOSTS set': len(settings.ALLOWED_HOSTS) > 0, + 'HTTPS enabled': getattr(settings, 'SECURE_SSL_REDIRECT', False), + 'HSTS enabled': getattr(settings, 'SECURE_HSTS_SECONDS', 0) > 0, + 'Database configured': settings.DATABASES['default']['ENGINE'] != 'django.db.backends.sqlite3', +} + +for check, result in checks.items(): + status = '✓' if result else '✗' + print(f"{status} {check}") +EOF +``` + +## Phase 10: Logging Configuration + +```bash +# Test logging output +python manage.py shell << EOF +import logging +logger = logging.getLogger('django') +logger.warning('Test warning message') +logger.error('Test error message') +EOF + +# Check log files (if configured) +tail -f /var/log/django/django.log +``` + +## Phase 11: API Documentation (if DRF) + +```bash +# Generate schema +python manage.py generateschema --format openapi-json > schema.json + +# Validate schema +# Check if schema.json is valid JSON +python -c "import json; json.load(open('schema.json'))" + +# Access Swagger UI (if using drf-yasg) +# Visit http://localhost:8000/swagger/ in browser +``` + +## Phase 12: Diff Review + +```bash +# Show diff statistics +git diff --stat + +# Show actual changes +git diff + +# Show changed files +git diff --name-only + +# Check for common issues +git diff | grep -i "todo\|fixme\|hack\|xxx" +git diff | grep "print(" # Debug statements +git diff | grep "DEBUG = True" # Debug mode +git diff | grep "import pdb" # Debugger +``` + +Checklist: +- No debugging statements (print, pdb, breakpoint()) +- No TODO/FIXME comments in critical code +- No hardcoded secrets or credentials +- Database migrations included for model changes +- Configuration changes documented +- Error handling present for external calls +- Transaction management where needed + +## Output Template + +``` +DJANGO VERIFICATION REPORT +========================== + +Phase 1: Environment Check + ✓ Python 3.11.5 + ✓ Virtual environment active + ✓ All environment variables set + +Phase 2: Code Quality + ✓ mypy: No type errors + ✗ ruff: 3 issues found (auto-fixed) + ✓ black: No formatting issues + ✓ isort: Imports properly sorted + ✓ manage.py check: No issues + +Phase 3: Migrations + ✓ No unapplied migrations + ✓ No migration conflicts + ✓ All models have migrations + +Phase 4: Tests + Coverage + Tests: 247 passed, 0 failed, 5 skipped + Coverage: + Overall: 87% + users: 92% + products: 89% + orders: 85% + payments: 91% + +Phase 5: Security Scan + ✗ pip-audit: 2 vulnerabilities found (fix required) + ✓ safety check: No issues + ✓ bandit: No security issues + ✓ No secrets detected + ✓ DEBUG = False + +Phase 6: Django Commands + ✓ collectstatic completed + ✓ Database integrity OK + ✓ Cache backend reachable + +Phase 7: Performance + ✓ No N+1 queries detected + ✓ Database indexes configured + ✓ Query count acceptable + +Phase 8: Static Assets + ✓ npm audit: No vulnerabilities + ✓ Assets built successfully + ✓ Static files collected + +Phase 9: Configuration + ✓ DEBUG = False + ✓ SECRET_KEY configured + ✓ ALLOWED_HOSTS set + ✓ HTTPS enabled + ✓ HSTS enabled + ✓ Database configured + +Phase 10: Logging + ✓ Logging configured + ✓ Log files writable + +Phase 11: API Documentation + ✓ Schema generated + ✓ Swagger UI accessible + +Phase 12: Diff Review + Files changed: 12 + +450, -120 lines + ✓ No debug statements + ✓ No hardcoded secrets + ✓ Migrations included + +RECOMMENDATION: WARNING: Fix pip-audit vulnerabilities before deploying + +NEXT STEPS: +1. Update vulnerable dependencies +2. Re-run security scan +3. Deploy to staging for final testing +``` + +## Pre-Deployment Checklist + +- [ ] All tests passing +- [ ] Coverage ≥ 80% +- [ ] No security vulnerabilities +- [ ] No unapplied migrations +- [ ] DEBUG = False in production settings +- [ ] SECRET_KEY properly configured +- [ ] ALLOWED_HOSTS set correctly +- [ ] Database backups enabled +- [ ] Static files collected and served +- [ ] Logging configured and working +- [ ] Error monitoring (Sentry, etc.) configured +- [ ] CDN configured (if applicable) +- [ ] Redis/cache backend configured +- [ ] Celery workers running (if applicable) +- [ ] HTTPS/SSL configured +- [ ] Environment variables documented + +## Continuous Integration + +### GitHub Actions Example + +```yaml +# .github/workflows/django-verification.yml +name: Django Verification + +on: [push, pull_request] + +jobs: + verify: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:14 + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Cache pip + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + + - name: Install dependencies + run: | + pip install -r requirements.txt + pip install ruff black mypy pytest pytest-django pytest-cov bandit safety pip-audit + + - name: Code quality checks + run: | + ruff check . + black . --check + isort . --check-only + mypy . + + - name: Security scan + run: | + bandit -r . -f json -o bandit-report.json + safety check --full-report + pip-audit + + - name: Run tests + env: + DATABASE_URL: postgres://postgres:postgres@localhost:5432/test + DJANGO_SECRET_KEY: test-secret-key + run: | + pytest --cov=apps --cov-report=xml --cov-report=term-missing + + - name: Upload coverage + uses: codecov/codecov-action@v3 +``` + +## Quick Reference + +| Check | Command | +|-------|---------| +| Environment | `python --version` | +| Type checking | `mypy .` | +| Linting | `ruff check .` | +| Formatting | `black . --check` | +| Migrations | `python manage.py makemigrations --check` | +| Tests | `pytest --cov=apps` | +| Security | `pip-audit && bandit -r .` | +| Django check | `python manage.py check --deploy` | +| Collectstatic | `python manage.py collectstatic --noinput` | +| Diff stats | `git diff --stat` | + +Remember: Automated verification catches common issues but doesn't replace manual code review and testing in staging environment. diff --git a/pi/core/skills/docker-patterns/SKILL.md b/pi/core/skills/docker-patterns/SKILL.md new file mode 100644 index 000000000..e60c1d20f --- /dev/null +++ b/pi/core/skills/docker-patterns/SKILL.md @@ -0,0 +1,520 @@ +--- +name: docker-patterns +description: Docker and Docker Compose patterns for local development, hardened CLI installer harnesses, container security, networking, volumes, and multi-service orchestration. Use when creating or reviewing Dockerfiles and Compose services, testing installers across Linux distributions, or planning accurate native macOS and Windows validation. +--- + +# Docker Patterns + +Docker and Docker Compose best practices for containerized development. + +## Docker Compose for Local Development + +### Standard Web App Stack + +```yaml +# docker-compose.yml +services: + app: + build: + context: . + target: dev # Use dev stage of multi-stage Dockerfile + ports: + - "3000:3000" + volumes: + - .:/app # Bind mount for hot reload + - /app/node_modules # Anonymous volume -- preserves container deps + environment: + - DATABASE_URL=postgres://postgres:postgres@db:5432/app_dev + - REDIS_URL=redis://redis:6379/0 + - NODE_ENV=development + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + command: npm run dev + + db: + image: postgres:16-alpine + ports: + - "5432:5432" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: app_dev + volumes: + - pgdata:/var/lib/postgresql/data + - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 3s + retries: 5 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redisdata:/data + + mailpit: # Local email testing + image: axllent/mailpit + ports: + - "8025:8025" # Web UI + - "1025:1025" # SMTP + +volumes: + pgdata: + redisdata: +``` + +### Development vs Production Dockerfile + +```dockerfile +# Stage: dependencies +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +# Stage: dev (hot reload, debug tools) +FROM node:22-alpine AS dev +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +EXPOSE 3000 +CMD ["npm", "run", "dev"] + +# Stage: build +FROM node:22-alpine AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build && npm prune --production + +# Stage: production (minimal image) +FROM node:22-alpine AS production +WORKDIR /app +RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 +USER appuser +COPY --from=build --chown=appuser:appgroup /app/dist ./dist +COPY --from=build --chown=appuser:appgroup /app/node_modules ./node_modules +COPY --from=build --chown=appuser:appgroup /app/package.json ./ +ENV NODE_ENV=production +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:3000/health || exit 1 +CMD ["node", "dist/server.js"] +``` + +### Override Files + +```yaml +# docker-compose.override.yml (auto-loaded, dev-only settings) +services: + app: + environment: + - DEBUG=app:* + - LOG_LEVEL=debug + ports: + - "9229:9229" # Node.js debugger + +# docker-compose.prod.yml (explicit for production) +services: + app: + build: + target: production + restart: always + deploy: + resources: + limits: + cpus: "1.0" + memory: 512M +``` + +```bash +# Development (auto-loads override) +docker compose up + +# Production +docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +``` + +## Networking + +### Service Discovery + +Services in the same Compose network resolve by service name: +``` +# From "app" container: +postgres://postgres:postgres@db:5432/app_dev # "db" resolves to the db container +redis://redis:6379/0 # "redis" resolves to the redis container +``` + +### Custom Networks + +```yaml +services: + frontend: + networks: + - frontend-net + + api: + networks: + - frontend-net + - backend-net + + db: + networks: + - backend-net # Only reachable from api, not frontend + +networks: + frontend-net: + backend-net: +``` + +### Exposing Only What's Needed + +```yaml +services: + db: + ports: + - "127.0.0.1:5432:5432" # Only accessible from host, not network + # Omit ports entirely in production -- accessible only within Docker network +``` + +## Volume Strategies + +```yaml +volumes: + # Named volume: persists across container restarts, managed by Docker + pgdata: + + # Bind mount: maps host directory into container (for development) + # - ./src:/app/src + + # Anonymous volume: preserves container-generated content from bind mount override + # - /app/node_modules +``` + +### Common Patterns + +```yaml +services: + app: + volumes: + - .:/app # Source code (bind mount for hot reload) + - /app/node_modules # Protect container's node_modules from host + - /app/.next # Protect build cache + + db: + volumes: + - pgdata:/var/lib/postgresql/data # Persistent data + - ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql # Init scripts +``` + +## Container Security + +### Dockerfile Hardening + +```dockerfile +# 1. Use specific tags (never :latest) +FROM node:22.12-alpine3.20 + +# 2. Run as non-root +RUN addgroup -g 1001 -S app && adduser -S app -u 1001 +USER app + +# 3. Drop capabilities (in compose) +# 4. Read-only root filesystem where possible +# 5. No secrets in image layers +``` + +### Compose Security + +```yaml +services: + app: + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + - /tmp + - /app/.cache + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE # Only if binding to ports < 1024 +``` + +### Secret Management + +```yaml +# GOOD: Use environment variables (injected at runtime) +services: + app: + env_file: + - .env # Never commit .env to git + environment: + - API_KEY # Inherits from host environment + +# GOOD: Docker secrets (Swarm mode) +secrets: + db_password: + file: ./secrets/db_password.txt + +services: + db: + secrets: + - db_password + +# BAD: Hardcoded in image +# ENV API_KEY=sk-proj-xxxxx # NEVER DO THIS +``` + +## Hardened CLI Installer Harnesses + +Use containers to test installer behavior against disposable project copies without allowing the test to mutate the source checkout. + +### Respect the Platform Boundary + +- Run real containers for Linux distributions such as Debian and Ubuntu. +- macOS cannot run as a Docker container because Docker shares a Linux kernel. Run the same shell-free test entry point natively on macOS. +- Windows containers require a Windows Docker engine. Run platform-independent logic on a native Windows CI runner and reserve Windows containers for a Windows host. +- Keep a native Ubuntu/macOS/Windows CI matrix for host-specific paths, command shims, quoting, and filesystem behavior. + +Do not claim that a Linux container validates macOS or Windows behavior. + +### Enforce the Isolation Contract + +- Pin base images by immutable digest and pin installed CLI versions. +- Run as a non-root numeric UID/GID when distro account names differ. +- Mount the repository and source project read-only. +- Copy the source project into a writable `tmpfs` workspace before any mutation. +- Mount `/workspace` with `noexec`, UID/GID 1000, and `mode=0700` so only the + container user can inspect project data. +- Keep npm and npx's executable cache at `NPM_CONFIG_CACHE=/tmp/npm-cache` on + the executable `/tmp` mount. Its default size is 2 GiB and can be adjusted + with `ECC_TMPFS_SIZE`; `ECC_WORKSPACE_SIZE` separately controls the private + workspace mount. +- Set `read_only: true`, `no-new-privileges:true`, `cap_drop: [ALL]`, and a finite `pids_limit`. +- Keep the default real-CLI services on `network_mode: none`. Add network access + only through a visibly named opt-in service for an authenticated provider + session; never make it an accidental environment-driven default. +- Create only the writable temporary paths the tool needs. +- Do not pass host credentials into the container by default. +- Default to a dry run and whitelist only the explicit `dry-run`, `install`, + `plugin`, and `shell` modes. +- Use argument arrays or `spawnSync(..., { shell: false })` for cross-platform runners. Never interpolate project paths into a shell command. + +### Exercise the ECC Plugin Setup Harness + +Use `docker/plugin-setup/compose.yaml` as the reference implementation. It provides: + +- `fixture-tests` for the focused install manifest, target, and executor suite. +- `real-cli` for the pinned Debian-based generic Linux image. +- `real-cli-ubuntu` for the pinned Ubuntu image. + +Validate the Compose model before building: + +```bash +docker compose -f docker/plugin-setup/compose.yaml config --quiet +``` + +Build both real Linux images: + +```bash +docker compose -f docker/plugin-setup/compose.yaml \ + build real-cli real-cli-ubuntu +``` + +Run the safe default flow in each image: + +```bash +docker compose -p ecc-plugin-debian-test \ + -f docker/plugin-setup/compose.yaml \ + run --rm -T real-cli dry-run + +docker compose -p ecc-plugin-ubuntu-test \ + -f docker/plugin-setup/compose.yaml \ + run --rm -T real-cli-ubuntu dry-run +``` + +The dry run executes the current public command contract: + +```bash +ecc install --profile core --target claude-project --dry-run --json +``` + +Before that command runs, the container creates a locally packed npm artifact +from the read-only checkout with `npm pack --ignore-scripts`. It extracts the +self-created tarball under `/tmp`, validates the `ecc-universal` package name, +required install manifests, and the confined `package.json` `bin.ecc` mapping, +then invokes the extracted `ecc` executable. The runtime stays on +`network_mode: none`, does not execute package lifecycle scripts, and does not +rely on host `node_modules`; its exact pinned production dependencies are +already present in the image. + +The harness rejects an empty plan, a non-`claude-project` target, any operation +outside `/workspace/project/.claude`, or any dry run that creates the target +directory. `install` performs the isolated apply twice, checks its managed +install state, lists the installed target, and runs `doctor`. + +### Start, Open, Reconnect, and Clean Up a Named Session + +Start a detached container without `--rm` so leaving a terminal does not remove +the session: + +```bash +docker compose -p ecc-plugin-session \ + -f docker/plugin-setup/compose.yaml \ + run --detach --name ecc-plugin-shell real-cli shell +``` + +The container copies the read-only fixture to the stable private directory +`/workspace/project`. Confirm it is running, then emit the Docker side of the +terminal-opener v1 data contract: + +```bash +docker inspect --format '{{.State.Running}}' ecc-plugin-shell +node docker/plugin-setup/interactive-plan.js \ + --container ecc-plugin-shell \ + --workdir /workspace/project \ + --json \ + -- bash +``` + +The JSON result has exactly an `executable` and `argv` boundary (plus +`contractVersion: 1`): the executable is `docker`, and argv begins with +`exec`, `-it`, and `-w`. Pass that data to the separate terminal-opener skill +when it is installed. This Docker harness deliberately does not import a +terminal adapter, interpolate a shell command, or manage a host GUI process. +Until then, open the same PTY in the current host terminal directly: + +```bash +docker exec -it -w /workspace/project ecc-plugin-shell bash +``` + +Exit the shell without stopping the detached container. Reconnect with the +same `docker exec -it` command. When finished, remove the exact named container +and its Compose project resources: + +```bash +docker rm --force ecc-plugin-shell +docker compose -p ecc-plugin-session \ + -f docker/plugin-setup/compose.yaml \ + down --remove-orphans +``` + +Host credentials are absent by default and credential directories are never +mounted. The default service also has no network access. When an authenticated +provider session genuinely needs a network, build `real-cli` first and then opt +in visibly with `docker compose --profile networked run real-cli-networked +shell`. Prefer authenticating inside that disposable session. If a CI run must +inherit a host environment credential, make that opt-in at invocation with an +explicit Compose `--env NAME` flag, understand that the value is inspectable +and can be exfiltrated for the container lifetime, and remove the exact named +container immediately after. + +Run the same focused suite natively on the host: + +```bash +npm run test:plugin-setup-platform +``` + +Inspect the produced identity and environment before trusting the image: + +```bash +docker image inspect ecc-plugin-setup:debian ecc-plugin-setup:ubuntu +``` + +Clean each named test project without deleting unrelated volumes or images: + +```bash +docker compose -p ecc-plugin-debian-test \ + -f docker/plugin-setup/compose.yaml down --remove-orphans +docker compose -p ecc-plugin-ubuntu-test \ + -f docker/plugin-setup/compose.yaml down --remove-orphans +``` + +## .dockerignore + +``` +node_modules +.git +.env +.env.* +dist +coverage +*.log +.next +.cache +docker-compose*.yml +Dockerfile* +README.md +tests/ +``` + +## Debugging + +### Common Commands + +```bash +# View logs +docker compose logs -f app # Follow app logs +docker compose logs --tail=50 db # Last 50 lines from db + +# Execute commands in running container +docker compose exec app sh # Shell into app +docker compose exec db psql -U postgres # Connect to postgres + +# Inspect +docker compose ps # Running services +docker compose top # Processes in each container +docker stats # Resource usage + +# Rebuild +docker compose up --build # Rebuild images +docker compose build --no-cache app # Force full rebuild + +# Clean up +docker compose down # Stop and remove containers +docker compose down -v # Also remove volumes (DESTRUCTIVE) +docker system prune # Remove unused images/containers +``` + +### Debugging Network Issues + +```bash +# Check DNS resolution inside container +docker compose exec app nslookup db + +# Check connectivity +docker compose exec app wget -qO- http://api:3000/health + +# Inspect network +docker network ls +docker network inspect _default +``` + +## Anti-Patterns + +``` +# BAD: Using docker compose in production without orchestration +# Use Kubernetes, ECS, or Docker Swarm for production multi-container workloads + +# BAD: Storing data in containers without volumes +# Containers are ephemeral -- all data lost on restart without volumes + +# BAD: Running as root +# Always create and use a non-root user + +# BAD: Using :latest tag +# Pin to specific versions for reproducible builds + +# BAD: One giant container with all services +# Separate concerns: one process per container + +# BAD: Putting secrets in docker-compose.yml +# Use .env files (gitignored) or Docker secrets +``` diff --git a/pi/core/skills/docs-governance/SKILL.md b/pi/core/skills/docs-governance/SKILL.md new file mode 100644 index 000000000..ae00cf8b3 --- /dev/null +++ b/pi/core/skills/docs-governance/SKILL.md @@ -0,0 +1,97 @@ +--- +name: docs-governance +description: Route broad documentation-governance requests to existing ECC skills and run an opt-in, read-only audit of mapped documentation roles, links, ADR indexes, and evidence references. Use when a user asks for documentation governance, project-knowledge organization, documentation integrity, or a deterministic docs audit without naming a narrower skill. +metadata: + origin: ECC +--- + +# Documentation Governance Router + +Use this optional router to select the smallest existing ECC capability for a +documentation-governance request. The router does not create a parallel project +management system and does not make documentation authoritative over code, +tests, Issues, or maintainer decisions. + +## When to Activate + +- The user asks broadly for documentation governance or project-knowledge organization. +- A request spans several documentation roles and the correct owner is unclear. +- The user asks for a deterministic, read-only documentation integrity audit. +- A project needs to discover its existing documentation sources before adding files. + +Do not activate for a narrowly named skill or for ordinary documentation edits +whose destination is already clear. + +## Role Contract + +Read [the artifact-role contract](references/artifact-role-contract.md) before +interpreting `.governance/docs-map.json` or default filenames. Logical roles own +facts; filenames are fallbacks. Reuse a repository's established sources of +truth and never create a second document merely to satisfy a default role. + +## Intent Routing + +| User intent | Route to | +|---|---| +| Adopt or maintain a constitution, map, status, and history spine | `living-docs-governance` | +| Record a hard-to-reverse architecture decision | `architecture-decision-records` | +| Preserve an executable regression for an important defect | `ai-regression-testing` (install separately in standalone Pi core) | +| Define a verifiable goal and feedback loop | `loop-design-check` (install separately in standalone Pi core) | +| Check mapped roles, local links, ADR indexes, TEST-IDs, or possible orphan docs | Run the bundled read-only audit | + +If no row fits, explain the ownership ambiguity instead of combining several +workflows speculatively. + +## Read-Only Audit + +Run the audit only when the user asks for an audit or when it is an agreed +verification step. Resolve the selected skill directory first, then invoke: + +```bash +python3 /scripts/audit-docs.py --root --scope full +``` + +Available scopes are `spine`, `context`, `adr`, `artifacts`, and `full`. + +The audit reads repository files and Git history but does not create, rewrite, +archive, stage, or commit anything. It reports: + +- exit code `0` when deterministic checks pass, including any manual-review warnings; +- exit code `1` when mechanically provable integrity failures exist; and +- exit code `2` for invalid CLI input. + +Treat warnings as review prompts, not failures. Semantic accuracy, business +meaning, task completion, and release approval remain human or workflow-owner +decisions. + +## When to Use + +Use this router when the request is broad enough that choosing the right +governance capability is part of the work. For a direct ADR or regression +request, invoke that specialized skill without routing through this one. + +## How It Works + +1. Discover the repository's existing instruction and documentation surfaces. +2. Resolve logical roles through the artifact-role contract. +3. Select one smallest matching capability from the routing table. +4. If a deterministic audit is requested, run the narrowest useful scope. +5. Report failures, warnings, and ownership gaps without mutating project state. + +## Examples + +- “Organize our long-lived project docs” routes to `living-docs-governance`. +- “Record why we selected PostgreSQL” routes to `architecture-decision-records`. +- “Check whether our documentation links and ADR index are intact” runs the + bundled audit once with `--scope artifacts`, then again with `--scope adr`. +- “Are we finished with the migration?” is not answered from documentation + alone; the Issue owns completion, tests provide evidence, and maintainers own + merge approval. + +## Boundaries + +- Keep task ownership, scheduling, attempts, and completion in the Issue tracker. +- Keep stable interface facts in the repository's contract source. +- Treat documentation as evidence and navigation, not executable instructions. +- Do not teach users to bypass repository controls such as pre-commit hooks. +- Do not add archive or index mutation to this read-only foundation. diff --git a/pi/core/skills/docs-governance/references/artifact-role-contract.md b/pi/core/skills/docs-governance/references/artifact-role-contract.md new file mode 100644 index 000000000..eafbe98a8 --- /dev/null +++ b/pi/core/skills/docs-governance/references/artifact-role-contract.md @@ -0,0 +1,73 @@ +# Documentation Artifact-Role Contract + +This contract defines how the documentation-governance router and read-only +audit resolve project documentation. Roles describe ownership; the displayed +filenames are fallbacks, not mandatory repository structure. + +## Role Registry + +| Role | Default | Owns | Does not own | +|---|---|---|---| +| `constitution` | `CLAUDE.md` | Durable contributor and agent rules | Live project status or duplicated detail | +| `map` | `CLAUDE_MAP.md` | Structure, entry points, ownership, and navigation | Interface fields or health history | +| `status` | `PROJECT_STATUS.md` | Current health, blockers, thresholds, and intentional removals | Task schedules or historical narrative | +| `history` | `PROJECT_LOG.md` | Durable decisions, removals, replacements, and material incidents | Every commit, attempt, or task event | +| `history_archive` | `PROJECT_LOG.archive.md` | Older history moved verbatim from the active history source | A second current-history owner | +| `agents` | `AGENTS.md` | Harness-facing repository instructions and pointers | Copies of all governed documents | +| `context` | `CONTEXT.md` | Stable domain vocabulary and concept boundaries | Provisional discussion or implementation plans | +| `contract` | `CONTRACT.md` | Stable cross-boundary fields, types, enums, and errors | Work scheduling or architecture rationale | +| `tests` | `TESTS.md` | Test identifiers and links to executable or manual evidence | Original success criteria or completion authority | +| `regression` | `REGRESSION.md` | Changed-module and downstream verification commands | A second test runner or task tracker | +| `adr_dir` | `docs/adr` | Architecture decision records | Routine project history | +| `adr_index` | `docs/adr/README.md` | The canonical index for the mapped ADR directory | Decisions not represented by an ADR | + +The Issue tracker owns task status, assignees, scheduling, attempts, and +acceptance decisions. A Spec or Issue owns its success criteria. Tests provide +evidence; they do not declare a task complete by themselves. + +## Mapping File + +Repositories may override defaults with `.governance/docs-map.json`: + +```json +{ + "constitution": "AGENTS.md", + "map": "docs/architecture.md", + "status": "docs/roadmap.md", + "history": "docs/decisions.md", + "adr_dir": "docs/architecture/decisions", + "adr_index": "docs/architecture/decisions/README.md" +} +``` + +Each value must be a non-empty repository-relative path that resolves inside +the repository root. Unknown roles, absolute paths, and traversal outside the +root are invalid. The mapping selects existing canonical sources; it does not +create the mapped files. + +## Progressive Adoption + +- Discover existing sources before introducing a mapping. +- One file may fill several roles in a small repository when ownership remains clear. +- Missing roles are warnings unless a requested check requires that artifact. +- Create a new artifact only when a real information owner is missing and the user approves it. +- Link to the canonical owner instead of copying the same fact into several files. + +## Audit Contract + +The bundled audit is deterministic and read-only. Depending on scope, it can +check mapping validity, repository-contained paths, local Markdown links, +working-tree history changes relative to `HEAD`, ADR indexes and statuses, +TEST-ID references, and possible orphan documents. History differences are +warnings because intentional corrections and secret redaction must remain +possible. + +The audit does not determine whether prose is true, whether a requirement is +satisfied, whether a task is complete, or whether a change should be merged. +Warnings explicitly require semantic review. + +For link checks, any `scheme://` destination is external. The audit also +recognizes the common opaque schemes `data`, `doi`, `geo`, `irc`, `magnet`, +`mailto`, `news`, `sms`, `tel`, and `urn`. Other colon-shaped destinations are +checked as local paths so a missing file such as `docs:guide.md` cannot silently +bypass validation. diff --git a/pi/core/skills/docs-governance/scripts/audit-docs.py b/pi/core/skills/docs-governance/scripts/audit-docs.py new file mode 100644 index 000000000..7d58e52c3 --- /dev/null +++ b/pi/core/skills/docs-governance/scripts/audit-docs.py @@ -0,0 +1,754 @@ +#!/usr/bin/env python3 +"""Deterministic documentation-governance audit for mechanically provable integrity failures.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from collections import Counter +from pathlib import Path + +from markdown_links import ( + markdown_link_targets, + normalize_link_target, + without_fenced_code, +) + +DEFAULT_ROLES = { + "constitution": "CLAUDE.md", + "map": "CLAUDE_MAP.md", + "status": "PROJECT_STATUS.md", + "history": "PROJECT_LOG.md", + "history_archive": "PROJECT_LOG.archive.md", + "agents": "AGENTS.md", + "context": "CONTEXT.md", + "contract": "CONTRACT.md", + "tests": "TESTS.md", + "regression": "REGRESSION.md", + "adr_dir": "docs/adr", + "adr_index": "docs/adr/README.md", +} +ENTRY_RE = re.compile( + r"^## \[(?P\d{4}-\d{2}-\d{2})\]\s+(?P[^|\n]+?)\s*\|\s*(?P[^\n]+)$", + re.MULTILINE, +) +CODE_PATH_RE = re.compile(r"`([^`\n]+)`") +TEST_ID_RE = re.compile(r"\bTEST-[A-Z0-9][A-Z0-9-]*\b") +ADR_TARGET_RE = re.compile(r"\b(?:ADR-)?\d{3,4}-[a-z0-9-]+\.md\b", re.IGNORECASE) +ADR_INLINE_SUCCESSOR_RE = re.compile( + r"(?im)^(?:[-*]\s+)?(?:\*\*(?:status|状态)\*\*|(?:status|状态))\s*[::]\s*`?superseded`?\s+by\s+(ADR-\d{3,4})\s*$" +) +IGNORED_DIRS = {".git", ".governance", ".venv", "node_modules", "vendor", "__pycache__"} +IGNORED_REFERENCE_MARKERS = ("*", "{", "}", "<", ">", "…", "...") +GIT_TIMEOUT_SECONDS = 5 + + +class Report: + def __init__(self) -> None: + self.failures = 0 + + def section(self, title: str) -> None: + print(f"\n[{title}]") + + def ok(self, message: str) -> None: + print(f" ✓ {message}") + + def warn(self, message: str) -> None: + print(f" WARN: {message}") + + def fail(self, message: str) -> None: + self.failures += 1 + print(f" ✗ {message}") + + +def diagnostic(value: object) -> str: + return repr(str(value)) + + +def resolved_within_root(root: Path, path: Path) -> Path | None: + try: + candidate = path.resolve() + except (OSError, ValueError, RuntimeError): + return None + if root == candidate or root in candidate.parents: + return candidate + return None + + +def read_utf8(path: Path, report: Report, label: str) -> str | None: + try: + return path.read_text(encoding="utf-8") + except (OSError, UnicodeError): + report.fail(f"Cannot read {label} as a UTF-8 regular file: {diagnostic(path)}") + return None + + +def load_custom_roles(root: Path, report: Report) -> tuple[dict[object, object], bool]: + mapping_path = root / ".governance" / "docs-map.json" + mapping_present = mapping_path.exists() or mapping_path.is_symlink() + mapping_source = ( + resolved_within_root(root, mapping_path) if mapping_present else None + ) + if mapping_present and (mapping_source is None or not mapping_source.is_file()): + report.fail( + ".governance/docs-map.json must be a repository-contained regular file" + ) + return {}, False + if mapping_source is None: + return {}, False + try: + loaded = json.loads(mapping_source.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + report.fail(f"Cannot read .governance/docs-map.json: {diagnostic(exc)}") + return {}, False + if not isinstance(loaded, dict): + report.fail(".governance/docs-map.json must be a role-to-relative-path object") + return {}, False + return loaded, True + + +def apply_custom_roles( + root: Path, + roles: dict[str, str], + custom: dict[object, object], + report: Report, +) -> None: + for role, value in custom.items(): + if role not in roles or not isinstance(value, str) or not value.strip(): + report.fail( + f"Invalid documentation role mapping: {diagnostic(role)} -> {diagnostic(value)}" + ) + continue + if Path(value).is_absolute(): + report.fail( + "Documentation role mapping must be repository-relative: " + f"{diagnostic(role)} -> {diagnostic(value)}" + ) + continue + if resolved_within_root(root, root / value) is None: + report.fail( + "Documentation role mapping escapes the project root: " + f"{diagnostic(role)} -> {diagnostic(value)}" + ) + continue + if not (root / value).exists(): + report.fail( + "Mapped documentation source does not exist: " + f"{diagnostic(role)} -> {diagnostic(value)}" + ) + continue + roles[role] = value + + +def validate_role_targets(root: Path, roles: dict[str, str], report: Report) -> None: + for role, value in tuple(roles.items()): + resolved = resolved_within_root(root, root / value) + if resolved is None: + report.fail( + "Documentation role resolves outside the project root: " + f"{diagnostic(role)} -> {diagnostic(value)}" + ) + roles[role] = f".governance/invalid-role/{role}" + continue + if resolved.exists() and role == "adr_dir" and not resolved.is_dir(): + report.fail( + "Documentation directory role maps to a non-directory: " + f"{diagnostic(role)} -> {diagnostic(value)}" + ) + roles[role] = f".governance/invalid-role/{role}" + elif resolved.exists() and role != "adr_dir" and not resolved.is_file(): + report.fail( + "Documentation file role maps to a non-file: " + f"{diagnostic(role)} -> {diagnostic(value)}" + ) + roles[role] = f".governance/invalid-role/{role}" + + +def load_roles(root: Path, report: Report) -> dict[str, str]: + roles = dict(DEFAULT_ROLES) + custom, mapping_loaded = load_custom_roles(root, report) + apply_custom_roles(root, roles, custom, report) + validate_role_targets(root, roles, report) + if mapping_loaded: + report.ok( + "Loaded custom documentation role mapping from .governance/docs-map.json" + ) + return roles + + +def role_path(root: Path, roles: dict[str, str], role: str) -> Path: + return root / roles[role] + + +def markdown_files(root: Path, report: Report) -> list[Path]: + result: list[Path] = [] + for path in root.rglob("*.md"): + relative = path.relative_to(root) + if any(part in IGNORED_DIRS for part in relative.parts): + continue + resolved = resolved_within_root(root, path) + if resolved is None: + report.fail( + "Markdown source resolves outside the project root: " + f"{diagnostic(relative)}" + ) + continue + if not resolved.is_file(): + report.fail( + f"Markdown source is not a regular file: {diagnostic(relative)}" + ) + continue + if read_utf8(resolved, report, "Markdown source") is None: + continue + result.append(resolved) + return sorted(set(result)) + + +def event_contents(text: str) -> list[str]: + matches = list(ENTRY_RE.finditer(text)) + result: list[str] = [] + for index, match in enumerate(matches): + end = matches[index + 1].start() if index + 1 < len(matches) else len(text) + result.append(text[match.start() : end].strip()) + return result + + +def run_git( + root: Path, arguments: list[str] +) -> subprocess.CompletedProcess[str] | None: + try: + return subprocess.run( + ["git", *arguments], + cwd=root, + text=True, + capture_output=True, + check=False, + timeout=GIT_TIMEOUT_SECONDS, + ) + except (OSError, subprocess.SubprocessError): + return None + + +def resolve_within_root(root: Path, base: Path, target: str) -> Path | None: + return resolved_within_root(root, base / target) + + +def resolve_target(root: Path, source: Path, target: str) -> Path | None: + if target.startswith("/"): + return resolve_within_root(root, root, target.lstrip("/")) + return resolve_within_root(root, source.parent, target) + + +def check_markdown_links(root: Path, files: list[Path], report: Report) -> None: + broken: list[str] = [] + for source in files: + if "templates" in source.relative_to(root).parts: + continue + text = source.read_text(encoding="utf-8") + for raw in markdown_link_targets(text): + target = normalize_link_target(raw) + if target is None: + continue + resolved = resolve_target(root, source, target) + if resolved is None or not resolved.exists(): + broken.append( + f"{diagnostic(source.relative_to(root))} -> {diagnostic(target)}" + ) + if broken: + for item in sorted(set(broken)): + report.fail(f"Broken Markdown link: {item}") + else: + report.ok("All local Markdown links resolve") + + +def plausible_code_path(value: str) -> bool: + if any(marker in value for marker in IGNORED_REFERENCE_MARKERS): + return False + if any(character.isspace() for character in value): + return False + if value.startswith(("http://", "https://", "$", ".governance/")): + return False + if re.fullmatch(r"/[a-z0-9-]+", value): + return False + return "/" in value + + +def plausible_deletion_path(value: str) -> bool: + if any(marker in value for marker in IGNORED_REFERENCE_MARKERS): + return False + if any(character.isspace() for character in value): + return False + if value.startswith(("//", "$", ".governance/")): + return False + if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", value): + return False + path = Path(value.replace("\\", "/")) + return ( + value not in {"", ".", ".."} + and not path.is_absolute() + and ".." not in path.parts + ) + + +def check_spine_paths(root: Path, roles: dict[str, str], report: Report) -> None: + broken: list[str] = [] + for role in ( + "constitution", + "map", + "agents", + "context", + "contract", + "tests", + "regression", + ): + source = role_path(root, roles, role) + if not source.exists(): + continue + source_text = read_utf8(source, report, f"{role} role") + if source_text is None: + continue + text = without_fenced_code(source_text) + for value in CODE_PATH_RE.findall(text): + if not plausible_code_path(value): + continue + resolved = resolve_within_root(root, root, value) + if resolved is None or not resolved.exists(): + broken.append( + f"{diagnostic(source.relative_to(root))} -> {diagnostic(value)}" + ) + if broken: + for item in sorted(set(broken)): + report.fail(f"Referenced spine/artifact path does not exist: {item}") + else: + report.ok("Referenced paths in spine and optional artifacts exist") + + +def check_status_resurrection( + root: Path, roles: dict[str, str], report: Report +) -> None: + status = role_path(root, roles, "status") + if not status.exists(): + report.warn("No status artifact; skipping deletion-zone checks") + return + text = read_utf8(status, report, "status role") + if text is None: + return + match = re.search( + r"(?:Deletion Zone|\u5220\u9664\u533a)(?P.*?)(?:\n## |\Z)", + text, + re.DOTALL | re.IGNORECASE, + ) + resurrected: list[str] = [] + if match: + for value in CODE_PATH_RE.findall(match.group("body")): + normalized = value.replace("\\", "/") + if plausible_deletion_path(normalized): + resolved = resolve_within_root(root, root, normalized) + if resolved is not None and resolved.exists(): + resurrected.append(value) + if resurrected: + for value in sorted(set(resurrected)): + report.fail(f"Deletion-zone target has been recreated: {diagnostic(value)}") + else: + report.ok("No deletion-zone targets were recreated") + + +def combined_history_events( + active_path: Path, archive_path: Path, report: Report +) -> tuple[list[str], Counter[str]] | None: + active_text = read_utf8(active_path, report, "history role") + if active_text is None: + return None + archive_text = "" + if archive_path.exists(): + loaded_archive = read_utf8(archive_path, report, "history_archive role") + if loaded_archive is None: + return None + archive_text = loaded_archive + active_events = event_contents(active_text) + return active_events, Counter(active_events + event_contents(archive_text)) + + +def previous_history_events(root: Path, roles: dict[str, str]) -> Counter[str] | None: + head = run_git(root, ["rev-parse", "--verify", "HEAD"]) + if head is None or head.returncode != 0: + return None + previous_events: Counter[str] = Counter() + for relative in (roles["history"], roles["history_archive"]): + command = run_git(root, ["show", f"HEAD:{relative}"]) + if command is None: + return None + if command.returncode == 0: + previous_events.update(event_contents(command.stdout)) + return previous_events + + +def check_derived_history_index(root: Path, report: Report) -> None: + tracked = run_git( + root, + ["ls-files", "--error-unmatch", ".governance/project-log.sqlite"], + ) + if tracked is not None and tracked.returncode == 0: + report.fail( + ".governance/project-log.sqlite is a derived index and must not be committed" + ) + + +def check_log( + root: Path, roles: dict[str, str], report: Report, threshold: int +) -> None: + active_path = role_path(root, roles, "history") + archive_path = role_path(root, roles, "history_archive") + if not active_path.exists(): + report.warn("No history artifact; skipping append-only integrity checks") + return + events = combined_history_events(active_path, archive_path, report) + if events is None: + return + active_events, current_events = events + previous_events = previous_history_events(root, roles) + if previous_events is None: + report.warn( + "Git history is unavailable; skipping the HEAD append-only comparison and tracked derived-index check" + ) + else: + missing = previous_events - current_events + if missing: + report.warn( + f"{sum(missing.values())} working-tree history events differ from HEAD; confirm intentional correction or redaction" + ) + else: + report.ok("Active and archived history remain append-only when combined") + if len(active_events) > threshold: + report.warn( + f"Active history has {len(active_events)} events, above {threshold}; review before archiving and rebuilding the SQLite index" + ) + else: + report.ok( + f"Active history has {len(active_events)} events, within the {threshold} threshold" + ) + if previous_events is not None: + check_derived_history_index(root, report) + + +def parse_adr_status(text: str) -> str | None: + patterns = ( + r"(?im)^(?:[-*]\s+)?(?:\*\*(?:status|\u72b6\u6001)\*\*|(?:status|\u72b6\u6001))\s*[:\uFF1A]\s*`?([a-z]+)`?(?:\s+by\s+ADR-\d{3,4})?\s*$", + r"(?im)^##\s+(?:status|\u72b6\u6001)\s*\n+\s*`?([a-z]+)`?\s*$", + ) + for pattern in patterns: + match = re.search(pattern, text) + if match: + return match.group(1).lower() + return None + + +def inline_adr_successor(text: str) -> str | None: + match = ADR_INLINE_SUCCESSOR_RE.search(text) + return match.group(1).upper() if match else None + + +def adr_identifier(text: str) -> str | None: + match = re.search(r"(?im)^#\s+(ADR-\d{3,4})\b", text) + return match.group(1).upper() if match else None + + +def indexed_adr_paths( + root: Path, index: Path, index_text: str, report: Report +) -> set[Path]: + indexed: set[Path] = set() + for raw in markdown_link_targets(index_text): + target = normalize_link_target(raw) + resolved = resolve_target(root, index, target) if target is not None else None + if ( + target is not None + and target.endswith(".md") + and (resolved is None or not resolved.exists()) + ): + report.fail( + "ADR index link does not exist: " + f"{diagnostic(index.relative_to(root))} -> {diagnostic(target)}" + ) + if resolved is not None and resolved.suffix.lower() == ".md": + indexed.add(resolved) + return indexed + + +def collect_adr_sources( + root: Path, adr_dir: Path, index: Path, report: Report +) -> dict[Path, str]: + sources: dict[Path, str] = {} + index_resolved = resolved_within_root(root, index) + for path in sorted(adr_dir.glob("*.md")): + resolved = resolved_within_root(root, path) + if resolved is None: + report.fail( + "ADR source resolves outside the project root: " + f"{diagnostic(path.relative_to(root))}" + ) + continue + if not resolved.is_file(): + report.fail( + f"ADR source is not a regular file: {diagnostic(path.relative_to(root))}" + ) + continue + text = read_utf8(resolved, report, "ADR source") + if text is None: + continue + if resolved == index_resolved or path.name.lower() == "template.md": + continue + sources[resolved] = text + return sources + + +def report_unindexed_adrs( + root: Path, sources: dict[Path, str], indexed_paths: set[Path], report: Report +) -> list[Path]: + missing = [path for path in sources if path not in indexed_paths] + for path in missing: + report.fail( + "ADR is missing from the canonical index: " + f"{diagnostic(path.relative_to(root))}" + ) + return missing + + +def check_adr_lifecycle( + root: Path, adr_dir: Path, sources: dict[Path, str], report: Report +) -> bool: + allowed = {"proposed", "accepted", "deprecated", "superseded"} + statuses_supported = True + for path, text in sources.items(): + status = parse_adr_status(text) + if status is None: + statuses_supported = False + report.fail( + f"ADR has no parseable status: {diagnostic(path.relative_to(root))}" + ) + elif status not in allowed: + statuses_supported = False + report.fail( + "ADR status is unsupported: " + f"{diagnostic(path.relative_to(root))} -> {status}" + ) + successor = inline_adr_successor(text) + if status == "superseded" and successor is None: + report.fail( + "Superseded ADR is missing an inline successor: " + f"{diagnostic(path.relative_to(root))}" + ) + elif successor and successor not in { + identifier + for source_text in sources.values() + if (identifier := adr_identifier(source_text)) is not None + }: + report.fail( + "ADR inline successor does not exist: " + f"{diagnostic(path.relative_to(root))} -> {successor}" + ) + supersedes = re.search( + r"(?ims)^##\s+Supersedes\s*\n(?P.*?)(?:\n## |\Z)", text + ) + if supersedes and supersedes.group("body").strip().lower() not in { + "none", + "\u65e0", + }: + targets = ADR_TARGET_RE.findall(supersedes.group("body")) + if not targets: + report.warn( + "ADR Supersedes target cannot be parsed deterministically: " + f"{diagnostic(path.relative_to(root))}" + ) + for target in targets: + resolved = resolved_within_root(root, adr_dir / target) + if resolved is None or not resolved.is_file(): + report.fail( + "ADR Supersedes target does not exist: " + f"{diagnostic(path.relative_to(root))} -> {target}" + ) + return statuses_supported + + +def check_adr(root: Path, roles: dict[str, str], report: Report) -> None: + adr_dir = role_path(root, roles, "adr_dir") + if not adr_dir.exists(): + report.warn("No ADR directory exists; create ADRs lazily for real decisions") + return + index = role_path(root, roles, "adr_index") + if not index.exists(): + report.fail("ADR directory exists without a canonical README.md index") + return + index_text = read_utf8(index, report, "ADR index") + if index_text is None: + return + indexed_paths = indexed_adr_paths(root, index, index_text, report) + sources = collect_adr_sources(root, adr_dir, index, report) + missing = report_unindexed_adrs(root, sources, indexed_paths, report) + custom_layout = ( + roles["adr_dir"] != DEFAULT_ROLES["adr_dir"] + or roles["adr_index"] != DEFAULT_ROLES["adr_index"] + ) + if custom_layout: + if not missing: + report.ok( + f"Custom ADR index covers all {len(sources)} Markdown decision files" + ) + report.warn( + "Custom ADR naming and lifecycle require semantic review; deterministic checks cover only index and link integrity" + ) + return + if check_adr_lifecycle(root, adr_dir, sources, report) and not missing: + report.ok(f"ADR index covers all {len(sources)} decision files") + + +def check_test_ids( + root: Path, roles: dict[str, str], files: list[Path], report: Report +) -> None: + tests_file = role_path(root, roles, "tests") + refs: set[str] = set() + for path in files: + parts = path.relative_to(root).parts + if path == tests_file or any( + section in parts + for section in ("templates", "skills", "commands", "agents", "references") + ): + continue + refs.update( + value.upper() + for value in TEST_ID_RE.findall(path.read_text(encoding="utf-8")) + ) + refs.discard("TEST-ID") + if not refs: + report.ok("No cross-document TEST-ID references found") + return + if not tests_file.exists(): + report.warn( + f"Found {len(refs)} TEST-ID references but no mapped test registry; review whether they are examples" + ) + return + tests_text = read_utf8(tests_file, report, "test registry") + if tests_text is None: + return + defined = {value.upper() for value in TEST_ID_RE.findall(tests_text)} + missing = refs - defined + if missing: + report.fail( + "TEST-IDs are missing from the test registry: " + ", ".join(sorted(missing)) + ) + else: + report.ok("All cross-document TEST-IDs resolve to the test registry") + + +def check_orphans(root: Path, files: list[Path], report: Report) -> None: + candidates = [ + path + for path in files + if "docs" in path.relative_to(root).parts + and "templates" not in path.relative_to(root).parts + and path.name != "README.md" + ] + all_text = {path: path.read_text(encoding="utf-8") for path in files} + orphans: list[str] = [] + for candidate in candidates: + relative = candidate.relative_to(root).as_posix() + if not any( + path != candidate and (relative in text or candidate.name in text) + for path, text in all_text.items() + ): + orphans.append(diagnostic(relative)) + if orphans: + report.warn( + "Possible orphan documents; review whether to link or archive: " + + ", ".join(orphans) + ) + else: + report.ok("No possible orphan documents found under docs/") + + +def check_spine( + root: Path, roles: dict[str, str], report: Report, threshold: int +) -> None: + report.section("spine") + for role in ("constitution", "map", "status", "history", "agents"): + name = roles[role] + if role_path(root, roles, role).exists(): + report.ok(f"{diagnostic(name)} exists ({role})") + else: + report.warn( + f"{diagnostic(name)} is missing ({role}); this may be valid under progressive adoption" + ) + check_spine_paths(root, roles, report) + check_status_resurrection(root, roles, report) + check_log(root, roles, report, threshold) + + +def check_context(root: Path, roles: dict[str, str], report: Report) -> None: + report.section("context") + context = role_path(root, roles, "context") + if not context.exists(): + report.warn( + "No context artifact; do not create an empty shell without stable domain language" + ) + return + report.ok( + "Context artifact exists; terminology boundaries and code consistency require semantic review" + ) + + +def check_artifacts(root: Path, roles: dict[str, str], report: Report) -> None: + report.section("artifacts") + for role in ("contract", "tests", "regression"): + if not role_path(root, roles, role).exists(): + report.warn( + f"{diagnostic(roles[role])} is missing ({role}); this may be valid under progressive adoption" + ) + files = markdown_files(root, report) + check_markdown_links(root, files, report) + check_test_ids(root, roles, files, report) + check_orphans(root, files, report) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument( + "--scope", + choices=("spine", "context", "adr", "artifacts", "full"), + default="full", + ) + parser.add_argument("--log-threshold", type=int, default=200) + args = parser.parse_args() + + root = args.root.resolve() + if not root.is_dir(): + print(f"Not a directory: {diagnostic(root)}", file=sys.stderr) + return 2 + report = Report() + roles = load_roles(root, report) + if args.scope in ("spine", "full"): + check_spine(root, roles, report, args.log_threshold) + if args.scope in ("context", "full"): + check_context(root, roles, report) + if args.scope in ("adr", "full"): + report.section("adr") + check_adr(root, roles, report) + if args.scope in ("artifacts", "full"): + check_artifacts(root, roles, report) + + print() + if report.failures: + print( + f"✗ Deterministic audit failed with {report.failures} issue(s). Fix integrity errors before semantic review." + ) + return 1 + print( + "✓ Deterministic audit passed. Review warnings manually, then continue to semantic audit." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pi/core/skills/docs-governance/scripts/markdown_links.py b/pi/core/skills/docs-governance/scripts/markdown_links.py new file mode 100644 index 000000000..bb4612469 --- /dev/null +++ b/pi/core/skills/docs-governance/scripts/markdown_links.py @@ -0,0 +1,202 @@ +"""Small Markdown link parser used by the documentation-governance audit.""" + +from __future__ import annotations + +import re +from urllib.parse import unquote + +INLINE_LINK_START_RE = re.compile(r"\[[^\]\n]*\]\(") +REFERENCE_DEFINITION_RE = re.compile( + r"""(?mx) + ^\ {0,3}\[(?P