@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
@@ -6,8 +6,8 @@
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <link rel="stylesheet" href="/inspect/operator-chrome.css" />
8
8
  <title>Loop 操作台 · Operator Console</title>
9
- <script type="module" crossorigin src="/assets/index-CvsQgALl.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-hJqCPs_g.css">
9
+ <script type="module" crossorigin src="/assets/index-gVHrlqI9.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-DRqZiQ7J.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
@@ -2,6 +2,21 @@ import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { formatOperatorUserError } from "../../operator-user-error.js";
3
3
  import { ACTIVE_CHAT_SESSION_STORAGE_KEY } from "../../chat/workspace-landing.js";
4
4
  import { confirmationToken, CONFIRMATION_HEADER, LONG_RUNNING_ACTIONS, postOperator, waitForOperation, } from "./console-types.js";
5
+ /** Human-readable in-progress copy for sync actions that take tens of
6
+ * seconds server-side (imports + intake). Prevents the "page looks frozen"
7
+ * gap between click and the terminal flash message. */
8
+ const ACTION_PROGRESS_COPY = {
9
+ bootstrapFromPrd: "正在导入 PRD 并创建任务:归档文档 → 确定性解析 → 语义结构化(约 10–60 秒,请勿关闭页面)",
10
+ importPrd: "正在导入 PRD:归档文档 → 确定性解析 → 语义结构化(约 10–60 秒,请勿关闭页面)",
11
+ interviewStart: "正在启动需求访谈…",
12
+ interviewTurn: "正在提交访谈回答…",
13
+ interviewSkip: "正在跳过访谈并生成评估…",
14
+ contractValidate: "正在校验契约草稿…",
15
+ contractDiff: "正在计算契约差异…",
16
+ contractApply: "正在应用契约(写入需求与执行约束)…",
17
+ spineCheck: "正在运行 Spine 审计…",
18
+ dagGenerate: "正在生成 DAG…",
19
+ };
5
20
  /** Central operator-action dispatcher: POST /api/operator/v1/operations with
6
21
  * polling, long-running background waits, interview payload handoff and the
7
22
  * per-operation SSE follow log. */
