@tea-agent/loop-agent 0.16.1-beta.2 → 0.16.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 (94) hide show
  1. package/AGENTS.md +4 -8
  2. package/CHANGELOG.md +55 -18
  3. package/README.md +76 -299
  4. package/dist/application/evaluation/alias.js +184 -0
  5. package/dist/application/evaluation/budget.js +192 -0
  6. package/dist/application/evaluation/campaign-hash.js +47 -0
  7. package/dist/application/evaluation/campaign-matrix.js +372 -0
  8. package/dist/application/evaluation/campaign-scorecard.js +135 -0
  9. package/dist/application/evaluation/campaign.js +370 -0
  10. package/dist/application/evaluation/candidate.js +23 -6
  11. package/dist/application/evaluation/corpus-hash.js +38 -0
  12. package/dist/application/evaluation/corpus.js +56 -0
  13. package/dist/application/evaluation/experiment.js +294 -0
  14. package/dist/application/evaluation/ignition.js +198 -0
  15. package/dist/application/evaluation/integrity-audit.js +162 -0
  16. package/dist/application/evaluation/outer-loop.js +132 -0
  17. package/dist/application/evaluation/pi-cell-executor.js +39 -0
  18. package/dist/application/evaluation/private-verifier.js +46 -0
  19. package/dist/application/evaluation/promotion-policy.js +151 -0
  20. package/dist/application/evaluation/proposer.js +98 -0
  21. package/dist/application/evaluation/types.js +522 -0
  22. package/dist/cli/command-definitions.js +19 -3
  23. package/dist/commands/dag-reconcile-run.js +3 -116
  24. package/dist/commands/eval.js +1176 -13
  25. package/dist/commands/init.js +7 -1
  26. package/dist/executors/dag-pi-executor.js +4 -44
  27. package/dist/executors/pi-sdk-executor.js +3 -3
  28. package/dist/executors/shell-executor.js +1 -1
  29. package/dist/infrastructure/evaluation/alias-store.js +199 -0
  30. package/dist/infrastructure/evaluation/campaign-store.js +154 -0
  31. package/dist/infrastructure/evaluation/corpus-store.js +181 -0
  32. package/dist/infrastructure/evaluation/experiment-store.js +124 -0
  33. package/dist/infrastructure/evaluation/ignition-store.js +82 -0
  34. package/dist/infrastructure/evaluation/private-verifier-store.js +145 -0
  35. package/dist/infrastructure/evaluation/proposer-store.js +78 -0
  36. package/dist/records/promotion.js +3 -1
  37. package/dist/worker/cli.js +83 -0
  38. package/dist/worker/delivery/git-transaction.js +75 -0
  39. package/dist/worker/delivery/verification-bundle.js +13 -2
  40. package/dist/worker/feature/review.js +3 -2
  41. package/dist/worker/observe/static/dag-helpers.js +0 -62
  42. package/dist/worker/observe/static/styles.css +18 -55
  43. package/dist/worker/observe/static/views/dag.js +13 -5
  44. package/dist/worker/outcomes/adapters.js +4 -1
  45. package/dist/worker/outcomes/declared-artifacts.js +103 -0
  46. package/dist/worker/outcomes/evidence-tokens.js +29 -0
  47. package/dist/worker/outcomes/gate.js +10 -11
  48. package/dist/worker/outcomes/projector.js +30 -4
  49. package/dist/worker/outcomes/types.js +3 -0
  50. package/dist/worker/pool/reconcile.js +285 -0
  51. package/dist/worker/run-task/run-task.js +81 -4
  52. package/dist/worker/runner/run-ready.js +25 -2
  53. package/dist/worker/task-graph/ready-planner.js +14 -8
  54. package/dist/worker/task-graph/task-graph-schema.js +5 -3
  55. package/dist/workflows/dag/budget-enforcement.js +67 -0
  56. package/dist/workflows/dag/context-policy.js +137 -0
  57. package/dist/workflows/dag/failure-routing.js +7 -0
  58. package/dist/workflows/dag/frontend-implementation-contract.js +0 -77
  59. package/dist/workflows/dag/init-hybrid.js +33 -53
  60. package/dist/workflows/dag/knowledge-curator.js +3 -0
  61. package/dist/workflows/dag/node-execution.js +11 -4
  62. package/dist/workflows/dag/prompt.js +1 -1
  63. package/dist/workflows/dag/reconcile-run.js +121 -0
  64. package/dist/workflows/dag/report.js +12 -0
  65. package/dist/workflows/dag/runner.js +43 -16
  66. package/dist/workflows/dag/skill-snapshot.js +11 -7
  67. package/dist/workflows/dag/types.js +18 -1
  68. package/dist/workflows/dag/validate.js +15 -1
  69. package/docs/README.md +3 -1
  70. package/docs/architecture/runtime-boundaries.md +3 -2
  71. package/docs/init-surface.manifest.json +4 -0
  72. package/docs/local-development-environment.md +52 -0
  73. package/docs/templates/agent-dag.schema.json +0 -5
  74. package/docs/templates/agent-dag.supervised-implementation.json +23 -4
  75. package/docs/templates/branch-merge-report.md +14 -0
  76. package/docs/templates/evaluation/campaign-budget-v1.json +12 -0
  77. package/docs/templates/evaluation/campaign-dogfood-v0.json +24 -0
  78. package/docs/templates/evaluation/campaign-evidence-v1.json +44 -0
  79. package/docs/templates/evaluation/context-policy-baseline-v1.json +17 -0
  80. package/docs/templates/evaluation/context-policy-role-specialized-v1.json +28 -0
  81. package/docs/templates/evaluation/corpus-dogfood-v0.manifest.json +118 -0
  82. package/docs/templates/evaluation/matrix-dag-dry-run-v1.json +21 -0
  83. package/docs/templates/evaluation/matrix-fixture-v1.json +10 -0
  84. package/docs/templates/evaluation/private-verifier-dogfood-v0.json +16 -0
  85. package/docs/templates/product-line/AGENTS.md +1 -0
  86. package/docs/templates/product-line/README.md +17 -0
  87. package/docs/templates/product-line/acceptance.yaml +9 -0
  88. package/docs/templates/product-line/feature.yaml +11 -0
  89. package/docs/templates/product-line/task-graph.yaml +8 -0
  90. package/docs/templates/product-line/task.yaml +4 -0
  91. package/package.json +2 -1
  92. package/skills/frontend-implementation/references/node-contracts.md +3 -3
  93. package/skills/loop-agent/references/command-reference.md +5 -0
  94. package/skills/loop-agent/references/hybrid-dag.md +3 -3
