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.
- package/CHANGELOG.md +11 -0
- package/README.md +25 -6
- package/UPGRADING.md +14 -0
- package/docs/00-PRODUCT-CONSTITUTION.md +44 -12
- package/docs/08-INSTALLATION-AND-DISTRIBUTION.md +2 -2
- package/docs/32-PROJECT-TO-TASK-INTERACTION-PROPOSAL.md +173 -0
- package/docs/33-BOUNDED-TASK-HANDOFF-REDESIGN.md +207 -0
- package/docs/34-A0-CODEX-HOST-FEASIBILITY.md +61 -0
- package/docs/35-CONTEXT-FIRST-TASK-HANDOFF-DESIGN.md +248 -0
- package/docs/36-PRODUCT-VALUE-AND-USABILITY-REVIEW.md +158 -0
- package/docs/37-EVERYDAY-HANDOFF-FLOW-DESIGN.md +271 -0
- package/docs/38-EVERYDAY-HANDOFF-OPERATION.md +147 -0
- package/docs/39-SELF-HOST-RELEASE-ACCEPTANCE.md +39 -0
- package/docs/AI-PROJECT-INITIALIZATION.md +5 -1
- package/docs/README.md +13 -1
- package/docs/USER-AND-AI-OPERATION-MANUAL.md +9 -3
- package/examples/package.json +1 -1
- package/migration-manifest.json +14 -6
- package/package.json +2 -2
- package/schemas/capabilities.schema.json +17 -7
- package/schemas/evidence-bundle.schema.json +1 -1
- package/schemas/initialization-instruction.schema.json +1 -1
- package/schemas/migration-manifest.schema.json +1 -1
- package/schemas/migration-plan.schema.json +1 -1
- package/schemas/project-brief.schema.json +400 -0
- package/schemas/projection-lock.schema.json +1 -1
- package/schemas/task-context-record.schema.json +27 -0
- package/schemas/task-handoff-input.schema.json +23 -0
- package/schemas/task-handoff-result.schema.json +320 -0
- package/schemas/upgrade-assessment.schema.json +1 -1
- package/schemas/upgrade-result-bundle.schema.json +1 -1
- package/schemas/work-view.schema.json +541 -0
- package/src/project-context/adaptive-context.mjs +4 -6
- package/src/project-context/ai-entry.mjs +13 -10
- package/src/project-context/capabilities.mjs +9 -0
- package/src/project-context/cli.mjs +45 -1
- package/src/project-context/context-bundle.mjs +2 -1
- package/src/project-context/contract-schema.mjs +1 -1
- package/src/project-context/exchange-schema.mjs +3 -2
- package/src/project-context/migration-manifest.mjs +1 -1
- package/src/project-context/path-policy.mjs +13 -0
- package/src/project-context/task-context.mjs +4 -14
- package/src/project-context/task-handoff-schema.mjs +185 -0
- package/src/project-context/task-handoff.mjs +259 -0
- package/src/project-context/upgrade.mjs +1 -0
- package/src/project-context/work-view-schema.mjs +125 -0
- 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
|
|
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.
|
|
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
|
+
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.
|
|
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
|
|
94
|
+
function selectedItems(contract, targetPaths, findings) {
|
|
96
95
|
const byId = new Map();
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
+
}
|