diff --git a/CHANGELOG.md b/CHANGELOG.md index d4e81dcc..7b2157c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to Context are documented here. +## 0.7.71 - 2026-10-14 + +- `translate prepare` uses the stale translation itself as the draft and, when Git history holds the approved version it was translated from, writes a `source_diff` from that version. Without history, `source_diff` is `null`. Translations no longer store `source_parts`; existing ones are read as before. Drafts are never overwritten and are removed after a successful submission. A kept draft (`draft: kept`) has no `source_diff` and must be aligned with the whole current article, because the source may have changed again since it was written. The draft generation records, `rebase-required`, `*.unrecorded.md`, `*.previous.md` and the per-article `source_digest` submission field are removed. +- Replacing a task in a plan amendment no longer copies its draft. The old draft directory is listed in `previous_drafts`, also when the same amendment is replayed, and in the new task's `task.md` on every preparation. +- Planning no longer adds a revised article's cited sources to its task. Omitted citations are still inherited when submitting; list a source in the plan to re-confirm it. +- Submission receipts no longer echo `resolved_references`. +- Installation instructions list Bun alongside Node.js 20: knowledge workspaces install their dependencies with `bun install`. +- A fragment may cite regions from at most three distinct source files, with several regions per file and up to eight regions in total; previously it was limited to three regions. Existing knowledge stays valid, but structures with more than three regions in a fragment cannot be read by earlier CLIs. +- Rejected revision references are reported as user input (`user-input-invalid`) with the fragment and input path: an unmatched locator, an oversized fragment, or a source outside the revision, which now lists the allowed sources. Source-update decisions that omit a candidate page or name a page that is not one are reported the same way and list those pages. +- A `heading` locator may include its ATX marker (`## Install`); the marker also selects the heading level. +- `translate prepare` reseeds a leftover draft whose body is still the unedited seed, such as one left by an earlier CLI, and gives it a `source_diff` instead of reporting it as `kept`. +- Reader pages also expose each fragment ID as an anchor, so links such as `page.md#upgrade` resolve, unless a heading on that page already owns the slug. +- When a source update settles, citations of kept pages whose text only moved in the updated source are relocated, so `verify` no longer reports them. Repository content keeps its own settlement. Kept pages whose cited text changed or cannot be confirmed are listed in the completion result's `reference_warnings`; the citations keep their previous locations, and the pages can be corrected with `context revise` after the update queue finishes. +- A revision citing a source outside its scope explains how to add new independent evidence: register the source, associate it with the requirement in `src/indexers.yaml`, then run `context task adjust`. +- A source added with `context task adjust` during a multi-page revision stays citable by the remaining queued pages. Repeating the adjustment for an unchanged source no longer discards pages already prepared in the batch. +- The translation guide explains that `source_diff` is `null` when approve-and-translate rounds ran without a commit in between. +- The README documents installing the `c4a` plugin in Claude Code or Codex by adding this repository as a plugin marketplace; the CLI is still installed from npm. + ## 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. diff --git a/README.md b/README.md index 061e71cc..c4a2a46d 100644 --- a/README.md +++ b/README.md @@ -42,14 +42,26 @@ for the method and results. ## Quick start -Install the CLI and its Agent plugin once. The plugin is installed for the -Claude Code, Codex and Cursor hosts that are available on the machine: +You need Node.js 20 or newer and [Bun](https://bun.sh), which knowledge +workspaces use to install their dependencies. Install the CLI and its Agent +plugin once. The plugin is installed for the Claude Code, Codex and Cursor +hosts that are available on the machine: ```bash npm install -g @c4a/context-cli@latest context plugin install ``` +This repository is also a plugin marketplace. In Claude Code or Codex you can +add it and install the `c4a` plugin through the host instead of +`context plugin install`; the CLI is still required, because the plugin +delegates the workflow and the bundled Indexers to it: + +```bash +claude plugin marketplace add context4ai/context && claude plugin install c4a@c4a +codex plugin marketplace add context4ai/context && codex plugin add c4a@c4a +``` + Restart the Agent session and describe the knowledge you want: ```text diff --git a/README.zh-CN.md b/README.zh-CN.md index 478d08b6..4cf601cc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -29,13 +29,20 @@ Context 把代码仓库、文档、笔记和会话整理成经过审阅、带版 ## 快速开始 -安装 CLI 与 Agent 插件(一次即可)。插件会安装到本机可用的 Claude Code、Codex 与 Cursor: +需要 Node.js 20 及以上,以及 [Bun](https://bun.sh)(知识工作区用它安装依赖)。安装 CLI 与 Agent 插件(一次即可)。插件会安装到本机可用的 Claude Code、Codex 与 Cursor: ```bash npm install -g @c4a/context-cli@latest context plugin install ``` +本仓库本身也是插件市场。在 Claude Code 或 Codex 中,可以添加它并通过宿主安装 `c4a` 插件,代替 `context plugin install`;CLI 仍需安装,因为插件把工作流和随包的 Indexer 交给它执行: + +```bash +claude plugin marketplace add context4ai/context && claude plugin install c4a@c4a +codex plugin marketplace add context4ai/context && codex plugin add c4a@c4a +``` + 重启 Agent 会话,用自然语言说明想要的知识: ```text diff --git a/bun.lock b/bun.lock index 54fafd16..e4d2b303 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ }, "packages/context": { "name": "@c4a/context", - "version": "0.7.70", + "version": "0.7.71", "dependencies": { "yaml": "^2.5.1", "zod": "^3.23.8", @@ -26,7 +26,7 @@ }, "packages/context-cli": { "name": "@c4a/context-cli", - "version": "0.7.70", + "version": "0.7.71", "bin": { "context": "dist/cli.js", }, @@ -73,7 +73,7 @@ }, "packages/core": { "name": "@c4a/core", - "version": "0.7.70", + "version": "0.7.71", "dependencies": { "picomatch": "^4.0.4", "yaml": "^2.4.5", @@ -85,7 +85,7 @@ }, "packages/dev-cli": { "name": "@c4a/dev-cli", - "version": "0.7.70", + "version": "0.7.71", "dependencies": { "@c4a/context": "workspace:*", "@c4a/core": "workspace:*", @@ -97,7 +97,7 @@ }, "packages/extract": { "name": "@c4a/extract", - "version": "0.7.70", + "version": "0.7.71", "bin": { "c4a-extract-code": "./dist/bin/c4a-extract-code.js", }, @@ -112,7 +112,7 @@ }, "packages/extract-contract": { "name": "@c4a/extract-contract", - "version": "0.7.70", + "version": "0.7.71", "dependencies": { "@c4a/core": "workspace:*", "graphql": "^16.14.2", @@ -122,7 +122,7 @@ }, "packages/extract-go": { "name": "@c4a/extract-go", - "version": "0.7.70", + "version": "0.7.71", "dependencies": { "@c4a/core": "workspace:*", "@c4a/extract": "workspace:*", @@ -132,7 +132,7 @@ }, "packages/extract-mdx": { "name": "@c4a/extract-mdx", - "version": "0.7.70", + "version": "0.7.71", "dependencies": { "@c4a/core": "workspace:*", "remark-mdx": "^3.1.1", @@ -144,7 +144,7 @@ }, "packages/extract-proto": { "name": "@c4a/extract-proto", - "version": "0.7.70", + "version": "0.7.71", "dependencies": { "@c4a/core": "workspace:*", "zod": "^3.23.8", @@ -152,7 +152,7 @@ }, "packages/extract-rush": { "name": "@c4a/extract-rush", - "version": "0.7.70", + "version": "0.7.71", "dependencies": { "@c4a/core": "workspace:*", "typescript": "^5.5.4", @@ -162,7 +162,7 @@ }, "packages/extract-sql": { "name": "@c4a/extract-sql", - "version": "0.7.70", + "version": "0.7.71", "dependencies": { "@c4a/core": "workspace:*", "node-sql-parser": "^5.4.0", @@ -171,7 +171,7 @@ }, "packages/extract-style": { "name": "@c4a/extract-style", - "version": "0.7.70", + "version": "0.7.71", "dependencies": { "@c4a/core": "workspace:*", "postcss": "^8.5.26", @@ -183,7 +183,7 @@ }, "packages/extract-thrift": { "name": "@c4a/extract-thrift", - "version": "0.7.70", + "version": "0.7.71", "dependencies": { "@c4a/core": "workspace:*", "zod": "^3.23.8", @@ -191,7 +191,7 @@ }, "packages/extract-ts": { "name": "@c4a/extract-ts", - "version": "0.7.70", + "version": "0.7.71", "dependencies": { "@c4a/core": "workspace:*", "@c4a/extract": "workspace:*", @@ -202,7 +202,7 @@ }, "packages/tui": { "name": "@c4a/tui", - "version": "0.7.70", + "version": "0.7.71", "dependencies": { "ink": "^5.0.0", "react": "^18.3.1", diff --git a/package.json b/package.json index 5c156635..acd19fa5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "context", - "version": "0.7.70", + "version": "0.7.71", "packageManager": "bun@1.3.9", "repository": { "type": "git", diff --git a/packages/context-cli/CLAUDE.md b/packages/context-cli/CLAUDE.md index b930ff0f..31820a1f 100644 --- a/packages/context-cli/CLAUDE.md +++ b/packages/context-cli/CLAUDE.md @@ -85,7 +85,7 @@ adapter、对应 plugin shell、SDK 手册和 Graph tests,并运行 `bun run b - Approved Markdown 头部维护 `title`、`type`、`description`、`timestamp`,按需保留真实 `resource` 和阅读标签。正文保留稳定片段 ID;文章身份、路径、知识分类、可见性和片段来源区域仅在 `knowledge/structure.yaml` 的 `articles` 中维护。导出由结构引用生成读者溯源,不能要求 Agent 再维护一份来源列表。构建清单记录发布路径到批准文章的映射,不得改写 `knowledge/`。 - PART1 重构不再要求 prose/structure knowledge 提取独立主体、填写 node_type 或主体 tags;文章知识分类仍保留,不以主体分类推导。 - Markdown 不写 `article_id`、`sources`、`source_refs`、`visibility` 或 `code_symbols`;片段标记为 `` 和对应结束标记,不附证据属性。 -- 实际引用由 `source_ref`、区域 `locator` 与工具计算的 `content_digest` 表达;每个输出片段最多 3 个位置,不是章节或文章的总上限。不维护独立事实/证据 ID 或逐绑定指纹。 +- 实际引用由 `source_ref`、区域 `locator` 与工具计算的 `content_digest` 表达;每个输出片段最多引用 3 个不同来源文件、共 8 个区域,不是章节或文章的总上限。不维护独立事实/证据 ID 或逐绑定指纹。 - 当前 Indexer approved Markdown 不保存 `candidate_fingerprint`、Indexer digest 或 `code_origin`; current/stale 由本机 runtime 与批准页面的实际正文、来源共同判定。 - 不写 `schema` 字段。协议版本由工具/文档和 verify 约束,不是 approved MD 的 frontmatter 字段。 diff --git a/packages/context-cli/README.md b/packages/context-cli/README.md index cf8dffaf..050c3617 100644 --- a/packages/context-cli/README.md +++ b/packages/context-cli/README.md @@ -33,6 +33,9 @@ an LLM itself. ## Install the Agent integration +Requires Node.js 20 or newer. Knowledge workspaces install their dependencies +with Bun (`bun install` after `context init`), so install Bun as well. + ```bash npm install -g @c4a/context-cli@latest context plugin install # global installation @@ -40,6 +43,12 @@ context plugin install # global installation context plugin install --local /path/to/repo --agent auto-detect ``` +The source repository is also a plugin marketplace. Claude Code and Codex can +install the `c4a` plugin from it with `claude plugin marketplace add context4ai/context` +and `claude plugin install c4a@c4a`, or `codex plugin marketplace add context4ai/context` +and `codex plugin add c4a@c4a`. The CLI is still required; the plugin delegates +the workflow and the bundled Indexers to it. + `--local` requires explicit `--agent`: `claude`, `cursor`, `codex`, `all`, `auto-detect`, or `standalone`. The path is a repository root: explicit hosts install into `.claude`, `.cursor`, or `.agents` without detection. `all` installs all three. Only `auto-detect` checks existing `.claude`, `.cursor`, and `.agents` directories in the target repository, never PATH or installed desktop applications; it falls back to `.agents/skills` when none exist. `standalone` instead writes all skills directly to `/skills`, with no commands or host subdirectory. All modes support `--dry-run` and preserve global configuration. Migration from 0.7.20: add `--agent auto-detect` to bare `--local`; for explicit hosts pass the repository root, not its host directory. Global installation is unchanged. Host detection does not verify runtime loading. diff --git a/packages/context-cli/README.zh-CN.md b/packages/context-cli/README.zh-CN.md index 3375d89b..665c99a7 100644 --- a/packages/context-cli/README.zh-CN.md +++ b/packages/context-cli/README.zh-CN.md @@ -33,6 +33,8 @@ context plugin install # 全局安装 context plugin install --local /path/to/repo --agent auto-detect ``` +源码仓库本身也是插件市场。Claude Code 与 Codex 可以从中安装 `c4a` 插件:`claude plugin marketplace add context4ai/context` 后执行 `claude plugin install c4a@c4a`,或 `codex plugin marketplace add context4ai/context` 后执行 `codex plugin add c4a@c4a`。CLI 仍需安装,插件把工作流和随包的 Indexer 交给它执行。 + `--local` 必须显式搭配 `--agent`:`claude`、`cursor`、`codex`、`all`、`auto-detect` 或 `standalone`。路径为仓库根;指定宿主直接写入对应 `.claude`、`.cursor`、`.agents` 子目录,不检测是否安装宿主。`all` 全装;只有 `auto-detect` 检测目标仓库已有的 `.claude`、`.cursor`、`.agents` 目录,不检测 PATH 或桌面应用;均不存在时兜底 `.agents/skills`。`standalone` 则直接写入 `/skills`,不生成 commands 或宿主子目录。均支持 `--dry-run`,不改全局配置。 从 0.7.20 迁移:裸 `--local` 补充 `--agent auto-detect`;指定宿主时传仓库根,不再传宿主目录。全局安装不变。自动探测不代表宿主已加载,安装后需刷新宿主。 diff --git a/packages/context-cli/context-workflow/provider.yaml b/packages/context-cli/context-workflow/provider.yaml index 2425b9f6..4249ff61 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.70 +version: 0.7.71 name: Context workflow description: Internal work contract for Context knowledge workspaces. graphs: diff --git a/packages/context-cli/context-workflow/resources/manuals/guides/code-indexer-skill-authoring.md b/packages/context-cli/context-workflow/resources/manuals/guides/code-indexer-skill-authoring.md index 0b0f4b05..bfd9576d 100644 --- a/packages/context-cli/context-workflow/resources/manuals/guides/code-indexer-skill-authoring.md +++ b/packages/context-cli/context-workflow/resources/manuals/guides/code-indexer-skill-authoring.md @@ -50,7 +50,8 @@ proof of runtime behavior. Preserve expressions without executing project code, distinguish declaration defaults from implementation defaults, and state unknowns. Use the current stage's Markdown and reference-file schemas. Each fragment cites -at most three actual source locations; the CLI computes regional digests. Split +actual source regions from at most three files (up to eight regions); the CLI +computes regional digests. Split an explanation when it needs separate evidence, not to fill a fixed template. Reuse stable article and fragment identities when revising. Supporting documents, notes or sessions may contribute to the same article without becoming code facts. 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 314b2ab2..e036f497 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 @@ -338,7 +338,7 @@ changing structure, adding a page or when a section has no unambiguous ID. To change a fragment's citations, include `references` alongside its `section_id`: each entry supplies `source_ref` and `locator` (`path`, `start_line`, `end_line`). Context computes the region fingerprint. This uses the same revision submission, -with at most three source positions per fragment. Full Markdown may include a +with at most three source files and eight regions per fragment. Full Markdown may include a `sections` list of reference edits; local section edits may change content, references, or both. Omission preserves citations; an empty list removes them. @@ -703,17 +703,19 @@ Revision section edits may supply `references` as `source_ref` plus a `locator` containing `path`, `start_line`, and `end_line`. Context computes the regional digest. Omitted references preserve the current fragment's citations; an explicit empty list removes them, and deleting a fragment removes its -citations. Each fragment may cite at most three source positions. Register and +citations. Each fragment may cite at most three source files and eight regions. 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 +line numbers. Use exactly one form. A heading is the title text of an exact ATX heading +outside code fences in a Markdown source; a leading `##` marker is optional and, +when present, also selects the heading level; 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. +The per-fragment limits (three source files, eight regions) apply after +resolving and deduplicating positions. diff --git a/packages/context-cli/context-workflow/resources/manuals/guides/markdown-indexer-skill-authoring.md b/packages/context-cli/context-workflow/resources/manuals/guides/markdown-indexer-skill-authoring.md index 7db2a50d..15b38251 100644 --- a/packages/context-cli/context-workflow/resources/manuals/guides/markdown-indexer-skill-authoring.md +++ b/packages/context-cli/context-workflow/resources/manuals/guides/markdown-indexer-skill-authoring.md @@ -45,7 +45,7 @@ submit the short stage-relative manifest. Title and description belong to the article, not an internal layout-mapping tuple. Keep stable article and fragment identities when revising. Each fragment cites -at most three actual source regions. The CLI handles source digests and safety; +actual source regions from at most three files (up to eight regions). The CLI handles source digests and safety; the Agent judges whether the evidence supports the explanation. For a local failure, use the returned fragment identifier or position to repair the affected part. Completed independent tasks can be submitted without the rest of the batch. 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 6c325329..a6f1bdbd 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 @@ -80,23 +80,24 @@ 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. +`prepare` writes a draft for each pending article and a `submission_path` +template. A stale article's draft is its current translation; a missing +article's draft is the approved source. When Git history contains the approved +version the translation was made from, `source_diff` names a diff from that +version to the current article. Without such history `source_diff` is `null`; +compare the draft with the whole approved article. Update only what changed and +submit the draft. History is missing, for example, when several approve and +translate rounds ran without a commit in between: the version each translation +was made from was never recorded. Committing approved knowledge and translations +after a round lets the next generated draft carry a diff; it does not guarantee +one, because a kept draft never has a diff. + +An existing edited draft is kept (`draft: kept`) as unfinished work, with +`source_diff: null`; a draft whose body is still the unedited starting text is +regenerated with its diff. A kept draft has no diff because the source may have +changed again, even back, since it was written, so align the whole draft with +the current approved article. Delete it +to start again from the current translation. Successful submission removes the +drafts and diffs it saved. diff --git a/packages/context-cli/context-workflow/resources/manuals/reference/package-templates.md b/packages/context-cli/context-workflow/resources/manuals/reference/package-templates.md index 61eac859..d8a84e5f 100644 --- a/packages/context-cli/context-workflow/resources/manuals/reference/package-templates.md +++ b/packages/context-cli/context-workflow/resources/manuals/reference/package-templates.md @@ -316,8 +316,8 @@ Approved Markdown under `knowledge/` and the article index in and fragment `sections` with their region `references`. - Markdown fragment markers contain only their stable ID: `` and ``. - Structure records each corresponding ID and at most three source positions - per fragment, not three per article. + Structure records each corresponding ID and its source positions: at most + three distinct source files and eight regions per fragment, not per article. - A reference contains `source_ref`, `locator: { path, start_line, end_line }` and the Host-computed regional `content_digest`. Do not duplicate this in Markdown frontmatter or comments. 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 6076ca6e..e75cdb37 100644 --- a/packages/context-cli/context-workflow/resources/procedures/approved-revision.md +++ b/packages/context-cli/context-workflow/resources/procedures/approved-revision.md @@ -34,6 +34,8 @@ association. Then retry the same `task adjust` without refresh and, once materia is available, with `refresh: true`; reread the resulting revision. Do not invent module references, replace an older note, delete sources or clear the task to satisfy this boundary. Already imported material does not need to be imported again. +The adjusted source stays citable by the remaining pages queued in the same revision; +repeating the adjustment for an unchanged source keeps already prepared pages. A managed-source `base_digest` protects file replacement from conflicts; it is not source-scope authorization and is not interchangeable with a snapshot hash. @@ -137,7 +139,7 @@ Action schema and the same source and baseline validation. When a fragment's support changes, include `references` with its actual `source_ref` and `locator` (`path`, `start_line`, `end_line`). Each fragment allows -up to three positions; Context reads the captured region and computes its digest. +regions from up to three source files, eight regions in total; Context reads the captured region and computes its digest. For full Markdown, optional `sections` contains only `{ section_id, references }` entries; for local edits it can accompany `content` or update references alone. An explicit empty list removes that fragment's citations. Deleted fragments lose @@ -174,10 +176,12 @@ 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 +line numbers. Use exactly one form. A heading is the title text of an exact ATX heading +outside code fences in a Markdown source; a leading `##` marker is optional and, +when present, also selects the heading level; 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. +The per-fragment limits (three source files, eight regions) apply 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 0f45c06d..9bfe8bdf 100644 --- a/packages/context-cli/context-workflow/resources/procedures/knowledge-updates.md +++ b/packages/context-cli/context-workflow/resources/procedures/knowledge-updates.md @@ -338,7 +338,7 @@ changing structure, adding a page or when a section has no unambiguous ID. To change a fragment's citations, include `references` alongside its `section_id`: each entry supplies `source_ref` and `locator` (`path`, `start_line`, `end_line`). Context computes the region fingerprint. This uses the same revision submission, -with at most three source positions per fragment. Full Markdown may include a +with at most three source files and eight regions per fragment. Full Markdown may include a `sections` list of reference edits; local section edits may change content, references, or both. Omission preserves citations; an empty list removes them. @@ -703,17 +703,19 @@ Revision section edits may supply `references` as `source_ref` plus a `locator` containing `path`, `start_line`, and `end_line`. Context computes the regional digest. Omitted references preserve the current fragment's citations; an explicit empty list removes them, and deleting a fragment removes its -citations. Each fragment may cite at most three source positions. Register and +citations. Each fragment may cite at most three source files and eight regions. 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 +line numbers. Use exactly one form. A heading is the title text of an exact ATX heading +outside code fences in a Markdown source; a leading `##` marker is optional and, +when present, also selects the heading level; 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. +The per-fragment limits (three source files, eight regions) apply 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 52463d6a..eef9b821 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 @@ -70,7 +70,7 @@ complete article with stable `context:section` markers. References contain `sections`, each with its matching `id` and `references` list. A reference is `source_ref` plus `locator: {path, start_line, end_line}` from actual authorized text. CLI computes source digests. A fragment may cite at most three distinct -source positions; an article may have multiple fragments. +source files and eight regions; an article may have multiple fragments. When prose names a file, directory or Skill from a cited repository, write its exact repository-relative path, or the unique name of its directory such as a @@ -108,10 +108,12 @@ 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 +line numbers. Use exactly one form. A heading is the title text of an exact ATX heading +outside code fences in a Markdown source; a leading `##` marker is optional and, +when present, also selects the heading level; 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. +The per-fragment limits (three source files, eight regions) apply after +resolving and deduplicating positions. diff --git a/packages/context-cli/context-workflow/resources/procedures/source-update.md b/packages/context-cli/context-workflow/resources/procedures/source-update.md index baa31b94..24d5f6e8 100644 --- a/packages/context-cli/context-workflow/resources/procedures/source-update.md +++ b/packages/context-cli/context-workflow/resources/procedures/source-update.md @@ -26,6 +26,13 @@ Use an empty list only when existing pages cover the change or the additions are outside the confirmed purpose. New pages go through the same writing and review process as revisions; a new topic is not a no-change decision. +A kept page's citations whose text only moved in the updated source are +relocated when the update settles. When cited text changed or cannot be +confirmed, the completion result lists it in `reference_warnings`; those +citations keep their previous locations. The warning is a receipt for the +accepted decision, not a request to resubmit it: after the current update queue +finishes, run `context revise` on each listed page that needs a correction. + The CLI keeps the previous processed version until every requested page has been approved, closed and built. If temporary progress is lost, inspect again from that version and preserve the pages already approved. A saved report or 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 2750bad1..1932ff39 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,12 @@ 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: +Edit each returned draft `file` and submit the returned `submission_path`. With +`draft: generated`, a stale article's draft is its current translation; when +`source_diff` is a path, apply those source changes. A missing article's draft +is the approved source to translate in a natural tone. With `draft: kept`, or +whenever `source_diff` is `null`, align the whole draft with the current +approved article before submitting. Keep the translation's structure exactly: - frontmatter contains only a translated `title` and optional `description`; - keep every `context:section` marker, id and order unchanged; diff --git a/packages/context-cli/package.json b/packages/context-cli/package.json index e7b223ce..0a7350ce 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.70", + "version": "0.7.71", "type": "module", "license": "MIT", "engines": { diff --git a/packages/context-cli/src/__tests__/approvedRevisionReferences.test.ts b/packages/context-cli/src/__tests__/approvedRevisionReferences.test.ts index 050b99eb..3e6957a6 100644 --- a/packages/context-cli/src/__tests__/approvedRevisionReferences.test.ts +++ b/packages/context-cli/src/__tests__/approvedRevisionReferences.test.ts @@ -40,10 +40,22 @@ test("revision resolves exact regions, retains untouched citations and removes d { section_id: "first", references: [] }] })).rejects.toThrow(); await expect(prepareRevisionReferences({ ...input, edits: [{ section_id: "first", references: [ { source_ref: "note:20260912/other.md", locator }, - ] }] })).rejects.toThrow(/outside this revision/); + ] }] })).rejects.toMatchObject({ message: expect.stringContaining(`allowed sources: ${sourceRef}`), + path: ["sections", 0, "references", 0, "source_ref"] }); + await expect(prepareRevisionReferences({ ...input, edits: [{ section_id: "first", references: [ + { source_ref: sourceRef, locator: { path: "source.md", quote: "Missing behavior" } }, + ] }] })).rejects.toMatchObject({ message: expect.stringContaining("Fragment first:"), + path: ["sections", 0, "references", 0, "locator"] }); await expect(prepareRevisionReferences({ ...input, edits: [{ section_id: "first", references: [ { source_ref: sourceRef, locator: { ...locator, path: "other.md" } }, ] }] })).rejects.toThrow(/not owned/); + await writeFile(join(root, "sources/note/20260912/source.md"), Array.from({ length: 12 }, (_, line) => `Line ${line}`).join("\n")); + const lines = (count: number) => Array.from({ length: count }, (_, index) => + ({ source_ref: sourceRef, locator: { path: "source.md", start_line: index + 1, end_line: index + 1 } })); + expect((await prepareRevisionReferences({ ...input, edits: [{ section_id: "second", references: lines(8) }] }))[1]!.references).toHaveLength(8); + await expect(prepareRevisionReferences({ ...input, edits: [{ section_id: "first", references: [] }, { section_id: "second", references: lines(9) }] })) + .rejects.toMatchObject({ message: expect.stringContaining("Fragment second: A fragment can cite at most 8 source regions"), + path: ["sections", 1, "references"] }); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/packages/context-cli/src/__tests__/articleReferenceInput.test.ts b/packages/context-cli/src/__tests__/articleReferenceInput.test.ts index 5f0fd4ee..ab26eec5 100644 --- a/packages/context-cli/src/__tests__/articleReferenceInput.test.ts +++ b/packages/context-cli/src/__tests__/articleReferenceInput.test.ts @@ -28,7 +28,7 @@ test("author references hash only the requested captured region and never manufa const resolve = articleReferenceResolver({ projectRoot: root, view }); expect(resolve([reference, reference])).toEqual([createArticleSourceReference(reference.source_ref, reference.locator, content)]); expect(resolve([])).toEqual([]); - expect(() => resolve(Array.from({ length: 4 }, () => reference))).toThrow("at most three"); + expect(resolve(Array.from({ length: 4 }, () => reference))).toHaveLength(1); for (const locator of [ { path: "entry.ts", start_line: 0, end_line: 2 }, { path: "entry.ts", start_line: 3, end_line: 2 }, diff --git a/packages/context-cli/src/__tests__/indexerPageTemplateV076.test.ts b/packages/context-cli/src/__tests__/indexerPageTemplateV076.test.ts index 318c0f51..0824ec95 100644 --- a/packages/context-cli/src/__tests__/indexerPageTemplateV076.test.ts +++ b/packages/context-cli/src/__tests__/indexerPageTemplateV076.test.ts @@ -66,20 +66,20 @@ test("a slot keeps its own references and never truncates long prose", async () expect(block.references).toEqual([reference]); }); -test("separate template paragraphs can cite six regions while a single paragraph is capped at three", async () => { +test("separate template paragraphs can cite nine regions while a single paragraph is capped at eight", async () => { const input = await fixture(); const api = input.template.contract.sections.find(section => section.section_key === "api")!; const variable = input.template.contract.variables.find(item => item.id === "api")!; input.template.contract.variables = [{ ...variable, id: "first" }, { ...variable, id: "second" }]; input.template.contract.sections = [{ ...api, variable_ids: ["first", "second"] }]; input.template.section_bodies.api = "## API\n\n{{variable:first}}\n\n{{variable:second}}"; - const regions = (offset: number) => [1, 2, 3].map(line => ({ ...reference, - locator: { ...reference.locator, start_line: line + offset, end_line: line + offset } })); - const semanticVariables = { first: { value: "First paragraph", references: regions(0) }, - second: { value: "Second paragraph", references: regions(3) } }; + const regions = (offset: number, count: number) => Array.from({ length: count }, (_, index) => ({ ...reference, + locator: { ...reference.locator, start_line: index + 1 + offset, end_line: index + 1 + offset } })); + const semanticVariables = { first: { value: "First paragraph", references: regions(0, 5) }, + second: { value: "Second paragraph", references: regions(5, 4) } }; applySelectedPageTemplate({ ...input, semanticVariables }); const blocks = input.artifact.sections.find(section => section.section_key === "api")!.blocks; - expect(blocks.map(block => block.references.length)).toEqual([0, 3, 3]); + expect(blocks.map(block => block.references.length)).toEqual([0, 5, 4]); expect(blocks.map(block => block.markdown).join("\n\n")).toBe("## API\n\nFirst paragraph\n\nSecond paragraph"); const ids = blocks.map(block => block.block_id); semanticVariables.first.value = "Updated first paragraph"; diff --git a/packages/context-cli/src/__tests__/productionArticleEdits.test.ts b/packages/context-cli/src/__tests__/productionArticleEdits.test.ts index f78fb1b6..2f5a2fd1 100644 --- a/packages/context-cli/src/__tests__/productionArticleEdits.test.ts +++ b/packages/context-cli/src/__tests__/productionArticleEdits.test.ts @@ -78,16 +78,17 @@ test("reports every oversized fragment together before reading sources", async ( base: `sha256:${"b".repeat(64)}`, batch: "one", after: [], status: "issued" as const }; const task = { ...planned, input: productionTaskInput(planned) }; const file = (path: string, text: string) => ({ path, text, bytes: Buffer.byteLength(text), digest: durableContentDigest(text) }); - const refs = (count: number) => Array.from({ length: count }, (_, index) => ({ source_ref: "note:source.md", - locator: { path: "source.md", start_line: index + 1, end_line: index + 1 } })); + const refs = (count: number, path = "source.md") => Array.from({ length: count }, (_, index) => ({ source_ref: "note:source.md", + locator: { path, start_line: index + 1, end_line: index + 1 } })); let reads = 0; await expect(prepareProductionArticle({ projectRoot: ".tmp/unused-production-references", task, approvedBaseDigest: null, sourceReader: async () => { reads++; return "Source text"; }, files: { task: task.id, input: task.input, content: file("content.md", base.markdown), references: file("references.yaml", YAML.stringify({ sections: [ - { id: "a", references: [...refs(4), ...refs(4)] }, { id: "b", references: refs(5) }, + { id: "a", references: [...refs(9), ...refs(9)] }, + { id: "b", references: ["one.md", "two.md", "three.md", "four.md"].flatMap(path => refs(1, path)) }, ] })) }, - })).rejects.toThrow("a: 4 distinct source regions (maximum 3); b: 5 distinct source regions (maximum 3)"); + })).rejects.toThrow("a: 9 distinct source regions (maximum 8); b: 4 distinct source files (maximum 3)"); expect(reads).toBe(0); }); diff --git a/packages/context-cli/src/__tests__/productionFeedback.test.ts b/packages/context-cli/src/__tests__/productionFeedback.test.ts index ea7f1206..0a4fd666 100644 --- a/packages/context-cli/src/__tests__/productionFeedback.test.ts +++ b/packages/context-cli/src/__tests__/productionFeedback.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; import { z } from "zod"; import YAML from "yaml"; -import { withProductionFeedback } from "../project/productionFeedback.js"; +import { SubmissionInputError, withProductionFeedback } from "../project/productionFeedback.js"; import { ContextError } from "../lib/errors.js"; import { ExitCode } from "../types/exitCode.js"; @@ -31,3 +31,12 @@ test("schema and YAML failures identify the file and actual input fields; I/O re })).rejects.toMatchObject({ code: ExitCode.WorkspaceStateError, detail: { io_code: "EACCES", reason_code: "production-prepare-failed" } }); }); + +test("a correctable submitted value is user input, located at its input path", async () => { + const path = ["sections", 1, "references", 0, "locator"]; + await expect(withProductionFeedback({ operation: "action", file: "revision.yaml" }, async () => { + throw new SubmissionInputError("Fragment answer: Source locator matched 0 regions", path); + })).rejects.toMatchObject({ code: ExitCode.UserError, detail: { category: "user-input-invalid", file: "revision.yaml", + issues: [{ path, message: "Fragment answer: Source locator matched 0 regions" }], + next_action: { instruction: expect.stringContaining("resubmit") } } }); +}); diff --git a/packages/context-cli/src/__tests__/productionPlanAmendments.test.ts b/packages/context-cli/src/__tests__/productionPlanAmendments.test.ts index d0a60431..823a9d25 100644 --- a/packages/context-cli/src/__tests__/productionPlanAmendments.test.ts +++ b/packages/context-cli/src/__tests__/productionPlanAmendments.test.ts @@ -1,12 +1,12 @@ import { expect, test } from "bun:test"; -import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { mkdtemp, mkdir, writeFile, 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 { resolveProductionDependency } from "../project/productionDependencies.js"; +import { previousProductionDrafts } 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", @@ -28,35 +28,18 @@ test("dependency paths choose the current task and every branch rejects retired 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-")); +test("replacing a task reports its non-empty draft without copying it", async () => { + const root = await mkdtemp(join(tmpdir(), "previous-drafts-")); try { - const old = task("old"); const next = task("next"); const value = stage([old]); + const next = task("next"); const value = stage([task("old", "replaced"), next]); const from = join(productionAgentDirectory(value.id), "batches/one/old"); + const to = join(productionAgentDirectory(value.id), "batches/one/next"); 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"); + expect(await previousProductionDrafts(root, value)).toEqual([]); + await writeFile(join(root, from, "edits.yaml"), "edits:\n - replace: [a]\n with: [{ id: a, markdown: Revised }]\n"); + expect(await previousProductionDrafts(root, value)).toEqual([{ task: "next", from, to }]); + expect(await previousProductionDrafts(root, stage([task("old"), next]))).toEqual([]); + expect(existsSync(join(root, to, "edits.yaml"))).toBe(false); } 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 4d871c8d..f5debf16 100644 --- a/packages/context-cli/src/__tests__/productionPlanning.integration.test.ts +++ b/packages/context-cli/src/__tests__/productionPlanning.integration.test.ts @@ -180,6 +180,42 @@ test("writing amendments append once, preserve issued work and explicitly replac expect((await readProductionStage(projectRoot))!.pending_scopes).toEqual([]); }); +test("a replaced task's draft stays discoverable after the first planning response is lost", async () => { + const parent = resolve(".tmp/production-planning-entry-tests"); + await mkdir(parent, { recursive: true }); + const outer = await mkdtemp(join(parent, "previous-draft-")); roots.push(outer); + const { projectRoot } = await initContextProject({ cwd: outer, projectDir: "workspace", dev: true }); + const note = await importManagedDocument(projectRoot, { type: "note", name: "20260913/decisions.md", markdown: "# Decisions\nService boundaries." }); + await writeFile(join(projectRoot, "src/indexers.yaml"), YAML.stringify({ requirements: [{ id: "service", purpose: "Explain service decisions", + target_scope: { targets: [{ source_ref: note.source_ref }] } }] })); + await prepareCurrentProductionStage({ projectRoot, revision: (await productionPlanningRequest(projectRoot))!.revision }); + const stage = (await readProductionStage(projectRoot))!; + const agent = join(projectRoot, productionAgentDirectory(stage.id)); + await mkdir(join(agent, "submissions"), { recursive: true }); + const article = { path: "decision/service.md", question: "Explain the service boundary", sources: [note.source_ref], batch: "service" }; + const plan = { stage: stage.id, capabilities: { multi_agent: false, skills: [] }, articles: [article] }; + await writeFile(join(agent, "submissions/plan.yaml"), YAML.stringify(plan)); + await submitProductionPlan({ projectRoot, stage: stage.id, path: "submissions/plan.yaml" }); + await writeFile(join(agent, "submissions/report.yaml"), YAML.stringify({ stage: stage.id, decision: "approved" })); + await approveProductionReport({ projectRoot, stage: stage.id, revision: productionReportRevision((await readProductionStage(projectRoot))!), path: "submissions/report.yaml" }); + const issued = (await readProductionStage(projectRoot))!.tasks[0]!; + const from = join(productionAgentDirectory(stage.id), "batches", issued.batch, issued.id); + await mkdir(join(projectRoot, from), { recursive: true }); + await writeFile(join(projectRoot, from, "article.md"), "# Service\nWork in progress.\n"); + const amendmentPath = join(agent, "submissions/amendment.yaml"); + await writeFile(amendmentPath, YAML.stringify({ ...plan, replaces: [issued.id], articles: [{ ...article, question: "Explain the revised boundary" }] })); + const args = ["action", "complete-current", "--revision", stage.id, "--input", amendmentPath]; + const first = command(projectRoot, args); + const next = (await readProductionStage(projectRoot))!.tasks.find(task => task.status !== "replaced")!; + const expected = [{ task: next.id, from, to: join(productionAgentDirectory(stage.id), "batches", next.batch, next.id) }]; + expect(first.previous_drafts).toEqual(expected); + expect(command(projectRoot, args).previous_drafts).toEqual(expected); + command(projectRoot, ["action", "prepare-current", "--revision", stage.id]); + expect(await readFile(join(projectRoot, productionStageDirectory(stage.id), "batches", next.batch, "tasks", next.id, "task.md"), "utf8")) + .toContain(`Replaced task draft (compare before reusing; not copied): ${from}`); + await expect(readFile(join(projectRoot, expected[0]!.to, "article.md"), "utf8")).rejects.toThrow(); +}); + test("rejected prose follows the actual route through section repair, repeated Review and close", async () => { const parent = resolve(".tmp/production-planning-entry-tests"); await mkdir(parent, { recursive: true }); diff --git a/packages/context-cli/src/__tests__/projectKnowledgeUpdate.test.ts b/packages/context-cli/src/__tests__/projectKnowledgeUpdate.test.ts index 832dab4c..6d648963 100644 --- a/packages/context-cli/src/__tests__/projectKnowledgeUpdate.test.ts +++ b/packages/context-cli/src/__tests__/projectKnowledgeUpdate.test.ts @@ -150,7 +150,7 @@ test("late development context updates one code page without moving its code ver `\n\n\nThe development discussion chose separate constants for separate callers. This is intent, not a runtime guarantee.\n\n\n`; const locator = { path: note.path.split("/").at(-1)!, start_line: 1, end_line: 1 }; await expect(completeApprovedRevision({ projectRoot: root, revision: request.revision, preview: true, - markdown, sections: [{ section_id: "rationale", references: [{ source_ref: "note:unregistered/extra", locator }] }] })).rejects.toThrow("outside this revision"); + markdown, sections: [{ section_id: "rationale", references: [{ source_ref: "note:unregistered/extra", locator }] }] })).rejects.toThrow(/outside this revision[\s\S]*context task adjust/u); await complete(root, { stage: "approved-revision", markdown, sections: [{ section_id: "rationale", references: [{ source_ref: note.source_ref, locator: { ...locator, start_line: 3, end_line: 3 } }] }] }); expect(await currentLedger(root)).toBeUndefined(); @@ -265,7 +265,7 @@ test("same-task input adjustment keeps the approved body and queued pages and re await adjustCurrentTaskSources(root, { ...addition, refresh: true }); const extended = (await readApprovedRevision(root))!; expect(extended.target.source_refs).toContain(note.source_ref); - expect(extended.pending_targets).toEqual(old.pending_targets); + expect(extended.pending_targets).toEqual(old.pending_targets?.map(item => ({ ...item, supporting_sources: [note.source_ref] }))); expect(extended.processed_scopes).toHaveLength(2); expect(await baseline(root)).toEqual([]); diff --git a/packages/context-cli/src/__tests__/projectPackageKnowledgeProjection.test.ts b/packages/context-cli/src/__tests__/projectPackageKnowledgeProjection.test.ts index 136e2228..64076f3b 100644 --- a/packages/context-cli/src/__tests__/projectPackageKnowledgeProjection.test.ts +++ b/packages/context-cli/src/__tests__/projectPackageKnowledgeProjection.test.ts @@ -27,6 +27,16 @@ describe("package knowledge consumer projection", () => { expect(projectPackageKnowledgeMarkdown(projected)).toBe(projected); } }); + test("a section id is also its own anchor unless a heading already owns that slug", () => { + const projected = projectPackageKnowledgeMarkdown([ + "---", "title: Guide", "---", "", "# Guide", "", + '', "## Upgrade", "", "Steps.", "", "", + '', "## Which tool?", "", "Choose.", "", + ].join("\n")); + expect(projected).toContain('\n\n\n'); + expect(projected).toContain('\n\n'); + expect(projected).not.toContain(''); + }); test("preserves an opening title behind a projected section anchor", () => { const projected = projectPackageKnowledgeMarkdown([ "---", "title: Guide", "---", "", diff --git a/packages/context-cli/src/__tests__/repoContent.integration.test.ts b/packages/context-cli/src/__tests__/repoContent.integration.test.ts index d28825e8..1ed5a0b0 100644 --- a/packages/context-cli/src/__tests__/repoContent.integration.test.ts +++ b/packages/context-cli/src/__tests__/repoContent.integration.test.ts @@ -233,6 +233,9 @@ describe("repository content", () => { expect(update.candidates.map(item => item.path)).toEqual(["faq/guide.md"]); expect(JSON.stringify(update.repo_content)).toContain('"state":"changed"'); expect(YAML.parse(await readFile(map, "utf8")).articles[0].sections[0].references[0]).toEqual(reference); + await expect(completeKnowledgeUpdate({ projectRoot: root, revision: update.revision, decisions: [{ path: "faq/other.md" }], + scope_summary: "Wrong page", new_topics: [] })).rejects.toMatchObject({ path: ["decisions"], + message: expect.stringMatching(/Missing: faq\/guide\.md\. Not candidates .*: faq\/other\.md\./u) }); await completeKnowledgeUpdate({ projectRoot: root, revision: update.revision, decisions: [{ path: "faq/guide.md" }], scope_summary: "The clarification does not change the published claim.", new_topics: [] }); const settled = YAML.parse(await readFile(map, "utf8")); diff --git a/packages/context-cli/src/__tests__/revisionSourceScopeRecovery.integration.test.ts b/packages/context-cli/src/__tests__/revisionSourceScopeRecovery.integration.test.ts index 51af1f25..a0fbe8ae 100644 --- a/packages/context-cli/src/__tests__/revisionSourceScopeRecovery.integration.test.ts +++ b/packages/context-cli/src/__tests__/revisionSourceScopeRecovery.integration.test.ts @@ -6,6 +6,11 @@ import { initialRevisionKnowledge } from "./initialRevisionKnowledge.fixture.js" import { prepareApprovedRevision, readApprovedRevision } from "../project/approvedRevision.js"; import { adjustCurrentTaskSources } from "../project/taskSourceAdjustment.js"; import { importManagedDocument } from "../project/managedDocumentImport.js"; +import { readCandidateRecords } from "../project/candidateLedger.js"; +import { collectProjectStatus } from "../project/status.js"; +import { completeCurrentIndexerAction } from "../project/indexerCurrentAction.js"; +import { currentScopeSourceVersion } from "../project/processedScopeStorage.js"; +import { DOCUMENT_REVISION_SOURCE_REF } from "./projectDocumentRevisionV074.fixture.js"; test("new evidence scope diagnostics identify the missing association without mutating revision or sources", async () => { const roots: string[] = []; @@ -46,3 +51,49 @@ test("new evidence scope diagnostics identify the missing association without mu expect(await readFile(join(root, note.path), "utf8")).toBe(noteBytes); } finally { for (const root of roots) await rm(root, { recursive: true, force: true }); } }, 60_000); + +test("evidence added during a queued revision stays citable by its later pages without discarding prepared ones", async () => { + const roots: string[] = []; + try { + const root = await initialRevisionKnowledge(roots); + const structurePath = join(root, "knowledge/structure.yaml"); + const structure = YAML.parse(await readFile(structurePath, "utf8")); + structure.processed_scopes = [{ requirement_ref: "workspace-knowledge", source_ref: DOCUMENT_REVISION_SOURCE_REF, + processed_version: await currentScopeSourceVersion(root, DOCUMENT_REVISION_SOURCE_REF) }]; + await writeFile(structurePath, YAML.stringify(structure)); + await prepareApprovedRevision({ projectRoot: root, selector: "architecture/overview.md", instruction: "Add confirmed detail.", + pending_targets: [{ path: "architecture/usage.md", instruction: "Add the same confirmed detail." }] }); + const note = await importManagedDocument(root, { type: "note", name: "20260917/new-evidence.md", markdown: "# Evidence\n\nConfirmed detail." }); + const config = join(root, "src/indexers.yaml"); + const registry = YAML.parse(await readFile(config, "utf8")); + registry.requirements[0].evidence_source_scope = { targets: [{ source_ref: note.source_ref }] }; + await writeFile(config, YAML.stringify(registry)); + const payload = { instruction: "Use independently recorded evidence.", scopes: [{ source_ref: note.source_ref, requirement_ref: registry.requirements[0].id }] }; + await adjustCurrentTaskSources(root, payload); + await adjustCurrentTaskSources(root, { ...payload, refresh: true }); + const first = (await readApprovedRevision(root))!; + expect(first.pending_targets?.[0]?.supporting_sources).toContain(note.source_ref); + const cite = (id: string) => [{ section_id: id, references: [ + { source_ref: DOCUMENT_REVISION_SOURCE_REF, locator: { path: "src/index.ts", start_line: 1, end_line: 1 } }, + { source_ref: note.source_ref, locator: { path: "new-evidence.md", quote: "Confirmed detail." } }] }]; + const route = (await collectProjectStatus(root, { managed: true })).workflow.current!; + await completeCurrentIndexerAction({ cwd: root, revision: route.revision, managed: true, value: { stage: "approved-revision", + markdown: first.target.markdown.replace("value 42.", "value 42. Confirmed detail."), sections: cite("overview") } }); + const second = (await readApprovedRevision(root))!; + expect(second.target.path).toBe("architecture/usage.md"); + expect(second.target.source_refs).toContain(note.source_ref); + const prepared = await readCandidateRecords(root); + expect(prepared).toHaveLength(1); + await adjustCurrentTaskSources(root, payload); + await adjustCurrentTaskSources(root, { ...payload, refresh: true }); + const again = (await readApprovedRevision(root))!; + expect(again.batch_candidates?.map(item => item.candidate_id)).toEqual(prepared.map(item => item.candidate_id)); + expect(again.pending_targets ?? []).toHaveLength(0); + expect(await readCandidateRecords(root)).toEqual(prepared); + const repo = { instruction: "Cite the unchanged repository as well.", scopes: [{ source_ref: DOCUMENT_REVISION_SOURCE_REF, requirement_ref: registry.requirements[0].id }] }; + await adjustCurrentTaskSources(root, repo); + await adjustCurrentTaskSources(root, { ...repo, refresh: true }); + expect((await readApprovedRevision(root))!.batch_candidates?.map(item => item.candidate_id)).toEqual(prepared.map(item => item.candidate_id)); + expect(await readCandidateRecords(root)).toEqual(prepared); + } finally { for (const root of roots) await rm(root, { recursive: true, force: true }); } +}, 60_000); diff --git a/packages/context-cli/src/__tests__/settledReferenceRelocation.test.ts b/packages/context-cli/src/__tests__/settledReferenceRelocation.test.ts new file mode 100644 index 00000000..6cf1f8db --- /dev/null +++ b/packages/context-cli/src/__tests__/settledReferenceRelocation.test.ts @@ -0,0 +1,106 @@ +import { initialRevisionKnowledge } from "./initialRevisionKnowledge.fixture.js"; +import { afterEach, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { readFile, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import YAML from "yaml"; +import { DOCUMENT_REVISION_SOURCE_REF } from "./projectDocumentRevisionV074.fixture.js"; +import { approveCandidates } from "./projectDocumentRevisionStages.fixture.js"; +import { completeCurrentIndexerAction } from "./knowledgeMapReview.fixture.js"; +import { readCandidateRecords } from "../project/candidateLedger.js"; +import { closeProjectWorkspace } from "../project/close.js"; +import { buildFixturePackages as buildProjectPackages } from "./workspaceVersionDelivery.fixture.js"; +import { beginKnowledgeUpdate, readKnowledgeUpdate } from "../project/knowledgeUpdate.js"; +import { readApprovedRevision } from "../project/approvedRevision.js"; +import { collectProjectStatus } from "../project/status.js"; + +const roots: string[] = []; +afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); }); +const scope = { requirement_ref: "workspace-knowledge", source_ref: DOCUMENT_REVISION_SOURCE_REF, module_refs: ["module:app"] }; + +async function complete(root: string, value: unknown) { + const status = await collectProjectStatus(root, { managed: true }); + return completeCurrentIndexerAction({ cwd: root, revision: status.workflow.current!.revision, managed: true, value }) as Promise>; +} + +async function commitSource(root: string, content: string) { + const source = join(root, "fixture-source"); + await writeFile(join(source, "src/index.ts"), content); + execFileSync("git", ["add", "src/index.ts"], { cwd: source }); + execFileSync("git", ["commit", "-qm", "change source"], { cwd: source }); + const version = execFileSync("git", ["rev-parse", "HEAD"], { cwd: source, encoding: "utf8" }).trim(); + const registry = join(root, "sources/repo/index.yaml"); + const parsed = YAML.parse(await readFile(registry, "utf8")); + parsed.sources[0].modules[0].git.ref = version; + await writeFile(registry, YAML.stringify(parsed)); +} + +async function citedLines(root: string) { + const structure = YAML.parse(await readFile(join(root, "knowledge/structure.yaml"), "utf8")); + return Object.fromEntries((structure.articles as Array<{ path: string; sections: Array<{ references: Array<{ locator: { start_line: number }; content_digest: string }> }> }>) + .map(article => [article.path, article.sections[0]!.references[0]!])); +} + +async function keepAll(root: string) { + await beginKnowledgeUpdate(root, { scopes: [scope] }); + const update = (await readKnowledgeUpdate(root))!; + return complete(root, { stage: "source-update", decisions: update.candidates.map(({ path }) => ({ path })), + scope_summary: "The pages remain correct.", new_topics: [] }); +} + +test("keeping pages relocates uniquely moved unchanged citations at settlement", async () => { + const root = await initialRevisionKnowledge(roots); + const before = await citedLines(root); + await commitSource(root, `// header\n${await readFile(join(root, "fixture-source/src/index.ts"), "utf8")}`); + const result = await keepAll(root); + expect(result.outcome).toBe("unchanged"); + expect(result.reference_warnings).toBeUndefined(); + const after = await citedLines(root); + for (const [path, reference] of Object.entries(after)) { + expect(reference.locator.start_line).toBe(2); + expect(reference.content_digest).toBe(before[path]!.content_digest); + } +}, 60_000); + +test("keeping pages whose cited text changed returns a receipt and retains the old citation", async () => { + const root = await initialRevisionKnowledge(roots); + const before = await citedLines(root); + await commitSource(root, "export const answer = 43;\n"); + const result = await keepAll(root); + expect(result.outcome).toBe("unchanged"); + const warnings = result.reference_warnings as Array<{ path: string; state: string }>; + expect(warnings.map(item => item.state)).toEqual(["changed", "changed"]); + expect(String(result.reference_warning_instruction)).toContain("context revise"); + expect(await citedLines(root)).toEqual(before); +}, 60_000); + +test("a missing region baseline is reported, never treated as unchanged", async () => { + const root = await initialRevisionKnowledge(roots); + const before = await citedLines(root); + await commitSource(root, `// header\n${await readFile(join(root, "fixture-source/src/index.ts"), "utf8")}`); + await rm(join(root, ".tmp/context-runtime/source-regions"), { recursive: true, force: true }); + const result = await keepAll(root); + expect((result.reference_warnings as Array<{ state: string }>).map(item => item.state)).toEqual(["unavailable", "unavailable"]); + expect(await citedLines(root)).toEqual(before); +}, 60_000); + +test("a mixed batch relocates the kept page when the revision queue settles", async () => { + const root = await initialRevisionKnowledge(roots); + await commitSource(root, `// header\n${await readFile(join(root, "fixture-source/src/index.ts"), "utf8")}`); + await beginKnowledgeUpdate(root, { scopes: [scope] }); + const update = (await readKnowledgeUpdate(root))!; + const [revised, kept] = update.candidates; + const result = await complete(root, { stage: "source-update", scope_summary: "Clarify one page.", new_topics: [], + decisions: [{ path: revised!.path, instruction: "Clarify the entry point." }, { path: kept!.path }] }); + expect(result.outcome).toBe("revisions-prepared"); + expect(result.reference_warnings).toBeUndefined(); + expect((await citedLines(root))[kept!.path]!.locator.start_line).toBe(1); + const request = (await readApprovedRevision(root))!; + await complete(root, { stage: "approved-revision", markdown: request.target.markdown.replace("public entry point", "documented public entry point") }); + await approveCandidates(root, await readCandidateRecords(root)); + await closeProjectWorkspace(root); + await buildProjectPackages(root); + const after = await citedLines(root); + expect(after[kept!.path]!.locator.start_line).toBe(2); + expect(after[revised!.path]!.locator.start_line).toBe(2); +}, 60_000); diff --git a/packages/context-cli/src/__tests__/siteTranslationIncremental.test.ts b/packages/context-cli/src/__tests__/siteTranslationIncremental.test.ts index f49348bb..52957166 100644 --- a/packages/context-cli/src/__tests__/siteTranslationIncremental.test.ts +++ b/packages/context-cli/src/__tests__/siteTranslationIncremental.test.ts @@ -1,61 +1,89 @@ import { expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; 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 source = '---\ntitle: Guide\n---\n# Guide\n\nFirst\n\n\nSecond\n\n'; +const translated = source.replaceAll("Guide", "指南").replace("First", "第一节").replace("Second", "第二节"); 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; +const git = (root: string, ...args: string[]) => execFileSync("git", ["-C", root, "-c", "user.email=test@example.com", "-c", "user.name=test", ...args], { stdio: "pipe" }); -test("legacy migration and retained drafts never silently adopt a newer source", async () => { - const root = await mkdtemp(join(tmpdir(), "translation-incremental-")); +async function workspace(prefix: string) { + const root = await mkdtemp(join(tmpdir(), prefix)); + await mkdir(join(root, "knowledge/codeindex"), { recursive: true }); + await mkdir(join(root, "translations/zh-CN/codeindex"), { recursive: true }); + return { root, original: join(root, "knowledge/codeindex/a.md"), destination: join(root, "translations/zh-CN/codeindex/a.md") }; +} +const stored = (digest: string) => translated.replace("title: 指南", `title: 指南\ntranslation:\n source_digest: ${digest}`); + +test("a stale translation becomes the draft beside the source diff since it was translated", async () => { + const { root, original, destination } = await workspace("translation-diff-"); 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")); + await writeFile(destination, stored(approvedArticleDigest(source))); + git(root, "init", "-q"); git(root, "add", "."); git(root, "commit", "-qm", "translated"); + await writeFile(original, source.replace("Second", "Changed")); const first = await prepareSiteTranslation(root, [pkg]); - const item = first.items.find(item => item.kind === "article")!; + const item = first.items[0]!; 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("第一节"); + expect(item).toMatchObject({ draft: "generated", source_diff: `${item.file}.diff` }); + expect(await readFile(join(root, item.file), "utf8")).toContain("第二节"); + const diff = await readFile(join(root, item.source_diff!), "utf8"); + expect(diff).toContain("-Second"); + expect(diff).toContain("+Changed"); + expect(diff).not.toContain("-First"); + await writeFile(join(root, item.file), stored("ignored").replace("第二节", "已修改")); + // The draft's edit is withdrawn upstream while another section changes; the + // stored translation's diff no longer describes the kept draft. + await writeFile(original, source.replace("First", "Other")); + const again = await prepareSiteTranslation(root, [pkg]); + expect(again.items[0]).toMatchObject({ draft: "kept", source_diff: null }); + expect(existsSync(join(root, item.source_diff!))).toBe(false); + expect(await readFile(join(root, item.file), "utf8")).toContain("已修改"); + await submitSiteTranslation(root, [pkg], { expected_digest: again.expected_digest!, map_entries: [], + articles: [{ locale: "zh-CN", approved_path: item.approved_path, file: item.file }] }, file => join(root, file)); + const saved = await readFile(destination, "utf8"); + expect(saved).toContain("已修改"); + expect(saved).not.toContain("source_parts"); + expect(existsSync(join(root, item.file))).toBe(false); + expect(existsSync(join(root, item.source_diff!))).toBe(false); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("an unedited leftover draft is reseeded with the source diff instead of kept", async () => { + const { root, original, destination } = await workspace("translation-leftover-"); + try { + await writeFile(original, source); + await writeFile(destination, stored(approvedArticleDigest(source))); + git(root, "init", "-q"); git(root, "add", "."); git(root, "commit", "-qm", "translated"); + await writeFile(original, source.replace("Second", "Changed")); + const first = (await prepareSiteTranslation(root, [pkg])).items[0]!; + if (first.kind !== "article") throw new Error("Missing article"); + await rm(join(root, first.source_diff!)); + await writeFile(join(root, first.file), stored("left-by-older-cli")); + const again = (await prepareSiteTranslation(root, [pkg])).items[0]!; + expect(again).toMatchObject({ draft: "generated", source_diff: `${first.file}.diff` }); + expect(await readFile(join(root, first.source_diff!), "utf8")).toContain("+Changed"); } 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-")); +test("without history the previous translation is still the draft; missing pages start from the source", async () => { + const { root, original, destination } = await workspace("translation-nohistory-"); 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); + await writeFile(original, source.replace("Second", "Changed")); + await writeFile(destination, stored(approvedArticleDigest(source))); + const stale = (await prepareSiteTranslation(root, [pkg])).items[0]!; + if (stale.kind !== "article") throw new Error("Missing article"); + expect(stale).toMatchObject({ state: "stale", draft: "generated", source_diff: null }); + expect(await readFile(join(root, stale.file), "utf8")).toContain("第二节"); + await rm(destination); + await rm(join(root, stale.file)); + const missing = (await prepareSiteTranslation(root, [pkg])).items[0]!; + if (missing.kind !== "article") throw new Error("Missing article"); + expect(missing).toMatchObject({ state: "missing", source_diff: null }); + expect(await readFile(join(root, missing.file), "utf8")).toContain("Changed"); } 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 deleted file mode 100644 index a5f9016c..00000000 --- a/packages/context-cli/src/__tests__/siteTranslationParts.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 index dd7c3a66..41c018aa 100644 --- a/packages/context-cli/src/__tests__/submittedSourceLocator.test.ts +++ b/packages/context-cli/src/__tests__/submittedSourceLocator.test.ts @@ -10,6 +10,14 @@ test("headings resolve sections, ignore fences and stop at same or higher level" expect(() => resolveSubmittedSourceLocator({ path: "code.ts", heading: "Usage" }, text)).toThrow("Markdown"); }); +test("a heading may include its ATX marker, which also selects the level", () => { + const text = "# Usage\nTop\n## Usage\nNested\n"; + expect(resolveSubmittedSourceLocator({ path: "guide.md", heading: "## Usage" }, text)).toEqual({ path: "guide.md", start_line: 3, end_line: 4 }); + expect(resolveSubmittedSourceLocator({ path: "guide.md", heading: "# Usage" }, text)).toEqual({ path: "guide.md", start_line: 1, end_line: 4 }); + expect(() => resolveSubmittedSourceLocator({ path: "guide.md", heading: "Usage" }, text)).toThrow("2 regions"); + expect(() => resolveSubmittedSourceLocator({ path: "guide.md", heading: "### Usage" }, text)).toThrow("candidates: none"); +}); + 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"); diff --git a/packages/context-cli/src/project/actionCompletionOutput.ts b/packages/context-cli/src/project/actionCompletionOutput.ts index ab8f85a3..e30705f2 100644 --- a/packages/context-cli/src/project/actionCompletionOutput.ts +++ b/packages/context-cli/src/project/actionCompletionOutput.ts @@ -64,6 +64,7 @@ export async function prepareActionCompletionOutput(input: { result_bytes: Buffer.byteLength(full), outcome_counts: counts, ...pick(result, ["workflow_summary", "composer_result", "submitted_slice"]), + ...(Array.isArray(result.reference_warnings) ? { reference_warning_count: result.reference_warnings.length } : {}), committed_count: Array.isArray(result.outcomes) ? outcomes.filter((item) => item.committed === true).length : null, outcomes: outcomes.map((item) => ({ ...pick(item, ["task_key", "outcome", "committed"]), @@ -98,6 +99,7 @@ export async function prepareActionCompletionOutput(input: { summary.outcomes_omitted++; } summary.details_required = summary.outcomes_omitted > 0 || failure?.outcome === "failed" || + (Array.isArray(result.reference_warnings) && result.reference_warnings.length > 0) || outcomes.some(item => !["accepted", "material-expanded"].includes(String(item.outcome))) || (outcomes.length === 0 && result.outcome === undefined && result.workflow_summary === undefined) || (next === undefined && result.workflow_summary === undefined); diff --git a/packages/context-cli/src/project/approvedRevisionReferences.ts b/packages/context-cli/src/project/approvedRevisionReferences.ts index 7ada9063..98a3f2dd 100644 --- a/packages/context-cli/src/project/approvedRevisionReferences.ts +++ b/packages/context-cli/src/project/approvedRevisionReferences.ts @@ -1,7 +1,10 @@ +import { ZodError } from "zod"; import { resolveSubmittedSourceLocator } from "./submittedSourceLocator.js"; -import { articleFragmentReferences, articleSourceRegionDigest, createArticleSourceReference, - locateArticleRegion, repoContentScopeMatches, type ArticleStructureEntry } from "@c4a/context"; -import { readArticleRegionBaseline, rememberArticleRegion } from "./articleRegionBaselines.js"; +import { SubmissionInputError } from "./productionFeedback.js"; +import { articleFragmentReferences, createArticleSourceReference, + repoContentScopeMatches, type ArticleStructureEntry } from "@c4a/context"; +import { rememberArticleRegion } from "./articleRegionBaselines.js"; +import { relocateUnchangedReference } from "./settledReferenceRelocation.js"; import { registeredArticleSourceReader } from "./articleSourceReader.js"; import type { RevisionContentInput } from "./approvedRevisionEdits.js"; @@ -19,8 +22,9 @@ export async function prepareRevisionReferences(input: { throw new TypeError(`Reference edit targets a missing fragment: ${id}`); } let read: Awaited> | undefined; + const inScope = (source: string) => input.sourceRefs.some(scope => repoContentScopeMatches(scope, source)); const readSource = async (source: string, path: string) => { - if (!input.sourceRefs.some(scope => repoContentScopeMatches(scope, source))) throw new TypeError(`Source is outside this revision: ${source}`); + if (!inScope(source)) throw new TypeError(`Source is outside this revision: ${source}`); read ??= await registeredArticleSourceReader(input.projectRoot); return read(source, path, true); }; @@ -28,34 +32,36 @@ export async function prepareRevisionReferences(input: { for (const id of input.sectionIds) { const edit = edits.get(id); if (edit?.references !== undefined) { + const at = ["sections", input.edits!.indexOf(edit), "references"]; const references = []; - for (const reference of edit.references) { + for (const [index, reference] of edit.references.entries()) { + if (!inScope(reference.source_ref)) throw new SubmissionInputError( + `Fragment ${id} cites ${reference.source_ref}, which is outside this revision; allowed sources: ${input.sourceRefs.join(", ")}. ` + + "To cite new independent evidence, register it as a source, associate it with the selected requirement in src/indexers.yaml, then run context task adjust for this revision.", + [...at, index, "source_ref"]); const text = await readSource(reference.source_ref, reference.locator.path); - const resolved = createArticleSourceReference(reference.source_ref, resolveSubmittedSourceLocator(reference.locator, text), text); + let locator; + try { locator = resolveSubmittedSourceLocator(reference.locator, text); } + catch (error) { + if (error instanceof SubmissionInputError) throw new SubmissionInputError(`Fragment ${id}: ${error.message}`, [...at, index, "locator"]); + throw error; + } + const resolved = createArticleSourceReference(reference.source_ref, locator, text); rememberArticleRegion(input.projectRoot, resolved, text); references.push(resolved); } - result.push({ id, references: articleFragmentReferences(references) }); + try { result.push({ id, references: articleFragmentReferences(references) }); } + catch (error) { + if (error instanceof ZodError) throw new SubmissionInputError(`Fragment ${id}: ${error.issues.map(issue => issue.message).join("; ")}`, at); + throw error; + } continue; } const references = []; for (const reference of input.previous.find(section => section.id === id)?.references ?? []) { - let resolved = reference; - try { - const text = await readSource(reference.source_ref, reference.locator.path); - let digest: string | undefined; - try { digest = articleSourceRegionDigest(text, reference.locator); } - catch (error) { if (!(error instanceof RangeError)) throw error; } - if (digest !== reference.content_digest) { - const previous = readArticleRegionBaseline(input.projectRoot, reference.content_digest); - const locator = previous === undefined ? null : locateArticleRegion(previous, text, reference.locator.path); - if (locator) resolved = { ...reference, locator }; - } - } catch { - // An expression-only revision need not recapture unavailable sources. - // Keep the old reference so normal region warnings remain observable. - } - references.push(resolved); + // An expression-only revision need not recapture unavailable sources. + // Unresolved references stay unchanged so normal region warnings remain observable. + references.push((await relocateUnchangedReference(input.projectRoot, readSource, reference)).reference); } result.push({ id, references }); } diff --git a/packages/context-cli/src/project/articleReferenceInput.ts b/packages/context-cli/src/project/articleReferenceInput.ts index 59f9c389..1e8f5b85 100644 --- a/packages/context-cli/src/project/articleReferenceInput.ts +++ b/packages/context-cli/src/project/articleReferenceInput.ts @@ -30,7 +30,6 @@ export function articleReferenceResolver(input: { value: item.value as Record, })); return (references: readonly ReferenceInput[]): ArticleSourceReference[] => { - if (references.length > 3) throw new TypeError("A fragment can cite at most three source positions"); return articleFragmentReferences(references.map(reference => { const locator = articleSourceLocatorSchema.parse(reference.locator); const key = JSON.stringify([reference.source_ref, locator.path]); diff --git a/packages/context-cli/src/project/indexerAgentReading.ts b/packages/context-cli/src/project/indexerAgentReading.ts index 5867875b..ce7cb248 100644 --- a/packages/context-cli/src/project/indexerAgentReading.ts +++ b/packages/context-cli/src/project/indexerAgentReading.ts @@ -211,7 +211,7 @@ export function buildIndexerTaskReading(input: IndexerTaskReadingInput): Indexer } const sources = buildIndexerAuthorSourceItems({ view }); if (workset.stage === "author") { - output.push("Write section content and references: source_ref plus locator { path, start_line, end_line }. Choose only the regions actually used, at most three per fragment; split the fragment when necessary. Context computes regional digests. Do not submit parser facts, evidence IDs or file-wide unions.", ""); + output.push("Write section content and references: source_ref plus locator { path, start_line, end_line }. Choose only the regions actually used: at most three source files and eight regions per fragment; split the fragment when necessary. Context computes regional digests. Do not submit parser facts, evidence IDs or file-wide unions.", ""); for (const source of sources.choices) { const body = [`### ${source.path}`, "", readingBlock({ source_ref: source.source_ref, path: source.path, available_ranges: source.ranges, diff --git a/packages/context-cli/src/project/indexerBaseContracts.ts b/packages/context-cli/src/project/indexerBaseContracts.ts index 8a8870af..3c3b2a2f 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.70"; +export const BUNDLED_INDEXER_PARSER_PACKAGE_VERSION = "0.7.71"; 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 7ce7c982..d99569db 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, 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); + ...(candidate === undefined ? {} : { candidate_id: candidate.candidate_id }) }, input.managed === true, authorities); } if (semantic.stage === "structure-review") { const { readKnowledgeUpdate, completeUpdateStructureReview } = await import("./knowledgeUpdate.js"); diff --git a/packages/context-cli/src/project/knowledgeUpdate.ts b/packages/context-cli/src/project/knowledgeUpdate.ts index 2ab4c056..8f09ae56 100644 --- a/packages/context-cli/src/project/knowledgeUpdate.ts +++ b/packages/context-cli/src/project/knowledgeUpdate.ts @@ -14,9 +14,11 @@ import { captureProcessedScopes, currentScopeSourceVersion, commitProcessedScope import { readProductionStage } from "./productionStageStore.js"; import { readCandidateRecords } from "./candidateLedger.js"; import { withProjectWriteLock } from "./writeLock.js"; +import { SubmissionInputError } from "./productionFeedback.js"; import { repoContentScopeMatches } from "@c4a/context"; import { ensureRepoContentLinks } from "./repoContentLinks.js"; import { inspectRepoContentChanges, repoContentImpactReader } from "./repoContentEvidence.js"; +import { inspectSettledReferences } from "./settledReferenceRelocation.js"; export const knowledgeUpdateInputSchema = z.object({ scopes: z.array(z.object({ requirement_ref: z.string().min(1), source_ref: z.string().min(1), @@ -94,6 +96,23 @@ export async function beginKnowledgeUpdate(projectRoot: string, value: unknown) }); } +/** A receipt for decisions already accepted, not a gate: kept pages whose cited + * text changed or cannot be confirmed retain their previous citations. */ +async function keptReferenceWarnings(projectRoot: string, + decisions: ReadonlyArray<{ path: string; instruction?: string | undefined }>, scopes: readonly string[]) { + const kept = new Set(decisions.filter((decision) => !decision.instruction?.trim()).map((decision) => decision.path)); + if (kept.size === 0) return {}; + const structure = await readKnowledgeStructure(projectRoot); + const articles = validateArticleStructureEntries(structure.parsed?.articles ?? []).filter((article) => kept.has(article.path)); + const { unresolved } = await inspectSettledReferences(projectRoot, articles, scopes); + if (unresolved.length === 0) return {}; + return { + reference_warnings: unresolved.map((item) => ({ path: item.path, section_id: item.section_id, state: item.state, + source_ref: item.reference.source_ref, locator: item.reference.locator })), + reference_warning_instruction: "These pages were kept unchanged as decided. Their listed citations changed or could not be confirmed against the updated source and keep their previous locations. The decision cannot be resubmitted; after the current update queue finishes, run context revise --instruction for each page that needs a correction.", + }; +} + export async function completeKnowledgeUpdate(input: { projectRoot: string; revision: string; decisions: Array<{ path: string; instruction?: string | undefined; supporting_sources?: string[] | undefined }>; scope_summary: string; new_topics: NewKnowledgePage[]; structure_approved?: boolean }) { return withProjectWriteLock(input.projectRoot, "resolve-knowledge-update", async () => { @@ -108,23 +127,32 @@ export async function completeKnowledgeUpdate(input: { projectRoot: string; revi for (const page of input.new_topics) { newKnowledgePageTarget(page); if (page.source_refs.some((ref) => !request.scopes.some((scope) => repoContentScopeMatches(scope.source_ref, ref) || ref.startsWith(`${scope.source_ref}/`)))) { - throw new TypeError("New topic references material outside the confirmed update scope"); + throw new SubmissionInputError(`New topic ${page.path} references material outside the confirmed update scope; allowed sources: ${request.scopes.map((scope) => scope.source_ref).join(", ")}`, + ["new_topics", input.new_topics.indexOf(page), "source_refs"]); } } - for (const decision of input.decisions) { + for (const [index, decision] of input.decisions.entries()) { if (decision.supporting_sources && (!decision.instruction || decision.supporting_sources.some((ref) => !request.scopes.some((scope) => repoContentScopeMatches(scope.source_ref, ref))))) { - throw new TypeError("Supporting sources must belong to the confirmed update and accompany a concrete revision instruction"); + throw new SubmissionInputError(`Supporting sources for ${decision.path} must belong to the confirmed update and accompany a concrete revision instruction`, + ["decisions", index, "supporting_sources"]); } } const paths = [...input.decisions.map((decision) => decision.path), ...input.new_topics.map((page) => page.path)]; - if (new Set(paths).size !== paths.length) throw new TypeError("Update page paths must be unique"); - const selected = new Set(input.decisions.map((decision) => decision.path)); - if (selected.size !== input.decisions.length || selected.size !== request.candidates.length || - request.candidates.some((candidate) => !selected.has(candidate.path))) { - throw new TypeError("Decide each source-bound candidate once; an omitted page is not a no-change decision"); + const repeated = [...new Set(paths.filter((path, index) => paths.indexOf(path) !== index))]; + if (repeated.length) throw new SubmissionInputError(`Update page paths must be unique; repeated: ${repeated.join(", ")}`, ["decisions"]); + const candidates = new Set(request.candidates.map((candidate) => candidate.path)); + const missing = [...candidates].filter((path) => !input.decisions.some((decision) => decision.path === path)); + const unknown = input.decisions.map((decision) => decision.path).filter((path) => !candidates.has(path)); + if (missing.length || unknown.length) { + throw new SubmissionInputError([ + "Decide each source-bound candidate once; an omitted page is not a no-change decision. List a candidate with only its path to keep it unchanged.", + ...(missing.length ? [`Missing: ${missing.join(", ")}.`] : []), + ...(unknown.length ? [`Not candidates (use new_topics for new pages): ${unknown.join(", ")}.`] : []), + ].join(" "), ["decisions"]); } await captureProcessedScopes(input.projectRoot, request.scopes); + const receipt = await keptReferenceWarnings(input.projectRoot, input.decisions, request.scopes.map((scope) => scope.source_ref)); const revisions: Array<{ path: string; instruction: string; create?: NewKnowledgePage; supporting_sources?: string[] | undefined }> = input.decisions.filter((decision): decision is { path: string; instruction: string } => typeof decision.instruction === "string" && decision.instruction.trim().length > 0); revisions.push(...input.new_topics.map((page) => ({ path: page.path, instruction: page.instruction, create: page }))); @@ -140,13 +168,13 @@ export async function completeKnowledgeUpdate(input: { projectRoot: string; revi await commitProcessedScopes(input.projectRoot, request.scopes); const { clearCompletedLifecycle } = await import("./lifecycleCleanup.js"); await clearCompletedLifecycle(input.projectRoot); - return { outcome: "unchanged" as const }; + return { outcome: "unchanged" as const, ...receipt }; } await prepareApprovedRevision({ projectRoot: input.projectRoot, replace_current: true, selector: first.path, instruction: first.instruction, pending_targets: remaining, processed_scopes: request.scopes, requirements: request.requirements, ...(first.supporting_sources === undefined ? {} : { supporting_sources: first.supporting_sources }), ...(first.create === undefined ? {} : { create: first.create }) }); - return { outcome: "revisions-prepared" as const, page_count: revisions.length }; + return { outcome: "revisions-prepared" as const, page_count: revisions.length, ...receipt }; }); } diff --git a/packages/context-cli/src/project/packageKnowledgeProjection.ts b/packages/context-cli/src/project/packageKnowledgeProjection.ts index ccc002ae..d54d43e1 100644 --- a/packages/context-cli/src/project/packageKnowledgeProjection.ts +++ b/packages/context-cli/src/project/packageKnowledgeProjection.ts @@ -1,6 +1,7 @@ import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { ensureMarkdownPageTitle } from "./markdownPageTitle.js"; import { isRuntimeOnlyKnowledgeMetadataField } from "./knowledgeMetadataPolicy.js"; +import { packageMarkdownAnchors } from "./packageMarkdownAnchors.js"; const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u; const CONTEXT_METADATA_BLOCK_RE = /[ \t]*(?:\r?\n){0,2}/giu; @@ -198,12 +199,17 @@ export function projectPackageKnowledgeMarkdown(content: string): string { if (match === null) return content; const frontmatter = parseKnowledgeFrontmatter(content); if (Object.keys(frontmatter).length === 0) return content; - const body = content - .slice(match[0].length) + const source = content.slice(match[0].length); + // Authors link `#
`; expose it unless a heading on the page already owns that slug. + let headingAnchors: Set | undefined; + const body = source .replace(CONTEXT_METADATA_BLOCK_RE, "") .replace(CONTEXT_SECTION_OPEN_RE, opening => { const key = /\bid="([a-zA-Z0-9_-]+)"/u.exec(opening)?.[1]; - return key === undefined ? "" : `\n\n\n\n`; + if (key === undefined) return ""; + headingAnchors ??= packageMarkdownAnchors(source); + const bare = headingAnchors.has(key) ? "" : `\n`; + return `\n\n${bare}\n\n`; }) .replace(CONTEXT_SECTION_CLOSE_RE, "\n") .replace(LARK_RESOURCE_LOCATOR_COMMENT_RE, "") diff --git a/packages/context-cli/src/project/packageRenderCache.ts b/packages/context-cli/src/project/packageRenderCache.ts index f8b000b8..273bba9c 100644 --- a/packages/context-cli/src/project/packageRenderCache.ts +++ b/packages/context-cli/src/project/packageRenderCache.ts @@ -5,7 +5,7 @@ import { atomicWriteFile } from "../lib/atomicWrite.js"; import { projectPackageKnowledgeMarkdown } from "./packageKnowledgeProjection.js"; const digest = (text: string) => createHash("sha256").update(text).digest("hex"); -export const PACKAGE_READER_MARKDOWN_VERSION = "package-reader-markdown/v4"; +export const PACKAGE_READER_MARKDOWN_VERSION = "package-reader-markdown/v5"; /** One replaceable cache entry per page/projection. A later batch reuses reader * Markdown when its exact input has not changed; nothing is added to the page. */ diff --git a/packages/context-cli/src/project/packageSiteTheme.ts b/packages/context-cli/src/project/packageSiteTheme.ts index f211cbfe..d03a073c 100644 --- a/packages/context-cli/src/project/packageSiteTheme.ts +++ b/packages/context-cli/src/project/packageSiteTheme.ts @@ -76,7 +76,7 @@ export const siteMarkdownConfig = String.raw` }); md.block.ruler.before('paragraph', 'context_anchor', (state, line, end, silent) => { const raw = state.src.slice(state.bMarks[line] + state.tShift[line], state.eMarks[line]); - const match = /^<\/a>$/.exec(raw); + const match = /^<\/a>$/.exec(raw); if (!match) return false; if (!silent) { const token = state.push('context_anchor', '', 0); token.content = match[1]; } state.line = line + 1; diff --git a/packages/context-cli/src/project/processedScopeStorage.ts b/packages/context-cli/src/project/processedScopeStorage.ts index 158554a9..15484a20 100644 --- a/packages/context-cli/src/project/processedScopeStorage.ts +++ b/packages/context-cli/src/project/processedScopeStorage.ts @@ -9,6 +9,7 @@ import { readKnowledgeStructure } from "./packageBuildInventory.js"; import { parseDocumentSnapshotForSource } from "./documentBatchManifest.js"; import { assertPinnedSource } from "./indexerParserSourceMaterialization.js"; import { repoContentWorkingVersion, advanceRepoContentReferences } from "./repoContentEvidence.js"; +import { inspectSettledReferences } from "./settledReferenceRelocation.js"; export async function currentScopeSourceVersion(projectRoot: string, sourceRef: string): Promise { if (sourceRef.startsWith("repo-content:")) return repoContentWorkingVersion(projectRoot, sourceRef); @@ -64,8 +65,9 @@ export async function commitProcessedScopes(projectRoot: string, scopes: readonl const structure = await readKnowledgeStructure(projectRoot); if (structure.parsed === null) throw new TypeError("A closed knowledge structure is required before recording processed scopes"); const processed = mergeProcessedScopes(readProcessedScopes(structure.parsed), scopes); - const articles = await advanceRepoContentReferences(projectRoot, + const repoContent = await advanceRepoContentReferences(projectRoot, validateArticleStructureEntries(structure.parsed.articles ?? []), scopes.filter(scope => !scope.module_refs).map(scope => scope.source_ref)); + const { articles } = await inspectSettledReferences(projectRoot, repoContent, scopes.map(scope => scope.source_ref)); await atomicWriteFile(join(projectRoot, structure.path), YAML.stringify({ ...structure.parsed, articles, processed_scopes: processed })); } diff --git a/packages/context-cli/src/project/productionArticle.ts b/packages/context-cli/src/project/productionArticle.ts index be21db96..63d6fb93 100644 --- a/packages/context-cli/src/project/productionArticle.ts +++ b/packages/context-cli/src/project/productionArticle.ts @@ -1,7 +1,7 @@ import { resolveSubmittedSourceLocator } from "./submittedSourceLocator.js"; import YAML from "yaml"; import { z } from "zod"; -import { articleFragmentReferences, createArticleSourceReference, +import { ARTICLE_FRAGMENT_REGION_LIMIT, ARTICLE_FRAGMENT_SOURCE_FILE_LIMIT, articleFragmentReferences, createArticleSourceReference, indexerKnowledgeCollectionSchema, indexerProtocolDigest, repoContentScopeMatches } from "@c4a/context"; import { ContextError } from "../lib/errors.js"; import { ErrorCategory } from "../lib/cliFeedback.js"; @@ -127,11 +127,13 @@ 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 => { + const files = new Set(section.references.map(ref => JSON.stringify([ref.source_ref, ref.locator.path]))).size; + if (files > ARTICLE_FRAGMENT_SOURCE_FILE_LIMIT) return [`${section.id}: ${files} distinct source files (maximum ${ARTICLE_FRAGMENT_SOURCE_FILE_LIMIT})`]; 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; - return count > 3 ? [`${section.id}: ${count} distinct source regions (maximum 3)`] : []; + return count > ARTICLE_FRAGMENT_REGION_LIMIT ? [`${section.id}: ${count} distinct source regions (maximum ${ARTICLE_FRAGMENT_REGION_LIMIT})`] : []; }); if (oversized.length) throw articleError(task, files.references.path, `Split the claims or choose sufficient references for these fragments: ${oversized.join("; ")}.`); diff --git a/packages/context-cli/src/project/productionDependencies.ts b/packages/context-cli/src/project/productionDependencies.ts index a1350f7c..c1205f23 100644 --- a/packages/context-cli/src/project/productionDependencies.ts +++ b/packages/context-cli/src/project/productionDependencies.ts @@ -1,19 +1,5 @@ -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; diff --git a/packages/context-cli/src/project/productionDraftMigration.ts b/packages/context-cli/src/project/productionDraftMigration.ts index 5550be42..acf18438 100644 --- a/packages/context-cli/src/project/productionDraftMigration.ts +++ b/packages/context-cli/src/project/productionDraftMigration.ts @@ -1,11 +1,8 @@ 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 { safeProjectTarget } from "./durableMultiFileTransaction.js"; import type { ProductionStage, ProductionTask } from "./productionStage.js"; /** The projected edits skeleton carries no Agent work. */ @@ -14,41 +11,25 @@ function emptyEdits(text: string): boolean { 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; } - }; +export interface PreviousDraft { task: string; from: string; to: string } +/** Drafts of replaced tasks for the same article are reported, never copied: + * only the Agent can judge whether that work still fits. Derived from the saved + * stage, so every preparation and replay reports them again. */ +export async function previousProductionDrafts(root: string, stage: ProductionStage, tasks: readonly ProductionTask[] = stage.tasks): Promise { + const result: PreviousDraft[] = []; 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 (task.status === "replaced") continue; + for (const old of stage.tasks.filter(old => old.status === "replaced" && old.article_id === task.article_id && old.path === task.path)) { + const from = join(productionAgentDirectory(stage.id), "batches", old.batch, old.id); + for (const name of ["edits.yaml", "article.md", "references.yaml"]) { + let content: string; + try { content = await readFile(await safeProjectTarget(root, join(from, name)), "utf8"); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; } + if (name === "edits.yaml" && emptyEdits(content)) continue; + result.push({ task: task.id, from, to: join(productionAgentDirectory(stage.id), "batches", task.batch, task.id) }); + break; + } } - 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/productionFeedback.ts b/packages/context-cli/src/project/productionFeedback.ts index a804698d..09148907 100644 --- a/packages/context-cli/src/project/productionFeedback.ts +++ b/packages/context-cli/src/project/productionFeedback.ts @@ -5,6 +5,12 @@ import { ContextError } from "../lib/errors.js"; import { ErrorCategory } from "../lib/cliFeedback.js"; import { ExitCode } from "../types/exitCode.js"; +/** A submitted value that the author can correct in the same input file, such as + * an unmatched locator, an unauthorized source or an oversized fragment. */ +export class SubmissionInputError extends TypeError { + constructor(message: string, readonly path: ReadonlyArray = []) { super(message); } +} + /** Add recovery information at the production boundary, without new checks, * retries or reads. Existing specific errors keep their category and reason. */ export async function withProductionFeedback(input: { @@ -13,15 +19,18 @@ export async function withProductionFeedback(input: { try { return await run(); } catch (error) { const schemaError = error instanceof ZodError; + const inputError = error instanceof SubmissionInputError; const syntaxError = error instanceof YAMLParseError; const existing = error instanceof ContextError ? error : undefined; const ioCode = (error as NodeJS.ErrnoException | undefined)?.code; const detail: Record = { - category: schemaError || syntaxError ? ErrorCategory.UserInputInvalid : ErrorCategory.WorkspaceStateInvalid, + category: schemaError || syntaxError || inputError ? ErrorCategory.UserInputInvalid : ErrorCategory.WorkspaceStateInvalid, reason_code: `production-${input.operation}-failed`, - next_action: { command: "context status --format json", ...(input.recovery ? { instruction: input.recovery } : {}) }, + next_action: { command: "context status --format json", ...(input.recovery ? { instruction: input.recovery } + : inputError ? { instruction: "Correct the reported item in the input file and resubmit it with the same revision." } : {}) }, ...(input.file ? { file: input.file } : {}), ...(schemaError ? { issues: error.issues.map(issue => ({ path: issue.path, message: issue.message })) } : {}), + ...(inputError ? { issues: [{ path: error.path, message: error.message }] } : {}), ...(syntaxError && error.linePos ? { location: error.linePos } : {}), ...(typeof ioCode === "string" ? { io_code: ioCode } : {}), ...existing?.detail, @@ -30,7 +39,7 @@ export async function withProductionFeedback(input: { if (input.schema && detail.input_schema === undefined) { detail.input_schema = zodToJsonSchema(input.schema, { $refStrategy: "none" }); } - throw new ContextError(existing?.code ?? (schemaError || syntaxError ? ExitCode.UserError : ExitCode.WorkspaceStateError), + throw new ContextError(existing?.code ?? (schemaError || syntaxError || inputError ? ExitCode.UserError : ExitCode.WorkspaceStateError), error instanceof Error ? error.message : String(error), detail); } } diff --git a/packages/context-cli/src/project/productionPlanning.ts b/packages/context-cli/src/project/productionPlanning.ts index 6b37570d..51840316 100644 --- a/packages/context-cli/src/project/productionPlanning.ts +++ b/packages/context-cli/src/project/productionPlanning.ts @@ -1,5 +1,5 @@ -import { migrateProductionDrafts } from "./productionDraftMigration.js"; -import { inheritedProductionSources, resolveProductionDependency } from "./productionDependencies.js"; +import { previousProductionDrafts } from "./productionDraftMigration.js"; +import { resolveProductionDependency } from "./productionDependencies.js"; import { randomUUID } from "node:crypto"; import { readFile, access } from "node:fs/promises"; import { join } from "node:path"; @@ -170,15 +170,6 @@ export async function submitProductionPlan(input: { projectRoot: string; stage: 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)); @@ -218,9 +209,12 @@ export async function submitProductionPlan(input: { projectRoot: string; stage: if (identities.size !== plan.articles.length) throw new TypeError("Plan article paths must be unique"); if (stage.report_approved && identities.size > 0 && [...identities.values()].every(id => stage.tasks.some(task => task.id === id))) { const prepared = await materializeProductionStage({ projectRoot: input.projectRoot, stage, capabilities: plan.capabilities }); - return { stage_state: prepared.state, next: { directory: prepared.directory, agent_directory: prepared.agent_directory, mode: prepared.mode, + const previous = await previousProductionDrafts(input.projectRoot, stage, stage.tasks.filter(task => [...identities.values()].includes(task.id))); + return { stage_state: prepared.state, ...(previous.length ? { previous_drafts: previous } : {}), 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 && @@ -250,9 +244,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 previous = await previousProductionDrafts(input.projectRoot, updated, tasks); const prepared = await materializeProductionStage({ projectRoot: input.projectRoot, stage: updated, capabilities: plan.capabilities }); - return { stage_state: prepared.state, draft_migrations, ...(prepared.submission ? { next: { directory: prepared.directory, agent_directory: prepared.agent_directory, + return { stage_state: prepared.state, ...(previous.length ? { previous_drafts: previous } : {}), ...(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 25efd292..40ef0243 100644 --- a/packages/context-cli/src/project/productionStageStore.ts +++ b/packages/context-cli/src/project/productionStageStore.ts @@ -7,6 +7,7 @@ import { atomicWriteFile } from "../lib/atomicWrite.js"; import { safeProjectTarget } from "./durableMultiFileTransaction.js"; import { withProjectWriteLock } from "./writeLock.js"; import { productionAgentDirectory } from "./productionSubmissionFiles.js"; +import { previousProductionDrafts } from "./productionDraftMigration.js"; import { readCandidateRecords } from "./candidateLedger.js"; import { readApprovedKnowledgeMetadataIndex } from "./approvedKnowledgeMetadata.js"; import { productionApprovedTargetsIndex, readProductionArticleTarget } from "./productionArticleTarget.js"; @@ -213,10 +214,12 @@ export async function materializeProductionStage(input: { 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`); + const replacedDrafts = await previousProductionDrafts(input.projectRoot, stage, [task]); await write(join(taskRoot, "task.md"), [`# ${task.question}`, "", `Target: ${task.path}`, ...(edits ? [`Draft edits: ${join(agent, output, "edits.yaml")}`] : [ `Draft Markdown: ${join(agent, output, "article.md")}`, `Draft references: ${join(agent, output, "references.yaml")}`]), + ...replacedDrafts.map(draft => `Replaced task draft (compare before reusing; not copied): ${draft.from}`), `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")}`, diff --git a/packages/context-cli/src/project/productionSubmission.ts b/packages/context-cli/src/project/productionSubmission.ts index e88fa47b..5294c947 100644 --- a/packages/context-cli/src/project/productionSubmission.ts +++ b/packages/context-cli/src/project/productionSubmission.ts @@ -44,7 +44,6 @@ 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" }; @@ -93,7 +92,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", resolved_references: [] }; + const result: ProductionSubmissionResult = { accepted: [], failed: [], stage_state: "active" }; const tasksById = new Map(stage.tasks.map(task => [task.id, task])); const pending = new Map(); // Check only currently issued submitted inputs. Independent source checks @@ -198,11 +197,7 @@ 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 }); - 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 }))) ?? []); - } + for (const [task, proposal] of pending) result.accepted.push({ task, receipt: proposal.candidate.candidate_id }); } 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/settledReferenceRelocation.ts b/packages/context-cli/src/project/settledReferenceRelocation.ts new file mode 100644 index 00000000..6fd6dafe --- /dev/null +++ b/packages/context-cli/src/project/settledReferenceRelocation.ts @@ -0,0 +1,53 @@ +import { articleSourceRegionDigest, locateArticleRegion, type ArticleSourceReference, type ArticleStructureEntry } from "@c4a/context"; +import { readArticleRegionBaseline } from "./articleRegionBaselines.js"; +import { registeredArticleSourceReader } from "./articleSourceReader.js"; + +type SourceRead = (sourceRef: string, path: string, captured: boolean) => Promise; + +export type ReferenceRelocation = + | { state: "current" | "moved"; reference: ArticleSourceReference } + | { state: "changed" | "unavailable"; reference: ArticleSourceReference }; + +/** Only uniquely moved, unchanged cited text may update a locator without an + * Agent decision. A missing baseline is uncertainty, never proof of no change. */ +export async function relocateUnchangedReference(root: string, read: SourceRead, + reference: ArticleSourceReference): Promise { + let text: string; + try { text = await read(reference.source_ref, reference.locator.path, true); } + catch { return { state: "unavailable", reference }; } + let digest: string | undefined; + try { digest = articleSourceRegionDigest(text, reference.locator); } + catch (error) { if (!(error instanceof RangeError)) throw error; } + if (digest === reference.content_digest) return { state: "current", reference }; + const previous = readArticleRegionBaseline(root, reference.content_digest); + if (previous === undefined) return { state: "unavailable", reference }; + const locator = locateArticleRegion(previous, text, reference.locator.path); + return locator ? { state: "moved", reference: { ...reference, locator } } : { state: "changed", reference }; +} + +const settledBy = (scopes: readonly string[], sourceRef: string) => !sourceRef.startsWith("repo-content:") && + scopes.some(scope => sourceRef === scope || sourceRef.startsWith(`${scope}#`) || sourceRef.startsWith(`${scope}/`)); + +/** Repository content keeps its own settlement in advanceRepoContentReferences. */ +export async function inspectSettledReferences(root: string, articles: readonly ArticleStructureEntry[], scopes: readonly string[]) { + const sources = scopes.filter(scope => !scope.startsWith("repo-content:")); + if (!articles.some(article => article.sections.some(section => + section.references.some(reference => settledBy(sources, reference.source_ref))))) { + return { articles: [...articles], unresolved: [] as Array<{ path: string; section_id: string; reference: ArticleSourceReference; state: "changed" | "unavailable" }> }; + } + const read = await registeredArticleSourceReader(root); + const unresolved: Array<{ path: string; section_id: string; reference: ArticleSourceReference; state: "changed" | "unavailable" }> = []; + const next = await Promise.all(articles.map(async article => ({ ...article, + sections: await Promise.all(article.sections.map(async section => ({ ...section, + references: await Promise.all(section.references.map(async reference => { + if (!settledBy(sources, reference.source_ref)) return reference; + const result = await relocateUnchangedReference(root, read, reference); + if (result.state === "changed" || result.state === "unavailable") { + unresolved.push({ path: article.path, section_id: section.id, reference, state: result.state }); + } + return result.reference; + })), + }))), + }))); + return { articles: next, unresolved }; +} diff --git a/packages/context-cli/src/project/siteTranslation.ts b/packages/context-cli/src/project/siteTranslation.ts index 63a033c9..2eaab8d0 100644 --- a/packages/context-cli/src/project/siteTranslation.ts +++ b/packages/context-cli/src/project/siteTranslation.ts @@ -1,4 +1,3 @@ -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"; @@ -10,6 +9,7 @@ import { ContextError } from "../lib/errors.js"; import { ErrorCategory } from "../lib/cliFeedback.js"; import { ExitCode } from "../types/exitCode.js"; import { readKnowledgeMap } from "./knowledgeMap.js"; +import { canReadRepoObjects, optionalRepoGit } from "./repoContentGit.js"; import { markdownReaderLinks } from "./markdownLinks.js"; import { listApprovedKnowledge } from "./packageBuilder.js"; import type { ApprovedKnowledgeFile } from "./packageIndexes.js"; @@ -50,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, source_digest: text.optional() }).strict()).default([]), + articles: z.array(z.object({ locale: text, approved_path: text, file: text }).strict()).default([]), map_entries: z.array(z.object({ locale: text, key: text, title: text }).strict()).default([]), }).strict(); export type TranslationSubmitInput = z.infer; @@ -197,18 +197,27 @@ 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)); +const DRAFT_ROOT = ".tmp/agent-payloads/translations"; +const SOURCE_HISTORY_LIMIT = 50; + +/** Diff from the approved version a translation was made from. Missing or + * partial-clone history yields undefined; never trigger a lazy fetch. */ +async function sourceDiffSince(projectRoot: string, relPath: string, digest: string | undefined): Promise { + if (digest === undefined || !await canReadRepoObjects(projectRoot)) return undefined; + const path = `knowledge/${relPath}`; + const commits = await optionalRepoGit(projectRoot, ["log", "--format=%H", "-n", String(SOURCE_HISTORY_LIMIT), "--", path]); + for (const commit of commits?.split("\n").filter(Boolean) ?? []) { + const content = await optionalRepoGit(projectRoot, ["show", `${commit}:./${path}`]); + if (content === undefined || approvedArticleDigest(content) !== digest) continue; + return optionalRepoGit(projectRoot, ["diff", "--no-color", "--no-ext-diff", "--no-textconv", commit, "--", path]); + } + return undefined; } export async function prepareSiteTranslation(projectRoot: string, packages: readonly PackageDefinition[]) { 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 = []; @@ -217,43 +226,28 @@ export async function prepareSiteTranslation(projectRoot: string, packages: read if (item.state !== "current") { items.push(item); map_entries.push({ locale: item.locale, key: item.key, title: item.title }); } continue; } + if (item.state === "current") 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 file = `${DRAFT_ROOT}/${item.locale}/${source.relPath}`; + // An existing draft is unfinished work; submission removes drafts it saved. + // A draft whose body is still the unedited seed (for example one left by + // an older CLI that did not remove drafts) holds no work and is reseeded. + const seed = stored ?? source.content; 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 }); + const body = (content: string) => content.slice(FRONTMATTER_RE.exec(content)?.[0].length ?? 0); + const kept = existing !== undefined && body(existing) !== body(seed); + if (!kept) await atomicWrite(join(projectRoot, file), seed); + const translation = stored === undefined ? undefined : splitFrontmatter(stored).frontmatter.translation as Record | undefined; + // A kept draft may contain edits for source text that later changed back; + // the stored translation's diff does not describe it. + const diff = kept || stored === undefined ? undefined : await sourceDiffSince(projectRoot, source.relPath, stringField(translation?.source_digest)); + const diffFile = `${file}.diff`; + if (diff) await atomicWrite(join(projectRoot, diffFile), `${diff}\n`); + else await rm(join(projectRoot, diffFile), { force: true }); + items.push({ ...item, file, draft: kept ? "kept" as const : "generated" as const, source_diff: diff ? diffFile : null }); + 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`; @@ -336,8 +330,7 @@ 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; + const drafts: string[] = []; for (const article of input.articles) { const file = approved.get(article.approved_path); if (!inspection.locales.includes(article.locale) || file === undefined) { @@ -345,14 +338,7 @@ export async function submitSiteTranslation(projectRoot: string, packages: reado 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; - } + if (draftPath.startsWith(`${DRAFT_ROOT}/`)) drafts.push(draftPath); 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}`] }); @@ -360,15 +346,11 @@ 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), source_parts: translationSourceParts(file.content), translated_at: translatedAt } }).trimEnd(); + source_digest: approvedArticleDigest(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])); @@ -389,7 +371,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 draft of drafts) for (const path of [draft, `${draft}.diff`]) await rm(join(projectRoot, path), { force: true }); 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 deleted file mode 100644 index 5bc80476..00000000 --- a/packages/context-cli/src/project/siteTranslationParts.ts +++ /dev/null @@ -1,63 +0,0 @@ -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 index ef480011..50b6c1da 100644 --- a/packages/context-cli/src/project/submittedSourceLocator.ts +++ b/packages/context-cli/src/project/submittedSourceLocator.ts @@ -1,4 +1,5 @@ import { articleSourceLocatorSchema, articleSourceLocatorInputSchema, type ArticleSourceLocator, type ArticleSourceLocatorInput } from "@c4a/context"; +import { SubmissionInputError } from "./productionFeedback.js"; export const submittedSourceLocatorSchema = articleSourceLocatorInputSchema; export type SubmittedSourceLocator = ArticleSourceLocatorInput; @@ -18,7 +19,10 @@ export function resolveSubmittedSourceLocator(input: SubmittedSourceLocator, cap 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."); + if (!/\.(?:md|markdown|mdx)$/iu.test(locator.path)) throw new SubmissionInputError("heading requires a Markdown source; use quote or start_line/end_line instead."); + // `## Install` and `Install` name the same heading; the marker also fixes its level. + const marked = /^(#{1,6})[ \t]+(.+)$/u.exec(locator.heading.trim()); + const wanted = { level: marked?.[1]!.length, title: (marked?.[2] ?? locator.heading).trim() }; const headings: Array<{ line: number; level: number; title: string }> = []; let fence: { character: string; length: number } | undefined; for (const [index, line] of lines.entries()) { @@ -32,12 +36,12 @@ export function resolveSubmittedSourceLocator(input: SubmittedSourceLocator, cap 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; + if (heading.title !== wanted.title || (wanted.level !== undefined && heading.level !== wanted.level)) 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.`); + if (matches.length !== 1) throw new SubmissionInputError(`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-cli/src/project/taskLocalSourceAdjustment.ts b/packages/context-cli/src/project/taskLocalSourceAdjustment.ts index 59d69951..ab8e1f89 100644 --- a/packages/context-cli/src/project/taskLocalSourceAdjustment.ts +++ b/packages/context-cli/src/project/taskLocalSourceAdjustment.ts @@ -6,7 +6,7 @@ import { readProductionStage } from "./productionStageStore.js"; import { revisionStoragePath } from "./maintenanceStorage.js"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; -import { validateArticleStructureEntries, indexerProtocolDigest, type IndexerProjectFileTarget } from "@c4a/context"; +import { validateArticleStructureEntries, indexerProtocolDigest, readProcessedScopes, type IndexerProjectFileTarget } from "@c4a/context"; import { readApprovedRevision, requestDigest, currentApprovedRevisionTarget } from "./approvedRevision.js"; import { readKnowledgeStructure } from "./packageBuildInventory.js"; import { readKnowledgeUpdate } from "./knowledgeUpdate.js"; @@ -88,7 +88,21 @@ export async function adjustLocalRevisionSources(root: string, input: { const ids = new Set(scopes.map((scope) => scope.requirement_ref)); const requirements = registry.requirements.filter((item) => ids.has(item.id)); if (revision) { - const affected = additions.length > 0 || revision.target.source_refs.some((ref) => selected.some((source) => ref === source || ref.startsWith(`${source}#`) || ref.startsWith(`${source}/`))); + const cites = (ref: string, sources: string[]) => sources.some((source) => ref === source || ref.startsWith(`${source}#`) || ref.startsWith(`${source}/`)); + // Selecting a source already fixed at the same version only widens what + // this task's pages may cite; prepared work that cites it stays valid. + // Without a task baseline, the delivered knowledge baseline is the version they cited. + const delivered = readProcessedScopes((await readKnowledgeStructure(root)).parsed); + const unchanged: string[] = []; + for (const source of selected) { + const fixed = (revision.processed_scopes ?? []).filter((old) => old.source_ref === source); + const versions = (fixed.length ? fixed : delivered.filter((old) => old.source_ref === source)).map((old) => old.processed_version); + const current = await currentScopeSourceVersion(root, source); + if (versions.length > 0 && versions.every((version) => version === current)) unchanged.push(source); + } + const refreshed = selected.filter((source) => !unchanged.includes(source)); + const targetAdditions = selected.filter((source) => !revision.target.source_refs.some((ref) => cites(ref, [source]))); + const affected = targetAdditions.length > 0 || revision.target.source_refs.some((ref) => cites(ref, refreshed)); const currentTarget = affected ? await currentApprovedRevisionTarget(root, revision) : revision.target; const { refresh_sources: _refresh, candidate, ...rest } = revision; void _refresh; @@ -98,7 +112,9 @@ export async function adjustLocalRevisionSources(root: string, input: { const changedSources = selected.filter((source) => (revision.processed_scopes ?? []).some((old) => old.source_ref === source && scopes.some((scope) => scope.source_ref === source && scope.processed_version !== old.processed_version))); - const pending = [...(revision.pending_targets ?? [])]; + // The adjustment belongs to the whole queued task, not only the page open now. + const pending: NonNullable = (revision.pending_targets ?? []).map((item) => targetAdditions.length === 0 ? item + : { ...item, supporting_sources: [...new Set([...item.supporting_sources ?? [], ...targetAdditions])] }); const known = new Set([revision.target.path, ...pending.map((item) => item.path)]); const structure = await readKnowledgeStructure(root); for (const article of validateArticleStructureEntries(structure.parsed?.articles ?? [])) { @@ -108,7 +124,7 @@ export async function adjustLocalRevisionSources(root: string, input: { known.add(article.path); } const keptBatch = (revision.batch_candidates ?? []).filter((item) => { - if (!item.source_refs.some((ref) => selected.some((source) => ref === source || ref.startsWith(`${source}#`) || ref.startsWith(`${source}/`)))) return true; + if (!item.source_refs.some((ref) => cites(ref, refreshed))) return true; discardIds.add(item.candidate_id); if (!known.has(item.path)) { pending.push({ path: item.path, instruction: `${item.review.reason}\n${input.instruction}`, @@ -124,7 +140,7 @@ export async function adjustLocalRevisionSources(root: string, input: { currentTarget.sections.flatMap(section => section.references)) : revision.program_blocks; const payload = { ...rest, ...(programBlocks ? { program_blocks: programBlocks } : {}), review_ready: false, batch_candidates: keptBatch, pending_targets: pending, ...(revision.processed_scopes || additions.length ? { processed_scopes: scopes, requirements } : {}), - target: affected ? { ...currentTarget, markdown: candidate?.body ?? currentTarget.markdown, source_refs: [...new Set([...currentTarget.source_refs, ...additions.map((scope) => scope.source_ref)])] } : revision.target, + target: affected ? { ...currentTarget, markdown: candidate?.body ?? currentTarget.markdown, source_refs: [...new Set([...currentTarget.source_refs, ...targetAdditions])] } : revision.target, instruction: affected ? `${revision.instruction}\n\n${input.instruction}` : revision.instruction }; next = { ...payload, revision: requestDigest(payload), ...(!affected && candidate ? { candidate } : {}) }; if (affected && candidate) discardIds.add(candidate.candidate_id); diff --git a/packages/context/docs/getting-started.md b/packages/context/docs/getting-started.md index 7ef6abf9..f1c25c36 100644 --- a/packages/context/docs/getting-started.md +++ b/packages/context/docs/getting-started.md @@ -36,6 +36,8 @@ context status --format json ``` Use the dependency-install command returned by initialization if it differs. +The workspace installs its dependencies with Bun, so Bun must be installed +alongside Node.js 20 or newer. Initialization creates the project configuration, source directories, package templates and workspace rules. Read the generated `AGENTS.md`. It does not create an empty `src/indexers.yaml`: the later configuration Route supplies its schema diff --git a/packages/context/docs/guides/code-indexer-skill-authoring.md b/packages/context/docs/guides/code-indexer-skill-authoring.md index 6e79d419..02d9eabf 100644 --- a/packages/context/docs/guides/code-indexer-skill-authoring.md +++ b/packages/context/docs/guides/code-indexer-skill-authoring.md @@ -44,7 +44,8 @@ proof of runtime behavior. Preserve expressions without executing project code, distinguish declaration defaults from implementation defaults, and state unknowns. Use the current stage's Markdown and reference-file schemas. Each fragment cites -at most three actual source locations; the CLI computes regional digests. Split +actual source regions from at most three files (up to eight regions); the CLI +computes regional digests. Split an explanation when it needs separate evidence, not to fill a fixed template. Reuse stable article and fragment identities when revising. Supporting documents, notes or sessions may contribute to the same article without becoming code facts. diff --git a/packages/context/docs/guides/knowledge-updates.md b/packages/context/docs/guides/knowledge-updates.md index 631905e9..b9217f08 100644 --- a/packages/context/docs/guides/knowledge-updates.md +++ b/packages/context/docs/guides/knowledge-updates.md @@ -332,7 +332,7 @@ changing structure, adding a page or when a section has no unambiguous ID. To change a fragment's citations, include `references` alongside its `section_id`: each entry supplies `source_ref` and `locator` (`path`, `start_line`, `end_line`). Context computes the region fingerprint. This uses the same revision submission, -with at most three source positions per fragment. Full Markdown may include a +with at most three source files and eight regions per fragment. Full Markdown may include a `sections` list of reference edits; local section edits may change content, references, or both. Omission preserves citations; an empty list removes them. @@ -697,17 +697,19 @@ Revision section edits may supply `references` as `source_ref` plus a `locator` containing `path`, `start_line`, and `end_line`. Context computes the regional digest. Omitted references preserve the current fragment's citations; an explicit empty list removes them, and deleting a fragment removes its -citations. Each fragment may cite at most three source positions. Register and +citations. Each fragment may cite at most three source files and eight regions. 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 +line numbers. Use exactly one form. A heading is the title text of an exact ATX heading +outside code fences in a Markdown source; a leading `##` marker is optional and, +when present, also selects the heading level; 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. +The per-fragment limits (three source files, eight regions) apply after +resolving and deduplicating positions. diff --git a/packages/context/docs/guides/markdown-indexer-skill-authoring.md b/packages/context/docs/guides/markdown-indexer-skill-authoring.md index 82a471bd..a4122886 100644 --- a/packages/context/docs/guides/markdown-indexer-skill-authoring.md +++ b/packages/context/docs/guides/markdown-indexer-skill-authoring.md @@ -39,7 +39,7 @@ submit the short stage-relative manifest. Title and description belong to the article, not an internal layout-mapping tuple. Keep stable article and fragment identities when revising. Each fragment cites -at most three actual source regions. The CLI handles source digests and safety; +actual source regions from at most three files (up to eight regions). The CLI handles source digests and safety; the Agent judges whether the evidence supports the explanation. For a local failure, use the returned fragment identifier or position to repair the affected part. Completed independent tasks can be submitted without the rest of the batch. diff --git a/packages/context/docs/guides/site-translation.md b/packages/context/docs/guides/site-translation.md index 5456bb3f..dd83334d 100644 --- a/packages/context/docs/guides/site-translation.md +++ b/packages/context/docs/guides/site-translation.md @@ -74,23 +74,24 @@ 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. +`prepare` writes a draft for each pending article and a `submission_path` +template. A stale article's draft is its current translation; a missing +article's draft is the approved source. When Git history contains the approved +version the translation was made from, `source_diff` names a diff from that +version to the current article. Without such history `source_diff` is `null`; +compare the draft with the whole approved article. Update only what changed and +submit the draft. History is missing, for example, when several approve and +translate rounds ran without a commit in between: the version each translation +was made from was never recorded. Committing approved knowledge and translations +after a round lets the next generated draft carry a diff; it does not guarantee +one, because a kept draft never has a diff. + +An existing edited draft is kept (`draft: kept`) as unfinished work, with +`source_diff: null`; a draft whose body is still the unedited starting text is +regenerated with its diff. A kept draft has no diff because the source may have +changed again, even back, since it was written, so align the whole draft with +the current approved article. Delete it +to start again from the current translation. Successful submission removes the +drafts and diffs it saved. diff --git a/packages/context/docs/reference/indexer-provider-protocol.md b/packages/context/docs/reference/indexer-provider-protocol.md index d3fba129..3f313fe2 100644 --- a/packages/context/docs/reference/indexer-provider-protocol.md +++ b/packages/context/docs/reference/indexer-provider-protocol.md @@ -255,8 +255,8 @@ partial outcome reopens the owning semantic step; it does not create a profile revision ledger or an override route. Artifact content contains reader Markdown and actual source-region references. -Each semantic-prose block supplies at most three positions using `source_ref` -and a file/line `locator`; the Host verifies the current authorized source and +Each semantic-prose block supplies positions from at most three source files +(up to eight regions) using `source_ref` and a file/line `locator`; the Host verifies the current authorized source and computes the region `content_digest`. The limit applies to an output fragment, not the whole article. Select necessary references or split the writing by meaning; never truncate necessary references or merge disjoint source regions. diff --git a/packages/context/docs/reference/package-templates.md b/packages/context/docs/reference/package-templates.md index d939a1ed..d2df8c06 100644 --- a/packages/context/docs/reference/package-templates.md +++ b/packages/context/docs/reference/package-templates.md @@ -310,8 +310,8 @@ Approved Markdown under `knowledge/` and the article index in and fragment `sections` with their region `references`. - Markdown fragment markers contain only their stable ID: `` and ``. - Structure records each corresponding ID and at most three source positions - per fragment, not three per article. + Structure records each corresponding ID and its source positions: at most + three distinct source files and eight regions per fragment, not per article. - A reference contains `source_ref`, `locator: { path, start_line, end_line }` and the Host-computed regional `content_digest`. Do not duplicate this in Markdown frontmatter or comments. diff --git a/packages/context/package.json b/packages/context/package.json index 55b90027..513e3f7d 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.70", + "version": "0.7.71", "type": "module", "license": "MIT", "engines": { diff --git a/packages/context/src/__tests__/articleStructure.test.ts b/packages/context/src/__tests__/articleStructure.test.ts index b0978a9b..b01393f1 100644 --- a/packages/context/src/__tests__/articleStructure.test.ts +++ b/packages/context/src/__tests__/articleStructure.test.ts @@ -24,9 +24,21 @@ describe("article structure", () => { { ...section, references: [reference(), reference(), reference()] }, { ...section, id: "next", references: [reference(), reference(), reference()] }, ] }])[0]!.sections).toHaveLength(2); + const lines = Array.from({ length: 10 }, (_, index) => `line ${index}`).join("\n"); + const at = (path: string, line: number) => createArticleSourceReference("repo:app", { path, start_line: line, end_line: line }, lines); + const regions = (count: number) => Array.from({ length: count }, (_, index) => at("src/login.ts", index + 1)); + expect(validateArticleStructureEntries([{ ...article(), sections: [ + { ...section, references: regions(8) }, + ] }])[0]!.sections[0]!.references).toHaveLength(8); expect(() => validateArticleStructureEntries([{ ...article(), sections: [ - { ...section, references: [reference(), reference(), reference(), reference()] }, - ] }])).toThrow(); + { ...section, references: regions(9) }, + ] }])).toThrow("at most 8 source regions"); + expect(validateArticleStructureEntries([{ ...article(), sections: [ + { ...section, references: ["a.ts", "a.ts", "b.md", "c.md"].map((path, index) => at(path, index + 1)) }, + ] }])[0]!.sections[0]!.references).toHaveLength(4); + expect(() => validateArticleStructureEntries([{ ...article(), sections: [ + { ...section, references: ["a.ts", "b.md", "c.md", "d.md"].map(path => at(path, 1)) }, + ] }])).toThrow("at most 3 distinct source files"); }); test("rejects duplicate identities or paths instead of overwriting", () => { diff --git a/packages/context/src/__tests__/indexerArtifactResultV070.test.ts b/packages/context/src/__tests__/indexerArtifactResultV070.test.ts index e574aa3b..0476fcb1 100644 --- a/packages/context/src/__tests__/indexerArtifactResultV070.test.ts +++ b/packages/context/src/__tests__/indexerArtifactResultV070.test.ts @@ -85,7 +85,12 @@ describe("Article result contract", () => { ...reference, locator: { path: "src/button.ts", start_line: 4, end_line: 4 }, }); rehash(result); - expect(() => validate(result)).toThrow(); + expect(validate(result)).toEqual(result); + current.blocks[0]!.references = ["a", "b", "c", "d"].map(name => ({ + ...reference, locator: { path: `src/${name}.ts`, start_line: 1, end_line: 1 }, + })); + rehash(result); + expect(() => validate(result)).toThrow("at most 3 distinct source files"); }); test.each(["provider_layer_ref", "provider_integrity", "provider_bundle_digest", diff --git a/packages/context/src/articleStructure.ts b/packages/context/src/articleStructure.ts index 7c95f1fb..b38cd871 100644 --- a/packages/context/src/articleStructure.ts +++ b/packages/context/src/articleStructure.ts @@ -21,9 +21,27 @@ export const articleSourceReferenceSchema = z.object({ content_digest: indexerDigestSchema, }).strict(); +/** A fragment cites at most three distinct source files. Several regions of one + * file are allowed, within a total region cap, so a long file does not force the + * author to merge unrelated lines into one wide range. */ +export const ARTICLE_FRAGMENT_SOURCE_FILE_LIMIT = 3; +export const ARTICLE_FRAGMENT_REGION_LIMIT = 8; + +export function articleFragmentReferenceListSchema>(item: T) { + return z.array(item).max(ARTICLE_FRAGMENT_REGION_LIMIT, { + message: `A fragment can cite at most ${ARTICLE_FRAGMENT_REGION_LIMIT} source regions`, + }).superRefine((references, context) => { + const files = new Set(references.map(reference => JSON.stringify([reference.source_ref, reference.locator.path]))); + if (files.size > ARTICLE_FRAGMENT_SOURCE_FILE_LIMIT) context.addIssue({ + code: z.ZodIssueCode.custom, + message: `A fragment can cite at most ${ARTICLE_FRAGMENT_SOURCE_FILE_LIMIT} distinct source files (got ${files.size}); split the fragment or drop a supporting file`, + }); + }); +} + export const articleSectionSchema = z.object({ id: z.string().min(1), - references: z.array(articleSourceReferenceSchema).max(3), + references: articleFragmentReferenceListSchema(articleSourceReferenceSchema), }).strict(); export const articleStructureEntrySchema = z.object({ diff --git a/packages/context/src/indexerArtifact.ts b/packages/context/src/indexerArtifact.ts index a8c018a3..08377338 100644 --- a/packages/context/src/indexerArtifact.ts +++ b/packages/context/src/indexerArtifact.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { articleSourceReferenceSchema } from "./articleStructure.js"; +import { articleSourceReferenceSchema, articleFragmentReferenceListSchema } from "./articleStructure.js"; import { indexerArtifactContentBlockSchema, indexerCanonicalJsonSchema, @@ -23,7 +23,7 @@ const indexerArtifactSectionSchema = indexerArtifactSectionProjectionSchema.exte const indexerArtifactTemplateVariableSchema = z.object({ value: indexerCanonicalJsonSchema, - references: z.array(articleSourceReferenceSchema).max(3), + references: articleFragmentReferenceListSchema(articleSourceReferenceSchema), }).strict(); const artifactCommonFields = { diff --git a/packages/context/src/indexerCandidateCompile.ts b/packages/context/src/indexerCandidateCompile.ts index d4a6137c..c4bd2014 100644 --- a/packages/context/src/indexerCandidateCompile.ts +++ b/packages/context/src/indexerCandidateCompile.ts @@ -1,4 +1,4 @@ -import { articleFragmentReferences, articleSourceReferenceSchema, type ArticleSourceReference } from "./articleStructure.js"; +import { articleFragmentReferences, articleSourceReferenceSchema, type ArticleSourceReference, articleFragmentReferenceListSchema } from "./articleStructure.js"; import { z } from "zod"; import { indexerArtifactResultDigest, @@ -91,7 +91,7 @@ const candidateFilePayloadSchema = z.object({ sections: z.array(z.object({ section_ref: z.string().min(1), section_key: indexerIdSchema, - references: z.array(articleSourceReferenceSchema).max(3), + references: articleFragmentReferenceListSchema(articleSourceReferenceSchema), markdown: z.string().min(1), markdown_digest: indexerDigestSchema, }).strict()).min(1), diff --git a/packages/context/src/indexerContentLayers.ts b/packages/context/src/indexerContentLayers.ts index b2164f69..e251b4db 100644 --- a/packages/context/src/indexerContentLayers.ts +++ b/packages/context/src/indexerContentLayers.ts @@ -1,7 +1,7 @@ import { publicContractSupport, reconcilePublicContractFacts } from "./indexerPublicContractFacts.js"; import { projectIndexerPublicContractTable } from "./indexerPublicContractTable.js"; import { z } from "zod"; -import { articleSourceReferenceSchema, type ArticleSourceReference } from "./articleStructure.js"; +import { articleSourceReferenceSchema, type ArticleSourceReference, articleFragmentReferenceListSchema } from "./articleStructure.js"; import { canonicalIndexerJson, compareIndexerCanonicalText, @@ -44,13 +44,13 @@ export const indexerArtifactContentBlockSchema = z.object({ block_id: indexerIdSchema, layer: z.literal("semantic-prose"), markdown: z.string().min(1), - references: z.array(articleSourceReferenceSchema).max(3), + references: articleFragmentReferenceListSchema(articleSourceReferenceSchema), }).strict(); const renderedContentBlockPayloadSchema = z.object({ layer: z.enum(["deterministic-block", "semantic-prose"]), markdown: z.string().min(1), - references: z.array(articleSourceReferenceSchema).max(3), + references: articleFragmentReferenceListSchema(articleSourceReferenceSchema), }).strict(); export const indexerRenderedContentBlockSchema = renderedContentBlockPayloadSchema.extend({ diff --git a/packages/context/src/indexerLayoutResolver.ts b/packages/context/src/indexerLayoutResolver.ts index 57c3cfa6..bd131893 100644 --- a/packages/context/src/indexerLayoutResolver.ts +++ b/packages/context/src/indexerLayoutResolver.ts @@ -1,4 +1,4 @@ -import { articleFragmentReferences, articleSourceReferenceSchema, type ArticleSourceReference } from "./articleStructure.js"; +import { articleFragmentReferences, articleSourceReferenceSchema, type ArticleSourceReference, articleFragmentReferenceListSchema } from "./articleStructure.js"; import { z } from "zod"; import type { KnowledgeCollection } from "./contracts.js"; import { validateIndexerArtifactBundle } from "./indexerArtifactPolicy.js"; @@ -44,7 +44,7 @@ const layoutSectionSchema = z.object({ artifact_kind: indexerIdSchema, state: z.enum(["structured", "rendered", "material-gap"]), content_digest: indexerDigestSchema.nullable(), - references: z.array(articleSourceReferenceSchema).max(3), + references: articleFragmentReferenceListSchema(articleSourceReferenceSchema), material_question_proposal_ref: indexerCanonicalRefSchema.nullable(), collection_resolution_digest: indexerDigestSchema, }).strict(); diff --git a/packages/context/src/indexerSemanticInput.ts b/packages/context/src/indexerSemanticInput.ts index b33296eb..3387482f 100644 --- a/packages/context/src/indexerSemanticInput.ts +++ b/packages/context/src/indexerSemanticInput.ts @@ -1,5 +1,5 @@ import { articleSourceLocatorInputSchema } from "./articleSourceLocatorInput.js"; -import { articleSourceLocatorSchema } from "./articleStructure.js"; +import { articleFragmentReferenceListSchema, articleSourceLocatorSchema } from "./articleStructure.js"; import { indexerIdSchema } from "./indexerProtocolCommon.js"; import { knowledgeMapUpdateSchema } from "./knowledgeMap.js"; import { indexerArticleKeySchema, indexerArticlePlanSchema } from "./indexerArticlePlan.js"; @@ -145,7 +145,7 @@ const authorSectionSchema = z.object({ heading: z.string().min(1), visuals: z.array(authorVisualSchema).optional(), markdown: z.string().min(1), - references: z.array(z.object({ source_ref: z.string().min(1), locator: articleSourceLocatorSchema }).strict()).max(3).default([]), + references: articleFragmentReferenceListSchema(z.object({ source_ref: z.string().min(1), locator: articleSourceLocatorSchema }).strict()).default([]), answers: z.array(z.string().min(1)).default([]), }).strict(); @@ -153,7 +153,7 @@ const authorTemplateVariablesSchema = z.record(z.union([ z.string(), z.object({ value: z.string(), - references: z.array(z.object({ source_ref: z.string().min(1), locator: articleSourceLocatorSchema }).strict()).max(3).default([]), + references: articleFragmentReferenceListSchema(z.object({ source_ref: z.string().min(1), locator: articleSourceLocatorSchema }).strict()).default([]), }).strict(), ])); @@ -284,7 +284,7 @@ const postAuthorSectionSchema = z.object({ heading: z.string().min(1), visuals: z.array(authorVisualSchema).optional(), markdown: z.string().min(1), - references: z.array(z.object({ source_ref: z.string().min(1), locator: articleSourceLocatorSchema }).strict()).max(3), + references: articleFragmentReferenceListSchema(z.object({ source_ref: z.string().min(1), locator: articleSourceLocatorSchema }).strict()), }).strict(); const postAuthorInputBaseSchema = z.object({ diff --git a/packages/context/src/indexerTemplateRendering.ts b/packages/context/src/indexerTemplateRendering.ts index 81b7c1c2..afb8d242 100644 --- a/packages/context/src/indexerTemplateRendering.ts +++ b/packages/context/src/indexerTemplateRendering.ts @@ -1,4 +1,4 @@ -import { articleFragmentReferences, articleSourceReferenceSchema } from "./articleStructure.js"; +import { articleFragmentReferences, articleSourceReferenceSchema, articleFragmentReferenceListSchema } from "./articleStructure.js"; import { z } from "zod"; import { indexerDeterministicBlockRendererSchema, @@ -177,7 +177,7 @@ const renderedSectionSchema = z.object({ artifact_kind: indexerIdSchema, markdown: z.string().min(1), content_blocks: z.array(indexerRenderedContentBlockSchema).min(1), - references: z.array(articleSourceReferenceSchema).max(3), + references: articleFragmentReferenceListSchema(articleSourceReferenceSchema), content_digest: indexerDigestSchema, }).strict(); diff --git a/packages/core/package.json b/packages/core/package.json index 57a9c600..a67a82dd 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.70", + "version": "0.7.71", "type": "module", "license": "MIT", "engines": { diff --git a/packages/dev-cli/package.json b/packages/dev-cli/package.json index 0dcfa695..92840a66 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.70", + "version": "0.7.71", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extract-contract/package.json b/packages/extract-contract/package.json index aa4e75c8..ba6bfe99 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.70", + "version": "0.7.71", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-go/package.json b/packages/extract-go/package.json index 3ef7cf91..706a5e7f 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.70", + "version": "0.7.71", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-mdx/package.json b/packages/extract-mdx/package.json index 203b0744..1fb45fc1 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.70", + "version": "0.7.71", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-proto/package.json b/packages/extract-proto/package.json index a22b738c..3741775a 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.70", + "version": "0.7.71", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-rush/package.json b/packages/extract-rush/package.json index 60a22e8b..ab1edb7f 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.70", + "version": "0.7.71", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-sql/package.json b/packages/extract-sql/package.json index 6be33b4a..5baaba40 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.70", + "version": "0.7.71", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-style/package.json b/packages/extract-style/package.json index 72b5156d..07ba0d42 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.70", + "version": "0.7.71", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-thrift/package.json b/packages/extract-thrift/package.json index 568b9ebf..a5c9385a 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.70", + "version": "0.7.71", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-ts/package.json b/packages/extract-ts/package.json index 7ee2cafa..8eed95aa 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.70", + "version": "0.7.71", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract/package.json b/packages/extract/package.json index 11e02bb4..31a6a45f 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.70", + "version": "0.7.71", "type": "module", "license": "MIT", "engines": { diff --git a/packages/tui/package.json b/packages/tui/package.json index 9d1e0c55..a80dedb7 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.70", + "version": "0.7.71", "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 dbcbdc5f..fb59580b 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.70", + "version": "0.7.71", "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 6505bbc4..37123433 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.70", + "version": "0.7.71", "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.70", + "shortDescription": "Initialize and advance a local, source-linked project knowledge workspace.\nv0.7.71", "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 d814ba4a..de1d1ad7 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.70", + "version": "0.7.71", "description": "Start or continue a project-local knowledge workspace through one graph-routed entry.", "author": { "name": "Context4AI", diff --git a/plugins/context/repo-install/skills/context-code-indexer/references/composers/contracts-and-chains.md b/plugins/context/repo-install/skills/context-code-indexer/references/composers/contracts-and-chains.md index 03f63942..d76a9693 100644 --- a/plugins/context/repo-install/skills/context-code-indexer/references/composers/contracts-and-chains.md +++ b/plugins/context/repo-install/skills/context-code-indexer/references/composers/contracts-and-chains.md @@ -24,5 +24,5 @@ empty `fragments` array. Use only the supplied primary artifacts and their actual regional references. Do not require a primary Fact table, evidence IDs, or graph nodes. Each derived -output fragment retains at most three real source locations; no reference -ledger is reconstructed from the full inventory. +output fragment retains real source regions from at most three files (up to +eight regions); no reference ledger is reconstructed from the full inventory. diff --git a/plugins/context/repo-install/skills/context-code-indexer/references/composers/cross-module-chain.md b/plugins/context/repo-install/skills/context-code-indexer/references/composers/cross-module-chain.md index 7af2de9a..7fd33ebd 100644 --- a/plugins/context/repo-install/skills/context-code-indexer/references/composers/cross-module-chain.md +++ b/plugins/context/repo-install/skills/context-code-indexer/references/composers/cross-module-chain.md @@ -23,5 +23,5 @@ empty result: the accepted layer-fragment result with `fragments: []`. Use only the supplied primary artifacts and their actual regional references. Do not require a primary Fact table, evidence IDs, or graph nodes. Each derived -output fragment retains at most three real source locations; no reference -ledger is reconstructed from the full inventory. +output fragment retains real source regions from at most three files (up to +eight regions); no reference ledger is reconstructed from the full inventory. diff --git a/plugins/context/repo-install/skills/context-code-indexer/references/composers/development-and-delivery.md b/plugins/context/repo-install/skills/context-code-indexer/references/composers/development-and-delivery.md index eaf79b61..41d4725d 100644 --- a/plugins/context/repo-install/skills/context-code-indexer/references/composers/development-and-delivery.md +++ b/plugins/context/repo-install/skills/context-code-indexer/references/composers/development-and-delivery.md @@ -23,5 +23,5 @@ return `fragments: []` in the ordinary structured result. Use only the supplied primary artifacts and their actual regional references. Do not require a primary Fact table, evidence IDs, or graph nodes. Each derived -output fragment retains at most three real source locations; no reference -ledger is reconstructed from the full inventory. +output fragment retains real source regions from at most three files (up to +eight regions); no reference ledger is reconstructed from the full inventory. diff --git a/plugins/context/repo-install/skills/context-code-indexer/references/composers/event-flow.md b/plugins/context/repo-install/skills/context-code-indexer/references/composers/event-flow.md index eba249ea..156b0fa1 100644 --- a/plugins/context/repo-install/skills/context-code-indexer/references/composers/event-flow.md +++ b/plugins/context/repo-install/skills/context-code-indexer/references/composers/event-flow.md @@ -22,5 +22,5 @@ reader value must return the normal result envelope with `fragments: []`. Use only the supplied primary artifacts and their actual regional references. Do not require a primary Fact table, evidence IDs, or graph nodes. Each derived -output fragment retains at most three real source locations; no reference -ledger is reconstructed from the full inventory. +output fragment retains real source regions from at most three files (up to +eight regions); no reference ledger is reconstructed from the full inventory. diff --git a/plugins/context/repo-install/skills/context-code-indexer/references/composers/examples-and-documentation.md b/plugins/context/repo-install/skills/context-code-indexer/references/composers/examples-and-documentation.md index 92f1ff40..70fe46ec 100644 --- a/plugins/context/repo-install/skills/context-code-indexer/references/composers/examples-and-documentation.md +++ b/plugins/context/repo-install/skills/context-code-indexer/references/composers/examples-and-documentation.md @@ -23,5 +23,5 @@ never invent tutorial steps. Use only the supplied primary artifacts and their actual regional references. Do not require a primary Fact table, evidence IDs, or graph nodes. Each derived -output fragment retains at most three real source locations; no reference -ledger is reconstructed from the full inventory. +output fragment retains real source regions from at most three files (up to +eight regions); no reference ledger is reconstructed from the full inventory. diff --git a/plugins/context/repo-install/skills/context-code-indexer/references/composers/persistence-boundary.md b/plugins/context/repo-install/skills/context-code-indexer/references/composers/persistence-boundary.md index af7b5fdd..4ca652b7 100644 --- a/plugins/context/repo-install/skills/context-code-indexer/references/composers/persistence-boundary.md +++ b/plugins/context/repo-install/skills/context-code-indexer/references/composers/persistence-boundary.md @@ -22,5 +22,5 @@ set rather than reading outside the workset. Use only the supplied primary artifacts and their actual regional references. Do not require a primary Fact table, evidence IDs, or graph nodes. Each derived -output fragment retains at most three real source locations; no reference -ledger is reconstructed from the full inventory. +output fragment retains real source regions from at most three files (up to +eight regions); no reference ledger is reconstructed from the full inventory. diff --git a/plugins/context/repo-install/skills/context-code-indexer/references/composers/protocol-boundary.md b/plugins/context/repo-install/skills/context-code-indexer/references/composers/protocol-boundary.md index 3b34bfc6..62567e78 100644 --- a/plugins/context/repo-install/skills/context-code-indexer/references/composers/protocol-boundary.md +++ b/plugins/context/repo-install/skills/context-code-indexer/references/composers/protocol-boundary.md @@ -23,5 +23,5 @@ answers the reader question, return a valid result with `fragments: []`. Use only the supplied primary artifacts and their actual regional references. Do not require a primary Fact table, evidence IDs, or graph nodes. Each derived -output fragment retains at most three real source locations; no reference -ledger is reconstructed from the full inventory. +output fragment retains real source regions from at most three files (up to +eight regions); no reference ledger is reconstructed from the full inventory. diff --git a/plugins/context/repo-install/skills/context-code-indexer/references/composers/public-contract.md b/plugins/context/repo-install/skills/context-code-indexer/references/composers/public-contract.md index 333784da..e4d3c6d2 100644 --- a/plugins/context/repo-install/skills/context-code-indexer/references/composers/public-contract.md +++ b/plugins/context/repo-install/skills/context-code-indexer/references/composers/public-contract.md @@ -36,5 +36,5 @@ Composer's declared result schema and actions. Use only the supplied primary artifacts and their actual regional references. Do not require a primary Fact table, evidence IDs, or graph nodes. Each derived -output fragment retains at most three real source locations; no reference -ledger is reconstructed from the full inventory. +output fragment retains real source regions from at most three files (up to +eight regions); no reference ledger is reconstructed from the full inventory. diff --git a/plugins/context/repo-install/skills/context-code-indexer/references/indexer.md b/plugins/context/repo-install/skills/context-code-indexer/references/indexer.md index 8ed32ed3..33172d84 100644 --- a/plugins/context/repo-install/skills/context-code-indexer/references/indexer.md +++ b/plugins/context/repo-install/skills/context-code-indexer/references/indexer.md @@ -129,8 +129,8 @@ affected sections when appropriate. Preserve still-valid examples, limitations and confirmed contributions. New explanatory context does not require repartitioning unchanged code. -Each fragment uses actual source regions, at most three locations; the CLI -computes content digests. Do not use fallback evidence from an unrelated +Each fragment uses actual source regions from at most three source files (up +to eight regions in total); the CLI computes content digests. Do not use fallback evidence from an unrelated member, fabricate a continuous span for unrelated locations, or include process identities, full source bodies or credentials in reader output. diff --git a/plugins/context/repo-install/skills/context-code-indexer/templates/data-sync-reconciliation.md b/plugins/context/repo-install/skills/context-code-indexer/templates/data-sync-reconciliation.md index 2064482d..6facd9fa 100644 --- a/plugins/context/repo-install/skills/context-code-indexer/templates/data-sync-reconciliation.md +++ b/plugins/context/repo-install/skills/context-code-indexer/templates/data-sync-reconciliation.md @@ -61,7 +61,8 @@ owner or inventory membership is required; avoid duplicating existing prose. The article may cite every authorized participating source. Cover the reader question and relevant handoffs defined in the task brief. Explain operations and handoffs in reader prose, links or diagrams, citing the -actual source regions for each fragment (at most three locations per fragment). +actual source regions for each fragment (at most three source files per fragment; +several regions of one file are fine, up to eight regions in total). Do not emit graph edges, classify nodes or create a relationship ledger. ## Granularity and stop conditions diff --git a/plugins/context/repo-install/skills/context-markdown-indexer/references/indexer.md b/plugins/context/repo-install/skills/context-markdown-indexer/references/indexer.md index dec2a432..7a6d8dd0 100644 --- a/plugins/context/repo-install/skills/context-markdown-indexer/references/indexer.md +++ b/plugins/context/repo-install/skills/context-markdown-indexer/references/indexer.md @@ -27,8 +27,8 @@ needed section edits when appropriate. Stable section markers and title and description follow that schema; there is no ArtifactResult, subject identity or profile-owned output mapping to assemble. -For each output fragment, cite the actual source regions used, at most three -locations. Use source_ref and an inclusive file/line locator; the CLI computes +For each output fragment, cite the actual source regions used, from at most +three source files (up to eight regions in total). Use source_ref and an inclusive file/line locator; the CLI computes content digests. Do not attach an entire corpus to a generic paragraph or combine unrelated locations into a fabricated continuous span. Split genuinely distinct explanations when they need distinct references. diff --git a/plugins/context/skills/context-code-indexer/references/composers/contracts-and-chains.md b/plugins/context/skills/context-code-indexer/references/composers/contracts-and-chains.md index 03f63942..d76a9693 100644 --- a/plugins/context/skills/context-code-indexer/references/composers/contracts-and-chains.md +++ b/plugins/context/skills/context-code-indexer/references/composers/contracts-and-chains.md @@ -24,5 +24,5 @@ empty `fragments` array. Use only the supplied primary artifacts and their actual regional references. Do not require a primary Fact table, evidence IDs, or graph nodes. Each derived -output fragment retains at most three real source locations; no reference -ledger is reconstructed from the full inventory. +output fragment retains real source regions from at most three files (up to +eight regions); no reference ledger is reconstructed from the full inventory. diff --git a/plugins/context/skills/context-code-indexer/references/composers/cross-module-chain.md b/plugins/context/skills/context-code-indexer/references/composers/cross-module-chain.md index 7af2de9a..7fd33ebd 100644 --- a/plugins/context/skills/context-code-indexer/references/composers/cross-module-chain.md +++ b/plugins/context/skills/context-code-indexer/references/composers/cross-module-chain.md @@ -23,5 +23,5 @@ empty result: the accepted layer-fragment result with `fragments: []`. Use only the supplied primary artifacts and their actual regional references. Do not require a primary Fact table, evidence IDs, or graph nodes. Each derived -output fragment retains at most three real source locations; no reference -ledger is reconstructed from the full inventory. +output fragment retains real source regions from at most three files (up to +eight regions); no reference ledger is reconstructed from the full inventory. diff --git a/plugins/context/skills/context-code-indexer/references/composers/development-and-delivery.md b/plugins/context/skills/context-code-indexer/references/composers/development-and-delivery.md index eaf79b61..41d4725d 100644 --- a/plugins/context/skills/context-code-indexer/references/composers/development-and-delivery.md +++ b/plugins/context/skills/context-code-indexer/references/composers/development-and-delivery.md @@ -23,5 +23,5 @@ return `fragments: []` in the ordinary structured result. Use only the supplied primary artifacts and their actual regional references. Do not require a primary Fact table, evidence IDs, or graph nodes. Each derived -output fragment retains at most three real source locations; no reference -ledger is reconstructed from the full inventory. +output fragment retains real source regions from at most three files (up to +eight regions); no reference ledger is reconstructed from the full inventory. diff --git a/plugins/context/skills/context-code-indexer/references/composers/event-flow.md b/plugins/context/skills/context-code-indexer/references/composers/event-flow.md index eba249ea..156b0fa1 100644 --- a/plugins/context/skills/context-code-indexer/references/composers/event-flow.md +++ b/plugins/context/skills/context-code-indexer/references/composers/event-flow.md @@ -22,5 +22,5 @@ reader value must return the normal result envelope with `fragments: []`. Use only the supplied primary artifacts and their actual regional references. Do not require a primary Fact table, evidence IDs, or graph nodes. Each derived -output fragment retains at most three real source locations; no reference -ledger is reconstructed from the full inventory. +output fragment retains real source regions from at most three files (up to +eight regions); no reference ledger is reconstructed from the full inventory. diff --git a/plugins/context/skills/context-code-indexer/references/composers/examples-and-documentation.md b/plugins/context/skills/context-code-indexer/references/composers/examples-and-documentation.md index 92f1ff40..70fe46ec 100644 --- a/plugins/context/skills/context-code-indexer/references/composers/examples-and-documentation.md +++ b/plugins/context/skills/context-code-indexer/references/composers/examples-and-documentation.md @@ -23,5 +23,5 @@ never invent tutorial steps. Use only the supplied primary artifacts and their actual regional references. Do not require a primary Fact table, evidence IDs, or graph nodes. Each derived -output fragment retains at most three real source locations; no reference -ledger is reconstructed from the full inventory. +output fragment retains real source regions from at most three files (up to +eight regions); no reference ledger is reconstructed from the full inventory. diff --git a/plugins/context/skills/context-code-indexer/references/composers/persistence-boundary.md b/plugins/context/skills/context-code-indexer/references/composers/persistence-boundary.md index af7b5fdd..4ca652b7 100644 --- a/plugins/context/skills/context-code-indexer/references/composers/persistence-boundary.md +++ b/plugins/context/skills/context-code-indexer/references/composers/persistence-boundary.md @@ -22,5 +22,5 @@ set rather than reading outside the workset. Use only the supplied primary artifacts and their actual regional references. Do not require a primary Fact table, evidence IDs, or graph nodes. Each derived -output fragment retains at most three real source locations; no reference -ledger is reconstructed from the full inventory. +output fragment retains real source regions from at most three files (up to +eight regions); no reference ledger is reconstructed from the full inventory. diff --git a/plugins/context/skills/context-code-indexer/references/composers/protocol-boundary.md b/plugins/context/skills/context-code-indexer/references/composers/protocol-boundary.md index 3b34bfc6..62567e78 100644 --- a/plugins/context/skills/context-code-indexer/references/composers/protocol-boundary.md +++ b/plugins/context/skills/context-code-indexer/references/composers/protocol-boundary.md @@ -23,5 +23,5 @@ answers the reader question, return a valid result with `fragments: []`. Use only the supplied primary artifacts and their actual regional references. Do not require a primary Fact table, evidence IDs, or graph nodes. Each derived -output fragment retains at most three real source locations; no reference -ledger is reconstructed from the full inventory. +output fragment retains real source regions from at most three files (up to +eight regions); no reference ledger is reconstructed from the full inventory. diff --git a/plugins/context/skills/context-code-indexer/references/composers/public-contract.md b/plugins/context/skills/context-code-indexer/references/composers/public-contract.md index 333784da..e4d3c6d2 100644 --- a/plugins/context/skills/context-code-indexer/references/composers/public-contract.md +++ b/plugins/context/skills/context-code-indexer/references/composers/public-contract.md @@ -36,5 +36,5 @@ Composer's declared result schema and actions. Use only the supplied primary artifacts and their actual regional references. Do not require a primary Fact table, evidence IDs, or graph nodes. Each derived -output fragment retains at most three real source locations; no reference -ledger is reconstructed from the full inventory. +output fragment retains real source regions from at most three files (up to +eight regions); no reference ledger is reconstructed from the full inventory. diff --git a/plugins/context/skills/context-code-indexer/references/indexer.md b/plugins/context/skills/context-code-indexer/references/indexer.md index 8ed32ed3..33172d84 100644 --- a/plugins/context/skills/context-code-indexer/references/indexer.md +++ b/plugins/context/skills/context-code-indexer/references/indexer.md @@ -129,8 +129,8 @@ affected sections when appropriate. Preserve still-valid examples, limitations and confirmed contributions. New explanatory context does not require repartitioning unchanged code. -Each fragment uses actual source regions, at most three locations; the CLI -computes content digests. Do not use fallback evidence from an unrelated +Each fragment uses actual source regions from at most three source files (up +to eight regions in total); the CLI computes content digests. Do not use fallback evidence from an unrelated member, fabricate a continuous span for unrelated locations, or include process identities, full source bodies or credentials in reader output. diff --git a/plugins/context/skills/context-code-indexer/templates/data-sync-reconciliation.md b/plugins/context/skills/context-code-indexer/templates/data-sync-reconciliation.md index 2064482d..6facd9fa 100644 --- a/plugins/context/skills/context-code-indexer/templates/data-sync-reconciliation.md +++ b/plugins/context/skills/context-code-indexer/templates/data-sync-reconciliation.md @@ -61,7 +61,8 @@ owner or inventory membership is required; avoid duplicating existing prose. The article may cite every authorized participating source. Cover the reader question and relevant handoffs defined in the task brief. Explain operations and handoffs in reader prose, links or diagrams, citing the -actual source regions for each fragment (at most three locations per fragment). +actual source regions for each fragment (at most three source files per fragment; +several regions of one file are fine, up to eight regions in total). Do not emit graph edges, classify nodes or create a relationship ledger. ## Granularity and stop conditions diff --git a/plugins/context/skills/context-markdown-indexer/references/indexer.md b/plugins/context/skills/context-markdown-indexer/references/indexer.md index dec2a432..7a6d8dd0 100644 --- a/plugins/context/skills/context-markdown-indexer/references/indexer.md +++ b/plugins/context/skills/context-markdown-indexer/references/indexer.md @@ -27,8 +27,8 @@ needed section edits when appropriate. Stable section markers and title and description follow that schema; there is no ArtifactResult, subject identity or profile-owned output mapping to assemble. -For each output fragment, cite the actual source regions used, at most three -locations. Use source_ref and an inclusive file/line locator; the CLI computes +For each output fragment, cite the actual source regions used, from at most +three source files (up to eight regions in total). Use source_ref and an inclusive file/line locator; the CLI computes content digests. Do not attach an entire corpus to a generic paragraph or combine unrelated locations into a fabricated continuous span. Split genuinely distinct explanations when they need distinct references.