@tea-agent/loop-agent 0.35.2 → 0.35.3

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 (45) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +31 -0
  3. package/README.md +1 -1
  4. package/bin/loop-agent.js +37 -1
  5. package/dist/build-stamp.json +6 -0
  6. package/dist/cli/program.js +2 -2
  7. package/dist/executors/dag-pi-executor.js +44 -0
  8. package/dist/shared/package-metadata.js +42 -0
  9. package/dist/worker/console/chat/assistant-content.js +11 -0
  10. package/dist/worker/console/chat/pi-runtime.js +6 -2
  11. package/dist/worker/console/chat/turn-process.js +17 -9
  12. package/dist/worker/console/chat/workspace-landing.js +1 -1
  13. package/dist/worker/console/static/assets/index-DuVLjCIT.js +57 -0
  14. package/dist/worker/console/static/index.html +1 -1
  15. package/dist/worker/console/static-src/app/useRecoveryConsole.js +5 -0
  16. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +15 -3
  17. package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
  18. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +3 -0
  19. package/dist/worker/console/static-src/operator-chat/useChatThread.js +1 -0
  20. package/dist/worker/loop-agent/loop-agent-client.js +17 -3
  21. package/dist/worker/observability/read-model.js +20 -0
  22. package/dist/worker/observe/spec-evidence.js +3 -8
  23. package/dist/worker/observe/static/views/dag-inspector.js +6 -71
  24. package/dist/worker/preflight.js +2 -1
  25. package/dist/workflows/dag/backend-test-scenario-param.js +33 -23
  26. package/dist/workflows/dag/contract-output-registry.js +14 -0
  27. package/dist/workflows/dag/contract-validator-registrations.js +8 -0
  28. package/dist/workflows/dag/dynamic-runtime/shared.js +9 -1
  29. package/dist/workflows/dag/frontend-implementation-contract.js +233 -39
  30. package/dist/workflows/dag/frontend-prewrite-gate.js +364 -61
  31. package/dist/workflows/dag/frontend-recovery-plan.js +73 -0
  32. package/dist/workflows/dag/frontend-recovery-root-manifest.js +123 -0
  33. package/dist/workflows/dag/frontend-recovery-run.js +539 -0
  34. package/dist/workflows/dag/frontend-repair.js +219 -18
  35. package/dist/workflows/dag/frontend-verification-trace.js +47 -32
  36. package/dist/workflows/dag/frontend-writer-recovery.js +106 -0
  37. package/dist/workflows/dag/frontend-writer-rollback.js +821 -0
  38. package/dist/workflows/dag/init-hybrid.js +49 -24
  39. package/dist/workflows/dag/node-execution.js +89 -0
  40. package/dist/workflows/dag/recovery-recommendation.js +58 -0
  41. package/dist/workflows/dag/runner.js +245 -11
  42. package/dist/workflows/dag/scheduler.js +257 -3
  43. package/dist/workflows/dag/types.js +130 -2
  44. package/package.json +4 -3
  45. package/dist/worker/console/static/assets/index-gVHrlqI9.js +0 -56
package/AGENTS.md CHANGED
@@ -94,6 +94,8 @@ npm run build
94
94
  node bin/loop-agent.js --help
