@tea-agent/loop-agent 0.25.6 → 0.26.1

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 (41) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +27 -1
  3. package/README.md +8 -3
  4. package/dist/cli/command-definitions.js +25 -10
  5. package/dist/cli/help.js +4 -3
  6. package/dist/cli/program.js +43 -17
  7. package/dist/commands/import-prd.js +7 -2
  8. package/dist/commands/init.js +7 -5
  9. package/dist/commands/task-source-prepare.js +468 -0
  10. package/dist/executors/dag-pi-executor.js +66 -25
  11. package/dist/executors/model-routing.js +34 -18
  12. package/dist/executors/shell-write-guard.js +161 -25
  13. package/dist/governance/manifest-types.js +33 -5
  14. package/dist/task/source-prepare/build-draft.js +215 -0
  15. package/dist/task/source-prepare/completeness.js +195 -0
  16. package/dist/task/source-prepare/index.js +7 -0
  17. package/dist/task/source-prepare/parse-intent.js +373 -0
  18. package/dist/task/source-prepare/path-policy.js +197 -0
  19. package/dist/task/source-prepare/prepare.js +506 -0
  20. package/dist/task/source-prepare/reference-integrity.js +274 -0
  21. package/dist/task/source-prepare/types.js +7 -0
  22. package/dist/task/task-demand-routing.js +3 -1
  23. package/dist/worker/console/chat/model-resolver.js +15 -3
  24. package/dist/worker/observe/static/constants.js +3 -2
  25. package/dist/worker/observe/static/dag-model.js +1 -0
  26. package/dist/worker/observe/static/styles.css +182 -42
  27. package/dist/workflows/dag/lifecycle.js +40 -30
  28. package/dist/workflows/dag/node-execution.js +13 -0
  29. package/dist/workflows/dag/types.js +59 -19
  30. package/docs/templates/harness.schema.json +29 -7
  31. package/docs/templates/init-managed-agents.md +10 -5
  32. package/harness.json +1 -2
  33. package/package.json +1 -1
  34. package/skills/loop-agent/SKILL.md +5 -2
  35. package/skills/loop-agent/references/command-reference.md +17 -15
  36. package/skills/loop-agent/references/harness-policy.md +3 -4
  37. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  38. package/skills/loop-agent/references/model-routing.md +2 -0
  39. package/skills/loop-agent/references/post-implementation-and-patterns.md +1 -1
  40. package/skills/loop-agent/references/source-and-plan-practice.md +3 -2
  41. package/skills/loop-agent/references/task-workflow.md +7 -5