@@ -3,6 +3,7 @@ import { mkdir, readFile, realpath, rename, unlink, writeFile } from "node:fs/pr
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
5
  import { controllerIdentitiesMatch } from "../loop-agent/loop-agent-client.js";
6
+ import { artifactMatchesEvidenceToken, isShellVerificationToken, } from "../outcomes/evidence-tokens.js";
6
7
  import { readVerifiedOutcome } from "../outcomes/store.js";
7
8
  import { getTaskPoolRoot, readFeatureTaskPoolStates } from "../pool/run-store.js";
8
9
  import { workflowSchema } from "../task-spec/workflow-routing.js";
@@ -30,7 +31,12 @@ const bundleOutcomeSchema = z
30
31
  outcomePath: z.string().min(1),
31
32
  outcomeSha256: z.string().regex(/^[a-f0-9]{64}$/),
32
33
  acceptanceCoverage: z.array(z.string().min(1)),
33
- artifacts: z.array(z.object({ path: z.string().min(1), sha256: z.string().regex(/^[a-f0-9]{64}$/), kind: z.string().min(1).optional() }).strict()),
34
+ artifacts: z.array(z.object({
35
+ path: z.string().min(1),
36
+ sha256: z.string().regex(/^[a-f0-9]{64}$/),
37
+ kind: z.string().min(1).optional(),
38
+ schemaId: z.string().min(1).optional(),
39
+ }).strict()),
34
40
  integrationStatus: z.object({ mock: z.boolean(), real: z.boolean() }).strict(),
35
41
  outcomeStatus: z.enum(["succeeded", "failed"]),
36
42
  controllerIdentity: z
@@ -243,7 +249,12 @@ function projectBundleAcceptance(item, backendTests, frontendTests, implementati
243
249
  return { acId: item.id, status: "covered", priority: item.priority };
244
250
  }
245
251
  function evidenceKindPresent(entry, kind) {
246
- return entry.artifacts.some((artifact) => artifact.kind === kind);
252
+ if (isShellVerificationToken(kind)) {
253
+ // Bundle outcomes do not embed shellVerification; shell evidence is
254
+ // already implied by outcomeStatus === "succeeded" for typed tests.
255
+ return entry.outcomeStatus === "succeeded";
256
+ }
257
+ return entry.artifacts.some((artifact) => artifactMatchesEvidenceToken(artifact, kind));
247
258
  }
