diff --git a/docs/adr/ADR-0043-agent-context-provider.md b/docs/adr/ADR-0043-agent-context-provider.md new file mode 100644 index 0000000..0497bbb --- /dev/null +++ b/docs/adr/ADR-0043-agent-context-provider.md @@ -0,0 +1,115 @@ +--- +schemaVersion: archcontext.adr/v1 +id: adr.0043.agent-context-provider +title: Agent Context Provider +status: accepted +decidedAt: 2026-07-05 +appliesTo: + - package.contracts + - package.projection-engine + - package.model-store-yaml + - package.surfaces-cli +supersedes: [] +--- + +# Context + +repo-harness maintains its own capability filing (`.ai/context/capabilities.json` plus +seven consumers) so coding agents can discover which files a capability owns. That +filing work is moving to ArchContext so agentic runtimes across products read +capability-context from one provider instead of each product reinventing path-ownership +and agent-context file conventions. + +ArchContext's `archcontext.node/v1` schema already carries `id`, `kind`, and +`source.include`/`source.exclude`/`entrypoints` globs +(`schemas/repo/architecture-node.schema.json`). No node schema field is missing; what is +missing is the cross-product contract for how an agentic runtime is supposed to consume +these fields: which field is the capability identifier, how ownership ties break when +more than one node's globs match a path, where non-schema conventions live, and what a +per-capability agent-context file looks like. + +# Decision + +1. **Capability identity.** A node's `id` doubles as the capability ID for agentic + runtimes when `kind` is `capability`. Capability IDs use the naming convention + `capability..` (for example `capability.workflow-engine.inspection-migration`). + This convention already fits the existing `id` pattern in + `schemas/repo/architecture-node.schema.json`; no schema change is required. + +2. **Path ownership and tie-break.** `source.include` globs are the source of truth for + which paths a node owns. Resolving "which capability owns path P" against the full + node set follows exactly one tie-break, in this order: + - Apply every candidate node's `source.exclude` globs first; a node whose `exclude` + matches P is disqualified for P regardless of its `include` globs. + - Among the remaining candidates, the node whose matching `include` glob has the + longest literal prefix (the most specific declaration) wins. + - If two or more surviving candidates tie at the same longest literal-prefix length, + resolution is **rejected as ambiguous**. This is a deliberate outcome, not a + fallback: it mirrors the default projection manifest's + `ownership.ambiguousOwnership: "reject"` + (`packages/local-runtime/model-store-yaml/src/index.ts`) and repo-harness's existing + same-length-ambiguity-fails rule. Callers must narrow the declared globs; ArchContext + does not guess an owner. + - A node with no `source` field, or with an empty `include` list, owns no paths and + never participates in resolution. + + This tie-break is implemented exactly once: `resolveArchitectureOwnerForPath` + (`packages/core/projection-engine/src/index.ts`), called by `archctx resolve --path` + (`packages/surfaces/cli/src/main.ts`). Other adapters, including a future repo-harness + Stage 0 adapter, are expected to call this command rather than re-implementing glob + tie-break semantics, so the two products cannot drift apart on what "ambiguous" means. + `archctx resolve --path

