diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b2157c5..edb3cc7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to Context are documented here. +## 0.7.72 - 2026-10-15 + +- Settling a source update or a revision queue no longer leaves `knowledge/structure.yaml` stale. When citations were relocated or repository content references advanced, the structure kept its previous input hash, so `verify` reported "Structure is stale; run Close" until Close ran again. The hash is now recomputed with the rewritten references; a structure that was already stale stays stale for Close. +- Settlement can relocate citations on a machine without the local source region cache, such as a fresh clone. For repository sources it reads the cited region from the previously settled commit and uses it only when it still matches the recorded digest; otherwise the citation is kept and reported as before. +- A source update whose scopes name `processed_version` no longer leaves the workspace write lock behind when the repository checkout differs from its pinned ref. The error is returned and the lock is released. +- Kept pages whose `repo-content:` citations changed or cannot be confirmed are now listed in the source update's `reference_warnings`. Settlement still re-signs these citations to the current repository text, so the receipt is the only place they are reported. +- The package output guide no longer says the website theme follows the operating system; websites open in light mode until the reader switches. + ## 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. diff --git a/bun.lock b/bun.lock index e4d2b303..6b94331e 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ }, "packages/context": { "name": "@c4a/context", - "version": "0.7.71", + "version": "0.7.72", "dependencies": { "yaml": "^2.5.1", "zod": "^3.23.8", @@ -26,7 +26,7 @@ }, "packages/context-cli": { "name": "@c4a/context-cli", - "version": "0.7.71", + "version": "0.7.72", "bin": { "context": "dist/cli.js", }, @@ -73,7 +73,7 @@ }, "packages/core": { "name": "@c4a/core", - "version": "0.7.71", + "version": "0.7.72", "dependencies": { "picomatch": "^4.0.4", "yaml": "^2.4.5", @@ -85,7 +85,7 @@ }, "packages/dev-cli": { "name": "@c4a/dev-cli", - "version": "0.7.71", + "version": "0.7.72", "dependencies": { "@c4a/context": "workspace:*", "@c4a/core": "workspace:*", @@ -97,7 +97,7 @@ }, "packages/extract": { "name": "@c4a/extract", - "version": "0.7.71", + "version": "0.7.72", "bin": { "c4a-extract-code": "./dist/bin/c4a-extract-code.js", }, @@ -112,7 +112,7 @@ }, "packages/extract-contract": { "name": "@c4a/extract-contract", - "version": "0.7.71", + "version": "0.7.72", "dependencies": { "@c4a/core": "workspace:*", "graphql": "^16.14.2", @@ -122,7 +122,7 @@ }, "packages/extract-go": { "name": "@c4a/extract-go", - "version": "0.7.71", + "version": "0.7.72", "dependencies": { "@c4a/core": "workspace:*", "@c4a/extract": "workspace:*", @@ -132,7 +132,7 @@ }, "packages/extract-mdx": { "name": "@c4a/extract-mdx", - "version": "0.7.71", + "version": "0.7.72", "dependencies": { "@c4a/core": "workspace:*", "remark-mdx": "^3.1.1", @@ -144,7 +144,7 @@ }, "packages/extract-proto": { "name": "@c4a/extract-proto", - "version": "0.7.71", + "version": "0.7.72", "dependencies": { "@c4a/core": "workspace:*", "zod": "^3.23.8", @@ -152,7 +152,7 @@ }, "packages/extract-rush": { "name": "@c4a/extract-rush", - "version": "0.7.71", + "version": "0.7.72", "dependencies": { "@c4a/core": "workspace:*", "typescript": "^5.5.4", @@ -162,7 +162,7 @@ }, "packages/extract-sql": { "name": "@c4a/extract-sql", - "version": "0.7.71", + "version": "0.7.72", "dependencies": { "@c4a/core": "workspace:*", "node-sql-parser": "^5.4.0", @@ -171,7 +171,7 @@ }, "packages/extract-style": { "name": "@c4a/extract-style", - "version": "0.7.71", + "version": "0.7.72", "dependencies": { "@c4a/core": "workspace:*", "postcss": "^8.5.26", @@ -183,7 +183,7 @@ }, "packages/extract-thrift": { "name": "@c4a/extract-thrift", - "version": "0.7.71", + "version": "0.7.72", "dependencies": { "@c4a/core": "workspace:*", "zod": "^3.23.8", @@ -191,7 +191,7 @@ }, "packages/extract-ts": { "name": "@c4a/extract-ts", - "version": "0.7.71", + "version": "0.7.72", "dependencies": { "@c4a/core": "workspace:*", "@c4a/extract": "workspace:*", @@ -202,7 +202,7 @@ }, "packages/tui": { "name": "@c4a/tui", - "version": "0.7.71", + "version": "0.7.72", "dependencies": { "ink": "^5.0.0", "react": "^18.3.1", diff --git a/package.json b/package.json index acd19fa5..514895ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "context", - "version": "0.7.71", + "version": "0.7.72", "packageManager": "bun@1.3.9", "repository": { "type": "git", diff --git a/packages/context-cli/context-workflow/provider.yaml b/packages/context-cli/context-workflow/provider.yaml index 4249ff61..bfd35df4 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.71 +version: 0.7.72 name: Context workflow description: Internal work contract for Context knowledge workspaces. graphs: diff --git a/packages/context-cli/context-workflow/resources/manuals/guides/package-outputs.md b/packages/context-cli/context-workflow/resources/manuals/guides/package-outputs.md index 09b694c5..deb10d5b 100644 --- a/packages/context-cli/context-workflow/resources/manuals/guides/package-outputs.md +++ b/packages/context-cli/context-workflow/resources/manuals/guides/package-outputs.md @@ -113,8 +113,8 @@ top bar, homepage and articles share a centered 1440px container with 32px margins (16px on phones); on wide screens the sidebar sits at its left edge and articles keep a wide reading area and a smaller outline. Navigation is centered in the top bar, which ends with icon buttons for search, theme, social links and -reader language; search, or Cmd/Ctrl+K, opens a dialog over a blurred page. The theme follows the -operating system until the reader switches it; that choice is remembered in +reader language; search, or Cmd/Ctrl+K, opens a dialog over a blurred page. The theme opens +in light mode until the reader switches it; that choice is remembered in browser storage. Search runs locally, without a search service. Mermaid renders in the browser using the website theme, with fit, original-size, zoom, full-screen and source controls. Controls appear on hover or keyboard focus 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 24d5f6e8..769eda4b 100644 --- a/packages/context-cli/context-workflow/resources/procedures/source-update.md +++ b/packages/context-cli/context-workflow/resources/procedures/source-update.md @@ -29,7 +29,9 @@ 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 +citations keep their previous locations. Listed `repo-content:` citations are +still re-signed to the current repository text, so `verify` will not report +them later; check those pages from the receipt. 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. diff --git a/packages/context-cli/package.json b/packages/context-cli/package.json index 0a7350ce..b85718e8 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.71", + "version": "0.7.72", "type": "module", "license": "MIT", "engines": { diff --git a/packages/context-cli/src/__tests__/projectKnowledgeUpdate.test.ts b/packages/context-cli/src/__tests__/projectKnowledgeUpdate.test.ts index 6d648963..879d7340 100644 --- a/packages/context-cli/src/__tests__/projectKnowledgeUpdate.test.ts +++ b/packages/context-cli/src/__tests__/projectKnowledgeUpdate.test.ts @@ -50,6 +50,17 @@ async function commitSource(root: string, content = "export const answer = 43;\n return version; } +test("source update with an explicit target releases the write lock when the checkout differs from the pinned ref", async () => { + const root = await initialRevisionKnowledge(roots); + const source = join(root, "fixture-source"); + await writeFile(join(source, "src/index.ts"), "export const answer = 43;\n"); + execFileSync("git", ["commit", "-qam", "move checkout"], { cwd: source }); + const moved = execFileSync("git", ["rev-parse", "HEAD"], { cwd: source, encoding: "utf8" }).trim(); + await expect(beginKnowledgeUpdate(root, { scopes: [{ ...scope, processed_version: moved }] })).rejects.toThrow(); + await expect(readFile(join(root, ".tmp/context-runtime/locks/project-write.lock/owner.json"))).rejects.toThrow(); + expect(await readKnowledgeUpdate(root)).toBeUndefined(); +}); + test("source update preserves old baseline until every revised page is built and resumes after temporary loss", async () => { const root = await initialRevisionKnowledge(roots); await beginKnowledgeUpdate(root, { scopes: [scope] }); diff --git a/packages/context-cli/src/__tests__/repoContent.integration.test.ts b/packages/context-cli/src/__tests__/repoContent.integration.test.ts index 1ed5a0b0..4d8b3932 100644 --- a/packages/context-cli/src/__tests__/repoContent.integration.test.ts +++ b/packages/context-cli/src/__tests__/repoContent.integration.test.ts @@ -236,13 +236,34 @@ describe("repository content", () => { 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" }], + const receipt: Record = await completeKnowledgeUpdate({ projectRoot: root, revision: update.revision, decisions: [{ path: "faq/guide.md" }], scope_summary: "The clarification does not change the published claim.", new_topics: [] }); + expect(receipt.reference_warnings).toEqual([{ path: "faq/guide.md", section_id: "contract", state: "changed", + source_ref: reference.source_ref, locator }]); + expect(receipt.reference_warning_instruction).toContain("re-signs"); const settled = YAML.parse(await readFile(map, "utf8")); expect(settled.processed_scopes).toHaveLength(1); expect(settled.articles[0].sections[0].references[0].source_ref).toBe(`repo-content:docs@${sha}+worktree`); expect(settled.articles[0].sections[0].references[0].content_digest).not.toBe(reference.content_digest); }); + test("keeping a page after an unrelated repository edit returns no citation warning", async () => { + const root = await fixture(); const sha = await commit(root); + const locator = { path: "docs/guide.md", start_line: 2, end_line: 2 }; + const reference = { source_ref: `repo-content:docs@${sha}`, locator, + content_digest: articleSourceRegionDigest(await readFile(join(root, locator.path), "utf8"), locator) }; + await mkdir(join(root, "src")); await mkdir(join(root, "knowledge/faq"), { recursive: true }); + await writeFile(join(root, "src/indexers.yaml"), YAML.stringify({ requirements: [{ id: "docs", purpose: "Explain the contract", + target_scope: { targets: [{ source_ref: "repo-content:docs" }] } }] })); + await writeFile(join(root, "knowledge/faq/guide.md"), "---\ntitle: Guide\n---\n\n\nThe contract.\n\n"); + await writeFile(join(root, "knowledge/structure.yaml"), YAML.stringify({ schema_version: "context.approved-structure.v1", articles: [{ article_id: "guide", + path: "faq/guide.md", collection: "faq", visibility: "public", sections: [{ id: "contract", references: [reference] }] }] })); + await writeFile(join(root, locator.path), "# Guide\nStable contract.\nChanged other detail.\n"); + await beginKnowledgeUpdate(root, { scopes: [{ requirement_ref: "docs", source_ref: "repo-content:docs" }] }); + const update = (await readKnowledgeUpdate(root))!; + const receipt: Record = await completeKnowledgeUpdate({ projectRoot: root, revision: update.revision, decisions: [{ path: "faq/guide.md" }], + scope_summary: "Only an unrelated detail changed.", new_topics: [] }); + expect(receipt.reference_warnings).toBeUndefined(); + }); test("Skill script changes and untracked resources are candidates even with unchanged SKILL.md", async () => { const root = await fixture({ skills: { kind: "skills", path: ".agents/skills" } }); await mkdir(join(root, ".agents/skills/check/scripts"), { recursive: true }); diff --git a/packages/context-cli/src/__tests__/settledReferenceRelocation.test.ts b/packages/context-cli/src/__tests__/settledReferenceRelocation.test.ts index 6cf1f8db..314187a4 100644 --- a/packages/context-cli/src/__tests__/settledReferenceRelocation.test.ts +++ b/packages/context-cli/src/__tests__/settledReferenceRelocation.test.ts @@ -13,6 +13,8 @@ import { buildFixturePackages as buildProjectPackages } from "./workspaceVersion import { beginKnowledgeUpdate, readKnowledgeUpdate } from "../project/knowledgeUpdate.js"; import { readApprovedRevision } from "../project/approvedRevision.js"; import { collectProjectStatus } from "../project/status.js"; +import { validateApprovedStructure } from "../project/verifyApprovedStructure.js"; +import type { ProjectVerifyIssue } from "../project/verifyTypes.js"; const roots: string[] = []; afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); }); @@ -41,6 +43,12 @@ async function citedLines(root: string) { .map(article => [article.path, article.sections[0]!.references[0]!])); } +async function structureIssues(root: string) { + const issues: ProjectVerifyIssue[] = []; + await validateApprovedStructure({ projectRoot: root, issues }); + return issues; +} + async function keepAll(root: string) { await beginKnowledgeUpdate(root, { scopes: [scope] }); const update = (await readKnowledgeUpdate(root))!; @@ -60,6 +68,7 @@ test("keeping pages relocates uniquely moved unchanged citations at settlement", expect(reference.locator.start_line).toBe(2); expect(reference.content_digest).toBe(before[path]!.content_digest); } + expect(await structureIssues(root)).toEqual([]); }, 60_000); test("keeping pages whose cited text changed returns a receipt and retains the old citation", async () => { @@ -84,6 +93,32 @@ test("a missing region baseline is reported, never treated as unchanged", async expect(await citedLines(root)).toEqual(before); }, 60_000); +test("a missing region baseline is recovered from the previously settled commit", async () => { + const root = await initialRevisionKnowledge(roots); + await keepAll(root); + 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).toBeUndefined(); + for (const [path, reference] of Object.entries(await citedLines(root))) { + expect(reference.locator.start_line).toBe(2); + expect(reference.content_digest).toBe(before[path]!.content_digest); + } + expect(await structureIssues(root)).toEqual([]); +}, 60_000); + +test("a recovered baseline still reports cited text that changed", async () => { + const root = await initialRevisionKnowledge(roots); + await keepAll(root); + const before = await citedLines(root); + await commitSource(root, "export const answer = 43;\n"); + 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(["changed", "changed"]); + 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")}`); @@ -103,4 +138,5 @@ test("a mixed batch relocates the kept page when the revision queue settles", as const after = await citedLines(root); expect(after[kept!.path]!.locator.start_line).toBe(2); expect(after[revised!.path]!.locator.start_line).toBe(2); + expect(await structureIssues(root)).toEqual([]); }, 60_000); diff --git a/packages/context-cli/src/project/articleSourceReader.ts b/packages/context-cli/src/project/articleSourceReader.ts index ebf4f3ea..6cd18245 100644 --- a/packages/context-cli/src/project/articleSourceReader.ts +++ b/packages/context-cli/src/project/articleSourceReader.ts @@ -16,6 +16,29 @@ function inside(root: string, path: string): void { } } +/** Reads a registered repository file at an earlier commit; callers must verify + * the result against a recorded digest before trusting it. */ +export async function registeredRepoHistoryReader(projectRoot: string) { + const workspace = await realpath(projectRoot); + const registry = await loadSourcesRegistry({ rootDir: projectRoot }); + const roots = new Map>(); + return async (sourceRef: string, path: string, commit: string): Promise => { + if (!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(commit)) throw new TypeError("History reads require a fixed commit"); + let root = roots.get(sourceRef); + if (!root) { + const name = sourceRef.startsWith("repo:") ? sourceRef.slice("repo:".length) : undefined; + const entry = registry.repos.find(item => item.id === name || item.name === name); + if (!entry) throw new TypeError(`Source is not a registered repository: ${sourceRef}`); + root = realpath(resolve(workspace, entry.materializedAt)).then(actual => { inside(workspace, actual); return actual; }); + roots.set(sourceRef, root); + } + const blob = await execute("git", ["-C", await root, "show", `${commit}:./${path}`], { + encoding: "buffer", timeout: 5000, maxBuffer: 16 * 1024 * 1024, + }); + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(blob.stdout); + }; +} + /** One submission/check, one read per cited file. Registration identifies the * source; a document manifest identifies its files even in a shared date folder. * This never prepares parsers or loads uncited document bodies. */ diff --git a/packages/context-cli/src/project/close.ts b/packages/context-cli/src/project/close.ts index 7376acce..6fba88b7 100644 --- a/packages/context-cli/src/project/close.ts +++ b/packages/context-cli/src/project/close.ts @@ -83,6 +83,16 @@ export async function approvedKnowledgeInputHash(projectRoot: string): Promise[]): Promise { + const files = await approvedKnowledgeFiles(projectRoot); + return approvedStructureInputHash({ + schemaVersion: STRUCTURE_SCHEMA_VERSION, + files: approvedStructureInputFiles(files.map(file => ({ ...file, content: compactApprovedKnowledgeMarkdown(file.content) }))), + metadata: articles, + }); +} + function approvedStructureInputFiles(files: readonly ApprovedKnowledgeFile[]): ApprovedStructureInputFile[] { return files.map((file) => ({ path: file.relPath, diff --git a/packages/context-cli/src/project/indexerBaseContracts.ts b/packages/context-cli/src/project/indexerBaseContracts.ts index 3c3b2a2f..a3e4248f 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.71"; +export const BUNDLED_INDEXER_PARSER_PACKAGE_VERSION = "0.7.72"; const BUNDLED_PARSER_REQUIREMENTS = buildIndexerParserCapabilityRequirements( BUNDLED_INDEXER_PARSER_PACKAGE_VERSION, ); diff --git a/packages/context-cli/src/project/knowledgeUpdate.ts b/packages/context-cli/src/project/knowledgeUpdate.ts index 8f09ae56..64dc49a2 100644 --- a/packages/context-cli/src/project/knowledgeUpdate.ts +++ b/packages/context-cli/src/project/knowledgeUpdate.ts @@ -59,8 +59,9 @@ export async function beginKnowledgeUpdate(projectRoot: string, value: unknown) } const versions = new Map>(); const scopes = await captureProcessedScopes(projectRoot, await Promise.all(input.scopes.map(async (scope) => { + if (scope.processed_version !== undefined) return { ...scope, processed_version: scope.processed_version }; if (!versions.has(scope.source_ref)) versions.set(scope.source_ref, currentScopeSourceVersion(projectRoot, scope.source_ref)); - return { ...scope, processed_version: scope.processed_version ?? await versions.get(scope.source_ref)! }; + return { ...scope, processed_version: await versions.get(scope.source_ref)! }; }))); const structure = await readKnowledgeStructure(projectRoot); if (!structure.parsed) throw new TypeError("Close the existing knowledge before checking source updates"); @@ -97,19 +98,35 @@ 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. */ + * text changed or cannot be confirmed retain their previous citations, except + * repository content, which settlement still re-signs to the current text. */ 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 {}; + const { unresolved } = await inspectSettledReferences(projectRoot, articles, scopes, readProcessedScopes(structure.parsed)); + const repoScopes = scopes.filter((scope) => scope.startsWith("repo-content:")); + const repoReferences = articles.flatMap((article) => article.sections.flatMap((section) => section.references + .filter((reference) => reference.source_ref.startsWith("repo-content:") && repoScopes.some((scope) => repoContentScopeMatches(scope, reference.source_ref))) + .map((reference) => ({ path: article.path, section_id: section.id, reference })))); + const impact = repoReferences.length ? await repoContentImpactReader(projectRoot) : undefined; + const resigned = (await Promise.all(repoReferences.map(async (item) => { + const { state } = await impact!(item.reference); + return state === "changed" ? { ...item, state: "changed" as const } + : state === "unavailable" || state === "worktree" ? { ...item, state: "unavailable" as const } : undefined; + }))).filter((item) => item !== undefined); + if (unresolved.length === 0 && resigned.length === 0) return {}; return { - reference_warnings: unresolved.map((item) => ({ path: item.path, section_id: item.section_id, state: item.state, + reference_warnings: [...unresolved, ...resigned].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.", + reference_warning_instruction: [ + "These pages were kept unchanged as decided.", + ...(unresolved.length ? ["Their listed citations changed or could not be confirmed against the updated source and keep their previous locations."] : []), + ...(resigned.length ? ["Listed repo-content citations changed or could not be confirmed; settlement still re-signs them to the current repository text, so verify will not report them later."] : []), + "The decision cannot be resubmitted; after the current update queue finishes, run context revise --instruction for each page that needs a correction.", + ].join(" "), }; } diff --git a/packages/context-cli/src/project/processedScopeStorage.ts b/packages/context-cli/src/project/processedScopeStorage.ts index 15484a20..303efab1 100644 --- a/packages/context-cli/src/project/processedScopeStorage.ts +++ b/packages/context-cli/src/project/processedScopeStorage.ts @@ -64,11 +64,18 @@ export async function commitProcessedScopes(projectRoot: string, scopes: readonl await captureProcessedScopes(projectRoot, scopes); 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 settled = readProcessedScopes(structure.parsed); + const processed = mergeProcessedScopes(settled, scopes); + const previous = validateArticleStructureEntries(structure.parsed.articles ?? []); 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 })); + previous, scopes.filter(scope => !scope.module_refs).map(scope => scope.source_ref)); + const { articles } = await inspectSettledReferences(projectRoot, repoContent, scopes.map(scope => scope.source_ref), settled); + // Rewritten references are hashed metadata; a structure that was already stale stays stale for Close. + const { approvedStructureInputHashFor } = await import("./close.js"); + const input_hash = structure.parsed.input_hash === await approvedStructureInputHashFor(projectRoot, previous) + ? await approvedStructureInputHashFor(projectRoot, articles) + : structure.parsed.input_hash; + await atomicWriteFile(join(projectRoot, structure.path), YAML.stringify({ ...structure.parsed, input_hash, articles, processed_scopes: processed })); } /** Clear only proofs whose purpose/boundary changed, before committing that diff --git a/packages/context-cli/src/project/settledReferenceRelocation.ts b/packages/context-cli/src/project/settledReferenceRelocation.ts index 6fd6dafe..384eb1b3 100644 --- a/packages/context-cli/src/project/settledReferenceRelocation.ts +++ b/packages/context-cli/src/project/settledReferenceRelocation.ts @@ -1,8 +1,9 @@ -import { articleSourceRegionDigest, locateArticleRegion, type ArticleSourceReference, type ArticleStructureEntry } from "@c4a/context"; +import { articleSourceRegion, articleSourceRegionDigest, locateArticleRegion, type ArticleSourceReference, type ArticleStructureEntry, type ProcessedScope } from "@c4a/context"; import { readArticleRegionBaseline } from "./articleRegionBaselines.js"; -import { registeredArticleSourceReader } from "./articleSourceReader.js"; +import { registeredArticleSourceReader, registeredRepoHistoryReader } from "./articleSourceReader.js"; type SourceRead = (sourceRef: string, path: string, captured: boolean) => Promise; +type RegionHistory = (reference: ArticleSourceReference) => Promise; export type ReferenceRelocation = | { state: "current" | "moved"; reference: ArticleSourceReference } @@ -11,7 +12,7 @@ export type ReferenceRelocation = /** 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 { + reference: ArticleSourceReference, history?: RegionHistory): Promise { let text: string; try { text = await read(reference.source_ref, reference.locator.path, true); } catch { return { state: "unavailable", reference }; } @@ -19,7 +20,7 @@ export async function relocateUnchangedReference(root: string, read: SourceRead, 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); + const previous = readArticleRegionBaseline(root, reference.content_digest) ?? await history?.(reference); 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 }; @@ -28,20 +29,42 @@ export async function relocateUnchangedReference(root: string, read: SourceRead, 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[]) { +/** The region cache is machine-local. A repository's previously settled commits + * can stand in for it, but only when the old region still matches its digest. */ +async function settledRegionHistory(root: string, previous: readonly ProcessedScope[]): Promise { + if (!previous.some(scope => scope.source_ref.startsWith("repo:"))) return undefined; + let readAt: Awaited> | undefined; + return async reference => { + if (!reference.source_ref.startsWith("repo:")) return undefined; + const versions = new Set(previous.filter(scope => settledBy([scope.source_ref], reference.source_ref)).map(scope => scope.processed_version)); + for (const version of versions) { + try { + readAt ??= await registeredRepoHistoryReader(root); + const text = await readAt(reference.source_ref, reference.locator.path, version); + if (articleSourceRegionDigest(text, reference.locator) === reference.content_digest) return articleSourceRegion(text, reference.locator); + } catch { /* Unreadable history leaves the reference unconfirmed. */ } + } + return undefined; + }; +} + +/** Repository content keeps its own settlement in advanceRepoContentReferences. + * `previous` are the processed scopes recorded before this settlement. */ +export async function inspectSettledReferences(root: string, articles: readonly ArticleStructureEntry[], scopes: readonly string[], + previous: readonly ProcessedScope[] = []) { 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 history = await settledRegionHistory(root, previous); 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); + const result = await relocateUnchangedReference(root, read, reference, history); if (result.state === "changed" || result.state === "unavailable") { unresolved.push({ path: article.path, section_id: section.id, reference, state: result.state }); } diff --git a/packages/context/docs/guides/package-outputs.md b/packages/context/docs/guides/package-outputs.md index 61e9282d..69b4c527 100644 --- a/packages/context/docs/guides/package-outputs.md +++ b/packages/context/docs/guides/package-outputs.md @@ -107,8 +107,8 @@ top bar, homepage and articles share a centered 1440px container with 32px margins (16px on phones); on wide screens the sidebar sits at its left edge and articles keep a wide reading area and a smaller outline. Navigation is centered in the top bar, which ends with icon buttons for search, theme, social links and -reader language; search, or Cmd/Ctrl+K, opens a dialog over a blurred page. The theme follows the -operating system until the reader switches it; that choice is remembered in +reader language; search, or Cmd/Ctrl+K, opens a dialog over a blurred page. The theme opens +in light mode until the reader switches it; that choice is remembered in browser storage. Search runs locally, without a search service. Mermaid renders in the browser using the website theme, with fit, original-size, zoom, full-screen and source controls. Controls appear on hover or keyboard focus diff --git a/packages/context/package.json b/packages/context/package.json index 513e3f7d..d23087e1 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.71", + "version": "0.7.72", "type": "module", "license": "MIT", "engines": { diff --git a/packages/core/package.json b/packages/core/package.json index a67a82dd..daba9bad 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.71", + "version": "0.7.72", "type": "module", "license": "MIT", "engines": { diff --git a/packages/dev-cli/package.json b/packages/dev-cli/package.json index 92840a66..950dfdf3 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.71", + "version": "0.7.72", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extract-contract/package.json b/packages/extract-contract/package.json index ba6bfe99..eb825fb8 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.71", + "version": "0.7.72", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-go/package.json b/packages/extract-go/package.json index 706a5e7f..6bb9e0ea 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.71", + "version": "0.7.72", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-mdx/package.json b/packages/extract-mdx/package.json index 1fb45fc1..d8886931 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.71", + "version": "0.7.72", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-proto/package.json b/packages/extract-proto/package.json index 3741775a..b3354a38 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.71", + "version": "0.7.72", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-rush/package.json b/packages/extract-rush/package.json index ab1edb7f..d32e6d26 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.71", + "version": "0.7.72", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-sql/package.json b/packages/extract-sql/package.json index 5baaba40..cfd4832b 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.71", + "version": "0.7.72", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-style/package.json b/packages/extract-style/package.json index 07ba0d42..3e646e34 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.71", + "version": "0.7.72", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-thrift/package.json b/packages/extract-thrift/package.json index a5c9385a..eb01a0e6 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.71", + "version": "0.7.72", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-ts/package.json b/packages/extract-ts/package.json index 8eed95aa..a93aea7d 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.71", + "version": "0.7.72", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract/package.json b/packages/extract/package.json index 31a6a45f..3d34b9a3 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.71", + "version": "0.7.72", "type": "module", "license": "MIT", "engines": { diff --git a/packages/tui/package.json b/packages/tui/package.json index a80dedb7..c052af47 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.71", + "version": "0.7.72", "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 fb59580b..2827a018 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.71", + "version": "0.7.72", "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 37123433..17f1af28 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.71", + "version": "0.7.72", "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.71", + "shortDescription": "Initialize and advance a local, source-linked project knowledge workspace.\nv0.7.72", "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 de1d1ad7..d4ec5862 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.71", + "version": "0.7.72", "description": "Start or continue a project-local knowledge workspace through one graph-routed entry.", "author": { "name": "Context4AI",