248
259
  function isTypedVerificationWorkflow(workflow) {
249
260
  return TYPED_VERIFICATION_WORKFLOWS.includes(workflow);
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import YAML from "yaml";
5
5
  import { z } from "zod";
6
6
  import { getRunsJsonlPath, getTaskPoolRoot, readFeatureTaskPoolStates, readJsonlFile } from "../pool/run-store.js";
7
+ import { artifactMatchesEvidenceToken, isShellVerificationToken, } from "../outcomes/evidence-tokens.js";
7
8
  import { readVerifiedOutcome } from "../outcomes/store.js";
8
9
  import { acceptanceSpecSchema } from "../task-graph/acceptance-schema.js";
9
10
  import { taskGraphSpecSchema } from "../task-graph/task-graph-schema.js";
@@ -591,9 +592,9 @@ export function projectDualCoverage(item, states, outcomes, failed, done, expect
591
592
  return "awaiting-verification";
592
593
  }
593
594
  const requiredEvidence = item.verification.required_evidence ?? [];
594
- if (requiredEvidence.some((kind) => !verEnvelopes.some(({ envelope }) => kind === "shell-verification"
595
+ if (requiredEvidence.some((token) => !verEnvelopes.some(({ envelope }) => isShellVerificationToken(token)
595
596
  ? envelope.shellVerification?.exitZero === true
596
- : envelope.artifacts?.some((artifact) => artifact.kind === kind)))) {
597
+ : envelope.artifacts?.some((artifact) => artifactMatchesEvidenceToken(artifact, token))))) {
597
598
  return "awaiting-verification";
598
599
  }
599
600
  // 3. integration policy: real-required demands real evidence on every
@@ -2,11 +2,9 @@
2
2
  import { UI_TEXT } from "./constants.js";
3
3
  import { el } from "./dom.js";
4
4
  import {
5
- badge,
6
5
  dagStatusBadge,
7
6
  formatDuration,
8
7
  formatAgo,
9
- formatMs,
10
8
  } from "./format.js";
11
9
  import { navigate } from "./router.js";
12
10
 
@@ -139,63 +137,3 @@ export function getActiveNodes(dag) {
139
137
  card.appendChild(link);
140
138
  return card;
141
139
  }
142
-
143
- export function renderRankLanes(dag) {
144
- const container = el("div", "rank-lanes");
145
- const nodes = dag.nodes ?? [];
146
- const ranks = dag.ranks;
147
- const rankGroups = new Map();
148
-
149
- if (ranks && ranks.length > 0) {
150
- for (let i = 0; i < ranks.length; i++) {
151
- rankGroups.set(String(i), ranks[i]);
152
- }
153
- for (const node of nodes) {
154
- const rank = node.rank ?? "—";
155
- if (!rankGroups.has(rank)) {
156
- rankGroups.set(rank, [node.nodeId]);
157
- } else if (!rankGroups.get(rank).includes(node.nodeId)) {
158
- rankGroups.get(rank).push(node.nodeId);
159
- }
160
- }
161
- } else {
162
- for (const node of nodes) {
163
- const rank = node.rank ?? "—";
164
- const list = rankGroups.get(rank) ?? [];
165
- list.push(node.nodeId);
166
- rankGroups.set(rank, list);
167
- }
168
- }
169
-
170
- const sortedRanks = [...rankGroups.keys()].sort((a, b) => {
171
- const na = Number(a);
172
- const nb = Number(b);
173
- if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
174
- return a.localeCompare(b);
175
- });
176
-
177
- const nodeById = Object.fromEntries(nodes.map((n) => [n.nodeId, n]));
178
-
179
- for (const rank of sortedRanks) {
180
- const lane = el("div", "rank-lane");
181
- lane.appendChild(el("div", "rank-lane-label", `第 ${rank} 层`));
182
- const chips = el("div", "rank-lane-nodes");
183
- for (const nodeId of rankGroups.get(rank) ?? []) {
184
- const node = nodeById[nodeId] ?? { nodeId };
185
- const chip = el("div", "rank-node-chip");
186
- if (isNodeActive(node.status)) chip.classList.add("rank-node-active");
187
- if (isNodeFailed(node.status)) chip.classList.add("rank-node-error");
188
- chip.appendChild(el("span", "rank-node-id", nodeId));
189
- if (node.status) chip.appendChild(badge(node.status));
190
- if (node.durationMs !== undefined) {
191
- chip.appendChild(
192
- el("span", "rank-node-dur", formatMs(node.durationMs)),
193
- );
194
- }
195
- chips.appendChild(chip);
196
- }
197
- lane.appendChild(chips);
198
- container.appendChild(lane);
199
- }
200
- return container;
201
- }
@@ -70,10 +70,10 @@ a {
70
70
  code,
71
71
  pre,
72
72
  .output-block,
73
- .rank-node-id,
74
73
  .dag-node-label,
75
74
  .dag-node-meta,
76
75
  .dag-node-status,
76
+ .dag-node-table td:nth-child(2),
77
77
  td:first-child,
78
78
  .meta-item dd,
79
79
  .header-refresh,
@@ -1462,35 +1462,43 @@ body.is-resizing-dag-graph {
1462
1462
  }
1463
1463
  .dag-node-table th:nth-child(1),
1464
1464
  .dag-node-table td:nth-child(1) {
1465
- width: 19%;
1465
+ width: 5%;
1466
1466
  }
1467
1467
  .dag-node-table th:nth-child(2),
1468
1468
  .dag-node-table td:nth-child(2) {
1469
- width: 7%;
1469
+ width: 17%;
1470
1470
  }
1471
1471
  .dag-node-table th:nth-child(3),
1472
1472
  .dag-node-table td:nth-child(3) {
1473
- width: 12%;
1473
+ width: 7%;
1474
1474
  }
1475
1475
  .dag-node-table th:nth-child(4),
1476
1476
  .dag-node-table td:nth-child(4) {
1477
- width: 16%;
1477
+ width: 11%;
1478
1478
  }
1479
1479
  .dag-node-table th:nth-child(5),
1480
1480
  .dag-node-table td:nth-child(5) {
1481
- width: 12%;
1481
+ width: 15%;
1482
1482
  }
1483
1483
  .dag-node-table th:nth-child(6),
1484
1484
  .dag-node-table td:nth-child(6) {
1485
- width: 9%;
1485
+ width: 11%;
1486
1486
  }
1487
1487
  .dag-node-table th:nth-child(7),
1488
1488
  .dag-node-table td:nth-child(7) {
1489
+ width: 9%;
1490
+ }
1491
+ .dag-node-table th:nth-child(8),
1492
+ .dag-node-table td:nth-child(8) {
1489
1493
  width: 25%;
1490
1494
  }
1491
- .dag-node-table td:nth-child(1),
1492
- .dag-node-table td:nth-child(4),
1493
- .dag-node-table td:nth-child(7) {
1495
+ .dag-node-table th:first-child,
1496
+ .dag-node-table td:first-child {
1497
+ text-align: center;
1498
+ }
1499
+ .dag-node-table td:nth-child(2),
1500
+ .dag-node-table td:nth-child(5),
1501
+ .dag-node-table td:nth-child(8) {
1494
1502
  overflow-wrap: anywhere;
1495
1503
  }
1496
1504
  .dag-active-node {
@@ -1503,51 +1511,6 @@ body.is-resizing-dag-graph {
1503
1511
  box-shadow: inset 3px 0 0 var(--orange);
1504
1512
  }
1505
1513
 
1506
- .rank-lanes {
1507
- display: grid;
1508
- gap: var(--space-3);
1509
- }
1510
- .rank-lane {
1511
- display: grid;
1512
- grid-template-columns: 56px minmax(0, 1fr);
1513
- gap: var(--space-3);
1514
- align-items: center;
1515
- }
1516
- .rank-lane-label {
1517
- color: var(--muted);
1518
- font-size: 11px;
1519
- font-weight: 700;
1520
- }
1521
- .rank-lane-nodes {
1522
- display: flex;
1523
- flex-wrap: wrap;
1524
- gap: 8px;
1525
- }
1526
- .rank-node-chip {
1527
- display: inline-flex;
1528
- align-items: center;
1529
- gap: 7px;
1530
- min-height: 32px;
1531
- padding: 5px 8px;
1532
- border: 1px solid var(--hairline);
1533
- border-radius: var(--radius-sm);
1534
- background: var(--canvas-soft);
1535
- }
1536
- .rank-node-active {
1537
- border-color: var(--orange);
1538
- }
1539
- .rank-node-error {
1540
- border-color: var(--red);
1541
- }
1542
- .rank-node-id {
1543
- color: var(--ink);
1544
- font-size: 11px;
1545
- }
1546
- .rank-node-dur {
1547
- color: var(--muted);
1548
- font-size: 11px;
1549
- }
1550
-
1551
1514
  .process-timeline-note {
1552
1515
  margin: 0 0 var(--space-2);
1553
1516
  color: var(--muted);
@@ -82,7 +82,6 @@ import {
82
82
  isNodeFailed,
83
83
  isNodeFinished,
84
84
  isNodeActive,
85
- renderRankLanes,
86
85
  } from "../dag-helpers.js";
87
86
  import { findBatchRunIdForTask } from "../run-processing.js";
88
87
  import { renderDagGraph } from "./dag-graph.js";
@@ -275,7 +274,6 @@ export async function renderDagDetail(dagRunId, initial = true) {
275
274
  clearNode(ranksEl);
276
275
  ranksEl.appendChild(el("h3", null, "DAG 依赖图"));
277
276
  ranksEl.appendChild(renderDagGraph(dag, dagRunId, null));
278
- ranksEl.appendChild(renderRankLanes(dag));
279
277
  }
280
278
 
281
279
  const nodesScrollLeft = nodesEl.scrollLeft;
@@ -298,7 +296,7 @@ export async function renderDagDetail(dagRunId, initial = true) {
298
296
  return a.nodeId.localeCompare(b.nodeId);
299
297
  });
300
298
 
301
- const rows = sortedNodes.map((node) => {
299
+ const rows = sortedNodes.map((node, index) => {
302
300
  const active = isNodeActive(node.status);
303
301
  const failed = isNodeFailed(node.status);
304
302
  let className = "";
@@ -313,6 +311,7 @@ export async function renderDagDetail(dagRunId, initial = true) {
313
311
  className,
314
312
  onClick: () => selectDagNode(dagRunId, node.nodeId),
315
313
  cells: [
314
+ index + 1,
316
315
  node.nodeId,
317
316
  node.rank ?? "—",
318
317
  node.executor ?? "—",
@@ -324,12 +323,21 @@ export async function renderDagDetail(dagRunId, initial = true) {
324
323
  };
325
324
  });
326
325
  const nodeTable = buildTable(
327
- ["节点 ID", "依赖层", "执行方式", "模型", "状态", "耗时", "备注"],
326
+ [
327
+ "序号",
328
+ "节点 ID",
329
+ "依赖层",
330
+ "执行方式",
331
+ "模型",
332
+ "状态",
333
+ "耗时",
334
+ "备注",
335
+ ],
328
336
  rows,
329
337
  );
330
338
  nodeTable.classList.add("dag-node-table");
331
339
  for (const row of nodeTable.tBodies[0]?.rows ?? []) {
332
- for (const columnIndex of [0, 3, 6]) {
340
+ for (const columnIndex of [1, 4, 7]) {
333
341
  const cell = row.cells[columnIndex];
334
342
  if (cell?.textContent) cell.title = cell.textContent;
335
343
  }
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { splitStructuredArtifactIdentity } from "./evidence-tokens.js";
2
3
  /**
3
4
  * Shared artifact extraction. Every successful worker run produces a canonical
4
5
  * `run_record` (the worker-run-record.json) and a `dag` (the generated DAG
@@ -43,10 +44,12 @@ function structuredArtifactsFromReport(run) {
43
44
  if (node.structuredArtifactPath &&
44
45
  node.structuredArtifactSha256 &&
45
46
  node.structuredArtifactSchemaId) {
47
+ const identity = splitStructuredArtifactIdentity(node.structuredArtifactSchemaId);
46
48
  artifacts.push({
47
49
  path: node.structuredArtifactPath,
48
50
  sha256: node.structuredArtifactSha256,
49
- kind: node.structuredArtifactSchemaId,
51
+ kind: identity.kind,
52
+ schemaId: identity.schemaId,
50
53
  });
51
54
  }
52
55
  }
@@ -0,0 +1,103 @@
1
+ /** Identity key for domain artifact uniqueness within one Outcome. */
2
+ export function artifactIdentityKey(kind, schemaId) {
3
+ return `${kind}::${schemaId ?? ""}`;
4
+ }
5
+ /**
6
+ * Minimal glob matcher aligned with Git write-boundary audit.
7
+ * Supports `*` (one path segment) and `**` (any depth).
8
+ */
9
+ export function matchesPathGlob(filePath, glob) {
10
+ const normalizedPath = filePath.replace(/\\/g, "/");
11
+ const normalizedGlob = glob.replace(/\\/g, "/");
12
+ let pattern = "^";
13
+ for (let index = 0; index < normalizedGlob.length; index += 1) {
14
+ const char = normalizedGlob[index];
15
+ if (char === "*" && normalizedGlob[index + 1] === "*") {
16
+ pattern += ".*";
17
+ index += 1;
18
+ continue;
19
+ }
20
+ if (char === "*") {
21
+ pattern += "[^/]*";
22
+ continue;
23
+ }
24
+ pattern += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
25
+ }
26
+ return new RegExp(`${pattern}$`).test(normalizedPath);
27
+ }
28
+ export function isPathAllowedForTask(filePath, allowedPaths, forbiddenPaths) {
29
+ const normalized = filePath.replace(/\\/g, "/");
30
+ if (normalized.startsWith("/") || normalized.includes("..")) {
31
+ return { ok: false, reason: `artifact path escapes repo or uses traversal: ${filePath}` };
32
+ }
33
+ if (forbiddenPaths.some((glob) => matchesPathGlob(normalized, glob))) {
34
+ return { ok: false, reason: `artifact path is forbidden: ${filePath}` };
35
+ }
36
+ if (allowedPaths.length > 0 &&
37
+ !allowedPaths.some((glob) => matchesPathGlob(normalized, glob))) {
38
+ return { ok: false, reason: `artifact path is outside allowed_paths: ${filePath}` };
39
+ }
40
+ return { ok: true };
41
+ }
42
+ /**
43
+ * Project path-bound TaskGraph `produces[]` into Outcome artifact claims.
44
+ * Entries without `path` are skipped (structured DAG artifacts may satisfy them).
45
+ * Empty sha256 is filled by the projector after on-disk hash recomputation.
46
+ */
47
+ export function projectDeclaredPathArtifacts(declared) {
48
+ if (!declared || declared.length === 0)
49
+ return [];
50
+ const artifacts = [];
51
+ for (const entry of declared) {
52
+ if (!entry.path)
53
+ continue;
54
+ artifacts.push({
55
+ path: entry.path.replace(/\\/g, "/"),
56
+ sha256: "",
57
+ kind: entry.kind,
58
+ ...(entry.schemaId ? { schemaId: entry.schemaId } : {}),
59
+ });
60
+ }
61
+ return artifacts;
62
+ }
63
+ /**
64
+ * Merge adapter artifacts with declared path bindings.
65
+ * Same `{kind,schemaId}` must resolve to one path; conflicts fail closed.
66
+ */
67
+ export function mergeOutcomeArtifacts(input) {
68
+ const byIdentity = new Map();
69
+ const withoutIdentity = [];
70
+ const ingest = (artifact) => {
71
+ if (!artifact.kind) {
72
+ withoutIdentity.push(artifact);
73
+ return undefined;
74
+ }
75
+ const key = artifactIdentityKey(artifact.kind, artifact.schemaId);
76
+ const existing = byIdentity.get(key);
77
+ if (!existing) {
78
+ byIdentity.set(key, artifact);
79
+ return undefined;
80
+ }
81
+ const existingPath = existing.path.replace(/\\/g, "/");
82
+ const nextPath = artifact.path.replace(/\\/g, "/");
83
+ if (existingPath !== nextPath) {
84
+ return `duplicate artifact identity ${key} with conflicting paths: ${existingPath} vs ${nextPath}`;
85
+ }
86
+ // Prefer the entry that already carries a declared sha256.
87
+ if (!existing.sha256 && artifact.sha256) {
88
+ byIdentity.set(key, artifact);
89
+ }
90
+ return undefined;
91
+ };
92
+ for (const artifact of input.base) {
93
+ const error = ingest(artifact);
94
+ if (error)
95
+ return { ok: false, reason: error };
96
+ }
97
+ for (const artifact of input.declared) {
98
+ const error = ingest(artifact);
99
+ if (error)
100
+ return { ok: false, reason: error };
101
+ }
102
+ return { ok: true, artifacts: [...withoutIdentity, ...byIdentity.values()] };
103
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Shared evidence / artifact identity helpers for Outcome gates, Feature review,
3
+ * and Feature Verification Bundle projection.
4
+ */
5
+ /** Canonical required-output / required_evidence token for shell exit-zero. */
6
+ export const SHELL_VERIFICATION_TOKEN = "shell_verification";
7
+ /**
8
+ * Accept the canonical underscore form and the legacy hyphen form used in early
9
+ * fullstack packets so dual-coverage and bundle evidence stay aligned.
10
+ */
11
+ export function isShellVerificationToken(token) {
12
+ return token === SHELL_VERIFICATION_TOKEN || token === "shell-verification";
13
+ }
14
+ /** Match an evidence token against artifact `kind` or `schemaId`. */
15
+ export function artifactMatchesEvidenceToken(artifact, token) {
16
+ return artifact.kind === token || artifact.schemaId === token;
17
+ }
18
+ /**
19
+ * Derive stable `kind` + versioned `schemaId` from a DAG structured-artifact
20
+ * schema id (e.g. `backend-test-result-v1` → kind `backend-test-result`).
21
+ * Unversioned ids keep kind === schemaId.
22
+ */
23
+ export function splitStructuredArtifactIdentity(schemaId) {
24
+ const match = /^(.*)-v\d+$/.exec(schemaId);
25
+ if (match?.[1]) {
26
+ return { kind: match[1], schemaId };
27
+ }
28
+ return { kind: schemaId, schemaId };
29
+ }
@@ -1,18 +1,16 @@
1
+ import { isShellVerificationToken, } from "./evidence-tokens.js";
2
+ export { artifactMatchesEvidenceToken, isShellVerificationToken, } from "./evidence-tokens.js";
1
3
  /**
2
4
  * Deterministic required-output gate.
3
5
  *
4
6
  * The gate runs *after* a successful DAG report. It performs a **literal
5
- * string match** of each `outputs.required` token against the `kind` of every
6
- * artifact the adapter produced (and the run-record/dag canonical kinds). A
7
- * required token with no matching produced artifact fails closed — the run is
8
- * downgraded from `succeeded` to `failed` with `ContractMismatch`.
7
+ * string match** of each known `outputs.required` token against produced
8
+ * artifact kinds (and the shell-verification exit-zero fact).
9
9
  *
10
- * - No regex / substring matching: a token matches only when an artifact
11
- * `kind` equals it exactly.
12
- * - Unknown tokens (no adapter mapping) are simply unmatched missing. The
13
- * gate never invents a mapping and never passes a run missing a known
14
- * required output.
15
- * - An empty `required` list always passes (nothing to enforce).
10
+ * Known tokens (`run_record`, `dag_json`, `shell_verification` / legacy
11
+ * `shell-verification`) fail closed when absent. Unknown legacy tokens are
12
+ * intentionally ignored for compatibility they never invent a mapping and
13
+ * never block promotion. An empty `required` list always passes.
16
14
  *
17
15
  * This gate is the success-criteria guardrail: "DAG report succeeded but a
18
16
  * known required output is missing ⇒ the Task must not be marked Done."
@@ -26,7 +24,7 @@ export function checkRequiredOutputs(envelope, requiredOutputs) {
26
24
  .filter((value) => Boolean(value)));
27
25
  const missing = [];
28
26
  for (const token of requiredOutputs) {
29
- if (token === "shell_verification") {
27
+ if (isShellVerificationToken(token)) {
30
28
  if (!envelope.shellVerification?.exitZero)
31
29
  missing.push(token);
32
30
  continue;
@@ -34,6 +32,7 @@ export function checkRequiredOutputs(envelope, requiredOutputs) {
34
32
  if (KNOWN_ARTIFACT_OUTPUTS.has(token) && !producedKinds.has(token)) {
35
33
  missing.push(token);
36
34
  }
35
+ // Unknown tokens: intentionally ignored (legacy compatibility).
37
36
  }
38
37
  return { passed: missing.length === 0, missing };
39
38
  }
@@ -3,6 +3,7 @@ import { readFile, stat } from "node:fs/promises";
3
3
  import { sha256File } from "../pool/run-store.js";
4
4
  import { taskOutcomeEnvelopeV1Schema } from "./types.js";
5
5
  import { getOutcomeAdapter } from "./adapters.js";
6
+ import { isPathAllowedForTask, mergeOutcomeArtifacts, projectDeclaredPathArtifacts, } from "./declared-artifacts.js";
6
7
  /**
7
8
  * Deterministic path-containment guard. Mirrors run-task's `isPathInside`: a
8
9
  * path must resolve *inside* the repo root and must not escape it. Absolute
@@ -66,11 +67,27 @@ export async function projectOutcome(input) {
66
67
  if (!adapter) {
67
68
  return contractError(`no outcome adapter for workflow "${workflow}"`);
68
69
  }
69
- // 3. Adapter projection (pure; no FS).
70
+ // 3. Adapter projection (pure; no FS) + frozen graph `produces.path` bindings.
70
71
  const adapterProjection = adapter.project(input);
72
+ const declaredPathArtifacts = projectDeclaredPathArtifacts(input.declaredArtifacts);
73
+ const allowedPaths = input.taskSpec.constraints?.allowed_paths ?? [];
74
+ const forbiddenPaths = input.taskSpec.constraints?.forbidden_paths ?? [];
75
+ for (const artifact of declaredPathArtifacts) {
76
+ const containment = isPathAllowedForTask(artifact.path, allowedPaths, forbiddenPaths);
77
+ if (!containment.ok) {
78
+ return contractError(containment.reason);
79
+ }
80
+ }
81
+ const merged = mergeOutcomeArtifacts({
82
+ base: adapterProjection.artifacts,
83
+ declared: declaredPathArtifacts,
84
+ });
85
+ if (!merged.ok) {
86
+ return contractError(merged.reason);
87
+ }
71
88
  // 4. Artifact path + hash validation.
72
89
  const validatedArtifacts = [];
73
- for (const artifact of adapterProjection.artifacts) {
90
+ for (const artifact of merged.artifacts) {
74
91
  if (!isRepoRelativePath(input.repoRoot, artifact.path)) {
75
92
  return contractError(`artifact path is not repo-relative: ${artifact.path}`);
76
93
  }
@@ -94,9 +111,10 @@ export async function projectOutcome(input) {
94
111
  return contractError(`artifact sha256 mismatch for ${artifact.path}: declared=${artifact.sha256} actual=${recomputed}`);
95
112
  }
96
113
  validatedArtifacts.push({
97
- path: artifact.path,
114
+ path: artifact.path.replace(/\\/g, "/"),
98
115
  sha256: recomputed,
99
116
  ...(artifact.kind ? { kind: artifact.kind } : {}),
117
+ ...(artifact.schemaId ? { schemaId: artifact.schemaId } : {}),
100
118
  });
101
119
  }
102
120
  // 5. Shell verification from report decision (exit-zero = report succeeded).
@@ -113,6 +131,13 @@ export async function projectOutcome(input) {
113
131
  }
114
132
  : undefined;
115
133
  const sourceBinding = input.sourceBinding ?? deriveSourceBinding(input);
134
+ const outcomeStatus = input.reportDecision.succeeded ? "succeeded" : "failed";
135
+ const outcomeFailure = outcomeStatus === "failed"
136
+ ? {
137
+ category: "EnvFailure",
138
+ reason: input.reportDecision.reason || "DAG report did not succeed",
139
+ }
140
+ : undefined;
116
141
  const envelope = {
117
142
  schemaVersion: 1,
118
143
  identity: {
@@ -131,7 +156,8 @@ export async function projectOutcome(input) {
131
156
  integrationStatus: adapterProjection.integrationStatus,
132
157
  ...(controllerIdentity ? { controllerIdentity } : {}),
133
158
  createdAt: input.now.toISOString(),
134
- outcomeStatus: input.reportDecision.succeeded ? "succeeded" : "failed",
159
+ outcomeStatus,
160
+ ...(outcomeFailure ? { outcomeFailure } : {}),
135
161
  };
136
162
  // 6. Strict schema parse.
137
163
  const parsed = taskOutcomeEnvelopeV1Schema.safeParse(envelope);
@@ -30,7 +30,10 @@ export const OUTCOME_WORKFLOWS = [
30
30
  export const outcomeArtifactSchema = z.object({
31
31
  path: z.string().min(1),
32
32
  sha256: z.string().regex(/^[a-f0-9]{64}$/, "sha256 must be lowercase hex"),
33
+ /** Stable artifact family (e.g. `backend-test-result`). */
33
34
  kind: z.string().min(1).optional(),
35
+ /** Versioned schema id (e.g. `backend-test-result-v1`); orthogonal to kind. */
36
+ schemaId: z.string().min(1).optional(),
34
37
  });
35
38
  export const shellVerificationSchema = z.object({
36
39
  exitZero: z.boolean(),