diff --git a/docs/architecture/ownership/cells/harness-eval.md b/docs/architecture/ownership/cells/harness-eval.md index 0b84c0fa33..12aed86050 100644 --- a/docs/architecture/ownership/cells/harness-eval.md +++ b/docs/architecture/ownership/cells/harness-eval.md @@ -37,6 +37,9 @@ code_anchors: - packages/api/src/infrastructure/harness-eval/harness-ledger-snapshot-provider.ts - packages/api/src/infrastructure/harness-eval/segment-judgment-engine.ts - packages/api/src/infrastructure/harness-eval/guard-threshold-escalation.ts + - packages/api/src/infrastructure/harness-eval/objective-registry.ts + - packages/api/src/routes/callback-docs-routes.ts + - packages/mcp-server/src/tools/list-objectives-tool.ts - packages/api/src/infrastructure/harness-eval/publish-verdict/harness-ledger-generator-adapter.ts - packages/api/src/domains/prompt-hooks/InjectionTraceStore.ts - packages/api/src/domains/prompt-hooks/SegmentJudgmentCache.ts @@ -52,15 +55,17 @@ doc_anchors: - docs/features/F245-friction-signal-eval.md - docs/features/F257-harness-ledger.md - docs/features/assets/F257/ + - docs/harness-feedback/objectives/registry.yaml - docs/harness-feedback/eval-domains/eval-harness-ledger.yaml - docs/harness-feedback/ - feature-discussions/2026-05-21-f192-phase-e-eval-hub-kickoff/README.md - sop-definitions/README.md -static_scan_hints: [harness-eval, VerdictHandoffPacket, eval-domain, reeval, harness-fit-digest, Eval Hub, SopDefinition, sop-definitions, predicate, friction, paw-feel, FrictionSignal, harness-ledger, GuardRejectionEvent, SegmentJudgment, segment-lifeline, denominatorKind] +static_scan_hints: [harness-eval, VerdictHandoffPacket, eval-domain, reeval, harness-fit-digest, Eval Hub, SopDefinition, sop-definitions, predicate, friction, paw-feel, FrictionSignal, harness-ledger, GuardRejectionEvent, SegmentJudgment, segment-lifeline, denominatorKind, ObjectiveRegistry, objective-registry, list_objectives, objectiveId] cited_by: - F192 Phase E-pilot - F245 Phase A (paw-feel friction collector) + Phase B (cancel/user-feedback/eval-domain adapters + aggregator + clusterer + rollup input; domain registration + rollup sink land in Phase C) - F257 Phase A (observation + snapshot) + Phase C (eval:harness-ledger + SegmentJudgment) + Phase D (segment lifeline + governance operations) + - F257 #3 (objective registry definition layer + list_objectives discovery; canonical objectiveId source for report_harness_signal) --- # Harness Eval Control Plane @@ -88,6 +93,7 @@ F192 owns the socio-technical harness evaluation contract: harnesses declare exp - Require dry-run evidence before disabling or redirecting legacy scheduled tasks. - Keep F257 observation and judgment joins on the frozen three-key window contract unless a later schema version explicitly upgrades correlation. - Add new ledger evidence sources through the existing snapshot/judgment path instead of creating a second eval control plane. +- F257 #3 objective registry (`objective-registry.ts` loader + `docs/harness-feedback/objectives/registry.yaml` + `list_objectives` MCP discovery) is the **canonical objective DEFINITION layer** of this same control plane — the read-only `id + statement` source `report_harness_signal`'s `objectiveId` and (V2) eval_models reference. It is NOT a parallel registry: unit→objective membership stays in the versioned `UnitEvaluationManifest` (redesign §4.8), and the loader fail-closes rather than emit an empty catalog, so one authority governs discovery. ## Do NOT Unify With diff --git a/docs/harness-feedback/objectives/registry.yaml b/docs/harness-feedback/objectives/registry.yaml new file mode 100644 index 0000000000..01a687ce90 --- /dev/null +++ b/docs/harness-feedback/objectives/registry.yaml @@ -0,0 +1,32 @@ +--- +# F257 #3 — Objective registry (definition layer). Read-only discovery source +# for report_harness_signal's `objectiveId`, so cats stop doing archaeology to +# find valid objectives (修复清单 #3: "三次上报三次考古"). Surfaced at call time +# by the `cat_cafe_list_objectives` MCP tool + report_harness_signal description +# sync. +# +# KD-3 (F257 spec): registry 定义层用 YAML;运行时 stats 拆到 Redis/eval,Console +# 读时 join. OQ-1: YAML registry + runtime stats split. This file is the canonical +# objective DEFINITION layer only (id / statement). Unit→objective membership +# (挂靠段) is NOT authored here — per frozen redesign §4.8 it belongs to the +# versioned UnitEvaluationManifest.objectives keyed by unitRef; a derived +# read-model can expose attachments in V2 (2a R1 P1-1: single authority, no dual +# writable source). eval_models (metrics/conditions) are likewise V2. +# +# SCOPE (slice 2a): objectives are enumerated as they are CANONIZED. The redesign +# (assets/F257/objective-driven-redesign-v1.md §2) names 8 objectives (OBJ-1..8) +# but only canonizes ids for the two that are in use / imminent. The other six +# (OBJ-2/4/5/6/7/8) are Chinese-name-only there; their canonical ids + eval_models +# land with redesign V2. Registering invented ids now would fabricate a contract +# V2 depends on — so this registry starts with the canonized set and grows as +# objectives are canonized. The discovery tool always reflects the current set. +registryVersion: 1 +objectives: + - id: obj-routing-delivery + # OBJ-1 (redesign §2). In use: V1 T-A RoutingDecisionFact / report_harness_signal. + statement: 球权经 @ 路由准确送达目标猫,不掉地、不假接 + - id: obj-identity-integrity + # OBJ-3 (redesign §2). Target objective for #4's signature-miss ledger closure + # (deferred to after PR #47 merge). Registered now so #4's closure has a + # canonical home instead of a free-string objectiveId. + statement: 签名/身份/能力边界始终正确 diff --git a/packages/api/src/infrastructure/harness-eval/objective-registry.ts b/packages/api/src/infrastructure/harness-eval/objective-registry.ts new file mode 100644 index 0000000000..d5223bdf85 --- /dev/null +++ b/packages/api/src/infrastructure/harness-eval/objective-registry.ts @@ -0,0 +1,130 @@ +/** + * F257 修复清单 #3 — Objective registry loader (definition layer). + * + * Reads `docs/harness-feedback/objectives/registry.yaml` — the read-only + * discovery source for `report_harness_signal`'s `objectiveId`, so cats stop + * doing archaeology to find valid objectives ("三次上报三次考古"). KD-3: + * registry 定义层用 YAML;运行时 stats 拆到 Redis/eval. + * + * This is the canonical objective DEFINITION layer only: `id + statement`. + * Unit→objective membership (which segments an objective is evaluated over) is + * NOT authored here — per the frozen redesign §4.8 it lives in the versioned + * `UnitEvaluationManifest.objectives` keyed by typed unitRef, and a derived + * read-model can expose attachments in V2. Keeping a second writable authority + * out of this file avoids the dual-source drift sol flagged (2a R1 P1-1). + * + * Fail-closed (2a R1 P1-2): a load/parse/validation failure is reported as an + * explicit error, NEVER collapsed to a valid-but-empty catalog — a silent empty + * would recreate the archaeology gap it exists to close. + */ + +import { readFile } from 'node:fs/promises'; +import { parse as parseYaml } from 'yaml'; + +export interface ObjectiveDefinition { + id: string; + statement: string; +} + +export interface ObjectiveRegistry { + registryVersion: number; + objectives: ObjectiveDefinition[]; +} + +/** Discriminated result: a valid registry, or an explicit failure reason. */ +export type ObjectiveRegistryResult = { ok: true; registry: ObjectiveRegistry } | { ok: false; error: string }; + +/** Canonical objective id shape: `obj-` + kebab-case (lowercase alnum groups). */ +const OBJECTIVE_ID_RE = /^obj-[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** The only registry schema version this loader implements (2a R3 P2-1). */ +const SUPPORTED_REGISTRY_VERSION = 1; + +function fail(error: string): ObjectiveRegistryResult { + return { ok: false, error }; +} + +/** Validate a single objective row → definition, or an error string. */ +function validateObjective(entry: unknown, index: number, seen: Set): ObjectiveDefinition | string { + if (!entry || typeof entry !== 'object') return `objectives[${index}] is not a mapping`; + // 2a R2 P2-1: fail-closed on unknown keys (registryVersion=1 allows only id/statement). + // Rejecting rather than stripping keeps the file the single authority — a stray + // `segments`/typo/未版本化 field can't silently reappear and mislead a human reader. + for (const key of Object.keys(entry)) { + if (key !== 'id' && key !== 'statement') { + const hint = + key === 'segments' + ? ' — unit→objective membership belongs to the versioned UnitEvaluationManifest (§4.8), not this definition registry' + : ' (registryVersion=1 allows only id/statement; bump the schema to add fields)'; + return `objectives[${index}] has unsupported key "${key}"${hint}`; + } + } + const o = entry as { id?: unknown; statement?: unknown }; + if (typeof o.id !== 'string' || o.id.trim() !== o.id || o.id.length === 0) { + return `objectives[${index}].id must be a trimmed non-empty string`; + } + if (!OBJECTIVE_ID_RE.test(o.id)) return `objectives[${index}].id "${o.id}" must match ${OBJECTIVE_ID_RE.source}`; + if (typeof o.statement !== 'string' || o.statement.trim().length === 0) { + return `objectives[${o.id}].statement must be a non-empty string`; + } + if (seen.has(o.id)) return `duplicate objective id "${o.id}"`; + seen.add(o.id); + return { id: o.id, statement: o.statement.trim() }; +} + +/** + * Parse + strictly validate the objective registry YAML. Pure (no I/O) so it is + * unit-testable. Returns an explicit failure (ok:false) on malformed YAML, a + * non-mapping root, a non-positive-integer version, a missing/non-array + * `objectives`, any invalid row, or a duplicate id — never a silent empty. + */ +export function parseObjectiveRegistry(rawYaml: string): ObjectiveRegistryResult { + let doc: unknown; + try { + doc = parseYaml(rawYaml); + } catch (err) { + return fail(`malformed registry YAML: ${err instanceof Error ? err.message : String(err)}`); + } + if (!doc || typeof doc !== 'object') return fail('registry root must be a mapping'); + // 2a R2 P2-1: fail-closed on unknown root keys (registryVersion=1 allows only + // registryVersion/objectives). Future fields must bump the schema, not slip through. + for (const key of Object.keys(doc)) { + if (key !== 'registryVersion' && key !== 'objectives') { + return fail(`unknown registry key "${key}" (registryVersion=1 allows only registryVersion/objectives)`); + } + } + + const record = doc as { registryVersion?: unknown; objectives?: unknown }; + const version = record.registryVersion; + // 2a R3 P2-1: this loader implements ONLY v1 semantics, so accept exactly v1. A future + // schema must ship a versioned parser + bump this — advertising an unimplemented version + // (2, 999, …) as supported to discovery clients is the inconsistency being closed. + if (version !== SUPPORTED_REGISTRY_VERSION) { + return fail(`registryVersion must be exactly ${SUPPORTED_REGISTRY_VERSION} (this loader implements only v1)`); + } + if (!Array.isArray(record.objectives)) return fail('registry `objectives` must be an array'); + + const objectives: ObjectiveDefinition[] = []; + const seen = new Set(); + for (let i = 0; i < record.objectives.length; i++) { + const result = validateObjective(record.objectives[i], i, seen); + if (typeof result === 'string') return fail(result); + objectives.push(result); + } + return { ok: true, registry: { registryVersion: version, objectives } }; +} + +/** + * Load + validate the objective registry from disk. Returns an explicit failure + * (ok:false) when the file is unreadable — the caller (route/tool) must surface + * it (503/error), not present it as an empty catalog. + */ +export async function loadObjectiveRegistry(registryPath: string): Promise { + let raw: string; + try { + raw = await readFile(registryPath, 'utf-8'); + } catch (err) { + return fail(`registry unreadable at ${registryPath}: ${err instanceof Error ? err.message : String(err)}`); + } + return parseObjectiveRegistry(raw); +} diff --git a/packages/api/src/routes/callback-docs-routes.ts b/packages/api/src/routes/callback-docs-routes.ts index 40233a9a65..269abd026d 100644 --- a/packages/api/src/routes/callback-docs-routes.ts +++ b/packages/api/src/routes/callback-docs-routes.ts @@ -12,6 +12,7 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { FastifyPluginAsync } from 'fastify'; import { RICH_BLOCK_RULES } from '../domains/cats/services/context/rich-block-rules.js'; +import { loadObjectiveRegistry } from '../infrastructure/harness-eval/objective-registry.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -27,17 +28,47 @@ function refsPath(fileName: string): string { return resolve(__dirname, '..', '..', '..', '..', 'cat-cafe-skills', 'refs', fileName); } +/** F257 #3: resolve the objective registry YAML (docs/harness-feedback/objectives/). */ +function objectiveRegistryPath(): string { + return resolve(__dirname, '..', '..', '..', '..', 'docs', 'harness-feedback', 'objectives', 'registry.yaml'); +} + +export interface CallbackDocsRoutesOptions { + /** Test seam: override the objective registry path (defaults to the shipped location). */ + objectiveRegistryPath?: string; +} + /** * Register documentation endpoints (fallback for Skills system). * No auth required — these return static reference text. */ -export const registerCallbackDocsRoutes: FastifyPluginAsync = async (app) => { +export const registerCallbackDocsRoutes: FastifyPluginAsync = async (app, opts) => { + const registryPath = opts.objectiveRegistryPath ?? objectiveRegistryPath(); // Rich block usage rules app.get('/api/callbacks/rich-block-rules', async (_request, reply) => { reply.header('cache-control', 'public, max-age=3600'); return { rules: RICH_BLOCK_RULES }; }); + // F257 #3: objective registry — read-only discovery for report_harness_signal + // objectiveId (so cats stop doing archaeology). Definition layer (id/statement). + // Fail-closed (2a R1 P1-2): an unreadable/malformed/invalid catalog returns 503, + // never a cacheable empty list that would masquerade as "no objectives". + app.get('/api/callbacks/objectives', async (request, reply) => { + const result = await loadObjectiveRegistry(registryPath); + if (!result.ok) { + // 2a R2 P2-1: this endpoint is UNAUTHENTICATED. The loader's reason contains the + // registry path + fs errno — log it server-side, but return a stable, path-free 503 + // so a caller (and the MCP tool that forwards response.text()) never learns the + // install path / layout. The MCP tool still only needs to recognize the 503. + request.log.error({ reason: result.error }, '[F257] objective registry unavailable'); + reply.code(503); + return { error: 'Objective registry unavailable' }; + } + reply.header('cache-control', 'public, max-age=3600'); + return { registryVersion: result.registry.registryVersion, objectives: result.registry.objectives }; + }); + // MCP callback instructions — reads refs file (SOT moved from skill to refs/) app.get('/api/callbacks/instructions', async (_request, reply) => { try { diff --git a/packages/api/test/callback-docs-route.test.js b/packages/api/test/callback-docs-route.test.js index 4ca45e17dc..58568acf4d 100644 --- a/packages/api/test/callback-docs-route.test.js +++ b/packages/api/test/callback-docs-route.test.js @@ -3,10 +3,10 @@ import { describe, test } from 'node:test'; import Fastify from 'fastify'; describe('Callback Docs Routes', () => { - async function createApp() { + async function createApp(opts = {}) { const { registerCallbackDocsRoutes } = await import('../dist/routes/callback-docs-routes.js'); const app = Fastify(); - await app.register(registerCallbackDocsRoutes); + await app.register(registerCallbackDocsRoutes, opts); await app.ready(); return app; } @@ -43,4 +43,44 @@ describe('Callback Docs Routes', () => { await app.close(); } }); + + // F257 #3: objective registry discovery route — serves the shipped registry.yaml + // so cat_cafe_list_objectives can surface valid objectiveIds (no archaeology). + test('GET /api/callbacks/objectives returns 200 with canonized objectives', async () => { + const app = await createApp(); + try { + const response = await app.inject({ method: 'GET', url: '/api/callbacks/objectives' }); + assert.equal(response.statusCode, 200); + const body = response.json(); + assert.ok(Array.isArray(body.objectives), 'response should have objectives array'); + const ids = body.objectives.map((o) => o.id); + assert.ok(ids.includes('obj-routing-delivery'), 'obj-routing-delivery served'); + assert.ok(ids.includes('obj-identity-integrity'), 'obj-identity-integrity served'); + for (const o of body.objectives) { + assert.ok(o.id && o.statement, 'each objective has id + statement'); + assert.equal('segments' in o, false, 'no segments authority in served objective'); + } + } finally { + await app.close(); + } + }); + + // 2a R1 P1-2: an unreadable/invalid registry must fail-closed (503), never a + // cacheable 200 empty list that masquerades as "no objectives". + // 2a R2 P2-1: the unauthenticated 503 must NOT leak the internal path / fs errno. + test('GET /api/callbacks/objectives returns a path-free 503 when registry unreadable', async () => { + const secretPath = '/private/secret-install/objectives-registry.yaml'; + const app = await createApp({ objectiveRegistryPath: secretPath }); + try { + const response = await app.inject({ method: 'GET', url: '/api/callbacks/objectives' }); + assert.equal(response.statusCode, 503); + const body = response.json(); + assert.match(body.error, /unavailable/i, 'surfaces an explicit unavailability error'); + assert.doesNotMatch(body.error, /secret-install/, 'must not leak the install path'); + assert.doesNotMatch(body.error, /ENOENT|errno|no such file/i, 'must not leak fs errno'); + assert.equal(response.headers['cache-control'], undefined, 'failure is not cached'); + } finally { + await app.close(); + } + }); }); diff --git a/packages/api/test/f257-objective-registry.test.js b/packages/api/test/f257-objective-registry.test.js new file mode 100644 index 0000000000..a3ebed6175 --- /dev/null +++ b/packages/api/test/f257-objective-registry.test.js @@ -0,0 +1,122 @@ +/** + * F257 修复清单 #3 — objective registry loader. + * + * 契约(2a R1 修订):parseObjectiveRegistry 返回 discriminated Result。 + * 合法 → {ok:true, registry:{registryVersion, objectives:[{id,statement}]}}。 + * malformed YAML / 非 mapping / 非正整数 version / objectives 非数组 / 任一非法行 + * (缺/空白 id·statement、id 不匹配 pattern、重复 id)→ {ok:false, error}(fail-closed, + * 绝不静默塌成空 catalog)。并验 shipped registry.yaml 含 canonized 目标且**无 segments**。 + */ + +import assert from 'node:assert/strict'; +import { dirname, resolve } from 'node:path'; +import { describe, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const { parseObjectiveRegistry, loadObjectiveRegistry } = await import( + '../dist/infrastructure/harness-eval/objective-registry.js' +); + +const testDir = dirname(fileURLToPath(import.meta.url)); +const shippedRegistryPath = resolve( + testDir, + '..', + '..', + '..', + 'docs', + 'harness-feedback', + 'objectives', + 'registry.yaml', +); + +describe('F257 #3 — parseObjectiveRegistry (valid)', () => { + test('parses valid registry (id/statement only — no segments authority)', () => { + const r = parseObjectiveRegistry('registryVersion: 1\nobjectives:\n - id: obj-x\n statement: does x\n'); + assert.equal(r.ok, true); + assert.equal(r.registry.registryVersion, 1); + assert.deepEqual(r.registry.objectives, [{ id: 'obj-x', statement: 'does x' }]); + // segments must NOT be carried through even if authored (single authority) + assert.equal('segments' in r.registry.objectives[0], false); + }); + + test('trims statement whitespace', () => { + const r = parseObjectiveRegistry('registryVersion: 1\nobjectives:\n - id: obj-y\n statement: " padded "\n'); + assert.equal(r.ok, true); + assert.equal(r.registry.objectives[0].statement, 'padded'); + assert.equal(r.registry.registryVersion, 1); + }); +}); + +describe('F257 #3 — parseObjectiveRegistry (fail-closed, no silent empty)', () => { + const cases = [ + ['malformed YAML', ': : [unclosed'], + ['non-mapping root', '- just\n- a\n- list\n'], + ['version -2.5 (sol repro)', 'registryVersion: -2.5\nobjectives: []\n'], + ['version 0', 'registryVersion: 0\nobjectives: []\n'], + ['version non-integer 1.5', 'registryVersion: 1.5\nobjectives: []\n'], + ['missing registryVersion', 'objectives: []\n'], + // 2a R3 P2-1: unsupported versions must fail closed (loader implements only v1). + ['unsupported version 2', 'registryVersion: 2\nobjectives: []\n'], + ['unsupported version 999', 'registryVersion: 999\nobjectives: []\n'], + ['objectives not an array', 'registryVersion: 1\nobjectives: nope\n'], + ['missing id', 'registryVersion: 1\nobjectives:\n - statement: no id\n'], + ['whitespace-only id (sol repro)', 'registryVersion: 1\nobjectives:\n - id: " "\n statement: x\n'], + ['whitespace-only statement (sol repro)', 'registryVersion: 1\nobjectives:\n - id: obj-x\n statement: " "\n'], + ['id not matching pattern', 'registryVersion: 1\nobjectives:\n - id: Routing_Delivery\n statement: x\n'], + [ + 'duplicate ids (sol repro)', + 'registryVersion: 1\nobjectives:\n - id: obj-x\n statement: a\n - id: obj-x\n statement: b\n', + ], + // 2a R2 P2-1: forbidden/unknown fields must REJECT (not silently strip), so a stray + // `segments` can't reappear and be mistaken for挂靠 authority. + [ + 'segments field (forbidden, not stripped)', + 'registryVersion: 1\nobjectives:\n - id: obj-x\n statement: x\n segments: [S1, D1]\n', + ], + ['unknown entry key', 'registryVersion: 1\nobjectives:\n - id: obj-x\n statement: x\n weight: 3\n'], + ['unknown root key', 'registryVersion: 1\nfoo: bar\nobjectives: []\n'], + ]; + for (const [name, yaml] of cases) { + test(`rejects: ${name}`, () => { + const r = parseObjectiveRegistry(yaml); + assert.equal(r.ok, false, `${name} must fail-closed`); + assert.equal(typeof r.error, 'string'); + assert.ok(r.error.length > 0, 'error reason is non-empty'); + }); + } + + test('segments rejection is descriptive (points to UnitEvaluationManifest authority)', () => { + const r = parseObjectiveRegistry( + 'registryVersion: 1\nobjectives:\n - id: obj-x\n statement: x\n segments: [S1]\n', + ); + assert.equal(r.ok, false); + assert.match(r.error, /segments/); + assert.match(r.error, /UnitEvaluationManifest/); + }); + + test('valid-but-empty objectives is honestly ok (not a failure)', () => { + const r = parseObjectiveRegistry('registryVersion: 1\nobjectives: []\n'); + assert.equal(r.ok, true); + assert.deepEqual(r.registry.objectives, []); + }); +}); + +describe('F257 #3 — loadObjectiveRegistry', () => { + test('nonexistent path → ok:false (fail-closed, distinguishable from empty)', async () => { + const r = await loadObjectiveRegistry('/no/such/registry.yaml'); + assert.equal(r.ok, false); + assert.match(r.error, /unreadable/); + }); + + test('shipped registry.yaml → ok, canonized objectives, no segments', async () => { + const r = await loadObjectiveRegistry(shippedRegistryPath); + assert.equal(r.ok, true, r.ok ? '' : r.error); + const ids = r.registry.objectives.map((o) => o.id); + assert.ok(ids.includes('obj-routing-delivery'), 'obj-routing-delivery registered'); + assert.ok(ids.includes('obj-identity-integrity'), 'obj-identity-integrity registered'); + for (const o of r.registry.objectives) { + assert.ok(o.id.length > 0 && o.statement.length > 0, `objective ${o.id} has id + statement`); + assert.equal('segments' in o, false, `objective ${o.id} carries no segments authority`); + } + }); +}); diff --git a/packages/mcp-server/src/server-toolsets.ts b/packages/mcp-server/src/server-toolsets.ts index 13a35d48f0..5b0c525fb8 100644 --- a/packages/mcp-server/src/server-toolsets.ts +++ b/packages/mcp-server/src/server-toolsets.ts @@ -18,6 +18,7 @@ import { hubActionTools, libraryLifecycleTools, limbTools, + listObjectivesTools, perspectiveTools, publishVerdictTools, recentTools, @@ -53,6 +54,7 @@ export const READONLY_ALLOWED_TOOLS = new Set([ 'cat_cafe_graph_resolve', // F188 Phase F AC-F1 'cat_cafe_list_recent', // F188 Phase F AC-F2 'cat_cafe_get_rich_block_rules', + 'cat_cafe_list_objectives', // F257 #3: objective registry discovery 'cat_cafe_read_file_slice', // Session chain (read-only API calls, no callback creds needed) 'cat_cafe_list_session_chain', @@ -201,6 +203,8 @@ const COLLAB_TOOL_SOURCES: readonly ToolDef[] = [ ...publishVerdictTools, // F192 Phase H AC-H4 ...reportHarnessSignalTools, // F257 V1 (T-C §3.6) ...richBlockRulesTools, + ...listObjectivesTools, // F257 #3: objective registry discovery + ...gameActionTools, ...scheduleTools, ...shellTools, @@ -337,6 +341,7 @@ export const EXPLICIT_TOOL_ANNOTATIONS: Record = { cat_cafe_list_external_runtime_sessions: A_READ_LOCAL, cat_cafe_read_external_runtime_session: A_READ_LOCAL, cat_cafe_get_rich_block_rules: A_READ_LOCAL, + cat_cafe_list_objectives: A_READ_LOCAL, // F257 #3: objective registry discovery cat_cafe_run_perspective: A_READ_LOCAL, // search_evidence hits remote/external knowledge stores → openWorld cat_cafe_search_evidence: A_READ_OPEN_WORLD, diff --git a/packages/mcp-server/src/tools/index.ts b/packages/mcp-server/src/tools/index.ts index e733a77b7b..56a1f2f6da 100644 --- a/packages/mcp-server/src/tools/index.ts +++ b/packages/mcp-server/src/tools/index.ts @@ -141,6 +141,12 @@ export { limbListToolsInputSchema, limbTools, } from './limb-tools.js'; +// F257 #3: cat_cafe_list_objectives (objective registry discovery) +export { + handleListObjectives, + listObjectivesInputSchema, + listObjectivesTools, +} from './list-objectives-tool.js'; export { handleRunPerspective, perspectiveTools, diff --git a/packages/mcp-server/src/tools/list-objectives-tool.ts b/packages/mcp-server/src/tools/list-objectives-tool.ts new file mode 100644 index 0000000000..568e4fd8bb --- /dev/null +++ b/packages/mcp-server/src/tools/list-objectives-tool.ts @@ -0,0 +1,57 @@ +/** + * F257 修复清单 #3 — List Objectives Tool + * MCP 工具: 只读发现 report_harness_signal 可用的 objectiveId,取代"三次上报三次考古"。 + */ + +import type { ToolResult } from './file-tools.js'; +import { errorResult, successResult } from './file-tools.js'; + +const API_URL = process.env.CAT_CAFE_API_URL ?? 'http://localhost:3004'; + +interface ObjectiveDefinition { + id: string; + statement: string; +} + +export async function handleListObjectives(): Promise { + const url = `${API_URL}/api/callbacks/objectives`; + + try { + const response = await fetch(url); + + if (!response.ok) { + const text = await response.text(); + return errorResult(`Failed to fetch objectives (${response.status}): ${text}`); + } + + const data = (await response.json()) as { objectives?: ObjectiveDefinition[] }; + const objectives = data.objectives ?? []; + if (objectives.length === 0) { + // API fail-closes (503) on unreadable/malformed/invalid registry — a 200 with + // an empty list is therefore a genuinely empty (but valid) catalog, not a + // masked failure (2a R1 P1-2). + return successResult('No objectives registered yet.'); + } + const lines = objectives.map((o) => `- ${o.id} — ${o.statement}`); + return successResult( + `Valid objectiveIds for cat_cafe_report_harness_signal (pick one; do not invent):\n${lines.join('\n')}`, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return errorResult(`List objectives request failed: ${message}`); + } +} + +export const listObjectivesInputSchema = {}; + +export const listObjectivesTools = [ + { + name: 'cat_cafe_list_objectives', + description: + 'F257: list the registered objectives (id + statement) that cat_cafe_report_harness_signal accepts as objectiveId. ' + + 'Call this BEFORE report_harness_signal to pick a valid objectiveId instead of guessing — no more archaeology. ' + + 'Read-only; the set grows as objectives are canonized.', + inputSchema: listObjectivesInputSchema, + handler: handleListObjectives, + }, +] as const; diff --git a/packages/mcp-server/src/tools/report-harness-signal-tool.ts b/packages/mcp-server/src/tools/report-harness-signal-tool.ts index dfdd9c3862..aa6f9393bf 100644 --- a/packages/mcp-server/src/tools/report-harness-signal-tool.ts +++ b/packages/mcp-server/src/tools/report-harness-signal-tool.ts @@ -22,7 +22,13 @@ const unitRefShape = z.object({ }); const attributionShape = z.object({ - objectiveId: z.string().min(1).describe('Objective this deviation counts against (e.g. obj-routing-delivery).'), + objectiveId: z + .string() + .min(1) + .describe( + 'Objective this deviation counts against. Call cat_cafe_list_objectives to discover valid ids ' + + '(e.g. obj-routing-delivery, obj-identity-integrity) — do not invent one.', + ), unitRefs: z.array(unitRefShape).min(1).describe('Units (e.g. prompt segments) this observation attributes to.'), weight: z .number() diff --git a/packages/mcp-server/test/list-objectives-tool.test.js b/packages/mcp-server/test/list-objectives-tool.test.js new file mode 100644 index 0000000000..ea875ad323 --- /dev/null +++ b/packages/mcp-server/test/list-objectives-tool.test.js @@ -0,0 +1,77 @@ +/** + * F257 #3 (2a R1 P2-1) — cat_cafe_list_objectives handler tests. + * + * Focused formatting + failure-path coverage. Mocks globalThis.fetch since the + * handler calls the API discovery route internally. Proves: success formatting + * (id — statement, no segments), honest empty, and that transport/HTTP failures + * surface as errorResult (isError) rather than a misleading empty catalog. + */ + +import assert from 'node:assert/strict'; +import { after, afterEach, before, describe, test } from 'node:test'; + +let handleListObjectives; +let originalFetch; + +before(async () => { + ({ handleListObjectives } = await import('../dist/tools/list-objectives-tool.js')); + originalFetch = globalThis.fetch; +}); + +after(() => { + globalThis.fetch = originalFetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe('F257 #3 — handleListObjectives', () => { + test('formats registered objectives (id — statement, no segments)', async () => { + globalThis.fetch = async () => ({ + ok: true, + json: async () => ({ + registryVersion: 1, + objectives: [ + { id: 'obj-routing-delivery', statement: '球权路由准确送达' }, + { id: 'obj-identity-integrity', statement: '签名/身份正确' }, + ], + }), + }); + const res = await handleListObjectives(); + assert.notEqual(res.isError, true); + const text = res.content[0].text; + assert.match(text, /obj-routing-delivery — 球权路由准确送达/); + assert.match(text, /obj-identity-integrity — 签名\/身份正确/); + assert.doesNotMatch(text, /segments/i, 'no segments authority leaks into output'); + assert.match(text, /do not invent/i, 'guides cats to pick, not invent'); + }); + + test('honest empty for a valid-but-empty catalog', async () => { + globalThis.fetch = async () => ({ ok: true, json: async () => ({ registryVersion: 1, objectives: [] }) }); + const res = await handleListObjectives(); + assert.notEqual(res.isError, true); + assert.match(res.content[0].text, /No objectives registered yet/); + }); + + test('HTTP failure (503 fail-closed) surfaces as errorResult, not empty success', async () => { + globalThis.fetch = async () => ({ + ok: false, + status: 503, + text: async () => 'Objective registry unavailable: registry unreadable', + }); + const res = await handleListObjectives(); + assert.equal(res.isError, true, 'failure must be an error result'); + assert.match(res.content[0].text, /503/); + assert.match(res.content[0].text, /unavailable/i); + }); + + test('network error surfaces as errorResult', async () => { + globalThis.fetch = async () => { + throw new Error('ECONNREFUSED'); + }; + const res = await handleListObjectives(); + assert.equal(res.isError, true); + assert.match(res.content[0].text, /ECONNREFUSED/); + }); +}); diff --git a/packages/mcp-server/test/tool-registration.test.js b/packages/mcp-server/test/tool-registration.test.js index 3dfe2896fd..98fe222dab 100644 --- a/packages/mcp-server/test/tool-registration.test.js +++ b/packages/mcp-server/test/tool-registration.test.js @@ -38,6 +38,9 @@ const EXPECTED_TOOLS = [ 'cat_cafe_list_events', 'cat_cafe_backfill_events', 'cat_cafe_get_rich_block_rules', + // F257 V1 (PR #42) + #3: harness-signal report + objective discovery + 'cat_cafe_report_harness_signal', + 'cat_cafe_list_objectives', 'cat_cafe_register_pr_tracking', 'cat_cafe_register_issue_tracking', 'cat_cafe_unregister_tracking', @@ -171,6 +174,9 @@ const EXPECTED_COLLAB_TOOLS = [ 'cat_cafe_list_events', 'cat_cafe_backfill_events', 'cat_cafe_get_rich_block_rules', + // F257 V1 (PR #42) + #3: harness-signal report + objective discovery + 'cat_cafe_report_harness_signal', + 'cat_cafe_list_objectives', 'cat_cafe_request_permission', 'cat_cafe_check_permission_status', 'cat_cafe_register_pr_tracking', @@ -550,6 +556,7 @@ const EXPECTED_READONLY_TOOLS = [ 'cat_cafe_list_recent', // F188 Phase F AC-F2 // cat_cafe_reflect removed in F193 Phase D AC-D1 'cat_cafe_get_rich_block_rules', + 'cat_cafe_list_objectives', // F257 #3: objective registry discovery (A_READ_LOCAL) 'cat_cafe_list_session_chain', 'cat_cafe_read_session_events', 'cat_cafe_read_session_digest',