frontend-project-context 1.8.0 → 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 (69) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/README.md +26 -7
  3. package/UPGRADING.md +24 -0
  4. package/docs/00-PRODUCT-CONSTITUTION.md +44 -12
  5. package/docs/08-INSTALLATION-AND-DISTRIBUTION.md +9 -3
  6. package/docs/14-FORMAL-RELEASE-READINESS.md +14 -0
  7. package/docs/28-REAL-PROJECT-ONBOARDING-CLOSURE-DESIGN.md +10 -0
  8. package/docs/29-REAL-PROJECT-1.8.0-INITIALIZATION-OBSERVATIONS.md +228 -0
  9. package/docs/30-TASK-CONTEXT-CONSUMPTION-CLOSURE-DESIGN.md +563 -0
  10. package/docs/31-TASK-CONTEXT-INTEGRITY-REPAIR-DESIGN.md +281 -0
  11. package/docs/32-PROJECT-TO-TASK-INTERACTION-PROPOSAL.md +173 -0
  12. package/docs/33-BOUNDED-TASK-HANDOFF-REDESIGN.md +207 -0
  13. package/docs/34-A0-CODEX-HOST-FEASIBILITY.md +61 -0
  14. package/docs/35-CONTEXT-FIRST-TASK-HANDOFF-DESIGN.md +248 -0
  15. package/docs/36-PRODUCT-VALUE-AND-USABILITY-REVIEW.md +158 -0
  16. package/docs/37-EVERYDAY-HANDOFF-FLOW-DESIGN.md +271 -0
  17. package/docs/38-EVERYDAY-HANDOFF-OPERATION.md +147 -0
  18. package/docs/39-SELF-HOST-RELEASE-ACCEPTANCE.md +39 -0
  19. package/docs/AI-PROJECT-INITIALIZATION.md +8 -3
  20. package/docs/PRODUCT-SHARING-AND-ADOPTION-GUIDE.md +111 -0
  21. package/docs/README.md +26 -2
  22. package/docs/USER-AND-AI-OPERATION-MANUAL.md +9 -3
  23. package/docs/assets/product-sharing-01-overview.svg +32 -0
  24. package/docs/assets/product-sharing-02-how-it-works.svg +17 -0
  25. package/docs/assets/product-sharing-03-example.svg +19 -0
  26. package/examples/package.json +1 -1
  27. package/migration-manifest.json +38 -12
  28. package/package.json +2 -2
  29. package/schemas/capabilities.schema.json +26 -12
  30. package/schemas/context-bundle.schema.json +32 -0
  31. package/schemas/coverage-audit.schema.json +6 -4
  32. package/schemas/evidence-bundle.schema.json +1 -1
  33. package/schemas/initialization-instruction.schema.json +17 -5
  34. package/schemas/migration-manifest.schema.json +3 -3
  35. package/schemas/migration-plan.schema.json +1 -1
  36. package/schemas/project-brief.schema.json +400 -0
  37. package/schemas/projection-lock.schema.json +1 -1
  38. package/schemas/task-context-record.schema.json +27 -0
  39. package/schemas/task-handoff-input.schema.json +23 -0
  40. package/schemas/task-handoff-result.schema.json +320 -0
  41. package/schemas/upgrade-assessment.schema.json +1 -1
  42. package/schemas/upgrade-result-bundle.schema.json +1 -1
  43. package/schemas/work-view.schema.json +541 -0
  44. package/src/project-context/adaptive-context-schema.mjs +1 -1
  45. package/src/project-context/adaptive-context.mjs +16 -29
  46. package/src/project-context/ai-entry.mjs +28 -12
  47. package/src/project-context/approver.mjs +6 -2
  48. package/src/project-context/authoring.mjs +28 -8
  49. package/src/project-context/capabilities.mjs +13 -1
  50. package/src/project-context/checker.mjs +1 -1
  51. package/src/project-context/cli.mjs +74 -17
  52. package/src/project-context/context-bundle.mjs +379 -0
  53. package/src/project-context/contract-schema.mjs +62 -9
  54. package/src/project-context/coverage-profile.mjs +127 -0
  55. package/src/project-context/exchange-schema.mjs +24 -6
  56. package/src/project-context/exchange.mjs +3 -0
  57. package/src/project-context/initialization-instruction.mjs +20 -2
  58. package/src/project-context/maintenance.mjs +5 -4
  59. package/src/project-context/migration-manifest.mjs +3 -3
  60. package/src/project-context/path-policy.mjs +13 -0
  61. package/src/project-context/renderer.mjs +11 -4
  62. package/src/project-context/source-reader.mjs +27 -2
  63. package/src/project-context/task-context.mjs +4 -14
  64. package/src/project-context/task-handoff-schema.mjs +185 -0
  65. package/src/project-context/task-handoff.mjs +259 -0
  66. package/src/project-context/upgrade-schema.mjs +1 -1
  67. package/src/project-context/upgrade.mjs +1 -0
  68. package/src/project-context/work-view-schema.mjs +125 -0
  69. package/src/project-context/work-view.mjs +135 -0
