diff --git a/bun.lock b/bun.lock index d2ec94f4..b6606e71 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ }, "packages/context": { "name": "@c4a/context", - "version": "0.7.36", + "version": "0.7.38", "dependencies": { "yaml": "^2.5.1", "zod": "^3.23.8", @@ -26,7 +26,7 @@ }, "packages/context-cli": { "name": "@c4a/context-cli", - "version": "0.7.36", + "version": "0.7.38", "bin": { "context": "dist/cli.js", }, @@ -73,7 +73,7 @@ }, "packages/core": { "name": "@c4a/core", - "version": "0.7.36", + "version": "0.7.38", "dependencies": { "picomatch": "^4.0.4", "yaml": "^2.4.5", @@ -85,7 +85,7 @@ }, "packages/dev-cli": { "name": "@c4a/dev-cli", - "version": "0.7.36", + "version": "0.7.38", "dependencies": { "@c4a/context": "workspace:*", "@c4a/core": "workspace:*", @@ -97,7 +97,7 @@ }, "packages/extract": { "name": "@c4a/extract", - "version": "0.7.36", + "version": "0.7.38", "bin": { "c4a-extract-code": "./dist/bin/c4a-extract-code.js", }, @@ -112,7 +112,7 @@ }, "packages/extract-contract": { "name": "@c4a/extract-contract", - "version": "0.7.36", + "version": "0.7.38", "dependencies": { "@c4a/core": "workspace:*", "graphql": "^16.14.2", @@ -122,7 +122,7 @@ }, "packages/extract-go": { "name": "@c4a/extract-go", - "version": "0.7.36", + "version": "0.7.38", "dependencies": { "@c4a/core": "workspace:*", "@c4a/extract": "workspace:*", @@ -132,7 +132,7 @@ }, "packages/extract-mdx": { "name": "@c4a/extract-mdx", - "version": "0.7.36", + "version": "0.7.38", "dependencies": { "@c4a/core": "workspace:*", "remark-mdx": "^3.1.1", @@ -144,7 +144,7 @@ }, "packages/extract-proto": { "name": "@c4a/extract-proto", - "version": "0.7.36", + "version": "0.7.38", "dependencies": { "@c4a/core": "workspace:*", "zod": "^3.23.8", @@ -152,7 +152,7 @@ }, "packages/extract-rush": { "name": "@c4a/extract-rush", - "version": "0.7.36", + "version": "0.7.38", "dependencies": { "@c4a/core": "workspace:*", "typescript": "^5.5.4", @@ -162,7 +162,7 @@ }, "packages/extract-sql": { "name": "@c4a/extract-sql", - "version": "0.7.36", + "version": "0.7.38", "dependencies": { "@c4a/core": "workspace:*", "node-sql-parser": "^5.4.0", @@ -171,7 +171,7 @@ }, "packages/extract-style": { "name": "@c4a/extract-style", - "version": "0.7.36", + "version": "0.7.38", "dependencies": { "@c4a/core": "workspace:*", "postcss": "^8.5.26", @@ -183,7 +183,7 @@ }, "packages/extract-thrift": { "name": "@c4a/extract-thrift", - "version": "0.7.36", + "version": "0.7.38", "dependencies": { "@c4a/core": "workspace:*", "zod": "^3.23.8", @@ -191,7 +191,7 @@ }, "packages/extract-ts": { "name": "@c4a/extract-ts", - "version": "0.7.36", + "version": "0.7.38", "dependencies": { "@c4a/core": "workspace:*", "@c4a/extract": "workspace:*", @@ -202,7 +202,7 @@ }, "packages/tui": { "name": "@c4a/tui", - "version": "0.7.36", + "version": "0.7.38", "dependencies": { "ink": "^5.0.0", "react": "^18.3.1", diff --git a/package.json b/package.json index db8cb00f..2019cef6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "context", - "version": "0.7.37", + "version": "0.7.38", "packageManager": "bun@1.3.9", "repository": { "type": "git", diff --git a/packages/context-cli/context-workflow/provider.yaml b/packages/context-cli/context-workflow/provider.yaml index f9645120..718698fe 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.37 +version: 0.7.38 name: Context workflow description: Internal work contract for Context knowledge workspaces. graphs: diff --git a/packages/context-cli/context-workflow/resources/procedures/production-requirements.md b/packages/context-cli/context-workflow/resources/procedures/production-requirements.md index 5fab818d..5d8b7ed7 100644 --- a/packages/context-cli/context-workflow/resources/procedures/production-requirements.md +++ b/packages/context-cli/context-workflow/resources/procedures/production-requirements.md @@ -52,6 +52,9 @@ writing batches are declared in the temporary investigation plan. After editing, run `context status --format json` and consume its current Route. +Read `context action prepare-current --schema --format json` for the complete +known-task input contract before creating a stage; no workspace or revision is required. + When article targets and source grouping are already clear, put the existing plan fields (`capabilities`, `articles`, optional `indexer_usage`) in a file under `.tmp/agent-work/`, omitting only `stage`, which does not exist yet. Each article @@ -60,27 +63,17 @@ contains `path`, `question`, `sources` and `batch`, with optional `brief` and command to prepare those tasks without a separate investigation-plan submission. This does not approve the report or permit writing before its applicable scope decision. -When targets are unclear, use normal preparation and investigate the skeletons. - -## Configured coverage and the current request - -`src/indexers.yaml` describes the workspace's standing coverage configuration. -Source registration or presence in that configuration does not, by itself, mean -that the source needs investigation again for this request. For a bounded document -addition or revision, identify its reader task and actual evidence dependencies; -reuse existing approved articles and applicable completed work. Do not broaden a -document task to unrelated code repositories merely because they are configured. - -In planning and completion reports distinguish: -- work requested and completed in this task; -- specific content gaps established by reading, with their affected reader tasks; -- source/environment failures, and whether this task depends on those sources. +When targets are unclear, repeat `--source ` on the preparation command +for the sources selected by this request. Without a selection, only a sole +configured target is inferred; multiple targets require the Agent to select +from the user's request, not to ask the user to understand CLI source IDs. +The CLI prepares only this scope. A source in `evidence_source_scope` remains +available without becoming an investigation assignment. A later article plan +can select it in `articles[].sources`; the CLI validates and prepares that +actual dependency before allowing writing. Missing required evidence still +blocks the dependent article. -A `pending_scopes` count is workflow state, not a count of missing topics, -repositories to rewrite, or articles to produce. A Git spawn/baseline-read error -means source availability could not be checked; it does not prove that existing -knowledge is absent or obsolete. If unrelated configured sources remain blocked, -say so as a workflow limitation rather than announcing new investigation work. -Do not delete configured sources, invent exclusions, clear stage files, or mark -unread material investigated to make the current task appear complete. Follow the -returned resolution action when the workflow still needs a decision. +Long-term configuration additions do not restart an unchanged stage. Plans and +refreshes track this request's selected investigation and actual article inputs. +Preserve genuinely unfinished work when extending a multi-stage request; do not +turn an incremental addition into a whole-workspace refresh. diff --git a/packages/context-cli/context-workflow/resources/procedures/production-stage-files.md b/packages/context-cli/context-workflow/resources/procedures/production-stage-files.md index 6c21723f..5d918e42 100644 --- a/packages/context-cli/context-workflow/resources/procedures/production-stage-files.md +++ b/packages/context-cli/context-workflow/resources/procedures/production-stage-files.md @@ -11,7 +11,9 @@ directories. Read relevant source text before writing; a skeleton is navigation, not semantic evidence. Skill names guide work, not article ownership or versions. Use the [reader organization guidance](knowledge-updates.md#reader-tasks-names-and-reading-order) -for article names, placement and manually arranged navigation. Check the draft's +for article names, placement and manually arranged navigation. +Use `context task adjust --inspect --format json` to obtain current categories, +reading order and the exact revision before preparing a navigation adjustment. Check the draft's main reader task against its planned title and group; adjust through the existing plan, revision or knowledge-map flow when needed. A navigation change alone does not call for rewriting valid prose. @@ -57,7 +59,7 @@ current task does not mean other authorized work is finished. New source or purp authorization still follows the current Route. Write results under the returned Agent directory. All submission paths are -relative to that stage directory, even when the manifest is in `submissions/`. +relative to `.tmp/agent-work/production-stages//`, even when the manifest is in `submissions/`. They are never relative to `.tmp/context-runtime/` or to the manifest location. Each task lists the complete workspace-relative Markdown and reference output paths. Copy the CLI's submission template, keep completed tasks only, and keep each task's prefilled `input`. Submit the manifest using the returned command. Do not edit submitted files until the command returns. diff --git a/packages/context-cli/context-workflow/resources/procedures/work-start-report.md b/packages/context-cli/context-workflow/resources/procedures/work-start-report.md index 311df83d..1a9502a1 100644 --- a/packages/context-cli/context-workflow/resources/procedures/work-start-report.md +++ b/packages/context-cli/context-workflow/resources/procedures/work-start-report.md @@ -184,32 +184,20 @@ a top-level navigation change already required by this procedure. ## Configured coverage and the current request -`src/indexers.yaml` describes the workspace's standing coverage configuration. -Source registration or presence in that configuration does not, by itself, mean -that the source needs investigation again for this request. For a bounded document -addition or revision, identify its reader task and actual evidence dependencies; -reuse existing approved articles and applicable completed work. Do not broaden a -document task to unrelated code repositories merely because they are configured. - -In planning and completion reports distinguish: -- work requested and completed in this task; -- specific content gaps established by reading, with their affected reader tasks; -- source/environment failures, and whether this task depends on those sources. - -A `pending_scopes` count is workflow state, not a count of missing topics, -repositories to rewrite, or articles to produce. A Git spawn/baseline-read error -means source availability could not be checked; it does not prove that existing -knowledge is absent or obsolete. If unrelated configured sources remain blocked, -say so as a workflow limitation rather than announcing new investigation work. -Do not delete configured sources, invent exclusions, clear stage files, or mark -unread material investigated to make the current task appear complete. Follow the -returned resolution action when the workflow still needs a decision. - -For a bounded addition inside an existing production stage, completed articles can -enter Review with `context run --deliver --format json`. This retains pending -configured scopes and unfinished tasks; it does not authorize approval or publish. -Use this existing delivery path when remaining source failures are unrelated to -the requested articles. If the selected articles need the missing repository, use -`context source recovery-plan` and `context source restore` with an explicit local -checkout or clone decision. Do not ask users to investigate unrelated repositories -just to deliver saved notes, and do not clear gaps to imitate task completion. +Long-term source configuration is the authorized reading boundary, not the work +requested in every conversation. Prepare this request's selected investigation +sources or known article tasks. Keep unused supporting material available for +later article plans without treating it as unfinished work. For an addition to +an existing article, prefer the existing article-revision workflow with the +selected supporting sources rather than starting unrelated production. + +Report this request's planned and completed work. Restore missing sources that +its investigation or articles actually require. Reference material not selected +for this work does not need restoration just to complete an independent note. +Never infer missing knowledge merely from unavailable source-code checkouts. + +For an explicitly requested partial delivery, `context run --deliver --format json` +reviews completed articles while retaining genuine remaining tasks and selected +investigation. It does not approve or publish articles, and is unnecessary for +an otherwise completed bounded request. Do not clear unfinished work to force +completion; ordinary source authorization and citation checks still apply. diff --git a/packages/context-cli/package.json b/packages/context-cli/package.json index b9909ae0..695e82ae 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.37", + "version": "0.7.38", "type": "module", "license": "MIT", "engines": { diff --git a/packages/context-cli/src/__tests__/knowledgeMap.integration.test.ts b/packages/context-cli/src/__tests__/knowledgeMap.integration.test.ts index 4eb620a4..124ce6d1 100644 --- a/packages/context-cli/src/__tests__/knowledgeMap.integration.test.ts +++ b/packages/context-cli/src/__tests__/knowledgeMap.integration.test.ts @@ -38,7 +38,11 @@ test("the existing task adjustment updates only organization and rejects stale c const next = { expected_revision: first.revision, upsert: [{ ...first.entries[0]!, title: "New reader label" }], remove: [] }; expect(await adjustCurrentTaskSources(root, { knowledge_map: next })).toMatchObject({ outcome: "knowledge-map-updated" }); expect(await readFile(join(root, "knowledge/entry.md"), "utf8")).toBe(content); - await expect(adjustCurrentTaskSources(root, { knowledge_map: next })).rejects.toThrow("reread"); + await expect(adjustCurrentTaskSources(root, { knowledge_map: next })).rejects.toMatchObject({ detail: { + reason_code: "knowledge-map-stale-revision", expected_revision: first.revision, + current_revision: (await readKnowledgeMap(root))!.revision, + next_action: { command: "context task adjust --inspect --format json" }, + } }); expect((await readKnowledgeMap(root))?.entries[0]?.title).toBe("New reader label"); } finally { await rm(root, { recursive: true, force: true }); } }); diff --git a/packages/context-cli/src/__tests__/knowledgeMapRevision.test.ts b/packages/context-cli/src/__tests__/knowledgeMapRevision.test.ts index 93ee5e44..82069bc5 100644 --- a/packages/context-cli/src/__tests__/knowledgeMapRevision.test.ts +++ b/packages/context-cli/src/__tests__/knowledgeMapRevision.test.ts @@ -40,7 +40,7 @@ test("navigation binds a local revision's new draft before approval and rejects expect((await readKnowledgeMap(root))!.entries.find(entry => entry.key === "new-task")!.target) .toEqual({ artifact_ref: candidate.article_id, section_key: "usage" }); const afterReport = await writeReviewHtml({ projectRoot: root, all: true }); - expect(afterReport.navigation).toEqual({ ready: true, unplaced_count: 0, unplaced: [] }); + expect(afterReport.navigation).toMatchObject({ ready: true, unplaced_count: 0, unplaced: [] }); expect(afterReport.next_action).toBeUndefined(); expect(await readCandidateRecords(root)).toEqual(candidates); await expect(readFile(join(root, "knowledge", path))).rejects.toThrow(); diff --git a/packages/context-cli/src/__tests__/productionArticleWorkflow.fixture.ts b/packages/context-cli/src/__tests__/productionArticleWorkflow.fixture.ts index 96d63545..dbba022f 100644 --- a/packages/context-cli/src/__tests__/productionArticleWorkflow.fixture.ts +++ b/packages/context-cli/src/__tests__/productionArticleWorkflow.fixture.ts @@ -27,7 +27,7 @@ export async function produceFixtureArticles(root: string, articles: FixtureProd } = {}) { const request = await productionPlanningRequest(root); if (!request) throw new Error("Fixture needs canonical production requirements"); - await prepareCurrentProductionStage({ projectRoot: root, revision: request.revision }); + await prepareCurrentProductionStage({ projectRoot: root, revision: request.revision, sources: [...new Set(articles.flatMap(article => article.sources))] }); const initial = (await readProductionStage(root))!; const agent = join(root, productionAgentDirectory(initial.id)); await mkdir(join(agent, "submissions"), { recursive: true }); diff --git a/packages/context-cli/src/__tests__/productionExclusion.integration.test.ts b/packages/context-cli/src/__tests__/productionExclusion.integration.test.ts index 72686da2..11dc357c 100644 --- a/packages/context-cli/src/__tests__/productionExclusion.integration.test.ts +++ b/packages/context-cli/src/__tests__/productionExclusion.integration.test.ts @@ -64,7 +64,7 @@ test("confirmed exclusion settles unfinished scope, retains accepted work and su expect(dispatchProductionStage((await readProductionStage(root))!, productionCapabilitiesSchema.parse({})).state).toBe("ended"); expect(await readCandidateRecords(root)).toEqual(accepted); await rm(join(root, ".tmp"), { recursive: true, force: true }); - await prepareCurrentProductionStage({ projectRoot: root, revision: (await productionPlanningRequest(root))!.revision }); + await prepareCurrentProductionStage({ projectRoot: root, revision: (await productionPlanningRequest(root))!.revision, sources: targets.map(target => target.source_ref) }); const fresh = (await readProductionStage(root))!; expect(fresh.scopes.map(source => source.scope)).toEqual([first.source_ref]); expect(fresh.tasks).toEqual([]); diff --git a/packages/context-cli/src/__tests__/productionKnownTasks.integration.test.ts b/packages/context-cli/src/__tests__/productionKnownTasks.integration.test.ts index 3074a1bf..724d1442 100644 --- a/packages/context-cli/src/__tests__/productionKnownTasks.integration.test.ts +++ b/packages/context-cli/src/__tests__/productionKnownTasks.integration.test.ts @@ -116,7 +116,7 @@ test("known tasks retain source authorization and reject unsafe files before cre await writeFile(join(f.root, f.path), YAML.stringify({ ...f.plan, articles: [{ ...f.plan.articles[0], sources: ["repo:not-authorized"] }] })); await expect(prepareKnownProductionTasks({ projectRoot: f.root, cwd: f.root, revision: f.revision, path: f.path })).rejects.toThrow("outside authorized"); - expect((await readProductionStage(f.root))!.tasks).toEqual([]); + expect(await readProductionStage(f.root)).toBeUndefined(); }); test("the real action boundary returns actionable plan, report and syntax failures without altering tasks", async () => { @@ -175,3 +175,11 @@ test("the same known article saves one real CLI round trip before writing withou // excluding model reading, authoring and the human's report response time. console.info(`Same-article CLI comparison: ${JSON.stringify(measurements)}`); }); + + +test("known-task schema is available before a workspace or revision exists", async () => { + const schema = JSON.parse(await runCliInDir(resolve(".tmp"), ["action", "prepare-current", "--schema", "--format", "json"])); + expect(schema.properties.articles.items.properties.sources).toBeDefined(); + expect(schema.required).not.toContain("stage"); + expect(schema.properties.capabilities).toBeDefined(); +}); diff --git a/packages/context-cli/src/__tests__/productionMixedSources.integration.test.ts b/packages/context-cli/src/__tests__/productionMixedSources.integration.test.ts index 7518a560..bd79552f 100644 --- a/packages/context-cli/src/__tests__/productionMixedSources.integration.test.ts +++ b/packages/context-cli/src/__tests__/productionMixedSources.integration.test.ts @@ -61,7 +61,7 @@ test("code plus two documents share reader topics across skills and batches thro target_scope: { targets: sources.map(source_ref => ({ source_ref })) }, exclusions: [{ scope: { targets: [{ source_ref: sources[0] }] }, paths: ["src/deprecated"], reason: "User requests only current APIs" }] }] }); await writeFile(join(root, "src/indexers.yaml"), requirements); - await prepareCurrentProductionStage({ projectRoot: root, revision: (await productionPlanningRequest(root))!.revision }); + await prepareCurrentProductionStage({ projectRoot: root, revision: (await productionPlanningRequest(root))!.revision, sources }); const initial = (await readProductionStage(root))!; expect(initial.gaps).toEqual([]); const shared = (source: string) => readFile(join(root, productionStageDirectory(initial.id), productionSourceFile(source)), "utf8"); diff --git a/packages/context-cli/src/__tests__/productionNavigation.integration.test.ts b/packages/context-cli/src/__tests__/productionNavigation.integration.test.ts index 44b2e02e..404188a3 100644 --- a/packages/context-cli/src/__tests__/productionNavigation.integration.test.ts +++ b/packages/context-cli/src/__tests__/productionNavigation.integration.test.ts @@ -7,7 +7,7 @@ import { readCandidateRecords } from "../project/candidateLedger.js"; import { closeProjectWorkspace } from "../project/close.js"; import { acceptStarterPackageTemplates } from "../project/packageTemplateReview.js"; import { buildFixturePackages } from "./workspaceVersionDelivery.fixture.js"; -import { readKnowledgeMap } from "../project/knowledgeMap.js"; +import { readKnowledgeMap, inspectKnowledgeMap } from "../project/knowledgeMap.js"; import { adjustCurrentTaskSources } from "../project/taskSourceAdjustment.js"; test("reader navigation spans current articles and can be renamed without rewriting their prose", async () => { @@ -34,6 +34,13 @@ test("reader navigation spans current articles and can be renamed without rewrit expect(await readFile(join(root, "dist/update-kb", decodeURIComponent(path)), "utf8")).toContain(''); } const map = (await readKnowledgeMap(root))!; + const inspection = await inspectKnowledgeMap(root); + expect(inspection.revision).toBe(map.revision); + expect(inspection.entries).toEqual(map.entries); + await expect(adjustCurrentTaskSources(root, { knowledge_map: { expected_revision: "stale", upsert: [], remove: [] } })) + .rejects.toMatchObject({ detail: { reason_code: "knowledge-map-stale-revision", current_revision: map.revision, + next_action: { command: "context task adjust --inspect --format json" } } }); + expect(await readKnowledgeMap(root)).toEqual(map); await adjustCurrentTaskSources(root, { knowledge_map: { expected_revision: map.revision, remove: [], upsert: [{ ...map.entries.find(entry => entry.key === "guide")!, title: "Adoption by reader task" }] } }); expect((await buildFixturePackages(root)).packages[0]!.state).toBe("updated"); @@ -67,6 +74,10 @@ test("accepted drafts can be placed and reviewed while unrelated repository gaps await expect(adjustCurrentTaskSources(root, { knowledge_map: { expected_revision: map.revision, upsert: [{ ...entries[0]!, target: { artifact_ref: candidates[0]!.article_id, section_key: "missing" } }] } })).rejects.toThrow(); await expect(readProductionReviewCandidates(root)).rejects.toThrow(); + const { productionWorkflowRoute } = await import("../project/productionWorkflowRoute.js"); + const route = await productionWorkflowRoute({ projectRoot: root, authorities: [] }); + expect(route?.commands).toContainEqual({ command: "context run --deliver --format json", effect: "write", + availability: "after-human-confirmation", managed_execution: "agent-required" }); expect(await requestProductionDelivery(root)).toBe(true); expect((await readProductionReviewCandidates(root))!.candidates).toHaveLength(candidates.length); const after = (await readProductionStage(root))!; diff --git a/packages/context-cli/src/__tests__/productionPlanning.integration.test.ts b/packages/context-cli/src/__tests__/productionPlanning.integration.test.ts index 502b33a2..3450b2a0 100644 --- a/packages/context-cli/src/__tests__/productionPlanning.integration.test.ts +++ b/packages/context-cli/src/__tests__/productionPlanning.integration.test.ts @@ -251,7 +251,7 @@ test("an initially unavailable source does not block independent writing and is const initiallyMissing = join(projectRoot, "sources/note/20260913/later.md"); const heldInitialSource = join(outer, "held-later.md"); await rename(initiallyMissing, heldInitialSource); - await prepareCurrentProductionStage({ projectRoot, revision: (await productionPlanningRequest(projectRoot))!.revision }); + await prepareCurrentProductionStage({ projectRoot, revision: (await productionPlanningRequest(projectRoot))!.revision, sources: sources.map(source => source.source_ref) }); const stage = (await readProductionStage(projectRoot))!; expect(stage.scopes.find(source => source.scope === sources[1]!.source_ref)?.baseline).toBeNull(); expect(stage.gaps.map(gap => gap.scope)).toEqual([sources[1]!.source_ref]); diff --git a/packages/context-cli/src/__tests__/productionPlanningMaterials.test.ts b/packages/context-cli/src/__tests__/productionPlanningMaterials.test.ts index 6db36176..d1cdde31 100644 --- a/packages/context-cli/src/__tests__/productionPlanningMaterials.test.ts +++ b/packages/context-cli/src/__tests__/productionPlanningMaterials.test.ts @@ -89,7 +89,7 @@ test("saved material planning honors shared scope and explicit exclusions withou exclusions: [{ scope: unavailableScope, reason: "Explicitly out of scope" }] }, requirements.requirements[1], ] })); - await prepareCurrentProductionStage({ projectRoot, revision: (await productionPlanningRequest(projectRoot))!.revision }); + await prepareCurrentProductionStage({ projectRoot, revision: (await productionPlanningRequest(projectRoot))!.revision, sources: [note.source_ref, "repo:missing"] }); const stage = (await readProductionStage(projectRoot))!; expect(stage.scopes.map(source => source.scope)).toEqual([note.source_ref]); expect(stage.pending_scopes).toEqual([note.source_ref]); diff --git a/packages/context-cli/src/__tests__/productionScope.integration.test.ts b/packages/context-cli/src/__tests__/productionScope.integration.test.ts new file mode 100644 index 00000000..5093e5e6 --- /dev/null +++ b/packages/context-cli/src/__tests__/productionScope.integration.test.ts @@ -0,0 +1,129 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import YAML from "yaml"; +import { initContextProject } from "../project/workspace.js"; +import { importManagedDocument } from "../project/managedDocumentImport.js"; +import { prepareKnownProductionTasks } from "../project/productionKnownTasks.js"; +import { productionPlanningRequest, productionRequirementsAreCurrent, submitProductionPlan } from "../project/productionPlanning.js"; +import { readProductionStage } from "../project/productionStageStore.js"; +import { prepareCurrentProductionStage } from "../project/productionStagePreparation.js"; +import { refreshProductionStageSources } from "../project/productionStageRefresh.js"; +import { productionAgentDirectory } from "../project/productionSubmissionFiles.js"; +import { approveProductionReport, productionReportRevision } from "../project/productionReport.js"; +import { produceFixtureArticles } from "./productionArticleWorkflow.fixture.js"; +import { readProductionReviewCandidates } from "../project/productionReviewCandidates.js"; +import { readCandidateRecords } from "../project/candidateLedger.js"; +import { approveCandidates } from "./projectDocumentRevisionStages.fixture.js"; +import { closeProjectWorkspace } from "../project/close.js"; + +const roots: string[] = []; +afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); }); +async function fixture(missing = true) { + const parent = resolve(".tmp/scoped-production-tests"); + await mkdir(parent, { recursive: true }); + const outer = await mkdtemp(join(parent, "case-")); roots.push(outer); + const root = (await initContextProject({ cwd: outer, projectDir: "workspace", dev: true })).projectRoot; + const note = await importManagedDocument(root, { type: "note", name: "20260924/new.md", markdown: "# New\nKeep interaction simple.\n" }); + const reference = await importManagedDocument(root, { type: "note", name: "20260924/reference.md", markdown: "# Reference\nKeep the existing context.\n" }); + const old = await importManagedDocument(root, { type: "note", name: "20260924/old.md", markdown: "# Old\nOld independent work.\n" }); + const requirements = { requirements: [{ id: "guide", purpose: "Explain interaction", + target_scope: { targets: [{ source_ref: note.source_ref }, { source_ref: old.source_ref }] }, + evidence_source_scope: { targets: [{ source_ref: reference.source_ref }] } }] }; + await writeFile(join(root, "src/indexers.yaml"), YAML.stringify(requirements)); + if (missing) for (const name of ["old", "reference"]) await rename(join(root, `sources/note/20260924/${name}.md`), join(outer, `${name}.md`)); + const path = ".tmp/agent-work/known.yaml"; + await mkdir(join(root, ".tmp/agent-work"), { recursive: true }); + const plan = { capabilities: { skills: [] }, articles: [{ path: "sop/new.md", question: "How should interaction work?", sources: [note.source_ref], batch: "notes" }] }; + await writeFile(join(root, path), YAML.stringify(plan)); + return { root, outer, note, reference, old, requirements, path, plan }; +} +async function prepare(f: Awaited>) { + await prepareKnownProductionTasks({ projectRoot: f.root, cwd: f.root, revision: (await productionPlanningRequest(f.root))!.revision, path: f.path }); + return (await readProductionStage(f.root))!; +} + +test("a bounded note ignores unavailable historical targets and references through review and close", async () => { + const f = await fixture(); + await produceFixtureArticles(f.root, [{ ...f.plan.articles[0]!, markdown: "---\ntitle: Simple interaction\ndescription: A design principle\n---\n\n\nKeep interaction simple.\n\n", + references: { sections: [{ id: "principle", references: [{ source_ref: f.note.source_ref, locator: { path: "new.md", start_line: 2, end_line: 2 } }] }] } }]); + const stage = (await readProductionStage(f.root))!; + expect(stage.scopes.map(source => source.scope)).toEqual([f.note.source_ref]); + expect(stage.pending_scopes).toEqual([]); + expect(stage.gaps).toEqual([]); + expect((await readProductionReviewCandidates(f.root))!.candidates).toHaveLength(1); + await approveCandidates(f.root, await readCandidateRecords(f.root)); + expect((await closeProjectWorkspace(f.root)).verifyErrors).toBe(0); +}, 30_000); + +test("investigation must select current work instead of defaulting to all configured targets", async () => { + const f = await fixture(); + const revision = (await productionPlanningRequest(f.root))!.revision; + await expect(prepareCurrentProductionStage({ projectRoot: f.root, revision })).rejects.toMatchObject({ detail: { reason_code: "production-scope-required" } }); + expect(await readProductionStage(f.root)).toBeUndefined(); + await prepareCurrentProductionStage({ projectRoot: f.root, revision, sources: [f.note.source_ref] }); + expect((await readProductionStage(f.root))!.pending_scopes).toEqual([f.note.source_ref]); +}); + +test("an unused reference remains adoptable by a later article and its real dependency is enforced", async () => { + const f = await fixture(false); + const stage = await prepare(f); + const agent = join(f.root, productionAgentDirectory(stage.id), "submissions"); + await mkdir(agent, { recursive: true }); + await writeFile(join(agent, "report.yaml"), YAML.stringify({ stage: stage.id, decision: "approved" })); + await approveProductionReport({ projectRoot: f.root, stage: stage.id, revision: productionReportRevision(stage), path: "submissions/report.yaml" }); + const plan = { ...f.plan, stage: stage.id, articles: [{ ...f.plan.articles[0]!, path: "sop/reference.md", sources: [f.reference.source_ref], batch: "later" }] }; + await writeFile(join(agent, "later.yaml"), YAML.stringify(plan)); + await submitProductionPlan({ projectRoot: f.root, stage: stage.id, path: "submissions/later.yaml" }); + const expanded = (await readProductionStage(f.root))!; + expect(expanded.tasks).toHaveLength(2); + expect(expanded.scopes.map(source => source.scope)).toEqual([f.note.source_ref, f.reference.source_ref]); + expect(expanded.pending_scopes).toEqual([]); + expect(await productionRequirementsAreCurrent(f.root, expanded)).toBe(true); + await rename(join(f.root, "sources/note/20260924/reference.md"), join(f.outer, "held.md")); + const refreshed = await refreshProductionStageSources(f.root, expanded); + expect(refreshed.tasks.find(task => task.path === "sop/reference.md")!.status).toBe("blocked"); + expect(refreshed.tasks.find(task => task.path === "sop/new.md")!.status).toBe("issued"); + expect(refreshed.gaps.map(gap => gap.scope)).toEqual([f.reference.source_ref]); +}); + +test("unrelated configuration and unavailable references neither invalidate nor refresh current work", async () => { + const f = await fixture(false); + const stage = await prepare(f); + f.requirements.requirements[0]!.evidence_source_scope.targets.push({ source_ref: "repo:later-authorized" }); + await writeFile(join(f.root, "src/indexers.yaml"), YAML.stringify(f.requirements)); + expect(await productionRequirementsAreCurrent(f.root, stage)).toBe(true); + await rename(join(f.root, "sources/note/20260924/reference.md"), join(f.outer, "held.md")); + expect(await refreshProductionStageSources(f.root, stage)).toEqual(stage); +}); + +test("required missing references and explicit remaining investigation are not silently discarded", async () => { + const f = await fixture(); + f.plan.articles[0]!.sources.push(f.reference.source_ref); + await writeFile(join(f.root, f.path), YAML.stringify(f.plan)); + await expect(prepare(f)).rejects.toThrow(); + const stage = (await readProductionStage(f.root))!; + expect(stage.gaps.map(gap => gap.scope)).toEqual([f.reference.source_ref]); + expect(stage.tasks).toEqual([]); + expect(stage.scopes.some(source => source.scope === f.old.source_ref)).toBe(false); +}); + + +test("later work can reuse a completed task's source at its new version without a refresh loop", async () => { + const f = await fixture(false); + await produceFixtureArticles(f.root, [{ ...f.plan.articles[0]!, markdown: '---\ntitle: Original rule\ndescription: First delivery\n---\n\n\nKeep interaction simple.\n\n', + references: { sections: [{ id: "rule", references: [{ source_ref: f.note.source_ref, locator: { path: "new.md", start_line: 2, end_line: 2 } }] }] } }]); + const stage = (await readProductionStage(f.root))!; + const accepted = stage.tasks[0]!; + await importManagedDocument(f.root, { type: "note", name: "20260924/new.md", base_digest: f.note.digest, + markdown: "# New\nKeep interaction simple and preserve context.\n" }); + const path = join(productionAgentDirectory(stage.id), "submissions/later.yaml"); + await writeFile(join(f.root, path), YAML.stringify({ ...f.plan, stage: stage.id, + articles: [{ ...f.plan.articles[0]!, path: "sop/later.md" }] })); + await submitProductionPlan({ projectRoot: f.root, stage: stage.id, path: "submissions/later.yaml" }); + const next = (await readProductionStage(f.root))!; + expect(next.tasks[0]).toEqual(accepted); + expect(next.tasks[1]!.sources[0]!.baseline).not.toBe(accepted.sources[0]!.baseline); + expect(next.tasks[1]!.status).toBe("issued"); + expect(next.pending_scopes).toEqual([]); +}); diff --git a/packages/context-cli/src/__tests__/productionStageStore.test.ts b/packages/context-cli/src/__tests__/productionStageStore.test.ts index b83b95db..09fd7df8 100644 --- a/packages/context-cli/src/__tests__/productionStageStore.test.ts +++ b/packages/context-cli/src/__tests__/productionStageStore.test.ts @@ -91,6 +91,7 @@ test("materializes current single batch and reuses unchanged shared files", asyn const capabilities = productionCapabilitiesSchema.parse({ skills: [{ name: "code" }] }); const first = await materializeProductionStage({ projectRoot: f.root, ...f, capabilities }); expect(first.mode).toBe("single-agent"); + expect(first.agent_directory).toBe(productionAgentDirectory(f.stage.id)); expect(first.batches.map(batch => batch.id)).toEqual(["first"]); const current = (await readProductionStage(f.root))!; expect(current.tasks.map(task => task.status)).toEqual(["issued", "pending"]); diff --git a/packages/context-cli/src/__tests__/productionSubmissionFiles.test.ts b/packages/context-cli/src/__tests__/productionSubmissionFiles.test.ts index c3ecb484..b13a3135 100644 --- a/packages/context-cli/src/__tests__/productionSubmissionFiles.test.ts +++ b/packages/context-cli/src/__tests__/productionSubmissionFiles.test.ts @@ -112,3 +112,15 @@ describe("stage-local fixed submission files", () => { } })).rejects.toThrow("budget"); }); }); + + +test("missing drafts report the Agent base and exact expected path without accepting runtime files", async () => { + const f = await fixture(); + const path = "batches/first/article.md"; + await mkdir(join(f.root, ".tmp/context-runtime/production-stages", f.stage, "batches/first"), { recursive: true }); + await writeFile(join(f.root, ".tmp/context-runtime/production-stages", f.stage, path), "Wrong directory"); + await expect(readProductionFile({ projectRoot: f.root, stage: f.stage, path })).rejects.toMatchObject({ detail: { + reason_code: "invalid-production-file", base_directory: productionAgentDirectory(f.stage), + expected_path: join(productionAgentDirectory(f.stage), path), + } }); +}); diff --git a/packages/context-cli/src/__tests__/projectIndexerBootstrapV075.test.ts b/packages/context-cli/src/__tests__/projectIndexerBootstrapV075.test.ts index 74119708..4d36d93a 100644 --- a/packages/context-cli/src/__tests__/projectIndexerBootstrapV075.test.ts +++ b/packages/context-cli/src/__tests__/projectIndexerBootstrapV075.test.ts @@ -63,13 +63,19 @@ describe("Production bootstrap follows the workspace Graph", () => { } }); - test(`deterministic execution prepares once and stops for planning and report approval (managed=${managed})`, async () => { + test(`execution waits for source selection then stops for planning and report approval (managed=${managed})`, async () => { const { root, requirementsPath, requirements, source } = await workspace(); const args = ["run", ...flags, "--until", "blocked-or-complete", "--format", "json", "--verbose"]; const missing = JSON.parse(await runCliInDir(root, args)); expect(missing).toMatchObject({ state: "blocked", steps: [], stop: { reasonCode: "workflow.until.configuration-required" } }); await writeFile(requirementsPath, requirements); + const selection = JSON.parse(await runCliInDir(root, args)); + expect(selection.steps).toEqual([]); + expect(selection.workflow.current.node).toBe("prepare-production-planning"); + expect(await readProductionStage(root)).toBeUndefined(); + await runCliInDir(root, ["action", "prepare-current", "--revision", selection.workflow.current.revision, + "--source", source, "--format", "json"]); const prepared = JSON.parse(await runCliInDir(root, args)); expect(prepared.workflow.current.node).toBe("plan-production-stage"); const stage = (await readProductionStage(root))!; diff --git a/packages/context-cli/src/__tests__/projectIndexerWorkflowRouteContractV075.test.ts b/packages/context-cli/src/__tests__/projectIndexerWorkflowRouteContractV075.test.ts index bd74ad63..9b2ac1ef 100644 --- a/packages/context-cli/src/__tests__/projectIndexerWorkflowRouteContractV075.test.ts +++ b/packages/context-cli/src/__tests__/projectIndexerWorkflowRouteContractV075.test.ts @@ -76,7 +76,7 @@ describe("current Indexer workflow Route contract", () => { expect(() => validateIndexerCurrentActionInput(retiredAgentFinalization)).toThrow(); }); - test("current requirements lead directly to deterministic preparation without an Agent output contract", async () => { + test("current requirements expose source selection and the known-task schema before preparation", async () => { const parent = resolve(".tmp/production-route-tests"); await mkdir(parent, { recursive: true }); const root = await mkdtemp(join(parent, "case-")); roots.push(root); @@ -88,7 +88,10 @@ describe("current Indexer workflow Route contract", () => { const route = await projectCurrentIndexerWorkflowRoute({ projectRoot: root, route: outerIndexerAgentRoute(), authorities: contextWorkflowAuthorities({ managed: true }), managed: true }); expect(route).toMatchObject({ node: "prepare-production-planning", availability: "immediate", - commands: [{ command: expect.stringContaining("action prepare-current"), effect: "write", managed_execution: "automatic" }] }); + commands: [{ command: expect.stringContaining("action prepare-current"), effect: "write", managed_execution: "agent-required" }] }); + expect(route?.resources.recommended).toContainEqual(expect.objectContaining({ + command: "context action prepare-current --schema --format json", media_type: "application/schema+json", + })); expect(route?.action).toBeUndefined(); expect(route?.gate).toBeUndefined(); expect(await readFile(path, "utf8")).toBe(configuration); diff --git a/packages/context-cli/src/commands/actionCommands.ts b/packages/context-cli/src/commands/actionCommands.ts index 078ef5b1..09cafe34 100644 --- a/packages/context-cli/src/commands/actionCommands.ts +++ b/packages/context-cli/src/commands/actionCommands.ts @@ -13,7 +13,7 @@ import { readYamlOrJsonInput } from "../project/payloadInput.js"; import { ExitCode } from "../types/exitCode.js"; import { prepareCurrentProductionStage } from "../project/productionStagePreparation.js"; import { currentProductionOwnsAction } from "../project/indexerCurrentAction.js"; -import { prepareKnownProductionTasks } from "../project/productionKnownTasks.js"; +import { prepareKnownProductionTasks, productionKnownTasksSchema } from "../project/productionKnownTasks.js"; function requiredString(value: unknown, flag: string): string { if (typeof value === "string" && value.trim().length > 0) return value.trim(); @@ -29,11 +29,20 @@ export function registerProjectActionCommands(program: Command): void { action.command("prepare-current") .description("Prepare or retry current production batch directories without resubmitting accepted articles") - .requiredOption("--revision ", "current production stage identity") + .option("--revision ", "current production stage identity") + .option("--schema", "show the known-task input schema without a workspace or revision") .option("--input ", "already-decided article tasks under .tmp/agent-work; prepare and submit the plan together, then wait for report approval") + .option("--source ", "source selected for this request; repeat for multi-source investigation", (value: string, previous: string[]) => [...previous, value], []) .option("--multi-agent", "this caller can coordinate multiple independent batch directories; default is one batch") .option("--format ", "output format: json | yaml", "json") - .action(async (options: { revision: string; multiAgent?: boolean; format: string; input?: string }) => { + .action(async (options: { revision?: string; schema?: boolean; multiAgent?: boolean; format: string; input?: string; source: string[] }) => { + if (options.schema) { + const { zodToJsonSchema } = await import("zod-to-json-schema"); + const { writeSchemaOutput, schemaOutputFormat } = await import("../lib/schemaOutput.js"); + writeSchemaOutput(zodToJsonSchema(productionKnownTasksSchema, { $refStrategy: "none" }), schemaOutputFormat(options.format)); + return; + } + const revision = requiredString(options.revision, "--revision"); if (options.format !== "json" && options.format !== "yaml") throw new ContextError(ExitCode.UserError, "--format must be json or yaml", { category: ErrorCategory.UserInputInvalid, reason_code: "invalid-action-format", flag: "--format", valid_formats: ["json", "yaml"], @@ -43,8 +52,8 @@ export function registerProjectActionCommands(program: Command): void { "prepare-current requires a Context workspace", { category: ErrorCategory.WorkspaceNotFound, reason_code: "action-workspace-not-found", next_action: { command: "context entry --format json", instruction: "Locate the intended workspace before preparing task files." } }); const result = options.input ? await prepareKnownProductionTasks({ projectRoot: root.projectRoot, cwd: process.cwd(), - revision: options.revision, path: options.input }) : await prepareCurrentProductionStage({ projectRoot: root.projectRoot, - revision: options.revision, multiAgent: options.multiAgent === true }); + revision, path: options.input }) : await prepareCurrentProductionStage({ projectRoot: root.projectRoot, + revision, multiAgent: options.multiAgent === true, ...(options.source.length ? { sources: options.source } : {}) }); await new Promise((resolve, reject) => { process.stdout.write(serializeActionCompletion(result, options.format as "json" | "yaml"), error => { if (error) reject(error); else resolve(); diff --git a/packages/context-cli/src/commands/documentRevisionCommands.ts b/packages/context-cli/src/commands/documentRevisionCommands.ts index 5b1259d7..3883c6b2 100644 --- a/packages/context-cli/src/commands/documentRevisionCommands.ts +++ b/packages/context-cli/src/commands/documentRevisionCommands.ts @@ -134,8 +134,16 @@ export function registerDocumentRevisionCommand(program: Command): void { task.command("adjust").description("Adjust current source/module inputs or reader organization while preserving unrelated work") .option("--input ", "YAML/JSON source scopes with instruction, or knowledge_map; - for stdin") .option("--schema", "show source adjustment or knowledge map input schema") + .option("--inspect", "read current navigation entries, revision and an adjustment template") .option("--format ", "output format: json", "json") - .action(async (options: { input?: string; schema?: boolean; format: string }) => { + .action(async (options: { input?: string; schema?: boolean; inspect?: boolean; format: string }) => { + if (options.inspect) { + if (options.input || options.schema || options.format !== "json") throw new ContextError(ExitCode.UserError, + "Use --inspect --format json without --input or --schema.", { category: ErrorCategory.UserInputInvalid }); + const { inspectKnowledgeMap } = await import("../project/knowledgeMap.js"); + process.stdout.write(`${JSON.stringify(await inspectKnowledgeMap(requireProjectRoot()))}\n`); + return; + } if (options.schema) { const { taskSourceAdjustmentSchema } = await import("../project/taskSourceAdjustment.js"); const { zodToJsonSchema } = await import("zod-to-json-schema"); diff --git a/packages/context-cli/src/project/indexerBaseContracts.ts b/packages/context-cli/src/project/indexerBaseContracts.ts index db678d4d..533554fe 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.37"; +export const BUNDLED_INDEXER_PARSER_PACKAGE_VERSION = "0.7.38"; const BUNDLED_PARSER_REQUIREMENTS = buildIndexerParserCapabilityRequirements( BUNDLED_INDEXER_PARSER_PACKAGE_VERSION, ); diff --git a/packages/context-cli/src/project/knowledgeMap.ts b/packages/context-cli/src/project/knowledgeMap.ts index 76290a8d..6ed10330 100644 --- a/packages/context-cli/src/project/knowledgeMap.ts +++ b/packages/context-cli/src/project/knowledgeMap.ts @@ -9,6 +9,9 @@ import { } from "@c4a/context"; import { durableContentDigest } from "./durableSingleFileTransaction.js"; import { recoverDurableMultiFileTransactions, runDurableMultiFileTransaction, type DurableMultiFileFailureInjector } from "./durableMultiFileTransaction.js"; +import { ContextError } from "../lib/errors.js"; +import { ErrorCategory } from "../lib/cliFeedback.js"; +import { ExitCode } from "../types/exitCode.js"; import { withProjectWriteLock } from "./writeLock.js"; export const KNOWLEDGE_MAP_PATH = "src/knowledge-map.yaml"; @@ -21,6 +24,24 @@ export async function readKnowledgeMap(root: string): Promise { await recoverDurableMultiFileTransactions(input.projectRoot); const current = await readKnowledgeMap(input.projectRoot); + if (input.knowledge_map !== undefined) assertMapRevision(current, input.knowledge_map); const next = input.knowledge_map === undefined ? current : updateKnowledgeMap(current, input.knowledge_map); if (input.article_targets !== undefined) { const known = [...await approvedKnowledgeMapTargets(input.projectRoot), ...input.article_targets]; @@ -56,6 +78,7 @@ export async function applyKnowledgeMapUpdate(projectRoot: string, update: Knowl return withProjectWriteLock(projectRoot, "adjust-knowledge-map", async () => { await recoverDurableMultiFileTransactions(projectRoot); const current = await readKnowledgeMap(projectRoot); + assertMapRevision(current, update); const next = updateKnowledgeMap(current, update); const approved = await approvedKnowledgeMapTargets(projectRoot); const previewText = await optionalText(projectRoot, ".tmp/context-runtime/indexer/structure-review/preview.json"); diff --git a/packages/context-cli/src/project/productionKnownTasks.ts b/packages/context-cli/src/project/productionKnownTasks.ts index f2616923..9ee93ee3 100644 --- a/packages/context-cli/src/project/productionKnownTasks.ts +++ b/packages/context-cli/src/project/productionKnownTasks.ts @@ -38,7 +38,8 @@ export async function prepareKnownProductionTasks(input: { throw invalid("Use known tasks before report approval, with the current preparation revision. Approved stages use ordinary task amendments."); } if (!stage) { - await prepareCurrentProductionStage({ projectRoot: input.projectRoot, revision: input.revision }); + await prepareCurrentProductionStage({ projectRoot: input.projectRoot, revision: input.revision, + sources: [...new Set([...plan.articles.flatMap(article => article.sources), ...plan.pending_scopes ?? []])] }); stage = (await readProductionStage(input.projectRoot))!; } // Validate and accept exactly the fixed input already read. Neither this diff --git a/packages/context-cli/src/project/productionPlanning.ts b/packages/context-cli/src/project/productionPlanning.ts index 7c6b457f..5b9b2c43 100644 --- a/packages/context-cli/src/project/productionPlanning.ts +++ b/packages/context-cli/src/project/productionPlanning.ts @@ -5,11 +5,11 @@ import YAML from "yaml"; import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; import { indexerProtocolDigest } from "@c4a/context"; -import { inspectProductionRequirements, productionSourceIsExcluded } from "./productionRequirements.js"; +import { inspectProductionRequirements, productionSourceIsExcluded, type ProductionRequirements } from "./productionRequirements.js"; import { prepareProductionPlanningMaterials } from "./productionPlanningMaterials.js"; import { productionSourceBaseline } from "./productionSubmission.js"; import { productionCapabilitiesSchema, productionIndexerUsageSchema, productionTaskInput, validateProductionStage, reviseProductionPlan, type ProductionStage } from "./productionStage.js"; -import { materializeProductionStage, readProductionStage, saveProductionStage, productionStageDirectory, +import { materializeProductionStage, readProductionStage, productionStageDirectory, productionSourceFile, writeProductionProjection } from "./productionStageStore.js"; import { productionAgentDirectory, readProductionFile, type FixedProductionFile } from "./productionSubmissionFiles.js"; import { withProjectWriteLock } from "./writeLock.js"; @@ -25,6 +25,8 @@ import { readMaintenance } from "./maintenanceStorage.js"; import { withProductionFeedback } from "./productionFeedback.js"; import { resolveProductionExclusions } from "./productionExclusions.js"; +import { authorizedProductionSources, selectProductionSources, scopedProductionRequirements, productionRequestedSources, activeProductionSources, saveExpandedProductionScope } from "./productionScope.js"; + import { productionSourceSummary, productionSourceSummaryMarkdown } from "./productionSourceSummary.js"; function refreshRequired(stage: string, message: string, detail: Record = {}): ContextError { @@ -64,7 +66,7 @@ export async function productionPlanningIsPrepared(root: string, stage: Producti const supplied = YAML.parse(await readFile(await safeProjectTarget(root, join(directory, "shared/requirements.md")), "utf8")); await access(await safeProjectTarget(root, join(directory, "planning.md"))); await access(await safeProjectTarget(root, join(directory, "planning.schema.json"))); - return !!current && current.revision === indexerProtocolDigest(supplied); + return !!current && indexerProtocolDigest(scopedProductionRequirements(current.requirements, productionRequestedSources(stage))) === indexerProtocolDigest(scopedProductionRequirements(supplied as ProductionRequirements, productionRequestedSources(stage))); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; @@ -76,7 +78,7 @@ export async function productionRequirementsAreCurrent(root: string, stage: Prod const current = await productionPlanningRequest(root); const supplied = YAML.parse(await readFile(await safeProjectTarget(root, join(productionStageDirectory(stage.id), "shared/requirements.md")), "utf8")); - return !!current && current.revision === indexerProtocolDigest(supplied); + return !!current && indexerProtocolDigest(scopedProductionRequirements(current.requirements, productionRequestedSources(stage))) === indexerProtocolDigest(scopedProductionRequirements(supplied as ProductionRequirements, productionRequestedSources(stage))); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } @@ -88,7 +90,7 @@ export async function assertProductionPlanRequirementsCurrent(root: string, stag "Confirmed requirements changed or their planning snapshot is missing; refresh the plan before submitting"); } -export async function prepareInitialProductionPlanning(input: { projectRoot: string; revision: string }) { +export async function prepareInitialProductionPlanning(input: { projectRoot: string; revision: string; sources?: string[] }) { return withProjectWriteLock(input.projectRoot, "production-planning", async () => { const previous = await readProductionStage(input.projectRoot); if (previous && (previous.id !== input.revision || previous.report_approved && await productionRequirementsAreCurrent(input.projectRoot, previous))) { @@ -96,10 +98,12 @@ export async function prepareInitialProductionPlanning(input: { projectRoot: str } const request = await productionPlanningRequest(input.projectRoot); if (!request || (!previous && request.revision !== input.revision)) throw new TypeError("Production requirements changed; read the current route"); - const scopeNames = [...new Set(request.requirements.requirements.flatMap(item => [ - ...item.target_scope.targets, ...item.evidence_source_scope?.targets ?? [], - ].map(target => target.source_ref)))].filter(scope => !productionSourceIsExcluded(request.requirements, scope)); - const prepared = await prepareProductionPlanningMaterials({ projectRoot: input.projectRoot, requirements: request.requirements }); + const authorized = new Set(authorizedProductionSources(request.requirements, true)); + const requested = selectProductionSources(request.requirements, input.sources ?? (previous + ? productionRequestedSources(previous).filter(source => authorized.has(source)) : undefined), true); + const scopeNames = requested.filter(source => !productionSourceIsExcluded(request.requirements, source)); + const scoped = scopedProductionRequirements(request.requirements, requested); + const prepared = await prepareProductionPlanningMaterials({ projectRoot: input.projectRoot, requirements: scoped, scopes: new Set(scopeNames) }); const sourceMaterials = new Map(prepared.materials.sources); const scopes: ProductionStage["scopes"] = []; for (const scope of scopeNames) { @@ -112,9 +116,9 @@ export async function prepareInitialProductionPlanning(input: { projectRoot: str } } const guidance = await productionExistingArticleNavigation(input.projectRoot); - const stage = validateProductionStage({ id: randomUUID(), purpose: request.requirements.requirements.map(item => + const stage = validateProductionStage({ id: randomUUID(), purpose: scoped.requirements.map(item => item.purpose ?? item.reader_goals?.join("; ") ?? item.questions!.join("; ")).join("\n"), - scopes, pending_scopes: scopeNames, + requested_sources: requested, scopes, pending_scopes: scopeNames, tasks: previous?.tasks.filter(task => previous.report_approved || ["accepted", "excluded", "replaced"].includes(task.status)) .map(task => ["accepted", "excluded", "replaced"].includes(task.status) ? task : { ...task, status: "replaced", reason: "Long-term requirements changed; the replacement plan requires a new report approval." }) ?? [], @@ -126,12 +130,12 @@ export async function prepareInitialProductionPlanning(input: { projectRoot: str `Existing reader topics: ${join(directory, "guidance/existing-articles.md")}`, `Stage: ${stage.id}`, "", ...productionSourceSummaryMarkdown(stage), ...scopes.map(scope => `- ${scope.scope}: ${join(directory, productionSourceFile(scope.scope))}`), "", "Use code skeletons and document outlines to identify the authorized capability families and document tasks, then selectively read full material to decide reader topics. Navigation is not a complete feature inventory. Keep unchecked scope pending; do not parse all code or maintain per-symbol disposition just to plan.", - "Configured sources are the knowledge workspace coverage boundary, not a new investigation assignment on every request. First distinguish the user's current task, its actual source dependencies, and unrelated configured sources. Reuse approved content; a source-level pending entry alone does not prove missing knowledge or require new articles.", - "Report source baseline/read failures separately from content gaps. For an unrelated configured source, explain that its availability check is unresolved outside this task; do not promise a new code investigation. If the Route still requires resolution, report that precise workflow limitation without deleting source configuration, clearing runtime state, or claiming the source was investigated.", + "These materials cover this request's selected sources. Long-term supporting sources remain authorized for later article plans without becoming pending work. Reuse approved content and declare only actual remaining investigation.", + "Report source baseline/read failures separately from content gaps. Restore the selected dependencies that an article actually needs; do not remove real unfinished investigation or manufacture successful source reads.", "Scale planning to the current request. For one or two documents or a clearly bounded module, decide which articles to add or revise and where they belong; do not redesign the whole knowledge base. Start with related existing topics, expand reading only when needed, and reuse applicable decisions. A large module may need several topics, but not investigation of unrelated modules.", "Separate the whole requested outcome from the current writing batch. Use one batch unless actual dependencies or useful parallel work justify more; do not invent page counts or dependencies. A first useful delivery does not settle remaining authorized work.", "Use question and brief to describe the reader task and useful depth: a checked file/symbol or document section with a concrete next step for navigation, or the behavior, conditions and steps needed for explanation. Reuse or revise existing articles without replacing valid detail with generic summaries; split distinct tasks, not sources or symbols.", - ...(request.reassess.length ? [`Reassess earlier content exclusions: ${request.reassess.join(", ")}. Their source material changed or is unavailable. The stored decisions were preserved but no longer suppress investigation.`] : []), + ...(request.reassess.some(source => requested.includes(source)) ? [`Reassess earlier content exclusions: ${request.reassess.filter(source => requested.includes(source)).join(", ")}. Their source material changed or is unavailable. The stored decisions were preserved but no longer suppress investigation.`] : []), "Keep user-confirmed long-term exclusions in the existing requirements file. For a content-based exclusion, retain source_baselines from the supplied stage scopes for the material actually read; changed material is investigated again. Do not turn a temporary failure or an unapproved suggestion into a permanent exclusion.", "Declare available relevant skills and whether you can coordinate multiple Agents. Article sources may cross registered source and skill boundaries. Batch dependencies in after name article paths.", "Article path is relative to knowledge/ (for example business/example.md, not knowledge/business/example.md). indexer_usage.scopes names authorized source refs from the stage, not knowledge collections. Declare skills here; no separate Indexer registry is required.", @@ -146,7 +150,7 @@ export async function prepareInitialProductionPlanning(input: { projectRoot: str export async function submitProductionPlan(input: { projectRoot: string; stage: string; path: string; manifest?: FixedProductionFile }) { return withProductionFeedback({ operation: "plan", file: input.path, schema: productionPlanInputSchema }, () => withProjectWriteLock(input.projectRoot, "production-plan-submit", async () => { - const stage = await readProductionStage(input.projectRoot); + let stage = await readProductionStage(input.projectRoot); if (!stage || stage.id !== input.stage) throw new TypeError("Plan replacement requires the current stage; read the current route"); if (stage.delivery) throw new TypeError("Finish the requested delivery or run context run --resume-writing --format json before amending the plan."); if ((await readMaintenance(input.projectRoot)).active) throw new TypeError("Finish or cancel the active maintenance task before amending the production plan."); @@ -157,6 +161,23 @@ export async function submitProductionPlan(input: { projectRoot: string; stage: if (new Set(plan.replaces).size !== plan.replaces.length) throw new TypeError("Replacement task identities must be unique"); if (!stage.report_approved && plan.replaces.length) throw new TypeError("Before report approval, submit the complete plan instead of replacement task identities"); if (stage.report_approved && !plan.articles.length && plan.pending_scopes === undefined) throw new TypeError("A writing-stage plan amendment needs new tasks or an explicit investigation update"); + const request = (await productionPlanningRequest(input.projectRoot))!; + const selected = [...new Set([...plan.articles.flatMap(article => article.sources), ...plan.pending_scopes ?? []])]; + if (selected.length) selectProductionSources(request.requirements, selected); + const existingScopes = new Set(stage.scopes.map(source => source.scope)); + const active = activeProductionSources(stage); + const additions = selected.filter(source => !existingScopes.has(source) || !active.has(source)); + const additional = await prepareProductionPlanningMaterials({ projectRoot: input.projectRoot, + requirements: scopedProductionRequirements(request.requirements, additions), scopes: new Set(additions) }); + const scopes = stage.scopes.filter(source => !additions.includes(source.scope)); + for (const scope of additions) { + try { scopes.push({ scope, baseline: await productionSourceBaseline(input.projectRoot, scope) }); } + catch (error) { + scopes.push({ scope, baseline: null }); + additional.gaps = [...additional.gaps.filter(gap => gap.scope !== scope), { scope, reason: error instanceof Error ? error.message : String(error) }]; + } + } + stage = { ...stage, requested_sources: [...new Set([...productionRequestedSources(stage), ...selected])], scopes, gaps: [...stage.gaps, ...additional.gaps] }; const pendingScopes = plan.pending_scopes ?? (stage.report_approved ? stage.pending_scopes : []); if (new Set(pendingScopes).size !== pendingScopes.length || pendingScopes.some(scope => !stage.scopes.some(source => source.scope === scope))) { throw new TypeError("Pending investigation must name unique authorized stage scopes"); @@ -169,7 +190,8 @@ export async function submitProductionPlan(input: { projectRoot: string; stage: if (plan.articles.some(article => article.sources.some(scope => stage.gaps.some(gap => gap.scope === scope)))) { throw refreshRequired(stage.id, "An article requires unavailable material in the captured stage. Restore the required source, including code dependencies of document-led work, then prepare the current stage; independent source plans may proceed.", { source_summary: productionSourceSummary(stage) }); } - for (const source of stage.scopes) if (!stage.gaps.some(gap => gap.scope === source.scope) && + const relevant = new Set([...activeProductionSources(stage), ...selected]); + for (const source of stage.scopes) if (relevant.has(source.scope) && !stage.gaps.some(gap => gap.scope === source.scope) && await productionSourceBaseline(input.projectRoot, source.scope) !== source.baseline) { throw refreshRequired(stage.id, `Planning source changed: ${source.scope}; refresh the investigation materials`); } @@ -179,7 +201,7 @@ export async function submitProductionPlan(input: { projectRoot: string; stage: if (identities.size !== plan.articles.length) throw new TypeError("Plan article paths must be unique"); if (stage.report_approved && identities.size > 0 && [...identities.values()].every(id => stage.tasks.some(task => task.id === id))) { const prepared = await materializeProductionStage({ projectRoot: input.projectRoot, stage, capabilities: plan.capabilities }); - return { stage_state: prepared.state, next: { directory: prepared.directory, mode: prepared.mode, + return { stage_state: prepared.state, next: { directory: prepared.directory, agent_directory: prepared.agent_directory, mode: prepared.mode, ...(prepared.submission ? { submission: prepared.submission } : {}) } }; } const candidates = await readCandidateRecords(input.projectRoot); @@ -212,10 +234,10 @@ export async function submitProductionPlan(input: { projectRoot: string; stage: : { ...stage, tasks: [...retained, ...tasks], pending_scopes: pendingScopes, planning_complete: true }; const usage = stage.report_approved ? [...stage.indexer_usage, ...plan.indexer_usage] : plan.indexer_usage; const updated = validateProductionStage({ ...amended, indexer_usage: [...new Map(usage.map(item => [JSON.stringify(item), item])).values()] }); - await saveProductionStage(input.projectRoot, updated); + await saveExpandedProductionScope(input.projectRoot, updated, request.requirements, additional.materials.sources); try { const prepared = await materializeProductionStage({ projectRoot: input.projectRoot, stage: updated, capabilities: plan.capabilities }); - return { stage_state: prepared.state, ...(prepared.submission ? { next: { directory: prepared.directory, + return { stage_state: prepared.state, ...(prepared.submission ? { next: { directory: prepared.directory, agent_directory: prepared.agent_directory, submission: prepared.submission, mode: prepared.mode } } : { next_action: { command: "context status --format json" } }) }; } catch (error) { return { stage_state: "active" as const, next_preparation: { outcome: "failed" as const, diff --git a/packages/context-cli/src/project/productionPlanningRoute.ts b/packages/context-cli/src/project/productionPlanningRoute.ts index f9b44f81..84a3cc75 100644 --- a/packages/context-cli/src/project/productionPlanningRoute.ts +++ b/packages/context-cli/src/project/productionPlanningRoute.ts @@ -28,13 +28,15 @@ export async function productionPlanningRoute(input: { projectRoot: string; auth return { protocol: "context.workflow.route.v1", id: route.routeId, node: route.node, revision, reason_code: route.reasonCode, availability: route.availability, summary: configure ? "Record the user's reader purpose and authorized sources, not Provider configuration. Ask only for missing scope or purpose decisions." - : prepare ? "Prepare lightweight navigation. If article targets are already decided, add --input with the known-task file under .tmp/agent-work to create those tasks in the same call; report approval still follows." : "Investigate the supplied skeletons and submit article goals, source grouping and writing batches; report approval follows.", - commands: configure ? [] : [{ command, effect: "write", availability: "immediate", managed_execution: prepare ? "automatic" : "agent-required" }], + : prepare ? "Select only this request's investigation sources with repeated --source , or add --input with decided article tasks under .tmp/agent-work. A sole configured target can be selected automatically. Supporting sources remain available for later article plans; do not select the whole reference pool. Report approval still follows." : "Investigate the supplied skeletons and submit article goals, source grouping and writing batches; report approval follows.", + commands: configure ? [] : [{ command, effect: "write", availability: "immediate", managed_execution: "agent-required" }], ...(configure ? { configuration: { file: "src/indexers.yaml" as const, action: "Write the confirmed long-term requirements using the supplied contract, then run context status --format json. Do not invent new source authorization or add Provider/process configuration." } } : {}), resources: { required: [...route.resources.required.map(resource => projectWorkflowResourceLocation(resource, revision, input.authorities)), ...(stage && !prepare ? [{ id: `production/${stage.id}/planning`, kind: "context-view" as const, media_type: "text/markdown", path: join(input.projectRoot, productionStageDirectory(stage.id), "planning.md"), read_state: "read-required" as const }] : [])], - recommended: route.resources.recommended.map(resource => projectWorkflowResourceLocation(resource, revision, input.authorities)) }, + recommended: [...route.resources.recommended.map(resource => projectWorkflowResourceLocation(resource, revision, input.authorities)), + ...(prepare ? [{ id: "production/known-tasks-schema", kind: "context-view" as const, media_type: "application/schema+json", + command: "context action prepare-current --schema --format json", read_state: "read-required" as const }] : [])] }, after_action: { evaluate: true } }; } diff --git a/packages/context-cli/src/project/productionScope.ts b/packages/context-cli/src/project/productionScope.ts new file mode 100644 index 00000000..5b36f93e --- /dev/null +++ b/packages/context-cli/src/project/productionScope.ts @@ -0,0 +1,86 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import YAML from "yaml"; +import { ContextError } from "../lib/errors.js"; +import { ErrorCategory } from "../lib/cliFeedback.js"; +import { ExitCode } from "../types/exitCode.js"; +import { productionSourceIsExcluded, type ProductionRequirements } from "./productionRequirements.js"; +import type { ProductionStage } from "./productionStage.js"; +import { safeProjectTarget, runDurableMultiFileTransaction } from "./durableMultiFileTransaction.js"; +import { durableContentDigest } from "./durableSingleFileTransaction.js"; +import { productionStageDirectory, productionSourceFile } from "./productionStageStore.js"; + +export function authorizedProductionSources(requirements: ProductionRequirements, includeExcluded = false): string[] { + return [...new Set(requirements.requirements.flatMap(item => [ + ...item.target_scope.targets, ...item.evidence_source_scope?.targets ?? [], + ].map(target => target.source_ref)))].filter(source => includeExcluded || !productionSourceIsExcluded(requirements, source)); +} + +/** Long-term permission is not a work assignment. Known article inputs select + * their dependencies; investigation explicitly selects its own source refs. */ +export function selectProductionSources(requirements: ProductionRequirements, sources?: string[], includeExcluded = false): string[] { + const authorized = authorizedProductionSources(requirements, includeExcluded); + const targets = [...new Set(requirements.requirements.flatMap(item => item.target_scope.targets.map(target => target.source_ref)))] + .filter(source => authorized.includes(source)); + const selected = sources ?? (targets.length === 1 ? targets : []); + if (!selected.length) throw new ContextError(ExitCode.UserError, "Select the sources needed for this request before preparing investigation", { + category: ErrorCategory.UserInputInvalid, reason_code: "production-scope-required", + authorized_sources: authorized, + next_action: { command: "context status --format json", instruction: "Use --input for decided article tasks, or repeat --source on the current prepare-current command for this request's investigation. Registered reference material remains available for later plans." }, + }); + if (new Set(selected).size !== selected.length || selected.some(source => !authorized.includes(source))) { + throw new TypeError("Selected production sources are duplicated or outside authorized scope"); + } + return selected; +} + +/** Compare only the requirements affecting this stage. Adding an unrelated + * source to the reference pool cannot revoke existing task inputs. */ +export function scopedProductionRequirements(requirements: ProductionRequirements, sources: readonly string[]): ProductionRequirements { + const selected = new Set(sources); + return { requirements: requirements.requirements.flatMap(item => { + const targets = item.target_scope.targets.filter(target => selected.has(target.source_ref)); + const evidence = item.evidence_source_scope?.targets.filter(target => selected.has(target.source_ref)); + if (!targets.length && !evidence?.length) return []; + return [{ ...item, target_scope: { targets }, + evidence_source_scope: { targets: evidence ?? [] }, + exclusions: (item.exclusions ?? []).flatMap(exclusion => { + const targets = exclusion.scope.targets.filter(target => selected.has(target.source_ref)); + return targets.length ? [{ ...exclusion, scope: { targets }, ...(exclusion.source_baselines + ? { source_baselines: Object.fromEntries(Object.entries(exclusion.source_baselines).filter(([source]) => selected.has(source))) } : {}) }] : []; + }), + }]; + }) }; +} + +export function productionRequestedSources(stage: ProductionStage): string[] { + return stage.requested_sources ?? stage.scopes.map(source => source.scope); +} + +export function activeProductionSources(stage: ProductionStage): Set { + return new Set([...stage.pending_scopes, ...stage.tasks.filter(task => !["accepted", "excluded", "replaced"].includes(task.status)) + .flatMap(task => task.sources.map(source => source.scope))]); +} + +/** A plan can adopt a previously unused authorized reference. Publish its + * navigation, requirement snapshot and manifest as one recoverable change. */ +export async function saveExpandedProductionScope(root: string, stage: ProductionStage, + requirements: ProductionRequirements, materials: ReadonlyMap): Promise { + const directory = productionStageDirectory(stage.id); + const contents = new Map([ + [join(directory, "manifest.json"), `${JSON.stringify(stage)}\n`], + [join(directory, "shared/requirements.md"), YAML.stringify(scopedProductionRequirements(requirements, productionRequestedSources(stage)))], + ...[...materials].map(([scope, content]) => [join(directory, productionSourceFile(scope)), content] as [string, string]), + ]); + const targets = []; + for (const [path, content] of contents) { + let previous: string | undefined; + try { previous = await readFile(await safeProjectTarget(root, path), "utf8"); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + if (previous === content) continue; + targets.push({ path, operation: "write" as const, content, + base_digest: previous === undefined ? null : durableContentDigest(previous), target_digest: durableContentDigest(content) }); + } + if (targets.length) await runDurableMultiFileTransaction({ projectRoot: root, kind: "production-scope-plan", + proposal_digest: durableContentDigest(JSON.stringify(stage)), targets: targets.sort((a, b) => a.path.localeCompare(b.path)) }); +} diff --git a/packages/context-cli/src/project/productionStage.ts b/packages/context-cli/src/project/productionStage.ts index 1004994b..5e9a2341 100644 --- a/packages/context-cli/src/project/productionStage.ts +++ b/packages/context-cli/src/project/productionStage.ts @@ -38,6 +38,7 @@ export const productionTaskSchema = z.object({ export const productionStageSchema = z.object({ id, purpose: name, + requested_sources: z.array(name).optional(), // An authorized but unavailable source has no observed version yet. Tasks // still require a real digest; null never authorizes a writer. scopes: z.array(taskSource.extend({ baseline: digest.nullable() })), diff --git a/packages/context-cli/src/project/productionStagePreparation.ts b/packages/context-cli/src/project/productionStagePreparation.ts index 8779addc..4c64c2ef 100644 --- a/packages/context-cli/src/project/productionStagePreparation.ts +++ b/packages/context-cli/src/project/productionStagePreparation.ts @@ -13,7 +13,7 @@ import { withProductionFeedback } from "./productionFeedback.js"; /** Retry directory preparation, never production acceptance. This consumes an * existing current plan; it does not infer sources or create a new workflow. */ export async function prepareCurrentProductionStage(input: { - projectRoot: string; revision: string; multiAgent?: boolean; + projectRoot: string; revision: string; multiAgent?: boolean; sources?: string[]; }) { return withProductionFeedback({ operation: "prepare" }, () => withProjectWriteLock(input.projectRoot, "production-prepare", async () => { await recoverDurableMultiFileTransactions(input.projectRoot); @@ -31,6 +31,6 @@ export async function prepareCurrentProductionStage(input: { const dispatch = dispatchProductionStage(stage, capabilities); const prepared = await prepareNextProductionStage({ projectRoot: input.projectRoot, stage, multiAgent: capabilities.multi_agent }); return { stage_state: dispatch.state, mode: prepared?.mode ?? dispatch.mode, - ...(prepared ? { next: { directory: prepared.directory, submission: prepared.submission, mode: prepared.mode } } : {}) }; + ...(prepared ? { next: { directory: prepared.directory, agent_directory: prepared.agent_directory, submission: prepared.submission, mode: prepared.mode } } : {}) }; })); } diff --git a/packages/context-cli/src/project/productionStageRefresh.ts b/packages/context-cli/src/project/productionStageRefresh.ts index e656ec21..eb3b71a5 100644 --- a/packages/context-cli/src/project/productionStageRefresh.ts +++ b/packages/context-cli/src/project/productionStageRefresh.ts @@ -1,3 +1,4 @@ +import { activeProductionSources } from "./productionScope.js"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { productionSourceBaseline } from "./productionSubmission.js"; @@ -11,10 +12,12 @@ import { durableContentDigest } from "./durableSingleFileTransaction.js"; /** Explicit preparation may refresh changed sources. Ordinary submission * continuation does not call this source scan. Everything remains temporary. */ export async function refreshProductionStageSources(projectRoot: string, stage: ProductionStage): Promise { - const affected = new Set(stage.gaps.map(gap => gap.scope)); + const active = activeProductionSources(stage); + const affected = new Set(stage.gaps.filter(gap => active.has(gap.scope)).map(gap => gap.scope)); const unavailable = new Map(); const scopes = []; for (const source of stage.scopes) { + if (!active.has(source.scope)) { scopes.push(source); continue; } try { const baseline = await productionSourceBaseline(projectRoot, source.scope); if (baseline !== source.baseline) affected.add(source.scope); diff --git a/packages/context-cli/src/project/productionStageStore.ts b/packages/context-cli/src/project/productionStageStore.ts index 62fb0c4f..d9978889 100644 --- a/packages/context-cli/src/project/productionStageStore.ts +++ b/packages/context-cli/src/project/productionStageStore.ts @@ -126,7 +126,7 @@ export async function materializeProductionStage(input: { stage: ProductionStage; capabilities: ProductionCapabilities; materials?: ProductionStageMaterials; -}): Promise { +}): Promise { return withProjectWriteLock(input.projectRoot, "production-stage-materialize", async () => { const stage = validateProductionStage(input.stage); const previous = await readProductionStage(input.projectRoot, stage.id); @@ -204,6 +204,8 @@ export async function materializeProductionStage(input: { } } await write(join(taskRoot, "task.md"), [`# ${task.question}`, "", `Target: ${task.path}`, + `Draft Markdown: ${join(agent, "batches", batch.id, id, "article.md")}`, + `Draft references: ${join(agent, "batches", batch.id, id, "references.yaml")}`, `Article: ${task.article_id}`, `Dependencies: ${task.after.join(", ") || "none"}`, "", task.brief ?? "Read the authorized sources and write the complete article, or revise the existing article's affected fragments.", "", ...(task.base !== null ? [`Revision base: ${join(taskRoot, "base.md")}`, @@ -227,11 +229,11 @@ export async function materializeProductionStage(input: { // The CLI supplies a template on its own side; it never overwrites an Agent // draft or an edited submission on a repeated preparation or downgrade. const submission = dispatch.batches.length ? join(directory, "submission.yaml") : undefined; - if (submission) await write(submission, YAML.stringify({ stage: stage.id, tasks: submittedTasks })); + if (submission) await write(submission, `# Draft paths below are relative to ${agent}/, NOT this template directory.\n# Copy this template to ${agent}/submissions/ready.yaml before editing.\n${YAML.stringify({ stage: stage.id, tasks: submittedTasks })}`); await write(join(directory, "stage.md"), [`# ${stage.purpose}`, "", `Stage: ${stage.id}`, `State: ${dispatch.state}`, `Scheduling: ${dispatch.mode}`, "", ...dispatch.batches.map(batch => `- Batch ${batch.id}: ${join(directory, "batches", batch.id, "batch.md")}`), "", - `Agent output directory: ${agent}`, `Remaining investigation: ${stage.pending_scopes.join(", ") || "none"}`, "", + `Agent output directory (all content/references/edits paths resolve here): ${agent}`, `Remaining investigation: ${stage.pending_scopes.join(", ") || "none"}`, "", ...stage.tasks.filter(task => task.status === "blocked").map(task => `- Blocked task ${task.id}: ${task.path} — ${task.reason ?? "Investigate the task's source and dependencies."}`), "", `To add articles within this confirmed purpose and source scope, submit a plan amendment using ${join(directory, "planning.schema.json")}.`, "During writing, articles adds tasks; replaces explicitly names unfinished task IDs. Existing task IDs may be used in after. Do not resubmit the whole original plan or widen the purpose without user confirmation.", @@ -242,7 +244,7 @@ export async function materializeProductionStage(input: { "Completion of this stage does not approve or publish its candidates.", ""].join("\n")); await saveProductionStage(input.projectRoot, { ...stage, tasks: stage.tasks.map(task => issued.has(task.id) && task.status === "pending" ? { ...task, status: "issued" } : task) }); - return { ...dispatch, directory, ...(submission ? { submission } : {}), updated_files: changed }; + return { ...dispatch, directory, agent_directory: agent, ...(submission ? { submission } : {}), updated_files: changed }; }); } diff --git a/packages/context-cli/src/project/productionSubmissionFiles.ts b/packages/context-cli/src/project/productionSubmissionFiles.ts index dfd00712..a89e9f04 100644 --- a/packages/context-cli/src/project/productionSubmissionFiles.ts +++ b/packages/context-cli/src/project/productionSubmissionFiles.ts @@ -134,7 +134,9 @@ async function readFixedProductionFile(input: { return { path: input.path, text, bytes, digest: durableContentDigest(text) }; } catch (error) { if (error instanceof ContextError) throw error; - throw invalidFile(input.path, `Cannot read the stage-local file: ${error instanceof Error ? error.message : String(error)}. No tasks were saved.`); + const failure = invalidFile(input.path, `Cannot read ${local}. Draft paths are relative to ${input.directory}/, not the runtime or submission-file directory. ${error instanceof Error ? error.message : String(error)}. No tasks were saved.`); + throw new ContextError(failure.code, failure.message, { ...failure.detail, + base_directory: input.directory, expected_path: local }); } finally { await handle?.close(); } diff --git a/packages/context-cli/src/project/productionWorkflowRoute.ts b/packages/context-cli/src/project/productionWorkflowRoute.ts index 57b69965..9e954177 100644 --- a/packages/context-cli/src/project/productionWorkflowRoute.ts +++ b/packages/context-cli/src/project/productionWorkflowRoute.ts @@ -82,10 +82,14 @@ export async function productionWorkflowRoute(input: { summary: report ? `Present the report and apply context.gate.work_start_scope. After the applicable scope decision, write {stage: ${stage.id}, decision: approved} to ${path}.` : writing ? "Read the issued task directories. Coordinate them sequentially unless this caller supports independent Agents; only the coordinator submits shared state." : repair ? "Add revision tasks for rejected articles using the Review feedback; accepted production responsibilities remain unchanged." - : investigate ? `Review pending configured scopes: ${stage.pending_scopes.filter(scope => !stage.gaps.some(gap => gap.scope === scope)).join(", ")}. Check their relevance to the current request and existing approved content before assigning investigation. Submit supported article tasks and remaining pending_scopes; do not infer missing articles from this list or repeat accepted work. If the requested articles are already accepted, context run --deliver --format json enters their Review while retaining unrelated pending scopes.` - : resolved.node === "resolve-production-gap" ? `Source availability gaps: ${stage.gaps.map(gap => `${gap.scope}: ${gap.reason}`).join("; ")}. These failures do not establish missing knowledge or a new investigation assignment. Identify which sources the current task actually depends on; report unrelated configured-source failures separately. Preserve the configuration. To review completed articles independently, use context run --deliver --format json; this retains pending scopes and does not approve or publish content. If these sources are required, use context source recovery-plan --format json and context source restore with explicit local or clone decisions before preparation.` : "Prepare the current stage's eligible task directories.", - commands: report || prepare || writing || repair || investigate || gap ? [{ command, effect: "write", availability: report || gap ? "after-human-confirmation" : "immediate", - managed_execution: prepare ? "automatic" : "agent-required" }] : [], + : investigate ? `Continue this request's pending investigation: ${stage.pending_scopes.filter(scope => !stage.gaps.some(gap => gap.scope === scope)).length} sources (details in planning.md). Submit additional article tasks and the actual remaining investigation; preserve unfinished requested work. Sources authorized in the long-term requirements may be added as article dependencies when needed. For an explicitly requested partial delivery, context run --deliver --format json reviews completed articles while retaining the remaining work.` + : resolved.node === "resolve-production-gap" ? `Source availability gaps: ${stage.gaps.length} selected sources (details in planning.md). These selected sources are needed for remaining investigation or article dependencies. Preserve completed articles and restore the required material. To review completed articles independently, use context run --deliver --format json; this retains pending scopes and does not approve or publish content. If these sources are required, use context source recovery-plan --format json and context source restore with explicit local or clone decisions before preparation.` : "Prepare the current stage's eligible task directories.", + commands: [...(report || prepare || writing || repair || investigate || gap ? [{ command, effect: "write" as const, availability: report || gap ? "after-human-confirmation" as const : "immediate" as const, + managed_execution: prepare ? "automatic" as const : "agent-required" as const }] : []), + ...((investigate || gap) && stage.tasks.some(task => task.status === "accepted") ? [{ + command: "context run --deliver --format json", effect: "write" as const, + availability: "after-human-confirmation" as const, managed_execution: "agent-required" as const, + }] : [])], ...(resolved.gate ? { gate: { id: resolved.gate.id, delegatable: false, resolution: "user" as const, ...(action && action.effect !== "read" ? { resolution_action: { ...action, effect: action.effect } } : {}) } } : {}), resources: { required, recommended: resolved.resources.recommended.map(location => projectWorkflowResourceLocation(location, revision, input.authorities)) }, diff --git a/packages/context-cli/src/project/reviewHtml.ts b/packages/context-cli/src/project/reviewHtml.ts index 70c16505..2f7df08f 100644 --- a/packages/context-cli/src/project/reviewHtml.ts +++ b/packages/context-cli/src/project/reviewHtml.ts @@ -13,6 +13,8 @@ import { saveReviewReportScope } from "./reviewReportScope.js"; import { REVIEW_SITE_CLIENT } from "./reviewSiteClient.js"; import { REVIEW_SITE_STYLES } from "./reviewSiteStyles.js"; +import { readKnowledgeMap, KNOWLEDGE_MAP_PATH } from "./knowledgeMap.js"; + const REVIEW_HTML_ROOT = join(".tmp", "context-runtime", "review"); export async function collectReviewCandidates(projectRoot: string, collection: KnowledgeCollection): Promise { @@ -79,7 +81,7 @@ export async function writeReviewHtml(input: { candidates: number; candidate_set_digest: string; structure_digests: string[]; - navigation: { ready: boolean; unplaced_count: number; unplaced: { article_id: string; candidate_id: string; path: string; title: string }[] }; + navigation: { current_revision: string | null; structure_path: string; inspect_command: string; ready: boolean; unplaced_count: number; unplaced: { article_id: string; candidate_id: string; path: string; title: string }[] }; next_action?: { command: string; message: string }; }> { const reviewScope = input.all === true ? "all" : input.collection; @@ -108,7 +110,8 @@ export async function writeReviewHtml(input: { !model.nodes.some(node => node.page === page.id && node.parent !== "review-unplaced" && !node.removed)) .map(page => ({ article_id: page.id, candidate_id: page.candidate_id!, path: page.path, title: page.title })); return { - navigation: { ready: unplaced.length === 0, unplaced_count: unplaced.length, unplaced }, + navigation: { current_revision: (await readKnowledgeMap(input.projectRoot))?.revision ?? null, + structure_path: KNOWLEDGE_MAP_PATH, inspect_command: "context task adjust --inspect --format json", ready: unplaced.length === 0, unplaced_count: unplaced.length, unplaced }, ...(unplaced.length ? { next_action: { command: "context task adjust --input - --format json", message: "Bind the listed drafts to suitable existing categories before requesting review, then regenerate this report. Content approval is not required for navigation." } } : {}), path: outPath, diff --git a/packages/context-cli/src/project/workflow/workflowResource.ts b/packages/context-cli/src/project/workflow/workflowResource.ts index eb69f705..7e759980 100644 --- a/packages/context-cli/src/project/workflow/workflowResource.ts +++ b/packages/context-cli/src/project/workflow/workflowResource.ts @@ -414,7 +414,7 @@ export async function acknowledgeCurrentWorkflowResources(input: { protocol: "context.workflow.resource-receipts.v1", acknowledged: directResources.length, receiptReference: `@${join(found.projectRoot, continuation.path)}`, - message: "Read the returned workflow and follow its selected command unchanged, including revision and resource-receipt arguments. Do not pre-chain an earlier write command or replace this result with status without receipts. Acknowledgement does not necessarily change the revision; reread only changed or unavailable required content.", + message: "For workflow continuation, use the returned selected command with its revision and resource-receipt arguments. Receipts retain required-reading state for Route evaluation; they are not approval credentials or universal prerequisites for direct CLI primitives. Direct commands still enforce their own state, scope and confirmation checks. Do not pre-chain an earlier write command or replace this result with status without receipts. Acknowledgement does not necessarily change the revision; reread only changed or unavailable required content.", }, }; } diff --git a/packages/context/package.json b/packages/context/package.json index 5adc1aff..4f3b19b9 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.37", + "version": "0.7.38", "type": "module", "license": "MIT", "engines": { diff --git a/packages/core/package.json b/packages/core/package.json index 591f614e..12cde170 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.37", + "version": "0.7.38", "type": "module", "license": "MIT", "engines": { diff --git a/packages/dev-cli/package.json b/packages/dev-cli/package.json index 56f9e9b9..415ca1dd 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.37", + "version": "0.7.38", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extract-contract/package.json b/packages/extract-contract/package.json index f3002e0c..6ef08404 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.37", + "version": "0.7.38", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-go/package.json b/packages/extract-go/package.json index 7c255b97..cc01f614 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.37", + "version": "0.7.38", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-mdx/package.json b/packages/extract-mdx/package.json index 9184c905..79cf0ea9 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.37", + "version": "0.7.38", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-proto/package.json b/packages/extract-proto/package.json index 1514043f..9ed54b6a 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.37", + "version": "0.7.38", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-rush/package.json b/packages/extract-rush/package.json index ab161da8..a4694773 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.37", + "version": "0.7.38", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-sql/package.json b/packages/extract-sql/package.json index 19c25618..8ef0ef98 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.37", + "version": "0.7.38", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-style/package.json b/packages/extract-style/package.json index e1795322..53627a04 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.37", + "version": "0.7.38", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-thrift/package.json b/packages/extract-thrift/package.json index 18518907..cc29754a 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.37", + "version": "0.7.38", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract-ts/package.json b/packages/extract-ts/package.json index c8f0154a..8d43119c 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.37", + "version": "0.7.38", "type": "module", "license": "MIT", "engines": { diff --git a/packages/extract/package.json b/packages/extract/package.json index 1290cc9f..7b766696 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.37", + "version": "0.7.38", "type": "module", "license": "MIT", "engines": { diff --git a/packages/tui/package.json b/packages/tui/package.json index b615c4af..7f72f50c 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.37", + "version": "0.7.38", "private": true, "type": "module", "license": "MIT", diff --git a/plugins/context/repo-install/claude/.claude-plugin/plugin.json b/plugins/context/repo-install/claude/.claude-plugin/plugin.json index de45f078..cd08e6f3 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.37", + "version": "0.7.38", "author": { "name": "c4a" }, diff --git a/plugins/context/repo-install/codex/.codex-plugin/plugin.json b/plugins/context/repo-install/codex/.codex-plugin/plugin.json index 8b9fe3bd..1c64141d 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.37", + "version": "0.7.38", "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.37", + "shortDescription": "Initialize and advance a local, source-linked project knowledge workspace.\nv0.7.38", "longDescription": "Create a Context workspace and use agent-guided next steps to register sources, run extraction, review candidates, build package outputs, and verify health without silently mutating source repositories.", "developerName": "c4a", "category": "Productivity", diff --git a/plugins/context/repo-install/cursor/.cursor-plugin/plugin.json b/plugins/context/repo-install/cursor/.cursor-plugin/plugin.json index ff043952..abb2c84a 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.37", + "version": "0.7.38", "description": "Start or continue a project-local knowledge workspace through one graph-routed entry.", "author": { "name": "Context4AI",