@tea-agent/loop-agent 0.16.25 → 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 (115) hide show
  1. package/CHANGELOG.md +46 -0
  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 +118 -16
  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 +85 -8
  14. package/dist/executors/shell-presets.js +16 -3
  15. package/dist/executors/shell-write-guard.js +64 -2
  16. package/dist/shared/operator/capabilities.js +255 -0
  17. package/dist/shared/operator/envelope.js +59 -0
  18. package/dist/shared/operator/index.js +4 -0
  19. package/dist/shared/operator/registry.js +38 -0
  20. package/dist/shared/operator/types.js +5 -0
  21. package/dist/task/contract/adopt.js +166 -0
  22. package/dist/task/contract/apply.js +326 -0
  23. package/dist/task/contract/canonicalize.js +60 -0
  24. package/dist/task/contract/constants.js +29 -0
  25. package/dist/task/contract/diff.js +177 -0
  26. package/dist/task/contract/hash.js +42 -0
  27. package/dist/task/contract/import-revision.js +96 -0
  28. package/dist/task/contract/index.js +17 -0
  29. package/dist/task/contract/journal.js +155 -0
  30. package/dist/task/contract/lock.js +153 -0
  31. package/dist/task/contract/observe.js +296 -0
  32. package/dist/task/contract/paths.js +19 -0
  33. package/dist/task/contract/project.js +170 -0
  34. package/dist/task/contract/recover.js +312 -0
  35. package/dist/task/contract/request-ledger.js +37 -0
  36. package/dist/task/contract/schema.js +151 -0
  37. package/dist/task/contract/transaction.js +160 -0
  38. package/dist/task/contract/types.js +1 -0
  39. package/dist/task/contract/validate-draft.js +106 -0
  40. package/dist/task/index.js +3 -0
  41. package/dist/task/operator/capabilities.js +6 -0
  42. package/dist/task/operator/envelope.js +2 -0
  43. package/dist/task/operator/index.js +5 -0
  44. package/dist/task/operator/registry.js +2 -0
  45. package/dist/task/operator/types.js +1 -0
  46. package/dist/task/runtime.js +5 -1
  47. package/dist/task/source-references.js +7 -0
  48. package/dist/worker/cli.js +150 -32
  49. package/dist/worker/console/app-data.js +185 -0
  50. package/dist/worker/console/dag-confirmation.js +313 -0
  51. package/dist/worker/console/doctor.js +169 -0
  52. package/dist/worker/console/draft-store.js +80 -0
  53. package/dist/worker/console/index.js +15 -0
  54. package/dist/worker/console/interview/assessment.js +67 -0
  55. package/dist/worker/console/interview/session.js +100 -0
  56. package/dist/worker/console/interview/tools.js +109 -0
  57. package/dist/worker/console/loopback.js +16 -0
  58. package/dist/worker/console/observe-health-match.js +174 -0
  59. package/dist/worker/console/observe-link.js +33 -0
  60. package/dist/worker/console/operation-runner.js +166 -0
  61. package/dist/worker/console/operation-sse.js +158 -0
  62. package/dist/worker/console/operation-store.js +147 -0
  63. package/dist/worker/console/operator-actions.js +769 -0
  64. package/dist/worker/console/pi-readiness.js +94 -0
  65. package/dist/worker/console/recovery-cta.js +133 -0
  66. package/dist/worker/console/repo-fingerprint.js +29 -0
  67. package/dist/worker/console/resource-loader.js +95 -0
  68. package/dist/worker/console/routes.js +368 -0
  69. package/dist/worker/console/security.js +126 -0
  70. package/dist/worker/console/server.js +149 -0
  71. package/dist/worker/console/sibling-controller.js +28 -0
  72. package/dist/worker/console/static/assets/index-CbnMgdWa.js +9 -0
  73. package/dist/worker/console/static/assets/index-Dnj0RVs8.css +1 -0
  74. package/dist/worker/console/static/index.html +13 -0
  75. package/dist/worker/console/vite.config.js +27 -0
  76. package/dist/worker/delivery/git-transaction.js +43 -8
  77. package/dist/worker/observe/health.js +57 -0
  78. package/dist/worker/observe/paths.js +81 -0
  79. package/dist/worker/observe/routes.js +142 -27
  80. package/dist/worker/observe/spec-evidence.js +84 -0
  81. package/dist/worker/observe/static/api.js +23 -0
  82. package/dist/worker/observe/static/state.js +26 -0
  83. package/dist/worker/observe/static/styles.css +10 -0
  84. package/dist/worker/observe/static/views/dag-inspector.js +173 -6
  85. package/dist/workflows/dag/backend-test-analysis-contract.js +34 -9
  86. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -0
  87. package/dist/workflows/dag/frontend-repair.js +1 -10
  88. package/dist/workflows/dag/init-hybrid.js +372 -115
  89. package/dist/workflows/dag/node-execution.js +57 -6
  90. package/dist/workflows/dag/project-governance-context.js +508 -0
  91. package/dist/workflows/dag/prompt.js +46 -1
  92. package/dist/workflows/dag/retry-policy.js +16 -1
  93. package/dist/workflows/dag/runner.js +9 -0
  94. package/dist/workflows/dag/skill-snapshot.js +1 -0
  95. package/dist/workflows/dag/task-contract-binding.js +138 -0
  96. package/dist/workflows/dag/types.js +84 -10
  97. package/dist/workflows/dag/validate.js +53 -7
  98. package/docs/README.md +2 -0
  99. package/docs/architecture/evolution.md +2 -0
  100. package/docs/architecture/system-overview.md +6 -0
  101. package/docs/architecture/worker-and-feature.md +7 -0
  102. package/docs/templates/agent-dag.schema.json +64 -2
  103. package/docs/templates/agent-dag.supervised-implementation.json +1 -0
  104. package/docs/templates/backend-test-dag.classify.prompt.md +1 -1
  105. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -5
  106. package/docs/templates/backend-test-dag.json +26 -154
  107. package/docs/templates/backend-test-dag.retrospect.prompt.md +1 -1
  108. package/docs/templates/backend-test-dag.review-cases.prompt.md +2 -2
  109. package/package.json +8 -2
  110. package/skills/agent-worker/SKILL.md +1 -0
  111. package/skills/agent-worker/references/agent-worker-operator.md +3 -2
  112. package/skills/frontend-design-review/SKILL.md +25 -16
  113. package/skills/frontend-implementation/references/node-contracts.md +5 -5
  114. package/skills/loop-agent/references/command-reference.md +48 -1
  115. package/skills/loop-agent/references/hybrid-dag.md +4 -4
