@tea-agent/loop-agent 0.16.26 → 0.17.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 (103) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/README.md +14 -3
  3. package/dist/cli/command-definitions.js +43 -0
  4. package/dist/cli/program.js +26 -0
  5. package/dist/commands/dag-approve.js +4 -0
  6. package/dist/commands/dag-resume.js +1 -0
  7. package/dist/commands/dag-validate.js +6 -0
  8. package/dist/commands/operator.js +44 -0
  9. package/dist/commands/task-contract.js +271 -0
  10. package/dist/executors/dag-pi-executor.js +55 -7
  11. package/dist/executors/pi-executor.js +206 -13
  12. package/dist/executors/pi-sdk-executor.js +21 -6
  13. package/dist/executors/shell-executor.js +55 -8
  14. package/dist/executors/shell-presets.js +16 -3
  15. package/dist/shared/operator/capabilities.js +255 -0
  16. package/dist/shared/operator/envelope.js +59 -0
  17. package/dist/shared/operator/index.js +4 -0
  18. package/dist/shared/operator/registry.js +38 -0
  19. package/dist/shared/operator/types.js +5 -0
  20. package/dist/task/contract/adopt.js +166 -0
  21. package/dist/task/contract/apply.js +326 -0
  22. package/dist/task/contract/canonicalize.js +60 -0
  23. package/dist/task/contract/constants.js +29 -0
  24. package/dist/task/contract/diff.js +177 -0
  25. package/dist/task/contract/hash.js +42 -0
  26. package/dist/task/contract/import-revision.js +96 -0
  27. package/dist/task/contract/index.js +17 -0
  28. package/dist/task/contract/journal.js +155 -0
  29. package/dist/task/contract/lock.js +153 -0
  30. package/dist/task/contract/observe.js +296 -0
  31. package/dist/task/contract/paths.js +19 -0
  32. package/dist/task/contract/project.js +170 -0
  33. package/dist/task/contract/recover.js +312 -0
  34. package/dist/task/contract/request-ledger.js +37 -0
  35. package/dist/task/contract/schema.js +151 -0
  36. package/dist/task/contract/transaction.js +160 -0
  37. package/dist/task/contract/types.js +1 -0
  38. package/dist/task/contract/validate-draft.js +106 -0
  39. package/dist/task/index.js +3 -0
  40. package/dist/task/operator/capabilities.js +6 -0
  41. package/dist/task/operator/envelope.js +2 -0
  42. package/dist/task/operator/index.js +5 -0
  43. package/dist/task/operator/registry.js +2 -0
  44. package/dist/task/operator/types.js +1 -0
  45. package/dist/task/runtime.js +5 -1
  46. package/dist/task/source-references.js +7 -0
  47. package/dist/worker/cli.js +150 -32
  48. package/dist/worker/console/app-data.js +185 -0
  49. package/dist/worker/console/dag-confirmation.js +313 -0
  50. package/dist/worker/console/doctor.js +169 -0
  51. package/dist/worker/console/draft-store.js +80 -0
  52. package/dist/worker/console/index.js +15 -0
  53. package/dist/worker/console/interview/assessment.js +67 -0
  54. package/dist/worker/console/interview/session.js +100 -0
  55. package/dist/worker/console/interview/tools.js +109 -0
  56. package/dist/worker/console/loopback.js +16 -0
  57. package/dist/worker/console/observe-health-match.js +174 -0
  58. package/dist/worker/console/observe-link.js +33 -0
  59. package/dist/worker/console/operation-runner.js +166 -0
  60. package/dist/worker/console/operation-sse.js +158 -0
  61. package/dist/worker/console/operation-store.js +147 -0
  62. package/dist/worker/console/operator-actions.js +769 -0
  63. package/dist/worker/console/pi-readiness.js +94 -0
  64. package/dist/worker/console/recovery-cta.js +133 -0
  65. package/dist/worker/console/repo-fingerprint.js +29 -0
  66. package/dist/worker/console/resource-loader.js +95 -0
  67. package/dist/worker/console/routes.js +368 -0
  68. package/dist/worker/console/security.js +126 -0
  69. package/dist/worker/console/server.js +149 -0
  70. package/dist/worker/console/sibling-controller.js +28 -0
  71. package/dist/worker/console/static/assets/index-CbnMgdWa.js +9 -0
  72. package/dist/worker/console/static/assets/index-Dnj0RVs8.css +1 -0
  73. package/dist/worker/console/static/index.html +13 -0
  74. package/dist/worker/console/vite.config.js +27 -0
  75. package/dist/worker/observe/health.js +57 -0
  76. package/dist/worker/observe/routes.js +16 -9
  77. package/dist/workflows/dag/backend-test-analysis-contract.js +34 -9
  78. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -0
  79. package/dist/workflows/dag/frontend-repair.js +1 -10
  80. package/dist/workflows/dag/init-hybrid.js +269 -116
  81. package/dist/workflows/dag/node-execution.js +25 -1
  82. package/dist/workflows/dag/retry-policy.js +16 -1
  83. package/dist/workflows/dag/runner.js +9 -0
  84. package/dist/workflows/dag/task-contract-binding.js +138 -0
  85. package/dist/workflows/dag/types.js +74 -10
  86. package/dist/workflows/dag/validate.js +25 -7
  87. package/docs/README.md +2 -0
  88. package/docs/architecture/evolution.md +2 -0
  89. package/docs/architecture/system-overview.md +6 -0
  90. package/docs/architecture/worker-and-feature.md +7 -0
  91. package/docs/templates/agent-dag.schema.json +49 -2
  92. package/docs/templates/backend-test-dag.classify.prompt.md +1 -1
  93. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -5
  94. package/docs/templates/backend-test-dag.json +26 -154
  95. package/docs/templates/backend-test-dag.retrospect.prompt.md +1 -1
  96. package/docs/templates/backend-test-dag.review-cases.prompt.md +2 -2
  97. package/package.json +8 -2
  98. package/skills/agent-worker/SKILL.md +1 -0
  99. package/skills/agent-worker/references/agent-worker-operator.md +3 -2
  100. package/skills/frontend-design-review/SKILL.md +25 -16
  101. package/skills/frontend-implementation/references/node-contracts.md +5 -5
  102. package/skills/loop-agent/references/command-reference.md +48 -1
  103. package/skills/loop-agent/references/hybrid-dag.md +4 -4
