@tea-agent/loop-agent 0.25.6 → 0.26.1

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 (41) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +27 -1
  3. package/README.md +8 -3
  4. package/dist/cli/command-definitions.js +25 -10
  5. package/dist/cli/help.js +4 -3
  6. package/dist/cli/program.js +43 -17
  7. package/dist/commands/import-prd.js +7 -2
  8. package/dist/commands/init.js +7 -5
  9. package/dist/commands/task-source-prepare.js +468 -0
  10. package/dist/executors/dag-pi-executor.js +66 -25
  11. package/dist/executors/model-routing.js +34 -18
  12. package/dist/executors/shell-write-guard.js +161 -25
  13. package/dist/governance/manifest-types.js +33 -5
  14. package/dist/task/source-prepare/build-draft.js +215 -0
  15. package/dist/task/source-prepare/completeness.js +195 -0
  16. package/dist/task/source-prepare/index.js +7 -0
  17. package/dist/task/source-prepare/parse-intent.js +373 -0
  18. package/dist/task/source-prepare/path-policy.js +197 -0
  19. package/dist/task/source-prepare/prepare.js +506 -0
  20. package/dist/task/source-prepare/reference-integrity.js +274 -0
  21. package/dist/task/source-prepare/types.js +7 -0
  22. package/dist/task/task-demand-routing.js +3 -1
  23. package/dist/worker/console/chat/model-resolver.js +15 -3
  24. package/dist/worker/observe/static/constants.js +3 -2
  25. package/dist/worker/observe/static/dag-model.js +1 -0
  26. package/dist/worker/observe/static/styles.css +182 -42
  27. package/dist/workflows/dag/lifecycle.js +40 -30
  28. package/dist/workflows/dag/node-execution.js +13 -0
  29. package/dist/workflows/dag/types.js +59 -19
  30. package/docs/templates/harness.schema.json +29 -7
  31. package/docs/templates/init-managed-agents.md +10 -5
  32. package/harness.json +1 -2
  33. package/package.json +1 -1
  34. package/skills/loop-agent/SKILL.md +5 -2
  35. package/skills/loop-agent/references/command-reference.md +17 -15
  36. package/skills/loop-agent/references/harness-policy.md +3 -4
  37. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  38. package/skills/loop-agent/references/model-routing.md +2 -0
  39. package/skills/loop-agent/references/post-implementation-and-patterns.md +1 -1
  40. package/skills/loop-agent/references/source-and-plan-practice.md +3 -2
  41. package/skills/loop-agent/references/task-workflow.md +7 -5
