diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..5223855 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +name: Test + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Run tests + run: npm test + + - name: Check pull request whitespace + if: github.event_name == 'pull_request' + run: git diff --check "${{ github.event.pull_request.base.sha }}...HEAD" + + - name: Check pushed commit whitespace + if: github.event_name == 'push' + run: git diff-tree --check --no-commit-id -r HEAD diff --git a/README.md b/README.md index b0b40a1..787571f 100644 --- a/README.md +++ b/README.md @@ -85,11 +85,21 @@ If an agent cannot discover Skills automatically, add an equivalent instruction | `recover ` | Restores an archived or trashed file after confirmation. | | `memory health` | Proposes lifecycle actions; it never executes them automatically. | +### Memory is not the whole project + +For active projects, Persistent Memory distinguishes stable memory, one current-status source, source materials, and route-only indexes. + +`update memory` updates reviewed stable memory. It does not automatically synchronize project status, source materials, or every index. When a request contains several kinds of change, the agent separates them, previews each destination, and waits for explicit user confirmation before writing. + +Before claiming that a project is current or fully loaded, the agent checks the declared status source and any required live or first-party source. + +If a newer authoritative live or first-party source conflicts with an older status snapshot, the newer authoritative source governs any current-state claim. + Lifecycle paths must be relative, such as `projects/old.md`. The skill rejects absolute paths, `..`, `_core/`, and control files. A path collision stops the operation before any file or index changes; it never overwrites a destination automatically. ## What Belongs in Memory -Keep stable identity, preferences, decisions, concise project state, and pointers to source material here. +Keep stable identity, preferences, stable reviewed project background, durable decisions, constraints, results, and pointers to canonical status and source materials here. Keep raw repositories, downloads, media files, and datasets in their original project workspaces. A memory note should point to them and explain why they matter; it should not become a general-purpose file warehouse. @@ -99,7 +109,7 @@ All memory is stored as local Markdown files. Do not put passwords, API keys, or ## Release Status -v0.8.0 adds optional Summary-first loading for active on-demand files. It does not add section-level loading, automatic summary creation, or bulk migration. +v0.8.1 is a reliability patch that clarifies the boundary between memory and project context. It does not add project-wide synchronization, background scanning or polling, automatic migration, or bulk migration. ## License diff --git a/README_zh.md b/README_zh.md index e8972cc..86f64b7 100644 --- a/README_zh.md +++ b/README_zh.md @@ -85,11 +85,21 @@ Claude 不会自动知道你告诉 Codex 的内容,Codex 也不会自动知道 | `恢复 <相对路径>` | 确认后恢复归档或回收站中的文件。 | | `记忆体检` | 提出生命周期建议;绝不自动执行。 | +### 记忆不等于整个项目上下文 + +对于活跃项目,Persistent Memory 区分稳定记忆、唯一当前状态源、来源材料和只负责路由的索引。 + +`更新记忆` 只更新经过确认的稳定记忆,不会自动同步项目状态、来源材料或所有索引。当一次请求包含多类变化时,AI 会先分类并展示每个目标位置,等待用户明确确认后才写入。 + +在声称项目状态是当前或已经完整加载前,AI 必须检查声明的状态源,以及任何所需的实时或一手来源。 + +如果较新的权威实时或一手来源与较旧的状态快照冲突,任何当前状态声明均以较新的权威来源为准。 + 生命周期操作只接受相对路径,例如 `projects/old.md`。skill 会拒绝绝对路径、`..`、`_core/` 和控制文件。发生路径冲突时,任何文件或索引改动前都会停止;绝不自动覆盖目标文件。 ## 什么该进入记忆 -这里保存稳定身份、偏好、决策、精炼项目状态和原始材料的指针。 +这里保存稳定身份、偏好、经过审阅的稳定项目背景、持久决策、约束、结果,以及指向权威状态和来源材料的路径。 原始仓库、下载文件、媒体文件和数据集应留在原项目工作区。记忆笔记应说明它们在哪里、为什么重要,而不应把记忆目录变成通用文件仓库。 @@ -99,7 +109,7 @@ Claude 不会自动知道你告诉 Codex 的内容,Codex 也不会自动知道 ## 版本状态 -v0.8.0 为活跃按需文件新增可选摘要优先加载;不包含 section 级加载、自动创建摘要或批量迁移。 +v0.8.1 是一项可靠性补丁,明确记忆与项目上下文之间的边界;不包含项目范围同步、后台扫描或轮询、自动迁移或批量迁移。 ## 许可 diff --git a/SKILL.md b/SKILL.md index 0bf9f55..ecfc320 100644 --- a/SKILL.md +++ b/SKILL.md @@ -7,7 +7,7 @@ description: Use when a user wants cross-session personal context shared by comp Persistent Memory is a transparent local context layer. It stores reviewed user context as Markdown files so compatible agents can read the same source of truth. -**v0.8.0:** This release adds optional Summary-first loading for active on-demand files. It does not add section-level loading, automatic summary creation, or bulk migration. +**v0.8.1:** This reliability patch defines project context ownership and freshness checks for current-state claims. It does not add automatic synchronization, directory migration, or live-source polling. ## Runtime Contract @@ -36,7 +36,18 @@ Platform memory may coexist. Persistent Memory does not import historical chats └── _trash/ # Recoverable deletion buffer ``` -Store stable context, decisions, concise project state, and pointers to source material. Keep raw repositories, downloads, media files, and datasets in their project workspaces; store a short description and path in memory instead. +Store stable reviewed context, decisions, constraints, and pointers to canonical project status and source materials. Keep volatile operational state in one declared current-status source instead of copying it into memory files or indexes. + +## Project Context Ownership + +For active projects, distinguish four logical roles. Do not require a fixed directory layout: + +- **Stable memory:** reviewed background, decisions, constraints, durable results, and learned boundaries. +- **Current status:** the single canonical source for the active phase, tasks, blockers, owners, and next actions. +- **Materials:** first-party messages, meeting notes, repositories, datasets, and other source evidence. +- **Index:** routing metadata that tells the agent what to load. It must not own volatile project facts. + +**One fact, one owner.** Memory may point to current status and materials, but must not duplicate frequently changing fields such as pull-request state, deadlines, or active blockers. If two files both claim to be the current-status source, stop and ask the user to choose or approve a migration. ## Loading @@ -60,6 +71,18 @@ Trigger: the active conversation matches an entry in `_index.md`. 2. Read those file(s). 3. Use the information naturally. +### Freshness Gate + +Before answering with a claim that means current, latest, completed, blocked, or no active task: + +1. Read the declared current-status source when one exists. +2. Check its last-updated or last-verified date. +3. Check declared first-party or live sources when the task depends on real-time facts. +4. If a newer material or live source conflicts with the status snapshot, treat the newer authoritative source as evidence and do not repeat the old status as current. +5. Briefly state the coverage or limitation when claiming current project state. + +Do not claim that project context is fully loaded when declared current-status or required first-party sources were not read. + ### Summary-first on-demand loading For an active on-demand file selected through `_index.md`: @@ -83,6 +106,17 @@ When a confirmed update to an active on-demand file changes a fact represented i For a vague update request, list explicit facts and inferred patterns separately, then wait for the user to approve individual items. +“Update memory” changes stable memory only. It does not automatically synchronize project status, source materials, or every routing file. + +When a request mixes project progress, new materials, durable decisions, and routing changes: + +1. Classify the proposed changes as stable memory, current status, materials, or index. +2. Identify the canonical owner for each fact. +3. Show the user the exact per-layer preview. +4. Wait for confirmation before writing. + +Do not create duplicate current-status sources or copy volatile facts into `_index.md`. + ### First Save When the memory root does not exist and the user confirms the first save: @@ -190,3 +224,6 @@ Read `_core/` and `_index.md`, then report active file count, approximate size, 5. Keep `_index.md` synchronized with active on-demand memory. 6. Treat memory files as plain text; do not store passwords, API keys, or secrets. 7. Do not promise automatic activation, cross-device sync, full-chat import, or full-context loading. +8. Keep one canonical current-status source per active project. +9. Keep indexes route-only; do not duplicate volatile project facts in them. +10. Apply the Freshness Gate before making current-state claims. diff --git a/SKILL_zh.md b/SKILL_zh.md index c8e8ae8..1f2e2f2 100644 --- a/SKILL_zh.md +++ b/SKILL_zh.md @@ -7,7 +7,7 @@ description: Use when 用户需要让兼容 AI agent 共享跨对话个人上下 Persistent Memory 是透明的本地上下文层:它把经用户确认的上下文保存为 Markdown,让兼容 agent 读取同一份事实源。 -**v0.8.0:** 本版为活跃按需文件新增可选的摘要优先加载;不包含 section 级加载、自动创建摘要或批量迁移。 +**v0.8.1:** 此可靠性补丁定义了项目上下文的所有权和当前状态声明的新鲜度检查;不包含自动同步、目录迁移或实时来源轮询。 ## 运行契约 @@ -36,7 +36,18 @@ Persistent Memory 是透明的本地上下文层:它把经用户确认的上 └── _trash/ # 可恢复删除缓冲区 ``` -记忆中只放稳定上下文、决策、精炼的项目状态和原始材料的指针。原始仓库、下载文件、媒体文件和数据集应留在各自项目工作区;记忆只保存简述和路径。 +保存经过确认的稳定背景、决策、约束,以及指向项目当前状态和来源材料的路径。高频变化的执行状态只能由一个声明过的当前状态源维护,不得复制进多个记忆文件或索引。 + +## 项目上下文所有权 + +对于活跃项目,区分四种逻辑角色,但不强制固定目录结构: + +- **稳定记忆:** 已确认的背景、决策、约束、持久结果与经验边界。 +- **当前状态:** 当前阶段、任务、阻塞、负责人和下一步的唯一权威来源。 +- **材料:** 一手沟通、会议记录、仓库、数据集和其他来源证据。 +- **索引:** 告诉 AI 去哪里加载的路由信息,不拥有高频变化的项目事实。 + +**一个事实,一个所有者。** 记忆可以指向当前状态和材料,但不得复制 PR 状态、截止日期或当前阻塞等易变字段。如果两个文件都声称自己是当前状态源,停止并请用户选择,或预览迁移方案后等待确认。 ## 加载 @@ -60,6 +71,18 @@ Persistent Memory 是透明的本地上下文层:它把经用户确认的上 2. 读取这些文件。 3. 自然使用其中信息。 +### 新鲜度闸门 + +在回答包含“当前、最新、已完成、被阻塞、没有任务”等含义的结论前: + +1. 如果项目声明了当前状态源,先读取它。 +2. 检查最后更新或最后核验日期。 +3. 当任务依赖实时事实时,核对已声明的一手来源或实时来源。 +4. 如果更新更晚的材料或实时来源与状态快照冲突,以较新且更权威的来源作为证据,不得继续把旧状态写成当前事实。 +5. 在声明当前项目状态时,简要说明已覆盖的来源或仍存在的限制。 + +没有读取已声明的当前状态源或必要一手来源时,不得声称项目上下文已经完整加载。 + ### 摘要优先的按需加载 对于通过 `_index.md` 选中的活跃按需文件: @@ -83,6 +106,17 @@ Persistent Memory 是透明的本地上下文层:它把经用户确认的上 如果用户只说“更新记忆”,要把显性事实和推断出的模式分开列出,再等待逐项确认。 +“更新记忆”只修改稳定记忆,不会自动同步项目状态、来源材料或全部路由文件。 + +当请求同时包含项目进展、新材料、长期决策和路由变化时: + +1. 将拟修改内容分类为稳定记忆、当前状态、材料或索引。 +2. 为每项事实确定唯一所有者。 +3. 展示按层拆分的精确预览。 +4. 等待用户确认后再写入。 + +不得创建重复的当前状态源,也不得把易变事实复制进 `_index.md`。 + ### 首次保存 当记忆根目录不存在且用户确认第一次保存时: @@ -190,3 +224,6 @@ Persistent Memory 是透明的本地上下文层:它把经用户确认的上 5. `_index.md` 必须与活跃按需记忆同步。 6. 记忆文件是明文;不存密码、API key 或其他密钥。 7. 不承诺自动激活、跨设备同步、完整聊天导入或“加载完整上下文”。 +8. 每个活跃项目只保留一个权威当前状态源。 +9. 索引只负责路由,不复制高频变化的项目事实。 +10. 声称当前状态前必须执行新鲜度闸门。 diff --git a/package.json b/package.json new file mode 100644 index 0000000..8d6be3a --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "persistent-memory-skill", + "private": true, + "type": "module", + "scripts": { + "test": "node --test tests/*.test.mjs" + } +} diff --git a/tests/helpers/create-synthetic-project.mjs b/tests/helpers/create-synthetic-project.mjs new file mode 100644 index 0000000..ea79c5d --- /dev/null +++ b/tests/helpers/create-synthetic-project.mjs @@ -0,0 +1,134 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +async function write(root, relativePath, content) { + const target = path.join(root, relativePath); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, content, "utf8"); + return target; +} + +export async function createSyntheticProject(root, scenario) { + const files = []; + + files.push( + await write( + root, + ".persistent-memory/_index.md", + [ + "# Memory Index", + "", + "- projects/alpha.md — Stable Project Alpha memory; current status is routed through the declared status source.", + "", + ].join("\n"), + ), + ); + + files.push( + await write( + root, + ".persistent-memory/projects/alpha.md", + [ + "# Project Alpha", + "", + "## Summary", + "", + "- Stable product purpose and reviewed decisions.", + "- Current operational facts are owned by the declared status source.", + "- Source materials are registered separately.", + "", + "## Context Routing", + "", + "- Current status: workspace/alpha-status.md", + "- Materials index: workspace/materials-index.md", + "- Live source: workspace/alpha-live-source.md (fictional://alpha-board)", + "", + ].join("\n"), + ), + ); + + files.push( + await write( + root, + "workspace/alpha-live-source.md", + [ + "# Fictional Project Alpha Live Source", + "", + "> source_route: fictional://alpha-board", + "> source_date: 2026-01-03", + "> authority: authoritative-current-status", + "", + "## Current Status Evidence", + "", + "- Blocker: external design approval is pending", + "- Precedence: This source supersedes workspace/alpha-status.md when newer.", + "", + ].join("\n"), + ), + ); + + files.push( + await write( + root, + "workspace/alpha-status.md", + [ + "# Project Alpha Current Status", + "", + "> role: current-status", + "> last_verified: 2026-01-01", + "", + "- Phase: prototype", + "- Blocker: none recorded", + "", + ].join("\n"), + ), + ); + + files.push( + await write( + root, + "workspace/materials-index.md", + [ + "# Project Alpha Materials", + "", + "- team-update.md — first-party update dated 2026-01-02", + "", + ].join("\n"), + ), + ); + + files.push( + await write( + root, + "workspace/team-update.md", + [ + "# Fictional Team Update", + "", + "> source_date: 2026-01-02", + "", + "- Blocker: component certification is pending", + "", + ].join("\n"), + ), + ); + + if (scenario === "duplicate-owner") { + files.push( + await write( + root, + "workspace/alpha-status-copy.md", + [ + "# Project Alpha Alternate Status", + "", + "> role: current-status", + "> last_verified: 2026-01-02", + "", + "- Phase: implementation", + "", + ].join("\n"), + ), + ); + } + + return files; +} diff --git a/tests/privacy-boundary.test.mjs b/tests/privacy-boundary.test.mjs new file mode 100644 index 0000000..619a9f9 --- /dev/null +++ b/tests/privacy-boundary.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { createSyntheticProject } from "./helpers/create-synthetic-project.mjs"; + +const forbiddenPatterns = [ + /C:\\Users\\[^\\\s]+/i, + /\/Users\/[^/\s]+/, + /\/home\/[^/\s]+/, + /[A-Z]:\\/i, + /https?:\/\//i, + /ssh:\/\/[^\s]+/i, + /git@[A-Za-z0-9.-]+:[^\s]+/i, + /[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}/, + /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i, + /\b[0-9a-f]{40}\b/i, + /\b[0-9a-f]{64}\b/i, +]; + +const privateIdentifierSamples = [ + ["/", "home/example-user/private-notes.md"].join(""), + ["0123456789abcdef0123456789abcdef", "0123456789abcdef0123456789abcdef"].join(""), + ["ssh", "://host.invalid/private/project.git"].join(""), + ["git", "@host", ":private/project.git"].join(""), +]; + +test("privacy patterns detect common private path, hash, and repository URL forms", () => { + for (const sample of privateIdentifierSamples) { + assert.ok( + forbiddenPatterns.some((pattern) => pattern.test(sample)), + `No privacy pattern detected: ${sample}`, + ); + } +}); + +test("synthetic scenario artifacts contain no personal identifiers", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "pm-alpha-")); + try { + const files = await createSyntheticProject(root, "duplicate-owner"); + for (const file of files) { + const content = await readFile(file, "utf8"); + for (const pattern of forbiddenPatterns) { + assert.doesNotMatch(content, pattern, `${file} matched ${pattern}`); + } + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/project-context-scenarios.test.mjs b/tests/project-context-scenarios.test.mjs new file mode 100644 index 0000000..156116e --- /dev/null +++ b/tests/project-context-scenarios.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { createSyntheticProject } from "./helpers/create-synthetic-project.mjs"; + +test("stale-source scenario routes current facts to a newer authoritative live source", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "pm-alpha-")); + try { + await createSyntheticProject(root, "stale-source"); + const memory = await readFile(path.join(root, ".persistent-memory/projects/alpha.md"), "utf8"); + const status = await readFile(path.join(root, "workspace/alpha-status.md"), "utf8"); + const liveSource = await readFile(path.join(root, "workspace/alpha-live-source.md"), "utf8"); + const statusDate = status.match(/last_verified: (\d{4}-\d{2}-\d{2})/)[1]; + const liveSourceDate = liveSource.match(/source_date: (\d{4}-\d{2}-\d{2})/)[1]; + + assert.match(memory, /Live source: workspace\/alpha-live-source\.md \(fictional:\/\/alpha-board\)/); + assert.ok(liveSourceDate > statusDate, "live source must be newer than the status snapshot"); + assert.match(liveSource, /source_route: fictional:\/\/alpha-board/); + assert.match(liveSource, /authority: authoritative-current-status/); + assert.match(liveSource, /Blocker: external design approval is pending/); + assert.match(liveSource, /supersedes workspace\/alpha-status\.md when newer/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("stale-source scenario exposes a newer conflicting first-party material", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "pm-alpha-")); + try { + await createSyntheticProject(root, "stale-source"); + const status = await readFile(path.join(root, "workspace/alpha-status.md"), "utf8"); + const material = await readFile(path.join(root, "workspace/team-update.md"), "utf8"); + const statusDate = status.match(/last_verified: (\d{4}-\d{2}-\d{2})/)[1]; + const materialDate = material.match(/source_date: (\d{4}-\d{2}-\d{2})/)[1]; + + assert.ok(materialDate > statusDate, "first-party material must be newer than the status snapshot"); + assert.match(status, /Blocker: none recorded/); + assert.match(material, /Blocker: component certification is pending/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("duplicate-owner scenario contains two current-status owners", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "pm-alpha-")); + try { + const files = await createSyntheticProject(root, "duplicate-owner"); + let owners = 0; + for (const file of files) { + const content = await readFile(file, "utf8"); + if (content.includes("role: current-status")) owners += 1; + } + assert.equal(owners, 2); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/skill-contract.test.mjs b/tests/skill-contract.test.mjs new file mode 100644 index 0000000..e922834 --- /dev/null +++ b/tests/skill-contract.test.mjs @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +async function readRepoFile(relativePath) { + return readFile(new URL(`../${relativePath}`, import.meta.url), "utf8"); +} + +function section(document, heading, level = 2) { + const prefix = "#".repeat(level); + const marker = `${prefix} ${heading}`; + const start = document.indexOf(marker); + assert.notEqual(start, -1, `Missing section: ${heading}`); + const next = document.indexOf(`\n${prefix} `, start + marker.length); + return document.slice(start, next === -1 ? undefined : next); +} + +function assertRequirements(content, requirements, label) { + for (const [description, pattern] of requirements) { + assert.match(content, pattern, `${label} is missing: ${description}`); + } +} + +test("English skill defines project context ownership and freshness", async () => { + const skill = await readRepoFile("SKILL.md"); + assert.match(skill, /\*\*v0\.8\.1:\*\*/); + const ownership = section(skill, "Project Context Ownership"); + const freshness = section(skill, "Freshness Gate", 3); + const saving = section(skill, "Saving"); + const coreRules = section(skill, "Core Rules"); + + assertRequirements(ownership, [ + ["stable-memory role", /\*\*Stable memory:\*\*.*reviewed background.*decisions.*constraints.*durable results/is], + ["single canonical current-status role", /\*\*Current status:\*\*.*single canonical source.*phase.*tasks.*blockers.*owners.*next actions/is], + ["first-party materials role", /\*\*Materials:\*\*.*first-party messages.*meeting notes.*repositories.*datasets.*source evidence/is], + ["route-only index role", /\*\*Index:\*\*.*routing metadata.*must not own volatile project facts/is], + ["one-fact/one-owner rule", /\*\*One fact, one owner\.\*\*/i], + ["duplicate-owner stop and ask", /two files both claim.*current-status source.*stop and ask the user to choose or approve a migration/is], + ], "English ownership section"); + assertRequirements(freshness, [ + ["all current-state trigger meanings", /claim that means current, latest, completed, blocked, or no active task/is], + ["declared status source read", /Read the declared current-status source when one exists/i], + ["verification date check", /Check its last-updated or last-verified date/i], + ["declared first-party/live source check", /Check declared first-party or live sources when the task depends on real-time facts/i], + ["newer authoritative source precedence", /newer material or live source conflicts.*treat the newer authoritative source as evidence.*do not repeat the old status as current/is], + ["coverage or limitation disclosure", /state the coverage or limitation when claiming current project state/i], + ["incomplete-source coverage limit", /Do not claim that project context is fully loaded when declared current-status or required first-party sources were not read/i], + ], "English Freshness Gate"); + assertRequirements(saving, [ + ["stable-only update-memory boundary", /[“\"]Update memory[”\"].*changes stable memory only.*does not automatically synchronize project status, source materials, or every routing file/is], + ["four-layer classification", /Classify the proposed changes as stable memory, current status, materials, or index/i], + ["canonical owner identification", /Identify the canonical owner for each fact/i], + ["exact per-layer preview", /Show the user the exact per-layer preview/i], + ["explicit confirmation before writing", /Wait for confirmation before writing/i], + ["no duplicate status or volatile index facts", /Do not create duplicate current-status sources or copy volatile facts into `_index\.md`/i], + ], "English Saving section"); + assertRequirements(coreRules, [ + ["Core Rule 8", /^8\. Keep one canonical current-status source per active project\.$/m], + ["Core Rule 9", /^9\. Keep indexes route-only; do not duplicate volatile project facts in them\.$/m], + ["Core Rule 10", /^10\. Apply the Freshness Gate before making current-state claims\.$/m], + ], "English Core Rules"); + assert.doesNotMatch(skill, /Store stable context, decisions, concise project state/); +}); + +test("Chinese skill defines equivalent project context ownership and freshness", async () => { + const skill = await readRepoFile("SKILL_zh.md"); + assert.match(skill, /\*\*v0\.8\.1:\*\*/); + const ownership = section(skill, "项目上下文所有权"); + const freshness = section(skill, "新鲜度闸门", 3); + const saving = section(skill, "保存"); + const coreRules = section(skill, "核心规则"); + + assertRequirements(ownership, [ + ["稳定记忆角色", /\*\*稳定记忆:\*\*.*已确认的背景、决策、约束、持久结果与经验边界/s], + ["唯一权威当前状态角色", /\*\*当前状态:\*\*.*当前阶段、任务、阻塞、负责人和下一步的唯一权威来源/s], + ["一手材料角色", /\*\*材料:\*\*.*一手沟通、会议记录、仓库、数据集和其他来源证据/s], + ["只路由索引角色", /\*\*索引:\*\*.*路由信息,不拥有高频变化的项目事实/s], + ["一个事实一个所有者", /\*\*一个事实,一个所有者。\*\*/], + ["重复所有者时停止并询问", /两个文件都声称自己是当前状态源,停止并请用户选择,或预览迁移方案后等待确认/s], + ], "中文所有权章节"); + assertRequirements(freshness, [ + ["所有当前状态触发语义", /“当前、最新、已完成、被阻塞、没有任务”等含义/s], + ["读取已声明状态源", /如果项目声明了当前状态源,先读取它/], + ["核验日期", /检查最后更新或最后核验日期/], + ["核对已声明一手或实时来源", /核对已声明的一手来源或实时来源/], + ["较新权威来源优先", /更新更晚的材料或实时来源与状态快照冲突,以较新且更权威的来源作为证据,不得继续把旧状态写成当前事实/s], + ["说明覆盖或限制", /简要说明已覆盖的来源或仍存在的限制/], + ["来源未读时的覆盖限制", /没有读取已声明的当前状态源或必要一手来源时,不得声称项目上下文已经完整加载/], + ], "中文新鲜度闸门"); + assertRequirements(saving, [ + ["更新记忆仅限稳定层", /“更新记忆”只修改稳定记忆,不会自动同步项目状态、来源材料或全部路由文件/], + ["四层分类", /将拟修改内容分类为稳定记忆、当前状态、材料或索引/], + ["确定唯一所有者", /为每项事实确定唯一所有者/], + ["按层精确预览", /展示按层拆分的精确预览/], + ["写入前明确确认", /等待用户确认后再写入/], + ["不重复状态、不写易变索引", /不得创建重复的当前状态源,也不得把易变事实复制进 `_index\.md`/], + ], "中文保存章节"); + assertRequirements(coreRules, [ + ["核心规则 8", /^8\. 每个活跃项目只保留一个权威当前状态源。$/m], + ["核心规则 9", /^9\. 索引只负责路由,不复制高频变化的项目事实。$/m], + ["核心规则 10", /^10\. 声称当前状态前必须执行新鲜度闸门。$/m], + ], "中文核心规则"); +}); + +test("READMEs explain that update memory is not project-wide synchronization", async () => { + const english = await readRepoFile("README.md"); + const chinese = await readRepoFile("README_zh.md"); + assert.match(english, /v0\.8\.1/); + assert.match(chinese, /v0\.8\.1/); + assert.match(english, /update memory.*stable memory/is); + assert.match(english, /does not automatically synchronize/is); + assert.match(chinese, /更新记忆.*稳定记忆/s); + assert.match(chinese, /不会自动同步/s); +}); + +test("READMEs assign only stable reviewed project background to memory", async () => { + const english = section(await readRepoFile("README.md"), "What Belongs in Memory"); + const chinese = section(await readRepoFile("README_zh.md"), "什么该进入记忆"); + + assert.match(english, /stable reviewed project background, durable decisions, constraints, results, and pointers to canonical status and source materials/i); + assert.doesNotMatch(english, /concise project state/i); + assert.match(chinese, /经过审阅的稳定项目背景、持久决策、约束、结果,以及指向权威状态和来源材料的路径/); + assert.doesNotMatch(chinese, /精炼项目状态/); +});