@@ -18,7 +33,10 @@ export function useOperatorActions(params) {
18
33
  setBusy(true);
19
34
  setOpError(null);
20
35
  setOpErrorCode(null);
21
- setOpMessage(null);
36
+ // Show an immediate progress hint instead of a blank flash region:
37
+ // sync actions (imports/intake) block for tens of seconds with no
38
+ // other UI feedback, which reads as a frozen page.
39
+ setOpMessage(ACTION_PROGRESS_COPY[action] ?? null);
22
40
  try {
23
41
  const { status, body } = await postOperator(action, actionParams, opts?.clientRequestId);
24
42
  if (status === 412 || body.error?.code === "PI_SETUP_REQUIRED") {
@@ -22,7 +22,6 @@ export function useRecoveryConsole(params) {
22
22
  const [recoveryNodesLoading, setRecoveryNodesLoading] = useState(false);
23
23
  const [recoveryRunStatus, setRecoveryRunStatus] = useState(null);
24
24
  const [recoveryFailureCategory, setRecoveryFailureCategory] = useState(null);
25
- const [frontendRecovery, setFrontendRecovery] = useState(null);
26
25
  const [manualRunId, setManualRunId] = useState(false);
27
26
  const [manualNodeId, setManualNodeId] = useState(false);
28
27
  const [recoveryReport, setRecoveryReport] = useState(null);
@@ -125,7 +124,6 @@ export function useRecoveryConsole(params) {
125
124
  setRecoveryNodes([]);
126
125
  setRecoveryRunStatus(null);
127
126
  setRecoveryFailureCategory(null);
128
- setFrontendRecovery(null);
129
127
  return;
130
128
  }
131
129
  setRecoveryNodesLoading(true);
@@ -169,13 +167,11 @@ export function useRecoveryConsole(params) {
169
167
  n.failureCategory &&
170
168
  n.failureCategory !== "success")?.failureCategory;
171
169
  setRecoveryFailureCategory(runFailure ?? nodeFailure ?? null);
172
- setFrontendRecovery(body.frontendRecovery ?? null);
173
170
  }
174
171
  catch {
175
172
  setRecoveryNodes([]);
176
173
  setRecoveryRunStatus(null);
177
174
  setRecoveryFailureCategory(null);
178
- setFrontendRecovery(null);
179
175
  }
180
176
  finally {
181
177
  setRecoveryNodesLoading(false);
@@ -327,7 +323,6 @@ export function useRecoveryConsole(params) {
327
323
  recoveryRunsLoading,
328
324
  recoveryNodesLoading,
329
325
  recoveryRunStatus,
330
- frontendRecovery,
331
326
  manualRunId,
332
327
  setManualRunId,
333
328
  manualNodeId,
@@ -208,12 +208,24 @@ export function useTaskWizard(params) {
208
208
  const semanticNote = result.semanticIntake?.applied
209
209
  ? "semantic"
210
210
  : (result.semanticIntake?.code ?? "no-semantic");
211
+ const autoImported = (result.autoImportedReferences ?? []).filter((ref) => ref.repoRelative);
212
+ const unresolvedRefs = (result.unresolvedReferences ?? []).filter((ref) => ref.token);
211
213
  setOpMessage([
212
214
  `已创建任务「${result.title ?? ""}」(${result.taskId ?? ""})`,
213
215
  `来源:${result.identitySource ?? "prd"}`,
214
216
  `解析:${parseLabel}(${semanticNote})`,
215
217
  objectiveHint ? `目标:${objectiveHint.slice(0, 120)}` : null,
216
218
  gapCount > 0 ? `缺口 ${gapCount} 项待确认` : null,
219
+ autoImported.length > 0
220
+ ? `已自动导入 ${autoImported.length} 个引用文档(${autoImported
221
+ .map((ref) => `${ref.repoRelative}${ref.role ? `:${ref.role}` : ""}`)
222
+ .join("、")})`
223
+ : null,
224
+ unresolvedRefs.length > 0
225
+ ? `有 ${unresolvedRefs.length} 个引用未能解析:${unresolvedRefs
226
+ .map((ref) => `${ref.token}(${ref.reason ?? "未知原因"})`)
227
+ .join("、")}`
228
+ : null,
217
229
  ]
218
230
  .filter(Boolean)
219
231
  .join(" · "));
@@ -2,6 +2,7 @@ import { artifactFromEventPayload, mergeArtifactCardState, } from "../../chat/ar
2
2
  import { humanGateCardFromEventPayload, mergeHumanGateCardState, } from "../../chat/human-gate-card.js";
3
3
  import { mergeOperationCardState, operationFromEventPayload, } from "../../chat/operation-card.js";
4
4
  import { requestId } from "./format.js";
5
+ import { parseAssistantContent } from "../../chat/assistant-content.js";
5
6
  /** Shared handler for a single parsed Chat SSE event (M1 reconnect).
6
7
  *
7
8
  * Used by BOTH the inline prompt POST SSE and the background reconnect
@@ -214,10 +215,17 @@ export function createChatSseEventApplier(deps) {
214
215
  return;
215
216
  }
216
217
  const text = typeof payload.text === "string" ? payload.text : "";
218
+ const content = parseAssistantContent(payload.content);
217
219
  setMessages((m) => {
218
220
  const exists = m.some((msg) => msg.id === resolvedAssistantId);
219
221
  if (exists) {
220
- return m.map((msg) => msg.id === resolvedAssistantId ? { ...msg, text } : msg);
222
+ return m.map((msg) => msg.id === resolvedAssistantId
223
+ ? {
224
+ ...msg,
225
+ text,
226
+ ...(content ? { content } : {}),
227
+ }
228
+ : msg);
221
229
  }
222
230
  // Reconnect path: the assistant message may not exist yet
223
231
  // (e.g. only tool_call events arrived). Append a placeholder.
@@ -228,6 +236,7 @@ export function createChatSseEventApplier(deps) {
228
236
  role: "assistant",
229
237
  text,
230
238
  createdAt: Date.now(),
239
+ ...(content ? { content } : {}),
231
240
  },
232
241
  ];
233
242
  });
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { parseAssistantContent } from "../../chat/assistant-content.js";
2
3
  import { runtimeSelectionFromRecord } from "../../chat/runtime-selection.js";
3
4
  import { hydrateSessionThread } from "../../chat/turn-process.js";
4
5
  import { ACTIVE_CHAT_SESSION_STORAGE_KEY } from "../../chat/workspace-landing.js";
@@ -301,12 +302,16 @@ export function useChatSessions(params) {
301
302
  setInput(body.session.composerDraft ?? "");
302
303
  setMessages((body.session.messages ?? [])
303
304
  .filter((message) => message.role === "user" || message.role === "assistant")
304
- .map((message) => ({
305
- id: message.id,
306
- role: message.role,
307
- text: message.text,
308
- createdAt: Date.parse(message.at) || Date.now(),
309
- })));
305
+ .map((message) => {
306
+ const content = parseAssistantContent(message.content);
307
+ return {
308
+ id: message.id,
309
+ role: message.role,
310
+ text: message.text,
311
+ createdAt: Date.parse(message.at) || Date.now(),
312
+ ...(content && content.length > 0 ? { content } : {}),
313
+ };
314
+ }));
310
315
  }
311
316
  catch {
312
317
  // Recovery is best-effort; a new session remains available.
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { parseAssistantContent } from "../../chat/assistant-content.js";
2
3
  import { buildTurnProcessGroups } from "../../chat/turn-process.js";
3
4
  import { createChatSseEventApplier, } from "./chat-sse-events.js";
4
5
  import { taskContextUnchanged } from "./format.js";
@@ -220,12 +221,16 @@ export function useChatThread(params) {
220
221
  refs.turnAssistantIdsRef.current = new Map();
221
222
  setMessages((body.session.messages ?? [])
222
223
  .filter((message) => message.role === "user" || message.role === "assistant")
223
- .map((message) => ({
224
- id: message.id,
225
- role: message.role,
226
- text: message.text,
227
- createdAt: Date.parse(message.at) || Date.now(),
228
- })));
224
+ .map((message) => {
225
+ const content = parseAssistantContent(message.content);
226
+ return {
227
+ id: message.id,
228
+ role: message.role,
229
+ text: message.text,
230
+ createdAt: Date.parse(message.at) || Date.now(),
231
+ ...(content && content.length > 0 ? { content } : {}),
232
+ };
233
+ }));
229
234
  setStreamingAssistantId(null);
230
235
  return true;
231
236
  }
@@ -6,10 +6,24 @@ import { attachRuntimeSnapshotController } from "./useRuntimeSnapshot.js";
6
6
  * IME composition guards, auto-grow and server-side draft sync. */
7
7
  export function useComposer(params) {
8
8
  const { origin, sessionId, refs, setError } = params;
9
- const [input, setInput] = useState("");
9
+ const [input, setInputState] = useState("");
10
10
  const [fileOptions, setFileOptions] = useState([]);
11
11
  const [pendingImages, setPendingImages] = useState([]);
12
12
  const [shortcutOpen, setShortcutOpen] = useState(false);
13
+ /** Any composer text that starts with `/` should reveal the palette,
14
+ * including draft restore. Escape can still close it without clearing. */
15
+ const setInput = useCallback((value) => {
16
+ if (typeof value === "function") {
17
+ setInputState((prev) => {
18
+ const next = value(prev);
19
+ setShortcutOpen(next.startsWith("/"));
20
+ return next;
21
+ });
22
+ return;
23
+ }
24
+ setInputState(value);
25
+ setShortcutOpen(value.startsWith("/"));
26
+ }, []);
13
27
  const [shortcutIndex, setShortcutIndex] = useState(0);
14
28
  const [dynamicCommands, setDynamicCommands] = useState([]);
15
29
  const [dynamicCommandsLoading, setDynamicCommandsLoading] = useState(false);
@@ -2,15 +2,11 @@ import { spawn } from "node:child_process";
2
2
  import { appendFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
3
3
  import { mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
- import { computeBinarySha256, computePackageFingerprint, findPackageRoot, isJavaScriptScript, normalizeCliVersionLabel, readPackageName, readPackageVersion, resolveBinPaths, resolveCmdShim, } from "../../shared/package-metadata.js";
5
+ import { computeBinarySha256, computePackageFingerprint, findPackageRoot, isJavaScriptScript, readPackageName, readPackageVersion, resolveBinPaths, resolveCmdShim, } from "../../shared/package-metadata.js";
6
6
  import { parseCommandJson } from "./parse-json.js";
7
7
  export const DEFAULT_WORKER_COMMAND_TIMEOUT_MS = 120_000;
8
8
  export const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000;
9
9
  export const LOOP_AGENT_PACKAGE_NAME = "@tea-agent/loop-agent";
10
- /** Grace window between the SIGTERM and SIGKILL stages of a hard timeout, so a
11
- * runner can persist terminal state (see executeDagCheckpoint) before being
12
- * force-killed. */
13
- export const HARD_TIMEOUT_KILL_GRACE_MS = 10_000;
14
10
  const MAX_BUFFERED_OUTPUT_BYTES = 64 * 1024;
15
11
  /**
16
12
  * Bind evidence supplied by an orchestration layer to the identity exposed by
@@ -182,11 +178,10 @@ export class LoopAgentClient {
182
178
  if (!this._identity) {
183
179
  throw new Error("controller identity: cannot record a reported version without a pinned identity");
184
180
  }
185
- const normalized = normalizeCliVersionLabel(reportedVersion);
186
- if (normalized !== this._identity.packageVersion) {
181
+ if (reportedVersion !== this._identity.packageVersion) {
187
182
  throw new Error(`controller identity: reported version ${reportedVersion} does not match package version ${this._identity.packageVersion}`);
188
183
  }
189
- this._identity = { ...this._identity, reportedVersion: normalized };
184
+ this._identity = { ...this._identity, reportedVersion };
190
185
  return this._identity;
191
186
  }
192
187
  async run(args, options) {
@@ -386,17 +381,10 @@ function spawnCommand(input) {
386
381
  });
387
382
  };
388
383
  let hardTimeout;
389
- let killGraceTimeout;
390
384
  if (input.hardTimeoutMs !== undefined && input.hardTimeoutMs > 0) {
391
385
  hardTimeout = setTimeout(() => {
392
386
  timedOut = true;
393
- // Two-stage termination: SIGTERM first so the runner can persist a
394
- // terminal state (see executeDagCheckpoint signal handler), then
395
- // SIGKILL only after the grace window expires.
396
387
  child.kill("SIGTERM");
397
- killGraceTimeout = setTimeout(() => {
398
- child.kill("SIGKILL");
399
- }, HARD_TIMEOUT_KILL_GRACE_MS);
400
388
  }, input.hardTimeoutMs);
401
389
  }
402
390
  let heartbeatTimer;
@@ -406,8 +394,6 @@ function spawnCommand(input) {
406
394
  const clearTimers = () => {
407
395
  if (hardTimeout !== undefined)
408
396
  clearTimeout(hardTimeout);
409
- if (killGraceTimeout !== undefined)
410
- clearTimeout(killGraceTimeout);
411
397
  if (heartbeatTimer !== undefined) {
412
398
  clearInterval(heartbeatTimer);
413
399
  heartbeatTimer = undefined;
@@ -1491,7 +1491,6 @@ function mergeDagRun(existing, incoming) {
1491
1491
  failureCategory: incoming.failureCategory ?? existing.failureCategory,
1492
1492
  stateConsistent: incoming.stateConsistent ?? existing.stateConsistent,
1493
1493
  recoveryEligibility: incoming.recoveryEligibility ?? existing.recoveryEligibility,
1494
- frontendRecovery: incoming.frontendRecovery ?? existing.frontendRecovery,
1495
1494
  title: incoming.title ?? existing.title,
1496
1495
  startedAt,
1497
1496
  finishedAt,
@@ -1791,23 +1790,6 @@ async function loadFrontendLintProjection(runDir) {
1791
1790
  blockingReasons: parsed.data.blockingReasons,
1792
1791
  };
1793
1792
  }
1794
- function projectFrontendRecovery(state) {
1795
- const s = state.frontendRecoveryState;
1796
- const r = state.frontendRecoveryResult;
1797
- if (!s && !r)
1798
- return undefined;
1799
- return {
1800
- ...(s?.recoveryRootRunId ? { recoveryRootRunId: s.recoveryRootRunId } : {}),
1801
- ...(s?.parentRunId ? { parentRunId: s.parentRunId } : {}),
1802
- ...(s?.childRunId ? { childRunId: s.childRunId } : {}),
1803
- ...(s?.attemptIndex !== undefined ? { attemptIndex: s.attemptIndex } : {}),
1804
- ...(s?.continuationCount !== undefined
1805
- ? { continuationCount: s.continuationCount }
1806
- : {}),
1807
- ...(s?.phase ? { phase: s.phase } : {}),
1808
- ...(r?.outcome ? { outcome: r.outcome } : {}),
1809
- };
1810
- }
1811
1793
  async function parseDagStateFile(statePath, now) {
1812
1794
  try {
1813
1795
  if (!existsSync(statePath))
@@ -1878,7 +1860,6 @@ async function parseDagStateFile(statePath, now) {
1878
1860
  loadBackendTestProjection(runDir, nodes),
1879
1861
  loadFrontendLintProjection(runDir),
1880
1862
  ]);
1881
- const frontendRecovery = projectFrontendRecovery(state);
1882
1863
  const continuationRecord = state.continuation &&
1883
1864
  typeof state.continuation.parentRunId === "string" &&
1884
1865
  typeof state.continuation.effectiveFromNodeId === "string" &&
@@ -1937,7 +1918,6 @@ async function parseDagStateFile(statePath, now) {
1937
1918
  ...(continuation ? { continuation } : {}),
1938
1919
  ...(backendTest ? { backendTest } : {}),
1939
1920
  ...(frontendLint ? { frontendLint } : {}),
1940
- ...(frontendRecovery ? { frontendRecovery } : {}),
1941
1921
  };
1942
1922
  }
1943
1923
  catch {
@@ -359,11 +359,16 @@ export async function extractSpecEvidence(repoRoot, dagRunId, nodeId) {
359
359
  if (status === "no-evidence") {
360
360
  summaryLines.push("未观察到任何规范证据:无 skill 注入、无文件读取、无检索操作。");
361
361
  }
362
- // Openspec directory reads/searches remain in the payload for API consumers
363
- // (tests and downstream projections); the inspector no longer displays them
364
- // separately from the generic spec reads/searches.
362
+ // Separate openspec spec directory reads and searches for Dashboard display.
363
+ // Knowledge base and openspec are parallel sources.
365
364
  const openspecReads = specReads.filter((r) => isOpenspecPath(r.path));
366
365
  const openspecSearches = searches.filter((s) => isOpenspecSearch(s.query, s.path));
366
+ if (openspecReads.length > 0) {
367
+ summaryLines.push(`openspec 已读取 ${openspecReads.length} 个文件:${openspecReads.map((r) => r.path).join("、")}`);
368
+ }
369
+ if (openspecSearches.length > 0) {
370
+ summaryLines.push(`openspec 检索 ${openspecSearches.length} 次。`);
371
+ }
367
372
  return {
368
373
  dagRunId,
369
374
  nodeId,
@@ -360,9 +360,15 @@ async function renderSpecEvidence(content, dagRunId, nodeId, cachedEvidence) {
360
360
  evidence.sourceBinding?.requirementIds,
361
361
  (id) => el("li", null, id),
362
362
  );
363
+ const openspecReadPaths = new Set(
364
+ (evidence.openspecReads ?? []).map((entry) => entry.path),
365
+ );
366
+ const otherSpecReads = (evidence.specReads ?? []).filter(
367
+ (entry) => !openspecReadPaths.has(entry.path),
368
+ );
363
369
  appendListSection(
364
- `规范已读取(${evidence.specReads?.length ?? 0})`,
365
- evidence.specReads,
370
+ `其他已读取规范文件(${otherSpecReads.length})`,
371
+ otherSpecReads,
366
372
  (read) => {
367
373
  const item = el("li", null);
368
374
  const button = document.createElement("button");
@@ -374,7 +380,7 @@ async function renderSpecEvidence(content, dagRunId, nodeId, cachedEvidence) {
374
380
  button.setAttribute("data-read-at", read.timestamp);
375
381
  }
376
382
  button.id = `spec-evidence-btn-read-${read.path}`;
377
- const icon = el("i", "ri-book-open-line");
383
+ const icon = el("i", "ri-file-text-line");
378
384
  icon.setAttribute("aria-hidden", "true");
379
385
  button.append(icon, el("code", null, read.path));
380
386
  if (read.timestamp) {
@@ -399,10 +405,9 @@ async function renderSpecEvidence(content, dagRunId, nodeId, cachedEvidence) {
399
405
  item.appendChild(button);
400
406
  return item;
401
407
  },
402
- "unavailable:未观察到规范读取。",
403
408
  );
404
409
  appendListSection(
405
- `规范检索(${evidence.specSearches?.length ?? 0})`,
410
+ `检索操作(${evidence.specSearches?.length ?? 0})`,
406
411
  evidence.specSearches,
407
412
  (search) => {
408
413
  const item = el("li", null);
@@ -414,7 +419,6 @@ async function renderSpecEvidence(content, dagRunId, nodeId, cachedEvidence) {
414
419
  );
415
420
  return item;
416
421
  },
417
- "unavailable:未观察到规范检索。",
418
422
  );
419
423
  appendListSection(
420
424
  `知识库查询(${evidence.knowledgeBaseQueries?.length ?? 0})`,
@@ -429,6 +433,67 @@ async function renderSpecEvidence(content, dagRunId, nodeId, cachedEvidence) {
429
433
  "unavailable:未观察到知识库查询;connector 可能未配置或未调用。",
430
434
  );
431
435
 
436
+ // openspec reads are displayed independently from skill/docs reads.
437
+ appendListSection(
438
+ `openspec 已读取(${evidence.openspecReads?.length ?? 0})`,
439
+ evidence.openspecReads,
440
+ (read) => {
441
+ const item = el("li", null);
442
+ const button = document.createElement("button");
443
+ button.type = "button";
444
+ button.className = "spec-evidence-file-btn";
445
+ button.setAttribute("data-source", "read");
446
+ button.setAttribute("data-path", read.path);
447
+ if (read.timestamp) {
448
+ button.setAttribute("data-read-at", read.timestamp);
449
+ }
450
+ button.id = `spec-evidence-btn-read-${read.path}`;
451
+ const icon = el("i", "ri-book-open-line");
452
+ icon.setAttribute("aria-hidden", "true");
453
+ button.append(icon, el("code", null, read.path));
454
+ if (read.timestamp) {
455
+ button.appendChild(
456
+ el(
457
+ "span",
458
+ "spec-evidence-time",
459
+ formatSessionEventTime({ timestamp: read.timestamp }),
460
+ ),
461
+ );
462
+ }
463
+ button.addEventListener("click", () =>
464
+ onSpecEvidenceFileClick(
465
+ content,
466
+ dagRunId,
467
+ nodeId,
468
+ "read",
469
+ read.path,
470
+ button,
471
+ ),
472
+ );
473
+ item.appendChild(button);
474
+ return item;
475
+ },
476
+ evidence.openspecSearches?.length
477
+ ? "已检索但未观察到 openspec 成功读取;若生成期存在候选,预写门禁会阻断 writer。"
478
+ : "unavailable:未观察到 openspec 成功读取。",
479
+ );
480
+
481
+ appendListSection(
482
+ `openspec 检索(${evidence.openspecSearches?.length ?? 0})`,
483
+ evidence.openspecSearches,
484
+ (search) => {
485
+ const item = el("li", null);
486
+ const icon = el("i", "ri-search-line");
487
+ icon.setAttribute("aria-hidden", "true");
488
+ item.append(
489
+ icon,
490
+ document.createTextNode(` ${search.tool}: ${search.query}`),
491
+ );
492
+ return item;
493
+ },
494
+ "unavailable:未观察到 openspec 检索。",
495
+ );
496
+
432
497
  if (
433
498
  evidence.status === "source-bound" ||
434
499
  evidence.status === "spec-injected" ||
@@ -1,6 +1,5 @@
1
1
  import { access } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { normalizeCliVersionLabel } from "../shared/package-metadata.js";
4
3
  import { controllerIdentityExpectationFailure, resolveControllerIdentity, } from "./loop-agent/loop-agent-client.js";
5
4
  export const DEFAULT_CHECK_REPO_TIMEOUT_MS = 300_000;
6
5
  export async function preflightTargetRepo(input) {
@@ -48,7 +47,7 @@ export async function preflightTargetRepo(input) {
48
47
  controllerIdentity: identity,
49
48
  };
50
49
  }
51
- const reportedVersion = normalizeCliVersionLabel(version.stdout);
50
+ const reportedVersion = version.stdout.trim();
52
51
  let controllerIdentity = identity
53
52
  ? { ...identity, reportedVersion }
54
53
  : undefined;
@@ -724,40 +724,30 @@ export function deterministicRewriteScenarioParam(input) {
724
724
  return { ok: true, source: nextSource, detail: "rewritten" };
725
725
  }
726
726
  function validatePythonSyntaxWithoutExecution(source) {
727
- // Prefer `python3` (present on modern POSIX images and macOS); fall back to
728
- // `python` for older or Windows layouts where only the unversioned shim exists.
729
- // A missing interpreter must not silently roll back an otherwise-valid
730
- // deterministic rewrite.
731
- const candidates = ["python3", "python"];
732
- let lastError;
733
- for (const interpreter of candidates) {
734
- const result = spawnSync(interpreter, ["-c", "import ast,sys; ast.parse(sys.stdin.read())"], {
735
- input: source,
736
- encoding: "utf8",
737
- env: {
738
- ...process.env,
739
- PYTHONDONTWRITEBYTECODE: "1",
740
- PYTHONUTF8: "1",
741
- },
742
- timeout: 15_000,
743
- });
744
- if (result.error) {
745
- lastError = result.error.message;
746
- continue;
747
- }
748
- if (result.status !== 0) {
749
- const detail = String(result.stderr || result.stdout || "invalid Python syntax")
750
- .trim()
751
- .replace(/\s+/g, " ")
752
- .slice(0, 500);
753
- return { ok: false, detail: `python syntax validation failed: ${detail}` };
754
- }
755
- return { ok: true, detail: "python ast.parse PASS" };
727
+ const result = spawnSync("python", ["-c", "import ast,sys; ast.parse(sys.stdin.read())"], {
728
+ input: source,
729
+ encoding: "utf8",
730
+ env: {
731
+ ...process.env,
732
+ PYTHONDONTWRITEBYTECODE: "1",
733
+ PYTHONUTF8: "1",
734
+ },
735
+ timeout: 15_000,
736
+ });
737
+ if (result.error) {
738
+ return {
739
+ ok: false,
740
+ detail: `python syntax validation unavailable: ${result.error.message}`,
741
+ };
756
742
  }
757
- return {
758
- ok: false,
759
- detail: `python syntax validation unavailable: ${lastError ?? "python/python3 not found"}`,
760
- };
743
+ if (result.status !== 0) {
744
+ const detail = String(result.stderr || result.stdout || "invalid Python syntax")
745
+ .trim()
746
+ .replace(/\s+/g, " ")
747
+ .slice(0, 500);
748
+ return { ok: false, detail: `python syntax validation failed: ${detail}` };
749
+ }
750
+ return { ok: true, detail: "python ast.parse PASS" };
761
751
  }
762
752
  export async function applyDeterministicScenarioParamRepairs(input) {
763
753
  const repairable = input.facts.entries.filter((entry) => entry.status === "MISMATCH" && entry.repairability === "repairable");
@@ -31,15 +31,7 @@ export function getNodeOutputAsJson(state, nodeId) {
31
31
  export function normalizeVerdictCandidateLine(value) {
32
32
  const trimmed = String(value).trim();
33
33
  const emphasized = trimmed.match(/^\*{1,3}\s*(VERDICT:[^*]+?)\s*\*{1,3}$/);
34
- const line = (emphasized ? emphasized[1] : trimmed).trim();
35
- // Canonicalize the colon separator and trailing sentence punctuation so
36
- // "VERDICT:pass", "VERDICT: pass", or "VERDICT: pass." match the declared
37
- // enum lines without accepting free-text verdicts. Conflicting verdicts
38
- // still canonicalize to distinct enum lines and remain fail-closed.
39
- const verdictMatch = line.match(/^VERDICT:\s*(pass|request-revision)\s*[.!?。!?]*$/i);
40
- if (verdictMatch)
41
- return `VERDICT: ${verdictMatch[1].toLowerCase()}`;
42
- return line;
34
+ return (emphasized ? emphasized[1] : trimmed).trim();
43
35
  }
44
36
  /** First line matching /^VERDICT:/ from node assistantText/stdout (markdown emphasis OK). */
45
37
  export function extractFirstVerdictLineFromNodeText(text) {