@@ -0,0 +1,274 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, open, readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { getTaskPaths } from "../runtime.js";
5
+ import { readSourceManifest, } from "../source-references.js";
6
+ import { PREPARE_MAX_FILE_BYTES, PREPARE_MAX_PARSE_DOCS, PREPARE_MAX_TOTAL_BYTES, } from "./types.js";
7
+ const PARSEABLE_EXTENSIONS = new Set([".md", ".markdown", ".txt"]);
8
+ const PARSEABLE_ROLES = new Set(["requirement", "acceptance"]);
9
+ function isSafeRelativeReferencePath(materializedPath) {
10
+ const normalized = materializedPath.replace(/\\/g, "/").trim();
11
+ if (!normalized)
12
+ return false;
13
+ if (normalized.includes("\0"))
14
+ return false;
15
+ if (path.isAbsolute(normalized) || /^[a-zA-Z]:[\\/]/.test(normalized)) {
16
+ return false;
17
+ }
18
+ if (normalized.split("/").some((part) => part === ".."))
19
+ return false;
20
+ if (!normalized.startsWith("references/"))
21
+ return false;
22
+ return true;
23
+ }
24
+ async function streamSha256AndBytes(filePath, maxBytes) {
25
+ const handle = await open(filePath, "r");
26
+ try {
27
+ const hash = createHash("sha256");
28
+ const buffer = Buffer.alloc(64 * 1024);
29
+ let bytes = 0;
30
+ while (true) {
31
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
32
+ if (bytesRead === 0)
33
+ break;
34
+ bytes += bytesRead;
35
+ if (maxBytes !== undefined && bytes > maxBytes) {
36
+ throw Object.assign(new Error("INPUT_TOO_LARGE"), {
37
+ code: "INPUT_TOO_LARGE",
38
+ bytes,
39
+ });
40
+ }
41
+ hash.update(buffer.subarray(0, bytesRead));
42
+ }
43
+ return { sha256: hash.digest("hex"), bytes };
44
+ }
45
+ finally {
46
+ await handle.close();
47
+ }
48
+ }
49
+ function isParseableDoc(doc) {
50
+ const ext = path.extname(doc.materializedPath).toLowerCase();
51
+ if (!PARSEABLE_EXTENSIONS.has(ext))
52
+ return false;
53
+ return PARSEABLE_ROLES.has(doc.role);
54
+ }
55
+ /**
56
+ * Validate source-manifest + reference files before deriving a draft.
57
+ * Fail-closed on taskId/path/hash/symlink/size issues.
58
+ */
59
+ export async function validateImportedPrdReferences(input) {
60
+ const paths = getTaskPaths(input.repoRoot, input.taskId);
61
+ const issues = [];
62
+ const documents = [];
63
+ const manifest = await readSourceManifest(paths.sourceDir);
64
+ if (!manifest) {
65
+ issues.push({
66
+ code: "MANIFEST_MISSING",
67
+ level: "blocking",
68
+ message: "source-manifest.json missing; run import-prd before --use-imported-prd",
69
+ path: path.join(paths.sourceDir, "source-manifest.json"),
70
+ });
71
+ return { ok: false, issues, documents, parseableDocuments: [] };
72
+ }
73
+ if (manifest.schemaVersion !== 1) {
74
+ issues.push({
75
+ code: "MANIFEST_SCHEMA",
76
+ level: "blocking",
77
+ message: `unsupported source-manifest schemaVersion: ${String(manifest.schemaVersion)}`,
78
+ });
79
+ }
80
+ if (manifest.taskId !== input.taskId) {
81
+ issues.push({
82
+ code: "MANIFEST_TASK_MISMATCH",
83
+ level: "blocking",
84
+ message: `manifest.taskId "${manifest.taskId}" does not match task ${input.taskId}`,
85
+ });
86
+ }
87
+ if (!Array.isArray(manifest.documents)) {
88
+ issues.push({
89
+ code: "MANIFEST_INVALID",
90
+ level: "blocking",
91
+ message: "source-manifest.documents must be an array",
92
+ });
93
+ return { ok: false, issues, documents, parseableDocuments: [] };
94
+ }
95
+ // Stable order by materializedPath (import already sorts; re-sort for safety).
96
+ const ordered = [...manifest.documents].sort((a, b) => a.materializedPath < b.materializedPath
97
+ ? -1
98
+ : a.materializedPath > b.materializedPath
99
+ ? 1
100
+ : 0);
101
+ let totalParseBytes = 0;
102
+ let parseableCount = 0;
103
+ for (const doc of ordered) {
104
+ if (!doc ||
105
+ typeof doc.role !== "string" ||
106
+ typeof doc.name !== "string" ||
107
+ typeof doc.materializedPath !== "string" ||
108
+ typeof doc.sha256 !== "string" ||
109
+ typeof doc.bytes !== "number") {
110
+ issues.push({
111
+ code: "MANIFEST_DOC_INVALID",
112
+ level: "blocking",
113
+ message: "manifest document missing required fields",
114
+ });
115
+ continue;
116
+ }
117
+ if (!isSafeRelativeReferencePath(doc.materializedPath)) {
118
+ issues.push({
119
+ code: "REFERENCE_PATH_UNSAFE",
120
+ level: "blocking",
121
+ message: `reference path not contained under references/: ${doc.materializedPath}`,
122
+ path: doc.materializedPath,
123
+ });
124
+ continue;
125
+ }
126
+ const absolutePath = path.resolve(paths.sourceDir, doc.materializedPath);
127
+ const relativeToSource = path
128
+ .relative(paths.sourceDir, absolutePath)
129
+ .split(path.sep)
130
+ .join("/");
131
+ if (relativeToSource.startsWith("..") ||
132
+ path.isAbsolute(relativeToSource)) {
133
+ issues.push({
134
+ code: "REFERENCE_PATH_ESCAPE",
135
+ level: "blocking",
136
+ message: `reference path escapes source dir: ${doc.materializedPath}`,
137
+ path: doc.materializedPath,
138
+ });
139
+ continue;
140
+ }
141
+ let stats;
142
+ try {
143
+ stats = await lstat(absolutePath);
144
+ }
145
+ catch {
146
+ issues.push({
147
+ code: "REFERENCE_MISSING",
148
+ level: "blocking",
149
+ message: `reference file missing: ${doc.materializedPath}`,
150
+ path: doc.materializedPath,
151
+ });
152
+ continue;
153
+ }
154
+ if (stats.isSymbolicLink()) {
155
+ issues.push({
156
+ code: "REFERENCE_SYMLINK",
157
+ level: "blocking",
158
+ message: `reference must be a regular file (symlink rejected): ${doc.materializedPath}`,
159
+ path: doc.materializedPath,
160
+ });
161
+ continue;
162
+ }
163
+ if (!stats.isFile()) {
164
+ issues.push({
165
+ code: "REFERENCE_NOT_FILE",
166
+ level: "blocking",
167
+ message: `reference is not a regular file: ${doc.materializedPath}`,
168
+ path: doc.materializedPath,
169
+ });
170
+ continue;
171
+ }
172
+ const parseable = isParseableDoc(doc);
173
+ if (parseable) {
174
+ parseableCount += 1;
175
+ if (parseableCount > PREPARE_MAX_PARSE_DOCS) {
176
+ issues.push({
177
+ code: "INPUT_TOO_LARGE",
178
+ level: "blocking",
179
+ message: `more than ${PREPARE_MAX_PARSE_DOCS} parseable imported docs`,
180
+ });
181
+ continue;
182
+ }
183
+ }
184
+ let hashResult;
185
+ try {
186
+ hashResult = await streamSha256AndBytes(absolutePath, parseable ? PREPARE_MAX_FILE_BYTES : undefined);
187
+ }
188
+ catch (error) {
189
+ if (error &&
190
+ typeof error === "object" &&
191
+ "code" in error &&
192
+ error.code === "INPUT_TOO_LARGE") {
193
+ issues.push({
194
+ code: "INPUT_TOO_LARGE",
195
+ level: "blocking",
196
+ message: `reference exceeds ${PREPARE_MAX_FILE_BYTES} bytes: ${doc.materializedPath}`,
197
+ path: doc.materializedPath,
198
+ });
199
+ continue;
200
+ }
201
+ issues.push({
202
+ code: "REFERENCE_READ_FAILED",
203
+ level: "blocking",
204
+ message: `failed to hash reference: ${doc.materializedPath}`,
205
+ path: doc.materializedPath,
206
+ });
207
+ continue;
208
+ }
209
+ if (hashResult.bytes !== doc.bytes) {
210
+ issues.push({
211
+ code: "REFERENCE_BYTES_MISMATCH",
212
+ level: "blocking",
213
+ message: `bytes mismatch for ${doc.materializedPath}: manifest=${doc.bytes} disk=${hashResult.bytes}`,
214
+ path: doc.materializedPath,
215
+ });
216
+ }
217
+ if (hashResult.sha256 !== doc.sha256) {
218
+ issues.push({
219
+ code: "REFERENCE_HASH_MISMATCH",
220
+ level: "blocking",
221
+ message: `sha256 mismatch for ${doc.materializedPath}`,
222
+ path: doc.materializedPath,
223
+ });
224
+ }
225
+ let content;
226
+ if (parseable) {
227
+ totalParseBytes += hashResult.bytes;
228
+ if (totalParseBytes > PREPARE_MAX_TOTAL_BYTES) {
229
+ issues.push({
230
+ code: "INPUT_TOO_LARGE",
231
+ level: "blocking",
232
+ message: `total parseable reference bytes exceed ${PREPARE_MAX_TOTAL_BYTES}`,
233
+ });
234
+ }
235
+ else {
236
+ content = await readFile(absolutePath, "utf-8");
237
+ }
238
+ }
239
+ documents.push({
240
+ role: doc.role,
241
+ name: doc.name,
242
+ materializedPath: doc.materializedPath,
243
+ absolutePath,
244
+ sha256: hashResult.sha256,
245
+ bytes: hashResult.bytes,
246
+ content,
247
+ parseable,
248
+ });
249
+ }
250
+ const parseableDocuments = documents.filter((doc) => doc.parseable);
251
+ const hasRequirement = parseableDocuments.some((doc) => doc.role === "requirement");
252
+ if (input.requireParseableRequirement !== false && !hasRequirement) {
253
+ // Still allow if caller will supply supplemental flags/text; prepare.ts decides.
254
+ // Here we only mark warning-level absence; prepare elevates when needed.
255
+ issues.push({
256
+ code: "NO_PARSEABLE_REQUIREMENT",
257
+ level: "warning",
258
+ message: "no parseable requirement-role document under source/references (role=requirement, .md/.txt)",
259
+ });
260
+ }
261
+ const blocking = issues.some((issue) => issue.level === "blocking");
262
+ return {
263
+ ok: !blocking,
264
+ issues,
265
+ documents,
266
+ parseableDocuments,
267
+ };
268
+ }
269
+ export function draftReferencesFromManifest(documents) {
270
+ return documents.map((doc) => ({
271
+ role: doc.role,
272
+ ref: doc.materializedPath,
273
+ }));
274
+ }
@@ -0,0 +1,7 @@
1
+ export const PREPARE_MAX_FILE_BYTES = 2 * 1024 * 1024;
2
+ export const PREPARE_MAX_PARSE_DOCS = 8;
3
+ export const PREPARE_MAX_TOTAL_BYTES = 8 * 1024 * 1024;
4
+ export const PREPARE_DEFAULT_FORBIDDEN_PATHS = [
5
+ ".harness/**",
6
+ "node_modules/**",
7
+ ];
@@ -156,7 +156,7 @@ function isDocumentationOnlyClause(clause) {
156
156
  if (DOCUMENTATION_ONLY_MARKERS.test(clause)) {
157
157
  return !PRODUCT_AND_DOCUMENT_DELIVERY.test(clause);
158
158
  }
159
- return TEST_ONLY_MARKERS.test(clause) && !PRODUCT_AND_TEST_DELIVERY.test(clause);
159
+ return (TEST_ONLY_MARKERS.test(clause) && !PRODUCT_AND_TEST_DELIVERY.test(clause));
160
160
  }
