frontend-project-context 1.9.1 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +25 -6
  3. package/UPGRADING.md +14 -0
  4. package/docs/00-PRODUCT-CONSTITUTION.md +44 -12
  5. package/docs/08-INSTALLATION-AND-DISTRIBUTION.md +2 -2
  6. package/docs/32-PROJECT-TO-TASK-INTERACTION-PROPOSAL.md +173 -0
  7. package/docs/33-BOUNDED-TASK-HANDOFF-REDESIGN.md +207 -0
  8. package/docs/34-A0-CODEX-HOST-FEASIBILITY.md +61 -0
  9. package/docs/35-CONTEXT-FIRST-TASK-HANDOFF-DESIGN.md +248 -0
  10. package/docs/36-PRODUCT-VALUE-AND-USABILITY-REVIEW.md +158 -0
  11. package/docs/37-EVERYDAY-HANDOFF-FLOW-DESIGN.md +271 -0
  12. package/docs/38-EVERYDAY-HANDOFF-OPERATION.md +147 -0
  13. package/docs/39-SELF-HOST-RELEASE-ACCEPTANCE.md +39 -0
  14. package/docs/AI-PROJECT-INITIALIZATION.md +5 -1
  15. package/docs/README.md +13 -1
  16. package/docs/USER-AND-AI-OPERATION-MANUAL.md +9 -3
  17. package/examples/package.json +1 -1
  18. package/migration-manifest.json +14 -6
  19. package/package.json +2 -2
  20. package/schemas/capabilities.schema.json +17 -7
  21. package/schemas/evidence-bundle.schema.json +1 -1
  22. package/schemas/initialization-instruction.schema.json +1 -1
  23. package/schemas/migration-manifest.schema.json +1 -1
  24. package/schemas/migration-plan.schema.json +1 -1
  25. package/schemas/project-brief.schema.json +400 -0
  26. package/schemas/projection-lock.schema.json +1 -1
  27. package/schemas/task-context-record.schema.json +27 -0
  28. package/schemas/task-handoff-input.schema.json +23 -0
  29. package/schemas/task-handoff-result.schema.json +320 -0
  30. package/schemas/upgrade-assessment.schema.json +1 -1
  31. package/schemas/upgrade-result-bundle.schema.json +1 -1
  32. package/schemas/work-view.schema.json +541 -0
  33. package/src/project-context/adaptive-context.mjs +4 -6
  34. package/src/project-context/ai-entry.mjs +13 -10
  35. package/src/project-context/capabilities.mjs +9 -0
  36. package/src/project-context/cli.mjs +45 -1
  37. package/src/project-context/context-bundle.mjs +2 -1
  38. package/src/project-context/contract-schema.mjs +1 -1
  39. package/src/project-context/exchange-schema.mjs +3 -2
  40. package/src/project-context/migration-manifest.mjs +1 -1
  41. package/src/project-context/path-policy.mjs +13 -0
  42. package/src/project-context/task-context.mjs +4 -14
  43. package/src/project-context/task-handoff-schema.mjs +185 -0
  44. package/src/project-context/task-handoff.mjs +259 -0
  45. package/src/project-context/upgrade.mjs +1 -0
  46. package/src/project-context/work-view-schema.mjs +125 -0
  47. package/src/project-context/work-view.mjs +135 -0
@@ -24,6 +24,8 @@ import { buildProjectStatus } from "./project-status.mjs";
24
24
  import { buildIntegrationReviewBundleFiles, buildStageContextBundleFiles } from "./task-context.mjs";
25
25
  import { applyMigrationPlanFile, buildMigrationPlanFile, buildUpgradeAssessment } from "./upgrade.mjs";
26
26
  import { buildTruthReconciliationReviewFiles } from "./truth-reconciliation.mjs";
27
+ import { buildProjectBrief, buildTaskHandoff } from "./task-handoff.mjs";
28
+ import { renderWorkView } from "./work-view.mjs";
27
29
  import { buildInitializationInstruction, renderInitializationPrompt } from "./initialization-instruction.mjs";
28
30
 
