@tea-agent/loop-agent 0.35.1-beta.4 → 0.35.2

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 (63) hide show
  1. package/AGENTS.md +0 -2
  2. package/CHANGELOG.md +43 -26
  3. package/bin/loop-agent.js +1 -37
  4. package/dist/application/dag/generate-task-dag.js +4 -1
  5. package/dist/application/task-lifecycle/advance.js +14 -0
  6. package/dist/cli/program.js +2 -2
  7. package/dist/commands/task-advance.js +1 -0
  8. package/dist/executors/dag-pi-executor.js +0 -44
  9. package/dist/shared/package-metadata.js +0 -42
  10. package/dist/task/config-types.js +2 -0
  11. package/dist/task/contract/project.js +3 -0
  12. package/dist/task/contract/schema.js +1 -0
  13. package/dist/task/source-prepare/build-draft.js +7 -0
  14. package/dist/task/source-prepare/semantic-intake.js +37 -10
  15. package/dist/task/task-demand-routing.js +10 -0
  16. package/dist/worker/console/chat/assistant-content.js +110 -0
  17. package/dist/worker/console/chat/pi-runtime.js +13 -32
  18. package/dist/worker/console/chat/routes.js +9 -4
  19. package/dist/worker/console/chat/session-store.js +3 -0
  20. package/dist/worker/console/chat/shortcuts.js +9 -7
  21. package/dist/worker/console/chat/turn-process.js +62 -15
  22. package/dist/worker/console/operator-actions.js +72 -6
  23. package/dist/worker/console/prd-intake-bridge.js +10 -3
  24. package/dist/worker/console/prd-reference-discovery.js +124 -0
  25. package/dist/worker/console/static/assets/index-DRqZiQ7J.css +1 -0
  26. package/dist/worker/console/static/assets/index-gVHrlqI9.js +56 -0
  27. package/dist/worker/console/static/index.html +2 -2
  28. package/dist/worker/console/static-src/app/useOperatorActions.js +19 -1
  29. package/dist/worker/console/static-src/app/useRecoveryConsole.js +0 -5
  30. package/dist/worker/console/static-src/app/useTaskWizard.js +12 -0
  31. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +10 -1
  32. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +11 -6
  33. package/dist/worker/console/static-src/operator-chat/useChatThread.js +11 -6
  34. package/dist/worker/console/static-src/operator-chat/useComposer.js +15 -1
  35. package/dist/worker/loop-agent/loop-agent-client.js +3 -17
  36. package/dist/worker/observability/read-model.js +0 -20
  37. package/dist/worker/observe/spec-evidence.js +8 -3
  38. package/dist/worker/observe/static/views/dag-inspector.js +71 -6
  39. package/dist/worker/preflight.js +1 -2
  40. package/dist/workflows/dag/backend-test-scenario-param.js +23 -33
  41. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -9
  42. package/dist/workflows/dag/frontend-implementation-contract.js +39 -233
  43. package/dist/workflows/dag/frontend-prewrite-gate.js +61 -364
  44. package/dist/workflows/dag/frontend-repair.js +18 -219
  45. package/dist/workflows/dag/frontend-verification-trace.js +32 -47
  46. package/dist/workflows/dag/init-hybrid.js +26 -49
  47. package/dist/workflows/dag/node-execution.js +0 -89
  48. package/dist/workflows/dag/recovery-recommendation.js +0 -58
  49. package/dist/workflows/dag/runner.js +11 -245
  50. package/dist/workflows/dag/scheduler.js +3 -257
  51. package/dist/workflows/dag/types.js +2 -130
  52. package/harness.json +1 -1
  53. package/package.json +2 -2
  54. package/dist/build-stamp.json +0 -6
  55. package/dist/worker/console/static/assets/index-CvsQgALl.js +0 -56
  56. package/dist/worker/console/static/assets/index-hJqCPs_g.css +0 -1
  57. package/dist/workflows/dag/contract-output-registry.js +0 -14
  58. package/dist/workflows/dag/contract-validator-registrations.js +0 -8
  59. package/dist/workflows/dag/frontend-recovery-plan.js +0 -73
  60. package/dist/workflows/dag/frontend-recovery-root-manifest.js +0 -123
  61. package/dist/workflows/dag/frontend-recovery-run.js +0 -539
  62. package/dist/workflows/dag/frontend-writer-recovery.js +0 -106
  63. package/dist/workflows/dag/frontend-writer-rollback.js +0 -821
