@tea-agent/loop-agent 0.24.4 → 0.24.6

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 (31) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +47 -0
  3. package/README.md +5 -2
  4. package/dist/application/dag/generate-task-dag.js +5 -8
  5. package/dist/commands/init.js +19 -5
  6. package/dist/executors/shell-executor.js +10 -2
  7. package/dist/task/task-demand-routing.js +27 -14
  8. package/dist/worker/cli.js +59 -14
  9. package/dist/worker/observe/dag-run-artifacts.js +90 -0
  10. package/dist/worker/observe/node-input.js +444 -0
  11. package/dist/worker/observe/routes.js +17 -0
  12. package/dist/worker/observe/static/api.js +9 -0
  13. package/dist/worker/observe/static/constants.js +9 -0
  14. package/dist/worker/observe/static/state.js +14 -0
  15. package/dist/worker/observe/static/styles.css +74 -0
  16. package/dist/worker/observe/static/views/dag-inspector.js +371 -15
  17. package/dist/workflows/dag/backend-test-markdown-workflow.js +75 -9
  18. package/dist/workflows/dag/backend-test-result-contract.js +103 -0
  19. package/dist/workflows/dag/init-hybrid.js +9 -8
  20. package/dist/workflows/dag/node-execution.js +3 -2
  21. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +3 -2
  22. package/docs/templates/backend-test-dag.json +7 -7
  23. package/docs/templates/backend-test-dag.review-cases.prompt.md +1 -1
  24. package/docs/templates/evaluation/agents-map-slim-v1.md +1 -1
  25. package/docs/templates/evaluation/agents-map-verbose-v0.md +2 -2
  26. package/harness.json +1 -1
  27. package/package.json +1 -1
  28. package/skills/agent-worker/SKILL.md +1 -1
  29. package/skills/loop-agent/SKILL.md +1 -1
  30. package/skills/loop-agent/references/command-reference.md +3 -2
  31. package/skills/loop-agent/references/hybrid-dag.md +2 -2
package/AGENTS.md CHANGED
@@ -37,7 +37,7 @@
37
37
  4. 涉及测试纪律/验证声明/调试时继续读:`docs/governance/harness-methodology-*.md`。
38
38
  5. 查看最近提交、相关 plan/progress/report;`git status --short --branch`;跑最小基线验证。
39
39
  6. 后端/接口/pytest → `taskKind: "backend-test"`(不是 `--profile`);知识回写 `knowledge-sync`;图谱开荒 `knowledge-graph-bootstrap`。`--profile` 仅 `auto|minimal|standard|reviewed|supervised`。
40
- 7. 看板/observe → `agent-worker console serve --repo . --port 8790`(`/inspect/` 只读);`observe serve` 仅为兼容入口。
40
+ 7. 看板/observe → `agent-worker console`(默认 repo=当前目录、port=8790;兼容入口 `agent-worker console serve --repo . --port 8790`)(`/inspect/` 只读);`observe serve` 仅为兼容入口。
41
41
  8. 分支合并 → 先读 `docs/operations/branch-merge-guideline.md`。
42
42
 
43
43
  ## 会话协议