` exits `0` on a single match, `1` when no node owns the + path, and `2` when resolution is ambiguous. + +3. **Non-schema conventions live under `extensions`.** Per + `docs/runbooks/schema-upgrade-guide.md`, new node fields must be optional or live + under `extensions` until every adapter understands them. This ADR reserves two + `extensions` keys by convention, not by schema change: + - `extensions.lspProfile: string` — names the language-server/tooling profile an + agentic runtime should assume for this capability's source tree. + - `extensions.verification: string[]` — the commands an agentic runtime should run to + verify changes inside this capability, in the same spirit as the per-capability + "Verification" line already used in repo-local `CLAUDE.md`/`AGENTS.md` contracts. + Neither key is schema-validated; adapters must treat a missing or malformed value as + absent, not as an error, until a later ADR promotes them to validated schema fields. + +4. **`agent-context` projection.** A new `ProjectionTarget` `type` value, `agent-context` + (`schemas/runtime/projection-target.schema.json`), projects one capability node into + its own primary source directory as a marker-owned region inside that directory's + `CLAUDE.md` and `AGENTS.md` files. The primary source directory is derived from the + node's first `source.include` entry: the literal prefix before its first wildcard, + with any trailing partial path segment dropped, or the containing directory when the + entry has no wildcard at all. The projected region: + - Is delimited by its own marker pair, `BEGIN/END ARCHCONTEXT AGENT CONTEXT`, distinct + from the `docs/architecture/*` `ARCHCONTEXT:generated` marker family, so it can + coexist with unrelated generated regions already present in the same + `CLAUDE.md`/`AGENTS.md` file (for example a repo-harness-managed architecture + contract block). + - Carries the capability's `id`, `name`, `summary`, `source`, and an `extensions` + digest, so the region changes only when the underlying node facts change. + - Replaces only its own marker-delimited region on re-render; every other line in the + file, including regions owned by other tools, is preserved untouched. This reuses + the existing projection ownership model — marker-owned generated regions replace by + marker, human-authored regions are preserved, ambiguous ownership is rejected — + rather than introducing a second ownership model. + +# Consequences + +- Agentic runtimes, including this product's own CLI/MCP surfaces and any future + repo-harness adapter, resolve "who owns this path" and "what capability context + applies here" through one command and one tie-break, instead of re-deriving glob + semantics per product. +- `extensions.lspProfile` and `extensions.verification` are conventions, not schema + contracts; they can be promoted to validated schema fields in a later ADR once more + than one adapter depends on them, without a breaking migration, since they are already + optional and namespaced under `extensions`. +- Ambiguous ownership is a first-class, deliberate resolution outcome (`archctx resolve + --path` exit code `2`, not a thrown error), so callers see declaration conflicts + instead of a silently guessed owner. +- The `agent-context` projection only writes into a capability's own primary source + directory; it is not part of the fixed `docs/architecture/*` drift/rebuild pipeline, so + it does not change the behavior or coverage of the other eight projection target + types. +- This ADR does not migrate repo-harness's existing capabilities into ArchContext, add a + repo-harness-side adapter, extend the MCP `prepare_task` output, or add a write-time + overlap-exclusivity validator; those remain separate, later decisions. diff --git a/packages/contracts/test/contracts.test.ts b/packages/contracts/test/contracts.test.ts index 2eb66d8..1686281 100644 --- a/packages/contracts/test/contracts.test.ts +++ b/packages/contracts/test/contracts.test.ts @@ -160,6 +160,19 @@ describe("JSON schema contracts", () => { } }); + test("projection-target schema accepts the agent-context targetType (ADR-0043)", () => { + const schema = readJson("schemas/runtime/projection-target.schema.json"); + const fixture = readJson("packages/contracts/fixtures/valid/projection-target.json") as Record; + + const agentContextFixture = { + ...fixture, + type: "agent-context", + scope: { kind: "entity", id: "capability.example.agent-context", entityKind: "capability" } + }; + expect(validateJsonSchema(schema as any, agentContextFixture as Json).valid).toBe(true); + expect(validateJsonSchema(schema as any, { ...fixture, type: "agent-contexts" } as Json).valid).toBe(false); + }); + test("practice policy schema accepts explicit fail-open and fail-closed modes", () => { const schema = readJson("schemas/repo/practices/practice-policy.schema.json"); const fixture = readJson("packages/contracts/fixtures/valid/practice-policy.json") as Record; diff --git a/packages/core/projection-engine/src/index.ts b/packages/core/projection-engine/src/index.ts index ec10dd5..abae1ec 100644 --- a/packages/core/projection-engine/src/index.ts +++ b/packages/core/projection-engine/src/index.ts @@ -8,6 +8,13 @@ import { type ModelExportResult, type ProjectionTargetV1 } from "@archcontext/contracts"; +import { parseJsonOrStableYaml } from "../../architecture-domain/src/index"; + +export interface NativeNodeSource { + include?: string[]; + exclude?: string[]; + entrypoints?: string[]; +} export interface NativeNode extends Record { id: string; @@ -15,6 +22,18 @@ export interface NativeNode extends Record { name: string; status?: string; summary?: string; + // Typed as Json (not NativeNodeSource) so this interface still satisfies its own + // Record index signature; use nativeNodeSource(node) to read + // this field with the NativeNodeSource shape. + source?: Json; + extensions?: Record; +} + +/** Reads `node.source` (ADR-0043 `source.include`/`source.exclude`/`entrypoints`) as NativeNodeSource. */ +export function nativeNodeSource(node: NativeNode): NativeNodeSource | undefined { + const value = node.source; + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + return value as unknown as NativeNodeSource; } export interface NativeRelation extends Record { @@ -338,20 +357,10 @@ function readYamlObjects(dir: string): Record[] { return readdirSync(dir) .filter((file) => /\.ya?ml$/.test(file)) .sort() - .map((file) => parseFlatYaml(readFileSync(resolve(dir, file), "utf8"))); -} - -function parseFlatYaml(body: string): Record { - const out: Record = {}; - for (const line of body.split(/\r?\n/)) { - const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); - if (!match) continue; - const [, key, raw] = match; - if (raw.startsWith("\"") && raw.endsWith("\"")) out[key] = JSON.parse(raw); - else if (raw === "true" || raw === "false") out[key] = raw === "true"; - else out[key] = raw; - } - return out; + .map((file) => { + const path = resolve(dir, file); + return parseJsonOrStableYaml(readFileSync(path, "utf8"), path) as Record; + }); } function escapeMermaid(value: string): string { @@ -723,3 +732,242 @@ function escapeDsl(value: string): string { function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } + +// --- Agent Context Provider (ADR-0043) --- +// +// Projects one capability node's identity/source/extensions into a marker-owned region +// inside that capability's own primary source directory (CLAUDE.md and AGENTS.md). +// `type: "agent-context"` is a new `ProjectionTarget` target type (see +// schemas/runtime/projection-target.schema.json); the shape below intentionally does not +// import the strict `ProjectionTargetV1`/`ProjectionTargetType` TS union from +// @archcontext/contracts, whose `packages/contracts/src` is out of scope for this change. + +export const AGENT_CONTEXT_RENDERER_VERSION = "archcontext.agent-context-renderer/v1" as const; +export const AGENT_CONTEXT_BEGIN_PREFIX = "`; +} + +function agentContextEndMarker(nodeId: string): string { + return `${AGENT_CONTEXT_END_PREFIX} id="${nodeId}" -->`; +} + +function wrapAgentContextRegion(target: AgentContextProjectionTarget, generatedBody: string): string { + return [target.generatedRegion.startMarker, generatedBody.trimEnd(), target.generatedRegion.endMarker, ""].join("\n"); +} + +function mergeAgentContextRegion(target: AgentContextProjectionTarget, wrapped: string, existing?: string): string { + if (!existing) return wrapped; + const region = findAgentContextRegion(existing, target.scope.id); + if (!region) return `${existing.trimEnd()}\n\n${wrapped}`; + return `${existing.slice(0, region.start)}${wrapped}${existing.slice(region.end)}`; +} + +function findAgentContextRegion(body: string, nodeId: string): { start: number; end: number } | undefined { + const startPattern = new RegExp(``); + const startMatch = startPattern.exec(body); + if (!startMatch || startMatch.index === undefined) return undefined; + const endMarker = agentContextEndMarker(nodeId); + const endIndex = body.indexOf(endMarker, startMatch.index + startMatch[0].length); + if (endIndex < 0) return undefined; + const regionEnd = endIndex + endMarker.length + (body[endIndex + endMarker.length] === "\n" ? 1 : 0); + return { start: startMatch.index, end: regionEnd }; +} + +// --- Path Ownership Resolution (ADR-0043 tie-break) --- +// +// Single implementation of the ADR-0043 tie-break: apply `source.exclude` first, then the +// most-specific (longest literal prefix) matching `source.include` wins, and an equal- +// specificity tie is rejected as ambiguous. `archctx resolve --path` +// (packages/surfaces/cli/src/main.ts) is the only caller today; a future repo-harness +// adapter is expected to call the CLI rather than re-deriving this glob semantics. + +export type ResolveArchitectureOwnerResult = + | { status: "matched"; node: NativeNode } + | { status: "no-match" } + | { status: "ambiguous"; candidates: NativeNode[] }; + +export function resolveArchitectureOwnerForPath(nodes: NativeNode[], path: string): ResolveArchitectureOwnerResult { + const candidates: { node: NativeNode; specificity: number }[] = []; + for (const node of nodes) { + const source = nativeNodeSource(node); + const include = source?.include ?? []; + if (include.length === 0) continue; + const excluded = (source?.exclude ?? []).some((pattern) => matchesGlob(path, pattern)); + if (excluded) continue; + const specificity = include + .filter((pattern) => matchesGlob(path, pattern)) + .reduce((max, pattern) => Math.max(max, globLiteralPrefixLength(pattern)), -1); + if (specificity >= 0) candidates.push({ node, specificity }); + } + if (candidates.length === 0) return { status: "no-match" }; + const maxSpecificity = candidates.reduce((max, candidate) => Math.max(max, candidate.specificity), -1); + const winners = candidates.filter((candidate) => candidate.specificity === maxSpecificity).map((candidate) => candidate.node); + if (winners.length > 1) return { status: "ambiguous", candidates: winners }; + return { status: "matched", node: winners[0] }; +} + +export function matchesGlob(path: string, pattern: string): boolean { + return globToRegExp(pattern).test(path); +} + +/** Length of the glob's literal prefix (before its first `*`/`?`); used as the ADR-0043 specificity score. */ +export function globLiteralPrefixLength(pattern: string): number { + const index = pattern.search(/[*?]/); + return index === -1 ? pattern.length : index; +} + +function globToRegExp(pattern: string): RegExp { + let out = ""; + let index = 0; + while (index < pattern.length) { + if (pattern.startsWith("**/", index)) { + out += "(?:.*/)?"; + index += 3; + continue; + } + if (pattern.startsWith("**", index)) { + out += ".*"; + index += 2; + continue; + } + const char = pattern[index]; + if (char === "*") { + out += "[^/]*"; + } else if (char === "?") { + out += "[^/]"; + } else { + out += escapeRegExp(char); + } + index += 1; + } + return new RegExp(`^${out}$`); +} diff --git a/packages/core/projection-engine/test/agent-context.test.ts b/packages/core/projection-engine/test/agent-context.test.ts new file mode 100644 index 0000000..9951538 --- /dev/null +++ b/packages/core/projection-engine/test/agent-context.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, test } from "bun:test"; +import { + AGENT_CONTEXT_BEGIN_PREFIX, + AGENT_CONTEXT_END_PREFIX, + AGENT_CONTEXT_RENDERER_VERSION, + primarySourceDirectoryFromInclude, + renderAgentContextProjection, + type NativeModel +} from "../src/index"; + +const sourceDigest = "sha256:1111111111111111111111111111111111111111111111111111111111111111"; + +const model: NativeModel = { + nodes: [ + { + id: "capability.workflow-engine.inspection-migration", + kind: "capability", + name: "Inspection Migration", + status: "active", + summary: "Runs project-state inspection and template migration.", + source: { + include: ["scripts/inspect-project-state.ts"] + }, + extensions: { + lspProfile: "typescript-lsp", + verification: ["bun test", "bash scripts/check-task-sync.sh"] + } + }, + { + id: "capability.projection.agent-context", + kind: "capability", + name: "Agent Context Projection", + status: "active", + summary: "Projects capability facts into CLAUDE.md and AGENTS.md.", + source: { + include: ["packages/core/projection-engine/**"], + exclude: ["packages/core/projection-engine/test/**"] + } + }, + { + id: "module.no-source", + kind: "module", + name: "No Source Module", + summary: "A module with no declared source paths." + } + ], + relations: [] +}; + +describe("renderAgentContextProjection (ADR-0043)", () => { + test("primarySourceDirectoryFromInclude derives the directory root of an include glob", () => { + expect(primarySourceDirectoryFromInclude("packages/core/projection-engine/**")).toBe("packages/core/projection-engine"); + expect(primarySourceDirectoryFromInclude("src/subscription/generated/**")).toBe("src/subscription/generated"); + expect(primarySourceDirectoryFromInclude("scripts/inspect-project-state.ts")).toBe("scripts"); + expect(primarySourceDirectoryFromInclude("README.md")).toBe("."); + }); + + test("constructs one agent-context target per CLAUDE.md/AGENTS.md for every capability node with a declared source", () => { + const plan = renderAgentContextProjection({ model, sourceDigest }); + + expect(plan.schemaVersion).toBe("archcontext.agent-context-projection-plan/v1"); + expect(plan.rendererVersion).toBe(AGENT_CONTEXT_RENDERER_VERSION); + // Two capability nodes declare `source.include`; the module node does not, so it is skipped. + expect(plan.targets).toHaveLength(4); + expect(plan.targets.every((target) => target.type === "agent-context")).toBe(true); + expect(plan.targets.every((target) => target.ownership === "mixed")).toBe(true); + expect(plan.targets.every((target) => target.format === "markdown")).toBe(true); + expect(plan.targets.every((target) => target.scope.kind === "entity" && target.scope.entityKind === "capability")).toBe(true); + + const inspectionTargets = plan.files.filter((file) => file.target.scope.id === "capability.workflow-engine.inspection-migration"); + expect(inspectionTargets.map((file) => file.path).sort()).toEqual(["scripts/AGENTS.md", "scripts/CLAUDE.md"]); + + const projectionTargets = plan.files.filter((file) => file.target.scope.id === "capability.projection.agent-context"); + expect(projectionTargets.map((file) => file.path).sort()).toEqual([ + "packages/core/projection-engine/AGENTS.md", + "packages/core/projection-engine/CLAUDE.md" + ]); + + // The module without a declared source produces no agent-context target at all. + expect(plan.targets.some((target) => target.scope.id === "module.no-source")).toBe(false); + }); + + test("renders a marker-delimited body carrying id/name/summary/source/extensions", () => { + const plan = renderAgentContextProjection({ model, sourceDigest }); + const claudeFile = plan.files.find((file) => file.path === "scripts/CLAUDE.md")!; + + expect(claudeFile.body).toContain(AGENT_CONTEXT_BEGIN_PREFIX); + expect(claudeFile.body).toContain(AGENT_CONTEXT_END_PREFIX); + expect(claudeFile.body).toContain('id="capability.workflow-engine.inspection-migration"'); + expect(claudeFile.body).toContain("Inspection Migration"); + expect(claudeFile.body).toContain("Runs project-state inspection and template migration."); + expect(claudeFile.body).toContain("scripts/inspect-project-state.ts"); + expect(claudeFile.body).toContain("extensions.lspProfile: `typescript-lsp`"); + expect(claudeFile.body).toContain("extensions.verification:"); + expect(claudeFile.body).toContain("extensions digest:"); + + const projectionFile = plan.files.find((file) => file.path === "packages/core/projection-engine/CLAUDE.md")!; + expect(projectionFile.body).toContain("source.include:"); + expect(projectionFile.body).toContain("source.exclude:"); + }); + + test("rendering is deterministic: identical input yields identical output digests", () => { + const first = renderAgentContextProjection({ model, sourceDigest }); + const second = renderAgentContextProjection({ model: { nodes: [...model.nodes].reverse(), relations: [] }, sourceDigest }); + + const firstDigests = first.targets.map((target) => target.outputDigest).sort(); + const secondDigests = second.targets.map((target) => target.outputDigest).sort(); + expect(firstDigests).toEqual(secondDigests); + }); + + test("preserves surrounding human-authored content and replaces only its own marker region", () => { + const initial = renderAgentContextProjection({ + model, + sourceDigest, + existingFiles: [{ path: "scripts/CLAUDE.md", body: "# Scripts\n\nHuman-written context for this directory.\n" }] + }); + const claudeFile = initial.files.find((file) => file.path === "scripts/CLAUDE.md")!; + expect(claudeFile.body).toContain("Human-written context for this directory."); + expect(claudeFile.body).toContain(AGENT_CONTEXT_BEGIN_PREFIX); + + // Re-rendering against the previous output (same facts) must not duplicate the region + // or disturb the human-authored heading above it. + const again = renderAgentContextProjection({ + model, + sourceDigest, + existingFiles: initial.files.map(({ path, body }) => ({ path, body })) + }); + const claudeAgain = again.files.find((file) => file.path === "scripts/CLAUDE.md")!; + expect(claudeAgain.body).toBe(claudeFile.body); + expect(claudeAgain.body.match(new RegExp(AGENT_CONTEXT_BEGIN_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"))?.length).toBe(1); + expect(claudeAgain.body).toContain("Human-written context for this directory."); + + // A change to the underlying facts (new sourceDigest) must replace only the marker + // region in place, leaving the human-authored heading untouched. + const changed = renderAgentContextProjection({ + model, + sourceDigest: "sha256:2222222222222222222222222222222222222222222222222222222222222222", + existingFiles: initial.files.map(({ path, body }) => ({ path, body })) + }); + const claudeChanged = changed.files.find((file) => file.path === "scripts/CLAUDE.md")!; + expect(claudeChanged.body).toContain("Human-written context for this directory."); + expect(claudeChanged.body.match(new RegExp(AGENT_CONTEXT_BEGIN_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"))?.length).toBe(1); + expect(claudeChanged.target.sourceDigest).toBe("sha256:2222222222222222222222222222222222222222222222222222222222222222"); + }); + + test("does not project non-capability nodes even when they declare a source", () => { + const plan = renderAgentContextProjection({ + model: { + nodes: [ + { id: "module.with-source", kind: "module", name: "Module With Source", source: { include: ["src/module/**"] } } + ], + relations: [] + }, + sourceDigest + }); + expect(plan.targets).toHaveLength(0); + expect(plan.files).toHaveLength(0); + }); +}); diff --git a/packages/core/projection-engine/test/resolve.test.ts b/packages/core/projection-engine/test/resolve.test.ts new file mode 100644 index 0000000..96505fc --- /dev/null +++ b/packages/core/projection-engine/test/resolve.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; +import { + globLiteralPrefixLength, + matchesGlob, + resolveArchitectureOwnerForPath, + type NativeNode +} from "../src/index"; + +function node(id: string, include: string[], exclude?: string[]): NativeNode { + return { + id, + kind: "capability", + name: id, + source: { include, ...(exclude ? { exclude } : {}) } + }; +} + +describe("matchesGlob / globLiteralPrefixLength", () => { + test("`**` matches any depth under a directory prefix", () => { + expect(matchesGlob("packages/core/projection-engine/src/index.ts", "packages/core/projection-engine/**")).toBe(true); + expect(matchesGlob("packages/core/other/src/index.ts", "packages/core/projection-engine/**")).toBe(false); + }); + + test("a literal pattern with no wildcard requires an exact match", () => { + expect(matchesGlob("scripts/inspect-project-state.ts", "scripts/inspect-project-state.ts")).toBe(true); + expect(matchesGlob("scripts/inspect-project-state.ts.bak", "scripts/inspect-project-state.ts")).toBe(false); + }); + + test("literal prefix length stops at the first wildcard", () => { + expect(globLiteralPrefixLength("packages/core/projection-engine/**")).toBe("packages/core/projection-engine/".length); + expect(globLiteralPrefixLength("scripts/inspect-project-state.ts")).toBe("scripts/inspect-project-state.ts".length); + }); +}); + +describe("resolveArchitectureOwnerForPath (ADR-0043 tie-break)", () => { + test("matches the single node whose include glob covers the path", () => { + const nodes = [node("capability.a", ["packages/a/**"])]; + const result = resolveArchitectureOwnerForPath(nodes, "packages/a/src/index.ts"); + expect(result.status).toBe("matched"); + if (result.status === "matched") expect(result.node.id).toBe("capability.a"); + }); + + test("source.exclude overrides a matching source.include for the same node", () => { + const nodes = [node("capability.a", ["packages/a/**"], ["packages/a/generated/**"])]; + const result = resolveArchitectureOwnerForPath(nodes, "packages/a/generated/output.ts"); + expect(result.status).toBe("no-match"); + }); + + test("equal-specificity include globs across two nodes reject as ambiguous", () => { + const nodes = [node("capability.a", ["packages/shared/**"]), node("capability.b", ["packages/shared/**"])]; + const result = resolveArchitectureOwnerForPath(nodes, "packages/shared/utils.ts"); + expect(result.status).toBe("ambiguous"); + if (result.status === "ambiguous") { + expect(result.candidates.map((candidate) => candidate.id).sort()).toEqual(["capability.a", "capability.b"]); + } + }); + + test("no node's include glob covers the path", () => { + const nodes = [node("capability.a", ["packages/a/**"])]; + const result = resolveArchitectureOwnerForPath(nodes, "docs/unrelated.md"); + expect(result.status).toBe("no-match"); + }); + + test("the more specific (longer literal prefix) include wins over a broader sibling, not ambiguous", () => { + const nodes = [node("capability.broad", ["packages/**"]), node("capability.narrow", ["packages/core/projection-engine/**"])]; + const result = resolveArchitectureOwnerForPath(nodes, "packages/core/projection-engine/src/index.ts"); + expect(result.status).toBe("matched"); + if (result.status === "matched") expect(result.node.id).toBe("capability.narrow"); + }); + + test("a node with no source field owns no paths and never participates", () => { + const nodes: NativeNode[] = [{ id: "capability.no-source", kind: "capability", name: "No Source" }]; + const result = resolveArchitectureOwnerForPath(nodes, "packages/a/src/index.ts"); + expect(result.status).toBe("no-match"); + }); + + test("a node with an empty include list owns no paths", () => { + const nodes = [node("capability.empty", [])]; + const result = resolveArchitectureOwnerForPath(nodes, "packages/a/src/index.ts"); + expect(result.status).toBe("no-match"); + }); +}); diff --git a/packages/local-runtime/model-store-yaml/src/index.ts b/packages/local-runtime/model-store-yaml/src/index.ts index ac94982..5d26f85 100644 --- a/packages/local-runtime/model-store-yaml/src/index.ts +++ b/packages/local-runtime/model-store-yaml/src/index.ts @@ -109,6 +109,22 @@ export function createDefaultProjectionTargetManifest(): Json { pathTemplate: "docs/architecture/diagrams/architecture.likec4", ownership: "generated", format: "likec4" + }, + { + id: "projection_rule.agent-context.claude", + targetType: "agent-context", + scope: { kind: "entity", entityKind: "capability" }, + pathTemplate: "{primarySourceDir}/CLAUDE.md", + ownership: "mixed", + format: "markdown" + }, + { + id: "projection_rule.agent-context.agents", + targetType: "agent-context", + scope: { kind: "entity", entityKind: "capability" }, + pathTemplate: "{primarySourceDir}/AGENTS.md", + ownership: "mixed", + format: "markdown" } ] }; diff --git a/packages/surfaces/cli/src/main.ts b/packages/surfaces/cli/src/main.ts index b659226..7e9ae8c 100755 --- a/packages/surfaces/cli/src/main.ts +++ b/packages/surfaces/cli/src/main.ts @@ -3,7 +3,7 @@ import { execFileSync, spawn, spawnSync } from "node:child_process"; import { accessSync, chmodSync, closeSync, constants, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { CALLER_PROVIDED_ATTESTATION_FIELDS, digestJson, errorEnvelope, okEnvelope, productVersionManifest } from "@archcontext/contracts"; +import { CALLER_PROVIDED_ATTESTATION_FIELDS, digestJson, errorEnvelope, isRepoRelativePosixPath, okEnvelope, productVersionManifest } from "@archcontext/contracts"; import type { AgentJobV1, AttestationV2, GitHubGovernancePort, Json, ReviewChallengeV2 } from "@archcontext/contracts"; import { computeWorktreeDigest, repositoryFingerprint } from "@archcontext/core/architecture-domain"; import { DEFAULT_AGENT_ORCHESTRATION_POLICY, DEFAULT_AGENT_QUEUE_MAX_QUEUED_JOBS, DEFAULT_AGENT_QUEUE_MAX_RUNNING_JOBS_PER_REPOSITORY } from "@archcontext/core/agent-orchestrator"; @@ -38,6 +38,7 @@ import { loadArchitectureDocumentationInputs, loadNativeModelFromArchContext, renderArchitectureDocumentationProjection, + resolveArchitectureOwnerForPath, type ArchitectureDocumentationProjectionFile } from "@archcontext/surfaces/renderer"; @@ -82,6 +83,7 @@ if (import.meta.main) { ); process.stdout.write(`${renderResult(result, readFlag(args, "--format") ?? "json")}\n`); if (result.ok === false) process.exitCode = 1; + if (command === "resolve") process.exitCode = resolveCommandExitCode(result); } } @@ -419,6 +421,36 @@ async function runCliUnchecked(command = "help", args: string[] = [], cwd: strin revocation: "archctx tunnel --revoke" } }; + case "resolve": { + const path = readFlag(args, "--path"); + if (!path) return errorEnvelope("resolve", "AC_SCHEMA_INVALID", "resolve requires --path"); + if (!isRepoRelativePosixPath(path)) { + return errorEnvelope("resolve", "AC_SCHEMA_INVALID", "resolve --path must be a repository-relative POSIX path"); + } + const model = loadNativeModelFromArchContext(cwd); + const outcome = resolveArchitectureOwnerForPath(model.nodes, path); + if (outcome.status === "matched") { + const node = outcome.node; + return okEnvelope("resolve", { + matched: true, + ambiguous: false, + stableId: node.id, + kind: node.kind, + name: node.name, + ...(node.source ? { source: node.source } : {}), + ...(node.extensions ? { extensions: node.extensions } : {}) + } as unknown as Json); + } + if (outcome.status === "ambiguous") { + return okEnvelope("resolve", { + matched: false, + ambiguous: true, + path, + candidates: outcome.candidates.map((node) => node.id) + } as unknown as Json); + } + return okEnvelope("resolve", { matched: false, ambiguous: false, path } as unknown as Json); + } case "help": default: return { @@ -426,8 +458,8 @@ async function runCliUnchecked(command = "help", args: string[] = [], cwd: strin ok: true, requestId: "help", data: { - commands: ["init", "sync", "validate", "context", "status", "daemon", "repo", "landscape", "ledger", "book", "recommendations", "explore", "prepare", "practices", "checkpoint", "hook", "hooks", "investigate", "agents", "jobs", "audit", "plan", "apply", "review", "complete", "github", "config", "mcp", "install", "uninstall", "doctor", "update", "paths", "privacy-audit", "export", "import", "tunnel"], - examples: ["archctx init --name MyApp", "archctx ledger migrate --from-yaml --dry-run", "archctx ledger promote --mode authoritative --preflight --rollback-plan", "archctx book recommendations --open --explain", "archctx recommendations accept --id recommendation. --reason 'Accepted after local readback.'", "archctx recommendations metrics", "archctx practices validate --strict", "archctx practices list --json", "archctx practices waivers", "archctx practices waive --practice-id modularity.no-new-cycle --owner team-architecture --reason 'External migration window requires this edge until cutover.' --review-at 2026-07-10T00:00:00.000Z --expires-at 2026-07-24T00:00:00.000Z --evidence-digest sha256:<64-hex> --subject module.a->module.b", "archctx checkpoint --task-session-id task_cli", "archctx investigate --runner-port codex", "archctx agents status --status queued,running", "archctx agents budget", "archctx hook enqueue --event post-edit --path src/app.ts", "archctx jobs list --status queued", "archctx audit run --reason 'quarterly architecture audit'", "archctx audit run --no-wait", "archctx audit list --status pending", "archctx audit show audit_run.", "archctx audit approve audit_run.", "archctx audit approve audit_run. --confirm-public-repo public:::", "archctx audit approve audit_run. --resume", "archctx hooks install --host codex", "archctx paths", "archctx update --check", "archctx doctor --check-updates", "archctx github connect", "archctx github status", "archctx daemon start", "archctx explore start --foreground", "archctx export likec4", "archctx import structurizr --content ''", "archctx tunnel"] + commands: ["init", "sync", "validate", "context", "status", "daemon", "repo", "landscape", "ledger", "book", "recommendations", "explore", "prepare", "practices", "checkpoint", "hook", "hooks", "investigate", "agents", "jobs", "audit", "plan", "apply", "review", "complete", "github", "config", "mcp", "install", "uninstall", "doctor", "update", "paths", "privacy-audit", "export", "import", "resolve", "tunnel"], + examples: ["archctx init --name MyApp", "archctx ledger migrate --from-yaml --dry-run", "archctx ledger promote --mode authoritative --preflight --rollback-plan", "archctx book recommendations --open --explain", "archctx recommendations accept --id recommendation. --reason 'Accepted after local readback.'", "archctx recommendations metrics", "archctx practices validate --strict", "archctx practices list --json", "archctx practices waivers", "archctx practices waive --practice-id modularity.no-new-cycle --owner team-architecture --reason 'External migration window requires this edge until cutover.' --review-at 2026-07-10T00:00:00.000Z --expires-at 2026-07-24T00:00:00.000Z --evidence-digest sha256:<64-hex> --subject module.a->module.b", "archctx checkpoint --task-session-id task_cli", "archctx investigate --runner-port codex", "archctx agents status --status queued,running", "archctx agents budget", "archctx hook enqueue --event post-edit --path src/app.ts", "archctx jobs list --status queued", "archctx audit run --reason 'quarterly architecture audit'", "archctx audit run --no-wait", "archctx audit list --status pending", "archctx audit show audit_run.", "archctx audit approve audit_run.", "archctx audit approve audit_run. --confirm-public-repo public:::", "archctx audit approve audit_run. --resume", "archctx hooks install --host codex", "archctx paths", "archctx update --check", "archctx doctor --check-updates", "archctx github connect", "archctx github status", "archctx daemon start", "archctx explore start --foreground", "archctx export likec4", "archctx import structurizr --content ''", "archctx resolve --path packages/core/projection-engine/src/index.ts", "archctx tunnel"] } }; } @@ -3078,6 +3110,15 @@ function renderResult(result: any, format: string): string { return `OK ${result.requestId}\n${JSON.stringify(result.data, null, 2)}`; } +/** ADR-0043: `archctx resolve --path` exit codes are 0=matched, 1=no-match, 2=ambiguous. */ +export function resolveCommandExitCode(result: { ok?: boolean; data?: unknown }): number { + if (!result || result.ok !== true) return 1; + const data = result.data as { matched?: boolean; ambiguous?: boolean } | undefined; + if (data?.matched === true) return 0; + if (data?.ambiguous === true) return 2; + return 1; +} + function readCurrentBranch(root: string): string { try { const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { diff --git a/packages/surfaces/cli/test/cli.test.ts b/packages/surfaces/cli/test/cli.test.ts index 4e4be2c..c73dacc 100644 --- a/packages/surfaces/cli/test/cli.test.ts +++ b/packages/surfaces/cli/test/cli.test.ts @@ -12,9 +12,9 @@ import { SqliteLocalStore, migrateLegacyLocalStoreIfNeeded, runtimeStatePaths } import { initializeArchContextModel } from "@archcontext/local-runtime/model-store-yaml"; import { DevicePrivateKeyStore, InMemoryCredentialSecretStore, KeychainTokenStore } from "@archcontext/cloud/control-plane-client"; import { createReviewChallengeV2 } from "@archcontext/cloud/attestation"; -import { ARCHCONTEXT_PRODUCT_VERSION } from "@archcontext/contracts"; +import { ARCHCONTEXT_PRODUCT_VERSION, stableYaml } from "@archcontext/contracts"; import { runFastHookEnqueue } from "../src/hook-fast"; -import { runCli } from "../src/main"; +import { resolveCommandExitCode, runCli } from "../src/main"; const CLI_ENTRY = join(process.cwd(), "packages/surfaces/cli/src/main.ts"); const CLI_PROCESS_TIMEOUT_MS = process.platform === "win32" ? 180_000 : 30_000; @@ -3024,6 +3024,91 @@ describe("archctx CLI", () => { removeTempRoot(root); } }); + + test("archctx resolve --path matches the most specific declared capability (ADR-0043)", async () => { + const root = mkdtempSync(join(tmpdir(), "archctx-cli-resolve-")); + try { + writeFileSync(join(root, "README.md"), "# tmp\n", "utf8"); + initializeArchContextModel(root, "CLI Resolve App"); + writeFileSync( + join(root, ".archcontext/model/nodes/capability.projection-engine.yaml"), + stableYaml({ + schemaVersion: "archcontext.node/v1", + id: "capability.projection-engine", + kind: "capability", + name: "Projection Engine", + status: "active", + summary: "Renders architecture and agent-context projections.", + source: { include: ["packages/core/projection-engine/**"] }, + extensions: { lspProfile: "typescript-lsp", verification: ["bun test"] } + }), + "utf8" + ); + + const matched = await runTestCli("resolve", ["--path", "packages/core/projection-engine/src/index.ts"], root); + expect(matched.ok).toBe(true); + expect((matched.data as any).matched).toBe(true); + expect((matched.data as any).stableId).toBe("capability.projection-engine"); + expect((matched.data as any).source.include).toEqual(["packages/core/projection-engine/**"]); + expect((matched.data as any).extensions.lspProfile).toBe("typescript-lsp"); + expect(resolveCommandExitCode(matched)).toBe(0); + + const noMatch = await runTestCli("resolve", ["--path", "docs/unrelated.md"], root); + expect((noMatch.data as any).matched).toBe(false); + expect((noMatch.data as any).ambiguous).toBe(false); + expect(resolveCommandExitCode(noMatch)).toBe(1); + + const missingPath = await runTestCli("resolve", [], root); + expect(missingPath.ok).toBe(false); + expect((missingPath as any).error.code).toBe("AC_SCHEMA_INVALID"); + expect(resolveCommandExitCode(missingPath)).toBe(1); + } finally { + removeTempRoot(root); + } + }); + + test("archctx resolve --path rejects equal-specificity ownership as ambiguous", async () => { + const root = mkdtempSync(join(tmpdir(), "archctx-cli-resolve-ambiguous-")); + try { + writeFileSync(join(root, "README.md"), "# tmp\n", "utf8"); + initializeArchContextModel(root, "CLI Resolve Ambiguous App"); + writeFileSync( + join(root, ".archcontext/model/nodes/capability.shared-a.yaml"), + stableYaml({ + schemaVersion: "archcontext.node/v1", + id: "capability.shared-a", + kind: "capability", + name: "Shared A", + status: "active", + summary: "First capability declaring the shared package.", + source: { include: ["packages/shared/**"] } + }), + "utf8" + ); + writeFileSync( + join(root, ".archcontext/model/nodes/capability.shared-b.yaml"), + stableYaml({ + schemaVersion: "archcontext.node/v1", + id: "capability.shared-b", + kind: "capability", + name: "Shared B", + status: "active", + summary: "Second capability declaring the same shared package.", + source: { include: ["packages/shared/**"] } + }), + "utf8" + ); + + const ambiguous = await runTestCli("resolve", ["--path", "packages/shared/utils.ts"], root); + expect(ambiguous.ok).toBe(true); + expect((ambiguous.data as any).matched).toBe(false); + expect((ambiguous.data as any).ambiguous).toBe(true); + expect((ambiguous.data as any).candidates.sort()).toEqual(["capability.shared-a", "capability.shared-b"]); + expect(resolveCommandExitCode(ambiguous)).toBe(2); + } finally { + removeTempRoot(root); + } + }); }); async function runCliProcess(root: string, ...args: string[]): Promise { diff --git a/plans/plan-20260705-1543-agent-context-provider.md b/plans/plan-20260705-1543-agent-context-provider.md new file mode 100644 index 0000000..9bfd5ca --- /dev/null +++ b/plans/plan-20260705-1543-agent-context-provider.md @@ -0,0 +1,213 @@ +# Plan: Agent-context provider: ADR-0043 capability handover contract, agent-context projection, resolve surface + +> **Status**: Executing +> **Created**: 20260705-1543 +> **Slug**: agent-context-provider +> **Planning Source**: waza-think +> **Orchestration Kind**: host-plan +> **Source Ref**: repo-harness capability-filing handover Stage 1 (recon-corrected 2026-07-05) +> **Artifact Level**: work-package +> **Promotion Reason**: merge_boundary +> **Verification Boundary**: bun run typecheck + bun test + bun run test:contracts + node scripts/package-boundary-audit.mjs; full bun run verify pre-PR +> **Rollback Surface**: Revert branch codex/agent-context-provider; no data migration. +> **Spec**: `docs/spec.md` +> **Research**: See `docs/researches/` +> **Task Contract**: `tasks/contracts/20260705-1543-agent-context-provider.contract.md` +> **Task Review**: `tasks/reviews/20260705-1543-agent-context-provider.review.md` +> **Implementation Notes**: `tasks/notes/20260705-1543-agent-context-provider.notes.md` + +## Agentic Routing +- Selected route: planning +- Routing reason: Captured from waza-think planning output. +- Source ref: repo-harness capability-filing handover Stage 1 (recon-corrected 2026-07-05) +- Due diligence: + - P1 map: See captured planning output below. + - P2 trace: See captured planning output below. + - P3 decision rationale: See captured planning output below. + +## Workflow Inventory +Complete this inventory before implementation. If any line is unknown, keep the plan in Draft and fill it before projection. + +- Active plan: `plans/plan-20260705-1543-agent-context-provider.md` +- Sprint contract: `tasks/contracts/20260705-1543-agent-context-provider.contract.md` +- Sprint review: `tasks/reviews/20260705-1543-agent-context-provider.review.md` +- Implementation notes: `tasks/notes/20260705-1543-agent-context-provider.notes.md` +- Deferred-goal ledger: `tasks/todos.md` +- Current checks: `.ai/harness/checks/latest.json` +- Run snapshots: `.ai/harness/runs/` +- Scope authority: `tasks/contracts/20260705-1543-agent-context-provider.contract.md` `allowed_paths` +- Concurrency rule: `.ai/harness/active-plan` selects the active plan for this worktree when present; `.ai/harness/active-worktree` records the owning worktree; `.claude/.active-plan` is a legacy fallback during transition. If another worktree already owns active work, open or switch to the matching worktree instead of serializing unrelated plans. +- Execution isolation: approved contract-level work projects through `repo-harness run plan-to-todo --plan plans/plan-20260705-1543-agent-context-provider.md` and may start `repo-harness run contract-worktree start --plan plans/plan-20260705-1543-agent-context-provider.md`. + +## Approach +### Strategy +Use the captured planning output below as the execution source of truth. + +### Trade-offs +| Option | Pros | Cons | Decision | +|--------|------|------|----------| +| Captured plan | Preserves the approved Codex Plan or Waza think decision | Requires the captured text to be concrete enough to execute | Use | + +## Detailed Design +### File Changes +| File | Action | Description | +|------|--------|-------------| +| See captured planning output | Follow | Implement only the approved scope named below | + +### Code Snippets +See captured planning output. + +### Data Flow +See captured planning output. + +## Risk Assessment +| Risk | Likelihood | Impact | Mitigation | +|------|------------|--------|------------| +| Captured plan lacks enough detail | Medium | Execution may need clarification | Stop before implementation if the captured output contradicts repo rules or lacks concrete file targets | + +## Task Contracts +- Contract file: `tasks/contracts/20260705-1543-agent-context-provider.contract.md` +- Review file: `tasks/reviews/20260705-1543-agent-context-provider.review.md` +- Implementation notes file: `tasks/notes/20260705-1543-agent-context-provider.notes.md` +- Template: `.claude/templates/contract.template.md` +- Verification command: `repo-harness run verify-contract --contract tasks/contracts/20260705-1543-agent-context-provider.contract.md --strict` +- Active plan rule: this captured plan is written to `.ai/harness/active-plan`, the owning worktree is written to `.ai/harness/active-worktree`, and the plan is mirrored to `.claude/.active-plan` unless --no-active is used. Do not infer active execution from the latest non-archived plan. + +## Handoff + +- Checks file: `.ai/harness/checks/latest.json` +- Session handoff: `.ai/harness/handoff/current.md` + +## Promotion Gate + +- **Merge/PR unit**: Captured plan `plans/plan-20260705-1543-agent-context-provider.md` is the proposed mergeable execution unit; revise before execute if this is only a checklist step. +- **Rollback surface**: Revert branch codex/agent-context-provider; no data migration. +- **Verification boundary**: bun run typecheck + bun test + bun run test:contracts + node scripts/package-boundary-audit.mjs; full bun run verify pre-PR +- **Review/acceptance boundary**: `tasks/reviews/20260705-1543-agent-context-provider.review.md` must record pass against the captured acceptance criteria. +- **High-risk surface**: Risks named in captured planning output; keep the plan Draft if risk ownership is not concrete. +- **Why not checklist row**: merge_boundary + +## Evidence Contract + +- **State/progress path**: `plans/plan-20260705-1543-agent-context-provider.md` task breakdown, `tasks/todos.md` deferred-goal ledger, `tasks/contracts/20260705-1543-agent-context-provider.contract.md`, `tasks/reviews/20260705-1543-agent-context-provider.review.md`, and `tasks/notes/20260705-1543-agent-context-provider.notes.md` +- **Verification evidence**: `.ai/harness/checks/latest.json`, `.ai/harness/runs/`, and the commands named in the captured planning output +- **Evaluator rubric**: `tasks/reviews/20260705-1543-agent-context-provider.review.md` must record a passing Waza /check style recommendation +- **Stop condition**: all task breakdown items are complete, sprint verification passes, and the review recommends pass +- **Rollback surface**: Revert branch codex/agent-context-provider; no data migration. + +## Captured Planning Output + +## Context + +repo-harness 的 capability filing(`.ai/context/capabilities.json` + 7 個 consumer)移交給 ArchContext;跨 repo 總方案見 repo-harness `docs/researches/20260705-archcontext-capability-filing-handover.md`。本 plan 是 arch-context 側 Stage 1。 + +Recon 事實(2026-07-05,file:line): +- M0 已全綠凍結:`plans/sprints/archctx-sprint.md:75,113-140`(23/23 + Exit Gates 全 ☑),凍結證據 `docs/verification/m0-contracts-gate.md`。 +- node/v1 **已有**路徑歸屬:`source.include/exclude/entrypoints` glob(`schemas/repo/architecture-node.schema.json`;PRD §17.7:2166-2238 有意設計)——無需新欄位。 +- 頂層 schema `additionalProperties: false`;升級規則「新欄位必須 optional 或住 `extensions`」(`docs/runbooks/schema-upgrade-guide.md:1-6`)。 +- targetType 硬編三處:`schemas/runtime/projection-target.schema.json:13`、`packages/core/projection-engine/src/index.ts:388-434`、`packages/local-runtime/model-store-yaml/src/index.ts:39-115`。 +- CLI 目前無任何 path→node 解析面(`packages/surfaces/cli/src/main.ts:426` 命令清單)。 +- PRD/spec 無 repo-harness 整合章節——本 plan + ADR-0043 是該跨產品契約的第一份文件;ADR 下一號為 0043。 +- stableId regex(`schemas/repo/architecture-node.schema.json:10`)已容納 `capability..`,ID 統一零 schema 改動。 + +## Scope / Non-scope + +In scope: +1. `docs/adr/ADR-0043-agent-context-provider.md`(沿統一 ADR 模板): + - stableId 即 agentic-runtime 的 capability ID,命名慣例 `capability..`。 + - `source.include`(glob)為路徑歸屬事實源;解析 tie-break:`source.exclude` 先裁 → 最具體(最長字面前綴)include 勝出 → 同分歧義 **reject**(對齊 manifest `ownership.ambiguousOwnership: "reject"` 與 repo-harness「same-length ambiguity fails」)。 + - `extensions.lspProfile: string`、`extensions.verification: string[]` 慣例(按升級規則走 extensions;adapter 普及後再議升頂層)。 + - `agent-context` projection 語義:per-capability 塊、marker-owned generated region、human 區保留。 +2. `agent-context` projection targetType(三處同步): + - `schemas/runtime/projection-target.schema.json` enum 加 `"agent-context"`。 + - `packages/core/projection-engine/src/index.ts` target 構造函數加 agent-context 目標(scope: entity / entityKind: capability)。 + - `packages/local-runtime/model-store-yaml/src/index.ts` default manifest 加對應 placementRule。 + - pathTemplate 需要能定位 capability 的主源目錄(如新模板變數 `{primarySourceDir}`,取 `source.include[0]` 的目錄根);**實作者先讀 renderer 的變數展開代碼再定變數名與實作位置**——本 plan 唯一的開放實作細節。 + - 產出:`/CLAUDE.md` 與 `/AGENTS.md` 中的 `BEGIN/END ARCHCONTEXT AGENT CONTEXT` marker 塊(id/name/summary/source/extensions 摘要)。 +3. `archctx resolve --path

` 新 subcommand(薄):讀 model nodes → 按 ADR tie-break 匹配 → `JsonEnvelope` 輸出 `{stableId, kind, name, source, extensions}`;退出碼 0=匹配、1=無匹配、2=歧義。tie-break 語義只在這裡實作一次,repo-harness Stage 0 adapter 之後直接調它,避免兩邊漂移。 +4. 測試:contracts schema enum 斷言;projection-engine agent-context target 構造 + marker 渲染;resolve 匹配/exclude/歧義/無匹配 4 型;fixtures 更新。 + +Non-scope: +- repo-harness 側 Stage 0 adapter(另一 repo 的 plan)。 +- MCP `prepare_task` 輸出擴充(Stage 2 縫合時另開)。 +- PRD §29.3 Q1(node kind enum 開放性)不觸碰。 +- PRD §17.7:2235 的寫入時 overlap-exclusivity validator(resolve 的 query 時 reject 已部分覆蓋;寫入時驗證另開)。 +- 遷移 repo-harness 的 6 個 capability(Stage 2)。 + +## Approach + +### Strategy +零 schema bump:路徑歸屬用既有 `source`,新屬性走 `extensions`,ID 用既有 regex。新增面只有 projection targetType(三處硬編點同步)與一個薄 CLI 解析命令;跨產品語義全部收進 ADR-0043,tie-break 單點實作在 resolve。 + +### Trade-offs +| Option | Pros | Cons | Decision | +|--------|------|------|----------| +| extensions 慣例 + 既有 source(本案) | 零 schema bump;完全符合升級規則 | extensions 無 schema 驗證 | 採用 | +| schema v2 加頂層欄位 | 強驗證 | 違反 additive 規則,動員全 adapter | 拒絕 | +| 只寫 ADR 不做 resolve CLI | 更小 | repo-harness 只能自行實作 glob 語義,兩實作漂移 | 拒絕(tie-break 必須單點) | + +## Detailed Design + +### File Changes +| File | Action | Description | +|------|--------|-------------| +| `docs/adr/ADR-0043-agent-context-provider.md` | add | 跨產品契約(上述四點) | +| `schemas/runtime/projection-target.schema.json` | modify | targetType enum 加 `agent-context` | +| `packages/core/projection-engine/src/index.ts` | modify | agent-context target 構造;必要時 pathTemplate 變數展開擴充 | +| `packages/local-runtime/model-store-yaml/src/index.ts` | modify | default manifest 加 agent-context placementRule | +| `packages/surfaces/cli/src/main.ts`(+必要的 core 輔助) | modify | `resolve` subcommand,envelope 輸出,退出碼語義 | +| `packages/contracts/test/contracts.test.ts`、projection-engine/resolve 相關 tests、fixtures | modify/add | 上述測試四組 | + +### 關鍵語義 +- resolve 是唯一 tie-break 實作點;歧義 reject 是特性,不是錯誤處理的兜底。 +- agent-context 塊只寫 marker 區,human 區保留,歸屬不明 reject——沿 projection 既有 ownership 模型,不新造。 + +## Risk Assessment +| Risk | Likelihood | Impact | Mitigation | +|------|------------|--------|------------| +| renderer 不支援新模板變數 | 中 | 中 | 實作前先讀 renderer 展開代碼;必要時在 projection-engine 擴充變數表 | +| full `verify` 鏈過重(privacy audits/readbacks) | 低 | 低 | 本地跑 targeted 驗證;full verify 留 pre-PR/CI | +| extensions 無驗證導致髒數據 | 低 | 低 | ADR 記載慣例;resolve 容錯讀取並在輸出標注缺欄位 | + +## Promotion Gate +- **Merge/PR unit**: 是,單一 PR to main。 +- **Rollback surface**: revert branch `codex/agent-context-provider`;無資料遷移。 +- **Verification boundary**: `bun run typecheck` + `bun test` + `bun run test:contracts` + `node scripts/package-boundary-audit.mjs`;full `bun run verify` 於 PR 前。 +- **Review/acceptance boundary**: review 檔 recommend pass。 +- **High-risk surface**: projection 三處硬編點同步;新公共 CLI 面(additive,符合 PRD §24.5 v1 穩定規則)。 +- **Why not checklist row**: merge_boundary——跨產品契約 + ADR + 4 個 package 面 + 新 CLI 面,獨立回退與驗證邊界。 + +## Evidence Contract +- **State/progress path**: 本 plan `## Task Breakdown` + `tasks/contracts/.contract.md`。 +- **Verification evidence**: 上列 targeted 命令輸出;full verify 留 PR 前。 +- **Evaluator rubric**: contract exit_criteria 通過 + review recommend pass。 +- **Stop condition**: Task Breakdown 全勾 + targeted 驗證全綠。 +- **Rollback surface**: revert 分支;無資料遷移。 + +## Task Breakdown +- [x] `docs/adr/ADR-0043-agent-context-provider.md`(stableId 慣例、source=路徑歸屬、tie-break、extensions 慣例、agent-context 語義) +- [x] projection targetType 三處同步(schema enum、projection-engine builder、default manifest)+ pathTemplate 變數(先讀 renderer 再定名) +- [x] `archctx resolve --path` subcommand(envelope、退出碼 0/1/2、exclude→最具體→歧義 reject) +- [x] 測試四組(schema enum、target 構造+渲染、resolve 4 型、fixtures) +- [x] targeted 驗證:`bun run typecheck`、`bun test`、`bun run test:contracts`、`node scripts/package-boundary-audit.mjs` + +## Verification +```bash +bun run typecheck +bun test +bun run test:contracts +node scripts/package-boundary-audit.mjs +# 手測:archctx resolve --path <已宣告 include 的路徑> → envelope 含 stableId; +# 對歧義 fixture → 退出碼 2;對未宣告路徑 → 退出碼 1。 +# PR 前:bun run verify(完整鏈) +``` + +## Annotations + + +## Task Breakdown +- [x] `docs/adr/ADR-0043-agent-context-provider.md`(stableId 慣例、source=路徑歸屬、tie-break、extensions 慣例、agent-context 語義) +- [x] projection targetType 三處同步(schema enum、projection-engine builder、default manifest)+ pathTemplate 變數(先讀 renderer 再定名) +- [x] `archctx resolve --path` subcommand(envelope、退出碼 0/1/2、exclude→最具體→歧義 reject) +- [x] 測試四組(schema enum、target 構造+渲染、resolve 4 型、fixtures) +- [x] targeted 驗證:`bun run typecheck`、`bun test`、`bun run test:contracts`、`node scripts/package-boundary-audit.mjs` diff --git a/schemas/runtime/projection-target.schema.json b/schemas/runtime/projection-target.schema.json index c16e4f1..70c781c 100644 --- a/schemas/runtime/projection-target.schema.json +++ b/schemas/runtime/projection-target.schema.json @@ -10,7 +10,7 @@ "targetId": { "type": "string", "pattern": "^projection_target\\.[a-zA-Z0-9_.-]+$" }, "type": { "type": "string", - "enum": ["architecture-index", "entity-summary", "relation-summary", "decision-index", "architecture-changelog", "diagram-mermaid", "diagram-structurizr", "diagram-likec4"] + "enum": ["architecture-index", "entity-summary", "relation-summary", "decision-index", "architecture-changelog", "diagram-mermaid", "diagram-structurizr", "diagram-likec4", "agent-context"] }, "scope": { "type": "object", diff --git a/tasks/contracts/20260705-1543-agent-context-provider.contract.md b/tasks/contracts/20260705-1543-agent-context-provider.contract.md new file mode 100644 index 0000000..e99b297 --- /dev/null +++ b/tasks/contracts/20260705-1543-agent-context-provider.contract.md @@ -0,0 +1,125 @@ +# Task Contract: agent-context-provider + +> **Status**: Active +> **Plan**: plans/plan-20260705-1543-agent-context-provider.md +> **Task Profile**: code-change +> **Owner**: ancienttwo +> **Capability ID**: root +> **Last Updated**: 2026-07-05 15:44 +> **Review File**: `tasks/reviews/20260705-1543-agent-context-provider.review.md` +> **Notes File**: `tasks/notes/20260705-1543-agent-context-provider.notes.md` + +## Goal + +讓 ArchContext 成為 agentic runtime 的 capability-context provider:ADR-0043 固化跨產品契約(stableId=capability ID、`source.include` glob=路徑歸屬、tie-break=exclude 先裁→最具體 include 勝出→歧義 reject、`extensions.lspProfile`/`extensions.verification` 慣例);新增 `agent-context` projection targetType(三個硬編點同步);新增 `archctx resolve --path` 薄命令作為唯一 tie-break 實作點。實作以 plan `## Detailed Design` 為準。 + +## Scope + +- In scope: + - `docs/adr/ADR-0043-agent-context-provider.md`(統一 ADR 模板) + - `schemas/runtime/projection-target.schema.json`:targetType enum 加 `agent-context` + - `packages/core/projection-engine/src/index.ts`:agent-context target 構造;必要時 pathTemplate 變數展開擴充(先讀 renderer 現況再定名) + - `packages/local-runtime/model-store-yaml/src/index.ts`:default projection manifest 加 agent-context placementRule + - `packages/surfaces/cli/src/main.ts`(+必要 core 輔助):`resolve` subcommand,JsonEnvelope 輸出,退出碼 0=匹配/1=無匹配/2=歧義 + - 測試四組(schema enum、target 構造+marker 渲染、resolve 四型、fixtures) +- Out of scope: + - repo-harness 側 Stage 0 adapter(另一 repo) + - MCP `prepare_task` 輸出擴充(Stage 2) + - node schema 頂層欄位變更 / schemaVersion bump(升級規則:走 extensions) + - PRD §29.3 Q1 kind enum 開放性 + - PRD §17.7:2235 寫入時 overlap-exclusivity validator + - 遷移 repo-harness 的 6 個 capability(Stage 2) + - 任何 privacy/governance 面(GitHub App、cloud、tunnel)改動 + +## Workflow Inventory + +- Source plan: `plans/plan-20260705-1543-agent-context-provider.md` +- Deferred-goal ledger: `tasks/todos.md` +- Review file: `tasks/reviews/20260705-1543-agent-context-provider.review.md` +- Notes file: `tasks/notes/20260705-1543-agent-context-provider.notes.md` +- Checks file: `.ai/harness/checks/latest.json` +- Run snapshots: `.ai/harness/runs/` +- Scope gate: edit only paths listed under `allowed_paths`; update this contract before widening scope. +- Completion gate: `scripts/verify-sprint.sh` must see this contract pass, the review recommend pass, and `## External Acceptance Advice` pass or record a manual override. + +## Allowed Paths + +```yaml +allowed_paths: + - plans/ + - tasks/contracts/20260705-1543-agent-context-provider.contract.md + - tasks/reviews/20260705-1543-agent-context-provider.review.md + - tasks/notes/20260705-1543-agent-context-provider.notes.md + - docs/adr/ADR-0043-agent-context-provider.md + - schemas/runtime/projection-target.schema.json + - packages/core/projection-engine/ + - packages/local-runtime/model-store-yaml/ + - packages/surfaces/cli/ + - packages/contracts/test/ +``` + +## Delegation Contract + +```yaml +delegation: + budget: + tokens: null + tool_calls: null + wall_time_minutes: null + permission_scope: + mode: inherit_allowed_paths + writable_paths: [] + network: inherited + roles: + parent: + mode: narrate_and_gatekeep + purpose: approval_checkpoint_owner + explorer: + mode: read_only + purpose: codebase_research + worker: + mode: edit_within_allowed_paths + purpose: implementation + verifier: + mode: read_only + purpose: exit_criteria_review +``` + +## Exit Criteria (Machine Verifiable) + +```yaml +exit_criteria: + files_exist: + - docs/adr/ADR-0043-agent-context-provider.md + artifacts_exist: + - tasks/notes/20260705-1543-agent-context-provider.notes.md + files_contain: + - path: schemas/runtime/projection-target.schema.json + pattern: "agent-context" + - path: packages/core/projection-engine/src/index.ts + pattern: "agent-context" + - path: packages/local-runtime/model-store-yaml/src/index.ts + pattern: "agent-context" + tests_pass: + - path: packages/contracts/test/contracts.test.ts + commands_succeed: + - bun run typecheck + - bun run test:contracts + - node scripts/package-boundary-audit.mjs + qa_scores: + - dimension: functionality + min: 7 + manual_checks: + - "Evaluator review file recommends pass" +``` + +## Acceptance Notes (Human Review) + +- Functional behavior: `archctx resolve --path` 對已宣告 include 路徑回 envelope 含 stableId;歧義 fixture 退出碼 2;未宣告路徑退出碼 1。agent-context projection 對 capability node 產出 marker 塊,human 區保留。 +- Edge cases: `source.exclude` 覆蓋 include;node 無 `source` 欄位(視為不擁有任何路徑,不參與匹配);extensions 缺欄位時 resolve 輸出照常、僅省略。 +- Regression risks: targetType 三處硬編點漏改任何一處(schema/builder/default-manifest);pathTemplate 變數擴充影響既有 8 個 targetType 的渲染(以既有 projection 測試守住)。 + +## Rollback Point + +- Commit / checkpoint: base `2729112`(main,"Record archctx Apache license release") +- Revert strategy: 刪除 branch `codex/agent-context-provider` 與 worktree;無資料遷移。 diff --git a/tasks/notes/20260705-1543-agent-context-provider.notes.md b/tasks/notes/20260705-1543-agent-context-provider.notes.md new file mode 100644 index 0000000..bf3aab7 --- /dev/null +++ b/tasks/notes/20260705-1543-agent-context-provider.notes.md @@ -0,0 +1,124 @@ +# Implementation Notes: agent-context-provider + +> **Status**: Active +> **Plan**: plans/plan-20260705-1543-agent-context-provider.md +> **Contract**: tasks/contracts/20260705-1543-agent-context-provider.contract.md +> **Review**: tasks/reviews/20260705-1543-agent-context-provider.review.md +> **Last Updated**: 2026-07-05 15:44 +> **Lifecycle**: notes + +## Design Decisions + +- `NativeNode.source` is typed as `Json` (not a nested named `NativeNodeSource` interface), + because `NativeNode extends Record` and TypeScript rejects a + named interface property whose own index signature isn't exactly `Json` (TS2411), + even when every concrete field is Json-compatible. Added `nativeNodeSource(node)` as the + one typed accessor (`packages/core/projection-engine/src/index.ts`); all new code reads + `source` through it instead of casting inline. +- Agent-context target objects (`AgentContextProjectionTarget`) use a locally-scoped type + in `projection-engine/src/index.ts`, not the strict `ProjectionTargetV1`/ + `ProjectionTargetType` from `packages/contracts/src/ledger.ts` — that file is outside + this contract's `allowed_paths` (only `packages/contracts/test/` is listed), so the + `"agent-context"` literal cannot be added to the TS union there. The JSON Schema + (`schemas/runtime/projection-target.schema.json`, in allowed_paths and the actual + runtime validation source of truth) was updated normally. The new type is structurally + identical to `ProjectionTargetV1`, so widening the contracts union later is a + compatible, additive follow-up. +- `renderAgentContextProjection` is an independent entrypoint, not folded into + `renderArchitectureDocumentationProjection`'s `docs/architecture/*` pipeline. That + pipeline's existing-file discovery (`loadArchitectureDocumentationFiles`) only scans a + fixed whitelist under `docs/architecture/*`, and its drift/merge functions + (`findGeneratedRegion` et al.) hardcode the `` + marker family. The brief specifies a distinct marker label, `BEGIN/END ARCHCONTEXT + AGENT CONTEXT`, for the agent-context region, most plausibly so it doesn't collide with + repo-harness's own pre-existing CLAUDE.md/AGENTS.md generated-block mechanism + (`context-contract-sync.sh`, referenced in this repo's own root `CLAUDE.md`) when the + two coexist in the same file. Reusing the existing pipeline's marker/drift machinery + wholesale would have meant either colliding marker formats or a generalization of that + machinery well beyond "target construction + marker rendering." +- Replaced the ad hoc flat YAML parser (`parseFlatYaml`/`readYamlObjects`) in + `projection-engine` with the repo's existing full-fidelity `parseJsonOrStableYaml` + (`packages/core/architecture-domain/src/index.ts`, imported via the same relative-path + pattern `architecture-ledger` already uses). The flat parser only read unindented + top-level scalars and silently dropped nested structures, so it could never have + produced `node.source.include`/`node.extensions` — both required for agent-context + rendering and `resolve`. Verified no regression: `packages/surfaces/renderer/test/ + renderer.test.ts` (existing, out of allowed_paths, not edited) still passes unchanged. +- `archctx resolve --path` exit codes (0/1/2) are new, isolated CLI behavior. Confirmed by + reading the dispatcher that every other command always exits `0` regardless of the + envelope's `ok` field (the only other `process.exitCode` write in the file is the + `daemon start --foreground` crash path). Implemented as one added `if (command === + "resolve") process.exitCode = resolveCommandExitCode(result);` line plus one exported + helper, instead of changing exit-code behavior for every command. +- The `resolve` envelope always returns `ok: true` with `data.matched`/`data.ambiguous` + flags, rather than an error code, for the no-match/ambiguous cases. + `packages/contracts/src/schema.ts`'s `ArchContextErrorCode` enum is out of allowed_paths + and none of the existing codes fit "no owner" or "ambiguous owner." This also matches + the ADR's framing: ambiguity is a deliberate, first-class resolution outcome, not a + thrown error. +- `primarySourceDirectoryFromInclude`: the directory root of a `source.include` entry is + the literal prefix before its first `*`/`?`, truncated back to the last `/`; a literal + entry with no wildcard is treated as an entrypoint file and resolves to its containing + directory. This was the plan's one named open implementation detail; the exact rule is + now written into ADR-0043 §4 rather than left implicit in code. + +## Deviations From Plan Or Spec + +- The brief said to read ADR-0042 (and one or two others) for format. ADR-0041/ADR-0042 do + not exist in this worktree at base `2729112` (highest present is ADR-0040) — presumably + reserved by concurrent sibling work off a later `main`. Used ADR-0040, ADR-0037, and + ADR-0026 for format instead; ADR-0043 itself is written exactly as named by the contract + and plan, so the target filename/number was never in question. +- Test group (b) ("projection-engine test for agent-context target construction + marker + rendering") and the pure-function half of group (c) ("resolve 4 型") were added under a + new `packages/core/projection-engine/test/` directory, rather than extending + `packages/surfaces/renderer/test/renderer.test.ts`, which is where every other + projection-engine rendering behavior is currently tested. `packages/surfaces/renderer/` + is not in this contract's `allowed_paths`; `packages/core/projection-engine/` is, and a + per-module `test/` directory is already the dominant convention across every other + `packages/core/*` module (projection-engine was the one exception, with no test + directory of its own before this change). +- The CLI-level half of group (c) (end-to-end `archctx resolve --path` behavior plus the + exit-code mapping) was added to the existing `packages/surfaces/cli/test/cli.test.ts`, + matching that file's single-flat-`describe` convention. + +## Tradeoffs Considered + +| Option | Decision | Reason | +|--------|----------|--------| +| Fold `agent-context` into `renderArchitectureDocumentationProjection`'s existing docs/architecture/* pipeline | Rejected — built an independent `renderAgentContextProjection` entrypoint | Existing pipeline's file discovery and marker/drift functions are hardcoded to `docs/architecture/*` paths and the `ARCHCONTEXT:generated` marker family; forcing agent-context through it means either a marker collision or a scope-expanding generalization of tested, shared code | +| Widen `parseFlatYaml` to also parse nested `source`/`extensions` | Rejected — reused the existing `parseJsonOrStableYaml` full-fidelity parser | Avoids a second hand-rolled YAML parser for the same file shape; `parseJsonOrStableYaml` already round-trips `stableYaml()` output exactly, verified via the existing renderer test | +| Tie process exit code to the `ok` field for every CLI command | Rejected — special-cased only `resolve` | Every other command currently always exits `0` regardless of `ok`; a global change is out of scope and risks unrelated regressions | +| Widen `packages/contracts/src/ledger.ts`'s `ProjectionTargetType` union to add `"agent-context"` | Rejected — locally-scoped structural type in projection-engine | `packages/contracts/src/` is outside this contract's allowed_paths | + +## Open Questions + +- `docs/adr/README.md`'s ADR index table was not updated to list ADR-0043 — that file is + outside this contract's allowed_paths. +- Wiring `agent-context` into an actual write path (a `docs apply`-style command that + writes `/CLAUDE.md`/`AGENTS.md` to disk through ChangeSet, per + ADR-0040's ledger-mutation boundary) is not implemented. Scope named "target + construction + marker rendering" only; `renderAgentContextProjection` is a pure + function today with no CLI-triggered disk write. +- `packages/contracts/src/ledger.ts`'s `ProjectionTargetType` union does not include + `"agent-context"` (see Tradeoffs). A follow-up contract should widen it once + agent-context targets need to flow through code typed against the strict contracts + interface. +- ADR-0041/ADR-0042 numbering gap: neither exists in this worktree at base `2729112` + (highest present is ADR-0040). Not renumbered or backfilled here; presumably owned by + concurrent sibling work. +- Confirmed out of scope and untouched, per the contract: repo-harness Stage 0 adapter, + MCP `prepare_task` output extension, PRD §29.3 Q1 (node kind enum openness), PRD + §17.7:2235 write-time overlap-exclusivity validator, and migrating repo-harness's six + existing capabilities. + +## Evidence Links + +- Checks: `.ai/harness/checks/latest.json` +- Run snapshots: `.ai/harness/runs/` + +## Promotion Candidates + +- Promote to `tasks/lessons.md` only after a repeated correction or failure pattern. +- Promote to `docs/researches/` only when it is durable repo knowledge with evidence. +- Promote to harness asset files only after verification across more than one task or fixture. diff --git a/tasks/reviews/20260705-1543-agent-context-provider.review.md b/tasks/reviews/20260705-1543-agent-context-provider.review.md new file mode 100644 index 0000000..f4617c7 --- /dev/null +++ b/tasks/reviews/20260705-1543-agent-context-provider.review.md @@ -0,0 +1,78 @@ +# Task Review: agent-context-provider + +> **Status**: Pending +> **Plan**: plans/plan-20260705-1543-agent-context-provider.md +> **Contract**: tasks/contracts/20260705-1543-agent-context-provider.contract.md +> **Notes File**: tasks/notes/20260705-1543-agent-context-provider.notes.md +> **Checks File**: .ai/harness/checks/latest.json +> **Last Updated**: 2026-07-05 15:44 +> **Recommendation**: fail + +## Human Review Card + +- Verdict: pending +- Change type: code-change | docs-only | ledger-closeout | migration | eval-only | delegated-run +- Intended files changed: +- Actual files changed: +- Commands passed: +- External acceptance: unavailable +- Residual risks: +- Reviewer action required: inspect diff and card +- Rollback: + +## Mode Evidence + +- Selected route: +- P1/P2/P3 evidence: +- Root cause or plan evidence: + +## Verification Evidence + +- Waza `/check` run: +- Commands run: +- Manual checks: +- Supporting artifacts: +- Implementation notes reviewed: +- Run snapshot: + +## External Acceptance Advice + +> **External Acceptance**: unavailable +> **External Reviewer**: +> **External Source**: +> **External Started**: +> **External Completed**: + +- P1 blockers: +- P2 advisories: +- Acceptance checklist: + +## Behavior Diff Notes + +- ... + +## Residual Risks / Follow-ups + +- ... + +## Scorecard + +| Dimension | Score | Notes | +|-----------|-------|-------| +| Functionality | 0/10 | | +| Product depth | 0/10 | | +| Design quality | 0/10 | | +| Code quality | 0/10 | | + +## Failing Items + +- ... + +## Retest Steps + +- Re-run: +- Re-check: + +## Summary + +- ...