@tea-agent/loop-agent 0.26.5-beta.2 → 0.27.0

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 (71) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +13 -23
  4. package/dist/application/dag/args.js +7 -7
  5. package/dist/application/dag/generate-task-dag.js +2 -2
  6. package/dist/application/dag/run-dag.js +3 -4
  7. package/dist/application/dag/validate-dag.js +1 -1
  8. package/dist/application/task-lifecycle/advance.js +845 -0
  9. package/dist/application/task-lifecycle/gates.js +80 -0
  10. package/dist/application/task-lifecycle/index.js +7 -0
  11. package/dist/application/task-lifecycle/observe.js +395 -0
  12. package/dist/application/task-lifecycle/plan-transitions.js +224 -0
  13. package/dist/application/task-lifecycle/recommendations.js +180 -0
  14. package/dist/application/task-lifecycle/record.js +73 -0
  15. package/dist/application/task-lifecycle/types.js +1 -0
  16. package/dist/cli/command-definitions.js +14 -111
  17. package/dist/cli/help.js +6 -7
  18. package/dist/cli/program.js +26 -90
  19. package/dist/cli/update/policy.js +0 -1
  20. package/dist/commands/dag-final-verification.js +1 -1
  21. package/dist/commands/dag-init-hybrid.js +1 -1
  22. package/dist/commands/delegate.js +2 -2
  23. package/dist/commands/init.js +21 -19
  24. package/dist/commands/run-dag-progress.js +1 -1
  25. package/dist/commands/status.js +18 -17
  26. package/dist/commands/study-init.js +2 -2
  27. package/dist/commands/task-advance.js +335 -0
  28. package/dist/commands/task-contract.js +3 -5
  29. package/dist/commands/task-source-prepare.js +9 -4
  30. package/dist/commands/task-status.js +133 -0
  31. package/dist/governance/manifest-types.js +2 -2
  32. package/dist/shared/operator/capabilities.js +1358 -243
  33. package/dist/task/contract/adopt.js +1 -1
  34. package/dist/task/contract/apply.js +1 -1
  35. package/dist/task/contract/import-revision.js +1 -1
  36. package/dist/task/contract/recover.js +2 -2
  37. package/dist/task/read-model.js +18 -23
  38. package/dist/task/runtime.js +1 -1
  39. package/dist/task/source-prepare/completeness.js +1 -1
  40. package/dist/task/source-prepare/parse-intent.js +6 -1
  41. package/dist/task/source-prepare/prepare.js +23 -20
  42. package/dist/worker/console/operator-actions.js +288 -95
  43. package/dist/worker/console/recovery-cta.js +4 -4
  44. package/dist/worker/console/static/assets/{index-CSRIhuzh.js → index-CNO7n6qB.js} +1 -1
  45. package/dist/worker/console/static/index.html +1 -1
  46. package/dist/worker/materialize/harness-task-materializer.js +6 -5
  47. package/dist/worker/run-task/run-task.js +204 -112
  48. package/dist/worker/runner/run-ready.js +1 -1
  49. package/dist/workflows/dag/frontend-implementation-contract.js +6 -72
  50. package/dist/workflows/dag/frontend-prewrite-gate.js +4 -17
  51. package/dist/workflows/dag/init-hybrid.js +11 -66
  52. package/docs/templates/evaluation/agents-map-slim-v1.md +1 -1
  53. package/docs/templates/evaluation/agents-map-verbose-v0.md +3 -3
  54. package/docs/templates/harness.schema.json +2 -2
  55. package/docs/templates/init-managed-agents.md +13 -16
  56. package/docs/templates/production-readiness-checklist.md +3 -3
  57. package/harness.json +4 -4
  58. package/package.json +1 -1
  59. package/scripts/kb-bootstrap-init-skeleton.sh +2 -1
  60. package/scripts/kb-graph-incremental-prepare.mjs +2 -2
  61. package/skills/loop-agent/SKILL.md +18 -13
  62. package/skills/loop-agent/references/README.md +1 -1
  63. package/skills/loop-agent/references/command-reference.md +55 -84
  64. package/skills/loop-agent/references/harness-policy.md +19 -23
  65. package/skills/loop-agent/references/hybrid-dag.md +31 -33
  66. package/skills/loop-agent/references/long-running-loop.md +2 -2
  67. package/skills/loop-agent/references/one-shot-runs.md +4 -5
  68. package/skills/loop-agent/references/orchestrator-and-interventions.md +3 -3
  69. package/skills/loop-agent/references/post-implementation-and-patterns.md +4 -4
  70. package/skills/loop-agent/references/source-and-plan-practice.md +45 -51
  71. package/skills/loop-agent/references/task-workflow.md +14 -15