package/CHANGELOG.md CHANGED
@@ -2,6 +2,52 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ### 新增
6
+
7
+ - Observe / Inspect 节点检查器在「节点输出」左侧新增「节点输入」页签:只读投影冻结 `run.json` 顶层节点定义(任务正文、依赖、执行器摘要、边界)与可选 assembled prompt 指纹;完整 assembled prompt 与动态展开子节点仍不在本轮范围
8
+
9
+ ## [0.24.6] - 2026-07-29
10
+
11
+ ### 重点更新
12
+
13
+ - 修复后端测试 Markdown-first 成功路径未投影 `backend-test-result` 的问题,Ready Planner 可再次放行 FE-TEST
14
+
15
+ ### 修复
16
+
17
+ - Markdown-first `markdown-execute-html` 在解析原生 pytest-html 后立即物化 `contracts/backend-test-result.json`(Result v1),避免 styled HTML 覆盖后丢失 JSON island
18
+ - `node-execution` 对 `markdown-execute-html` 绑定 `structuredArtifact*`,Outcome 适配器可投影 `kind: backend-test-result`
19
+
20
+ ## [0.24.5] - 2026-07-28
21
+
22
+ ### 重点更新
23
+
24
+ - 后端测试 Markdown 模块与 pytest 脚本实现一对一精准映射,提升测试执行的确定性
25
+ - 修复后端测试 HTML 报告中请求参数显示为空的问题,正确展示请求与响应日志
26
+ - 初始化项目支持将「帮我完成/实现/修复」等通用需求表达直接路由进 Agent DAG
27
+ - 新增裸 agent-worker console 入口,简化本地看板启动流程
28
+
29
+ ### 新增
30
+
31
+ - 初始化后的目标项目支持将「帮我完成/实现/修复/开发」等通用需求表达确定性地路由进 Agent DAG(new-task → dag run-task → dag validate → run-dag),无需追加额外的 cli 关键词
32
+ - 新增裸 agent-worker console 入口,直接启动 Loop Operator Console,repo 默认当前目录、port 默认 8790
33
+ - check-skill-entry.sh 及初始化投影脚本支持解析 YAML 折叠块,对通用需求触发词建立机器门禁以防漂移
34
+
35
+ ### 改进
36
+
37
+ - 后端测试收敛模块映射,生成与评审 prompt 强制模块文件一对一映射,在映射缺失时支持确定性恢复
38
+ - 后端测试 execute/report 节点文案与 pytest-html 真源对齐,移除残留的 JUnit 表述
39
+ - 后端测试 HTML 报告对齐 R04 模板样式,采用全 inline style 设计(渐变 banner、可折叠用例卡片、状态胶囊及请求/响应分块展示)
40
+ - 本地看板启动更简,端口被占用时不再自动更换,改为明确提示使用 --port 显式指定其它端口
41
+ - 旧项目经 init check-update 与 apply-safe 即可安全补回新的「通用需求实现」路由行,并保留用户自定义前言
42
+
43
+ ### 修复
44
+
45
+ - 修复后端测试 HTML 报告中请求参数显示为空的问题:渲染器现已兼容 payload= 键或 JSON request_record 格式的请求摘要
46
+ - 修复后端测试报告无法正确获取请求/响应日志的问题:兼容 HTTP_REQUEST 与 HTTP REQUEST 两种日志前缀,并在解析前正确解码 HTML 实体
47
+ - 修复 shell-executor 在 Windows 环境下因反斜杠路径导致 runDir 损坏的问题,确保路径注入稳定
48
+ - 修复前端 lint baseline 在 Windows 下因路径解析逻辑缺陷,拒绝相对路径输出导致评估误报为不可用的问题
49
+ - 修复多个测试固件在 Windows 下因嵌套 Shell 引号剥离导致命令执行输出为空或报错的问题(涉及 composite-shell 与 init-command)
50
+
5
51
  ## [0.24.4] - 2026-07-28
6
52
 
7
53
  ### 重点更新
@@ -26,6 +72,7 @@
26
72
  ### 重点更新
27
73
 
28
74
  - 发布工作流改为发布已通过完整门禁并经 SHA-256 校验的 npm tarball,避免 `npm publish` 再次触发全量测试、类型检查与构建。
75
+ - 默认 `standard` 实现任务在 React、Next.js 或 Vue 项目中直接选择 `frontend-implementation` DAG,不再依赖需求关键词;明确后端、文档/测试范围或前端否定仍保留对应通用流程。
29
76
 
30
77
  ## [0.24.2] - 2026-07-28
31
78
 
package/README.md CHANGED
@@ -52,6 +52,8 @@ loop-agent init reconcile --repo-root .
52
52
 
53
53
  ### 运行第一个任务
54
54
 
55
+ 初始化后,在已初始化的目标项目中你可以直接对宿主 agent 说「**loop-agent 帮我完成 XXX 需求**」(或「帮我实现 / 帮我修复 / 帮我开发 XXX」「使用 loop-agent 完成 XXX」「按 loop-agent 流程处理 XXX」),初始化写入的 `AGENTS.md` 与 `.agents/skills/loop-agent/` 会把这类通用需求表达确定性地路由进 Agent DAG,**无需**追加额外的 `cli` 关键词。主会话只负责编排与验证,业务实现由受治理 DAG writer 完成。
56
+
55
57
  ```bash
56
58
  loop-agent new-task example-task "实现一个有明确验收标准的小功能"
57
59
  ```