@@ -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
+ }
@@ -0,0 +1,259 @@
1
+ import { lstat, readFile, readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { digestJson, sha256 } from "./canonical-json.mjs";
4
+ import { buildContextBundle } from "./context-bundle.mjs";
5
+ import { ProjectContextError, fail } from "./errors.mjs";
6
+ import { inspectContextTarget, normalizeRelativePath, resolveExistingInside } from "./path-policy.mjs";
7
+ import { loadProject } from "./project-store.mjs";
8
+ import { buildProjectStatus } from "./project-status.mjs";
9
+ import { PERMANENT_BOUNDARIES } from "./capabilities.mjs";
10
+ import { TASK_DIRECTORY, TASK_HANDOFF_RESULT_SCHEMA_VERSION, normalizeTaskRecordPaths, validateHandoffInput, validateTaskRecord, taskPath } from "./task-handoff-schema.mjs";
11
+ import { projectWorkView, taskWorkView } from "./work-view.mjs";
12
+ import { validateProjectBrief } from "./work-view-schema.mjs";
13
+
14
+ const MAX_RECORD = 256 * 1024;
15
+ const MAX_INPUT = 512 * 1024;
16
+ const MAX_TASKS = 100;
17
+ const ordered = (values) => [...new Set(values)].sort((a, b) => a.localeCompare(b));
18
+
19
+ async function readBounded(file, max, label) {
20
+ const stat = await lstat(file);
21
+ if (!stat.isFile() || stat.size > max) fail("task-record-invalid", `${label} is not a bounded regular file`);
22
+ return readFile(file, "utf8");
23
+ }
24
+
25
+ async function parseBoundedJson(file, max, label) {
26
+ let content;
27
+ try { content = await readBounded(file, max, label); }
28
+ catch (error) {
29
+ if (error instanceof ProjectContextError) throw error;
30
+ fail("task-record-unavailable", `cannot read ${label}`, { details: { reason: error.code ?? "unavailable" } });
31
+ }
32
+ try { return JSON.parse(content); }
33
+ catch { fail("task-handoff-schema-invalid", `${label} is not JSON`); }
34
+ }
35
+
36
+ export async function loadTaskRecord(root, recordPath) {
37
+ const normalized = normalizeRelativePath(recordPath, { label: "record path" });
38
+ const taskId = normalized.startsWith(`${TASK_DIRECTORY}/`) && normalized.endsWith(".json") ? normalized.slice(TASK_DIRECTORY.length + 1, -5) : "";
39
+ if (!/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/u.test(taskId) || normalized !== taskPath(taskId)) {
40
+ fail("task-handoff-schema-invalid", "recordPath must identify a top-level task record");
41
+ }
42
+ let stat;
43
+ try { stat = await lstat(path.join(root, normalized)); }
44
+ catch (error) {
45
+ if (error?.code === "ENOENT") fail("task-record-unavailable", "task record does not exist", { details: { path: normalized } });
46
+ throw error;
47
+ }
48
+ if (!stat.isFile()) fail("task-record-invalid", "task record must be a regular file");
49
+ const resolved = await resolveExistingInside(root, normalized);
50
+ const record = validateTaskRecord(await parseBoundedJson(resolved.absolute, MAX_RECORD, "task record"));
51
+ if (normalized !== taskPath(record.taskId)) fail("task-handoff-schema-invalid", "task record filename and taskId differ");
52
+ return record;
53
+ }
54
+
55
+ export async function buildProjectBrief(root, options = {}) {
56
+ const statusResult = await buildProjectStatus(root, PERMANENT_BOUNDARIES);
57
+ const status = statusResult.status;
58
+ const taskDir = path.join(root, TASK_DIRECTORY);
59
+ const tasks = [];
60
+ let entries;
61
+ try {
62
+ const stat = await lstat(taskDir);
63
+ if (!stat.isDirectory()) fail("task-record-invalid", "task directory must be a regular directory");
64
+ entries = await readdir(taskDir, { withFileTypes: true });
65
+ } catch (error) {
66
+ if (error?.code === "ENOENT") entries = [];
67
+ else throw error;
68
+ }
69
+ const candidates = entries.filter((entry) => entry.name.endsWith(".json")).sort((a, b) => a.name.localeCompare(b.name));
70
+ if (candidates.length > MAX_TASKS) fail("task-discovery-budget-exceeded", "too many task records; provide an exact taskId", { exitCode: 1, details: { count: candidates.length, limit: MAX_TASKS } });
71
+ for (const entry of candidates) {
72
+ if (!entry.isFile()) { tasks.push({ path: `${TASK_DIRECTORY}/${entry.name}`, status: "invalid-record" }); continue; }
73
+ try {
74
+ const record = await loadTaskRecord(root, `${TASK_DIRECTORY}/${entry.name}`);
75
+ tasks.push({ taskId: record.taskId, title: record.title, lifecycle: record.lifecycle, readPaths: record.scope.readPaths, writePaths: record.scope.writeTargets.map((x) => x.path), unresolvedDecisions: record.decisions.filter((x) => x.status === "open").length,
76
+ ...(options.view === "work" ? { progressSummary: { revision: record.revision, identity: "host-reported", ...record.progress } } : {}),
77
+ });
78
+ } catch (error) {
79
+ tasks.push({ path: `${TASK_DIRECTORY}/${entry.name}`, status: "invalid-record", reason: error.code ?? "invalid" });
80
+ }
81
+ }
82
+ const brief = {
83
+ schemaVersion: 1, kind: "project-brief",
84
+ project: status.project, package: status.package,
85
+ connection: status.initialization.state === "uninitialized" ? "not-connected" : status.health === "clean" && status.contractReadiness === "contract-ready" && status.entry.state === "current" ? "available" : "needs-governance",
86
+ projectHealth: status.health, contractReadiness: status.contractReadiness,
87
+ findingCodes: status.findingCodes, taskStatus: "none", tasks,
88
+ nextAction: status.initialization.state === "uninitialized" ? "read-initialization-instructions" :
89
+ status.health !== "clean" ? "resolve-project-findings" :
90
+ status.contractReadiness !== "contract-ready" ? "complete-onboarding" :
91
+ tasks.length === 0 ? "describe-task-or-provide-existing-design" : "select-task-by-id-or-describe-new-task",
92
+ authorityGranted: false,
93
+ };
94
+ if (options.view !== "work") return validateProjectBrief(brief);
95
+ let context = null;
96
+ let items = [];
97
+ if (brief.connection === "available") {
98
+ const project = await loadProject(root);
99
+ if (project.contractDigest !== status.snapshots.contract || project.sourcesLockDigest !== status.snapshots.sourcesLock || project.projectionsLockDigest !== status.snapshots.projectionsLock) fail("project-state-changed", "project snapshot changed while building the brief", { exitCode: 1 });
100
+ context = await buildContextBundle(root, project, { mode: "locate", task: "项目概览;没有选定开发任务" });
101
+ items = project.contract.items.filter((x) => x.status === "approved");
102
+ }
103
+ return projectWorkView(brief, context, items);
104
+ }
105
+
106
+ async function readInput(root, inputPath, stdinText) {
107
+ if (inputPath === "-") {
108
+ if (typeof stdinText !== "string" || Buffer.byteLength(stdinText) > MAX_INPUT) fail("task-handoff-schema-invalid", "stdin handoff input is absent or over budget");
109
+ try { return JSON.parse(stdinText); } catch { fail("task-handoff-schema-invalid", "stdin handoff input is not JSON"); }
110
+ }
111
+ const resolved = await resolveExistingInside(root, inputPath);
112
+ return parseBoundedJson(resolved.absolute, MAX_INPUT, "handoff input");
113
+ }
114
+
115
+ function relevant(entry, intent) { return entry.appliesTo.includes(intent); }
116
+ function evidenceStatus(id, map) { return map.get(id)?.status ?? "missing"; }
117
+ function sourceEvidenceIds(record, requirement) {
118
+ return requirement.evidenceIds.filter((id) => record.evidence.some((entry) => entry.id === id));
119
+ }
120
+
121
+ function currentEvidence(record, intent) {
122
+ const allRefs = new Set([...record.requirements, ...record.decisions].flatMap((entry) => entry.evidenceIds));
123
+ const currentRefs = new Set([...record.requirements, ...record.decisions].filter((entry) => relevant(entry, intent)).flatMap((entry) => entry.evidenceIds));
124
+ // Unassigned evidence is shared task context. Evidence assigned only to a
125
+ // different intent must not silently become a blocker for this one.
126
+ return record.evidence.filter((entry) => !allRefs.has(entry.id) || currentRefs.has(entry.id));
127
+ }
128
+
129
+ async function targetDigest(root, target) {
130
+ const resolved = await resolveExistingInside(root, target);
131
+ const stat = await lstat(resolved.absolute);
132
+ if (!stat.isFile()) fail("task-read-target-not-file", "task consumption needs an exact regular file; narrow directory targets before handoff", { details: { path: target }, exitCode: 2 });
133
+ return sha256(await readFile(resolved.absolute));
134
+ }
135
+
136
+ export async function buildTaskHandoff(root, inputPath, stdinText, options = {}) {
137
+ const input = validateHandoffInput(await readInput(root, inputPath, stdinText));
138
+ if (input.record && Buffer.byteLength(JSON.stringify(input.record)) > MAX_RECORD) fail("task-record-invalid", "inline task record exceeds 256 KiB");
139
+ const rawRecord = input.recordPath ? await loadTaskRecord(root, input.recordPath) : input.record;
140
+ const record = normalizeTaskRecordPaths(rawRecord);
141
+ const selectedRecord = { ...record, requirements: record.requirements.filter((x) => relevant(x, input.intent)), decisions: record.decisions.filter((x) => relevant(x, input.intent)), evidence: currentEvidence(record, input.intent) };
142
+ const status = (await buildProjectStatus(root, PERMANENT_BOUNDARIES)).status;
143
+ const project = status.initialization.state === "initialized" ? await loadProject(root) : null;
144
+ const base = {
145
+ schemaVersion: TASK_HANDOFF_RESULT_SCHEMA_VERSION, kind: "task-handoff-result",
146
+ taskIdentity: { projectId: record.projectId, taskId: record.taskId, title: record.title, lifecycle: record.lifecycle },
147
+ projectHealth: status.health, intent: input.intent, recordDigest: digestJson(rawRecord),
148
+ authorityGranted: false, semanticCompleteness: "not-claimed",
149
+ };
150
+ if (!project || status.health !== "clean" || status.contractReadiness !== "contract-ready" || project.contract.project.id !== record.projectId) {
151
+ const reason = !project ? "project-unavailable" : project.contract.project.id !== record.projectId ? "project-mismatch" : "project-needs-governance";
152
+ const blocked = { ...base, taskStatus: "blocked", gate: { mode: "instructional", currentAction: input.intent === "implement" ? "implementation" : "read-only", status: "hold", reasonIds: [reason], instruction: "先处理项目或任务身份问题。" }, contextBundleDigest: null, requirementsDigest: null, progressKey: digestJson([record.taskId, reason]), itemIds: [], scopeCoverage: [], requiredTargets: [], conditionalTargets: [], evidenceResults: [], gaps: [{ id: reason, code: reason }], questions: [], nextAction: "resolve-project-findings" };
153
+ return options.view === "work" ? taskWorkView(blocked, null, selectedRecord) : blocked;
154
+ }
155
+
156
+ const evidence = currentEvidence(record, input.intent);
157
+ const currentRequirements = record.requirements.filter((x) => relevant(x, input.intent));
158
+ const currentDecisions = record.decisions.filter((x) => relevant(x, input.intent));
159
+ const targetPaths = ordered([...record.scope.readPaths, ...record.scope.writeTargets.map((x) => x.path), ...evidence.filter((x) => x.kind === "local").map((x) => x.path)]);
160
+ const bundle = await buildContextBundle(root, project, { mode: targetPaths.length ? "targeted" : "locate", paths: targetPaths, task: record.origin.text });
161
+ const evidenceResults = [];
162
+ for (const entry of evidence) {
163
+ let state = "current";
164
+ if (entry.kind === "local") {
165
+ try {
166
+ state = await targetDigest(root, entry.path) === entry.digest ? "current" : "changed";
167
+ } catch (error) {
168
+ if (error?.code === "source-missing") state = "missing";
169
+ else throw error;
170
+ }
171
+ }
172
+ evidenceResults.push({ id: entry.id, status: state, identity: entry.identity });
173
+ }
174
+ const evidenceMap = new Map(evidenceResults.map((x) => [x.id, x]));
175
+ const gaps = [];
176
+ const add = (id, code, details = {}) => { if (!gaps.some((x) => x.id === id)) gaps.push({ id, code, ...details }); };
177
+ for (const gap of bundle.gaps) {
178
+ if (gap.code === "target-path-not-yet-known") continue;
179
+ if (gap.code === "project-scope-only" || gap.code === "registration-coverage-not-declared") continue;
180
+ add(`context:${gap.code}:${gap.path ?? "."}`, gap.code, { path: gap.path ?? null });
181
+ }
182
+ for (const entry of evidenceResults) {
183
+ if (entry.status !== "current" && record.evidence.find((x) => x.id === entry.id)?.purpose !== "implementation") add(`evidence:${entry.id}`, `evidence-${entry.status}`, { evidenceId: entry.id });
184
+ }
185
+ if (input.intent === "implement" && record.scope.writeTargets.length === 0) add("target:implementation-scope-empty", "implementation-scope-empty");
186
+ if (input.intent === "implement" && currentRequirements.length === 0) add("requirements:empty", "requirements-empty");
187
+ for (const entry of currentRequirements.filter((x) => x.blocking)) {
188
+ const refs = sourceEvidenceIds(record, entry);
189
+ if (refs.length === 0 || refs.every((id) => evidenceStatus(id, evidenceMap) !== "current")) add(`requirement:${entry.id}`, "requirement-evidence-missing", { requirementId: entry.id });
190
+ }
191
+ for (const entry of currentDecisions.filter((x) => x.blocking)) {
192
+ if (entry.status !== "confirmed" || entry.evidenceIds.some((id) => evidenceStatus(id, evidenceMap) !== "current")) add(`decision:${entry.id}`, "decision-open", { decisionId: entry.id });
193
+ }
194
+ for (const target of input.intent === "implement" ? record.scope.writeTargets : []) {
195
+ const actual = await inspectContextTarget(root, target.path);
196
+ const progress = record.progress.implementation.find((x) => x?.path === target.path && x?.operation === target.operation && x?.status === "completed");
197
+ if (target.operation === "create" && actual === "existing" && !progress) add(`target:${target.path}`, "create-target-exists", { path: target.path });
198
+ if (target.operation !== "create" && actual !== "existing" && !progress) add(`target:${target.path}`, "target-missing", { path: target.path });
199
+ if (progress && target.operation === "delete" && actual === "existing") add(`target:${target.path}`, "completed-delete-still-exists", { path: target.path });
200
+ if (progress && target.operation !== "delete") {
201
+ if (actual !== "existing") add(`target:${target.path}`, "completed-output-missing", { path: target.path });
202
+ else if (await targetDigest(root, target.path) !== progress.digest) add(`target:${target.path}`, "completed-output-changed", { path: target.path });
203
+ }
204
+ }
205
+ const requiredTargets = bundle.readTargets.filter((x) => x.role === "required" && !record.scope.writeTargets.some((target) => {
206
+ if (target.path !== x.path) return false;
207
+ const completed = record.progress.implementation.some((entry) => entry.path === target.path && entry.operation === target.operation && entry.status === "completed");
208
+ return (target.operation === "create" && !completed) || (target.operation === "delete" && completed);
209
+ })).map((x) => x.path);
210
+ const conditionalTargets = bundle.readTargets.filter((x) => x.role === "conditional").map((x) => x.path);
211
+ for (const target of requiredTargets) {
212
+ try {
213
+ const resolved = await resolveExistingInside(root, target);
214
+ const stat = await lstat(resolved.absolute);
215
+ if (!stat.isFile()) add(`read:${target}`, "read-target-needs-file-scope", { path: target });
216
+ } catch (error) { if (error?.code !== "source-missing") throw error; }
217
+ }
218
+ const requirementsDigest = digestJson({ projectId: record.projectId, taskId: record.taskId, intent: input.intent, requirements: currentRequirements, decisions: currentDecisions, scope: record.scope, evidence: evidence.filter((x) => x.purpose !== "implementation").map((x) => ({ id: x.id, digest: x.digest })), items: bundle.items.map((x) => ({ id: x.id, value: x.value })) });
219
+ if (input.consumption && input.consumption.taskId !== record.taskId) add("consumption:task", "consumption-task-mismatch");
220
+ if (input.consumption && input.consumption.requirementsDigest !== requirementsDigest) add("consumption:requirements", "requirements-not-consumed");
221
+ if (input.consumption?.contextBundleDigest !== bundle.bundleDigest) add("consumption:digest", "context-not-consumed");
222
+ if (input.consumption?.contextBundleDigest === bundle.bundleDigest) {
223
+ for (const target of requiredTargets) {
224
+ if (gaps.some((x) => x.id === `read:${target}` && x.code === "read-target-needs-file-scope")) continue;
225
+ const consumed = input.consumption.readTargets.find((x) => x.path === target);
226
+ if (!consumed) add(`read:${target}`, "required-not-read", { path: target });
227
+ else {
228
+ try {
229
+ if (await targetDigest(root, target) !== consumed.digest) add(`read:${target}`, "required-read-changed", { path: target });
230
+ } catch (error) { if (error?.code === "source-missing") add(`read:${target}`, "required-source-missing", { path: target }); else throw error; }
231
+ }
232
+ }
233
+ for (const target of conditionalTargets) {
234
+ const decision = input.consumption.conditional.find((x) => x.path === target);
235
+ if (!decision) add(`conditional:${target}`, "conditional-not-evaluated", { path: target });
236
+ else if (decision.decision === "read") {
237
+ try {
238
+ if (await targetDigest(root, target) !== decision.digest) add(`conditional:${target}`, "conditional-read-changed", { path: target });
239
+ } catch (error) { if (error?.code === "source-missing") add(`conditional:${target}`, "conditional-source-missing", { path: target }); else if (error?.code === "task-read-target-not-file") add(`conditional:${target}`, "read-target-needs-file-scope", { path: target }); else throw error; }
240
+ }
241
+ }
242
+ for (const entry of evidence.filter((x) => x.purpose !== "implementation")) if (!input.consumption.evidenceIds.includes(entry.id)) add(`consume-evidence:${entry.id}`, "evidence-not-consumed", { evidenceId: entry.id });
243
+ }
244
+ if (targetPaths.length === 0) add("target:unknown", "target-path-not-yet-known");
245
+ const hasDecision = gaps.some((x) => x.code === "decision-open");
246
+ const hasEvidence = gaps.some((x) => !["decision-open", "target-path-not-yet-known", "implementation-scope-empty"].includes(x.code));
247
+ const taskStatus = record.lifecycle !== "active" ? "inactive" : targetPaths.length === 0 || (input.intent === "implement" && record.scope.writeTargets.length === 0) ? "discovering" : hasEvidence ? "needs-evidence" : hasDecision ? "needs-decision" : input.intent === "implement" ? "ready-to-implement" : "ready-for-read-only";
248
+ const gateStatus = taskStatus === "inactive" ? "not-applicable" : taskStatus.startsWith("ready-") ? "proceed" : "hold";
249
+ const nextAction = taskStatus === "inactive" ? "select-active-task-or-explicitly-restore" : taskStatus === "discovering" ? "locate-target-paths" : gaps.some((x) => x.code === "read-target-needs-file-scope") ? "narrow-directory-targets-to-files" : hasEvidence ? "read-or-update-required-evidence" : hasDecision ? "resolve-listed-decisions" : input.intent === "implement" ? "check-current-user-authority-then-implement" : "complete-current-read-only-task";
250
+ const result = {
251
+ ...base, taskStatus,
252
+ gate: { mode: "instructional", currentAction: input.intent === "implement" ? "implementation" : "read-only", status: gateStatus, reasonIds: gaps.map((x) => x.id), instruction: gateStatus === "hold" ? "处理列出的缺口后重新交接;当前不要实施受阻的业务修改。" : gateStatus === "proceed" && input.intent === "implement" ? "先核对当前用户授权与范围,再执行实施。" : "按当前只读意图继续。" },
253
+ contextBundleDigest: bundle.bundleDigest, requirementsDigest,
254
+ progressKey: digestJson({ requirementsDigest, gaps: gaps.map((x) => x.id), nextAction, consumed: input.consumption ? [input.consumption.readTargets, input.consumption.evidenceIds, input.consumption.conditional] : null }),
255
+ itemIds: bundle.items.map((x) => x.id), scopeCoverage: bundle.targets.map((x) => ({ path: x.path, coverage: x.scopeCoverage })), requiredTargets, conditionalTargets, evidenceResults,
256
+ gaps, questions: record.decisions.filter((x) => x.status === "open").map((x) => ({ id: x.id, question: x.question, blockingNow: x.blocking && relevant(x, input.intent) })), nextAction,
257
+ };
258
+ return options.view === "work" ? taskWorkView(result, bundle, selectedRecord) : result;
259
+ }
@@ -121,7 +121,7 @@ export function validateUpgradeAssessment(input) {
121
121
  stores: new Set(["contract", "projectionLock", "proposal", "sourceLock"]),
122
122
  renderers: new Set(["aiEntry", "projection"]),
123
123
  protocols: new Set([
124
- "actionPlan", "adaptiveContextBundle", "contextQuery", "coverageAudit", "evidenceBundle", "evidenceInput", "exchange",
124
+ "actionPlan", "adaptiveContextBundle", "contextBundle", "contextQuery", "coverageAudit", "evidenceBundle", "evidenceInput", "exchange",
125
125
  "hostPromotionEvidence", "integrationReviewBundle", "reviewBundle", "routingIndex", "stageContextBundle", "stageReceipt",
126
126
  "taskContextPlan", "truthReconciliationInput", "truthReconciliationReviewBundle", "initializationInstruction", "projectStatus",
127
127
  ]),
@@ -123,6 +123,7 @@ async function inspectUpgradeState(root, project) {
123
123
  const lockedSources = new Map(project.sourcesLock.sources.map((entry) => [entry.id, entry.digest]));
124
124
  for (const source of project.contract.sources) {
125
125
  if (sourceStatus(source) === "deprecated") continue;
126
+ if (source.kind === "human-decision" || source.kind === "external-reference") continue;
126
127
  const locked = lockedSources.get(source.id);
127
128
  if (!locked) findings.push({ code: "source-lock-missing", source: source.id });
128
129
  else if (source.digest !== undefined && source.digest !== locked) findings.push({ code: "source-lock-mismatch", source: source.id });
@@ -0,0 +1,125 @@
1
+ import { prettyCanonicalJson } from "./canonical-json.mjs";
2
+ import { validateContextBundle } from "./context-bundle.mjs";
3
+ import { validateTaskContent } from "./task-handoff-schema.mjs";
4
+ import { fail } from "./errors.mjs";
5
+
6
+ const briefKeys = ["schemaVersion", "kind", "project", "package", "connection", "projectHealth", "contractReadiness", "findingCodes", "taskStatus", "tasks", "nextAction", "authorityGranted"];
7
+ const handoffKeys = ["schemaVersion", "kind", "taskIdentity", "projectHealth", "intent", "recordDigest", "authorityGranted", "semanticCompleteness", "taskStatus", "gate", "contextBundleDigest", "requirementsDigest", "progressKey", "itemIds", "scopeCoverage", "requiredTargets", "conditionalTargets", "evidenceResults", "gaps", "questions", "nextAction"];
8
+ function invalid(message) { fail("work-view-schema-invalid", message); }
9
+ function object(value, keys, label, optional = []) {
10
+ if (!value || typeof value !== "object" || Array.isArray(value)) invalid(`${label} must be an object`);
11
+ for (const key of Object.keys(value)) if (!keys.includes(key) && !optional.includes(key)) invalid(`${label}.${key} is unknown`);
12
+ for (const key of keys) if (!Object.hasOwn(value, key)) invalid(`${label}.${key} is required`);
13
+ }
14
+ function string(value, label) { if (typeof value !== "string" || !value.length) invalid(`${label} must be a non-empty string`); }
15
+ function array(value, label) { if (!Array.isArray(value)) invalid(`${label} must be an array`); return value; }
16
+ function strings(value, label) { array(value, label).forEach((x) => string(x, label)); }
17
+ function choice(value, values, label) { if (!values.includes(value)) invalid(`${label} is invalid`); }
18
+ function integer(value, minimum, label) { if (!Number.isSafeInteger(value) || value < minimum) invalid(`${label} is invalid`); }
19
+ function scope(value) {
20
+ if (!value || typeof value !== "object") invalid("scope must be an object");
21
+ choice(value.kind, ["project", "path-prefix", "file"], "scope.kind");
22
+ object(value, value.kind === "project" ? ["kind"] : ["kind", "path"], "scope");
23
+ if (value.kind !== "project") string(value.path, "scope.path");
24
+ }
25
+ function progress(value) {
26
+ if (value === null) return;
27
+ object(value, ["revision", "identity", "completed", "remaining", "implementation", "nextAction"], "progress");
28
+ integer(value.revision, 1, "progress.revision");
29
+ if (value.identity !== "host-reported") invalid("progress identity is not a runtime verification");
30
+ strings(value.completed, "progress.completed"); strings(value.remaining, "progress.remaining");
31
+ for (const x of array(value.implementation, "progress.implementation")) {
32
+ object(x, ["path", "operation", "status", "digest"], "implementation");
33
+ string(x.path, "implementation.path"); choice(x.operation, ["create", "modify", "delete"], "implementation.operation");
34
+ if (x.status !== "completed" || !/^sha256:[a-f0-9]{64}$/u.test(x.digest)) invalid("implementation progress is invalid");
35
+ }
36
+ string(value.nextAction, "progress.nextAction");
37
+ }
38
+ function briefBase(value) {
39
+ if (value.kind !== "project-brief" || value.taskStatus !== "none" || value.authorityGranted !== false) invalid("brief identity/authority is invalid");
40
+ for (const key of ["projectHealth", "contractReadiness", "nextAction"]) string(value[key], key);
41
+ choice(value.connection, ["available", "needs-governance", "not-connected"], "connection");
42
+ strings(value.findingCodes, "findingCodes");
43
+ for (const x of array(value.tasks, "tasks")) {
44
+ if (x.status === "invalid-record") { object(x, ["path", "status"], "task", ["reason"]); string(x.path, "task.path"); continue; }
45
+ object(x, ["taskId", "title", "lifecycle", "readPaths", "writePaths", "unresolvedDecisions", ...(value.schemaVersion === 2 ? ["progressSummary"] : [])], "task");
46
+ string(x.taskId, "taskId"); string(x.title, "title"); choice(x.lifecycle, ["active", "completed", "cancelled"], "lifecycle");
47
+ strings(x.readPaths, "readPaths"); strings(x.writePaths, "writePaths"); integer(x.unresolvedDecisions, 0, "unresolvedDecisions");
48
+ if (value.schemaVersion === 2) progress(x.progressSummary);
49
+ }
50
+ }
51
+ export function validateProjectBrief(value) {
52
+ if (value.schemaVersion === 2) return validateWorkView(value);
53
+ if (value.schemaVersion !== 1) invalid("brief version is unsupported");
54
+ object(value, briefKeys, "brief"); briefBase(value); return value;
55
+ }
56
+
57
+ export function validateWorkView(value) {
58
+ if (value.schemaVersion !== 2) invalid("work view version must be 2");
59
+ const isBrief = value.kind === "project-brief";
60
+ if (!isBrief && value.kind !== "task-handoff-result") invalid("work view kind is invalid");
61
+ object(value, [...(isBrief ? briefKeys : handoffKeys), "context", isBrief ? "overview" : "taskSnapshot", "presentation", "delivery"], "work");
62
+ if (value.authorityGranted !== false) invalid("work view cannot grant authority");
63
+ object(value.delivery, ["status", "reasonCodes", "bytes", "maxBytes"], "delivery");
64
+ choice(value.delivery.status, ["ready", "withheld"], "delivery.status"); strings(value.delivery.reasonCodes, "delivery.reasonCodes");
65
+ integer(value.delivery.bytes, 1, "delivery.bytes");
66
+ if (value.delivery.maxBytes !== 1048576 || value.delivery.bytes > value.delivery.maxBytes || value.delivery.bytes !== Buffer.byteLength(prettyCanonicalJson(value))) invalid("delivery byte budget/count is invalid");
67
+ if ((value.context !== null) !== (value.delivery.status === "ready")) invalid("delivery/context mismatch");
68
+ if (value.context !== null) validateContextBundle(value.context);
69
+ const p = value.presentation;
70
+ object(p, ["headline", "constraints", "remainingConstraints", "basis", "remainingBasis", "progress", "issues", "questions", "nextStep"], "presentation");
71
+ string(p.headline, "headline"); string(p.nextStep, "nextStep"); progress(p.progress);
72
+ integer(p.remainingConstraints, 0, "remainingConstraints"); integer(p.remainingBasis, 0, "remainingBasis");
73
+ const itemIds = new Set(value.context?.items.map((x) => x.id) ?? []);
74
+ if (array(p.constraints, "constraints").length > 5 || array(p.basis, "basis").length > 3) invalid("presentation preview exceeds limit");
75
+ for (const x of p.constraints) {
76
+ object(x, ["text", "itemIds", "scope"], "constraint"); string(x.text, "constraint.text"); strings(x.itemIds, "constraint.itemIds"); scope(x.scope);
77
+ if (!x.itemIds.length || x.itemIds.some((id) => !itemIds.has(id))) invalid("constraint is not bound to delivered Context");
78
+ }
79
+ for (const x of p.basis) { object(x, ["text", "evidenceIds"], "basis", ["requirementIds", "decisionIds"]); string(x.text, "basis.text"); strings(x.evidenceIds, "basis.evidenceIds"); for (const key of ["requirementIds", "decisionIds"]) if (x[key] !== undefined) strings(x[key], key); }
80
+ for (const x of array(p.issues, "issues")) { object(x, ["code", "object", "explanation", "actor", "nextStep"], "issue"); Object.values(x).forEach((v) => string(v, "issue")); }
81
+ for (const x of array(p.questions, "questions")) { object(x, ["decisionId", "question"], "question"); string(x.decisionId, "decisionId"); string(x.question, "question"); }
82
+ if (isBrief) {
83
+ briefBase(value);
84
+ object(value.overview, ["items", "scopeSummary", "limitations"], "overview");
85
+ strings(value.overview.limitations, "overview.limitations");
86
+ if (array(value.overview.items, "overview.items").length > 5) invalid("overview preview exceeds limit");
87
+ for (const x of value.overview.items) {
88
+ object(x, ["id", "kind", "subject", "statement", "value", "scope", "sourceIds"], "overview.item");
89
+ for (const key of ["id", "subject", "statement"]) string(x[key], key);
90
+ choice(x.kind, ["fact", "policy", "reference", "validation-description"], "item.kind"); scope(x.scope); strings(x.sourceIds, "sourceIds");
91
+ }
92
+ for (const x of array(value.overview.scopeSummary, "scopeSummary")) { object(x, ["scope", "items"], "scopeSummary"); scope(x.scope); integer(x.items, 1, "scopeSummary.items"); }
93
+ if (value.context && (value.context.mode !== "locate" || value.project?.id !== value.context.project.id)) invalid("brief Context scope/project mismatch");
94
+ } else {
95
+ if (value.semanticCompleteness !== "not-claimed") invalid("work view cannot claim semantic completeness");
96
+ choice(value.intent, ["investigate", "design", "implement"], "intent");
97
+ choice(value.taskStatus, ["blocked", "discovering", "needs-evidence", "needs-decision", "ready-for-read-only", "ready-to-implement", "inactive"], "taskStatus");
98
+ object(value.gate, ["mode", "currentAction", "status", "reasonIds", "instruction"], "gate");
99
+ choice(value.gate.currentAction, ["read-only", "implementation"], "gate.currentAction"); strings(value.gate.reasonIds, "gate.reasonIds"); string(value.gate.instruction, "gate.instruction");
100
+ if (value.gate.mode !== "instructional") invalid("gate must be instructional");
101
+ choice(value.gate.status, ["hold", "proceed", "not-applicable"], "gate.status");
102
+ if (value.context && (value.context.bundleDigest !== value.contextBundleDigest || value.taskIdentity.projectId !== value.context.project.id)) invalid("handoff Context digest/project mismatch");
103
+ if (value.delivery.status === "withheld" && value.gate.status === "proceed") invalid("withheld delivery cannot proceed");
104
+ if (value.taskSnapshot !== null) {
105
+ const t = value.taskSnapshot;
106
+ object(t, ["origin", "scope", "requirements", "decisions", "evidence", "progress"], "taskSnapshot"); progress(t.progress);
107
+ if (!t.progress) invalid("taskSnapshot needs progress");
108
+ array(t.evidence, "evidence");
109
+ const {revision, identity, ...contentProgress} = t.progress;
110
+ validateTaskContent({...t, progress: contentProgress, evidence: t.evidence.map(({status, ...x}) => {
111
+ choice(status, ["current", "changed", "missing", "not-checked"], "evidence.status"); return x;
112
+ })});
113
+ object(t.origin, ["text"], "origin", ["reference"]); string(t.origin.text, "origin.text");
114
+ object(t.scope, ["readPaths", "writeTargets"], "taskScope", ["workspaceLabel"]); strings(t.scope.readPaths, "scope.readPaths"); array(t.scope.writeTargets, "scope.writeTargets");
115
+ array(t.requirements, "requirements"); array(t.decisions, "decisions"); array(t.evidence, "evidence");
116
+ if ([...t.requirements, ...t.decisions].some((x) => !x.appliesTo.includes(value.intent))) invalid("taskSnapshot contains another intent");
117
+ const evidenceIds = new Set(t.evidence.map((x) => x.id));
118
+ if ([...t.requirements, ...t.decisions].some((x) => x.evidenceIds.some((id) => !evidenceIds.has(id)))) invalid("taskSnapshot evidence binding is incomplete");
119
+ if (p.basis.some((x) => x.evidenceIds.some((id) => !evidenceIds.has(id)) ||
120
+ x.requirementIds?.some((id) => !t.requirements.some((r) => r.id === id)) ||
121
+ x.decisionIds?.some((id) => !t.decisions.some((r) => r.id === id)))) invalid("presentation basis is unbound");
122
+ }
123
+ }
124
+ return value;
125
+ }