@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.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +48 -38
- package/README.md +3 -3
- package/dist/application/dag/args.js +9 -1
- package/dist/application/dag/run-dag.js +16 -2
- package/dist/application/dag/validate-dag.js +14 -1
- package/dist/cli/command-definitions.js +22 -4
- package/dist/cli/help.js +3 -2
- package/dist/cli/program.js +7 -5
- package/dist/commands/import-prd.js +76 -0
- package/dist/commands/init.js +230 -32
- package/dist/commands/instructions.js +90 -58
- package/dist/executors/config-core.js +3 -2
- package/dist/executors/dag-pi-executor.js +1 -0
- package/dist/executors/model-routing.js +43 -0
- package/dist/executors/pi-sdk-executor.js +63 -1
- package/dist/governance/manifest-types.js +9 -1
- package/dist/shared/preview.js +39 -0
- package/dist/task/source-references.js +221 -0
- package/dist/worker/cli.js +62 -1
- package/dist/worker/loop-agent/loop-agent-client.js +97 -5
- package/dist/worker/materialize/harness-task-materializer.js +162 -5
- package/dist/worker/observability/event-store.js +82 -0
- package/dist/worker/observability/events.js +79 -0
- package/dist/worker/observability/progress-composite.js +33 -0
- package/dist/worker/observability/read-model.js +1013 -0
- package/dist/worker/observability/snapshot-store.js +43 -0
- package/dist/worker/observability/types.js +1 -0
- package/dist/worker/observe/paths.js +64 -0
- package/dist/worker/observe/routes.js +423 -0
- package/dist/worker/observe/server.js +61 -0
- package/dist/worker/observe/static/app.js +1419 -0
- package/dist/worker/observe/static/index.html +63 -0
- package/dist/worker/observe/static/styles.css +613 -0
- package/dist/worker/pool/failure-routing.js +41 -6
- package/dist/worker/pool/run-store.js +59 -1
- package/dist/worker/progress-reporter.js +0 -18
- package/dist/worker/run-task/run-task.js +327 -92
- package/dist/worker/runner/run-ready.js +112 -4
- package/dist/workflows/dag/event-observer.js +132 -0
- package/dist/workflows/dag/init-hybrid.js +150 -26
- package/dist/workflows/dag/observer-compose.js +52 -0
- package/dist/workflows/dag/skill-instructions.js +4 -0
- package/dist/workflows/dag/types.js +1 -1
- package/dist/workflows/dag/validate.js +3 -2
- package/docs/README.md +2 -0
- package/docs/architecture/runtime-boundaries.md +18 -3
- package/docs/design/README.md +22 -9
- package/docs/exec-plans/active/README.md +6 -1
- package/docs/exec-plans/completed/README.md +12 -0
- package/docs/init-surface.manifest.json +32 -2
- package/docs/loop-agent-harness.md +13 -0
- package/docs/reports/README.md +4 -0
- package/docs/templates/agent-dag.base.json +1 -1
- package/docs/templates/agent-dag.final-verification.json +1 -1
- package/docs/templates/agent-dag.supervised-implementation.json +1 -1
- package/docs/templates/hybrid-dag.json +1 -1
- package/docs/templates/worker-dogfood-evidence.md +52 -0
- package/docs/templates/worker-dogfood-setup.md +48 -0
- package/examples/example-dag.json +1 -1
- package/examples/hybrid-loop-agent-dag.json +1 -1
- package/harness.json +5 -29
- package/package.json +6 -6
- package/skills/loop-agent/SKILL.md +5 -3
- package/skills/loop-agent/references/command-reference.md +12 -3
- package/skills/loop-agent/references/harness-policy.md +7 -3
- 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
|
|
33
|
-
|
|
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,
|
|
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
|
|
107
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
183
|
+
const sectionMatch = requirementMarkdown.match(SUCCESS_CRITERIA_SECTION);
|
|
122
184
|
if (sectionMatch) {
|
|
123
|
-
for (const line of sectionMatch[
|
|
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
|
-
|
|
302
|
+
requirementExcerpt.text,
|
|
232
303
|
];
|
|
233
304
|
if (sources.constraintMarkdown) {
|
|
234
|
-
|
|
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
|
-
|
|
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
|
}
|