@@ -90,12 +92,13 @@ loop-agent dag report --run-id <run-id>
90
92
  | 入口 | 命令 | 角色 |
91
93
  | --- | --- | --- |
92
94
  | 受治理 CLI | `loop-agent …` / `agent-worker …` | 权威写入与诊断 |
93
- | Loop Operator Console | `agent-worker console serve --repo .` | Official 本地控制面(默认 `127.0.0.1:8790`,含 Operate 与 Inspect) |
95
+ | Loop Operator Console | `agent-worker console` | Official 本地控制面(默认 repo=当前目录、`127.0.0.1:8790`,含 Operate 与 Inspect) |
94
96
  | Observe | `agent-worker observe serve --repo . --port 8787` | 兼容期独立只读入口;不再作为推荐路径 |
95
97
 
96
98
  ```bash
97
99
  # 终端 A:Console(任务/运行操作面;canonical mutation 只经 sibling loop-agent)
98
- agent-worker console serve --repo .
100
+ agent-worker console # 默认 repo=当前目录、port=8790
101
+ agent-worker console serve --repo . # 兼容入口,等价于裸入口
99
102
  agent-worker console doctor --repo .
100
103
 
101
104
  ```
@@ -239,10 +239,8 @@ export async function generateTaskDagUseCase(input) {
239
239
  reasons: ["dag run-task validate did not report governanceProfile"],
240
240
  });
241
241
  const hasExplicitSpecializedTaskKind = candidateResult.templateSelection.source === "taskKind";