@@ -1,4 +1,5 @@
1
- import { DEFAULT_DAG_EXECUTOR_MODELS, } from '../workflows/dag/types.js';
1
+ import { DEFAULT_DAG_EXECUTOR_MODELS, } from "../workflows/dag/types.js";
2
+ import { normalizeExecutorTierValue, } from "../governance/manifest-types.js";
2
3
  export const DEFAULT_DAG_MODELS = {
3
4
  HIGH: "gpt-5.5",
4
5
  MED: "gpt-5.5",
@@ -8,6 +9,16 @@ export const DEFAULT_DAG_MODELS = {
8
9
  * DAG executor model tier keys that may carry a per-complexity override.
9
10
  */
10
11
  const EXECUTOR_MODEL_TIERS = ["LOW", "MED", "HIGH"];
12
+ /** Resolve one tier to a model and optional explicit thinking override. */
13
+ export function resolveExecutorTierSelection(execConfig, tier) {
14
+ const tierSelection = normalizeExecutorTierValue(execConfig?.[tier]);
15
+ if (tierSelection)
16
+ return tierSelection;
17
+ const defaultModel = execConfig?.defaultModel;
18
+ if (defaultModel && defaultModel !== "default")
19
+ return { model: defaultModel };
20
+ return { model: DEFAULT_DAG_EXECUTOR_MODELS.pi[tier] };
21
+ }
11
22
  /**
12
23
  * Resolve the DAG executor model matrix for Pi from harness `executors.pi`.
13
24
  *
@@ -19,22 +30,23 @@ const EXECUTOR_MODEL_TIERS = ["LOW", "MED", "HIGH"];
19
30
  * The "default" literal (injected by the schema `.default("default")`) and
20
31
  * absent/undefined both mean "no override, fall through".
21
32
  */
22
- export function resolveExecutorModelMatrix(executor, execConfig) {
23
- const tierValue = (tier) => {
24
- const tierOverride = execConfig?.[tier];
25
- if (tierOverride && tierOverride !== "default")
26
- return tierOverride;
27
- const defaultModel = execConfig?.defaultModel;
28
- if (defaultModel && defaultModel !== "default")
29
- return defaultModel;
30
- return DEFAULT_DAG_EXECUTOR_MODELS[executor][tier];
31
- };
33
+ export function resolveExecutorModelMatrix(_executor, execConfig) {
32
34
  return {
33
- LOW: tierValue("LOW"),
34
- MED: tierValue("MED"),
35
- HIGH: tierValue("HIGH"),
35
+ LOW: resolveExecutorTierSelection(execConfig, "LOW").model,
36
+ MED: resolveExecutorTierSelection(execConfig, "MED").model,
37
+ HIGH: resolveExecutorTierSelection(execConfig, "HIGH").model,
36
38
  };
37
39
  }
40
+ /** Resolve only explicitly configured per-tier thinking values. */
41
+ export function resolveExecutorThinkingMatrix(execConfig) {
42
+ const result = {};
43
+ for (const tier of EXECUTOR_MODEL_TIERS) {
44
+ const selection = normalizeExecutorTierValue(execConfig?.[tier]);
45
+ if (selection?.thinking)
46
+ result[tier] = selection.thinking;
47
+ }
48
+ return result;
49
+ }
38
50
  /**
39
51
  * Resolve the Pi DAG executor model matrix from a harness manifest.
40
52
  */
@@ -55,7 +67,9 @@ export function resolveModelSelection(manifest, taskConfig, step, options) {
55
67
  };
56
68
  }
57
69
  const profileName = resolveProfileName(manifest, taskConfig.complexity, step, retryAttempt);
58
- const profile = profileName ? manifest.modelProfiles?.[profileName] : undefined;
70
+ const profile = profileName
71
+ ? manifest.modelProfiles?.[profileName]
72
+ : undefined;
59
73
  if (!profile) {
60
74
  return {
61
75
  modelConfig: manifest.models?.[step],
@@ -74,7 +88,9 @@ export function resolveModelSelection(manifest, taskConfig, step, options) {
74
88
  };
75
89
  }
76
90
  function resolveProfileName(manifest, complexity, step, retryAttempt) {
77
- if (step === 'implement' && retryAttempt > 0 && manifest.modelRouting?.implementRetry) {
91
+ if (step === "implement" &&
92
+ retryAttempt > 0 &&
93
+ manifest.modelRouting?.implementRetry) {
78
94
  return manifest.modelRouting.implementRetry;
79
95
  }
80
96
  const route = manifest.modelRouting?.[step];
@@ -85,11 +101,11 @@ function resolveProfileName(manifest, complexity, step, retryAttempt) {
85
101
  }
86
102
  export function formatModelSelectionLabel(modelConfig, profileName) {
87
103
  if (!modelConfig) {
88
- return profileName ? `${profileName}` : 'default';
104
+ return profileName ? `${profileName}` : "default";
89
105
  }
90
106
  const base = modelConfig.provider && modelConfig.model
91
107
  ? `${modelConfig.provider}/${modelConfig.model}`
92
- : modelConfig.model ?? 'default';
108
+ : (modelConfig.model ?? "default");
93
109
  return profileName ? `${profileName}:${base}` : base;
94
110
  }
95
111
  export function formatFallbackLabel(profile) {
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
- import { createReadStream } from "node:fs";
4
- import { lstat, readlink } from "node:fs/promises";
3
+ import { constants, createReadStream } from "node:fs";
4
+ import { access, lstat, readlink } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { pathMatchesPattern } from "../shared/git-progress.js";
7
7
  async function sha256File(filePath) {
@@ -164,40 +164,176 @@ export function validateShellWriteGuard(input) {
164
164
  }
165
165
  return { ok: violations.length === 0, violations };
166
166
  }
167
+ export class GitStatusUnavailableError extends Error {
168
+ diagnostics;
169
+ constructor(input) {
170
+ const last = input.attempts.at(-1);
171
+ const exitCode = last?.exitCode === undefined ? "unavailable" : String(last.exitCode);
172
+ const signal = last?.signal ?? "unavailable";
173
+ const phase = input.phase ?? "unspecified";
174
+ super(`git status failed after ${input.attempts.length} attempts (phase=${phase}, exit code=${exitCode}, exit code hex=${last?.exitCodeHex ?? "unavailable"}, signal=${signal}, cwd=${path.resolve(input.cwd)}): ${last?.detail ?? "unknown error"}`);
175
+ this.name = "GitStatusUnavailableError";
176
+ this.diagnostics = {
177
+ schemaVersion: 1,
178
+ phase,
179
+ cwd: path.resolve(input.cwd),
180
+ platform: input.platform ?? process.platform,
181
+ executableCandidates: input.executableCandidates ?? [],
182
+ requiredWindowsEnvironment: input.requiredWindowsEnvironment ?? requiredWindowsEnvironment(process.env),
183
+ attempts: input.attempts,
184
+ };
185
+ }
186
+ }
187
+ function requiredWindowsEnvironment(env) {
188
+ return {
189
+ SystemRootPresent: Boolean(env.SystemRoot ?? env.SYSTEMROOT),
190
+ windirPresent: Boolean(env.windir ?? env.WINDIR),
191
+ ComSpecPresent: Boolean(env.ComSpec ?? env.COMSPEC),
192
+ PATHEXTPresent: Boolean(env.PATHEXT),
193
+ };
194
+ }
195
+ function errorExitCode(error) {
196
+ if (error &&
197
+ typeof error === "object" &&
198
+ "exitCode" in error &&
199
+ typeof error.exitCode === "number") {
200
+ return error.exitCode;
201
+ }
202
+ return undefined;
203
+ }
204
+ function errorSignal(error) {
205
+ if (error &&
206
+ typeof error === "object" &&
207
+ "signal" in error &&
208
+ typeof error.signal === "string") {
209
+ return error.signal;
210
+ }
211
+ return null;
212
+ }
213
+ function formatWindowsExitCode(exitCode) {
214
+ if (exitCode === undefined)
215
+ return undefined;
216
+ return `0x${(exitCode >>> 0).toString(16).padStart(8, "0").toUpperCase()}`;
217
+ }
218
+ function isWindowsDllInitializationFailure(platform, exitCode) {
219
+ return platform === "win32" && exitCode !== undefined && (exitCode >>> 0) === 0xc0000142;
220
+ }
221
+ function boundedErrorDetail(error) {
222
+ const detail = error instanceof Error ? error.message : String(error);
223
+ return detail.length <= 1000 ? detail : `${detail.slice(0, 1000)}...[truncated]`;
224
+ }
225
+ export function deriveSameInstallationGitCandidates(primary) {
226
+ const normalized = path.win32.normalize(primary);
227
+ const lower = normalized.toLowerCase();
228
+ let fallback;
229
+ if (lower.endsWith("\\mingw64\\bin\\git.exe")) {
230
+ const root = path.win32.resolve(path.win32.dirname(normalized), "..", "..");
231
+ fallback = path.win32.join(root, "cmd", "git.exe");
232
+ }
233
+ else if (lower.endsWith("\\cmd\\git.exe")) {
234
+ const root = path.win32.resolve(path.win32.dirname(normalized), "..");
235
+ fallback = path.win32.join(root, "mingw64", "bin", "git.exe");
236
+ }
237
+ return Array.from(new Set([normalized, ...(fallback ? [fallback] : [])]));
238
+ }
239
+ async function resolveGitExecutableCandidates(platform, env) {
240
+ if (platform !== "win32")
241
+ return ["git"];
242
+ const pathValue = env.PATH ?? env.Path ?? "";
243
+ for (const rawEntry of pathValue.split(path.delimiter)) {
244
+ const entry = rawEntry.trim().replace(/^"|"$/g, "");
245
+ if (!entry)
246
+ continue;
247
+ const candidate = path.win32.join(entry, "git.exe");
248
+ try {
249
+ await access(candidate, constants.X_OK);
250
+ const sameInstallation = deriveSameInstallationGitCandidates(candidate);
251
+ const existing = [];
252
+ for (const executable of sameInstallation) {
253
+ try {
254
+ await access(executable, constants.X_OK);
255
+ existing.push(executable);
256
+ }
257
+ catch {
258
+ // Optional same-installation fallback is absent.
259
+ }
260
+ }
261
+ if (existing.length > 0)
262
+ return existing;
263
+ }
264
+ catch {
265
+ // Keep searching PATH entries.
266
+ }
267
+ }
268
+ return ["git"];
269
+ }
167
270
  export async function readGitStatusPorcelain(cwd, options = {}) {
168
- const attempts = Math.max(1, options.attempts ?? 5);
169
- const retryDelayMs = Math.max(0, options.retryDelayMs ?? 150);
170
- let lastError;
171
- for (let attempt = 1; attempt <= attempts; attempt += 1) {
271
+ return readGitStatusPorcelainWithDependencies(cwd, options, {
272
+ platform: process.platform,
273
+ resolveExecutableCandidates: () => resolveGitExecutableCandidates(process.platform, process.env),
274
+ runAttempt: readGitStatusPorcelainOnce,
275
+ sleep: async (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
276
+ now: Date.now,
277
+ env: process.env,
278
+ });
279
+ }
280
+ export async function readGitStatusPorcelainWithDependencies(cwd, options, dependencies) {
281
+ const candidates = await dependencies.resolveExecutableCandidates();
282
+ const executableCandidates = candidates.length > 0 ? candidates : ["git"];
283
+ const normalMaxAttempts = Math.max(1, options.attempts ?? 5);
284
+ const transientMaxAttempts = Math.max(1, options.attempts ?? 10);
285
+ const normalRetryBaseMs = Math.max(0, options.retryDelayMs ?? 150);
286
+ const transientRetryBaseMs = Math.max(0, options.retryDelayMs ?? 250);
287
+ const attemptDiagnostics = [];
288
+ for (let attempt = 1; attempt <= transientMaxAttempts; attempt += 1) {
289
+ const executable = executableCandidates[(attempt - 1) % executableCandidates.length];
290
+ const startedAt = dependencies.now();
172
291
  try {
173
- return await readGitStatusPorcelainOnce(cwd);
292
+ return await dependencies.runAttempt(cwd, executable);
174
293
  }
175
294
  catch (error) {
176
- lastError = error;
177
- if (attempt < attempts && retryDelayMs > 0) {
178
- await new Promise((resolve) => setTimeout(resolve, retryDelayMs * attempt));
179
- }
295
+ const exitCode = errorExitCode(error);
296
+ const transient = isWindowsDllInitializationFailure(dependencies.platform, exitCode);
297
+ attemptDiagnostics.push({
298
+ attempt,
299
+ executable,
300
+ ...(exitCode === undefined ? {} : { exitCode }),
301
+ ...(formatWindowsExitCode(exitCode)
302
+ ? { exitCodeHex: formatWindowsExitCode(exitCode) }
303
+ : {}),
304
+ signal: errorSignal(error),
305
+ durationMs: Math.max(0, dependencies.now() - startedAt),
306
+ ...(transient
307
+ ? { transientKind: "windows-dll-init-failed" }
308
+ : {}),
309
+ detail: boundedErrorDetail(error),
310
+ });
311
+ const maxAttempts = transient ? transientMaxAttempts : normalMaxAttempts;
312
+ if (attempt >= maxAttempts)
313
+ break;
314
+ const delayMs = transient
315
+ ? Math.min(4000, transientRetryBaseMs * 2 ** (attempt - 1))
316
+ : normalRetryBaseMs * attempt;
317
+ if (delayMs > 0)
318
+ await dependencies.sleep(delayMs);
180
319
  }
181
320
  }
182
- const detail = lastError instanceof Error ? lastError.message : String(lastError);
183
- const exitCode = lastError instanceof Error &&
184
- "exitCode" in lastError &&
185
- typeof lastError.exitCode === "number"
186
- ? String(lastError.exitCode)
187
- : "unavailable";
188
- const signal = lastError instanceof Error &&
189
- "signal" in lastError &&
190
- typeof lastError.signal === "string"
191
- ? lastError.signal
192
- : "unavailable";
193
- throw new Error(`git status failed after ${attempts} attempts (exit code=${exitCode}, signal=${signal}, cwd=${path.resolve(cwd)}): ${detail}`, { cause: lastError });
321
+ throw new GitStatusUnavailableError({
322
+ cwd,
323
+ phase: options.phase,
324
+ platform: dependencies.platform,
325
+ executableCandidates,
326
+ requiredWindowsEnvironment: requiredWindowsEnvironment(dependencies.env ?? process.env),
327
+ attempts: attemptDiagnostics,
328
+ });
194
329
  }
195
- function readGitStatusPorcelainOnce(cwd) {
330
+ function readGitStatusPorcelainOnce(cwd, executable) {
196
331
  return new Promise((resolve, reject) => {
197
- const child = spawn("git", ["status", "--porcelain=v1", "--untracked-files=all"], {
332
+ const child = spawn(executable, ["status", "--porcelain=v1", "--untracked-files=all"], {
198
333
  cwd,
199
334
  env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
200
335
  stdio: ["ignore", "pipe", "pipe"],
336
+ windowsHide: true,
201
337
  });
202
338
  let stdout = "";
203
339
  let stderr = "";
@@ -18,6 +18,34 @@ export const worktreeManifestConfigSchema = z.object({
18
18
  });
19
19
  export const taskExecutorSchema = z.enum(["pi"]);
20
20
  export const CURSOR_TASK_EXECUTOR_REMOVED_ERROR = 'task executor "cursor" is no longer supported; governed runtime is Pi-only';
21
+ /** Per-tier model override: bare model id string, or `{ model, thinking? }`. */
22
+ export const executorTierModelSchema = z.union([
23
+ z.string(),
24
+ z.object({
25
+ model: z.string().min(1),
26
+ thinking: z.string().optional(),
27
+ }),
28
+ ]);
29
+ /** Normalize an executors.pi LOW|MED|HIGH value. */
30
+ export function normalizeExecutorTierValue(value) {
31
+ if (value === null || value === undefined)
32
+ return undefined;
33
+ if (typeof value === "string") {
34
+ if (value === "default" || value.length === 0)
35
+ return undefined;
36
+ return { model: value };
37
+ }
38
+ if (typeof value !== "object" || Array.isArray(value))
39
+ return undefined;
40
+ const model = value.model;
41
+ if (typeof model !== "string" || model.length === 0 || model === "default") {
42
+ return undefined;
43
+ }
44
+ const thinking = value.thinking;
45
+ return typeof thinking === "string" && thinking.length > 0
46
+ ? { model, thinking }
47
+ : { model };
48
+ }
21
49
  export const executorManifestSchema = z.object({
22
50
  description: z.string().optional(),
23
51
  enabled: z.boolean().optional(),
@@ -27,9 +55,9 @@ export const executorManifestSchema = z.object({
27
55
  * these take priority over defaultModel for the matching DAG executor tier.
28
56
  * "default" literal and absent/undefined both mean "no override, fall through".
29
57
  */
30
- LOW: z.string().optional(),
31
- MED: z.string().optional(),
32
- HIGH: z.string().optional(),
58
+ LOW: executorTierModelSchema.optional(),
59
+ MED: executorTierModelSchema.optional(),
60
+ HIGH: executorTierModelSchema.optional(),
33
61
  requiresApiKey: z.string().optional(),
34
62
  });
35
63
  export const workflowPolicyProfileNameSchema = z.enum([
@@ -98,7 +126,7 @@ export const workflowPolicySchema = z
98
126
  })
99
127
  .optional()
100
128
  .default({});
101
- export const CURSOR_HARNESS_EXECUTOR_REMOVED_ERROR = 'harness executors.cursor is no longer supported; remove it and use executors.pi only (Cursor is available only via cursor-prompt sidecar)';
129
+ export const CURSOR_HARNESS_EXECUTOR_REMOVED_ERROR = "harness executors.cursor is no longer supported; remove it and use executors.pi only (Cursor is available only via cursor-prompt sidecar)";
102
130
  export const harnessManifestSchema = z
103
131
  .object({
104
132
  version: z.number(),
@@ -160,7 +188,7 @@ export const harnessManifestSchema = z
160
188
  if ("cursorExecutorUsage" in entrypoints) {
161
189
  ctx.addIssue({
162
190
  code: z.ZodIssueCode.custom,
163
- message: 'entrypoints.cursorExecutorUsage is no longer supported; remove it (Cursor is cursor-prompt sidecar only)',
191
+ message: "entrypoints.cursorExecutorUsage is no longer supported; remove it (Cursor is cursor-prompt sidecar only)",
164
192
  path: ["entrypoints", "cursorExecutorUsage"],
165
193
  });
166
194
  }
@@ -0,0 +1,215 @@
1
+ import { TASK_CONTRACT_DRAFT_SCHEMA_VERSION } from "../contract/constants.js";
2
+ import { taskKindSchema } from "../config-types.js";
3
+ import { assessPreparePaths, mergeForbiddenPaths, normalizePreparePath, dedupeStable, } from "./path-policy.js";
4
+ function trimNonEmpty(value) {
5
+ if (value === undefined)
6
+ return undefined;
7
+ const trimmed = value.trim();
8
+ return trimmed ? trimmed : undefined;
9
+ }
10
+ function cleanStringList(values) {
11
+ if (values === undefined)
12
+ return undefined;
13
+ return values.map((v) => v.trim()).filter(Boolean);
14
+ }
15
+ function resolveTaskKind(flags, baseDraft, existing) {
16
+ const candidate = flags.taskKind ?? baseDraft?.taskKind ?? existing?.taskKind ?? "standard";
17
+ const parsed = taskKindSchema.safeParse(candidate);
18
+ return parsed.success ? parsed.data : String(candidate);
19
+ }
20
+ function resolveTitle(taskId, flags, facts, baseDraft, existing) {
21
+ return (trimNonEmpty(flags.title) ??
22
+ trimNonEmpty(baseDraft?.title) ??
23
+ trimNonEmpty(facts?.title) ??
24
+ trimNonEmpty(existing?.title) ??
25
+ taskId);
26
+ }
27
+ function resolveObjective(flags, facts, baseDraft, title) {
28
+ return (trimNonEmpty(flags.objective) ??
29
+ trimNonEmpty(baseDraft?.requirement.objective) ??
30
+ trimNonEmpty(facts?.objective) ??
31
+ title);
32
+ }
33
+ function resolveStringArrayField(input) {
34
+ if (input.explicit !== undefined) {
35
+ return cleanStringList(input.explicit) ?? [];
36
+ }
37
+ if (input.base && input.base.length > 0)
38
+ return [...input.base];
39
+ if (input.facts && input.facts.length > 0)
40
+ return [...input.facts];
41
+ return [];
42
+ }
43
+ function resolveAcceptance(input) {
44
+ if (input.explicit !== undefined) {
45
+ return input.explicit
46
+ .map((item) => ({ id: item.id.trim(), text: item.text.trim() }))
47
+ .filter((item) => item.id && item.text);
48
+ }
49
+ if (input.base && input.base.length > 0)
50
+ return [...input.base];
51
+ if (input.facts && input.facts.length > 0)
52
+ return [...input.facts];
53
+ return [];
54
+ }
55
+ function resolveAllowedPaths(input) {
56
+ const source = input.flags.allowedPaths !== undefined
57
+ ? (cleanStringList(input.flags.allowedPaths) ?? [])
58
+ : input.baseDraft?.constraints.allowedPaths?.length
59
+ ? [...input.baseDraft.constraints.allowedPaths]
60
+ : input.existing?.allowedPaths?.length
61
+ ? [...input.existing.allowedPaths]
62
+ : [];
63
+ return source.map((raw) => {
64
+ const n = normalizePreparePath(raw);
65
+ return n.ok ? n.value : raw.trim();
66
+ });
67
+ }
68
+ function resolveVerifyCommands(input) {
69
+ if (input.flags.verifyCommands !== undefined) {
70
+ return input.flags.verifyCommands.map((cmd) => ({
71
+ label: cmd.label.trim(),
72
+ command: cmd.command.trim(),
73
+ ...(cmd.timeoutMs !== undefined ? { timeoutMs: cmd.timeoutMs } : {}),
74
+ }));
75
+ }
76
+ if (input.baseDraft?.verification.commands?.length) {
77
+ return input.baseDraft.verification.commands.map((cmd) => ({
78
+ label: cmd.label,
79
+ command: cmd.command,
80
+ ...(cmd.timeoutMs !== undefined ? { timeoutMs: cmd.timeoutMs } : {}),
81
+ }));
82
+ }
83
+ if (input.existing?.verifyCommands?.length) {
84
+ return input.existing.verifyCommands.map((cmd) => ({
85
+ label: cmd.label,
86
+ command: cmd.command,
87
+ ...(cmd.timeoutMs !== undefined ? { timeoutMs: cmd.timeoutMs } : {}),
88
+ }));
89
+ }
90
+ return [];
91
+ }
92
+ /**
93
+ * Build a thin TaskContractDraftV1 from draft base / PRD facts / flags / task config.
94
+ * Engineering fields never come from PRD facts.
95
+ */
96
+ export function buildPrepareDraft(input) {
97
+ const { taskId, existingTaskConfig, baseDraft, facts, flags, references } = input;
98
+ const title = resolveTitle(taskId, flags, facts, baseDraft, existingTaskConfig);
99
+ const taskKind = resolveTaskKind(flags, baseDraft, existingTaskConfig);
100
+ const featureId = trimNonEmpty(flags.featureId) ??
101
+ trimNonEmpty(baseDraft?.featureId) ??
102
+ trimNonEmpty(existingTaskConfig?.featureId) ??
103
+ undefined;
104
+ const objective = resolveObjective(flags, facts, baseDraft, title);
105
+ const scope = resolveStringArrayField({
106
+ explicit: flags.scope,
107
+ base: baseDraft?.requirement.scope,
108
+ facts: facts?.scope,
109
+ });
110
+ const nonGoals = resolveStringArrayField({
111
+ explicit: flags.nonGoals,
112
+ base: baseDraft?.requirement.nonGoals,
113
+ facts: facts?.nonGoals,
114
+ });
115
+ const acceptanceCriteria = resolveAcceptance({
116
+ explicit: flags.acceptanceCriteria,
117
+ base: baseDraft?.requirement.acceptanceCriteria,
118
+ facts: facts?.acceptanceCriteria,
119
+ });
120
+ const allowedPaths = resolveAllowedPaths({
121
+ flags,
122
+ baseDraft,
123
+ existing: existingTaskConfig,
124
+ });
125
+ // D12: protected defaults always merge unless explicitly disabled.
126
+ // Explicit --forbidden-path appends; does not replace defaults.
127
+ const finalForbidden = mergeForbiddenPaths({
128
+ existing: baseDraft?.constraints.forbiddenPaths ??
129
+ existingTaskConfig?.forbiddenPaths ??
130
+ [],
131
+ explicit: flags.forbiddenPaths,
132
+ noDefaultForbiddenPaths: flags.noDefaultForbiddenPaths,
133
+ });
134
+ const invariants = resolveStringArrayField({
135
+ explicit: flags.invariants,
136
+ base: baseDraft?.constraints.invariants,
137
+ facts: facts?.invariants,
138
+ });
139
+ const openQuestions = resolveStringArrayField({
140
+ explicit: flags.openQuestions,
141
+ base: baseDraft?.openQuestions,
142
+ facts: facts?.openQuestions,
143
+ });
144
+ const assumptions = resolveStringArrayField({
145
+ explicit: flags.assumptions,
146
+ base: baseDraft?.assumptions,
147
+ facts: facts?.assumptions,
148
+ });
149
+ const verifyCommands = resolveVerifyCommands({
150
+ flags,
151
+ baseDraft,
152
+ existing: existingTaskConfig,
153
+ });
154
+ const pathAssessment = assessPreparePaths({
155
+ allowedPaths,
156
+ forbiddenPaths: finalForbidden,
157
+ noDefaultForbiddenPaths: flags.noDefaultForbiddenPaths,
158
+ });
159
+ const draft = {
160
+ schemaVersion: TASK_CONTRACT_DRAFT_SCHEMA_VERSION,
161
+ taskId,
162
+ title,
163
+ taskKind: taskKind,
164
+ requirement: {
165
+ objective,
166
+ scope,
167
+ nonGoals,
168
+ acceptanceCriteria,
169
+ },
170
+ constraints: {
171
+ invariants,
172
+ allowedPaths: pathAssessment.allowed.length > 0
173
+ ? pathAssessment.allowed
174
+ : dedupeStable(allowedPaths),
175
+ forbiddenPaths: pathAssessment.forbidden.length > 0
176
+ ? pathAssessment.forbidden
177
+ : dedupeStable(finalForbidden),
178
+ },
179
+ verification: {
180
+ commands: verifyCommands
181
+ .filter((cmd) => cmd.label && cmd.command)
182
+ .map((cmd) => ({
183
+ label: cmd.label,
184
+ command: cmd.command,
185
+ ...(cmd.timeoutMs !== undefined ? { timeoutMs: cmd.timeoutMs } : {}),
186
+ })),
187
+ },
188
+ };
189
+ if (featureId)
190
+ draft.featureId = featureId;
191
+ if (references && references.length > 0) {
192
+ draft.references = references.map((ref) => ({
193
+ role: ref.role,
194
+ ref: ref.ref,
195
+ }));
196
+ }
197
+ else if (baseDraft?.references?.length) {
198
+ draft.references = [...baseDraft.references];
199
+ }
200
+ if (openQuestions.length > 0)
201
+ draft.openQuestions = openQuestions;
202
+ if (assumptions.length > 0)
203
+ draft.assumptions = assumptions;
204
+ if (baseDraft?.sourceResolutions?.length) {
205
+ draft.sourceResolutions = [...baseDraft.sourceResolutions];
206
+ }
207
+ if (baseDraft?.hardConstraints?.length && invariants.length === 0) {
208
+ draft.hardConstraints = [...baseDraft.hardConstraints];
209
+ }
210
+ return {
211
+ draft,
212
+ pathAssessment,
213
+ acceptanceConflicts: facts?.acceptanceConflicts ?? [],
214
+ };
215
+ }