From cd7b314d752f2d3800cf846ed0aba3532d1c90e0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 24 Oct 2026 07:50:18 +0000 Subject: [PATCH] Add an offline experience library keyed by repo and stack Step 2 of #874 stores lessons under core/dev/experience and reads them through harnessExperience. A lookup opens only the repo or shared stack the caller named. Tiers stay declarative and applied stays false. References #874 Co-authored-by: Hongsheng Liu --- core/dev/experience/README.md | 48 +++ core/dev/experience/check.mjs | 92 +++++ .../ThinkFlowLab/FlyBot/one-conclusion.json | 9 + .../repos/ThinkFlowLab/FlyBot/stale-base.json | 11 + .../stacks/node-host/generated-gates.json | 11 + core/src/host/experience.js | 325 ++++++++++++++++++ core/src/host/harness.js | 12 + core/test/experience.test.js | 258 ++++++++++++++ docs/subsystems/core-host.md | 12 +- docs/subsystems/ds-bot-core.md | 1 + patches/ds-bot/files/dev/experience/README.md | 48 +++ patches/ds-bot/files/dev/experience/check.mjs | 92 +++++ .../ThinkFlowLab/FlyBot/one-conclusion.json | 9 + .../repos/ThinkFlowLab/FlyBot/stale-base.json | 11 + .../stacks/node-host/generated-gates.json | 11 + patches/ds-bot/files/src/host/experience.js | 325 ++++++++++++++++++ patches/ds-bot/files/src/host/harness.js | 12 + patches/ds-bot/files/test/experience.test.js | 258 ++++++++++++++ 18 files changed, 1544 insertions(+), 1 deletion(-) create mode 100644 core/dev/experience/README.md create mode 100644 core/dev/experience/check.mjs create mode 100644 core/dev/experience/repos/ThinkFlowLab/FlyBot/one-conclusion.json create mode 100644 core/dev/experience/repos/ThinkFlowLab/FlyBot/stale-base.json create mode 100644 core/dev/experience/stacks/node-host/generated-gates.json create mode 100644 core/src/host/experience.js create mode 100644 core/test/experience.test.js create mode 100644 patches/ds-bot/files/dev/experience/README.md create mode 100644 patches/ds-bot/files/dev/experience/check.mjs create mode 100644 patches/ds-bot/files/dev/experience/repos/ThinkFlowLab/FlyBot/one-conclusion.json create mode 100644 patches/ds-bot/files/dev/experience/repos/ThinkFlowLab/FlyBot/stale-base.json create mode 100644 patches/ds-bot/files/dev/experience/stacks/node-host/generated-gates.json create mode 100644 patches/ds-bot/files/src/host/experience.js create mode 100644 patches/ds-bot/files/test/experience.test.js diff --git a/core/dev/experience/README.md b/core/dev/experience/README.md new file mode 100644 index 00000000..5365fb5a --- /dev/null +++ b/core/dev/experience/README.md @@ -0,0 +1,48 @@ +# Experience library (#874 Step 2) + +Dated lessons keyed by repository and tech stack. This is a Harness²-style **library only**. It does not rewrite Lite, Medium, or Heavy while a task runs, and it does not call a model. Root [LESSONS.md](../../../LESSONS.md) stays the global agent log. This tree does not replace it, and a harness tier does not inject either one. + +## Layout + +``` +core/dev/experience/ + repos///.json + stacks//.json +``` + +A file under `repos/` must say `"repo": "/"`. It may list `techStacks`. Those tags stay inside that repo. + +A file under `stacks/` must say `"repo": null` and `"techStacks": [""]`. That is the only shared scope. Another repo's file is never opened for a lookup that did not name it. + +Each entry has `id`, `date` (`YYYY-MM-DD`), `trigger`, `wrong`, and `right`. `seen` and `refs` (`#123`) are optional. + +## Read + +`lookupExperience({ repo, techStack, query, root })` in `core/src/host/experience.js`: + +| Call | Result | +| --- | --- | +| `repo` only | That repo's files | +| `techStack` only | Shared files under `stacks//` | +| both | That repo's entries tagged with the stack, plus the shared stack entries | +| neither | No entries | + +`query` filters `id`, `trigger`, `wrong`, and `right` after the scope is fixed. Results sort by `date` descending, then `id` ascending. + +`harnessExperience` on the host harness is that read. `resolveHarness` does not attach lessons. `applied` stays false. + +The npm package does not ship `core/dev/`. A packaged install's default read returns no entries. + +## Write + +Add or edit a JSON file and commit it. + +```sh +node core/dev/experience/check.mjs +node core/dev/experience/check.mjs add --repo ThinkFlowLab/FlyBot --id \ + --date YYYY-MM-DD --trigger '...' --wrong '...' --right '...' +node core/dev/experience/check.mjs add --tech-stack node-host --id \ + --date YYYY-MM-DD --trigger '...' --wrong '...' --right '...' +``` + +`add` writes one new file and refuses to overwrite. It does not edit harness configs. `check` exits non-zero when a path and its fields disagree. diff --git a/core/dev/experience/check.mjs b/core/dev/experience/check.mjs new file mode 100644 index 00000000..954d1cac --- /dev/null +++ b/core/dev/experience/check.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +// Commit-reviewed writer and checker for the experience library. +// Adding a lesson writes one JSON file under this directory. It does not edit +// harness tiers. Review the file in the pull request that adds it. +// +// node core/dev/experience/check.mjs [--root dir] +// node core/dev/experience/check.mjs [--root dir] add --id --date YYYY-MM-DD \ +// --trigger --wrong --right [--repo owner/name] [--tech-stack id] +// [--seen N] [--ref #N] +// +// --repo alone writes repos///.json. +// --tech-stack alone writes stacks//.json with repo null. +// Both write a repo entry tagged with that stack. +// Passing neither --repo nor --tech-stack is an error. + +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { dirname, resolve, sep } from 'node:path' +import { checkExperience, DEFAULT_EXPERIENCE_ROOT, prepareExperienceEntry } from '../../src/host/experience.js' + +const argv = process.argv.slice(2) +const positional = [] +const flags = new Map() +const refs = [] + +for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + if (!arg.startsWith('--')) { + positional.push(arg) + continue + } + const key = arg.slice(2) + const value = argv[index + 1] + if (value === undefined || value.startsWith('--')) { + console.error(`missing value for --${key}`) + process.exit(2) + } + index += 1 + if (key === 'ref') refs.push(value) + else if (flags.has(key)) { + console.error(`duplicate --${key}`) + process.exit(2) + } else flags.set(key, value) +} + +const root = flags.get('root') ?? DEFAULT_EXPERIENCE_ROOT +const command = positional[0] ?? 'check' +if (positional.length > 1 || (command !== 'check' && command !== 'add')) { + console.error('usage: check.mjs [--root dir] [add --id ... --date ... --trigger ... --wrong ... --right ...]') + process.exit(2) +} + +if (command === 'check') { + const report = checkExperience(root) + if (!report.ok) { + for (const line of report.failures) console.error(line) + process.exit(1) + } + console.log(`ok ${root}`) + process.exit(0) +} + +try { + const stack = flags.get('tech-stack') + const fields = { + id: flags.get('id'), + repo: flags.has('repo') ? flags.get('repo') : null, + techStacks: stack === undefined ? [] : [stack], + date: flags.get('date'), + trigger: flags.get('trigger'), + wrong: flags.get('wrong'), + right: flags.get('right'), + } + if (flags.has('seen')) { + if (!/^[1-9]\d*$/.test(flags.get('seen'))) throw new Error('seen') + fields.seen = Number(flags.get('seen')) + } + if (refs.length > 0) fields.refs = refs + const planned = prepareExperienceEntry(fields) + const base = resolve(root) + const file = resolve(base, planned.relativePath) + if (file !== base && !file.startsWith(base + sep)) throw new Error('experience path escapes root') + if (existsSync(file)) { + console.error(`exists ${file}`) + process.exit(1) + } + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, planned.body, { flag: 'wx' }) + console.log(file) +} catch (error) { + console.error(error.message) + process.exit(1) +} diff --git a/core/dev/experience/repos/ThinkFlowLab/FlyBot/one-conclusion.json b/core/dev/experience/repos/ThinkFlowLab/FlyBot/one-conclusion.json new file mode 100644 index 00000000..050e5a10 --- /dev/null +++ b/core/dev/experience/repos/ThinkFlowLab/FlyBot/one-conclusion.json @@ -0,0 +1,9 @@ +{ + "id": "one-conclusion", + "repo": "ThinkFlowLab/FlyBot", + "techStacks": [], + "date": "2026-10-24", + "trigger": "A tip already carries a Verdict.", + "wrong": "Posting a second Verdict at the same SHA, or editing the first when the answer changed.", + "right": "One Verdict per tip. A new commit voids the old one. A correction carries no Verdict line." +} diff --git a/core/dev/experience/repos/ThinkFlowLab/FlyBot/stale-base.json b/core/dev/experience/repos/ThinkFlowLab/FlyBot/stale-base.json new file mode 100644 index 00000000..5bedbd78 --- /dev/null +++ b/core/dev/experience/repos/ThinkFlowLab/FlyBot/stale-base.json @@ -0,0 +1,11 @@ +{ + "id": "stale-base", + "repo": "ThinkFlowLab/FlyBot", + "techStacks": [], + "date": "2026-10-24", + "seen": 4, + "trigger": "Branching from a local main that this run did not fetch, then opening a pull request.", + "wrong": "Reading a green pull request page as proof the branch is current. Ghost files in the diff are the branch restoring an old main.", + "right": "git diff origin/main...HEAD --stat must list only files you changed. Anything else means start again from the fetched tip.", + "refs": ["#864", "#876", "#887"] +} diff --git a/core/dev/experience/stacks/node-host/generated-gates.json b/core/dev/experience/stacks/node-host/generated-gates.json new file mode 100644 index 00000000..a0b42f4d --- /dev/null +++ b/core/dev/experience/stacks/node-host/generated-gates.json @@ -0,0 +1,11 @@ +{ + "id": "generated-gates", + "repo": null, + "techStacks": ["node-host"], + "date": "2026-10-15", + "seen": 3, + "trigger": "An edit under core/ after the last patch generate, or before arch-docs runs.", + "wrong": "Trusting an earlier green check. arch-docs reports no changed files while the edit is still uncommitted.", + "right": "After the last core/ edit, build the client when src/client changed, regenerate ds-bot patches, then verify.", + "refs": ["#94", "#99", "#177"] +} diff --git a/core/src/host/experience.js b/core/src/host/experience.js new file mode 100644 index 00000000..08c7eefd --- /dev/null +++ b/core/src/host/experience.js @@ -0,0 +1,325 @@ +// Experience library (#874 Step 2). Offline lessons keyed by repository and tech +// stack. Agents and HarnessPro read them. A lesson changes only when a file in +// core/dev/experience/ is committed. This module does not call a model and does +// not edit Lite, Medium, or Heavy. +// +// The published npm package does not ship core/dev/. When that directory is +// absent, a read with the default root returns no entries so loading the host +// does not depend on dev data. An explicit root that is missing throws. + +import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs' +import { dirname, join, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' + +export const DEFAULT_EXPERIENCE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../../dev/experience') + +const REPO_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/ +const STACK_ID = /^[a-z0-9][a-z0-9-]*$/ +const DATE = /^\d{4}-\d{2}-\d{2}$/ +const REF = /^#[1-9]\d{0,6}$/ +const TEXT_MAX = 600 +const ENTRY_KEYS = new Set(['id', 'repo', 'techStacks', 'date', 'seen', 'trigger', 'wrong', 'right', 'refs']) +const TOP_FILES = new Set(['README.md', 'check.mjs']) + +function isRepoSegment(value) { + return typeof value === 'string' && REPO_SEGMENT.test(value) +} + +function parseRepo(repo) { + if (typeof repo !== 'string') throw new Error(`bad repo: ${repo}`) + const parts = repo.split('/') + if (parts.length !== 2 || !isRepoSegment(parts[0]) || !isRepoSegment(parts[1])) throw new Error(`bad repo: ${repo}`) + return parts +} + +function parseStack(techStack) { + if (typeof techStack !== 'string' || !STACK_ID.test(techStack)) throw new Error(`bad techStack: ${techStack}`) + return techStack +} + +function scoped(root, ...parts) { + const base = resolve(root) + const full = resolve(base, ...parts) + if (full !== base && !full.startsWith(base + sep)) throw new Error(`experience path escapes root: ${full}`) + return full +} + +function validDate(value) { + if (typeof value !== 'string' || !DATE.test(value)) return false + const year = Number(value.slice(0, 4)) + const month = Number(value.slice(5, 7)) + const day = Number(value.slice(8, 10)) + const date = new Date(Date.UTC(year, month - 1, day)) + return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day +} + +function secretAt(value, path) { + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) { + const found = secretAt(value[index], `${path}[${index}]`) + if (found) return found + } + return '' + } + if (value !== null && typeof value === 'object') { + for (const [key, child] of Object.entries(value)) { + const here = path === '' ? key : `${path}.${key}` + if (/api[_-]?key/i.test(key) || /^(token|secret|password|authorization)$/i.test(key)) return here + const found = secretAt(child, here) + if (found) return found + } + return '' + } + if (typeof value === 'string' && (/\bsk-[A-Za-z0-9]{8,}\b/.test(value) || value.includes('DEEPSEEK_API_KEY') || value.includes('GITHUB_TOKEN'))) { + return path === '' ? 'value' : path + } + return '' +} + +function validateEntry(entry, expected) { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) throw new Error('entry') + for (const key of Object.keys(entry)) { + if (!ENTRY_KEYS.has(key)) throw new Error(`unknown field ${key}`) + } + if (typeof entry.id !== 'string' || !STACK_ID.test(entry.id) || entry.id !== expected.id) throw new Error('id') + if (expected.kind === 'repo') { + if (entry.repo !== expected.repo) throw new Error('repo') + } else if (entry.repo !== null) throw new Error('repo') + if (!Array.isArray(entry.techStacks)) throw new Error('techStacks') + for (const name of entry.techStacks) { + if (typeof name !== 'string' || !STACK_ID.test(name)) throw new Error('techStacks') + } + if (new Set(entry.techStacks).size !== entry.techStacks.length) throw new Error('techStacks') + if (expected.kind === 'stack' && (entry.techStacks.length !== 1 || entry.techStacks[0] !== expected.techStack)) { + throw new Error('techStacks') + } + if (!validDate(entry.date)) throw new Error('date') + if (entry.seen !== undefined && (!Number.isInteger(entry.seen) || entry.seen < 1)) throw new Error('seen') + for (const key of ['trigger', 'wrong', 'right']) { + const text = entry[key] + if (typeof text !== 'string' || text.trim() === '' || text.length > TEXT_MAX) throw new Error(key) + } + if (entry.refs !== undefined) { + if (!Array.isArray(entry.refs) || entry.refs.length > 20 || entry.refs.some(ref => typeof ref !== 'string' || !REF.test(ref))) { + throw new Error('refs') + } + } + const secret = secretAt(entry, '') + if (secret) throw new Error(`secret at ${secret}`) + const clean = { + id: entry.id, + repo: entry.repo, + techStacks: [...entry.techStacks], + date: entry.date, + } + if (entry.seen !== undefined) clean.seen = entry.seen + clean.trigger = entry.trigger + clean.wrong = entry.wrong + clean.right = entry.right + if (entry.refs !== undefined) clean.refs = [...entry.refs] + return clean +} + +function readValidated(file, expected) { + let raw + try { + raw = JSON.parse(readFileSync(file, 'utf8')) + } catch (error) { + throw new Error(`${file}: json: ${error.message}`) + } + try { + return validateEntry(raw, expected) + } catch (error) { + throw new Error(`${file}: ${error.message}`) + } +} + +function loadDir(dir, expectedFor) { + if (!existsSync(dir)) return [] + const stat = lstatSync(dir) + if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`${dir}: not a directory`) + const entries = [] + for (const dirent of readdirSync(dir, { withFileTypes: true })) { + const file = join(dir, dirent.name) + if (dirent.isSymbolicLink() || !dirent.isFile() || !dirent.name.endsWith('.json')) { + throw new Error(`${file}: unexpected file`) + } + const id = dirent.name.slice(0, -'.json'.length) + if (!STACK_ID.test(id)) throw new Error(`${file}: id`) + entries.push(readValidated(file, expectedFor(id))) + } + return entries +} + +function matches(entry, needle) { + return [entry.id, entry.trigger, entry.wrong, entry.right].some(text => text.toLowerCase().includes(needle)) +} + +function byDateThenId(a, b) { + if (a.date !== b.date) return a.date < b.date ? 1 : -1 + if (a.id !== b.id) return a.id < b.id ? -1 : 1 + return 0 +} + +export function lookupExperience({ repo, techStack, query, root } = {}) { + const explicitRoot = root !== undefined + const base = explicitRoot ? root : DEFAULT_EXPERIENCE_ROOT + if (typeof base !== 'string' || base.trim() === '') throw new Error('bad root') + const askedRepo = repo !== undefined && repo !== null + const askedStack = techStack !== undefined && techStack !== null + if (!existsSync(base)) { + if (!explicitRoot) return { entries: [] } + throw new Error(`experience root missing: ${base}`) + } + if (!askedRepo && !askedStack) return { entries: [] } + let owner + let name + let stack + if (askedRepo) [owner, name] = parseRepo(repo) + if (askedStack) stack = parseStack(techStack) + const entries = [] + if (askedRepo) { + const loaded = loadDir(scoped(base, 'repos', owner, name), id => ({ kind: 'repo', id, repo })) + entries.push(...(askedStack ? loaded.filter(item => item.techStacks.includes(stack)) : loaded)) + } + if (askedStack) { + entries.push(...loadDir(scoped(base, 'stacks', stack), id => ({ kind: 'stack', id, techStack: stack }))) + } + if (query !== undefined && query !== null && typeof query !== 'string') throw new Error('bad query') + const needle = typeof query === 'string' ? query.trim().toLowerCase() : '' + const filtered = needle === '' ? entries : entries.filter(item => matches(item, needle)) + filtered.sort(byDateThenId) + return { entries: filtered } +} + +function rejectDirent(dirent, label, { directory }) { + if (dirent.isSymbolicLink()) return `symlink ${label}` + if (directory && !dirent.isDirectory()) return `unexpected ${label}` + if (!directory && !dirent.isFile()) return `unexpected ${label}` + return '' +} + +function checkJsonDir(dir, expectedFor) { + const failures = [] + for (const dirent of readdirSync(dir, { withFileTypes: true })) { + const label = join(dir, dirent.name) + const problem = rejectDirent(dirent, label, { directory: false }) + if (problem || !dirent.name.endsWith('.json')) { + failures.push(problem || `unexpected ${label}`) + continue + } + const id = dirent.name.slice(0, -'.json'.length) + if (!STACK_ID.test(id)) { + failures.push(`${label}: id`) + continue + } + try { + readValidated(label, expectedFor(id)) + } catch (error) { + failures.push(error.message) + } + } + return failures +} + +function checkRepos(root) { + const failures = [] + const repos = scoped(root, 'repos') + for (const owner of readdirSync(repos, { withFileTypes: true })) { + const ownerLabel = `repos/${owner.name}` + const ownerProblem = rejectDirent(owner, ownerLabel, { directory: true }) + if (ownerProblem || !isRepoSegment(owner.name)) { + failures.push(ownerProblem || `unexpected ${ownerLabel}`) + continue + } + for (const name of readdirSync(join(repos, owner.name), { withFileTypes: true })) { + const nameLabel = `${ownerLabel}/${name.name}` + const nameProblem = rejectDirent(name, nameLabel, { directory: true }) + if (nameProblem || !isRepoSegment(name.name)) { + failures.push(nameProblem || `unexpected ${nameLabel}`) + continue + } + const repo = `${owner.name}/${name.name}` + failures.push(...checkJsonDir(join(repos, owner.name, name.name), id => ({ kind: 'repo', id, repo }))) + } + } + return failures +} + +function checkStacks(root) { + const failures = [] + const stacks = scoped(root, 'stacks') + for (const stack of readdirSync(stacks, { withFileTypes: true })) { + const label = `stacks/${stack.name}` + const problem = rejectDirent(stack, label, { directory: true }) + if (problem || !STACK_ID.test(stack.name)) { + failures.push(problem || `unexpected ${label}`) + continue + } + failures.push(...checkJsonDir(join(stacks, stack.name), id => ({ kind: 'stack', id, techStack: stack.name }))) + } + return failures +} + +export function checkExperience(root) { + if (typeof root !== 'string' || root.trim() === '') return { ok: false, failures: ['bad root'] } + if (!existsSync(root)) return { ok: false, failures: [`missing root ${root}`] } + let top + try { + top = readdirSync(root, { withFileTypes: true }) + } catch (error) { + return { ok: false, failures: [error.message] } + } + const failures = [] + for (const dirent of top) { + if (dirent.isSymbolicLink()) { + failures.push(`symlink ${dirent.name}`) + continue + } + if (TOP_FILES.has(dirent.name)) { + if (!dirent.isFile()) failures.push(`unexpected ${dirent.name}`) + continue + } + if (dirent.name === 'repos' || dirent.name === 'stacks') { + if (!dirent.isDirectory()) failures.push(`unexpected ${dirent.name}`) + continue + } + failures.push(`unexpected ${dirent.name}`) + } + if (top.some(dirent => dirent.name === 'repos' && dirent.isDirectory() && !dirent.isSymbolicLink())) { + failures.push(...checkRepos(root)) + } + if (top.some(dirent => dirent.name === 'stacks' && dirent.isDirectory() && !dirent.isSymbolicLink())) { + failures.push(...checkStacks(root)) + } + return { ok: failures.length === 0, failures } +} + +export function prepareExperienceEntry(fields) { + if (fields === null || typeof fields !== 'object' || Array.isArray(fields)) throw new Error('bad entry') + for (const key of Object.keys(fields)) { + if (!ENTRY_KEYS.has(key)) throw new Error(`unknown field ${key}`) + } + const repo = fields.repo === undefined ? null : fields.repo + const techStacks = fields.techStacks === undefined ? [] : fields.techStacks + const draft = { + id: fields.id, + repo, + techStacks, + date: fields.date, + } + if (fields.seen !== undefined) draft.seen = fields.seen + draft.trigger = fields.trigger + draft.wrong = fields.wrong + draft.right = fields.right + if (fields.refs !== undefined) draft.refs = fields.refs + if (typeof repo === 'string') parseRepo(repo) + const expected = repo === null + ? { kind: 'stack', id: fields.id, techStack: Array.isArray(techStacks) ? techStacks[0] : undefined } + : { kind: 'repo', id: fields.id, repo } + const entry = validateEntry(draft, expected) + const relativePath = entry.repo === null + ? join('stacks', entry.techStacks[0], `${entry.id}.json`) + : join('repos', ...parseRepo(entry.repo), `${entry.id}.json`) + return { relativePath, entry, body: `${JSON.stringify(entry, null, 2)}\n` } +} diff --git a/core/src/host/harness.js b/core/src/host/harness.js index b652a804..a895840e 100644 --- a/core/src/host/harness.js +++ b/core/src/host/harness.js @@ -13,10 +13,17 @@ // HarnessPro is the offline check for that config. It names CI, core/dev/eval, // and rsi-baseline. It does not run them and it does not call a model. // +// Step 2: harnessExperience reads the experience library (core/dev/experience/, +// keyed by repo and tech stack). Lessons change only by a committed file. This +// is not Option D: a tier is not rewritten while a task runs, and the library +// does not call a model. resolveHarness does not attach the entries. +// // Tree prompt exception: `@flybot/tree` keeps its own marked prompt under // `plugins/tree` (not one of the three mirrors). That injection stays outside // this file; eval `P3-tree-split` must keep passing. +import { lookupExperience } from './experience.js' + export const DEFAULT_HARNESS_ID = 'default' // Triage sets exactly one of these. The controller does not write them, and @@ -218,8 +225,13 @@ export function harnessTierOrder() { return { ok: failures.length === 0, failures, axes } } +export function harnessExperience(query = {}) { + return lookupExperience(query ?? {}) +} + export function install(rt) { rt.harness = resolveHarness(DEFAULT_HARNESS_ID) rt.resolveHarness = resolveHarness rt.routeHarness = routeHarness + rt.harnessExperience = harnessExperience } diff --git a/core/test/experience.test.js b/core/test/experience.test.js new file mode 100644 index 00000000..e9567f9f --- /dev/null +++ b/core/test/experience.test.js @@ -0,0 +1,258 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { checkExperience, DEFAULT_EXPERIENCE_ROOT, lookupExperience, prepareExperienceEntry } from '../src/host/experience.js' +import { defaultHarness, harnessExperience, harnessPro, install, resolveHarness } from '../src/host/harness.js' + +const here = dirname(fileURLToPath(import.meta.url)) +const cli = join(here, '../dev/experience/check.mjs') + +function entry(fields) { + return { + techStacks: [], + seen: 1, + refs: ['#1'], + ...fields, + } +} + +function writeTree(root, files) { + mkdirSync(root, { recursive: true }) + writeFileSync(join(root, 'README.md'), '# fixture\n') + writeFileSync(join(root, 'check.mjs'), '// fixture\n') + for (const [rel, body] of Object.entries(files)) { + const file = join(root, rel) + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, typeof body === 'string' ? body : `${JSON.stringify(body, null, 2)}\n`) + } +} + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'flybot-experience-')) + writeTree(root, { + 'repos/Alpha/One/keep.json': entry({ + id: 'keep', + repo: 'Alpha/One', + techStacks: ['node-host'], + date: '2026-10-02', + trigger: 'keep the local lesson', + wrong: 'copy it into every repo', + right: 'leave it on Alpha/One', + }), + 'repos/Alpha/One/plain.json': entry({ + id: 'plain', + repo: 'Alpha/One', + date: '2026-10-01', + trigger: 'plain repo lesson', + wrong: 'tag it as shared', + right: 'keep the stack list empty', + }), + 'repos/Beta/Two/leak.json': entry({ + id: 'leak', + repo: 'Beta/Two', + techStacks: ['node-host'], + date: '2026-10-03', + trigger: 'leak across repos', + wrong: 'return it for Alpha', + right: 'read only the asked repo', + }), + 'stacks/node-host/shared.json': entry({ + id: 'shared', + repo: null, + techStacks: ['node-host'], + date: '2026-10-04', + trigger: 'shared stack lesson', + wrong: 'attach it to one repo', + right: 'store it under stacks with repo null', + }), + 'stacks/github-actions/actions.json': entry({ + id: 'actions', + repo: null, + techStacks: ['github-actions'], + date: '2026-10-05', + trigger: 'actions only', + wrong: 'show it for node-host', + right: 'read only the asked stack', + }), + }) + return root +} + +test('lookup reads only the asked repo and never another repo directory', () => { + const root = fixture() + try { + const repoOnly = lookupExperience({ repo: 'Alpha/One', root }) + assert.deepEqual(repoOnly.entries.map(item => item.id), ['keep', 'plain']) + assert.equal(repoOnly.entries.some(item => item.repo === 'Beta/Two'), false) + assert.equal(repoOnly.entries.some(item => item.id === 'shared'), false) + + const stackOnly = lookupExperience({ techStack: 'node-host', root }) + assert.deepEqual(stackOnly.entries.map(item => item.id), ['shared']) + assert.equal(stackOnly.entries[0].repo, null) + + const both = lookupExperience({ repo: 'Alpha/One', techStack: 'node-host', root }) + assert.deepEqual(both.entries.map(item => item.id), ['shared', 'keep']) + + const other = lookupExperience({ repo: 'Beta/Two', techStack: 'node-host', root }) + assert.deepEqual(other.entries.map(item => item.id), ['shared', 'leak']) + assert.equal(other.entries.some(item => item.repo === 'Alpha/One'), false) + + assert.deepEqual(lookupExperience({ root }).entries, []) + assert.deepEqual(lookupExperience({ repo: 'Alpha/One', query: 'leak', root }).entries, []) + assert.deepEqual(lookupExperience({ techStack: 'node-host', query: 'leak', root }).entries, []) + assert.deepEqual(lookupExperience({ repo: 'Beta/Two', query: 'LEAK', root }).entries.map(item => item.id), ['leak']) + assert.deepEqual(lookupExperience({ techStack: 'github-actions', root }).entries.map(item => item.id), ['actions']) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('a corrupt entry outside the asked scope is not read', () => { + const root = fixture() + try { + writeFileSync(join(root, 'repos/Beta/Two/leak.json'), '{ not json\n') + writeFileSync(join(root, 'stacks/github-actions/actions.json'), '{ not json\n') + const found = lookupExperience({ repo: 'Alpha/One', techStack: 'node-host', root }) + assert.deepEqual(found.entries.map(item => item.id), ['shared', 'keep']) + assert.throws(() => lookupExperience({ repo: 'Beta/Two', root }), /leak\.json/) + assert.throws(() => lookupExperience({ techStack: 'github-actions', root }), /actions\.json/) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('lookup rejects a scope that would leave the root', () => { + const root = fixture() + try { + assert.throws(() => lookupExperience({ repo: '../Beta/Two', root }), /bad repo/) + assert.throws(() => lookupExperience({ techStack: '../repos', root }), /bad techStack/) + assert.throws(() => lookupExperience({ repo: 'Alpha/One', root: join(root, 'missing') }), /root missing/) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('check fails closed when a file disagrees with its directory', () => { + const root = fixture() + try { + assert.equal(checkExperience(root).ok, true) + const leak = join(root, 'repos/Beta/Two/leak.json') + const body = JSON.parse(readFileSync(leak, 'utf8')) + body.repo = 'Alpha/One' + writeFileSync(leak, `${JSON.stringify(body, null, 2)}\n`) + const mismatched = checkExperience(root) + assert.equal(mismatched.ok, false) + assert.ok(mismatched.failures.some(line => line.includes('leak.json'))) + assert.throws(() => lookupExperience({ repo: 'Beta/Two', root }), /repo/) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('a shared stack file that names a repo fails the check', () => { + const root = fixture() + try { + const shared = join(root, 'stacks/node-host/shared.json') + const body = JSON.parse(readFileSync(shared, 'utf8')) + body.repo = 'Beta/Two' + writeFileSync(shared, `${JSON.stringify(body, null, 2)}\n`) + const report = checkExperience(root) + assert.equal(report.ok, false) + assert.ok(report.failures.some(line => line.includes('shared.json'))) + assert.throws(() => lookupExperience({ techStack: 'node-host', root }), /repo/) + assert.deepEqual(lookupExperience({ repo: 'Alpha/One', root }).entries.map(item => item.id), ['keep', 'plain']) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('the committed library isolates ThinkFlowLab/FlyBot from the shared node-host stack', () => { + const report = checkExperience(DEFAULT_EXPERIENCE_ROOT) + assert.equal(report.ok, true, report.failures.join('; ')) + const flybot = lookupExperience({ repo: 'ThinkFlowLab/FlyBot' }) + assert.deepEqual(flybot.entries.map(item => item.id), ['one-conclusion', 'stale-base']) + assert.equal(flybot.entries.every(item => item.repo === 'ThinkFlowLab/FlyBot'), true) + const stack = lookupExperience({ techStack: 'node-host' }) + assert.deepEqual(stack.entries.map(item => item.id), ['generated-gates']) + assert.equal(stack.entries[0].repo, null) + const other = lookupExperience({ repo: 'Acme/Other', techStack: 'node-host' }) + assert.deepEqual(other.entries.map(item => item.id), ['generated-gates']) + assert.deepEqual(lookupExperience({}).entries, []) + assert.deepEqual(lookupExperience({ repo: 'ThinkFlowLab/FlyBot', query: 'verdict' }).entries.map(item => item.id), ['one-conclusion']) +}) + +test('harnessExperience is a read hook and leaves tiers unchanged', () => { + const harness = defaultHarness() + assert.deepEqual(Object.keys(harness), ['id', 'prompt', 'tools', 'model', 'stepRetry', 'verifiers', 'exceptions']) + for (const id of ['lite', 'medium', 'heavy']) { + const tier = resolveHarness(id) + assert.equal(tier.applied, false, id) + assert.equal(harnessPro(tier).ok, true, id) + } + const rt = {} + install(rt) + assert.equal(rt.harness.id, 'default') + assert.equal(rt.harnessExperience, harnessExperience) + assert.deepEqual(rt.harnessExperience({}).entries, []) + assert.deepEqual(rt.harnessExperience({ repo: 'ThinkFlowLab/FlyBot' }).entries.map(item => item.id), ['one-conclusion', 'stale-base']) +}) + +test('the CLI writes one new entry file and refuses to overwrite it', () => { + const root = mkdtempSync(join(tmpdir(), 'flybot-experience-cli-')) + try { + writeFileSync(join(root, 'README.md'), '# fixture\n') + writeFileSync(join(root, 'check.mjs'), '// fixture\n') + const planned = prepareExperienceEntry({ + id: 'from-cli', + repo: 'Alpha/One', + techStacks: [], + date: '2026-10-24', + trigger: 'a file change', + wrong: 'edit the harness while the task runs', + right: 'commit the json file', + }) + assert.equal(planned.relativePath, join('repos', 'Alpha', 'One', 'from-cli.json')) + const added = spawnSync(process.execPath, [ + cli, '--root', root, 'add', + '--repo', 'Alpha/One', + '--id', 'from-cli', + '--date', '2026-10-24', + '--trigger', 'a file change', + '--wrong', 'edit the harness while the task runs', + '--right', 'commit the json file', + ], { encoding: 'utf8' }) + assert.equal(added.status, 0, added.stderr) + assert.deepEqual(lookupExperience({ repo: 'Alpha/One', root }).entries.map(item => item.id), ['from-cli']) + assert.equal(checkExperience(root).ok, true) + const shared = spawnSync(process.execPath, [ + cli, '--root', root, 'add', + '--tech-stack', 'node-host', + '--id', 'from-stack', + '--date', '2026-10-24', + '--trigger', 'a shared file', + '--wrong', 'copy it into every repo', + '--right', 'store it under stacks with repo null', + ], { encoding: 'utf8' }) + assert.equal(shared.status, 0, shared.stderr) + assert.deepEqual(lookupExperience({ techStack: 'node-host', root }).entries.map(item => item.id), ['from-stack']) + assert.equal(lookupExperience({ repo: 'Alpha/One', root }).entries.some(item => item.id === 'from-stack'), false) + const again = spawnSync(process.execPath, [ + cli, '--root', root, 'add', + '--repo', 'Alpha/One', + '--id', 'from-cli', + '--date', '2026-10-24', + '--trigger', 'a file change', + '--wrong', 'edit the harness while the task runs', + '--right', 'commit the json file', + ], { encoding: 'utf8' }) + assert.equal(again.status, 1) + const checked = spawnSync(process.execPath, [cli, '--root', root], { encoding: 'utf8' }) + assert.equal(checked.status, 0, checked.stderr) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index a45374fe..6c6d1882 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -45,7 +45,8 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | `core/src/host/schedules.js` | Scheduled tasks through DSH's schedule service: list, create, edit, pause/resume, retry and delete for Settings → Scheduled tasks, each task's last run, and moving a Bot's tasks to its fresh part (#354, #473, see **Scheduled tasks: edit, pause, missed runs** and **Scheduled tasks: last run and Retry**). Also the `bot_schedules` tool: the Bot-wide list DSH's own `schedule_list` cannot give, group chats' own Sessions included (#628, see **Scheduled tasks: the Bot-wide list**). Tests: `core/test/schedules.test.js`. | | `core/src/host/automation-default.js` | Automation tasks on by default (#378): once per profile, `offerBundle` asks DSH's plugin manager to switch on `@deepseek-ai/dsh-experimental-schedule-bundle` and writes the offer to `dsh-defaults.json` next to the team. Tests: `core/test/automation-default.test.js`. | | `core/src/host/notify.js` | Out-of-app delivery, first half (#355): a transport-agnostic adapter with Slack's `chat.postMessage` shape, one key per reminder so a retried send cannot duplicate, exponential backoff that honours `Retry-After`, three attempts at most, a structural failure not retried, and zero transport calls while the channel is off. Ships `fakeSlack` for tests; nothing calls it until the real Slack transport lands (see **Out-of-app delivery, first half**). Tests: `core/test/notify.test.js`. | -| `core/src/host/harness.js` | Declarative harness (#874 Option B). Step 0 (#903): `default` names today's prompt / tools / model / step·retry / verifiers (`rt.harness`, `rt.resolveHarness`); behavior-neutral. Step 1: `lite` / `medium` / `heavy` load through the same resolver with distinct declared budgets; `rt.routeHarness` reads one triage label (`harness:lite`, `harness:medium`, `harness:heavy`). `applied` stays false, so the named modules are not rewritten. `harnessPro` checks a config offline against CI, `core/dev/eval`, and `rsi-baseline` and does not call a model. Tree prompt exception: `@flybot/tree` keeps its marked prompt under `plugins/tree` (outside the three mirrors); listed on the harness, injection stays outside this file. Installed in `MODULES` right after `store`. Tests: `core/test/harness.test.js`. | +| `core/src/host/harness.js` | Declarative harness (#874 Option B). Step 0 (#903): `default` names today's prompt / tools / model / step·retry / verifiers (`rt.harness`, `rt.resolveHarness`); behavior-neutral. Step 1: `lite` / `medium` / `heavy` load through the same resolver with distinct declared budgets; `rt.routeHarness` reads one triage label (`harness:lite`, `harness:medium`, `harness:heavy`). `applied` stays false, so the named modules are not rewritten. `harnessPro` checks a config offline against CI, `core/dev/eval`, and `rsi-baseline` and does not call a model. Step 2: `rt.harnessExperience` reads the experience library and does not attach it to a tier. Tree prompt exception: `@flybot/tree` keeps its marked prompt under `plugins/tree` (outside the three mirrors); listed on the harness, injection stays outside this file. Installed in `MODULES` right after `store`. Tests: `core/test/harness.test.js`, `core/test/experience.test.js`. | +| `core/src/host/experience.js` | Experience library read API (#874 Step 2). `lookupExperience({ repo, techStack, query, root })` reads `core/dev/experience/`. Data and `check.mjs` stay under `core/dev/` (see [ds-bot-core](ds-bot-core.md)). | | `core/src/host/usage.js` | Usage ledger at `$DSH_HOME/bot/usage.sqlite` (`calls`). Ledger v3 (#903): every row carries `harness`, `issue_pr`, `tool_calls`, `wall_ms`, plus existing token columns (`withUsageFields` / `missingUsageFields`). Session turns are metered via `foldCalls` over turn events (`tool/call` count, wall time from attempt start, `issuePr` from turn text or `rt.noteUsageIssuePr(sessionId, ref)`), not from llm/stream alone. A triage tier is stamped with `rt.noteUsageHarness(sessionId, idOrLabels)` and lands on the row's `harness` field; the resume cursor (`marks.fold`) stays provider / model / retry. Sample row fixture: `core/test/fixtures/usage-sample.sqlite`. Tests: `core/test/usage-fields.test.js`, `core/test/usage.test.js`. | | `core/src/host/tool-schema.js` | The tool definitions the model sees: its `defineTool` wraps DSH's and completes the compiled schema with `explicitObjectShape`, which fills `properties: {}` and `required: []` on every object node (recursing through `properties`, `items`, `oneOf`/`anyOf`/`allOf`, `$defs`, …). A strict OpenAI-compatible gateway reads an absent member as null and rejects the whole request (`null is not of type "array"`), so one argument-less tool would break every turn; argument validation keeps using DSH's schema unchanged. Every host module registers tools through this wrapper. Tests: `core/test/tool-schema.test.js`. | | `core/src/shared/remote-label.js` | Owner-badge labels for a friend's Bot and “Shared room from …” titles. `friendBotTitle(bot)` / `friendBotOwner(bot)` name a friend's chat from the roster (`Chief (SelfB1233)`) and its owner (`SelfB1233`, the person an approval card names) with one formatter for every client surface (#498). `remoteBotName` / `isAutoOwnerName` / `seatOwnerName` keep group and face-room labels in that format too (#526): Bot Social's ` · ` member label is not a Bot name, and an owner name made up from an install id (`FlyBot 682b8a43`) gives way to the username in the home handle (`Reviewer (flybot682b8a43)`). `isListedRemoteChat` / `listedChatBots` keep gone, hidden, and home-level (`@something.home`) remotes out of the sidebar, the **发给** picker, mentions, and `list_bots` friends lines; history stays on disk. | @@ -72,6 +73,15 @@ Lite, Medium, and Heavy are three configs on the Step 0 harness, not a second ru - `harnessPro(config)` is the offline check an RSI edit of a tier has to pass: the config still names `./prompt.js`, `./tools.js`, `./models.js`, and `./turns.js`, its `verifiers.offline` list is `ci`, `core/dev/eval`, `rsi-baseline`, and it carries no key or token field. The function does not run those three checks and does not call a model. Running them is the pull request's own CI, `node core/dev/eval/run.mjs --list`, and `node --test .github/scripts/rsi-baseline.test.mjs`. - `rt.noteUsageHarness(sessionId, idOrLabels)` stores the chosen tier for that Session. Later `foldCalls` rows and live meter rows record it in `harness`. Clearing it records `default` again. Issue/PR, tool-call count, and wall time stay the Step 0 fields. +### Experience library (#874 Step 2) + +Lessons keyed by repository and tech stack, kept off the global `LESSONS.md` bag and off the harness tier objects. This is not Option D: nothing rewrites a tier while a task runs, and the library does not call a model. + +- Files live in `core/dev/experience/repos///.json` and `core/dev/experience/stacks//.json`. A repo file names that `owner/name`. A shared stack file has `repo: null` and lives only under `stacks/`. A repo file may list `techStacks`; those tags do not publish it to other repos. The shape is trigger, wrong, right, plus a date, the same spirit as `LESSONS.md`, scoped to the key. +- `lookupExperience` reads only the directories the caller named. A call that passes neither key returns an empty list. A repo lookup opens that repo's directory only. A tech-stack lookup returns `repo: null` entries from that stack. Passing both returns the repo's tagged entries plus the shared stack entries. `query` filters `id`, `trigger`, `wrong`, and `right` after that scope is fixed. Sort is date descending, then id ascending. +- `harnessExperience` / `rt.harnessExperience` call that reader. `resolveHarness` still returns the Step 0 / Step 1 config. `applied` stays false. Budgets stay as declared in Step 1. `harnessPro` still names CI, `core/dev/eval`, and `rsi-baseline`. The library does not replace those checks. +- A new lesson is a JSON file in git. `node core/dev/experience/check.mjs` checks the tree (path and fields must agree). `add` writes one new file under that tree and refuses to overwrite. It does not edit harness configs. `core/dev/` is outside the npm `files` list, so a packaged install's default read returns no entries instead of failing the host. Covered by `core/test/experience.test.js`. + ### Room execution boundary `groups.js` owns room scheduling, membership checks, private-brief selection, diff --git a/docs/subsystems/ds-bot-core.md b/docs/subsystems/ds-bot-core.md index 25521278..91e5e6c4 100644 --- a/docs/subsystems/ds-bot-core.md +++ b/docs/subsystems/ds-bot-core.md @@ -42,6 +42,7 @@ holds. Task bookkeeping does not imply runtime execution or tool permission. | `scripts/ds-bot-patches.mjs` | generate / check patches. | | `core/dev/eval/` | User-scenario runner (#358): pure rubrics (`rubric.mjs`), canonical journey specs (`specs.mjs`), optional model judge (`judge.mjs`), grader hashing (#398, `guard.mjs`) and a CLI (`run.mjs`). Dev only, not part of `npm test`. | | `core/dev/token-report.mjs` | Reads the usage ledger into per-Bot, per-Session and per-day token numbers and each Session's prompt growth, keeping fresh input, cache read and cache write apart (#799). Read-only; `--db`, `--from`, `--to`, `--tz`, `--bot`, `--top`, `--growth`, `--json`. | +| `core/dev/experience/` | Offline experience library (#874 Step 2): JSON lessons under `repos///` and `stacks//`. The read API is `core/src/host/experience.js` (see [core-host](core-host.md)). `check.mjs` validates the tree and can add one entry file. Not part of the npm `files` list, and not injected into a harness tier. | | `core/src/host/hooks.js` (approvals) | Held sends bound to their owner — provenance, owner-bound decisions, stale revalidation. Chief replacement invalidates sender-side holds eagerly on `setMain` (not via `staleReason`). The authority contract lives in [team](team.md) (#52). | ## Entry points diff --git a/patches/ds-bot/files/dev/experience/README.md b/patches/ds-bot/files/dev/experience/README.md new file mode 100644 index 00000000..5365fb5a --- /dev/null +++ b/patches/ds-bot/files/dev/experience/README.md @@ -0,0 +1,48 @@ +# Experience library (#874 Step 2) + +Dated lessons keyed by repository and tech stack. This is a Harness²-style **library only**. It does not rewrite Lite, Medium, or Heavy while a task runs, and it does not call a model. Root [LESSONS.md](../../../LESSONS.md) stays the global agent log. This tree does not replace it, and a harness tier does not inject either one. + +## Layout + +``` +core/dev/experience/ + repos///.json + stacks//.json +``` + +A file under `repos/` must say `"repo": "/"`. It may list `techStacks`. Those tags stay inside that repo. + +A file under `stacks/` must say `"repo": null` and `"techStacks": [""]`. That is the only shared scope. Another repo's file is never opened for a lookup that did not name it. + +Each entry has `id`, `date` (`YYYY-MM-DD`), `trigger`, `wrong`, and `right`. `seen` and `refs` (`#123`) are optional. + +## Read + +`lookupExperience({ repo, techStack, query, root })` in `core/src/host/experience.js`: + +| Call | Result | +| --- | --- | +| `repo` only | That repo's files | +| `techStack` only | Shared files under `stacks//` | +| both | That repo's entries tagged with the stack, plus the shared stack entries | +| neither | No entries | + +`query` filters `id`, `trigger`, `wrong`, and `right` after the scope is fixed. Results sort by `date` descending, then `id` ascending. + +`harnessExperience` on the host harness is that read. `resolveHarness` does not attach lessons. `applied` stays false. + +The npm package does not ship `core/dev/`. A packaged install's default read returns no entries. + +## Write + +Add or edit a JSON file and commit it. + +```sh +node core/dev/experience/check.mjs +node core/dev/experience/check.mjs add --repo ThinkFlowLab/FlyBot --id \ + --date YYYY-MM-DD --trigger '...' --wrong '...' --right '...' +node core/dev/experience/check.mjs add --tech-stack node-host --id \ + --date YYYY-MM-DD --trigger '...' --wrong '...' --right '...' +``` + +`add` writes one new file and refuses to overwrite. It does not edit harness configs. `check` exits non-zero when a path and its fields disagree. diff --git a/patches/ds-bot/files/dev/experience/check.mjs b/patches/ds-bot/files/dev/experience/check.mjs new file mode 100644 index 00000000..954d1cac --- /dev/null +++ b/patches/ds-bot/files/dev/experience/check.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +// Commit-reviewed writer and checker for the experience library. +// Adding a lesson writes one JSON file under this directory. It does not edit +// harness tiers. Review the file in the pull request that adds it. +// +// node core/dev/experience/check.mjs [--root dir] +// node core/dev/experience/check.mjs [--root dir] add --id --date YYYY-MM-DD \ +// --trigger --wrong --right [--repo owner/name] [--tech-stack id] +// [--seen N] [--ref #N] +// +// --repo alone writes repos///.json. +// --tech-stack alone writes stacks//.json with repo null. +// Both write a repo entry tagged with that stack. +// Passing neither --repo nor --tech-stack is an error. + +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { dirname, resolve, sep } from 'node:path' +import { checkExperience, DEFAULT_EXPERIENCE_ROOT, prepareExperienceEntry } from '../../src/host/experience.js' + +const argv = process.argv.slice(2) +const positional = [] +const flags = new Map() +const refs = [] + +for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + if (!arg.startsWith('--')) { + positional.push(arg) + continue + } + const key = arg.slice(2) + const value = argv[index + 1] + if (value === undefined || value.startsWith('--')) { + console.error(`missing value for --${key}`) + process.exit(2) + } + index += 1 + if (key === 'ref') refs.push(value) + else if (flags.has(key)) { + console.error(`duplicate --${key}`) + process.exit(2) + } else flags.set(key, value) +} + +const root = flags.get('root') ?? DEFAULT_EXPERIENCE_ROOT +const command = positional[0] ?? 'check' +if (positional.length > 1 || (command !== 'check' && command !== 'add')) { + console.error('usage: check.mjs [--root dir] [add --id ... --date ... --trigger ... --wrong ... --right ...]') + process.exit(2) +} + +if (command === 'check') { + const report = checkExperience(root) + if (!report.ok) { + for (const line of report.failures) console.error(line) + process.exit(1) + } + console.log(`ok ${root}`) + process.exit(0) +} + +try { + const stack = flags.get('tech-stack') + const fields = { + id: flags.get('id'), + repo: flags.has('repo') ? flags.get('repo') : null, + techStacks: stack === undefined ? [] : [stack], + date: flags.get('date'), + trigger: flags.get('trigger'), + wrong: flags.get('wrong'), + right: flags.get('right'), + } + if (flags.has('seen')) { + if (!/^[1-9]\d*$/.test(flags.get('seen'))) throw new Error('seen') + fields.seen = Number(flags.get('seen')) + } + if (refs.length > 0) fields.refs = refs + const planned = prepareExperienceEntry(fields) + const base = resolve(root) + const file = resolve(base, planned.relativePath) + if (file !== base && !file.startsWith(base + sep)) throw new Error('experience path escapes root') + if (existsSync(file)) { + console.error(`exists ${file}`) + process.exit(1) + } + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, planned.body, { flag: 'wx' }) + console.log(file) +} catch (error) { + console.error(error.message) + process.exit(1) +} diff --git a/patches/ds-bot/files/dev/experience/repos/ThinkFlowLab/FlyBot/one-conclusion.json b/patches/ds-bot/files/dev/experience/repos/ThinkFlowLab/FlyBot/one-conclusion.json new file mode 100644 index 00000000..050e5a10 --- /dev/null +++ b/patches/ds-bot/files/dev/experience/repos/ThinkFlowLab/FlyBot/one-conclusion.json @@ -0,0 +1,9 @@ +{ + "id": "one-conclusion", + "repo": "ThinkFlowLab/FlyBot", + "techStacks": [], + "date": "2026-10-24", + "trigger": "A tip already carries a Verdict.", + "wrong": "Posting a second Verdict at the same SHA, or editing the first when the answer changed.", + "right": "One Verdict per tip. A new commit voids the old one. A correction carries no Verdict line." +} diff --git a/patches/ds-bot/files/dev/experience/repos/ThinkFlowLab/FlyBot/stale-base.json b/patches/ds-bot/files/dev/experience/repos/ThinkFlowLab/FlyBot/stale-base.json new file mode 100644 index 00000000..5bedbd78 --- /dev/null +++ b/patches/ds-bot/files/dev/experience/repos/ThinkFlowLab/FlyBot/stale-base.json @@ -0,0 +1,11 @@ +{ + "id": "stale-base", + "repo": "ThinkFlowLab/FlyBot", + "techStacks": [], + "date": "2026-10-24", + "seen": 4, + "trigger": "Branching from a local main that this run did not fetch, then opening a pull request.", + "wrong": "Reading a green pull request page as proof the branch is current. Ghost files in the diff are the branch restoring an old main.", + "right": "git diff origin/main...HEAD --stat must list only files you changed. Anything else means start again from the fetched tip.", + "refs": ["#864", "#876", "#887"] +} diff --git a/patches/ds-bot/files/dev/experience/stacks/node-host/generated-gates.json b/patches/ds-bot/files/dev/experience/stacks/node-host/generated-gates.json new file mode 100644 index 00000000..a0b42f4d --- /dev/null +++ b/patches/ds-bot/files/dev/experience/stacks/node-host/generated-gates.json @@ -0,0 +1,11 @@ +{ + "id": "generated-gates", + "repo": null, + "techStacks": ["node-host"], + "date": "2026-10-15", + "seen": 3, + "trigger": "An edit under core/ after the last patch generate, or before arch-docs runs.", + "wrong": "Trusting an earlier green check. arch-docs reports no changed files while the edit is still uncommitted.", + "right": "After the last core/ edit, build the client when src/client changed, regenerate ds-bot patches, then verify.", + "refs": ["#94", "#99", "#177"] +} diff --git a/patches/ds-bot/files/src/host/experience.js b/patches/ds-bot/files/src/host/experience.js new file mode 100644 index 00000000..08c7eefd --- /dev/null +++ b/patches/ds-bot/files/src/host/experience.js @@ -0,0 +1,325 @@ +// Experience library (#874 Step 2). Offline lessons keyed by repository and tech +// stack. Agents and HarnessPro read them. A lesson changes only when a file in +// core/dev/experience/ is committed. This module does not call a model and does +// not edit Lite, Medium, or Heavy. +// +// The published npm package does not ship core/dev/. When that directory is +// absent, a read with the default root returns no entries so loading the host +// does not depend on dev data. An explicit root that is missing throws. + +import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs' +import { dirname, join, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' + +export const DEFAULT_EXPERIENCE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../../dev/experience') + +const REPO_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/ +const STACK_ID = /^[a-z0-9][a-z0-9-]*$/ +const DATE = /^\d{4}-\d{2}-\d{2}$/ +const REF = /^#[1-9]\d{0,6}$/ +const TEXT_MAX = 600 +const ENTRY_KEYS = new Set(['id', 'repo', 'techStacks', 'date', 'seen', 'trigger', 'wrong', 'right', 'refs']) +const TOP_FILES = new Set(['README.md', 'check.mjs']) + +function isRepoSegment(value) { + return typeof value === 'string' && REPO_SEGMENT.test(value) +} + +function parseRepo(repo) { + if (typeof repo !== 'string') throw new Error(`bad repo: ${repo}`) + const parts = repo.split('/') + if (parts.length !== 2 || !isRepoSegment(parts[0]) || !isRepoSegment(parts[1])) throw new Error(`bad repo: ${repo}`) + return parts +} + +function parseStack(techStack) { + if (typeof techStack !== 'string' || !STACK_ID.test(techStack)) throw new Error(`bad techStack: ${techStack}`) + return techStack +} + +function scoped(root, ...parts) { + const base = resolve(root) + const full = resolve(base, ...parts) + if (full !== base && !full.startsWith(base + sep)) throw new Error(`experience path escapes root: ${full}`) + return full +} + +function validDate(value) { + if (typeof value !== 'string' || !DATE.test(value)) return false + const year = Number(value.slice(0, 4)) + const month = Number(value.slice(5, 7)) + const day = Number(value.slice(8, 10)) + const date = new Date(Date.UTC(year, month - 1, day)) + return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day +} + +function secretAt(value, path) { + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) { + const found = secretAt(value[index], `${path}[${index}]`) + if (found) return found + } + return '' + } + if (value !== null && typeof value === 'object') { + for (const [key, child] of Object.entries(value)) { + const here = path === '' ? key : `${path}.${key}` + if (/api[_-]?key/i.test(key) || /^(token|secret|password|authorization)$/i.test(key)) return here + const found = secretAt(child, here) + if (found) return found + } + return '' + } + if (typeof value === 'string' && (/\bsk-[A-Za-z0-9]{8,}\b/.test(value) || value.includes('DEEPSEEK_API_KEY') || value.includes('GITHUB_TOKEN'))) { + return path === '' ? 'value' : path + } + return '' +} + +function validateEntry(entry, expected) { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) throw new Error('entry') + for (const key of Object.keys(entry)) { + if (!ENTRY_KEYS.has(key)) throw new Error(`unknown field ${key}`) + } + if (typeof entry.id !== 'string' || !STACK_ID.test(entry.id) || entry.id !== expected.id) throw new Error('id') + if (expected.kind === 'repo') { + if (entry.repo !== expected.repo) throw new Error('repo') + } else if (entry.repo !== null) throw new Error('repo') + if (!Array.isArray(entry.techStacks)) throw new Error('techStacks') + for (const name of entry.techStacks) { + if (typeof name !== 'string' || !STACK_ID.test(name)) throw new Error('techStacks') + } + if (new Set(entry.techStacks).size !== entry.techStacks.length) throw new Error('techStacks') + if (expected.kind === 'stack' && (entry.techStacks.length !== 1 || entry.techStacks[0] !== expected.techStack)) { + throw new Error('techStacks') + } + if (!validDate(entry.date)) throw new Error('date') + if (entry.seen !== undefined && (!Number.isInteger(entry.seen) || entry.seen < 1)) throw new Error('seen') + for (const key of ['trigger', 'wrong', 'right']) { + const text = entry[key] + if (typeof text !== 'string' || text.trim() === '' || text.length > TEXT_MAX) throw new Error(key) + } + if (entry.refs !== undefined) { + if (!Array.isArray(entry.refs) || entry.refs.length > 20 || entry.refs.some(ref => typeof ref !== 'string' || !REF.test(ref))) { + throw new Error('refs') + } + } + const secret = secretAt(entry, '') + if (secret) throw new Error(`secret at ${secret}`) + const clean = { + id: entry.id, + repo: entry.repo, + techStacks: [...entry.techStacks], + date: entry.date, + } + if (entry.seen !== undefined) clean.seen = entry.seen + clean.trigger = entry.trigger + clean.wrong = entry.wrong + clean.right = entry.right + if (entry.refs !== undefined) clean.refs = [...entry.refs] + return clean +} + +function readValidated(file, expected) { + let raw + try { + raw = JSON.parse(readFileSync(file, 'utf8')) + } catch (error) { + throw new Error(`${file}: json: ${error.message}`) + } + try { + return validateEntry(raw, expected) + } catch (error) { + throw new Error(`${file}: ${error.message}`) + } +} + +function loadDir(dir, expectedFor) { + if (!existsSync(dir)) return [] + const stat = lstatSync(dir) + if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`${dir}: not a directory`) + const entries = [] + for (const dirent of readdirSync(dir, { withFileTypes: true })) { + const file = join(dir, dirent.name) + if (dirent.isSymbolicLink() || !dirent.isFile() || !dirent.name.endsWith('.json')) { + throw new Error(`${file}: unexpected file`) + } + const id = dirent.name.slice(0, -'.json'.length) + if (!STACK_ID.test(id)) throw new Error(`${file}: id`) + entries.push(readValidated(file, expectedFor(id))) + } + return entries +} + +function matches(entry, needle) { + return [entry.id, entry.trigger, entry.wrong, entry.right].some(text => text.toLowerCase().includes(needle)) +} + +function byDateThenId(a, b) { + if (a.date !== b.date) return a.date < b.date ? 1 : -1 + if (a.id !== b.id) return a.id < b.id ? -1 : 1 + return 0 +} + +export function lookupExperience({ repo, techStack, query, root } = {}) { + const explicitRoot = root !== undefined + const base = explicitRoot ? root : DEFAULT_EXPERIENCE_ROOT + if (typeof base !== 'string' || base.trim() === '') throw new Error('bad root') + const askedRepo = repo !== undefined && repo !== null + const askedStack = techStack !== undefined && techStack !== null + if (!existsSync(base)) { + if (!explicitRoot) return { entries: [] } + throw new Error(`experience root missing: ${base}`) + } + if (!askedRepo && !askedStack) return { entries: [] } + let owner + let name + let stack + if (askedRepo) [owner, name] = parseRepo(repo) + if (askedStack) stack = parseStack(techStack) + const entries = [] + if (askedRepo) { + const loaded = loadDir(scoped(base, 'repos', owner, name), id => ({ kind: 'repo', id, repo })) + entries.push(...(askedStack ? loaded.filter(item => item.techStacks.includes(stack)) : loaded)) + } + if (askedStack) { + entries.push(...loadDir(scoped(base, 'stacks', stack), id => ({ kind: 'stack', id, techStack: stack }))) + } + if (query !== undefined && query !== null && typeof query !== 'string') throw new Error('bad query') + const needle = typeof query === 'string' ? query.trim().toLowerCase() : '' + const filtered = needle === '' ? entries : entries.filter(item => matches(item, needle)) + filtered.sort(byDateThenId) + return { entries: filtered } +} + +function rejectDirent(dirent, label, { directory }) { + if (dirent.isSymbolicLink()) return `symlink ${label}` + if (directory && !dirent.isDirectory()) return `unexpected ${label}` + if (!directory && !dirent.isFile()) return `unexpected ${label}` + return '' +} + +function checkJsonDir(dir, expectedFor) { + const failures = [] + for (const dirent of readdirSync(dir, { withFileTypes: true })) { + const label = join(dir, dirent.name) + const problem = rejectDirent(dirent, label, { directory: false }) + if (problem || !dirent.name.endsWith('.json')) { + failures.push(problem || `unexpected ${label}`) + continue + } + const id = dirent.name.slice(0, -'.json'.length) + if (!STACK_ID.test(id)) { + failures.push(`${label}: id`) + continue + } + try { + readValidated(label, expectedFor(id)) + } catch (error) { + failures.push(error.message) + } + } + return failures +} + +function checkRepos(root) { + const failures = [] + const repos = scoped(root, 'repos') + for (const owner of readdirSync(repos, { withFileTypes: true })) { + const ownerLabel = `repos/${owner.name}` + const ownerProblem = rejectDirent(owner, ownerLabel, { directory: true }) + if (ownerProblem || !isRepoSegment(owner.name)) { + failures.push(ownerProblem || `unexpected ${ownerLabel}`) + continue + } + for (const name of readdirSync(join(repos, owner.name), { withFileTypes: true })) { + const nameLabel = `${ownerLabel}/${name.name}` + const nameProblem = rejectDirent(name, nameLabel, { directory: true }) + if (nameProblem || !isRepoSegment(name.name)) { + failures.push(nameProblem || `unexpected ${nameLabel}`) + continue + } + const repo = `${owner.name}/${name.name}` + failures.push(...checkJsonDir(join(repos, owner.name, name.name), id => ({ kind: 'repo', id, repo }))) + } + } + return failures +} + +function checkStacks(root) { + const failures = [] + const stacks = scoped(root, 'stacks') + for (const stack of readdirSync(stacks, { withFileTypes: true })) { + const label = `stacks/${stack.name}` + const problem = rejectDirent(stack, label, { directory: true }) + if (problem || !STACK_ID.test(stack.name)) { + failures.push(problem || `unexpected ${label}`) + continue + } + failures.push(...checkJsonDir(join(stacks, stack.name), id => ({ kind: 'stack', id, techStack: stack.name }))) + } + return failures +} + +export function checkExperience(root) { + if (typeof root !== 'string' || root.trim() === '') return { ok: false, failures: ['bad root'] } + if (!existsSync(root)) return { ok: false, failures: [`missing root ${root}`] } + let top + try { + top = readdirSync(root, { withFileTypes: true }) + } catch (error) { + return { ok: false, failures: [error.message] } + } + const failures = [] + for (const dirent of top) { + if (dirent.isSymbolicLink()) { + failures.push(`symlink ${dirent.name}`) + continue + } + if (TOP_FILES.has(dirent.name)) { + if (!dirent.isFile()) failures.push(`unexpected ${dirent.name}`) + continue + } + if (dirent.name === 'repos' || dirent.name === 'stacks') { + if (!dirent.isDirectory()) failures.push(`unexpected ${dirent.name}`) + continue + } + failures.push(`unexpected ${dirent.name}`) + } + if (top.some(dirent => dirent.name === 'repos' && dirent.isDirectory() && !dirent.isSymbolicLink())) { + failures.push(...checkRepos(root)) + } + if (top.some(dirent => dirent.name === 'stacks' && dirent.isDirectory() && !dirent.isSymbolicLink())) { + failures.push(...checkStacks(root)) + } + return { ok: failures.length === 0, failures } +} + +export function prepareExperienceEntry(fields) { + if (fields === null || typeof fields !== 'object' || Array.isArray(fields)) throw new Error('bad entry') + for (const key of Object.keys(fields)) { + if (!ENTRY_KEYS.has(key)) throw new Error(`unknown field ${key}`) + } + const repo = fields.repo === undefined ? null : fields.repo + const techStacks = fields.techStacks === undefined ? [] : fields.techStacks + const draft = { + id: fields.id, + repo, + techStacks, + date: fields.date, + } + if (fields.seen !== undefined) draft.seen = fields.seen + draft.trigger = fields.trigger + draft.wrong = fields.wrong + draft.right = fields.right + if (fields.refs !== undefined) draft.refs = fields.refs + if (typeof repo === 'string') parseRepo(repo) + const expected = repo === null + ? { kind: 'stack', id: fields.id, techStack: Array.isArray(techStacks) ? techStacks[0] : undefined } + : { kind: 'repo', id: fields.id, repo } + const entry = validateEntry(draft, expected) + const relativePath = entry.repo === null + ? join('stacks', entry.techStacks[0], `${entry.id}.json`) + : join('repos', ...parseRepo(entry.repo), `${entry.id}.json`) + return { relativePath, entry, body: `${JSON.stringify(entry, null, 2)}\n` } +} diff --git a/patches/ds-bot/files/src/host/harness.js b/patches/ds-bot/files/src/host/harness.js index b652a804..a895840e 100644 --- a/patches/ds-bot/files/src/host/harness.js +++ b/patches/ds-bot/files/src/host/harness.js @@ -13,10 +13,17 @@ // HarnessPro is the offline check for that config. It names CI, core/dev/eval, // and rsi-baseline. It does not run them and it does not call a model. // +// Step 2: harnessExperience reads the experience library (core/dev/experience/, +// keyed by repo and tech stack). Lessons change only by a committed file. This +// is not Option D: a tier is not rewritten while a task runs, and the library +// does not call a model. resolveHarness does not attach the entries. +// // Tree prompt exception: `@flybot/tree` keeps its own marked prompt under // `plugins/tree` (not one of the three mirrors). That injection stays outside // this file; eval `P3-tree-split` must keep passing. +import { lookupExperience } from './experience.js' + export const DEFAULT_HARNESS_ID = 'default' // Triage sets exactly one of these. The controller does not write them, and @@ -218,8 +225,13 @@ export function harnessTierOrder() { return { ok: failures.length === 0, failures, axes } } +export function harnessExperience(query = {}) { + return lookupExperience(query ?? {}) +} + export function install(rt) { rt.harness = resolveHarness(DEFAULT_HARNESS_ID) rt.resolveHarness = resolveHarness rt.routeHarness = routeHarness + rt.harnessExperience = harnessExperience } diff --git a/patches/ds-bot/files/test/experience.test.js b/patches/ds-bot/files/test/experience.test.js new file mode 100644 index 00000000..e9567f9f --- /dev/null +++ b/patches/ds-bot/files/test/experience.test.js @@ -0,0 +1,258 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { checkExperience, DEFAULT_EXPERIENCE_ROOT, lookupExperience, prepareExperienceEntry } from '../src/host/experience.js' +import { defaultHarness, harnessExperience, harnessPro, install, resolveHarness } from '../src/host/harness.js' + +const here = dirname(fileURLToPath(import.meta.url)) +const cli = join(here, '../dev/experience/check.mjs') + +function entry(fields) { + return { + techStacks: [], + seen: 1, + refs: ['#1'], + ...fields, + } +} + +function writeTree(root, files) { + mkdirSync(root, { recursive: true }) + writeFileSync(join(root, 'README.md'), '# fixture\n') + writeFileSync(join(root, 'check.mjs'), '// fixture\n') + for (const [rel, body] of Object.entries(files)) { + const file = join(root, rel) + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, typeof body === 'string' ? body : `${JSON.stringify(body, null, 2)}\n`) + } +} + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'flybot-experience-')) + writeTree(root, { + 'repos/Alpha/One/keep.json': entry({ + id: 'keep', + repo: 'Alpha/One', + techStacks: ['node-host'], + date: '2026-10-02', + trigger: 'keep the local lesson', + wrong: 'copy it into every repo', + right: 'leave it on Alpha/One', + }), + 'repos/Alpha/One/plain.json': entry({ + id: 'plain', + repo: 'Alpha/One', + date: '2026-10-01', + trigger: 'plain repo lesson', + wrong: 'tag it as shared', + right: 'keep the stack list empty', + }), + 'repos/Beta/Two/leak.json': entry({ + id: 'leak', + repo: 'Beta/Two', + techStacks: ['node-host'], + date: '2026-10-03', + trigger: 'leak across repos', + wrong: 'return it for Alpha', + right: 'read only the asked repo', + }), + 'stacks/node-host/shared.json': entry({ + id: 'shared', + repo: null, + techStacks: ['node-host'], + date: '2026-10-04', + trigger: 'shared stack lesson', + wrong: 'attach it to one repo', + right: 'store it under stacks with repo null', + }), + 'stacks/github-actions/actions.json': entry({ + id: 'actions', + repo: null, + techStacks: ['github-actions'], + date: '2026-10-05', + trigger: 'actions only', + wrong: 'show it for node-host', + right: 'read only the asked stack', + }), + }) + return root +} + +test('lookup reads only the asked repo and never another repo directory', () => { + const root = fixture() + try { + const repoOnly = lookupExperience({ repo: 'Alpha/One', root }) + assert.deepEqual(repoOnly.entries.map(item => item.id), ['keep', 'plain']) + assert.equal(repoOnly.entries.some(item => item.repo === 'Beta/Two'), false) + assert.equal(repoOnly.entries.some(item => item.id === 'shared'), false) + + const stackOnly = lookupExperience({ techStack: 'node-host', root }) + assert.deepEqual(stackOnly.entries.map(item => item.id), ['shared']) + assert.equal(stackOnly.entries[0].repo, null) + + const both = lookupExperience({ repo: 'Alpha/One', techStack: 'node-host', root }) + assert.deepEqual(both.entries.map(item => item.id), ['shared', 'keep']) + + const other = lookupExperience({ repo: 'Beta/Two', techStack: 'node-host', root }) + assert.deepEqual(other.entries.map(item => item.id), ['shared', 'leak']) + assert.equal(other.entries.some(item => item.repo === 'Alpha/One'), false) + + assert.deepEqual(lookupExperience({ root }).entries, []) + assert.deepEqual(lookupExperience({ repo: 'Alpha/One', query: 'leak', root }).entries, []) + assert.deepEqual(lookupExperience({ techStack: 'node-host', query: 'leak', root }).entries, []) + assert.deepEqual(lookupExperience({ repo: 'Beta/Two', query: 'LEAK', root }).entries.map(item => item.id), ['leak']) + assert.deepEqual(lookupExperience({ techStack: 'github-actions', root }).entries.map(item => item.id), ['actions']) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('a corrupt entry outside the asked scope is not read', () => { + const root = fixture() + try { + writeFileSync(join(root, 'repos/Beta/Two/leak.json'), '{ not json\n') + writeFileSync(join(root, 'stacks/github-actions/actions.json'), '{ not json\n') + const found = lookupExperience({ repo: 'Alpha/One', techStack: 'node-host', root }) + assert.deepEqual(found.entries.map(item => item.id), ['shared', 'keep']) + assert.throws(() => lookupExperience({ repo: 'Beta/Two', root }), /leak\.json/) + assert.throws(() => lookupExperience({ techStack: 'github-actions', root }), /actions\.json/) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('lookup rejects a scope that would leave the root', () => { + const root = fixture() + try { + assert.throws(() => lookupExperience({ repo: '../Beta/Two', root }), /bad repo/) + assert.throws(() => lookupExperience({ techStack: '../repos', root }), /bad techStack/) + assert.throws(() => lookupExperience({ repo: 'Alpha/One', root: join(root, 'missing') }), /root missing/) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('check fails closed when a file disagrees with its directory', () => { + const root = fixture() + try { + assert.equal(checkExperience(root).ok, true) + const leak = join(root, 'repos/Beta/Two/leak.json') + const body = JSON.parse(readFileSync(leak, 'utf8')) + body.repo = 'Alpha/One' + writeFileSync(leak, `${JSON.stringify(body, null, 2)}\n`) + const mismatched = checkExperience(root) + assert.equal(mismatched.ok, false) + assert.ok(mismatched.failures.some(line => line.includes('leak.json'))) + assert.throws(() => lookupExperience({ repo: 'Beta/Two', root }), /repo/) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('a shared stack file that names a repo fails the check', () => { + const root = fixture() + try { + const shared = join(root, 'stacks/node-host/shared.json') + const body = JSON.parse(readFileSync(shared, 'utf8')) + body.repo = 'Beta/Two' + writeFileSync(shared, `${JSON.stringify(body, null, 2)}\n`) + const report = checkExperience(root) + assert.equal(report.ok, false) + assert.ok(report.failures.some(line => line.includes('shared.json'))) + assert.throws(() => lookupExperience({ techStack: 'node-host', root }), /repo/) + assert.deepEqual(lookupExperience({ repo: 'Alpha/One', root }).entries.map(item => item.id), ['keep', 'plain']) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('the committed library isolates ThinkFlowLab/FlyBot from the shared node-host stack', () => { + const report = checkExperience(DEFAULT_EXPERIENCE_ROOT) + assert.equal(report.ok, true, report.failures.join('; ')) + const flybot = lookupExperience({ repo: 'ThinkFlowLab/FlyBot' }) + assert.deepEqual(flybot.entries.map(item => item.id), ['one-conclusion', 'stale-base']) + assert.equal(flybot.entries.every(item => item.repo === 'ThinkFlowLab/FlyBot'), true) + const stack = lookupExperience({ techStack: 'node-host' }) + assert.deepEqual(stack.entries.map(item => item.id), ['generated-gates']) + assert.equal(stack.entries[0].repo, null) + const other = lookupExperience({ repo: 'Acme/Other', techStack: 'node-host' }) + assert.deepEqual(other.entries.map(item => item.id), ['generated-gates']) + assert.deepEqual(lookupExperience({}).entries, []) + assert.deepEqual(lookupExperience({ repo: 'ThinkFlowLab/FlyBot', query: 'verdict' }).entries.map(item => item.id), ['one-conclusion']) +}) + +test('harnessExperience is a read hook and leaves tiers unchanged', () => { + const harness = defaultHarness() + assert.deepEqual(Object.keys(harness), ['id', 'prompt', 'tools', 'model', 'stepRetry', 'verifiers', 'exceptions']) + for (const id of ['lite', 'medium', 'heavy']) { + const tier = resolveHarness(id) + assert.equal(tier.applied, false, id) + assert.equal(harnessPro(tier).ok, true, id) + } + const rt = {} + install(rt) + assert.equal(rt.harness.id, 'default') + assert.equal(rt.harnessExperience, harnessExperience) + assert.deepEqual(rt.harnessExperience({}).entries, []) + assert.deepEqual(rt.harnessExperience({ repo: 'ThinkFlowLab/FlyBot' }).entries.map(item => item.id), ['one-conclusion', 'stale-base']) +}) + +test('the CLI writes one new entry file and refuses to overwrite it', () => { + const root = mkdtempSync(join(tmpdir(), 'flybot-experience-cli-')) + try { + writeFileSync(join(root, 'README.md'), '# fixture\n') + writeFileSync(join(root, 'check.mjs'), '// fixture\n') + const planned = prepareExperienceEntry({ + id: 'from-cli', + repo: 'Alpha/One', + techStacks: [], + date: '2026-10-24', + trigger: 'a file change', + wrong: 'edit the harness while the task runs', + right: 'commit the json file', + }) + assert.equal(planned.relativePath, join('repos', 'Alpha', 'One', 'from-cli.json')) + const added = spawnSync(process.execPath, [ + cli, '--root', root, 'add', + '--repo', 'Alpha/One', + '--id', 'from-cli', + '--date', '2026-10-24', + '--trigger', 'a file change', + '--wrong', 'edit the harness while the task runs', + '--right', 'commit the json file', + ], { encoding: 'utf8' }) + assert.equal(added.status, 0, added.stderr) + assert.deepEqual(lookupExperience({ repo: 'Alpha/One', root }).entries.map(item => item.id), ['from-cli']) + assert.equal(checkExperience(root).ok, true) + const shared = spawnSync(process.execPath, [ + cli, '--root', root, 'add', + '--tech-stack', 'node-host', + '--id', 'from-stack', + '--date', '2026-10-24', + '--trigger', 'a shared file', + '--wrong', 'copy it into every repo', + '--right', 'store it under stacks with repo null', + ], { encoding: 'utf8' }) + assert.equal(shared.status, 0, shared.stderr) + assert.deepEqual(lookupExperience({ techStack: 'node-host', root }).entries.map(item => item.id), ['from-stack']) + assert.equal(lookupExperience({ repo: 'Alpha/One', root }).entries.some(item => item.id === 'from-stack'), false) + const again = spawnSync(process.execPath, [ + cli, '--root', root, 'add', + '--repo', 'Alpha/One', + '--id', 'from-cli', + '--date', '2026-10-24', + '--trigger', 'a file change', + '--wrong', 'edit the harness while the task runs', + '--right', 'commit the json file', + ], { encoding: 'utf8' }) + assert.equal(again.status, 1) + const checked = spawnSync(process.execPath, [cli, '--root', root], { encoding: 'utf8' }) + assert.equal(checked.status, 0, checked.stderr) + } finally { + rmSync(root, { recursive: true, force: true }) + } +})