diff --git a/agent/tools/write_card.ts b/agent/tools/write_card.ts index a47dcf62..577f40dd 100644 --- a/agent/tools/write_card.ts +++ b/agent/tools/write_card.ts @@ -235,7 +235,8 @@ export default defineTool({ .describe( "ТОЛЬКО для SUPERSEDE: одна строка о прежней истине, переносимая в ## History, " + "в формате 'YYYY-MM-DD: факт' (своя дата сохраняется; без неё ставится сегодняшняя). " + - "На ADD отбрасывается как шум, на UPDATE/NOOP — ошибка.", + "На ADD отбрасывается как шум, на UPDATE/NOOP непустое значение — ошибка " + + "(пробельная пустота после trim() равна отсутствию поля).", ), confidence: z .enum(["EXTRACTED", "INFERRED", "AMBIGUOUS"]) @@ -299,8 +300,14 @@ export default defineTool({ const file = id.file; const rel = relative(VAULT(), file).split(sep).join("/"); + // Пробельная пустота history_entry (value.trim() === "") ничего не вытесняет и не + // подделывает History: для UPDATE и NOOP она равна отсутствующему полю — так же, как + // SUPERSEDE читает его через trim(). Модели, заполняющие все поля схемы, шлют "" и + // без этого зацикливаются на одном отказе. + const historyEntry = history_entry?.trim() ? history_entry : undefined; + if (operation === "NOOP") { - if (replace_body || history_entry !== undefined) { + if (replace_body || historyEntry !== undefined) { return { ok: false, error: "NOOP не принимает replace_body или history_entry.", @@ -344,8 +351,8 @@ export default defineTool({ existing, }); // history_entry несёт вытесненную истину, которой у ADD ещё нет: там он шум и молча - // отбрасывается (с записью в журнал), а у UPDATE — попытка подделать архив. - if (history_entry !== undefined && effectiveOperation === "UPDATE") { + // отбрасывается (с записью в журнал), а у UPDATE — попытка подделать History. + if (historyEntry !== undefined && effectiveOperation === "UPDATE") { return { ok: false, error: "history_entry допустим только для SUPERSEDE.", @@ -403,7 +410,9 @@ export default defineTool({ replaceBody: replace_body === true, // Сырая operation: по её отсутствию mergeCard узнаёт легаси-путь replace_body. operation, - historyEntry: history_entry, + // ADD получает сырое поле: пустую строку он отбрасывает сам и фиксирует это в журнале. + historyEntry: + effectiveOperation === "ADD" ? history_entry : historyEntry, }); if (action !== "noop") atomicWrite(file, content); if (ignoredHistoryEntry) logIgnoredHistoryEntry(); diff --git a/scripts/write-card.test.ts b/scripts/write-card.test.ts index 09e7bb09..8ff1ba03 100644 --- a/scripts/write-card.test.ts +++ b/scripts/write-card.test.ts @@ -566,6 +566,205 @@ test("ADD отбрасывает шумовой history_entry один раз, } }); +test("UPDATE и NOOP читают пустой history_entry как отсутствующий, непустой — по-прежнему отказ", async () => { + const base = { + operation: "ADD", + type: "note", + title: "Blank history entry", + description: "пустое поле не подделывает History", + tags: ["note", "blank"], + body: "Текущая истина.", + }; + const created = await call(base); + assert.equal(created.ok, true); + // Тела разные намеренно: повтор уже лежащего факта даёт "updated", а не "merged". + for (const [blank, fact] of [ + ["", "Первый новый факт."], + [" ", "Второй новый факт."], + ]) { + const updated = await call({ + ...base, + operation: "UPDATE", + body: fact, + history_entry: blank, + }); + assert.equal(updated.ok, true, updated.error); + assert.equal(updated.action, "merged"); + const noop = await call({ + ...base, + operation: "NOOP", + history_entry: blank, + }); + assert.equal(noop.ok, true, noop.error); + assert.equal(noop.action, "noop"); + } + const out = read(created.file); + assert.doesNotMatch(out, /^## History$/gm); + assert.match(out, /^## Log$/m); + + const before = out; + const forged = await call({ + ...base, + operation: "UPDATE", + body: "Ещё факт.", + history_entry: "2026-01-01: прежняя истина", + }); + assert.equal(forged.ok, false); + assert.match(forged.error, /допустим только для SUPERSEDE/); + assert.equal(read(created.file), before); + const forgedNoop = await call({ + ...base, + operation: "NOOP", + history_entry: "2026-01-01: прежняя истина", + }); + assert.equal(forgedNoop.ok, false); + assert.match(forgedNoop.error, /NOOP не принимает/); + assert.equal( + read(created.file), + before, + "rejected NOOP leaves the card byte-identical", + ); + + const superseded = await call({ + ...base, + operation: "SUPERSEDE", + body: "Новая истина.", + history_entry: "2026-08-26: Текущая истина.", + }); + assert.equal(superseded.ok, true, superseded.error); + assert.equal(superseded.action, "replaced"); + const after = read(created.file); + assert.match(after, /^## History$/m); + assert.match(after, /Новая истина/); + assert.match(after, /2026-08-26: Текущая истина/); +}); + +test("property: whitespace-only history_entry on UPDATE/NOOP matches omitting the field", async () => { + const trimWhitespace = [ + "\t", + "\n", + "\v", + "\f", + "\r", + " ", + "\u00a0", + "\u1680", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\u200a", + "\u2028", + "\u2029", + "\u202f", + "\u205f", + "\u3000", + "\ufeff", + ] as const; + for (const character of trimWhitespace) assert.equal(character.trim(), ""); + const blankHistory = fc.string({ + unit: fc.constantFrom(...trimWhitespace), + minLength: 0, + maxLength: 24, + }); + const outcome = (result: WriteCardResult) => ({ + ok: result.ok, + action: result.action, + error: result.error, + file: result.file, + status: result.status, + type: result.type, + matchedBy: result.matchedBy, + }); + let counter = 0; + try { + await fc.assert( + fc.asyncProperty(blankHistory, async (historyEntry) => { + assert.equal(historyEntry.trim(), ""); + const title = `Blank history property ${counter++}`; + const base = { + operation: "ADD" as const, + type: "note", + title, + description: "пробельная пустота не подделывает History", + tags: ["note", "blank"], + body: "Текущая истина.", + }; + const created = await call(base); + assert.equal(created.ok, true, created.error); + const afterAdd = read(created.file); + + const omittedUpdate = await call({ + ...base, + operation: "UPDATE", + body: "Совместимый факт.", + }); + const omittedUpdateOutcome = outcome(omittedUpdate); + const omittedUpdateFile = read(created.file); + assert.doesNotMatch(omittedUpdateFile, /^## History$/gm); + + writeFileSync(join(VAULT, created.file), afterAdd); + const blankUpdate = await call({ + ...base, + operation: "UPDATE", + body: "Совместимый факт.", + history_entry: historyEntry, + }); + assert.deepEqual(outcome(blankUpdate), omittedUpdateOutcome); + assert.equal(read(created.file), omittedUpdateFile); + assert.doesNotMatch(read(created.file), /^## History$/gm); + + const afterUpdate = omittedUpdateFile; + const omittedNoop = await call({ ...base, operation: "NOOP" }); + const omittedNoopOutcome = outcome(omittedNoop); + const omittedNoopFile = read(created.file); + + writeFileSync(join(VAULT, created.file), afterUpdate); + const blankNoop = await call({ + ...base, + operation: "NOOP", + history_entry: historyEntry, + }); + assert.deepEqual(outcome(blankNoop), omittedNoopOutcome); + assert.equal(read(created.file), omittedNoopFile); + assert.doesNotMatch(read(created.file), /^## History$/gm); + + const beforeRefusal = read(created.file); + const forgedUpdate = await call({ + ...base, + operation: "UPDATE", + body: "Ещё факт.", + history_entry: "2026-01-01: прежняя истина", + }); + assert.equal(forgedUpdate.ok, false); + assert.match(forgedUpdate.error, /допустим только для SUPERSEDE/); + assert.equal(read(created.file), beforeRefusal); + + const forgedNoop = await call({ + ...base, + operation: "NOOP", + history_entry: "2026-01-01: прежняя истина", + }); + assert.equal(forgedNoop.ok, false); + assert.match(forgedNoop.error, /NOOP не принимает/); + assert.equal(read(created.file), beforeRefusal); + }), + { numRuns: 40 }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const seed = /\bseed:\s*(-?\d+)/.exec(message)?.[1]; + if (seed !== undefined) console.error(`PBT seed: ${seed}`); + throw error; + } +}); + test("ADD отбрасывает пустой и длинный однострочный history_entry, не создавая пустых полей", async () => { const previousWarn = console.warn; const warnings: string[] = [];