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
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,135 @@
1
+ import { canonicalJson, prettyCanonicalJson } from "./canonical-json.mjs";
2
+ import { fail } from "./errors.mjs";
3
+ import { validateWorkView } from "./work-view-schema.mjs";
4
+
5
+ export const WORK_VIEW_MAX_BYTES = 1024 * 1024;
6
+ const priority = { policy: 0, "validation-description": 1, fact: 2, reference: 3 };
7
+ const intentNames = { investigate: "调查", design: "设计", implement: "实施" };
8
+ const ranked = (items) => [...items].sort((a, b) => priority[a.kind] - priority[b.kind] || a.id.localeCompare(b.id));
9
+ const excerpt = (value, limit = 240) => {
10
+ const text = typeof value === "string" ? value : JSON.stringify(value);
11
+ return text.length <= limit ? text : `${text.slice(0, limit)}…(预览省略,须消费完整内容)`;
12
+ };
13
+ const progress = (record) => record ? { revision: record.revision, identity: "host-reported", ...structuredClone(record.progress) } : null;
14
+
15
+ function constraints(items) {
16
+ return ranked(items).slice(0, 5).map((x) => ({ text: excerpt(`${x.statement};内容:${JSON.stringify(x.value)}`), itemIds: [x.id], scope: x.scope }));
17
+ }
18
+
19
+ function issue(gap, record) {
20
+ const object = gap.path ?? gap.evidenceId ?? gap.requirementId ?? gap.decisionId ?? gap.id;
21
+ const descriptions = {
22
+ "required-not-read": ["Host", `还需读取 ${object} 并核对当前内容。`, "读取该文件后提交本次消费声明。"],
23
+ "context-not-consumed": ["Host", "当前 Context 尚未提交本次消费声明。", "实际阅读 required、判断 conditional,并核对任务证据后复查。"],
24
+ "requirements-not-consumed": ["Host", "声明没有绑定当前需求与依据。", "核对最新需求摘要并重新消费;不要求用户重述未变需求。"],
25
+ "consumption-task-mismatch": ["Host", "消费声明不属于当前任务。", "使用当前任务身份重新消费。"],
26
+ "evidence-missing": ["Host", `任务依据 ${object} 缺失。`, "在授权范围内定位具体依据;无法找到时报告缺口。"],
27
+ "evidence-changed": ["Host", `任务依据 ${object} 已变化。`, "读取并比较最新内容;必要的业务决定变化才请人确认。"],
28
+ "decision-open": ["用户", record?.decisions.find((x) => x.id === gap.decisionId)?.question ?? `决定 ${object} 尚未确认。`, "回答该具体问题;Host 不推断确认值。"],
29
+ "read-target-needs-file-scope": ["Host", `${object} 是目录,不能作为已读文件。`, "在 targetRoot 内收窄到实际文件并重新交接。"],
30
+ "target-path-not-yet-known": ["Host", "目标路径尚未锁定。", "消费项目范围 Context,在 targetRoot 内有限定位,再提交完整最小文件集合。"],
31
+ "project-needs-governance": ["维护者", "项目来源、批准或入口需要维护,当前交接保持阻断。", "运行 sync,审查并处理精确维护项,恢复 clean 后继续。"],
32
+ "work-view-budget-exceeded": ["Host", "工作交付超过上限,完整内容未交付。", "收窄具体目标或证据;概览枚举过大时提供明确 taskId。无新事实不重复重试。"],
33
+ };
34
+ const [actor, explanation, nextStep] = descriptions[gap.code] ?? ["Host", `${gap.code}:${object}`, "核对该对象和原始缺口;不能忽略后开工。"];
35
+ return { code: gap.code, object, explanation, actor, nextStep };
36
+ }
37
+
38
+ function presentation(base, context, record) {
39
+ const items = context?.items ?? [];
40
+ const issues = (base.gaps ?? base.findingCodes?.map((code) => ({ id: code, code })) ?? []).map((x) => issue(x, record));
41
+ const active = base.tasks?.filter((x) => x.lifecycle === "active") ?? [];
42
+ const questions = base.questions?.filter((x) => x.blockingNow).map((x) => ({ decisionId: x.id, question: x.question })) ?? [];
43
+ const basis = record ? [
44
+ ...record.requirements.map((x) => ({ text: excerpt(x.text), requirementIds: [x.id], evidenceIds: x.evidenceIds })),
45
+ ...record.decisions.filter((x) => x.status === "confirmed").map((x) => ({ text: excerpt(`${x.question}:${x.value}`), decisionIds: [x.id], evidenceIds: x.evidenceIds })),
46
+ ] : [];
47
+ let nextStep;
48
+ if (base.kind === "project-brief") {
49
+ nextStep = base.connection === "not-connected" ? "Host 先读取包内 instructions,按唯一初始化指引接入。" : base.connection !== "available" ? "维护者按项目 findings 处理治理或入口问题。" : active.length ? "直接点名任务标题继续,或描述新需求;有歧义时只核对一次选择。" : "直接描述要改的现象或功能,给出现有设计,或请求只读盘点。";
50
+ } else {
51
+ nextStep = base.gate.status === "not-applicable" ? "该任务已结束;可查看历史,不自动恢复实施。" : base.gate.status === "hold" ? (issues[0]?.nextStep ?? base.gate.instruction) : `Host 核对当前用户授权后继续${intentNames[base.intent]};门禁不授予权限。`;
52
+ }
53
+ return {
54
+ headline: record ? `任务「${excerpt(base.taskIdentity.title)}」:当前${intentNames[base.intent]};${base.gate.status === "hold" ? "需处理缺口" : base.gate.status === "not-applicable" ? "已结束" : "当前上下文就绪"}。` : `项目「${excerpt(base.project?.name ?? "未接入")}」:${base.connection === "available" ? "规则可用,目前没有选定开发任务" : "需要接入或维护"}。`,
55
+ constraints: constraints(items), remainingConstraints: Math.max(0, items.length - 5),
56
+ basis: basis.slice(0, 3), remainingBasis: Math.max(0, basis.length - 3),
57
+ progress: progress(record), issues, questions, nextStep,
58
+ };
59
+ }
60
+
61
+ function measure(value) {
62
+ for (let i = 0; i < 8; i += 1) {
63
+ const bytes = Buffer.byteLength(prettyCanonicalJson(value));
64
+ if (bytes === value.delivery.bytes) return bytes;
65
+ value.delivery.bytes = bytes;
66
+ }
67
+ fail("work-view-budget-exceeded", "cannot stabilize output byte count", { exitCode: 1 });
68
+ }
69
+
70
+ export function finalizeWorkView(value) {
71
+ value.delivery.bytes = 0;
72
+ if (measure(value) > WORK_VIEW_MAX_BYTES) {
73
+ const gap = { id: "delivery:budget", code: "work-view-budget-exceeded" };
74
+ value.context = null;
75
+ value.delivery = { status: "withheld", reasonCodes: [gap.code], bytes: 0, maxBytes: WORK_VIEW_MAX_BYTES };
76
+ if (value.kind === "task-handoff-result") {
77
+ value.taskSnapshot = null;
78
+ value.gaps = [gap]; value.questions = []; value.itemIds = []; value.scopeCoverage = [];
79
+ value.requiredTargets = []; value.conditionalTargets = []; value.evidenceResults = [];
80
+ if (value.taskStatus !== "inactive" && value.taskStatus !== "blocked") value.taskStatus = "needs-evidence";
81
+ value.gate = { ...value.gate, status: value.taskStatus === "inactive" ? "not-applicable" : "hold", reasonIds: [gap.id], instruction: "完整内容未交付,收窄目标后重新交接。" };
82
+ value.nextAction = "narrow-work-view-targets";
83
+ } else {
84
+ value.tasks = []; value.overview = { items: [], scopeSummary: [], limitations: [gap.code] };
85
+ value.findingCodes = [...new Set([...value.findingCodes, gap.code])]; value.nextAction = "provide-exact-task-id-or-narrow-context";
86
+ }
87
+ value.presentation = { headline: "完整工作内容未交付,当前不能据此开始任务。", constraints: [], remainingConstraints: 0, basis: [], remainingBasis: 0, progress: null, issues: [issue(gap)], questions: [], nextStep: issue(gap).nextStep };
88
+ if (measure(value) > WORK_VIEW_MAX_BYTES) fail("work-view-budget-exceeded", "metadata also exceeds output budget; provide a smaller explicit task record", { exitCode: 1 });
89
+ }
90
+ return validateWorkView(value);
91
+ }
92
+
93
+ export function taskWorkView(base, context, record) {
94
+ const evidenceMap = new Map(base.evidenceResults.map((x) => [x.id, x.status]));
95
+ const taskSnapshot = record ? {
96
+ origin: structuredClone(record.origin), scope: structuredClone(record.scope),
97
+ requirements: structuredClone(record.requirements), decisions: structuredClone(record.decisions),
98
+ evidence: record.evidence.map((x) => ({ ...structuredClone(x), status: evidenceMap.get(x.id) ?? "not-checked" })),
99
+ progress: progress(record),
100
+ } : null;
101
+ return finalizeWorkView({ ...base, schemaVersion: 2, context, taskSnapshot, presentation: presentation(base, context, record), delivery: { status: context ? "ready" : "withheld", reasonCodes: context ? [] : base.gaps.map((x) => x.code), bytes: 0, maxBytes: WORK_VIEW_MAX_BYTES } });
102
+ }
103
+
104
+ export function projectWorkView(base, context, items) {
105
+ const scopeMap = new Map();
106
+ for (const x of items) {
107
+ const key = canonicalJson(x.scope);
108
+ if (!scopeMap.has(key)) scopeMap.set(key, { scope: structuredClone(x.scope), items: 0 });
109
+ scopeMap.get(key).items += 1;
110
+ }
111
+ const cards = ranked(items).slice(0, 5).map((x) => ({ id: x.id, kind: x.kind, subject: x.subject, statement: x.statement, value: structuredClone(x.value), scope: structuredClone(x.scope), sourceIds: [...x.sources].sort() }));
112
+ const limitations = context ? [...new Set(context.gaps.map((x) => x.code).filter((x) => x !== "target-path-not-yet-known"))] : [...base.findingCodes];
113
+ return finalizeWorkView({ ...base, schemaVersion: 2, context, overview: { items: cards, scopeSummary: [...scopeMap.values()].sort((a, b) => canonicalJson(a.scope).localeCompare(canonicalJson(b.scope))), limitations }, presentation: presentation(base, context), delivery: { status: context ? "ready" : "withheld", reasonCodes: context ? [] : base.findingCodes, bytes: 0, maxBytes: WORK_VIEW_MAX_BYTES } });
114
+ }
115
+
116
+ export function renderWorkView(value) {
117
+ const p = value.presentation;
118
+ const lines = [p.headline];
119
+ if (value.kind !== "project-brief" && p.constraints.length) lines.push("本次可追溯规则:", ...p.constraints.map((x) => `- ${x.text}`));
120
+ if (value.kind !== "project-brief" && p.remainingConstraints) lines.push(`还有 ${p.remainingConstraints} 条,请消费完整 Context。`);
121
+ if (p.basis.length) lines.push("当前依据:", ...p.basis.map((x) => `- ${x.text}`));
122
+ if (p.remainingBasis) lines.push(`还有 ${p.remainingBasis} 条依据,请查看完整 taskSnapshot。`);
123
+ if (p.progress) lines.push("进度(Host 记录):", ...p.progress.completed.map((x) => `- 已完成:${excerpt(x)}`), ...p.progress.remaining.map((x) => `- 剩余:${excerpt(x)}`));
124
+ if (value.kind === "project-brief") {
125
+ if (value.overview.items.length) lines.push("已批准知识预览(按声明范围适用):", ...value.overview.items.map((x) => `- [${x.scope.kind}${x.scope.path ? `:${x.scope.path}` : ""}] ${excerpt(`${x.statement};内容:${JSON.stringify(x.value)}`)}`));
126
+ if (value.overview.scopeSummary.length) lines.push("已声明范围:", ...value.overview.scopeSummary.map((x) => `- ${x.scope.kind}${x.scope.path ? `:${x.scope.path}` : ""},${x.items} 条;仅说明注册范围。`));
127
+ const active = value.tasks.filter((x) => x.lifecycle === "active");
128
+ if (active.length) lines.push("活动任务:", ...active.map((x) => `- ${excerpt(x.title)} (${x.taskId});下一步:${excerpt(x.progressSummary.nextAction)}`));
129
+ if (value.overview.limitations.length) lines.push(`覆盖提示:${value.overview.limitations.join("、")};未声明业务语义完整。`);
130
+ }
131
+ if (p.issues.length) lines.push("当前缺口:", ...p.issues.map((x) => `- ${x.actor}:${excerpt(x.explanation)}`));
132
+ if (p.questions.length) lines.push("需要决定:", ...p.questions.map((x) => `- ${excerpt(x.question)}`));
133
+ lines.push(`下一步:${p.nextStep}`);
134
+ return `${lines.join("\n")}\n`;
135
+ }