@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,468 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { stdin as stdinStream } from "node:process";
4
+ import { parseAcceptanceFlag, parseVerifyFlag, parseVerifyTimeoutFlag, prepareTaskSource, PREPARE_MAX_FILE_BYTES, } from "../task/source-prepare/index.js";
5
+ import { parseTaskContractDraft } from "../task/contract/schema.js";
6
+ import { buildOperatorResult, operatorFailed, processExitCodeForOutcome, writeOperatorJson, } from "../shared/operator/index.js";
7
+ const COMMAND = "task source prepare";
8
+ const USAGE = `usage:
9
+ task source prepare <task-id>
10
+ [--from-text <string>]
11
+ [--from-file <path>]
12
+ [--from-stdin]
13
+ [--from-draft <path-to-TaskContractDraftV1.json>]
14
+ [--use-imported-prd]
15
+ [--title <string>]
16
+ [--objective <text>]
17
+ [--scope <text>]...
18
+ [--task-kind <TaskKind>]
19
+ [--feature-id <string>]
20
+ [--allowed-path <glob>]...
21
+ [--forbidden-path <glob>]...
22
+ [--no-default-forbidden-paths]
23
+ [--invariant <text>]...
24
+ [--ac <id:text> | --acceptance <id:text>]...
25
+ [--non-goal <text>]...
26
+ [--assumption <text>]...
27
+ [--open-question <text>]...
28
+ [--verify <label:command>]...
29
+ [--verify-timeout <label:timeoutMs>]...
30
+ [--request-id <id>]
31
+ [--dry-run | --apply]
32
+ [--force-overwrite-source]
33
+ [--json]`;
34
+ function pushList(target, value) {
35
+ const list = target ?? [];
36
+ list.push(value);
37
+ return list;
38
+ }
39
+ export function parseTaskSourcePrepareArgs(args) {
40
+ const options = {};
41
+ const positional = [];
42
+ for (let i = 0; i < args.length; i += 1) {
43
+ const token = args[i];
44
+ const next = () => {
45
+ const value = args[i + 1];
46
+ if (value === undefined || value.startsWith("-")) {
47
+ throw new Error(`missing value for ${token}\n${USAGE}`);
48
+ }
49
+ i += 1;
50
+ return value;
51
+ };
52
+ if (token === "--json") {
53
+ options.json = true;
54
+ continue;
55
+ }
56
+ if (token === "--dry-run") {
57
+ options.dryRun = true;
58
+ continue;
59
+ }
60
+ if (token === "--apply") {
61
+ options.apply = true;
62
+ continue;
63
+ }
64
+ if (token === "--force-overwrite-source") {
65
+ options.forceOverwriteSource = true;
66
+ continue;
67
+ }
68
+ if (token === "--use-imported-prd") {
69
+ options.useImportedPrd = true;
70
+ continue;
71
+ }
72
+ if (token === "--from-stdin") {
73
+ options.fromStdin = true;
74
+ continue;
75
+ }
76
+ if (token === "--no-default-forbidden-paths") {
77
+ options.noDefaultForbiddenPaths = true;
78
+ continue;
79
+ }
80
+ if (token === "--from-text") {
81
+ options.fromText = next();
82
+ continue;
83
+ }
84
+ if (token === "--from-file") {
85
+ options.fromFile = next();
86
+ continue;
87
+ }
88
+ if (token === "--from-draft") {
89
+ options.fromDraft = next();
90
+ continue;
91
+ }
92
+ if (token === "--title") {
93
+ options.title = next();
94
+ continue;
95
+ }
96
+ if (token === "--objective") {
97
+ options.objective = next();
98
+ continue;
99
+ }
100
+ if (token === "--scope") {
101
+ options.scope = pushList(options.scope, next());
102
+ continue;
103
+ }
104
+ if (token === "--task-kind") {
105
+ options.taskKind = next();
106
+ continue;
107
+ }
108
+ if (token === "--feature-id") {
109
+ options.featureId = next();
110
+ continue;
111
+ }
112
+ if (token === "--allowed-path") {
113
+ options.allowedPaths = pushList(options.allowedPaths, next());
114
+ continue;
115
+ }
116
+ if (token === "--forbidden-path") {
117
+ options.forbiddenPaths = pushList(options.forbiddenPaths, next());
118
+ continue;
119
+ }
120
+ if (token === "--invariant") {
121
+ options.invariants = pushList(options.invariants, next());
122
+ continue;
123
+ }
124
+ if (token === "--ac" || token === "--acceptance") {
125
+ const parsed = parseAcceptanceFlag(next());
126
+ options.acceptanceCriteria = [
127
+ ...(options.acceptanceCriteria ?? []),
128
+ parsed,
129
+ ];
130
+ continue;
131
+ }
132
+ if (token === "--non-goal") {
133
+ options.nonGoals = pushList(options.nonGoals, next());
134
+ continue;
135
+ }
136
+ if (token === "--assumption") {
137
+ options.assumptions = pushList(options.assumptions, next());
138
+ continue;
139
+ }
140
+ if (token === "--open-question") {
141
+ options.openQuestions = pushList(options.openQuestions, next());
142
+ continue;
143
+ }
144
+ if (token === "--verify") {
145
+ options.verify = pushList(options.verify, next());
146
+ continue;
147
+ }
148
+ if (token === "--verify-timeout") {
149
+ options.verifyTimeout = pushList(options.verifyTimeout, next());
150
+ continue;
151
+ }
152
+ if (token === "--request-id") {
153
+ options.requestId = next();
154
+ continue;
155
+ }
156
+ if (token.startsWith("-")) {
157
+ throw new Error(`unknown flag ${token}\n${USAGE}`);
158
+ }
159
+ positional.push(token);
160
+ }
161
+ const taskId = positional[0];
162
+ if (!taskId) {
163
+ throw new Error(USAGE);
164
+ }
165
+ if (positional.length > 1) {
166
+ throw new Error(`unexpected arguments: ${positional.slice(1).join(" ")}\n${USAGE}`);
167
+ }
168
+ return { taskId, options };
169
+ }
170
+ function validateOptionCombinations(options) {
171
+ if (options.dryRun && options.apply) {
172
+ throw new Error(`--dry-run and --apply are mutually exclusive\n${USAGE}`);
173
+ }
174
+ const textSources = [
175
+ options.fromText !== undefined,
176
+ options.fromFile !== undefined,
177
+ Boolean(options.fromStdin),
178
+ ].filter(Boolean).length;
179
+ if (textSources > 1) {
180
+ throw new Error(`--from-text / --from-file / --from-stdin are mutually exclusive\n${USAGE}`);
181
+ }
182
+ if (options.fromDraft) {
183
+ if (options.useImportedPrd ||
184
+ options.fromText !== undefined ||
185
+ options.fromFile !== undefined ||
186
+ options.fromStdin) {
187
+ throw new Error(`--from-draft is mutually exclusive with --use-imported-prd / --from-text / --from-file / --from-stdin\n${USAGE}`);
188
+ }
189
+ }
190
+ const hasIntentSource = Boolean(options.fromDraft) ||
191
+ Boolean(options.useImportedPrd) ||
192
+ options.fromText !== undefined ||
193
+ options.fromFile !== undefined ||
194
+ Boolean(options.fromStdin) ||
195
+ Boolean(options.title) ||
196
+ Boolean(options.objective) ||
197
+ Boolean(options.acceptanceCriteria?.length) ||
198
+ Boolean(options.allowedPaths?.length);
199
+ if (!hasIntentSource) {
200
+ throw new Error(`need at least one intent source: --use-imported-prd, --from-text/--from-file/--from-stdin, --from-draft, or sufficient flags (title/objective + --ac + --allowed-path)\n${USAGE}`);
201
+ }
202
+ }
203
+ async function readStdinText() {
204
+ if (stdinStream.isTTY) {
205
+ throw new Error("--from-stdin refused on TTY; pipe input or use --from-file/--from-text");
206
+ }
207
+ const chunks = [];
208
+ let total = 0;
209
+ for await (const chunk of stdinStream) {
210
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
211
+ total += buf.byteLength;
212
+ if (total > PREPARE_MAX_FILE_BYTES) {
213
+ throw new Error(`stdin input exceeds ${PREPARE_MAX_FILE_BYTES} bytes (INPUT_TOO_LARGE)`);
214
+ }
215
+ chunks.push(buf);
216
+ }
217
+ return Buffer.concat(chunks).toString("utf-8");
218
+ }
219
+ async function loadTextInput(repoRoot, options) {
220
+ if (options.fromText !== undefined) {
221
+ if (Buffer.byteLength(options.fromText, "utf-8") > PREPARE_MAX_FILE_BYTES) {
222
+ throw new Error(`--from-text exceeds ${PREPARE_MAX_FILE_BYTES} bytes (INPUT_TOO_LARGE)`);
223
+ }
224
+ return { text: options.fromText, label: "from-text" };
225
+ }
226
+ if (options.fromFile) {
227
+ const absolute = path.isAbsolute(options.fromFile)
228
+ ? options.fromFile
229
+ : path.resolve(repoRoot, options.fromFile);
230
+ const raw = await readFile(absolute);
231
+ if (raw.byteLength > PREPARE_MAX_FILE_BYTES) {
232
+ throw new Error(`--from-file exceeds ${PREPARE_MAX_FILE_BYTES} bytes (INPUT_TOO_LARGE)`);
233
+ }
234
+ return { text: raw.toString("utf-8"), label: options.fromFile };
235
+ }
236
+ if (options.fromStdin) {
237
+ const text = await readStdinText();
238
+ return { text, label: "stdin" };
239
+ }
240
+ return {};
241
+ }
242
+ async function loadDraftInput(repoRoot, draftPath) {
243
+ const absolute = path.isAbsolute(draftPath)
244
+ ? draftPath
245
+ : path.resolve(repoRoot, draftPath);
246
+ const raw = await readFile(absolute);
247
+ if (raw.byteLength > PREPARE_MAX_FILE_BYTES) {
248
+ throw new Error(`--from-draft exceeds ${PREPARE_MAX_FILE_BYTES} bytes (INPUT_TOO_LARGE)`);
249
+ }
250
+ let json;
251
+ try {
252
+ json = JSON.parse(raw.toString("utf-8"));
253
+ }
254
+ catch (error) {
255
+ throw new Error(`--from-draft is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
256
+ }
257
+ return parseTaskContractDraft(json);
258
+ }
259
+ function buildFlags(options) {
260
+ const flags = {
261
+ title: options.title,
262
+ objective: options.objective,
263
+ scope: options.scope,
264
+ taskKind: options.taskKind,
265
+ featureId: options.featureId,
266
+ allowedPaths: options.allowedPaths,
267
+ forbiddenPaths: options.forbiddenPaths,
268
+ noDefaultForbiddenPaths: options.noDefaultForbiddenPaths,
269
+ invariants: options.invariants,
270
+ acceptanceCriteria: options.acceptanceCriteria,
271
+ nonGoals: options.nonGoals,
272
+ assumptions: options.assumptions,
273
+ openQuestions: options.openQuestions,
274
+ };
275
+ if (options.verify || options.verifyTimeout) {
276
+ const timeouts = new Map();
277
+ for (const raw of options.verifyTimeout ?? []) {
278
+ const parsed = parseVerifyTimeoutFlag(raw);
279
+ timeouts.set(parsed.label, parsed.timeoutMs);
280
+ }
281
+ const commands = (options.verify ?? []).map((raw) => {
282
+ const parsed = parseVerifyFlag(raw);
283
+ const timeoutMs = timeouts.get(parsed.label);
284
+ return {
285
+ label: parsed.label,
286
+ command: parsed.command,
287
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
288
+ };
289
+ });
290
+ // If only timeouts provided without verify, treat as empty explicit list? No — only set when --verify appears.
291
+ if (options.verify) {
292
+ flags.verifyCommands = commands;
293
+ }
294
+ }
295
+ return flags;
296
+ }
297
+ function toOperatorEnvelope(result) {
298
+ const payload = {
299
+ taskId: result.taskId,
300
+ mode: result.mode,
301
+ taskKind: result.taskKind,
302
+ title: result.title,
303
+ draft: result.draft,
304
+ gaps: result.gaps,
305
+ risks: result.risks,
306
+ projected: result.projected,
307
+ apply: result.apply ?? null,
308
+ next: result.next,
309
+ contractStatus: result.contractStatus,
310
+ };
311
+ if (result.ok) {
312
+ return buildOperatorResult({
313
+ command: COMMAND,
314
+ ok: true,
315
+ outcome: result.outcome,
316
+ result: payload,
317
+ warnings: result.warnings ?? [],
318
+ });
319
+ }
320
+ return buildOperatorResult({
321
+ command: COMMAND,
322
+ ok: false,
323
+ outcome: result.outcome,
324
+ result: payload,
325
+ warnings: result.warnings ?? [],
326
+ error: result.error ?? {
327
+ code: "INVALID_INPUT",
328
+ message: "task source prepare failed",
329
+ },
330
+ });
331
+ }
332
+ function printHuman(result) {
333
+ const lines = [
334
+ `task source prepare: ${result.taskId}`,
335
+ ` mode: ${result.mode}`,
336
+ ` outcome: ${result.outcome}`,
337
+ ` ok: ${result.ok}`,
338
+ ];
339
+ if (result.title)
340
+ lines.push(` title: ${result.title}`);
341
+ if (result.taskKind)
342
+ lines.push(` taskKind: ${result.taskKind}`);
343
+ if (result.contractStatus) {
344
+ lines.push(` contractStatus: ${result.contractStatus}`);
345
+ }
346
+ if (result.gaps.length > 0) {
347
+ lines.push(" gaps:");
348
+ for (const gap of result.gaps) {
349
+ lines.push(` - [${gap.level}] ${gap.code}: ${gap.message}`);
350
+ }
351
+ }
352
+ if (result.risks.length > 0) {
353
+ lines.push(" risks:");
354
+ for (const risk of result.risks) {
355
+ lines.push(` - [${risk.level}] ${risk.code}: ${risk.message}`);
356
+ }
357
+ }
358
+ if (result.projected) {
359
+ lines.push(` projected: ${result.projected.requirementPath}, ${result.projected.constraintsPath}`);
360
+ if (result.draft) {
361
+ lines.push(` allowedPaths: ${result.draft.constraints.allowedPaths.join(", ") || "(none)"}`);
362
+ lines.push(` forbiddenPaths: ${result.draft.constraints.forbiddenPaths.join(", ") || "(none)"}`);
363
+ lines.push(` verifyCommands: ${result.draft.verification.commands
364
+ .map((c) => c.label)
365
+ .join(", ") || "(none)"}`);
366
+ }
367
+ }
368
+ if (result.apply) {
369
+ lines.push(` apply: revision=${result.apply.ref.revision} txId=${result.apply.txId}`);
370
+ }
371
+ if (result.error) {
372
+ lines.push(` error: ${result.error.code}: ${result.error.message}`);
373
+ }
374
+ if (result.next.length > 0) {
375
+ lines.push(" next:");
376
+ for (const step of result.next) {
377
+ lines.push(` - ${step}`);
378
+ }
379
+ }
380
+ console.log(lines.join("\n"));
381
+ }
382
+ export async function runTaskSourcePrepare(repoRoot, args) {
383
+ let taskId;
384
+ let options = {};
385
+ try {
386
+ ({ taskId, options } = parseTaskSourcePrepareArgs(args));
387
+ validateOptionCombinations(options);
388
+ }
389
+ catch (error) {
390
+ const message = error instanceof Error ? error.message : String(error);
391
+ const envelope = operatorFailed({
392
+ command: COMMAND,
393
+ outcome: "invalid",
394
+ code: "INVALID_INPUT",
395
+ message,
396
+ });
397
+ if (options.json || args.includes("--json")) {
398
+ writeOperatorJson(envelope);
399
+ }
400
+ else {
401
+ console.error(message);
402
+ }
403
+ process.exitCode = processExitCodeForOutcome(envelope.outcome);
404
+ return;
405
+ }
406
+ try {
407
+ const flags = buildFlags(options);
408
+ const mode = options.apply ? "apply" : "dry-run";
409
+ let prepareInput;
410
+ if (options.fromDraft) {
411
+ const draft = await loadDraftInput(repoRoot, options.fromDraft);
412
+ prepareInput = {
413
+ repoRoot,
414
+ taskId,
415
+ mode,
416
+ intent: { kind: "draft", draft, sourcePath: options.fromDraft },
417
+ flags,
418
+ requestId: options.requestId,
419
+ forceOverwriteSource: options.forceOverwriteSource,
420
+ };
421
+ }
422
+ else {
423
+ const textInput = await loadTextInput(repoRoot, options);
424
+ prepareInput = {
425
+ repoRoot,
426
+ taskId,
427
+ mode,
428
+ intent: {
429
+ kind: "facts",
430
+ useImportedPrd: Boolean(options.useImportedPrd),
431
+ text: textInput.text,
432
+ textLabel: textInput.label,
433
+ },
434
+ flags,
435
+ requestId: options.requestId,
436
+ forceOverwriteSource: options.forceOverwriteSource,
437
+ };
438
+ }
439
+ const result = await prepareTaskSource(prepareInput);
440
+ const envelope = toOperatorEnvelope(result);
441
+ if (options.json) {
442
+ writeOperatorJson(envelope);
443
+ }
444
+ else {
445
+ printHuman(result);
446
+ }
447
+ process.exitCode = processExitCodeForOutcome(result.outcome);
448
+ }
449
+ catch (error) {
450
+ const message = error instanceof Error ? error.message : String(error);
451
+ const code = message.includes("INPUT_TOO_LARGE")
452
+ ? "INPUT_TOO_LARGE"
453
+ : "INVALID_INPUT";
454
+ const envelope = operatorFailed({
455
+ command: COMMAND,
456
+ outcome: "invalid",
457
+ code,
458
+ message,
459
+ });
460
+ if (options.json) {
461
+ writeOperatorJson(envelope);
462
+ }
463
+ else {
464
+ console.error(message);
465
+ }
466
+ process.exitCode = processExitCodeForOutcome(envelope.outcome);
467
+ }
468
+ }
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { writeDagNodeJsonArtifact, writeTextArtifactFile, } from "../infrastructure/harness/artifact-store.js";
4
4
  import { executePiStep, } from "./pi-executor.js";
5
5
  import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
6
- import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
6
+ import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
7
7
  import { redactSecrets, truncateUtf8Preview } from "../shared/preview.js";
8
8
  export const DAG_PI_READONLY_TOOLS = ["read", "grep", "find", "ls"];
9
9
  export const DAG_PI_WRITE_TOOLS = [
@@ -169,14 +169,25 @@ export function buildDagPiUserMessage(task, persona, step) {
169
169
  "Do not wrap the output in code fences and do not add conversational preamble.",
170
170
  ].join(" ");
171
171
  }
172
- function resolveDagPiModelConfig(model) {
173
- const provider = DAG_PI_MODEL_PROVIDERS[model] ?? DEFAULT_DAG_PI_PROVIDER;
172
+ export function resolveDagPiModelConfig(modelReference, options) {
173
+ const separatorIndex = modelReference.indexOf("/");
174
+ const qualified = separatorIndex >= 0;
175
+ const provider = qualified
176
+ ? modelReference.slice(0, separatorIndex)
177
+ : (DAG_PI_MODEL_PROVIDERS[modelReference] ?? DEFAULT_DAG_PI_PROVIDER);
178
+ const model = qualified
179
+ ? modelReference.slice(separatorIndex + 1)
180
+ : modelReference;
181
+ if (!provider || !model) {
182
+ throw new Error(`invalid DAG Pi model reference "${modelReference}": expected non-empty provider/model`);
183
+ }
184
+ const explicitThinking = options?.thinking?.trim();
185
+ const thinking = explicitThinking ??
186
+ (provider === "wizard-local" && model === "gpt-5.5" ? "low" : undefined);
174
187
  return {
175
188
  provider,
176
189
  model,
177
- ...(provider === "wizard-local" && model === "gpt-5.5"
178
- ? { thinking: "low" }
179
- : {}),
190
+ ...(thinking ? { thinking } : {}),
180
191
  };
181
192
  }
182
193
  const SUMMARY_STDOUT_MAX = 4_000;
@@ -246,7 +257,10 @@ export async function writePiExecutorArtifacts(artifactsDir, input) {
246
257
  await writeTextArtifactFile(summaryPath, buildPiResultSummaryMarkdown(input));
247
258
  return { promptPath, summaryPath };
248
259
  }
249
- export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
260
+ const DEFAULT_DAG_PI_WRITE_GUARD_DEPENDENCIES = {
261
+ readGitStatusPorcelain,
262
+ };
263
+ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, writeGuardDependencies = DEFAULT_DAG_PI_WRITE_GUARD_DEPENDENCIES) {
250
264
  const started = Date.now();
251
265
  const persona = resolveDagPiPersona(input.task);
252
266
  const step = resolveDagPiStepName(input.task);
@@ -265,10 +279,19 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
265
279
  let beforePathFingerprints;
266
280
  if (isWriteTask) {
267
281
  try {
268
- beforeStatus = await readGitStatusPorcelain(input.cwd);
269
- beforePathFingerprints = await snapshotGitStatusPathFingerprints(input.cwd, snapshotGitStatusPorcelain(beforeStatus));
282
+ beforeStatus = await writeGuardDependencies.readGitStatusPorcelain(input.cwd, { phase: "pi-writer-before" });
283
+ const beforeSnapshot = snapshotGitStatusPorcelain(beforeStatus);
284
+ beforePathFingerprints = await snapshotGitStatusPathFingerprints(input.cwd, beforeSnapshot);
285
+ await persistWriterGitBaseline({
286
+ runDir: meta.runDir,
287
+ nodeId: input.task.id,
288
+ beforeStatus,
289
+ beforeSnapshot,
290
+ beforePathFingerprints,
291
+ });
270
292
  }
271
293
  catch (error) {
294
+ await persistGitWriteGuardDiagnostics(meta.runDir, input.task.id, error);
272
295
  return {
273
296
  ok: false,
274
297
  stdout: "",
@@ -291,7 +314,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
291
314
  : undefined;
292
315
  const result = await piStepFn({
293
316
  attachedFiles: [],
294
- modelConfig: resolveDagPiModelConfig(input.model),
317
+ modelConfig: resolveDagPiModelConfig(input.model, input.thinking ? { thinking: input.thinking } : undefined),
295
318
  prompt: input.prompt,
296
319
  repoRoot: input.cwd,
297
320
  step,
@@ -304,8 +327,8 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
304
327
  abortGraceMs: input.abortGraceMs,
305
328
  onActivity: bridgeActivity
306
329
  ? (activity) => {
307
- if (activity.kind === "lease"
308
- || activity.kind === "synthetic-heartbeat") {
330
+ if (activity.kind === "lease" ||
331
+ activity.kind === "synthetic-heartbeat") {
309
332
  return;
310
333
  }
311
334
  bridgeActivity(activity.kind, activity.at);
@@ -338,7 +361,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
338
361
  let changeManifestChangedFiles;
339
362
  if (beforeStatus !== undefined) {
340
363
  try {
341
- const afterStatus = await readGitStatusPorcelain(input.cwd);
364
+ const afterStatus = await writeGuardDependencies.readGitStatusPorcelain(input.cwd, { phase: "pi-writer-after" });
342
365
  const afterSnapshot = snapshotGitStatusPorcelain(afterStatus);
343
366
  const afterPathFingerprints = await snapshotGitStatusPathFingerprints(input.cwd, afterSnapshot);
344
367
  const changedFiles = pathsChangedDuringRun(snapshotGitStatusPorcelain(beforeStatus), afterSnapshot, beforePathFingerprints, afterPathFingerprints);
@@ -353,6 +376,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
353
376
  writeGuardViolations = guard.violations;
354
377
  }
355
378
  catch (error) {
379
+ await persistGitWriteGuardDiagnostics(meta.runDir, input.task.id, error);
356
380
  writeGuardOk = false;
357
381
  writeGuardViolations = [
358
382
  `git status unavailable: ${error instanceof Error ? error.message : String(error)}`,
@@ -372,7 +396,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
372
396
  }
373
397
  }
374
398
  }
375
- if (writeGuardOk && beforeStatus !== undefined && changeManifestChangedFiles !== undefined) {
399
+ if (writeGuardOk &&
400
+ beforeStatus !== undefined &&
401
+ changeManifestChangedFiles !== undefined) {
376
402
  await persistWriterChangeManifest({
377
403
  runDir: meta.runDir,
378
404
  nodeId: input.task.id,
@@ -445,12 +471,8 @@ function parseWriterImplementationOutcome(text) {
445
471
  const normalized = normalizeProtocolLine(line, WRITER_OUTCOME_PROTOCOL_LINE, nextLine);
446
472
  if (normalized === undefined)
447
473
  continue;
448
- const value = normalized
449
- .slice(WRITER_OUTCOME_PROTOCOL_LINE.length)
450
- .trim();
451
- const outcome = isWriterImplementationOutcome(value)
452
- ? value
453
- : undefined;
474
+ const value = normalized.slice(WRITER_OUTCOME_PROTOCOL_LINE.length).trim();
475
+ const outcome = isWriterImplementationOutcome(value) ? value : undefined;
454
476
  candidates.push({
455
477
  lineIndex,
456
478
  value,
@@ -474,9 +496,7 @@ function parseWriterImplementationOutcome(text) {
474
496
  };
475
497
  }
476
498
  function isWriterImplementationOutcome(value) {
477
- return (value === "changed" ||
478
- value === "already-satisfied" ||
479
- value === "blocked");
499
+ return (value === "changed" || value === "already-satisfied" || value === "blocked");
480
500
  }
481
501
  function writerOutcomeDiagnostics(text, parsed, changedFiles) {
482
502
  const firstNonEmpty = text
@@ -543,8 +563,7 @@ function canonicalizeProtocolFirstLine(assistantText, firstProtocolLine) {
543
563
  if (protocolNextLineIndex === protocolIndex + 1) {
544
564
  after.shift();
545
565
  }
546
- while (before.at(-1)?.trim() === "" &&
547
- after.at(0)?.trim() === "") {
566
+ while (before.at(-1)?.trim() === "" && after.at(0)?.trim() === "") {
548
567
  after.shift();
549
568
  }
550
569
  const bodyLines = [...before, ...after];
@@ -598,6 +617,28 @@ function stripMarkdownLineDecorations(line) {
598
617
  function escapeRegExp(value) {
599
618
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
600
619
  }
620
+ async function persistWriterGitBaseline(input) {
621
+ const fingerprintEntries = [...input.beforePathFingerprints.entries()].sort(([left], [right]) => left.localeCompare(right));
622
+ const baseline = {
623
+ schemaVersion: 1,
624
+ nodeId: input.nodeId,
625
+ phase: "pi-writer-before",
626
+ capturedAt: new Date().toISOString(),
627
+ statusSha256: createHash("sha256")
628
+ .update(input.beforeStatus)
629
+ .digest("hex"),
630
+ dirtyPaths: [...input.beforeSnapshot.keys()].sort(),
631
+ pathFingerprintsSha256: createHash("sha256")
632
+ .update(JSON.stringify(fingerprintEntries))
633
+ .digest("hex"),
634
+ };
635
+ await writeDagNodeJsonArtifact(input.runDir, input.nodeId, "write-guard-baseline.json", baseline);
636
+ }
637
+ async function persistGitWriteGuardDiagnostics(runDir, nodeId, error) {
638
+ if (!(error instanceof GitStatusUnavailableError))
639
+ return;
640
+ await writeDagNodeJsonArtifact(runDir, nodeId, "git-write-guard-diagnostics.json", error.diagnostics);
641
+ }
601
642
  /**
602
643
  * Validate the writer's observed diff against its declared write boundary.
603
644
  * Inlined mirror of runPostRunWriteGuard that reuses the already-computed diff