@tea-agent/loop-agent 0.34.2 → 0.34.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.34.3] - 2026-08-12
6
+
7
+ ### 重点更新
8
+
9
+ - 修复 PRD 导入时 grill-me 模板占位符路径污染写入集的问题,确保仅处理真实需求路径
10
+
11
+ ### 改进
12
+
13
+ - 语义化导入现可作为兜底方案推荐工程边界,提升需求解析的准确性
14
+ - 将语义约束与验证信息合并至控制台草稿,避免表单默认值覆盖精确的验证命令
15
+
16
+ ### 修复
17
+
18
+ - 修复 grill-me 模板占位符路径未被过滤即进入写入集,导致占位内容污染实际工程数据的问题
19
+
5
20
  ## [0.34.2] - 2026-08-12
6
21
 
7
22
  ### 重点更新
@@ -4,6 +4,7 @@ import { createTask, getTaskPaths, loadTaskConfig, } from "../../task/runtime.js
4
4
  import { importPrdDocument } from "../../task/source-references.js";
5
5
  import { prepareTaskSource } from "../../task/source-prepare/index.js";
6
6
  import { isIntakeSoftGapCode, resolvePrdImportRole, } from "../../task/source-prepare/artifact-meta.js";
7
+ import { sanitizeDraftPlaceholders } from "../../task/source-prepare/placeholder-paths.js";
7
8
  import { evaluateSemanticIntakeEligibility, isSemanticIntakeDisabled, readSemanticIntakeAttempted, runSemanticIntake, } from "../../task/source-prepare/semantic-intake.js";
8
9
  import { validateImportedPrdReferences } from "../../task/source-prepare/reference-integrity.js";
9
10
  import { logicalRevisionForState, observeTaskContractState, recoverTaskContract, } from "../../task/contract/index.js";