@@ -28,6 +28,7 @@
28
28
  import { createHash, randomBytes } from "node:crypto";
29
29
  import { access } from "node:fs/promises";
30
30
  import path from "node:path";
31
+ import { extractAssistantContentBlocks, extractAssistantText, } from "./assistant-content.js";
31
32
  import { projectCompactSnapshot, } from "./chat-event-store.js";
32
33
  import { extractUsageSample } from "./usage.js";
33
34
  import { OPERATOR_CHAT_ALLOWED_TOOLS, authorizeOperatorChatTool, assertNoWriteToolInList, } from "./tools.js";
@@ -563,28 +564,7 @@ export function projectRuntimeSnapshot(input) {
563
564
  hasBash: input.hasBash,
564
565
  };
565
566
  }
566
- /**
567
- * Extract the latest assistant text fragment from an SDK message object.
568
- * Tolerant of multiple message shapes the SDK emits.
569
- */
570
- export function extractAssistantText(message) {
571
- if (!message || typeof message !== "object")
572
- return "";
573
- const m = message;
574
- if (typeof m.text === "string")
575
- return m.text;
576
- const contents = Array.isArray(m.content) ? m.content : [];
577
- const parts = Array.isArray(m.parts) ? m.parts : [];
578
- const segments = [];
579
- for (const c of [...contents, ...parts]) {
580
- if (!c || typeof c !== "object")
581
- continue;
582
- const seg = c;
583
- if (typeof seg.text === "string")
584
- segments.push(seg.text);
585
- }
586
- return segments.join("");
587
- }
567
+ export { extractAssistantContentBlocks, extractAssistantText };
588
568
  function readSessionManagerId(manager) {
589
569
  if (!manager || typeof manager !== "object")
590
570
  return undefined;
@@ -2116,6 +2096,15 @@ export class ConsolePiRuntime {
2116
2096
  this.dispose(id);
2117
2097
  }
2118
2098
  }