161
161
  function masksExistingBackendDependency(clause) {
162
162
  return clause
@@ -335,6 +335,8 @@ export function classifyTaskDemand(input) {
335
335
  }
336
336
  const backendDelivery = titleSignals.backendDelivery || requirementSignals.backendDelivery;
337
337
  const frontendProjectDefaultImplementation = hasStrongFrontendProjectEvidence &&
338
+ frontendPath &&
339
+ !hasBackendTaskType &&
338
340
  !backendDelivery &&
339
341
  !frontendNegated &&
340
342
  !allowedPathsOnlyCoverNonProductArtifacts;
@@ -20,6 +20,18 @@ import { readFile } from "node:fs/promises";
20
20
  import path from "node:path";
21
21
  /** Tier keys in harness executors.pi that may override the default. */
22
22
  const EXECUTOR_TIERS = ["LOW", "MED", "HIGH"];
23
+ function modelIdFromTierValue(value) {
24
+ if (typeof value === "string") {
25
+ return value && value !== "default" ? value : undefined;
26
+ }
27
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
28
+ return undefined;
29
+ }
30
+ const model = value.model;
31
+ return typeof model === "string" && model && model !== "default"
32
+ ? model
33
+ : undefined;
34
+ }
23
35
  /**
24
36
  * Read executors.pi.<tier> from the repo harness.json. Returns undefined when
25
37
  * harness.json is absent or the tier is unset / "default" sentinel.
@@ -33,9 +45,9 @@ export async function readHarnessPiModelTier(repoRoot, tier = "MED") {
33
45
  const pi = manifest.executors?.pi;
34
46
  if (!pi)
35
47
  return undefined;
36
- const tierValue = pi[tier];
37
- if (tierValue && tierValue !== "default")
38
- return tierValue;
48
+ const tierModel = modelIdFromTierValue(pi[tier]);
49
+ if (tierModel)
50
+ return tierModel;
39
51
  const defaultModel = pi.defaultModel;
40
52
  if (defaultModel && defaultModel !== "default")
41
53
  return defaultModel;
@@ -67,8 +67,8 @@ export const STATUS_LABELS = {
67
67
  done: "完成",
68
68
  failed: "失败",
69
69
  error: "错误",
70
- partial_failed: "部分失败",
71
- partialfailed: "部分失败",
70
+ partial_failed: "部分成功",
71
+ partialfailed: "部分成功",
72
72
  blocked: "阻塞",
73
73
  stale: "心跳失联",
74
74
  reused: "复用",
@@ -104,6 +104,7 @@ export const DAG_EFFECTIVE_STATUS_LABELS = {
104
104
  interrupted: "执行已中断",
105
105
  "remote-unknown": "远端状态未知",
106
106
  finished: "已完成",
107
+ partial_failed: "部分成功",
107
108
  failed: "执行失败",
108
109
  superseded: "任务已另行完成",
109
110
  abandoned: "已放弃",
@@ -2,6 +2,7 @@
2
2
 
3
3
  const TERMINAL_DAG_EFFECTIVE_STATUSES = new Set([
4
4
  "finished",
5
+ "partial_failed",
5
6
  "failed",
6
7
  "superseded",
7
8
  "abandoned",
@@ -47,8 +47,16 @@
47
47
 
48
48
  html {
49
49
  background:
50
- radial-gradient(1200px 480px at 8% -10%, rgb(241 216 203 / 45%), transparent 55%),
51
- radial-gradient(900px 420px at 100% 0%, rgb(216 234 213 / 35%), transparent 50%),
50
+ radial-gradient(
51
+ 1200px 480px at 8% -10%,
52
+ rgb(241 216 203 / 45%),
53
+ transparent 55%
54
+ ),
55
+ radial-gradient(
56
+ 900px 420px at 100% 0%,
57
+ rgb(216 234 213 / 35%),
58
+ transparent 50%
59
+ ),
52
60
  var(--canvas);
53
61
  min-height: 100%;
54
62
  scroll-behavior: smooth;
@@ -62,14 +70,8 @@ body {
62
70
  color: var(--ink);
63
71
  /* Keep system-ui first for shell contract tests; Chinese fonts follow. */
