@tea-agent/loop-agent 0.24.10 → 0.25.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 (72) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/executors/dag-pi-executor.js +74 -13
  3. package/dist/executors/pi-executor.js +25 -7
  4. package/dist/executors/pi-prompt-transport.js +198 -0
  5. package/dist/executors/pi-sdk-executor.js +1 -1
  6. package/dist/executors/process-tree.js +33 -0
  7. package/dist/executors/shell-executor.js +239 -47
  8. package/dist/executors/shell-verification.js +4 -2
  9. package/dist/infrastructure/harness/task-store.js +31 -0
  10. package/dist/shared/operator/capabilities.js +6 -0
  11. package/dist/verification/maven/cache.js +142 -0
  12. package/dist/verification/maven/index.js +120 -0
  13. package/dist/verification/maven/plan-commands.js +421 -0
  14. package/dist/verification/maven/pom-static.js +136 -0
  15. package/dist/verification/maven/scope-resolve.js +153 -0
  16. package/dist/verification/maven/stale.js +130 -0
  17. package/dist/verification/maven/types.js +23 -0
  18. package/dist/verification/maven/workspace-graph.js +322 -0
  19. package/dist/worker/cli.js +177 -7
  20. package/dist/worker/delivery/final-verification.js +12 -0
  21. package/dist/worker/feature/advance.js +301 -0
  22. package/dist/worker/feature/doctor.js +223 -0
  23. package/dist/worker/feature/next-action.js +11 -3
  24. package/dist/worker/feature/scaffold.js +798 -0
  25. package/dist/workflows/dag/backend-test-markdown-workflow.js +127 -0
  26. package/dist/workflows/dag/failure-category.js +5 -1
  27. package/dist/workflows/dag/frontend-implementation-contract.js +4 -0
  28. package/dist/workflows/dag/frontend-prewrite-gate.js +0 -17
  29. package/dist/workflows/dag/init-hybrid.js +208 -19
  30. package/dist/workflows/dag/reconcile-run.js +24 -0
  31. package/dist/workflows/dag/types.js +44 -0
  32. package/dist/workflows/dag/validate.js +16 -0
  33. package/docs/architecture/dag-execution.md +5 -1
  34. package/docs/architecture/runtime-boundaries.md +11 -1
  35. package/docs/architecture/worker-and-feature.md +13 -0
  36. package/docs/init-surface.manifest.json +3 -0
  37. package/docs/templates/agent-dag.schema.json +9 -0
  38. package/docs/templates/backend-test-dag.json +3 -3
  39. package/docs/templates/frontend-implementation-contract.schema.json +2 -2
  40. package/docs/templates/product-line/README.md +17 -1
  41. package/docs/templates/product-line/feature-scaffold-batch.example.yaml +31 -0
  42. package/docs/templates/product-line/scaffold-samples/README.md +36 -0
  43. package/docs/templates/product-line/scaffold-samples/backend-only/acceptance.yaml +13 -0
  44. package/docs/templates/product-line/scaffold-samples/backend-only/design.md +14 -0
  45. package/docs/templates/product-line/scaffold-samples/backend-only/feature.yaml +3 -0
  46. package/docs/templates/product-line/scaffold-samples/backend-only/requirement.md +6 -0
  47. package/docs/templates/product-line/scaffold-samples/backend-only/tasks/BE-IMPL-001.yaml +83 -0
  48. package/docs/templates/product-line/scaffold-samples/backend-only/tasks/task-graph.yaml +14 -0
  49. package/docs/templates/product-line/scaffold-samples/fe-with-api/acceptance.yaml +15 -0
  50. package/docs/templates/product-line/scaffold-samples/fe-with-api/design.md +18 -0
  51. package/docs/templates/product-line/scaffold-samples/fe-with-api/feature.yaml +3 -0
  52. package/docs/templates/product-line/scaffold-samples/fe-with-api/requirement.md +6 -0
  53. package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/BE-IMPL-001.yaml +84 -0
  54. package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/CONTRACT-001.yaml +84 -0
  55. package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/FE-IMPL-001.yaml +86 -0
  56. package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/task-graph.yaml +40 -0
  57. package/docs/templates/product-line/scaffold-samples/frontend-only/acceptance.yaml +13 -0
  58. package/docs/templates/product-line/scaffold-samples/frontend-only/design.md +14 -0
  59. package/docs/templates/product-line/scaffold-samples/frontend-only/feature.yaml +3 -0
  60. package/docs/templates/product-line/scaffold-samples/frontend-only/requirement.md +6 -0
  61. package/docs/templates/product-line/scaffold-samples/frontend-only/tasks/FE-IMPL-001.yaml +85 -0
  62. package/docs/templates/product-line/scaffold-samples/frontend-only/tasks/task-graph.yaml +14 -0
  63. package/examples/l5-report-coms-process-definition.html +322 -0
  64. package/package.json +1 -1
  65. package/skills/agent-worker/references/agent-worker-operator.md +84 -0
  66. package/skills/frontend-bounded-implement/SKILL.md +53 -0
  67. package/skills/frontend-implementation/SKILL.md +3 -7
  68. package/skills/frontend-implementation/references/node-contracts.md +3 -3
  69. package/skills/loop-agent/SKILL.md +8 -8
  70. package/skills/loop-agent/references/command-reference.md +14 -2
  71. package/skills/loop-agent/references/harness-policy.md +22 -0
  72. package/skills/loop-agent/references/task-workflow.md +5 -0