@@ -587,9 +588,12 @@ export async function advanceTaskLifecycle(input) {
587
588
  if (input.fromDraftPath) {
588
589
  try {
589
590
  const draftRaw = await readFile(path.resolve(input.fromDraftPath), "utf-8");
591
+ const parsedDraft = JSON.parse(draftRaw);
592
+ // Strip Console grill-me placeholder strings so they never become
593
+ // writeSet globs / projected 需求.md bullets.
590
594
  draftIntent = {
591
595
  kind: "draft",
592
- draft: JSON.parse(draftRaw),
596
+ draft: sanitizeDraftPlaceholders(parsedDraft),
593
597
  sourcePath: input.fromDraftPath,
594
598
  };
595
599
  }
@@ -2,6 +2,7 @@ export * from "./types.js";
2
2
  export * from "./path-policy.js";
3
3
  export * from "./parse-intent.js";
4
4
  export * from "./artifact-meta.js";
5
+ export * from "./placeholder-paths.js";
5
6
  export * from "./semantic-intake.js";
6
7
  export * from "./reference-integrity.js";
7
8
  export * from "./completeness.js";
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Shared filters for Console / grill-me placeholder text that must never
3
+ * become writeSet paths or contract scope bullets.
4
+ *
5
+ * Placeholders are Chinese cue-words + fullwidth brackets from interview
6
+ * recommendations (e.g. "(工程边界须人类确认;示例:src/**, test/**)").
7
+ * Do not require glob syntax — legitimate paths like `package.json` must pass.
8
+ */
9
+ export const PLACEHOLDER_PATH_RE = /请|示例|模板|(|)|须人类确认|勿套用|暂无|填写|确认|按.*结构/;
10
+ export function isNonPlaceholderPath(value) {
11
+ return (typeof value === "string" &&
12
+ value.trim().length > 0 &&
13
+ !PLACEHOLDER_PATH_RE.test(value));
14
+ }
15
+ export function filterPlaceholderPaths(values) {
16
+ if (!Array.isArray(values))
17
+ return [];
18
+ return values
19
+ .map((item) => (typeof item === "string" ? item.trim() : ""))
20
+ .filter(isNonPlaceholderPath);
21
+ }
22
+ function isPlaceholderText(value) {
23
+ return typeof value === "string" && PLACEHOLDER_PATH_RE.test(value);
24
+ }
25
+ /**
26
+ * Strip grill-me / form placeholder strings from a draft before prepare.
27
+ * Keeps real defaults like `npm run typecheck` and `.harness/**`.
28
+ */
29
+ export function sanitizeDraftPlaceholders(draft) {
30
+ if (!draft || typeof draft !== "object")
31
+ return draft;
32
+ const root = draft;
33
+ const next = { ...root };
34
+ if (root.constraints && typeof root.constraints === "object") {
35
+ const constraints = root.constraints;
36
+ next.constraints = {
37
+ ...constraints,
38
+ allowedPaths: filterPlaceholderPaths(constraints.allowedPaths),
39
+ forbiddenPaths: filterPlaceholderPaths(constraints.forbiddenPaths),
40
+ };
41
+ }
42
+ if (root.requirement && typeof root.requirement === "object") {
43
+ const requirement = root.requirement;
44
+ let scope = requirement.scope;
45
+ if (Array.isArray(requirement.scope)) {
46
+ scope = requirement.scope
47
+ .map((item) => (typeof item === "string" ? item.trim() : ""))
48
+ .filter((item) => item.length > 0 && !isPlaceholderText(item));
49
+ }
50
+ let nonGoals = requirement.nonGoals;
51
+ if (Array.isArray(requirement.nonGoals)) {
52
+ nonGoals = requirement.nonGoals
53
+ .map((item) => (typeof item === "string" ? item.trim() : ""))
54
+ .filter((item) => item.length > 0 && !isPlaceholderText(item));
55
+ }
56
+ let acceptanceCriteria = requirement.acceptanceCriteria;
57
+ if (Array.isArray(requirement.acceptanceCriteria)) {
58
+ acceptanceCriteria = requirement.acceptanceCriteria.filter((item) => {
59
+ if (typeof item === "string") {
60
+ return item.trim().length > 0 && !isPlaceholderText(item);
61
+ }
62
+ if (item && typeof item === "object") {
63
+ const text = item.text;
64
+ return typeof text === "string" && !isPlaceholderText(text);
65
+ }
66
+ return true;
67
+ });
68
+ }
69
+ next.requirement = {
70
+ ...requirement,
71
+ scope,
72
+ nonGoals,
73
+ acceptanceCriteria,
74
+ };
75
+ }
76
+ return next;
77
+ }
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * P2 Semantic Intake: one-shot, read-only Pi structuring of imported PRDs
3
3
  * into TaskContractDraftV1. Does not write source/需求.md directly — caller
4
- * re-enters prepare with kind:"draft". Engineering paths/verify never come
5
- * from the model; only from flags / existing task config.
4
+ * re-enters prepare with kind:"draft".
5
+ *
6
+ * Engineering boundary priority: explicit flags > existing task config >
7
+ * optional model recommendation (repo-layout-aware fallback for Console
8
+ * web-wizard flows that never collect human paths).
6
9
  */
7
- import { mkdir, readFile, writeFile } from "node:fs/promises";
10
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
8
11
  import path from "node:path";
9
12
  import { z } from "zod";
10
13
  import { executePiStep } from "../../executors/pi-executor.js";
@@ -14,6 +17,7 @@ import { loadHarnessManifest } from "../../governance/harness.js";
14
17
  import { TASK_CONTRACT_DRAFT_SCHEMA_VERSION } from "../contract/constants.js";
15
18
  import { getTaskPaths } from "../runtime.js";
16
19
  import { detectProductArtifactMeta } from "./artifact-meta.js";
20
+ import { filterPlaceholderPaths } from "./placeholder-paths.js";
17
21
  export const SEMANTIC_INTAKE_ARTIFACT_REL = "artifacts/intake/semantic-draft.json";
18
22
  export const SEMANTIC_INTAKE_ATTEMPT_MARKER = "artifacts/intake/semantic-intake-attempted.json";
19
23
  const productArtifactBlockers = new Set([
@@ -22,6 +26,20 @@ const productArtifactBlockers = new Set([
22
26
  "PRODUCT_REQUIREMENT_PENDING",
23
27
  "PRODUCT_ARTIFACT_NOT_EXECUTABLE",
24
28
  ]);
29
+ const engineeringRecommendationSchema = z
30
+ .object({
31
+ taskKind: z.string().min(1).optional(),
32
+ allowedPaths: z.array(z.string()).optional(),
33
+ forbiddenPaths: z.array(z.string()).optional(),
34
+ verifyCommands: z
35
+ .array(z.object({
36
+ label: z.string().min(1),
37
+ command: z.string().min(1),
38
+ timeoutMs: z.number().int().positive().optional(),
39
+ }))
40
+ .optional(),
41
+ })
42
+ .optional();
25
43
  const requirementDraftSchema = z.object({
26
44
  objective: z.string().min(1),
27
45
  scope: z.array(z.string()).default([]),
@@ -39,6 +57,8 @@ const requirementDraftSchema = z.object({
39
57
  openQuestions: z.array(z.string()).optional(),
40
58
  assumptions: z.array(z.string()).optional(),
41
59
  title: z.string().optional(),
60
+ /** Optional model engineering recommendation (flags/existing still win). */
61
+ engineering: engineeringRecommendationSchema,
42
62
  });
43
63
  export function isSemanticIntakeDisabled(env = process.env) {
44
64
  const raw = (env.LOOP_AGENT_SEMANTIC_INTAKE ?? "").trim().toLowerCase();
@@ -130,26 +150,62 @@ export function assertSemanticDraftAcceptable(draft) {
130
150
  }
131
151
  }
132
152
  export function buildDraftFromSemanticIntake(input) {
133
- const allowedPaths = input.flags.allowedPaths?.length
134
- ? [
135
- ...(input.existingAllowedPaths ?? []),
136
- ...input.flags.allowedPaths,
137
- ]
138
- : (input.existingAllowedPaths ?? input.flags.allowedPaths ?? []);
139
- const forbiddenPaths = input.flags.forbiddenPaths?.length
140
- ? [
141
- ...(input.existingForbiddenPaths ?? []),
142
- ...input.flags.forbiddenPaths,
143
- ]
144
- : (input.existingForbiddenPaths ?? input.flags.forbiddenPaths ?? []);
145
- const verifyCommands = input.flags.verifyCommands?.length
146
- ? input.flags.verifyCommands
147
- : (input.existingVerify ?? []);
153
+ const modelEng = input.semantic.engineering ?? {};
154
+ const modelAllowed = filterPlaceholderPaths(modelEng.allowedPaths);
155
+ const modelForbidden = filterPlaceholderPaths(modelEng.forbiddenPaths);
156
+ const modelVerify = (modelEng.verifyCommands ?? []).filter((c) => c.label?.trim() && c.command?.trim());
157
+ // Priority: explicit flags > existing task config > model recommendation.
158
+ const flagAllowed = input.flags.allowedPaths ?? [];
159
+ const existingAllowed = input.existingAllowedPaths ?? [];
160
+ let allowedPaths;
161
+ if (flagAllowed.length > 0) {
162
+ allowedPaths = [...existingAllowed, ...flagAllowed];
163
+ }
164
+ else if (existingAllowed.length > 0) {
165
+ allowedPaths = existingAllowed;
166
+ }
167
+ else {
168
+ allowedPaths = modelAllowed;
169
+ }
170
+ const flagForbidden = input.flags.forbiddenPaths ?? [];
171
+ const existingForbidden = input.existingForbiddenPaths ?? [];
172
+ let forbiddenPaths;
173
+ if (flagForbidden.length > 0) {
174
+ forbiddenPaths = [...existingForbidden, ...flagForbidden];
175
+ }
176
+ else if (existingForbidden.length > 0) {
177
+ forbiddenPaths = existingForbidden;
178
+ }
179
+ else {
180
+ forbiddenPaths = modelForbidden;
181
+ }
182
+ let verifyCommands;
183
+ if (input.flags.verifyCommands?.length) {
184
+ verifyCommands = input.flags.verifyCommands;
185
+ }
186
+ else if (input.existingVerify?.length) {
187
+ verifyCommands = input.existingVerify;
188
+ }
189
+ else {
190
+ verifyCommands = modelVerify;
191
+ }
192
+ // flags.taskKind is hard; bare caller "standard" is a soft default and must
193
+ // not block a model recommendation for Console web-wizard flows.
194
+ const explicitTaskKind = input.flags.taskKind?.trim();
195
+ const callerTaskKind = input.taskKind?.trim();
196
+ const modelTaskKind = modelEng.taskKind?.trim();
197
+ const taskKind = explicitTaskKind ||
198
+ (callerTaskKind && callerTaskKind !== "standard"
199
+ ? callerTaskKind
200
+ : undefined) ||
201
+ modelTaskKind ||
202
+ callerTaskKind ||
203
+ "standard";
148
204
  const draft = {
149
205
  schemaVersion: TASK_CONTRACT_DRAFT_SCHEMA_VERSION,
150
206
  taskId: input.taskId,
151
207
  title: input.semantic.title?.trim() || input.title,
152
- taskKind: input.flags.taskKind || input.taskKind || "standard",
208
+ taskKind,
153
209
  requirement: {
154
210
  objective: input.semantic.objective.trim(),
155
211
  scope: input.semantic.scope.map((s) => s.trim()).filter(Boolean),
@@ -161,7 +217,9 @@ export function buildDraftFromSemanticIntake(input) {
161
217
  },
162
218
  constraints: {
163
219
  invariants: input.flags.invariants ?? [],
164
- allowedPaths: [...new Set(allowedPaths.map((p) => p.trim()).filter(Boolean))],
220
+ allowedPaths: [
221
+ ...new Set(allowedPaths.map((p) => p.trim()).filter(Boolean)),
222
+ ],
165
223
  forbiddenPaths: [
166
224
  ...new Set(forbiddenPaths.map((p) => p.trim()).filter(Boolean)),
167
225
  ],
@@ -192,7 +250,96 @@ export function buildDraftFromSemanticIntake(input) {
192
250
  }
193
251
  return draft;
194
252
  }
195
- function buildPrompt(input) {
253
+ /**
254
+ * Cheap repo-layout hint injected into the semantic intake prompt so the model
255
+ * can recommend minimal write boundaries and real verify commands when humans
256
+ * never fill Console form engineering fields.
257
+ */
258
+ export async function buildRepoHint(repoRoot) {
259
+ const lines = ["Repo layout hint (best-effort, may be incomplete):"];
260
+ try {
261
+ const top = await readdir(repoRoot, { withFileTypes: true });
262
+ const dirs = top
263
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
264
+ .map((entry) => entry.name)
265
+ .slice(0, 24);
266
+ const files = top
267
+ .filter((entry) => entry.isFile())
268
+ .map((entry) => entry.name)
269
+ .slice(0, 24);
270
+ lines.push(`top-level dirs: ${dirs.join(", ") || "(none)"}`);
271
+ lines.push(`top-level files: ${files.join(", ") || "(none)"}`);
272
+ const packageCandidates = [
273
+ "package.json",
274
+ path.join("source", "package.json"),
275
+ path.join("packages", "package.json"),
276
+ ];
277
+ for (const rel of packageCandidates) {
278
+ const abs = path.join(repoRoot, rel);
279
+ try {
280
+ const raw = await readFile(abs, "utf-8");
281
+ const pkg = JSON.parse(raw);
282
+ const scripts = Object.keys(pkg.scripts ?? {}).slice(0, 20);
283
+ lines.push(`${rel} scripts: ${scripts.join(", ") || "(none)"}`);
284
+ if (pkg.workspaces !== undefined) {
285
+ lines.push(`${rel} workspaces: ${JSON.stringify(pkg.workspaces).slice(0, 200)}`);
286
+ }
287
+ }
288
+ catch {
289
+ // optional
290
+ }
291
+ }
292
+ // Sample test-like files under common roots (bounded).
293
+ const testRoots = ["src", "source", "packages", "test", "tests"].filter((name) => dirs.includes(name));
294
+ const samples = [];
295
+ const counts = new Map();
296
+ const walk = async (dir, depth) => {
297
+ if (depth > 3 || samples.length >= 15)
298
+ return;
299
+ let entries;
300
+ try {
301
+ entries = await readdir(dir, { withFileTypes: true });
302
+ }
303
+ catch {
304
+ return;
305
+ }
306
+ for (const entry of entries) {
307
+ if (entry.name.startsWith(".") || entry.name === "node_modules")
308
+ continue;
309
+ const abs = path.join(dir, entry.name);
310
+ if (entry.isDirectory()) {
311
+ await walk(abs, depth + 1);
312
+ continue;
313
+ }
314
+ if (!entry.isFile())
315
+ continue;
316
+ if (!/\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs)$/i.test(entry.name))
317
+ continue;
318
+ const rel = path.relative(repoRoot, abs).replace(/\\/g, "/");
319
+ const top = rel.split("/")[0] ?? rel;
320
+ counts.set(top, (counts.get(top) ?? 0) + 1);
321
+ if (samples.length < 15)
322
+ samples.push(rel);
323
+ }
324
+ };
325
+ for (const root of testRoots) {
326
+ await walk(path.join(repoRoot, root), 0);
327
+ }
328
+ const total = [...counts.values()].reduce((sum, n) => sum + n, 0);
329
+ if (total > 0) {
330
+ const byTop = [...counts.entries()]
331
+ .map(([name, n]) => `${name}:${n}`)
332
+ .join(", ");
333
+ lines.push(`test-like files: total=${total} byTop=${byTop}`);
334
+ lines.push(`test samples: ${samples.join(", ")}`);
335
+ }
336
+ }
337
+ catch {
338
+ lines.push("(repo scan failed)");
339
+ }
340
+ return lines.join("\n");
341
+ }
342
+ async function buildPrompt(input) {
196
343
  const bodies = input.documents
197
344
  .map((doc, index) => {
198
345
  const meta = detectProductArtifactMeta(doc.content);
@@ -213,11 +360,18 @@ function buildPrompt(input) {
213
360
  ].join("\n");
214
361
  })
215
362
  .join("\n\n");
363
+ const repoHint = input.repoRoot
364
+ ? await buildRepoHint(input.repoRoot)
365
+ : "(no repo layout hint)";
216
366
  return [
217
367
  "You are a read-only requirement structurer for loop-agent task intake.",
218
368
  "Convert imported product documents into a thin requirement draft JSON.",
219
369
  "Do NOT invent product behavior that is not supported by the documents.",
220
- "Do NOT emit engineering fields (allowedPaths, forbiddenPaths, verifyCommands, taskKind).",
370
+ "You MAY recommend an optional engineering block when the repo layout hint supports it:",
371
+ "- allowedPaths / forbiddenPaths as minimal precise globs (e.g. source/packages/**/*.test.ts)",
372
+ "- verifyCommands as real package scripts (e.g. pnpm -C source test)",
373
+ "- taskKind when clearly implied (backend-test / frontend-test / standard / …)",
374
+ "Prefer smallest correct write boundary; do not invent product paths that are not in the layout hint.",
221
375
  "Do NOT rewrite or suggest editing the original PRD files.",
222
376
  "Prefer explicit text; use provenance derived for rephrasing; use inferred only when clearly implied — if you must invent ACs, leave acceptanceCriteria empty so the system can stop for clarification.",
223
377
  "Return exactly one JSON object, no markdown prose outside optional fence:",
@@ -235,9 +389,18 @@ function buildPrompt(input) {
235
389
  ],
236
390
  openQuestions: ["optional"],
237
391
  assumptions: ["optional"],
392
+ engineering: {
393
+ taskKind: "optional string",
394
+ allowedPaths: ["optional globs"],
395
+ forbiddenPaths: ["optional globs"],
396
+ verifyCommands: [
397
+ { label: "test", command: "pnpm -C source test" },
398
+ ],
399
+ },
238
400
  }, null, 2),
239
401
  `taskId: ${input.taskId}`,
240
402
  `fallbackTitle: ${input.title}`,
403
+ repoHint,
241
404
  "Documents:",
242
405
  bodies,
243
406
  ].join("\n\n");
@@ -314,14 +477,16 @@ export async function runSemanticIntake(input) {
314
477
  const attached = usableDocs
315
478
  .map((d) => d.absolutePath)
316
479
  .filter((p) => Boolean(p));
480
+ const prompt = await buildPrompt({
481
+ taskId: input.taskId,
482
+ title: input.title,
483
+ documents: usableDocs,
484
+ repoRoot: input.repoRoot,
485
+ });
317
486
  const result = await execute({
318
487
  attachedFiles: attached,
319
488
  modelConfig: resolveDagPiModelConfig(model),
320
- prompt: buildPrompt({
321
- taskId: input.taskId,
322
- title: input.title,
323
- documents: usableDocs,
324
- }),
489
+ prompt,
325
490
  repoRoot: input.repoRoot,
326
491
  step: "analyze",
327
492
  toolNames: ["read", "grep", "find", "ls"],
@@ -8,6 +8,7 @@ import path from "node:path";
8
8
  import { getTaskPaths } from "../../task/runtime.js";
9
9
  import { SEMANTIC_INTAKE_ARTIFACT_REL, } from "../../task/source-prepare/semantic-intake.js";
10
10
  import { extractRequirementFactsFromMarkdown } from "../../task/source-prepare/parse-intent.js";
11
+ import { filterPlaceholderPaths, isNonPlaceholderPath, } from "../../task/source-prepare/placeholder-paths.js";
11
12
  import { emptyDraft } from "./interview/grill-me.js";
12
13
  import { normalizeConsoleWorkflowKind } from "./workflow-kinds.js";
13
14
  function asStringList(value) {
@@ -105,13 +106,13 @@ export function parseEngineeringBoundaryFromParams(p) {
105
106
  if (Array.isArray(value)) {
106
107
  return value
107
108
  .map((item) => (typeof item === "string" ? item.trim() : ""))
108
- .filter(Boolean);
109
+ .filter(isNonPlaceholderPath);
109
110
  }
110
111
  if (typeof value === "string") {
111
112
  return value
112
113
  .split(/[\n,;,;]+/)
113
114
  .map((s) => s.trim())
114
- .filter(Boolean);
115
+ .filter(isNonPlaceholderPath);
115
116
  }
116
117
  return [];
117
118
  };
@@ -176,10 +177,10 @@ export function parseEngineeringBoundaryFromParams(p) {
176
177
  };
177
178
  }
178
179
  export function appendEngineeringCliArgs(args, boundary) {
179
- for (const pathGlob of boundary.allowedPaths ?? []) {
180
+ for (const pathGlob of filterPlaceholderPaths(boundary.allowedPaths)) {
180
181
  args.push("--allowed-path", pathGlob);
181
182
  }
182
- for (const pathGlob of boundary.forbiddenPaths ?? []) {
183
+ for (const pathGlob of filterPlaceholderPaths(boundary.forbiddenPaths)) {
183
184
  args.push("--forbidden-path", pathGlob);
184
185
  }
185
186
  for (const cmd of boundary.verifyCommands ?? []) {
@@ -187,8 +188,10 @@ export function appendEngineeringCliArgs(args, boundary) {
187
188
  }
188
189
  }
189
190
  export function applyEngineeringBoundaryToDraft(draft, boundary) {
190
- if (!(boundary.allowedPaths?.length) &&
191
- !(boundary.forbiddenPaths?.length) &&
191
+ const allowedPaths = filterPlaceholderPaths(boundary.allowedPaths);
192
+ const forbiddenPaths = filterPlaceholderPaths(boundary.forbiddenPaths);
193
+ if (allowedPaths.length === 0 &&
194
+ forbiddenPaths.length === 0 &&
192
195
  !(boundary.verifyCommands?.length)) {
193
196
  return draft;
194
197
  }
@@ -196,11 +199,11 @@ export function applyEngineeringBoundaryToDraft(draft, boundary) {
196
199
  ...draft,
197
200
  constraints: {
198
201
  invariants: draft.constraints?.invariants ?? [],
199
- allowedPaths: boundary.allowedPaths?.length
200
- ? boundary.allowedPaths
202
+ allowedPaths: allowedPaths.length > 0
203
+ ? allowedPaths
201
204
  : (draft.constraints?.allowedPaths ?? []),
202
- forbiddenPaths: boundary.forbiddenPaths?.length
203
- ? boundary.forbiddenPaths
205
+ forbiddenPaths: forbiddenPaths.length > 0
206
+ ? forbiddenPaths
204
207
  : (draft.constraints?.forbiddenPaths ?? []),
205
208
  },
206
209
  verification: {
@@ -253,15 +256,53 @@ export async function buildDraftFromTaskIntake(input) {
253
256
  const title = artifact?.draft?.title?.trim() ||
254
257
  artifact?.semantic?.title?.trim() ||
255
258
  input.title;
259
+ // Preserve semantic engineering boundary (paths/verify/taskKind) so the
260
+ // Console draft does not fall back to interview defaults and overwrite
261
+ // TX1 precise values on contractApply --from-draft.
262
+ const semDraftConstraints = artifact?.draft?.constraints;
263
+ const semDraftVerification = artifact?.draft?.verification;
264
+ const semTaskKind = artifact?.draft?.taskKind?.trim();
265
+ const mergedAllowed = filterPlaceholderPaths(semDraftConstraints?.allowedPaths);
266
+ const mergedForbidden = filterPlaceholderPaths(semDraftConstraints?.forbiddenPaths);
267
+ const mergedVerify = Array.isArray(semDraftVerification?.commands)
268
+ ? semDraftVerification.commands
269
+ .filter((cmd) => typeof cmd?.command === "string" && cmd.command.trim().length > 0)
270
+ .map((cmd) => ({
271
+ label: typeof cmd.label === "string" && cmd.label.trim()
272
+ ? cmd.label.trim()
273
+ : "verify",
274
+ command: cmd.command.trim(),
275
+ ...(typeof cmd.timeoutMs === "number"
276
+ ? { timeoutMs: cmd.timeoutMs }
277
+ : {}),
278
+ }))
279
+ : [];
256
280
  draft = {
257
281
  ...draft,
258
282
  title,
283
+ ...(semTaskKind
284
+ ? { taskKind: normalizeConsoleWorkflowKind(semTaskKind, taskKind) }
285
+ : {}),
259
286
  requirement: {
260
287
  objective: semanticReq.objective.trim(),
261
288
  scope: asStringList(semanticReq.scope),
262
289
  nonGoals: asStringList(semanticReq.nonGoals),
263
290
  acceptanceCriteria: asAcceptance(semanticReq.acceptanceCriteria),
264
291
  },
292
+ ...(mergedAllowed.length > 0 ||
293
+ mergedForbidden.length > 0 ||
294
+ Array.isArray(semDraftConstraints?.invariants)
295
+ ? {
296
+ constraints: {
297
+ invariants: asStringList(semDraftConstraints?.invariants),
298
+ allowedPaths: mergedAllowed,
299
+ forbiddenPaths: mergedForbidden,
300
+ },
301
+ }
302
+ : {}),
303
+ ...(mergedVerify.length > 0
304
+ ? { verification: { commands: mergedVerify } }
305
+ : {}),
265
306
  openQuestions: asStringList(artifact?.draft?.openQuestions ?? artifact?.semantic?.openQuestions) || undefined,
266
307
  assumptions: asStringList(artifact?.draft?.assumptions ?? artifact?.semantic?.assumptions) || undefined,
267
308
  references: artifact?.draft?.references,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.34.2",
3
+ "version": "0.34.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",