2099
+ function mapAssistantMessageEvent(type, sessionId, message) {
2100
+ const content = extractAssistantContentBlocks(message);
2101
+ return {
2102
+ type,
2103
+ sessionId,
2104
+ text: extractAssistantText(message),
2105
+ ...(content.length > 0 ? { content } : {}),
2106
+ };
2107
+ }
2119
2108
  function mapSdkEvent(sessionId, event) {
2120
2109
  switch (event.type) {
2121
2110
  case "agent_start":
@@ -2127,17 +2116,9 @@ function mapSdkEvent(sessionId, event) {
2127
2116
  willRetry: Boolean(event.willRetry),
2128
2117
  };
2129
2118
  case "message_update":
2130
- return {
2131
- type: "message_update",
2132
- sessionId,
2133
- text: extractAssistantText(event.message),
2134
- };
2119
+ return mapAssistantMessageEvent("message_update", sessionId, event.message);
2135
2120
  case "message_end":
2136
- return {
2137
- type: "message_end",
2138
- sessionId,
2139
- text: extractAssistantText(event.message),
2140
- };
2121
+ return mapAssistantMessageEvent("message_end", sessionId, event.message);
2141
2122
  case "turn_end":
2142
2123
  // Usage is emitted from message_end (see prompt subscribe). turn_end
2143
2124
  // carries the same final assistant message and would double-count.
@@ -12,6 +12,7 @@
12
12
  * Mutation routes go through the Console mutation gate (boot token + Origin).
13
13
  * Read routes (GET capabilities / sessions) are open to loopback.
14
14
  */
15
+ import { parseAssistantContent } from "./assistant-content.js";
15
16
  import { OPERATOR_CHAT_ALLOWED_TOOLS, OPERATOR_CHAT_DENIED_TOOLS, OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS, } from "./tools.js";
16
17
  import { buildModelCallableToolSchemas } from "./tool-adapter.js";
17
18
  import { cleanChatTitle } from "./session-store.js";
@@ -83,12 +84,16 @@ function turnEventToStorePartial(event) {
83
84
  case "agent_start":
84
85
  return { kind: "agent_start", data: {} };
85
86
  case "message_update":
87
+ case "message_end": {
88
+ const content = parseAssistantContent(event.content);
86
89
  return {
87
- kind: "message_update",
88
- data: { text: String(event.text ?? "") },
90
+ kind: event.type,
91
+ data: {
92
+ text: String(event.text ?? ""),
93
+ ...(content ? { content } : {}),
94
+ },
89
95
  };
90
- case "message_end":
91
- return { kind: "message_end", data: { text: String(event.text ?? "") } };
96
+ }
92
97
  case "usage":
93
98
  return { kind: "usage", data: (event.usage ?? {}) };
94
99
  case "tool_call":
@@ -368,6 +368,9 @@ export async function runChatTurn(options) {
368
368
  pending.push(store.appendMessage(sessionId, {
369
369
  role: "assistant",
370
370
  text: event.text,
371
+ ...(event.content && event.content.length > 0
372
+ ? { content: event.content }
373
+ : {}),
371
374
  }));
372
375
  }
373
376
  break;
@@ -41,13 +41,6 @@ export const SHORTCUTS = [
41
41
  action: "reload",
42
42
  source: "operator",
43
43
  },
44
- {
45
- command: "/help",
46
- label: "Shortcuts",
47
- description: "显示可用 shortcut",
48
- action: "show-help",
49
- source: "operator",
50
- },
51
44
  ];
52
45
  /** Pi Web commands with real browser-equivalent behavior (UI-11). */
53
46
  export const WEB_SLASH_COMMANDS = [
@@ -103,6 +96,15 @@ export function resolveShortcut(input) {
103
96
  return { command: local.command, action: local.action };
104
97
  return { command: token, action: "unknown" };
105
98
  }
99
+ /** Map Operator panel shortcuts onto Console workspace routes. */
100
+ export function workspaceForShortcut(action) {
101
+ if (action === "focus-panel:task" || action === "focus-panel:contract") {
102
+ return "tasks";
103
+ }
104
+ if (action === "focus-panel:dag")
105
+ return "recovery";
106
+ return undefined;
107
+ }
106
108
  /**
107
109
  * Merge Operator, Web, and dynamic Session commands with conflict dedupe.
108
110
  * Priority: operator/web > extension > skill > prompt. /reload is single-card.
@@ -1,6 +1,16 @@
1
1
  /**
2
2
  * Turn grouping for Operator Chat inline process UX (pi-web ProcessDetails mind).
3
3
  */
4
+ import { joinAssistantText, parseAssistantContent, splitFinalAssistantBlocks, } from "./assistant-content.js";
5
+ function emptyGroup(key) {
6
+ return {
7
+ key,
8
+ assistants: [],
9
+ tools: [],
10
+ processItems: [],
11
+ live: false,
12
+ };
13
+ }
4
14
  /**
5
15
  * Restore chat bubbles + tool cards from durable session messages.
6
16
  * Tool rows often appear before the final assistant `message_end` message;
@@ -36,11 +46,13 @@ export function hydrateSessionThread(raw) {
36
46
  if (item.role === "assistant") {
37
47
  if (!turnId)
38
48
  turnId = `restored-${item.id}`;
49
+ const content = parseAssistantContent(item.content);
39
50
  messages.push({
40
51
  id: item.id,
41
52
  role: "assistant",
42
53
  text: item.text,
43
54
  createdAt,
55
+ ...(content && content.length > 0 ? { content } : {}),
44
56
  });
45
57
  flushPendingToAssistant(item.id);
46
58
  continue;
@@ -64,6 +76,45 @@ export function hydrateSessionThread(raw) {
64
76
  }
65
77
  return { messages, toolCalls };
66
78
  }
79
+ function fillProcessDetails(group) {
80
+ const finalAssistant = group.assistants[group.assistants.length - 1];
81
+ const blocks = finalAssistant?.content ?? [];
82
+ const split = splitFinalAssistantBlocks(blocks, { isStreaming: group.live });
83
+ const usedToolIds = new Set();
84
+ const processItems = [];
85
+ for (const block of split.processBlocks) {
86
+ if (block.type === "thinking") {
87
+ processItems.push({ kind: "thinking", thinking: block.thinking });
88
+ continue;
89
+ }
90
+ if (block.type === "text") {
91
+ processItems.push({ kind: "text", text: block.text });
92
+ continue;
93
+ }
94
+ const tool = group.tools.find((entry) => entry.id === block.toolCallId);
95
+ if (tool) {
96
+ processItems.push({ kind: "tool", tool });
97
+ usedToolIds.add(tool.id);
98
+ }
99
+ }
100
+ for (const tool of group.tools) {
101
+ if (!usedToolIds.has(tool.id))
102
+ processItems.push({ kind: "tool", tool });
103
+ }
104
+ group.processItems = processItems;
105
+ if (!finalAssistant)
106
+ return;
107
+ if (blocks.length === 0) {
108
+ group.answer = finalAssistant;
109
+ return;
110
+ }
111
+ const answerText = joinAssistantText(split.answerBlocks);
112
+ group.answer = {
113
+ ...finalAssistant,
114
+ text: answerText,
115
+ content: split.answerBlocks,
116
+ };
117
+ }
67
118
  /**
68
119
  * Build user → process → answer groups for the message timeline.
69
120
  */
@@ -72,12 +123,7 @@ export function buildTurnProcessGroups(input) {
72
123
  const groups = [];
73
124
  let current = null;
74
125
  const startGroup = (key) => {
75
- const group = {
76
- key,
77
- assistants: [],
78
- tools: [],
79
- live: false,
80
- };
126
+ const group = emptyGroup(key);
81
127
  groups.push(group);
82
128
  return group;
83
129
  };
@@ -92,12 +138,7 @@ export function buildTurnProcessGroups(input) {
92
138
  current.assistants.push(message);
93
139
  }
94
140
  if (groups.length === 0 && input.toolCalls.length > 0) {
95
- groups.push({
96
- key: "orphan-tools",
97
- assistants: [],
98
- tools: [],
99
- live: false,
100
- });
141
+ groups.push(emptyGroup("orphan-tools"));
101
142
  }
102
143
  const assigned = new Set();
103
144
  const assistantToGroup = new Map();
@@ -162,17 +203,23 @@ export function buildTurnProcessGroups(input) {
162
203
  input.streaming &&
163
204
  input.activeAssistantId !== null &&
164
205
  group.assistants.some((a) => a.id === input.activeAssistantId);
206
+ fillProcessDetails(group);
165
207
  }
166
208
  return groups;
167
209
  }
168
- export function processGroupSummary(tools) {
210
+ export function processGroupSummary(tools, options) {
169
211
  const count = tools.length;
212
+ const thinkingCount = options?.thinkingCount ?? 0;
213
+ const textCount = options?.textCount ?? 0;
214
+ const stepCount = thinkingCount + textCount + count;
170
215
  const errorCount = tools.filter((t) => t.status === "error").length;
171
216
  const runningCount = tools.filter((t) => t.status === "running").length;
172
- const parts = [`执行过程 · ${count} 工具`];
217
+ const parts = [`处理详情 · ${stepCount} 步`];
218
+ if (count > 0)
219
+ parts.push(`${count} 工具`);
173
220
  if (errorCount > 0)
174
221
  parts.push(`${errorCount} 出错`);
175
222
  else if (runningCount > 0)
176
223
  parts.push("运行中");
177
- return { count, errorCount, runningCount, label: parts.join(" · ") };
224
+ return { count, errorCount, runningCount, stepCount, label: parts.join(" · ") };
178
225
  }
@@ -17,6 +17,9 @@ import { toCanonicalDraftForCli, } from "./draft-store.js";
17
17
  import { normalizeConsoleWorkflowKind } from "./workflow-kinds.js";
18
18
  import { deriveTaskIdentityFromPrd, nextTaskIdRevision, } from "./prd-identity.js";
19
19
  import { appendEngineeringCliArgs, buildDraftFromTaskIntake, parseEngineeringBoundaryFromParams, } from "./prd-intake-bridge.js";
20
+ import { discoverPrdReferences } from "./prd-reference-discovery.js";
21
+ import { clearSemanticIntakeAttempt } from "../../task/source-prepare/semantic-intake.js";
22
+ import nodePath from "node:path";
20
23
  import { resolveSiblingAgentWorkerBin } from "./sibling-controller.js";
21
24
  import { projectOperationEventSummary, projectOperationForChat, } from "./chat/chat-event-store.js";
22
25
  import { MutationGateReceiptStore } from "./mutation-gate-receipt-store.js";
@@ -1364,7 +1367,9 @@ export async function dispatchOperatorAction(ctx, req) {
1364
1367
  if (!taskId || !content) {
1365
1368
  return invalid(action, "taskId and content are required");
1366
1369
  }
1367
- const taskKind = normalizeConsoleWorkflowKind(str(p.taskKind), "standard");
1370
+ const taskKindRaw = str(p.taskKind)?.trim();
1371
+ const taskKind = normalizeConsoleWorkflowKind(taskKindRaw, "standard");
1372
+ const taskKindExplicit = Boolean(taskKindRaw);
1368
1373
  const engineering = parseEngineeringBoundaryFromParams(p);
1369
1374
  const identity = deriveTaskIdentityFromPrd(content);
1370
1375
  const title = str(p.title) ?? identity.title;
@@ -1407,7 +1412,7 @@ export async function dispatchOperatorAction(ctx, req) {
1407
1412
  };
1408
1413
  }
1409
1414
  const intakeArgs = ["task", "advance", taskId, "--json"];
1410
- if (taskKind && taskKind !== "standard") {
1415
+ if (taskKindExplicit) {
1411
1416
  intakeArgs.push("--task-kind", taskKind);
1412
1417
  }
1413
1418
  appendEngineeringCliArgs(intakeArgs, engineering);
@@ -1424,6 +1429,7 @@ export async function dispatchOperatorAction(ctx, req) {
1424
1429
  taskId,
1425
1430
  title,
1426
1431
  taskKind,
1432
+ taskKindExplicit,
1427
1433
  advanceJson: intakeJson,
1428
1434
  engineering,
1429
1435
  });
@@ -2495,7 +2501,38 @@ async function dispatchBootstrapFromPrd(ctx, p) {
2495
2501
  return invalid("bootstrapFromPrd", parsedDocs.message);
2496
2502
  }
2497
2503
  const documents = parsedDocs.documents;
2498
- const taskKind = normalizeConsoleWorkflowKind(str(p.taskKind), "standard");
2504
+ // B1: free-form prose that references repo-local markdown paths (e.g.
2505
+ // "请参照这三个prd路径 docs/product-analysis/foo.md …") only materializes
2506
+ // the sentence itself. Discover the referenced files and import them as
2507
+ // additional documents so intake has real requirement facts to structure.
2508
+ let autoImportedReferences = [];
2509
+ let unresolvedReferences = [];
2510
+ const autoDocs = [];
2511
+ if (!documents) {
2512
+ const discovery = await discoverPrdReferences({
2513
+ prose: content,
2514
+ repoRoot: ctx.repoRoot,
2515
+ });
2516
+ autoImportedReferences = discovery.references.map((r) => ({
2517
+ repoRelative: r.repoRelative,
2518
+ role: r.role,
2519
+ }));
2520
+ unresolvedReferences = discovery.unresolved;
2521
+ for (const ref of discovery.references) {
2522
+ const absolute = nodePath.resolve(ctx.repoRoot, ref.repoRelative);
2523
+ const refContent = await readFile(absolute, "utf-8").catch(() => "");
2524
+ if (!refContent.trim())
2525
+ continue;
2526
+ autoDocs.push({
2527
+ name: ref.repoRelative.split("/").pop() ?? ref.repoRelative,
2528
+ content: refContent,
2529
+ fileName: ref.repoRelative,
2530
+ });
2531
+ }
2532
+ }
2533
+ const taskKindRaw = str(p.taskKind)?.trim();
2534
+ const taskKind = normalizeConsoleWorkflowKind(taskKindRaw, "standard");
2535
+ const taskKindExplicit = Boolean(taskKindRaw);
2499
2536
  const engineering = parseEngineeringBoundaryFromParams(p);
2500
2537
  const identity = deriveTaskIdentityFromPrd(content);
2501
2538
  let taskId;
@@ -2547,7 +2584,7 @@ async function dispatchBootstrapFromPrd(ctx, p) {
2547
2584
  }
2548
2585
  const succeededImports = [];
2549
2586
  const importResults = [];
2550
- const runImportViaAdvance = async (label, text, name) => {
2587
+ const runImportViaAdvance = async (label, text, name, role = "requirement") => {
2551
2588
  const staged = await stageUtf8Text(ctx.appData, text, {
2552
2589
  extension: ".md",
2553
2590
  });
@@ -2558,7 +2595,7 @@ async function dispatchBootstrapFromPrd(ctx, p) {
2558
2595
  "--prd",
2559
2596
  staged.path,
2560
2597
  "--role",
2561
- "requirement",
2598
+ role,
2562
2599
  "--json",
2563
2600
  ], {
2564
2601
  cwd: ctx.repoRoot,
@@ -2599,6 +2636,23 @@ async function dispatchBootstrapFromPrd(ctx, p) {
2599
2636
  if (composedFailure)
2600
2637
  return composedFailure;
2601
2638
  }
2639
+ else if (autoDocs.length > 0) {
2640
+ // B1: discovered repo-local references become explicit imports (role
2641
+ // inferred per document), then the prose itself imports as composed.
2642
+ const roleByBase = new Map(autoImportedReferences.map((r) => {
2643
+ const base = r.repoRelative.split("/").pop() ?? r.repoRelative;
2644
+ return [base, r.role];
2645
+ }));
2646
+ for (const doc of autoDocs) {
2647
+ const role = roleByBase.get(doc.name) ?? "requirement";
2648
+ const failure = await runImportViaAdvance(doc.name, doc.content, doc.name, role);
2649
+ if (failure)
2650
+ return failure;
2651
+ }
2652
+ const composedFailure = await runImportViaAdvance("composed", content, "composed");
2653
+ if (composedFailure)
2654
+ return composedFailure;
2655
+ }
2602
2656
  else {
2603
2657
  const staged = await stageUtf8Text(ctx.appData, content, {
2604
2658
  extension: ".md",
@@ -2625,11 +2679,16 @@ async function dispatchBootstrapFromPrd(ctx, p) {
2625
2679
  }
2626
2680
  importResults.push(imported.json ?? null);
2627
2681
  }
2682
+ // C: a fresh import (manual or discovered) is new evidence; clear the
2683
+ // one-shot semantic-intake attempt marker so intake may run again.
2684
+ if (succeededImports.length > 0) {
2685
+ await clearSemanticIntakeAttempt(ctx.repoRoot, taskId);
2686
+ }
2628
2687
  // Phase 2 (design 2026-08-11): drive task advance intake after import so
2629
2688
  // deterministic parse + one-shot Semantic Intake run on archived references.
2630
2689
  // Timeout is longer than import: Pi MED may take minutes.
2631
2690
  const intakeArgs = ["task", "advance", taskId, "--json"];
2632
- if (taskKind && taskKind !== "standard") {
2691
+ if (taskKindExplicit) {
2633
2692
  intakeArgs.push("--task-kind", taskKind);
2634
2693
  }
2635
2694
  appendEngineeringCliArgs(intakeArgs, engineering);
@@ -2646,6 +2705,7 @@ async function dispatchBootstrapFromPrd(ctx, p) {
2646
2705
  taskId,
2647
2706
  title,
2648
2707
  taskKind,
2708
+ taskKindExplicit,
2649
2709
  advanceJson: intakeJson,
2650
2710
  engineering,
2651
2711
  });
@@ -2679,7 +2739,13 @@ async function dispatchBootstrapFromPrd(ctx, p) {
2679
2739
  imports: importResults,
2680
2740
  documentNames: documents && documents.length > 0
2681
2741
  ? [...documents.map((d) => d.name), "composed"]
2742
+ : autoDocs.length > 0
2743
+ ? [...autoDocs.map((d) => d.name), "composed"]
2744
+ : undefined,
2745
+ autoImportedReferences: autoImportedReferences.length > 0
2746
+ ? autoImportedReferences
2682
2747
  : undefined,
2748
+ unresolvedReferences: unresolvedReferences.length > 0 ? unresolvedReferences : undefined,
2683
2749
  draftPreview: {
2684
2750
  objective: draftSaved.draft.requirement?.objective ?? null,
2685
2751
  scope: draftSaved.draft.requirement?.scope ?? [],
@@ -215,6 +215,7 @@ export function applyEngineeringBoundaryToDraft(draft, boundary) {
215
215
  }
216
216
  export async function buildDraftFromTaskIntake(input) {
217
217
  const taskKind = normalizeConsoleWorkflowKind(input.taskKind, "standard");
218
+ const taskKindExplicit = Boolean(input.taskKindExplicit);
218
219
  const paths = getTaskPaths(input.repoRoot, input.taskId);
219
220
  const warnings = warningList(input.advanceJson);
220
221
  const lifecycleState = lifecycleFromAdvanceJson(input.advanceJson);
@@ -262,6 +263,14 @@ export async function buildDraftFromTaskIntake(input) {
262
263
  const semDraftConstraints = artifact?.draft?.constraints;
263
264
  const semDraftVerification = artifact?.draft?.verification;
264
265
  const semTaskKind = artifact?.draft?.taskKind?.trim();
266
+ // User-explicit Console taskKind is a hard contract: semantic intake
267
+ // must never overwrite it (it may recommend backend-test for prose like
268
+ // "削减单元测试数量" even when the operator picked standard/后端研发).
269
+ const resolvedTaskKind = taskKindExplicit
270
+ ? taskKind
271
+ : semTaskKind
272
+ ? normalizeConsoleWorkflowKind(semTaskKind, taskKind)
273
+ : taskKind;
265
274
  const mergedAllowed = filterPlaceholderPaths(semDraftConstraints?.allowedPaths);
266
275
  const mergedForbidden = filterPlaceholderPaths(semDraftConstraints?.forbiddenPaths);
267
276
  const mergedVerify = Array.isArray(semDraftVerification?.commands)
@@ -280,9 +289,7 @@ export async function buildDraftFromTaskIntake(input) {
280
289
  draft = {
281
290
  ...draft,
282
291
  title,
283
- ...(semTaskKind
284
- ? { taskKind: normalizeConsoleWorkflowKind(semTaskKind, taskKind) }
285
- : {}),
292
+ taskKind: resolvedTaskKind,
286
293
  requirement: {
287
294
  objective: semanticReq.objective.trim(),
288
295
  scope: asStringList(semanticReq.scope),
@@ -0,0 +1,124 @@
1
+ /**
2
+ * B1: discover repo-local markdown references inside free-form PRD prose.
3
+ *
4
+ * Console "文字录入" flows often paste one sentence like
5
+ * "请参照这三个prd路径 docs/product-analysis/foo/*.md 完成业务需求".
6
+ * The deterministic import only materializes the sentence itself; the
7
+ * referenced files never reach source/references/, so semantic intake has
8
+ * no requirement facts to structure. This module extracts candidate
9
+ * repo-relative .md paths from the prose, filters exclusions, and resolves
10
+ * each to an importable document (path + inferred role) so the caller can
11
+ * auto-import them before intake.
12
+ */
13
+ import { readFile, stat } from "node:fs/promises";
14
+ import path from "node:path";
15
+ import { resolvePrdImportRole } from "../../task/source-prepare/artifact-meta.js";
16
+ /** Repo paths that must never be auto-imported as PRD references. */
17
+ const EXCLUDED_PREFIXES = [
18
+ ".harness/",
19
+ "node_modules/",
20
+ ".git/",
21
+ "ai_workspace/",
22
+ "dist/",
23
+ "build/",
24
+ "coverage/",
25
+ ];
26
+ /** Derived contract files are never valid PRD inputs. */
27
+ const EXCLUDED_BASENAMES = new Set(["需求.md", "执行约束.md"]);
28
+ /** Markdown path tokens with unicode path segments (supports Chinese dirs/files). */
29
+ const MD_PATH_TOKEN_RE = /(?:[\w\u4e00-\u9fff.-]+\/)+[\w\u4e00-\u9fff.-]+\.(?:md|markdown)\b/gu;
30
+ /** Strip URLs so their path fragments are never treated as repo paths. */
31
+ const URL_RE = /(?:https?|ftp|file):\/\/[^\s\u4e00-\u9fff\uff08\uff09]+/gu;
32
+ const MAX_AUTO_IMPORT_BYTES = 512 * 1024;
33
+ const MAX_AUTO_IMPORT_COUNT = 8;
34
+ function isExcluded(repoRelative) {
35
+ const posix = repoRelative.replace(/\\/g, "/");
36
+ for (const prefix of EXCLUDED_PREFIXES) {
37
+ if (posix === prefix.slice(0, -1) || posix.startsWith(prefix)) {
38
+ return `excluded prefix ${prefix}`;
39
+ }
40
+ }
41
+ const base = posix.split("/").pop() ?? posix;
42
+ if (EXCLUDED_BASENAMES.has(base)) {
43
+ return `derived contract file ${base}`;
44
+ }
45
+ return null;
46
+ }
47
+ /**
48
+ * Extract candidate markdown path tokens from free-form prose.
49
+ * Returns unique tokens in first-occurrence order.
50
+ */
51
+ export function extractPrdPathTokens(prose) {
52
+ const withoutUrls = prose.replace(URL_RE, " ");
53
+ const seen = new Set();
54
+ const out = [];
55
+ for (const match of withoutUrls.matchAll(MD_PATH_TOKEN_RE)) {
56
+ const token = match[0];
57
+ if (!token || token.length <= "x.md".length)
58
+ continue;
59
+ if (seen.has(token))
60
+ continue;
61
+ seen.add(token);
62
+ out.push(token);
63
+ if (out.length >= 32)
64
+ break;
65
+ }
66
+ return out;
67
+ }
68
+ /**
69
+ * Resolve prose path tokens against the repo root. Existing, non-excluded,
70
+ * size-bounded markdown files become importable references.
71
+ */
72
+ export async function discoverPrdReferences(input) {
73
+ const tokens = extractPrdPathTokens(input.prose);
74
+ const references = [];
75
+ const unresolved = [];
76
+ for (const token of tokens) {
77
+ if (references.length >= MAX_AUTO_IMPORT_COUNT) {
78
+ unresolved.push({ token, reason: "auto-import limit reached" });
79
+ continue;
80
+ }
81
+ const repoRelative = token.replace(/\\/g, "/");
82
+ const excludedReason = isExcluded(repoRelative);
83
+ if (excludedReason) {
84
+ unresolved.push({ token, reason: excludedReason });
85
+ continue;
86
+ }
87
+ const absolute = path.resolve(input.repoRoot, repoRelative);
88
+ // Defense in depth: resolved candidates must stay under repoRoot.
89
+ const rel = path.relative(input.repoRoot, absolute);
90
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
91
+ unresolved.push({ token, reason: "outside repo root" });
92
+ continue;
93
+ }
94
+ let content;
95
+ try {
96
+ const fileStat = await stat(absolute);
97
+ if (!fileStat.isFile()) {
98
+ unresolved.push({ token, reason: "not a regular file" });
99
+ continue;
100
+ }
101
+ if (fileStat.size > MAX_AUTO_IMPORT_BYTES) {
102
+ unresolved.push({
103
+ token,
104
+ reason: `exceeds ${MAX_AUTO_IMPORT_BYTES} bytes`,
105
+ });
106
+ continue;
107
+ }
108
+ content = await readFile(absolute, "utf-8");
109
+ }
110
+ catch {
111
+ unresolved.push({ token, reason: "not found in repo" });
112
+ continue;
113
+ }
114
+ references.push({
115
+ repoRelative,
116
+ role: resolvePrdImportRole({
117
+ filePath: absolute,
118
+ markdown: content,
119
+ }),
120
+ sizeBytes: Buffer.byteLength(content, "utf-8"),
121
+ });
122
+ }
123
+ return { references, unresolved };
124
+ }