242
- const hasSafeAutomaticTaskSourceRoute = candidateResult.templateSelection.source === "task-source" &&
243
- profileRouting.selectedByProfile !== "supervised" &&
244
- profileRouting.selectedTemplate !== "supervised-implementation";
245
- if (hasExplicitSpecializedTaskKind || hasSafeAutomaticTaskSourceRoute) {
242
+ const hasAutomaticTaskSourceRoute = candidateResult.templateSelection.source === "task-source";
243
+ if (hasExplicitSpecializedTaskKind || hasAutomaticTaskSourceRoute) {
246
244
  profileRouting.selectedTemplate = candidateResult.template;
247
245
  profileRouting.source = hasExplicitSpecializedTaskKind
248
246
  ? "taskKind"
@@ -256,10 +254,9 @@ export async function generateTaskDagUseCase(input) {
256
254
  profileRouting.selectedByProfile = parsed.profile;
257
255
  }
258
256
  }
259
- // Persist only a safe automatic route. Explicit supervised/profile policy
260
- // must remain authoritative and must not be weakened by this convenience
261
- // marker. Keep taskId stable; frontend-implementation is the taskKind.
262
- if (hasSafeAutomaticTaskSourceRoute && candidateResult.template === "frontend-implementation") {
257
+ // Persist the automatic business workflow route while preserving the
258
+ // governance profile as a separate routing dimension.
259
+ if (hasAutomaticTaskSourceRoute && candidateResult.template === "frontend-implementation") {
263
260
  const taskConfig = await loadTaskConfig(repoRoot, parsed.taskId);
264
261
  if (!taskConfig.taskKind || taskConfig.taskKind === "standard") {
265
262
  await writeTaskConfig(repoRoot, parsed.taskId, {
@@ -415,6 +415,18 @@ check_skill "loop-agent" \\
415
415
  "references/command-reference.md"
416
416
  check_skill "agent-worker" "references/agent-worker-operator.md"
417
417
 
418
+ # loop-agent description is a YAML folded block (>-); grep ^description: only
419
+ # captures the indicator line. Validate the general-demand trigger terms
420
+ # against the whole skill file so host auto-discovery phrasing cannot drift.
421
+ loop_agent_skill=".agents/skills/loop-agent/SKILL.md"
422
+ loop_agent_text="$(tr '[:upper:]' '[:lower:]' < "\${loop_agent_skill}")"
423
+ for term in "loop-agent 帮我完成" "帮我实现" "帮我修复" "帮我开发" "agent dag" "new-task" "dag run-task" "dag validate" "run-dag"; do
424
+ if [[ "\${loop_agent_text}" != *"\${term}"* ]]; then
425
+ echo "skill entry 检查失败(loop-agent):description 缺少触发/路由词 \${term}" >&2
426
+ exit 1
427
+ fi
428
+ done
429
+
418
430
  description_line="$(grep -E '^description:' .agents/skills/agent-worker/SKILL.md | head -n 1 | tr '[:upper:]' '[:lower:]')"
419
431
  for term in "agent-worker" "feature packet" "taskspec" "task pool" "self-host" "candidate" "loop-agent"; do
420
432
  if [[ "\${description_line}" != *"\${term}"* ]]; then
@@ -802,6 +814,7 @@ function buildManagedAgentsBlock(input) {
802
814
  "| 初始化更新校验 / 检查初始化更新 / loop-agent 初始化更新校验 / loop agent初始化更新校验 | 更新校验 | 只读报告,不写入 |",
803
815
  "| 初始化对齐 / 升级后对齐 / reconcile 初始化 / loop-agent 初始化对齐 | 升级对齐 | 自动应用确定性安全动作;活跃 DAG/Worker 或人工决策时零写入 |",
804
816
  "| 初始化安全更新 / 应用初始化更新 / loop-agent 初始化安全更新 / loop agent初始化安全更新 | 安全更新 | 先 check-update,再只执行确定性安全动作 |",
817
+ "| loop-agent 帮我完成 / 帮我实现 / 帮我修复 / 帮我开发 <需求>;使用 loop-agent 完成 <X>;按 loop-agent 流程处理 <X> | 通用需求实现 | 先建任务源与 `task.json.allowedPaths` / `forbiddenPaths`,再 `new-task` → `dag run-task` → `dag validate` → `run-dag`;主会话不得直接修改业务实现 |",
805
818
  "",
806
819
  "**更新校验(只读)**:只读执行下面命令;**不得自动**执行 `apply-safe` 或模型合并。",
807
820
  "",
@@ -861,10 +874,11 @@ function buildManagedAgentsBlock(input) {
861
874
  "### 运行看板(只读)",
862
875
  "",
863
876
  "```bash",
864
- "agent-worker console serve --repo . --port 8790",
877
+ "agent-worker console # 默认 repo=当前目录,port=8790",
878
+ "agent-worker console serve --repo . --port 8790 # 兼容入口,等价于上面裸入口",
865
879
  "```",
866
880
  "",
867
- "浏览器打开 `http://127.0.0.1:8790/`;检视面为 `http://127.0.0.1:8790/inspect/`。默认绑定本机 `127.0.0.1`;不要直接暴露到公开网络。`agent-worker observe serve` 仅为兼容入口。",
881
+ "浏览器打开 `http://127.0.0.1:8790/`;检视面为 `http://127.0.0.1:8790/inspect/`。默认绑定本机 `127.0.0.1`;不要直接暴露到公开网络。端口被占用时不会自动更换,请用 `--port <port>` 显式指定。`agent-worker observe serve` 仅为兼容入口。",
868
882
  "",
869
883
  "### DAG 诊断与收口",
870
884
  "",
@@ -2049,11 +2063,11 @@ function buildTargetFeatureWorkflow(input) {
2049
2063
  "",
2050
2064
  "## Specialized Task Kinds",
2051
2065
  "",
2052
- "- standard tasks are classified conservatively by combining the task title, `source/需求.md`, structured `allowedPaths`, strong React/Next/Vue project evidence, and user-visible interaction/state semantics.",
2053
- "- An implicit frontend demand selects the frontend DAG only when project or frontend-path evidence is paired with product-delivery semantics; framework evidence alone is insufficient. Backend, mixed, negated, documentation/test maintenance, and unknown demands keep the template selected by the normal governance profile.",
2066
+ "- standard tasks first use the structured task type in `source/需求.md`, then combine `allowedPaths` with strong React/Next/Vue project evidence for deterministic routing.",
2067
+ "- A frontend project defaults eligible implementation work to the frontend DAG without requirement keyword matching. Explicit backend, mixed, frontend-negated, and documentation/test-only scopes keep the template selected by the normal governance profile.",
2054
2068
  "- `frontend-mock-assess-pi` reads Mock/API/schema evidence and selects `native|browser-intercept|request-adapter|not-needed|blocked` before frontend planning; its deterministic gate rejects blocked or malformed output, while the existing frontend implementer remains the only writer.",
2055
2069
  "- Mock-backed verification never proves real API integration. When the backend was not exercised, closeout must retain the gap and name `<task-id>-real-api-integration-verify`; that follow-up is explicitly created/run after backend readiness, never automatic.",
2056
- "- Automatic frontend classification never replaces a supervised template selected by an explicit profile, workflowPolicy, or the supervised quality gate.",
2070
+ "- Automatic frontend classification selects the frontend implementation workflow and persists taskKind; explicit profiles, workflowPolicy, and supervised quality gates record governance strength without switching the business workflow back to a generic DAG.",
2057
2071
  "- A backend implementation does not select the backend test DAG. `backend-test` remains an explicit test-engineering workflow.",
2058
2072
  "- Explicit specialized `taskKind` values remain compatible and take precedence over task-source classification.",
2059
2073
  "",
@@ -17,7 +17,7 @@ import { formatFrontendReviewContextStdout, runFrontendReviewContextGate, } from
17
17
  import { materializeFrontendLintAssessment, materializeFrontendLintBaseline, } from "../workflows/dag/frontend-lint-baseline.js";
18
18
  import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBackendTestTraceabilityGate, } from "../workflows/dag/backend-test-case-manifest.js";
19
19
  import { materializeBackendTestExecutionContract } from "../workflows/dag/backend-test-execution-contract.js";
20
- import { materializeBackendTestResultFromRunDir, parsePytestHtmlReport } from "../workflows/dag/backend-test-result-contract.js";
20
+ import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport } from "../workflows/dag/backend-test-result-contract.js";
21
21
  import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
22
22
  import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
23
23
  import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
@@ -414,6 +414,14 @@ async function executeBackendTestPipeline(input, meta) {
414
414
  if (![0, 1].includes(pytestExitCode))
415
415
  throw new Error(`pytest did not complete with a reportable exit code: ${pytestExitCode}`);
416
416
  const parsed = parsePytestHtmlReport(pytestHtmlContent);
417
+ // Bind Result v1 from the native pytest-html report BEFORE overwriting with the
418
+ // styled renderer (which drops the data-jsonblob island).
419
+ const resultArtifact = await materializeBackendTestResultFromPytestHtml({
420
+ runDir: meta.runDir,
421
+ htmlRelativePath: "reports/backend-test.html",
422
+ htmlContent: pytestHtmlContent,
423
+ pytestExitCode,
424
+ });
417
425
  const cases = await collectBackendTestHumanCaseCatalog(input.cwd);
418
426
  const caseValidationSummary = await readRequiredRunReport(reportsDir, "backend-md-case-validation.md");
419
427
  const traceabilitySummary = await readRequiredRunReport(reportsDir, "backend-test-traceability.md");
@@ -430,7 +438,7 @@ async function executeBackendTestPipeline(input, meta) {
430
438
  const markdownPath = await writeRunReport(meta.runDir, "backend-test.md", facts);
431
439
  const factsPath = await writeRunReport(meta.runDir, "backend-test-facts.md", facts);
432
440
  const sanitizedOutputs = results.map((result) => redactBackendTestOutput(result.stdout));
433
- outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `markdown=${markdownPath}`, `facts=${factsPath}`, facts);
441
+ outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `markdown=${markdownPath}`, `facts=${factsPath}`, `result=${resultArtifact.path}`, facts);
434
442
  }
435
443
  else if (pipeline === "contracts") {
436
444
  const wrapperPath = path.join(meta.runDir, "analyze-and-discover-backend-test-pi.json");
@@ -298,6 +298,11 @@ export function classifyTaskDemand(input) {
298
298
  message: "allowedPaths include a backend-specific source path",
299
299
  });
300
300
  }
301
+ const allowedPathsOnlyCoverNonProductArtifacts = input.allowedPaths.length > 0 &&
302
+ input.allowedPaths.every((rawPath) => {
303
+ const allowedPath = normalizedAllowedPath(rawPath);
304
+ return matchesAny(allowedPath, NON_PRODUCT_ALLOWED_PATH_PATTERNS);
305
+ });
301
306
  const pathSupportedFrontendDelivery = frontendPath &&
302
307
  (titleSignals.pathSupportedFrontendDelivery ||
303
308
  requirementSignals.pathSupportedFrontendDelivery ||
@@ -328,20 +333,27 @@ export function classifyTaskDemand(input) {
328
333
  message: "task text contains user-visible interaction/state delivery and the project has strong frontend framework evidence",
329
334
  });
330
335
  }
336
+ const backendDelivery = titleSignals.backendDelivery || requirementSignals.backendDelivery;
337
+ const frontendProjectDefaultImplementation = hasStrongFrontendProjectEvidence &&
338
+ !backendDelivery &&
339
+ !frontendNegated &&
340
+ !allowedPathsOnlyCoverNonProductArtifacts;
341
+ if (frontendProjectDefaultImplementation) {
342
+ addSignal(signals, {
343
+ id: "frontend-project-default-implementation",
344
+ source: "project",
345
+ message: "frontend project evidence selects the frontend implementation workflow by default",
346
+ });
347
+ }
331
348
  const frontendDelivery = titleSignals.frontendDelivery ||
332
349
  requirementSignals.frontendDelivery ||
333
350
  pathSupportedFrontendDelivery ||
334
- projectSupportedFrontendDelivery;
335
- const allowedPathsOnlyCoverNonProductArtifacts = input.allowedPaths.length > 0 &&
336
- input.allowedPaths.every((rawPath) => {
337
- const allowedPath = normalizedAllowedPath(rawPath);
338
- return matchesAny(allowedPath, NON_PRODUCT_ALLOWED_PATH_PATTERNS);
339
- });
351
+ projectSupportedFrontendDelivery ||
352
+ frontendProjectDefaultImplementation;
340
353
  if (allowedPathsOnlyCoverNonProductArtifacts && frontendDelivery) {
341
354
  blockers.push("non-product-allowed-paths");
342
355
  }
343
356
  const eligibleFrontendDelivery = frontendDelivery && !allowedPathsOnlyCoverNonProductArtifacts;
344
- const backendDelivery = titleSignals.backendDelivery || requirementSignals.backendDelivery;
345
357
  const taskTypeConflict = (hasFrontendTaskType && (backendDelivery || frontendNegated)) ||
346
358
  (hasBackendTaskType && eligibleFrontendDelivery);
347
359
  if (taskTypeConflict)
@@ -402,13 +414,6 @@ export function resolveTaskDagTemplateSelection(input) {
402
414
  reasons: [],
403
415
  };
404
416
  }
405
- if (requestedTemplate !== "standard-dag") {
406
- return {
407
- template: requestedTemplate,
408
- source: "requested-template",
409
- reasons: [],
410
- };
411
- }
412
417
  const classification = classifyTaskDemand(input);
413
418
  if (classification.kind === "frontend") {
414
419
  const frontendSignals = classification.signals
@@ -423,6 +428,14 @@ export function resolveTaskDagTemplateSelection(input) {
423
428
  classification,
424
429
  };
425
430
  }
431
+ if (requestedTemplate !== "standard-dag") {
432
+ return {
433
+ template: requestedTemplate,
434
+ source: "requested-template",
435
+ reasons: [],
436
+ classification,
437
+ };
438
+ }
426
439
  return {
427
440
  template: requestedTemplate,
428
441
  source: "requested-template",
@@ -41,7 +41,8 @@ export function buildAgentWorkerProgram() {
41
41
  .description("Local product-line worker utilities for TaskSpec validation")
42
42
  .version(readPackageVersion(path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))))) ?? "0.0.0", "-V, --version", "display version")
43
43
  .showHelpAfterError()
44
- .showSuggestionAfterError();
44
+ .showSuggestionAfterError()
45
+ .enablePositionalOptions();
45
46
  const task = program.command("task").description("TaskSpec utilities");
46
47
  const batch = program
47
48
  .command("batch")
@@ -593,18 +594,17 @@ export function buildAgentWorkerProgram() {
593
594
  process.stdout.write(`${JSON.stringify(snapshot)}\n`);
594
595
  });