64
72
  font-family:
65
- system-ui,
66
- -apple-system,
67
- "Segoe UI",
68
- "PingFang SC",
69
- "Hiragino Sans GB",
70
- "Noto Sans SC",
71
- BlinkMacSystemFont,
72
- sans-serif;
73
+ system-ui, -apple-system, "Segoe UI", "PingFang SC", "Hiragino Sans GB",
74
+ "Noto Sans SC", BlinkMacSystemFont, sans-serif;
73
75
  font-size: 14px;
74
76
  line-height: 1.5;
75
77
  }
@@ -868,7 +870,7 @@ td:first-child,
868
870
  transition: opacity var(--transition-fast);
869
871
  }
870
872
  /* Five-column default: right two columns open inward (left) to avoid viewport overflow. */
871
- .kpi-grid:not(.pool-health-grid) > .kpi-card:nth-child(5n-1) .kpi-tip,
873
+ .kpi-grid:not(.pool-health-grid) > .kpi-card:nth-child(5n - 1) .kpi-tip,
872
874
  .kpi-grid:not(.pool-health-grid) > .kpi-card:nth-child(5n) .kpi-tip {
873
875
  left: auto;
874
876
  right: 12px;
@@ -1114,7 +1116,10 @@ td:first-child,
1114
1116
  }
