@tea-agent/loop-agent 0.4.0 → 0.6.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 (67) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +48 -38
  3. package/README.md +3 -3
  4. package/dist/application/dag/args.js +9 -1
  5. package/dist/application/dag/run-dag.js +16 -2
  6. package/dist/application/dag/validate-dag.js +14 -1
  7. package/dist/cli/command-definitions.js +22 -4
  8. package/dist/cli/help.js +3 -2
  9. package/dist/cli/program.js +7 -5
  10. package/dist/commands/import-prd.js +76 -0
  11. package/dist/commands/init.js +230 -32
  12. package/dist/commands/instructions.js +90 -58
  13. package/dist/executors/config-core.js +3 -2
  14. package/dist/executors/dag-pi-executor.js +1 -0
  15. package/dist/executors/model-routing.js +43 -0
  16. package/dist/executors/pi-sdk-executor.js +63 -1
  17. package/dist/governance/manifest-types.js +9 -1
  18. package/dist/shared/preview.js +39 -0
  19. package/dist/task/source-references.js +221 -0
  20. package/dist/worker/cli.js +62 -1
  21. package/dist/worker/loop-agent/loop-agent-client.js +97 -5
  22. package/dist/worker/materialize/harness-task-materializer.js +162 -5
  23. package/dist/worker/observability/event-store.js +82 -0
  24. package/dist/worker/observability/events.js +79 -0
  25. package/dist/worker/observability/progress-composite.js +33 -0
  26. package/dist/worker/observability/read-model.js +1013 -0
  27. package/dist/worker/observability/snapshot-store.js +43 -0
  28. package/dist/worker/observability/types.js +1 -0
  29. package/dist/worker/observe/paths.js +64 -0
  30. package/dist/worker/observe/routes.js +423 -0
  31. package/dist/worker/observe/server.js +61 -0
  32. package/dist/worker/observe/static/app.js +1419 -0
  33. package/dist/worker/observe/static/index.html +63 -0
  34. package/dist/worker/observe/static/styles.css +613 -0
  35. package/dist/worker/pool/failure-routing.js +41 -6
  36. package/dist/worker/pool/run-store.js +59 -1
  37. package/dist/worker/progress-reporter.js +0 -18
  38. package/dist/worker/run-task/run-task.js +327 -92
  39. package/dist/worker/runner/run-ready.js +112 -4
  40. package/dist/workflows/dag/event-observer.js +132 -0
  41. package/dist/workflows/dag/init-hybrid.js +150 -26
  42. package/dist/workflows/dag/observer-compose.js +52 -0
  43. package/dist/workflows/dag/skill-instructions.js +4 -0
  44. package/dist/workflows/dag/types.js +1 -1
  45. package/dist/workflows/dag/validate.js +3 -2
  46. package/docs/README.md +2 -0
  47. package/docs/architecture/runtime-boundaries.md +18 -3
  48. package/docs/design/README.md +22 -9
  49. package/docs/exec-plans/active/README.md +6 -1
  50. package/docs/exec-plans/completed/README.md +12 -0
  51. package/docs/init-surface.manifest.json +32 -2
  52. package/docs/loop-agent-harness.md +13 -0
  53. package/docs/reports/README.md +4 -0
  54. package/docs/templates/agent-dag.base.json +1 -1
  55. package/docs/templates/agent-dag.final-verification.json +1 -1
  56. package/docs/templates/agent-dag.supervised-implementation.json +1 -1
  57. package/docs/templates/hybrid-dag.json +1 -1
  58. package/docs/templates/worker-dogfood-evidence.md +52 -0
  59. package/docs/templates/worker-dogfood-setup.md +48 -0
  60. package/examples/example-dag.json +1 -1
  61. package/examples/hybrid-loop-agent-dag.json +1 -1
  62. package/harness.json +5 -29
  63. package/package.json +6 -6
  64. package/skills/loop-agent/SKILL.md +5 -3
  65. package/skills/loop-agent/references/command-reference.md +12 -3
  66. package/skills/loop-agent/references/harness-policy.md +7 -3
  67. package/skills/loop-agent/references/task-workflow.md +8 -3
@@ -2,10 +2,10 @@ import { createHash } from "node:crypto";
2
2
  import { readFile, writeFile, mkdir } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import YAML from "yaml";
5
- import { deriveFailureRoute } from "../pool/failure-routing.js";
5
+ import { deriveFailureRoute, deriveFailureRouteFromError, } from "../pool/failure-routing.js";
6
6
  import { findRunByWorkerRunId, getTaskPoolRoot, readAllTaskPoolStates, recordTaskPoolRun, writeTaskPoolState, } from "../pool/run-store.js";
7
7
  import { runTaskSpec, } from "../run-task/run-task.js";