@@ -3,7 +3,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { writeTextAtomic } from "../../infrastructure/harness/atomic-write.js";
5
5
  import { getTaskPaths } from "../../task/runtime.js";
6
- import { formatDuration, noopProgressReporter } from "../progress-reporter.js";
6
+ import { formatDuration, noopProgressReporter, } from "../progress-reporter.js";
7
7
  import { redactForPreview, truncatePreview } from "../observability/events.js";
8
8
  import { controllerIdentityExpectationFailure, resolveControllerIdentity, } from "../loop-agent/loop-agent-client.js";
9
9
  import { materializeTaskSpec, } from "../materialize/harness-task-materializer.js";
@@ -33,12 +33,19 @@ export async function runTaskSpec(options) {
33
33
  const preflightStep = "preflight";
34
34
  const preflightStartedAt = Date.now();
35
35
  await runObservedStep(eventCtx, preflightStep, async () => {
36
- progress.step("preflight: version + inspect + docs-audit + git-status" + (options.preflight && typeof options.preflight === "object" && options.preflight.runCheckRepo ? " + check-repo" : ""));
36
+ progress.step("preflight: version + inspect + docs-audit + git-status" +
37
+ (options.preflight &&
38
+ typeof options.preflight === "object" &&
39
+ options.preflight.runCheckRepo
40
+ ? " + check-repo"
41
+ : ""));
37
42
  const preflight = await preflightTargetRepo({
38
43
  repoRoot: options.repoRoot,
39
44
  client,
40
45
  ...(typeof options.preflight === "object" ? options.preflight : {}),
41
- ...(options.controllerExpectation ? { expectation: options.controllerExpectation } : {}),
46
+ ...(options.controllerExpectation
47
+ ? { expectation: options.controllerExpectation }
48
+ : {}),
42
49
  });
43
50
  if (!preflight.ok) {
44
51
  progress.note(`preflight FAILED: ${preflight.code}`);
@@ -65,81 +72,95 @@ export async function runTaskSpec(options) {
65
72
  await mkdir(taskArtifactsDir, { recursive: true });
66
73
  const dagPath = path.join(taskArtifactsDir, `${workerRunId}-dag.json`);
67
74
  const runRecordPath = path.join(taskArtifactsDir, "worker-run-record.json");
68
- const dagGenStep = "dag-run-task";
69
- const dagGenStartedAt = Date.now();
70
- await runObservedStep(eventCtx, dagGenStep, async () => {
71
- progress.step("dag run-task: generate DAG");
72
- await runRequiredCommand(options.repoRoot, client, "dag-run-task", [
73
- "dag",
74
- "run-task",
75
+ const canonicalDraftPath = getTaskPaths(options.repoRoot, materializeManifest.harnessTaskId).dagDraftPath;
76
+ // Standard lifecycle: task advance to writeSet gate (generate + strict validate).
77
+ const prepareStep = "task-advance-prepare";
78
+ const prepareStartedAt = Date.now();
79
+ let gateToken = await runObservedStep(eventCtx, prepareStep, async () => {
80
+ progress.step("task advance: generate/validate DAG and open writeSet gate");
81
+ const result = await runRequiredCommand(options.repoRoot, client, "task-advance-prepare", [
82
+ "task",
83
+ "advance",
75
84
  materializeManifest.harnessTaskId,
76
85
  "--profile",
77
86
  materializeManifest.loopAgentProfile,
78
- "--strict-models",
79
- "--output",
87
+ "--dag-output",
80
88
  dagPath,
81
- "--cwd",
82
- options.repoRoot,
89
+ "--json",
83
90
  ], true, undefined, eventCtx);
91
+ return extractWriteSetGateToken(result, materializeManifest.harnessTaskId);
84
92
  });
85
- progress.step(`dag generated in ${formatDuration(Date.now() - dagGenStartedAt)}`);
93
+ progress.step(`task advance prepare finished in ${formatDuration(Date.now() - prepareStartedAt)}`);
86
94
  if (options.piModel) {
87
95
  const piModelStep = "pi-model-override";
88
96
  await runObservedStep(eventCtx, piModelStep, async () => {
89
97
  progress.step(`pi-model override: rewriting executorModels.pi → ${options.piModel}`);
98
+ // Rewrite both per-run copy and canonical draft so re-validate sees override.
99
+ await applyPiModelOverride(canonicalDraftPath, options.piModel);
90
100
  await applyPiModelOverride(dagPath, options.piModel);
91
101
  });
92
- }
93
- const validateStep = "dag-validate";
94
- const validateStartedAt = Date.now();
95
- await runObservedStep(eventCtx, validateStep, async () => {
96
- progress.step("dag validate");
97
- await runRequiredCommand(options.repoRoot, client, "dag-validate", [
98
- "dag",
99
- "validate",
100
- "--dag",
101
- dagPath,
102
- ...strictModelsArgs(options.piModel),
103
- "--strict-governance",
104
- "--spine-task",
105
- materializeManifest.harnessTaskId,
106
- ], true, undefined, eventCtx);
107
- });
108
- progress.step(`dag validated in ${formatDuration(Date.now() - validateStartedAt)}`);
109
- const canonicalDraftPath = getTaskPaths(options.repoRoot, materializeManifest.harnessTaskId).dagDraftPath;
110
- await runObservedStep(eventCtx, "publish-canonical-dag-draft", async () => {
111
- const dagBytes = await readFile(dagPath, "utf-8");
112
- await writeTextAtomic(canonicalDraftPath, dagBytes, {
113
- repoRoot: options.repoRoot,
102
+ // Re-open gate after draft rewrite (digest bound to DAG hash).
103
+ gateToken = await runObservedStep(eventCtx, "task-advance-revalidate", async () => {
104
+ progress.step("task advance: re-validate after piModel override (no strict-models)");
105
+ const result = await runRequiredCommand(options.repoRoot, client, "task-advance-revalidate", [
106
+ "task",
107
+ "advance",
108
+ materializeManifest.harnessTaskId,
109
+ "--profile",
110
+ materializeManifest.loopAgentProfile,
111
+ "--no-strict-models",
112
+ "--dag-output",
113
+ dagPath,
114
+ "--json",
115
+ ], true, undefined, eventCtx);
116
+ return extractWriteSetGateToken(result, materializeManifest.harnessTaskId);
114
117
  });
115
- });
116
- const runDagStep = "run-dag";
117
- const runDagStartedAt = Date.now();
118
+ }
119
+ const runAdvanceStep = "task-advance-execute";
120
+ const runAdvanceStartedAt = Date.now();
118
121
  const dagEventsPath = path.join(getTaskPoolRoot(options.repoRoot), "observability", "runs", workerRunId, "dag-events.jsonl");
119
122
  await mkdir(path.dirname(dagEventsPath), { recursive: true }).catch(() => { });
120
- await runObservedStep(eventCtx, runDagStep, async () => {
121
- progress.step("run-dag: executing DAG nodes (this is the long step)");
122
- await runDagWithEventsFallback(options.repoRoot, client, [
123
- "run-dag",
124
- "--dag",
125
- dagPath,
126
- "--cwd",
127
- options.repoRoot,
128
- "--run-id",
123
+ const workerAssociation = JSON.stringify({
124
+ schemaVersion: 1,
125
+ workerRunId,
126
+ featureId: options.taskSpec.feature_id,
127
+ taskId: options.taskSpec.id,
128
+ });
129
+ await runObservedStep(eventCtx, runAdvanceStep, async () => {
130
+ progress.step("task advance: approve writeSet gate and execute DAG (long step)");
131
+ const advanceResult = await runTaskAdvanceExecuteWithEventsFallback({
132
+ repoRoot: options.repoRoot,
133
+ client,
134
+ taskId: materializeManifest.harnessTaskId,
135
+ gateToken,
129
136
  workerRunId,
130
- "--worker-association",
131
- JSON.stringify({
132
- schemaVersion: 1,
133
- workerRunId,
134
- featureId: options.taskSpec.feature_id,
135
- taskId: options.taskSpec.id,
136
- }),
137
- "--events-jsonl",
137
+ workerAssociation,
138
138
  dagEventsPath,
139
- ...maxConcurrentArgs(options.taskSpec),
140
- ], resolveRunDagTimeoutMs(options.taskSpec), eventCtx);
139
+ maxConcurrent: options.taskSpec.loop_agent.max_concurrent,
140
+ timeoutMs: resolveRunDagTimeoutMs(options.taskSpec),
141
+ eventCtx,
142
+ });
143
+ // Terminal DAG failure is still a completed execute step for Worker: report
144
+ // decision + failure artifacts remain authoritative downstream.
145
+ if (!advanceResult.ok && !isTerminalTaskAdvanceFailure(advanceResult)) {
146
+ assertAdvanceExecuteOk(advanceResult, [
147
+ "task",
148
+ "advance",
149
+ materializeManifest.harnessTaskId,
150
+ "--approve-gate",
151
+ gateToken,
152
+ ]);
153
+ }
154
+ return advanceResult;
155
+ });
156
+ progress.step(`task advance execute finished in ${formatDuration(Date.now() - runAdvanceStartedAt)}`);
157
+ // Keep per-run DAG artifact aligned with canonical draft after execution prep.
158
+ await runObservedStep(eventCtx, "publish-per-run-dag-copy", async () => {
159
+ const dagBytes = await readFile(canonicalDraftPath, "utf-8");
160
+ await writeTextAtomic(dagPath, dagBytes, {
161
+ repoRoot: options.repoRoot,
162
+ });
141
163
  });
142
- progress.step(`run-dag finished in ${formatDuration(Date.now() - runDagStartedAt)}`);
143
164
  const reportStep = "report-decision";
144
165
  let capturedReportJson;
145
166
  const reportDecision = await runObservedStep(eventCtx, reportStep, async () => {
@@ -171,7 +192,9 @@ export async function runTaskSpec(options) {
171
192
  artifactName: "dag-report-markdown",
172
193
  });
173
194
  return decision;
174
- }, { statusForResult: (decision) => decision.succeeded ? "succeeded" : "failed" });
195
+ }, {
196
+ statusForResult: (decision) => decision.succeeded ? "succeeded" : "failed",
197
+ });
175
198
  const dagReportRun = extractDagReportRun(workerRunId, capturedReportJson);
176
199
  const declaredArtifacts = options.declaredArtifacts
177
200
  ? options.declaredArtifacts.map((entry) => ({
@@ -180,7 +203,9 @@ export async function runTaskSpec(options) {
180
203
  ...(entry.path ? { path: entry.path } : {}),
181
204
  }))
182
205
  : undefined;
183
- let status = reportDecision.succeeded ? "succeeded" : "failed";
206
+ let status = reportDecision.succeeded
207
+ ? "succeeded"
208
+ : "failed";
184
209
  progress.step(`report decision: ${status} (${reportDecision.reason}${reportDecision.runStatus ? `, status=${reportDecision.runStatus}` : ""})`);
185
210
  const failureArtifacts = reportDecision.succeeded
186
211
  ? undefined
@@ -237,7 +262,10 @@ export async function runTaskSpec(options) {
237
262
  });
238
263
  if (!projection.ok) {
239
264
  status = "failed";
240
- outcomeFailure = { category: projection.category, reason: projection.reason };
265
+ outcomeFailure = {
266
+ category: projection.category,
267
+ reason: projection.reason,
268
+ };
241
269
  progress.step(`outcome projection failed: ${projection.category} (${projection.reason})`);
242
270
  }
243
271
  else {
@@ -271,13 +299,10 @@ export async function runTaskSpec(options) {
271
299
  outcome = await writeOutcome(options.repoRoot, envelope);
272
300
  }
273
301
  if (status === "succeeded" && !options.skipSuccessFinalization) {
274
- await runObservedStep(eventCtx, "promote-run", async () => {
275
- progress.step("promote run");
276
- await runRequiredCommand(options.repoRoot, client, "promote-run", ["promote-run", materializeManifest.harnessTaskId, "--run-id", workerRunId], true, undefined, eventCtx);
277
- });
278
- await runObservedStep(eventCtx, "closeout-task", async () => {
279
- progress.step("closeout task");
280
- await runRequiredCommand(options.repoRoot, client, "closeout-task", ["closeout", "task", materializeManifest.harnessTaskId], true, undefined, eventCtx);
302
+ await runObservedStep(eventCtx, "task-advance-closeout", async () => {
303
+ progress.step("task advance: promote + closeout");
304
+ // Lifecycle closeout path; advance is idempotent when run already associated.
305
+ await runRequiredCommand(options.repoRoot, client, "task-advance-closeout", ["task", "advance", materializeManifest.harnessTaskId, "--json"], true, undefined, eventCtx);
281
306
  });
282
307
  }
283
308
  const record = {
@@ -351,47 +376,111 @@ async function runRequiredCommand(repoRoot, client, artifactName, args, expectJs
351
376
  }
352
377
  return result;
353
378
  }
379
+ function extractWriteSetGateToken(result, taskId) {
380
+ const root = result.json;
381
+ if (!root || typeof root !== "object") {
382
+ throw new Error(`task advance prepare did not return JSON for ${taskId}; cannot approve writeSet gate`);
383
+ }
384
+ const envelope = root;
385
+ const payload = envelope.result && typeof envelope.result === "object"
386
+ ? envelope.result
387
+ : envelope;
388
+ const gate = payload.gate && typeof payload.gate === "object"
389
+ ? payload.gate
390
+ : undefined;
391
+ const id = typeof gate?.id === "string" ? gate.id : undefined;
392
+ const digest = typeof gate?.digest === "string" ? gate.digest : undefined;
393
+ if (!id || !digest) {
394
+ const lifecycleState = typeof payload.lifecycleState === "string"
395
+ ? payload.lifecycleState
396
+ : "unknown";
397
+ throw new Error(`task advance prepare for ${taskId} did not open a writeSet gate (lifecycleState=${lifecycleState})`);
398
+ }
399
+ return `${id}:${digest}`;
400
+ }
354
401
  /**
355
- * `--events-jsonl` is additive observability. Older published controllers do
356
- * not know the flag; their parser rejects before any DAG node can execute, so
357
- * retrying the exact command without it is safe and preserves the same run id.
402
+ * `--events-jsonl` is additive observability. Older published controllers may
403
+ * reject the flag; retry without it, keeping the same run id / gate digest.
358
404
  */
359
- function assertRunDagCommandOk(result, args) {
360
- if (!result.ok) {
405
+ function assertAdvanceExecuteOk(result, args) {
406
+ if (!result.ok && !isTerminalTaskAdvanceFailure(result)) {
361
407
  throw new Error(`loop-agent command failed: ${args.join(" ")}`);
362
408
  }
363
409
  }
364
- async function runDagWithEventsFallback(repoRoot, client, args, timeoutMs, eventCtx) {
365
- const result = await client.run(args, {
366
- cwd: repoRoot,
367
- artifactName: "run-dag",
410
+ function readTaskAdvancePayload(result) {
411
+ const root = result.json;
412
+ if (!root || typeof root !== "object")
413
+ return null;
414
+ const envelope = root;
415
+ if (envelope.result && typeof envelope.result === "object") {
416
+ return envelope.result;
417
+ }
418
+ return envelope;
419
+ }
420
+ /**
421
+ * Terminal DAG failure from `task advance --approve-gate` is an expected
422
+ * worker path: CLI may return non-zero / outcome=blocked while still writing
423
+ * a completed run that `dag report` can diagnose.
424
+ */
425
+ function isTerminalTaskAdvanceFailure(result) {
426
+ const payload = readTaskAdvancePayload(result);
427
+ if (!payload)
428
+ return false;
429
+ const lifecycleState = typeof payload.lifecycleState === "string" ? payload.lifecycleState : "";
430
+ if (lifecycleState === "run-failed" ||
431
+ lifecycleState === "needs-attention" ||
432
+ lifecycleState === "awaiting-decision") {
433
+ return true;
434
+ }
435
+ const run = payload.run && typeof payload.run === "object"
436
+ ? payload.run
437
+ : null;
438
+ const runStatus = typeof run?.status === "string" ? run.status : "";
439
+ const runLifecycle = typeof run?.lifecycle === "string" ? run.lifecycle : "";
440
+ return (runStatus === "failed" ||
441
+ runStatus === "partial_failed" ||
442
+ (runLifecycle === "completed" && runStatus !== "finished"));
443
+ }
444
+ async function runTaskAdvanceExecuteWithEventsFallback(input) {
445
+ const baseArgs = [
446
+ "task",
447
+ "advance",
448
+ input.taskId,
449
+ "--approve-gate",
450
+ input.gateToken,
451
+ "--run-id",
452
+ input.workerRunId,
453
+ "--worker-association",
454
+ input.workerAssociation,
455
+ "--max-concurrent",
456
+ String(input.maxConcurrent),
457
+ "--skip-finalize",
458
+ "--json",
459
+ ];
460
+ const withEvents = [...baseArgs, "--events-jsonl", input.dagEventsPath];
461
+ const result = await input.client.run(withEvents, {
462
+ cwd: input.repoRoot,
463
+ artifactName: "task-advance-execute",
368
464
  expectJson: true,
369
- timeoutMs,
465
+ timeoutMs: input.timeoutMs,
370
466
  });
371
- if (result.ok)
467
+ if (result.ok || isTerminalTaskAdvanceFailure(result))
372
468
  return result;
373
- if (!rejectsEventsJsonl(result) || !args.includes("--events-jsonl")) {
374
- assertRunDagCommandOk(result, args);
469
+ if (!rejectsEventsJsonl(result)) {
470
+ assertAdvanceExecuteOk(result, withEvents);
375
471
  }
376
- const fallbackArgs = withoutFlagAndValue(args, "--events-jsonl");
377
- eventCtx.progress.note("run-dag controller does not support --events-jsonl; retrying without DAG event stream");
378
- const fallback = await client.run(fallbackArgs, {
379
- cwd: repoRoot,
380
- artifactName: "run-dag-events-fallback",
472
+ input.eventCtx.progress.note("task advance controller does not support --events-jsonl; retrying without DAG event stream");
473
+ const fallback = await input.client.run(baseArgs, {
474
+ cwd: input.repoRoot,
475
+ artifactName: "task-advance-execute-events-fallback",
381
476
  expectJson: true,
382
- timeoutMs,
477
+ timeoutMs: input.timeoutMs,
383
478
  });
384
- assertRunDagCommandOk(fallback, fallbackArgs);
479
+ assertAdvanceExecuteOk(fallback, baseArgs);
385
480
  return fallback;
386
481
  }
387
482
  function rejectsEventsJsonl(result) {
388
- return /unknown run-dag flag:\s*--events-jsonl/i.test(`${result.stderr}\n${result.stdout}`);
389
- }
390
- function withoutFlagAndValue(args, flag) {
391
- const index = args.indexOf(flag);
392
- if (index < 0)
393
- return args;
394
- return [...args.slice(0, index), ...args.slice(index + 2)];
483
+ return /unknown (?:run-dag|dag execute|task advance) flag:\s*--events-jsonl|unknown flag --events-jsonl/i.test(`${result.stderr}\n${result.stdout}`);
395
484
  }
396
485
  function emit(progress, input) {
397
486
  const structured = progress;
@@ -479,7 +568,10 @@ async function collectFailureArtifacts(input) {
479
568
  }
480
569
  function isPathInside(root, target) {
481
570
  const relative = path.relative(root, target);
482
- return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
571
+ return (relative !== "" &&
572
+ relative !== ".." &&
573
+ !relative.startsWith(`..${path.sep}`) &&
574
+ !path.isAbsolute(relative));
483
575
  }
484
576
  /** Exported for unit tests: map DAG report JSON into Worker report decision. */
485
577
  export function decideFromReport(workerRunId, result) {
@@ -553,7 +645,9 @@ function extractDagReportRun(workerRunId, result) {
553
645
  ...(readString(node, "nodeId") || readString(node, "id")
554
646
  ? { id: readString(node, "nodeId") ?? readString(node, "id") }
555
647
  : {}),
556
- ...(readString(node, "status") ? { status: readString(node, "status") } : {}),
648
+ ...(readString(node, "status")
649
+ ? { status: readString(node, "status") }
650
+ : {}),
557
651
  ...(readString(node, "failureCategory")
558
652
  ? { failureCategory: readString(node, "failureCategory") }
559
653
  : {}),
@@ -567,10 +661,14 @@ function extractDagReportRun(workerRunId, result) {
567
661
  ? { structuredArtifactPath: readString(node, "structuredArtifactPath") }
568
662
  : {}),
569
663
  ...(readString(node, "structuredArtifactSha256")
570
- ? { structuredArtifactSha256: readString(node, "structuredArtifactSha256") }
664
+ ? {
665
+ structuredArtifactSha256: readString(node, "structuredArtifactSha256"),
666
+ }
571
667
  : {}),
572
668
  ...(readString(node, "structuredArtifactSchemaId")
573
- ? { structuredArtifactSchemaId: readString(node, "structuredArtifactSchemaId") }
669
+ ? {
670
+ structuredArtifactSchemaId: readString(node, "structuredArtifactSchemaId"),
671
+ }
574
672
  : {}),
575
673
  }));
576
674
  return {
@@ -733,19 +831,13 @@ class ObservedRunTaskClient {
733
831
  function commandRecordName(args, artifactName) {
734
832
  if (args[0] === "new-task")
735
833
  return "new-task";
834
+ if (args[0] === "task" && args[1] === "advance") {
835
+ return artifactName.startsWith("task-advance-")
836
+ ? artifactName
837
+ : "task-advance";
838
+ }
736
839
  return artifactName;
737
840
  }
738
- function maxConcurrentArgs(taskSpec) {
739
- return ["--max-concurrent", String(taskSpec.loop_agent.max_concurrent)];
740
- }
741
- /**
742
- * When a smoke `piModel` override is active, drop `--strict-models` so the
743
- * non-canonical `executorModels.pi` rewrite passes `dag validate`. Governance,
744
- * forbidden-executor and spine audits are still enforced by their own flags.
745
- */
746
- function strictModelsArgs(piModel) {
747
- return piModel ? [] : ["--strict-models"];
748
- }
749
841
  /**
750
842
  * Rewrite `executorModels.pi.{LOW,MED,HIGH}` in an already-generated DAG JSON
751
843
  * so every pi executor node resolves to the smoke override model.
@@ -125,7 +125,7 @@ export async function runReadyTasks(options) {
125
125
  workerRunId,
126
126
  status: "running",
127
127
  });
128
- progress.step(`materialize → dag-run-task validate run-dag → report`);
128
+ progress.step(`materialize → task advance → report`);
129
129
  let result;
130
130
  try {
131
131
  await writeTaskPoolState(options.repoRoot, {
@@ -155,7 +155,7 @@ export const frontendImplementationContractSchema = z
155
155
  "not-needed",
156
156
  ]),
157
157
  productionDefaultOff: z.literal(true),
158
- activation: z.preprocess((value) => (value === "" ? "explicit activation boundary" : value), z.string().min(1)),
158
+ activation: z.string().min(1),
159
159
  endpoints: z.array(z
160
160
  .object({
161
161
  method: z.enum([
@@ -168,10 +168,7 @@ export const frontendImplementationContractSchema = z
168
168
  "OPTIONS",
169
169
  ]),
170
170
  path: z.string().startsWith("/"),
171
- // Models occasionally emit an empty fixture when Mock is
172
- // intentionally not needed. Treat it like an omitted optional
173
- // field; active Mock strategies still fail the refinement below.
174
- fixture: z.preprocess((value) => (value === "" ? undefined : value), safePath.optional()),
171
+ fixture: safePath.optional(),
175
172
  consumer: safePath.optional(),
176
173
  })
177
174
  .strict()),
@@ -190,7 +187,7 @@ export const frontendImplementationContractSchema = z
190
187
  type: z.enum(["static", "unit", "component", "integration", "mock"]),
191
188
  commandLabel: z.string().min(1),
192
189
  file: safePath,
193
- symbol: z.preprocess((value) => (value === "" ? undefined : value), z.string().min(1).optional()),
190
+ symbol: z.string().min(1).optional(),
194
191
  requirementIds: z.array(id),
195
192
  uiStates: z.array(z.string().min(1)),
196
193
  })
@@ -346,75 +343,12 @@ function secretIssues(value, at = "$", issues = []) {
346
343
  }
347
344
  export function extractFrontendImplementationJson(text) {
348
345
  const trimmed = text.trim();
349
- const parse = (source) => {
350
- try {
351
- return JSON.parse(source);
352
- }
353
- catch (error) {
354
- // Models sometimes put ordinary ASCII quotes inside a JSON string
355
- // (for example: `reason: "支持..."`). Repair only quotes that are
356
- // clearly not structural: a closing quote is followed by JSON
357
- // punctuation, while an embedded quote is followed by content.
358
- let repaired = "";
359
- let inString = false;
360
- let escaped = false;
361
- for (let index = 0; index < source.length; index += 1) {
362
- const character = source[index];
363
- if (character !== '"') {
364
- repaired += character;
365
- if (inString && character === "\\" && !escaped)
366
- escaped = true;
367
- else
368
- escaped = false;
369
- continue;
370
- }
371
- if (escaped) {
372
- repaired += character;
373
- escaped = false;
374
- continue;
375
- }
376
- if (!inString) {
377
- inString = true;
378
- repaired += character;
379
- continue;
380
- }
381
- const next = source.slice(index + 1).trimStart()[0];
382
- if ([",", "}", "]", ":"].includes(next ?? "") || source.slice(index + 1).trim() === "") {
383
- inString = false;
384
- repaired += character;
385
- }
386
- else {
387
- repaired += "\\\"";
388
- }
389
- }
390
- try {
391
- return JSON.parse(repaired);
392
- }
393
- catch {
394
- throw error;
395
- }
396
- }
397
- };
398
346
  if (trimmed.startsWith("{") && trimmed.endsWith("}"))
399
- return parse(trimmed);
347
+ return JSON.parse(trimmed);
400
348
  const blocks = [...trimmed.matchAll(/```json\s*\n([\s\S]*?)\n```/gi)];
401
- if (blocks.length === 0)
349
+ if (blocks.length !== 1)
402
350
  throw new Error("output must contain exactly one fenced json object");
403
- const candidates = [];
404
- for (const block of blocks) {
405
- try {
406
- const candidate = parse(block[1]);
407
- if (candidate && typeof candidate === "object" && !Array.isArray(candidate))
408
- candidates.push(candidate);
409
- }
410
- catch {
411
- // Ignore incomplete model scratch blocks. A later complete contract
412
- // block may still be deterministically recoverable.
413
- }
414
- }
415
- if (candidates.length !== 1)
416
- throw new Error(`output must contain exactly one valid fenced json object (found ${candidates.length})`);
417
- return candidates[0];
351
+ return JSON.parse(blocks[0][1]);
418
352
  }
419
353
  /**
420
354
  * Build the authoritative frontend-implementation-contract sourceBinding from
@@ -1,8 +1,7 @@
1
1
  import { readFile, stat } from "node:fs/promises";
2
- import { createHash } from "node:crypto";
3
2
  import path from "node:path";
4
3
  import { isOpenspecSpecFilePath } from "../../shared/openspec-spec.js";
5
- import { frontendImplementationContractSchema, FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID, materializeFrontendImplementationContract, assertFrontendSourceBindingFresh, } from "./frontend-implementation-contract.js";
4
+ import { frontendImplementationContractSchema, materializeFrontendImplementationContract, assertFrontendSourceBindingFresh, } from "./frontend-implementation-contract.js";
6
5
  import { captureFrontendWorktreeBaseline } from "./frontend-worktree-diff.js";
7
6
  import { pathMatchesPattern } from "../../shared/git-progress.js";
8
7
  async function selectNode(runDir, primary, fallbacks) {
@@ -47,12 +46,7 @@ function firstNonEmptyVerdictLine(text) {
47
46
  // The prompt requires VERDICT to be the first non-empty line, but model
48
47
  // output can still prepend a summary. Keep the protocol strict in the
49
48
  // prompt while making the deterministic gate resilient to that drift.
50
- const verdictLine = normalizedLines.find((line) => /^VERDICT:/.test(line)) ?? first;
51
- if (/^VERDICT:\s*pass(?:\s*[.!?。!?]|\s+|$)/i.test(verdictLine))
52
- return "VERDICT: pass";
53
- if (/^VERDICT:\s*request-revision(?:\s*[.!?。!?]|\s+|$)/i.test(verdictLine))
54
- return "VERDICT: request-revision";
55
- return verdictLine;
49
+ return normalizedLines.find((line) => /^VERDICT:/.test(line)) ?? first;
56
50
  }
57
51
  function eventArgs(event) {
58
52
  return event.args ?? event.toolInput ?? event.input ?? {};
@@ -161,20 +155,13 @@ export async function runFrontendPrewriteGate(input) {
161
155
  if (missingIds.length > 0) {
162
156
  throw new Error(`frontend prewrite gate missing requirement ids: ${missingIds.join(", ")}`);
163
157
  }
164
- const artifactPath = path.join(input.runDir, input.config.outputDir, input.config.artifactName);
165
- const artifact = await stat(artifactPath)
166
- .then(async () => ({
167
- path: artifactPath,
168
- schemaId: FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID,
169
- sha256: createHash("sha256").update(await readFile(artifactPath)).digest("hex"),
170
- }))
171
- .catch(() => materializeFrontendImplementationContract({
158
+ const artifact = await materializeFrontendImplementationContract({
172
159
  runDir: input.runDir,
173
160
  fromNodeId: planNodeId,
174
161
  artifactName: input.config.artifactName,
175
162
  outputDir: input.config.outputDir,
176
163
  sourceBinding: input.sourceBinding,
177
- }));
164
+ });
178
165
  const raw = JSON.parse(await readFile(artifact.path, "utf8"));
179
166
  const contract = frontendImplementationContractSchema.parse(raw);
180
167
  if (!input.config.allowedMockStrategies.includes(contract.mockApi.strategy)) {