@tea-agent/loop-agent 0.28.8 → 0.28.9

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.
@@ -457,6 +457,11 @@ export async function executeSingleSdkAttempt(options) {
457
457
  ...(requireWriterCustomTools ? { requireWriterCustomTools: true } : {}),
458
458
  });
459
459
  let resolveStall;
460
+ let resolveSettlement;
461
+ let settled = false;
462
+ const settlementPromise = new Promise((resolve) => {
463
+ resolveSettlement = resolve;
464
+ });
460
465
  const stallPromise = stallTimeoutMs > 0
461
466
  ? new Promise((resolve) => {
462
467
  resolveStall = resolve;
@@ -470,6 +475,10 @@ export async function executeSingleSdkAttempt(options) {
470
475
  stallHandle = setTimeout(() => resolveStall?.("stall"), stallTimeoutMs);
471
476
  };
472
477
  unsubscribe = session.subscribe((event) => {
478
+ if (event.type === "agent_settled" && !settled) {
479
+ settled = true;
480
+ resolveSettlement?.("settled");
481
+ }
473
482
  // Every real SDK event proves transport activity, including noisy deltas
474
483
  // intentionally excluded from persisted JSONL and meaningful DAG progress.
475
484
  armStallWatchdog();
@@ -502,6 +511,9 @@ export async function executeSingleSdkAttempt(options) {
502
511
  ? `${filePrefix}\n${options.userMessage}`
503
512
  : options.userMessage;
504
513
  const promptPromise = session.prompt(promptMessage);
514
+ // A settlement event is permitted to win the race while prompt() remains
515
+ // pending. Observe a later rejection so it never becomes unhandled.
516
+ void promptPromise.catch(() => { });
505
517
  armStallWatchdog();
506
518
  const timeoutPromise = timeoutMs > 0
507
519
  ? new Promise((resolve) => {
@@ -512,10 +524,11 @@ export async function executeSingleSdkAttempt(options) {
512
524
  : null;
513
525
  const raced = await Promise.race([
514
526
  promptPromise.then(() => "done"),
527
+ settlementPromise,
515
528
  ...(timeoutPromise ? [timeoutPromise] : []),
516
529
  ...(stallPromise ? [stallPromise] : []),
517
530
  ]);
518
- if (raced !== "done") {
531
+ if (raced !== "done" && raced !== "settled") {
519
532
  timedOut = true;
520
533
  appendStderr(raced === "stall"
521
534
  ? `pi SDK step stalled after ${stallTimeoutMs}ms with no provider activity`
@@ -35,6 +35,7 @@ import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
35
35
  import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
36
36
  import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
37
37
  import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
38
+ import { parseAndValidateFinalWriteSetApproval, } from "../workflows/dag/node-execution.js";
38
39
  import { buildShellProcessEnv } from "./shell-verification.js";
39
40
  import { readRunState } from "../workflows/dag/run-store.js";
40
41
  import { resolveDagTaskSourcePath } from "../task/dag-source-paths.js";
@@ -42,26 +43,25 @@ import { readProjectGovernanceContext } from "../workflows/dag/project-governanc
42
43
  import { assertMavenPlanFresh, MavenPlanStaleError, } from "../verification/maven/index.js";
43
44
  /** In-memory same-run success cache. Never shared across runIds. */
44
45
  const sameRunShellCommandCaches = new Map();
45
- function shellCommandCacheKey(runId, nodeId) {
46
- return `${runId}::${nodeId}`;
46
+ function shellCommandCacheKey(runId, workspaceFingerprint, contractKey) {
47
+ return `${runId}::${workspaceFingerprint}::${contractKey}`;
47
48
  }
48
49
  function buildShellCommandContractKey(input) {
50
+ const environment = Object.fromEntries((input.envAllowlist ?? []).sort().map((name) => [name, process.env[name] ?? null]));
49
51
  return JSON.stringify({
50
- commands: input.commands,
51
52
  cwd: input.cwd,
52
53
  timeoutMs: input.timeoutMs,
53
- envAllowlist: input.envAllowlist ?? [],
54
+ environment,
54
55
  failFast: input.failFast,
55
56
  nonZeroExitPolicy: input.nonZeroExitPolicy ?? "fail",
57
+ controllerInput: input.controllerInput ?? null,
56
58
  });
57
59
  }
58
60
  export function clearSameRunShellCommandCachesForTests() {
59
61
  sameRunShellCommandCaches.clear();
60
62
  }
61
63
  function shouldReuseSuccessfulShellCommands(shell) {
62
- // AC-005 targets supervised hard verify; also allow any shell node that
63
- // carries final verifyEvidence so intermediate gates stay fail-closed fresh.
64
- return shell.verifyEvidence?.phase === "final";
64
+ return shell.verifyEvidence?.phase === "intermediate" || shell.verifyEvidence?.phase === "final";
65
65
  }
66
66
  const DEFAULT_SHELL_TIMEOUT_MS = 300_000;
67
67
  const SUMMARY_STDOUT_MAX = 4_000;
@@ -236,6 +236,15 @@ export function resolveShellCommands(shell) {
236
236
  export async function executeShellCommand(input) {
237
237
  return new Promise((resolve) => {
238
238
  const startedAt = Date.now();
239
+ const startedAtIso = new Date(startedAt).toISOString();
240
+ let lastOutputActivityAt = 0;
241
+ const reportOutputActivity = () => {
242
+ const now = Date.now();
243
+ if (now - lastOutputActivityAt < 500)
244
+ return;
245
+ lastOutputActivityAt = now;
246
+ input.reportActivity?.({ attempt: 1, kind: "output", at: new Date(now).toISOString() });
247
+ };
239
248
  const injectedEnv = input.dagRunMeta
240
249
  ? {
241
250
  // Git Bash treats backslashes as escape characters inside double
@@ -271,6 +280,9 @@ export async function executeShellCommand(input) {
271
280
  command: input.command,
272
281
  cwd: input.cwd,
273
282
  durationMs: Date.now() - startedAt,
283
+ wallDurationMs: Date.now() - startedAt,
284
+ startedAt: startedAtIso,
285
+ finishedAt: new Date().toISOString(),
274
286
  stdout: formatBoundedOutput(stdout),
275
287
  stdoutBytes: stdout.bytes,
276
288
  stdoutTruncated: stdout.truncated,
@@ -293,10 +305,12 @@ export async function executeShellCommand(input) {
293
305
  child.stdout.on("data", (chunk) => {
294
306
  stdout = appendBoundedOutput(stdout, chunk);
295
307
  appendOutputArtifact(input.outputArtifacts?.stdoutPath, chunk);
308
+ reportOutputActivity();
296
309
  });
297
310
  child.stderr.on("data", (chunk) => {
298
311
  stderr = appendBoundedOutput(stderr, chunk);
299
312
  appendOutputArtifact(input.outputArtifacts?.stderrPath, chunk);
313
+ reportOutputActivity();
300
314
  });
301
315
  child.on("error", (error) => {
302
316
  stderr = appendBoundedOutput(stderr, `${stderr.text ? "\n" : ""}${error.message}`);
@@ -387,6 +401,17 @@ function summarizeCommandResults(results) {
387
401
  stderr: truncateOutput(stderr, NODE_STDERR_MAX).text,
388
402
  };
389
403
  }
404
+ function parseCheckRepoSubcheckDurations(result) {
405
+ if (!/scripts\/check-repo\.sh/.test(result.command))
406
+ return [];
407
+ const durations = [];
408
+ for (const line of `${result.stdout}\n${result.stderr}`.split(/\r?\n/)) {
409
+ const match = /^CHECK_REPO_SUBCHECK_END name=(\S+) status=(success|failed) durationMs=(\d+)$/.exec(line.trim());
410
+ if (match)
411
+ durations.push({ name: match[1], status: match[2], durationMs: Number(match[3]) });
412
+ }
413
+ return durations;
414
+ }
390
415
  export function buildShellResultSummaryMarkdown(input) {
391
416
  const lines = [
392
417
  "# Shell execution summary",
@@ -402,11 +427,11 @@ export function buildShellResultSummaryMarkdown(input) {
402
427
  "",
403
428
  "## Commands",
404
429
  "",
405
- "| # | ok | exitCode | failureCategory | durationMs | cwd | command |",
406
- "|---|----|----------|-----------------|------------|-----|---------|",
430
+ "| # | ok | exitCode | failureCategory | startedAt | finishedAt | wallDurationMs | executionDurationMs | reused | cwd | command |",
431
+ "|---|----|----------|-----------------|-----------|------------|----------------|---------------------|--------|-----|---------|",
407
432
  ];
408
433
  input.results.forEach((result, index) => {
409
- lines.push(`| ${index + 1} | ${result.ok} | ${result.exitCode ?? "null"} | ${result.failureCategory} | ${result.durationMs} | ${result.cwd} | ${result.command.replace(/\|/g, "\\|")} |`);
434
+ lines.push(`| ${index + 1} | ${result.ok} | ${result.exitCode ?? "null"} | ${result.failureCategory} | ${result.startedAt ?? "(unknown)"} | ${result.finishedAt ?? "(unknown)"} | ${result.wallDurationMs ?? result.durationMs} | ${result.executionDurationMs ?? result.durationMs} | ${result.reused === true} | ${result.cwd} | ${result.command.replace(/\|/g, "\\|")} |`);
410
435
  });
411
436
  if (input.writeGuardViolations && input.writeGuardViolations.length > 0) {
412
437
  lines.push("", "## Write guard violations", "");
@@ -1299,6 +1324,7 @@ async function executePipelineCommands(input, meta, overrideCommands) {
1299
1324
  timeoutMs: shell.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS,
1300
1325
  envAllowlist: shell.envAllowlist,
1301
1326
  dagRunMeta: { runDir: meta.runDir, runId: meta.runId },
1327
+ reportActivity: input.reportActivity,
1302
1328
  outputArtifacts: {
1303
1329
  stdoutPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stdout.txt`),
1304
1330
  stderrPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stderr.txt`),
@@ -1332,6 +1358,7 @@ async function executeFrontendVerificationBundle(input, meta) {
1332
1358
  timeoutMs: shell.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS,
1333
1359
  envAllowlist: shell.envAllowlist,
1334
1360
  dagRunMeta: { runDir: meta.runDir, runId: meta.runId },
1361
+ reportActivity: input.reportActivity,
1335
1362
  outputArtifacts: {
1336
1363
  stdoutPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stdout.txt`),
1337
1364
  stderrPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stderr.txt`),
@@ -1382,6 +1409,7 @@ async function executeFrontendVerificationBundle(input, meta) {
1382
1409
  timeoutMs: shell.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS,
1383
1410
  envAllowlist: shell.envAllowlist,
1384
1411
  dagRunMeta: { runDir: meta.runDir, runId: meta.runId },
1412
+ reportActivity: input.reportActivity,
1385
1413
  outputArtifacts: {
1386
1414
  stdoutPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stdout.txt`),
1387
1415
  stderrPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stderr.txt`),
@@ -1727,6 +1755,43 @@ async function executeFrontendLintBaseline(input, meta) {
1727
1755
  }
1728
1756
  export async function executeDagShellNode(input, meta) {
1729
1757
  const shell = input.task.shell;
1758
+ if (shell?.finalWriteSetApprovalGate) {
1759
+ const started = Date.now();
1760
+ try {
1761
+ const state = await readRunState(meta.runDir);
1762
+ const writer = meta.spec.tasks.find((task) => task.id === shell.finalWriteSetApprovalGate.writerNodeId);
1763
+ if (!writer)
1764
+ throw new Error("final write-set approval gate writer is missing");
1765
+ const authorization = parseAndValidateFinalWriteSetApproval({
1766
+ task: writer,
1767
+ spec: meta.spec,
1768
+ state,
1769
+ });
1770
+ if (!authorization.ok)
1771
+ throw new Error(authorization.reason);
1772
+ return {
1773
+ ok: true,
1774
+ stdout: JSON.stringify({
1775
+ status: "approved",
1776
+ writerNodeId: writer.id,
1777
+ approvalDigest: authorization.approvalDigest,
1778
+ effectiveWriteSet: authorization.effectiveWriteSet,
1779
+ }),
1780
+ stderr: "",
1781
+ failureCategory: "success",
1782
+ durationMs: Date.now() - started,
1783
+ };
1784
+ }
1785
+ catch (error) {
1786
+ return {
1787
+ ok: false,
1788
+ stdout: "",
1789
+ stderr: `final-write-set-approval-invalid: ${error instanceof Error ? error.message : String(error)}`,
1790
+ failureCategory: "invalid-output",
1791
+ durationMs: Date.now() - started,
1792
+ };
1793
+ }
1794
+ }
1730
1795
  if (shell?.frontendBrowserToolPreflight) {
1731
1796
  const started = Date.now();
1732
1797
  try {
@@ -2160,6 +2225,9 @@ export async function executeDagShellNode(input, meta) {
2160
2225
  command,
2161
2226
  cwd: input.cwd,
2162
2227
  durationMs: 0,
2228
+ wallDurationMs: 0,
2229
+ startedAt: new Date().toISOString(),
2230
+ finishedAt: new Date().toISOString(),
2163
2231
  exitCode: null,
2164
2232
  failureCategory: "verification-plan-stale",
2165
2233
  ok: false,
@@ -2198,6 +2266,7 @@ export async function executeDagShellNode(input, meta) {
2198
2266
  const timeoutMs = shell.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS;
2199
2267
  const failFast = shell.failFast !== false;
2200
2268
  const results = [];
2269
+ const executedCommandKeys = new Set();
2201
2270
  let beforeStatus;
2202
2271
  let workspaceFingerprint;
2203
2272
  try {
@@ -2223,48 +2292,52 @@ export async function executeDagShellNode(input, meta) {
2223
2292
  workspaceFingerprint = undefined;
2224
2293
  }
2225
2294
  commandContractKey = buildShellCommandContractKey({
2226
- commands,
2227
2295
  cwd,
2228
2296
  timeoutMs,
2229
2297
  envAllowlist: shell.envAllowlist,
2230
2298
  failFast,
2231
2299
  nonZeroExitPolicy: shell.nonZeroExitPolicy,
2300
+ controllerInput: {
2301
+ sourceBinding: meta.spec.sourceBinding,
2302
+ taskContractBinding: meta.spec.taskContractBinding,
2303
+ runtimeContract: meta.spec.runtimeContract,
2304
+ },
2232
2305
  });
2233
- const cacheKey = shellCommandCacheKey(meta.runId, input.task.id);
2234
- const existing = sameRunShellCommandCaches.get(cacheKey);
2235
- if (existing &&
2236
- existing.runId === meta.runId &&
2237
- existing.nodeId === input.task.id &&
2238
- existing.commandContractKey === commandContractKey &&
2239
- workspaceFingerprint &&
2240
- existing.workspaceFingerprint === workspaceFingerprint) {
2306
+ const cacheKey = workspaceFingerprint
2307
+ ? shellCommandCacheKey(meta.runId, workspaceFingerprint, commandContractKey)
2308
+ : undefined;
2309
+ const existing = cacheKey ? sameRunShellCommandCaches.get(cacheKey) : undefined;
2310
+ if (existing && existing.runId === meta.runId && existing.contractKey === commandContractKey) {
2241
2311
  cache = existing;
2242
2312
  }
2243
- else {
2244
- sameRunShellCommandCaches.delete(cacheKey);
2245
- if (workspaceFingerprint) {
2246
- cache = {
2247
- runId: meta.runId,
2248
- nodeId: input.task.id,
2249
- commandContractKey,
2250
- workspaceFingerprint,
2251
- byCommand: new Map(),
2252
- };
2253
- sameRunShellCommandCaches.set(cacheKey, cache);
2254
- }
2313
+ else if (cacheKey && workspaceFingerprint) {
2314
+ cache = {
2315
+ runId: meta.runId,
2316
+ contractKey: commandContractKey,
2317
+ workspaceFingerprint,
2318
+ byCommand: new Map(),
2319
+ };
2320
+ sameRunShellCommandCaches.set(cacheKey, cache);
2255
2321
  }
2256
2322
  }
2257
2323
  for (const command of commands) {
2258
2324
  const commandNumber = results.length + 1;
2259
2325
  // Cache each contractual occurrence independently. A result written for an
2260
2326
  // earlier duplicate in this round must not satisfy a later occurrence.
2261
- const commandCacheKey = `${commandNumber}:${command}`;
2327
+ const commandCacheKey = command;
2262
2328
  const cached = cache?.byCommand.get(commandCacheKey);
2263
- if (cached) {
2329
+ if (cached && !executedCommandKeys.has(commandCacheKey)) {
2330
+ const reusedAt = new Date().toISOString();
2264
2331
  results.push({
2265
2332
  command,
2266
2333
  cwd: cached.cwd,
2267
- durationMs: cached.durationMs,
2334
+ durationMs: 0,
2335
+ wallDurationMs: 0,
2336
+ executionDurationMs: cached.durationMs,
2337
+ startedAt: reusedAt,
2338
+ finishedAt: reusedAt,
2339
+ sourceNodeId: cached.sourceNodeId,
2340
+ sourceReceiptNodeId: cached.sourceReceiptNodeId,
2268
2341
  exitCode: cached.exitCode,
2269
2342
  failureCategory: cached.failureCategory,
2270
2343
  ok: true,
@@ -2285,12 +2358,14 @@ export async function executeDagShellNode(input, meta) {
2285
2358
  timeoutMs,
2286
2359
  envAllowlist: shell.envAllowlist,
2287
2360
  dagRunMeta: { runDir: meta.runDir, runId: meta.runId },
2361
+ reportActivity: input.reportActivity,
2288
2362
  outputArtifacts: {
2289
2363
  stdoutPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stdout.txt`),
2290
2364
  stderrPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stderr.txt`),
2291
2365
  },
2292
2366
  });
2293
2367
  results.push(result);
2368
+ executedCommandKeys.add(commandCacheKey);
2294
2369
  if (result.ok && cache) {
2295
2370
  cache.byCommand.set(commandCacheKey, {
2296
2371
  ok: true,
@@ -2304,6 +2379,10 @@ export async function executeDagShellNode(input, meta) {
2304
2379
  stderrTruncated: result.stderrTruncated,
2305
2380
  timedOut: result.timedOut,
2306
2381
  durationMs: result.durationMs,
2382
+ startedAt: result.startedAt ?? new Date().toISOString(),
2383
+ finishedAt: result.finishedAt ?? new Date().toISOString(),
2384
+ sourceNodeId: input.task.id,
2385
+ sourceReceiptNodeId: input.task.id,
2307
2386
  cwd: result.cwd,
2308
2387
  });
2309
2388
  }
@@ -2330,8 +2409,8 @@ export async function executeDagShellNode(input, meta) {
2330
2409
  }
2331
2410
  }
2332
2411
  // Write-guard or workspace mutation invalidates same-run success reuse.
2333
- if (!writeGuardOk && reuseEligible) {
2334
- sameRunShellCommandCaches.delete(shellCommandCacheKey(meta.runId, input.task.id));
2412
+ if (!writeGuardOk && reuseEligible && workspaceFingerprint && commandContractKey) {
2413
+ sameRunShellCommandCaches.delete(shellCommandCacheKey(meta.runId, workspaceFingerprint, commandContractKey));
2335
2414
  }
2336
2415
  const commandsOk = results.every((result) => result.ok);
2337
2416
  const recordNonZero = shell.nonZeroExitPolicy === "record";
@@ -2348,7 +2427,15 @@ export async function executeDagShellNode(input, meta) {
2348
2427
  ok: result.ok,
2349
2428
  exitCode: result.exitCode,
2350
2429
  failureCategory: result.failureCategory,
2430
+ startedAt: result.startedAt,
2431
+ finishedAt: result.finishedAt,
2432
+ wallDurationMs: result.wallDurationMs,
2433
+ executionDurationMs: result.executionDurationMs ?? result.durationMs,
2351
2434
  reused: result.reused === true,
2435
+ reuseSource: result.reused
2436
+ ? { nodeId: result.sourceNodeId, receiptNodeId: result.sourceReceiptNodeId }
2437
+ : null,
2438
+ subchecks: parseCheckRepoSubcheckDurations(result),
2352
2439
  })),
2353
2440
  };
2354
2441
  await writeDagNodeJsonArtifact(meta.runDir, input.task.id, "shell-command-receipt.json", receipt);
@@ -1,18 +1,11 @@
1
1
  import { z } from "zod";
2
+ /** Compatibility type for Pi invocation internals; not a harness.json field. */
2
3
  export const stepModelConfigSchema = z.object({
3
4
  provider: z.string().optional(),
4
5
  model: z.string().optional(),
5
6
  thinking: z.string().optional(),
6
7
  timeoutMs: z.number().int().positive().optional(),
7
8
  });
8
- export const modelProfileSchema = stepModelConfigSchema.extend({
9
- fallback: stepModelConfigSchema.optional(),
10
- });
11
- export const complexityRouteSchema = z.object({
12
- small: z.string().optional(),
13
- medium: z.string().optional(),
14
- large: z.string().optional(),
15
- });
16
9
  export const worktreeManifestConfigSchema = z.object({
17
10
  rootRelativePath: z.string().optional().default(".worktrees"),
18
11
  });
@@ -21,10 +14,12 @@ export const CURSOR_TASK_EXECUTOR_REMOVED_ERROR = 'task executor "cursor" is no
21
14
  /** Per-tier model override: bare model id string, or `{ model, thinking? }`. */
22
15
  export const executorTierModelSchema = z.union([
23
16
  z.string(),
24
- z.object({
17
+ z
18
+ .object({
25
19
  model: z.string().min(1),
26
20
  thinking: z.string().optional(),
27
- }),
21
+ })
22
+ .strict(),
28
23
  ]);
29
24
  /** Normalize an executors.pi LOW|MED|HIGH value. */
30
25
  export function normalizeExecutorTierValue(value) {
@@ -46,20 +41,18 @@ export function normalizeExecutorTierValue(value) {
46
41
  ? { model, thinking }
47
42
  : { model };
48
43
  }
49
- export const executorManifestSchema = z.object({
44
+ export const executorManifestSchema = z
45
+ .object({
50
46
  description: z.string().optional(),
51
47
  enabled: z.boolean().optional(),
48
+ /** Compatibility fallback for old projects; fresh init writes LOW/MED/HIGH instead. */
52
49
  defaultModel: z.string().optional().default("default"),
53
- /**
54
- * Per-complexity DAG model overrides. When set (and not the "default" sentinel),
55
- * these take priority over defaultModel for the matching DAG executor tier.
56
- * "default" literal and absent/undefined both mean "no override, fall through".
57
- */
50
+ /** Per-complexity overrides take priority over defaultModel. */
58
51
  LOW: executorTierModelSchema.optional(),
59
52
  MED: executorTierModelSchema.optional(),
60
53
  HIGH: executorTierModelSchema.optional(),
61
- requiresApiKey: z.string().optional(),
62
- });
54
+ })
55
+ .strict();
63
56
  export const workflowPolicyProfileNameSchema = z.enum([
64
57
  "minimal",
65
58
  "standard",
@@ -192,6 +185,7 @@ export const workerPolicySchema = z.object({
192
185
  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)";
193
186
  export const harnessManifestSchema = z
194
187
  .object({
188
+ $schema: z.string().optional(),
195
189
  version: z.number(),
196
190
  project: z.string(),
197
191
  adapter: z.string().optional(),
@@ -207,48 +201,21 @@ export const harnessManifestSchema = z
207
201
  entrypoints: z.record(z.string(), z.string().nullable()),
208
202
  artifacts: z.record(z.string(), z.string().nullable()),
209
203
  scripts: z.record(z.string(), z.string().nullable()),
210
- models: z
211
- .object({
212
- analyze: stepModelConfigSchema.optional(),
213
- plan: stepModelConfigSchema.optional(),
214
- implement: stepModelConfigSchema.optional(),
215
- verify: stepModelConfigSchema.optional(),
216
- retrospective: stepModelConfigSchema.optional(),
217
- })
218
- .optional()
219
- .default({}),
220
- modelProfiles: z
221
- .record(z.string(), modelProfileSchema)
222
- .optional()
223
- .default({}),
224
- modelRouting: z
225
- .object({
226
- analyze: complexityRouteSchema.optional(),
227
- plan: complexityRouteSchema.optional(),
228
- implement: complexityRouteSchema.optional(),
229
- retrospective: complexityRouteSchema.optional(),
230
- implementRetry: z.string().optional(),
231
- })
232
- .optional()
233
- .default({}),
234
204
  workflowPolicy: workflowPolicySchema,
235
205
  /** Optional; absent means Night Scheduler uses package defaults when enabled by callers. */
236
206
  workerPolicy: workerPolicySchema.optional(),
237
207
  worktree: worktreeManifestConfigSchema.optional(),
238
208
  executors: z
239
- .record(z.string(), executorManifestSchema)
209
+ .object({ pi: executorManifestSchema.optional() })
210
+ .strict()
240
211
  .optional()
241
212
  .default({}),
242
213
  })
214
+ .strict()
243
215
  .superRefine((manifest, ctx) => {
244
- const executors = manifest.executors ?? {};
245
- if ("cursor" in executors) {
246
- ctx.addIssue({
247
- code: z.ZodIssueCode.custom,
248
- message: CURSOR_HARNESS_EXECUTOR_REMOVED_ERROR,
249
- path: ["executors", "cursor"],
250
- });
251
- }
216
+ // Preserve the empty read-only compatibility projection for callers that
217
+ // inspect parsed manifests, while rejecting this field in JSON input.
218
+ Object.assign(manifest, { modelProfiles: {} });
252
219
  const entrypoints = manifest.entrypoints ?? {};
253
220
  if ("cursorExecutorUsage" in entrypoints) {
254
221
  ctx.addIssue({
@@ -53,17 +53,18 @@ function resolveAcceptance(input) {
53
53
  return [];
54
54
  }
55
55
  function resolveAllowedPaths(input) {
56
+ const existingAllowed = input.baseDraft?.constraints.allowedPaths?.length
57
+ ? input.baseDraft.constraints.allowedPaths
58
+ : (input.existing?.allowedPaths ?? []);
59
+ const explicitAllowed = cleanStringList(input.flags.allowedPaths) ?? [];
60
+ // `--allowed-path` extends a managed boundary; it never replaces it.
56
61
  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) => {
62
+ ? [...existingAllowed, ...explicitAllowed]
63
+ : [...existingAllowed];
64
+ return dedupeStable(source.map((raw) => {
64
65
  const n = normalizePreparePath(raw);
65
66
  return n.ok ? n.value : raw.trim();
66
- });
67
+ }));
67
68
  }
68
69
  function resolveAllowedRoots(input) {
69
70
  const roots = input.flags.allowedRoots ?? input.baseDraft?.constraints.allowedRoots ?? input.existing?.allowedRoots;
@@ -213,7 +214,7 @@ export function buildPrepareDraft(input) {
213
214
  if (baseDraft?.sourceResolutions?.length) {
214
215
  draft.sourceResolutions = [...baseDraft.sourceResolutions];
215
216
  }
216
- if (baseDraft?.hardConstraints?.length && invariants.length === 0) {
217
+ if (baseDraft?.hardConstraints?.length) {
217
218
  draft.hardConstraints = [...baseDraft.hardConstraints];
218
219
  }
219
220
  return {
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { lstat } from "node:fs/promises";
2
+ import { lstat, readFile } from "node:fs/promises";
3
3
  import { ContractMutationError, applyTaskContract, logicalRevisionForState, observeTaskContractState, projectConstraintsMarkdown, projectRequirementMarkdown, sha256OfCanonicalJson, validateDraftForTask, } from "../contract/index.js";
4
+ import { TASK_CONTRACT_DRAFT_SCHEMA_VERSION } from "../contract/constants.js";
4
5
  import { getTaskContractPaths } from "../contract/paths.js";
5
6
  import { loadTaskConfig } from "../runtime.js";
6
7
  import { buildPrepareDraft } from "./build-draft.js";
@@ -26,6 +27,84 @@ async function loadExistingTaskConfig(repoRoot, taskId) {
26
27
  return null;
27
28
  }
28
29
  }
30
+ async function loadManagedProjectionDraft(input) {
31
+ const config = input.existingTaskConfig;
32
+ if (!config) {
33
+ return { ok: false, message: "managed task config could not be loaded" };
34
+ }
35
+ try {
36
+ const [requirement, constraints] = await Promise.all([
37
+ readFile(input.requirementPath, "utf-8"),
38
+ readFile(input.constraintsPath, "utf-8"),
39
+ ]);
40
+ const requirementFacts = extractRequirementFactsFromMarkdown(requirement);
41
+ const constraintFacts = extractRequirementFactsFromMarkdown(constraints);
42
+ if (/^##\s+(?:References|Source Resolutions)\s*$/im.test(requirement)) {
43
+ return {
44
+ ok: false,
45
+ message: "managed requirement projection contains unsupported References or Source Resolutions; use loop-agent task advance " +
46
+ input.taskId +
47
+ " --from-draft <reviewed-draft.json> --json",
48
+ };
49
+ }
50
+ if (!requirementFacts.objective?.trim()) {
51
+ return {
52
+ ok: false,
53
+ message: "managed requirement projection is missing objective",
54
+ };
55
+ }
56
+ if (requirementFacts.acceptanceCriteria.length === 0) {
57
+ return {
58
+ ok: false,
59
+ message: "managed requirement projection is missing acceptance criteria",
60
+ };
61
+ }
62
+ if (!config.allowedPaths.length || !config.forbiddenPaths.length) {
63
+ return {
64
+ ok: false,
65
+ message: "managed task config is missing path boundaries",
66
+ };
67
+ }
68
+ return {
69
+ ok: true,
70
+ draft: {
71
+ schemaVersion: TASK_CONTRACT_DRAFT_SCHEMA_VERSION,
72
+ taskId: input.taskId,
73
+ title: config.title,
74
+ taskKind: config.taskKind,
75
+ ...(config.featureId ? { featureId: config.featureId } : {}),
76
+ requirement: {
77
+ objective: requirementFacts.objective,
78
+ scope: requirementFacts.scope,
79
+ nonGoals: requirementFacts.nonGoals,
80
+ acceptanceCriteria: requirementFacts.acceptanceCriteria,
81
+ },
82
+ constraints: {
83
+ invariants: constraintFacts.invariants,
84
+ allowedPaths: config.allowedPaths,
85
+ forbiddenPaths: config.forbiddenPaths,
86
+ ...(config.allowedRoots !== undefined
87
+ ? { allowedRoots: config.allowedRoots }
88
+ : {}),
89
+ },
90
+ verification: { commands: config.verifyCommands },
91
+ ...(requirementFacts.openQuestions.length > 0
92
+ ? { openQuestions: requirementFacts.openQuestions }
93
+ : {}),
94
+ ...(requirementFacts.assumptions.length > 0
95
+ ? { assumptions: requirementFacts.assumptions }
96
+ : {}),
97
+ hardConstraints: config.hardConstraints,
98
+ },
99
+ };
100
+ }
101
+ catch (error) {
102
+ return {
103
+ ok: false,
104
+ message: `managed projection could not be reconstructed: ${error instanceof Error ? error.message : String(error)}`,
105
+ };
106
+ }
107
+ }
29
108
  function mutationOutcome(code) {
30
109
  switch (code) {
31
110
  case "REVISION_CONFLICT":
@@ -69,9 +148,8 @@ function buildNextSteps(input) {
69
148
  next.push(`loop-agent task advance ${input.taskId} --profile auto --json`);
70
149
  next.push("审查 DAG writeSet 后再 task advance --approve-gate ...");
71
150
  }
72
- if (input.gaps.some((g) => g.code === "DIRTY_SOURCE")) {
73
- next.push("loop-agent task status --json | task advance --from-draft ...");
74
- next.push("或显式 --force-overwrite-source 后重跑 prepare");
151
+ if (input.gaps.some((g) => g.code === "DIRTY_SOURCE" || g.code === "MANAGED_PROJECTION_INCOMPLETE")) {
152
+ next.push(`loop-agent task advance ${input.taskId} --from-draft <reviewed-draft.json> --json`);
75
153
  }
76
154
  if (input.gaps.some((g) => g.code === "TRANSACTION_INCOMPLETE")) {
77
155
  next.push(`loop-agent task advance ${input.taskId} --json`);
@@ -132,6 +210,24 @@ export async function prepareTaskSource(input) {
132
210
  }
133
211
  }
134
212
  let baseDraft;
213
+ let managedProjectionError;
214
+ if ((input.intent.kind === "facts" &&
215
+ !input.intent.text &&
216
+ (state.effectiveStatus === "managed" ||
217
+ Boolean(state.ref && state.ref.revision > 0)))) {
218
+ const projection = await loadManagedProjectionDraft({
219
+ taskId,
220
+ existingTaskConfig,
221
+ requirementPath: contractPaths.requirementPath,
222
+ constraintsPath: contractPaths.constraintsPath,
223
+ });
224
+ if (projection.ok) {
225
+ baseDraft = projection.draft;
226
+ }
227
+ else {
228
+ managedProjectionError = projection.message;
229
+ }
230
+ }
135
231
  let facts;
136
232
  let sourceIntegrity = null;
137
233
  let hasImportedPrd = false;
@@ -272,6 +368,13 @@ export async function prepareTaskSource(input) {
272
368
  sourceFilesPresent,
273
369
  forceOverwriteSource: input.forceOverwriteSource,
274
370
  });
371
+ if (managedProjectionError) {
372
+ gaps.push({
373
+ code: "MANAGED_PROJECTION_INCOMPLETE",
374
+ level: "blocking",
375
+ message: managedProjectionError,
376
+ });
377
+ }
275
378
  // If --use-imported-prd and no parseable requirement and no AC from flags/text → blocking
276
379
  if (input.intent.kind === "facts" &&
277
380
  input.intent.useImportedPrd &&