From 8d91dce098f82b1dcc3fded5f5f665c0d116f0cf Mon Sep 17 00:00:00 2001 From: qiansc Date: Sat, 3 Oct 2026 17:03:53 +0800 Subject: [PATCH 1/3] feat(v0.7.50): add same-repository content registration and evidence --- bun.lock | 30 +- package.json | 2 +- packages/context-cli/CLAUDE.md | 2 +- .../context-workflow/provider.yaml | 2 +- .../manuals/guides/knowledge-updates.md | 11 + .../manuals/guides/package-outputs.md | 5 + .../resources/manuals/guides/repo-content.md | 77 +++++ .../resources/procedures/knowledge-updates.md | 11 + packages/context-cli/package.json | 2 +- packages/context-cli/scripts/build-plugin.ts | 18 +- .../context-cli/scripts/build-workflow.ts | 1 + .../scripts/evidence-wasm.test.mjs | 26 ++ .../src/__tests__/packageSiteSources.test.ts | 10 + .../__tests__/pluginRootSourceV070.test.ts | 2 +- .../__tests__/repoContent.integration.test.ts | 273 ++++++++++++++++++ .../repoContentSite.integration.test.ts | 36 +++ .../approvedKnowledgeDependencyWarnings.ts | 15 +- .../src/project/approvedRevision.ts | 8 +- .../src/project/approvedRevisionContext.ts | 4 +- .../src/project/approvedRevisionReferences.ts | 4 +- .../src/project/articleSourceReader.ts | 6 + .../src/project/indexerBaseContracts.ts | 2 +- .../src/project/indexerDistributionBuild.ts | 2 +- .../src/project/knowledgeUpdate.ts | 21 +- .../src/project/packageBuildContent.ts | 12 +- .../context-cli/src/project/packageBuilder.ts | 10 +- .../context-cli/src/project/packageSite.ts | 18 +- .../src/project/packageSiteSources.ts | 16 +- .../src/project/pluginInstallLocal.ts | 8 +- .../src/project/processedScopeStorage.ts | 8 +- .../src/project/productionArticle.ts | 6 +- .../project/productionPlanningMaterials.ts | 17 ++ .../src/project/repoContentEvidence.ts | 218 ++++++++++++++ .../context-cli/src/project/repoContentGit.ts | 68 +++++ .../src/project/repoContentLinks.ts | 90 ++++++ .../src/project/repoContentPages.ts | 199 +++++++++++++ .../src/project/repoContentRegistry.ts | 104 +++++++ packages/context-cli/src/project/run.ts | 2 + .../context-cli/src/project/sourceCommands.ts | 11 + packages/context-cli/src/project/status.ts | 2 + .../context-cli/src/project/statusCommand.ts | 6 + .../context-cli/src/project/statusRender.ts | 3 + .../context-cli/src/project/statusTypes.ts | 1 + packages/context-cli/src/project/workspace.ts | 5 +- .../src/project/workspaceGuidanceTemplates.ts | 19 +- packages/context-evidence-wasm/README.md | 8 + .../official-digests.json | 1 + packages/context-evidence-wasm/plugin.json | 2 +- packages/context-evidence-wasm/src/lib.rs | 4 +- packages/context-evidence-wasm/src/sources.rs | 32 +- .../context/docs/guides/knowledge-updates.md | 11 + .../context/docs/guides/package-outputs.md | 5 + packages/context/docs/guides/repo-content.md | 71 +++++ packages/context/package.json | 2 +- packages/context/src/index.ts | 14 + packages/context/src/repoContent.ts | 40 +++ .../kb/skills/knowledge-query/SKILL.md | 4 + .../kb/skills/knowledge-query/SKILL.md | 6 + .../maintain-project-knowledge/SKILL.md | 5 + .../maintain-project-knowledge/SKILL.md | 7 + packages/core/package.json | 2 +- packages/dev-cli/package.json | 2 +- packages/extract-contract/package.json | 2 +- packages/extract-go/package.json | 2 +- packages/extract-mdx/package.json | 2 +- packages/extract-proto/package.json | 2 +- packages/extract-rush/package.json | 2 +- packages/extract-sql/package.json | 2 +- packages/extract-style/package.json | 2 +- packages/extract-thrift/package.json | 2 +- packages/extract-ts/package.json | 2 +- packages/extract/package.json | 2 +- packages/tui/package.json | 2 +- .../.claude-plugin/plugin.json.template | 3 +- .../.cursor-plugin/plugin.json.template | 3 +- .../claude/.claude-plugin/plugin.json | 5 +- .../claude/commands/context-inspect-search.md | 25 ++ .../claude/commands/context-repo-content.md | 10 + .../repo-install/claude/commands/context.md | 8 + .../skills/context-inspect-search/SKILL.md | 25 ++ .../claude/skills/context-plan/SKILL.md | 7 + .../skills/context-repo-content/SKILL.md | 62 ++++ .../references/document-outlines.md | 17 ++ .../references/registration.md | 48 +++ .../codex/.codex-plugin/plugin.json | 4 +- .../skills/context-inspect-search/SKILL.md | 25 ++ .../codex/skills/context-plan/SKILL.md | 7 + .../skills/context-repo-content/SKILL.md | 61 ++++ .../references/document-outlines.md | 17 ++ .../references/registration.md | 48 +++ .../codex/skills/context/SKILL.md | 8 + .../cursor/.cursor-plugin/plugin.json | 5 +- .../commands/c4a-context-inspect-search.md | 25 ++ .../commands/c4a-context-repo-content.md | 9 + .../cursor/commands/c4a-context.md | 8 + .../skills/context-inspect-search/SKILL.md | 25 ++ .../cursor/skills/context-plan/SKILL.md | 7 + .../skills/context-repo-content/SKILL.md | 62 ++++ .../references/document-outlines.md | 17 ++ .../references/registration.md | 48 +++ .../skills/context-inspect-search/SKILL.md | 25 ++ .../skills/context-markdown-indexer/SKILL.md | 6 + .../repo-install/skills/context-plan/SKILL.md | 7 + .../skills/context-repo-content/SKILL.md | 61 ++++ .../references/document-outlines.md | 17 ++ .../references/registration.md | 48 +++ .../repo-install/skills/context/SKILL.md | 8 + .../skills/context-inspect-search/SKILL.md | 25 ++ .../skills/context-markdown-indexer/SKILL.md | 6 + plugins/context/skills/context-plan/SKILL.md | 7 + .../skills/context-repo-content/SKILL.md | 61 ++++ .../references/document-outlines.md | 17 ++ .../references/registration.md | 48 +++ plugins/context/skills/context/SKILL.md | 8 + 114 files changed, 2474 insertions(+), 90 deletions(-) create mode 100644 packages/context-cli/context-workflow/resources/manuals/guides/repo-content.md create mode 100644 packages/context-cli/src/__tests__/repoContent.integration.test.ts create mode 100644 packages/context-cli/src/__tests__/repoContentSite.integration.test.ts create mode 100644 packages/context-cli/src/project/repoContentEvidence.ts create mode 100644 packages/context-cli/src/project/repoContentGit.ts create mode 100644 packages/context-cli/src/project/repoContentLinks.ts create mode 100644 packages/context-cli/src/project/repoContentPages.ts create mode 100644 packages/context-cli/src/project/repoContentRegistry.ts create mode 100644 packages/context/docs/guides/repo-content.md create mode 100644 packages/context/src/repoContent.ts create mode 100644 plugins/context/repo-install/claude/commands/context-repo-content.md create mode 100644 plugins/context/repo-install/claude/skills/context-repo-content/SKILL.md create mode 100644 plugins/context/repo-install/claude/skills/context-repo-content/references/document-outlines.md create mode 100644 plugins/context/repo-install/claude/skills/context-repo-content/references/registration.md create mode 100644 plugins/context/repo-install/codex/skills/context-repo-content/SKILL.md create mode 100644 plugins/context/repo-install/codex/skills/context-repo-content/references/document-outlines.md create mode 100644 plugins/context/repo-install/codex/skills/context-repo-content/references/registration.md create mode 100644 plugins/context/repo-install/cursor/commands/c4a-context-repo-content.md create mode 100644 plugins/context/repo-install/cursor/skills/context-repo-content/SKILL.md create mode 100644 plugins/context/repo-install/cursor/skills/context-repo-content/references/document-outlines.md create mode 100644 plugins/context/repo-install/cursor/skills/context-repo-content/references/registration.md create mode 100644 plugins/context/repo-install/skills/context-repo-content/SKILL.md create mode 100644 plugins/context/repo-install/skills/context-repo-content/references/document-outlines.md create mode 100644 plugins/context/repo-install/skills/context-repo-content/references/registration.md create mode 100644 plugins/context/skills/context-repo-content/SKILL.md create mode 100644 plugins/context/skills/context-repo-content/references/document-outlines.md create mode 100644 plugins/context/skills/context-repo-content/references/registration.md diff --git a/bun.lock b/bun.lock index 269743c4..3e421225 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ }, "packages/context": { "name": "@c4a/context", - "version": "0.7.43", + "version": "0.7.50", "dependencies": { "yaml": "^2.5.1", "zod": "^3.23.8", @@ -26,7 +26,7 @@ }, "packages/context-cli": { "name": "@c4a/context-cli", - "version": "0.7.43", + "version": "0.7.50", "bin": { "context": "dist/cli.js", }, @@ -73,7 +73,7 @@ }, "packages/core": { "name": "@c4a/core", - "version": "0.7.43", + "version": "0.7.50", "dependencies": { "picomatch": "^4.0.4", "yaml": "^2.4.5", @@ -85,7 +85,7 @@ }, "packages/dev-cli": { "name": "@c4a/dev-cli", - "version": "0.7.43", + "version": "0.7.50", "dependencies": { "@c4a/context": "workspace:*", "@c4a/core": "workspace:*", @@ -97,7 +97,7 @@ }, "packages/extract": { "name": "@c4a/extract", - "version": "0.7.43", + "version": "0.7.50", "bin": { "c4a-extract-code": "./dist/bin/c4a-extract-code.js", }, @@ -112,7 +112,7 @@ }, "packages/extract-contract": { "name": "@c4a/extract-contract", - "version": "0.7.43", + "version": "0.7.50", "dependencies": { "@c4a/core": "workspace:*", "graphql": "^16.14.2", @@ -122,7 +122,7 @@ }, "packages/extract-go": { "name": "@c4a/extract-go", - "version": "0.7.43", + "version": "0.7.50", "dependencies": { "@c4a/core": "workspace:*", "@c4a/extract": "workspace:*", @@ -132,7 +132,7 @@ }, "packages/extract-mdx": { "name": "@c4a/extract-mdx", - "version": "0.7.43", + "version": "0.7.50", "dependencies": { "@c4a/core": "workspace:*", "remark-mdx": "^3.1.1", @@ -144,7 +144,7 @@ }, "packages/extract-proto": { "name": "@c4a/extract-proto", - "version": "0.7.43", + "version": "0.7.50", "dependencies": { "@c4a/core": "workspace:*", "zod": "^3.23.8", @@ -152,7 +152,7 @@ }, "packages/extract-rush": { "name": "@c4a/extract-rush", - "version": "0.7.43", + "version": "0.7.50", "dependencies": { "@c4a/core": "workspace:*", "typescript": "^5.5.4", @@ -162,7 +162,7 @@ }, "packages/extract-sql": { "name": "@c4a/extract-sql", - "version": "0.7.43", + "version": "0.7.50", "dependencies": { "@c4a/core": "workspace:*", "node-sql-parser": "^5.4.0", @@ -171,7 +171,7 @@ }, "packages/extract-style": { "name": "@c4a/extract-style", - "version": "0.7.43", + "version": "0.7.50", "dependencies": { "@c4a/core": "workspace:*", "postcss": "^8.5.26", @@ -183,7 +183,7 @@ }, "packages/extract-thrift": { "name": "@c4a/extract-thrift", - "version": "0.7.43", + "version": "0.7.50", "dependencies": { "@c4a/core": "workspace:*", "zod": "^3.23.8", @@ -191,7 +191,7 @@ }, "packages/extract-ts": { "name": "@c4a/extract-ts", - "version": "0.7.43", + "version": "0.7.50", "dependencies": { "@c4a/core": "workspace:*", "@c4a/extract": "workspace:*", @@ -202,7 +202,7 @@ }, "packages/tui": { "name": "@c4a/tui", - "version": "0.7.43", + "version": "0.7.50", "dependencies": { "ink": "^5.0.0", "react": "^18.3.1", diff --git a/package.json b/package.json index e62f92fa..875643ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "context", - "version": "0.7.43", + "version": "0.7.50", "packageManager": "bun@1.3.9", "repository": { "type": "git", diff --git a/packages/context-cli/CLAUDE.md b/packages/context-cli/CLAUDE.md index a4fe312c..b930ff0f 100644 --- a/packages/context-cli/CLAUDE.md +++ b/packages/context-cli/CLAUDE.md @@ -308,7 +308,7 @@ block 标题用 `**Label**:` 或 `**Label** (meta):`,统一英文(中文标 构建不变量: - Claude/Cursor commands 发布生产、项目规划与显式查询入口,并提供 Indexer 创建 Skill。项目规划 command 转交完整 `context-plan` Skill,保留其参考文件和模板。 -- `dist/plugins/codex/skills/` 包含 `context`、`context-plan`、`context-inspect-search` 和 `context-indexer-create`;宿主 plugin root 不内嵌 lifecycle Provider。 +- `dist/plugins/codex/skills/` 包含 `context`、`context-plan`、`context-inspect-search`、`context-repo-content` 和 `context-indexer-create`;宿主 plugin root 不内嵌 lifecycle Provider。`context-repo-content` 独立编辑/登记同仓原文,不启动知识生产。 - `dist/plugins/skills/` 直接投影根级全部 Skills;安装器把其中 lifecycle Provider 原子复制到 `~/.agents/skills` 和 `~/.claude/skills`,而不是复制进 Host plugin root。 - `dist/plugins/{claude,codex,cursor}/` 各带 generated guard(`CLAUDE.md` 或 `AGENTS.md` + `.generated`);看到 guard 不要编辑 build 产物。`dist/plugins/skills/` 顶层 README 统一说明。 - 生命周期规则、长诊断、Schema 发现说明和语义规则统一住在 diff --git a/packages/context-cli/context-workflow/provider.yaml b/packages/context-cli/context-workflow/provider.yaml index 2715e0af..eaf3beec 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.43 +version: 0.7.50 name: Context workflow description: Internal work contract for Context knowledge workspaces. graphs: diff --git a/packages/context-cli/context-workflow/resources/manuals/guides/knowledge-updates.md b/packages/context-cli/context-workflow/resources/manuals/guides/knowledge-updates.md index edabb873..1b3db74d 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 @@ -24,6 +24,17 @@ actual behavior, a confirmed decision, and a proposal that is not implemented. ## Keep planning local to the change +Same-repository originals use `repo-content.yaml` rather than another capture. +For editing/registering them, use the `context-repo-content` Skill without +starting production. An explicitly requested source update can select +`repo-content:` in its confirmed requirement and update scope. The +prepared update includes current local changes and chapter impact candidates. +Docs compare cited regions; Skills compare the whole owning Skill directory, +including untracked additions. Missing historical objects are unknown, not an +empty diff. Inspect/edit does not advance references; delivery or an explicit +no-impact outcome settles the selected scope. Historical locators always store +the real repository path at the cited commit, not today's registration path. + Before starting production, compare the proposed content with the workspace's reader purpose. For clearly unrelated anecdotes or personal rankings, briefly recommend leaving them out of the formal manual or saving them separately because 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 3de5e199..eb0c3f6b 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 @@ -23,6 +23,11 @@ package index, and section fingerprint rebuilds are not current close output. ## Default New-Workspace Outputs: Knowledge Base + Website +Same-repository originals have an optional [repository entrance](repo-content.md): +`repoContentPage: true` in a KB declaration projects README and Skill summaries, +not the full document tree. It does not expose that page on a configured website; +use `repoContentPage: { site: true }` to opt in. Existing declarations are kept. + Output channels support multiple selection. In a new workspace without explicit preferences, the Agent proposes and configures KB + website as the default. Honor user feedback, session authority and existing workspace declarations; LLMS is an diff --git a/packages/context-cli/context-workflow/resources/manuals/guides/repo-content.md b/packages/context-cli/context-workflow/resources/manuals/guides/repo-content.md new file mode 100644 index 00000000..500f84d5 --- /dev/null +++ b/packages/context-cli/context-workflow/resources/manuals/guides/repo-content.md @@ -0,0 +1,77 @@ +--- +id: context.sdk.repo-content +kind: procedure +mediaType: text/markdown +--- + +# Same-repository content + +Keep project documentation and authored Skills at their original locations. +Context registers an entrance without copying their bodies to knowledge or +source snapshots. For selection and editing use `context-repo-content`. + +At the Context workspace root, create `repo-content.yaml`: + +```yaml +protocol: context.repo-content/v1 +entries: + docs: + kind: docs + path: docs + tools: + kind: skills + path: .agents/skills +``` + +Paths are Git-root-relative, not workspace-relative. Kinds are `docs`, +`document`, `skills`, and `skill`. Optional `group` is one module level; +`mount` overrides the default `[group/]`. Optional `exclude` globs +are relative to the entry; they are not access control. Overlapping entries, +outside targets, nested repositories and cycles are rejected. + +`context source ensure repo-content --format json` maintains relative symlinks +in `repo-content/`; `source inspect repo-content` and `status` inspect without +repairing. Ordinary files are not overwritten. With disabled symlinks, search +real paths from the registry rather than reading Git placeholders. No Git +configuration or index is changed. Registry and relative links can be committed +by the user; originals remain the only authoring location. + +Invalid registration is advisory in `status` and automatic pre-operation link +maintenance: correct `repo-content.yaml`; existing links are left unchanged. +The repair command is suggested only for missing or misdirected symlinks, not +invalid registration, missing targets, ordinary-file conflicts or disabled +symlinks. Fix those reported conditions rather than repeatedly running ensure. +Explicit repository-content updates and `context build` require valid +registration. An invalid registry stops build before replacing existing package +outputs; it is not treated as an empty registry. Missing source files and +unavailable Git objects follow the documented fallback behavior instead. + +Article navigation uses `[Guide](context:repo/docs/guide.md)`. Build projects +this to an upstream link when available, otherwise a plain location. Evidence +instead records `repo-content:docs@` plus real historical path, +line range and content digest. Uncommitted evidence uses `+worktree`. +The evidence Wasm supports these references without treating their existence +as proof that the source was read in the current query. + +## Optional entrance page + +```ts +kbPackage({ + name: "project-kb", + template: { path: "src/package-templates/kb" }, + repoContentPage: true, +}); +``` + +SDK default is off. New initialization with an existing nonempty registry +creates a KB declaration with the entrance enabled; existing declarations are +preserved. It projects a group's README and Skill names/descriptions under +`wikis/repo-content.md` and optional group pages, not all docs or Skill scripts. +HEAD objects are preferred without network fetching, with declared worktree +fallback when unavailable. Review README sensitivity before exposing it. +With a configured website, `{ site: true }` opts into a top-level site entrance; +plain `true` keeps these pages out of the site. + +Other repositories and externally installed Skills are not same-repository +content. Registering an entrance does not imply installation, execution, +production or publication authority. 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 13804b09..a46e37f9 100644 --- a/packages/context-cli/context-workflow/resources/procedures/knowledge-updates.md +++ b/packages/context-cli/context-workflow/resources/procedures/knowledge-updates.md @@ -24,6 +24,17 @@ actual behavior, a confirmed decision, and a proposal that is not implemented. ## Keep planning local to the change +Same-repository originals use `repo-content.yaml` rather than another capture. +For editing/registering them, use the `context-repo-content` Skill without +starting production. An explicitly requested source update can select +`repo-content:` in its confirmed requirement and update scope. The +prepared update includes current local changes and chapter impact candidates. +Docs compare cited regions; Skills compare the whole owning Skill directory, +including untracked additions. Missing historical objects are unknown, not an +empty diff. Inspect/edit does not advance references; delivery or an explicit +no-impact outcome settles the selected scope. Historical locators always store +the real repository path at the cited commit, not today's registration path. + Before starting production, compare the proposed content with the workspace's reader purpose. For clearly unrelated anecdotes or personal rankings, briefly recommend leaving them out of the formal manual or saving them separately because diff --git a/packages/context-cli/package.json b/packages/context-cli/package.json index 5f88f3c9..3b8c82dc 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.43", + "version": "0.7.50", "type": "module", "license": "MIT", "engines": { diff --git a/packages/context-cli/scripts/build-plugin.ts b/packages/context-cli/scripts/build-plugin.ts index 1fe7745c..6d9205d6 100644 --- a/packages/context-cli/scripts/build-plugin.ts +++ b/packages/context-cli/scripts/build-plugin.ts @@ -184,15 +184,15 @@ function stripHtmlComments(markdown: string): string { } async function readCommands(): Promise { - return Promise.all(["context", "context-inspect-search", "context-plan"].map(async (slug) => { + return Promise.all(["context", "context-inspect-search", "context-plan", "context-repo-content"].map(async (slug) => { const file = join(PLUGIN_SOURCE_ROOT, "skills", slug, "SKILL.md"); const { frontmatter, body } = parseFrontmatter(await readFile(file, "utf8"), file); return { slug, title: titleFromSlug(slug), description: frontmatterValue(frontmatter, "description", file), - body: slug === "context-plan" ? [ - "# Context Plan", + body: ["context-plan", "context-repo-content"].includes(slug) ? [ + `# ${titleFromSlug(slug)}`, "", - "Read the installed `context-plan` skill at `../skills/context-plan/SKILL.md`", + `Read the installed \`${slug}\` skill at \`../skills/${slug}/SKILL.md\``, "relative to this command file, then follow its instructions for the user's request.", "Resolve its references and templates from that skill directory.", ].join("\n") : body, @@ -312,14 +312,16 @@ async function copyAuthoringSkill(outputRoot: string): Promise { async function copyHostRoutedSkills(outputRoot: string): Promise { await copyAuthoringSkill(outputRoot); - for (const slug of ["context-inspect-search", "context-plan"]) { + for (const slug of ["context-inspect-search", "context-plan", "context-repo-content"]) { await copyDir(join(PLUGIN_SOURCE_ROOT, "skills", slug), join(outputRoot, "skills", slug)); } // The command is the visible entry; keep the underlying Skill available to // model routing and relative resource loading without a duplicate command. - const planningEntry = join(outputRoot, "skills", "context-plan", "SKILL.md"); - const { frontmatter, body } = parseFrontmatter(await readFile(planningEntry, "utf8"), planningEntry); - await writeFile(planningEntry, `---\nuser-invocable: false\n${frontmatter.replace(/^user-invocable:.*\n?/mu, "")}\n---\n\n${body}`, "utf8"); + for (const slug of ["context-plan", "context-repo-content"]) { + const entry = join(outputRoot, "skills", slug, "SKILL.md"); + const { frontmatter, body } = parseFrontmatter(await readFile(entry, "utf8"), entry); + await writeFile(entry, `---\nuser-invocable: false\n${frontmatter.replace(/^user-invocable:.*\n?/mu, "")}\n---\n\n${body}`, "utf8"); + } } async function copyContextEntrySkill(outputRoot: string): Promise { diff --git a/packages/context-cli/scripts/build-workflow.ts b/packages/context-cli/scripts/build-workflow.ts index 55a1971e..e87ad54c 100644 --- a/packages/context-cli/scripts/build-workflow.ts +++ b/packages/context-cli/scripts/build-workflow.ts @@ -59,6 +59,7 @@ const sdkManuals = [ "guides/lark-resources.md", "guides/source-batches.md", "guides/knowledge-updates.md", + "guides/repo-content.md", "guides/workspace-prepare.md", "guides/workspace-commit.md", "guides/workspace-restore.md", diff --git a/packages/context-cli/scripts/evidence-wasm.test.mjs b/packages/context-cli/scripts/evidence-wasm.test.mjs index c5f97ef6..938779a1 100644 --- a/packages/context-cli/scripts/evidence-wasm.test.mjs +++ b/packages/context-cli/scripts/evidence-wasm.test.mjs @@ -94,6 +94,32 @@ test('optional digest and monorepo root', () => { request.files[0].path = 'docs/knowledge/example.md'; assert.equal(refs(host(files).run(request))[0].content_digest, reference().content_digest); }); + +test('repo-content uses its historical repository path and source commit without registry joins', () => { + const sha = 'c'.repeat(40); + const ref = { ...reference(`repo-content:docs@${sha}`), locator: { path: 'packages/cli/docs/old.md', start_line: 3, end_line: 9 } }; + const files = Object.fromEntries(Object.entries(fixtures([ref])).map(([p, v]) => [`workspace/${p}`, v])); + delete files['workspace/sources/repo/index.yaml']; + const request = input(2, 2, { workspace_root: 'workspace' }); + request.repository = 'https://example.org/team/project.git'; + request.commit = 'd'.repeat(40); + request.files[0].path = 'workspace/knowledge/example.md'; + const h = host(files); + assert.deepEqual(refs(h.run(request)), [`https://example.org/team/project/blob/${sha}/packages/cli/docs/old.md#L3-L9`]); + assert.deepEqual(h.calls.sort(), ['workspace/knowledge/example.md', 'workspace/knowledge/structure.yaml']); +}); + +test('repo-content retains worktree uncertainty and does not invent a URL from a repository name', () => { + for (const worktree of [false, true]) { + const sourceRef = `repo-content:docs@${'c'.repeat(40)}${worktree ? '+worktree' : ''}`; + const request = input(); request.repository = 'team/project'; + const result = refs(host(fixtures([reference(sourceRef)])).run(request))[0]; + assert.equal(result.source_ref, sourceRef); + assert.equal(result.path, 'src/example.ts'); + assert.equal(result.url, undefined); + if (worktree) assert.equal(result.content_digest, reference().content_digest); + } +}); test('source URLs encode file segments and normalize credential-free Git transports', () => { for (const remote of ['https://github.com/team/source.git', 'git@github.com:team/source.git', 'ssh://git@github.com/team/source.git']) { const ref = reference(); ref.locator = { path: 'src/a #中文%.ts', start_line: 3, end_line: 3 }; diff --git a/packages/context-cli/src/__tests__/packageSiteSources.test.ts b/packages/context-cli/src/__tests__/packageSiteSources.test.ts index 20234a17..2262e938 100644 --- a/packages/context-cli/src/__tests__/packageSiteSources.test.ts +++ b/packages/context-cli/src/__tests__/packageSiteSources.test.ts @@ -28,3 +28,13 @@ test("unknown sources and prose do not invent provenance; unsafe paths and URLs const result = siteArticleSources(article("../../secret"), unsafe); expect(result.every(source => source.href === undefined)).toBe(true); }); + +test("same-repository provenance uses the source SHA and never labels worktree bytes as committed", () => { + const entry = article("packages/core/docs/guide.md"); + entry.sections[0]!.references = [entry.sections[0]!.references[0]!]; + entry.sections[0]!.references[0]!.source_ref = `repo-content:docs@${"c".repeat(40)}`; + const source = siteArticleSources(entry, registry, "https://example.org/team/project.git")[0]!; + expect(source.href).toBe(`https://example.org/team/project/blob/${"c".repeat(40)}/packages/core/docs/guide.md#L3-L8`); + entry.sections[0]!.references[0]!.source_ref += "+worktree"; + expect(siteArticleSources(entry, registry, "https://example.org/team/project.git")[0]!.href).toBeUndefined(); +}); diff --git a/packages/context-cli/src/__tests__/pluginRootSourceV070.test.ts b/packages/context-cli/src/__tests__/pluginRootSourceV070.test.ts index ce50cc6c..9766d609 100644 --- a/packages/context-cli/src/__tests__/pluginRootSourceV070.test.ts +++ b/packages/context-cli/src/__tests__/pluginRootSourceV070.test.ts @@ -88,7 +88,7 @@ describe("0.7.0 root plugin source", () => { "context-code-indexer", "context-markdown-indexer", ])); - expect(installedSkills).toEqual(["context", "context-indexer-create", "context-inspect-search", "context-plan"]); + expect(installedSkills).toEqual(["context", "context-indexer-create", "context-inspect-search", "context-plan", "context-repo-content"]); for (const host of ["claude", "codex", "cursor"] as const) { for (const provider of sourceSkills.filter((skill) => skill.includes("-indexer") && skill !== "context-indexer-create")) { await expect(readFile( diff --git a/packages/context-cli/src/__tests__/repoContent.integration.test.ts b/packages/context-cli/src/__tests__/repoContent.integration.test.ts new file mode 100644 index 00000000..d28825e8 --- /dev/null +++ b/packages/context-cli/src/__tests__/repoContent.integration.test.ts @@ -0,0 +1,273 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, mkdir, writeFile, readFile, readdir, readlink, rename, rm, symlink } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import YAML from "yaml"; +import { execFileSync, spawnSync } from "node:child_process"; +import { articleSourceRegionDigest, kbPackage, repoContentRegistrySchema, repoContentScopeMatches } from "@c4a/context"; +import { repoContentGit, repoContentRealPath } from "../project/repoContentGit.js"; +import { ensureRepoContentLinks, inspectRepoContentLinks, repoContentLinksNeedRepair, type RepoContentLinkResult } from "../project/repoContentLinks.js"; +import { readRepoContentRegistry } from "../project/repoContentRegistry.js"; +import { repoContentImpactReader, repoContentReferenceReader } from "../project/repoContentEvidence.js"; +import { repoContentPages, repoContentLinkProjector, repoContentNavigation, writeRepoContentPages } from "../project/repoContentPages.js"; +import { packageTemplateVars } from "../project/packageBuildContent.js"; +import { initContextProject } from "../project/workspace.js"; +import { beginKnowledgeUpdate, completeKnowledgeUpdate, readKnowledgeUpdate } from "../project/knowledgeUpdate.js"; + +const roots: string[] = []; +afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); }); +async function fixture(entries: Record = { docs: { kind: "docs", path: "docs" } }) { + const temporary = resolve(import.meta.dir, "../../../../.tmp/v0750/tests"); + await mkdir(temporary, { recursive: true }); + const root = await mkdtemp(join(temporary, "repo-")); roots.push(root); + await mkdir(join(root, "docs")); + await writeFile(join(root, "docs/guide.md"), "# Guide\nStable contract.\nOther detail.\n"); + await writeFile(join(root, "repo-content.yaml"), YAML.stringify({ protocol: "context.repo-content/v1", entries })); + await repoContentGit(root, ["init", "-b", "main"]); + await repoContentGit(root, ["config", "user.email", "test@example.org"]); + await repoContentGit(root, ["config", "user.name", "Test"]); + await repoContentGit(root, ["config", "commit.gpgsign", "false"]); + await repoContentGit(root, ["remote", "add", "origin", "https://example.org/team/project.git"]); + return root; +} +async function commit(root: string) { + await repoContentGit(root, ["add", "."]); + await repoContentGit(root, ["commit", "-m", "fixture"]); + return (await repoContentGit(root, ["rev-parse", "HEAD"])).trim(); +} + +describe("repository content", () => { + test("invalid registration is advisory and preserves existing view links", async () => { + const root = await fixture(); + await initContextProject({ cwd: root, projectDir: ".", dev: true, allowNonempty: true }); + await ensureRepoContentLinks(root); + await writeFile(join(root, "repo-content.yaml"), YAML.stringify({ protocol: "context.repo-content/v1", entries: { + docs: { kind: "docs", path: "docs" }, duplicate: { kind: "docs", path: "docs" }, + } })); + expect((await inspectRepoContentLinks(root))[0]?.status).toBe("invalid"); + expect((await ensureRepoContentLinks(root))[0]?.status).toBe("invalid"); + expect(await readlink(join(root, "repo-content/docs"))).toBe("../docs"); + const status = JSON.parse(execFileSync("node", [resolve(import.meta.dir, "../../dist/cli.js"), "status", "--format", "json"], + { cwd: root, encoding: "utf8" })); + expect(status.repo_content.entries[0].status).toBe("invalid"); + expect(status.repo_content).not.toHaveProperty("repair_command"); + const text = execFileSync("node", [resolve(import.meta.dir, "../../dist/cli.js"), "status"], { cwd: root, encoding: "utf8" }); + expect(text).toContain("repo-content.yaml"); + expect(text).not.toContain("context source ensure repo-content"); + }); + test("repair guidance is limited to repairable view states", async () => { + const states: RepoContentLinkResult["status"][] = ["ready", "missing", "mismatch", "target-missing", "conflict", "removed", "repaired", "symlinks-disabled", "invalid"]; + for (const status of states) { + expect(repoContentLinksNeedRepair([{ type: "repo-content", name: "docs", path: "repo-content/docs", status }])) + .toBe(status === "missing" || status === "mismatch"); + } + const root = await fixture(); + await initContextProject({ cwd: root, projectDir: ".", dev: true, allowNonempty: true }); + const status = JSON.parse(execFileSync("node", [resolve(import.meta.dir, "../../dist/cli.js"), "status", "--format", "json"], { cwd: root, encoding: "utf8" })); + expect(status.repo_content.repair_command).toBe("context source ensure repo-content --format json"); + await ensureRepoContentLinks(root); + await rm(join(root, "repo-content.yaml")); + await repoContentGit(root, ["config", "core.symlinks", "false"]); + expect(repoContentLinksNeedRepair(await inspectRepoContentLinks(root))).toBe(false); + }); + test("invalid registry rejects a real build without replacing previous outputs", async () => { + const root = await fixture(); + await initContextProject({ cwd: root, projectDir: ".", dev: true, allowNonempty: true }); + await writeFile(join(root, "src/package-templates/kb/wikis/index.md"), "# Project documentation\n\n{{knowledgeGroupsMarkdown}}\n"); + const cli = resolve(import.meta.dir, "../../dist/cli.js"); + execFileSync("node", [cli, "build", "--format", "json"], { cwd: root, encoding: "utf8" }); + async function snapshot() { + const base = join(root, "dist"); + const paths = (await readdir(base, { recursive: true, withFileTypes: true })).filter(item => item.isFile()); + return Promise.all(paths.map(item => join(item.parentPath, item.name)).sort().map(async path => { + return [path, (await readFile(path)).toString("base64")]; + })); + } + const previous = await snapshot(); + expect(previous.length).toBeGreaterThan(0); + await writeFile(join(root, "repo-content.yaml"), "protocol: invalid\nentries: {}\n"); + const result = spawnSync("node", [cli, "build", "--format", "json"], { cwd: root, encoding: "utf8" }); + expect(result.status).not.toBe(0); + expect(result.stdout + result.stderr).toContain("repo-content"); + expect(await snapshot()).toEqual(previous); + }); + test("removing the registry removes only obsolete symlinks", async () => { + const root = await fixture(); await ensureRepoContentLinks(root); + await writeFile(join(root, "repo-content/keep.md"), "user content"); + await rm(join(root, "repo-content.yaml")); + expect((await inspectRepoContentLinks(root))[0]?.status).toBe("mismatch"); + expect((await ensureRepoContentLinks(root))[0]?.status).toBe("removed"); + expect(await readFile(join(root, "repo-content/keep.md"), "utf8")).toBe("user content"); + expect(await readFile(join(root, "docs/guide.md"), "utf8")).toContain("Stable contract"); + }); + test("line shifts relocate unchanged regions but do not hide modified or ambiguous evidence", async () => { + const root = await fixture(); const sha = await commit(root); + const original = await readFile(join(root, "docs/guide.md"), "utf8"); + const locator = { path: "docs/guide.md", start_line: 2, end_line: 2 }; + const reference = { source_ref: `repo-content:docs@${sha}`, locator, content_digest: articleSourceRegionDigest(original, locator) }; + await writeFile(join(root, locator.path), `New introduction\n${original}`); + expect((await (await repoContentImpactReader(root))(reference)).state).toBe("moved"); + await writeFile(join(root, locator.path), "# Guide\nChanged contract.\n"); + expect((await (await repoContentImpactReader(root))(reference)).state).toBe("changed"); + await writeFile(join(root, locator.path), "# Guide\nOther\nStable contract.\nStable contract.\n"); + expect((await (await repoContentImpactReader(root))(reference)).state).toBe("changed"); + }); + test("generated entrance joins template navigation, reports invalid Skills and does not append custom indexes", async () => { + const root = await fixture({ docs: { kind: "docs", path: "docs" }, skills: { kind: "skills", path: ".agents/skills" } }); + await mkdir(join(root, ".agents/skills/broken"), { recursive: true }); + await writeFile(join(root, ".agents/skills/broken/SKILL.md"), "---\nname: broken\n---\nMissing description\n"); + await writeFile(join(root, "package.json"), JSON.stringify({ context: { language: "zh-CN" } })); + await writeFile(join(root, "docs/README.md"), "# 项目\n\n![Diagram](figure.png)\n"); + await commit(root); + const pkg = kbPackage({ name: "example", template: { path: "templates/kb" }, repoContentPage: true }); + const pages = await repoContentPages(root, pkg); + expect(pages[0]?.content).toContain("不可用的 README 或技能条目"); + expect(pages[0]?.content).toContain(".agents/skills/broken/SKILL.md"); + expect(pages[0]?.content).not.toContain("![Diagram]"); + expect(pages[0]?.content).toContain("[Diagram]( { + const root = await fixture(); + await initContextProject({ cwd: root, projectDir: ".", dev: true, allowNonempty: true }); + const entry = join(root, "src/index.ts"); + expect(await readFile(entry, "utf8")).toContain("repoContentPage: true"); + const receipt = JSON.parse(execFileSync("node", [resolve(import.meta.dir, "../../dist/cli.js"), "source", "ensure", "repo-content", "--format", "json"], + { cwd: root, encoding: "utf8" })); + expect(receipt[0].status).toBe("repaired"); + const original = "export default { sources: [], phases: [], packages: [] };\n"; + await writeFile(entry, original); + await initContextProject({ cwd: root, projectDir: ".", dev: true, allowNonempty: true }); + expect(await readFile(entry, "utf8")).toBe(original); + }); + test("registry rejects remote scope and traversal; revision selectors remain exact", () => { + for (const path of ["../docs", "/docs", "a/.git/config", "a\\b"]) { + expect(repoContentRegistrySchema.safeParse({ protocol: "context.repo-content/v1", entries: { docs: { kind: "docs", path } } }).success).toBe(false); + } + expect(repoContentScopeMatches("repo-content:docs", `repo-content:docs@${"a".repeat(40)}`)).toBe(true); + expect(repoContentScopeMatches(`repo-content:docs@${"b".repeat(40)}`, `repo-content:docs@${"a".repeat(40)}`)).toBe(false); + }); + test("inspection is read-only; repair creates relative links and preserves ordinary conflicts", async () => { + const root = await fixture(); + expect((await inspectRepoContentLinks(root))[0]?.status).toBe("missing"); + expect((await ensureRepoContentLinks(root))[0]?.status).toBe("repaired"); + expect(await readlink(join(root, "repo-content/docs"))).toBe("../docs"); + expect((await inspectRepoContentLinks(root))[0]?.status).toBe("ready"); + await rm(join(root, "repo-content/docs")); + await writeFile(join(root, "repo-content/docs"), "keep me"); + expect((await ensureRepoContentLinks(root))[0]?.status).toBe("conflict"); + expect(await readFile(join(root, "repo-content/docs"), "utf8")).toBe("keep me"); + }); + test("disabled symlinks use real paths without overwriting placeholders", async () => { + const root = await fixture(); + await repoContentGit(root, ["config", "core.symlinks", "false"]); + await mkdir(join(root, "repo-content")); await writeFile(join(root, "repo-content/docs"), "../docs"); + expect((await ensureRepoContentLinks(root))[0]?.status).toBe("symlinks-disabled"); + expect(await readFile(join(root, "repo-content/docs"), "utf8")).toBe("../docs"); + }); + test("overlap and nested repository links fail before view creation", async () => { + const root = await fixture({ docs: { kind: "docs", path: "docs" }, guide: { kind: "document", path: "docs/guide.md" } }); + await expect(readRepoContentRegistry(root)).rejects.toThrow("overlap"); + await mkdir(join(root, "nested/.git"), { recursive: true }); + await symlink("nested", join(root, "alias")); + await expect(repoContentRealPath(root, "alias/file.md")).rejects.toThrow("nested repository"); + }); + test("historical evidence survives directory moves and current registration changes", async () => { + const root = await fixture(); const sha = await commit(root); + await rename(join(root, "docs"), join(root, "handbook")); + await writeFile(join(root, "repo-content.yaml"), YAML.stringify({ protocol: "context.repo-content/v1", entries: { docs: { kind: "docs", path: "handbook" } } })); + const read = await repoContentReferenceReader(root); + expect(await read(`repo-content:docs@${sha}`, "docs/guide.md", true)).toContain("Stable contract"); + await expect(read(`repo-content:docs@${sha}`, "handbook/guide.md", true)).rejects.toThrow(); + }); + test("tracked moves outside the old directory retain historical evidence until settlement", 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 rename(join(root, "docs"), join(root, "manual")); + await writeFile(join(root, "repo-content.yaml"), YAML.stringify({ protocol: "context.repo-content/v1", entries: { docs: { kind: "docs", path: "manual" } } })); + await repoContentGit(root, ["add", "docs", "manual", "repo-content.yaml"]); + const before = await repoContentGit(root, ["diff", "--cached"]); + const result = await (await repoContentImpactReader(root))(reference); + expect(result).toMatchObject({ state: "moved", current_path: "manual/guide.md" }); + expect(result.changes[0]?.old_path).toBe("docs/guide.md"); + expect(await repoContentGit(root, ["diff", "--cached"])).toBe(before); + expect(reference.locator.path).toBe("docs/guide.md"); + }); + test("document region digest separates an unrelated edit from a changed claim", 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 writeFile(join(root, locator.path), "# Guide\nStable contract.\nChanged other detail.\n"); + expect((await (await repoContentImpactReader(root))(reference)).state).toBe("moved"); + await writeFile(join(root, locator.path), "# Guide\nChanged contract.\nOther detail.\n"); + expect((await (await repoContentImpactReader(root))(reference)).state).toBe("changed"); + }); + test("update includes repository chapters and advances evidence only after explicit no-impact", 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"); + const structure = { schema_version: "context.approved-structure.v1", articles: [{ article_id: "guide", path: "faq/guide.md", collection: "faq", visibility: "public", + sections: [{ id: "contract", references: [reference] }] }] }; + const map = join(root, "knowledge/structure.yaml"); + await writeFile(map, YAML.stringify(structure)); + await writeFile(join(root, locator.path), "# Guide\nStable contract, clarified.\nOther detail.\n"); + await beginKnowledgeUpdate(root, { scopes: [{ requirement_ref: "docs", source_ref: "repo-content:docs" }] }); + const update = (await readKnowledgeUpdate(root))!; + 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 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")); + 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("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 }); + const path = ".agents/skills/check/SKILL.md"; + const text = "---\nname: check\ndescription: Check things\n---\nRun scripts/check.sh\n"; + await writeFile(join(root, path), text); + await writeFile(join(root, ".agents/skills/check/scripts/check.sh"), "exit 0\n"); + const sha = await commit(root); + await writeFile(join(root, ".agents/skills/check/scripts/check.sh"), "exit 1\n"); + await writeFile(join(root, ".agents/skills/check/scripts/new.sh"), "exit 0\n"); + const locator = { path, start_line: 1, end_line: 5 }; + const result = await (await repoContentImpactReader(root))({ source_ref: `repo-content:skills@${sha}`, locator, + content_digest: articleSourceRegionDigest(text, locator) }); + expect(result.state).toBe("changed"); + expect(result.changes.map(item => item.status)).toContain("untracked"); + expect(result.changes.some(item => item.path.endsWith("check.sh"))).toBe(true); + }); + test("optional pages project only README and Skill summaries at HEAD, not every document", async () => { + const root = await fixture(); + await writeFile(join(root, "docs/README.md"), "# Project\n\nOverview\n\n[Guide](guide.md)\n"); + const sha = await commit(root); + await writeFile(join(root, "docs/README.md"), "# Uncommitted replacement\n"); + expect(await repoContentPages(root, kbPackage({ name: "example", template: { path: "templates/kb" } }))).toEqual([]); + const pages = await repoContentPages(root, kbPackage({ name: "example", template: { path: "templates/kb" }, repoContentPage: true })); + expect(pages).toHaveLength(1); + expect(pages[0]?.content).toContain(`/blob/${sha}/docs/guide.md`); + expect(pages[0]?.content).not.toContain("Uncommitted replacement"); + expect(pages[0]?.content).not.toContain("Stable contract"); + expect((await repoContentLinkProjector(root))("See [Guide](context:repo/docs/guide.md)")).toContain("/blob/main/docs/guide.md"); + }); +}); diff --git a/packages/context-cli/src/__tests__/repoContentSite.integration.test.ts b/packages/context-cli/src/__tests__/repoContentSite.integration.test.ts new file mode 100644 index 00000000..aa93559c --- /dev/null +++ b/packages/context-cli/src/__tests__/repoContentSite.integration.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { kbPackage } from "@c4a/context"; +import { repoContentPages, repoContentNavigation, writeRepoContentPages } from "../project/repoContentPages.js"; +import { writeRenderedPackageTemplate } from "../project/packageBuildContent.js"; +import { writePackageSite } from "../project/packageSite.js"; + +test("repository entrance stays package-only until website exposure is explicitly selected", async () => { + const parent = resolve(import.meta.dir, "../../../../.tmp/v0750/site-tests"); + await mkdir(parent, { recursive: true }); + const root = await mkdtemp(join(parent, "repo-")); + try { + await mkdir(join(root, "docs")); + await writeFile(join(root, "docs/README.md"), "# Repository overview\n\nOriginal repository introduction.\n"); + await writeFile(join(root, "repo-content.yaml"), "protocol: context.repo-content/v1\nentries:\n docs:\n kind: docs\n path: docs\n"); + // Isolate from the enclosing developer repository without changing its Git state. + const { repoContentGit } = await import("../project/repoContentGit.js"); + await repoContentGit(root, ["init", "-b", "main"]); + for (const exposed of [false, true]) { + const pkg = kbPackage({ name: exposed ? "public" : "internal", site: {}, + template: { path: "template" }, repoContentPage: exposed ? { site: true } : true }); + await mkdir(join(root, pkg.outDir, "wikis"), { recursive: true }); + const pages = await repoContentPages(root, pkg); + await writeRenderedPackageTemplate({ projectRoot: root, pkg, + files: [{ relPath: "wikis/index.md", absPath: "", content: "# Reference\n\n{{knowledgeGroupsMarkdown}}\n" }], + bundle: "", knowledgeTimestamp: "", selected: [], navigationFiles: repoContentNavigation(pages), buildInventory: {}, knowledgeStructure: null }); + expect(await readFile(join(root, pkg.outDir, "wikis/index.md"), "utf8")).toContain("repo-content.md"); + await writeRepoContentPages(root, pkg, pages); + expect(await readFile(join(root, pkg.outDir, "wikis/repo-content.md"), "utf8")).toContain("Original repository introduction"); + await writePackageSite({ projectRoot: root, pkg, selected: [] }); + const map = JSON.parse(await readFile(join(root, pkg.outDir, "context-site-map.json"), "utf8")); + expect(map.pages.some((page: { package_path: string }) => page.package_path === "wikis/repo-content.md")).toBe(exposed); + } + } finally { await rm(root, { recursive: true, force: true }); } +}, 120000); diff --git a/packages/context-cli/src/project/approvedKnowledgeDependencyWarnings.ts b/packages/context-cli/src/project/approvedKnowledgeDependencyWarnings.ts index dcc8aaad..d665cff3 100644 --- a/packages/context-cli/src/project/approvedKnowledgeDependencyWarnings.ts +++ b/packages/context-cli/src/project/approvedKnowledgeDependencyWarnings.ts @@ -4,6 +4,7 @@ import { readArticleRegionBaseline } from "./articleRegionBaselines.js"; import { readKnowledgeStructure } from "./packageBuildInventory.js"; import { approvedKnowledgeSnapshotsFromStructure } from "./approvedKnowledgeSnapshots.js"; import type { ProjectVerifyIssue } from "./verifyTypes.js"; +import { repoContentImpactReader } from "./repoContentEvidence.js"; /** Read only cited captured files, once per verification. No parser invocation, * article dependency graph, or mutation of approved references. Changed regions @@ -17,11 +18,23 @@ export async function approvedKnowledgeDependencyWarnings( if (!articles.some(article => article.sections.some(section => section.references.length))) return []; const read = await registeredArticleSourceReader(projectRoot); const issues: ProjectVerifyIssue[] = []; + let repoImpact: ReturnType | undefined; for (const article of articles) { const changed: string[] = []; const moved: string[] = []; for (const section of article.sections) { for (const reference of section.references) { + if (reference.source_ref.startsWith("repo-content:")) { + repoImpact ??= repoContentImpactReader(projectRoot); + const impact = await (await repoImpact)(reference); + if (impact.state === "unchanged") continue; + if (impact.state === "moved") { + moved.push(`${section.id}: ${reference.source_ref}/${impact.current_path ?? reference.locator.path}`); + continue; + } + changed.push(section.id); + break; + } try { const text = await read(reference.source_ref, reference.locator.path); let current: string | undefined; @@ -47,7 +60,7 @@ export async function approvedKnowledgeDependencyWarnings( }); if (moved.length) issues.push({ severity: "warning", code: "approved-source-region-moved", path: article.path, - message: `Unchanged source regions have new positions: ${moved.join("; ")}. Refresh these locators during the source update; do not rewrite unaffected prose.`, + message: `Cited regions are unchanged but their location or surrounding source changed: ${moved.join("; ")}. Check locators during the source update; do not rewrite unaffected prose.`, }); } return issues; diff --git a/packages/context-cli/src/project/approvedRevision.ts b/packages/context-cli/src/project/approvedRevision.ts index 3c003204..b105af2a 100644 --- a/packages/context-cli/src/project/approvedRevision.ts +++ b/packages/context-cli/src/project/approvedRevision.ts @@ -6,7 +6,7 @@ import { readFile, realpath, rm } from "node:fs/promises"; import { join, relative, isAbsolute } from "node:path"; import { z } from "zod"; import YAML from "yaml"; -import { articleSectionSchema, validateArticleStructureEntries, indexerProtocolDigest, indexerKnowledgeCollectionSchema, processedScopesSchema, +import { articleSectionSchema, validateArticleStructureEntries, indexerProtocolDigest, indexerKnowledgeCollectionSchema, processedScopesSchema, repoContentScopeMatches, type ProcessedScope } from "@c4a/context"; import { newKnowledgePageTarget, type NewKnowledgePage } from "./newKnowledgePage.js"; import { atomicWriteFile } from "../lib/atomicWrite.js"; @@ -56,12 +56,12 @@ export function requestDigest(input: Pick input.target.source_refs.some((ref) => - ref === scope.source_ref || ref.startsWith(`${scope.source_ref}#`) || ref.startsWith(`${scope.source_ref}/`))); + repoContentScopeMatches(scope.source_ref, ref) || ref.startsWith(`${scope.source_ref}#`) || ref.startsWith(`${scope.source_ref}/`))); const ids = new Set(scopes?.map((scope) => scope.requirement_ref)); return indexerProtocolDigest({ target: input.target, instruction: input.instruction, ...(input.merge_context ? { merge_context: input.merge_context } : {}), ...(input.regenerate ? { regenerate: true, program_blocks: input.program_blocks ?? [] } : {}), - ...(input.requirements === undefined ? {} : { requirements: input.requirements.filter((item) => ids.has(item.id) || item.target_scope.targets.some((source) => input.target.source_refs.some((ref) => ref === source.source_ref || ref.startsWith(`${source.source_ref}#`) || ref.startsWith(`${source.source_ref}/`)))) }), + ...(input.requirements === undefined ? {} : { requirements: input.requirements.filter((item) => ids.has(item.id) || item.target_scope.targets.some((source) => input.target.source_refs.some((ref) => repoContentScopeMatches(source.source_ref, ref) || ref.startsWith(`${source.source_ref}#`) || ref.startsWith(`${source.source_ref}/`)))) }), ...(scopes === undefined ? {} : { processed_scopes: scopes }), }); } @@ -273,7 +273,7 @@ export async function prepareApprovedRevision(input: { const { prepareRevisionProgramBlocks } = await import("./approvedRevisionPrograms.js"); const registry = await readProductionRequirements(input.projectRoot); const requirements = input.requirements ?? registry.requirements.filter((requirement) => requirement.target_scope.targets.some((source) => - target.source_refs.some((ref) => ref === source.source_ref || ref.startsWith(`${source.source_ref}#`) || ref.startsWith(`${source.source_ref}/`)))); + target.source_refs.some((ref) => repoContentScopeMatches(source.source_ref, ref) || ref.startsWith(`${source.source_ref}#`) || ref.startsWith(`${source.source_ref}/`)))); const { currentScopeSourceVersion } = await import("./processedScopeStorage.js"); const regenerationScopes = input.regenerate ? await Promise.all(requirements.flatMap(requirement => requirement.target_scope.targets.filter(source => source.source_ref.startsWith("repo:") && target.source_refs.some(ref => diff --git a/packages/context-cli/src/project/approvedRevisionContext.ts b/packages/context-cli/src/project/approvedRevisionContext.ts index 8e117c39..fd805bad 100644 --- a/packages/context-cli/src/project/approvedRevisionContext.ts +++ b/packages/context-cli/src/project/approvedRevisionContext.ts @@ -1,7 +1,7 @@ import { readProductionRequirements } from "./productionRequirements.js"; import { readProductionStage } from "./productionStageStore.js"; import { readFile } from "node:fs/promises"; -import { assertManagedDocumentPath, readSessionChanges, indexerProtocolDigest, type ArticleStructureEntry } from "@c4a/context"; +import { assertManagedDocumentPath, readSessionChanges, indexerProtocolDigest, repoContentScopeMatches, type ArticleStructureEntry } from "@c4a/context"; import { approvedContextSectionsInMarkdown } from "./verifyContextSections.js"; /** Resolve current selected writing resources even for an explicit page revise @@ -9,7 +9,7 @@ import { approvedContextSectionsInMarkdown } from "./verifyContextSections.js"; export async function approvedRevisionContext(root: string, target: { source_refs: string[]; markdown: string; sections: ArticleStructureEntry["sections"] }) { const registry = await readProductionRequirements(root); const requirements = registry.requirements.filter((requirement) => requirement.target_scope.targets.some((source) => - target.source_refs.some((ref) => ref === source.source_ref || ref.startsWith(`${source.source_ref}#`) || ref.startsWith(`${source.source_ref}/`)))); + target.source_refs.some((ref) => repoContentScopeMatches(source.source_ref, ref) || ref.startsWith(`${source.source_ref}#`) || ref.startsWith(`${source.source_ref}/`)))); // Planning guidance is useful within this run, not permanent production provenance. // A new run does not recover skills from the formal article. const stage = await readProductionStage(root); diff --git a/packages/context-cli/src/project/approvedRevisionReferences.ts b/packages/context-cli/src/project/approvedRevisionReferences.ts index 8708e05f..752f7c9b 100644 --- a/packages/context-cli/src/project/approvedRevisionReferences.ts +++ b/packages/context-cli/src/project/approvedRevisionReferences.ts @@ -1,5 +1,5 @@ import { articleFragmentReferences, articleSourceRegionDigest, createArticleSourceReference, - locateArticleRegion, type ArticleStructureEntry } from "@c4a/context"; + locateArticleRegion, repoContentScopeMatches, type ArticleStructureEntry } from "@c4a/context"; import { readArticleRegionBaseline, rememberArticleRegion } from "./articleRegionBaselines.js"; import { registeredArticleSourceReader } from "./articleSourceReader.js"; import type { RevisionContentInput } from "./approvedRevisionEdits.js"; @@ -19,7 +19,7 @@ export async function prepareRevisionReferences(input: { } let read: Awaited> | undefined; const readSource = async (source: string, path: string) => { - if (!input.sourceRefs.includes(source)) throw new TypeError(`Source is outside this revision: ${source}`); + if (!input.sourceRefs.some(scope => repoContentScopeMatches(scope, source))) throw new TypeError(`Source is outside this revision: ${source}`); read ??= await registeredArticleSourceReader(input.projectRoot); return read(source, path, true); }; diff --git a/packages/context-cli/src/project/articleSourceReader.ts b/packages/context-cli/src/project/articleSourceReader.ts index ccebeafb..ebf4f3ea 100644 --- a/packages/context-cli/src/project/articleSourceReader.ts +++ b/packages/context-cli/src/project/articleSourceReader.ts @@ -5,6 +5,7 @@ import { readFile, realpath, stat } from "node:fs/promises"; import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; import { loadSourcesRegistry, type SourcesRegistry } from "@c4a/context"; import { parseDocumentSnapshotForSource } from "./documentBatchManifest.js"; +import { repoContentReferenceReader } from "./repoContentEvidence.js"; const execute = promisify(execFile); @@ -19,6 +20,7 @@ function inside(root: string, path: string): void { * source; a document manifest identifies its files even in a shared date folder. * This never prepares parsers or loads uncited document bodies. */ export async function registeredArticleSourceReader(projectRoot: string) { + let repoContentRead: ReturnType | undefined; const workspace = await realpath(projectRoot); const registry = await loadSourcesRegistry({ rootDir: projectRoot }); const sources = new Map [file.path, file.content_hash])) }; } return async (sourceRef: string, path: string, requireCapturedVersion = false): Promise => { + if (sourceRef.startsWith("repo-content:")) { + repoContentRead ??= repoContentReferenceReader(projectRoot); + return (await repoContentRead)(sourceRef, path, requireCapturedVersion); + } const key = JSON.stringify([sourceRef, path]); let pending = texts.get(key); if (!pending) { diff --git a/packages/context-cli/src/project/indexerBaseContracts.ts b/packages/context-cli/src/project/indexerBaseContracts.ts index e694a2ef..fa32764f 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.43"; +export const BUNDLED_INDEXER_PARSER_PACKAGE_VERSION = "0.7.50"; const BUNDLED_PARSER_REQUIREMENTS = buildIndexerParserCapabilityRequirements( BUNDLED_INDEXER_PARSER_PACKAGE_VERSION, ); diff --git a/packages/context-cli/src/project/indexerDistributionBuild.ts b/packages/context-cli/src/project/indexerDistributionBuild.ts index 1db23fb2..93c76393 100644 --- a/packages/context-cli/src/project/indexerDistributionBuild.ts +++ b/packages/context-cli/src/project/indexerDistributionBuild.ts @@ -138,7 +138,7 @@ export async function materializeBundledIndexerDistribution(input: { const sourceEntries = (await readdir(sourceRoot, { withFileTypes: true })) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name); - const nonProviderEntries = ["context", "context-inspect-search", "context-indexer-create", "context-plan"]; + const nonProviderEntries = ["context", "context-inspect-search", "context-indexer-create", "context-plan", "context-repo-content"]; const communityEntries = [...nonProviderEntries, ...EXPECTED_BUNDLES.map((bundle) => bundle.id)]; const missingCommunityEntries = communityEntries.filter((entry) => !sourceEntries.includes(entry)); if (missingCommunityEntries.length > 0) { diff --git a/packages/context-cli/src/project/knowledgeUpdate.ts b/packages/context-cli/src/project/knowledgeUpdate.ts index 664eb51c..2ab4c056 100644 --- a/packages/context-cli/src/project/knowledgeUpdate.ts +++ b/packages/context-cli/src/project/knowledgeUpdate.ts @@ -14,6 +14,9 @@ import { captureProcessedScopes, currentScopeSourceVersion, commitProcessedScope import { readProductionStage } from "./productionStageStore.js"; import { readCandidateRecords } from "./candidateLedger.js"; import { withProjectWriteLock } from "./writeLock.js"; +import { repoContentScopeMatches } from "@c4a/context"; +import { ensureRepoContentLinks } from "./repoContentLinks.js"; +import { inspectRepoContentChanges, repoContentImpactReader } from "./repoContentEvidence.js"; export const knowledgeUpdateInputSchema = z.object({ scopes: z.array(z.object({ requirement_ref: z.string().min(1), source_ref: z.string().min(1), @@ -29,6 +32,7 @@ const updateSchema = z.object({ refresh_sources: z.array(z.string().min(1)).min(1).optional(), structure_proposal: indexerCurrentActionInputDefinitions.sourceUpdate.omit({ stage: true }).optional(), previous_versions: z.array(z.string().nullable()), changes: z.string().optional(), + repo_content: z.unknown().optional(), }).strict(); export type KnowledgeUpdate = z.infer; @@ -46,6 +50,7 @@ export async function readKnowledgeUpdate(projectRoot: string): Promise { const input = knowledgeUpdateInputSchema.parse(value); + await ensureRepoContentLinks(projectRoot); const { readTaskRollback } = await import("./taskRollback.js"); if (await readTaskRollback(projectRoot) || await readApprovedRevision(projectRoot) || await readKnowledgeUpdate(projectRoot) || await readProductionStage(projectRoot) || (await readCandidateRecords(projectRoot)).length > 0) { throw new TypeError("Finish or explicitly roll back the active task before starting an independent source update."); @@ -59,7 +64,7 @@ export async function beginKnowledgeUpdate(projectRoot: string, value: unknown) if (!structure.parsed) throw new TypeError("Close the existing knowledge before checking source updates"); const candidates = await Promise.all(validateArticleStructureEntries(structure.parsed.articles ?? []) .filter(article => article.sections.some(section => section.references.some(reference => - scopes.some(scope => scope.source_ref === reference.source_ref)))) + scopes.some(scope => repoContentScopeMatches(scope.source_ref, reference.source_ref))))) .map(async article => { const title = parseFrontmatterLoose(await targetBytes(projectRoot, article.path)).title; return { path: article.path, title: typeof title === "string" ? title : article.path, @@ -67,9 +72,19 @@ export async function beginKnowledgeUpdate(projectRoot: string, value: unknown) source_refs: [...new Set(article.sections.flatMap(section => section.references.map(reference => reference.source_ref)))] }; })); const registry = await readProductionRequirements(projectRoot); + const repoReferences = validateArticleStructureEntries(structure.parsed.articles ?? []).flatMap(article => + article.sections.flatMap(section => section.references.filter(ref => ref.source_ref.startsWith("repo-content:") && + scopes.some(scope => repoContentScopeMatches(scope.source_ref, ref.source_ref))) + .map(reference => ({ article: article.path, section: section.id, reference })))); + const impact = repoReferences.length ? await repoContentImpactReader(projectRoot) : undefined; + const repoContent = scopes.some(scope => scope.source_ref.startsWith("repo-content:")) ? { + entries: await inspectRepoContentChanges(projectRoot), + impacts: await Promise.all(repoReferences.map(async item => ({ ...item, impact: await impact!(item.reference) }))), + } : undefined; const requirementIds = new Set(scopes.map((scope) => scope.requirement_ref)); const requirements = registry.requirements.filter((requirement) => requirementIds.has(requirement.id)); const payload = { protocol: "context.source-update/v1" as const, scopes, requirements, candidates, + ...(repoContent === undefined ? {} : { repo_content: repoContent }), previous_versions: scopes.map((scope) => processedVersionForScope(readProcessedScopes(structure.parsed), scope) ?? null), ...(input.changes === undefined ? {} : { changes: input.changes }) }; const request = updateSchema.parse({ ...payload, revision: indexerProtocolDigest(payload) }); @@ -92,13 +107,13 @@ 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) => ref === scope.source_ref || ref.startsWith(`${scope.source_ref}/`)))) { + 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"); } } for (const decision of input.decisions) { if (decision.supporting_sources && (!decision.instruction || decision.supporting_sources.some((ref) => - !request.scopes.some((scope) => ref === scope.source_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"); } } diff --git a/packages/context-cli/src/project/packageBuildContent.ts b/packages/context-cli/src/project/packageBuildContent.ts index f1732d07..4720fd5b 100644 --- a/packages/context-cli/src/project/packageBuildContent.ts +++ b/packages/context-cli/src/project/packageBuildContent.ts @@ -1,4 +1,6 @@ import { articleProvenanceMarkdown } from "./packageSiteSources.js"; +import { repoContentLinkProjector } from "./repoContentPages.js"; +import { optionalRepoGit } from "./repoContentGit.js"; import { loadSourcesRegistry } from "@c4a/context"; import { projectPackageArticleLinks, type PackageArticleLinkWarning } from "./packageArticleLinks.js"; import { buildLlmsDocuments, llmsArticles } from "./packageLlms.js"; @@ -107,13 +109,14 @@ export function packageTemplateVars(input: { knowledgeCount: number; knowledgeTimestamp: string; selected: readonly ApprovedKnowledgeFile[]; + navigationFiles?: readonly ApprovedKnowledgeFile[]; buildInventory?: Record; knowledgeStructure?: Record | null; templateRelPath?: string; logicalTemplateRelPath?: string; }): Record { const inventory = knowledgeInventory( - input.selected, + [...input.selected, ...input.navigationFiles ?? []], input.templateRelPath ?? `${packageOkfRootPath(input.pkg, "wikis")}/index.md`, packageNavigation(input.pkg), input.pkg, @@ -184,6 +187,7 @@ export async function writeRenderedPackageTemplate(input: { bundle: string; knowledgeTimestamp: string; selected: readonly ApprovedKnowledgeFile[]; + navigationFiles?: readonly ApprovedKnowledgeFile[]; buildInventory: Record; knowledgeStructure: Record | null; }): Promise<{ files: number; consumesKnowledge: boolean }> { @@ -261,6 +265,8 @@ export async function writeSelectedPackageKnowledge(input: { const approvedByOutput = new Map([...outputByApproved].map(([approved, output]) => [output, approved])); const byPath = new Map(input.files.map(file => [file.relPath, file])); const registry = await loadSourcesRegistry({ rootDir: input.projectRoot }); + const projectRepoLinks = await repoContentLinkProjector(input.projectRoot); + const repositoryRemote = await optionalRepoGit(input.projectRoot, ["remote", "get-url", "origin"]); for (let offset = 0; offset < projectedPages.length; offset += 8) { const results = await Promise.allSettled(projectedPages.slice(offset, offset + 8).map(async projected => { assertSafeRenderedPath(projected.pageOutputPath, "knowledge path"); @@ -283,11 +289,11 @@ export async function writeSelectedPackageKnowledge(input: { return undefined; }); await mkdir(dirname(outputPath), { recursive: true }); - const links = projectPackageArticleLinks({ markdown: rewritten, approvedPath: approvedByOutput.get(projected.pageOutputPath)!, outputPath: projected.pageOutputPath, selected: outputByApproved }); + const links = projectPackageArticleLinks({ markdown: projectRepoLinks(rewritten), approvedPath: approvedByOutput.get(projected.pageOutputPath)!, outputPath: projected.pageOutputPath, selected: outputByApproved }); const markdown = await cachedPackageKnowledgeMarkdown({ projectRoot: input.projectRoot, key: `${input.pkg.name}/page/${projected.pageOutputPath}`, content: links.markdown }); const file = byPath.get(approvedByOutput.get(projected.pageOutputPath)!); - await writeFile(outputPath, markdown + articleProvenanceMarkdown(file?.article, registry), "utf8"); + await writeFile(outputPath, markdown + articleProvenanceMarkdown(file?.article, registry, repositoryRemote), "utf8"); return links.warnings; })); // Finish all temporary writes before propagating a failure. The caller may diff --git a/packages/context-cli/src/project/packageBuilder.ts b/packages/context-cli/src/project/packageBuilder.ts index 63f67626..f7433c8a 100644 --- a/packages/context-cli/src/project/packageBuilder.ts +++ b/packages/context-cli/src/project/packageBuilder.ts @@ -1,4 +1,5 @@ import { resolveSiteTheme } from "./siteTheme.js"; +import { repoContentPages, repoContentFingerprint, repoContentNavigation, writeRepoContentPages } from "./repoContentPages.js"; import { readPackageSiteUrl } from "./packageSiteAddress.js"; import { readKnowledgeMap } from "./knowledgeMap.js"; import { readApprovedMarkdownFiles } from "./approvedFileRead.js"; @@ -245,6 +246,7 @@ async function packageInputFingerprint(input: { const siteRegistry = await loadSourcesRegistry({ rootDir: input.projectRoot }); const siteTheme = input.pkg.kind === "package.kb" && input.pkg.site ? await resolveSiteTheme(input.projectRoot, input.pkg.site.theme) : null; return stableHash({ + repoContent: await repoContentFingerprint(input.projectRoot, input.pkg), siteExtensions: input.pkg.kind === "package.kb" && input.pkg.site ? (await readSiteExtensions(input.projectRoot, input.pkg.site.extensions)).digest : null, siteTheme, @@ -555,12 +557,15 @@ async function buildProjectPackagesInternal(projectRoot: string, options: { deli knowledgeGroups, previousManifest?.outputs ?? [], ); + const repositoryPages = await repoContentPages(projectRoot, pkg); + const navigationFiles = repoContentNavigation(repositoryPages); const vars = packageTemplateVars({ pkg, bundle, knowledgeCount: selected.length, knowledgeTimestamp, selected, + navigationFiles, buildInventory, knowledgeStructure: structure.parsed, }); @@ -576,6 +581,7 @@ async function buildProjectPackagesInternal(projectRoot: string, options: { deli ...(assetProcessor === undefined ? {} : { assetProcessor }), }); const siteUrl = await readPackageSiteUrl(projectRoot, pkg); + if (repositoryPages.length) buildInventory.generated_pages = repositoryPages.map(({ path, source, revision }) => ({ path, source, revision, kind: "repo-content" })); const writtenKnowledge = await withStagedPackageOutput(projectRoot, pkg, async (stagedPkg) => { const rendered = await writeRenderedPackageTemplate({ projectRoot, @@ -584,6 +590,7 @@ async function buildProjectPackagesInternal(projectRoot: string, options: { deli bundle, knowledgeTimestamp, selected, + navigationFiles, buildInventory, knowledgeStructure: structure.parsed, }); @@ -594,10 +601,11 @@ async function buildProjectPackagesInternal(projectRoot: string, options: { deli ...(assetProcessor === undefined ? {} : { assetProcessor }), prepared: preparedKnowledge, }); + await writeRepoContentPages(projectRoot, stagedPkg, repositoryPages); await writeKnowledgeDirectoryIndexes({ projectRoot, pkg: stagedPkg, - selected, + selected: [...selected, ...navigationFiles], knowledgeTimestamp, }); await writePackageKnowledgeMap({ projectRoot, pkg: stagedPkg, selected, structure: await readKnowledgeMap(projectRoot) }); diff --git a/packages/context-cli/src/project/packageSite.ts b/packages/context-cli/src/project/packageSite.ts index a6b625c8..03ad124e 100644 --- a/packages/context-cli/src/project/packageSite.ts +++ b/packages/context-cli/src/project/packageSite.ts @@ -1,4 +1,6 @@ import { resolveSiteTheme, siteThemeVariables } from "./siteTheme.js"; +import { optionalRepoGit } from "./repoContentGit.js"; +import { repoContentLabels } from "./repoContentPages.js"; import { createHash } from "node:crypto"; import { packageSiteOutputDir } from "./packageOutputPaths.js"; import { readWorkspaceChangelog } from "./workspaceChangelog.js"; @@ -176,12 +178,14 @@ export async function writePackageSite(input: { const temporary = await mkdtemp(join(temporaryRoot, "website-")); try { const registry = await loadSourcesRegistry({ rootDir: projectRoot }); + const repositoryRemote = await optionalRepoGit(projectRoot, ["remote", "get-url", "origin"]); const sourceContent = new Map(selected.map(file => [packageKnowledgeOutputPath(pkg, file.relPath), file])); const mapping = createSiteNavigation(pkg, selected, structure); const delivered = await walkPackageFiles(root); const byPath = new Map(mapping.pages.map(page => [page.package_path, page])); // Preserve linked package reference pages without exposing packaging directories as navigation. for (const file of delivered) { + if (/^wikis\/repo-content(?:\/|\.md$)/u.test(file.relPath) && typeof pkg.repoContentPage !== "object") continue; if (!/^(?:skills|wikis|guides|rules|feats)\/.*\.md$/u.test(file.relPath) || byPath.has(file.relPath)) continue; const content = await readFile(file.absPath, "utf8"); const meta = parseKnowledgeFrontmatter(content); @@ -217,19 +221,25 @@ export async function writePackageSite(input: { contextSections: sections.map(({ key, title, href, pages, items }) => ({ key, title, href, pages, items })), contextHome: { resources: options.home?.resources ?? [], branding: packageSiteBranding }, sidebar: sections[0]?.items ?? [], - nav: [...sections.map(section => ({ text: section.title, link: section.href })), { text: "更多", items: [{ text: "LLM Docs", link: "/llms/index.html" }, { text: "Changelog", link: "/changelog.html" }] }], + nav: [...sections.map(section => ({ text: section.title, link: section.href })), + ...(typeof pkg.repoContentPage === "object" && byPath.has("wikis/repo-content.md") + ? [{ text: (await repoContentLabels(projectRoot)).title, link: `/${byPath.get("wikis/repo-content.md")!.site_path}` }] : []), + { text: "更多", items: [{ text: "LLM Docs", link: "/llms/index.html" }, { text: "Changelog", link: "/changelog.html" }] }], } }; await writeFile(join(configRoot, "config.mjs"), `export default { ...${JSON.stringify(config)}, markdown: { ${siteMarkdownConfig} } };\n`); await mkdir(join(temporary, "pages"), { recursive: true }); for (const page of byPath.values()) { - const content = await readFile(join(root, page.package_path), "utf8"); + let content = await readFile(join(root, page.package_path), "utf8"); + if (page.package_path === "wikis/index.md" && typeof pkg.repoContentPage !== "object") { + content = content.replace(/^.*\]\((?:<)?(?:\.\/)?repo-content\.md(?:>)?\).*\n?/gmu, ""); + } // Only the presentation title is passed as frontmatter; source frontmatter // cannot supply scripts, layouts, imports or head tags to the compiler. const original = sourceContent.get(page.package_path); - const provenance = articleProvenanceMarkdown(original?.article, registry); + const provenance = articleProvenanceMarkdown(original?.article, registry, repositoryRemote); const pageContent = provenance && content.endsWith(provenance) ? content.slice(0, -provenance.length) : content; const body = siteMarkdown(pageContent, page.package_path, byPath, resources); - const sources = siteArticleSources(original?.article, registry); + const sources = siteArticleSources(original?.article, registry, repositoryRemote); const timestamp = parseKnowledgeFrontmatter(original?.content ?? content).timestamp; const updated = typeof timestamp === "string" && Number.isFinite(Date.parse(timestamp)) ? new Date(timestamp).toISOString() : null; diff --git a/packages/context-cli/src/project/packageSiteSources.ts b/packages/context-cli/src/project/packageSiteSources.ts index f391555a..93b0144d 100644 --- a/packages/context-cli/src/project/packageSiteSources.ts +++ b/packages/context-cli/src/project/packageSiteSources.ts @@ -1,9 +1,10 @@ -import type { ArticleStructureEntry, SourcesRegistry } from "@c4a/context"; +import { parseRepoContentRef, type ArticleStructureEntry, type SourcesRegistry } from "@c4a/context"; +import { repoContentWebUrl } from "./repoContentPages.js"; export interface SiteSource { label: string; href?: string } /** Generated only for exported Markdown; the workspace keeps a single reference record. */ -export function articleProvenanceMarkdown(article: ArticleStructureEntry | undefined, registry: SourcesRegistry): string { - const sources = siteArticleSources(article, registry); +export function articleProvenanceMarkdown(article: ArticleStructureEntry | undefined, registry: SourcesRegistry, repositoryRemote?: string): string { + const sources = siteArticleSources(article, registry, repositoryRemote); if (!sources.length) return ""; const label = (value: string) => value.replace(/[\\[\]<>`*]/gu, "\\$&").replace(/[\r\n]/gu, " "); return ["", "---", "", "## Sources", "", ...sources.map(source => source.href @@ -24,12 +25,19 @@ function repoUrl(remote: string): string | undefined { const encodedPath = (value: string) => value.split("/").map(encodeURIComponent).join("/"); /** Project recorded provenance only; do not infer source files from prose or titles. */ -export function siteArticleSources(article: ArticleStructureEntry | undefined, registry: SourcesRegistry): SiteSource[] { +export function siteArticleSources(article: ArticleStructureEntry | undefined, registry: SourcesRegistry, repositoryRemote?: string): SiteSource[] { const sources: SiteSource[] = []; for (const reference of article?.sections.flatMap(section => section.references) ?? []) { const ref = reference.source_ref; const locator = reference.locator; const region = `L${locator.start_line}–L${locator.end_line}`; + const local = parseRepoContentRef(ref); + if (local) { + const url = !local.worktree ? repoContentWebUrl(repositoryRemote, local.commit, locator.path) : undefined; + sources.push({ label: `${local.id} · ${locator.path} ${region} · ${local.commit?.slice(0, 7) ?? "unknown revision"}${local.worktree ? " + worktree" : ""}`, + ...(url ? { href: `${url}#L${locator.start_line}-L${locator.end_line}` } : {}) }); + continue; + } const repo = registry.repos.find(entry => ref === `repo:${entry.id}` || ref === `repo:${entry.name}`); if (repo) { const base = repoUrl(repo.remote); diff --git a/packages/context-cli/src/project/pluginInstallLocal.ts b/packages/context-cli/src/project/pluginInstallLocal.ts index e87e99c8..0bb6bcf1 100644 --- a/packages/context-cli/src/project/pluginInstallLocal.ts +++ b/packages/context-cli/src/project/pluginInstallLocal.ts @@ -66,7 +66,7 @@ export async function installLocalSkills(root: string, path: string, dryRun: boo for (const entry of await readdir(join(root, agent, "commands"), { withFileTypes: true })) { if (!entry.isFile() || !entry.name.endsWith(".md")) continue; let content = await readFile(join(root, agent, "commands", entry.name), "utf8"); - if (agent === "claude" && entry.name !== "context-plan.md") { + if (agent === "claude" && !["context-plan.md", "context-repo-content.md"].includes(entry.name)) { const frontmatter = /^(---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/.exec(content); if (!frontmatter) conflict(join(root, agent, "commands", entry.name)); content = `${frontmatter[1]}disable-model-invocation: true\n${frontmatter[2]!.replace(/^disable-model-invocation:.*\r?\n?/mu, "")}${frontmatter[3]}${content.slice(frontmatter[0].length)}`; @@ -92,7 +92,7 @@ export async function installLocalSkills(root: string, path: string, dryRun: boo if (!entry.isDirectory() || !await stat(join(sourceRoot, entry.name, "SKILL.md"))) continue; const source = join(sourceRoot, entry.name); const target = join(skillsRoot, entry.name); - if (commandEntries.has(entry.name) && entry.name !== "context-plan") { + if (commandEntries.has(entry.name) && !["context-plan", "context-repo-content"].includes(entry.name)) { // Never leave a duplicate Skill entry alongside its slash command. if (await stat(target)) conflict(`${target} (use a fresh host directory to avoid duplicate command/skill entries)`); continue; @@ -121,11 +121,11 @@ export async function installLocalSkills(root: string, path: string, dryRun: boo let moved = false; try { await cp(plan.source, candidate, { recursive: true }); - if (agent === "claude" || (agent === "cursor" && plan.name === "context-plan")) { + if (agent === "claude" || (agent === "cursor" && ["context-plan", "context-repo-content"].includes(plan.name))) { const skillFile = join(candidate, "SKILL.md"); const content = await readFile(skillFile, "utf8"); const parts = /^(---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/.exec(content); - if (parts && (plan.name === "context-plan" || /^\s*context-public-entry:\s*["']?false["']?\s*$/mu.test(parts[2]!))) { + if (parts && (["context-plan", "context-repo-content"].includes(plan.name) || /^\s*context-public-entry:\s*["']?false["']?\s*$/mu.test(parts[2]!))) { const frontmatter = parts[2]!.replace(/^user-invocable:.*\r?\n?/mu, ""); await writeFile(skillFile, `${parts[1]}user-invocable: false\n${frontmatter}${parts[3]}${content.slice(parts[0].length)}`); } diff --git a/packages/context-cli/src/project/processedScopeStorage.ts b/packages/context-cli/src/project/processedScopeStorage.ts index 232fdf3a..158554a9 100644 --- a/packages/context-cli/src/project/processedScopeStorage.ts +++ b/packages/context-cli/src/project/processedScopeStorage.ts @@ -3,13 +3,15 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import YAML from "yaml"; import { loadSourcesRegistry, mergeProcessedScopes, processedScopesSchema, - readProcessedScopes, indexerProtocolDigest, type ProcessedScope } from "@c4a/context"; + readProcessedScopes, indexerProtocolDigest, validateArticleStructureEntries, type ProcessedScope } from "@c4a/context"; import { atomicWriteFile } from "../lib/atomicWrite.js"; import { readKnowledgeStructure } from "./packageBuildInventory.js"; import { parseDocumentSnapshotForSource } from "./documentBatchManifest.js"; import { assertPinnedSource } from "./indexerParserSourceMaterialization.js"; +import { repoContentWorkingVersion, advanceRepoContentReferences } from "./repoContentEvidence.js"; export async function currentScopeSourceVersion(projectRoot: string, sourceRef: string): Promise { + if (sourceRef.startsWith("repo-content:")) return repoContentWorkingVersion(projectRoot, sourceRef); const sources = await loadSourcesRegistry({ rootDir: projectRoot }); const split = sourceRef.indexOf(":"); const type = sourceRef.slice(0, split); @@ -62,7 +64,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); - await atomicWriteFile(join(projectRoot, structure.path), YAML.stringify({ ...structure.parsed, processed_scopes: processed })); + const articles = await advanceRepoContentReferences(projectRoot, + validateArticleStructureEntries(structure.parsed.articles ?? []), scopes.filter(scope => !scope.module_refs).map(scope => scope.source_ref)); + await atomicWriteFile(join(projectRoot, structure.path), YAML.stringify({ ...structure.parsed, articles, processed_scopes: processed })); } /** Clear only proofs whose purpose/boundary changed, before committing that diff --git a/packages/context-cli/src/project/productionArticle.ts b/packages/context-cli/src/project/productionArticle.ts index c29f2281..abb75aa1 100644 --- a/packages/context-cli/src/project/productionArticle.ts +++ b/packages/context-cli/src/project/productionArticle.ts @@ -1,7 +1,7 @@ import YAML from "yaml"; import { z } from "zod"; import { articleFragmentReferences, createArticleSourceReference, - indexerKnowledgeCollectionSchema, indexerProtocolDigest } from "@c4a/context"; + indexerKnowledgeCollectionSchema, indexerProtocolDigest, repoContentScopeMatches } from "@c4a/context"; import { ContextError } from "../lib/errors.js"; import { ErrorCategory } from "../lib/cliFeedback.js"; import { ExitCode } from "../types/exitCode.js"; @@ -39,7 +39,7 @@ export async function prefetchProductionArticleSources( const declared = productionReferencesSchema.parse(YAML.parse(files.references.text)); const allowed = new Set(task.sources.map(source => source.scope)); for (const section of declared.sections) for (const ref of section.references) { - if (!allowed.has(ref.source_ref)) continue; + if (![...allowed].some(scope => repoContentScopeMatches(scope, ref.source_ref))) continue; const location = { source: ref.source_ref, path: ref.locator.path }; locations.set(JSON.stringify(location), location); } @@ -138,7 +138,7 @@ export async function prepareProductionArticle(input: { const references = []; for (const ref of byId.get(fragment.id)!) { try { - if (!allowed.has(ref.source_ref)) throw new TypeError(`Source is outside this task's authorized inputs: ${ref.source_ref}`); + if (![...allowed].some(scope => repoContentScopeMatches(scope, ref.source_ref))) throw new TypeError(`Source is outside this task's authorized inputs: ${ref.source_ref}`); const text = await read(ref.source_ref, ref.locator.path, true); const reference = createArticleSourceReference(ref.source_ref, ref.locator, text); const previous = retained?.get(fragment.id)?.find(value => value.source_ref === ref.source_ref && diff --git a/packages/context-cli/src/project/productionPlanningMaterials.ts b/packages/context-cli/src/project/productionPlanningMaterials.ts index 9b0fd8f1..e557c378 100644 --- a/packages/context-cli/src/project/productionPlanningMaterials.ts +++ b/packages/context-cli/src/project/productionPlanningMaterials.ts @@ -11,6 +11,8 @@ import { registeredArticleSourceReader } from "./articleSourceReader.js"; import { parseDocumentSnapshotForSource } from "./documentBatchManifest.js"; import { productionSourceIsExcluded, type ProductionRequirements } from "./productionRequirements.js"; import type { ProductionStageMaterials } from "./productionStageStore.js"; +import { readRepoContentRegistry, repoContentExcluded } from "./repoContentRegistry.js"; +import { optionalRepoGit, repoContentGit } from "./repoContentGit.js"; const execute = promisify(execFile); const markdownParser = unified().use(remarkParse); @@ -85,6 +87,21 @@ export async function prepareProductionPlanningMaterials(input: { continue; } try { + if (scope.startsWith("repo-content:")) { + const local = await readRepoContentRegistry(input.projectRoot); + const entry = local?.entries.find(item => scope === `repo-content:${item.id}`); + if (!local || !entry) throw new TypeError(`Register the selected same-repository input: ${scope}`); + const head = await optionalRepoGit(local.repoRoot, ["rev-parse", "HEAD"]); + if (!head) throw new TypeError("Repository evidence needs a committed HEAD"); + const files = [...new Set((await repoContentGit(local.repoRoot, ["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", entry.path])) + .split("\0").filter(path => path && !repoContentExcluded(entry, path) && !sourceExcludes(input.requirements, scope, path)))]; + sources.set(scope, [`# ${scope}`, "", `Original repository: ${local.repoRoot}`, `Entry: ${entry.path}`, `HEAD: ${head}`, + "Read selected originals in place. No snapshot or duplicate article is implied by registration.", + `For evidence use ${scope}@${head}; append +worktree when citing uncommitted content. Locators are real repository-root paths, never mount paths.`, + "", "## File navigation sample", "", ...files.slice(0, 128).map(path => `- ${path}`), + ...(files.length > 128 ? [`${files.length - 128} additional files; narrow to the relevant directory.`] : []), ""].join("\n")); + continue; + } const separator = scope.indexOf(":"); const type = scope.slice(0, separator); const name = scope.slice(separator + 1); diff --git a/packages/context-cli/src/project/repoContentEvidence.ts b/packages/context-cli/src/project/repoContentEvidence.ts new file mode 100644 index 00000000..04e9a189 --- /dev/null +++ b/packages/context-cli/src/project/repoContentEvidence.ts @@ -0,0 +1,218 @@ +import { createHash } from "node:crypto"; +import { posix, relative } from "node:path"; +import YAML from "yaml"; +import { articleSourceRegion, articleSourceRegionDigest, locateArticleRegion, parseRepoContentRef, repoContentPathSchema, + repoContentScopeMatches, type ArticleSourceReference, type ArticleStructureEntry } from "@c4a/context"; +import { optionalRepoGit, readRepoContentAt, repoContentGit } from "./repoContentGit.js"; +import { readRepoContentRegistry, repoContentContains, repoContentExcluded, + type RepoContentRegistration, type RegisteredRepoContent } from "./repoContentRegistry.js"; + +export interface RepoContentChange { + status: string; + path: string; + old_path?: string; +} +export interface RepoContentImpact { + state: "unchanged" | "changed" | "moved" | "unavailable" | "worktree"; + changes: RepoContentChange[]; + current_path?: string; + message?: string; +} +function parseChanges(value: string): RepoContentChange[] { + const fields = value.split("\0"); + const changes: RepoContentChange[] = []; + for (let i = 0; fields[i];) { + const status = fields[i++]!; + const old = fields[i++]; + if (!old) break; + const renamed = /^[RC]/u.test(status); + const path = renamed ? fields[i++] : old; + if (path) changes.push({ status, path, ...(renamed ? { old_path: old } : {}) }); + } + return changes; +} +export async function repoContentWorkingVersion(projectRoot: string, sourceRef: string): Promise { + const parsed = parseRepoContentRef(sourceRef); + const registry = await readRepoContentRegistry(projectRoot); + const entry = registry?.entries.find(item => item.id === parsed?.id); + if (!registry || !entry) throw new TypeError(`Register ${sourceRef} in repo-content.yaml before updating it`); + const head = await optionalRepoGit(registry.repoRoot, ["rev-parse", "HEAD"]); + if (!head) throw new TypeError("Commit the repository before starting a source-bound knowledge update"); + const tracked = await repoContentGit(registry.repoRoot, ["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", entry.path]); + const portableEntry = Object.fromEntries(Object.entries(entry).filter(([key]) => key !== "absolutePath")); + const hash = createHash("sha256").update(JSON.stringify(portableEntry)); + for (const path of [...new Set(tracked.split("\0").filter(Boolean))].sort()) { + if (repoContentExcluded(entry, path)) continue; + hash.update(path).update("\0"); + try { hash.update(await readRepoContentAt(registry.repoRoot, path)); } + catch { hash.update("\0unavailable"); } + } + // One scope digest binds a pending update to the actual worktree. No per-file + // digest registry or source snapshot is written. + return `${head}+content:${hash.digest("hex")}`; +} + +export async function repoContentReferenceReader(projectRoot: string) { + const registry = await readRepoContentRegistry(projectRoot); + const root = registry?.repoRoot ?? await optionalRepoGit(projectRoot, ["rev-parse", "--show-toplevel"]); + return async (sourceRef: string, path: string, captured = false): Promise => { + const ref = parseRepoContentRef(sourceRef); + if (!root || !ref?.commit) throw new TypeError("Repository evidence requires its original full commit and real path"); + repoContentPathSchema.parse(path); + if (ref.worktree || !captured) { + const entry = registry?.entries.find(item => item.id === ref.id); + if (!entry || !repoContentContains(entry.path, path) || repoContentExcluded(entry, path)) { + throw new TypeError("Current repository evidence is outside its registered entry"); + } + if (ref.worktree && captured && await optionalRepoGit(root, ["rev-parse", "HEAD"]) !== ref.commit) { + throw new TypeError("Working-tree evidence HEAD changed; reread and recalculate its reference"); + } + return readRepoContentAt(root, path); + } + // Historical path is repository-relative; never prepend today's entry path. + return readRepoContentAt(root, path, ref.commit); + }; +} + +export async function repoContentImpactReader(projectRoot: string) { + let registry; + try { registry = await readRepoContentRegistry(projectRoot); } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + return async (): Promise => ({ state: "unavailable", changes: [], message }); + } + const gitRoot = registry?.repoRoot ?? await optionalRepoGit(projectRoot, ["rev-parse", "--show-toplevel"]); + const diffs = new Map>(); + const untracked = new Map>(); + async function changesFor(commit: string, oldScope: string, entry: RegisteredRepoContent) { + const key = JSON.stringify([commit, oldScope, entry.path]); + if (!diffs.has(key)) diffs.set(key, (async () => { + let changes = parseChanges(await repoContentGit(gitRoot!, ["diff", "--no-ext-diff", "--no-textconv", "--name-status", "-z", "-M", commit, "--", oldScope, entry.path])); + if (changes.some(item => item.status === "D" && repoContentContains(oldScope, item.path))) { + // Unknown destination: recognize moves before filtering, not after. + changes = parseChanges(await repoContentGit(gitRoot!, ["diff", "--no-ext-diff", "--no-textconv", "--name-status", "-z", "-M", commit])); + } + return changes; + })()); + if (!untracked.has(entry.id)) untracked.set(entry.id, repoContentGit(gitRoot!, ["ls-files", "--others", "--exclude-standard", "-z", "--", entry.path]) + .then(value => value.split("\0").filter(Boolean).map(path => ({ status: "untracked", path })))); + return [...await diffs.get(key)!, ...await untracked.get(entry.id)!]; + } + async function oldEntryPath(commit: string, entry: RegisteredRepoContent): Promise { + try { + const path = relative(gitRoot!, projectRoot).split("\\").join("/"); + const text = await readRepoContentAt(gitRoot!, posix.join(path, "repo-content.yaml"), commit); + const old: unknown = YAML.parse(text)?.entries?.[entry.id]?.path; + return repoContentPathSchema.parse(old); + } catch { return entry.path; } + } + async function skillRoot(path: string, commit: string): Promise { + let directory = posix.dirname(path); + while (directory !== ".") { + try { await readRepoContentAt(gitRoot!, `${directory}/SKILL.md`, commit); return directory; } + catch { directory = posix.dirname(directory); } + } + return undefined; + } + return async (reference: ArticleSourceReference): Promise => { + const ref = parseRepoContentRef(reference.source_ref); + const entry = registry?.entries.find(item => item.id === ref?.id); + if (!ref?.commit || !gitRoot || !entry) return { state: "unavailable", changes: [], message: "Entry or fixed baseline is unavailable; assess current material before advancing the baseline." }; + try { + repoContentPathSchema.parse(reference.locator.path); + // A missing baseline is not equivalent to an empty diff. + if (!ref.worktree || entry.kind === "skill" || entry.kind === "skills") { + await readRepoContentAt(gitRoot, reference.locator.path, ref.commit); + } + const oldPath = await oldEntryPath(ref.commit, entry); + const skill = entry.kind === "skill" || entry.kind === "skills" ? await skillRoot(reference.locator.path, ref.commit) : undefined; + const oldScope = skill ?? reference.locator.path; + const mapped = repoContentContains(oldPath, reference.locator.path) + ? entry.path + reference.locator.path.slice(oldPath.length) : reference.locator.path; + const currentScope = skill && repoContentContains(oldPath, skill) ? entry.path + skill.slice(oldPath.length) : skill ?? mapped; + const changes = (await changesFor(ref.commit, oldScope, entry)).filter(change => + (repoContentContains(oldScope, change.old_path ?? change.path) || repoContentContains(currentScope, change.path)) && + !(repoContentContains(entry.path, change.path) && repoContentExcluded(entry, change.path))); + const renamed = changes.find(change => change.old_path === reference.locator.path); + const path = renamed?.path ?? mapped; + if (entry.kind === "skill" || entry.kind === "skills") { + if (!skill) return { state: "unavailable", changes, message: "Cannot recover the cited Skill directory; assess its current contents." }; + return { state: ref.worktree ? "worktree" : changes.length ? "changed" : "unchanged", changes, current_path: path }; + } + const text = await readRepoContentAt(gitRoot, path); + let unchanged = false; + try { unchanged = articleSourceRegionDigest(text, { ...reference.locator, path }) === reference.content_digest; } + catch (error) { if (!(error instanceof RangeError)) throw error; } + if (!unchanged && !ref.worktree) { + const baseline = await readRepoContentAt(gitRoot, reference.locator.path, ref.commit); + // Verify the recorded baseline before relocating; a matching phrase alone + // must not hide an invalid digest or an ambiguous repeated paragraph. + if (articleSourceRegionDigest(baseline, reference.locator) === reference.content_digest && + locateArticleRegion(articleSourceRegion(baseline, reference.locator), text, path)) { + return { state: "moved", changes, current_path: path }; + } + } + return { state: unchanged ? (changes.length ? "moved" : "unchanged") : "changed", changes, current_path: path }; + } catch (error) { + return { state: "unavailable", changes: [], message: error instanceof Error ? error.message : String(error) }; + } + }; +} + +export async function inspectRepoContentChanges(projectRoot: string, registry?: RepoContentRegistration) { + const registered = registry ?? await readRepoContentRegistry(projectRoot); + if (!registered) return []; + const head = await optionalRepoGit(registered.repoRoot, ["rev-parse", "HEAD"]); + return Promise.all(registered.entries.map(async entry => ({ id: entry.id, path: entry.path, + baseline: head ?? null, + changes: head ? parseChanges(await repoContentGit(registered.repoRoot, ["diff", "--no-ext-diff", "--no-textconv", "--name-status", "-z", "-M", head, "--", entry.path])) + .filter(change => !repoContentExcluded(entry, change.path)) : [], + untracked: (await repoContentGit(registered.repoRoot, ["ls-files", "--others", "--exclude-standard", "-z", "--", entry.path])) + .split("\0").filter(path => path && !repoContentExcluded(entry, path)) }))); +} + +/** Called only after a source scope is explicitly settled, never by inspect or + * editing. Preserve unavailable evidence instead of manufacturing a new locator. */ +export async function advanceRepoContentReferences(projectRoot: string, articles: ArticleStructureEntry[], scopes: readonly string[]) { + if (!scopes.some(scope => scope.startsWith("repo-content:"))) return articles; + const registry = await readRepoContentRegistry(projectRoot); + if (!registry) return articles; + const head = await optionalRepoGit(registry.repoRoot, ["rev-parse", "HEAD"]); + if (!head) return articles; + const impact = await repoContentImpactReader(projectRoot); + return 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 => { + const parsed = parseRepoContentRef(reference.source_ref); + const entry = registry.entries.find(item => item.id === parsed?.id); + if (!parsed || !entry || !scopes.some(scope => repoContentScopeMatches(scope, reference.source_ref))) return reference; + const result = await impact(reference); + const path = result.current_path ?? reference.locator.path; + if (!repoContentContains(entry.path, path) || repoContentExcluded(entry, path)) return reference; + try { + const text = await readRepoContentAt(registry.repoRoot, path); + let locator = { ...reference.locator, path }; + if (!parsed.worktree && parsed.commit) { + try { + const original = await readRepoContentAt(registry.repoRoot, reference.locator.path, parsed.commit); + const relocated = locateArticleRegion(articleSourceRegion(original, reference.locator), text, path); + if (relocated) locator = relocated; + } catch { /* Explicit scope assessment still controls settlement. */ } + } + let scope = path; + if (entry.kind === "skill" || entry.kind === "skills") { + scope = posix.dirname(path); + while (repoContentContains(entry.path, scope)) { + try { await readRepoContentAt(registry.repoRoot, `${scope}/SKILL.md`); break; } + catch { scope = posix.dirname(scope); } + } + if (!repoContentContains(entry.path, scope)) return reference; + } + const dirty = (await repoContentGit(registry.repoRoot, ["status", "--porcelain", "--untracked-files=all", "--", scope])).trim(); + return { ...reference, source_ref: `repo-content:${parsed.id}@${head}${dirty ? "+worktree" : ""}`, + locator, content_digest: articleSourceRegionDigest(text, locator) }; + } catch { return reference; } + })), + }))), + }))); +} diff --git a/packages/context-cli/src/project/repoContentGit.ts b/packages/context-cli/src/project/repoContentGit.ts new file mode 100644 index 00000000..26fccc37 --- /dev/null +++ b/packages/context-cli/src/project/repoContentGit.ts @@ -0,0 +1,68 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { lstat, readFile, realpath, stat } from "node:fs/promises"; +import { isAbsolute, relative, resolve, sep } from "node:path"; +import { repoContentPathSchema } from "@c4a/context"; + +const exec = promisify(execFile); +export async function repoContentGit(root: string, args: string[]): Promise { + const result = await exec("git", ["-C", root, ...args], { + encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 15000, + env: { ...process.env, GIT_NO_LAZY_FETCH: "1", GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0" }, + }); + return result.stdout; +} +export async function optionalRepoGit(root: string, args: string[]): Promise { + try { return (await repoContentGit(root, args)).trim(); } catch { return undefined; } +} +export function insideRepo(root: string, path: string): boolean { + const rel = relative(root, path); + return !isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`); +} + +/** Validate every existing component before reading. Nested repositories and + * directory symlinks are not silently treated as files owned by this repo. */ +export async function repoContentRealPath(root: string, path: string): Promise { + repoContentPathSchema.parse(path); + const base = await realpath(root); + let current = base; + for (const part of path.split("/")) { + current = resolve(current, part); + try { + const info = await lstat(current); + if (info.isSymbolicLink()) current = await realpath(current); + if (!insideRepo(base, current)) throw new TypeError("Repository content escapes its Git repository"); + if ((info.isSymbolicLink() ? await stat(current) : info).isDirectory()) { + let nested = false; + try { await lstat(resolve(current, ".git")); nested = true; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + if (nested) throw new TypeError("Repository content cannot cross a nested repository or submodule"); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + return current; +} + +export async function canReadRepoObjects(root: string): Promise { + const partial = await optionalRepoGit(root, ["config", "--get-regexp", "^(extensions\\.partialclone|remote\\..*\\.promisor)$"]); + if (!partial) return true; + // Feature probing does not read blobs. Older Git must use the working tree, + // because ignoring GIT_NO_LAZY_FETCH could otherwise trigger network access. + return (await optionalRepoGit(root, ["--no-lazy-fetch", "rev-parse", "--git-dir"])) !== undefined; +} +export async function readRepoContentAt(root: string, path: string, commit?: string): Promise { + repoContentPathSchema.parse(path); + if (commit) { + if (!/^[a-fA-F0-9]{40}(?:[a-fA-F0-9]{24})?$/u.test(commit) || !await canReadRepoObjects(root)) { + throw new TypeError("The fixed repository object is unavailable offline"); + } + const mode = await repoContentGit(root, ["ls-tree", commit, "--", path]); + if (!/^100(?:644|755) blob /u.test(mode)) throw new TypeError("Evidence must identify a regular Git file"); + return repoContentGit(root, ["show", `${commit}:${path}`]); + } + const actual = await repoContentRealPath(root, path); + if (!(await lstat(actual)).isFile()) throw new TypeError("Repository content must be a regular file"); + return readFile(actual, "utf8"); +} diff --git a/packages/context-cli/src/project/repoContentLinks.ts b/packages/context-cli/src/project/repoContentLinks.ts new file mode 100644 index 00000000..bad1521e --- /dev/null +++ b/packages/context-cli/src/project/repoContentLinks.ts @@ -0,0 +1,90 @@ +import { lstat, mkdir, readdir, readlink, symlink, unlink } from "node:fs/promises"; +import { dirname, join, relative } from "node:path"; +import { optionalRepoGit } from "./repoContentGit.js"; +import { withProjectWriteLock } from "./writeLock.js"; +import { readRepoContentRegistry, repoContentTargetExists } from "./repoContentRegistry.js"; + +export interface RepoContentLinkResult { + type: "repo-content"; + name: string; + path: string; + status: "ready" | "missing" | "mismatch" | "target-missing" | "conflict" | "removed" | "repaired" | "symlinks-disabled" | "invalid"; + message?: string; +} +export function repoContentLinksNeedRepair(results: readonly RepoContentLinkResult[]): boolean { + return results.some(item => item.status === "missing" || item.status === "mismatch"); +} +export async function ensureRepoContentLinks(projectRoot: string): Promise { + return withProjectWriteLock(projectRoot, "ensure-repo-content", () => inspectRepoContentLinks(projectRoot, true)); +} +async function info(path: string) { + try { return await lstat(path); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } +} + +/** Namespace is a derived view. Never follow a view symlink while inspecting + * or removing obsolete entries, and never replace an ordinary user file. */ +export async function inspectRepoContentLinks(projectRoot: string, repair = false): Promise { + let registry; + try { registry = await readRepoContentRegistry(projectRoot); } + catch (error) { + return [{ type: "repo-content", name: "repo-content.yaml", path: join(projectRoot, "repo-content.yaml"), + status: "invalid", message: `${error instanceof Error ? error.message : String(error)}. Correct the registry before repairing the view; existing links are unchanged.` }]; + } + const view = join(registry?.projectRoot ?? projectRoot, "repo-content"); + const viewInfo = await info(view); + if (viewInfo && !viewInfo.isDirectory()) return [{ type: "repo-content", name: "repo-content", path: view, + status: "conflict", message: "The view root must be an ordinary directory; it was not replaced." }]; + const disabled = await optionalRepoGit(registry?.repoRoot ?? projectRoot, ["config", "--get", "core.symlinks"]) === "false"; + const results: RepoContentLinkResult[] = []; + const expected = new Set(registry?.entries.map(entry => entry.mountPath) ?? []); + async function obsolete(directory: string, prefix = "") { + if (!await info(directory)) return; + for (const file of await readdir(directory, { withFileTypes: true })) { + const mount = prefix + file.name; + const path = join(directory, file.name); + if (file.isSymbolicLink() && !expected.has(mount)) { + if (repair && !disabled) await unlink(path); + results.push({ type: "repo-content", name: mount, path, + status: disabled ? "symlinks-disabled" : repair ? "removed" : "mismatch", message: "Link is not declared in repo-content.yaml." }); + } else if (file.isDirectory()) await obsolete(path, `${mount}/`); + } + } + // Registry validation finishes before the first mutation. + for (const entry of registry?.entries ?? []) { + const path = join(view, entry.mountPath); + const base = { type: "repo-content" as const, name: entry.id, path }; + if (!await repoContentTargetExists(entry)) { + results.push({ ...base, status: "target-missing", message: `Check the registered path ${entry.path}, sparse checkout, or a possible move; no content was restored.` }); + continue; + } + const parent = dirname(path); + const parentInfo = parent === view ? viewInfo : await info(parent); + if (parentInfo && !parentInfo.isDirectory()) { + results.push({ ...base, status: "conflict", message: "View parent is not an ordinary directory." }); continue; + } + const existing = await info(path); + const target = relative(parent, entry.absolutePath).split("\\").join("/"); + if (existing?.isSymbolicLink() && await readlink(path) === target) { + results.push({ ...base, status: "ready" }); continue; + } + if (disabled) { + results.push({ ...base, status: "symlinks-disabled", message: `Use the registered real path ${entry.path}; do not read a Git symlink placeholder as content.` }); continue; + } + if (existing && !existing.isSymbolicLink()) { + results.push({ ...base, status: "conflict", message: "An ordinary file or directory occupies this mount; it was not replaced." }); continue; + } + if (!repair) { results.push({ ...base, status: existing ? "mismatch" : "missing" }); continue; } + try { + await mkdir(parent, { recursive: true }); + if (existing) await unlink(path); + await symlink(target, path, entry.kind === "document" ? "file" : "dir"); + results.push({ ...base, status: "repaired" }); + } catch (error) { + if (!["EPERM", "EACCES", "ENOTSUP"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error; + results.push({ ...base, status: "symlinks-disabled", message: `Cannot create symlinks here; use ${entry.path}. No Git or system settings were changed.` }); + } + } + await obsolete(view); + return results; +} diff --git a/packages/context-cli/src/project/repoContentPages.ts b/packages/context-cli/src/project/repoContentPages.ts new file mode 100644 index 00000000..42fc8819 --- /dev/null +++ b/packages/context-cli/src/project/repoContentPages.ts @@ -0,0 +1,199 @@ +import { readdir, readFile, mkdir, lstat, writeFile } from "node:fs/promises"; +import { dirname, join, posix } from "node:path"; +import YAML from "yaml"; +import { repoContentPathSchema, type PackageDefinition } from "@c4a/context"; +import type { ApprovedKnowledgeFile } from "./packageIndexes.js"; +import { markdownReaderLinks } from "./markdownLinks.js"; +import { canReadRepoObjects, optionalRepoGit, readRepoContentAt, repoContentGit } from "./repoContentGit.js"; +import { readRepoContentRegistry, repoContentError, repoContentExcluded, + type RepoContentRegistration, type RegisteredRepoContent } from "./repoContentRegistry.js"; + +export function repoContentWebUrl(remote: string | undefined, revision: string | undefined, path: string, directory = false): string | undefined { + if (!remote || !revision || !repoContentPathSchema.safeParse(path).success) return undefined; + const ssh = /^(?:git@|ssh:\/\/git@)([^/:]+)[:/](.+)$/u.exec(remote); + try { + const url = new URL((ssh ? `https://${ssh[1]}/${ssh[2]}` : remote).replace(/\.git\/?$/u, "")); + if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) return undefined; + if (["bitbucket.org", "dev.azure.com"].includes(url.hostname)) return undefined; + const route = url.hostname === "gitlab.com" ? "-/" : ""; + return `${url.href.replace(/\/$/u, "")}/${route}${directory ? "tree" : "blob"}/${encodeURIComponent(revision)}/${path.split("/").map(encodeURIComponent).join("/")}`; + } catch { return undefined; } +} + +export async function repoContentLinkProjector(projectRoot: string) { + const registry = await readRepoContentRegistry(projectRoot); + const remote = registry && await optionalRepoGit(registry.repoRoot, ["remote", "get-url", "origin"]); + const branch = registry && (await optionalRepoGit(registry.repoRoot, ["symbolic-ref", "--short", "HEAD"]) + ?? await optionalRepoGit(registry.repoRoot, ["rev-parse", "HEAD"])); + return (markdown: string): string => { + const edits: { start: number; end: number; text: string }[] = []; + for (const link of markdownReaderLinks(markdown)) { + if (!link.target.startsWith("context:repo/")) continue; + const [id, ...suffix] = link.target.slice("context:repo/".length).split("/"); + const entry = registry?.entries.find(item => item.id === id); + const subpath = suffix.join("/"); + const safe = entry && (!subpath || (entry.kind !== "document" && repoContentPathSchema.safeParse(subpath).success)); + const path = safe ? [entry.path, subpath].filter(Boolean).join("/") : undefined; + const url = path && !repoContentExcluded(entry!, path) + ? repoContentWebUrl(remote, branch, path, !subpath && entry!.kind !== "document") : undefined; + edits.push({ start: link.start, end: link.end, text: url ? `[${link.label}](<${url}>)` + : `${link.label} (${path ?? `unresolved repository entry: ${id}`})` }); + } + for (const edit of edits.reverse()) markdown = markdown.slice(0, edit.start) + edit.text + markdown.slice(edit.end); + return markdown; + }; +} + +export interface RepoContentPage { path: string; content: string; revision: string | null; source: "repo-content.yaml" } +export async function repoContentLabels(projectRoot: string) { + let chinese = false; + try { chinese = JSON.parse(await readFile(join(projectRoot, "package.json"), "utf8")).context?.language === "zh-CN"; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + return chinese ? { title: "仓库内容", description: "项目文档与技能", skills: "技能", skill: "技能", summary: "说明", project: "项目", + working: "基于工作树文件生成", commit: "基于仓库提交生成", authoritative: "以仓库原文为准", missing: "不可用的 README 或技能条目" } + : { title: "Repository content", description: "Project documentation and Skills", skills: "Skills", skill: "Skill", summary: "Description", project: "Project", + working: "Generated from working-tree files", commit: "Generated from repository commit", authoritative: "Original repository content is authoritative", missing: "Unavailable README or Skill entries" }; +} + +/** Navigation-only records; these are not approved knowledge or copied sources. */ +export function repoContentNavigation(pages: RepoContentPage[]): ApprovedKnowledgeFile[] { + return pages.filter(page => page.path === "wikis/repo-content.md") + .map(page => ({ relPath: page.path, absPath: "", content: page.content })); +} +function stripHeader(text: string): { body: string; description: string; title?: string } { + const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(text); + let metadata: Record = {}; + if (match) { try { metadata = YAML.parse(match[1]!) ?? {}; } catch { /* README frontmatter is optional. */ } } + const body = match ? text.slice(match[0].length) : text; + const firstParagraph = body.split(/\r?\n\s*\r?\n/u).find(p => p.trim() && !p.trim().startsWith("#")) ?? ""; + let description = typeof metadata.description === "string" ? metadata.description : firstParagraph; + for (const link of markdownReaderLinks(description).reverse()) { + description = description.slice(0, link.start) + link.label + description.slice(link.end); + } + return { body, description: description.replace(/\s+/gu, " ").trim(), + ...(typeof metadata.title === "string" ? { title: metadata.title } : {}) }; +} +const cell = (value: string) => value.replace(/\|/gu, "\\|").replace(/[\r\n]+/gu, " "); +async function localFiles(root: string, path: string): Promise { + const output: string[] = []; + const info = await lstat(join(root, path)); + if (info.isFile()) return [path]; + if (!info.isDirectory()) return []; + for (const item of await readdir(join(root, path), { withFileTypes: true })) { + if (item.name === ".git" || item.isSymbolicLink()) continue; + output.push(...await localFiles(root, `${path}/${item.name}`)); + } + return output; +} + +export async function repoContentPages(projectRoot: string, pkg: PackageDefinition): Promise { + if (pkg.kind !== "package.kb" || !pkg.repoContentPage) return []; + const registry = await readRepoContentRegistry(projectRoot); + if (!registry) return []; + const labels = await repoContentLabels(projectRoot); + const head = await optionalRepoGit(registry.repoRoot, ["rev-parse", "HEAD"]); + const remote = await optionalRepoGit(registry.repoRoot, ["remote", "get-url", "origin"]); + const branch = await optionalRepoGit(registry.repoRoot, ["symbolic-ref", "--short", "HEAD"]); + const objects = !!head && await canReadRepoObjects(registry.repoRoot); + const groups = new Map(); + for (const entry of registry.entries) { + const group = entry.group ?? ""; + groups.set(group, [...groups.get(group) ?? [], entry]); + } + const pages: RepoContentPage[] = []; + const rows: string[] = []; + for (const [group, entries] of groups) { + let working = !objects; + async function read(path: string): Promise { + if (objects) { + try { return await readRepoContentAt(registry!.repoRoot, path, head); } catch { working = true; } + } + return readRepoContentAt(registry!.repoRoot, path); + } + let readmePath: string | undefined; + let readme = ""; + for (const entry of [...entries.filter(e => e.kind === "document" && posix.basename(e.path) === "README.md"), + ...entries.filter(e => e.kind === "docs")]) { + const path = entry.kind === "document" ? entry.path : `${entry.path}/README.md`; + if (repoContentExcluded(entry, path)) continue; + try { readme = await read(path); readmePath = path; break; } catch { /* Missing README does not block skills. */ } + } + const skills: { name: string; description: string; path: string }[] = []; + const missing: string[] = []; + for (const entry of entries.filter(e => e.kind === "skill" || e.kind === "skills")) { + let files: string[] = []; + try { + files = objects ? (await repoContentGit(registry.repoRoot, ["ls-tree", "-r", "-z", "--name-only", head!, "--", entry.path])).split("\0").filter(Boolean) + : await localFiles(registry.repoRoot, entry.path); + } catch { working = true; try { files = await localFiles(registry.repoRoot, entry.path); } catch { missing.push(entry.path); } } + for (const path of files.filter(path => posix.basename(path) === "SKILL.md" && !repoContentExcluded(entry, path))) { + try { + const text = await read(path); + const match = /^---\r?\n([\s\S]*?)\r?\n---/u.exec(text); + const metadata: unknown = match ? YAML.parse(match[1]!) : undefined; + if (metadata && typeof metadata === "object" && "name" in metadata && "description" in metadata && + typeof metadata.name === "string" && metadata.name.trim() && typeof metadata.description === "string" && metadata.description.trim()) { + skills.push({ name: metadata.name, description: metadata.description, path }); + } else missing.push(path); + } catch { missing.push(path); } + } + } + const parsed = stripHeader(readme); + const title = /^#\s+(.+)$/mu.exec(parsed.body)?.[1] ?? parsed.title ?? (group || labels.title); + const revision = working ? branch : head; + let body = parsed.body || `# ${title}\n`; + if (readmePath) { + const edits: { start: number; end: number; text: string }[] = []; + for (const link of markdownReaderLinks(body)) { + if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/iu.test(link.target)) continue; + const split = link.target.search(/[?#]/u); + const raw = split < 0 ? link.target : link.target.slice(0, split); + const suffix = split < 0 ? "" : link.target.slice(split); + let path: string; + try { path = posix.normalize(posix.join(posix.dirname(readmePath), decodeURIComponent(raw))); } catch { continue; } + const url = repoContentWebUrl(remote, revision, path); + // Repository web pages are not image bytes. Keep images as ordinary + // source links rather than inventing provider-specific raw endpoints. + edits.push({ start: link.start, end: link.end, text: url ? `[${link.label}](<${url}${suffix}>)` : `${link.label} (${path})` }); + } + for (const edit of edits.reverse()) body = body.slice(0, edit.start) + edit.text + body.slice(edit.end); + } + const origin = working ? labels.working : `${labels.commit} ${head}`; + body += `\n\n## ${labels.skills}\n\n| ${labels.skill} | ${labels.summary} |\n| --- | --- |\n${skills.map(skill => { + const url = repoContentWebUrl(remote, revision, skill.path); + return `| ${url ? `[${cell(skill.name)}](<${url}>)` : cell(skill.name)} | ${cell(skill.description)} |`; + }).join("\n")}\n`; + if (!readmePath) missing.push(...entries.filter(e => e.kind === "document" || e.kind === "docs").map(e => e.path)); + if (missing.length) body += `\n${labels.missing}: ${missing.map(cell).join(", ")}.\n`; + const path = group ? `wikis/repo-content/${group}.md` : "wikis/repo-content.md"; + const front = YAML.stringify({ title, type: "repo-content", description: parsed.description }); + pages.push({ path, content: `---\n${front}---\n\n> ${origin}; ${labels.authoritative}.\n\n${body}`, + revision: working ? null : head ?? null, source: "repo-content.yaml" }); + if (group) rows.push(`| [${cell(title)}](repo-content/${group}.md) | ${cell(parsed.description)} | ${skills.length} |`); + } + if (rows.length) { + let main = pages.find(page => page.path === "wikis/repo-content.md"); + if (!main) { main = { path: "wikis/repo-content.md", source: "repo-content.yaml", revision: head ?? null, + content: `---\n${YAML.stringify({ title: labels.title, type: "repo-content", description: labels.description })}---\n\n# ${labels.title}\n\n${labels.authoritative}.\n` }; pages.unshift(main); } + main.content += `\n| ${labels.project} | ${labels.summary} | ${labels.skills} |\n| --- | --- | --- |\n${rows.join("\n")}\n`; + } + return pages; +} + +export async function writeRepoContentPages(projectRoot: string, pkg: PackageDefinition, pages: RepoContentPage[]) { + for (const page of pages) { + const path = join(projectRoot, pkg.outDir, page.path); + try { await lstat(path); throw repoContentError(`Generated page collides with existing output: ${page.path}`); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, page.content); + } +} + +export async function repoContentFingerprint(projectRoot: string, pkg: PackageDefinition) { + const registry: RepoContentRegistration | undefined = await readRepoContentRegistry(projectRoot); + if (!registry) return null; + return { registry: registry.content, pages: await repoContentPages(projectRoot, pkg), + remote: await optionalRepoGit(registry.repoRoot, ["remote", "get-url", "origin"]), + branch: await optionalRepoGit(registry.repoRoot, ["symbolic-ref", "--short", "HEAD"]) }; +} diff --git a/packages/context-cli/src/project/repoContentRegistry.ts b/packages/context-cli/src/project/repoContentRegistry.ts new file mode 100644 index 00000000..3174c085 --- /dev/null +++ b/packages/context-cli/src/project/repoContentRegistry.ts @@ -0,0 +1,104 @@ +import { lstat, readFile, realpath } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import YAML from "yaml"; +import { repoContentMount, repoContentRegistrySchema, type RepoContentEntry } from "@c4a/context"; +import { ContextError } from "../lib/errors.js"; +import { ErrorCategory } from "../lib/cliFeedback.js"; +import { ExitCode } from "../types/exitCode.js"; +import { insideRepo, optionalRepoGit, repoContentRealPath } from "./repoContentGit.js"; + +export type RegisteredRepoContent = RepoContentEntry & { id: string; mountPath: string; absolutePath: string }; +export interface RepoContentRegistration { + projectRoot: string; + repoRoot: string; + entries: RegisteredRepoContent[]; + content: string; + git: boolean; +} +export function repoContentError(message: string): ContextError { + return new ContextError(ExitCode.WorkspaceStateError, `Invalid repo-content registry: ${message}`, { + category: ErrorCategory.WorkspaceStateInvalid, + next: "Correct repo-content.yaml, then run context source inspect --format json. Existing files are unchanged.", + }); +} +export function repoContentContains(parent: string, child: string): boolean { + return child === parent || child.startsWith(`${parent}/`); +} + +export async function readRepoContentRegistry(projectRoot: string): Promise { + const root = await realpath(projectRoot); + let content: string; + try { + const registryPath = join(root, "repo-content.yaml"); + if (!(await lstat(registryPath)).isFile()) throw repoContentError("repo-content.yaml must be a regular file"); + content = await readFile(registryPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + try { + const registry = repoContentRegistrySchema.parse(YAML.parse(content)); + const gitRoot = await optionalRepoGit(root, ["rev-parse", "--show-toplevel"]); + const repoRoot = await realpath(gitRoot ?? root); + const entries: RegisteredRepoContent[] = []; + for (const [id, entry] of Object.entries(registry.entries)) { + const mountPath = repoContentMount(entry); + if (mountPath.split("/").length !== (entry.group ? 2 : 1) || (entry.group && !mountPath.startsWith(`${entry.group}/`))) { + throw new TypeError(`Entry ${id}: mount must have at most one group and agree with group`); + } + const absolutePath = await repoContentRealPath(repoRoot, entry.path); + if (insideRepo(absolutePath, root) || insideRepo(join(root, "repo-content"), absolutePath)) { + throw new TypeError(`Entry ${id}: target would include the workspace or its link view`); + } + const submodule = gitRoot && await optionalRepoGit(repoRoot, ["ls-files", "--stage", "--", entry.path]); + if (submodule?.split("\n").some(line => line.startsWith("160000 "))) { + throw new TypeError(`Entry ${id}: target includes a submodule`); + } + for (const other of entries) { + if (insideRepo(absolutePath, other.absolutePath) || insideRepo(other.absolutePath, absolutePath)) { + throw new TypeError(`Entries ${id} and ${other.id} overlap; register a source once`); + } + if (repoContentContains(mountPath, other.mountPath) || repoContentContains(other.mountPath, mountPath)) { + throw new TypeError(`Entries ${id} and ${other.id} have overlapping mounts; set distinct mounts`); + } + } + entries.push({ ...entry, id, mountPath, absolutePath }); + } + return { projectRoot: root, repoRoot, content, entries, git: gitRoot !== undefined }; + } catch (error) { + if (error instanceof ContextError) throw error; + throw repoContentError(error instanceof Error ? error.message : String(error)); + } +} + +export function repoContentExcluded(entry: RepoContentEntry, repositoryPath: string): boolean { + const path = repositoryPath === entry.path ? "" : repositoryPath.slice(entry.path.length + 1); + return (entry.exclude ?? []).some(pattern => { + let source = ""; + for (let i = 0; i < pattern.length; i++) { + const char = pattern[i]!; + if (char === "*" && pattern[i + 1] === "*") { + i++; + if (pattern[i + 1] === "/") { i++; source += "(?:.*/)?"; } + else source += ".*"; + } else if (char === "*") source += "[^/]*"; + else if (char === "?") source += "[^/]"; + else source += char.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + } + return new RegExp(`^${source}$`, "u").test(path); + }); +} + +export function repoContentEntryForPath(registry: RepoContentRegistration, path: string): RegisteredRepoContent | undefined { + return registry.entries.find(entry => repoContentContains(entry.path, path) && !repoContentExcluded(entry, path)); +} + +export async function repoContentTargetExists(entry: RegisteredRepoContent): Promise { + try { + const info = await lstat(resolve(entry.absolutePath)); + return entry.kind === "document" ? info.isFile() : info.isDirectory(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} diff --git a/packages/context-cli/src/project/run.ts b/packages/context-cli/src/project/run.ts index f180a32b..7d67bad4 100644 --- a/packages/context-cli/src/project/run.ts +++ b/packages/context-cli/src/project/run.ts @@ -22,6 +22,7 @@ import { type ProjectPhaseListEntry, } from "./documentRun.js"; import { ensureRepoSources } from "./repoSources.js"; +import { ensureRepoContentLinks } from "./repoContentLinks.js"; import { errorView, resultSummary, writeRunSuccess, type ProjectRunFormat } from "./runOutput.js"; import { createPhaseRunId, writePhaseRunLog } from "./runLog.js"; import { findContextProjectRoot, loadContextProjectModule } from "./workspace.js"; @@ -276,6 +277,7 @@ export async function runProjectPhaseCommand(input: { } const format = input.format ?? "text"; + if (!input.list && !input.dryRun) await ensureRepoContentLinks(found.projectRoot); if (input.list === true) { const loaded = await loadContextProjectModule(found.projectRoot); const phaseEntries = await normalizeRunPhasesForList({ diff --git a/packages/context-cli/src/project/sourceCommands.ts b/packages/context-cli/src/project/sourceCommands.ts index 3ff18d50..42b46e4d 100644 --- a/packages/context-cli/src/project/sourceCommands.ts +++ b/packages/context-cli/src/project/sourceCommands.ts @@ -1,4 +1,5 @@ import { readFile } from "node:fs/promises"; +import { ensureRepoContentLinks, inspectRepoContentLinks } from "./repoContentLinks.js"; import { isAbsolute, resolve } from "node:path"; import { loadSourcesRegistry } from "@c4a/context"; import { Command, Option } from "commander"; @@ -476,6 +477,10 @@ Resume performs registration only unless --configure is explicitly supplied. const options = actionOptions(...args); const format = assertChoice(options.format, DATA_FORMATS, "--format") as DataFormat; const projectRoot = requireProjectRoot(process.cwd(), "source ensure"); + if (name === "repo-content") { + writeFormatted(await ensureRepoContentLinks(projectRoot), format); + return; + } const documentMatches = await documentSourcesForName({ projectRoot, ...(name !== undefined ? { name } : {}), @@ -484,6 +489,7 @@ Resume performs registration only unless --configure is explicitly supplied. repo.name === name || repo.id === name || repo.namespace === name ); const result = [ + ...(name === undefined ? await ensureRepoContentLinks(projectRoot) : []), ...(repoMatches || documentMatches.length === 0 ? await ensureRepoSources({ projectRoot, @@ -596,6 +602,10 @@ Resume performs registration only unless --configure is explicitly supplied. .action(async (name: string | undefined, ...args: unknown[]) => { const options = actionOptions(...args); const projectRoot = requireProjectRoot(process.cwd(), "source inspect"); + if (name === "repo-content") { + writeFormatted(await inspectRepoContentLinks(projectRoot), assertChoice(options.format, DATA_FORMATS, "--format")); + return; + } const documentMatches = options.repoOnly === true ? [] : await documentSourcesForName({ @@ -606,6 +616,7 @@ Resume performs registration only unless --configure is explicitly supplied. repo.name === name || repo.id === name || repo.namespace === name ); const result = [ + ...(name === undefined ? await inspectRepoContentLinks(projectRoot) : []), ...(repoMatches || documentMatches.length === 0 ? await inspectRepoSources({ projectRoot, diff --git a/packages/context-cli/src/project/status.ts b/packages/context-cli/src/project/status.ts index 1b9e1fe7..1100546c 100644 --- a/packages/context-cli/src/project/status.ts +++ b/packages/context-cli/src/project/status.ts @@ -1,6 +1,7 @@ import { attachDocumentAcquisitionWarnings } from "./documentCaptureAvailability.js"; import { productionRequirementsAreCurrent } from "./productionPlanning.js"; import { readTaskPreparation } from "./taskResumption.js"; +import { inspectRepoContentLinks } from "./repoContentLinks.js"; import { readApprovedRevision } from "./approvedRevision.js"; import { readKnowledgeUpdate } from "./knowledgeUpdate.js"; import { observeApprovedRevisionBatch } from "./approvedRevisionBatch.js"; @@ -298,6 +299,7 @@ async function collectProjectStatusSnapshotInternal( observation: workflowSnapshot.observation, }); const status: ProjectStatus = { + repoContent: await inspectRepoContentLinks(projectRoot), projectRoot, sourceCount: observation.sourceCount, readySources, diff --git a/packages/context-cli/src/project/statusCommand.ts b/packages/context-cli/src/project/statusCommand.ts index 56372232..befc1cc6 100644 --- a/packages/context-cli/src/project/statusCommand.ts +++ b/packages/context-cli/src/project/statusCommand.ts @@ -4,6 +4,7 @@ import { ExitCode } from "../types/exitCode.js"; import type { ResourceReadReceiptSet } from "@c4a/agent-graph"; import { collectProjectStatus } from "./status.js"; import { formatProjectStatus } from "./statusRender.js"; +import { repoContentLinksNeedRepair } from "./repoContentLinks.js"; import type { ProjectStatus } from "./statusTypes.js"; import { assertContextStatusWorkspaceAllowed, @@ -89,6 +90,11 @@ async function projectStatusSummary(status: ProjectStatus, projectRoot: string): projectionRefreshIssues: status.projectionRefreshIssues, }, diagnostics: status.workflow.diagnostics, + ...(status.repoContent?.length ? { repo_content: { + entries: status.repoContent, + ...(repoContentLinksNeedRepair(status.repoContent) + ? { repair_command: "context source ensure repo-content --format json" } : {}), + } } : {}), }; } diff --git a/packages/context-cli/src/project/statusRender.ts b/packages/context-cli/src/project/statusRender.ts index 682e3d9b..a67140f7 100644 --- a/packages/context-cli/src/project/statusRender.ts +++ b/packages/context-cli/src/project/statusRender.ts @@ -1,5 +1,6 @@ import { formatFeedback } from "../lib/cliFeedback.js"; import type { ProjectStatus } from "./statusTypes.js"; +import { repoContentLinksNeedRepair } from "./repoContentLinks.js"; export function formatProjectStatus(status: ProjectStatus): string { const repoSourceLines = status.sources.map((source) => @@ -47,6 +48,8 @@ export function formatProjectStatus(status: ProjectStatus): string { "", "**Project**:", `- root → \`${status.projectRoot}\``, + ...(status.repoContent ?? []).map(item => `- repository content ${item.name}: ${item.status}${item.message ? ` — ${item.message}` : ""}`), + ...(repoContentLinksNeedRepair(status.repoContent ?? []) ? ["- repair view → `context source ensure repo-content --format json` (does not change original content)"] : []), `- state: ${status.state}`, ...(status.executionMode !== undefined ? [`- execution mode: ${status.executionMode.mode} (${status.executionMode.scope})`] diff --git a/packages/context-cli/src/project/statusTypes.ts b/packages/context-cli/src/project/statusTypes.ts index 9284d988..9bb9c3dd 100644 --- a/packages/context-cli/src/project/statusTypes.ts +++ b/packages/context-cli/src/project/statusTypes.ts @@ -62,6 +62,7 @@ export interface DocumentSourceStatus { } export interface ProjectStatus { + repoContent?: import("./repoContentLinks.js").RepoContentLinkResult[]; projectRoot: string; sourceCount: number; readySources: number; diff --git a/packages/context-cli/src/project/workspace.ts b/packages/context-cli/src/project/workspace.ts index 9b5842e6..8ca211c1 100644 --- a/packages/context-cli/src/project/workspace.ts +++ b/packages/context-cli/src/project/workspace.ts @@ -16,6 +16,7 @@ import { enableContextDebug } from "./debugTrace.js"; import { renderAgents, renderProjectEntry, renderReadme } from "./workspaceGuidanceTemplates.js"; import { assertTrustedContextProjectConfigBoundary } from "./projectModulePolicy.js"; import { installEvidencePlugin, type EvidencePluginResult } from "./evidencePlugin.js"; +import { readRepoContentRegistry } from "./repoContentRegistry.js"; const PROJECT_DIRS = ["src", "sources", "knowledge", "dist"] as const; const PROJECT_SCRATCH_DIRS = [join(".tmp", "agent-payloads")] as const; @@ -506,7 +507,9 @@ export async function initContextProject(input: ProjectInitInput): Promise@` with a +historical repository-root-relative locator. They do not join today's registry +or prepend the plugin/workspace root. If the host's `repository` is a supported +credential-free remote URL, the plugin emits a fixed source URL; a repository +name alone retains structured evidence instead of guessing a host. `+worktree` +references preserve their digest and uncommitted limitation without a HEAD URL. +Ordinary repository docs and Skills are not enriched with self-references. + The host may pool instances only for the same repo, revision, artifact, effective scope and configuration. Each instance is exclusive; parsed metadata/section outlines are cached with bounded eviction. No output is retained between requests. diff --git a/packages/context-evidence-wasm/official-digests.json b/packages/context-evidence-wasm/official-digests.json index e40a94ec..3c0934d3 100644 --- a/packages/context-evidence-wasm/official-digests.json +++ b/packages/context-evidence-wasm/official-digests.json @@ -1,4 +1,5 @@ [ + "067640f43d0bcd345c6c5f052b5a17a041fceb7b4c30de82127624146456c6e2", "d0afc61569c824be6c0f819d3be4a26cab954cba6508ae654b69a9d1c7a43550", "2edd8fb97eea337877468d2ff42f8d1973f69d1d29ed8dc60fda13a11572130b" ] diff --git a/packages/context-evidence-wasm/plugin.json b/packages/context-evidence-wasm/plugin.json index a2781c75..21752b69 100644 --- a/packages/context-evidence-wasm/plugin.json +++ b/packages/context-evidence-wasm/plugin.json @@ -2,7 +2,7 @@ "name": "context-evidence", "title": "Context evidence", "description": "Attach recorded section sources to knowledge reads; does not verify original source contents.", - "version": "0.3.0", + "version": "0.4.0", "abi_version": 2, "operations": ["read", "read_many"], "default_enabled": true, diff --git a/packages/context-evidence-wasm/src/lib.rs b/packages/context-evidence-wasm/src/lib.rs index 09abe6eb..f04a7fba 100644 --- a/packages/context-evidence-wasm/src/lib.rs +++ b/packages/context-evidence-wasm/src/lib.rs @@ -20,6 +20,8 @@ pub struct Input { pub abi_version: u32, pub operation: String, #[serde(default)] + pub repository: Option, + #[serde(default)] pub args: Option, pub files: Vec, } @@ -223,7 +225,7 @@ impl Engine { let mut references = Vec::new(); let mut seen = HashSet::new(); for reference in refs { - match self.reference(reader, root, reference, args.include_digest) { + match self.reference(reader, root, reference, args.include_digest, input.repository.as_deref()) { Ok(value) => { if seen.insert(value.to_string()) { references.push(value); } }, Err(message) => issues.push(json!({"code":"SOURCE_UNRESOLVED","message":message,"path":file.path,"section_id":section.id,"source_ref":reference["source_ref"]})), } diff --git a/packages/context-evidence-wasm/src/sources.rs b/packages/context-evidence-wasm/src/sources.rs index 4651cea6..bd98c32b 100644 --- a/packages/context-evidence-wasm/src/sources.rs +++ b/packages/context-evidence-wasm/src/sources.rs @@ -80,6 +80,7 @@ impl Engine { root: &str, reference: &Value, digest: bool, + repository: Option<&str>, ) -> Result { let source_ref = string(reference, "source_ref")?; let (kind, name) = source_ref.split_once(':').ok_or("Invalid source_ref")?; @@ -97,7 +98,8 @@ impl Engine { } let mut out = json!({"source_ref":source_ref,"path":path,"start_line":start,"end_line":end}); - if digest { + let worktree = kind == "repo-content" && name.ends_with("+worktree"); + if digest || worktree { let value = string(reference, "content_digest")?; if value.len() != 71 || !value.starts_with("sha256:") @@ -107,6 +109,34 @@ impl Engine { } out["content_digest"] = json!(value); } + if kind == "repo-content" { + let (id, version) = name.rsplit_once('@').ok_or("Repository content requires a full commit")?; + if id.is_empty() || !id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) { + return Err("Invalid repository content identity".into()); + } + let commit = version.strip_suffix("+worktree").unwrap_or(version); + if !matches!(commit.len(), 40 | 64) || !commit.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("Repository content requires a full commit".into()); + } + out["ref"] = json!(version); + if let Some(repo) = repository.filter(|r| !r.is_empty() && !r.chars().any(char::is_control)) { + // A host repository name need not be a web origin. Keep it as + // identity, and never invent an origin from a group/name pair. + if repo.contains("://") && !safe_remote(repo) { + return Err("Repository identity is unsafe or contains credentials".into()); + } + out["repository"] = json!(repo); + if !worktree { + if let Some(url) = code_url(repo, commit, path, start, end) { + return Ok(if digest { json!({"url":url,"content_digest":out["content_digest"]}) } else { json!({"url":url}) }); + } + } + } + if worktree { + out["note"] = json!("Uncommitted evidence; HEAD alone cannot reproduce the recorded content"); + } + return Ok(out); + } if matches!(kind, "note" | "sessions") { let (date, file) = name .split_once('/') diff --git a/packages/context/docs/guides/knowledge-updates.md b/packages/context/docs/guides/knowledge-updates.md index 90b2ddbf..a240fe6f 100644 --- a/packages/context/docs/guides/knowledge-updates.md +++ b/packages/context/docs/guides/knowledge-updates.md @@ -18,6 +18,17 @@ actual behavior, a confirmed decision, and a proposal that is not implemented. ## Keep planning local to the change +Same-repository originals use `repo-content.yaml` rather than another capture. +For editing/registering them, use the `context-repo-content` Skill without +starting production. An explicitly requested source update can select +`repo-content:` in its confirmed requirement and update scope. The +prepared update includes current local changes and chapter impact candidates. +Docs compare cited regions; Skills compare the whole owning Skill directory, +including untracked additions. Missing historical objects are unknown, not an +empty diff. Inspect/edit does not advance references; delivery or an explicit +no-impact outcome settles the selected scope. Historical locators always store +the real repository path at the cited commit, not today's registration path. + Before starting production, compare the proposed content with the workspace's reader purpose. For clearly unrelated anecdotes or personal rankings, briefly recommend leaving them out of the formal manual or saving them separately because diff --git a/packages/context/docs/guides/package-outputs.md b/packages/context/docs/guides/package-outputs.md index 9973b43f..3132a7f2 100644 --- a/packages/context/docs/guides/package-outputs.md +++ b/packages/context/docs/guides/package-outputs.md @@ -17,6 +17,11 @@ package index, and section fingerprint rebuilds are not current close output. ## Default New-Workspace Outputs: Knowledge Base + Website +Same-repository originals have an optional [repository entrance](repo-content.md): +`repoContentPage: true` in a KB declaration projects README and Skill summaries, +not the full document tree. It does not expose that page on a configured website; +use `repoContentPage: { site: true }` to opt in. Existing declarations are kept. + Output channels support multiple selection. In a new workspace without explicit preferences, the Agent proposes and configures KB + website as the default. Honor user feedback, session authority and existing workspace declarations; LLMS is an diff --git a/packages/context/docs/guides/repo-content.md b/packages/context/docs/guides/repo-content.md new file mode 100644 index 00000000..bbe51b8b --- /dev/null +++ b/packages/context/docs/guides/repo-content.md @@ -0,0 +1,71 @@ +# Same-repository content + +Keep project documentation and authored Skills at their original locations. +Context registers an entrance without copying their bodies to knowledge or +source snapshots. For selection and editing use `context-repo-content`. + +At the Context workspace root, create `repo-content.yaml`: + +```yaml +protocol: context.repo-content/v1 +entries: + docs: + kind: docs + path: docs + tools: + kind: skills + path: .agents/skills +``` + +Paths are Git-root-relative, not workspace-relative. Kinds are `docs`, +`document`, `skills`, and `skill`. Optional `group` is one module level; +`mount` overrides the default `[group/]`. Optional `exclude` globs +are relative to the entry; they are not access control. Overlapping entries, +outside targets, nested repositories and cycles are rejected. + +`context source ensure repo-content --format json` maintains relative symlinks +in `repo-content/`; `source inspect repo-content` and `status` inspect without +repairing. Ordinary files are not overwritten. With disabled symlinks, search +real paths from the registry rather than reading Git placeholders. No Git +configuration or index is changed. Registry and relative links can be committed +by the user; originals remain the only authoring location. + +Invalid registration is advisory in `status` and automatic pre-operation link +maintenance: correct `repo-content.yaml`; existing links are left unchanged. +The repair command is suggested only for missing or misdirected symlinks, not +invalid registration, missing targets, ordinary-file conflicts or disabled +symlinks. Fix those reported conditions rather than repeatedly running ensure. +Explicit repository-content updates and `context build` require valid +registration. An invalid registry stops build before replacing existing package +outputs; it is not treated as an empty registry. Missing source files and +unavailable Git objects follow the documented fallback behavior instead. + +Article navigation uses `[Guide](context:repo/docs/guide.md)`. Build projects +this to an upstream link when available, otherwise a plain location. Evidence +instead records `repo-content:docs@` plus real historical path, +line range and content digest. Uncommitted evidence uses `+worktree`. +The evidence Wasm supports these references without treating their existence +as proof that the source was read in the current query. + +## Optional entrance page + +```ts +kbPackage({ + name: "project-kb", + template: { path: "src/package-templates/kb" }, + repoContentPage: true, +}); +``` + +SDK default is off. New initialization with an existing nonempty registry +creates a KB declaration with the entrance enabled; existing declarations are +preserved. It projects a group's README and Skill names/descriptions under +`wikis/repo-content.md` and optional group pages, not all docs or Skill scripts. +HEAD objects are preferred without network fetching, with declared worktree +fallback when unavailable. Review README sensitivity before exposing it. +With a configured website, `{ site: true }` opts into a top-level site entrance; +plain `true` keeps these pages out of the site. + +Other repositories and externally installed Skills are not same-repository +content. Registering an entrance does not imply installation, execution, +production or publication authority. diff --git a/packages/context/package.json b/packages/context/package.json index 5f981e61..62fe9772 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.43", + "version": "0.7.50", "type": "module", "license": "MIT", "engines": { diff --git a/packages/context/src/index.ts b/packages/context/src/index.ts index a7af1a02..7e49262a 100644 --- a/packages/context/src/index.ts +++ b/packages/context/src/index.ts @@ -12,6 +12,9 @@ import type { PhaseDefinition, PhaseResourceReference } from "./phases.js"; import type { ProjectSourceDefinition } from "./sources.js"; export * from "./indexerCoreExports.js"; +export { repoContentRegistrySchema, repoContentEntrySchema, repoContentPathSchema, + repoContentMount, parseRepoContentRef, repoContentScopeMatches, + type RepoContentEntry, type RepoContentRegistry } from "./repoContent.js"; export { indexerComposerContractSchema, indexerComposerDeclarationSchema, @@ -413,6 +416,7 @@ export type KbPackageDefinition = BasePackageDefinition & { distribution?: PackageDistributionDefinition; assets?: PackageAssetDefinition; site?: PackageSiteDefinition; + repoContentPage?: boolean | { site: true }; }; export type LlmsPackageDefinition = BasePackageDefinition & { @@ -695,11 +699,20 @@ export const kbPackage = (definition: { distribution?: PackageDistributionDefinition; assets?: PackageAssetDefinition; site?: PackageSiteDefinition; + repoContentPage?: boolean | { site: true }; }): KbPackageDefinition => { const base = createPackageDefinitionBase("kb", definition); const distribution = normalizePackageDistribution(definition.distribution); const assets = normalizePackageAssets(definition.assets); const site = normalizePackageSite(definition.site); + if (definition.repoContentPage !== undefined && typeof definition.repoContentPage !== "boolean" && + (!definition.repoContentPage || typeof definition.repoContentPage !== "object" || + definition.repoContentPage.site !== true || Object.keys(definition.repoContentPage).some(key => key !== "site"))) { + throw new TypeError("repoContentPage must be a boolean or { site: true }"); + } + if (typeof definition.repoContentPage === "object" && !site) { + throw new TypeError("repoContentPage.site requires a configured site"); + } return { kind: "package.kb", ...base, @@ -707,6 +720,7 @@ export const kbPackage = (definition: { ...(distribution === undefined ? {} : { distribution }), assets, ...(site === undefined ? {} : { site }), + ...(definition.repoContentPage === undefined ? {} : { repoContentPage: definition.repoContentPage }), }; }; diff --git a/packages/context/src/repoContent.ts b/packages/context/src/repoContent.ts new file mode 100644 index 00000000..ccf3ab6e --- /dev/null +++ b/packages/context/src/repoContent.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; + +export const repoContentPathSchema = z.string().min(1).refine(value => + !/[\\\u0000-\u001f\u007f:]/u.test(value) && value.split("/").every(part => + part !== "" && part !== "." && part !== ".." && part.toLowerCase() !== ".git"), +"Use a repository-relative path without traversal or .git components"); +const segment = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u); +export const repoContentEntrySchema = z.object({ + kind: z.enum(["docs", "skills", "document", "skill"]), + path: repoContentPathSchema, + exclude: z.array(z.string().min(1)).optional(), + group: segment.optional(), + mount: repoContentPathSchema.optional(), + title: z.string().min(1).optional(), + description: z.string().optional(), +}).strict(); +export const repoContentRegistrySchema = z.object({ + protocol: z.literal("context.repo-content/v1"), + entries: z.record(segment, repoContentEntrySchema), +}).strict(); +export type RepoContentEntry = z.infer; +export type RepoContentRegistry = z.infer; + +export function repoContentMount(entry: RepoContentEntry): string { + return entry.mount ?? [entry.group, entry.path.split("/").at(-1)].filter(Boolean).join("/"); +} + +/** Scope selectors omit a revision; evidence references always include one. */ +export function parseRepoContentRef(value: string): { id: string; commit?: string; worktree: boolean } | undefined { + const match = /^repo-content:([A-Za-z0-9][A-Za-z0-9._-]*)(?:@([a-fA-F0-9]{40}(?:[a-fA-F0-9]{24})?)(\+worktree)?)?$/u.exec(value); + if (!match) return undefined; + return { id: match[1]!, ...(match[2] ? { commit: match[2] } : {}), worktree: !!match[3] }; +} + +export function repoContentScopeMatches(scope: string, reference: string): boolean { + if (scope === reference) return true; + const selected = parseRepoContentRef(scope); + const cited = parseRepoContentRef(reference); + return !!selected && !selected.commit && !!cited && selected.id === cited.id; +} diff --git a/packages/context/templates/package-templates.zh-CN/kb/skills/knowledge-query/SKILL.md b/packages/context/templates/package-templates.zh-CN/kb/skills/knowledge-query/SKILL.md index 6782a785..9cae79c9 100644 --- a/packages/context/templates/package-templates.zh-CN/kb/skills/knowledge-query/SKILL.md +++ b/packages/context/templates/package-templates.zh-CN/kb/skills/knowledge-query/SKILL.md @@ -18,6 +18,10 @@ description: 查询 {{displayName}} 中经过审核、可追溯来源的知识 ## 知识根目录 +若存在 `{{wikisRoot}}/repo-content.md`,它是生成的仓库入口页:README 与 Skill +简介,不是批准知识文章,也不包含全部项目文档。需要且有权访问时沿链接读取原文; +阅读 Skill 说明不代表安装或执行。原仓库是真源,页面标明构建所用基线。 + | 根目录 | 用途 | |---|---| | `{{wikisRoot}}/` | 来自 codeindex、business、product 的代码、业务和产品说明。 | diff --git a/packages/context/templates/package-templates/kb/skills/knowledge-query/SKILL.md b/packages/context/templates/package-templates/kb/skills/knowledge-query/SKILL.md index e0807b6e..33c15973 100644 --- a/packages/context/templates/package-templates/kb/skills/knowledge-query/SKILL.md +++ b/packages/context/templates/package-templates/kb/skills/knowledge-query/SKILL.md @@ -25,6 +25,12 @@ as evidence rather than relying on memory or frontmatter summaries. ## Package Roots +If present, `{{wikisRoot}}/repo-content.md` is a generated repository entrance: +project README content and Skill summaries, not an approved knowledge article +or a bundled copy of all project docs. Follow its original links when authorized +and needed; reading a Skill description does not install or execute it. The +original repository remains authoritative, and the page states its build baseline. + | Root | Use | |---|---| | `{{wikisRoot}}/` | Code, business and product reference articles. | diff --git a/packages/context/templates/project-skills.zh-CN/maintain-project-knowledge/SKILL.md b/packages/context/templates/project-skills.zh-CN/maintain-project-knowledge/SKILL.md index 3bb66634..b8ab1aa9 100644 --- a/packages/context/templates/project-skills.zh-CN/maintain-project-knowledge/SKILL.md +++ b/packages/context/templates/project-skills.zh-CN/maintain-project-knowledge/SKILL.md @@ -11,6 +11,11 @@ Skill,并以当前 Route、资源和命令为权威;这里只保留稳定的 ## 仓库来源的获取和更新 +`repo-content.yaml` 登记的同仓文档和自有 Skills 不是采集副本。编辑原文或登记时 +使用 `context-repo-content`,不启动生产。另行授权的知识更新可选择 +`repo-content:`;文档检查引用区域,Skill 检查整个目录及未跟踪新增文件。 +检查与编辑不推进处理基线,旧证据保留当时的真实仓库路径。 + 先检查当前 Context Route。已登记仓库模块缺失或本地链接断开时,使用 Route 选择的仓库恢复流程,让用户选择已有 checkout、授权在一个限定目录内扫描, 或明确允许 clone 已登记的固定 commit。 diff --git a/packages/context/templates/project-skills/maintain-project-knowledge/SKILL.md b/packages/context/templates/project-skills/maintain-project-knowledge/SKILL.md index 06c996eb..a8fecec4 100644 --- a/packages/context/templates/project-skills/maintain-project-knowledge/SKILL.md +++ b/packages/context/templates/project-skills/maintain-project-knowledge/SKILL.md @@ -12,6 +12,13 @@ mappings here. ## Repository sources +Same-repository docs and authored Skills registered in `repo-content.yaml` are +not captured source copies. For original edits or registration use +`context-repo-content`, without starting production. A separately requested +knowledge update may select `repo-content:`; compare cited document regions +and entire cited Skill directories, including untracked additions. Inspecting +or editing never advances the processed baseline. Keep historical real paths. + Start with the current Context Route. If registered repository modules are missing or their local links are broken, follow the route-selected repository recovery procedure. Let the user choose an existing checkout, authorize a scan diff --git a/packages/core/package.json b/packages/core/package.json index a1e25688..f8299d04 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.43", + "version": "0.7.50", "type": "module", "license": "MIT", "engines": { diff --git a/packages/dev-cli/package.json b/packages/dev-cli/package.json index 4609e641..92c8f6f6 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.43", + "version": "0.7.50", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extract-contract/package.json b/packages/extract-contract/package.json index d9067e08..9fa19326 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.43", + "version": "0.7.50", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-go/package.json b/packages/extract-go/package.json index f85e44cc..51d8d205 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.43", + "version": "0.7.50", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-mdx/package.json b/packages/extract-mdx/package.json index 81d9a99c..2082a2d8 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.43", + "version": "0.7.50", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-proto/package.json b/packages/extract-proto/package.json index 54035601..c9488a1c 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.43", + "version": "0.7.50", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-rush/package.json b/packages/extract-rush/package.json index eae9369b..cca62493 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.43", + "version": "0.7.50", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-sql/package.json b/packages/extract-sql/package.json index a7efe969..c071d96f 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.43", + "version": "0.7.50", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-style/package.json b/packages/extract-style/package.json index 35027f84..b39299d8 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.43", + "version": "0.7.50", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-thrift/package.json b/packages/extract-thrift/package.json index c0123a6c..ac24a328 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.43", + "version": "0.7.50", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-ts/package.json b/packages/extract-ts/package.json index cbc17c84..6298e296 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.43", + "version": "0.7.50", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract/package.json b/packages/extract/package.json index 48a3432e..0aae9f86 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.43", + "version": "0.7.50", "type": "module", "license": "MIT", "engines": { diff --git a/packages/tui/package.json b/packages/tui/package.json index 2416c079..92850127 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.43", + "version": "0.7.50", "private": true, "type": "module", "license": "MIT", diff --git a/plugins/context/.claude-plugin/plugin.json.template b/plugins/context/.claude-plugin/plugin.json.template index a8f074a6..0ac57df1 100644 --- a/plugins/context/.claude-plugin/plugin.json.template +++ b/plugins/context/.claude-plugin/plugin.json.template @@ -16,6 +16,7 @@ "skills": [ "./skills/context-indexer-create", "./skills/context-inspect-search", - "./skills/context-plan" + "./skills/context-plan", + "./skills/context-repo-content" ] } diff --git a/plugins/context/.cursor-plugin/plugin.json.template b/plugins/context/.cursor-plugin/plugin.json.template index 66217fdc..83818605 100644 --- a/plugins/context/.cursor-plugin/plugin.json.template +++ b/plugins/context/.cursor-plugin/plugin.json.template @@ -28,6 +28,7 @@ "skills": [ "./skills/context-indexer-create", "./skills/context-inspect-search", - "./skills/context-plan" + "./skills/context-plan", + "./skills/context-repo-content" ] } diff --git a/plugins/context/repo-install/claude/.claude-plugin/plugin.json b/plugins/context/repo-install/claude/.claude-plugin/plugin.json index f43c3146..a09bc0c8 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.43", + "version": "0.7.50", "author": { "name": "c4a" }, @@ -16,6 +16,7 @@ "skills": [ "./skills/context-indexer-create", "./skills/context-inspect-search", - "./skills/context-plan" + "./skills/context-plan", + "./skills/context-repo-content" ] } diff --git a/plugins/context/repo-install/claude/commands/context-inspect-search.md b/plugins/context/repo-install/claude/commands/context-inspect-search.md index cb1ce7c3..fcf51760 100644 --- a/plugins/context/repo-install/claude/commands/context-inspect-search.md +++ b/plugins/context/repo-install/claude/commands/context-inspect-search.md @@ -59,6 +59,31 @@ access policies, not additional source-order modes or production settings. ## Search available material according to the configured mode +Same-repository original documents and authored Skills may be registered in +workspace-root `repo-content.yaml`, with a `repo-content/` symlink view. They are +another readable entrance, not duplicated approved knowledge. Select them when +the question concerns the project's documentation or operational instructions; +keep the existing knowledge-first path for synthesized knowledge questions. +Read-only retrieval never registers content or starts production. Reading a +Skill as evidence does not activate or authorize executing it. + +Locally, use `rg -L` on selected views, or search the registry's real paths if +symlinks are disabled. Remotely, include the relevant view paths alongside +knowledge paths rather than blindly restricting all searches to `knowledge/**`. +Only explicit `Meta.Coverage.Symlinks: followed` permits relying on followed +views, and still respect result/expansion limits. If absent, mixed, not_followed +or incomplete, read that same commit's `repo-content.yaml`, then search the +registered real paths; verify they are covered at that commit. Do not treat an +unindexed target or placeholder as an empty document, silently change SHA, or +request indexing privileges. Keep real repository paths and actual commits in +citations; results through multiple views can refer to the same file. + +`repo-content:@` evidence locators are historical repository-root +paths, not entry-relative paths. Do not prepend current registration or workspace +paths. `+worktree` means uncommitted evidence: retain that limitation and digest, +never substitute a fixed HEAD URL as if it contains the cited bytes. Navigation +`context:repo/` resolves current registration, not historical provenance. + Apply `CONTEXT_QUERY_SOURCE_MODE` before starting retrieval. In `repo-first`, start with approved knowledge, not original-code checkout preparation. With readable `knowledge/`, do not probe `dist/`, build inventories or installed diff --git a/plugins/context/repo-install/claude/commands/context-repo-content.md b/plugins/context/repo-install/claude/commands/context-repo-content.md new file mode 100644 index 00000000..2b4b66c6 --- /dev/null +++ b/plugins/context/repo-install/claude/commands/context-repo-content.md @@ -0,0 +1,10 @@ +--- +description: "Create, edit or register same-repository project documentation and authored Skills in a Context workspace without starting knowledge production. Use for repository content maintenance, not external imports or knowledge article publication." +argument-hint: "[project-dir or user intent]" +--- + +# Context Repo Content + +Read the installed `context-repo-content` skill at `../skills/context-repo-content/SKILL.md` +relative to this command file, then follow its instructions for the user's request. +Resolve its references and templates from that skill directory. diff --git a/plugins/context/repo-install/claude/commands/context.md b/plugins/context/repo-install/claude/commands/context.md index 8b3ea594..fcb8178e 100644 --- a/plugins/context/repo-install/claude/commands/context.md +++ b/plugins/context/repo-install/claude/commands/context.md @@ -29,6 +29,14 @@ use this production workflow's current Route and preserve existing work. ## Route project-scale work before production +For creating, editing or registering same-repository docs and authored Skills, +use `context-repo-content` instead of starting production. Originals stay in their +project directories; registration/editing does not authorize knowledge updates. +During authorized onboarding, select only the current project's conventional +README/docs/Skill locations, with ownership and workspace boundaries from that +skill. Do not capture registered originals into sources merely to duplicate them +as knowledge. Explicit audience-oriented rewriting remains ordinary production. + For an authorized knowledge-production request involving more than 30 original documents or at least two repositories needing substantive investigation, use the installed `context-plan` Skill before registering the full request. Also use diff --git a/plugins/context/repo-install/claude/skills/context-inspect-search/SKILL.md b/plugins/context/repo-install/claude/skills/context-inspect-search/SKILL.md index 8bf376e2..199318b1 100644 --- a/plugins/context/repo-install/claude/skills/context-inspect-search/SKILL.md +++ b/plugins/context/repo-install/claude/skills/context-inspect-search/SKILL.md @@ -58,6 +58,31 @@ access policies, not additional source-order modes or production settings. ## Search available material according to the configured mode +Same-repository original documents and authored Skills may be registered in +workspace-root `repo-content.yaml`, with a `repo-content/` symlink view. They are +another readable entrance, not duplicated approved knowledge. Select them when +the question concerns the project's documentation or operational instructions; +keep the existing knowledge-first path for synthesized knowledge questions. +Read-only retrieval never registers content or starts production. Reading a +Skill as evidence does not activate or authorize executing it. + +Locally, use `rg -L` on selected views, or search the registry's real paths if +symlinks are disabled. Remotely, include the relevant view paths alongside +knowledge paths rather than blindly restricting all searches to `knowledge/**`. +Only explicit `Meta.Coverage.Symlinks: followed` permits relying on followed +views, and still respect result/expansion limits. If absent, mixed, not_followed +or incomplete, read that same commit's `repo-content.yaml`, then search the +registered real paths; verify they are covered at that commit. Do not treat an +unindexed target or placeholder as an empty document, silently change SHA, or +request indexing privileges. Keep real repository paths and actual commits in +citations; results through multiple views can refer to the same file. + +`repo-content:@` evidence locators are historical repository-root +paths, not entry-relative paths. Do not prepend current registration or workspace +paths. `+worktree` means uncommitted evidence: retain that limitation and digest, +never substitute a fixed HEAD URL as if it contains the cited bytes. Navigation +`context:repo/` resolves current registration, not historical provenance. + Apply `CONTEXT_QUERY_SOURCE_MODE` before starting retrieval. In `repo-first`, start with approved knowledge, not original-code checkout preparation. With readable `knowledge/`, do not probe `dist/`, build inventories or installed diff --git a/plugins/context/repo-install/claude/skills/context-plan/SKILL.md b/plugins/context/repo-install/claude/skills/context-plan/SKILL.md index fdf95a1b..52106ccb 100644 --- a/plugins/context/repo-install/claude/skills/context-plan/SKILL.md +++ b/plugins/context/repo-install/claude/skills/context-plan/SKILL.md @@ -27,6 +27,13 @@ are met or when the user explicitly requests project planning. ## Start or resume +Choose the workspace boundary before source inventory. A large multi-domain +monorepo may need a Context workspace per direction, with at most one project +group inside each; do not migrate existing workspaces automatically. For +same-repository docs and authored Skills, prefer registration/editing through +`context-repo-content` and preserve one original. Only explicit rewriting for +an audience enters production; do not count registration as completed research. + 1. Identify the selected project's existing `PLAN-*.md`, workspace and Git root. Reuse a matching plan. Inspect its current stage, permissions and pending work before expanding research; do not reset a live Context workflow. diff --git a/plugins/context/repo-install/claude/skills/context-repo-content/SKILL.md b/plugins/context/repo-install/claude/skills/context-repo-content/SKILL.md new file mode 100644 index 00000000..90d5af4a --- /dev/null +++ b/plugins/context/repo-install/claude/skills/context-repo-content/SKILL.md @@ -0,0 +1,62 @@ +--- +user-invocable: false +name: context-repo-content +description: Create, edit or register same-repository project documentation and authored Skills in a Context workspace without starting knowledge production. Use for repository content maintenance, not external imports or knowledge article publication. +--- + +# Repository content + +Maintain one original in its project directory. `repo-content.yaml` registers entrances; +`repo-content/` is a relative-symlink view, not a second authoring location. +This is an independent editing task: do not start production, update knowledge, +advance processed baselines, build, publish or commit merely because a document changed. + +## Select the boundary + +Identify the Context workspace and its Git root before writing. Paths in the registry +are relative to that Git root, even when Context lives in a subdirectory. +For a large multi-domain monorepo, use a workspace per direction; within one workspace +use at most one project/module group. Preserve existing workspaces and user choices. + +During authorized onboarding, examine only the selected project's tracked README, +`docs/`, and `.agents/skills`, `.claude/skills`, `.codex/skills`. Select useful directories, +not every Markdown file in the repository. Other locations require an explicit user +request. Read-only questions do not authorize new registration. + +Git tracking permits automatic discovery but does not prove ownership. Exclude +installed third-party Skills and de-duplicate copies across host directories. Ignored +Skills are external installations; untracked, nonignored content needs ownership +assessment. A Skill authored in the current task stays self-owned before its first commit. +Non-same-repository material, including registered source repositories, is not repo-content. +Do not copy it here or pretend imports support is provided by this skill. + +## Edit and register + +Edit the real project files, preserving local conventions. For a new project suggest +only useful documents (for example overview, development, architecture, feature/spec, +operations); do not create empty template sets or invent facts. Keep feature/spec under +documentation engineering. Keep executable Skill resources with their `SKILL.md`. +When creating a new document, use the optional [document outlines](references/document-outlines.md) +to select only the sections needed for the reader's task; do not generate a template set. + +Read [registration.md](references/registration.md) when creating or changing a registry. +Register directories where appropriate: adding a file inside a registered directory +does not require another entry. Keep stable IDs when paths move. Do not change another +workspace's registry or infer cross-domain ownership. + +After an authorized registry edit, run `context source ensure repo-content --format json` +to maintain the view; use `context source inspect repo-content --format json` for a +read-only check. Ordinary files occupying a mount must be preserved. When symlinks are +disabled or targets are absent, use the registered real path and report the limitation; +do not change Git settings, checkout, clone or fetch just to repair the view. + +## Finish without triggering production + +Report the originals changed and registrations/views maintained, plus any unresolved +paths. Explain that linked knowledge may need a later impact check; do not automatically +run it during editing. For a separately requested knowledge update, return to the normal +Context update workflow with `repo-content:` as the source scope. + +Original documents remain authoritative. If the user explicitly asks to rewrite selected +content into knowledge for an audience, capture those selected originals as document +sources and follow ordinary production/review; do not mirror the whole registry. diff --git a/plugins/context/repo-install/claude/skills/context-repo-content/references/document-outlines.md b/plugins/context/repo-install/claude/skills/context-repo-content/references/document-outlines.md new file mode 100644 index 00000000..8f5c624f --- /dev/null +++ b/plugins/context/repo-install/claude/skills/context-repo-content/references/document-outlines.md @@ -0,0 +1,17 @@ +# Optional project document outlines + +Reuse existing documentation first. Write originals beside the project in `docs/` +or its existing README, never inside the `repo-content/` view. Use the user's language. +Only include confirmed facts and commands; incomplete scaffolds are not registered. +Missing recommended documents do not block onboarding or build. + +| Document | Useful outline | +| --- | --- | +| `README.md` or `docs/README.md` | Project scope, document entrances organized by reader task, a link to the authored Skills directory, and maintenance conventions. Do not hand-maintain a Skills list: the repository content page derives it from `SKILL.md`. | +| `docs/development.md` | Environment prerequisites, startup steps, verification commands, debugging, and contribution conventions. | +| `docs/architecture.md` | Module responsibilities, important interactions, external dependencies, and design constraints. | + +Add feature/spec, API, deployment, troubleshooting or design-decision documents only +when the task needs them. Feature/spec remains documentation, not a separate content system. +Repeatable operational procedures may become authored Skills. Skills can reference +docs without copying their contents; keep executable resources with their Skill. diff --git a/plugins/context/repo-install/claude/skills/context-repo-content/references/registration.md b/plugins/context/repo-install/claude/skills/context-repo-content/references/registration.md new file mode 100644 index 00000000..6b6b6d39 --- /dev/null +++ b/plugins/context/repo-install/claude/skills/context-repo-content/references/registration.md @@ -0,0 +1,48 @@ +# Registration and reading + +Workspace-root `repo-content.yaml`: + +```yaml +protocol: context.repo-content/v1 +entries: + cli-docs: + kind: docs + group: cli + path: packages/cli/docs + exclude: [drafts/**] + cli-skills: + kind: skills + group: cli + path: packages/cli/.agents/skills +``` + +Kinds: `docs` (document directory), `document` (single file), `skills` (Skills directory), +`skill` (single Skill directory). Optional `title` and `description` describe the entry. +Default mount is `[group/]`; use optional `mount` only to resolve +collisions, keeping at most one group and agreeing with `group`. Do not register nested +overlapping entries, submodules, external paths, or an ancestor containing Context itself. +`exclude` is an entry-relative glob, not an access-control mechanism. No remote, branch, +commit or per-file digest ledger belongs in this registry. + +Navigation: `[Development](context:repo/cli-docs/development.md)` resolves to the original +repository in built articles; it is not an immutable evidence claim. Evidence instead uses +`source_ref: repo-content:cli-docs@` and `locator.path` equal to the real +repository-root-relative path **at the time read**. Preserve line range and content digest. +Use `+worktree` after the SHA for changed working content; do not present its HEAD URL as +the exact evidence. Moving an entry does not change historical locator paths or old SHAs. + +Local search can use `rg -L` over the selected view. On a checkout with symlink placeholders, +read the same registry and search real paths instead. Remote readers use the same fixed +commit throughout. Only explicit `Meta.Coverage.Symlinks: followed` supports assuming +the view was followed; mixed, not_followed, missing coverage or expansion limits require +checking the registry and relevant real-path coverage. Missing indexed targets are a +coverage gap, not proof content does not exist. Never request index-management rights +or silently switch to a newer commit to fill it. + +Optional packaging: `kbPackage({ name: "project-kb", repoContentPage: true })` emits +only a README/Skill-summary entrance in `wikis/`, not ordinary docs or Skill scripts. +`repoContentPage: { site: true }` additionally exposes it on a configured website. +Existing packages remain unchanged. For a new workspace with registered content, +include the entrance in the selected KB output, but keep website exposure opt-in. +Check README suitability before making it public. Do not enable extra output channels +or rewrite existing package choices without the user's request. diff --git a/plugins/context/repo-install/codex/.codex-plugin/plugin.json b/plugins/context/repo-install/codex/.codex-plugin/plugin.json index 2f40035e..6e0815fe 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.43", + "version": "0.7.50", "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.43", + "shortDescription": "Initialize and advance a local, source-linked project knowledge workspace.\nv0.7.50", "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/codex/skills/context-inspect-search/SKILL.md b/plugins/context/repo-install/codex/skills/context-inspect-search/SKILL.md index 8bf376e2..199318b1 100644 --- a/plugins/context/repo-install/codex/skills/context-inspect-search/SKILL.md +++ b/plugins/context/repo-install/codex/skills/context-inspect-search/SKILL.md @@ -58,6 +58,31 @@ access policies, not additional source-order modes or production settings. ## Search available material according to the configured mode +Same-repository original documents and authored Skills may be registered in +workspace-root `repo-content.yaml`, with a `repo-content/` symlink view. They are +another readable entrance, not duplicated approved knowledge. Select them when +the question concerns the project's documentation or operational instructions; +keep the existing knowledge-first path for synthesized knowledge questions. +Read-only retrieval never registers content or starts production. Reading a +Skill as evidence does not activate or authorize executing it. + +Locally, use `rg -L` on selected views, or search the registry's real paths if +symlinks are disabled. Remotely, include the relevant view paths alongside +knowledge paths rather than blindly restricting all searches to `knowledge/**`. +Only explicit `Meta.Coverage.Symlinks: followed` permits relying on followed +views, and still respect result/expansion limits. If absent, mixed, not_followed +or incomplete, read that same commit's `repo-content.yaml`, then search the +registered real paths; verify they are covered at that commit. Do not treat an +unindexed target or placeholder as an empty document, silently change SHA, or +request indexing privileges. Keep real repository paths and actual commits in +citations; results through multiple views can refer to the same file. + +`repo-content:@` evidence locators are historical repository-root +paths, not entry-relative paths. Do not prepend current registration or workspace +paths. `+worktree` means uncommitted evidence: retain that limitation and digest, +never substitute a fixed HEAD URL as if it contains the cited bytes. Navigation +`context:repo/` resolves current registration, not historical provenance. + Apply `CONTEXT_QUERY_SOURCE_MODE` before starting retrieval. In `repo-first`, start with approved knowledge, not original-code checkout preparation. With readable `knowledge/`, do not probe `dist/`, build inventories or installed diff --git a/plugins/context/repo-install/codex/skills/context-plan/SKILL.md b/plugins/context/repo-install/codex/skills/context-plan/SKILL.md index 1448f641..99407708 100644 --- a/plugins/context/repo-install/codex/skills/context-plan/SKILL.md +++ b/plugins/context/repo-install/codex/skills/context-plan/SKILL.md @@ -26,6 +26,13 @@ are met or when the user explicitly requests project planning. ## Start or resume +Choose the workspace boundary before source inventory. A large multi-domain +monorepo may need a Context workspace per direction, with at most one project +group inside each; do not migrate existing workspaces automatically. For +same-repository docs and authored Skills, prefer registration/editing through +`context-repo-content` and preserve one original. Only explicit rewriting for +an audience enters production; do not count registration as completed research. + 1. Identify the selected project's existing `PLAN-*.md`, workspace and Git root. Reuse a matching plan. Inspect its current stage, permissions and pending work before expanding research; do not reset a live Context workflow. diff --git a/plugins/context/repo-install/codex/skills/context-repo-content/SKILL.md b/plugins/context/repo-install/codex/skills/context-repo-content/SKILL.md new file mode 100644 index 00000000..ba6b5dcd --- /dev/null +++ b/plugins/context/repo-install/codex/skills/context-repo-content/SKILL.md @@ -0,0 +1,61 @@ +--- +name: context-repo-content +description: Create, edit or register same-repository project documentation and authored Skills in a Context workspace without starting knowledge production. Use for repository content maintenance, not external imports or knowledge article publication. +--- + +# Repository content + +Maintain one original in its project directory. `repo-content.yaml` registers entrances; +`repo-content/` is a relative-symlink view, not a second authoring location. +This is an independent editing task: do not start production, update knowledge, +advance processed baselines, build, publish or commit merely because a document changed. + +## Select the boundary + +Identify the Context workspace and its Git root before writing. Paths in the registry +are relative to that Git root, even when Context lives in a subdirectory. +For a large multi-domain monorepo, use a workspace per direction; within one workspace +use at most one project/module group. Preserve existing workspaces and user choices. + +During authorized onboarding, examine only the selected project's tracked README, +`docs/`, and `.agents/skills`, `.claude/skills`, `.codex/skills`. Select useful directories, +not every Markdown file in the repository. Other locations require an explicit user +request. Read-only questions do not authorize new registration. + +Git tracking permits automatic discovery but does not prove ownership. Exclude +installed third-party Skills and de-duplicate copies across host directories. Ignored +Skills are external installations; untracked, nonignored content needs ownership +assessment. A Skill authored in the current task stays self-owned before its first commit. +Non-same-repository material, including registered source repositories, is not repo-content. +Do not copy it here or pretend imports support is provided by this skill. + +## Edit and register + +Edit the real project files, preserving local conventions. For a new project suggest +only useful documents (for example overview, development, architecture, feature/spec, +operations); do not create empty template sets or invent facts. Keep feature/spec under +documentation engineering. Keep executable Skill resources with their `SKILL.md`. +When creating a new document, use the optional [document outlines](references/document-outlines.md) +to select only the sections needed for the reader's task; do not generate a template set. + +Read [registration.md](references/registration.md) when creating or changing a registry. +Register directories where appropriate: adding a file inside a registered directory +does not require another entry. Keep stable IDs when paths move. Do not change another +workspace's registry or infer cross-domain ownership. + +After an authorized registry edit, run `context source ensure repo-content --format json` +to maintain the view; use `context source inspect repo-content --format json` for a +read-only check. Ordinary files occupying a mount must be preserved. When symlinks are +disabled or targets are absent, use the registered real path and report the limitation; +do not change Git settings, checkout, clone or fetch just to repair the view. + +## Finish without triggering production + +Report the originals changed and registrations/views maintained, plus any unresolved +paths. Explain that linked knowledge may need a later impact check; do not automatically +run it during editing. For a separately requested knowledge update, return to the normal +Context update workflow with `repo-content:` as the source scope. + +Original documents remain authoritative. If the user explicitly asks to rewrite selected +content into knowledge for an audience, capture those selected originals as document +sources and follow ordinary production/review; do not mirror the whole registry. diff --git a/plugins/context/repo-install/codex/skills/context-repo-content/references/document-outlines.md b/plugins/context/repo-install/codex/skills/context-repo-content/references/document-outlines.md new file mode 100644 index 00000000..8f5c624f --- /dev/null +++ b/plugins/context/repo-install/codex/skills/context-repo-content/references/document-outlines.md @@ -0,0 +1,17 @@ +# Optional project document outlines + +Reuse existing documentation first. Write originals beside the project in `docs/` +or its existing README, never inside the `repo-content/` view. Use the user's language. +Only include confirmed facts and commands; incomplete scaffolds are not registered. +Missing recommended documents do not block onboarding or build. + +| Document | Useful outline | +| --- | --- | +| `README.md` or `docs/README.md` | Project scope, document entrances organized by reader task, a link to the authored Skills directory, and maintenance conventions. Do not hand-maintain a Skills list: the repository content page derives it from `SKILL.md`. | +| `docs/development.md` | Environment prerequisites, startup steps, verification commands, debugging, and contribution conventions. | +| `docs/architecture.md` | Module responsibilities, important interactions, external dependencies, and design constraints. | + +Add feature/spec, API, deployment, troubleshooting or design-decision documents only +when the task needs them. Feature/spec remains documentation, not a separate content system. +Repeatable operational procedures may become authored Skills. Skills can reference +docs without copying their contents; keep executable resources with their Skill. diff --git a/plugins/context/repo-install/codex/skills/context-repo-content/references/registration.md b/plugins/context/repo-install/codex/skills/context-repo-content/references/registration.md new file mode 100644 index 00000000..6b6b6d39 --- /dev/null +++ b/plugins/context/repo-install/codex/skills/context-repo-content/references/registration.md @@ -0,0 +1,48 @@ +# Registration and reading + +Workspace-root `repo-content.yaml`: + +```yaml +protocol: context.repo-content/v1 +entries: + cli-docs: + kind: docs + group: cli + path: packages/cli/docs + exclude: [drafts/**] + cli-skills: + kind: skills + group: cli + path: packages/cli/.agents/skills +``` + +Kinds: `docs` (document directory), `document` (single file), `skills` (Skills directory), +`skill` (single Skill directory). Optional `title` and `description` describe the entry. +Default mount is `[group/]`; use optional `mount` only to resolve +collisions, keeping at most one group and agreeing with `group`. Do not register nested +overlapping entries, submodules, external paths, or an ancestor containing Context itself. +`exclude` is an entry-relative glob, not an access-control mechanism. No remote, branch, +commit or per-file digest ledger belongs in this registry. + +Navigation: `[Development](context:repo/cli-docs/development.md)` resolves to the original +repository in built articles; it is not an immutable evidence claim. Evidence instead uses +`source_ref: repo-content:cli-docs@` and `locator.path` equal to the real +repository-root-relative path **at the time read**. Preserve line range and content digest. +Use `+worktree` after the SHA for changed working content; do not present its HEAD URL as +the exact evidence. Moving an entry does not change historical locator paths or old SHAs. + +Local search can use `rg -L` over the selected view. On a checkout with symlink placeholders, +read the same registry and search real paths instead. Remote readers use the same fixed +commit throughout. Only explicit `Meta.Coverage.Symlinks: followed` supports assuming +the view was followed; mixed, not_followed, missing coverage or expansion limits require +checking the registry and relevant real-path coverage. Missing indexed targets are a +coverage gap, not proof content does not exist. Never request index-management rights +or silently switch to a newer commit to fill it. + +Optional packaging: `kbPackage({ name: "project-kb", repoContentPage: true })` emits +only a README/Skill-summary entrance in `wikis/`, not ordinary docs or Skill scripts. +`repoContentPage: { site: true }` additionally exposes it on a configured website. +Existing packages remain unchanged. For a new workspace with registered content, +include the entrance in the selected KB output, but keep website exposure opt-in. +Check README suitability before making it public. Do not enable extra output channels +or rewrite existing package choices without the user's request. diff --git a/plugins/context/repo-install/codex/skills/context/SKILL.md b/plugins/context/repo-install/codex/skills/context/SKILL.md index cfe46147..8a51e9c3 100644 --- a/plugins/context/repo-install/codex/skills/context/SKILL.md +++ b/plugins/context/repo-install/codex/skills/context/SKILL.md @@ -27,6 +27,14 @@ use this production workflow's current Route and preserve existing work. ## Route project-scale work before production +For creating, editing or registering same-repository docs and authored Skills, +use `context-repo-content` instead of starting production. Originals stay in their +project directories; registration/editing does not authorize knowledge updates. +During authorized onboarding, select only the current project's conventional +README/docs/Skill locations, with ownership and workspace boundaries from that +skill. Do not capture registered originals into sources merely to duplicate them +as knowledge. Explicit audience-oriented rewriting remains ordinary production. + For an authorized knowledge-production request involving more than 30 original documents or at least two repositories needing substantive investigation, use the installed `context-plan` Skill before registering the full request. Also use diff --git a/plugins/context/repo-install/cursor/.cursor-plugin/plugin.json b/plugins/context/repo-install/cursor/.cursor-plugin/plugin.json index 9fc61fcb..acb8ca73 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.43", + "version": "0.7.50", "description": "Start or continue a project-local knowledge workspace through one graph-routed entry.", "author": { "name": "Context4AI", @@ -28,6 +28,7 @@ "skills": [ "./skills/context-indexer-create", "./skills/context-inspect-search", - "./skills/context-plan" + "./skills/context-plan", + "./skills/context-repo-content" ] } diff --git a/plugins/context/repo-install/cursor/commands/c4a-context-inspect-search.md b/plugins/context/repo-install/cursor/commands/c4a-context-inspect-search.md index e73afc79..ba45aea0 100644 --- a/plugins/context/repo-install/cursor/commands/c4a-context-inspect-search.md +++ b/plugins/context/repo-install/cursor/commands/c4a-context-inspect-search.md @@ -57,6 +57,31 @@ access policies, not additional source-order modes or production settings. ## Search available material according to the configured mode +Same-repository original documents and authored Skills may be registered in +workspace-root `repo-content.yaml`, with a `repo-content/` symlink view. They are +another readable entrance, not duplicated approved knowledge. Select them when +the question concerns the project's documentation or operational instructions; +keep the existing knowledge-first path for synthesized knowledge questions. +Read-only retrieval never registers content or starts production. Reading a +Skill as evidence does not activate or authorize executing it. + +Locally, use `rg -L` on selected views, or search the registry's real paths if +symlinks are disabled. Remotely, include the relevant view paths alongside +knowledge paths rather than blindly restricting all searches to `knowledge/**`. +Only explicit `Meta.Coverage.Symlinks: followed` permits relying on followed +views, and still respect result/expansion limits. If absent, mixed, not_followed +or incomplete, read that same commit's `repo-content.yaml`, then search the +registered real paths; verify they are covered at that commit. Do not treat an +unindexed target or placeholder as an empty document, silently change SHA, or +request indexing privileges. Keep real repository paths and actual commits in +citations; results through multiple views can refer to the same file. + +`repo-content:@` evidence locators are historical repository-root +paths, not entry-relative paths. Do not prepend current registration or workspace +paths. `+worktree` means uncommitted evidence: retain that limitation and digest, +never substitute a fixed HEAD URL as if it contains the cited bytes. Navigation +`context:repo/` resolves current registration, not historical provenance. + Apply `CONTEXT_QUERY_SOURCE_MODE` before starting retrieval. In `repo-first`, start with approved knowledge, not original-code checkout preparation. With readable `knowledge/`, do not probe `dist/`, build inventories or installed diff --git a/plugins/context/repo-install/cursor/commands/c4a-context-repo-content.md b/plugins/context/repo-install/cursor/commands/c4a-context-repo-content.md new file mode 100644 index 00000000..b817fa4c --- /dev/null +++ b/plugins/context/repo-install/cursor/commands/c4a-context-repo-content.md @@ -0,0 +1,9 @@ +--- +description: "Create, edit or register same-repository project documentation and authored Skills in a Context workspace without starting knowledge production. Use for repository content maintenance, not external imports or knowledge article publication." +--- + +# Context Repo Content + +Read the installed `context-repo-content` skill at `../skills/context-repo-content/SKILL.md` +relative to this command file, then follow its instructions for the user's request. +Resolve its references and templates from that skill directory. diff --git a/plugins/context/repo-install/cursor/commands/c4a-context.md b/plugins/context/repo-install/cursor/commands/c4a-context.md index e7720918..51ce3908 100644 --- a/plugins/context/repo-install/cursor/commands/c4a-context.md +++ b/plugins/context/repo-install/cursor/commands/c4a-context.md @@ -24,6 +24,14 @@ use this production workflow's current Route and preserve existing work. ## Route project-scale work before production +For creating, editing or registering same-repository docs and authored Skills, +use `context-repo-content` instead of starting production. Originals stay in their +project directories; registration/editing does not authorize knowledge updates. +During authorized onboarding, select only the current project's conventional +README/docs/Skill locations, with ownership and workspace boundaries from that +skill. Do not capture registered originals into sources merely to duplicate them +as knowledge. Explicit audience-oriented rewriting remains ordinary production. + For an authorized knowledge-production request involving more than 30 original documents or at least two repositories needing substantive investigation, use the installed `context-plan` Skill before registering the full request. Also use diff --git a/plugins/context/repo-install/cursor/skills/context-inspect-search/SKILL.md b/plugins/context/repo-install/cursor/skills/context-inspect-search/SKILL.md index 8bf376e2..199318b1 100644 --- a/plugins/context/repo-install/cursor/skills/context-inspect-search/SKILL.md +++ b/plugins/context/repo-install/cursor/skills/context-inspect-search/SKILL.md @@ -58,6 +58,31 @@ access policies, not additional source-order modes or production settings. ## Search available material according to the configured mode +Same-repository original documents and authored Skills may be registered in +workspace-root `repo-content.yaml`, with a `repo-content/` symlink view. They are +another readable entrance, not duplicated approved knowledge. Select them when +the question concerns the project's documentation or operational instructions; +keep the existing knowledge-first path for synthesized knowledge questions. +Read-only retrieval never registers content or starts production. Reading a +Skill as evidence does not activate or authorize executing it. + +Locally, use `rg -L` on selected views, or search the registry's real paths if +symlinks are disabled. Remotely, include the relevant view paths alongside +knowledge paths rather than blindly restricting all searches to `knowledge/**`. +Only explicit `Meta.Coverage.Symlinks: followed` permits relying on followed +views, and still respect result/expansion limits. If absent, mixed, not_followed +or incomplete, read that same commit's `repo-content.yaml`, then search the +registered real paths; verify they are covered at that commit. Do not treat an +unindexed target or placeholder as an empty document, silently change SHA, or +request indexing privileges. Keep real repository paths and actual commits in +citations; results through multiple views can refer to the same file. + +`repo-content:@` evidence locators are historical repository-root +paths, not entry-relative paths. Do not prepend current registration or workspace +paths. `+worktree` means uncommitted evidence: retain that limitation and digest, +never substitute a fixed HEAD URL as if it contains the cited bytes. Navigation +`context:repo/` resolves current registration, not historical provenance. + Apply `CONTEXT_QUERY_SOURCE_MODE` before starting retrieval. In `repo-first`, start with approved knowledge, not original-code checkout preparation. With readable `knowledge/`, do not probe `dist/`, build inventories or installed diff --git a/plugins/context/repo-install/cursor/skills/context-plan/SKILL.md b/plugins/context/repo-install/cursor/skills/context-plan/SKILL.md index fdf95a1b..52106ccb 100644 --- a/plugins/context/repo-install/cursor/skills/context-plan/SKILL.md +++ b/plugins/context/repo-install/cursor/skills/context-plan/SKILL.md @@ -27,6 +27,13 @@ are met or when the user explicitly requests project planning. ## Start or resume +Choose the workspace boundary before source inventory. A large multi-domain +monorepo may need a Context workspace per direction, with at most one project +group inside each; do not migrate existing workspaces automatically. For +same-repository docs and authored Skills, prefer registration/editing through +`context-repo-content` and preserve one original. Only explicit rewriting for +an audience enters production; do not count registration as completed research. + 1. Identify the selected project's existing `PLAN-*.md`, workspace and Git root. Reuse a matching plan. Inspect its current stage, permissions and pending work before expanding research; do not reset a live Context workflow. diff --git a/plugins/context/repo-install/cursor/skills/context-repo-content/SKILL.md b/plugins/context/repo-install/cursor/skills/context-repo-content/SKILL.md new file mode 100644 index 00000000..90d5af4a --- /dev/null +++ b/plugins/context/repo-install/cursor/skills/context-repo-content/SKILL.md @@ -0,0 +1,62 @@ +--- +user-invocable: false +name: context-repo-content +description: Create, edit or register same-repository project documentation and authored Skills in a Context workspace without starting knowledge production. Use for repository content maintenance, not external imports or knowledge article publication. +--- + +# Repository content + +Maintain one original in its project directory. `repo-content.yaml` registers entrances; +`repo-content/` is a relative-symlink view, not a second authoring location. +This is an independent editing task: do not start production, update knowledge, +advance processed baselines, build, publish or commit merely because a document changed. + +## Select the boundary + +Identify the Context workspace and its Git root before writing. Paths in the registry +are relative to that Git root, even when Context lives in a subdirectory. +For a large multi-domain monorepo, use a workspace per direction; within one workspace +use at most one project/module group. Preserve existing workspaces and user choices. + +During authorized onboarding, examine only the selected project's tracked README, +`docs/`, and `.agents/skills`, `.claude/skills`, `.codex/skills`. Select useful directories, +not every Markdown file in the repository. Other locations require an explicit user +request. Read-only questions do not authorize new registration. + +Git tracking permits automatic discovery but does not prove ownership. Exclude +installed third-party Skills and de-duplicate copies across host directories. Ignored +Skills are external installations; untracked, nonignored content needs ownership +assessment. A Skill authored in the current task stays self-owned before its first commit. +Non-same-repository material, including registered source repositories, is not repo-content. +Do not copy it here or pretend imports support is provided by this skill. + +## Edit and register + +Edit the real project files, preserving local conventions. For a new project suggest +only useful documents (for example overview, development, architecture, feature/spec, +operations); do not create empty template sets or invent facts. Keep feature/spec under +documentation engineering. Keep executable Skill resources with their `SKILL.md`. +When creating a new document, use the optional [document outlines](references/document-outlines.md) +to select only the sections needed for the reader's task; do not generate a template set. + +Read [registration.md](references/registration.md) when creating or changing a registry. +Register directories where appropriate: adding a file inside a registered directory +does not require another entry. Keep stable IDs when paths move. Do not change another +workspace's registry or infer cross-domain ownership. + +After an authorized registry edit, run `context source ensure repo-content --format json` +to maintain the view; use `context source inspect repo-content --format json` for a +read-only check. Ordinary files occupying a mount must be preserved. When symlinks are +disabled or targets are absent, use the registered real path and report the limitation; +do not change Git settings, checkout, clone or fetch just to repair the view. + +## Finish without triggering production + +Report the originals changed and registrations/views maintained, plus any unresolved +paths. Explain that linked knowledge may need a later impact check; do not automatically +run it during editing. For a separately requested knowledge update, return to the normal +Context update workflow with `repo-content:` as the source scope. + +Original documents remain authoritative. If the user explicitly asks to rewrite selected +content into knowledge for an audience, capture those selected originals as document +sources and follow ordinary production/review; do not mirror the whole registry. diff --git a/plugins/context/repo-install/cursor/skills/context-repo-content/references/document-outlines.md b/plugins/context/repo-install/cursor/skills/context-repo-content/references/document-outlines.md new file mode 100644 index 00000000..8f5c624f --- /dev/null +++ b/plugins/context/repo-install/cursor/skills/context-repo-content/references/document-outlines.md @@ -0,0 +1,17 @@ +# Optional project document outlines + +Reuse existing documentation first. Write originals beside the project in `docs/` +or its existing README, never inside the `repo-content/` view. Use the user's language. +Only include confirmed facts and commands; incomplete scaffolds are not registered. +Missing recommended documents do not block onboarding or build. + +| Document | Useful outline | +| --- | --- | +| `README.md` or `docs/README.md` | Project scope, document entrances organized by reader task, a link to the authored Skills directory, and maintenance conventions. Do not hand-maintain a Skills list: the repository content page derives it from `SKILL.md`. | +| `docs/development.md` | Environment prerequisites, startup steps, verification commands, debugging, and contribution conventions. | +| `docs/architecture.md` | Module responsibilities, important interactions, external dependencies, and design constraints. | + +Add feature/spec, API, deployment, troubleshooting or design-decision documents only +when the task needs them. Feature/spec remains documentation, not a separate content system. +Repeatable operational procedures may become authored Skills. Skills can reference +docs without copying their contents; keep executable resources with their Skill. diff --git a/plugins/context/repo-install/cursor/skills/context-repo-content/references/registration.md b/plugins/context/repo-install/cursor/skills/context-repo-content/references/registration.md new file mode 100644 index 00000000..6b6b6d39 --- /dev/null +++ b/plugins/context/repo-install/cursor/skills/context-repo-content/references/registration.md @@ -0,0 +1,48 @@ +# Registration and reading + +Workspace-root `repo-content.yaml`: + +```yaml +protocol: context.repo-content/v1 +entries: + cli-docs: + kind: docs + group: cli + path: packages/cli/docs + exclude: [drafts/**] + cli-skills: + kind: skills + group: cli + path: packages/cli/.agents/skills +``` + +Kinds: `docs` (document directory), `document` (single file), `skills` (Skills directory), +`skill` (single Skill directory). Optional `title` and `description` describe the entry. +Default mount is `[group/]`; use optional `mount` only to resolve +collisions, keeping at most one group and agreeing with `group`. Do not register nested +overlapping entries, submodules, external paths, or an ancestor containing Context itself. +`exclude` is an entry-relative glob, not an access-control mechanism. No remote, branch, +commit or per-file digest ledger belongs in this registry. + +Navigation: `[Development](context:repo/cli-docs/development.md)` resolves to the original +repository in built articles; it is not an immutable evidence claim. Evidence instead uses +`source_ref: repo-content:cli-docs@` and `locator.path` equal to the real +repository-root-relative path **at the time read**. Preserve line range and content digest. +Use `+worktree` after the SHA for changed working content; do not present its HEAD URL as +the exact evidence. Moving an entry does not change historical locator paths or old SHAs. + +Local search can use `rg -L` over the selected view. On a checkout with symlink placeholders, +read the same registry and search real paths instead. Remote readers use the same fixed +commit throughout. Only explicit `Meta.Coverage.Symlinks: followed` supports assuming +the view was followed; mixed, not_followed, missing coverage or expansion limits require +checking the registry and relevant real-path coverage. Missing indexed targets are a +coverage gap, not proof content does not exist. Never request index-management rights +or silently switch to a newer commit to fill it. + +Optional packaging: `kbPackage({ name: "project-kb", repoContentPage: true })` emits +only a README/Skill-summary entrance in `wikis/`, not ordinary docs or Skill scripts. +`repoContentPage: { site: true }` additionally exposes it on a configured website. +Existing packages remain unchanged. For a new workspace with registered content, +include the entrance in the selected KB output, but keep website exposure opt-in. +Check README suitability before making it public. Do not enable extra output channels +or rewrite existing package choices without the user's request. diff --git a/plugins/context/repo-install/skills/context-inspect-search/SKILL.md b/plugins/context/repo-install/skills/context-inspect-search/SKILL.md index 8bf376e2..199318b1 100644 --- a/plugins/context/repo-install/skills/context-inspect-search/SKILL.md +++ b/plugins/context/repo-install/skills/context-inspect-search/SKILL.md @@ -58,6 +58,31 @@ access policies, not additional source-order modes or production settings. ## Search available material according to the configured mode +Same-repository original documents and authored Skills may be registered in +workspace-root `repo-content.yaml`, with a `repo-content/` symlink view. They are +another readable entrance, not duplicated approved knowledge. Select them when +the question concerns the project's documentation or operational instructions; +keep the existing knowledge-first path for synthesized knowledge questions. +Read-only retrieval never registers content or starts production. Reading a +Skill as evidence does not activate or authorize executing it. + +Locally, use `rg -L` on selected views, or search the registry's real paths if +symlinks are disabled. Remotely, include the relevant view paths alongside +knowledge paths rather than blindly restricting all searches to `knowledge/**`. +Only explicit `Meta.Coverage.Symlinks: followed` permits relying on followed +views, and still respect result/expansion limits. If absent, mixed, not_followed +or incomplete, read that same commit's `repo-content.yaml`, then search the +registered real paths; verify they are covered at that commit. Do not treat an +unindexed target or placeholder as an empty document, silently change SHA, or +request indexing privileges. Keep real repository paths and actual commits in +citations; results through multiple views can refer to the same file. + +`repo-content:@` evidence locators are historical repository-root +paths, not entry-relative paths. Do not prepend current registration or workspace +paths. `+worktree` means uncommitted evidence: retain that limitation and digest, +never substitute a fixed HEAD URL as if it contains the cited bytes. Navigation +`context:repo/` resolves current registration, not historical provenance. + Apply `CONTEXT_QUERY_SOURCE_MODE` before starting retrieval. In `repo-first`, start with approved knowledge, not original-code checkout preparation. With readable `knowledge/`, do not probe `dist/`, build inventories or installed diff --git a/plugins/context/repo-install/skills/context-markdown-indexer/SKILL.md b/plugins/context/repo-install/skills/context-markdown-indexer/SKILL.md index 5378f748..65a3654f 100644 --- a/plugins/context/repo-install/skills/context-markdown-indexer/SKILL.md +++ b/plugins/context/repo-install/skills/context-markdown-indexer/SKILL.md @@ -8,6 +8,12 @@ metadata: # Context Markdown Indexer +Same-repository docs and authored Skills registered as repo-content remain +originals, not automatic capture or rewriting candidates. Use them as bounded +supporting evidence with their real repository path and read revision. Capture +and rewrite selected originals only when the user explicitly requests knowledge +production for an audience; registration alone is not that request. + Use the current stage's reader purpose, authorized sources and submission schema. Read [planning](references/semantic-planning.md) during investigation and [writing](references/indexer.md) when writing. Consult diff --git a/plugins/context/repo-install/skills/context-plan/SKILL.md b/plugins/context/repo-install/skills/context-plan/SKILL.md index 1448f641..99407708 100644 --- a/plugins/context/repo-install/skills/context-plan/SKILL.md +++ b/plugins/context/repo-install/skills/context-plan/SKILL.md @@ -26,6 +26,13 @@ are met or when the user explicitly requests project planning. ## Start or resume +Choose the workspace boundary before source inventory. A large multi-domain +monorepo may need a Context workspace per direction, with at most one project +group inside each; do not migrate existing workspaces automatically. For +same-repository docs and authored Skills, prefer registration/editing through +`context-repo-content` and preserve one original. Only explicit rewriting for +an audience enters production; do not count registration as completed research. + 1. Identify the selected project's existing `PLAN-*.md`, workspace and Git root. Reuse a matching plan. Inspect its current stage, permissions and pending work before expanding research; do not reset a live Context workflow. diff --git a/plugins/context/repo-install/skills/context-repo-content/SKILL.md b/plugins/context/repo-install/skills/context-repo-content/SKILL.md new file mode 100644 index 00000000..ba6b5dcd --- /dev/null +++ b/plugins/context/repo-install/skills/context-repo-content/SKILL.md @@ -0,0 +1,61 @@ +--- +name: context-repo-content +description: Create, edit or register same-repository project documentation and authored Skills in a Context workspace without starting knowledge production. Use for repository content maintenance, not external imports or knowledge article publication. +--- + +# Repository content + +Maintain one original in its project directory. `repo-content.yaml` registers entrances; +`repo-content/` is a relative-symlink view, not a second authoring location. +This is an independent editing task: do not start production, update knowledge, +advance processed baselines, build, publish or commit merely because a document changed. + +## Select the boundary + +Identify the Context workspace and its Git root before writing. Paths in the registry +are relative to that Git root, even when Context lives in a subdirectory. +For a large multi-domain monorepo, use a workspace per direction; within one workspace +use at most one project/module group. Preserve existing workspaces and user choices. + +During authorized onboarding, examine only the selected project's tracked README, +`docs/`, and `.agents/skills`, `.claude/skills`, `.codex/skills`. Select useful directories, +not every Markdown file in the repository. Other locations require an explicit user +request. Read-only questions do not authorize new registration. + +Git tracking permits automatic discovery but does not prove ownership. Exclude +installed third-party Skills and de-duplicate copies across host directories. Ignored +Skills are external installations; untracked, nonignored content needs ownership +assessment. A Skill authored in the current task stays self-owned before its first commit. +Non-same-repository material, including registered source repositories, is not repo-content. +Do not copy it here or pretend imports support is provided by this skill. + +## Edit and register + +Edit the real project files, preserving local conventions. For a new project suggest +only useful documents (for example overview, development, architecture, feature/spec, +operations); do not create empty template sets or invent facts. Keep feature/spec under +documentation engineering. Keep executable Skill resources with their `SKILL.md`. +When creating a new document, use the optional [document outlines](references/document-outlines.md) +to select only the sections needed for the reader's task; do not generate a template set. + +Read [registration.md](references/registration.md) when creating or changing a registry. +Register directories where appropriate: adding a file inside a registered directory +does not require another entry. Keep stable IDs when paths move. Do not change another +workspace's registry or infer cross-domain ownership. + +After an authorized registry edit, run `context source ensure repo-content --format json` +to maintain the view; use `context source inspect repo-content --format json` for a +read-only check. Ordinary files occupying a mount must be preserved. When symlinks are +disabled or targets are absent, use the registered real path and report the limitation; +do not change Git settings, checkout, clone or fetch just to repair the view. + +## Finish without triggering production + +Report the originals changed and registrations/views maintained, plus any unresolved +paths. Explain that linked knowledge may need a later impact check; do not automatically +run it during editing. For a separately requested knowledge update, return to the normal +Context update workflow with `repo-content:` as the source scope. + +Original documents remain authoritative. If the user explicitly asks to rewrite selected +content into knowledge for an audience, capture those selected originals as document +sources and follow ordinary production/review; do not mirror the whole registry. diff --git a/plugins/context/repo-install/skills/context-repo-content/references/document-outlines.md b/plugins/context/repo-install/skills/context-repo-content/references/document-outlines.md new file mode 100644 index 00000000..8f5c624f --- /dev/null +++ b/plugins/context/repo-install/skills/context-repo-content/references/document-outlines.md @@ -0,0 +1,17 @@ +# Optional project document outlines + +Reuse existing documentation first. Write originals beside the project in `docs/` +or its existing README, never inside the `repo-content/` view. Use the user's language. +Only include confirmed facts and commands; incomplete scaffolds are not registered. +Missing recommended documents do not block onboarding or build. + +| Document | Useful outline | +| --- | --- | +| `README.md` or `docs/README.md` | Project scope, document entrances organized by reader task, a link to the authored Skills directory, and maintenance conventions. Do not hand-maintain a Skills list: the repository content page derives it from `SKILL.md`. | +| `docs/development.md` | Environment prerequisites, startup steps, verification commands, debugging, and contribution conventions. | +| `docs/architecture.md` | Module responsibilities, important interactions, external dependencies, and design constraints. | + +Add feature/spec, API, deployment, troubleshooting or design-decision documents only +when the task needs them. Feature/spec remains documentation, not a separate content system. +Repeatable operational procedures may become authored Skills. Skills can reference +docs without copying their contents; keep executable resources with their Skill. diff --git a/plugins/context/repo-install/skills/context-repo-content/references/registration.md b/plugins/context/repo-install/skills/context-repo-content/references/registration.md new file mode 100644 index 00000000..6b6b6d39 --- /dev/null +++ b/plugins/context/repo-install/skills/context-repo-content/references/registration.md @@ -0,0 +1,48 @@ +# Registration and reading + +Workspace-root `repo-content.yaml`: + +```yaml +protocol: context.repo-content/v1 +entries: + cli-docs: + kind: docs + group: cli + path: packages/cli/docs + exclude: [drafts/**] + cli-skills: + kind: skills + group: cli + path: packages/cli/.agents/skills +``` + +Kinds: `docs` (document directory), `document` (single file), `skills` (Skills directory), +`skill` (single Skill directory). Optional `title` and `description` describe the entry. +Default mount is `[group/]`; use optional `mount` only to resolve +collisions, keeping at most one group and agreeing with `group`. Do not register nested +overlapping entries, submodules, external paths, or an ancestor containing Context itself. +`exclude` is an entry-relative glob, not an access-control mechanism. No remote, branch, +commit or per-file digest ledger belongs in this registry. + +Navigation: `[Development](context:repo/cli-docs/development.md)` resolves to the original +repository in built articles; it is not an immutable evidence claim. Evidence instead uses +`source_ref: repo-content:cli-docs@` and `locator.path` equal to the real +repository-root-relative path **at the time read**. Preserve line range and content digest. +Use `+worktree` after the SHA for changed working content; do not present its HEAD URL as +the exact evidence. Moving an entry does not change historical locator paths or old SHAs. + +Local search can use `rg -L` over the selected view. On a checkout with symlink placeholders, +read the same registry and search real paths instead. Remote readers use the same fixed +commit throughout. Only explicit `Meta.Coverage.Symlinks: followed` supports assuming +the view was followed; mixed, not_followed, missing coverage or expansion limits require +checking the registry and relevant real-path coverage. Missing indexed targets are a +coverage gap, not proof content does not exist. Never request index-management rights +or silently switch to a newer commit to fill it. + +Optional packaging: `kbPackage({ name: "project-kb", repoContentPage: true })` emits +only a README/Skill-summary entrance in `wikis/`, not ordinary docs or Skill scripts. +`repoContentPage: { site: true }` additionally exposes it on a configured website. +Existing packages remain unchanged. For a new workspace with registered content, +include the entrance in the selected KB output, but keep website exposure opt-in. +Check README suitability before making it public. Do not enable extra output channels +or rewrite existing package choices without the user's request. diff --git a/plugins/context/repo-install/skills/context/SKILL.md b/plugins/context/repo-install/skills/context/SKILL.md index cfe46147..8a51e9c3 100644 --- a/plugins/context/repo-install/skills/context/SKILL.md +++ b/plugins/context/repo-install/skills/context/SKILL.md @@ -27,6 +27,14 @@ use this production workflow's current Route and preserve existing work. ## Route project-scale work before production +For creating, editing or registering same-repository docs and authored Skills, +use `context-repo-content` instead of starting production. Originals stay in their +project directories; registration/editing does not authorize knowledge updates. +During authorized onboarding, select only the current project's conventional +README/docs/Skill locations, with ownership and workspace boundaries from that +skill. Do not capture registered originals into sources merely to duplicate them +as knowledge. Explicit audience-oriented rewriting remains ordinary production. + For an authorized knowledge-production request involving more than 30 original documents or at least two repositories needing substantive investigation, use the installed `context-plan` Skill before registering the full request. Also use diff --git a/plugins/context/skills/context-inspect-search/SKILL.md b/plugins/context/skills/context-inspect-search/SKILL.md index 8bf376e2..199318b1 100644 --- a/plugins/context/skills/context-inspect-search/SKILL.md +++ b/plugins/context/skills/context-inspect-search/SKILL.md @@ -58,6 +58,31 @@ access policies, not additional source-order modes or production settings. ## Search available material according to the configured mode +Same-repository original documents and authored Skills may be registered in +workspace-root `repo-content.yaml`, with a `repo-content/` symlink view. They are +another readable entrance, not duplicated approved knowledge. Select them when +the question concerns the project's documentation or operational instructions; +keep the existing knowledge-first path for synthesized knowledge questions. +Read-only retrieval never registers content or starts production. Reading a +Skill as evidence does not activate or authorize executing it. + +Locally, use `rg -L` on selected views, or search the registry's real paths if +symlinks are disabled. Remotely, include the relevant view paths alongside +knowledge paths rather than blindly restricting all searches to `knowledge/**`. +Only explicit `Meta.Coverage.Symlinks: followed` permits relying on followed +views, and still respect result/expansion limits. If absent, mixed, not_followed +or incomplete, read that same commit's `repo-content.yaml`, then search the +registered real paths; verify they are covered at that commit. Do not treat an +unindexed target or placeholder as an empty document, silently change SHA, or +request indexing privileges. Keep real repository paths and actual commits in +citations; results through multiple views can refer to the same file. + +`repo-content:@` evidence locators are historical repository-root +paths, not entry-relative paths. Do not prepend current registration or workspace +paths. `+worktree` means uncommitted evidence: retain that limitation and digest, +never substitute a fixed HEAD URL as if it contains the cited bytes. Navigation +`context:repo/` resolves current registration, not historical provenance. + Apply `CONTEXT_QUERY_SOURCE_MODE` before starting retrieval. In `repo-first`, start with approved knowledge, not original-code checkout preparation. With readable `knowledge/`, do not probe `dist/`, build inventories or installed diff --git a/plugins/context/skills/context-markdown-indexer/SKILL.md b/plugins/context/skills/context-markdown-indexer/SKILL.md index 5378f748..65a3654f 100644 --- a/plugins/context/skills/context-markdown-indexer/SKILL.md +++ b/plugins/context/skills/context-markdown-indexer/SKILL.md @@ -8,6 +8,12 @@ metadata: # Context Markdown Indexer +Same-repository docs and authored Skills registered as repo-content remain +originals, not automatic capture or rewriting candidates. Use them as bounded +supporting evidence with their real repository path and read revision. Capture +and rewrite selected originals only when the user explicitly requests knowledge +production for an audience; registration alone is not that request. + Use the current stage's reader purpose, authorized sources and submission schema. Read [planning](references/semantic-planning.md) during investigation and [writing](references/indexer.md) when writing. Consult diff --git a/plugins/context/skills/context-plan/SKILL.md b/plugins/context/skills/context-plan/SKILL.md index 1448f641..99407708 100644 --- a/plugins/context/skills/context-plan/SKILL.md +++ b/plugins/context/skills/context-plan/SKILL.md @@ -26,6 +26,13 @@ are met or when the user explicitly requests project planning. ## Start or resume +Choose the workspace boundary before source inventory. A large multi-domain +monorepo may need a Context workspace per direction, with at most one project +group inside each; do not migrate existing workspaces automatically. For +same-repository docs and authored Skills, prefer registration/editing through +`context-repo-content` and preserve one original. Only explicit rewriting for +an audience enters production; do not count registration as completed research. + 1. Identify the selected project's existing `PLAN-*.md`, workspace and Git root. Reuse a matching plan. Inspect its current stage, permissions and pending work before expanding research; do not reset a live Context workflow. diff --git a/plugins/context/skills/context-repo-content/SKILL.md b/plugins/context/skills/context-repo-content/SKILL.md new file mode 100644 index 00000000..ba6b5dcd --- /dev/null +++ b/plugins/context/skills/context-repo-content/SKILL.md @@ -0,0 +1,61 @@ +--- +name: context-repo-content +description: Create, edit or register same-repository project documentation and authored Skills in a Context workspace without starting knowledge production. Use for repository content maintenance, not external imports or knowledge article publication. +--- + +# Repository content + +Maintain one original in its project directory. `repo-content.yaml` registers entrances; +`repo-content/` is a relative-symlink view, not a second authoring location. +This is an independent editing task: do not start production, update knowledge, +advance processed baselines, build, publish or commit merely because a document changed. + +## Select the boundary + +Identify the Context workspace and its Git root before writing. Paths in the registry +are relative to that Git root, even when Context lives in a subdirectory. +For a large multi-domain monorepo, use a workspace per direction; within one workspace +use at most one project/module group. Preserve existing workspaces and user choices. + +During authorized onboarding, examine only the selected project's tracked README, +`docs/`, and `.agents/skills`, `.claude/skills`, `.codex/skills`. Select useful directories, +not every Markdown file in the repository. Other locations require an explicit user +request. Read-only questions do not authorize new registration. + +Git tracking permits automatic discovery but does not prove ownership. Exclude +installed third-party Skills and de-duplicate copies across host directories. Ignored +Skills are external installations; untracked, nonignored content needs ownership +assessment. A Skill authored in the current task stays self-owned before its first commit. +Non-same-repository material, including registered source repositories, is not repo-content. +Do not copy it here or pretend imports support is provided by this skill. + +## Edit and register + +Edit the real project files, preserving local conventions. For a new project suggest +only useful documents (for example overview, development, architecture, feature/spec, +operations); do not create empty template sets or invent facts. Keep feature/spec under +documentation engineering. Keep executable Skill resources with their `SKILL.md`. +When creating a new document, use the optional [document outlines](references/document-outlines.md) +to select only the sections needed for the reader's task; do not generate a template set. + +Read [registration.md](references/registration.md) when creating or changing a registry. +Register directories where appropriate: adding a file inside a registered directory +does not require another entry. Keep stable IDs when paths move. Do not change another +workspace's registry or infer cross-domain ownership. + +After an authorized registry edit, run `context source ensure repo-content --format json` +to maintain the view; use `context source inspect repo-content --format json` for a +read-only check. Ordinary files occupying a mount must be preserved. When symlinks are +disabled or targets are absent, use the registered real path and report the limitation; +do not change Git settings, checkout, clone or fetch just to repair the view. + +## Finish without triggering production + +Report the originals changed and registrations/views maintained, plus any unresolved +paths. Explain that linked knowledge may need a later impact check; do not automatically +run it during editing. For a separately requested knowledge update, return to the normal +Context update workflow with `repo-content:` as the source scope. + +Original documents remain authoritative. If the user explicitly asks to rewrite selected +content into knowledge for an audience, capture those selected originals as document +sources and follow ordinary production/review; do not mirror the whole registry. diff --git a/plugins/context/skills/context-repo-content/references/document-outlines.md b/plugins/context/skills/context-repo-content/references/document-outlines.md new file mode 100644 index 00000000..8f5c624f --- /dev/null +++ b/plugins/context/skills/context-repo-content/references/document-outlines.md @@ -0,0 +1,17 @@ +# Optional project document outlines + +Reuse existing documentation first. Write originals beside the project in `docs/` +or its existing README, never inside the `repo-content/` view. Use the user's language. +Only include confirmed facts and commands; incomplete scaffolds are not registered. +Missing recommended documents do not block onboarding or build. + +| Document | Useful outline | +| --- | --- | +| `README.md` or `docs/README.md` | Project scope, document entrances organized by reader task, a link to the authored Skills directory, and maintenance conventions. Do not hand-maintain a Skills list: the repository content page derives it from `SKILL.md`. | +| `docs/development.md` | Environment prerequisites, startup steps, verification commands, debugging, and contribution conventions. | +| `docs/architecture.md` | Module responsibilities, important interactions, external dependencies, and design constraints. | + +Add feature/spec, API, deployment, troubleshooting or design-decision documents only +when the task needs them. Feature/spec remains documentation, not a separate content system. +Repeatable operational procedures may become authored Skills. Skills can reference +docs without copying their contents; keep executable resources with their Skill. diff --git a/plugins/context/skills/context-repo-content/references/registration.md b/plugins/context/skills/context-repo-content/references/registration.md new file mode 100644 index 00000000..6b6b6d39 --- /dev/null +++ b/plugins/context/skills/context-repo-content/references/registration.md @@ -0,0 +1,48 @@ +# Registration and reading + +Workspace-root `repo-content.yaml`: + +```yaml +protocol: context.repo-content/v1 +entries: + cli-docs: + kind: docs + group: cli + path: packages/cli/docs + exclude: [drafts/**] + cli-skills: + kind: skills + group: cli + path: packages/cli/.agents/skills +``` + +Kinds: `docs` (document directory), `document` (single file), `skills` (Skills directory), +`skill` (single Skill directory). Optional `title` and `description` describe the entry. +Default mount is `[group/]`; use optional `mount` only to resolve +collisions, keeping at most one group and agreeing with `group`. Do not register nested +overlapping entries, submodules, external paths, or an ancestor containing Context itself. +`exclude` is an entry-relative glob, not an access-control mechanism. No remote, branch, +commit or per-file digest ledger belongs in this registry. + +Navigation: `[Development](context:repo/cli-docs/development.md)` resolves to the original +repository in built articles; it is not an immutable evidence claim. Evidence instead uses +`source_ref: repo-content:cli-docs@` and `locator.path` equal to the real +repository-root-relative path **at the time read**. Preserve line range and content digest. +Use `+worktree` after the SHA for changed working content; do not present its HEAD URL as +the exact evidence. Moving an entry does not change historical locator paths or old SHAs. + +Local search can use `rg -L` over the selected view. On a checkout with symlink placeholders, +read the same registry and search real paths instead. Remote readers use the same fixed +commit throughout. Only explicit `Meta.Coverage.Symlinks: followed` supports assuming +the view was followed; mixed, not_followed, missing coverage or expansion limits require +checking the registry and relevant real-path coverage. Missing indexed targets are a +coverage gap, not proof content does not exist. Never request index-management rights +or silently switch to a newer commit to fill it. + +Optional packaging: `kbPackage({ name: "project-kb", repoContentPage: true })` emits +only a README/Skill-summary entrance in `wikis/`, not ordinary docs or Skill scripts. +`repoContentPage: { site: true }` additionally exposes it on a configured website. +Existing packages remain unchanged. For a new workspace with registered content, +include the entrance in the selected KB output, but keep website exposure opt-in. +Check README suitability before making it public. Do not enable extra output channels +or rewrite existing package choices without the user's request. diff --git a/plugins/context/skills/context/SKILL.md b/plugins/context/skills/context/SKILL.md index cfe46147..8a51e9c3 100644 --- a/plugins/context/skills/context/SKILL.md +++ b/plugins/context/skills/context/SKILL.md @@ -27,6 +27,14 @@ use this production workflow's current Route and preserve existing work. ## Route project-scale work before production +For creating, editing or registering same-repository docs and authored Skills, +use `context-repo-content` instead of starting production. Originals stay in their +project directories; registration/editing does not authorize knowledge updates. +During authorized onboarding, select only the current project's conventional +README/docs/Skill locations, with ownership and workspace boundaries from that +skill. Do not capture registered originals into sources merely to duplicate them +as knowledge. Explicit audience-oriented rewriting remains ordinary production. + For an authorized knowledge-production request involving more than 30 original documents or at least two repositories needing substantive investigation, use the installed `context-plan` Skill before registering the full request. Also use From 945619c25222d5bdc599c93c7c1bc74dce98bc7d Mon Sep 17 00:00:00 2001 From: qiansc Date: Sat, 3 Oct 2026 17:11:27 +0800 Subject: [PATCH 2/3] fix(v0.7.50): include repository content guide in workflow bundle --- .../context-workflow/actions/repair-project-entry.yaml | 1 + packages/context-cli/src/lib/pathFreePathFieldInventory.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/context-cli/context-workflow/actions/repair-project-entry.yaml b/packages/context-cli/context-workflow/actions/repair-project-entry.yaml index a04f6fa7..f9c2e72c 100644 --- a/packages/context-cli/context-workflow/actions/repair-project-entry.yaml +++ b/packages/context-cli/context-workflow/actions/repair-project-entry.yaml @@ -8,6 +8,7 @@ files: - resources/manuals/guides/source-batches.md - resources/manuals/reference/code-extractors.md - resources/manuals/guides/knowledge-updates.md + - resources/manuals/guides/repo-content.md - resources/manuals/guides/note.md - resources/manuals/guides/sessions.md - resources/manuals/guides/workspace-prepare.md diff --git a/packages/context-cli/src/lib/pathFreePathFieldInventory.ts b/packages/context-cli/src/lib/pathFreePathFieldInventory.ts index edee6507..ffb59a29 100644 --- a/packages/context-cli/src/lib/pathFreePathFieldInventory.ts +++ b/packages/context-cli/src/lib/pathFreePathFieldInventory.ts @@ -30,6 +30,7 @@ const DEFAULT_INTERNAL_FIELDS = [ "expected_path", "current_path", "previous_path", + "old_path", "previousPath", "feedback_path", "candidate_path", @@ -105,7 +106,7 @@ function policyFor(field: string): PathFieldInventoryEntry["policy"] { if (field === "input_path" || field === "state_path") return "human-only"; if (field === "previousPath") return "human-only"; if (field === "feedback_path") return "internal-only"; - if (field === "previous_path" || field === "requested_approved_path" || field === "revision_path") { + if (field === "previous_path" || field === "old_path" || field === "requested_approved_path" || field === "revision_path") { return "external-input"; } if (field === "href" || field === "packageDir" || field === "package_dir" || field === "report_path" || field === "absolute_path") { @@ -162,6 +163,7 @@ function semanticReplacementFor(field: string): string { if (field === "feedback_path") return "CLI-owned revision feedback receipt; follow returned repair commands or the current Review resource instead of editing this file."; if (field === "previousPath") return "Explicit old article location displayed in the human Review diff; use the stable article identity for actions."; if (field === "previous_path") return "Explicit approved-page move origin, checked against stable View identity and current bytes before Review apply; not an inferred runtime path."; + if (field === "old_path") return "Historical repository-relative evidence path from Git rename detection; resolve only with the recorded repository and baseline commit, never with the current registration path."; if (field === "result_file") { return "An explicit completion-report locator for Host reading, not a semantic identity or an inferred cache path; retain revision and outcome fields in the summary."; } From ae7b73c230cff41bd612a77276984a68d80f5b62 Mon Sep 17 00:00:00 2001 From: qiansc Date: Sat, 3 Oct 2026 17:15:48 +0800 Subject: [PATCH 3/3] test(v0.7.50): distinguish URI references from local manual links --- CHANGELOG.md | 8 ++++++++ .../src/__tests__/indexerGuideResources.test.ts | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f80eb565..0d7417bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to Context are documented here. +## 0.7.50 - 2026-10-03 + +- Register same-repository documentation and authored Skills without copying their bodies into knowledge; maintain safe relative symlink entrances. +- Preserve historical repository paths and commits in evidence, detect relocated document regions and changes across entire Skill directories. +- Add optional repository entrance pages, original-source links, localized navigation and offline build fallbacks. +- Support repository-content references in the bundled evidence plugin and local or remote query workflows, including incomplete symlink-index coverage. +- Keep invalid-registration maintenance advisory, provide actionable repair guidance and reject invalid build inputs without replacing prior outputs. + ## 0.7.43 - 2026-10-01 - Upgrade repository evidence enrichment to ABI 2, attaching compact references to individual read items without replacing original content. diff --git a/packages/context-cli/src/__tests__/indexerGuideResources.test.ts b/packages/context-cli/src/__tests__/indexerGuideResources.test.ts index 79c4f220..3c13b7d9 100644 --- a/packages/context-cli/src/__tests__/indexerGuideResources.test.ts +++ b/packages/context-cli/src/__tests__/indexerGuideResources.test.ts @@ -20,7 +20,7 @@ test("the installed workflow carries the current guide and its linked manuals", const relative = file.split("/resources/manuals/")[1]!; expect(markdown).toBe(await readFile(resolve(packageRoot, "../context/docs", relative), "utf8")); for (const match of markdown.matchAll(/\]\(([^)]+\.md)(?:#[^)]*)?\)/gu)) { - if (/^https?:/u.test(match[1]!)) continue; + if (/^[a-z][a-z\d+.-]*:/iu.test(match[1]!)) continue; pending.push(resolve(dirname(file), match[1]!)); } } @@ -62,7 +62,7 @@ test("Route SDK manuals and their local links ship with the current SDK content" const manual = path.split("/resources/manuals/")[1]!; expect(markdown).toBe(await readFile(resolve(packageRoot, "../context/docs", manual), "utf8")); for (const match of markdown.matchAll(/\]\(([^)]+\.md)(?:#[^)]*)?\)/gu)) { - if (/^https?:/u.test(match[1]!)) continue; + if (/^[a-z][a-z\d+.-]*:/iu.test(match[1]!)) continue; pending.push(resolve(dirname(path), match[1]!)); } }