diff --git a/CHANGELOG.md b/CHANGELOG.md index 47f3a4e6..d4e81dcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to Context are documented here. +## 0.7.70 - 2026-10-14 + +- Revising an existing article no longer fails because its old citations are outside the new task's sources. A fragment that omits `references` keeps its validated citations; listing references re-confirms them against the task's authorized sources. Only candidate or approved citations are trusted, never a failed draft's. +- Revision tasks come with an `edits.yaml` skeleton that lists the existing fragment IDs. A complete draft written before the skeleton appeared is still submitted as a whole article. +- Plan amendments accept an article path in `after`, resolve it to the current task, and reject replaced or excluded tasks with a list of candidates. When a replacement keeps the same editing base, its draft is carried to the new task. Sources already authorized and cited by the revised article are added to its task. A plan without `stage` is rejected with an example. +- A citation can be located by `heading` (Markdown files) or by an exact `quote` instead of line numbers. The CLI resolves it to lines and echoes the result in `resolved_references`; stored locators are still line ranges. +- `translate prepare` is incremental: it reports changed sections, titles and surrounding text; writes drafts that keep unchanged translations; and writes a submission file. A draft edited against an older source must be reconciled and confirmed with `source_digest`. A draft without a generation record is set aside as `*.unrecorded.md`. Existing translations gain `source_parts` baselines once, without changing reader text. +- Website translations may translate prose in `text`, `txt`, and `plaintext` code blocks while preserving commands, paths, placeholders, block counts, and language markers. Other code blocks remain unchanged; Mermaid labels can still be translated. +- On multilingual websites, in-page and cross-page anchors keep the reader's language and scroll to the matching heading, including repeated clicks and back/forward navigation. + ## 0.7.67 - 2026-10-14 - Knowledge websites open in light mode regardless of the system color scheme. The reader's light/dark and language choices are remembered per origin by default, so sites on one origin share them. diff --git a/bun.lock b/bun.lock index 803a68eb..54fafd16 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ }, "packages/context": { "name": "@c4a/context", - "version": "0.7.67", + "version": "0.7.70", "dependencies": { "yaml": "^2.5.1", "zod": "^3.23.8", @@ -26,7 +26,7 @@ }, "packages/context-cli": { "name": "@c4a/context-cli", - "version": "0.7.67", + "version": "0.7.70", "bin": { "context": "dist/cli.js", }, @@ -73,7 +73,7 @@ }, "packages/core": { "name": "@c4a/core", - "version": "0.7.67", + "version": "0.7.70", "dependencies": { "picomatch": "^4.0.4", "yaml": "^2.4.5", @@ -85,7 +85,7 @@ }, "packages/dev-cli": { "name": "@c4a/dev-cli", - "version": "0.7.67", + "version": "0.7.70", "dependencies": { "@c4a/context": "workspace:*", "@c4a/core": "workspace:*", @@ -97,7 +97,7 @@ }, "packages/extract": { "name": "@c4a/extract", - "version": "0.7.67", + "version": "0.7.70", "bin": { "c4a-extract-code": "./dist/bin/c4a-extract-code.js", }, @@ -112,7 +112,7 @@ }, "packages/extract-contract": { "name": "@c4a/extract-contract", - "version": "0.7.67", + "version": "0.7.70", "dependencies": { "@c4a/core": "workspace:*", "graphql": "^16.14.2", @@ -122,7 +122,7 @@ }, "packages/extract-go": { "name": "@c4a/extract-go", - "version": "0.7.67", + "version": "0.7.70", "dependencies": { "@c4a/core": "workspace:*", "@c4a/extract": "workspace:*", @@ -132,7 +132,7 @@ }, "packages/extract-mdx": { "name": "@c4a/extract-mdx", - "version": "0.7.67", + "version": "0.7.70", "dependencies": { "@c4a/core": "workspace:*", "remark-mdx": "^3.1.1", @@ -144,7 +144,7 @@ }, "packages/extract-proto": { "name": "@c4a/extract-proto", - "version": "0.7.67", + "version": "0.7.70", "dependencies": { "@c4a/core": "workspace:*", "zod": "^3.23.8", @@ -152,7 +152,7 @@ }, "packages/extract-rush": { "name": "@c4a/extract-rush", - "version": "0.7.67", + "version": "0.7.70", "dependencies": { "@c4a/core": "workspace:*", "typescript": "^5.5.4", @@ -162,7 +162,7 @@ }, "packages/extract-sql": { "name": "@c4a/extract-sql", - "version": "0.7.67", + "version": "0.7.70", "dependencies": { "@c4a/core": "workspace:*", "node-sql-parser": "^5.4.0", @@ -171,7 +171,7 @@ }, "packages/extract-style": { "name": "@c4a/extract-style", - "version": "0.7.67", + "version": "0.7.70", "dependencies": { "@c4a/core": "workspace:*", "postcss": "^8.5.26", @@ -183,7 +183,7 @@ }, "packages/extract-thrift": { "name": "@c4a/extract-thrift", - "version": "0.7.67", + "version": "0.7.70", "dependencies": { "@c4a/core": "workspace:*", "zod": "^3.23.8", @@ -191,7 +191,7 @@ }, "packages/extract-ts": { "name": "@c4a/extract-ts", - "version": "0.7.67", + "version": "0.7.70", "dependencies": { "@c4a/core": "workspace:*", "@c4a/extract": "workspace:*", @@ -202,7 +202,7 @@ }, "packages/tui": { "name": "@c4a/tui", - "version": "0.7.67", + "version": "0.7.70", "dependencies": { "ink": "^5.0.0", "react": "^18.3.1", diff --git a/package.json b/package.json index b27e0c6a..5c156635 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "context", - "version": "0.7.67", + "version": "0.7.70", "packageManager": "bun@1.3.9", "repository": { "type": "git", diff --git a/packages/context-cli/context-workflow/provider.yaml b/packages/context-cli/context-workflow/provider.yaml index 9d69b4e4..2425b9f6 100644 --- a/packages/context-cli/context-workflow/provider.yaml +++ b/packages/context-cli/context-workflow/provider.yaml @@ -1,6 +1,6 @@ schema: agent-graph.provider.v1 id: c4a/context -version: 0.7.67 +version: 0.7.70 name: Context workflow description: Internal work contract for Context knowledge workspaces. graphs: diff --git a/packages/context-cli/context-workflow/resources/manuals/guides/knowledge-updates.md b/packages/context-cli/context-workflow/resources/manuals/guides/knowledge-updates.md index 18b2e043..314b2ab2 100644 --- a/packages/context-cli/context-workflow/resources/manuals/guides/knowledge-updates.md +++ b/packages/context-cli/context-workflow/resources/manuals/guides/knowledge-updates.md @@ -706,3 +706,14 @@ an explicit empty list removes them, and deleting a fragment removes its citations. Each fragment may cite at most three source positions. Register and authorize new material before citing it, then follow normal Review, close and build. Article links alone do not authorize reading new sources or prove a claim. + +### Locating submitted references + +Submission locators also accept `{path, heading}` or `{path, quote}` instead of +line numbers. Use exactly one form. A heading matches an exact ATX heading +outside code fences in a Markdown source; its region ends before the next +same-level or higher-level heading. A quote is an exact, unique, nonempty +substring (line endings are normalized). Ambiguous matches return candidate +line ranges. Both forms resolve against authorized captured text before hashing; +stored references still contain only `path`, `start_line` and `end_line`. +The three-region limit applies after resolving and deduplicating positions. diff --git a/packages/context-cli/context-workflow/resources/manuals/guides/site-translation.md b/packages/context-cli/context-workflow/resources/manuals/guides/site-translation.md index 90586764..6c325329 100644 --- a/packages/context-cli/context-workflow/resources/manuals/guides/site-translation.md +++ b/packages/context-cli/context-workflow/resources/manuals/guides/site-translation.md @@ -60,8 +60,11 @@ map_entries: ``` A translated article keeps every `context:section` marker, id and order, every -fenced code block with its language and content (Mermaid labels may change), and -every link target. Submission validates all items first and saves nothing when +fenced code block and its language marker, and every link target. Prose in +`text`, `txt`, and `plaintext` blocks may be translated; keep commands, paths, +and placeholders such as `` unchanged. Mermaid labels may change. +All other blocks, including blocks without a language marker, keep their content +unchanged. Submission validates all items first and saves nothing when any item fails. A digest from before an approved content change is rejected. `skip` records that the current pending items are published in the primary @@ -76,3 +79,24 @@ an outdated notice and a button that switches to the original; a missing page shows the original with a notice. Navigation titles without a translation use the translated article title, then the primary title. Site search covers every language; opening a result in another language switches to it. + + +## Incremental drafts + +`prepare` returns section changes, title/description changes, exterior-text changes +(including the article tail), and section order changes. It writes draft files +and a `submission_path` template. Unchanged parts retain their translation; +changed parts contain the current primary-language text for the Agent to translate. +Review every pending part before submitting; generated source text is not a translation. + +Older current translations acquire source-part baselines without changing their +reader text. Older stale translations report `diff: unknown` and get a full source +draft plus a `.previous.md` translation for comparison. + +Repeated preparation preserves edited drafts. `draft: rebase-required` means the +source changed while a draft was being edited; that draft is excluded from the +submission template. Reconcile it with the current source, then explicitly add +the returned `source_digest` to that article's submission entry. Never copy a new +digest onto an unreconciled draft. Ordinary submissions still use `expected_digest`. +A draft without a generation record, such as one left by an older workflow, is +kept as `unrecorded_draft` beside a freshly generated draft. diff --git a/packages/context-cli/context-workflow/resources/procedures/approved-revision.md b/packages/context-cli/context-workflow/resources/procedures/approved-revision.md index 9b56699d..6076ca6e 100644 --- a/packages/context-cli/context-workflow/resources/procedures/approved-revision.md +++ b/packages/context-cli/context-workflow/resources/procedures/approved-revision.md @@ -170,3 +170,14 @@ If the user explicitly requires a different presentation, explain the difference and resolve that choice before accepting it. Do not rerun unchanged Repair to force a format the program deliberately omits. Check the actual resulting page; accepted tasks or an empty Composer result alone do not demonstrate a correction. + +### Locating submitted references + +Submission locators also accept `{path, heading}` or `{path, quote}` instead of +line numbers. Use exactly one form. A heading matches an exact ATX heading +outside code fences in a Markdown source; its region ends before the next +same-level or higher-level heading. A quote is an exact, unique, nonempty +substring (line endings are normalized). Ambiguous matches return candidate +line ranges. Both forms resolve against authorized captured text before hashing; +stored references still contain only `path`, `start_line` and `end_line`. +The three-region limit applies after resolving and deduplicating positions. diff --git a/packages/context-cli/context-workflow/resources/procedures/knowledge-updates.md b/packages/context-cli/context-workflow/resources/procedures/knowledge-updates.md index 745f65d4..0f45c06d 100644 --- a/packages/context-cli/context-workflow/resources/procedures/knowledge-updates.md +++ b/packages/context-cli/context-workflow/resources/procedures/knowledge-updates.md @@ -706,3 +706,14 @@ an explicit empty list removes them, and deleting a fragment removes its citations. Each fragment may cite at most three source positions. Register and authorize new material before citing it, then follow normal Review, close and build. Article links alone do not authorize reading new sources or prove a claim. + +### Locating submitted references + +Submission locators also accept `{path, heading}` or `{path, quote}` instead of +line numbers. Use exactly one form. A heading matches an exact ATX heading +outside code fences in a Markdown source; its region ends before the next +same-level or higher-level heading. A quote is an exact, unique, nonempty +substring (line endings are normalized). Ambiguous matches return candidate +line ranges. Both forms resolve against authorized captured text before hashing; +stored references still contain only `path`, `start_line` and `end_line`. +The three-region limit applies after resolving and deduplicating positions. diff --git a/packages/context-cli/context-workflow/resources/procedures/production-stage-files.md b/packages/context-cli/context-workflow/resources/procedures/production-stage-files.md index d4852d46..52463d6a 100644 --- a/packages/context-cli/context-workflow/resources/procedures/production-stage-files.md +++ b/packages/context-cli/context-workflow/resources/procedures/production-stage-files.md @@ -104,3 +104,14 @@ build resumes the same stage; a failed build retains the pause and all tasks. To cancel that pause explicitly, use `context run --resume-writing --format json`. It preserves approved articles, candidates and existing task input handles. Partial delivery must include the approved articles needed by its local links. + +### Locating submitted references + +Submission locators also accept `{path, heading}` or `{path, quote}` instead of +line numbers. Use exactly one form. A heading matches an exact ATX heading +outside code fences in a Markdown source; its region ends before the next +same-level or higher-level heading. A quote is an exact, unique, nonempty +substring (line endings are normalized). Ambiguous matches return candidate +line ranges. Both forms resolve against authorized captured text before hashing; +stored references still contain only `path`, `start_line` and `end_line`. +The three-region limit applies after resolving and deduplicating positions. diff --git a/packages/context-cli/context-workflow/schemas/approved-revision-result.schema.json b/packages/context-cli/context-workflow/schemas/approved-revision-result.schema.json index e043fbe4..6463f679 100644 --- a/packages/context-cli/context-workflow/schemas/approved-revision-result.schema.json +++ b/packages/context-cli/context-workflow/schemas/approved-revision-result.schema.json @@ -30,26 +30,64 @@ "minLength": 1 }, "locator": { - "type": "object", - "properties": { - "path": { - "type": "string" + "anyOf": [ + { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "start_line": { + "type": "integer", + "minimum": 1 + }, + "end_line": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "path", + "start_line", + "end_line" + ], + "additionalProperties": false }, - "start_line": { - "type": "integer", - "minimum": 1 + { + "type": "object", + "properties": { + "path": { + "$ref": "#/anyOf/0/properties/sections/items/properties/references/items/properties/locator/anyOf/0/properties/path" + }, + "heading": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "path", + "heading" + ], + "additionalProperties": false }, - "end_line": { - "type": "integer", - "minimum": 1 + { + "type": "object", + "properties": { + "path": { + "$ref": "#/anyOf/0/properties/sections/items/properties/references/items/properties/locator/anyOf/0/properties/path" + }, + "quote": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "path", + "quote" + ], + "additionalProperties": false } - }, - "required": [ - "path", - "start_line", - "end_line" - ], - "additionalProperties": false + ] } }, "required": [ @@ -57,8 +95,7 @@ "locator" ], "additionalProperties": false - }, - "maxItems": 3 + } } }, "required": [ diff --git a/packages/context-cli/context-workflow/schemas/indexer-agent-step-result.schema.json b/packages/context-cli/context-workflow/schemas/indexer-agent-step-result.schema.json index 38dac58d..5c0e4220 100644 --- a/packages/context-cli/context-workflow/schemas/indexer-agent-step-result.schema.json +++ b/packages/context-cli/context-workflow/schemas/indexer-agent-step-result.schema.json @@ -129,26 +129,64 @@ "minLength": 1 }, "locator": { - "type": "object", - "properties": { - "path": { - "type": "string" + "anyOf": [ + { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "start_line": { + "type": "integer", + "minimum": 1 + }, + "end_line": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "path", + "start_line", + "end_line" + ], + "additionalProperties": false }, - "start_line": { - "type": "integer", - "minimum": 1 + { + "type": "object", + "properties": { + "path": { + "$ref": "#/$defs/approvedRevision/anyOf/0/properties/sections/items/properties/references/items/properties/locator/anyOf/0/properties/path" + }, + "heading": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "path", + "heading" + ], + "additionalProperties": false }, - "end_line": { - "type": "integer", - "minimum": 1 + { + "type": "object", + "properties": { + "path": { + "$ref": "#/$defs/approvedRevision/anyOf/0/properties/sections/items/properties/references/items/properties/locator/anyOf/0/properties/path" + }, + "quote": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "path", + "quote" + ], + "additionalProperties": false } - }, - "required": [ - "path", - "start_line", - "end_line" - ], - "additionalProperties": false + ] } }, "required": [ @@ -156,8 +194,7 @@ "locator" ], "additionalProperties": false - }, - "maxItems": 3 + } } }, "required": [ diff --git a/packages/context-cli/context-workflow/skills/translate-site-locales/SKILL.md b/packages/context-cli/context-workflow/skills/translate-site-locales/SKILL.md index 8eaa52f0..2750bad1 100644 --- a/packages/context-cli/context-workflow/skills/translate-site-locales/SKILL.md +++ b/packages/context-cli/context-workflow/skills/translate-site-locales/SKILL.md @@ -8,13 +8,20 @@ knowledge map title that is missing or stale for each declared locale, with the `expected_digest` to submit. Approved knowledge in the primary language remains the only source of truth; translations never change it. +Use the returned draft and submission paths. Preserve unchanged translated parts +and translate the listed changes in a natural tone. For `diff: unknown`, compare +with the previous translation. A `rebase-required` draft must be reconciled with +the current source before explicitly confirming its `source_digest`. + For each article, read the approved file and write a complete translation to a temporary file under `.tmp/agent-payloads/`. Keep its structure exactly: - frontmatter contains only a translated `title` and optional `description`; - keep every `context:section` marker, id and order unchanged; -- keep every fenced code block, language and content unchanged; only Mermaid - labels may be translated; +- keep every fenced code block and language marker; translate prose in `text`, + `txt`, and `plaintext` blocks, preserving commands, paths and placeholders such + as ``. Mermaid labels may be translated. Keep content unchanged + in every other block, including blocks without a language marker; - keep every link target unchanged; translate link labels only; - keep product names, commands, file paths, identifiers and API names as written. diff --git a/packages/context-cli/package.json b/packages/context-cli/package.json index 8d7a651d..e7b223ce 100644 --- a/packages/context-cli/package.json +++ b/packages/context-cli/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/context-cli", "description": "Local runtime and Agent integration for traceable knowledge production", - "version": "0.7.67", + "version": "0.7.70", "type": "module", "license": "MIT", "engines": { diff --git a/packages/context-cli/scripts/smoke-site-locales-browser.ts b/packages/context-cli/scripts/smoke-site-locales-browser.ts new file mode 100644 index 00000000..c65df111 --- /dev/null +++ b/packages/context-cli/scripts/smoke-site-locales-browser.ts @@ -0,0 +1,82 @@ +import { projectPackageKnowledgeMarkdown } from "../src/project/packageKnowledgeProjection.js"; +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises"; +import { join, extname, resolve, sep } from "node:path"; +import { chromium } from "playwright"; +import { kbPackage } from "@c4a/context"; +import { createSiteNavigation, writePackageSite } from "../src/project/packageSite.js"; +import { packageSiteOutputDir } from "../src/project/packageOutputPaths.js"; +import { approvedArticleDigest } from "../src/project/siteTranslation.js"; + +const root = await mkdtemp(resolve(".tmp/site-locales-browser-")); +const wrap = (id: string, body: string) => `\n${body}\n\n`; +const article = (name: string, chinese: boolean) => `---\ntitle: ${name}\n---\n# ${name}\n` + + wrap("intro", `${chinese ? "[去目标]" : "[Go]"}(b.md#target)\n\n${chinese ? "[本页目标]" : "[Local]"}(#target)\n\n` + Array.from({ length: 35 }, (_, i) => `${chinese ? "示例正文" : "Example prose"} ${i}.`).join("\n\n")) + + wrap("target", `## ${chinese ? "目标" : "Target"}\n\n${chinese ? "目标正文" : "Target body"}\n\n## ${chinese ? "其他" : "Other"}\n\n` + Array.from({ length: 35 }, (_, i) => `Paragraph ${i}.`).join("\n\n")); +const pkg = kbPackage({ name: "locales", template: "src/templates", site: { base: "/", lang: "en", locales: { "zh-CN": { title: "文档" } } } }); +const selected = ["a", "b"].map(name => ({ relPath: `codeindex/${name}.md`, absPath: "/unused", content: article(name, false), + article: { article_id: `article:${name}`, path: `codeindex/${name}.md`, collection: "codeindex" as const, visibility: "public", sections: [] } })); +const mapping = createSiteNavigation(pkg, selected); +let browser: Awaited> | undefined; +const server = createServer(); +try { + for (const page of mapping.pages) { + const file = selected.find(item => `knowledge/${item.relPath}` === page.approved_path)!; + const path = join(root, pkg.outDir, page.package_path); + await mkdir(join(path, ".."), { recursive: true }); await writeFile(path, projectPackageKnowledgeMarkdown(file.content)); + const translated = join(root, "translations/zh-CN", file.relPath); + await mkdir(join(translated, ".."), { recursive: true }); + await writeFile(translated, article(file.article.article_id.slice(-1), true).replace("---\n#", `translation:\n source_digest: ${approvedArticleDigest(file.content)}\n---\n#`)); + } + await writePackageSite({ projectRoot: root, pkg, selected }); + const output = resolve(root, packageSiteOutputDir(pkg)); + server.on("request", async (request, response) => { + try { + const path = resolve(output, "." + decodeURIComponent(new URL(request.url!, "http://localhost").pathname)); + if (!path.startsWith(output + sep)) { response.writeHead(403).end(); return; } + const types: Record = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".json": "application/json" }; + response.setHeader("Content-Type", types[extname(path)] ?? "application/octet-stream"); + response.end(await readFile(path)); + } catch { response.writeHead(404).end(); } + }); + await new Promise(done => server.listen(0, "127.0.0.1", done)); + const address = server.address(); assert(address && typeof address !== "string"); + const origin = `http://127.0.0.1:${address.port}`; + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + page.on("pageerror", error => console.log("PAGE ERROR", error.message)); + page.on("console", message => { if (message.type() === "error") console.log("CONSOLE ERROR", message.text()); }); + page.on("response", response => { if (response.status() >= 400) console.log("HTTP", response.status(), response.url()); }); + await page.addInitScript(() => localStorage.setItem("context-locale", "zh-CN")); + const url = (name: string) => origin + "/" + mapping.pages.find(p => p.artifact_ref === `article:${name}`)!.site_path; + const check = async () => { + console.log("Checking", page.url()); + try { + await page.waitForFunction(() => { + const heading = document.querySelector('[data-context-locales="zh-CN"] [data-context-heading="section-target/1"]'); + const top = heading?.getBoundingClientRect().top ?? -1000; + return document.documentElement.dataset.contextLocale === "zh-CN" && top >= 0 && top < 180; + }, undefined, { timeout: 5000 }); + } catch (error) { + console.log(JSON.stringify(await page.evaluate(() => ({ locale: document.documentElement.dataset.contextLocale, + headings: [...document.querySelectorAll('.VPDoc h2')].map(e => ({ id: e.id, key: (e as HTMLElement).dataset.contextHeading, top: e.getBoundingClientRect().top, visible: !!e.getClientRects().length, + locale: (e.closest('[data-context-locales]') as HTMLElement)?.dataset.contextLocales })) })), null, 2)); + throw error; + } + assert.equal(await page.locator("html").getAttribute("data-context-locale"), "zh-CN"); + }; + await page.goto(url("a")); + await page.getByRole("link", { name: "去目标", exact: true }).click(); await check(); + await page.getByRole("link", { name: "本页目标", exact: true }).click(); await check(); + await page.getByRole("link", { name: "本页目标", exact: true }).click(); await check(); + await page.goto(url("a") + "#target"); await check(); + await page.getByRole("link", { name: "去目标", exact: true }).click(); await check(); + await page.goBack(); await check(); + await page.goForward(); await check(); + console.log("PASS: cross-page, same-page, repeated hash, direct URL, back and forward keep Chinese and the target heading."); +} finally { + await browser?.close(); + await new Promise(done => server.close(() => done())); + await rm(root, { recursive: true, force: true }); +} diff --git a/packages/context-cli/src/__tests__/approvedRevisionReferences.test.ts b/packages/context-cli/src/__tests__/approvedRevisionReferences.test.ts index 6908b9d4..050b99eb 100644 --- a/packages/context-cli/src/__tests__/approvedRevisionReferences.test.ts +++ b/packages/context-cli/src/__tests__/approvedRevisionReferences.test.ts @@ -26,6 +26,13 @@ test("revision resolves exact regions, retains untouched citations and removes d expect(result).toEqual([{ id: "first", references: [reference] }, { id: "second", references: [ createArticleSourceReference(sourceRef, { ...locator, start_line: 3, end_line: 3 }, source), ] }]); + const symbolic = await prepareRevisionReferences({ ...input, edits: [{ section_id: "second", references: [ + { source_ref: sourceRef, locator: { path: "source.md", quote: "Second behavior" } }, + { source_ref: sourceRef, locator: { path: "source.md", start_line: 3, end_line: 3 } }, + { source_ref: sourceRef, locator: { path: "source.md", quote: "Second behavior" } }, + { source_ref: sourceRef, locator: { path: "source.md", quote: "Second behavior" } }, + ] }] }); + expect(symbolic).toEqual(result); expect(await prepareRevisionReferences({ ...input, edits: [{ section_id: "first", references: [] }] })) .toEqual([{ id: "first", references: [] }, { id: "second", references: [] }]); await expect(prepareRevisionReferences({ ...input, edits: [{ section_id: "missing", references: [] }] })).rejects.toThrow(); diff --git a/packages/context-cli/src/__tests__/productionArticleEdits.test.ts b/packages/context-cli/src/__tests__/productionArticleEdits.test.ts index 550ea70d..f78fb1b6 100644 --- a/packages/context-cli/src/__tests__/productionArticleEdits.test.ts +++ b/packages/context-cli/src/__tests__/productionArticleEdits.test.ts @@ -1,3 +1,6 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { expect, test } from "bun:test"; import YAML from "yaml"; import { createArticleSourceReference } from "@c4a/context"; @@ -101,3 +104,26 @@ test("a body-only edit cannot silently accept changed retained evidence", async } }, })).rejects.toThrow("Retained source text changed"); }); + +test("only validated citations grant inherited authority, including repair-draft edits", async () => { + const root = await mkdtemp(join(tmpdir(), "trusted-citations-")); + try { + const planned = { id: "revision", article_id: "article:existing", path: "architecture/existing.md", question: "Clarify", + sources: [], base: `sha256:${"b".repeat(64)}`, batch: "one", after: [], status: "issued" as const }; + const task = { ...planned, input: productionTaskInput(planned) }; + const run = (trustedBase?: ProductionArticleBase, source = "Source text\n", explicit = false) => { + const text = YAML.stringify({ edits: [{ replace: ["a"], with: [{ id: "a", markdown: "Rephrased", + ...(explicit ? { references: [{ source_ref: reference.source_ref, locator: reference.locator }] } : {}) }] }] }); + // A failed complete draft retains coordinates but has no validated digest. + const repair = { ...base, sections: base.sections.map(section => ({ ...section, + references: section.references.map(ref => ({ source_ref: ref.source_ref, locator: ref.locator })) })) }; + return prepareProductionArticle({ projectRoot: root, task, base: repair, + ...(trustedBase ? { trustedBase } : {}), approvedBaseDigest: null, sourceReader: async () => source, + files: { task: task.id, input: task.input, edits: { path: "edits.yaml", text, digest: durableContentDigest(text), bytes: Buffer.byteLength(text) } } }); + }; + expect((await run(base)).source_refs).toEqual([reference.source_ref]); + await expect(run()).rejects.toThrow("outside this task"); + await expect(run(base, "Changed evidence\n")).rejects.toThrow("Retained source text changed"); + await expect(run(base, "Source text\n", true)).rejects.toThrow("outside this task"); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/packages/context-cli/src/__tests__/productionPlanAmendments.test.ts b/packages/context-cli/src/__tests__/productionPlanAmendments.test.ts new file mode 100644 index 00000000..d0a60431 --- /dev/null +++ b/packages/context-cli/src/__tests__/productionPlanAmendments.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { inheritedProductionSources, resolveProductionDependency } from "../project/productionDependencies.js"; +import { migrateProductionDrafts } from "../project/productionDraftMigration.js"; +import { productionAgentDirectory } from "../project/productionSubmissionFiles.js"; +import { productionTaskInput, validateProductionStage, type ProductionTask } from "../project/productionStage.js"; +import { retainProductionRepairDraft } from "../project/productionRepairDraft.js"; +const baseline = `sha256:${"a".repeat(64)}`; +function task(id: string, status: ProductionTask["status"] = "pending") { + const value = { id, article_id: "article:one", path: "architecture/one.md", question: "Explain", + sources: [{ scope: "note:a", baseline }], base: baseline, batch: "one", after: [], status }; + return { ...value, input: productionTaskInput(value) }; +} +function stage(tasks: ProductionTask[]) { + return validateProductionStage({ id: "plan", purpose: "Explain", scopes: [{ scope: "note:a", baseline }], tasks, pending_scopes: [], report_approved: true }); +} + +test("dependency paths choose the current task and every branch rejects retired targets", () => { + const old = task("old", "replaced"); + const current = task("current"); + const value = stage([old, current]); + expect(resolveProductionDependency(current.path, new Map(), value, [])).toBe("current"); + expect(resolveProductionDependency(current.path, new Map([[current.path, "incoming"]]), value, ["current"])).toBe("incoming"); + expect(() => resolveProductionDependency("old", new Map(), value, [])).toThrow("replaced"); + expect(() => resolveProductionDependency("current", new Map(), value, ["current"])).toThrow("being replaced"); + expect(() => resolveProductionDependency(current.path, new Map(), stage([current, task("other")]), [])).toThrow("ambiguous"); +}); + +test("revised articles inherit only authorized and available cited sources", async () => { + const refs = [{ source_ref: "note:a" }, { source_ref: "note:b" }, { source_ref: "note:b" }, { source_ref: "note:c" }, { source_ref: "note:x" }]; + const inherited = await inheritedProductionSources(["note:a"], refs, ["note:a", "note:b", "note:c"], async scope => scope !== "note:c"); + expect(inherited).toEqual(["note:a", "note:b"]); +}); + +test("draft migration preserves existing work and refuses a changed or repair editing base", async () => { + const root = await mkdtemp(join(tmpdir(), "draft-migration-")); + try { + const old = task("old"); const next = task("next"); const value = stage([old]); + const from = join(productionAgentDirectory(value.id), "batches/one/old"); + await mkdir(join(root, from), { recursive: true }); + await writeFile(join(root, from, "edits.yaml"), "# Existing fragment IDs\nedits: []\n"); + expect(await migrateProductionDrafts(root, value, [next], [old.id])).toEqual([]); + const work = "edits:\n - replace: [a]\n with: [{ id: a, markdown: Revised }]\n"; + await writeFile(join(root, from, "edits.yaml"), work); + const target = join(productionAgentDirectory(value.id), "batches/one/next"); + await mkdir(join(root, target), { recursive: true }); + await writeFile(join(root, target, "edits.yaml"), "edits: []\n"); + const result = await migrateProductionDrafts(root, value, [next], [old.id]); + expect(result[0]!.outcome).toBe("copied"); + expect(await readFile(join(root, target, "edits.yaml"), "utf8")).toBe(work); + await writeFile(join(root, result[0]!.to, "edits.yaml"), "Agent work\n"); + expect((await migrateProductionDrafts(root, value, [next], [old.id]))[0]!.outcome).toBe("kept"); + expect(await readFile(join(root, result[0]!.to, "edits.yaml"), "utf8")).toBe("Agent work\n"); + expect((await migrateProductionDrafts(root, value, [{ ...task("changed"), base: null }], [old.id]))[0]!.outcome).toBe("rebase-required"); + const fixed = (text: string) => ({ path: "draft", text, digest: baseline, bytes: text.length }); + await retainProductionRepairDraft({ projectRoot: root, stage: value.id, task: old, + files: { task: old.id, input: old.input, content: fixed("Different failed draft"), references: fixed("sections: []") } }); + expect((await migrateProductionDrafts(root, value, [task("repair")], [old.id]))[0]!.outcome).toBe("rebase-required"); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/packages/context-cli/src/__tests__/productionPlanning.integration.test.ts b/packages/context-cli/src/__tests__/productionPlanning.integration.test.ts index 3450b2a0..4d871c8d 100644 --- a/packages/context-cli/src/__tests__/productionPlanning.integration.test.ts +++ b/packages/context-cli/src/__tests__/productionPlanning.integration.test.ts @@ -167,6 +167,9 @@ test("writing amendments append once, preserve issued work and explicitly replac expect(appended.tasks[1]!.status).toBe("pending"); expect(command(projectRoot, args).stage_state).toBe("active"); expect((await readProductionStage(projectRoot))!.tasks).toEqual(appended.tasks); + await writeFile(amendmentPath, YAML.stringify({ articles: [{ ...article, path: "decision/unstaged.md" }] })); + await expect(submitProductionPlan({ projectRoot, stage: stage.id, path: "submissions/amendment.yaml" })).rejects.toMatchObject({ + detail: { reason_code: "production-plan-stage-missing", example: { stage: stage.id } } }); const replacement = { ...plan, replaces: [issued.id], articles: [{ ...article, path: "decision/revised.md" }] }; await writeFile(amendmentPath, YAML.stringify(replacement)); await expect(submitProductionPlan({ projectRoot, stage: stage.id, path: "submissions/amendment.yaml" })).rejects.toThrow("dependent plans together"); diff --git a/packages/context-cli/src/__tests__/productionRevision.integration.test.ts b/packages/context-cli/src/__tests__/productionRevision.integration.test.ts index 4179b426..f70d8f64 100644 --- a/packages/context-cli/src/__tests__/productionRevision.integration.test.ts +++ b/packages/context-cli/src/__tests__/productionRevision.integration.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import YAML from "yaml"; import { prepareRevisionKnowledge } from "./initialRevisionKnowledge.fixture.js"; import { beginDocumentRevision } from "../project/documentRevision.js"; -import { readProductionStage } from "../project/productionStageStore.js"; +import { prepareNextProductionStage, productionStageDirectory, readProductionStage } from "../project/productionStageStore.js"; import { readCandidateRecords } from "../project/candidateLedger.js"; import { productionAgentDirectory } from "../project/productionSubmissionFiles.js"; import { completeProductionSubmission } from "../project/productionSubmission.js"; @@ -58,6 +58,25 @@ test("a selected candidate reopens writing directly and preserves its peer and a expect(await readFile(join(root, "knowledge", selected.path), "utf8")).toContain("# Exported value"); }); +test("a revision task projects an edits skeleton but keeps a complete draft written before it", async () => { + const root = await prepareRevisionKnowledge(roots); + const selected = (await readCandidateRecords(root)).find(candidate => candidate.path === "architecture/overview.md")!; + await beginDocumentRevision({ projectRoot: root, selector: selected.candidate_id, instruction: "Clarify the exported value." }); + const stage = (await readProductionStage(root))!; + const task = stage.tasks.find(task => task.status === "issued")!; + const output = join(root, productionAgentDirectory(stage.id), "batches", task.batch, task.id); + const taskFile = join(root, productionStageDirectory(stage.id), "batches", task.batch, "tasks", task.id, "task.md"); + expect(YAML.parse(await readFile(join(output, "edits.yaml"), "utf8"))).toEqual({ edits: [] }); + expect(await readFile(taskFile, "utf8")).toContain("Draft edits:"); + await rm(join(output, "edits.yaml")); + await writeFile(join(output, "article.md"), "# Complete draft\n"); + const prepared = (await prepareNextProductionStage({ projectRoot: root, stage }))!; + const manifest = YAML.parse(await readFile(join(root, prepared.submission!), "utf8")); + expect(manifest.tasks.find((entry: { task: string }) => entry.task === task.id)).toMatchObject({ content: expect.stringContaining("article.md") }); + expect(await readFile(taskFile, "utf8")).toContain("Draft Markdown:"); + await expect(readFile(join(output, "edits.yaml"), "utf8")).rejects.toThrow(); +}); + test("a production revision cannot silently move an article onto its peer", async () => { const root = await prepareRevisionKnowledge(roots); const before = await readCandidateRecords(root); diff --git a/packages/context-cli/src/__tests__/siteHeadingPairs.test.ts b/packages/context-cli/src/__tests__/siteHeadingPairs.test.ts new file mode 100644 index 00000000..0e8d83ac --- /dev/null +++ b/packages/context-cli/src/__tests__/siteHeadingPairs.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { siteMarkdownConfig } from "../project/packageSiteTheme.js"; + +test("renderer pairs repeated translated headings only when section shapes agree", async () => { + const block = (locale: string, body: string) => `\n\n\n\n${body}\n\n\n`; + const script = ` + import assert from 'node:assert/strict'; + import {createMarkdownRenderer} from ${JSON.stringify(import.meta.resolve("vitepress"))}; + const contextLocaleMarker = 'context-test'; + const renderer = await createMarkdownRenderer(process.cwd(), ({${siteMarkdownConfig}})); + const good = renderer.render(${JSON.stringify(block("en", "## Same\n\n## Same") + block("zh-CN", "## 相同\n\n## 相同"))}); + assert.equal((good.match(/data-context-heading="section-a\\/1"/g) || []).length, 2); + assert.equal((good.match(/data-context-heading="section-a\\/2"/g) || []).length, 2); + const bad = renderer.render(${JSON.stringify(block("en", "## First\n\n### Detail") + block("zh-CN", "## 第一\n\n## 详情"))}); + assert.ok(!bad.includes('data-context-heading')); + console.log('heading pairing verified'); + `; + const result = await promisify(execFile)("node", ["--input-type=module", "-e", script], { timeout: 20000 }); + expect(result.stdout).toContain("heading pairing verified"); +}, 25000); diff --git a/packages/context-cli/src/__tests__/siteTranslation.test.ts b/packages/context-cli/src/__tests__/siteTranslation.test.ts index fb38c309..bdf4b4f4 100644 --- a/packages/context-cli/src/__tests__/siteTranslation.test.ts +++ b/packages/context-cli/src/__tests__/siteTranslation.test.ts @@ -24,6 +24,25 @@ test("translations must keep sections, code, languages and link targets", () => expect(translationStructureIssues(approved, translated.replace("(next.md)", "(other.md)"))).toHaveLength(1); }); +test("plain-text fences allow translated prose while preserving block count and language", () => { + for (const lang of ["text", "txt", "plaintext"]) { + const source = `---\ntitle: Guide\n---\n\n\`\`\`${lang}\nExplain and run context status.\n\`\`\`\n`; + const translatedText = source.replace("Explain and run context status.", "解释 ,并运行 context status。"); + expect(translationStructureIssues(source, translatedText)).toEqual([]); + expect(translationStructureIssues(source, translatedText.replace(`\`\`\`${lang}\n`, "\`\`\`bash\n"))).toHaveLength(1); + expect(translationStructureIssues(source, "---\ntitle: Guide\n---\n")).toHaveLength(1); + expect(translationStructureIssues(source, translatedText + "\n\`\`\`text\nExtra\n\`\`\`\n")).toHaveLength(1); + } +}); + +test("unmarked and programming-language fences still reject content changes", () => { + for (const lang of ["", "bash", "yaml", "json", "ts"]) { + const source = `---\ntitle: Guide\n---\n\n\`\`\`${lang}\noriginal\n\`\`\`\n`; + expect(translationStructureIssues(source, source)).toEqual([]); + expect(translationStructureIssues(source, source.replace("original", "changed"))).toHaveLength(1); + } +}); + test("server-rendered origin nav links skip the site base", () => { const html = 'T' + 'Home' + diff --git a/packages/context-cli/src/__tests__/siteTranslationIncremental.test.ts b/packages/context-cli/src/__tests__/siteTranslationIncremental.test.ts new file mode 100644 index 00000000..f49348bb --- /dev/null +++ b/packages/context-cli/src/__tests__/siteTranslationIncremental.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { parse as yaml } from "yaml"; +import type { PackageDefinition } from "@c4a/context"; +import { approvedArticleDigest, prepareSiteTranslation, submitSiteTranslation } from "../project/siteTranslation.js"; +const source = '---\ntitle: Guide\n---\n# Guide\n\nFirst\n\n'; +const translated = source.replaceAll("Guide", "指南").replace("First", "第一节"); +const pkg = { kind: "package.kb", name: "kb", outDir: "dist/kb", reads: [], writes: [], template: { path: "src/templates", vars: {} }, site: { locales: { "zh-CN": {} } } } as unknown as PackageDefinition; + +test("legacy migration and retained drafts never silently adopt a newer source", async () => { + const root = await mkdtemp(join(tmpdir(), "translation-incremental-")); + try { + await mkdir(join(root, "knowledge/codeindex"), { recursive: true }); + await mkdir(join(root, "translations/zh-CN/codeindex"), { recursive: true }); + const original = join(root, "knowledge/codeindex/a.md"); + const destination = join(root, "translations/zh-CN/codeindex/a.md"); + await writeFile(original, source); + await writeFile(destination, translated.replace("title: 指南", `title: 指南\ntranslation:\n source_digest: ${approvedArticleDigest(source)}`)); + expect((await prepareSiteTranslation(root, [pkg])).items).toEqual([]); + expect(await readFile(destination, "utf8")).toContain("source_parts:"); + await writeFile(original, source.replace("First", "Changed")); + const first = await prepareSiteTranslation(root, [pkg]); + const item = first.items.find(item => item.kind === "article")!; + if (item.kind !== "article") throw new Error("Missing article"); + expect(item.diff).toMatchObject({ modified: ["a"] }); + await writeFile(join(root, item.file), translated.replace("第一节", "已修改的译文")); + const kept = await prepareSiteTranslation(root, [pkg]); + expect(kept.items[0]).toMatchObject({ draft: "kept" }); + await writeFile(original, source.replace("First", "Changed again")); + const newer = await prepareSiteTranslation(root, [pkg]); + expect(newer.items[0]).toMatchObject({ draft: "rebase-required" }); + expect(yaml(await readFile(join(root, newer.submission_path), "utf8")).articles).toEqual([]); + const input = { expected_digest: newer.expected_digest!, articles: [{ locale: "zh-CN", approved_path: item.approved_path, file: item.file }], map_entries: [] }; + await expect(submitSiteTranslation(root, [pkg], input, file => join(root, file))).rejects.toThrow("nothing was saved"); + // Explicitly reconciling the source version is a new author decision. + await submitSiteTranslation(root, [pkg], { ...input, articles: [{ ...input.articles[0]!, source_digest: approvedArticleDigest(source.replace("First", "Changed again")) }] }, file => join(root, file)); + expect(await readFile(destination, "utf8")).toContain("已修改的译文"); + await writeFile(destination, translated.replace("title: 指南", "title: 指南\ntranslation:\n source_digest: old")); + const unknown = await prepareSiteTranslation(root, [pkg]); + expect(unknown.items[0]).toMatchObject({ diff: "unknown" }); + expect(await readFile(join(root, `${item.file}.previous.md`), "utf8")).toContain("第一节"); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("a draft without a generation record is set aside and regenerated", async () => { + const root = await mkdtemp(join(tmpdir(), "translation-unrecorded-")); + try { + await mkdir(join(root, "knowledge/codeindex"), { recursive: true }); + await writeFile(join(root, "knowledge/codeindex/a.md"), source); + const file = ".tmp/agent-payloads/translations/zh-CN/codeindex/a.md"; + await mkdir(join(root, ".tmp/agent-payloads/translations/zh-CN/codeindex"), { recursive: true }); + await writeFile(join(root, file), "Leftover draft\n"); + const prepared = await prepareSiteTranslation(root, [pkg]); + expect(prepared.items[0]).toMatchObject({ draft: "generated", unrecorded_draft: `${file}.unrecorded.md` }); + expect(await readFile(join(root, `${file}.unrecorded.md`), "utf8")).toBe("Leftover draft\n"); + expect(await readFile(join(root, file), "utf8")).toContain("First"); + expect(yaml(await readFile(join(root, prepared.submission_path), "utf8")).articles).toHaveLength(1); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/packages/context-cli/src/__tests__/siteTranslationParts.test.ts b/packages/context-cli/src/__tests__/siteTranslationParts.test.ts new file mode 100644 index 00000000..a5f9016c --- /dev/null +++ b/packages/context-cli/src/__tests__/siteTranslationParts.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import { translationDraft, translationPartsDiff, translationSourceParts } from "../project/siteTranslationParts.js"; +const wrap = (id: string, text: string) => `\n${text}\n\n`; +const source = `---\ntitle: Guide\ndescription: Description\n---\nIntro\n${wrap("a", "First") }Between\n${wrap("b", "Second")}Tail\n`; +const translated = source.replace("Guide", "指南").replace("Description", "介绍").replace("Intro", "开头").replace("First", "第一节").replace("Between", "中间").replace("Second", "第二节").replace("Tail", "结尾"); + +test("incremental draft preserves translations and all exterior positions", () => { + const changed = source.replace("Second", "Second changed"); + const baseline = translationSourceParts(source); + const draft = translationDraft(changed, translated, baseline); + expect(draft).toContain("第一节"); + expect(draft).toContain("Second changed"); + expect(draft).toContain("开头"); + expect(draft).toContain("中间"); + expect(draft).toContain("结尾"); + expect(translationPartsDiff(translationSourceParts(changed), baseline)).toEqual({ title: false, description: false, preamble: false, order: false, added: [], deleted: [], modified: ["b"] }); +}); + +test("metadata, tail, section order and unknown baselines are explicit", () => { + const baseline = translationSourceParts(source); + expect(translationPartsDiff(translationSourceParts(source.replace("Tail", "New tail")), baseline)).toMatchObject({ preamble: true, modified: [] }); + expect(translationPartsDiff(translationSourceParts(source.replace("Guide", "New title")), baseline)).toMatchObject({ title: true, description: false, modified: [] }); + const reordered = source.replace(wrap("a", "First"), "TEMP").replace(wrap("b", "Second"), wrap("a", "First")).replace("TEMP", wrap("b", "Second")); + expect(translationPartsDiff(translationSourceParts(reordered), baseline)).toMatchObject({ order: true, modified: [] }); + expect(translationPartsDiff(baseline)).toBe("unknown"); + expect(translationDraft(source, translated)).toContain("First"); + const plain = "---\ntitle: Plain\n---\nAll outside\n"; + expect(translationDraft(plain, plain.replace("All outside", "全文"), translationSourceParts(plain))).toContain("全文"); +}); diff --git a/packages/context-cli/src/__tests__/submittedSourceLocator.test.ts b/packages/context-cli/src/__tests__/submittedSourceLocator.test.ts new file mode 100644 index 00000000..dd7c3a66 --- /dev/null +++ b/packages/context-cli/src/__tests__/submittedSourceLocator.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { articleSourceLocatorSchema, approvedRevisionSemanticInputSchema } from "@c4a/context"; +import { resolveSubmittedSourceLocator, submittedSourceLocatorSchema } from "../project/submittedSourceLocator.js"; + +test("headings resolve sections, ignore fences and stop at same or higher level", () => { + const text = "# Start\nIntro\n## Usage\nUse it\n### Detail\nMore\n\n## End\nDone\n"; + expect(resolveSubmittedSourceLocator({ path: "guide.md", heading: "Usage" }, text)).toEqual({ path: "guide.md", start_line: 3, end_line: 6 }); + expect(resolveSubmittedSourceLocator({ path: "guide.md", heading: "Usage" }, "```md\n## Usage\n```\n## Usage\nReal\n")).toEqual({ path: "guide.md", start_line: 4, end_line: 5 }); + expect(() => resolveSubmittedSourceLocator({ path: "guide.md", heading: "Usage" }, "## Usage\nA\n## Usage\nB")).toThrow("1-2, 3-4"); + expect(() => resolveSubmittedSourceLocator({ path: "code.ts", heading: "Usage" }, text)).toThrow("Markdown"); +}); + +test("quote is exact and unique, with normalized line endings and overlapping matches", () => { + expect(resolveSubmittedSourceLocator({ path: "guide.md", quote: "使用\n方法" }, "前言\r\n使用\r\n方法\r\n尾声")).toEqual({ path: "guide.md", start_line: 2, end_line: 3 }); + expect(() => resolveSubmittedSourceLocator({ path: "a.ts", quote: "aa" }, "aaa")).toThrow("2 regions"); + expect(() => resolveSubmittedSourceLocator({ path: "a.ts", quote: "missing" }, "text")).toThrow("candidates: none"); + expect(submittedSourceLocatorSchema.safeParse({ path: "guide.md", heading: "A", quote: "A" }).success).toBe(false); + expect(submittedSourceLocatorSchema.safeParse({ path: "../guide.md", quote: "A" }).success).toBe(false); +}); + +test("author schema accepts symbolic inputs without changing durable locator schema", () => { + const locator = { path: "guide.md", quote: "Unique" }; + expect(approvedRevisionSemanticInputSchema.safeParse({ stage: "approved-revision", sections: [{ section_id: "a", references: [{ source_ref: "note:a", locator }] }] }).success).toBe(true); + expect(articleSourceLocatorSchema.safeParse(locator).success).toBe(false); +}); diff --git a/packages/context-cli/src/commands/translateCommands.ts b/packages/context-cli/src/commands/translateCommands.ts index cc6f3500..20e7967f 100644 --- a/packages/context-cli/src/commands/translateCommands.ts +++ b/packages/context-cli/src/commands/translateCommands.ts @@ -60,7 +60,7 @@ export function registerTranslateCommands(program: Command) { write({ ...task, next_action: { command: "context translate submit --input --format json" }, input_example: { expected_digest: task.expected_digest, articles: task.items.filter(item => item.kind === "article").slice(0, 1).map(item => ({ locale: item.locale, - approved_path: item.kind === "article" ? item.approved_path : "", file: `.tmp/agent-payloads/${item.kind === "article" ? item.translation_path : ""}` })), + approved_path: item.kind === "article" ? item.approved_path : "", file: item.kind === "article" ? item.file : "" })), map_entries: task.items.filter(item => item.kind === "map-entry").slice(0, 1).map(item => ({ locale: item.locale, key: item.kind === "map-entry" ? item.key : "", title: "Translated title" })) } }); }); diff --git a/packages/context-cli/src/lib/pathFreePathFieldInventory.ts b/packages/context-cli/src/lib/pathFreePathFieldInventory.ts index 6ca0e485..6f2cdd3e 100644 --- a/packages/context-cli/src/lib/pathFreePathFieldInventory.ts +++ b/packages/context-cli/src/lib/pathFreePathFieldInventory.ts @@ -72,6 +72,7 @@ const DEFAULT_INTERNAL_FIELDS = [ "structure_path", "stage_path", "task_path", + "submission_path", "entry_path", "prompt_path", "packageDir", @@ -130,6 +131,7 @@ function policyFor(field: string): PathFieldInventoryEntry["policy"] { field === "runner_config_path" || field === "stage_path" || field === "task_path" || + field === "submission_path" || field === "entry_path" || field === "program_path" || field === "bundle_root" || diff --git a/packages/context-cli/src/project/approvedRevisionEdits.ts b/packages/context-cli/src/project/approvedRevisionEdits.ts index 753dca0d..48292c5d 100644 --- a/packages/context-cli/src/project/approvedRevisionEdits.ts +++ b/packages/context-cli/src/project/approvedRevisionEdits.ts @@ -1,11 +1,11 @@ import { approvedContextSectionsInMarkdown } from "./verifyContextSections.js"; import { expandRevisionProgramBlocks, type RevisionProgramBlock } from "./approvedRevisionPrograms.js"; -import type { ArticleSourceReference } from "@c4a/context"; +import type { SubmittedSourceReference } from "./submittedSourceLocator.js"; export interface RevisionContentInput { markdown?: string; sections?: Array<{ section_id: string; content?: Array<{ markdown: string } | { program: string }> | undefined; - references?: Array> | undefined }>; + references?: Array | undefined }>; } /** Replace only explicitly identified source-bound sections. All untouched bytes diff --git a/packages/context-cli/src/project/approvedRevisionReferences.ts b/packages/context-cli/src/project/approvedRevisionReferences.ts index 752f7c9b..7ada9063 100644 --- a/packages/context-cli/src/project/approvedRevisionReferences.ts +++ b/packages/context-cli/src/project/approvedRevisionReferences.ts @@ -1,3 +1,4 @@ +import { resolveSubmittedSourceLocator } from "./submittedSourceLocator.js"; import { articleFragmentReferences, articleSourceRegionDigest, createArticleSourceReference, locateArticleRegion, repoContentScopeMatches, type ArticleStructureEntry } from "@c4a/context"; import { readArticleRegionBaseline, rememberArticleRegion } from "./articleRegionBaselines.js"; @@ -27,11 +28,10 @@ export async function prepareRevisionReferences(input: { for (const id of input.sectionIds) { const edit = edits.get(id); if (edit?.references !== undefined) { - if (edit.references.length > 3) throw new TypeError("A fragment can cite at most three source positions"); const references = []; for (const reference of edit.references) { const text = await readSource(reference.source_ref, reference.locator.path); - const resolved = createArticleSourceReference(reference.source_ref, reference.locator, text); + const resolved = createArticleSourceReference(reference.source_ref, resolveSubmittedSourceLocator(reference.locator, text), text); rememberArticleRegion(input.projectRoot, resolved, text); references.push(resolved); } diff --git a/packages/context-cli/src/project/indexerBaseContracts.ts b/packages/context-cli/src/project/indexerBaseContracts.ts index d31582f0..8a8870af 100644 --- a/packages/context-cli/src/project/indexerBaseContracts.ts +++ b/packages/context-cli/src/project/indexerBaseContracts.ts @@ -21,7 +21,7 @@ import { bundledMarkdownReaderQuestionContracts } from "./indexerBaseMarkdownAuthoringCatalog.js"; const BASE_CONTRACT_VERSION = "1.1.0"; -export const BUNDLED_INDEXER_PARSER_PACKAGE_VERSION = "0.7.67"; +export const BUNDLED_INDEXER_PARSER_PACKAGE_VERSION = "0.7.70"; const BUNDLED_PARSER_REQUIREMENTS = buildIndexerParserCapabilityRequirements( BUNDLED_INDEXER_PARSER_PACKAGE_VERSION, ); diff --git a/packages/context-cli/src/project/indexerCurrentAction.ts b/packages/context-cli/src/project/indexerCurrentAction.ts index d99569db..7ce7c982 100644 --- a/packages/context-cli/src/project/indexerCurrentAction.ts +++ b/packages/context-cli/src/project/indexerCurrentAction.ts @@ -75,7 +75,7 @@ export async function completeCurrentIndexerAction(input: { revision: input.revision, ...content }); return completedRevisionReceipt(found.projectRoot, semantic.stage, { outcome: candidate === undefined ? "unchanged" : "candidate-prepared", - ...(candidate === undefined ? {} : { candidate_id: candidate.candidate_id }) }, input.managed === true, authorities); + ...(candidate === undefined ? {} : { candidate_id: candidate.candidate_id, resolved_references: candidate.indexer_candidate?.sections.flatMap(section => section.references.map(ref => ({ section: section.section_key, source_ref: ref.source_ref, locator: ref.locator }))) ?? [] }) }, input.managed === true, authorities); } if (semantic.stage === "structure-review") { const { readKnowledgeUpdate, completeUpdateStructureReview } = await import("./knowledgeUpdate.js"); diff --git a/packages/context-cli/src/project/packageSiteTheme.ts b/packages/context-cli/src/project/packageSiteTheme.ts index 57f319e3..f211cbfe 100644 --- a/packages/context-cli/src/project/packageSiteTheme.ts +++ b/packages/context-cli/src/project/packageSiteTheme.ts @@ -1,3 +1,4 @@ +import { siteHeadingPairingRule } from "./siteHeadingPairs.js"; import { readerContentStyles } from "./readerContentStyles.js"; import { DIAGRAM_VIEWER_SCRIPT } from "./diagramViewer.js"; import { DIAGRAM_STYLES } from "./diagramStyles.js"; @@ -109,6 +110,7 @@ export const siteMarkdownConfig = String.raw` state.line = line + 1; return true; }, { alt: ['paragraph'] }); +${siteHeadingPairingRule} md.renderer.rules.context_locale = (tokens, index) => { const meta = tokens[index].meta; if (meta.tags) return '
\n'; @@ -168,27 +170,49 @@ export default { (parseInt(getComputedStyle(element).paddingTop, 10) || 0)); // Anchors repeat across languages and search results can point into a hidden one. const revealHash = (switched = false) => { - const id = decodeURIComponent(location.hash.slice(1)); + let id; + try { id = decodeURIComponent(location.hash.slice(1)); } catch { return; } if (!id) return; const targets = [...document.querySelectorAll('[id="' + CSS.escape(id) + '"]')]; const visible = targets.find(element => element.getClientRects().length); // The browser already scrolled to the first match unless it was hidden. if (visible) { if (switched || visible !== targets[0]) scrollToElement(visible); return; } + const key = targets[0]?.dataset.contextHeading; + if (key) { + const paired = [...document.querySelectorAll('[data-context-heading="' + CSS.escape(key) + '"]')] + .find(element => element.getClientRects().length); + if (paired) { scrollToElement(paired); return; } + } const block = targets[0]?.closest('[data-context-locales]'); if (!block) return; setLocale(block.dataset.contextLocales.split(' ')[0]); nextTick(() => requestAnimationFrame(() => revealHash(true))); }; - onContentUpdated(() => { applyLocale(); setTimeout(() => revealHash()); }); - const onHashChange = () => revealHash(); + // VitePress schedules its own scroll in a frame after rendering. Apply the locale target afterwards. + const scheduleReveal = () => nextTick(() => requestAnimationFrame(() => requestAnimationFrame(() => revealHash(true)))); + onContentUpdated(() => { applyLocale(); scheduleReveal(); }); + const onHashChange = scheduleReveal; + const onAnchorClick = event => { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + const link = event.target?.closest?.('a[href]'); + if (!link || link.hasAttribute('download') || link.hasAttribute('target')) return; + const url = new URL(link.href, location.href); + if (url.origin === location.origin && url.pathname === location.pathname && url.search === location.search && url.hash) { + scheduleReveal(); + } + }; const switchToOriginal = event => { if (event.target.closest?.('.context-locale-original')) setLocale(readers.value.primary); }; onMounted(() => { locale.value = activeLocale(); window.addEventListener('hashchange', onHashChange); + window.addEventListener('popstate', onHashChange); + window.addEventListener('click', onAnchorClick, true); document.addEventListener('click', switchToOriginal); }); onUnmounted(() => { window.removeEventListener('hashchange', onHashChange); + window.removeEventListener('popstate', onHashChange); + window.removeEventListener('click', onAnchorClick, true); document.removeEventListener('click', switchToOriginal); }); watch(locale, () => nextTick(applyLocale)); diff --git a/packages/context-cli/src/project/productionArticle.ts b/packages/context-cli/src/project/productionArticle.ts index abb75aa1..be21db96 100644 --- a/packages/context-cli/src/project/productionArticle.ts +++ b/packages/context-cli/src/project/productionArticle.ts @@ -1,3 +1,4 @@ +import { resolveSubmittedSourceLocator } from "./submittedSourceLocator.js"; import YAML from "yaml"; import { z } from "zod"; import { articleFragmentReferences, createArticleSourceReference, @@ -73,6 +74,8 @@ export async function prepareProductionArticle(input: { sourceReader?: ProductionSourceReader; visibility?: string; base?: ProductionArticleBase; + /** Validated candidate/approved references only; repair drafts never grant authority. */ + trustedBase?: ProductionArticleBase; approvedBaseDigest: string | null; }): Promise { const { task } = input; @@ -124,6 +127,7 @@ export async function prepareProductionArticle(input: { // Report all oversized fragments together before reading source bodies. // Duplicate declarations of the same region still count only once. const oversized = declared.sections.flatMap(section => { + if (section.references.some(ref => !("start_line" in ref.locator))) return []; const count = new Set(section.references.map(ref => indexerProtocolDigest({ source_ref: ref.source_ref, locator: ref.locator, }))).size; @@ -138,10 +142,14 @@ export async function prepareProductionArticle(input: { const references = []; for (const ref of byId.get(fragment.id)!) { try { - if (![...allowed].some(scope => repoContentScopeMatches(scope, ref.source_ref))) throw new TypeError(`Source is outside this task's authorized inputs: ${ref.source_ref}`); + const inherited = retained?.get(fragment.id)?.some(value => value.source_ref === ref.source_ref && + indexerProtocolDigest(value.locator) === indexerProtocolDigest(ref.locator)); + const trusted = inherited ? input.trustedBase?.sections.find(section => section.id === fragment.id)?.references.find(value => + value.source_ref === ref.source_ref && indexerProtocolDigest(value.locator) === indexerProtocolDigest(ref.locator)) : undefined; + if (!trusted?.content_digest && ![...allowed].some(scope => repoContentScopeMatches(scope, ref.source_ref))) throw new TypeError(`Source is outside this task's authorized inputs: ${ref.source_ref}`); const text = await read(ref.source_ref, ref.locator.path, true); - const reference = createArticleSourceReference(ref.source_ref, ref.locator, text); - const previous = retained?.get(fragment.id)?.find(value => value.source_ref === ref.source_ref && + const reference = createArticleSourceReference(ref.source_ref, resolveSubmittedSourceLocator(ref.locator, text), text); + const previous = trusted ?? retained?.get(fragment.id)?.find(value => value.source_ref === ref.source_ref && indexerProtocolDigest(value.locator) === indexerProtocolDigest(ref.locator)); if (previous?.content_digest && previous.content_digest !== reference.content_digest) { throw new TypeError("Retained source text changed. Read it and explicitly revise this fragment's references; a body-only edit cannot confirm changed evidence."); diff --git a/packages/context-cli/src/project/productionArticleEdits.ts b/packages/context-cli/src/project/productionArticleEdits.ts index fd8f7401..617067b2 100644 --- a/packages/context-cli/src/project/productionArticleEdits.ts +++ b/packages/context-cli/src/project/productionArticleEdits.ts @@ -1,11 +1,11 @@ +import { submittedSourceLocatorSchema, type SubmittedSourceReference } from "./submittedSourceLocator.js"; import YAML from "yaml"; import { z } from "zod"; -import { articleSourceLocatorSchema, type ArticleSourceReference } from "@c4a/context"; import { approvedContextSectionsInMarkdown } from "./verifyContextSections.js"; import { durableContentDigest } from "./durableSingleFileTransaction.js"; import type { FixedProductionFile, FixedProductionTask } from "./productionSubmissionFiles.js"; -export const productionReferenceInputSchema = z.object({ source_ref: z.string().min(1), locator: articleSourceLocatorSchema }).strict(); +export const productionReferenceInputSchema = z.object({ source_ref: z.string().min(1), locator: submittedSourceLocatorSchema }).strict(); const fragment = z.object({ id: z.string().min(1).regex(/^[^"\r\n<>]+$/u), markdown: z.string().optional(), @@ -22,7 +22,7 @@ export const productionArticleEditsSchema = z.object({ }).strict()).min(1), }).strict(); -type BaseReference = Omit & { content_digest?: string }; +type BaseReference = SubmittedSourceReference & { content_digest?: string }; export interface ProductionArticleBase { markdown: string; sections: Array<{ id: string; references: BaseReference[] }>; @@ -54,7 +54,7 @@ export function applyProductionArticleEdits(base: ProductionArticleBase, files: }); const used = new Set(); const retainedReferences = new Map(previous); - const resulting = new Map>>(previous); + const resulting = new Map>(previous); const patches: Array<{ start: number; end: number; text: string }> = []; const anchors: string[] = []; const additions = new Set(); @@ -103,7 +103,7 @@ export function applyProductionArticleEdits(base: ProductionArticleBase, files: }).join("\n"); patches.push({ start, end, text: replacement }); } - if (anchors.some(id => used.has(id))) throw new TypeError("An insertion anchor cannot also be replaced in the same revision. Include the new fragment in that replacement instead."); + if (anchors.some(id => used.has(id))) throw new TypeError("An insertion anchor cannot also be replaced in the same revision. Include the new fragment in that replacement instead. Example:\nedits:\n - replace: [existing-section]\n with:\n - id: existing-section\n - id: new-section\n markdown: New content\n references: []"); let markdown = base.markdown; for (const patch of patches.sort((a, b) => b.start - a.start || b.end - a.end)) { markdown = markdown.slice(0, patch.start) + patch.text + markdown.slice(patch.end); diff --git a/packages/context-cli/src/project/productionDependencies.ts b/packages/context-cli/src/project/productionDependencies.ts new file mode 100644 index 00000000..a1350f7c --- /dev/null +++ b/packages/context-cli/src/project/productionDependencies.ts @@ -0,0 +1,31 @@ +import { repoContentScopeMatches } from "@c4a/context"; +import type { ProductionStage } from "./productionStage.js"; + +/** Sources cited by the article being revised join its task when they are + * already authorized. Unavailable history is still inherited at submission; + * it is never promoted into a new read permission or captured baseline. */ +export async function inheritedProductionSources(sources: readonly string[], references: ReadonlyArray<{ source_ref: string }>, + authorized: readonly string[], available: (scope: string) => Promise): Promise { + const result = [...sources]; + for (const ref of references) { + const scope = authorized.find(value => repoContentScopeMatches(value, ref.source_ref)); + if (scope && !result.includes(scope) && await available(scope)) result.push(scope); + } + return result; +} + +export function resolveProductionDependency(value: string, incoming: ReadonlyMap, stage: ProductionStage, replaces: readonly string[]): string { + const fresh = incoming.get(value); + if (fresh) return fresh; + const valid = (task: ProductionStage["tasks"][number]) => !replaces.includes(task.id) && !["replaced", "excluded"].includes(task.status); + const byId = stage.tasks.find(task => task.id === value); + if (byId) { + if (valid(byId)) return byId.id; + throw new TypeError(`Dependency ${value} is ${replaces.includes(value) ? "being replaced" : byId.status}; use the replacement article path or an active task ID.`); + } + const all = stage.tasks.filter(task => task.path === value); + const active = all.filter(task => valid(task) && ["pending", "issued", "blocked"].includes(task.status)); + const candidates = active.length ? active : all.filter(task => valid(task) && task.status === "accepted"); + if (candidates.length === 1) return candidates[0]!.id; + throw new TypeError(`Unknown dependency article path or existing task identity, or ambiguous dependency: ${value}. Candidates: ${all.map(task => `${task.id} (${task.status})`).join(", ") || "none"}. Set after to one current task ID.`); +} diff --git a/packages/context-cli/src/project/productionDraftMigration.ts b/packages/context-cli/src/project/productionDraftMigration.ts new file mode 100644 index 00000000..5550be42 --- /dev/null +++ b/packages/context-cli/src/project/productionDraftMigration.ts @@ -0,0 +1,54 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import YAML from "yaml"; +import { indexerProtocolDigest } from "@c4a/context"; +import { productionAgentDirectory } from "./productionSubmissionFiles.js"; +import { readProductionRepairDraft } from "./productionRepairDraft.js"; +import { safeProjectTarget, runDurableMultiFileTransaction } from "./durableMultiFileTransaction.js"; +import { durableContentDigest } from "./durableSingleFileTransaction.js"; +import type { ProductionStage, ProductionTask } from "./productionStage.js"; + +/** The projected edits skeleton carries no Agent work. */ +function emptyEdits(text: string): boolean { + try { const value = YAML.parse(text); return Array.isArray(value?.edits) && value.edits.length === 0 && Object.keys(value).length === 1; } + catch { return false; } +} + +export interface DraftMigration { from: string; to: string; outcome: "copied" | "kept" | "rebase-required" } +/** Called under the planning write lock. A repair draft is not the task.base + * snapshot; never attach its edits to a different editing base. */ +export async function migrateProductionDrafts(root: string, stage: ProductionStage, tasks: readonly ProductionTask[], replaces: readonly string[]): Promise { + const result: DraftMigration[] = []; + const read = async (path: string) => { + try { return await readFile(await safeProjectTarget(root, path), "utf8"); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } + }; + for (const task of tasks) { + const previous = stage.tasks.filter(old => replaces.includes(old.id) && old.article_id === task.article_id && old.path === task.path); + if (previous.length !== 1) continue; + const old = previous[0]!; + const from = join(productionAgentDirectory(stage.id), "batches", old.batch, old.id); + const to = join(productionAgentDirectory(stage.id), "batches", task.batch, task.id); + const names = ["edits.yaml", "article.md", "references.yaml"]; + const draftsIn = async (directory: string) => (await Promise.all(names.map(async name => ({ name, content: await read(join(directory, name)) })))) + .filter((item): item is { name: string; content: string } => item.content !== undefined && !(item.name === "edits.yaml" && emptyEdits(item.content))); + const drafts = await draftsIn(from); + if (!drafts.length) continue; + // A retained failed draft has no validated digest. Its editing provenance + // cannot be transferred just because the formal target is unchanged. + const repair = await readProductionRepairDraft({ projectRoot: root, stage: stage.id, task: old }); + if (old.base !== task.base || (repair && drafts.some(item => item.name === "edits.yaml"))) { + result.push({ from, to, outcome: "rebase-required" }); continue; + } + if ((await draftsIn(to)).length) { result.push({ from, to, outcome: "kept" }); continue; } + const targets = await Promise.all(drafts.map(async item => { + const existing = await read(join(to, item.name)); + return { path: join(to, item.name), operation: "write" as const, base_digest: existing === undefined ? null : durableContentDigest(existing), + target_digest: durableContentDigest(item.content), content: item.content }; + })); + await runDurableMultiFileTransaction({ projectRoot: root, kind: "production-draft-migration", + proposal_digest: indexerProtocolDigest({ from, to, drafts }), targets }); + result.push({ from, to, outcome: "copied" }); + } + return result; +} diff --git a/packages/context-cli/src/project/productionPlanning.ts b/packages/context-cli/src/project/productionPlanning.ts index 5b9b2c43..6b37570d 100644 --- a/packages/context-cli/src/project/productionPlanning.ts +++ b/packages/context-cli/src/project/productionPlanning.ts @@ -1,3 +1,5 @@ +import { migrateProductionDrafts } from "./productionDraftMigration.js"; +import { inheritedProductionSources, resolveProductionDependency } from "./productionDependencies.js"; import { randomUUID } from "node:crypto"; import { readFile, access } from "node:fs/promises"; import { join } from "node:path"; @@ -156,12 +158,27 @@ export async function submitProductionPlan(input: { projectRoot: string; stage: if ((await readMaintenance(input.projectRoot)).active) throw new TypeError("Finish or cancel the active maintenance task before amending the production plan."); await assertProductionPlanRequirementsCurrent(input.projectRoot, stage); const file = input.manifest ?? await readProductionFile(input); - const plan = productionPlanInputSchema.parse(YAML.parse(file.text)); + const rawPlan = YAML.parse(file.text); + if (rawPlan && typeof rawPlan === "object" && rawPlan.stage === undefined) throw new ContextError(ExitCode.UserError, + `The file requires a top-level stage. Example:\nstage: ${stage.id}\narticles: []`, { + category: ErrorCategory.UserInputInvalid, reason_code: "production-plan-stage-missing", missing_field: "stage", example: { stage: stage.id }, + next_action: { command: "context status --format json", instruction: `Add stage: ${stage.id} to the top of ${input.path}, then submit it again with the current action command.` }, + }); + const plan = productionPlanInputSchema.parse(rawPlan); if (plan.stage !== stage.id) throw new TypeError("The submitted plan belongs to a different stage"); if (new Set(plan.replaces).size !== plan.replaces.length) throw new TypeError("Replacement task identities must be unique"); if (!stage.report_approved && plan.replaces.length) throw new TypeError("Before report approval, submit the complete plan instead of replacement task identities"); if (stage.report_approved && !plan.articles.length && plan.pending_scopes === undefined) throw new TypeError("A writing-stage plan amendment needs new tasks or an explicit investigation update"); const request = (await productionPlanningRequest(input.projectRoot))!; + const candidates = await readCandidateRecords(input.projectRoot); + const approved = productionApprovedTargetsIndex(await readApprovedKnowledgeMetadataIndex(input.projectRoot)); + const authorized = authorizedProductionSources(request.requirements); + for (const article of plan.articles) { + const prior = candidates.find(candidate => candidate.path === article.path); + const sections = prior?.indexer_candidate?.sections.map(section => ({ references: section.references })) ?? approved.byPath.get(article.path)?.sections ?? []; + article.sources = await inheritedProductionSources(article.sources, sections.flatMap(section => section.references), authorized, + scope => productionSourceBaseline(input.projectRoot, scope).then(() => true, () => false)); + } const selected = [...new Set([...plan.articles.flatMap(article => article.sources), ...plan.pending_scopes ?? []])]; if (selected.length) selectProductionSources(request.requirements, selected); const existingScopes = new Set(stage.scopes.map(source => source.scope)); @@ -204,8 +221,6 @@ export async function submitProductionPlan(input: { projectRoot: string; stage: return { stage_state: prepared.state, next: { directory: prepared.directory, agent_directory: prepared.agent_directory, mode: prepared.mode, ...(prepared.submission ? { submission: prepared.submission } : {}) } }; } - const candidates = await readCandidateRecords(input.projectRoot); - const approved = productionApprovedTargetsIndex(await readApprovedKnowledgeMetadataIndex(input.projectRoot)); const tasks = []; for (const article of plan.articles) { if (stage.report_approved && stage.tasks.some(task => task.path === article.path && @@ -218,8 +233,7 @@ export async function submitProductionPlan(input: { projectRoot: string; stage: const sections = prior?.indexer_candidate?.sections.map(section => ({ id: section.section_key, references: section.references })) ?? formal?.sections; const task = { id: identities.get(article.path)!, article_id: prior?.article_id ?? formal?.article_id ?? stage.tasks.find(task => task.path === article.path)?.article_id ?? randomUUID(), path: article.path, question: article.question, batch: article.batch, - after: article.after.map(path => { const id = (stage.report_approved || retained.length > 0) && stage.tasks.some(task => task.id === path) ? path : identities.get(path); - if (!id) throw new TypeError(`Unknown dependency article path or existing task identity: ${path}`); return id; }), + after: article.after.map(value => resolveProductionDependency(value, identities, stage, plan.replaces)), sources: article.sources.map(scope => { const source = stage.scopes.find(source => source.scope === scope); if (!source) throw new TypeError(`Article source is outside authorized scope: ${scope}`); if (source.baseline === null) throw refreshRequired(stage.id, `Source ${scope} has no captured version; restore and prepare it before planning an article`); @@ -236,8 +250,9 @@ export async function submitProductionPlan(input: { projectRoot: string; stage: const updated = validateProductionStage({ ...amended, indexer_usage: [...new Map(usage.map(item => [JSON.stringify(item), item])).values()] }); await saveExpandedProductionScope(input.projectRoot, updated, request.requirements, additional.materials.sources); try { + const draft_migrations = await migrateProductionDrafts(input.projectRoot, stage, tasks, plan.replaces); const prepared = await materializeProductionStage({ projectRoot: input.projectRoot, stage: updated, capabilities: plan.capabilities }); - return { stage_state: prepared.state, ...(prepared.submission ? { next: { directory: prepared.directory, agent_directory: prepared.agent_directory, + return { stage_state: prepared.state, draft_migrations, ...(prepared.submission ? { next: { directory: prepared.directory, agent_directory: prepared.agent_directory, submission: prepared.submission, mode: prepared.mode } } : { next_action: { command: "context status --format json" } }) }; } catch (error) { return { stage_state: "active" as const, next_preparation: { outcome: "failed" as const, diff --git a/packages/context-cli/src/project/productionStageStore.ts b/packages/context-cli/src/project/productionStageStore.ts index d9978889..25efd292 100644 --- a/packages/context-cli/src/project/productionStageStore.ts +++ b/packages/context-cli/src/project/productionStageStore.ts @@ -183,6 +183,7 @@ export async function materializeProductionStage(input: { const projections = await Promise.allSettled(batch.tasks.slice(offset, offset + 8).map(async id => { const task = tasksById.get(id)!; const taskRoot = join(batchPath, "tasks", id); + let sectionIds: string[] = []; if (task.base !== null) { const basePath = join(taskRoot, "base.md"); const referencesPath = join(taskRoot, "base-references.yaml"); @@ -202,10 +203,20 @@ export async function materializeProductionStage(input: { await write(basePath, target.base.markdown); await write(referencesPath, YAML.stringify({ sections: target.base.sections })); } + const saved = YAML.parse((await readOptional(input.projectRoot, referencesPath))!); + sectionIds = saved.sections.map((section: { id: string }) => section.id); } + const output = `batches/${batch.id}/${id}`; + const draft = join(agent, output, "edits.yaml"); + // A complete draft written before edits were projected stays the submitted form. + const edits = task.base !== null && (await readOptional(input.projectRoot, draft) !== undefined || + await readOptional(input.projectRoot, join(agent, output, "article.md")) === undefined); + if (edits && await readOptional(input.projectRoot, draft) === undefined) await write(draft, + `# Existing fragment IDs: ${JSON.stringify(sectionIds)}\n# Replace selected fragments or insert with replace: [] and after: .\n# Omit references to inherit; supply references to re-confirm evidence.\nedits: []\n`); await write(join(taskRoot, "task.md"), [`# ${task.question}`, "", `Target: ${task.path}`, - `Draft Markdown: ${join(agent, "batches", batch.id, id, "article.md")}`, - `Draft references: ${join(agent, "batches", batch.id, id, "references.yaml")}`, + ...(edits ? [`Draft edits: ${join(agent, output, "edits.yaml")}`] : [ + `Draft Markdown: ${join(agent, output, "article.md")}`, + `Draft references: ${join(agent, output, "references.yaml")}`]), `Article: ${task.article_id}`, `Dependencies: ${task.after.join(", ") || "none"}`, "", task.brief ?? "Read the authorized sources and write the complete article, or revise the existing article's affected fragments.", "", ...(task.base !== null ? [`Revision base: ${join(taskRoot, "base.md")}`, @@ -215,8 +226,8 @@ export async function materializeProductionStage(input: { await write(join(taskRoot, "sources.md"), ["# Authorized sources", "", ...task.sources.map(source => `- ${source.scope}: ${scopePaths.get(source.scope)!}`), "", "Navigation is not semantic evidence. Read the relevant full text before writing and cite actual source regions.", ""].join("\n")); - const output = `batches/${batch.id}/${id}`; - return { task: id, input: task.input, content: `${output}/article.md`, references: `${output}/references.yaml` }; + return { task: id, input: task.input, ...(edits ? { edits: `${output}/edits.yaml` } + : { content: `${output}/article.md`, references: `${output}/references.yaml` }) }; })); // Drain all workers before releasing the lock on failure; partial // unissued projections are safe to reuse during normal preparation. diff --git a/packages/context-cli/src/project/productionSubmission.ts b/packages/context-cli/src/project/productionSubmission.ts index b16c466c..e88fa47b 100644 --- a/packages/context-cli/src/project/productionSubmission.ts +++ b/packages/context-cli/src/project/productionSubmission.ts @@ -44,6 +44,7 @@ function writeTarget(path: string, previous: string | undefined, content: string export interface ProductionSubmissionResult { accepted: Array<{ task: string; receipt: string }>; + resolved_references?: Array<{ task: string; section: string; source_ref: string; locator: { path: string; start_line: number; end_line: number } }>; failed: Array<{ task: string; reason: string; file?: string; section?: string }>; stage_state: "active" | "waiting-user" | "blocked" | "ended"; next?: { directory: string; submission?: string; mode: "single-agent" | "multi-agent" }; @@ -92,7 +93,7 @@ export async function completeProductionSubmission(input: { const baselines = new Map>(); let sourceReader: Awaited> | undefined; let approvedTargets: ReturnType | undefined; - const result: ProductionSubmissionResult = { accepted: [], failed: [], stage_state: "active" }; + const result: ProductionSubmissionResult = { accepted: [], failed: [], stage_state: "active", resolved_references: [] }; const tasksById = new Map(stage.tasks.map(task => [task.id, task])); const pending = new Map(); // Check only currently issued submitted inputs. Independent source checks @@ -158,6 +159,7 @@ export async function completeProductionSubmission(input: { sourceReader: sourceReader!, approvedBaseDigest: target.approvedBaseDigest, ...(base ? { base } : {}), + ...(target.base ? { trustedBase: target.base } : {}), visibility: target.visibility })); // Make successful proposals visible to subsequent conflict checks, // without exposing any acceptance until their joint transaction saves. @@ -196,7 +198,11 @@ export async function completeProductionSubmission(input: { writeTarget(manifestPath, manifestText, `${JSON.stringify(nextStage)}\n`)] .sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) })); stage = nextStage; - for (const [task, proposal] of pending) result.accepted.push({ task, receipt: proposal.candidate.candidate_id }); + for (const [task, proposal] of pending) { + result.accepted.push({ task, receipt: proposal.candidate.candidate_id }); + result.resolved_references?.push(...proposal.candidate.indexer_candidate?.sections.flatMap(section => section.references.map(ref => + ({ task, section: section.section_key, source_ref: ref.source_ref, locator: ref.locator }))) ?? []); + } } catch (error) { // A durable journal may already exist. Recover it before observing // receipts; never report an in-memory proposal as accepted. diff --git a/packages/context-cli/src/project/siteHeadingPairs.ts b/packages/context-cli/src/project/siteHeadingPairs.ts new file mode 100644 index 00000000..5e15ba31 --- /dev/null +++ b/packages/context-cli/src/project/siteHeadingPairs.ts @@ -0,0 +1,29 @@ +/** Included in the generated markdown-it configuration. */ +export const siteHeadingPairingRule = String.raw` + // Pair headings by stable section and structural ordinal, never by a + // translated slug. Tokens are the renderer's actual parsed headings. + md.core.ruler.push('context_heading_pairs', state => { + const groups = new Map(); + let locale = null; + let section = null; + for (const token of state.tokens) { + if (token.type === 'context_locale') { + if (token.meta.tags) { locale = token.meta.tags.join(' '); section = null; } + else if (!token.meta.original) { locale = null; section = null; } + } + if (locale && token.type === 'context_anchor') { + section = token.content; + if (!groups.has(section)) groups.set(section, new Map()); + groups.get(section).set(locale, []); + } + if (locale && section && token.type === 'heading_open') groups.get(section).get(locale).push(token); + } + for (const [key, languages] of groups) { + const headings = [...languages.values()]; + if (headings.length < 2) continue; + const shape = headings[0].map(token => token.tag).join(','); + if (headings.some(list => list.map(token => token.tag).join(',') !== shape)) continue; + headings.forEach(list => list.forEach((token, index) => token.attrSet('data-context-heading', key + '/' + (index + 1)))); + } + }); +`; diff --git a/packages/context-cli/src/project/siteTranslation.ts b/packages/context-cli/src/project/siteTranslation.ts index 2b796416..63a033c9 100644 --- a/packages/context-cli/src/project/siteTranslation.ts +++ b/packages/context-cli/src/project/siteTranslation.ts @@ -1,7 +1,8 @@ +import { translationPartsSchema, translationSourceParts, translationPartsDiff, translationDraft } from "./siteTranslationParts.js"; import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; -import { dirname, join, relative } from "node:path"; +import { dirname, join, relative, resolve } from "node:path"; import { z } from "zod"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import type { PackageDefinition } from "@c4a/context"; @@ -49,7 +50,7 @@ export interface TranslatedArticle { title: string; description?: string; body: const text = z.string().trim().min(1); export const translationSubmitSchema = z.object({ expected_digest: text, - articles: z.array(z.object({ locale: text, approved_path: text, file: text }).strict()).default([]), + articles: z.array(z.object({ locale: text, approved_path: text, file: text, source_digest: text.optional() }).strict()).default([]), map_entries: z.array(z.object({ locale: text, key: text, title: text }).strict()).default([]), }).strict(); export type TranslationSubmitInput = z.infer; @@ -196,14 +197,70 @@ export async function inspectSiteTranslations(projectRoot: string, packages: rea route_satisfied: pending === 0 || resolved === sourceSetDigest }; } +const DRAFTS_FILE = `${TRANSLATION_RUNTIME_ROOT}/drafts.json`; +const draftRecordsSchema = z.record(z.object({ generated_digest: text, source_digest: text }).strict()); +async function draftRecords(root: string) { + const stored = await optionalText(join(root, DRAFTS_FILE)); + return stored === undefined ? {} : draftRecordsSchema.parse(JSON.parse(stored)); +} + export async function prepareSiteTranslation(projectRoot: string, packages: readonly PackageDefinition[]) { - const inspection = await inspectSiteTranslations(projectRoot, packages); - const pending = inspection.items.filter(item => item.state !== "current"); - const task = { expected_digest: inspection.source_set_digest, locales: inspection.locales, items: pending, orphans: inspection.orphans }; - const taskPath = `${TRANSLATION_RUNTIME_ROOT}/${TASK_FILE}`; - await mkdir(join(projectRoot, TRANSLATION_RUNTIME_ROOT), { recursive: true }); - await writeFile(join(projectRoot, taskPath), JSON.stringify(task, null, 2) + "\n", "utf8"); - return { ...task, task_path: taskPath }; + return withProjectWriteLock(projectRoot, "translate-prepare", async () => { + const inspection = await inspectSiteTranslations(projectRoot, packages); + const approved = new Map((await selectedArticles(projectRoot, packages)).map(file => [`knowledge/${file.relPath}`, file])); + const records = await draftRecords(projectRoot); + const items = []; + const articles = []; + const map_entries = []; + for (const item of inspection.items) { + if (item.kind === "map-entry") { + if (item.state !== "current") { items.push(item); map_entries.push({ locale: item.locale, key: item.key, title: item.title }); } + continue; + } + const source = approved.get(item.approved_path)!; + const stored = await optionalText(join(projectRoot, item.translation_path)); + const parsed = stored === undefined ? undefined : splitFrontmatter(stored); + const record = parsed?.frontmatter.translation as Record | undefined; + const known = translationPartsSchema.safeParse(record?.source_parts); + const current = translationSourceParts(source.content); + if (item.state === "current") { + if (!known.success && parsed && record) { + parsed.frontmatter.translation = { ...record, source_parts: current }; + await atomicWrite(join(projectRoot, item.translation_path), `---\n${stringifyYaml(parsed.frontmatter).trimEnd()}\n---\n${parsed.body}`); + } + continue; + } + const baseline = known.success ? known.data : undefined; + const diff = translationPartsDiff(current, baseline); + const file = `.tmp/agent-payloads/translations/${item.locale}/${source.relPath}`; + const existing = await optionalText(join(projectRoot, file)); + const previous = records[file]; + // Without a generation record, a draft's source is unknown. Keep it beside + // a fresh draft instead of asking the Agent to reconcile it. + const unrecorded = existing !== undefined && !previous ? `${file}.unrecorded.md` : undefined; + if (unrecorded && await optionalText(join(projectRoot, unrecorded)) === undefined) await atomicWrite(join(projectRoot, unrecorded), existing!); + const edited = existing !== undefined && previous !== undefined && sha256(existing) !== previous.generated_digest; + const needsRebase = edited && previous?.source_digest !== item.source_digest; + if (!edited) { + const content = translationDraft(source.content, stored, baseline); + await atomicWrite(join(projectRoot, file), content); + records[file] = { generated_digest: sha256(content), source_digest: item.source_digest }; + } + if (diff === "unknown" && stored !== undefined && await optionalText(join(projectRoot, `${file}.previous.md`)) === undefined) { + await atomicWrite(join(projectRoot, `${file}.previous.md`), stored); + } + items.push({ ...item, diff, file, draft: needsRebase ? "rebase-required" : edited ? "kept" : "generated", ...(unrecorded ? { unrecorded_draft: unrecorded } : {}), + ...(needsRebase ? { instruction: "Reconcile this retained draft with the current source. Then explicitly add source_digest to its submission entry.", source_digest: item.source_digest } : {}) }); + if (!needsRebase) articles.push({ locale: item.locale, approved_path: item.approved_path, file }); + } + await atomicWrite(join(projectRoot, DRAFTS_FILE), JSON.stringify(records, null, 2) + "\n"); + const task = { expected_digest: inspection.source_set_digest, locales: inspection.locales, items, orphans: inspection.orphans }; + const taskPath = `${TRANSLATION_RUNTIME_ROOT}/${TASK_FILE}`; + const submissionPath = `${TRANSLATION_RUNTIME_ROOT}/submission.yaml`; + await atomicWrite(join(projectRoot, taskPath), JSON.stringify(task, null, 2) + "\n"); + await atomicWrite(join(projectRoot, submissionPath), stringifyYaml({ expected_digest: task.expected_digest, articles, map_entries })); + return { ...task, task_path: taskPath, submission_path: submissionPath }; + }); } function sectionIds(body: string): string[] { @@ -218,7 +275,7 @@ function linkTargets(body: string): string[] { return markdownReaderLinks(body).map(link => link.target).sort(); } -/** Structural parity keeps evidence, code and links identical across languages. */ +/** Structural parity preserves evidence, executable examples and link targets. */ export function translationStructureIssues(source: string, translated: string): string[] { const sourceBody = splitFrontmatter(source).body; const { frontmatter, body } = splitFrontmatter(translated); @@ -234,7 +291,9 @@ export function translationStructureIssues(source: string, translated: string): else sourceFences.forEach((fence, index) => { const other = translatedFences[index]!; if (fence.lang !== other.lang) issues.push(`Code block ${index + 1} must keep language "${fence.lang}".`); - else if (fence.lang !== "mermaid" && fence.content !== other.content) issues.push(`Code block ${index + 1} must not change.`); + else if (!["mermaid", "text", "txt", "plaintext"].includes(fence.lang) && fence.content !== other.content) { + issues.push(`Code block ${index + 1} must not change.`); + } }); if (JSON.stringify(linkTargets(sourceBody)) !== JSON.stringify(linkTargets(body))) issues.push("Link targets must match the approved article."); return issues; @@ -277,12 +336,23 @@ export async function submitSiteTranslation(projectRoot: string, packages: reado const writes: Array<{ path: string; content: string }> = []; const issues: Array<{ locale: string; target: string; issues: string[] }> = []; const translatedAt = new Date().toISOString(); + const drafts = await draftRecords(projectRoot); + let draftsChanged = false; for (const article of input.articles) { const file = approved.get(article.approved_path); if (!inspection.locales.includes(article.locale) || file === undefined) { issues.push({ locale: article.locale, target: article.approved_path, issues: ["Unknown locale or approved article."] }); continue; } + const draftPath = relative(resolve(projectRoot), resolve(resolveInput(article.file))).split("\\").join("/"); + const prepared = drafts[draftPath]; + const sourceDigest = approvedArticleDigest(file.content); + if ((article.source_digest !== undefined && article.source_digest !== sourceDigest) || + (prepared && prepared.source_digest !== sourceDigest && article.source_digest !== sourceDigest)) { + issues.push({ locale: article.locale, target: article.approved_path, + issues: ["The retained draft belongs to an older source. Reconcile it, then explicitly supply the current source_digest."] }); + continue; + } const content = await optionalText(resolveInput(article.file)); if (content === undefined) { issues.push({ locale: article.locale, target: article.approved_path, issues: [`Translation file not found: ${article.file}`] }); @@ -290,11 +360,15 @@ export async function submitSiteTranslation(projectRoot: string, packages: reado } const found = translationStructureIssues(file.content, content); if (found.length > 0) { issues.push({ locale: article.locale, target: article.approved_path, issues: found }); continue; } + if (prepared) { + drafts[draftPath] = { source_digest: sourceDigest, generated_digest: sha256(content) }; + draftsChanged = true; + } const { frontmatter, body } = splitFrontmatter(content); const description = stringField(frontmatter.description); const head = stringifyYaml({ title: stringField(frontmatter.title), ...(description === undefined ? {} : { description }), translation: { locale: article.locale, ...(file.article?.article_id ? { artifact_ref: file.article.article_id } : {}), - source_digest: approvedArticleDigest(file.content), translated_at: translatedAt } }).trimEnd(); + source_digest: approvedArticleDigest(file.content), source_parts: translationSourceParts(file.content), translated_at: translatedAt } }).trimEnd(); writes.push({ path: translationArticlePath(article.locale, file.relPath), content: `---\n${head}\n---\n\n${body.trimStart()}` }); } const mapEntries = new Map(((await readKnowledgeMap(projectRoot))?.entries ?? []).map(entry => [entry.key, entry])); @@ -315,6 +389,7 @@ export async function submitSiteTranslation(projectRoot: string, packages: reado writes.push({ path: mapTranslationPath(locale), content: stringifyYaml({ locale, entries }) }); } for (const write of writes) await atomicWrite(join(projectRoot, write.path), write.content); + if (draftsChanged) await atomicWrite(join(projectRoot, DRAFTS_FILE), JSON.stringify(drafts, null, 2) + "\n"); for (const orphan of inspection.orphans) await rm(join(projectRoot, orphan), { force: true }); await writeReceipt(projectRoot, digest, "submitted"); return { written: writes.map(write => write.path), removed: inspection.orphans }; diff --git a/packages/context-cli/src/project/siteTranslationParts.ts b/packages/context-cli/src/project/siteTranslationParts.ts new file mode 100644 index 00000000..5bc80476 --- /dev/null +++ b/packages/context-cli/src/project/siteTranslationParts.ts @@ -0,0 +1,63 @@ +import { createHash } from "node:crypto"; +import { z } from "zod"; +import { stringify as yaml } from "yaml"; +import { parseKnowledgeFrontmatter } from "./packageKnowledgeProjection.js"; +import { approvedContextSectionsInMarkdown } from "./verifyContextSections.js"; + +export const translationPartsSchema = z.object({ + title: z.string(), description: z.string(), order: z.array(z.string()), + sections: z.record(z.string()), exterior: z.record(z.string()), +}).strict(); +export type TranslationParts = z.infer; +export const translationTextDigest = (text: string) => createHash("sha256").update(text).digest("hex"); +type Segment = { kind: "sections" | "exterior"; key: string; text: string }; + +function parts(content: string) { + const header = /^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/u.exec(content); + const meta = parseKnowledgeFrontmatter(content); + const body = header ? content.slice(header[0].length) : content; + const sections = approvedContextSectionsInMarkdown(body); + const segments: Segment[] = []; + let cursor = 0; + let previous: string | null = null; + for (const section of sections) { + const start = body.lastIndexOf("\n", section.bodyStart - 2) + 1; + const closing = body.indexOf("\n", section.bodyEnd); + const end = closing < 0 ? body.length : closing + 1; + segments.push({ kind: "exterior", key: JSON.stringify([previous, section.id]), text: body.slice(cursor, start) }); + segments.push({ kind: "sections", key: section.id, text: body.slice(start, end) }); + previous = section.id; cursor = end; + } + segments.push({ kind: "exterior", key: JSON.stringify([previous, null]), text: body.slice(cursor) }); + return { title: typeof meta.title === "string" ? meta.title : "", description: typeof meta.description === "string" ? meta.description : undefined, + order: sections.map(section => section.id), segments }; +} +export function translationSourceParts(content: string): TranslationParts { + const parsed = parts(content); + return { title: translationTextDigest(parsed.title), description: translationTextDigest(JSON.stringify(parsed.description ?? null)), + order: parsed.order, sections: Object.fromEntries(parsed.segments.filter(s => s.kind === "sections").map(s => [s.key, translationTextDigest(s.text)])), + exterior: Object.fromEntries(parsed.segments.filter(s => s.kind === "exterior").map(s => [s.key, translationTextDigest(s.text)])) }; +} +export function translationPartsDiff(current: TranslationParts, previous?: TranslationParts) { + if (!previous) return "unknown" as const; + return { title: current.title !== previous.title, description: current.description !== previous.description, + preamble: JSON.stringify(current.exterior) !== JSON.stringify(previous.exterior), + order: JSON.stringify(current.order) !== JSON.stringify(previous.order), + added: current.order.filter(id => !(id in previous.sections)), + modified: current.order.filter(id => id in previous.sections && current.sections[id] !== previous.sections[id]), + deleted: previous.order.filter(id => !(id in current.sections)) }; +} +export function translationDraft(source: string, translation?: string, baseline?: TranslationParts): string { + const current = parts(source); + const hashes = translationSourceParts(source); + const previous = translation === undefined ? undefined : parts(translation); + const keepTitle = previous && baseline?.title === hashes.title; + const keepDescription = previous && baseline?.description === hashes.description; + const title = keepTitle ? previous.title : current.title; + const description = keepDescription ? previous.description : current.description; + const body = current.segments.map(segment => { + if (!previous || baseline?.[segment.kind][segment.key] !== hashes[segment.kind][segment.key]) return segment.text; + return previous.segments.find(old => old.kind === segment.kind && old.key === segment.key)?.text ?? segment.text; + }).join(""); + return `---\n${yaml({ title, ...(description === undefined ? {} : { description }) }).trimEnd()}\n---\n${body}`; +} diff --git a/packages/context-cli/src/project/submittedSourceLocator.ts b/packages/context-cli/src/project/submittedSourceLocator.ts new file mode 100644 index 00000000..ef480011 --- /dev/null +++ b/packages/context-cli/src/project/submittedSourceLocator.ts @@ -0,0 +1,43 @@ +import { articleSourceLocatorSchema, articleSourceLocatorInputSchema, type ArticleSourceLocator, type ArticleSourceLocatorInput } from "@c4a/context"; + +export const submittedSourceLocatorSchema = articleSourceLocatorInputSchema; +export type SubmittedSourceLocator = ArticleSourceLocatorInput; +export interface SubmittedSourceReference { source_ref: string; locator: SubmittedSourceLocator } + +/** Call only after authorization, with the same captured text used for hashing. */ +export function resolveSubmittedSourceLocator(input: SubmittedSourceLocator, capturedText: string): ArticleSourceLocator { + const locator = submittedSourceLocatorSchema.parse(input); + if ("start_line" in locator) return locator; + const text = capturedText.replace(/\r\n/gu, "\n"); + const lines = text.split("\n"); + const matches: Array<{ start_line: number; end_line: number }> = []; + if ("quote" in locator) { + const quote = locator.quote.replace(/\r\n/gu, "\n"); + for (let offset = text.indexOf(quote); offset >= 0; offset = text.indexOf(quote, offset + 1)) { + matches.push({ start_line: text.slice(0, offset).split("\n").length, + end_line: text.slice(0, offset + quote.length - 1).split("\n").length }); + } + } else { + if (!/\.(?:md|markdown|mdx)$/iu.test(locator.path)) throw new TypeError("heading requires a Markdown source; use quote or start_line/end_line instead."); + const headings: Array<{ line: number; level: number; title: string }> = []; + let fence: { character: string; length: number } | undefined; + for (const [index, line] of lines.entries()) { + const marker = /^ {0,3}(`{3,}|~{3,})(.*)$/u.exec(line); + if (fence) { + if (marker && marker[1]![0] === fence.character && marker[1]!.length >= fence.length && !marker[2]!.trim()) fence = undefined; + continue; + } + if (marker) { fence = { character: marker[1]![0]!, length: marker[1]!.length }; continue; } + const match = /^ {0,3}(#{1,6})(?:[ \t]+(.*)|[ \t]*)$/u.exec(line); + if (match) headings.push({ line: index + 1, level: match[1]!.length, title: (match[2] ?? "").replace(/[ \t]+#+[ \t]*$/u, "").trim() }); + } + for (const [index, heading] of headings.entries()) { + if (heading.title !== locator.heading) continue; + let end = (headings.slice(index + 1).find(next => next.level <= heading.level)?.line ?? lines.length + 1) - 1; + while (end > heading.line && !lines[end - 1]!.trim()) end--; + matches.push({ start_line: heading.line, end_line: end }); + } + } + if (matches.length !== 1) throw new TypeError(`Source locator matched ${matches.length} regions in ${locator.path}; candidates: ${matches.slice(0, 5).map(m => `${m.start_line}-${m.end_line}`).join(", ") || "none"}. Use a unique heading/quote or explicit start_line/end_line.`); + return articleSourceLocatorSchema.parse({ path: locator.path, ...matches[0] }); +} diff --git a/packages/context/docs/guides/knowledge-updates.md b/packages/context/docs/guides/knowledge-updates.md index 613c71e4..631905e9 100644 --- a/packages/context/docs/guides/knowledge-updates.md +++ b/packages/context/docs/guides/knowledge-updates.md @@ -700,3 +700,14 @@ an explicit empty list removes them, and deleting a fragment removes its citations. Each fragment may cite at most three source positions. Register and authorize new material before citing it, then follow normal Review, close and build. Article links alone do not authorize reading new sources or prove a claim. + +### Locating submitted references + +Submission locators also accept `{path, heading}` or `{path, quote}` instead of +line numbers. Use exactly one form. A heading matches an exact ATX heading +outside code fences in a Markdown source; its region ends before the next +same-level or higher-level heading. A quote is an exact, unique, nonempty +substring (line endings are normalized). Ambiguous matches return candidate +line ranges. Both forms resolve against authorized captured text before hashing; +stored references still contain only `path`, `start_line` and `end_line`. +The three-region limit applies after resolving and deduplicating positions. diff --git a/packages/context/docs/guides/site-translation.md b/packages/context/docs/guides/site-translation.md index 5444ad4c..5456bb3f 100644 --- a/packages/context/docs/guides/site-translation.md +++ b/packages/context/docs/guides/site-translation.md @@ -54,8 +54,11 @@ map_entries: ``` A translated article keeps every `context:section` marker, id and order, every -fenced code block with its language and content (Mermaid labels may change), and -every link target. Submission validates all items first and saves nothing when +fenced code block and its language marker, and every link target. Prose in +`text`, `txt`, and `plaintext` blocks may be translated; keep commands, paths, +and placeholders such as `` unchanged. Mermaid labels may change. +All other blocks, including blocks without a language marker, keep their content +unchanged. Submission validates all items first and saves nothing when any item fails. A digest from before an approved content change is rejected. `skip` records that the current pending items are published in the primary @@ -70,3 +73,24 @@ an outdated notice and a button that switches to the original; a missing page shows the original with a notice. Navigation titles without a translation use the translated article title, then the primary title. Site search covers every language; opening a result in another language switches to it. + + +## Incremental drafts + +`prepare` returns section changes, title/description changes, exterior-text changes +(including the article tail), and section order changes. It writes draft files +and a `submission_path` template. Unchanged parts retain their translation; +changed parts contain the current primary-language text for the Agent to translate. +Review every pending part before submitting; generated source text is not a translation. + +Older current translations acquire source-part baselines without changing their +reader text. Older stale translations report `diff: unknown` and get a full source +draft plus a `.previous.md` translation for comparison. + +Repeated preparation preserves edited drafts. `draft: rebase-required` means the +source changed while a draft was being edited; that draft is excluded from the +submission template. Reconcile it with the current source, then explicitly add +the returned `source_digest` to that article's submission entry. Never copy a new +digest onto an unreconciled draft. Ordinary submissions still use `expected_digest`. +A draft without a generation record, such as one left by an older workflow, is +kept as `unrecorded_draft` beside a freshly generated draft. diff --git a/packages/context/package.json b/packages/context/package.json index 9b6ceb9b..55b90027 100644 --- a/packages/context/package.json +++ b/packages/context/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/context", "description": "Declarative SDK for Context knowledge sources, workflows, review, and package outputs", - "version": "0.7.67", + "version": "0.7.70", "type": "module", "license": "MIT", "engines": { diff --git a/packages/context/src/articleSourceLocatorInput.ts b/packages/context/src/articleSourceLocatorInput.ts new file mode 100644 index 00000000..e89502a2 --- /dev/null +++ b/packages/context/src/articleSourceLocatorInput.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; +import { articleSourceLocatorSchema } from "./articleStructure.js"; +import { portableIndexerPathSchema } from "./indexerProtocolCommon.js"; + +/** Author submission syntax. Resolve against authorized captured text before + * persisting an articleSourceLocatorSchema value. */ +export const articleSourceLocatorInputSchema = z.union([ + articleSourceLocatorSchema, + z.object({ path: portableIndexerPathSchema, heading: z.string().trim().min(1) }).strict(), + z.object({ path: portableIndexerPathSchema, quote: z.string().min(1) }).strict(), +]); +export type ArticleSourceLocatorInput = z.infer; diff --git a/packages/context/src/index.ts b/packages/context/src/index.ts index 80ae2622..c1e3f650 100644 --- a/packages/context/src/index.ts +++ b/packages/context/src/index.ts @@ -762,3 +762,5 @@ export type { IndexerArticlePlan } from "./indexerArticlePlan.js"; export * from "./knowledgeMap.js"; export * from "./indexerApprovedKnowledge.js"; export * from "./articleStructure.js"; + +export * from "./articleSourceLocatorInput.js"; diff --git a/packages/context/src/indexerSemanticInput.ts b/packages/context/src/indexerSemanticInput.ts index 15c29dc8..b33296eb 100644 --- a/packages/context/src/indexerSemanticInput.ts +++ b/packages/context/src/indexerSemanticInput.ts @@ -1,3 +1,4 @@ +import { articleSourceLocatorInputSchema } from "./articleSourceLocatorInput.js"; import { articleSourceLocatorSchema } from "./articleStructure.js"; import { indexerIdSchema } from "./indexerProtocolCommon.js"; import { knowledgeMapUpdateSchema } from "./knowledgeMap.js"; @@ -366,8 +367,8 @@ export const indexerLayoutConfirmationInputSchema = z.discriminatedUnion("decisi ]); const revisionSectionReferencesSchema = z.array(z.object({ - source_ref: z.string().min(1), locator: articleSourceLocatorSchema, -}).strict()).max(3); + source_ref: z.string().min(1), locator: articleSourceLocatorInputSchema, +}).strict()); export const approvedRevisionSemanticInputSchema = z.union([ z.object({ stage: z.literal("approved-revision"), markdown: z.string().min(1), diff --git a/packages/core/package.json b/packages/core/package.json index 1ce690b3..57a9c600 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/core", "description": "Shared extraction types, schemas, and utilities for Context", - "version": "0.7.67", + "version": "0.7.70", "type": "module", "license": "MIT", "engines": { diff --git a/packages/dev-cli/package.json b/packages/dev-cli/package.json index e60484be..0dcfa695 100644 --- a/packages/dev-cli/package.json +++ b/packages/dev-cli/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/dev-cli", "description": "Developer menu for Context build, verification, link, and release preparation", - "version": "0.7.67", + "version": "0.7.70", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extract-contract/package.json b/packages/extract-contract/package.json index 26db6eed..aa4e75c8 100644 --- a/packages/extract-contract/package.json +++ b/packages/extract-contract/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/extract-contract", "description": "OpenAPI and GraphQL contract catalog adapter for Context", - "version": "0.7.67", + "version": "0.7.70", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-go/package.json b/packages/extract-go/package.json index 4d9fe654..3ef7cf91 100644 --- a/packages/extract-go/package.json +++ b/packages/extract-go/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/extract-go", "description": "Go extraction plugin and structural index for Context", - "version": "0.7.67", + "version": "0.7.70", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-mdx/package.json b/packages/extract-mdx/package.json index a8323fe6..203b0744 100644 --- a/packages/extract-mdx/package.json +++ b/packages/extract-mdx/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/extract-mdx", "description": "MDX component and example catalog bridge for Context", - "version": "0.7.67", + "version": "0.7.70", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-proto/package.json b/packages/extract-proto/package.json index 7d3825af..a22b738c 100644 --- a/packages/extract-proto/package.json +++ b/packages/extract-proto/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/extract-proto", "description": "Protocol Buffers IDL catalog parser for Context", - "version": "0.7.67", + "version": "0.7.70", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-rush/package.json b/packages/extract-rush/package.json index 1c0bf99f..60a22e8b 100644 --- a/packages/extract-rush/package.json +++ b/packages/extract-rush/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/extract-rush", "description": "Rush workspace structural index for Context", - "version": "0.7.67", + "version": "0.7.70", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-sql/package.json b/packages/extract-sql/package.json index 4ee1bc21..6be33b4a 100644 --- a/packages/extract-sql/package.json +++ b/packages/extract-sql/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/extract-sql", "description": "Dialect-bound lightweight SQL evidence adapter for Context", - "version": "0.7.67", + "version": "0.7.70", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-style/package.json b/packages/extract-style/package.json index c4170667..72b5156d 100644 --- a/packages/extract-style/package.json +++ b/packages/extract-style/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/extract-style", "description": "Lightweight CSS and SCSS evidence adapter for Context", - "version": "0.7.67", + "version": "0.7.70", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-thrift/package.json b/packages/extract-thrift/package.json index 42b5a55d..568b9ebf 100644 --- a/packages/extract-thrift/package.json +++ b/packages/extract-thrift/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/extract-thrift", "description": "Apache Thrift IDL catalog parser for Context", - "version": "0.7.67", + "version": "0.7.70", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-ts/package.json b/packages/extract-ts/package.json index 03dad028..7ee2cafa 100644 --- a/packages/extract-ts/package.json +++ b/packages/extract-ts/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/extract-ts", "description": "TypeScript and JavaScript extraction plugin for the Context ExtractionResult v2 contract", - "version": "0.7.67", + "version": "0.7.70", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract/package.json b/packages/extract/package.json index 777565bf..11e02bb4 100644 --- a/packages/extract/package.json +++ b/packages/extract/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/extract", "description": "Language-plugin framework and repository runner for Context code evidence", - "version": "0.7.67", + "version": "0.7.70", "type": "module", "license": "MIT", "engines": { diff --git a/packages/tui/package.json b/packages/tui/package.json index b4f3eae9..9d1e0c55 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "name": "@c4a/tui", "description": "Shared terminal UI components (Ink + React) for Context development tools", - "version": "0.7.67", + "version": "0.7.70", "private": true, "type": "module", "license": "MIT", diff --git a/plugins/context/repo-install/claude/.claude-plugin/plugin.json b/plugins/context/repo-install/claude/.claude-plugin/plugin.json index a93a0b47..dbcbdc5f 100644 --- a/plugins/context/repo-install/claude/.claude-plugin/plugin.json +++ b/plugins/context/repo-install/claude/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "c4a", "description": "Start or continue a project-local knowledge workspace through one graph-routed entry.", - "version": "0.7.67", + "version": "0.7.70", "author": { "name": "c4a" }, diff --git a/plugins/context/repo-install/codex/.codex-plugin/plugin.json b/plugins/context/repo-install/codex/.codex-plugin/plugin.json index 230cc339..6505bbc4 100644 --- a/plugins/context/repo-install/codex/.codex-plugin/plugin.json +++ b/plugins/context/repo-install/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "c4a", - "version": "0.7.67", + "version": "0.7.70", "description": "Start or continue a project-local knowledge workspace through one graph-routed entry.", "author": { "name": "c4a" }, "homepage": "https://github.com/context4ai/c4a", @@ -10,7 +10,7 @@ "skills": "./skills/", "interface": { "displayName": "C4A Context", - "shortDescription": "Initialize and advance a local, source-linked project knowledge workspace.\nv0.7.67", + "shortDescription": "Initialize and advance a local, source-linked project knowledge workspace.\nv0.7.70", "longDescription": "Create a Context workspace and use agent-guided next steps to register sources, run extraction, review candidates, build package outputs, and verify health without silently mutating source repositories.", "developerName": "c4a", "category": "Productivity", diff --git a/plugins/context/repo-install/cursor/.cursor-plugin/plugin.json b/plugins/context/repo-install/cursor/.cursor-plugin/plugin.json index 74baf527..d814ba4a 100644 --- a/plugins/context/repo-install/cursor/.cursor-plugin/plugin.json +++ b/plugins/context/repo-install/cursor/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "c4a", "displayName": "C4A Context", - "version": "0.7.67", + "version": "0.7.70", "description": "Start or continue a project-local knowledge workspace through one graph-routed entry.", "author": { "name": "Context4AI",