@@ -98,8 +98,49 @@ function buildReadOnlyBoundary(writePolicy) {
98
98
  "Return findings in the assistant response/stdout only; the DAG runner persists node artifacts under .harness/dag-runs/<state>/<run-id>/<node-id>/.",
99
99
  ];
100
100
  }
101
+ function formatProjectGovernanceContext(ctx) {
102
+ if (!ctx || !ctx.applicable)
103
+ return undefined;
104
+ const lines = [];
105
+ lines.push("Deterministic project governance manifest for the actual writer changeset of this run. Interpret it for compliance review; do not invent rules beyond the files and hashes listed here.");
106
+ lines.push("Before issuing a verdict, use read-only tools to read every applicable AGENTS.md and every mandatory referenced standard listed below from the repository; hashes bind the exact inputs for audit.");
107
+ lines.push("Your first non-empty output line must be exactly VERDICT: pass or VERDICT: request-revision. Request revision while any applicable mandatory instruction is violated.");
108
+ lines.push("");
109
+ lines.push("Change manifest (writer nodes and their changed files):");
110
+ for (const entry of ctx.changeManifest) {
111
+ if (entry.changedFiles.length === 0)
112
+ continue;
113
+ lines.push(`- ${entry.writerNodeId}: ${entry.changedFiles.join(", ")}`);
114
+ }
115
+ lines.push("");
116
+ lines.push("Applicable AGENTS.md chain (root -> nearest):");
117
+ for (const entry of ctx.agentsMdChain) {
118
+ lines.push(`- ${entry.path} (enforcement=mandatory, directory=${entry.directory || "/"}, sha256=${entry.sha256}, bytes=${entry.bytes}) applies to: ${entry.appliesTo.join(", ") || "(none)"}`);
119
+ }
120
+ if (ctx.referencedStandards.length > 0) {
121
+ lines.push("");
122
+ lines.push("Referenced repository-local code standards:");
123
+ for (const std of ctx.referencedStandards) {
124
+ lines.push(`- ${std.path} (enforcement=${std.enforcement}, from ${ctx.agentsMdChain[std.fromAgentsMd]?.path ?? "AGENTS.md"}, sha256=${std.sha256}, bytes=${std.bytes}, scope=${std.scope})`);
125
+ }
126
+ }
127
+ if (ctx.unresolvedReferences.length > 0) {
128
+ lines.push("");
129
+ lines.push("Unresolved reference diagnostics (structured, not read): for awareness only.");
130
+ for (const ref of ctx.unresolvedReferences) {
131
+ lines.push(`- ${ref.declaredPath}: ${ref.reason} (from ${ctx.agentsMdChain[ref.fromAgentsMd]?.path ?? "AGENTS.md"})`);
132
+ }
133
+ }
134
+ if (ctx.diagnostics.length > 0) {
135
+ lines.push("");
136
+ lines.push("Resolver diagnostics:");
137
+ for (const diag of ctx.diagnostics)
138
+ lines.push(`- ${diag}`);
139
+ }
140
+ return lines.join("\n");
141
+ }
101
142
  export function buildDagNodePromptEnvelope(input) {
102
- const { spec, task, upstream, resolvedSkills = [], resolvedSkillInstructions = [], maxUpstreamChars = MAX_UPSTREAM_CHARS, } = input;
143
+ const { spec, task, upstream, resolvedSkills = [], resolvedSkillInstructions = [], maxUpstreamChars = MAX_UPSTREAM_CHARS, projectGovernanceContext, } = input;
103
144
  const objective = spec.objective ?? spec.title;
104
145
  const successCriteria = formatBulletList(spec.successCriteria, "(none specified)");
105
146
  const globalConstraints = formatBulletList(spec.globalConstraints, "(none specified)");
@@ -150,6 +191,10 @@ export function buildDagNodePromptEnvelope(input) {
150
191
  else {
151
192
  sections.push("<upstream_context>\n(none)\n</upstream_context>");
152
193
  }
194
+ const governanceSection = formatProjectGovernanceContext(projectGovernanceContext);
195
+ if (governanceSection) {
196
+ sections.push(`<project_governance_context>\n${governanceSection}\n</project_governance_context>`);
197
+ }
153
198
  sections.push(`<task>\n${task.subtask_prompt}\n</task>`);
154
199
  return sections.join("\n\n");
155
200
  }
@@ -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();
@@ -524,6 +524,7 @@ export function buildNodePromptFromSnapshot(input) {
524
524
  resolvedSkills: skillNames,
525
525
  resolvedSkillInstructions,
526
526
  maxUpstreamChars: policy.resolveMaxUpstreamChars(input.task),
527
+ projectGovernanceContext: input.projectGovernanceContext,
527
528
  }),
