@tea-agent/loop-agent 0.44.0-next.8 → 0.44.0-next.9

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 (75) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/dist/application/evaluation/budget.js +21 -0
  3. package/dist/application/evaluation/corpus-hash.js +10 -15
  4. package/dist/application/evaluation/corpus.js +2 -1
  5. package/dist/application/evaluation/frontend-browser-acceptance.js +77 -0
  6. package/dist/application/evaluation/frontend-corpus-browser-acceptance.js +175 -0
  7. package/dist/application/evaluation/frontend-gateway-evidence.js +226 -0
  8. package/dist/application/evaluation/frontend-pair-registration.js +143 -0
  9. package/dist/application/evaluation/frontend-paired-summary.js +150 -0
  10. package/dist/application/evaluation/frontend-run-observation.js +144 -0
  11. package/dist/application/evaluation/frontend-shared-evidence.js +105 -0
  12. package/dist/application/evaluation/types.js +45 -12
  13. package/dist/application/task-lifecycle/observe.js +26 -1
  14. package/dist/build-stamp.json +3 -3
  15. package/dist/cli/command-definitions.js +11 -0
  16. package/dist/cli/program.js +4 -0
  17. package/dist/commands/task-advance.js +3 -5
  18. package/dist/commands/task-source-prepare.js +13 -1
  19. package/dist/executors/dag-pi/tools/design-terminal-tools.js +17 -7
  20. package/dist/executors/dag-pi/tools/review-terminal-tools.js +17 -7
  21. package/dist/executors/dag-pi-executor.js +2048 -125
  22. package/dist/executors/pi-executor.js +6 -1
  23. package/dist/executors/pi-sdk-executor.js +76 -11
  24. package/dist/executors/shell-executor.js +88 -12
  25. package/dist/infrastructure/console/operation-store.js +2 -0
  26. package/dist/infrastructure/evaluation/corpus-store.js +37 -12
  27. package/dist/shared/operator/capabilities.js +10 -5
  28. package/dist/task/config-types.js +22 -9
  29. package/dist/task/contract/project.js +13 -0
  30. package/dist/task/contract/schema.js +2 -8
  31. package/dist/task/source-prepare/parse-intent.js +124 -5
  32. package/dist/task/source-prepare/prepare.js +6 -1
  33. package/dist/task/source-prepare/semantic-intake.js +17 -2
  34. package/dist/task/source-prepare/source-execution.js +65 -0
  35. package/dist/task/source-prepare/source-fidelity-pi.js +21 -8
  36. package/dist/task/source-prepare/source-provider-budget.js +290 -0
  37. package/dist/worker/console/operator-actions.js +0 -1
  38. package/dist/worker/console/operator-user-error.js +1 -1
  39. package/dist/worker/console/prd-intake-bridge.js +5 -15
  40. package/dist/workflows/dag/budget-enforcement.js +31 -2
  41. package/dist/workflows/dag/frontend-closeout.js +3 -1
  42. package/dist/workflows/dag/frontend-contract-facts.js +11 -8
  43. package/dist/workflows/dag/frontend-design-policy.js +6 -6
  44. package/dist/workflows/dag/frontend-durable-tools.js +90 -70
  45. package/dist/workflows/dag/frontend-implementation-contract.js +146 -59
  46. package/dist/workflows/dag/frontend-plan-canary.js +66 -16
  47. package/dist/workflows/dag/frontend-plan-decision-contract.js +13 -21
  48. package/dist/workflows/dag/frontend-plan-progress.js +92 -0
  49. package/dist/workflows/dag/frontend-recovery-plan.js +11 -10
  50. package/dist/workflows/dag/frontend-recovery-run.js +51 -46
  51. package/dist/workflows/dag/frontend-repair-assertions.js +36 -0
  52. package/dist/workflows/dag/frontend-review-context.js +29 -1
  53. package/dist/workflows/dag/frontend-review-scopes.js +92 -2
  54. package/dist/workflows/dag/frontend-risk.js +8 -3
  55. package/dist/workflows/dag/frontend-root-observation.js +261 -0
  56. package/dist/workflows/dag/frontend-session-budget.js +52 -39
  57. package/dist/workflows/dag/frontend-session-context.js +10 -0
  58. package/dist/workflows/dag/frontend-test-execution-evidence.js +206 -48
  59. package/dist/workflows/dag/frontend-typed-event-store.js +11 -6
  60. package/dist/workflows/dag/frontend-verification-trace.js +6 -0
  61. package/dist/workflows/dag/frontend-writer-admission.js +2 -1
  62. package/dist/workflows/dag/init-hybrid.js +108 -37
  63. package/dist/workflows/dag/node-execution.js +11 -11
  64. package/dist/workflows/dag/rerun-feedback.js +35 -4
  65. package/dist/workflows/dag/rerun-task.js +62 -67
  66. package/dist/workflows/dag/runner.js +58 -12
  67. package/dist/workflows/dag/types.js +15 -8
  68. package/docs/architecture/runtime-boundaries.md +1 -1
  69. package/docs/templates/backend-test-dag.json +4 -4
  70. package/docs/templates/frontend-implementation-contract.schema.json +31 -11
  71. package/package.json +1 -1
  72. package/skills/frontend-design-review/SKILL.md +8 -11
  73. package/skills/frontend-design-review/references/review-checklist.md +3 -4
  74. package/skills/frontend-review/SKILL.md +5 -1
  75. package/skills/loop-agent/references/command-reference.md +8 -0