@@ -0,0 +1,798 @@
1
+ import { access, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, writeFile, } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import YAML from "yaml";
5
+ import { validateFeatureTaskGraph } from "../task-graph/validate.js";
6
+ export const FEATURE_SCAFFOLD_TEMPLATES = [
7
+ "backend-only",
8
+ "frontend-only",
9
+ "fe-with-api",
10
+ ];
11
+ export const FEATURE_ID_PATTERN = /^F-\d{4}-\d{3,}$/;
12
+ export class ScaffoldError extends Error {
13
+ code;
14
+ details;
15
+ constructor(code, message, details) {
16
+ super(message);
17
+ this.name = "ScaffoldError";
18
+ this.code = code;
19
+ this.details = details;
20
+ }
21
+ }
22
+ const DEFAULT_FORBIDDEN = ["config/prod/**", ".env", ".env.*", "secrets/**"];
23
+ /**
24
+ * Deterministic Feature Packet scaffold.
25
+ * No model calls, no `.harness` I/O, no LoopAgentClient.
26
+ */
27
+ export async function scaffoldFeaturePacket(request) {
28
+ const repoRoot = path.resolve(request.repoRoot);
29
+ await assertRepoRoot(repoRoot);
30
+ const inputs = await resolveInputs(request);
31
+ const plans = inputs.map((input) => renderWritePlan(input, repoRoot));
32
+ // Preflight: target existence + path containment + validate each plan in staging.
33
+ for (const plan of plans) {
34
+ const targetAbs = path.join(repoRoot, plan.featureDirRel);
35
+ await assertTargetAbsent(targetAbs, plan.featureId);
36
+ await assertFeatureDirContainment(repoRoot, targetAbs);
37
+ }
38
+ // Validate all plans in isolated temp dirs first (zero repo write until commit).
39
+ for (const plan of plans) {
40
+ await preflightValidatePlan(plan);
41
+ }
42
+ if (request.dryRun) {
43
+ return {
44
+ schemaVersion: 1,
45
+ ok: true,
46
+ dryRun: true,
47
+ features: plans.map((plan) => toFeatureResult(plan, repoRoot)),
48
+ writtenPaths: [],
49
+ };
50
+ }
51
+ const committed = [];
52
+ try {
53
+ for (const plan of plans) {
54
+ const written = await commitPlan(plan, repoRoot, request.faultInjector);
55
+ committed.push(written);
56
+ if (committed.length === 1 && plans.length > 1) {
57
+ await request.faultInjector?.("after-first-commit", plan.featureId);
58
+ }
59
+ }
60
+ }
61
+ catch (error) {
62
+ // Roll back any directories committed in this batch.
63
+ for (const abs of committed.reverse()) {
64
+ await rm(abs, { recursive: true, force: true }).catch(() => undefined);
65
+ }
66
+ // Leave no staging parents behind either.
67
+ await cleanupScaffoldStaging(repoRoot).catch(() => undefined);
68
+ if (error instanceof ScaffoldError)
69
+ throw error;
70
+ throw new ScaffoldError("write-failed", error instanceof Error ? error.message : String(error));
71
+ }
72
+ return {
73
+ schemaVersion: 1,
74
+ ok: true,
75
+ dryRun: false,
76
+ features: plans.map((plan) => toFeatureResult(plan, repoRoot)),
77
+ writtenPaths: committed.map((abs) => path.relative(repoRoot, abs).split(path.sep).join("/")),
78
+ };
79
+ }
80
+ async function resolveInputs(request) {
81
+ if (request.batchFile && request.feature) {
82
+ throw new ScaffoldError("invalid-args", "feature scaffold: --batch is mutually exclusive with single-feature flags");
83
+ }
84
+ if (request.batchFile) {
85
+ return loadBatchFile(request.batchFile);
86
+ }
87
+ if (!request.feature) {
88
+ throw new ScaffoldError("invalid-args", "feature scaffold requires either single-feature flags or --batch <file>");
89
+ }
90
+ return [normalizeFeatureInput(request.feature)];
91
+ }
92
+ async function loadBatchFile(batchFile) {
93
+ let rawText;
94
+ try {
95
+ rawText = await readFile(path.resolve(batchFile), "utf-8");
96
+ }
97
+ catch (error) {
98
+ throw new ScaffoldError("batch-parse-error", `cannot read batch file: ${batchFile}: ${error instanceof Error ? error.message : String(error)}`);
99
+ }
100
+ let parsed;
101
+ try {
102
+ const trimmed = rawText.trim();
103
+ parsed = trimmed.startsWith("{") || trimmed.startsWith("[")
104
+ ? JSON.parse(trimmed)
105
+ : YAML.parse(rawText);
106
+ }
107
+ catch (error) {
108
+ throw new ScaffoldError("batch-parse-error", `batch file is not valid YAML/JSON: ${error instanceof Error ? error.message : String(error)}`);
109
+ }
110
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
111
+ throw new ScaffoldError("batch-parse-error", "batch file must be an object with features[]");
112
+ }
113
+ const features = parsed.features;
114
+ if (!Array.isArray(features) || features.length === 0) {
115
+ throw new ScaffoldError("batch-parse-error", "batch file features[] must be a non-empty array");
116
+ }
117
+ const inputs = features.map((entry, index) => {
118
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
119
+ throw new ScaffoldError("batch-parse-error", `features[${index}] must be an object`);
120
+ }
121
+ const row = entry;
122
+ const featureId = String(row.feature_id ?? "");
123
+ const title = String(row.title ?? "");
124
+ const template = row.template;
125
+ const verifyCommands = asStringArray(row.verify_commands);
126
+ if (!featureId || !title || !template) {
127
+ throw new ScaffoldError("batch-parse-error", `features[${index}] requires feature_id, title, template`);
128
+ }
129
+ if (!verifyCommands.length) {
130
+ throw new ScaffoldError("batch-parse-error", `features[${index}] requires non-empty verify_commands`);
131
+ }
132
+ return normalizeFeatureInput({
133
+ featureId,
134
+ title,
135
+ template,
136
+ description: typeof row.description === "string" ? row.description : undefined,
137
+ bePaths: asStringArray(row.be_paths),
138
+ fePaths: asStringArray(row.fe_paths),
139
+ contractPath: typeof row.contract_path === "string" ? row.contract_path : undefined,
140
+ verifyCommands,
141
+ acceptance: Array.isArray(row.acceptance)
142
+ ? row.acceptance
143
+ : undefined,
144
+ });
145
+ });
146
+ const seen = new Set();
147
+ for (const input of inputs) {
148
+ if (seen.has(input.featureId)) {
149
+ throw new ScaffoldError("batch-conflict", `duplicate feature_id in batch: ${input.featureId}`);
150
+ }
151
+ seen.add(input.featureId);
152
+ }
153
+ return inputs;
154
+ }
155
+ function asStringArray(value) {
156
+ if (!Array.isArray(value))
157
+ return [];
158
+ return value.filter((item) => typeof item === "string" && item.length > 0);
159
+ }
160
+ export function normalizeFeatureInput(input) {
161
+ if (!FEATURE_ID_PATTERN.test(input.featureId)) {
162
+ throw new ScaffoldError("invalid-feature-id", `feature-id must match ${FEATURE_ID_PATTERN}: got ${input.featureId}`);
163
+ }
164
+ if (!FEATURE_SCAFFOLD_TEMPLATES.includes(input.template)) {
165
+ throw new ScaffoldError("invalid-template", `template must be one of ${FEATURE_SCAFFOLD_TEMPLATES.join("|")}; got ${input.template}`);
166
+ }
167
+ if (!input.title.trim()) {
168
+ throw new ScaffoldError("invalid-args", "title is required");
169
+ }
170
+ if (!input.verifyCommands.length) {
171
+ throw new ScaffoldError("invalid-args", "at least one --verify-command is required (scaffold does not invent project test commands)");
172
+ }
173
+ const bePaths = (input.bePaths ?? []).map(normalizeRepoRelativePath);
174
+ const fePaths = (input.fePaths ?? []).map(normalizeRepoRelativePath);
175
+ const needsBe = input.template === "backend-only" || input.template === "fe-with-api";
176
+ const needsFe = input.template === "frontend-only" || input.template === "fe-with-api";
177
+ if (needsBe && bePaths.length === 0) {
178
+ throw new ScaffoldError("invalid-args", `template ${input.template} requires at least one --be-path`);
179
+ }
180
+ if (needsFe && fePaths.length === 0) {
181
+ throw new ScaffoldError("invalid-args", `template ${input.template} requires at least one --fe-path`);
182
+ }
183
+ if (input.template === "backend-only" && fePaths.length > 0) {
184
+ throw new ScaffoldError("invalid-args", "backend-only does not accept --fe-path");
185
+ }
186
+ if (input.template === "frontend-only" && bePaths.length > 0) {
187
+ throw new ScaffoldError("invalid-args", "frontend-only does not accept --be-path");
188
+ }
189
+ const contractPath = normalizeRepoRelativePath(input.contractPath ?? `docs/contracts/${input.featureId}/api.md`);
190
+ return {
191
+ featureId: input.featureId,
192
+ title: input.title.trim(),
193
+ template: input.template,
194
+ description: (input.description ?? input.title).trim(),
195
+ bePaths,
196
+ fePaths,
197
+ contractPath,
198
+ verifyCommands: input.verifyCommands.map((c) => c.trim()).filter(Boolean),
199
+ acceptance: input.acceptance,
200
+ };
201
+ }
202
+ /**
203
+ * Normalize a user-supplied path pattern to a portable repo-relative form.
204
+ * Rejects absolute paths, empty segments with `..`, and Windows drive roots.
205
+ */
206
+ export function normalizeRepoRelativePath(raw) {
207
+ const trimmed = raw.trim().replace(/\\/g, "/");
208
+ if (!trimmed) {
209
+ throw new ScaffoldError("invalid-path", "path must be non-empty");
210
+ }
211
+ if (path.isAbsolute(trimmed) || /^[A-Za-z]:\//.test(trimmed) || trimmed.startsWith("/")) {
212
+ throw new ScaffoldError("invalid-path", `path must be repo-relative: ${raw}`);
213
+ }
214
+ const parts = trimmed.split("/").filter((p) => p.length > 0 && p !== ".");
215
+ if (parts.some((p) => p === "..")) {
216
+ throw new ScaffoldError("invalid-path", `path must not contain '..': ${raw}`);
217
+ }
218
+ return parts.join("/");
219
+ }
220
+ export function renderWritePlan(input, repoRoot) {
221
+ const normalized = normalizeFeatureInput(input);
222
+ const featureId = normalized.featureId;
223
+ const repoName = path.basename(repoRoot) || "repo";
224
+ const files = [];
225
+ files.push({
226
+ relativePath: "feature.yaml",
227
+ content: YAML.stringify({
228
+ schema_version: 1,
229
+ feature_id: featureId,
230
+ profile: "generic",
231
+ }),
232
+ });
233
+ files.push({
234
+ relativePath: "requirement.md",
235
+ content: [
236
+ `# ${normalized.title}`,
237
+ "",
238
+ normalized.description,
239
+ "",
240
+ `Feature ID: \`${featureId}\``,
241
+ `Template: \`${normalized.template}\``,
242
+ "",
243
+ ].join("\n"),
244
+ });
245
+ files.push({
246
+ relativePath: "design.md",
247
+ content: renderDesign(normalized),
248
+ });
249
+ const acceptanceItems = buildAcceptance(normalized);
250
+ files.push({
251
+ relativePath: "acceptance.yaml",
252
+ content: YAML.stringify({
253
+ schema_version: 1,
254
+ feature_id: featureId,
255
+ acceptance: acceptanceItems,
256
+ }),
257
+ });
258
+ const graphAndTasks = renderGraphAndTasks(normalized, repoName);
259
+ files.push({
260
+ relativePath: "tasks/task-graph.yaml",
261
+ content: YAML.stringify(graphAndTasks.graph),
262
+ });
263
+ for (const task of graphAndTasks.tasks) {
264
+ files.push({
265
+ relativePath: `tasks/${task.id}.yaml`,
266
+ content: YAML.stringify(task.spec),
267
+ });
268
+ }
269
+ return {
270
+ featureId,
271
+ template: normalized.template,
272
+ featureDirRel: `features/${featureId}`,
273
+ files,
274
+ };
275
+ }
276
+ function renderDesign(input) {
277
+ const lines = [
278
+ `# Design — ${input.title}`,
279
+ "",
280
+ "## Goals",
281
+ "",
282
+ input.description ?? input.title,
283
+ "",
284
+ "## Paths",
285
+ "",
286
+ ];
287
+ if (input.bePaths?.length) {
288
+ lines.push("- Backend allowed paths:");
289
+ for (const p of input.bePaths)
290
+ lines.push(` - \`${p}\``);
291
+ }
292
+ if (input.fePaths?.length) {
293
+ lines.push("- Frontend allowed paths:");
294
+ for (const p of input.fePaths)
295
+ lines.push(` - \`${p}\``);
296
+ }
297
+ if (input.template === "fe-with-api" && input.contractPath) {
298
+ lines.push(`- Contract path: \`${input.contractPath}\``);
299
+ }
300
+ lines.push("", "## Artifacts", "");
301
+ if (input.template === "backend-only") {
302
+ lines.push("- `BE-IMPL-001` produces `backend-impl`");
303
+ }
304
+ else if (input.template === "frontend-only") {
305
+ lines.push("- `FE-IMPL-001` produces `frontend-impl`");
306
+ }
307
+ else {
308
+ lines.push("- `CONTRACT-001` produces `api-contract`");
309
+ lines.push("- `BE-IMPL-001` / `FE-IMPL-001` consume `api-contract` (only formal consumers)");
310
+ }
311
+ lines.push("");
312
+ return lines.join("\n");
313
+ }
314
+ function buildAcceptance(input) {
315
+ if (input.acceptance?.length) {
316
+ return input.acceptance.map((item, index) => {
317
+ const taskRefs = defaultTaskRefs(input.template);
318
+ return {
319
+ id: item.id || `AC-${String(index + 1).padStart(3, "0")}`,
320
+ title: item.title,
321
+ priority: item.priority ?? "must",
322
+ type: item.type ?? "behavior",
323
+ given: item.given,
324
+ when: item.when,
325
+ then: item.then,
326
+ verification: {
327
+ expected_task_refs: taskRefs,
328
+ },
329
+ };
330
+ });
331
+ }
332
+ const taskRefs = defaultTaskRefs(input.template);
333
+ return [
334
+ {
335
+ id: "AC-001",
336
+ title: input.title,
337
+ priority: "must",
338
+ type: "behavior",
339
+ given: "需求与路径边界已冻结",
340
+ when: "Feature Packet 内实现任务完成",
341
+ then: "验收目标达成且 verify.commands 通过",
342
+ verification: {
343
+ expected_task_refs: taskRefs,
344
+ },
345
+ },
346
+ ];
347
+ }
348
+ function defaultTaskRefs(template) {
349
+ if (template === "backend-only")
350
+ return ["BE-IMPL-001"];
351
+ if (template === "frontend-only")
352
+ return ["FE-IMPL-001"];
353
+ return ["CONTRACT-001", "BE-IMPL-001", "FE-IMPL-001"];
354
+ }
355
+ function renderGraphAndTasks(input, repoName) {
356
+ const featureId = input.featureId;
357
+ const acIds = (input.acceptance?.length
358
+ ? input.acceptance.map((a, i) => a.id || `AC-${String(i + 1).padStart(3, "0")}`)
359
+ : ["AC-001"]);
360
+ if (input.template === "backend-only") {
361
+ const bePath = primaryPath(input.bePaths);
362
+ const graph = {
363
+ schema_version: 1,
364
+ feature_id: featureId,
365
+ nodes: [
366
+ {
367
+ id: "BE-IMPL-001",
368
+ task: "BE-IMPL-001.yaml",
369
+ type: "backend-feature",
370
+ depends_on: [],
371
+ produces: [
372
+ {
373
+ kind: "backend-impl",
374
+ schemaId: "backend-impl-v1",
375
+ path: bePath,
376
+ },
377
+ ],
378
+ },
379
+ ],
380
+ parallel_policy: {
381
+ max_parallel_tasks: 1,
382
+ disallow_same_file_parallel_writes: true,
383
+ },
384
+ };
385
+ const spec = baseTaskSpec({
386
+ id: "BE-IMPL-001",
387
+ featureId,
388
+ title: `${input.title} — 后端实现`,
389
+ description: input.description ?? input.title,
390
+ type: "backend-feature",
391
+ workflow: "agent-dag",
392
+ dependsOn: [],
393
+ acceptanceRefs: acIds,
394
+ allowedPaths: withProgressPaths(input.bePaths, featureId, "be"),
395
+ forbiddenPaths: [...DEFAULT_FORBIDDEN, "apps/**"],
396
+ verifyCommands: input.verifyCommands,
397
+ repoName,
398
+ goals: ["实现后端变更并满足验收"],
399
+ });
400
+ return { graph, tasks: [{ id: "BE-IMPL-001", spec }] };
401
+ }
402
+ if (input.template === "frontend-only") {
403
+ const fePath = primaryPath(input.fePaths);
404
+ const graph = {
405
+ schema_version: 1,
406
+ feature_id: featureId,
407
+ nodes: [
408
+ {
409
+ id: "FE-IMPL-001",
410
+ task: "FE-IMPL-001.yaml",
411
+ type: "frontend-feature",
412
+ depends_on: [],
413
+ produces: [
414
+ {
415
+ kind: "frontend-impl",
416
+ schemaId: "frontend-impl-v1",
417
+ path: fePath,
418
+ },
419
+ ],
420
+ },
421
+ ],
422
+ parallel_policy: {
423
+ max_parallel_tasks: 1,
424
+ disallow_same_file_parallel_writes: true,
425
+ },
426
+ };
427
+ const spec = baseTaskSpec({
428
+ id: "FE-IMPL-001",
429
+ featureId,
430
+ title: `${input.title} — 前端实现`,
431
+ description: input.description ?? input.title,
432
+ type: "frontend-feature",
433
+ workflow: "frontend-implementation",
434
+ dependsOn: [],
435
+ acceptanceRefs: acIds,
436
+ allowedPaths: withProgressPaths(input.fePaths, featureId, "fe"),
437
+ forbiddenPaths: [...DEFAULT_FORBIDDEN, "services/**"],
438
+ verifyCommands: input.verifyCommands,
439
+ repoName,
440
+ goals: ["实现前端变更并满足验收"],
441
+ capabilities: ["interactive-ui"],
442
+ });
443
+ return { graph, tasks: [{ id: "FE-IMPL-001", spec }] };
444
+ }
445
+ // fe-with-api
446
+ const contractPath = input.contractPath;
447
+ const bePath = primaryPath(input.bePaths);
448
+ const fePath = primaryPath(input.fePaths);
449
+ const graph = {
450
+ schema_version: 1,
451
+ feature_id: featureId,
452
+ nodes: [
453
+ {
454
+ id: "CONTRACT-001",
455
+ task: "CONTRACT-001.yaml",
456
+ type: "architecture",
457
+ depends_on: [],
458
+ produces: [
459
+ {
460
+ kind: "api-contract",
461
+ schemaId: "api-contract-v1",
462
+ path: contractPath,
463
+ },
464
+ ],
465
+ },
466
+ {
467
+ id: "BE-IMPL-001",
468
+ task: "BE-IMPL-001.yaml",
469
+ type: "backend-feature",
470
+ depends_on: ["CONTRACT-001"],
471
+ consumes: [
472
+ {
473
+ kind: "api-contract",
474
+ schemaId: "api-contract-v1",
475
+ producerTaskId: "CONTRACT-001",
476
+ },
477
+ ],
478
+ produces: [
479
+ {
480
+ kind: "backend-impl",
481
+ schemaId: "backend-impl-v1",
482
+ path: bePath,
483
+ },
484
+ ],
485
+ },
486
+ {
487
+ id: "FE-IMPL-001",
488
+ task: "FE-IMPL-001.yaml",
489
+ type: "frontend-feature",
490
+ depends_on: ["CONTRACT-001"],
491
+ consumes: [
492
+ {
493
+ kind: "api-contract",
494
+ schemaId: "api-contract-v1",
495
+ producerTaskId: "CONTRACT-001",
496
+ },
497
+ ],
498
+ produces: [
499
+ {
500
+ kind: "frontend-impl",
501
+ schemaId: "frontend-impl-v1",
502
+ path: fePath,
503
+ },
504
+ ],
505
+ },
506
+ ],
507
+ parallel_policy: {
508
+ max_parallel_tasks: 1,
509
+ disallow_same_file_parallel_writes: true,
510
+ },
511
+ };
512
+ const contractSpec = baseTaskSpec({
513
+ id: "CONTRACT-001",
514
+ featureId,
515
+ title: `${input.title} — API 契约`,
516
+ description: `冻结 ${contractPath};正式 consumers 仅为 BE-IMPL-001 与 FE-IMPL-001。`,
517
+ type: "architecture",
518
+ workflow: "agent-dag",
519
+ dependsOn: [],
520
+ acceptanceRefs: acIds,
521
+ allowedPaths: withProgressPaths([contractPath], featureId, "contract"),
522
+ forbiddenPaths: [...DEFAULT_FORBIDDEN, "services/**", "apps/**"],
523
+ verifyCommands: input.verifyCommands,
524
+ repoName,
525
+ goals: ["冻结 API 契约文档", "声明正式 consumers 仅为 BE-IMPL / FE-IMPL"],
526
+ riskLevel: "low",
527
+ priority: "P1",
528
+ });
529
+ const beSpec = baseTaskSpec({
530
+ id: "BE-IMPL-001",
531
+ featureId,
532
+ title: `${input.title} — 后端实现`,
533
+ description: input.description ?? input.title,
534
+ type: "backend-feature",
535
+ workflow: "agent-dag",
536
+ dependsOn: ["CONTRACT-001"],
537
+ acceptanceRefs: acIds,
538
+ allowedPaths: withProgressPaths(input.bePaths, featureId, "be"),
539
+ forbiddenPaths: [...DEFAULT_FORBIDDEN, "apps/**"],
540
+ verifyCommands: input.verifyCommands,
541
+ repoName,
542
+ goals: ["按契约实现后端"],
543
+ });
544
+ const feSpec = baseTaskSpec({
545
+ id: "FE-IMPL-001",
546
+ featureId,
547
+ title: `${input.title} — 前端实现`,
548
+ description: input.description ?? input.title,
549
+ type: "frontend-feature",
550
+ workflow: "frontend-implementation",
551
+ dependsOn: ["CONTRACT-001"],
552
+ acceptanceRefs: acIds,
553
+ allowedPaths: withProgressPaths(input.fePaths, featureId, "fe"),
554
+ forbiddenPaths: [...DEFAULT_FORBIDDEN, "services/**"],
555
+ verifyCommands: input.verifyCommands,
556
+ repoName,
557
+ goals: ["按契约实现前端"],
558
+ capabilities: ["interactive-ui"],
559
+ });
560
+ return {
561
+ graph,
562
+ tasks: [
563
+ { id: "CONTRACT-001", spec: contractSpec },
564
+ { id: "BE-IMPL-001", spec: beSpec },
565
+ { id: "FE-IMPL-001", spec: feSpec },
566
+ ],
567
+ };
568
+ }
569
+ function primaryPath(paths) {
570
+ const first = paths[0];
571
+ // Strip trailing globs for produces.path readability.
572
+ return first.replace(/\/\*\*$/, "").replace(/\*$/, "") || first;
573
+ }
574
+ function withProgressPaths(paths, featureId, lane) {
575
+ return [
576
+ ...paths,
577
+ `docs/progress/${featureId}/${lane}/status.md`,
578
+ `docs/progress/${featureId}/${lane}/**`,
579
+ ];
580
+ }
581
+ function baseTaskSpec(input) {
582
+ return {
583
+ schema_version: 1,
584
+ id: input.id,
585
+ feature_id: input.featureId,
586
+ title: input.title,
587
+ description: input.description,
588
+ repo: {
589
+ name: input.repoName,
590
+ path_ref: ".",
591
+ default_branch: "main",
592
+ },
593
+ type: input.type,
594
+ priority: input.priority ?? "P2",
595
+ risk_level: input.riskLevel ?? "medium",
596
+ ...(input.capabilities ? { capabilities: input.capabilities } : {}),
597
+ depends_on: input.dependsOn,
598
+ source_docs: {
599
+ requirement: "../requirement.md",
600
+ acceptance: "../acceptance.yaml",
601
+ design: "../design.md",
602
+ },
603
+ acceptance_refs: input.acceptanceRefs,
604
+ scope: {
605
+ goals: input.goals,
606
+ non_goals: ["不扩展到模板外的 Task 节点(无 BE-TEST/FE-TEST/FINAL-VERIFY)"],
607
+ assumptions: ["verify.commands 由 scaffold 调用方显式提供"],
608
+ open_questions: [],
609
+ },
610
+ constraints: {
611
+ allowed_paths: input.allowedPaths,
612
+ forbidden_paths: input.forbiddenPaths,
613
+ hard_constraints: [
614
+ "不读取生产密钥",
615
+ "不绕过 scaffold 冻结的 depends/produces/consumes",
616
+ ],
617
+ },
618
+ execution: {
619
+ workflow: input.workflow,
620
+ },
621
+ verify: {
622
+ preset: "standard",
623
+ mode: "serial",
624
+ quota: "full",
625
+ commands: input.verifyCommands.map((command, index) => ({
626
+ id: `verify-${index + 1}`,
627
+ label: command,
628
+ command,
629
+ required: true,
630
+ timeout_ms: 300000,
631
+ })),
632
+ },
633
+ worker: {
634
+ execution_mode: "generate-validate-run",
635
+ max_attempts: 1,
636
+ timeout_ms: 7200000,
637
+ require_clean_worktree: true,
638
+ require_human_review: true,
639
+ create_worktree: false,
640
+ },
641
+ loop_agent: {
642
+ profile_policy: "mapped",
643
+ strict_models: true,
644
+ strict_governance: true,
645
+ max_concurrent: 1,
646
+ },
647
+ outputs: {
648
+ required: [
649
+ "dag_json",
650
+ "dag_validate_json",
651
+ "run_record",
652
+ "dag_report_json",
653
+ "shell_verification",
654
+ "closeout_or_failure_handoff",
655
+ ],
656
+ },
657
+ failure_policy: {
658
+ failed_run_promote: false,
659
+ generate_closeout_draft: true,
660
+ create_followup_task: true,
661
+ },
662
+ };
663
+ }
664
+ async function preflightValidatePlan(plan) {
665
+ const stageRoot = await mkdtemp(path.join(os.tmpdir(), "feature-scaffold-validate-"));
666
+ try {
667
+ const featureDir = path.join(stageRoot, plan.featureId);
668
+ await materializePlan(plan, featureDir);
669
+ const result = await validateFeatureTaskGraph(featureDir);
670
+ if (!result.ok) {
671
+ throw new ScaffoldError("validate-failed", `rendered packet failed validate-feature for ${plan.featureId}`, result.errors);
672
+ }
673
+ if (result.summary.profile !== "generic") {
674
+ throw new ScaffoldError("validate-failed", `scaffold must emit generic profile; got ${result.summary.profile}`);
675
+ }
676
+ }
677
+ finally {
678
+ await rm(stageRoot, { recursive: true, force: true }).catch(() => undefined);
679
+ }
680
+ }
681
+ async function commitPlan(plan, repoRoot, faultInjector) {
682
+ const featuresRoot = path.join(repoRoot, "features");
683
+ await mkdir(featuresRoot, { recursive: true });
684
+ await assertFeatureDirContainment(repoRoot, path.join(featuresRoot, plan.featureId));
685
+ const stageParent = path.join(featuresRoot, `.scaffold-staging-${plan.featureId}`);
686
+ const stageDir = path.join(stageParent, plan.featureId);
687
+ const targetDir = path.join(featuresRoot, plan.featureId);
688
+ await rm(stageParent, { recursive: true, force: true }).catch(() => undefined);
689
+ try {
690
+ await mkdir(stageDir, { recursive: true });
691
+ // Reject symlink escape on features root after create.
692
+ await assertNoSymlinkEscape(repoRoot, stageDir);
693
+ await materializePlan(plan, stageDir);
694
+ await faultInjector?.("after-stage-materialize", plan.featureId);
695
+ // Re-validate staged packet (same FS as final) before rename.
696
+ const stagedValidation = await validateFeatureTaskGraph(stageDir);
697
+ if (!stagedValidation.ok) {
698
+ throw new ScaffoldError("validate-failed", `staged packet failed validate-feature for ${plan.featureId}`, stagedValidation.errors);
699
+ }
700
+ await assertTargetAbsent(targetDir, plan.featureId);
701
+ await faultInjector?.("before-rename", plan.featureId);
702
+ await rename(stageDir, targetDir);
703
+ await rm(stageParent, { recursive: true, force: true }).catch(() => undefined);
704
+ return targetDir;
705
+ }
706
+ catch (error) {
707
+ await rm(stageParent, { recursive: true, force: true }).catch(() => undefined);
708
+ if (error instanceof ScaffoldError)
709
+ throw error;
710
+ throw new ScaffoldError("write-failed", error instanceof Error ? error.message : String(error));
711
+ }
712
+ }
713
+ async function cleanupScaffoldStaging(repoRoot) {
714
+ const featuresRoot = path.join(repoRoot, "features");
715
+ try {
716
+ const entries = await readdir(featuresRoot, { withFileTypes: true });
717
+ for (const entry of entries) {
718
+ if (entry.isDirectory() && entry.name.startsWith(".scaffold-staging-")) {
719
+ await rm(path.join(featuresRoot, entry.name), {
720
+ recursive: true,
721
+ force: true,
722
+ });
723
+ }
724
+ }
725
+ }
726
+ catch {
727
+ // features/ may not exist after total rollback
728
+ }
729
+ }
730
+ async function materializePlan(plan, featureDirAbs) {
731
+ for (const file of plan.files) {
732
+ const abs = path.join(featureDirAbs, ...file.relativePath.split("/"));
733
+ await mkdir(path.dirname(abs), { recursive: true });
734
+ await writeFile(abs, file.content.endsWith("\n") ? file.content : `${file.content}\n`, "utf-8");
735
+ }
736
+ }
737
+ function toFeatureResult(plan, repoRoot) {
738
+ return {
739
+ featureId: plan.featureId,
740
+ template: plan.template,
741
+ featureDir: path.join(repoRoot, plan.featureDirRel),
742
+ files: plan.files.map((f) => f.relativePath).sort(),
743
+ };
744
+ }
745
+ async function assertRepoRoot(repoRoot) {
746
+ try {
747
+ const st = await lstat(repoRoot);
748
+ if (!st.isDirectory()) {
749
+ throw new ScaffoldError("repo-missing", `repo is not a directory: ${repoRoot}`);
750
+ }
751
+ }
752
+ catch (error) {
753
+ if (error instanceof ScaffoldError)
754
+ throw error;
755
+ throw new ScaffoldError("repo-missing", `repo does not exist: ${repoRoot}`);
756
+ }
757
+ }
758
+ async function assertTargetAbsent(targetAbs, featureId) {
759
+ try {
760
+ await access(targetAbs);
761
+ throw new ScaffoldError("target-exists", `feature directory already exists (no --force): features/${featureId}`);
762
+ }
763
+ catch (error) {
764
+ if (error instanceof ScaffoldError)
765
+ throw error;
766
+ // ENOENT is success
767
+ }
768
+ }
769
+ async function assertFeatureDirContainment(repoRoot, targetAbs) {
770
+ // Resolve the repo root (handles macOS /var -> /private/var). The target may
771
+ // not exist yet, so re-base it on the real repo rather than realpath(target).
772
+ const resolvedRepo = path.resolve(repoRoot);
773
+ const realRepo = await realpath(resolvedRepo).catch(() => resolvedRepo);
774
+ const featuresRoot = path.join(realRepo, "features");
775
+ const resolvedTarget = path.resolve(targetAbs);
776
+ let normalizedTarget = resolvedTarget;
777
+ const relFromResolvedRepo = path.relative(resolvedRepo, resolvedTarget);
778
+ if (!relFromResolvedRepo.startsWith("..") && !path.isAbsolute(relFromResolvedRepo)) {
779
+ normalizedTarget = path.join(realRepo, relFromResolvedRepo);
780
+ }
781
+ const rel = path.relative(featuresRoot, normalizedTarget);
782
+ if (rel === "" ||
783
+ rel === "." ||
784
+ rel === ".." ||
785
+ rel.startsWith(`..${path.sep}`) ||
786
+ path.isAbsolute(rel) ||
787
+ rel.split(path.sep).includes("..")) {
788
+ throw new ScaffoldError("invalid-path", `feature path escapes repo/features: ${targetAbs}`);
789
+ }
790
+ }
791
+ async function assertNoSymlinkEscape(repoRoot, candidate) {
792
+ const realRepo = await realpath(repoRoot);
793
+ const realCandidate = await realpath(candidate);
794
+ const rel = path.relative(realRepo, realCandidate);
795
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
796
+ throw new ScaffoldError("invalid-path", `staging path escapes repo via symlink: ${candidate}`);
797
+ }
798
+ }