@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
@@ -0,0 +1,197 @@
1
+ import { canonicalizePath } from "../contract/canonicalize.js";
2
+ import { PREPARE_DEFAULT_FORBIDDEN_PATHS } from "./types.js";
3
+ function hasNul(value) {
4
+ return value.includes("\0");
5
+ }
6
+ function isAbsolutePath(value) {
7
+ if (value.startsWith("/"))
8
+ return true;
9
+ // Windows drive or UNC
10
+ if (/^[a-zA-Z]:[\\/]/.test(value))
11
+ return true;
12
+ if (value.startsWith("\\\\") || value.startsWith("//"))
13
+ return true;
14
+ return false;
15
+ }
16
+ function hasTraversalSegment(value) {
17
+ const parts = value.split("/");
18
+ return parts.some((part) => part === "..");
19
+ }
20
+ /**
21
+ * Normalize path globs for policy comparison:
22
+ * - trim
23
+ * - backslash → /
24
+ * - collapse leading ./
25
+ * - reject empty / NUL
26
+ */
27
+ export function normalizePreparePath(raw) {
28
+ const trimmed = raw.trim();
29
+ if (!trimmed) {
30
+ return { ok: false, reason: "path is empty" };
31
+ }
32
+ if (hasNul(trimmed)) {
33
+ return { ok: false, reason: "path contains NUL" };
34
+ }
35
+ let normalized = canonicalizePath(trimmed);
36
+ while (normalized.startsWith("./")) {
37
+ normalized = normalized.slice(2);
38
+ }
39
+ if (normalized === "./" || normalized === ".") {
40
+ normalized = ".";
41
+ }
42
+ if (normalized === "./**") {
43
+ normalized = "**";
44
+ }
45
+ if (!normalized) {
46
+ return { ok: false, reason: "path is empty after normalization" };
47
+ }
48
+ if (isAbsolutePath(normalized)) {
49
+ return { ok: false, reason: "absolute paths are not allowed" };
50
+ }
51
+ if (hasTraversalSegment(normalized)) {
52
+ return { ok: false, reason: "path traversal (..) is not allowed" };
53
+ }
54
+ return { ok: true, value: normalized };
55
+ }
56
+ export function dedupeStable(paths) {
57
+ const seen = new Set();
58
+ const out = [];
59
+ for (const item of paths) {
60
+ if (seen.has(item))
61
+ continue;
62
+ seen.add(item);
63
+ out.push(item);
64
+ }
65
+ return out;
66
+ }
67
+ /**
68
+ * Protected defaults always merge unless noDefaultForbiddenPaths is set.
69
+ * Explicit forbiddenPaths append.
70
+ */
71
+ export function mergeForbiddenPaths(input) {
72
+ const parts = [];
73
+ if (!input.noDefaultForbiddenPaths) {
74
+ parts.push(...PREPARE_DEFAULT_FORBIDDEN_PATHS);
75
+ }
76
+ if (input.existing)
77
+ parts.push(...input.existing);
78
+ if (input.explicit)
79
+ parts.push(...input.explicit);
80
+ const normalized = [];
81
+ for (const raw of parts) {
82
+ const result = normalizePreparePath(raw);
83
+ if (result.ok)
84
+ normalized.push(result.value);
85
+ else
86
+ normalized.push(raw.trim());
87
+ }
88
+ return dedupeStable(normalized);
89
+ }
90
+ function forbiddenAncestorCoversAllowed(allowed, forbidden) {
91
+ // Only the deterministic prefix/** ancestor rule from C2.1.
92
+ if (!forbidden.endsWith("/**"))
93
+ return false;
94
+ const prefix = forbidden.slice(0, -3); // drop /**
95
+ if (!prefix)
96
+ return false;
97
+ if (allowed === prefix)
98
+ return true;
99
+ if (allowed.startsWith(`${prefix}/`))
100
+ return true;
101
+ return false;
102
+ }
103
+ export function assessPreparePaths(input) {
104
+ const blocking = [];
105
+ const elevated = [];
106
+ const warnings = [];
107
+ const allowed = [];
108
+ const forbidden = [];
109
+ for (const raw of input.allowedPaths) {
110
+ const result = normalizePreparePath(raw);
111
+ if (!result.ok) {
112
+ blocking.push({
113
+ code: "UNSAFE_ALLOWED_PATH",
114
+ level: "blocking",
115
+ message: `allowed path invalid: ${raw} (${result.reason})`,
116
+ path: raw,
117
+ });
118
+ continue;
119
+ }
120
+ allowed.push(result.value);
121
+ }
122
+ for (const raw of input.forbiddenPaths) {
123
+ const result = normalizePreparePath(raw);
124
+ if (!result.ok) {
125
+ blocking.push({
126
+ code: "UNSAFE_FORBIDDEN_PATH",
127
+ level: "blocking",
128
+ message: `forbidden path invalid: ${raw} (${result.reason})`,
129
+ path: raw,
130
+ });
131
+ continue;
132
+ }
133
+ forbidden.push(result.value);
134
+ }
135
+ const allowedSet = new Set(allowed);
136
+ for (const f of forbidden) {
137
+ if (allowedSet.has(f)) {
138
+ blocking.push({
139
+ code: "ALLOWED_FORBIDDEN_OVERLAP",
140
+ level: "blocking",
141
+ message: `allowedPaths and forbiddenPaths overlap exactly: ${f}`,
142
+ path: f,
143
+ });
144
+ }
145
+ }
146
+ for (const a of allowed) {
147
+ for (const f of forbidden) {
148
+ if (forbiddenAncestorCoversAllowed(a, f)) {
149
+ blocking.push({
150
+ code: "ALLOWED_UNDER_FORBIDDEN_ANCESTOR",
151
+ level: "blocking",
152
+ message: `allowed path ${a} is fully covered by forbidden ancestor ${f}`,
153
+ path: a,
154
+ });
155
+ }
156
+ }
157
+ }
158
+ for (const a of allowed) {
159
+ if (a === "**" || a === "*" || a === ".") {
160
+ elevated.push({
161
+ code: "BROAD_ALLOWED_ROOT",
162
+ level: "elevated",
163
+ message: `allowed path ${a} grants repo-root broad write`,
164
+ path: a,
165
+ });
166
+ }
167
+ else if (a === "src/**") {
168
+ elevated.push({
169
+ code: "BROAD_ALLOWED_SRC",
170
+ level: "elevated",
171
+ message: "allowed path src/** is broad for large repos; prefer a narrower glob",
172
+ path: a,
173
+ });
174
+ }
175
+ }
176
+ if (input.noDefaultForbiddenPaths) {
177
+ elevated.push({
178
+ code: "DEFAULT_FORBIDDEN_DISABLED",
179
+ level: "elevated",
180
+ message: "--no-default-forbidden-paths disabled protected defaults (.harness/**, node_modules/**)",
181
+ });
182
+ if (!forbidden.includes(".harness/**")) {
183
+ warnings.push({
184
+ code: "MISSING_HARNESS_FORBIDDEN",
185
+ level: "routine",
186
+ message: "forbiddenPaths lacks .harness/** after disabling protected defaults",
187
+ });
188
+ }
189
+ }
190
+ return {
191
+ allowed: dedupeStable(allowed),
192
+ forbidden: dedupeStable(forbidden),
193
+ blocking,
194
+ elevated,
195
+ warnings,
196
+ };
197
+ }
@@ -0,0 +1,506 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat } from "node:fs/promises";
3
+ import { ContractMutationError, applyTaskContract, logicalRevisionForState, observeTaskContractState, projectConstraintsMarkdown, projectRequirementMarkdown, sha256OfCanonicalJson, validateDraftForTask, } from "../contract/index.js";
4
+ import { getTaskContractPaths } from "../contract/paths.js";
5
+ import { loadTaskConfig } from "../runtime.js";
6
+ import { buildPrepareDraft } from "./build-draft.js";
7
+ import { hasBlockingGaps, hasBlockingRisks, isFreshLegacyUnversioned, listPrepareGaps, } from "./completeness.js";
8
+ import { extractRequirementFactsFromMarkdown, fillMissingRequirementFacts, mergeRequirementFacts, } from "./parse-intent.js";
9
+ import { draftReferencesFromManifest, validateImportedPrdReferences, } from "./reference-integrity.js";
10
+ import { readSourceManifest } from "../source-references.js";
11
+ import { PREPARE_MAX_FILE_BYTES } from "./types.js";
12
+ async function fileNonEmpty(filePath) {
13
+ try {
14
+ const stats = await lstat(filePath);
15
+ return stats.isFile() && stats.size > 0;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ async function loadExistingTaskConfig(repoRoot, taskId) {
22
+ try {
23
+ return await loadTaskConfig(repoRoot, taskId);
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ function mutationOutcome(code) {
30
+ switch (code) {
31
+ case "REVISION_CONFLICT":
32
+ case "OBSERVED_HASH_CONFLICT":
33
+ case "REQUEST_ID_REUSE_CONFLICT":
34
+ case "LEASE_HELD":
35
+ return "conflict";
36
+ case "NOT_FOUND":
37
+ return "not-found";
38
+ case "TRANSACTION_INCOMPLETE":
39
+ case "OPERATION_NEEDS_RECONCILE":
40
+ return "needs-reconcile";
41
+ case "EXTERNALLY_MODIFIED":
42
+ case "NOT_MANAGED":
43
+ case "BINDING_DRIFT":
44
+ return "blocked";
45
+ case "INVALID_INPUT":
46
+ case "PATH_OUTSIDE_REPO":
47
+ case "UNSUPPORTED_SCHEMA_VERSION":
48
+ return "invalid";
49
+ default:
50
+ return "rejected";
51
+ }
52
+ }
53
+ function buildNextSteps(input) {
54
+ const next = [];
55
+ if (!input.hasImportedPrd) {
56
+ next.push(`loop-agent import-prd ${input.taskId} --file <prd.md> --role requirement`);
57
+ }
58
+ if (input.gaps.some((g) => g.code === "EMPTY_ALLOWED_PATHS")) {
59
+ next.push("补 --allowed-path <glob>");
60
+ }
61
+ if (input.gaps.some((g) => g.code === "EMPTY_ACCEPTANCE")) {
62
+ next.push("补 --ac <id:text> 或完善 PRD 验收标准章节");
63
+ }
64
+ if (input.mode === "dry-run" && input.ok) {
65
+ next.push(`loop-agent task source prepare ${input.taskId} --use-imported-prd ... --apply --json`);
66
+ }
67
+ if (input.ok && input.mode === "apply") {
68
+ next.push(`loop-agent task contract show ${input.taskId} --json`);
69
+ next.push(`loop-agent dag run-task ${input.taskId} --profile auto --strict-models`);
70
+ next.push("审查 DAG writeSet 后再 run-dag");
71
+ }
72
+ if (input.gaps.some((g) => g.code === "DIRTY_SOURCE")) {
73
+ next.push("loop-agent task contract show|diff|adopt|recover");
74
+ next.push("或显式 --force-overwrite-source 后重跑 prepare");
75
+ }
76
+ if (input.gaps.some((g) => g.code === "TRANSACTION_INCOMPLETE")) {
77
+ next.push(`loop-agent task contract recover --task ${input.taskId}`);
78
+ }
79
+ return next;
80
+ }
81
+ function emptyObservedHashPlaceholder() {
82
+ return "0".repeat(64);
83
+ }
84
+ /**
85
+ * Core prepare orchestration: facts → thin Draft → gates → optional apply.
86
+ * No LLM. Production md writes only via applyTaskContract projection.
87
+ */
88
+ export async function prepareTaskSource(input) {
89
+ const { repoRoot, taskId } = input;
90
+ const contractPaths = getTaskContractPaths(repoRoot, taskId);
91
+ const state = await observeTaskContractState(repoRoot, taskId);
92
+ if (state.observationErrors.some((e) => e.code === "NOT_FOUND")) {
93
+ return {
94
+ ok: false,
95
+ outcome: "not-found",
96
+ mode: input.mode,
97
+ taskId,
98
+ gaps: [
99
+ {
100
+ code: "TASK_NOT_FOUND",
101
+ level: "blocking",
102
+ message: `task not found: ${taskId}`,
103
+ },
104
+ ],
105
+ risks: [],
106
+ next: [`loop-agent new-task ${taskId} "<title>"`],
107
+ error: {
108
+ code: "NOT_FOUND",
109
+ message: `task not found: ${taskId}`,
110
+ },
111
+ };
112
+ }
113
+ const existingTaskConfig = await loadExistingTaskConfig(repoRoot, taskId);
114
+ const sourceFilesPresent = {
115
+ requirement: await fileNonEmpty(contractPaths.requirementPath),
116
+ constraints: await fileNonEmpty(contractPaths.constraintsPath),
117
+ };
118
+ // Contract may record referenceManifestSha256 while observe only reports
119
+ // EXTERNALLY_MODIFIED after the manifest file disappears (hash already drifts).
120
+ // That is a non-fresh observation condition force must not bypass.
121
+ if (state.ref?.sourceHashes.referenceManifestSha256) {
122
+ const manifest = await readSourceManifest(contractPaths.sourceDir);
123
+ if (!manifest) {
124
+ state.observationErrors = [
125
+ ...state.observationErrors,
126
+ {
127
+ code: "MANIFEST_MISSING",
128
+ path: contractPaths.sourceManifestPath,
129
+ message: "contract records referenceManifestSha256 but source-manifest.json is missing",
130
+ },
131
+ ];
132
+ }
133
+ }
134
+ let baseDraft;
135
+ let facts;
136
+ let sourceIntegrity = null;
137
+ let hasImportedPrd = false;
138
+ let references;
139
+ if (input.intent.kind === "draft") {
140
+ baseDraft = input.intent.draft;
141
+ if (baseDraft.taskId !== taskId) {
142
+ return {
143
+ ok: false,
144
+ outcome: "invalid",
145
+ mode: input.mode,
146
+ taskId,
147
+ gaps: [
148
+ {
149
+ code: "DRAFT_TASK_ID_MISMATCH",
150
+ level: "blocking",
151
+ message: `draft.taskId "${baseDraft.taskId}" does not match ${taskId}`,
152
+ },
153
+ ],
154
+ risks: [],
155
+ next: [],
156
+ error: {
157
+ code: "INVALID_INPUT",
158
+ message: `draft.taskId "${baseDraft.taskId}" does not match ${taskId}`,
159
+ },
160
+ };
161
+ }
162
+ // Still verify any references in draft against manifest when present.
163
+ if (baseDraft.references?.length) {
164
+ sourceIntegrity = await validateImportedPrdReferences({
165
+ repoRoot,
166
+ taskId,
167
+ requireParseableRequirement: false,
168
+ });
169
+ hasImportedPrd = sourceIntegrity.documents.length > 0;
170
+ const allowedRefs = new Set(sourceIntegrity.documents.map((document) => document.materializedPath));
171
+ for (const ref of baseDraft.references) {
172
+ const normalizedRef = ref.ref.replace(/\\/g, "/");
173
+ if (!allowedRefs.has(normalizedRef)) {
174
+ sourceIntegrity.issues.push({
175
+ code: "REFERENCE_NOT_IN_MANIFEST",
176
+ level: "blocking",
177
+ message: `draft reference not found in source-manifest: ${ref.ref}`,
178
+ path: ref.ref,
179
+ });
180
+ sourceIntegrity.ok = false;
181
+ }
182
+ }
183
+ }
184
+ }
185
+ else {
186
+ const importedFactParts = [];
187
+ let supplementalFacts;
188
+ if (input.intent.useImportedPrd) {
189
+ sourceIntegrity = await validateImportedPrdReferences({
190
+ repoRoot,
191
+ taskId,
192
+ requireParseableRequirement: false,
193
+ });
194
+ hasImportedPrd = true;
195
+ if (sourceIntegrity.ok) {
196
+ references = draftReferencesFromManifest(sourceIntegrity.documents);
197
+ let acStart = 1;
198
+ for (const doc of sourceIntegrity.parseableDocuments) {
199
+ if (!doc.content)
200
+ continue;
201
+ const extracted = extractRequirementFactsFromMarkdown(doc.content, {
202
+ sourceLabel: doc.materializedPath,
203
+ acCounterStart: acStart,
204
+ });
205
+ acStart += extracted.acceptanceCriteria.filter((ac) => /^AC-\d+$/i.test(ac.id)).length;
206
+ // Better: count all auto ids; keep simple sequential across docs.
207
+ const maxAuto = extracted.acceptanceCriteria.reduce((max, ac) => {
208
+ const m = /^AC-(\d+)$/i.exec(ac.id);
209
+ if (!m)
210
+ return max;
211
+ return Math.max(max, Number(m[1]));
212
+ }, acStart - 1);
213
+ acStart = maxAuto + 1;
214
+ importedFactParts.push(extracted);
215
+ }
216
+ }
217
+ }
218
+ if (input.intent.text) {
219
+ if (Buffer.byteLength(input.intent.text, "utf-8") > PREPARE_MAX_FILE_BYTES) {
220
+ return {
221
+ ok: false,
222
+ outcome: "invalid",
223
+ mode: input.mode,
224
+ taskId,
225
+ gaps: [
226
+ {
227
+ code: "INPUT_TOO_LARGE",
228
+ level: "blocking",
229
+ message: `text input exceeds ${PREPARE_MAX_FILE_BYTES} bytes`,
230
+ },
231
+ ],
232
+ risks: [],
233
+ next: [],
234
+ error: {
235
+ code: "INPUT_TOO_LARGE",
236
+ message: `text input exceeds ${PREPARE_MAX_FILE_BYTES} bytes`,
237
+ },
238
+ };
239
+ }
240
+ supplementalFacts = extractRequirementFactsFromMarkdown(input.intent.text, {
241
+ sourceLabel: input.intent.textLabel ?? "supplemental-text",
242
+ });
243
+ }
244
+ const importedFacts = importedFactParts.length > 0
245
+ ? mergeRequirementFacts(importedFactParts)
246
+ : undefined;
247
+ const mergedSupplemental = supplementalFacts
248
+ ? mergeRequirementFacts([supplementalFacts])
249
+ : undefined;
250
+ if (importedFacts && mergedSupplemental) {
251
+ facts = fillMissingRequirementFacts(importedFacts, mergedSupplemental);
252
+ }
253
+ else {
254
+ facts = importedFacts ?? mergedSupplemental;
255
+ }
256
+ }
257
+ const built = buildPrepareDraft({
258
+ taskId,
259
+ existingTaskConfig,
260
+ baseDraft,
261
+ facts,
262
+ flags: input.flags,
263
+ references,
264
+ });
265
+ const { gaps, risks } = listPrepareGaps({
266
+ draft: built.draft,
267
+ state,
268
+ sourceIntegrity,
269
+ pathAssessment: built.pathAssessment,
270
+ hasImportedPrd,
271
+ acceptanceConflicts: built.acceptanceConflicts,
272
+ sourceFilesPresent,
273
+ forceOverwriteSource: input.forceOverwriteSource,
274
+ });
275
+ // If --use-imported-prd and no parseable requirement and no AC from flags/text → blocking
276
+ if (input.intent.kind === "facts" &&
277
+ input.intent.useImportedPrd &&
278
+ sourceIntegrity &&
279
+ !sourceIntegrity.parseableDocuments.some((d) => d.role === "requirement") &&
280
+ built.draft.requirement.acceptanceCriteria.length === 0 &&
281
+ !input.flags.acceptanceCriteria?.length &&
282
+ !input.intent.text) {
283
+ gaps.push({
284
+ code: "NO_PARSEABLE_REQUIREMENT",
285
+ level: "blocking",
286
+ message: "no parseable requirement document and no --ac / supplemental text; import a requirement-role PRD or pass flags",
287
+ });
288
+ }
289
+ const validation = await validateDraftForTask({
290
+ repoRoot,
291
+ taskId,
292
+ draft: built.draft,
293
+ });
294
+ if (!validation.ok) {
295
+ for (const message of validation.errors) {
296
+ gaps.push({
297
+ code: "DRAFT_VALIDATION",
298
+ level: "blocking",
299
+ message,
300
+ });
301
+ }
302
+ }
303
+ const requirementPreview = projectRequirementMarkdown(built.draft);
304
+ const constraintsPreview = projectConstraintsMarkdown(built.draft);
305
+ const projected = {
306
+ requirementPath: "source/需求.md",
307
+ constraintsPath: "source/执行约束.md",
308
+ requirementPreview,
309
+ constraintsPreview,
310
+ };
311
+ const blocking = hasBlockingGaps(gaps) ||
312
+ hasBlockingRisks(risks) ||
313
+ !validation.ok;
314
+ const warnings = gaps
315
+ .filter((g) => g.level === "warning")
316
+ .map((g) => ({ code: g.code, message: g.message }));
317
+ const next = buildNextSteps({
318
+ taskId,
319
+ hasImportedPrd,
320
+ mode: input.mode,
321
+ ok: !blocking,
322
+ gaps,
323
+ });
324
+ if (input.mode !== "apply") {
325
+ return {
326
+ ok: !blocking,
327
+ outcome: blocking ? "invalid" : "succeeded",
328
+ mode: "dry-run",
329
+ taskId,
330
+ taskKind: built.draft.taskKind,
331
+ title: built.draft.title,
332
+ draft: built.draft,
333
+ gaps,
334
+ risks,
335
+ projected,
336
+ next,
337
+ contractStatus: state.effectiveStatus,
338
+ warnings,
339
+ ...(blocking
340
+ ? {
341
+ error: {
342
+ code: "INVALID_INPUT",
343
+ message: "task source draft has blocking gaps",
344
+ details: {
345
+ gaps: gaps
346
+ .filter((g) => g.level === "blocking")
347
+ .map((g) => g.code),
348
+ },
349
+ },
350
+ }
351
+ : {}),
352
+ };
353
+ }
354
+ // apply path — prioritize state-machine outcomes over generic invalid gates
355
+ if (state.effectiveStatus === "transaction-incomplete" ||
356
+ gaps.some((g) => g.code === "TRANSACTION_INCOMPLETE")) {
357
+ return {
358
+ ok: false,
359
+ outcome: "needs-reconcile",
360
+ mode: "apply",
361
+ taskId,
362
+ taskKind: built.draft.taskKind,
363
+ title: built.draft.title,
364
+ draft: built.draft,
365
+ gaps,
366
+ risks,
367
+ projected,
368
+ next: [`loop-agent task contract recover --task ${taskId}`],
369
+ contractStatus: state.effectiveStatus,
370
+ warnings,
371
+ error: {
372
+ code: "TRANSACTION_INCOMPLETE",
373
+ message: "unfinished transaction; run task contract recover first",
374
+ },
375
+ };
376
+ }
377
+ if (blocking) {
378
+ const blockingCodes = gaps
379
+ .filter((g) => g.level === "blocking")
380
+ .map((g) => g.code);
381
+ const outcome = blockingCodes.includes("DIRTY_SOURCE")
382
+ ? "blocked"
383
+ : "invalid";
384
+ return {
385
+ ok: false,
386
+ outcome,
387
+ mode: "apply",
388
+ taskId,
389
+ taskKind: built.draft.taskKind,
390
+ title: built.draft.title,
391
+ draft: built.draft,
392
+ gaps,
393
+ risks,
394
+ projected,
395
+ next,
396
+ contractStatus: state.effectiveStatus,
397
+ warnings,
398
+ error: {
399
+ code: outcome === "blocked" ? "DIRTY_SOURCE" : "INVALID_INPUT",
400
+ message: "task source draft has blocking gaps",
401
+ details: {
402
+ gaps: blockingCodes,
403
+ },
404
+ },
405
+ };
406
+ }
407
+ const expectedRevision = logicalRevisionForState(state);
408
+ const expectedObservedHash = state.observedCanonicalHash ??
409
+ (expectedRevision === 0 ? emptyObservedHashPlaceholder() : null);
410
+ if (!expectedObservedHash) {
411
+ return {
412
+ ok: false,
413
+ outcome: "conflict",
414
+ mode: "apply",
415
+ taskId,
416
+ draft: built.draft,
417
+ gaps,
418
+ risks,
419
+ projected,
420
+ next: [`loop-agent task contract doctor --task ${taskId}`],
421
+ error: {
422
+ code: "OBSERVED_HASH_CONFLICT",
423
+ message: "unable to determine expectedObservedHash",
424
+ },
425
+ };
426
+ }
427
+ // Dirty without force already blocked above. Fresh legacy is allowed.
428
+ void isFreshLegacyUnversioned;
429
+ const requestId = input.requestId?.trim() || `prepare-${taskId}-${randomUUID()}`;
430
+ const requestPayloadSha256 = sha256OfCanonicalJson(built.draft);
431
+ try {
432
+ const applyResult = await applyTaskContract({
433
+ repoRoot,
434
+ taskId,
435
+ draft: built.draft,
436
+ expectedRevision,
437
+ expectedObservedHash,
438
+ requestId,
439
+ requestPayloadSha256,
440
+ payloadSha256Trusted: true,
441
+ });
442
+ return {
443
+ ok: true,
444
+ outcome: "succeeded",
445
+ mode: "apply",
446
+ taskId,
447
+ taskKind: built.draft.taskKind,
448
+ title: built.draft.title,
449
+ draft: built.draft,
450
+ gaps,
451
+ risks,
452
+ projected,
453
+ apply: applyResult,
454
+ next: buildNextSteps({
455
+ taskId,
456
+ hasImportedPrd,
457
+ mode: "apply",
458
+ ok: true,
459
+ gaps,
460
+ }),
461
+ contractStatus: "managed",
462
+ warnings,
463
+ };
464
+ }
465
+ catch (error) {
466
+ if (error instanceof ContractMutationError) {
467
+ const outcome = mutationOutcome(error.code);
468
+ return {
469
+ ok: false,
470
+ outcome,
471
+ mode: "apply",
472
+ taskId,
473
+ taskKind: built.draft.taskKind,
474
+ title: built.draft.title,
475
+ draft: built.draft,
476
+ gaps,
477
+ risks,
478
+ projected,
479
+ next: [
480
+ `loop-agent task contract show ${taskId} --json`,
481
+ `loop-agent task contract doctor --task ${taskId}`,
482
+ ],
483
+ error: {
484
+ code: error.code,
485
+ message: error.message,
486
+ details: error.details,
487
+ },
488
+ };
489
+ }
490
+ return {
491
+ ok: false,
492
+ outcome: "rejected",
493
+ mode: "apply",
494
+ taskId,
495
+ draft: built.draft,
496
+ gaps,
497
+ risks,
498
+ projected,
499
+ next: [],
500
+ error: {
501
+ code: "INTERNAL_ERROR",
502
+ message: error instanceof Error ? error.message : String(error),
503
+ },
504
+ };
505
+ }
506
+ }