diff --git a/codex-marketplace/plugins/neatcontext/skills/save/SKILL.md b/codex-marketplace/plugins/neatcontext/skills/save/SKILL.md index c9f2e45..f74f67d 100644 --- a/codex-marketplace/plugins/neatcontext/skills/save/SKILL.md +++ b/codex-marketplace/plugins/neatcontext/skills/save/SKILL.md @@ -69,6 +69,8 @@ For creation: "name": "Short specific name", "profile": "# Short specific name\n\n## Purpose\n...", "routingDescription": "Scope-only routing description", + "routingQuestions": ["why was checkout throwing 5xx last week", "..."], + "routingEntities": ["INC-1001", "checkout-api", "pgbouncer"], "knowledge": [ { "path": "session-summary.md", @@ -78,6 +80,8 @@ For creation: } ``` +`routingQuestions` holds 10 to 15 questions this context should answer, in the words a user would type rather than the words the profile uses. `routingEntities` holds the names that appear in this work and rarely elsewhere: services, repos, ticket ids, error strings, commands. Both are matched against and never shown, so prefer coverage over polish. On an update, omit both to leave the stored lists alone. + For an update, also include the exact `targetId` and `baseHash` printed by `save-target`. Every knowledge path must be a short relative `.md` path. For creation, run: diff --git a/plugins/claude-code/neatcontext/commands/save.md b/plugins/claude-code/neatcontext/commands/save.md index 8592e0f..4619b37 100644 --- a/plugins/claude-code/neatcontext/commands/save.md +++ b/plugins/claude-code/neatcontext/commands/save.md @@ -143,6 +143,19 @@ says only which future requests belong here, naming systems, repos, components, ticket prefixes, symptoms, and terminology someone would type. Do not put behavioral, tone, or answer-format instructions in this line. +Also derive two lists that are matched against and never shown. They are what +lets someone find this context when they have forgotten it exists. + +- `routingQuestions` — 10 to 15 questions this context should answer, written + the way the user would type them rather than the way the profile describes + them. Include the vague ones ("did we ever fix that timeout thing"). +- `routingEntities` — names that appear in this work and rarely anywhere else: + services, repos, ticket ids, error strings, commands, hosts, people. + +Nothing reads either list aloud, so prefer coverage over polish. On an update, +omit both fields to leave the stored lists untouched; supply them only when the +work has added vocabulary the context should now be found by. + ## Write the capture Write one valid JSON file, with no surrounding code fence, to: @@ -157,6 +170,8 @@ For a new context, use exactly this shape: "name": "Short specific name", "profile": "# Short specific name\n\n## Purpose\n...", "routingDescription": "One line describing only the matching scope", + "routingQuestions": ["why was checkout throwing 5xx last week", "..."], + "routingEntities": ["INC-1001", "checkout-api", "pgbouncer"], "knowledge": [ { "path": "session-summary.md", @@ -176,6 +191,8 @@ For an update, add the exact target values printed by `save-target`: "baseHash": "exact base hash", "profile": "# Exact existing context name\n\n## Purpose\n...", "routingDescription": "One line describing only the matching scope", + "routingQuestions": ["why was checkout throwing 5xx last week", "..."], + "routingEntities": ["INC-1001", "checkout-api", "pgbouncer"], "knowledge": [ { "path": "session-summary.md", diff --git a/plugins/copilot/neatcontext/commands/save.md b/plugins/copilot/neatcontext/commands/save.md index d0c65c6..8c5d847 100644 --- a/plugins/copilot/neatcontext/commands/save.md +++ b/plugins/copilot/neatcontext/commands/save.md @@ -114,6 +114,19 @@ says only which future requests belong here, naming systems, repos, components, ticket prefixes, symptoms, and terminology someone would type. Do not put behavioral, tone, or answer-format instructions in this line. +Also derive two lists that are matched against and never shown. They are what +lets someone find this context when they have forgotten it exists. + +- `routingQuestions` — 10 to 15 questions this context should answer, written + the way the user would type them rather than the way the profile describes + them. Include the vague ones ("did we ever fix that timeout thing"). +- `routingEntities` — names that appear in this work and rarely anywhere else: + services, repos, ticket ids, error strings, commands, hosts, people. + +Nothing reads either list aloud, so prefer coverage over polish. On an update, +omit both fields to leave the stored lists untouched; supply them only when the +work has added vocabulary the context should now be found by. + ## Write the capture Write one valid JSON file, with no surrounding code fence, to a uniquely named @@ -134,6 +147,8 @@ For a new context, use exactly this shape: "name": "Short specific name", "profile": "# Short specific name\n\n## Purpose\n...", "routingDescription": "One line describing only the matching scope", + "routingQuestions": ["why was checkout throwing 5xx last week", "..."], + "routingEntities": ["INC-1001", "checkout-api", "pgbouncer"], "knowledge": [ { "path": "session-summary.md", @@ -153,6 +168,8 @@ For an update, add the exact target values printed by `save-target`: "baseHash": "exact base hash", "profile": "# Exact existing context name\n\n## Purpose\n...", "routingDescription": "One line describing only the matching scope", + "routingQuestions": ["why was checkout throwing 5xx last week", "..."], + "routingEntities": ["INC-1001", "checkout-api", "pgbouncer"], "knowledge": [ { "path": "session-summary.md", diff --git a/plugins/kimi-code/neatcontext/skills/save/SKILL.md b/plugins/kimi-code/neatcontext/skills/save/SKILL.md index 566e4b6..a4f3a02 100644 --- a/plugins/kimi-code/neatcontext/skills/save/SKILL.md +++ b/plugins/kimi-code/neatcontext/skills/save/SKILL.md @@ -69,6 +69,8 @@ For creation: "name": "Short specific name", "profile": "# Short specific name\n\n## Purpose\n...", "routingDescription": "Scope-only routing description", + "routingQuestions": ["why was checkout throwing 5xx last week", "..."], + "routingEntities": ["INC-1001", "checkout-api", "pgbouncer"], "knowledge": [ { "path": "session-summary.md", @@ -78,6 +80,8 @@ For creation: } ``` +`routingQuestions` holds 10 to 15 questions this context should answer, in the words a user would type rather than the words the profile uses. `routingEntities` holds the names that appear in this work and rarely elsewhere: services, repos, ticket ids, error strings, commands. Both are matched against and never shown, so prefer coverage over polish. On an update, omit both to leave the stored lists alone. + For an update, also include the exact `targetId` and `baseHash` printed by `save-target`. Every knowledge path must be a short relative `.md` path. For creation, run: diff --git a/plugins/pi/neatcontext/extensions/neatcontext.js b/plugins/pi/neatcontext/extensions/neatcontext.js index 23faeee..6ec52a7 100644 --- a/plugins/pi/neatcontext/extensions/neatcontext.js +++ b/plugins/pi/neatcontext/extensions/neatcontext.js @@ -273,6 +273,22 @@ export default function (pi) { type: "string", description: "One line of scope, under 200 characters. Scope only, never behavior." }, + routingQuestions: { + type: "array", + items: { type: "string" }, + description: + "10-15 questions this context should catch, in the words a user would type " + + "rather than the words the profile uses. Matched against, never shown. Omit on " + + "an update to leave the stored list alone." + }, + routingEntities: { + type: "array", + items: { type: "string" }, + description: + "Names that appear in this work and rarely elsewhere: services, repos, ticket " + + "ids, error strings, commands, hosts. Matched against, never shown. Omit on an " + + "update to leave the stored list alone." + }, knowledge: { type: "array", description: diff --git a/plugins/pi/neatcontext/skills/save/SKILL.md b/plugins/pi/neatcontext/skills/save/SKILL.md index d4b9eec..c7259d9 100644 --- a/plugins/pi/neatcontext/skills/save/SKILL.md +++ b/plugins/pi/neatcontext/skills/save/SKILL.md @@ -74,10 +74,23 @@ symptoms, ticket prefixes, and terminology someone would actually type. Never include behavioral or formatting instructions: that line is read while *other* contexts are connected. +Also derive two lists that are matched against and never shown. They are what +lets someone find this context when they have forgotten it exists. + +- `routingQuestions` — 10 to 15 questions this context should answer, written + the way the user would type them rather than the way the profile describes + them. Include the vague ones ("did we ever fix that timeout thing"). +- `routingEntities` — names that appear in this work and rarely anywhere else: + services, repos, ticket ids, error strings, commands, hosts, people. + +Nothing reads either list aloud, so prefer coverage over polish. On an update, +omit both fields to leave the stored lists untouched; supply them only when the +work has added vocabulary the context should now be found by. + ## Apply For a new context, call `neatcontext_save` with `name`, `profile`, -`routingDescription`, and `knowledge`. It is created immediately. +`routingDescription`, `routingQuestions`, `routingEntities`, and `knowledge`. It is created immediately. For an update, call it with `targetId`, `baseHash`, `profile`, `routingDescription`, and `knowledge`. That returns a preview and changes diff --git a/plugins/pi/neatcontext/src/pi/runtime.mjs b/plugins/pi/neatcontext/src/pi/runtime.mjs index 657e6ec..0d0bdd3 100644 --- a/plugins/pi/neatcontext/src/pi/runtime.mjs +++ b/plugins/pi/neatcontext/src/pi/runtime.mjs @@ -1019,7 +1019,17 @@ function renderUpdatePreview(preview) { // hosts write JSON to disk and shell out to a CLI because their plugin runs in a // different process, and this one does not. export async function saveContext(args = {}) { - const { name, targetId, baseHash, profile, routingDescription, knowledge, confirm } = args; + const { + name, + targetId, + baseHash, + profile, + routingDescription, + routingQuestions, + routingEntities, + knowledge, + confirm + } = args; if (typeof profile !== "string" || profile.trim().length === 0) { return saveTarget(typeof name === "string" ? name : ""); @@ -1032,6 +1042,10 @@ export async function saveContext(args = {}) { routingDescription: typeof routingDescription === "string" ? routingDescription : "", knowledge: Array.isArray(knowledge) ? knowledge : [] }; + // Left off the capture entirely when the caller said nothing, so an update + // leaves whatever is stored alone rather than clearing it. + if (routingQuestions !== undefined) capture.routingQuestions = routingQuestions; + if (routingEntities !== undefined) capture.routingEntities = routingEntities; try { if (typeof targetId === "string" && targetId.length > 0) { diff --git a/plugins/pi/neatcontext/tests/pi-runtime.test.mjs b/plugins/pi/neatcontext/tests/pi-runtime.test.mjs index a13b773..48d7389 100644 --- a/plugins/pi/neatcontext/tests/pi-runtime.test.mjs +++ b/plugins/pi/neatcontext/tests/pi-runtime.test.mjs @@ -15,6 +15,7 @@ let home; let docs; let runtime; let session; +let store; before(async () => { home = await mkdtemp(path.join(os.tmpdir(), "neatcontext-pi-test-")); @@ -28,6 +29,7 @@ before(async () => { // inside the temporary home. runtime = await import("../src/pi/runtime.mjs"); session = await import("../src/pi/session.mjs"); + store = await import("../src/core/context-store.mjs"); }); after(async () => { @@ -538,3 +540,48 @@ describe("narrowing the menu to the request", () => { ); }); }); + +describe("saving the matching material", () => { + const BASE = { + name: "Pool limits June", + profile: "# Pool limits June\n\n## Purpose\n\nThe June regression.\n", + routingDescription: "billing-postgres default_pool_size regression after the June deploy", + knowledge: [{ path: "session-summary.md", content: "# Summary\n\nPool exhaustion.\n" }] + }; + + it("stores the questions and entities a save supplies", async () => { + await runtime.saveContext({ + ...BASE, + routingQuestions: ["why was checkout throwing 5xx last week"], + routingEntities: ["INC-1001", "checkout-api"] + }); + const stored = (await store.listContexts()).find((entry) => entry.name === "Pool limits June"); + assert.deepEqual(stored.routingQuestions, ["why was checkout throwing 5xx last week"]); + assert.deepEqual(stored.routingEntities, ["INC-1001", "checkout-api"]); + }); + + it("leaves stored lists alone when a save says nothing about them", async () => { + // pi may save from a turn that never generated them; that must not wipe + // what an earlier save wrote. + await runtime.saveContext({ + ...BASE, + routingQuestions: ["why was checkout throwing 5xx last week"], + routingEntities: ["INC-1001"] + }); + const before = (await store.listContexts()).find((entry) => entry.name === "Pool limits June"); + + await runtime.saveContext({ + name: before.name, + targetId: before.id, + baseHash: await store.fingerprintContext(before), + profile: "# Pool limits June\n\n## Purpose\n\nThe June regression, revisited.\n", + routingDescription: BASE.routingDescription, + knowledge: BASE.knowledge, + confirm: true + }); + + const after = (await store.listContexts()).find((entry) => entry.name === "Pool limits June"); + assert.deepEqual(after.routingQuestions, before.routingQuestions); + assert.deepEqual(after.routingEntities, before.routingEntities); + }); +}); diff --git a/tests/routing-expansion-docs.test.mjs b/tests/routing-expansion-docs.test.mjs new file mode 100644 index 0000000..c1055c0 --- /dev/null +++ b/tests/routing-expansion-docs.test.mjs @@ -0,0 +1,64 @@ +// Every host has to ask for the matching material, not just the one somebody +// happened to edit. +// +// The storage for `routingQuestions` and `routingEntities` is shared core, so it +// arrived everywhere at once. The instruction that fills them is not: each host +// carries its own save document, and a context saved from a host whose document +// forgot to ask is stored with nothing to match against and is quietly harder +// to find than the same work saved from another host. +// +// This is the same shape of bug the command sweep in plugin-commands.test.mjs +// exists for: one host's omission hiding in four other hosts' correctness. + +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "node:test"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +// The document each host's save flow actually reads. +const SAVE_DOCUMENTS = { + "claude-code": "plugins/claude-code/neatcontext/commands/save.md", + copilot: "plugins/copilot/neatcontext/commands/save.md", + "kimi-code": "plugins/kimi-code/neatcontext/skills/save/SKILL.md", + codex: "codex-marketplace/plugins/neatcontext/skills/save/SKILL.md", + pi: "plugins/pi/neatcontext/skills/save/SKILL.md" +}; + +describe("every host asks for the matching material", () => { + for (const [host, file] of Object.entries(SAVE_DOCUMENTS)) { + it(`${host} asks for both lists`, async () => { + const text = await readFile(path.join(root, file), "utf8"); + assert.ok(text.includes("routingQuestions"), `${file} never mentions routingQuestions`); + assert.ok(text.includes("routingEntities"), `${file} never mentions routingEntities`); + }); + + it(`${host} says the lists are never shown`, async () => { + // Without this the model writes them as prose for a reader, which is a + // different and much worse list than one written to be matched against. + const text = await readFile(path.join(root, file), "utf8"); + assert.match(text, /never shown|matched against and never shown|Matched against, never shown/i); + }); + + it(`${host} says omitting them leaves stored lists alone`, async () => { + // The rule that stops a host from wiping what another host wrote. + const text = await readFile(path.join(root, file), "utf8"); + assert.match(text, /omit both|Omit on an update|leave the stored list/i); + }); + } +}); + +describe("the pi save tool", () => { + it("accepts both lists as arrays", async () => { + // pi is the one host that takes a save as tool arguments rather than a + // capture file, so its schema has to carry the fields itself. + const text = await readFile( + path.join(root, "plugins", "pi", "neatcontext", "extensions", "neatcontext.js"), + "utf8" + ); + assert.match(text, /routingQuestions: \{\s*type: "array"/); + assert.match(text, /routingEntities: \{\s*type: "array"/); + }); +});