528
529
  resolvedSkills: resolvedSkillInstructions.map(stripPromptText),
529
530
  };
@@ -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",
@@ -152,6 +182,9 @@ export const dagShellConfigSchema = z.object({
152
182
  commands: z.array(z.string()).default([]),
153
183
  preset: dagShellPresetSchema.optional(),
154
184
  verdictGate: dagVerdictGateSchema.optional(),
185
+ projectGovernanceGate: z.object({
186
+ contextPath: z.literal(".runtime/project-governance-context.json"),
187
+ }).strict().optional(),
155
188
  requirementCoverageGate: dagRequirementCoverageGateSchema.optional(),
156
189
  jsonArtifactGate: dagJsonArtifactGateSchema.optional(),
157
190
  backendTestPipeline: dagBackendTestPipelineSchema.optional(),
@@ -310,7 +343,15 @@ export const dagTaskSchema = z.object({
310
343
  shell: dagShellConfigSchema.optional(),
311
344
  static: dagStaticConfigSchema.optional(),
312
345
  outputContract: z.string().optional(),
346
+ outputMode: dagOutputModeSchema.optional(),
313
347
  firstProtocolLine: z.string().min(1).optional(),
348
+ /**
349
+ * Explicit opt-in for the deterministic project governance context resolver
350
+ * (AGENTS.md chain + referenced code standards). Only tasks that set this
351
+ * to `true` receive a `<project_governance_context>` prompt section and
352
+ * closeout-blocking gate behavior. Never inferred from role/name.
353
+ */
354
+ governanceStandardReview: z.boolean().optional(),
314
355
  allowedPaths: z.array(z.string()).optional().default([]),
315
356
  forbiddenPaths: z.array(z.string()).optional().default([]),
316
357
  decisionGate: dagDecisionGateSchema.optional(),
@@ -366,9 +407,11 @@ export const dagSpecSchema = z
366
407
  title: z.string().min(1),
367
408
  runtimeContract: dagRuntimeContractSchema.optional(),
368
409
  evaluation: dagEvaluationBindingSchema.optional(),
369
- /** Optional hard/record-only budget; requires version 3. */
410
+ /** Optional hard/record-only budget; requires version 3 or 4. */
370
411
  budget: dagBudgetSchema.optional(),
371
412
  sourceBinding: dagSourceBindingSchema.optional(),
413
+ /** v4 managed Task Contract binding; only valid on version 4. */
414
+ taskContractBinding: dagTaskContractBindingSchema.optional(),
372
415
  outputLanguage: dagOutputLanguageSchema.optional(),
373
416
  objective: z.string().optional(),
374
417
  successCriteria: z.array(z.string()).optional(),
@@ -381,34 +424,65 @@ export const dagSpecSchema = z
381
424
  tasks: z.array(dagTaskSchema).min(1),
382
425
  })
383
426
  .superRefine((spec, ctx) => {
384
- if (spec.evaluation && spec.version !== 3) {
427
+ const supportsV3Fields = spec.version === 3 || spec.version === 4;
428
+ if (spec.evaluation && !supportsV3Fields) {
385
429
  ctx.addIssue({
386
430
  code: z.ZodIssueCode.custom,
387
- message: "evaluation requires DagSpec version 3",
431
+ message: "evaluation requires DagSpec version 3 or 4",
388
432
  path: ["evaluation"],
389
433
  });
390
434
  }
391
- if (spec.budget && spec.version !== 3) {
435
+ if (spec.budget && !supportsV3Fields) {
392
436
  ctx.addIssue({
393
437
  code: z.ZodIssueCode.custom,
394
- message: "budget requires DagSpec version 3",
438
+ message: "budget requires DagSpec version 3 or 4",
395
439
  path: ["budget"],
396
440
  });
397
441
  }
398
- if (spec.runtimeContract && spec.version !== 3) {
442
+ if (spec.runtimeContract && !supportsV3Fields) {
399
443
  ctx.addIssue({
400
444
  code: z.ZodIssueCode.custom,
401
- message: "runtimeContract requires DagSpec version 3",
445
+ message: "runtimeContract requires DagSpec version 3 or 4",
402
446
  path: ["version"],
403
447
  });
404
448
  }
405
- if (spec.version === 3 && !spec.runtimeContract) {
449
+ if (supportsV3Fields && !spec.runtimeContract) {
406
450
  ctx.addIssue({
407
451
  code: z.ZodIssueCode.custom,
408
- message: "DagSpec version 3 requires runtimeContract",
452
+ message: `DagSpec version ${spec.version} requires runtimeContract`,
409
453
  path: ["runtimeContract"],
410
454
  });
411
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
+ }
412
486
  // Catch raw cursor keys that Zod .strict() on nested objects already rejects when parsed
413
487
  // via parseDagSpec; this refine covers typed object construction paths.
414
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);
@@ -653,12 +671,40 @@ export function validateDagSpec(spec) {
653
671
  validateStaticTaskConfig(task, issues);
654
672
  validateDecisionGateTaskConfig(task, issues);
655
673
  validateRetryPolicyTaskConfig(task, issues);
674
+ validateProjectGovernanceTaskConfig(task, spec, issues);
656
675
  }
657
676
  validateSameRankWriteSetConflicts(spec, ranks, issues);
658
677
  validateSameRankAgentAttributionRisks(spec, ranks, issues);
659
678
  validateWriterSourceBinding(spec, issues);
660
679
  return issues;
661
680
  }
681
+ function validateProjectGovernanceTaskConfig(task, spec, issues) {
682
+ if (task.governanceStandardReview) {
683
+ if (task.executor !== "pi" ||
684
+ task.toolProfile === "write" ||
685
+ !["read-only", "none"].includes((task.writePolicy ?? "read-only"))) {
686
+ issues.push({
687
+ type: "invalid-project-governance-config",
688
+ message: `task ${task.id} governanceStandardReview requires a read-only Pi node`,
689
+ });
690
+ }
691
+ }
692
+ if (!task.shell?.projectGovernanceGate)
693
+ return;
694
+ const verdictGate = task.shell.verdictGate;
695
+ const source = verdictGate
696
+ ? spec.tasks.find((candidate) => candidate.id === verdictGate.fromNodeId)
697
+ : undefined;
698
+ if (task.executor !== "shell" ||
699
+ !verdictGate ||
700
+ !task.depends_on.includes(verdictGate.fromNodeId) ||
701
+ !source?.governanceStandardReview) {
702
+ issues.push({
703
+ type: "invalid-project-governance-config",
704
+ message: `task ${task.id} projectGovernanceGate requires verdictGate over a directly-dependent governanceStandardReview node`,
705
+ });
706
+ }
707
+ }
662
708
  export function assertValidDagSpec(spec, options = {}) {
663
709
  const issues = collectBlockingIssues(options.issues ?? validateDagSpec(spec), options);
664
710
  if (issues.length > 0) {
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