95
95
  ```
96
96
 
97
+ - 改 `src/**` 后必须先 `npm run build` 再用 `bin/loop-agent.js`:bin 内置 stale-dist 守卫(src 比 dist 新即硬失败,`--ignore-stale-dist` 逃生),`--version` 会显示构建 git SHA,发布/自测前用它确认跑的是目标改动。
98
+
97
99
  文档站变更:`npm run docs:build`。init / architecture / skill entry / pack 定向验证见 verification-matrix。
98
100
 
99
101
  ## 交接
package/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.35.3] - 2026-08-15
6
+
7
+ ### 新增
8
+
9
+ - chat as default landing + analyze-skills-first new-task flow
10
+ - guide plan verification targets to writeSet-internal files, command-level checks to static
11
+ - console recovery display — surface frontend recovery lineage + outcome
12
+ - frontend-implementation DAG phase 6 — recovery root manifest + statistics dedup
13
+ - frontend-implementation DAG phase 5 — writer partial-write rollback + child recovery
14
+ - frontend-implementation DAG phase 4 — writer transient rollback base
15
+ - frontend-implementation DAG phase 3c — scheduler activation gate + recovery trigger
16
+ - frontend-implementation DAG phase 3b — staged child-creation kernel
17
+ - frontend-implementation DAG phase 3a — recovery state/result types + terminal recovery intent
18
+ - producing-node contract self-check and run robustness hardening
19
+ - frontend-implementation DAG phase 2 — unified writer helper + structured repair assessment
20
+ - slim frontend-implementation DAG phase 1 — prewrite merge + writer admission gate
21
+
22
+ ### 修复
23
+
24
+ - 规范证据面板不再单列 openspec,统一为「规范已读取/规范检索」
25
+ - green full test suite — python3 fallback, build-stamp version normalization, DEP0205 suppression
26
+ - update candidate-continuation fixtures for failureCode (rebase semantic fix)
27
+
5
28
  ## [0.35.2] - 2026-08-15
6
29
 
7
30
  ### 重点更新
@@ -51,6 +74,14 @@
51
74
  - 修复文字录入「请参照这三个prd路径XXX」只物化一句话、引用文件从未导入的问题
52
75
  - 修复 semantic intake attempt marker 一次性失败后永不重试的问题:新的 PRD 导入会重置 marker,失败可随新证据重试
53
76
 
77
+ ## [0.35.1-beta.4] - 2026-08-15
78
+
79
+ > 观察面板规范证据不再单列 openspec:读取与检索统一为「规范已读取 / 规范检索」,空态文案同步中性化(beta,发布到 `beta` dist-tag)。
80
+
81
+ ## [0.35.1-beta.3] - 2026-08-14
82
+
83
+ > frontend-implementation DAG 优化 v9 全六阶段 + Console 展示层(beta,发布到 `beta` dist-tag)。
84
+
54
85
  ## [0.35.0] - 2026-08-13
55
86
 
56
87
  ### 重点更新
package/README.md CHANGED
@@ -110,7 +110,7 @@ agent-worker console doctor --repo .
110
110
 
111
111
  ```
112
112
 
113
- 访问 Console <http://127.0.0.1:8790/>;**Operator Chat Official** 是 Console 内的主引导交互。构建时设置 `VITE_OPERATOR_CHAT_DEFAULT_LANDING=chat` 可将 Chat 作为默认 landingflag 未开启时仍默认进入 Tasks,旧 Task Ops tab 始终保留。顶部「检视」以及 `/inspect/` 提供只读 Dashboard、DAG、时间线与 artifacts;`loop-agent …` / `agent-worker …` CLI direct path 仍是受治理 fallback。当前状态为 **code ready, dogfood pending/unmet**,不宣称已替代 openCode 或其他外部主会话。
113
+ 访问 Console <http://127.0.0.1:8790/>;**Operator Chat Official** 是 Console 内的主引导交互,默认打开「对话」工作区。构建时设置 `VITE_OPERATOR_CHAT_DEFAULT_LANDING=tasks` 可改回 TasksURL hash(`#/tasks`)与 localStorage 仍优先于默认值。顶部「检视」以及 `/inspect/` 提供只读 Dashboard、DAG、时间线与 artifacts;`loop-agent …` / `agent-worker …` CLI direct path 仍是受治理 fallback。当前状态为 **code ready, dogfood pending/unmet**,不宣称已替代 openCode 或其他外部主会话。
114
114
 
115
115
  ## 核心边界
116
116
 
package/bin/loop-agent.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, readdirSync, statSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
 
@@ -13,6 +13,42 @@ if (!existsSync(cliEntry)) {
13
13
  process.exit(1);
14
14
  }
15
15
 
16
+ /**
17
+ * Stale-dist guard: when running from a source checkout (src/ present), refuse
18
+ * to silently execute a dist/ that predates the latest source edit. The
19
+ * classic failure mode is editing src/** and running the bin without
20
+ * `npm run build`, which executes the previous build. Suppress with
21
+ * --ignore-stale-dist when intentionally running a stale build.
22
+ */
23
+ const srcDir = join(packageRoot, "src");
24
+ const ignoreStale = process.argv.includes("--ignore-stale-dist");
25
+ if (ignoreStale) {
26
+ process.argv = process.argv.filter((arg) => arg !== "--ignore-stale-dist");
27
+ }
28
+ if (!ignoreStale && existsSync(srcDir)) {
29
+ const distCliMtime = statSync(cliEntry).mtimeMs;
30
+ let newestSrcMtime = 0;
31
+ const walk = (dir) => {
32
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
33
+ const full = join(dir, entry.name);
34
+ if (entry.isDirectory()) {
35
+ walk(full);
36
+ } else {
37
+ const mtime = statSync(full).mtimeMs;
38
+ if (mtime > newestSrcMtime) newestSrcMtime = mtime;
39
+ }
40
+ }
41
+ };
42
+ walk(srcDir);
43
+ if (newestSrcMtime > distCliMtime) {
44
+ console.error(
45
+ `loop-agent: dist/ is stale relative to src/ (a source file is newer than ${cliEntry}).\n` +
46
+ ` Run \`npm run build\` before using the bin, or pass --ignore-stale-dist to run the stale build intentionally.`,
47
+ );
48
+ process.exit(1);
49
+ }
50
+ }
51
+
16
52
  try {
17
53
  await import(pathToFileURL(cliEntry).href);
18
54
  } catch (error) {
@@ -0,0 +1,6 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "version": "0.35.3",
4
+ "gitSha": "71e2505bbabe052302d753f25b4971e0bdb0f953",
5
+ "builtAt": "2026-08-15T11:42:48.937Z"
6
+ }
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { Command } from "commander";
4
- import { readPackageVersion } from "../shared/package-metadata.js";
4
+ import { readCliVersionLabel } from "../shared/package-metadata.js";
5
5
  import { resolveAdapter } from "../adapters/index.js";