@@ -25,6 +25,23 @@ export function buildNodePrompt(spec, task, upstream, options) {
25
25
  projectGovernanceContext: options?.projectGovernanceContext,
26
26
  });
27
27
  }
28
+ function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCategory) {
29
+ if (attemptNumber <= 1 ||
30
+ task.outputMode !== "structured-required" ||
31
+ previousFailureCategory !== "output-too-large") {
32
+ return basePrompt;
33
+ }
34
+ return [
35
+ basePrompt,
36
+ "",
37
+ "<retry_instruction>",
38
+ "Previous attempt exceeded the structured output size limit.",
39
+ "Return only the compact structured artifact required by this node's output contract.",
40
+ "Do not include explanatory prose, duplicated upstream context, long evidence excerpts, or additional markdown sections.",
41
+ "If a fenced JSON object is required, output exactly one fenced json block and nothing else.",
42
+ "</retry_instruction>",
43
+ ].join("\n");
44
+ }
28
45
  export async function buildNodePromptWithResolvedSkillInstructions(spec, task, upstream, cwd, options) {
29
46
  const policy = resolveContextPolicy(spec);
30
47
  const skillNames = policy.resolveSkills(spec, task);
@@ -283,6 +300,7 @@ export async function executeDagNode(input) {
283
300
  const attempts = [];
284
301
  let totalBackoffMs = 0;
285
302
  let terminalResult;
303
+ let previousFailureCategory;
286
304
  for (let attemptNumber = 1; attemptNumber <= maxAttempts; attemptNumber += 1) {
287
305
  const attemptStartedAt = new Date().toISOString();
288
306
  const attemptStarted = Date.now();
@@ -293,7 +311,12 @@ export async function executeDagNode(input) {
293
311
  tasksById,
294
312
  state,
295
313
  });
296
- result = await executeNode({ task, cwd, model, prompt });
314
+ result = await executeNode({
315
+ task,
316
+ cwd,
317
+ model,
318
+ prompt: buildAttemptPrompt(task, prompt, attemptNumber, previousFailureCategory),
319
+ });
297
320
  }
298
321
  catch (error) {
299
322
  result = {
@@ -356,6 +379,7 @@ export async function executeDagNode(input) {
356
379
  await input.persistState();
357
380
  }
358
381
  terminalResult = result;
382
+ previousFailureCategory = result.failureCategory;
359
383
  if (result.ok)
360
384
  break;
361
385
  const canRetry = retryPolicy !== undefined && attemptNumber < maxAttempts;
@@ -16,6 +16,11 @@ export const DEFAULT_DAG_RETRY_CATEGORIES = [
16
16
  "rate-limit",
17
17
  "unavailable",
18
18
  ];
19
+ export const STRUCTURED_OUTPUT_RETRY_CATEGORY = "output-too-large";
20
+ export const STRUCTURED_REQUIRED_DAG_RETRY_CATEGORIES = [
21
+ ...DEFAULT_DAG_RETRY_CATEGORIES,
22
+ STRUCTURED_OUTPUT_RETRY_CATEGORY,
23
+ ];
19
24
  const RETRY_SAFE_PI_ROLES = new Set([
20
25
  "planner",
21
26
  "scout",
@@ -23,7 +28,7 @@ const RETRY_SAFE_PI_ROLES = new Set([
23
28
  "verifier",
24
29
  "closeout",
25
30
  ]);
26
- export const dagRetryCategorySchema = z.enum(DEFAULT_DAG_RETRY_CATEGORIES);
31
+ export const dagRetryCategorySchema = z.enum(STRUCTURED_REQUIRED_DAG_RETRY_CATEGORIES);
27
32
  export const dagRetryBackoffSchema = z.enum(["exponential"]);
28
33
  /**
29
34
  * Opt-in retry policy for a DagTask. Generated only for safe read-only Pi
@@ -71,6 +76,16 @@ export const DEFAULT_READ_ONLY_PI_RETRY_POLICY = {
71
76
  maxDelayMs: 30000,
72
77
  retryCategories: [...DEFAULT_DAG_RETRY_CATEGORIES],
73
78
  };
79
+ /**
80
+ * Structured planner nodes may retry with a compact-output instruction
81
+ * when the model produced an oversized assistant response. This category is
82
+ * not part of the default read-only retry set because report/review nodes
83
+ * should not silently learn new output semantics.
84
+ */
85
+ export const STRUCTURED_REQUIRED_PI_RETRY_POLICY = {
86
+ ...DEFAULT_READ_ONLY_PI_RETRY_POLICY,
87
+ retryCategories: [...STRUCTURED_REQUIRED_DAG_RETRY_CATEGORIES],
88
+ };
74
89
  /**
75
90
  * Deterministic helper: is this raw failure category eligible for retry under
76
91
  * the given policy? Pure function; executor never decides retry eligibility.
@@ -117,6 +117,7 @@ export function createInitialRunState(spec, opts, ranks, runId = opts.runId ?? "
117
117
  status: "PENDING",
118
118
  executor: task.executor,
119
119
  complexity: task.complexity,
120
+ ...(task.outputMode ? { outputMode: task.outputMode } : {}),
120
121
  ...(resolveModelForTask(task, spec.executorModels)
121
122
  ? { model: resolveModelForTask(task, spec.executorModels) }
122
123
  : {}),
@@ -178,6 +179,10 @@ export async function runDag(spec, opts) {
178
179
  assertValidDagSpec(spec);
179
180
  // Candidate identity is validated before run-id allocation or directory creation.
180
181
  await assertEvaluationBindingPreflight(opts.cwd, spec.evaluation);
182
+ // Phase 0.5: new writer execution requires DagSpec v4 + live taskContractBinding match.
183
+ const { assertDagSpecAllowsNewWriterExecution, assertTaskContractBindingConsistent } = await import("./task-contract-binding.js");
184
+ assertDagSpecAllowsNewWriterExecution(spec);
185
+ await assertTaskContractBindingConsistent({ repoRoot: opts.cwd, spec });
181
186
  const runningIdentity = resolveRunningControllerIdentity();
182
187
  if (!runningIdentity) {
183
188
  throw new Error("running controller identity could not be resolved; refuse to create an unpinned DAG run");
@@ -273,6 +278,10 @@ export async function resumeDagRun(opts) {
273
278
  }
274
279
  assertFrozenEvaluationBinding(spec, state);
275
280
  assertFrozenBudget(spec.budget, state.budget, state.runId);
281
+ // Phase 0.5: refuse resume of pre-v4 writer DAGs; revalidate live binding.
282
+ const { assertDagSpecAllowsNewWriterExecution, assertTaskContractBindingConsistent } = await import("./task-contract-binding.js");
283
+ assertDagSpecAllowsNewWriterExecution(spec);
284
+ await assertTaskContractBindingConsistent({ repoRoot: opts.cwd, spec });
276
285
  // Runtime contract + controller identity must be re-verified before executing
277
286
  // any remaining node on resume; drift fails closed.
278
287
  const runningIdentity = resolveRunningControllerIdentity();
@@ -0,0 +1,138 @@
1
+ import { observeTaskContract } from "../../task/contract/observe.js";
2
+ export async function readCurrentTaskContractBinding(input) {
3
+ const state = await observeTaskContract({
4
+ repoRoot: input.repoRoot,
5
+ taskId: input.taskId,
6
+ });
7
+ if (state.effectiveStatus !== "managed" || !state.ref) {
8
+ return {
9
+ ok: false,
10
+ code: "BINDING_DRIFT",
11
+ message: `task ${input.taskId} is not managed (effectiveStatus=${state.effectiveStatus}); adopt/apply before DAG generate/run`,
12
+ };
13
+ }
14
+ const ref = state.ref;
15
+ return {
16
+ ok: true,
17
+ current: {
18
+ schemaVersion: 1,
19
+ taskId: ref.taskId,
20
+ revision: ref.revision,
21
+ projectionVersion: ref.projectionVersion,
22
+ canonicalizerVersion: ref.canonicalizerVersion,
23
+ taskConfigSchemaVersion: ref.taskConfigSchemaVersion,
24
+ canonicalHash: ref.canonicalHash,
25
+ taskConfigSha256: ref.taskConfigSha256,
26
+ },
27
+ };
28
+ }
29
+ export function compareTaskContractBinding(declared, current) {
30
+ if (declared.taskId !== current.taskId ||
31
+ declared.revision !== current.revision ||
32
+ declared.canonicalHash !== current.canonicalHash ||
33
+ declared.taskConfigSha256 !== current.taskConfigSha256 ||
34
+ declared.projectionVersion !== current.projectionVersion ||
35
+ declared.canonicalizerVersion !== current.canonicalizerVersion ||
36
+ declared.taskConfigSchemaVersion !== current.taskConfigSchemaVersion) {
37
+ return {
38
+ ok: false,
39
+ code: "BINDING_DRIFT",
40
+ message: `taskContractBinding drift for ${declared.taskId}: declared revision=${declared.revision} hash=${declared.canonicalHash}, current revision=${current.revision} hash=${current.canonicalHash}`,
41
+ declared,
42
+ current,
43
+ };
44
+ }
45
+ return { ok: true, declared, current };
46
+ }
47
+ /**
48
+ * Fail closed when a v4 DAG's frozen binding does not match current managed ref.
49
+ * Historical v3 specs without binding are left to migration matrix callers.
50
+ */
51
+ export async function assertTaskContractBindingConsistent(input) {
52
+ if (input.spec.version !== 4) {
53
+ return;
54
+ }
55
+ const declared = input.spec.taskContractBinding;
56
+ if (!declared) {
57
+ const err = new Error("DagSpec version 4 missing taskContractBinding");
58
+ err.code = "BINDING_DRIFT";
59
+ throw err;
60
+ }
61
+ const current = await readCurrentTaskContractBinding({
62
+ repoRoot: input.repoRoot,
63
+ taskId: declared.taskId,
64
+ });
65
+ if (!current.ok || !current.current) {
66
+ const err = new Error(current.message ?? "task contract binding unavailable");
67
+ err.code = current.code ?? "BINDING_DRIFT";
68
+ throw err;
69
+ }
70
+ const cmp = compareTaskContractBinding(declared, current.current);
71
+ if (!cmp.ok) {
72
+ const err = new Error(cmp.message ?? "taskContractBinding drift");
73
+ err.code = "BINDING_DRIFT";
74
+ throw err;
75
+ }
76
+ }
77
+ /**
78
+ * v3 migration matrix: historical DAGs may remain readable/executable only
79
+ * when they contain no exclusive writer. Terminal report/doctor never call
80
+ * this helper.
81
+ */
82
+ export function dagHasWriterExecution(spec) {
83
+ return spec.tasks.some((task) => (task.writePolicy ?? spec.defaults?.writePolicy) === "exclusive");
84
+ }
85
+ export function assertDagSpecAllowsNewWriterExecution(spec) {
86
+ if (!dagHasWriterExecution(spec))
87
+ return;
88
+ // Migration matrix (design §5.3.10):
89
+ // - New writer start/approve/resume requires DagSpec v4 + taskContractBinding.
90
+ // - Historical read-only/static/shell DAGs remain executable; they cannot
91
+ // introduce repository writer execution and remain useful for diagnostics,
92
+ // saved workflows, and deterministic verification.
93
+ // - v3 DAGs containing an exclusive writer must regenerate before execution.
94
+ if (spec.version !== 4) {
95
+ const err = new Error(`DagSpec version ${spec.version} cannot start/approve/resume new writer execution; regenerate as version 4 with taskContractBinding`);
96
+ err.code = "BINDING_DRIFT";
97
+ throw err;
98
+ }
99
+ }
100
+ /** Result-form API used by some callers; wraps assertTaskContractBindingConsistent. */
101
+ export async function assertTaskContractBindingFresh(input) {
102
+ const action = input.action ?? "dag-action";
103
+ if (input.rejectHistoricalV3ForWriter && input.spec.version === 3) {
104
+ return {
105
+ ok: false,
106
+ code: "BINDING_DRIFT",
107
+ message: `${action}: DagSpec version 3 cannot start/approve/resume new writers; regenerate as v4`,
108
+ };
109
+ }
110
+ if (input.spec.version !== 4) {
111
+ return {
112
+ ok: true,
113
+ binding: input.spec.taskContractBinding,
114
+ current: input.spec.taskContractBinding,
115
+ };
116
+ }
117
+ try {
118
+ await assertTaskContractBindingConsistent({
119
+ repoRoot: input.repoRoot,
120
+ spec: input.spec,
121
+ });
122
+ return {
123
+ ok: true,
124
+ binding: input.spec.taskContractBinding,
125
+ current: input.spec.taskContractBinding,
126
+ };
127
+ }
128
+ catch (error) {
129
+ const code = error && typeof error === "object" && "code" in error
130
+ ? String(error.code)
131
+ : "BINDING_DRIFT";
132
+ return {
133
+ ok: false,
134
+ code,
135
+ message: error instanceof Error ? error.message : String(error),
136
+ };
137
+ }
138
+ }
@@ -7,6 +7,7 @@ export const dagNodeExecutorSchema = z.enum(["pi", "shell", "static"]);
7
7
  export const CURSOR_DAG_EXECUTOR_REMOVED_ERROR = 'executor "cursor" is no longer supported; regenerate the DAG with Pi-only writers (implement-pi / repair-pi)';
8
8
  export const CURSOR_EXECUTOR_MODELS_REMOVED_ERROR = "executorModels.cursor is no longer supported; use executorModels.pi only";
9
9
  export const dagToolProfileSchema = z.enum(["read-only", "write"]);
10
+ export const dagOutputModeSchema = z.enum(["default", "structured-required"]);
10
11
  export const dagShellPresetSchema = z.enum(["loop-agent-standard-verify"]);
11
12
  export const dagVerifyQuotaSchema = z.enum(["1", "3", "full"]);
12
13
  export const dagVerifyStrategySchema = z
@@ -75,6 +76,12 @@ export const dagRequirementCoverageGateSchema = z.object({
75
76
  fromNodeIds: z
76
77
  .array(z.string().regex(/^[a-z][a-z0-9-]*$/, "fromNodeIds must be kebab-case"))
77
78
  .min(1),
79
+ fallbackFromNodeIds: z
80
+ .array(z
81
+ .string()
82
+ .regex(/^[a-z][a-z0-9-]*$/, "fallbackFromNodeIds must be kebab-case"))
83
+ .min(1)
84
+ .optional(),
78
85
  requiredIds: z
79
86
  .array(z
80
87
  .string()
@@ -95,6 +102,12 @@ export const dagJsonArtifactSchemaIdSchema = z.enum([
95
102
  ]);
96
103
  export const dagJsonArtifactGateSchema = z.object({
97
104
  fromNodeId: z.string().regex(/^[a-z][a-z0-9-]*$/),
105
+ fallbackFromNodeIds: z
106
+ .array(z
107
+ .string()
108
+ .regex(/^[a-z][a-z0-9-]*$/, "fallbackFromNodeIds must be kebab-case"))
109
+ .min(1)
110
+ .optional(),
98
111
  schemaId: dagJsonArtifactSchemaIdSchema,
99
112
  artifactName: z.string().regex(/^[a-z0-9][a-z0-9._-]*\.json$/),
100
113
  outputDir: z.string().regex(/^[a-z0-9][a-z0-9._-]*$/),
@@ -102,8 +115,25 @@ export const dagJsonArtifactGateSchema = z.object({
102
115
  });
103
116
  export const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
104
117
  export const dagVersionSchema = z
105
- .union([z.literal(1), z.literal(2), z.literal(3)])
118
+ .union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)])
106
119
  .default(1);
120
+ /** DagSpec v4 binding to managed TaskContractRefV1 (design §5.3.10). */
121
+ export const dagTaskContractBindingSchema = z
122
+ .object({
123
+ schemaVersion: z.literal(1),
124
+ taskId: z.string().min(1),
125
+ revision: z.number().int().nonnegative(),
126
+ projectionVersion: z.literal(1),
127
+ canonicalizerVersion: z.literal(1),
128
+ taskConfigSchemaVersion: z.literal(1),
129
+ canonicalHash: z
130
+ .string()
131
+ .regex(/^[a-f0-9]{64}$/, "canonicalHash must be lowercase hex"),
132
+ taskConfigSha256: z
133
+ .string()
134
+ .regex(/^[a-f0-9]{64}$/, "taskConfigSha256 must be lowercase hex"),
135
+ })
136
+ .strict();
107
137
  export const dagRoleSchema = z.enum([
108
138
  "planner",
109
139
  "scout",
@@ -313,6 +343,7 @@ export const dagTaskSchema = z.object({
313
343
  shell: dagShellConfigSchema.optional(),
314
344
  static: dagStaticConfigSchema.optional(),
315
345
  outputContract: z.string().optional(),
346
+ outputMode: dagOutputModeSchema.optional(),
316
347
  firstProtocolLine: z.string().min(1).optional(),
317
348
  /**
318
349
  * Explicit opt-in for the deterministic project governance context resolver
@@ -376,9 +407,11 @@ export const dagSpecSchema = z
376
407
  title: z.string().min(1),
377
408
  runtimeContract: dagRuntimeContractSchema.optional(),
378
409
  evaluation: dagEvaluationBindingSchema.optional(),
379
- /** Optional hard/record-only budget; requires version 3. */
410
+ /** Optional hard/record-only budget; requires version 3 or 4. */
380
411
  budget: dagBudgetSchema.optional(),
381
412
  sourceBinding: dagSourceBindingSchema.optional(),
413
+ /** v4 managed Task Contract binding; only valid on version 4. */
414
+ taskContractBinding: dagTaskContractBindingSchema.optional(),
382
415
  outputLanguage: dagOutputLanguageSchema.optional(),
383
416
  objective: z.string().optional(),
384
417
  successCriteria: z.array(z.string()).optional(),
@@ -391,34 +424,65 @@ export const dagSpecSchema = z
391
424
  tasks: z.array(dagTaskSchema).min(1),
392
425
  })
393
426
  .superRefine((spec, ctx) => {
394
- if (spec.evaluation && spec.version !== 3) {
427
+ const supportsV3Fields = spec.version === 3 || spec.version === 4;
428
+ if (spec.evaluation && !supportsV3Fields) {
395
429
  ctx.addIssue({
396
430
  code: z.ZodIssueCode.custom,
397
- message: "evaluation requires DagSpec version 3",
431
+ message: "evaluation requires DagSpec version 3 or 4",
398
432
  path: ["evaluation"],
399
433
  });
400
434
  }
401
- if (spec.budget && spec.version !== 3) {
435
+ if (spec.budget && !supportsV3Fields) {
402
436
  ctx.addIssue({
403
437
  code: z.ZodIssueCode.custom,
404
- message: "budget requires DagSpec version 3",
438
+ message: "budget requires DagSpec version 3 or 4",
405
439
  path: ["budget"],
406
440
  });
407
441
  }
408
- if (spec.runtimeContract && spec.version !== 3) {
442
+ if (spec.runtimeContract && !supportsV3Fields) {
409
443
  ctx.addIssue({
410
444
  code: z.ZodIssueCode.custom,
411
- message: "runtimeContract requires DagSpec version 3",
445
+ message: "runtimeContract requires DagSpec version 3 or 4",
412
446
  path: ["version"],
413
447
  });
414
448
  }
415
- if (spec.version === 3 && !spec.runtimeContract) {
449
+ if (supportsV3Fields && !spec.runtimeContract) {
416
450
  ctx.addIssue({
417
451
  code: z.ZodIssueCode.custom,
418
- message: "DagSpec version 3 requires runtimeContract",
452
+ message: `DagSpec version ${spec.version} requires runtimeContract`,
419
453
  path: ["runtimeContract"],
420
454
  });
421
455
  }
456
+ if (spec.taskContractBinding && spec.version !== 4) {
457
+ ctx.addIssue({
458
+ code: z.ZodIssueCode.custom,
459
+ message: "taskContractBinding requires DagSpec version 4",
460
+ path: ["taskContractBinding"],
461
+ });
462
+ }
463
+ if (spec.version === 4) {
464
+ if (!spec.sourceBinding) {
465
+ ctx.addIssue({
466
+ code: z.ZodIssueCode.custom,
467
+ message: "DagSpec version 4 requires sourceBinding",
468
+ path: ["sourceBinding"],
469
+ });
470
+ }
471
+ if (!spec.taskContractBinding) {
472
+ ctx.addIssue({
473
+ code: z.ZodIssueCode.custom,
474
+ message: "DagSpec version 4 requires taskContractBinding",
475
+ path: ["taskContractBinding"],
476
+ });
477
+ }
478
+ if (!spec.runtimeContract) {
479
+ ctx.addIssue({
480
+ code: z.ZodIssueCode.custom,
481
+ message: "DagSpec version 4 requires runtimeContract",
482
+ path: ["runtimeContract"],
483
+ });
484
+ }
485
+ }
422
486
  // Catch raw cursor keys that Zod .strict() on nested objects already rejects when parsed
423
487
  // via parseDagSpec; this refine covers typed object construction paths.
424
488
  const models = spec.executorModels;
@@ -256,10 +256,20 @@ function validateRequirementCoverageGateConfig(task, spec, issues) {
256
256
  issues.push({ type: "invalid-requirement-coverage-gate-config", message: `task ${task.id} shell.requirementCoverageGate requires executor=shell` });
257
257
  return;
258
258
  }
259
+ if (gate.fallbackFromNodeIds?.length && gate.fromNodeIds.length !== 1) {
260
+ issues.push({
261
+ type: "invalid-requirement-coverage-gate-config",
262
+ message: `task ${task.id} shell.requirementCoverageGate with fallbackFromNodeIds requires exactly one fromNodeIds entry`,
263
+ });
264
+ }
259
265
  const dependencyIds = new Set(task.depends_on);
260
- for (const fromNodeId of gate.fromNodeIds) {
266
+ const sourceNodeIds = [
267
+ ...gate.fromNodeIds,
268
+ ...(gate.fallbackFromNodeIds ?? []),
269
+ ];
270
+ for (const fromNodeId of new Set(sourceNodeIds)) {
261
271
  if (!dependencyIds.has(fromNodeId)) {
262
- issues.push({ type: "invalid-requirement-coverage-gate-config", message: `task ${task.id} shell.requirementCoverageGate.fromNodeIds entry "${fromNodeId}" must appear in depends_on` });
272
+ issues.push({ type: "invalid-requirement-coverage-gate-config", message: `task ${task.id} shell.requirementCoverageGate source "${fromNodeId}" must appear in depends_on` });
263
273
  }
264
274
  const upstream = spec.tasks.find((candidate) => candidate.id === fromNodeId);
265
275
  if (upstream?.executor === "shell") {
@@ -493,11 +503,19 @@ function validateShellTaskConfig(task, spec, issues) {
493
503
  }
494
504
  validateVerdictGateConfig(task, spec, issues);
495
505
  validateRequirementCoverageGateConfig(task, spec, issues);
496
- if (shell.jsonArtifactGate && !task.depends_on.includes(shell.jsonArtifactGate.fromNodeId)) {
497
- issues.push({
498
- type: "missing-dependency",
499
- message: `shell task ${task.id} jsonArtifactGate source ${shell.jsonArtifactGate.fromNodeId} must be a direct dependency`,
500
- });
506
+ if (shell.jsonArtifactGate) {
507
+ const sourceNodeIds = [
508
+ shell.jsonArtifactGate.fromNodeId,
509
+ ...(shell.jsonArtifactGate.fallbackFromNodeIds ?? []),
510
+ ];
511
+ for (const sourceNodeId of new Set(sourceNodeIds)) {
512
+ if (!task.depends_on.includes(sourceNodeId)) {
513
+ issues.push({
514
+ type: "missing-dependency",
515
+ message: `shell task ${task.id} jsonArtifactGate source ${sourceNodeId} must be a direct dependency`,
516
+ });
517
+ }
518
+ }
501
519
  }
502
520
  validateRepairArtifactGateConfig(task, spec, issues);
503
521
  validateShellVerdictGateGovernance(task, commands, issues);
package/docs/README.md CHANGED
@@ -31,6 +31,8 @@
31
31
  - `design/frontend-mock-data-workflow.md` — 已实现的前端 Mock 数据节点、触发条件、规范证据、验证与失败路由
32
32
  - `design/backend-test-workflow.md` — 已实现的 backend-test 15 节点单次执行全流程、pass-only 评审门禁、run-owned artifacts 与最终 outcome
33
33
  - `design/dag-source-binding-and-recovery.md` — 新生成 DAG 的权威任务源绑定、前端需求编号覆盖门禁与中断恢复规则
34
+ - `design/agent-worker-fullstack-workflow-integration.md` — 已实现的 workflow routing、Task Outcome、artifact-aware Ready、`fullstack-v1` 与 Verification Bundle,以及后续 failure routing / execute-existing 领域设计
35
+ - `design/fullstack-end-to-end-delivery-optimization-roadmap.md` — 当前全栈端到端优化的收敛路线图:先完成 release train / Delivery,再冻结 Final Verification 权威、Environment Contract、分类恢复与 Observe 指标
34
36
  - `cursor-prompt-sidecar.md` — `cursor-prompt` one-shot sidecar 用法(非受治理 writer)
35
37
  - `init-surface.manifest.json` — npm 包范围、目标项目初始化投影与 `init check-update` surface 分类的机器校验契约
36
38
 
@@ -40,6 +40,8 @@
40
40
  | Loop 与 Dynamic Workflow 更深的双向集成、稳定化与自动恢复 | 设计输入 | 同上;当前已有基础 `workflow` action,不应误写为完全缺失 |
41
41
 
42
42
  > 本地 Loop Operator Console 已在 `docs/design/local-operator-console-from-pi-web.md` 作为独立设计输入:单仓库、loopback、随 `@tea-agent/loop-agent` 同包发布、canonical mutation 只经 sibling CLI;它不是本表中的远端 Web Console,也不能把远端多租户/云编排需求偷渡进本地 MVP。
43
+ >
44
+ > **Phase 3 落地(Unreleased)**:versioned Observe `/api/health`、Console `observeLink` fail-closed、recovery CTA 矩阵;Console 与 Observe 仍分进程。Phase 4 General Operator Chat 与合服仍属后续,未实现。
43
45
 
44
46
  > 注意:`ai_workspace/loop-agent/design/dynamic-workflow-dag-engine-roadmap.md` 是 2026-07-04 历史叙述;文中凡把 Cursor 写成受治理 executor 或 `loop` 的 `cursor-fix` 动作,均为**历史叙述**,现状以 Pi-only + 显式 `cursor-prompt` sidecar 为准。
45
47
 
@@ -59,6 +59,12 @@ workflow runtime(调度 Pi / shell / static 节点)
59
59
 
60
60
  `agent-worker` 位于核心 runtime 的**上游调用侧**,不是 DAG 执行完成后的必经下游。Observe 可以在任一路径后读取现有事实,但不会改变执行结果。
61
61
 
62
+ ### 本地 Operator Console(Official,与 Observe 协作)
63
+
64
+ - `agent-worker console serve` 提供 loopback Operator Console;canonical mutation 只经 sibling 已发布 `loop-agent`(`LoopAgentClient`),不 in-process 跑 DAG kernel。
65
+ - Observe 保持**独立**只读进程;Console 通过 versioned Observe `/api/health`(`schemaVersion`、`repoFingerprint`、route capabilities)做深链 fail-closed,**不** mount / proxy。
66
+ - openCode 等主会话仍是 Compatibility / Operator Assist,与 Official Console **不等同**。设计锚点:ADR 0005、`docs/design/local-operator-console-from-pi-web.md`。
67
+
62
68
  ## 外部边界
63
69
 
64
70
  - npm 发布包:`@tea-agent/loop-agent`,包含两个 bin 与静态能力资料。
@@ -73,6 +73,13 @@ controller identity 与 DAG skill snapshot 是两个不同冻结层(前者跨
73
73
  - 模块:`src/worker/observe/`、`src/worker/observability/{read-model,event-store}.ts`。
74
74
  - 全局快照:`buildGlobalSnapshot({ repoRoot })`(`src/worker/observability/read-model.ts`),是 **derived** 视图,消费 `.harness/` 与 Task Pool 事实,**不**改变执行成败。
75
75
  - Observe 是本地只读暖白控制台;snapshot 投影失败返回安全错误摘要而非全零健康状态(见 `CHANGELOG.md [0.9.0]`)。
76
+ - Phase 3:`GET /api/health` 为 versioned DTO(`schemaVersion: 1`、`repoFingerprint` 复用 Console `repoFingerprintV1`、package 元数据、由 ROUTES 派生的 `routeCapabilities`)。Console 深链 fail-closed 依赖该合同;Observe 仍独立进程、不经 Console proxy。
77
+
78
+ ### Loop Operator Console(Official 控制面骨架)
79
+
80
+ - 模块:`src/worker/console/`(loopback serve、doctor、operator API、Vite SPA)。
81
+ - 与 Observe 协作:`observeLink` + recovery CTA 矩阵;无 Cancel / 无主 CTA「直接改代码」。
82
+ - 写入路径仍只经 `LoopAgentClient` → 已发布 `loop-agent`;不与 Observe 合服。
76
83
 
77
84
  ## 版本化自举的 deterministic canary
78
85
 
@@ -255,12 +255,54 @@
255
255
  "requirementCoverageGate": {
256
256
  "type": "object", "additionalProperties": false,
257
257
  "required": ["fromNodeIds", "requiredIds"],
258
+ "allOf": [
259
+ {
260
+ "if": { "required": ["fallbackFromNodeIds"] },
261
+ "then": { "properties": { "fromNodeIds": { "maxItems": 1 } } }
262
+ }
263
+ ],
258
264
  "properties": {
259
265
  "fromNodeIds": { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" } },
266
+ "fallbackFromNodeIds": {
267
+ "type": "array",
268
+ "minItems": 1,
269
+ "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
270
+ "description": "Optional effective-source fallbacks tried in order only when the single primary node output is absent. Each source must also appear in depends_on."
271
+ },
260
272
  "requiredIds": { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "^(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*$" } },
261
273
  "label": { "type": "string", "minLength": 1 }
262
274
  }
263
275
  },
276
+ "jsonArtifactGate": {
277
+ "type": "object",
278
+ "additionalProperties": false,
279
+ "required": ["fromNodeId", "schemaId", "artifactName", "outputDir"],
280
+ "properties": {
281
+ "fromNodeId": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
282
+ "fallbackFromNodeIds": {
283
+ "type": "array",
284
+ "minItems": 1,
285
+ "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
286
+ "description": "Optional effective-source fallbacks tried in order only when the primary current-run node output is absent. Existing malformed or schema-invalid primary output fails closed. Each source must also appear in depends_on."
287
+ },
288
+ "schemaId": {
289
+ "enum": [
290
+ "backend-test-analysis-v1",
291
+ "backend-test-analysis-v2",
292
+ "backend-test-execution-v1",
293
+ "backend-test-result-v1",
294
+ "backend-test-classification-v1",
295
+ "backend-test-semantic-review-v1",
296
+ "backend-test-case-manifest-v1",
297
+ "frontend-implementation-contract-v1",
298
+ "frontend-test-result-v1"
299
+ ]
300
+ },
301
+ "artifactName": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*\\.json$" },
302
+ "outputDir": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
303
+ "junitRelativePath": { "type": "string", "minLength": 1 }
304
+ }
305
+ },
264
306
  "backendTestPipeline": {
265
307
  "enum": ["contracts", "semantic-initial", "execute-parse-initial", "classification-result-context"]
266
308
  },
@@ -281,6 +323,7 @@
281
323
  { "required": ["preset"] },
282
324
  { "required": ["verdictGate"] },
283
325
  { "required": ["requirementCoverageGate"] },
326
+ { "required": ["jsonArtifactGate"] },
284
327
  { "required": ["backendTestPipeline"] }
285
328
  ]
286
329
  },
@@ -319,10 +362,10 @@
319
362
  "retryCategories": {
320
363
  "type": "array",
321
364
  "items": {
322
- "enum": ["timeout", "network", "rate-limit", "unavailable"]
365
+ "enum": ["timeout", "network", "rate-limit", "unavailable", "output-too-large"]
323
366
  },
324
367
  "default": ["timeout", "network", "rate-limit", "unavailable"],
325
- "description": "Failure categories eligible for retry. quota is never eligible."
368
+ "description": "Failure categories eligible for retry. quota is never eligible. output-too-large is reserved for explicit structured-required nodes and is not part of the default retry set."
326
369
  }
327
370
  }
328
371
  },
@@ -404,6 +447,10 @@
404
447
  "type": "string",
405
448
  "minLength": 1
406
449
  },
450
+ "outputMode": {
451
+ "enum": ["default", "structured-required"],
452
+ "description": "Optional output semantics for Pi nodes. structured-required means oversized assistant output must not be accepted as a truncated success; eligible read-only nodes may compact-retry and downstream gates should rely on canonical structured artifacts."
453
+ },
407
454
  "firstProtocolLine": {
408
455
  "type": "string",
409
456
  "minLength": 1,
@@ -71,5 +71,5 @@ Do **not** invent pass rates or failure lists from raw logs when Result v1 is pr
71
71
  ### Non-goals
72
72
 
73
73
  - Do not rewrite Result v1.
74
- - Do not decide final DAG success/failure (that is `backend-test-outcome-gate-shell`).
74
+ - Do not decide pipeline completion or L-5 readiness; classification is interpretive evidence only.
75
75
  - Do not implement M3 case manifest / Task Pool auto follow-up.
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Purpose
4
4
 
5
- Use this prompt for a **pytest code generation** node: `executor: "pi"`, `role: "implementer"`, `toolProfile: "write"`, `writePolicy: "exclusive"`. The implementer converts reviewed backend functional test cases into pytest automation code with 1:1 traceability.
5
+ Use this prompt for a **pytest code generation** node: `executor: "pi"`, `role: "implementer"`, `toolProfile: "write"`, `writePolicy: "exclusive"`. The implementer converts validated backend functional test cases into pytest automation code with 1:1 traceability. The independent case review is advisory evidence and does not authorize this writer.
6
6
 
7
7
  Do **not** create a new executor type. This is a standard `executor: pi` writer node.
8
8
 
@@ -11,7 +11,7 @@ Do **not** create a new executor type. This is a standard `executor: pi` writer
11
11
  ```json
12
12
  {
13
13
  "id": "generate-backend-pytest-pi",
14
- "depends_on": ["review-backend-cases-gate-shell", "backend-test-execution-contract-shell"],
14
+ "depends_on": ["backend-test-case-manifest-shell", "validate-backend-test-contracts-shell"],
15
15
  "complexity": "HIGH",
16
16
  "executor": "pi",
17
17
  "role": "implementer",
@@ -38,12 +38,12 @@ Your job is to convert reviewed test cases under `testcase/md/` into pytest auto
38
38
 
39
39
  ### Inputs
40
40
 
41
- 1. **Reviewed test cases** — files under `testcase/md/` (approved by `review-backend-cases-pi` / `review-backend-cases-gate-shell`).
42
- 2. **Validated Backend Test Analysis v1** — run-owned `contracts/backend-test-analysis.json` from `backend-test-analysis-contract-shell`.
41
+ 1. **Validated test cases and manifest** — files under `testcase/md/` plus run-owned `contracts/backend-test-case-manifest.json`. The case review runs independently as advisory evidence.
42
+ 2. **Validated Backend Test Analysis v2** — run-owned `contracts/backend-test-analysis.json`.
43
43
  3. **Validated Backend Test Execution Contract v1** — run-owned `contracts/backend-test-execution.json` from `backend-test-execution-contract-shell` (fixtures, env *names*, `testRoot`, `targetMode`, authenticationMode).
44
44
  4. **Target project conventions** — read `conftest.py`, `pytest.ini` / `pyproject.toml` to understand conventions, but do NOT modify them.
45
45
 
46
- Do NOT re-read source documents for free-form analysis. Use only reviewed cases and the validated contracts. Use only fixture/env/testRoot facts already present in the execution contract; never invent production credentials or secret values.
46
+ Do NOT re-read source documents for free-form analysis. Use only validated cases, manifest, and contracts. Use only fixture/env/testRoot facts already present in the execution contract; never invent production credentials or secret values.
47
47
 
48
48
  When `targetMode` is `in-process` (including demoted local npm/node managed servers): bootstrap the service inside function-scoped pytest fixtures under `testcase/**` (for example subprocess `node server.js` / `startWelcomeServer` with `PORT=0`). Never require host-injected base URL env vars such as `WELCOME_BASE_URL` / `API_BASE_URL` — the clean-env pytest shell will not provide them.
49
49