8
- import { formatDuration, noopProgressReporter } from "../progress-reporter.js";
8
+ import { formatDuration, noopProgressReporter, } from "../progress-reporter.js";
9
9
  import { computeReadyQueue } from "../task-graph/ready-queue.js";
10
10
  import { taskGraphSpecSchema } from "../task-graph/task-graph-schema.js";
11
11
  import { taskSpecSchema } from "../task-spec/schema.js";
@@ -23,18 +23,43 @@ export async function runReadyTasks(options) {
23
23
  const total = limitedTaskIds.length;
24
24
  let index = 0;
25
25
  const batchStartedAt = Date.now();
26
+ emit(progress, {
27
+ type: "readyQueue.computed",
28
+ source: "worker",
29
+ label: `ready queue: ${total} task${total === 1 ? "" : "s"} ready`,
30
+ batchRunId,
31
+ });
26
32
  progress.batch(`batch ${batchRunId}: feature=${graph.feature_id}, ${total} task${total === 1 ? "" : "s"} ready → starting`);
33
+ emit(progress, {
34
+ type: "batch.started",
35
+ source: "worker",
36
+ label: `batch ${batchRunId}: feature=${graph.feature_id}, ${total} task${total === 1 ? "" : "s"} ready`,
37
+ batchRunId,
38
+ status: "running",
39
+ });
27
40
  for (const taskId of limitedTaskIds) {
28
41
  index += 1;
29
42
  const node = graph.nodes.find((candidate) => candidate.id === taskId);
30
43
  const taskSpecPath = path.join(options.featureDir, "tasks", node?.task ?? `${taskId}.yaml`);
31
44
  const taskSpec = await loadTaskSpec(taskSpecPath);
32
- const workerRunId = options.workerRunIdForTask?.(taskId, taskSpec) ??
33
- buildStableWorkerRunId(taskId, taskSpec, now);
45
+ const retryOfWorkerRunId = states[taskId]?.retryOfWorkerRunId;
46
+ const workerRunId = retryOfWorkerRunId
47
+ ? buildRetryWorkerRunId(taskId, retryOfWorkerRunId, now)
48
+ : options.workerRunIdForTask?.(taskId, taskSpec) ??
49
+ buildStableWorkerRunId(taskId, taskSpec, now);
34
50
  if (workerRunId) {
35
51
  const existing = await findRunByWorkerRunId(options.repoRoot, workerRunId);
36
52
  if (existing) {
37
53
  progress.task(`task ${index}/${total} ${taskId}: reuse existing run ${workerRunId}`);
54
+ emit(progress, {
55
+ type: "task.reused",
56
+ source: "worker",
57
+ label: `task ${taskId}: reuse existing run ${workerRunId}`,
58
+ batchRunId,
59
+ taskId,
60
+ workerRunId,
61
+ status: "reused",
62
+ });
38
63
  tasks.push({
39
64
  taskId,
40
65
  workerRunId,
@@ -46,6 +71,15 @@ export async function runReadyTasks(options) {
46
71
  }
47
72
  const taskStartedAt = Date.now();
48
73
  progress.task(`task ${index}/${total} ${taskId} "${taskSpec.title}" (${workerRunId})`);
74
+ emit(progress, {
75
+ type: "task.started",
76
+ source: "worker",
77
+ label: `task ${index}/${total} ${taskId}`,
78
+ batchRunId,
79
+ taskId,
80
+ workerRunId,
81
+ status: "running",
82
+ });
49
83
  progress.step(`preflight → materialize → dag-run-task → validate → run-dag → report`);
50
84
  let result;
51
85
  try {
@@ -54,6 +88,16 @@ export async function runReadyTasks(options) {
54
88
  status: "Running",
55
89
  updatedAt: new Date().toISOString(),
56
90
  workerRunId,
91
+ ...(retryOfWorkerRunId ? { retryOfWorkerRunId } : {}),
92
+ });
93
+ emit(progress, {
94
+ type: "state.updated",
95
+ source: "worker",
96
+ label: `task pool state: ${taskId} → Running`,
97
+ batchRunId,
98
+ taskId,
99
+ workerRunId,
100
+ status: "running",
57
101
  });
58
102
  result = await runner({
59
103
  repoRoot: options.repoRoot,
@@ -72,7 +116,18 @@ export async function runReadyTasks(options) {
72
116
  }
73
117
  catch (error) {
74
118
  const message = errorMessage(error);
119
+ const failure = deriveFailureRouteFromError(message, taskId);
75
120
  progress.note(`task ${taskId}: ERROR ${message}`);
121
+ emit(progress, {
122
+ type: "task.finished",
123
+ source: "worker",
124
+ label: `task ${taskId} run-error`,
125
+ batchRunId,
126
+ taskId,
127
+ workerRunId,
128
+ status: "failed",
129
+ message,
130
+ });
76
131
  try {
77
132
  await recordTaskPoolRun({
78
133
  repoRoot: options.repoRoot,
@@ -85,6 +140,8 @@ export async function runReadyTasks(options) {
85
140
  status: "run-error",
86
141
  recordedAt: new Date().toISOString(),
87
142
  error: message,
143
+ failure,
144
+ ...(retryOfWorkerRunId ? { retryOfWorkerRunId } : {}),
88
145
  },
89
146
  });
90
147
  }
@@ -106,6 +163,29 @@ export async function runReadyTasks(options) {
106
163
  continue;
107
164
  }
108
165
  const failure = deriveFailureRoute(result);
166
+ emit(progress, {
167
+ type: "task.finished",
168
+ source: "worker",
169
+ label: `task ${taskId} ${result.status}`,
170
+ batchRunId,
171
+ taskId,
172
+ workerRunId: result.workerRunId,
173
+ status: result.status,
174
+ durationMs: Date.now() - taskStartedAt,
175
+ });
176
+ if (failure) {
177
+ emit(progress, {
178
+ type: "failure.routed",
179
+ source: "worker",
180
+ label: `failure routed: ${failure.category}`,
181
+ batchRunId,
182
+ taskId,
183
+ workerRunId: result.workerRunId,
184
+ failureCategory: failure.category,
185
+ recommendedFollowUp: failure.recommendedFollowUpKind,
186
+ status: "failed",
187
+ });
188
+ }
109
189
  if (result.status === "succeeded") {
110
190
  progress.step(`✓ ${taskId} succeeded in ${formatDuration(Date.now() - taskStartedAt)} (promoted + closed out)`);
111
191
  }
@@ -124,6 +204,7 @@ export async function runReadyTasks(options) {
124
204
  dagPath: result.dagPath,
125
205
  recordedAt: new Date().toISOString(),
126
206
  ...(failure ? { failure } : {}),
207
+ ...(retryOfWorkerRunId ? { retryOfWorkerRunId } : {}),
127
208
  ...(result.failureArtifacts ? { failureArtifacts: result.failureArtifacts } : {}),
128
209
  };
129
210
  try {
@@ -160,8 +241,22 @@ export async function runReadyTasks(options) {
160
241
  await mkdir(path.dirname(batchRunPath), { recursive: true });
161
242
  await writeFile(batchRunPath, `${JSON.stringify(output, null, 2)}\n`, "utf-8");
162
243
  progress.batch(`batch ${batchRunId} ${output.status}: ${summary.succeeded} succeeded, ${summary.failed} failed, ${summary.reused} reused in ${formatDuration(Date.now() - batchStartedAt)} → ${batchRunPath}`);
244
+ emit(progress, {
245
+ type: "batch.finished",
246
+ source: "worker",
247
+ label: `batch ${batchRunId} ${output.status}`,
248
+ batchRunId,
249
+ status: output.status === "completed" ? "succeeded" : "failed",
250
+ durationMs: Date.now() - batchStartedAt,
251
+ });
163
252
  return output;
164
253
  }
254
+ function emit(progress, input) {
255
+ const structured = progress;
256
+ if (typeof structured.event !== "function")
257
+ return;
258
+ void structured.event(input).catch(() => { });
259
+ }
165
260
  export function buildBatchRunId(now) {
166
261
  return `batch-${now.toISOString().replace(/[-:.]/g, "").slice(0, 15)}`;
167
262
  }
@@ -177,6 +272,19 @@ export function buildStableWorkerRunId(taskId, taskSpec, now) {
177
272
  .slice(0, 10);
178
273
  return `wr-${date}-${slug}-${hash}`;
179
274
  }
275
+ /** A retry is a distinct execution attempt and must not collide with failed evidence. */
276
+ export function buildRetryWorkerRunId(taskId, previousWorkerRunId, now) {
277
+ const date = now.toISOString().slice(0, 10).replace(/-/g, "");
278
+ const slug = taskId
279
+ .toLowerCase()
280
+ .replace(/[^a-z0-9]+/g, "-")
281
+ .replace(/^-+|-+$/g, "");
282
+ const hash = createHash("sha256")
283
+ .update(`${previousWorkerRunId}:${now.toISOString()}`)
284
+ .digest("hex")
285
+ .slice(0, 10);
286
+ return `wr-${date}-${slug}-retry-${hash}`;
287
+ }
180
288
  async function loadTaskGraph(featureDir) {
181
289
  const graphPath = path.join(featureDir, "tasks", "task-graph.yaml");
182
290
  return taskGraphSpecSchema.parse(YAML.parse(await readFile(graphPath, "utf-8")));
@@ -0,0 +1,132 @@
1
+ import { appendFile, mkdir } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import path from "node:path";
4
+ import { redactSecrets, truncateUtf8Preview } from "../../shared/preview.js";
5
+ import { resolveModelForTask, } from "./types.js";
6
+ const DEFAULT_MAX_OUTPUT_PREVIEW_BYTES = 4096;
7
+ /**
8
+ * Truncate `text` to fit within `maxBytes` (UTF-8), appending a marker suffix
9
+ * describing the number of elided bytes. Local implementation to avoid importing
10
+ * worker observability (which would create a circular dependency).
11
+ */
12
+ export function truncateEventPreview(text, maxBytes = DEFAULT_MAX_OUTPUT_PREVIEW_BYTES) {
13
+ return truncateUtf8Preview(text, maxBytes);
14
+ }
15
+ function reportError(context, error) {
16
+ const message = error instanceof Error ? error.message : String(error);
17
+ process.stderr.write(`[dag-event-observer] ${context} failed: ${message}\n`);
18
+ }
19
+ function findTask(spec, state, nodeId) {
20
+ const node = state.nodes[nodeId];
21
+ const task = spec?.tasks.find((candidate) => candidate.id === nodeId);
22
+ return { task, node };
23
+ }
24
+ function rankOf(state, nodeId) {
25
+ for (let i = 0; i < state.ranks.length; i += 1) {
26
+ if (state.ranks[i]?.includes(nodeId))
27
+ return String(i);
28
+ }
29
+ return undefined;
30
+ }
31
+ export function createDagEventObserver(options) {
32
+ const { eventsJsonlPath } = options;
33
+ const maxOutputPreviewBytes = options.maxOutputPreviewBytes ?? DEFAULT_MAX_OUTPUT_PREVIEW_BYTES;
34
+ let writeError = null;
35
+ const append = async (event) => {
36
+ try {
37
+ await mkdir(path.dirname(eventsJsonlPath), { recursive: true });
38
+ const line = `${JSON.stringify(event)}\n`;
39
+ await appendFile(eventsJsonlPath, line, "utf8");
40
+ }
41
+ catch (error) {
42
+ writeError = error;
43
+ reportError(`append ${event.type}`, error);
44
+ }
45
+ };
46
+ const buildBase = (type, state) => ({
47
+ schemaVersion: 1,
48
+ id: randomUUID(),
49
+ at: new Date().toISOString(),
50
+ dagRunId: state.runId || options.dagRunId || "dag",
51
+ });
52
+ const observer = {
53
+ onRunStart: async (state) => {
54
+ const event = {
55
+ ...buildBase("dag.run.started", state),
56
+ type: "dag.run.started",
57
+ status: state.status,
58
+ label: state.title,
59
+ };
60
+ await append(event);
61
+ },
62
+ onNodeStart: async (nodeId, state) => {
63
+ const { task, node } = findTask(options.spec, state, nodeId);
64
+ const event = {
65
+ ...buildBase("dag.node.started", state),
66
+ type: "dag.node.started",
67
+ nodeId,
68
+ status: node?.status,
69
+ rank: rankOf(state, nodeId),
70
+ executor: node?.executor,
71
+ model: task
72
+ ? resolveModelForTask(task, options.spec?.executorModels)
73
+ : undefined,
74
+ label: node?.id,
75
+ };
76
+ await append(event);
77
+ },
78
+ onNodeOutput: async (nodeId, chunk, state) => {
79
+ const { task, node } = findTask(options.spec, state, nodeId);
80
+ const event = {
81
+ ...buildBase("dag.node.output", state),
82
+ type: "dag.node.output",
83
+ nodeId,
84
+ status: node?.status,
85
+ rank: rankOf(state, nodeId),
86
+ executor: node?.executor,
87
+ model: task
88
+ ? resolveModelForTask(task, options.spec?.executorModels)
89
+ : undefined,
90
+ label: node?.id,
91
+ outputPreview: truncateEventPreview(redactSecrets(chunk), maxOutputPreviewBytes),
92
+ };
93
+ await append(event);
94
+ },
95
+ onNodeFinish: async (nodeId, state) => {
96
+ const { task, node } = findTask(options.spec, state, nodeId);
97
+ const event = {
98
+ ...buildBase("dag.node.finished", state),
99
+ type: "dag.node.finished",
100
+ nodeId,
101
+ status: node?.status,
102
+ rank: rankOf(state, nodeId),
103
+ executor: node?.executor,
104
+ model: task
105
+ ? resolveModelForTask(task, options.spec?.executorModels)
106
+ : undefined,
107
+ label: node?.id,
108
+ durationMs: node?.durationMs,
109
+ };
110
+ await append(event);
111
+ },
112
+ onRunFinish: async (state) => {
113
+ const event = {
114
+ ...buildBase("dag.run.finished", state),
115
+ type: "dag.run.finished",
116
+ status: state.status,
117
+ label: state.title,
118
+ };
119
+ await append(event);
120
+ },
121
+ };
122
+ return {
123
+ observer,
124
+ flush: async () => {
125
+ // No buffering: append is immediate. Surface the last write error
126
+ // (best-effort) only if the caller asks, but never throw.
127
+ if (writeError) {
128
+ reportError("flush", writeError);
129
+ }
130
+ },
131
+ };
132
+ }
@@ -1,18 +1,22 @@
1
- import { access, readFile, writeFile } from "node:fs/promises";
1
+ import { access, readdir, readFile, writeFile } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { assertValidDagSpec } from "./validate.js";
5
- import { parseDagSpec, } from "./types.js";
5
+ import { DEFAULT_DAG_EXECUTOR_MODELS, parseDagSpec, } from "./types.js";
6
6
  import { pathMatchesPattern } from "../../shared/git-progress.js";
7
7
  import { BASELINE_FORBIDDEN_PATHS } from "./governance-constants.js";
8
8
  import { resolveAdapter } from "../../adapters/index.js";
9
9
  import { loadHarnessManifest } from "../../governance/harness.js";
10
10
  import { buildAuthoritySurfaceAuditNode, buildAuthoritySurfaceGateNode, resolveAuthoritySurfaceAudit, } from "./authority-surface.js";
11
11
  import { getTaskPaths, loadTaskConfig } from "../../task/runtime.js";
12
+ import { materializeTaskReferenceDocs } from "../../task/source-references.js";
12
13
  import { resolveVerifyPreset } from "../../executors/shell-verification.js";
14
+ import { resolveExecutorModelMatrices } from "../../executors/model-routing.js";
13
15
  const REQUIREMENT_FILE = "需求.md";
14
16
  const CONSTRAINT_FILE = "执行约束.md";
17
+ const REFERENCE_DIRECTORY = "references";
15
18
  const MAX_SOURCE_EXCERPT_CHARS = 2000;
19
+ const MAX_SOURCE_REFERENCE_DOCUMENTS = 8;
16
20
  const HYBRID_DEFAULTS = {
17
21
  executor: "pi",
18
22
  piBackend: "sdk-first",
@@ -100,27 +104,85 @@ function mapTaskComplexity(complexity) {
100
104
  return "HIGH";
101
105
  return "MED";
102
106
  }
103
- function excerptMarkdown(content, maxChars = MAX_SOURCE_EXCERPT_CHARS) {
107
+ export function excerptMarkdown(content, options = {}) {
108
+ const maxChars = options.maxChars ?? MAX_SOURCE_EXCERPT_CHARS;
104
109
  const trimmed = content.trim();
105
- if (trimmed.length <= maxChars)
106
- return trimmed;
107
- return `${trimmed.slice(0, maxChars)}\n\n...[truncated for draft prompt]`;
110
+ if (trimmed.length <= maxChars) {
111
+ return {
112
+ text: trimmed,
113
+ truncated: false,
114
+ originalChars: trimmed.length,
115
+ maxChars,
116
+ };
117
+ }
118
+ const omitted = trimmed.length - maxChars;
119
+ const ref = options.sourceRef?.trim();
120
+ const pointer = ref
121
+ ? [
122
+ `...[truncated for draft prompt: showing first ${maxChars} of ${trimmed.length} chars; omitted ${omitted} chars]`,
123
+ `Full source (authoritative, do not invent missing content): ${ref}`,
124
+ "When the omitted tail may affect acceptance/non-goals/constraints, re-read that file before deciding.",
125
+ ].join("\n")
126
+ : `...[truncated for draft prompt: showing first ${maxChars} of ${trimmed.length} chars; omitted ${omitted} chars]`;
127
+ return {
128
+ text: `${trimmed.slice(0, maxChars)}\n\n${pointer}`,
129
+ truncated: true,
130
+ originalChars: trimmed.length,
131
+ maxChars,
132
+ };
133
+ }
134
+ function isHeadingLine(line) {
135
+ return /^#{1,6}\s+/.test(line.trim());
108
136
  }
109
- function extractObjective(requirementMarkdown, title) {
137
+ function isMetadataLine(line) {
138
+ const trimmed = line.trim();
139
+ if (!trimmed)
140
+ return true;
141
+ if (/^(TaskSpec business id|Feature id|Task type|Risk level)\s*:/i.test(trimmed)) {
142
+ return true;
143
+ }
144
+ if (/^(权威来源|SHA-256|冲突时以)/.test(trimmed))
145
+ return true;
146
+ if (/^>/.test(trimmed) && /(权威来源|SHA-256|原始 PRD|reference)/i.test(trimmed)) {
147
+ return true;
148
+ }
149
+ return false;
150
+ }
151
+ export function extractObjective(requirementMarkdown, title) {
152
+ const sectionMatchers = [
153
+ /##\s*(目标|Objective|Goals?)\s*\n([\s\S]*?)(?=\n##\s|\n#\s|$)/i,
154
+ /##\s*Description\s*\n([\s\S]*?)(?=\n##\s|\n#\s|$)/i,
155
+ ];
156
+ for (const matcher of sectionMatchers) {
157
+ const match = requirementMarkdown.match(matcher);
158
+ if (!match)
159
+ continue;
160
+ const body = (match[2] ?? match[1] ?? "").trim();
161
+ for (const line of body.split("\n")) {
162
+ const trimmed = line
163
+ .replace(/^[-*]\s*\[[ xX]\]\s*/, "")
164
+ .replace(/^[-*]\s*/, "")
165
+ .trim();
166
+ if (!trimmed || isHeadingLine(trimmed) || isMetadataLine(trimmed))
167
+ continue;
168
+ return trimmed.slice(0, 500);
169
+ }
170
+ }
110
171
  const lines = requirementMarkdown.split("\n");
111
172
  for (const line of lines) {
112
173
  const trimmed = line.trim();
113
- if (!trimmed || trimmed.startsWith("#"))
174
+ if (!trimmed || isHeadingLine(trimmed) || isMetadataLine(trimmed))
114
175
  continue;
115
176
  return trimmed.slice(0, 500);
116
177
  }
117
178
  return `Implement task: ${title}`;
118
179
  }
119
- function extractSuccessCriteria(requirementMarkdown, taskId) {
180
+ const SUCCESS_CRITERIA_SECTION = /##\s*(验收标准|完成标准|Acceptance(?:\s+References)?|Success Criteria)\s*\n([\s\S]*?)(?=\n##\s|\n#\s|$)/i;
181
+ export function extractSuccessCriteria(requirementMarkdown, taskId) {
120
182
  const criteria = [];
121
- const sectionMatch = requirementMarkdown.match(/##\s*验收标准\s*\n([\s\S]*?)(?=\n##\s|\n#\s|$)/);
183
+ const sectionMatch = requirementMarkdown.match(SUCCESS_CRITERIA_SECTION);
122
184
  if (sectionMatch) {
123
- for (const line of sectionMatch[1].split("\n")) {
185
+ for (const line of sectionMatch[2].split("\n")) {
124
186
  const trimmed = line.replace(/^[-*]\s*\[[ xX]\]\s*/, "").trim();
125
187
  if (trimmed.startsWith("-") || trimmed.startsWith("*")) {
126
188
  criteria.push(trimmed.replace(/^[-*]\s*/, "").trim());
@@ -225,20 +287,77 @@ function deriveParallelScoutPaths(taskConfig) {
225
287
  : allowed,
226
288
  };
227
289
  }
290
+ function toTaskRelativeSourcePath(sources, absolutePath) {
291
+ return path
292
+ .relative(sources.taskDir, absolutePath)
293
+ .replaceAll(path.sep, "/");
294
+ }
228
295
  function buildSourceContextBlock(sources) {
296
+ const requirementRef = toTaskRelativeSourcePath(sources, sources.requirementPath);
297
+ const requirementExcerpt = excerptMarkdown(sources.requirementMarkdown, {
298
+ sourceRef: requirementRef,
299
+ });
229
300
  const parts = [
230
301
  "## Task source: 需求.md",
231
- excerptMarkdown(sources.requirementMarkdown),
302
+ requirementExcerpt.text,
232
303
  ];
233
304
  if (sources.constraintMarkdown) {
234
- parts.push("## Task source: 执行约束.md", excerptMarkdown(sources.constraintMarkdown));
305
+ const constraintRef = toTaskRelativeSourcePath(sources, sources.constraintPath);
306
+ const constraintExcerpt = excerptMarkdown(sources.constraintMarkdown, {
307
+ sourceRef: constraintRef,
308
+ });
309
+ parts.push("## Task source: 执行约束.md", constraintExcerpt.text);
235
310
  }
236
- parts.push("## Task config summary", `- taskId: ${sources.taskConfig.taskId}`, `- flow: ${sources.taskConfig.flow}`, `- complexity: ${sources.taskConfig.complexity}`, `- contextProfile: ${sources.taskConfig.contextProfile}`, `- allowedPaths: ${sources.taskConfig.allowedPaths.join(", ") || "(none — review before execute)"}`, `- forbiddenPaths: ${sources.taskConfig.forbiddenPaths.join(", ") || "(none)"}`, '- Pi DAG nodes are read-only unless toolProfile="write" is explicitly selected for a bounded writer node.', "- Agent DAG read-only nodes must not write root artifacts/**; root artifacts/ is not a per-node scratchpad.");
311
+ for (const reference of sources.referenceDocuments ?? []) {
312
+ const relativePath = path
313
+ .relative(path.join(sources.taskDir, "source"), reference.path)
314
+ .replaceAll(path.sep, "/");
315
+ const referenceRef = toTaskRelativeSourcePath(sources, reference.path);
316
+ const referenceExcerpt = excerptMarkdown(reference.markdown, {
317
+ sourceRef: referenceRef,
318
+ });
319
+ parts.push(`## Task source reference: ${relativePath}`, referenceExcerpt.text);
320
+ }
321
+ parts.push("## Task config summary", `- taskId: ${sources.taskConfig.taskId}`, `- flow: ${sources.taskConfig.flow}`, `- complexity: ${sources.taskConfig.complexity}`, `- contextProfile: ${sources.taskConfig.contextProfile}`, `- allowedPaths: ${sources.taskConfig.allowedPaths.join(", ") || "(none — review before execute)"}`, `- forbiddenPaths: ${sources.taskConfig.forbiddenPaths.join(", ") || "(none)"}`, '- Pi DAG nodes are read-only unless toolProfile="write" is explicitly selected for a bounded writer node.', "- Agent DAG read-only nodes must not write root artifacts/**; root artifacts/ is not a per-node scratchpad.", "- source/references/* are immutable user/source facts; source/需求.md is the derived execution contract.");
237
322
  if (sources.taskConfig.hardConstraints.length > 0) {
238
323
  parts.push("- hardConstraints:", ...sources.taskConfig.hardConstraints.map((c) => ` - ${c}`));
239
324
  }
240
325
  return parts.join("\n\n");
241
326
  }
327
+ async function loadMaterializedSourceReferences(sourceDir) {
328
+ const referenceDir = path.join(sourceDir, REFERENCE_DIRECTORY);
329
+ const referencePaths = [];
330
+ async function collect(dir) {
331
+ let entries;
332
+ try {
333
+ entries = await readdir(dir, { withFileTypes: true });
334
+ }
335
+ catch (error) {
336
+ if (error.code === "ENOENT")
337
+ return;
338
+ throw error;
339
+ }
340
+ for (const entry of entries) {
341
+ const entryPath = path.join(dir, entry.name);
342
+ if (entry.isDirectory()) {
343
+ await collect(entryPath);
344
+ }
345
+ else if (entry.isFile()) {
346
+ // Skip index/manifest sidecars; keep only user/source reference content.
347
+ if (entry.name === "index.json" || entry.name === "source-manifest.json") {
348
+ continue;
349
+ }
350
+ referencePaths.push(entryPath);
351
+ }
352
+ }
353
+ }
354
+ await collect(referenceDir);
355
+ referencePaths.sort((left, right) => left.localeCompare(right));
356
+ return Promise.all(referencePaths.slice(0, MAX_SOURCE_REFERENCE_DOCUMENTS).map(async (filePath) => ({
357
+ path: filePath,
358
+ markdown: await readFile(filePath, "utf-8"),
359
+ })));
360
+ }
242
361
  export async function loadTaskHybridSources(repoRoot, taskId) {
243
362
  const paths = getTaskPaths(repoRoot, taskId);
244
363
  const requirementPath = path.join(paths.sourceDir, REQUIREMENT_FILE);
@@ -264,6 +383,12 @@ export async function loadTaskHybridSources(repoRoot, taskId) {
264
383
  // optional
265
384
  }
266
385
  const taskConfig = await loadTaskConfig(repoRoot, taskId);
386
+ await materializeTaskReferenceDocs({
387
+ repoRoot,
388
+ taskId,
389
+ taskConfig,
390
+ });
391
+ const referenceDocuments = await loadMaterializedSourceReferences(paths.sourceDir);
267
392
  const manifest = await loadHarnessManifest(repoRoot);
268
393
  const strategy = resolveDagVerifyStrategy(taskConfig);
269
394
  let verifyCommands;
@@ -299,8 +424,10 @@ export async function loadTaskHybridSources(repoRoot, taskId) {
299
424
  constraintPath,
300
425
  requirementMarkdown,
301
426
  constraintMarkdown,
427
+ referenceDocuments,
302
428
  taskConfig,
303
429
  enabledExecutors: resolveEnabledExecutors(manifest.executors),
430
+ executorModelMatrix: resolveExecutorModelMatrices(manifest),
304
431
  verifyCommands,
305
432
  };
306
433
  }
@@ -336,18 +463,7 @@ export function buildStandardHybridDagFromTask(sources) {
336
463
  contextProfile: taskConfig.contextProfile,
337
464
  },
338
465
  skillsByRole: HYBRID_SKILLS_BY_ROLE,
339
- executorModels: {
340
- cursor: {
341
- LOW: "composer-2.5",
342
- MED: "composer-2.5",
343
- HIGH: "composer-2.5",
344
- },
345
- pi: {
346
- LOW: "gpt-5.3-codex-spark",
347
- MED: "glm-5.2",
348
- HIGH: "gpt-5.5",
349
- },
350
- },
466
+ executorModels: sources.executorModelMatrix ?? DEFAULT_DAG_EXECUTOR_MODELS,
351
467
  tasks: [
352
468
  {
353
469
  id: "contract-pi",
@@ -518,6 +634,14 @@ function buildReviewNode(sources) {
518
634
  "Review upstream implementation and verification evidence.",
519
635
  "First non-empty line must be exactly VERDICT: pass or VERDICT: request-revision.",
520
636
  "List Critical/Important findings when present; any Critical/Important finding must force request-revision. Read-only: do not modify files.",
637
+ [
638
+ "Three-way source fidelity check (required):",
639
+ "1) immutable originals under source/references/ (especially requirement* and acceptance*);",
640
+ "2) derived source/需求.md execution contract (and source/执行约束.md);",
641
+ "3) actual implementation/diff + verification evidence.",
642
+ "If 需求.md conflicts with source/references/*, prefer references and request-revision when the implementation only satisfies the derived summary.",
643
+ "If prompt excerpts are truncated, re-read the Full source paths before verdict.",
644
+ ].join(" "),
521
645
  buildSourceContextBlock(sources),
522
646
  ].join("\n\n"),
523
647
  };
@@ -0,0 +1,52 @@
1
+ function reportError(context, observerIndex, error) {
2
+ const message = error instanceof Error ? error.message : String(error);
3
+ process.stderr.write(`[dag-observer-compose] observer#${observerIndex} ${context} failed: ${message}\n`);
4
+ }
5
+ const HOOK_NAMES = [
6
+ "onRunStart",
7
+ "onNodeStart",
8
+ "onNodeOutput",
9
+ "onNodeFinish",
10
+ "onRunFinish",
11
+ ];
12
+ /**
13
+ * Compose multiple `DagRunObserver`s into one. Each hook is invoked in order.
14
+ * A throw in a single observer's hook is reported to stderr and does not stop
15
+ * subsequent observers — observers are derived views and must not affect
16
+ * canonical DAG execution. Returns `undefined` when no observers are provided.
17
+ */
18
+ export function composeDagRunObservers(observers) {
19
+ const active = observers.filter((observer) => Boolean(observer));
20
+ if (active.length === 0)
21
+ return undefined;
22
+ const observer = {
23
+ onRunStart: async (state) => {
24
+ await runHook("onRunStart", active, (obs) => obs.onRunStart?.(state));
25
+ },
26
+ onNodeStart: async (nodeId, state) => {
27
+ await runHook("onNodeStart", active, (obs) => obs.onNodeStart?.(nodeId, state));
28
+ },
29
+ onNodeOutput: async (nodeId, chunk, state) => {
30
+ await runHook("onNodeOutput", active, (obs) => obs.onNodeOutput?.(nodeId, chunk, state));
31
+ },
32
+ onNodeFinish: async (nodeId, state) => {
33
+ await runHook("onNodeFinish", active, (obs) => obs.onNodeFinish?.(nodeId, state));
34
+ },
35
+ onRunFinish: async (state) => {
36
+ await runHook("onRunFinish", active, (obs) => obs.onRunFinish?.(state));
37
+ },
38
+ };
39
+ return observer;
40
+ }
41
+ async function runHook(hookName, active, invoke) {
42
+ for (let i = 0; i < active.length; i += 1) {
43
+ try {
44
+ await invoke(active[i]);
45
+ }
46
+ catch (error) {
47
+ reportError(hookName, i, error);
48
+ }
49
+ }
50
+ }
51
+ // Re-exported for tests/inspection of hook coverage.
52
+ export const COMPOSED_HOOK_NAMES = HOOK_NAMES;
@@ -42,6 +42,10 @@ function buildSkillCandidatePaths(input) {
42
42
  const piSkillsDir = path.join(input.homeDir, ".pi", "agent", "skills");
43
43
  candidates.push(normalizeCandidate(path.join(piSkillsDir, input.name, "SKILL.md")), normalizeCandidate(path.join(piSkillsDir, `${input.name}.md`)));
44
44
  candidates.push(normalizeCandidate(path.join(input.cwd, "skills", input.name, "SKILL.md")));
45
+ // Agent-compatible project path (e.g. OpenCode). `skills/` stays the primary
46
+ // loop-agent repo-local path; `.agents/skills` is searched after it and before
47
+ // bundled skills so a project with only the mirror can still resolve.
48
+ candidates.push(normalizeCandidate(path.join(input.cwd, ".agents", "skills", input.name, "SKILL.md")));
45
49
  if (input.name === "loop-agent") {
46
50
  candidates.push(normalizeCandidate(path.join(input.cwd, "skill", "SKILL.md")));
47
51
  }
@@ -251,7 +251,7 @@ export const DEFAULT_DAG_EXECUTOR_MODELS = {
251
251
  cursor: {
252
252
  LOW: "composer-2.5",
253
253
  MED: "composer-2.5",
254
- HIGH: "composer-2.5",
254
+ HIGH: "gpt-5.5",
255
255
  },
256
256
  pi: {
257
257
  LOW: "gpt-5.3-codex-spark",