6
6
  import { runCoverageAudit } from "../commands/coverage-audit.js";
7
7
  import { runCoverageReport } from "../commands/coverage-report.js";
@@ -547,7 +547,7 @@ export function buildLoopAgentProgram(options) {
547
547
  program
548
548
  .name("loop-agent")
549
549
  .description("Loop Agent task and DAG automation CLI")
550
- .version(readPackageVersion(path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))))) ?? "0.0.0", "-V, --version", "display version")
550
+ .version(readCliVersionLabel(path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))))) ?? "0.0.0", "-V, --version", "display version")
551
551
  .showHelpAfterError()
552
552
  .option("--repo-root <path>", "repository root")
553
553
  .option("--adapter <name>", "harness adapter name")
@@ -520,6 +520,33 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
520
520
  }
521
521
  let result;
522
522
  try {
523
+ // Phase 5 (P1-9): capture the writeSet baseline BEFORE the writer provider
524
+ // call so a transient partial write can be rolled back and recovered via a
525
+ // staged child. Only frontend-implementation writers; a failed capture is a
526
+ // hard fail (no baseline → no rollback → no recovery). Dynamic import avoids
527
+ // an executor ↔ scheduler static cycle.
528
+ if (isWriteTask &&
529
+ (input.task.id === "frontend-implement-pi" ||
530
+ input.task.id === "frontend-repair-pi") &&
531
+ (input.task.writeSet?.length ?? 0) > 0) {
532
+ try {
533
+ const { captureFrontendWriterAttemptIntent } = await import("../workflows/dag/frontend-writer-recovery.js");
534
+ await captureFrontendWriterAttemptIntent({
535
+ cwd: input.cwd,
536
+ runDir: meta.runDir,
537
+ writeSet: input.task.writeSet,
538
+ });
539
+ }
540
+ catch (error) {
541
+ return {
542
+ ok: false,
543
+ stdout: "",
544
+ stderr: `frontend writer attempt baseline capture failed: ${error instanceof Error ? error.message : String(error)}`,
545
+ failureCategory: "write-guard",
546
+ durationMs: Date.now() - started,
547
+ };
548
+ }
549
+ }
523
550
  result = await piStepFn({
524
551
  attachedFiles: [],
525
552
  modelConfig: resolveDagPiModelConfig(input.model, input.thinking ? { thinking: input.thinking } : undefined),
@@ -691,6 +718,23 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
691
718
  const changedFiles = pathsChangedDuringRun(snapshotGitStatusPorcelain(beforeStatus), afterSnapshot, beforePathFingerprints, afterPathFingerprints);
692
719
  changeManifestAfterStatus = afterStatus;
693
720
  changeManifestChangedFiles = changedFiles;
721
+ // Phase 5: record the attempt changed paths/hashes into the rollback
722
+ // intent so a transient partial write can be CAS-restored.
723
+ if ((input.task.id === "frontend-implement-pi" ||
724
+ input.task.id === "frontend-repair-pi") &&
725
+ (input.task.writeSet?.length ?? 0) > 0) {
726
+ try {
727
+ const { recordFrontendWriterAttempt } = await import("../workflows/dag/frontend-writer-recovery.js");
728
+ await recordFrontendWriterAttempt({
729
+ cwd: input.cwd,
730
+ runDir: meta.runDir,
731
+ changedPaths: changedFiles,
732
+ });
733
+ }
734
+ catch {
735
+ // best-effort: a missing intent (no baseline captured) is a no-op
736
+ }
737
+ }
694
738
  const guard = validateShellWriteGuardFromDiff({
695
739
  changedFiles,
696
740
  task: input.task,
@@ -45,6 +45,48 @@ export function readPackageName(packageDir) {
45
45
  }
46
46
  return undefined;
47
47
  }
48
+ /**
49
+ * Read the build stamp (git SHA + build time) written by the build pipeline
50
+ * into `<packageDir>/dist/build-stamp.json`. `undefined` when absent (e.g.
51
+ * running from source via tsx, or a hand-assembled package), which callers
52
+ * treat as "no stamp available".
53
+ */
54
+ export function readBuildStamp(packageDir) {
55
+ try {
56
+ const raw = readFileSync(path.join(packageDir, "dist", "build-stamp.json"), "utf-8");
57
+ const parsed = JSON.parse(raw);
58
+ if (parsed.schemaVersion !== 1 ||
59
+ typeof parsed.version !== "string" ||
60
+ typeof parsed.builtAt !== "string")
61
+ return undefined;
62
+ return parsed;
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ }
68
+ /**
69
+ * Human-readable CLI version label: plain version, enriched with the build
70
+ * stamp (short git SHA + build date) when available so a stale dist/ that was
71
+ * built from an old commit is immediately recognizable in `--version`.
72
+ */
73
+ export function readCliVersionLabel(packageDir) {
74
+ const version = readPackageVersion(packageDir) ?? "0.0.0";
75
+ const stamp = readBuildStamp(packageDir);
76
+ if (!stamp?.gitSha)
77
+ return version;
78
+ return `${version} (build ${stamp.gitSha.slice(0, 8)} ${stamp.builtAt.slice(0, 10)})`;
79
+ }
80
+ /**
81
+ * Strip the build-stamp suffix from a CLI `--version` label, returning the bare
82
+ * SemVer. Controller-identity/preflight comparisons must use this rather than
83
+ * the raw label so a build-stamped `0.35.0 (build 7f8c5b5b …)` still matches
84
+ * `packageVersion === "0.35.0"`.
85
+ */
86
+ export function normalizeCliVersionLabel(label) {
87
+ const match = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/.exec(label.trim());
88
+ return match ? match[0] : label.trim();
89
+ }
48
90
  /**
49
91
  * Compute the SHA-256 hash of a file on disk.
50
92
  *
@@ -64,6 +64,17 @@ export function extractAssistantContentBlocks(message) {
64
64
  const top = readString(rec.text);
65
65
  return top ? [{ type: "text", text: top }] : [];
66
66
  }
67
+ /**
68
+ * Pi emits message_end for user / toolResult / custom too. Only assistant
69
+ * payloads belong in the Operator Chat assistant bubble (pi-web same split).
70
+ */
71
+ export function isAssistantSdkMessage(message) {
72
+ const rec = asRecord(message);
73
+ if (!rec)
74
+ return false;
75
+ const role = readString(rec.role);
76
+ return role === undefined || role === "assistant";
77
+ }
67
78
  /**
68
79
  * Latest assistant answer text (text blocks only). Thinking is excluded.
69
80
  * Preserves the historical `{ text }` / `{ parts: [{ text }] }` fallbacks.
@@ -28,7 +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
+ import { extractAssistantContentBlocks, extractAssistantText, isAssistantSdkMessage, } from "./assistant-content.js";
32
32
  import { projectCompactSnapshot, } from "./chat-event-store.js";
33
33
  import { extractUsageSample } from "./usage.js";
34
34
  import { OPERATOR_CHAT_ALLOWED_TOOLS, authorizeOperatorChatTool, assertNoWriteToolInList, } from "./tools.js";
@@ -564,7 +564,7 @@ export function projectRuntimeSnapshot(input) {
564
564
  hasBash: input.hasBash,
565
565
  };
566
566
  }
567
- export { extractAssistantContentBlocks, extractAssistantText };
567
+ export { extractAssistantContentBlocks, extractAssistantText, isAssistantSdkMessage };
568
568
  function readSessionManagerId(manager) {
569
569
  if (!manager || typeof manager !== "object")
570
570
  return undefined;
@@ -2116,8 +2116,12 @@ function mapSdkEvent(sessionId, event) {
2116
2116
  willRetry: Boolean(event.willRetry),
2117
2117
  };
2118
2118
  case "message_update":
2119
+ if (!isAssistantSdkMessage(event.message))
2120
+ return undefined;
2119
2121
  return mapAssistantMessageEvent("message_update", sessionId, event.message);
2120
2122
  case "message_end":
2123
+ if (!isAssistantSdkMessage(event.message))
2124
+ return undefined;
2121
2125
  return mapAssistantMessageEvent("message_end", sessionId, event.message);
2122
2126
  case "turn_end":
2123
2127
  // Usage is emitted from message_end (see prompt subscribe). turn_end
@@ -1,7 +1,7 @@
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";
4
+ import { getDisplayableAssistantBlocks, joinAssistantText, parseAssistantContent, splitFinalAssistantBlocks, } from "./assistant-content.js";
5
5
  function emptyGroup(key) {
6
6
  return {
7
7
  key,
@@ -76,19 +76,15 @@ export function hydrateSessionThread(raw) {
76
76
  }
77
77
  return { messages, toolCalls };
78
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) {
79
+ function emitProcessBlocks(blocks, group, processItems, usedToolIds) {
80
+ for (const block of blocks) {
86
81
  if (block.type === "thinking") {
87
82
  processItems.push({ kind: "thinking", thinking: block.thinking });
88
83
  continue;
89
84
  }
90
85
  if (block.type === "text") {
91
- processItems.push({ kind: "text", text: block.text });
86
+ if (block.text.trim())
87
+ processItems.push({ kind: "text", text: block.text });
92
88
  continue;
93
89
  }
94
90
  const tool = group.tools.find((entry) => entry.id === block.toolCallId);
@@ -97,6 +93,18 @@ function fillProcessDetails(group) {
97
93
  usedToolIds.add(tool.id);
98
94
  }
99
95
  }
96
+ }
97
+ function fillProcessDetails(group) {
98
+ const usedToolIds = new Set();
99
+ const processItems = [];
100
+ const prior = group.assistants.slice(0, -1);
101
+ const finalAssistant = group.assistants[group.assistants.length - 1];
102
+ for (const assistant of prior) {
103
+ emitProcessBlocks(getDisplayableAssistantBlocks(assistant.content ?? []), group, processItems, usedToolIds);
104
+ }
105
+ const blocks = finalAssistant?.content ?? [];
106
+ const split = splitFinalAssistantBlocks(blocks, { isStreaming: group.live });
107
+ emitProcessBlocks(split.processBlocks, group, processItems, usedToolIds);
100
108
  for (const tool of group.tools) {
101
109
  if (!usedToolIds.has(tool.id))
102
110
  processItems.push({ kind: "tool", tool });
@@ -78,7 +78,7 @@ export function resolveDefaultLanding(input) {
78
78
  return (workspaceFromLocation(input.location) ??
79
79
  configuredLanding(input.storage?.getItem(DEFAULT_LANDING_STORAGE_KEY)) ??
80
80
  configuredLanding(input.env) ??
81
- "tasks");
81
+ "chat");
82
82
  }
83
83
  /** Hash path segment for Console virtual routing (no leading `#`). */
84
84
  export function buildConsoleHashPath(workspace, params = {}) {