@@ -0,0 +1,144 @@
1
+ import { readFrontendCorpusBrowserAcceptance } from "./frontend-corpus-browser-acceptance.js";
2
+ import { readFrontendGatewayGraph } from "./frontend-gateway-evidence.js";
3
+ import { readFrontendSharedEvidence } from "./frontend-shared-evidence.js";
4
+ import { readFrontendBrowserAcceptance } from "./frontend-browser-acceptance.js";
5
+ import { computeCampaignPlanHash } from "./campaign-hash.js";
6
+ import { readCampaignPlan } from "../../infrastructure/evaluation/campaign-store.js";
7
+ import { realpath } from "node:fs/promises";
8
+ import { createHash } from "node:crypto";
9
+ import path from "node:path";
10
+ import { readFileBoundarySafe } from "../../shared/path-safety.js";
11
+ import { locateDagRun, readDagRunSpec, readDagRunState } from "../../workflows/dag/lifecycle.js";
12
+ import { readFrontendRootObservation } from "../../workflows/dag/frontend-root-observation.js";
13
+ import { verifyControllerIdentityForResume } from "../../workflows/dag/controller-identity.js";
14
+ import { assertFrontendPairExecution } from "./frontend-pair-registration.js";
15
+ import { summarizeFrontendTaskPairs } from "./frontend-paired-summary.js";
16
+ /** Collect run/cost facts. FINISHED is deliberately not converted to a passed
17
+ * product: independent acceptance and shared-identity admission are separate. */
18
+ export async function readFrontendTaskRunObservation(input) {
19
+ if (input.acceptance && input.corpusBrowser)
20
+ throw Error("conflicting browser acceptance evidence");
21
+ const root = await readFrontendRootObservation(input.repoRoot, input.runId);
22
+ if (!root.runIds.length)
23
+ throw Error("frontend observation root missing");
24
+ const snapshots = [];
25
+ let binding;
26
+ let sharedRegistration;
27
+ let registeredAcceptance;
28
+ let firstStatus;
29
+ let lastState;
30
+ for (const runId of root.runIds) {
31
+ const located = await locateDagRun(input.repoRoot, runId);
32
+ if (!located)
33
+ throw Error("frontend observation run missing");
34
+ const read = (name) => readFileBoundarySafe({ boundaryRoot: located.runDir,
35
+ targetPath: path.join(located.runDir, name), label: "frontend run observation" });
36
+ const snapshot = { runDir: located.runDir, state: await read("state.json"), spec: await read("run.json"), controller: await read("controller-identity.json") };
37
+ const state = await readDagRunState(located.runDir), spec = await readDagRunSpec(located.runDir);
38
+ if (await realpath(state.cwd) !== await realpath(input.repoRoot))
39
+ throw Error("frontend observation workspace mismatch");
40
+ firstStatus ??= state.status;
41
+ if (state.runId !== runId || !state.evaluation?.frontendPair || !spec.evaluation?.frontendPair ||
42
+ JSON.stringify(state.evaluation) !== JSON.stringify(spec.evaluation))
43
+ throw Error("frontend observation frozen evaluation mismatch");
44
+ if (binding && JSON.stringify(binding) !== JSON.stringify(state.evaluation))
45
+ throw Error("frontend observation recovery evaluation drift");
46
+ binding = state.evaluation;
47
+ const artifact = JSON.parse(snapshot.controller.toString("utf8"));
48
+ if (state.controllerIdentityRef?.path !== "controller-identity.json" || artifact.status !== "pinned" || !artifact.identity)
49
+ throw Error("frontend observation controller not pinned");
50
+ const pinned = await verifyControllerIdentityForResume({ runDir: located.runDir, state, currentIdentity: artifact.identity });
51
+ const registration = await assertFrontendPairExecution({ repoRoot: input.repoRoot, binding,
52
+ controllerFingerprint: pinned.identity.packageFingerprint.value, startedAt: state.startedAt });
53
+ sharedRegistration = registration;
54
+ registeredAcceptance = registration.shared.acceptance;
55
+ snapshots.push(snapshot);
56
+ lastState = state;
57
+ }
58
+ let acceptance;
59
+ if (input.acceptance) {
60
+ if (input.acceptance.contract.sha256 !== registeredAcceptance)
61
+ throw Error("browser acceptance contract not registered for run");
62
+ acceptance = await readFrontendBrowserAcceptance({ repoRoot: input.repoRoot, runDir: snapshots.at(-1).runDir, ...input.acceptance });
63
+ }
64
+ let corpusBrowserEvidence;
65
+ if (input.corpusBrowser) {
66
+ if (input.corpusBrowser.contract.sha256 !== registeredAcceptance)
67
+ throw Error("corpus browser contract not registered for run");
68
+ corpusBrowserEvidence = await readFrontendCorpusBrowserAcceptance({ repoRoot: input.repoRoot, runDir: snapshots.at(-1).runDir, ...input.corpusBrowser });
69
+ }
70
+ // A failed required browser behavior disproves delivery. Passing browser
71
+ // checks alone does not prove the remaining product quality gates.
72
+ const productVerdict = corpusBrowserEvidence?.browserPassed === false || acceptance?.passed === false ? false : null;
73
+ const sharedEvidence = sharedRegistration?.sharedArtifacts ? await readFrontendSharedEvidence({
74
+ repoRoot: input.repoRoot, runDirs: snapshots.map(snapshot => snapshot.runDir),
75
+ artifacts: sharedRegistration.sharedArtifacts, expected: sharedRegistration.shared,
76
+ }) : undefined;
77
+ let gatewayObservation;
78
+ if (input.gatewaySnapshots) {
79
+ if (!sharedRegistration?.gatewayTopology)
80
+ throw Error("frontend run gateway topology not registered");
81
+ const graph = await readFrontendGatewayGraph({ repoRoot: input.repoRoot,
82
+ topology: sharedRegistration.gatewayTopology, expectedPlanHash: sharedRegistration.planHash,
83
+ evidence: input.gatewaySnapshots });
84
+ const providerAttempts = graph.providerAttempts.filter(row => row.originCellId === binding.frontendPair.cellId);
85
+ if (providerAttempts.some(row => row.originTaskRef !== binding.taskRef))
86
+ throw Error("frontend gateway task binding mismatch");
87
+ gatewayObservation = {
88
+ evidenceKind: "registered-cell-gateway-observations",
89
+ cellId: binding.frontendPair.cellId, taskRef: binding.taskRef,
90
+ topologySha256: graph.topologySha256, snapshots: input.gatewaySnapshots,
91
+ observedProviderAttempts: providerAttempts.length,
92
+ reportedProviderTokens: graph.diagnostics.length || providerAttempts.some(row => row.totalTokens === null)
93
+ ? null : providerAttempts.reduce((sum, row) => sum + row.totalTokens, 0),
94
+ providerAttempts,
95
+ // Graph-wide unresolved hops remain conservative even for another cell.
96
+ diagnostics: [...graph.diagnostics, "gateway-run-coverage-not-proven"],
97
+ };
98
+ }
99
+ for (const snapshot of snapshots)
100
+ for (const [file, expected] of [["state.json", snapshot.state], ["run.json", snapshot.spec], ["controller-identity.json", snapshot.controller]]) {
101
+ const actual = await readFileBoundarySafe({ boundaryRoot: snapshot.runDir, targetPath: path.join(snapshot.runDir, file), label: "frontend observation recheck" });
102
+ if (!actual.equals(expected))
103
+ throw Error("frontend observation changed during collection");
104
+ }
105
+ const recheckedRoot = await readFrontendRootObservation(input.repoRoot, input.runId);
106
+ if (JSON.stringify(recheckedRoot) !== JSON.stringify(root))
107
+ throw Error("frontend root observations changed during collection");
108
+ const knownFailure = root.complete && ["failed", "partial_failed"].includes(lastState.status);
109
+ const consumed = root.cumulativeBudget?.consumed;
110
+ // Reservations precede dispatch and can survive a failed dispatch. The root
111
+ // timer starts at DAG allocation, after task/source preparation. Keep these
112
+ // useful facts, but do not relabel them as task-wide request/time metrics.
113
+ const observedBudget = {
114
+ reservedRequests: root.complete ? consumed?.providerRequests ?? null : null,
115
+ cumulativeTokens: root.complete && consumed?.missingTokenNodeIds.length === 0 ? consumed.tokens : null,
116
+ dagWallClockMs: root.dagWallClockMs,
117
+ };
118
+ const metricDiagnostics = ["task-provider-request-count-not-observed", "task-wallclock-not-observed"];
119
+ if (!root.sourceUsage?.complete)
120
+ metricDiagnostics.push("task-source-usage-not-observed");
121
+ const observation = {
122
+ cellId: binding.frontendPair.cellId, bundleHash: binding.bundleHash, runId: root.runIds[0],
123
+ firstPassed: root.complete && root.runIds.length === 1 ? (knownFailure ? false : productVerdict) : root.complete && ["failed", "partial_failed"].includes(firstStatus) ? false : null,
124
+ finalPassed: root.complete ? (knownFailure ? false : productVerdict) : null,
125
+ requests: null,
126
+ tokens: root.sourceUsage?.complete ? observedBudget.cumulativeTokens : null,
127
+ wallClockMs: null,
128
+ };
129
+ return { campaignId: binding.campaignId, observation, outcome: root.complete ? lastState.status : null,
130
+ diagnostics: [...root.diagnostics, ...metricDiagnostics, ...(gatewayObservation?.diagnostics ?? ["gateway-snapshots-not-supplied"]), ...(corpusBrowserEvidence?.diagnostics ?? acceptance?.diagnostics ?? ["independent-acceptance-pending"]), ...(sharedEvidence?.diagnostics ?? ["shared-identity-admission-pending"])],
131
+ acceptance, corpusBrowserEvidence, sharedEvidence, observedBudget, gatewayObservation,
132
+ runs: snapshots.map((snapshot, index) => ({ runId: root.runIds[index], stateSha256: createHash("sha256").update(snapshot.state).digest("hex"), runSha256: createHash("sha256").update(snapshot.spec).digest("hex") })) };
133
+ }
134
+ export async function collectFrontendTaskPairSummary(input) {
135
+ const stored = await readCampaignPlan(input.repoRoot, input.plan.campaignId);
136
+ if (!stored || input.plan.planHash !== stored.planHash || computeCampaignPlanHash(input.plan.campaignId, input.plan.cells) !== stored.planHash)
137
+ throw Error("frontend summary registered plan mismatch");
138
+ const collected = [];
139
+ for (const runId of input.runIds)
140
+ collected.push(await readFrontendTaskRunObservation({ repoRoot: input.repoRoot, runId, corpusBrowser: input.corpusBrowserByRun?.[runId], gatewaySnapshots: input.gatewaySnapshotsByRun?.[runId], acceptance: input.acceptanceByRun?.[runId] }));
141
+ if (collected.some(row => row.campaignId !== input.plan.campaignId))
142
+ throw Error("frontend summary campaign mismatch");
143
+ return { summary: summarizeFrontendTaskPairs({ ...input, observations: collected.map(row => row.observation) }), collected };
144
+ }
@@ -0,0 +1,105 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { readFileBoundarySafe } from "../../shared/path-safety.js";
5
+ import { sha256OfCanonicalJson } from "../../task/contract/hash.js";
6
+ import { readDagRunSpec } from "../../workflows/dag/lifecycle.js";
7
+ import { readFrontendSessionBudgetReceipts } from "../../workflows/dag/frontend-session-budget.js";
8
+ import { verifyFrozenSourceUsage } from "../../task/source-prepare/source-provider-budget.js";
9
+ import { frontendSharedArtifactsSchema } from "./frontend-pair-registration.js";
10
+ export async function readFrontendSharedEvidence(input) {
11
+ if (!input.runDirs.length)
12
+ throw Error("shared evidence requires a root run");
13
+ const artifacts = frontendSharedArtifactsSchema.parse(input.artifacts);
14
+ const snapshots = [];
15
+ const read = async (root, name, expected) => {
16
+ const target = path.resolve(root, name);
17
+ const bytes = await readFileBoundarySafe({ boundaryRoot: root, targetPath: target, label: "frontend shared evidence" });
18
+ if (expected && createHash("sha256").update(bytes).digest("hex") !== expected)
19
+ throw Error(`shared artifact hash mismatch: ${name}`);
20
+ snapshots.push({ root, target, bytes });
21
+ return JSON.parse(bytes.toString("utf8"));
22
+ };
23
+ const values = {};
24
+ for (const key of ["source", "budget", "model", "initialTree"]) {
25
+ if (artifacts[key].sha256 !== input.expected[key])
26
+ throw Error(`shared artifact not registered: ${key}`);
27
+ values[key] = await read(input.repoRoot, artifacts[key].path, artifacts[key].sha256);
28
+ }
29
+ const model = z.object({ model: z.string().min(1), parameters: z.record(z.unknown()).optional() }).strict().parse(values.model);
30
+ const diagnostics = [];
31
+ if (model.parameters && Object.keys(model.parameters).length)
32
+ diagnostics.push("model-sampling-parameters-not-observed");
33
+ const expectedTree = z.object({ gitHeadSha: z.string().regex(/^[a-f0-9]{40,64}$/), changedPaths: z.array(z.unknown()), stableGovernanceInputs: z.array(z.unknown()) }).strict().parse(values.initialTree);
34
+ const checkpoint = await read(input.runDirs[0], ".runtime/workspace-checkpoint-start.json");
35
+ const actualTree = { gitHeadSha: checkpoint.gitHeadSha, changedPaths: checkpoint.changedPaths, stableGovernanceInputs: checkpoint.stableGovernanceInputs ?? [] };
36
+ if (sha256OfCanonicalJson(actualTree) !== sha256OfCanonicalJson(expectedTree))
37
+ throw Error("shared initial tree mismatch");
38
+ let attempts = 0;
39
+ const inspected = new Map();
40
+ const inspect = (receipt) => {
41
+ if (receipt.identity.executionKind !== "model")
42
+ return;
43
+ const key = JSON.stringify([receipt.identity.runId ?? receipt.identity.taskId, receipt.identity.sessionId]);
44
+ const contentHash = sha256OfCanonicalJson(receipt);
45
+ if (inspected.has(key)) {
46
+ if (inspected.get(key) !== contentHash)
47
+ throw Error("conflicting shared model receipt");
48
+ return;
49
+ }
50
+ inspected.set(key, contentHash);
51
+ if (receipt.completion !== "complete" || !receipt.attempts.length || !receipt.planned?.capabilities.model)
52
+ diagnostics.push("shared model observation incomplete");
53
+ if (receipt.planned?.capabilities.model && receipt.planned.capabilities.model !== model.model)
54
+ throw Error("shared planned model mismatch");
55
+ for (const attempt of receipt.attempts) {
56
+ if (attempt.model !== model.model)
57
+ throw Error("shared attempt model mismatch");
58
+ if (attempt.completion !== "complete")
59
+ diagnostics.push("shared model observation incomplete");
60
+ attempts++;
61
+ }
62
+ };
63
+ const sourceReceipts = new Set();
64
+ for (const runDir of input.runDirs) {
65
+ await read(runDir, "run.json");
66
+ const spec = await readDagRunSpec(runDir);
67
+ if (sha256OfCanonicalJson(spec.sourceBinding ?? null) !== sha256OfCanonicalJson(values.source))
68
+ throw Error("shared source mismatch");
69
+ if (sha256OfCanonicalJson(spec.budget ?? null) !== sha256OfCanonicalJson(values.budget))
70
+ throw Error("shared budget mismatch");
71
+ const state = await read(runDir, "state.json");
72
+ for (const task of spec.tasks) {
73
+ const receipts = await readFrontendSessionBudgetReceipts(runDir, task.id);
74
+ const node = state.nodes?.[task.id];
75
+ if (task.executor === "pi" && node && !["PENDING", "SKIPPED"].includes(node.status) && node.origin?.kind !== "imported" && !receipts.length)
76
+ diagnostics.push(`missing model receipts: ${task.id}`);
77
+ for (const receipt of receipts)
78
+ inspect(receipt);
79
+ }
80
+ if (spec.sourceProviderBudget) {
81
+ const verified = await verifyFrozenSourceUsage(input.repoRoot, spec.sourceProviderBudget);
82
+ diagnostics.push(...verified.diagnostics);
83
+ for (const source of spec.sourceProviderBudget.evidence?.receipts ?? []) {
84
+ if (sourceReceipts.has(source.sha256))
85
+ continue;
86
+ sourceReceipts.add(source.sha256);
87
+ inspect(JSON.parse(source.content));
88
+ }
89
+ }
90
+ }
91
+ if (!attempts)
92
+ diagnostics.push("shared model has no observed attempts");
93
+ // Sampling settings are not yet present in session receipts; matching model
94
+ // names alone cannot prove every parameter used by the SDK/provider.
95
+ if (!diagnostics.includes("model-sampling-parameters-not-observed"))
96
+ diagnostics.push("model-sampling-parameters-not-observed");
97
+ diagnostics.push("provider-resolved-model-not-observed");
98
+ for (const snapshot of snapshots) {
99
+ const latest = await readFileBoundarySafe({ boundaryRoot: snapshot.root, targetPath: snapshot.target, label: "shared evidence recheck" });
100
+ if (!latest.equals(snapshot.bytes))
101
+ throw Error("shared evidence changed during collection");
102
+ }
103
+ return { diagnostics: [...new Set(diagnostics)], model: model.model, observedAttempts: attempts,
104
+ verified: { source: true, budget: true, initialTree: true, modelReference: attempts > 0 && diagnostics.every(item => ["model-sampling-parameters-not-observed", "provider-resolved-model-not-observed"].includes(item)), samplingParameters: false } };
105
+ }
@@ -265,18 +265,51 @@ const corpusManifestObjectSchema = z
265
265
  .optional(),