1115
1117
  .feature-decision-row {
1116
1118
  display: grid;
1117
- grid-template-columns: minmax(120px, 0.9fr) minmax(88px, 0.55fr) minmax(0, 1.6fr) auto;
1119
+ grid-template-columns: minmax(120px, 0.9fr) minmax(88px, 0.55fr) minmax(
1120
+ 0,
1121
+ 1.6fr
1122
+ ) auto;
1118
1123
  align-items: center;
1119
1124
  gap: var(--space-3);
1120
1125
  padding: 12px 14px;
@@ -1254,8 +1259,8 @@ tr.clickable:focus-within {
1254
1259
  background: color-mix(in srgb, var(--red) 8%, var(--surface));
1255
1260
  }
1256
1261
  .badge-partial-failed {
1257
- color: color-mix(in srgb, var(--red) 70%, var(--ink));
1258
- background: color-mix(in srgb, var(--red) 6%, var(--surface));
1262
+ color: color-mix(in srgb, var(--amber) 80%, var(--ink));
1263
+ background: color-mix(in srgb, var(--amber) 10%, var(--surface));
1259
1264
  }
1260
1265
  .badge-running,
1261
1266
  .badge-started {
@@ -1494,7 +1499,11 @@ body.is-resizing-dag-graph {
1494
1499
  border: 1px solid var(--hairline);
1495
1500
  border-radius: var(--radius-lg);
1496
1501
  background:
1497
- radial-gradient(800px 280px at 0% 0%, rgb(241 216 203 / 28%), transparent 55%),
1502
+ radial-gradient(
1503
+ 800px 280px at 0% 0%,
1504
+ rgb(241 216 203 / 28%),
1505
+ transparent 55%
1506
+ ),
1498
1507
  var(--canvas-soft);
1499
1508
  scroll-behavior: auto;
1500
1509
  box-shadow: inset 0 1px 0 rgb(255 255 255 / 60%);
@@ -2007,11 +2016,25 @@ body.is-resizing-dag-graph {
2007
2016
  font-weight: 700;
2008
2017
  }
2009
2018
 
2010
- .spec-evidence-section { padding-bottom: var(--space-4); border-bottom: 1px solid var(--hairline); }
2011
- .spec-evidence-section + .spec-evidence-section { margin-top: var(--space-4); }
2012
- .spec-evidence-section h4 { margin: 0 0 var(--space-2); color: var(--ink); font-size: 12px; font-weight: 700; }
2019
+ .spec-evidence-section {
2020
+ padding-bottom: var(--space-4);
2021
+ border-bottom: 1px solid var(--hairline);
2022
+ }
2023
+ .spec-evidence-section + .spec-evidence-section {
2024
+ margin-top: var(--space-4);
2025
+ }
2026
+ .spec-evidence-section h4 {
2027
+ margin: 0 0 var(--space-2);
2028
+ color: var(--ink);
2029
+ font-size: 12px;
2030
+ font-weight: 700;
2031
+ }
2013
2032
 
2014
- .node-input-root { display: flex; flex-direction: column; gap: var(--space-3); }
2033
+ .node-input-root {
2034
+ display: flex;
2035
+ flex-direction: column;
2036
+ gap: var(--space-3);
2037
+ }
2015
2038
  .node-input-semantics {
2016
2039
  margin: 0;
2017
2040
  font-size: 12px;
@@ -2084,24 +2107,141 @@ body.is-resizing-dag-graph {
2084
2107
  color: var(--danger, #b42318);
2085
2108
  font-size: 13px;
2086
2109
  }
2087
- .spec-evidence-summary { margin: var(--space-2) 0 0; color: var(--body); font-size: 12px; line-height: 1.6; }
2088
- .spec-evidence-list { display: grid; gap: 6px; margin: var(--space-2) 0 0; padding: 0; list-style: none; }
2089
- .spec-evidence-list li { display: flex; align-items: flex-start; gap: 6px; color: var(--body); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
2090
- .spec-evidence-list li > i { flex-shrink: 0; margin-top: 2px; color: var(--muted); font-size: 13px; }
2091
- .spec-evidence-list code { font-size: 11px; }
2092
- .spec-evidence-time { margin-left: auto; color: var(--muted); font-size: 10px; font-variant-numeric: tabular-nums; white-space: nowrap; }
2093
- .spec-evidence-warning { display: flex; align-items: flex-start; gap: 8px; margin-top: var(--space-4); padding: 10px 12px; border: 1px solid var(--hairline); border-left: 3px solid color-mix(in srgb, var(--amber) 60%, var(--hairline)); border-radius: var(--radius-md); background: color-mix(in srgb, var(--amber) 8%, var(--surface)); color: var(--body); font-size: 12px; line-height: 1.6; }
2094
- .spec-evidence-warning > i { flex-shrink: 0; margin-top: 2px; color: var(--amber); }
2095
- .spec-evidence-file-btn { display: flex; align-items: flex-start; gap: 6px; width: 100%; padding: 6px 8px; background: none; border: none; text-align: left; cursor: pointer; color: inherit; font: inherit; line-height: 1.5; overflow-wrap: anywhere; border-radius: var(--radius-md); }
2096
- .spec-evidence-file-btn:hover, .spec-evidence-file-btn:focus-visible { background: var(--orange-soft); outline: none; }
2097
- .spec-evidence-file-btn:focus-visible { box-shadow: 0 0 0 2px color-mix(in srgb, var(--orange) 35%, transparent); }
2098
- .spec-evidence-detail { display: grid; gap: var(--space-3); }
2099
- .spec-evidence-back { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border: 1px solid var(--hairline); border-radius: var(--radius-pill); background: var(--surface); color: var(--ink); font-size: 12px; font-weight: 650; cursor: pointer; }
2100
- .spec-evidence-back:hover, .spec-evidence-back:focus-visible { background: var(--orange-soft); border-color: var(--orange); color: var(--orange-active); outline: none; }
2101
- .spec-evidence-detail-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 12px; color: var(--body); }
2102
- .spec-evidence-detail-content { min-height: 0; max-width: 100%; margin: 0; padding: var(--space-3); border: 1px solid var(--hairline); border-radius: var(--radius-md); background: var(--canvas-soft); overflow-wrap: anywhere; font-size: 12px; line-height: 1.6; }
2103
- .spec-evidence-detail-error { margin: 0; color: var(--red, #c00); font-size: 12px; }
2104
- .spec-evidence-detail-truncated { margin: 0; color: var(--muted); font-size: 11px; }
2110
+ .spec-evidence-summary {
2111
+ margin: var(--space-2) 0 0;
2112
+ color: var(--body);
2113
+ font-size: 12px;
2114
+ line-height: 1.6;
2115
+ }
2116
+ .spec-evidence-list {
2117
+ display: grid;
2118
+ gap: 6px;
2119
+ margin: var(--space-2) 0 0;
2120
+ padding: 0;
2121
+ list-style: none;
2122
+ }
2123
+ .spec-evidence-list li {
2124
+ display: flex;
2125
+ align-items: flex-start;
2126
+ gap: 6px;
2127
+ color: var(--body);
2128
+ font-size: 12px;
2129
+ line-height: 1.5;
2130
+ overflow-wrap: anywhere;
2131
+ }
2132
+ .spec-evidence-list li > i {
2133
+ flex-shrink: 0;
2134
+ margin-top: 2px;
2135
+ color: var(--muted);
2136
+ font-size: 13px;
2137
+ }
2138
+ .spec-evidence-list code {
2139
+ font-size: 11px;
2140
+ }
2141
+ .spec-evidence-time {
2142
+ margin-left: auto;
2143
+ color: var(--muted);
2144
+ font-size: 10px;
2145
+ font-variant-numeric: tabular-nums;
2146
+ white-space: nowrap;
2147
+ }
2148
+ .spec-evidence-warning {
2149
+ display: flex;
2150
+ align-items: flex-start;
2151
+ gap: 8px;
2152
+ margin-top: var(--space-4);
2153
+ padding: 10px 12px;
2154
+ border: 1px solid var(--hairline);
2155
+ border-left: 3px solid color-mix(in srgb, var(--amber) 60%, var(--hairline));
2156
+ border-radius: var(--radius-md);
2157
+ background: color-mix(in srgb, var(--amber) 8%, var(--surface));
2158
+ color: var(--body);
2159
+ font-size: 12px;
2160
+ line-height: 1.6;
2161
+ }
2162
+ .spec-evidence-warning > i {
2163
+ flex-shrink: 0;
2164
+ margin-top: 2px;
2165
+ color: var(--amber);
2166
+ }
2167
+ .spec-evidence-file-btn {
2168
+ display: flex;
2169
+ align-items: flex-start;
2170
+ gap: 6px;
2171
+ width: 100%;
2172
+ padding: 6px 8px;
2173
+ background: none;
2174
+ border: none;
2175
+ text-align: left;
2176
+ cursor: pointer;
2177
+ color: inherit;
2178
+ font: inherit;
2179
+ line-height: 1.5;
2180
+ overflow-wrap: anywhere;
2181
+ border-radius: var(--radius-md);
2182
+ }
2183
+ .spec-evidence-file-btn:hover,
2184
+ .spec-evidence-file-btn:focus-visible {
2185
+ background: var(--orange-soft);
2186
+ outline: none;
2187
+ }
2188
+ .spec-evidence-file-btn:focus-visible {
2189
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--orange) 35%, transparent);
2190
+ }
2191
+ .spec-evidence-detail {
2192
+ display: grid;
2193
+ gap: var(--space-3);
2194
+ }
2195
+ .spec-evidence-back {
2196
+ display: inline-flex;
2197
+ align-items: center;
2198
+ gap: 6px;
2199
+ padding: 6px 12px;
2200
+ border: 1px solid var(--hairline);
2201
+ border-radius: var(--radius-pill);
2202
+ background: var(--surface);
2203
+ color: var(--ink);
2204
+ font-size: 12px;
2205
+ font-weight: 650;
2206
+ cursor: pointer;
2207
+ }
2208
+ .spec-evidence-back:hover,
2209
+ .spec-evidence-back:focus-visible {
2210
+ background: var(--orange-soft);
2211
+ border-color: var(--orange);
2212
+ color: var(--orange-active);
2213
+ outline: none;
2214
+ }
2215
+ .spec-evidence-detail-meta {
2216
+ display: flex;
2217
+ flex-wrap: wrap;
2218
+ align-items: center;
2219
+ gap: 8px;
2220
+ font-size: 12px;
2221
+ color: var(--body);
2222
+ }
2223
+ .spec-evidence-detail-content {
2224
+ min-height: 0;
2225
+ max-width: 100%;
2226
+ margin: 0;
2227
+ padding: var(--space-3);
2228
+ border: 1px solid var(--hairline);
2229
+ border-radius: var(--radius-md);
2230
+ background: var(--canvas-soft);
2231
+ overflow-wrap: anywhere;
2232
+ font-size: 12px;
2233
+ line-height: 1.6;
2234
+ }
2235
+ .spec-evidence-detail-error {
2236
+ margin: 0;
2237
+ color: var(--red, #c00);
2238
+ font-size: 12px;
2239
+ }
2240
+ .spec-evidence-detail-truncated {
2241
+ margin: 0;
2242
+ color: var(--muted);
2243
+ font-size: 11px;
2244
+ }
2105
2245
 
2106
2246
  .run-layer-process {
2107
2247
  display: grid;
@@ -2312,10 +2452,12 @@ body.is-resizing-dag-graph {
2312
2452
  background: color-mix(in srgb, var(--green) 75%, white);
2313
2453
  }
2314
2454
  .dag-run-timeline-dot.status-failed,
2315
- .dag-run-timeline-dot.status-error,
2316
- .dag-run-timeline-dot.status-partial_failed {
2455
+ .dag-run-timeline-dot.status-error {
2317
2456
  background: color-mix(in srgb, var(--red) 75%, white);
2318
2457
  }
2458
+ .dag-run-timeline-dot.status-partial-failed {
2459
+ background: color-mix(in srgb, var(--amber) 75%, white);
2460
+ }
2319
2461
  .dag-run-timeline-dot.status-running,
2320
2462
  .dag-run-timeline-dot.status-started {
2321
2463
  background: var(--orange);
@@ -2374,7 +2516,7 @@ body.is-resizing-dag-graph {
2374
2516
  border-left: 0;
2375
2517
  }
2376
2518
  /* Three-column: reset five-column tip edges, then rightmost column opens inward. */
2377
- .kpi-grid:not(.pool-health-grid) > .kpi-card:nth-child(5n-1) .kpi-tip,
2519
+ .kpi-grid:not(.pool-health-grid) > .kpi-card:nth-child(5n - 1) .kpi-tip,
2378
2520
  .kpi-grid:not(.pool-health-grid) > .kpi-card:nth-child(5n) .kpi-tip {
2379
2521
  left: 12px;
2380
2522
  right: auto;
@@ -2662,8 +2804,6 @@ button.copy-command:focus-visible {
2662
2804
  margin: 0.15rem 0 0.35rem;
2663
2805
  }
2664
2806
 
2665
-
2666
-
2667
2807
  /* R5: unified view states + information hierarchy */
2668
2808
  .view-state {
2669
2809
  border-radius: var(--radius-lg);