595
596
  consoleCmd
596
- .command("serve")
597
- .requiredOption("--repo <repo-root>", "Target repo root")
597
+ .description("Loop Operator Console (local); bare `agent-worker console` starts it directly (repo defaults to current working directory, port defaults to 8790)")
598
+ .enablePositionalOptions()
599
+ .passThroughOptions()
600
+ .option("--repo <repo-root>", "Target repo root (default: current working directory)", process.cwd())
598
601
  .option("--port <port>", "HTTP port", "8790")
599
602
  .option("--host <host>", "Bind host (default 127.0.0.1; use 0.0.0.0 for LAN, no network auth)", "127.0.0.1")
600
603
  .option("--debug", "Log request source/method/path/status to stderr")
601
604
  .option("--app-data <path>", "Override Console application-data root (Draft/Operation/confirmation)")
602
- .description("Start Loop Operator Console HTTP server (Operate + Inspect; static SPA + Operator API)")
603
605
  .action(async (options) => {
604
- const { createConsoleServer } = await import("./console/server.js");
605
- const repoRoot = path.resolve(options.repo);
606
- const server = await createConsoleServer({
607
- repoRoot,
606
+ await runConsoleServe({
607
+ repoRoot: path.resolve(options.repo),
608
608
  host: options.host,
609
609
  port: Number.parseInt(options.port, 10),
610
610
  debug: options.debug === true,
@@ -612,13 +612,25 @@ export function buildAgentWorkerProgram() {
612
612
  ? path.resolve(options.appData)
613
613
  : undefined,
614
614
  });
615
- process.stdout.write(`${server.url}\n`);
616
- await new Promise((resolve) => {
617
- const shutdown = () => resolve();
618
- process.once("SIGINT", shutdown);
619
- process.once("SIGTERM", shutdown);
615
+ });
616
+ consoleCmd
617
+ .command("serve")
618
+ .option("--repo <repo-root>", "Target repo root (default: current working directory)", process.cwd())
619
+ .option("--port <port>", "HTTP port", "8790")
620
+ .option("--host <host>", "Bind host (default 127.0.0.1; use 0.0.0.0 for LAN, no network auth)", "127.0.0.1")
621
+ .option("--debug", "Log request source/method/path/status to stderr")
622
+ .option("--app-data <path>", "Override Console application-data root (Draft/Operation/confirmation)")
623
+ .description("Start Loop Operator Console HTTP server (compat entry; equivalent to bare `agent-worker console`)")
624
+ .action(async (options) => {
625
+ await runConsoleServe({
626
+ repoRoot: path.resolve(options.repo),
627
+ host: options.host,
628
+ port: Number.parseInt(options.port, 10),
629
+ debug: options.debug === true,
630
+ appDataRoot: options.appData
631
+ ? path.resolve(options.appData)
632
+ : undefined,
620
633
  });
621
- await server.close();
622
634
  });
623
635
  consoleCmd
624
636
  .command("doctor")
@@ -693,6 +705,39 @@ export async function main(argv = process.argv) {
693
705
  async function readTaskYaml(taskSpecPath) {
694
706
  return YAML.parse(await readFile(taskSpecPath, "utf-8"));
695
707
  }
708
+ /** Shared console serve entry used by both bare `console` and `console serve`. */
709
+ async function runConsoleServe(options) {
710
+ const { createConsoleServer } = await import("./console/server.js");
711
+ try {
712
+ const server = await createConsoleServer({
713
+ repoRoot: options.repoRoot,
714
+ host: options.host,
715
+ port: options.port,
716
+ debug: options.debug === true,
717
+ appDataRoot: options.appDataRoot,
718
+ });
719
+ process.stdout.write(`${server.url}\n`);
720
+ await new Promise((resolve) => {
721
+ const shutdown = () => resolve();
722
+ process.once("SIGINT", shutdown);
723
+ process.once("SIGTERM", shutdown);
724
+ });
725
+ await server.close();
726
+ }
727
+ catch (error) {
728
+ const detail = error instanceof Error ? error.message : String(error);
729
+ const code = error?.code;
730
+ const isPortInUse = code === "EADDRINUSE" ||
731
+ /listen EADDRINUSE/i.test(detail) ||
732
+ /address already in use/i.test(detail);
733
+ if (isPortInUse) {
734
+ process.stderr.write(`端口 ${options.port} 已被占用,请使用 --port <port> 指定其它端口后重试。\n`);
735
+ process.exit(1);
736
+ }
737
+ process.stderr.write(`${detail}\n`);
738
+ process.exit(1);
739
+ }
740
+ }
696
741
  function handlePoolRecoveryCliError(error) {
697
742
  const detail = error instanceof Error ? error.message : String(error);
698
743
  process.stderr.write(`${detail}\n`);
@@ -0,0 +1,90 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { isSafeObservabilityIdentifier } from "../observability/event-store.js";
4
+ export const DAG_RUN_LIFECYCLES = ["active", "completed", "paused"];
5
+ function isPathInside(root, target) {
6
+ const normalizedRoot = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
7
+ const normalizedTarget = path.normalize(target);
8
+ return (normalizedTarget === root || normalizedTarget.startsWith(normalizedRoot));
9
+ }
10
+ function normalizeRelativeSegments(relativeSegments) {
11
+ if (!Array.isArray(relativeSegments) || relativeSegments.length === 0) {
12
+ return null;
13
+ }
14
+ const segments = [];
15
+ for (const segment of relativeSegments) {
16
+ if (typeof segment !== "string" || !segment)
17
+ return null;
18
+ if (segment === "." || segment === "..")
19
+ return null;
20
+ if (segment.includes("/") || segment.includes("\\") || segment.includes("\0")) {
21
+ return null;
22
+ }
23
+ segments.push(segment);
24
+ }
25
+ return segments;
26
+ }
27
+ /**
28
+ * Resolve a path under `.harness/dag-runs/{lifecycle}/{dagRunId}/...`.
29
+ * Fail closed on invalid ids, path escape, or the same runId appearing in
30
+ * multiple lifecycle directories.
31
+ */
32
+ export function resolveDagRunArtifact(repoRoot, dagRunId, relativeSegments) {
33
+ if (!isSafeObservabilityIdentifier(dagRunId)) {
34
+ return { ok: false, reason: "invalid-id" };
35
+ }
36
+ const segments = normalizeRelativeSegments(relativeSegments);
37
+ if (!segments) {
38
+ return { ok: false, reason: "unsafe-path" };
39
+ }
40
+ for (const segment of segments) {
41
+ if (segment !== "run.json" &&
42
+ segment !== "state.json" &&
43
+ !isSafeObservabilityIdentifier(segment) &&
44
+ !/^[a-zA-Z0-9._-]+$/.test(segment)) {
45
+ // Allow known artifact filenames under a validated node id prefix.
46
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(segment)) {
47
+ return { ok: false, reason: "unsafe-path" };
48
+ }
49
+ }
50
+ }
51
+ const dagRunsRoot = path.resolve(repoRoot, ".harness", "dag-runs");
52
+ const matches = [];
53
+ for (const lifecycle of DAG_RUN_LIFECYCLES) {
54
+ const runDir = path.join(dagRunsRoot, lifecycle, dagRunId);
55
+ const absolutePath = path.resolve(runDir, ...segments);
56
+ if (!isPathInside(dagRunsRoot, absolutePath))
57
+ continue;
58
+ const relative = path.relative(dagRunsRoot, absolutePath);
59
+ const parts = relative.split(path.sep).filter(Boolean);
60
+ if (parts[0] !== lifecycle || parts[1] !== dagRunId)
61
+ continue;
62
+ if (!existsSync(absolutePath))
63
+ continue;
64
+ matches.push({
65
+ lifecycle,
66
+ runDir,
67
+ absolutePath,
68
+ relativePath: path.join(lifecycle, dagRunId, ...segments),
69
+ });
70
+ }
71
+ if (matches.length === 0) {
72
+ return { ok: false, reason: "not-found" };
73
+ }
74
+ if (matches.length > 1) {
75
+ return { ok: false, reason: "ambiguous" };
76
+ }
77
+ return { ok: true, result: matches[0] };
78
+ }
79
+ export function resolveDagRunJson(repoRoot, dagRunId) {
80
+ return resolveDagRunArtifact(repoRoot, dagRunId, ["run.json"]);
81
+ }
82
+ export function resolveDagNodePromptRedacted(repoRoot, dagRunId, nodeId) {
83
+ if (!isSafeObservabilityIdentifier(nodeId)) {
84
+ return { ok: false, reason: "invalid-id" };
85
+ }
86
+ return resolveDagRunArtifact(repoRoot, dagRunId, [
87
+ nodeId,
88
+ "prompt.redacted.md",
89
+ ]);
90
+ }