266
266
  })
267
267
  .strict();
268
- export const corpusManifestInputSchema = corpusManifestObjectSchema.superRefine(refineCorpusTasks);
269
- export const corpusManifestSchema = z
270
- .object({
271
- schemaVersion: z.literal(1),
272
- corpusId: corpusIdSchema,
273
- createdAt: z.string().min(1),
274
- description: z.string().optional(),
275
- tasks: z.array(corpusTaskSchema).min(12).max(20),
276
- corpusHash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
277
- })
278
- .strict()
279
- .superRefine(refineCorpusTasks);
268
+ // The six-case exploratory frontend protocol is distinct from the heterogeneous
269
+ // dogfood corpus. All six are public execution tasks, not held-out claims.
270
+ const frontendCorpusCategories = [
271
+ "local-interaction", "form-validation", "component-reuse",
272
+ "table-filter-pagination", "multi-api-mock-boundary", "shared-context-domains",
273
+ ];
274
+ const frontendCorpusFileSchema = z.object({
275
+ path: z.string().min(1).refine(value => !value.includes("\\") && !value.includes(":") &&
276
+ !value.startsWith("/") && value.split("/").every(part => part !== "" && part !== "." && part !== ".."), "corpus artifact must be a normalized relative file path"),
277
+ sha256: z.string().regex(/^[a-f0-9]{64}$/),
278
+ }).strict();
279
+ const frontendCorpusFilesSchema = z.array(frontendCorpusFileSchema).min(1).superRefine((files, ctx) => {
280
+ if (new Set(files.map(file => file.path)).size !== files.length)
281
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "duplicate corpus artifact path" });
282
+ });
283
+ const frontendCorpusTaskSchema = corpusTaskSchema.extend({
284
+ class: z.literal("frontend"), split: z.literal("public"),
285
+ category: z.enum(frontendCorpusCategories),
286
+ requirements: frontendCorpusFilesSchema,
287
+ initialFiles: frontendCorpusFilesSchema,
288
+ oracleFiles: frontendCorpusFilesSchema,
289
+ }).superRefine((task, ctx) => {
290
+ if (task.seeds.length !== 3)
291
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "frontend corpus requires three planned repetitions" });
292
+ });
293
+ const frontendCorpusObjectSchema = z.object({
294
+ schemaVersion: z.literal(2), purpose: z.literal("frontend-task-pairs"),
295
+ corpusId: corpusIdSchema, createdAt: z.string().datetime(), description: z.string().optional(),
296
+ tasks: z.array(frontendCorpusTaskSchema).length(6),
297
+ corpusHash: z.string().regex(/^(sha256:)?[a-f0-9]{64}$/i).optional(),
298
+ }).strict();
299
+ function refineFrontendCorpus(manifest, ctx) {
300
+ if (new Set(manifest.tasks.map(task => task.category)).size !== frontendCorpusCategories.length)
301
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "frontend corpus requires all six categories exactly once" });
302
+ if (new Set(manifest.tasks.map(task => task.taskRef)).size !== manifest.tasks.length)
303
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "duplicate frontend corpus taskRef" });
304
+ }
305
+ export const corpusManifestInputSchema = z.union([
306
+ corpusManifestObjectSchema.superRefine(refineCorpusTasks),
307
+ frontendCorpusObjectSchema.superRefine(refineFrontendCorpus),
308
+ ]);
309
+ export const corpusManifestSchema = z.union([
310
+ corpusManifestObjectSchema.extend({ corpusHash: z.string().regex(/^sha256:[a-f0-9]{64}$/) }).superRefine(refineCorpusTasks),
311
+ frontendCorpusObjectSchema.extend({ corpusHash: z.string().regex(/^sha256:[a-f0-9]{64}$/) }).superRefine(refineFrontendCorpus),
312
+ ]);
280
313
  // --- Private Verifier + Campaign (M4) ---