29
31
  const HELP = `project-context — model-neutral project contract compiler
@@ -33,6 +35,8 @@ Usage:
33
35
  project-context capabilities --project PATH [--json]
34
36
  project-context instructions --project PATH [--json | --prompt]
35
37
  project-context status --project PATH [--json]
38
+ project-context project-brief --project PATH [--view legacy|work] [--json]
39
+ project-context task-handoff --project PATH --input FILE_OR_DASH [--view legacy|work] [--json]
36
40
  project-context evidence --project PATH --input FILE [--json]
37
41
  project-context upgrade-check --project PATH --from-version VERSION [--json]
38
42
  project-context upgrade-plan --project PATH --assessment FILE [--json]
@@ -68,7 +72,7 @@ const VALUE_FLAGS = new Set([
68
72
  "project", "id", "name", "output", "proposal", "by", "task", "target", "rationale",
69
73
  "kind", "pointer", "reference", "subject", "value", "value-json", "statement", "scope", "scope-path",
70
74
  "verification", "verification-source", "verification-expected-json", "digest-mode",
71
- "expected-digest", "expected-item-digest", "expected-source-digest", "locale", "plan", "stage", "from-version", "assessment", "previous", "previous-review",
75
+ "expected-digest", "expected-item-digest", "expected-source-digest", "locale", "plan", "stage", "from-version", "assessment", "previous", "previous-review", "view",
72
76
  ]);
73
77
  const LIST_FLAGS = new Set([
74
78
  "ids", "path", "input", "changed-path", "sources", "overrides", "required-source", "conditional-source", "affected-items", "receipt", "receipt-bundle", "main-changed-path", "branch-changed-path",
@@ -79,6 +83,8 @@ const COMMAND_OPTIONS = new Map([
79
83
  ["capabilities", new Set(["project", "json", "help"])],
80
84
  ["instructions", new Set(["project", "json", "prompt", "help"])],
81
85
  ["status", new Set(["project", "json", "help"])],
86
+ ["project-brief", new Set(["project", "json", "help", "view"])],
87
+ ["task-handoff", new Set(["project", "input", "json", "help", "view"])],
82
88
  ["evidence", new Set(["project", "input", "json", "help"])],
83
89
  ["upgrade-check", new Set(["project", "from-version", "json", "help"])],
84
90
  ["upgrade-plan", new Set(["project", "assessment", "json", "help"])],
@@ -374,6 +380,44 @@ async function runCommand(command, options) {
374
380
  const summary = `Project Context: ${result.status.initialization.state}; health ${result.status.health}; AI Entry ${result.status.entry.state}.\n`;
375
381
  return { exitCode: result.exitCode, stdout: jsonOrText(options, result.status, summary), stderr: "" };
376
382
  }
383
+ if (command === "project-brief") {
384
+ const view = options.view ?? "legacy";
385
+ if (!["legacy", "work"].includes(view)) fail("argument-conflict", "--view must be legacy or work");
386
+ const brief = await buildProjectBrief(root, { view });
387
+ if (view === "work") return { exitCode: brief.delivery.status === "withheld" || brief.connection !== "available" ? 1 : 0, stdout: jsonOrText(options, brief, renderWorkView(brief)), stderr: "" };
388
+ const active = brief.tasks.filter((task) => task.lifecycle === "active");
389
+ const invalid = brief.tasks.filter((task) => task.status === "invalid-record");
390
+ const summary = [
391
+ `已确认:项目 ${brief.project?.id ?? "未初始化"};治理 ${brief.projectHealth};已发现 ${brief.tasks.length} 条任务记录。`,
392
+ `待核实:${brief.findingCodes.join("、") || "无项目治理缺口"}${invalid.length ? `;${invalid.length} 条任务记录无效` : ""}。`,
393
+ `需要你决定:${active.length > 1 ? `从 ${active.length} 条活动任务中选择 taskId` : active.length === 1 ? `确认是否继续 ${active[0].taskId}` : "有新任务时提供目标和依据"}。`,
394
+ `接下来会做什么:${brief.nextAction}。`,
395
+ ].join("\n") + "\n";
396
+ return { exitCode: brief.connection === "available" || brief.connection === "not-connected" ? 0 : 1, stdout: jsonOrText(options, brief, summary), stderr: "" };
397
+ }
398
+ if (command === "task-handoff") {
399
+ const view = options.view ?? "legacy";
400
+ if (!["legacy", "work"].includes(view)) fail("argument-conflict", "--view must be legacy or work");
401
+ const inputPath = oneListValue(options, "input");
402
+ let stdinText;
403
+ if (inputPath === "-") {
404
+ let chunks = "";
405
+ for await (const chunk of process.stdin) {
406
+ chunks += chunk;
407
+ if (Buffer.byteLength(chunks) > 512 * 1024) fail("task-handoff-schema-invalid", "stdin handoff input exceeds 512 KiB");
408
+ }
409
+ stdinText = chunks;
410
+ }
411
+ const bundle = await buildTaskHandoff(root, inputPath, stdinText, { view });
412
+ const summary = [
413
+ `已确认:任务 ${bundle.taskIdentity.taskId};当前意图 ${bundle.intent};状态 ${bundle.taskStatus}。`,
414
+ `待核实:${bundle.gaps.map((gap) => `${gap.code} (${gap.id})`).join("、") || "无当前缺口"}。`,
415
+ `需要你决定:${bundle.questions.filter((question) => question.blockingNow).map((question) => question.question).join("、") || "无当前阻断决定"}。`,
416
+ `接下来会做什么:${bundle.nextAction};${bundle.gate.instruction}`,
417
+ ].join("\n") + "\n";
418
+ const exitCode = bundle.taskStatus === "blocked" ? 2 : ["ready-to-implement", "ready-for-read-only", "inactive"].includes(bundle.taskStatus) ? 0 : 1;
419
+ return { exitCode: view === "work" && bundle.delivery.status === "withheld" ? bundle.taskStatus === "blocked" ? 2 : 1 : exitCode, stdout: jsonOrText(options, bundle, view === "work" ? renderWorkView(bundle) : summary), stderr: "" };
420
+ }
377
421
  if (command === "evidence") {
378
422
  const bundle = await buildEvidenceBundleFile(root, oneListValue(options, "input"));
379
423
  const summary = [
@@ -2,7 +2,7 @@ import { canonicalJson, canonicalValue, digestJson } from "./canonical-json.mjs"
2
2
  import { sourceStatus } from "./contract-schema.mjs";
3
3
  import { coverageDomainForPath, coverageProfile } from "./coverage-profile.mjs";
4
4
  import { fail } from "./errors.mjs";
5
- import { normalizeRelativePath, resolveExistingInside } from "./path-policy.mjs";
5
+ import { inspectContextTarget, normalizeRelativePath, resolveExistingInside } from "./path-policy.mjs";
6
6
  import { renderCollectedContextBundle } from "./renderer.mjs";
7
7
  import { effectiveItems } from "./scope-compiler.mjs";
8
8
 
@@ -176,6 +176,7 @@ export async function buildContextBundle(root, project, input) {
176
176
  ? []
177
177
  : uniqueSorted(input.paths.map((value) => normalizeRelativePath(value, { allowRoot: true, label: "target path" })));
178
178
  const task = input.task ?? "";
179
+ for (const targetPath of paths) await inspectContextTarget(root, targetPath);
179
180
  const sections = mode === "locate"
180
181
  ? [{ path: ".", items: effectiveItems(project.contract.items.filter((item) => item.scope.kind === "project"), ".") }]
181
182
  : paths.map((targetPath) => ({ path: targetPath, items: effectiveItems(project.contract.items, targetPath) }));
@@ -361,7 +361,7 @@ export function validateProjectionLock(lock) {
361
361
  if (entry.regionId !== "project-context-ai-entry") fail("schema-invalid-enum", "AI Entry regionId is invalid");
362
362
  string(entry.regionDigest, `${label}.regionDigest`);
363
363
  if (!SHA256.test(entry.regionDigest)) fail("schema-invalid", "AI Entry regionDigest must be sha256");
364
- if (![1, 2, 3, 4, 5].includes(entry.rendererVersion)) fail("schema-version-unsupported", "AI Entry rendererVersion must be 1, 2, 3, 4, or 5");
364
+ if (![1, 2, 3, 4, 5, 6, 7].includes(entry.rendererVersion)) fail("schema-version-unsupported", "AI Entry rendererVersion must be 1, 2, 3, 4, 5, 6, or 7");
365
365
  if (typeof entry.createdFile !== "boolean") fail("schema-invalid", `${label}.createdFile must be boolean`);
366
366
  } else {
367
367
  if (entry.target !== "agents" && entry.target !== "ruler") fail("schema-invalid-enum", "projection target is invalid");
@@ -2,11 +2,11 @@ import { canonicalValue, digestJson, validateJsonValue } from "./canonical-json.
2
2
  import { fail } from "./errors.mjs";
3
3
  import { normalizeRelativePath } from "./path-policy.mjs";
4
4
 
5
- export const PACKAGE_VERSION = "1.9.1";
5
+ export const PACKAGE_VERSION = "1.10.0";
6
6
  export const EXCHANGE_PROTOCOL_VERSION = 9;
7
7
  export const ACTION_PLAN_SCHEMA_VERSION = 2;
8
8
  export const REVIEW_BUNDLE_SCHEMA_VERSION = 2;
9
- export const CAPABILITIES_SCHEMA_VERSION = 9;
9
+ export const CAPABILITIES_SCHEMA_VERSION = 11;
10
10
 
11
11
  export const ACTION_KINDS = Object.freeze([
12
12
  "accept-source-change",
@@ -27,6 +27,7 @@ export const COMMANDS = Object.freeze([
27
27
  "index-context", "instructions", "publish-entry", "remove-entry", "review-source", "revise", "setup", "stage-context", "status", "sync",
28
28
  "upgrade-apply", "upgrade-check", "upgrade-plan",
29
29
  "reconcile-truth",
30
+ "project-brief", "task-handoff",
30
31
  ]);
31
32
 
32
33
  const ACTION_KIND_SET = new Set(ACTION_KINDS);
@@ -103,7 +103,7 @@ export function validateMigrationManifest(input) {
103
103
  ]), "migration manifest");
104
104
  if (manifest.schemaVersion !== MIGRATION_MANIFEST_SCHEMA_VERSION) invalid("migration manifest schemaVersion must be 2");
105
105
  exactKeys(manifest.package, new Set(["name", "version"]), "migration manifest package");
106
- if (manifest.package.name !== "frontend-project-context" || manifest.package.version !== "1.9.1") invalid("migration manifest package does not match this runtime");
106
+ if (manifest.package.name !== "frontend-project-context" || manifest.package.version !== "1.10.0") invalid("migration manifest package does not match this runtime");
107
107
  versions(manifest.upgradeFrom, "upgradeFrom");
108
108
  if (manifest.upgradeFrom.length === 0) invalid("upgradeFrom must not be empty");
109
109
  exactKeys(manifest.stores, new Set(["contract", "projectionLock", "proposal", "sourceLock"]), "stores");
@@ -134,6 +134,19 @@ export async function resolveWritableInside(root, relativePath, options = {}) {
134
134
  return { normalized, absolute };
135
135
  }
136
136
 
137
+ // Context can name a file that has not been created yet. Validate the nearest
138
+ // existing parent as well as existing targets, without creating any directory.
139
+ export async function inspectContextTarget(root, relativePath) {
140
+ try {
141
+ await resolveExistingInside(root, relativePath, { allowRoot: true, label: "context target" });
142
+ return "existing";
143
+ } catch (error) {
144
+ if (error?.code !== "source-missing") throw error;
145
+ await resolveWritableInside(root, relativePath, { allowRoot: true, label: "context target" });
146
+ return "prospective-or-deleted";
147
+ }
148
+ }
149
+
137
150
  export function assertProjectionPath(target, normalized) {
138
151
  if (target === "agents") {
139
152
  if (path.posix.basename(normalized) !== "AGENTS.md") {
@@ -1,5 +1,4 @@
1
1
  import { canonicalJson, digestJson } from "./canonical-json.mjs";
2
- import { selectAdaptiveItems } from "./adaptive-context.mjs";
3
2
  import { blockingContextFindings, checkProject } from "./checker.mjs";
4
3
  import { ProjectContextError, fail } from "./errors.mjs";
5
4
  import { readJsonFile } from "./io.mjs";
@@ -92,18 +91,10 @@ function normalizedReceipts(receiptInputs, plan) {
92
91
  return byStage;
93
92
  }
94
93
 
95
- function selectedItems(contract, targetPaths, findings, adaptiveText) {
94
+ function selectedItems(contract, targetPaths, findings) {
96
95
  const byId = new Map();
97
- if (adaptiveText !== undefined) {
98
- const adaptiveFindings = [];
99
- const selection = selectAdaptiveItems(contract, null, {
100
- task: { text: adaptiveText, topics: [], paths: targetPaths, changedPaths: [], itemIds: [] },
101
- level: "initial",
102
- }, { findings: adaptiveFindings });
103
- findings.push(...adaptiveFindings);
104
- for (const item of selection.selected) byId.set(item.id, item);
105
- }
106
- if (adaptiveText === undefined) {
96
+ // Schema-1 plans have no adaptive delivery/expansion contract. Preserve their
97
+ // complete scoped context; the existing budget gate blocks oversized bundles.
107
98
  for (const targetPath of targetPaths) {
108
99
  try {
109
100
  for (const item of effectiveItems(contract.items, targetPath)) byId.set(item.id, item);
@@ -113,7 +104,6 @@ function selectedItems(contract, targetPaths, findings, adaptiveText) {
113
104
  for (const item of contract.items.filter((entry) => entry.status === "approved" && scopeApplies(entry.scope, targetPath))) byId.set(item.id, item);
114
105
  }
115
106
  }
116
- }
117
107
  return [...byId.values()].sort((left, right) => left.id.localeCompare(right.id)).map((item) => ({
118
108
  id: item.id,
119
109
  kind: item.kind,
@@ -217,7 +207,7 @@ async function buildStageContextBundleCore(root, project, plan, options, receipt
217
207
  if (receipt.status !== "completed") findings.push(finding("stage-dependency-blocked", "blocked", { stageId: stage.id, dependencyStageId: dependencyId }));
218
208
  }
219
209
  const targetPaths = uniqueSorted([...stage.paths, ...changedPaths]);
220
- const contractItems = selectedItems(project.contract, targetPaths, findings, `${plan.task.goal}\n${stage.objective}`);
210
+ const contractItems = selectedItems(project.contract, targetPaths, findings);
221
211
  const readTargets = mergeReadTargets([
222
212
  ...stage.paths.map((entry) => ({ path: entry, reason: "stage-scope" })),
223
213
  ...changedPaths.map((entry) => ({ path: entry, reason: "host-changed-path-signal" })),
@@ -0,0 +1,185 @@
1
+ import { digestJson } from "./canonical-json.mjs";
2
+ import { fail } from "./errors.mjs";
3
+ import { normalizeRelativePath } from "./path-policy.mjs";
4
+
5
+ export const TASK_RECORD_SCHEMA_VERSION = 1;
6
+ export const TASK_HANDOFF_INPUT_SCHEMA_VERSION = 1;
7
+ export const TASK_HANDOFF_RESULT_SCHEMA_VERSION = 1;
8
+ export const TASK_DIRECTORY = ".project-context-tasks";
9
+ const ID = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/u;
10
+ const DIGEST = /^sha256:[a-f0-9]{64}$/u;
11
+ const INTENTS = ["investigate", "design", "implement"];
12
+
13
+ function invalid(message) { fail("task-handoff-schema-invalid", message); }
14
+ function object(value, label, allowed, required = allowed) {
15
+ if (!value || typeof value !== "object" || Array.isArray(value)) invalid(`${label} must be an object`);
16
+ for (const key of Object.keys(value)) if (!allowed.includes(key)) invalid(`${label}.${key} is unknown`);
17
+ for (const key of required) if (!Object.hasOwn(value, key)) invalid(`${label}.${key} is required`);
18
+ return value;
19
+ }
20
+ function string(value, label) {
21
+ if (typeof value !== "string" || value.trim().length === 0) invalid(`${label} must be a non-empty string`);
22
+ return value;
23
+ }
24
+ function choice(value, choices, label) {
25
+ if (!choices.includes(value)) invalid(`${label} is invalid`);
26
+ return value;
27
+ }
28
+ function array(value, label) { if (!Array.isArray(value)) invalid(`${label} must be an array`); return value; }
29
+ function id(value, label) {
30
+ if (typeof value !== "string" || value.length > 64 || !ID.test(value)) invalid(`${label} is invalid`);
31
+ return value;
32
+ }
33
+ function digest(value, label) { if (typeof value !== "string" || !DIGEST.test(value)) invalid(`${label} is invalid`); return value; }
34
+ function paths(values, label) {
35
+ const normalized = array(values, label).map((value) => normalizeRelativePath(value, { label }));
36
+ if (new Set(normalized).size !== normalized.length) invalid(`${label} contains duplicate paths`);
37
+ return normalized.sort();
38
+ }
39
+ function consumedTargets(values, label) {
40
+ const result = array(values, label).map((entry) => {
41
+ object(entry, label, ["path", "digest"]);
42
+ return { path: normalizeRelativePath(entry.path, { label }), digest: digest(entry.digest, `${label}.digest`) };
43
+ });
44
+ if (new Set(result.map((entry) => entry.path)).size !== result.length) invalid(`${label} contains duplicate paths`);
45
+ return result;
46
+ }
47
+ function applies(value, label) {
48
+ const result = array(value, label).map((item) => choice(item, INTENTS, label));
49
+ if (result.length === 0 || new Set(result).size !== result.length) invalid(`${label} must be non-empty and unique`);
50
+ return result.sort();
51
+ }
52
+ function uniqueIds(values, label) {
53
+ const ids = values.map((value) => value.id);
54
+ if (new Set(ids).size !== ids.length) invalid(`${label} contains duplicate IDs`);
55
+ }
56
+ function taskPath(taskId) { return `${TASK_DIRECTORY}/${taskId}.json`; }
57
+ export { taskPath };
58
+
59
+ export function normalizeTaskRecordPaths(record) {
60
+ const value = structuredClone(record);
61
+ value.scope.readPaths = paths(value.scope.readPaths, "readPaths");
62
+ for (const entry of value.scope.writeTargets) entry.path = normalizeRelativePath(entry.path);
63
+ for (const entry of value.evidence) if (entry.kind === "local") entry.path = normalizeRelativePath(entry.path);
64
+ for (const entry of value.progress.implementation) entry.path = normalizeRelativePath(entry.path);
65
+ return value;
66
+ }
67
+
68
+ export function validateTaskRecord(input) {
69
+ const value = object(input, "record", ["schemaVersion", "kind", "projectId", "taskId", "title", "revision", "previousDigest", "origin", "lifecycle", "scope", "requirements", "evidence", "decisions", "progress"]);
70
+ if (value.schemaVersion !== TASK_RECORD_SCHEMA_VERSION || value.kind !== "task-context-record") invalid("record identity is invalid");
71
+ string(value.projectId, "projectId"); id(value.taskId, "taskId"); string(value.title, "title");
72
+ if (!Number.isSafeInteger(value.revision) || value.revision < 1) invalid("revision must be a positive integer");
73
+ if (value.revision === 1 ? value.previousDigest !== null : !DIGEST.test(value.previousDigest ?? "")) invalid("previousDigest does not match revision");
74
+ choice(value.lifecycle, ["active", "completed", "cancelled"], "lifecycle");
75
+ return validateTaskContent(value);
76
+ }
77
+
78
+ // Shared content validation for records and delivered task snapshots.
79
+ export function validateTaskContent(value) {
80
+ object(value.origin, "origin", ["text", "reference"], ["text"]);
81
+ string(value.origin.text, "origin.text");
82
+ if (value.origin.reference !== undefined) string(value.origin.reference, "origin.reference");
83
+ object(value.scope, "scope", ["readPaths", "writeTargets", "workspaceLabel"], ["readPaths", "writeTargets"]);
84
+ paths(value.scope.readPaths, "readPaths");
85
+ if (value.scope.workspaceLabel !== undefined) string(value.scope.workspaceLabel, "workspaceLabel");
86
+ const writes = array(value.scope.writeTargets, "writeTargets");
87
+ for (const entry of writes) {
88
+ object(entry, "writeTarget", ["path", "operation"]);
89
+ normalizeRelativePath(entry.path, { label: "write target" });
90
+ choice(entry.operation, ["create", "modify", "delete"], "write operation");
91
+ }
92
+ if (new Set(writes.map((entry) => normalizeRelativePath(entry.path))).size !== writes.length) invalid("writeTargets contains duplicate paths");
93
+ const evidence = array(value.evidence, "evidence");
94
+ for (const entry of evidence) {
95
+ object(entry, "evidence", ["id", "purpose", "kind", "path", "text", "reference", "digest", "identity"], ["id", "purpose", "kind", "digest", "identity"]);
96
+ id(entry.id, "evidence.id"); choice(entry.purpose, ["requirement", "design", "decision", "implementation", "reference"], "evidence.purpose");
97
+ choice(entry.kind, ["local", "inline", "external"], "evidence.kind"); digest(entry.digest, "evidence.digest");
98
+ choice(entry.identity, ["local-verified", "user-statement", "host-asserted"], "evidence.identity");
99
+ if (entry.kind === "local") {
100
+ if (entry.path === undefined || entry.text !== undefined || entry.reference !== undefined || entry.identity !== "local-verified") invalid("local evidence requires path and local-verified identity");
101
+ normalizeRelativePath(entry.path, { label: "evidence path" });
102
+ } else if (entry.kind === "inline") {
103
+ if (entry.text === undefined || entry.path !== undefined || entry.reference !== undefined || entry.identity !== "user-statement") invalid("inline evidence requires user-statement text");
104
+ string(entry.text, "evidence.text");
105
+ if (digestJson(entry.text) !== entry.digest) invalid("inline evidence digest mismatch");
106
+ } else {
107
+ if (entry.reference === undefined || entry.text === undefined || entry.path !== undefined || entry.identity !== "host-asserted") invalid("external evidence requires a reference and transferable text");
108
+ string(entry.reference, "evidence.reference"); string(entry.text, "evidence.text");
109
+ if (digestJson(entry.text) !== entry.digest) invalid("external evidence digest mismatch");
110
+ }
111
+ }
112
+ uniqueIds(evidence, "evidence");
113
+ const evidenceIds = new Set(evidence.map((entry) => entry.id));
114
+ const requirements = array(value.requirements, "requirements");
115
+ for (const entry of requirements) {
116
+ object(entry, "requirement", ["id", "text", "source", "appliesTo", "blocking", "evidenceIds"]);
117
+ id(entry.id, "requirement.id"); string(entry.text, "requirement.text"); string(entry.source, "requirement.source");
118
+ applies(entry.appliesTo, "requirement.appliesTo");
119
+ if (typeof entry.blocking !== "boolean") invalid("requirement.blocking must be boolean");
120
+ const refs = array(entry.evidenceIds, "requirement.evidenceIds").map((ref) => id(ref, "requirement.evidenceId"));
121
+ if (new Set(refs).size !== refs.length) invalid("requirement.evidenceIds duplicate");
122
+ refs.forEach((ref) => { if (!evidenceIds.has(ref)) invalid(`unknown evidence: ${ref}`); });
123
+ }
124
+ uniqueIds(requirements, "requirements");
125
+ const decisions = array(value.decisions, "decisions");
126
+ for (const entry of decisions) {
127
+ object(entry, "decision", ["id", "question", "appliesTo", "blocking", "status", "value", "evidenceIds"], ["id", "question", "appliesTo", "blocking", "status", "evidenceIds"]);
128
+ id(entry.id, "decision.id"); string(entry.question, "decision.question"); applies(entry.appliesTo, "decision.appliesTo");
129
+ if (typeof entry.blocking !== "boolean") invalid("decision.blocking must be boolean");
130
+ choice(entry.status, ["open", "confirmed"], "decision.status");
131
+ const decisionRefs = array(entry.evidenceIds, "decision.evidenceIds");
132
+ if (entry.status === "confirmed" ? typeof entry.value !== "string" || !entry.value.trim() || decisionRefs.length === 0 : entry.value !== undefined) invalid("decision value or evidence is invalid");
133
+ if (new Set(decisionRefs).size !== decisionRefs.length) invalid("decision.evidenceIds duplicate");
134
+ decisionRefs.forEach((ref) => { id(ref, "decision.evidenceId"); if (!evidenceIds.has(ref)) invalid(`unknown evidence: ${ref}`); });
135
+ if (entry.status === "confirmed" && !decisionRefs.some((ref) => evidence.find((source) => source.id === ref)?.identity !== "host-asserted")) invalid("confirmed decision needs user or locally verified evidence");
136
+ }
137
+ uniqueIds(decisions, "decisions");
138
+ object(value.progress, "progress", ["completed", "remaining", "implementation", "nextAction"]);
139
+ for (const key of ["completed", "remaining"]) array(value.progress[key], `progress.${key}`).forEach((item) => string(item, `progress.${key} item`));
140
+ const implementation = array(value.progress.implementation, "progress.implementation");
141
+ for (const entry of implementation) {
142
+ object(entry, "implementation progress", ["path", "operation", "status", "digest"]);
143
+ normalizeRelativePath(entry.path, { label: "implementation progress path" });
144
+ choice(entry.operation, ["create", "modify", "delete"], "implementation progress operation");
145
+ choice(entry.status, ["completed"], "implementation progress status");
146
+ digest(entry.digest, "implementation progress digest");
147
+ if (!writes.some((target) => normalizeRelativePath(target.path) === normalizeRelativePath(entry.path) && target.operation === entry.operation)) invalid("implementation progress has no matching write target");
148
+ }
149
+ if (new Set(implementation.map((entry) => normalizeRelativePath(entry.path))).size !== implementation.length) invalid("implementation progress contains duplicate paths");
150
+ string(value.progress.nextAction, "progress.nextAction");
151
+ return value;
152
+ }
153
+
154
+ export function validateHandoffInput(input) {
155
+ const value = structuredClone(object(input, "input", ["schemaVersion", "kind", "recordPath", "record", "intent", "consumerRunId", "consumption"], ["schemaVersion", "kind", "intent", "consumerRunId"]));
156
+ if (value.schemaVersion !== TASK_HANDOFF_INPUT_SCHEMA_VERSION || value.kind !== "task-handoff-input") invalid("handoff input identity is invalid");
157
+ if ((value.recordPath === undefined) === (value.record === undefined)) invalid("exactly one of recordPath and record is required");
158
+ if (value.recordPath !== undefined) {
159
+ const normalized = normalizeRelativePath(value.recordPath, { label: "record path" });
160
+ const taskId = normalized.startsWith(`${TASK_DIRECTORY}/`) && normalized.endsWith(".json") ? normalized.slice(TASK_DIRECTORY.length + 1, -5) : "";
161
+ if (!ID.test(taskId) || normalized !== taskPath(taskId)) invalid("recordPath must be a top-level task record");
162
+ } else validateTaskRecord(value.record);
163
+ choice(value.intent, INTENTS, "intent"); string(value.consumerRunId, "consumerRunId");
164
+ if (value.consumption !== undefined) {
165
+ const c = object(value.consumption, "consumption", ["contextBundleDigest", "taskId", "requirementsDigest", "consumerRunId", "readTargets", "evidenceIds", "conditional"], ["contextBundleDigest", "consumerRunId", "readTargets", "evidenceIds", "conditional"]);
166
+ digest(c.contextBundleDigest, "consumption.contextBundleDigest");
167
+ if (c.taskId !== undefined) id(c.taskId, "consumption.taskId");
168
+ if (c.requirementsDigest !== undefined) digest(c.requirementsDigest, "consumption.requirementsDigest");
169
+ if (c.consumerRunId !== value.consumerRunId) invalid("consumption consumerRunId mismatch");
170
+ c.readTargets = consumedTargets(c.readTargets, "consumption.readTargets");
171
+ const ids = array(c.evidenceIds, "consumption.evidenceIds").map((x) => id(x, "consumption.evidenceId"));
172
+ if (new Set(ids).size !== ids.length) invalid("consumption.evidenceIds duplicate");
173
+ const conditional = array(c.conditional, "consumption.conditional");
174
+ for (const entry of conditional) {
175
+ object(entry, "conditional", ["path", "decision", "reason", "digest"], ["path", "decision", "reason"]);
176
+ entry.path = normalizeRelativePath(entry.path, { label: "conditional path" });
177
+ choice(entry.decision, ["read", "not-applicable"], "conditional.decision");
178
+ string(entry.reason, "conditional.reason");
179
+ if (entry.decision === "read") digest(entry.digest, "conditional.digest");
180
+ else if (entry.digest !== undefined) invalid("not-applicable conditional cannot claim a digest");
181
+ }
182
+ if (new Set(conditional.map((x) => x.path)).size !== conditional.length) invalid("conditional paths duplicate");
183
+ }
184
+ return value;
185
+ }