From e4cf6014bc89e8472ecb0c9dd42f245fbc820c0b Mon Sep 17 00:00:00 2001 From: "Cat8zfu14fb-Opus-4.8" Date: Tue, 21 Jul 2026 14:23:22 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(F257):=20#3=20objective=20registry=20?= =?UTF-8?q?=E2=80=94=20=E5=8F=AA=E8=AF=BB=E5=8F=91=E7=8E=B0=EF=BC=88?= =?UTF-8?q?=E5=8F=96=E4=BB=A3=20report=5Fharness=5Fsignal=20objectiveId=20?= =?UTF-8?q?=E8=80=83=E5=8F=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复清单 #3:report_harness_signal 的 objectiveId 是自由 string,零 list/schema/注入清单 → 三次上报三次考古。本 PR 建 objective registry 只读发现层: - registry YAML(docs/harness-feedback/objectives/registry.yaml,KD-3 registry-as-data)= 定义层(id/statement/segments)。**只登 canonized 目标**——redesign §2 名义 8 objectives 但只 canonize 了 2 个 id(obj-routing-delivery + obj-identity-integrity);不 invent 另 7 个 id(会伪造 V2 依赖的契约),registry 随 canonize 增长。 - loader(objective-registry.ts):parseObjectiveRegistry 纯函数(malformed 行丢弃不抛、 malformed YAML → 空)+ loadObjectiveRegistry fail-safe。 - GET /api/callbacks/objectives(镜像 rich-block-rules 只读路由)。 - cat_cafe_list_objectives MCP tool(镜像 rich-block-rules-tool;A_READ_LOCAL + readonly allowlist 注册)。 - report_harness_signal objectiveId description 同步指向 list_objectives。 边界:只做定义层 + 发现,不做 V2(evaluator/metrics/condition registry)。objectiveId 仍 自由(discovery-only,软校验留 V2)。 证据:9 tests(parseObjectiveRegistry 4 + loadObjectiveRegistry 2 + 路由 e2e + shipped registry 验证)全绿;api + mcp-server build clean;biome clean。切片 2a,独立 #47。 [宪宪/claude-opus-4-8🐾] Co-Authored-By: Claude Opus 4.8 --- .../harness-feedback/objectives/registry.yaml | 32 ++++++++ .../harness-eval/objective-registry.ts | 66 ++++++++++++++++ .../api/src/routes/callback-docs-routes.ts | 14 ++++ packages/api/test/callback-docs-route.test.js | 20 +++++ .../api/test/f257-objective-registry.test.js | 76 +++++++++++++++++++ packages/mcp-server/src/server-toolsets.ts | 5 ++ packages/mcp-server/src/tools/index.ts | 6 ++ .../src/tools/list-objectives-tool.ts | 57 ++++++++++++++ .../src/tools/report-harness-signal-tool.ts | 8 +- 9 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 docs/harness-feedback/objectives/registry.yaml create mode 100644 packages/api/src/infrastructure/harness-eval/objective-registry.ts create mode 100644 packages/api/test/f257-objective-registry.test.js create mode 100644 packages/mcp-server/src/tools/list-objectives-tool.ts diff --git a/docs/harness-feedback/objectives/registry.yaml b/docs/harness-feedback/objectives/registry.yaml new file mode 100644 index 0000000000..37ae3661af --- /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 +# definition layer only (id / statement / segments) — NOT eval_models +# (metrics/conditions), which are the objective-driven redesign V2 (condition +# registry + evaluator), layered on top by objectiveId. +# +# 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: 球权经 @ 路由准确送达目标猫,不掉地、不假接 + segments: [L3, D21, D8, D9, D13, D5, D4, R1, R2] + - 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: 签名/身份/能力边界始终正确 + segments: [S1, D1, S2, S3] 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..f7db50f31b --- /dev/null +++ b/packages/api/src/infrastructure/harness-eval/objective-registry.ts @@ -0,0 +1,66 @@ +/** + * 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 DEFINITION + * layer only (id / statement / segments) — eval_models (metrics/conditions) are + * the objective-driven redesign V2, layered on top by objectiveId. + */ + +import { readFile } from 'node:fs/promises'; +import { parse as parseYaml } from 'yaml'; + +export interface ObjectiveDefinition { + id: string; + statement: string; + /** Segment ids this objective is evaluated over (redesign §2 挂靠段). */ + segments: string[]; +} + +export interface ObjectiveRegistry { + registryVersion: number; + objectives: ObjectiveDefinition[]; +} + +const EMPTY_REGISTRY: ObjectiveRegistry = { registryVersion: 1, objectives: [] }; + +/** + * Parse + shape-validate the objective registry YAML. Pure (no I/O) so it is + * unit-testable. Malformed entries (missing id/statement) are dropped, never + * throwing — a bad row must not take down discovery for the good rows. + */ +export function parseObjectiveRegistry(rawYaml: string): ObjectiveRegistry { + let doc: unknown; + try { + doc = parseYaml(rawYaml); + } catch { + return EMPTY_REGISTRY; + } + if (!doc || typeof doc !== 'object') return EMPTY_REGISTRY; + const record = doc as { registryVersion?: unknown; objectives?: unknown }; + const registryVersion = typeof record.registryVersion === 'number' ? record.registryVersion : 1; + const rawObjectives = Array.isArray(record.objectives) ? record.objectives : []; + const objectives: ObjectiveDefinition[] = []; + for (const entry of rawObjectives) { + if (!entry || typeof entry !== 'object') continue; + const o = entry as { id?: unknown; statement?: unknown; segments?: unknown }; + if (typeof o.id !== 'string' || o.id.length === 0) continue; + if (typeof o.statement !== 'string' || o.statement.length === 0) continue; + const segments = Array.isArray(o.segments) ? o.segments.filter((s): s is string => typeof s === 'string') : []; + objectives.push({ id: o.id, statement: o.statement, segments }); + } + return { registryVersion, objectives }; +} + +/** Load the objective registry from disk. Returns the empty registry if unreadable. */ +export async function loadObjectiveRegistry(registryPath: string): Promise { + let raw: string; + try { + raw = await readFile(registryPath, 'utf-8'); + } catch { + return EMPTY_REGISTRY; + } + 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..fd606801a4 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,6 +28,11 @@ 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'); +} + /** * Register documentation endpoints (fallback for Skills system). * No auth required — these return static reference text. @@ -38,6 +44,14 @@ export const registerCallbackDocsRoutes: FastifyPluginAsync = async (app) => { 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/segments). + app.get('/api/callbacks/objectives', async (_request, reply) => { + const registry = await loadObjectiveRegistry(objectiveRegistryPath()); + reply.header('cache-control', 'public, max-age=3600'); + return { registryVersion: registry.registryVersion, objectives: 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..f07c0c7ace 100644 --- a/packages/api/test/callback-docs-route.test.js +++ b/packages/api/test/callback-docs-route.test.js @@ -43,4 +43,24 @@ 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'); + } + } 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..c99a01f125 --- /dev/null +++ b/packages/api/test/f257-objective-registry.test.js @@ -0,0 +1,76 @@ +/** + * F257 修复清单 #3 — objective registry loader. + * + * 契约:parseObjectiveRegistry 从 YAML 解析 objective 定义(id/statement/segments), + * 丢弃 malformed 行(缺 id/statement)而不抛,malformed YAML → 空 registry。 + * 并验 shipped registry.yaml 含 canonized 目标(obj-routing-delivery / + * obj-identity-integrity),供 report_harness_signal 只读发现(取代考古)。 + */ + +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)); +// packages/api/test → up 3 → worktree root → docs/harness-feedback/objectives/registry.yaml +const shippedRegistryPath = resolve( + testDir, + '..', + '..', + '..', + 'docs', + 'harness-feedback', + 'objectives', + 'registry.yaml', +); + +describe('F257 #3 — parseObjectiveRegistry', () => { + test('parses valid registry (id/statement/segments)', () => { + const r = parseObjectiveRegistry( + 'registryVersion: 1\nobjectives:\n - id: obj-x\n statement: does x\n segments: [S1, D1]\n', + ); + assert.equal(r.registryVersion, 1); + assert.equal(r.objectives.length, 1); + assert.deepEqual(r.objectives[0], { id: 'obj-x', statement: 'does x', segments: ['S1', 'D1'] }); + }); + + test('drops malformed entries (missing id or statement), keeps good ones', () => { + const r = parseObjectiveRegistry( + 'objectives:\n - id: obj-good\n statement: ok\n - statement: no-id\n - id: no-statement\n', + ); + assert.equal(r.objectives.length, 1); + assert.equal(r.objectives[0].id, 'obj-good'); + assert.deepEqual(r.objectives[0].segments, []); + }); + + test('missing objectives array → empty list, default version 1', () => { + assert.deepEqual(parseObjectiveRegistry('registryVersion: 2\n'), { registryVersion: 2, objectives: [] }); + }); + + test('malformed YAML → empty registry (no throw)', () => { + assert.deepEqual(parseObjectiveRegistry(': : [unclosed'), { registryVersion: 1, objectives: [] }); + }); +}); + +describe('F257 #3 — loadObjectiveRegistry', () => { + test('nonexistent path → empty registry (fail-safe, no throw)', async () => { + assert.deepEqual(await loadObjectiveRegistry('/no/such/registry.yaml'), { registryVersion: 1, objectives: [] }); + }); + + test('shipped registry.yaml exposes the canonized objectives', async () => { + const reg = await loadObjectiveRegistry(shippedRegistryPath); + const ids = reg.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'); + // every entry is well-formed + for (const o of reg.objectives) { + assert.ok(o.id.length > 0 && o.statement.length > 0, `objective ${o.id} has id + statement`); + assert.ok(Array.isArray(o.segments), `objective ${o.id} has segments array`); + } + }); +}); 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..0c86e9f056 --- /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; + segments: 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) { + return successResult('No objectives registered yet.'); + } + const lines = objectives.map( + (o) => `- ${o.id} — ${o.statement}${o.segments.length > 0 ? ` [segments: ${o.segments.join(', ')}]` : ''}`, + ); + 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() From 83b2c3b2e54651356862b6609f62d88e10ff116d Mon Sep 17 00:00:00 2001 From: "Cat8zfu14fb-Opus-4.8" Date: Tue, 21 Jul 2026 15:21:36 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(F257):=20#3=20(2a)=20R1=20=E2=80=94=20s?= =?UTF-8?q?ingle=20authority=20+=20fail-closed=20+=20contract=20tests=20+?= =?UTF-8?q?=20map=20delta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses sol's slice-2a R1 REQUEST CHANGES (all four, no pushback). ## P1-1 — remove `segments` (second writable authority) The registry authored `id/statement/segments`, duplicating the unit→objective membership that frozen redesign §4.8 keeps in the versioned UnitEvaluationManifest. Dropped `segments` from YAML + loader type + API output + MCP tool output; the registry is now the canonical `id + statement` definition layer only. Attachments become a V2 derived read-model from UnitEvaluationManifest — one authority. ## P1-2 — fail-closed loader (no silent empty), strict validation, lower complexity parseObjectiveRegistry/loadObjectiveRegistry now return a discriminated ObjectiveRegistryResult ({ok:true,registry} | {ok:false,error}). Unreadable file / malformed YAML / non-mapping root / non-positive-integer version / non-array objectives / any invalid row / duplicate id → explicit failure, never {version:1,objectives:[]}. Validates positive-integer version, trimmed non-empty id matching ^obj-[a-z0-9]+(?:-[a-z0-9]+)*$, trimmed non-empty statement, unique ids (sol repros now rejected: version -2.5, whitespace id/statement, dup ids). Route returns 503 (uncached) on failure; MCP tool surfaces it via its existing !ok error path. Validation extracted into helpers → below the Biome complexity-17 warning. Route gains an optional injectable registry path (test seam). ## P2-1 — MCP contract tests restored to true green Added cat_cafe_list_objectives to EXPECTED_TOOLS / EXPECTED_COLLAB_TOOLS / EXPECTED_READONLY_TOOLS, and cat_cafe_report_harness_signal to TOOLS/COLLAB (the PR #42 leftover that made the suite baseline-red 385/387) — not masking the new mismatch behind the old failure name. Added focused handler tests (success / honest empty / 503 HTTP failure / network error). Full mcp-server suite 391/391. ## P2-2 — architecture map delta harness-eval cell: added objective-registry.ts / callback-docs-routes.ts / list-objectives-tool.ts code anchors + registry.yaml doc anchor + scan hints + why this is the definition layer of the SAME control plane, not a parallel registry. ## Evidence - api: f257-objective-registry 18/0, callback-docs-route 4/0 (200 + 503 fail-closed). - mcp-server full suite 391/391 (was 385/387). - biome: changed files clean (useLiteralKeys + parser complexity + format resolved). Co-Authored-By: Claude Opus 4.8 --- .../ownership/cells/harness-eval.md | 8 +- .../harness-feedback/objectives/registry.yaml | 12 +-- .../harness-eval/objective-registry.ts | 93 ++++++++++++------ .../api/src/routes/callback-docs-routes.ts | 20 +++- packages/api/test/callback-docs-route.test.js | 20 +++- .../api/test/f257-objective-registry.test.js | 94 +++++++++++++------ .../src/tools/list-objectives-tool.ts | 10 +- .../test/list-objectives-tool.test.js | 77 +++++++++++++++ .../mcp-server/test/tool-registration.test.js | 7 ++ 9 files changed, 266 insertions(+), 75 deletions(-) create mode 100644 packages/mcp-server/test/list-objectives-tool.test.js 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 index 37ae3661af..01a687ce90 100644 --- a/docs/harness-feedback/objectives/registry.yaml +++ b/docs/harness-feedback/objectives/registry.yaml @@ -6,10 +6,12 @@ # sync. # # KD-3 (F257 spec): registry 定义层用 YAML;运行时 stats 拆到 Redis/eval,Console -# 读时 join. OQ-1: YAML registry + runtime stats split. This file is the -# definition layer only (id / statement / segments) — NOT eval_models -# (metrics/conditions), which are the objective-driven redesign V2 (condition -# registry + evaluator), layered on top by objectiveId. +# 读时 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) @@ -23,10 +25,8 @@ objectives: - id: obj-routing-delivery # OBJ-1 (redesign §2). In use: V1 T-A RoutingDecisionFact / report_harness_signal. statement: 球权经 @ 路由准确送达目标猫,不掉地、不假接 - segments: [L3, D21, D8, D9, D13, D5, D4, R1, R2] - 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: 签名/身份/能力边界始终正确 - segments: [S1, D1, S2, S3] diff --git a/packages/api/src/infrastructure/harness-eval/objective-registry.ts b/packages/api/src/infrastructure/harness-eval/objective-registry.ts index f7db50f31b..e9f45231ed 100644 --- a/packages/api/src/infrastructure/harness-eval/objective-registry.ts +++ b/packages/api/src/infrastructure/harness-eval/objective-registry.ts @@ -4,9 +4,18 @@ * 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 DEFINITION - * layer only (id / statement / segments) — eval_models (metrics/conditions) are - * the objective-driven redesign V2, layered on top by objectiveId. + * 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'; @@ -15,8 +24,6 @@ import { parse as parseYaml } from 'yaml'; export interface ObjectiveDefinition { id: string; statement: string; - /** Segment ids this objective is evaluated over (redesign §2 挂靠段). */ - segments: string[]; } export interface ObjectiveRegistry { @@ -24,43 +31,75 @@ export interface ObjectiveRegistry { objectives: ObjectiveDefinition[]; } -const EMPTY_REGISTRY: ObjectiveRegistry = { registryVersion: 1, objectives: [] }; +/** 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]+)*$/; + +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`; + 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 + shape-validate the objective registry YAML. Pure (no I/O) so it is - * unit-testable. Malformed entries (missing id/statement) are dropped, never - * throwing — a bad row must not take down discovery for the good rows. + * 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): ObjectiveRegistry { +export function parseObjectiveRegistry(rawYaml: string): ObjectiveRegistryResult { let doc: unknown; try { doc = parseYaml(rawYaml); - } catch { - return EMPTY_REGISTRY; + } catch (err) { + return fail(`malformed registry YAML: ${err instanceof Error ? err.message : String(err)}`); } - if (!doc || typeof doc !== 'object') return EMPTY_REGISTRY; + if (!doc || typeof doc !== 'object') return fail('registry root must be a mapping'); + const record = doc as { registryVersion?: unknown; objectives?: unknown }; - const registryVersion = typeof record.registryVersion === 'number' ? record.registryVersion : 1; - const rawObjectives = Array.isArray(record.objectives) ? record.objectives : []; + const version = record.registryVersion; + if (typeof version !== 'number' || !Number.isInteger(version) || version <= 0) { + return fail('registryVersion must be a positive integer'); + } + if (!Array.isArray(record.objectives)) return fail('registry `objectives` must be an array'); + const objectives: ObjectiveDefinition[] = []; - for (const entry of rawObjectives) { - if (!entry || typeof entry !== 'object') continue; - const o = entry as { id?: unknown; statement?: unknown; segments?: unknown }; - if (typeof o.id !== 'string' || o.id.length === 0) continue; - if (typeof o.statement !== 'string' || o.statement.length === 0) continue; - const segments = Array.isArray(o.segments) ? o.segments.filter((s): s is string => typeof s === 'string') : []; - objectives.push({ id: o.id, statement: o.statement, segments }); + 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 { registryVersion, objectives }; + return { ok: true, registry: { registryVersion: version, objectives } }; } -/** Load the objective registry from disk. Returns the empty registry if unreadable. */ -export async function loadObjectiveRegistry(registryPath: string): Promise { +/** + * 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 { - return EMPTY_REGISTRY; + } 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 fd606801a4..763b8a45c2 100644 --- a/packages/api/src/routes/callback-docs-routes.ts +++ b/packages/api/src/routes/callback-docs-routes.ts @@ -33,11 +33,17 @@ 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'); @@ -45,11 +51,17 @@ export const registerCallbackDocsRoutes: FastifyPluginAsync = async (app) => { }); // F257 #3: objective registry — read-only discovery for report_harness_signal - // objectiveId (so cats stop doing archaeology). Definition layer (id/statement/segments). + // 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 registry = await loadObjectiveRegistry(objectiveRegistryPath()); + const result = await loadObjectiveRegistry(registryPath); + if (!result.ok) { + reply.code(503); + return { error: `Objective registry unavailable: ${result.error}` }; + } reply.header('cache-control', 'public, max-age=3600'); - return { registryVersion: registry.registryVersion, objectives: registry.objectives }; + return { registryVersion: result.registry.registryVersion, objectives: result.registry.objectives }; }); // MCP callback instructions — reads refs file (SOT moved from skill to refs/) diff --git a/packages/api/test/callback-docs-route.test.js b/packages/api/test/callback-docs-route.test.js index f07c0c7ace..4f3b06509f 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; } @@ -58,9 +58,25 @@ describe('Callback Docs Routes', () => { 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". + test('GET /api/callbacks/objectives returns 503 when registry unreadable', async () => { + const app = await createApp({ objectiveRegistryPath: '/no/such/objectives-registry.yaml' }); + 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.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 index c99a01f125..3fb87a504f 100644 --- a/packages/api/test/f257-objective-registry.test.js +++ b/packages/api/test/f257-objective-registry.test.js @@ -1,10 +1,11 @@ /** * F257 修复清单 #3 — objective registry loader. * - * 契约:parseObjectiveRegistry 从 YAML 解析 objective 定义(id/statement/segments), - * 丢弃 malformed 行(缺 id/statement)而不抛,malformed YAML → 空 registry。 - * 并验 shipped registry.yaml 含 canonized 目标(obj-routing-delivery / - * obj-identity-integrity),供 report_harness_signal 只读发现(取代考古)。 + * 契约(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'; @@ -17,7 +18,6 @@ const { parseObjectiveRegistry, loadObjectiveRegistry } = await import( ); const testDir = dirname(fileURLToPath(import.meta.url)); -// packages/api/test → up 3 → worktree root → docs/harness-feedback/objectives/registry.yaml const shippedRegistryPath = resolve( testDir, '..', @@ -29,48 +29,82 @@ const shippedRegistryPath = resolve( 'registry.yaml', ); -describe('F257 #3 — parseObjectiveRegistry', () => { - test('parses valid registry (id/statement/segments)', () => { - const r = parseObjectiveRegistry( - 'registryVersion: 1\nobjectives:\n - id: obj-x\n statement: does x\n segments: [S1, D1]\n', - ); - assert.equal(r.registryVersion, 1); - assert.equal(r.objectives.length, 1); - assert.deepEqual(r.objectives[0], { id: 'obj-x', statement: 'does x', segments: ['S1', 'D1'] }); +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('drops malformed entries (missing id or statement), keeps good ones', () => { + test('ignores authored segments key (does not resurface it)', () => { const r = parseObjectiveRegistry( - 'objectives:\n - id: obj-good\n statement: ok\n - statement: no-id\n - id: no-statement\n', + 'registryVersion: 1\nobjectives:\n - id: obj-x\n statement: x\n segments: [S1, D1]\n', ); - assert.equal(r.objectives.length, 1); - assert.equal(r.objectives[0].id, 'obj-good'); - assert.deepEqual(r.objectives[0].segments, []); + assert.equal(r.ok, true); + assert.deepEqual(r.registry.objectives[0], { id: 'obj-x', statement: 'x' }); }); - test('missing objectives array → empty list, default version 1', () => { - assert.deepEqual(parseObjectiveRegistry('registryVersion: 2\n'), { registryVersion: 2, objectives: [] }); + test('trims statement whitespace', () => { + const r = parseObjectiveRegistry('registryVersion: 2\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, 2); }); +}); + +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'], + ['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', + ], + ]; + 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('malformed YAML → empty registry (no throw)', () => { - assert.deepEqual(parseObjectiveRegistry(': : [unclosed'), { registryVersion: 1, objectives: [] }); + 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 → empty registry (fail-safe, no throw)', async () => { - assert.deepEqual(await loadObjectiveRegistry('/no/such/registry.yaml'), { registryVersion: 1, objectives: [] }); + 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 exposes the canonized objectives', async () => { - const reg = await loadObjectiveRegistry(shippedRegistryPath); - const ids = reg.objectives.map((o) => o.id); + 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'); - // every entry is well-formed - for (const o of reg.objectives) { + for (const o of r.registry.objectives) { assert.ok(o.id.length > 0 && o.statement.length > 0, `objective ${o.id} has id + statement`); - assert.ok(Array.isArray(o.segments), `objective ${o.id} has segments array`); + assert.equal('segments' in o, false, `objective ${o.id} carries no segments authority`); } }); }); diff --git a/packages/mcp-server/src/tools/list-objectives-tool.ts b/packages/mcp-server/src/tools/list-objectives-tool.ts index 0c86e9f056..568e4fd8bb 100644 --- a/packages/mcp-server/src/tools/list-objectives-tool.ts +++ b/packages/mcp-server/src/tools/list-objectives-tool.ts @@ -6,12 +6,11 @@ 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'; +const API_URL = process.env.CAT_CAFE_API_URL ?? 'http://localhost:3004'; interface ObjectiveDefinition { id: string; statement: string; - segments: string[]; } export async function handleListObjectives(): Promise { @@ -28,11 +27,12 @@ export async function handleListObjectives(): Promise { 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}${o.segments.length > 0 ? ` [segments: ${o.segments.join(', ')}]` : ''}`, - ); + 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')}`, ); 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', From f471bcf56adf388a1318b6aec8f1560b9fb89d7a Mon Sep 17 00:00:00 2001 From: "Cat8zfu14fb-Opus-4.8" Date: Tue, 21 Jul 2026 16:02:43 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(F257):=20#3=20(2a)=20R2=20=E2=80=94=20p?= =?UTF-8?q?ath-free=20503=20+=20strict=20key-set=20(reject=20unknown,=20no?= =?UTF-8?q?t=20strip)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses sol's 2a R2 REQUEST CHANGES (both small, no scope change). R1's four findings were confirmed CLOSED. ## P2-1 — unauthenticated 503 leaked internal path / fs errno callback-docs-routes.ts /api/callbacks/objectives is unauthenticated but returned the loader's raw error, which contains the registry path + Node fs errno (e.g. "...registry unreadable at /private/secret/install/objectives.yaml: ENOENT..."), forwarded onward via the MCP tool's response.text(). Now: log the full internal reason server-side (request.log.error) and return a stable, path-free 503 body `{ error: 'Objective registry unavailable' }`. Test asserts the body contains neither the passed path nor ENOENT/errno, and is uncached; the MCP tool still recognizes 503 as an errorResult. ## P2-2 — "strict + single authority" schema was still fail-open on forbidden fields The loader read only id/statement and silently dropped everything else — so a stray `segments` (or a typo / future unversioned field) could reappear in the YAML, mislead a human into treating it as membership authority, while the runtime ignored it. Worse, the R1 test asserted that strip-behavior as "valid". Now registryVersion=1 fail-closes on the key set: root allows only registryVersion/objectives; each entry allows only id/statement. A `segments` key REJECTS (message points to the UnitEvaluationManifest §4.8 authority), not strips; any unknown key rejects. Future fields must bump the schema. Removed the fail-open "ignores segments" test; added red→green rejections for segments, unknown entry key, and unknown root key. ## Evidence - api: f257-objective-registry 21/0 (now includes segments/unknown-key rejections + descriptive-message assertion), callback-docs-route 4/0 (path-free 503 asserted). - shipped registry.yaml still validates (id/statement only). biome changed files clean. Co-Authored-By: Claude Opus 4.8 --- .../harness-eval/objective-registry.ts | 19 ++++++++++++++ .../api/src/routes/callback-docs-routes.ts | 9 +++++-- packages/api/test/callback-docs-route.test.js | 8 ++++-- .../api/test/f257-objective-registry.test.js | 25 +++++++++++++------ 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/packages/api/src/infrastructure/harness-eval/objective-registry.ts b/packages/api/src/infrastructure/harness-eval/objective-registry.ts index e9f45231ed..8dbaf69e8c 100644 --- a/packages/api/src/infrastructure/harness-eval/objective-registry.ts +++ b/packages/api/src/infrastructure/harness-eval/objective-registry.ts @@ -44,6 +44,18 @@ function fail(error: string): ObjectiveRegistryResult { /** 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`; @@ -71,6 +83,13 @@ export function parseObjectiveRegistry(rawYaml: string): ObjectiveRegistryResult 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; diff --git a/packages/api/src/routes/callback-docs-routes.ts b/packages/api/src/routes/callback-docs-routes.ts index 763b8a45c2..269abd026d 100644 --- a/packages/api/src/routes/callback-docs-routes.ts +++ b/packages/api/src/routes/callback-docs-routes.ts @@ -54,11 +54,16 @@ export const registerCallbackDocsRoutes: FastifyPluginAsync { + 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: ${result.error}` }; + return { error: 'Objective registry unavailable' }; } reply.header('cache-control', 'public, max-age=3600'); return { registryVersion: result.registry.registryVersion, objectives: result.registry.objectives }; diff --git a/packages/api/test/callback-docs-route.test.js b/packages/api/test/callback-docs-route.test.js index 4f3b06509f..58568acf4d 100644 --- a/packages/api/test/callback-docs-route.test.js +++ b/packages/api/test/callback-docs-route.test.js @@ -67,13 +67,17 @@ describe('Callback Docs Routes', () => { // 2a R1 P1-2: an unreadable/invalid registry must fail-closed (503), never a // cacheable 200 empty list that masquerades as "no objectives". - test('GET /api/callbacks/objectives returns 503 when registry unreadable', async () => { - const app = await createApp({ objectiveRegistryPath: '/no/such/objectives-registry.yaml' }); + // 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 index 3fb87a504f..d2fc5080bf 100644 --- a/packages/api/test/f257-objective-registry.test.js +++ b/packages/api/test/f257-objective-registry.test.js @@ -39,14 +39,6 @@ describe('F257 #3 — parseObjectiveRegistry (valid)', () => { assert.equal('segments' in r.registry.objectives[0], false); }); - test('ignores authored segments key (does not resurface it)', () => { - const r = parseObjectiveRegistry( - 'registryVersion: 1\nobjectives:\n - id: obj-x\n statement: x\n segments: [S1, D1]\n', - ); - assert.equal(r.ok, true); - assert.deepEqual(r.registry.objectives[0], { id: 'obj-x', statement: 'x' }); - }); - test('trims statement whitespace', () => { const r = parseObjectiveRegistry('registryVersion: 2\nobjectives:\n - id: obj-y\n statement: " padded "\n'); assert.equal(r.ok, true); @@ -72,6 +64,14 @@ describe('F257 #3 — parseObjectiveRegistry (fail-closed, no silent empty)', () '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}`, () => { @@ -82,6 +82,15 @@ describe('F257 #3 — parseObjectiveRegistry (fail-closed, no silent 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); From 0a357cfea74671e9affae9dcaf77bc269437341d Mon Sep 17 00:00:00 2001 From: "Cat8zfu14fb-Opus-4.8" Date: Tue, 21 Jul 2026 16:08:06 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(F257):=20#3=20(2a)=20R3=20=E2=80=94=20p?= =?UTF-8?q?in=20registryVersion=20to=20exactly=201=20(fail=20closed=20on?= =?UTF-8?q?=20unsupported)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses sol's 2a R3 sole finding (R2's two remain CLOSED). ## P2-1 — unsupported schema versions were accepted under v1 semantics The key-set contract advertised "registryVersion=1 allows only … / future fields must bump schema", but parseObjectiveRegistry accepted EVERY positive integer and applied the v1 parser (repro: v1/v2/v999 all ok:true). That advertises an unimplemented version back to discovery clients as supported. Now the version gate requires exactly SUPPORTED_REGISTRY_ VERSION (=1); anything else fails closed with a clear message. A future v2 must ship an explicit versioned parser + bump the constant, not ride the positive-integer gate. Tests: changed the version-2 "valid" fixture to 1; added v2 + v999 rejection cases (the existing -2.5 / 0 / 1.5 / missing cases still reject as ≠1). ## Evidence - f257-objective-registry 23/0. biome changed files clean; build clean. Co-Authored-By: Claude Opus 4.8 --- .../infrastructure/harness-eval/objective-registry.ts | 10 ++++++++-- packages/api/test/f257-objective-registry.test.js | 7 +++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/api/src/infrastructure/harness-eval/objective-registry.ts b/packages/api/src/infrastructure/harness-eval/objective-registry.ts index 8dbaf69e8c..d5223bdf85 100644 --- a/packages/api/src/infrastructure/harness-eval/objective-registry.ts +++ b/packages/api/src/infrastructure/harness-eval/objective-registry.ts @@ -37,6 +37,9 @@ export type ObjectiveRegistryResult = { ok: true; registry: ObjectiveRegistry } /** 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 }; } @@ -93,8 +96,11 @@ export function parseObjectiveRegistry(rawYaml: string): ObjectiveRegistryResult const record = doc as { registryVersion?: unknown; objectives?: unknown }; const version = record.registryVersion; - if (typeof version !== 'number' || !Number.isInteger(version) || version <= 0) { - return fail('registryVersion must be a positive integer'); + // 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'); diff --git a/packages/api/test/f257-objective-registry.test.js b/packages/api/test/f257-objective-registry.test.js index d2fc5080bf..a3ebed6175 100644 --- a/packages/api/test/f257-objective-registry.test.js +++ b/packages/api/test/f257-objective-registry.test.js @@ -40,10 +40,10 @@ describe('F257 #3 — parseObjectiveRegistry (valid)', () => { }); test('trims statement whitespace', () => { - const r = parseObjectiveRegistry('registryVersion: 2\nobjectives:\n - id: obj-y\n statement: " padded "\n'); + 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, 2); + assert.equal(r.registry.registryVersion, 1); }); }); @@ -55,6 +55,9 @@ describe('F257 #3 — parseObjectiveRegistry (fail-closed, no silent empty)', () ['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'],