281
314
  export const privateVerifierIdSchema = z
282
315
  .string()
@@ -198,6 +198,10 @@ export async function listExplicitlyAssociatedRuns(repoRoot, taskId) {
198
198
  const recorded = new Map((record?.runAssociations ?? []).map((entry) => [entry.runId, entry.source]));
199
199
  const entries = await listAllDagRunEntries(repoRoot);
200
200
  const related = [];
201
+ // Recency key per run. `runId` is NOT chronological (a later recovery run can
202
+ // sort before an earlier one lexicographically, e.g. `dag-…` vs `2026…`), so
203
+ // ordering by runId alone reports a superseded run as the latest.
204
+ const startedAtByRunId = new Map();
201
205
  for (const entry of entries) {
202
206
  let source = recorded.get(entry.runId) ?? null;
203
207
  let stateStatus;
@@ -207,6 +211,9 @@ export async function listExplicitlyAssociatedRuns(repoRoot, taskId) {
207
211
  try {
208
212
  const state = await readDagRunState(entry.runDir);
209
213
  stateStatus = state.status;
214
+ if (typeof state.startedAt === "string") {
215
+ startedAtByRunId.set(entry.runId, state.startedAt);
216
+ }
210
217
  const liveness = assessDagRunLiveness({ state });
211
218
  const activeNode = Object.values(state.nodes ?? {}).find((node) => node.status === "RUNNING");
212
219
  lastMeaningfulProgressAt =
@@ -266,7 +273,25 @@ export async function listExplicitlyAssociatedRuns(repoRoot, taskId) {
266
273
  contractBinding,
267
274
  });
268
275
  }
269
- return related.sort((a, b) => a.runId.localeCompare(b.runId));
276
+ // Chronological order by run start time; runs without a readable start time
277
+ // sort first so a run with real timing is never displaced by an unknown.
278
+ // runId breaks ties deterministically.
279
+ return related.sort((a, b) => {
280
+ const aStarted = startedAtByRunId.get(a.runId);
281
+ const bStarted = startedAtByRunId.get(b.runId);
282
+ if (aStarted !== bStarted) {
283
+ if (aStarted === undefined)
284
+ return -1;
285
+ if (bStarted === undefined)
286
+ return 1;
287
+ const aTime = Date.parse(aStarted);
288
+ const bTime = Date.parse(bStarted);
289
+ if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) {
290
+ return aTime - bTime;
291
+ }
292
+ }
293
+ return a.runId.localeCompare(b.runId);
294
+ });
270
295
  }
271
296
  function deriveLifecycleState(input) {
272
297
  if (!input.exists)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.44.0-next.8",
4
- "gitSha": "e100bbbeba403ae8cbe6c9a93ccce76cbadf434e",
5
- "builtAt": "2026-09-16T23:48:41.706Z"
3
+ "version": "0.44.0-next.9",
4
+ "gitSha": "e4db199463a7b550af890295b630d91660e4239f",
5
+ "builtAt": "2026-09-17T01:09:20.426Z"
6
6
  }
@@ -1,4 +1,5 @@
1
1
  import { runDoctor } from "../commands/doctor.js";
2
+ import { runPromoteRun } from "../commands/promote-run.js";
2
3
  import { runDocsArchive } from "../commands/docs-archive.js";
3
4
  import { runDocsAudit } from "../commands/docs-audit.js";
4
5
  import { runEval } from "../commands/eval.js";
@@ -197,6 +198,16 @@ export const COMMAND_DEFINITIONS = [
197
198
  await runDoctor(adapter, repoRoot, rest.filter((arg) => Boolean(arg)));
198
199
  },
199
200
  },
201
+ {
202
+ name: "promote-run",
203
+ adapter: "required",
204
+ tier: "operator",
205
+ intent: "Promote deterministic evidence from a completed DAG or one-shot run into task artifacts (修改记录.md, 验证结果.md).",
206
+ usage: "promote-run <task-id> --run-id <run-id>",
207
+ handler: async ({ adapter, repoRoot, rest }) => {
208
+ await runPromoteRun(repoRoot, rest.filter((arg) => Boolean(arg)));
209
+ },
210
+ },
200
211
  {
201
212
  name: "docs",
202
213
  adapter: "required",
@@ -29,6 +29,7 @@ import { runDocsArchive } from "../commands/docs-archive.js";
29
29
  import { runDocsAudit } from "../commands/docs-audit.js";
30
30
  import { runEval } from "../commands/eval.js";
31
31
  import { runDoctor } from "../commands/doctor.js";
32
+ import { runPromoteRun } from "../commands/promote-run.js";
32
33
  import { runExamples } from "../commands/examples.js";
33
34
  import { runGoal } from "../commands/goal.js";
34
35
  import { runHandoffCheck } from "../commands/handoff-check.js";
@@ -149,6 +150,9 @@ async function runCommanderAction(ctx, command, subcommand, rest) {
149
150
  case "doctor":
150
151
  await runDoctor(ctx.adapter, ctx.repoRoot, compactArgs([subcommand, ...rest]));
151
152
  return;
153
+ case "promote-run":
154
+ await runPromoteRun(ctx.repoRoot, compactArgs([subcommand, ...rest]));
155
+ return;
152
156
  case "docs":
153
157
  await runDocsCommand(ctx.repoRoot, subcommand, rest);
154
158
  return;
@@ -1,3 +1,4 @@
1
+ import { capabilityBoundarySchema } from "../task/config-types.js";
1
2
  import { advanceTaskLifecycle, } from "../application/task-lifecycle/index.js";
2
3
  import { backendTestConfigSchema, } from "../task/config-types.js";
3
4
  import { buildOperatorResult, operatorFailed, processExitCodeForOutcome, writeOperatorJson, } from "../shared/operator/index.js";
@@ -145,13 +146,10 @@ export function parseTaskAdvanceArgs(args) {
145
146
  if (token === "--capability-boundary") {
146
147
  try {
147
148
  const parsed = JSON.parse(next());
148
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
149
- throw new Error("not an object");
150
- }
151
- options.capabilityBoundary = parsed;
149
+ options.capabilityBoundary = capabilityBoundarySchema.parse(parsed);
152
150
  }
153
151
  catch (error) {
154
- throw new Error(`--capability-boundary must be a JSON object {allowed?, forbidden?}: ${error.message}`);
152
+ throw new Error(`--capability-boundary must be a JSON object {allowed?, forbidden?, requiredClauses?}: ${error.message}`);
155
153
  }
156
154
  continue;
157
155
  }
@@ -1,7 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { stdin as stdinStream } from "node:process";
4
- import { parseAcceptanceFlag, parseVerifyFlag, parseVerifyTimeoutFlag, prepareTaskSource, PREPARE_MAX_FILE_BYTES, } from "../task/source-prepare/index.js";
4
+ import { parseAcceptanceFlag, parseVerifyFlag, parseVerifyLaneFlag, parseVerifyTimeoutFlag, prepareTaskSource, PREPARE_MAX_FILE_BYTES, } from "../task/source-prepare/index.js";
5
5
  import { parseTaskContractDraft } from "../task/contract/schema.js";
6
6
  import { buildOperatorResult, operatorFailed, processExitCodeForOutcome, writeOperatorJson, } from "../shared/operator/index.js";
7
7
  const COMMAND = "task source prepare";
@@ -28,6 +28,7 @@ const USAGE = `usage:
28
28
  [--open-question <text>]...
29
29
  [--verify <label:command>]...
30
30
  [--verify-timeout <label:timeoutMs>]...
31
+ [--verify-lane <label:static|behavior>]...
31
32
  [--request-id <id>]
32
33
  [--dry-run | --apply]
33
34
  [--force-overwrite-source]
@@ -157,6 +158,10 @@ export function parseTaskSourcePrepareArgs(args) {
157
158
  options.verifyTimeout = pushList(options.verifyTimeout, next());
158
159
  continue;
159
160
  }
161
+ if (token === "--verify-lane") {
162
+ options.verifyLane = pushList(options.verifyLane, next());
163
+ continue;
164
+ }
160
165
  if (token === "--request-id") {
161
166
  options.requestId = next();
162
167
  continue;
@@ -287,13 +292,20 @@ function buildFlags(options) {
287
292
  const parsed = parseVerifyTimeoutFlag(raw);
288
293
  timeouts.set(parsed.label, parsed.timeoutMs);
289
294
  }
295
+ const lanes = new Map();
296
+ for (const raw of options.verifyLane ?? []) {
297
+ const parsed = parseVerifyLaneFlag(raw);
298
+ lanes.set(parsed.label, parsed.mode);
299
+ }
290
300
  const commands = (options.verify ?? []).map((raw) => {
291
301
  const parsed = parseVerifyFlag(raw);
292
302
  const timeoutMs = timeouts.get(parsed.label);
303
+ const mode = lanes.get(parsed.label);
293
304
  return {
294
305
  label: parsed.label,
295
306
  command: parsed.command,
296
307
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
308
+ ...(mode !== undefined ? { mode } : {}),
297
309
  };
298
310
  });
299
311
  // If only timeouts provided without verify, treat as empty explicit list? No — only set when --verify appears.
@@ -16,7 +16,7 @@ export async function createFrontendDesignTerminalTools(input) {
16
16
  import("typebox"),
17
17
  import("@earendil-works/pi-coding-agent"),
18
18
  ]);
19
- const { approveDesignFactSchema, readCommittedEvents, requestDesignChangesFactSchema, writeTypedEventStoreJsonl, } = await import("../../../workflows/dag/frontend-typed-event-store.js");
19
+ const { approveDesignFactSchema, readCommittedEvents, requestDesignChangesFactSchema, reviewFindingSchema, writeTypedEventStoreJsonl, } = await import("../../../workflows/dag/frontend-typed-event-store.js");
20
20
  const { adoptTypedEventFact, stageTypedEventFact } = await import("../../../workflows/dag/frontend-typed-event-transaction.js");
21
21
  let store = input.store;
22
22
  const attemptId = input.attemptId;
@@ -26,6 +26,7 @@ export async function createFrontendDesignTerminalTools(input) {
26
26
  line: Type.Optional(Type.Integer({ minimum: 1 })),
27
27
  issue: Type.String({ minLength: 1 }),
28
28
  requiredChange: Type.Optional(Type.String({ minLength: 1 })),
29
+ repairAssertions: Type.Optional(Type.Unknown()),
29
30
  }, { additionalProperties: false });
30
31
  const scopeProtocol = createFrontendReviewScopeProtocol({ phase: "design", inventory: input.inventory, getStore: () => store, attemptId });
31
32
  const savedFindings = () => [...new Map(readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "design-finding").map(r => [r.fact.id, r.fact.finding])).values()];
@@ -35,12 +36,21 @@ export async function createFrontendDesignTerminalTools(input) {
35
36
  description: "Save one finding with a stable id. Submit findings incrementally, then finalize without repeating the findings array. Saved blocking findings cannot be omitted from approval; correct a finding explicitly with replace:true.",
36
37
  parameters: Type.Object({ id: Type.String({ minLength: 1 }), finding: findingSchema }, { additionalProperties: false }),
37
38
  async execute(_callId, params) {
38
- const fact = { kind: "design-finding", id: params.id, finding: params.finding };
39
- const requestId = `${attemptId}:finding:${randomUUID()}`;
40
- const staged = stageTypedEventFact({ store, requestId, attemptId, fact });
41
- const adopted = await adoptTypedEventFact({ store, requestId, attemptId, fact, eventId: staged.eventId, expectedRevision: store.revision });
42
- const details = { ok: true, eventId: adopted.eventId, revision: adopted.revision };
43
- return { content: [{ type: "text", text: JSON.stringify(details) }], details };
39
+ try {
40
+ const previous = readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "design-finding" && r.fact.id === params.id).at(-1);
41
+ const scopeIds = scopeProtocol.findingScopeIds(previous?.fact.scopeIds);
42
+ const fact = { kind: "design-finding", id: params.id, finding: reviewFindingSchema.parse(params.finding), ...(scopeIds ? { scopeIds } : {}) };
43
+ const requestId = `${attemptId}:finding:${randomUUID()}`;
44
+ const staged = stageTypedEventFact({ store, requestId, attemptId, fact });
45
+ const adopted = await adoptTypedEventFact({ store, requestId, attemptId, fact, eventId: staged.eventId, expectedRevision: store.revision });
46
+ const details = { ok: true, eventId: adopted.eventId, revision: adopted.revision };
47
+ return { content: [{ type: "text", text: JSON.stringify(details) }], details };
48
+ }
49
+ catch (error) {
50
+ const code = error.code ?? "FRONTEND_INPUT_SCOPE_VIOLATION";
51
+ const details = { ok: false, code, error: error instanceof Error ? error.message : String(error) };
52
+ return { content: [{ type: "text", text: JSON.stringify(details) }], details };
53
+ }
44
54
  },
45
55
  });
46
56
  const approveParameters = Type.Object({
@@ -16,7 +16,7 @@ export async function createFrontendReviewTerminalTools(input) {
16
16
  import("typebox"),
17
17
  import("@earendil-works/pi-coding-agent"),
18
18
  ]);
19
- const { approveReviewFactSchema, readCommittedEvents, requestReviewChangesFactSchema, writeTypedEventStoreJsonl, } = await import("../../../workflows/dag/frontend-typed-event-store.js");
19
+ const { approveReviewFactSchema, readCommittedEvents, requestReviewChangesFactSchema, reviewFindingSchema, writeTypedEventStoreJsonl, } = await import("../../../workflows/dag/frontend-typed-event-store.js");
20
20
  const { adoptTypedEventFact, stageTypedEventFact } = await import("../../../workflows/dag/frontend-typed-event-transaction.js");
21
21
  let store = input.store;
22
22
  const attemptId = input.attemptId;
@@ -26,6 +26,7 @@ export async function createFrontendReviewTerminalTools(input) {
26
26
  line: Type.Optional(Type.Integer({ minimum: 1 })),
27
27
  issue: Type.String({ minLength: 1 }),
28
28
  requiredChange: Type.Optional(Type.String({ minLength: 1 })),
29
+ repairAssertions: Type.Optional(Type.Unknown()),
29
30
  }, { additionalProperties: false });
30
31
  const scopeProtocol = createFrontendReviewScopeProtocol({ phase: "review", inventory: input.inventory, getStore: () => store, attemptId });
31
32
  const savedFindings = () => [...new Map(readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "review-finding").map(r => [r.fact.id, r.fact.finding])).values()];
@@ -35,12 +36,21 @@ export async function createFrontendReviewTerminalTools(input) {
35
36
  description: "Save one finding with a stable id. Submit findings incrementally, then finalize without repeating the findings array. Saved blocking findings cannot be omitted from approval; correct a finding explicitly with replace:true.",
36
37
  parameters: Type.Object({ id: Type.String({ minLength: 1 }), finding: findingSchema }, { additionalProperties: false }),
37
38
  async execute(_callId, params) {
38
- const fact = { kind: "review-finding", id: params.id, finding: params.finding };
39
- const requestId = `${attemptId}:finding:${randomUUID()}`;
40
- const staged = stageTypedEventFact({ store, requestId, attemptId, fact });
41
- const adopted = await adoptTypedEventFact({ store, requestId, attemptId, fact, eventId: staged.eventId, expectedRevision: store.revision });
42
- const details = { ok: true, eventId: adopted.eventId, revision: adopted.revision };
43
- return { content: [{ type: "text", text: JSON.stringify(details) }], details };
39
+ try {
40
+ const previous = readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "review-finding" && r.fact.id === params.id).at(-1);
41
+ const scopeIds = scopeProtocol.findingScopeIds(previous?.fact.scopeIds);
42
+ const fact = { kind: "review-finding", id: params.id, finding: reviewFindingSchema.parse(params.finding), ...(scopeIds ? { scopeIds } : {}) };
43
+ const requestId = `${attemptId}:finding:${randomUUID()}`;
44
+ const staged = stageTypedEventFact({ store, requestId, attemptId, fact });
45
+ const adopted = await adoptTypedEventFact({ store, requestId, attemptId, fact, eventId: staged.eventId, expectedRevision: store.revision });
46
+ const details = { ok: true, eventId: adopted.eventId, revision: adopted.revision };
47
+ return { content: [{ type: "text", text: JSON.stringify(details) }], details };
48
+ }
49
+ catch (error) {
50
+ const code = error.code ?? "FRONTEND_INPUT_SCOPE_VIOLATION";
51
+ const details = { ok: false, code, error: error instanceof Error ? error.message : String(error) };
52
+ return { content: [{ type: "text", text: JSON.stringify(details) }], details };
53
+ }
44
54
  },
45
55
  });
46
56
  const approveParameters = Type.Object({