@tea-agent/loop-agent 0.28.7 → 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.
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
+ import { pathMatchesPattern } from "../../shared/git-progress.js";
5
6
  import { recordDecisionEnvelopeForNode, shouldPauseOnHumanEscalation, writeHumanEscalationArtifacts, } from "./decision-envelope.js";
6
7
  import { writeNodeRecord, writeNodeSkillArtifacts } from "./run-store.js";
7
8
  import { resolveContextPolicy } from "./context-policy.js";
@@ -18,6 +19,100 @@ import { parseRepairArtifactFromText, resolveRepairTaskForGate, validateRepairAr
18
19
  import { resolveModelForTask, } from "./types.js";
19
20
  import { loadHarnessManifest } from "../../governance/harness.js";
20
21
  import { resolveExecutorThinkingMatrix } from "../../executors/model-routing.js";
22
+ function canonicalApprovalJson(value) {
23
+ if (Array.isArray(value)) {
24
+ return `[${value.map((entry) => canonicalApprovalJson(entry)).join(",")}]`;
25
+ }
26
+ if (value && typeof value === "object") {
27
+ const record = value;
28
+ return `{${Object.keys(record)
29
+ .sort()
30
+ .map((key) => `${JSON.stringify(key)}:${canonicalApprovalJson(record[key])}`)
31
+ .join(",")}}`;
32
+ }
33
+ return JSON.stringify(value) ?? "null";
34
+ }
35
+ export function parseAndValidateFinalWriteSetApproval(input) {
36
+ const binding = input.task.finalWriteSetApproval;
37
+ if (!binding) {
38
+ return { ok: false, reason: "missing final write-set approval binding", approvalSourceNodeId: "(missing)" };
39
+ }
40
+ const source = input.state.nodes[binding.approvalSourceNodeId];
41
+ if (source?.status !== "FINISHED" || parseProcessVerdict(source) !== "pass") {
42
+ return {
43
+ ok: false,
44
+ reason: "final write-set approval source did not finish with VERDICT: pass",
45
+ approvalSourceNodeId: binding.approvalSourceNodeId,
46
+ };
47
+ }
48
+ const raw = canonicalNodeOutput(source);
49
+ const blocks = [...raw.matchAll(/```FINAL_WRITE_SET_APPROVAL_JSON\s*\r?\n([\s\S]*?)\r?\n```/g)];
50
+ if (blocks.length !== 1) {
51
+ return { ok: false, reason: `expected exactly one FINAL_WRITE_SET_APPROVAL_JSON block, found ${blocks.length}`, approvalSourceNodeId: binding.approvalSourceNodeId };
52
+ }
53
+ let approval;
54
+ try {
55
+ approval = JSON.parse(blocks[0][1]);
56
+ }
57
+ catch {
58
+ return { ok: false, reason: "final write-set approval is not valid JSON", approvalSourceNodeId: binding.approvalSourceNodeId };
59
+ }
60
+ const required = ["schemaVersion", "writerNodeId", "approvalSourceNodeId", "auditedPlanNodeId", "approvedWriteSet", "taskContractSha256", "auditedPlanSha256", "approvalDigest"];
61
+ if (Object.keys(approval).length !== required.length || required.some((key) => !(key in approval))) {
62
+ return { ok: false, reason: "final write-set approval has an invalid schema", approvalSourceNodeId: binding.approvalSourceNodeId };
63
+ }
64
+ const approved = approval.approvedWriteSet;
65
+ if (approval.schemaVersion !== 1 ||
66
+ approval.writerNodeId !== binding.writerNodeId ||
67
+ approval.writerNodeId !== input.task.id ||
68
+ approval.approvalSourceNodeId !== binding.approvalSourceNodeId ||
69
+ approval.auditedPlanNodeId !== binding.auditedPlanNodeId ||
70
+ !Array.isArray(approved) ||
71
+ !approved.every((entry) => typeof entry === "string") ||
72
+ typeof approval.taskContractSha256 !== "string" ||
73
+ typeof approval.auditedPlanSha256 !== "string" ||
74
+ typeof approval.approvalDigest !== "string") {
75
+ return { ok: false, reason: "final write-set approval binding or field types are invalid", approvalSourceNodeId: binding.approvalSourceNodeId };
76
+ }
77
+ if (!/^[a-f0-9]{64}$/.test(approval.taskContractSha256) ||
78
+ !/^[a-f0-9]{64}$/.test(approval.auditedPlanSha256) ||
79
+ !/^[a-f0-9]{64}$/.test(approval.approvalDigest)) {
80
+ return { ok: false, reason: "final write-set approval digest fields are invalid", approvalSourceNodeId: binding.approvalSourceNodeId };
81
+ }
82
+ const canonicalPayload = { ...approval };
83
+ delete canonicalPayload.approvalDigest;
84
+ const expectedDigest = createHash("sha256")
85
+ .update(canonicalApprovalJson(canonicalPayload))
86
+ .digest("hex");
87
+ if (approval.approvalDigest !== expectedDigest) {
88
+ return { ok: false, reason: "final write-set approval digest mismatch", approvalSourceNodeId: binding.approvalSourceNodeId };
89
+ }
90
+ const expectedTaskDigest = input.spec.taskContractBinding?.canonicalHash;
91
+ const plan = input.state.nodes[binding.auditedPlanNodeId];
92
+ const expectedPlanDigest = createHash("sha256")
93
+ .update(canonicalNodeOutput(plan))
94
+ .digest("hex");
95
+ if (!expectedTaskDigest || approval.taskContractSha256 !== expectedTaskDigest || approval.auditedPlanSha256 !== expectedPlanDigest) {
96
+ return { ok: false, reason: "final write-set approval is stale for the task contract or audited plan", approvalSourceNodeId: binding.approvalSourceNodeId };
97
+ }
98
+ const effectiveWriteSet = approved;
99
+ if (effectiveWriteSet.length === 0 || new Set(effectiveWriteSet).size !== effectiveWriteSet.length) {
100
+ return { ok: false, reason: "final write-set approval must contain a non-empty ordered unique path set", approvalSourceNodeId: binding.approvalSourceNodeId };
101
+ }
102
+ for (const entry of effectiveWriteSet) {
103
+ const normalized = entry.replace(/\\/g, "/").replace(/^\.\//, "");
104
+ if (!normalized || normalized === "." || normalized === ".." || normalized.includes("*") || normalized.includes("?") || normalized.includes("REPLACE/") || normalized.includes("PLACEHOLDER")) {
105
+ return { ok: false, reason: `final write-set approval contains a broad or placeholder path: ${entry}`, approvalSourceNodeId: binding.approvalSourceNodeId };
106
+ }
107
+ if (!input.task.allowedPaths.some((allowed) => pathMatchesPattern(normalized, allowed))) {
108
+ return { ok: false, reason: `final write-set approval exceeds allowedPaths: ${entry}`, approvalSourceNodeId: binding.approvalSourceNodeId };
109
+ }
110
+ if (input.task.forbiddenPaths.some((forbidden) => pathMatchesPattern(normalized, forbidden))) {
111
+ return { ok: false, reason: `final write-set approval overlaps forbiddenPaths: ${entry}`, approvalSourceNodeId: binding.approvalSourceNodeId };
112
+ }
113
+ }
114
+ return { ok: true, effectiveWriteSet, approvalDigest: approval.approvalDigest };
115
+ }
21
116
  export function buildNodePrompt(spec, task, upstream, options) {
22
117
  const policy = resolveContextPolicy(spec);
23
118
  return buildDagNodePromptEnvelope({
@@ -193,6 +288,13 @@ function recordRepairArtifactForSupervisorNode(input) {
193
288
  function sleep(ms) {
194
289
  return new Promise((resolve) => setTimeout(resolve, ms));
195
290
  }
291
+ function durationBetween(startedAt, finishedAt) {
292
+ const started = startedAt ? new Date(startedAt).getTime() : Number.NaN;
293
+ const finished = finishedAt ? new Date(finishedAt).getTime() : Number.NaN;
294
+ return Number.isFinite(started) && Number.isFinite(finished)
295
+ ? Math.max(0, finished - started)
296
+ : 0;
297
+ }
196
298
  function sumAttemptMetric(attempts, select) {
197
299
  const values = attempts.map(select).filter((value) => value !== undefined);
198
300
  return values.length > 0
@@ -213,7 +315,7 @@ async function notifyNodeObserver(observer, event, nodeId, state, chunk) {
213
315
  }
214
316
  export async function executeDagNode(input) {
215
317
  const { nodeId, tasksById, state, spec, cwd, runDir, executeNode } = input;
216
- const task = tasksById.get(nodeId);
318
+ let task = tasksById.get(nodeId);
217
319
  const node = state.nodes[nodeId];
218
320
  const failBeforePrompt = async (error, failureCategory) => {
219
321
  const failedAt = new Date().toISOString();
@@ -223,13 +325,42 @@ export async function executeDagNode(input) {
223
325
  node.failureCategory = failureCategory;
224
326
  node.finishedAt = failedAt;
225
327
  node.lastActivityAt = node.finishedAt;
226
- node.durationMs = Math.max(0, Date.now() - new Date(node.startedAt).getTime());
328
+ node.durationMs = durationBetween(node.startedAt, node.finishedAt);
329
+ node.timing = { retryBackoffMs: 0, settlementCleanupMs: 0 };
227
330
  state.nodes[nodeId].nodeRecordPath = path.join(runDir, `${nodeId}.json`);
228
331
  await writeNodeRecord(runDir, nodeId, state.nodes[nodeId]);
229
332
  await input.persistState();
230
333
  await notifyNodeObserver(input.observer, "onNodeFinish", nodeId, state);
231
334
  };
232
335
  const failSkillSnapshot = (error) => failBeforePrompt(error, "skill-snapshot-integrity");
336
+ if (task.finalWriteSetApproval) {
337
+ const authorization = parseAndValidateFinalWriteSetApproval({ task, spec, state });
338
+ if (!authorization.ok) {
339
+ node.runtimeWriteAuthorization = {
340
+ schemaVersion: 1,
341
+ status: "rejected",
342
+ approvalSourceNodeId: authorization.approvalSourceNodeId,
343
+ reason: authorization.reason,
344
+ };
345
+ await failBeforePrompt(new Error(`final-write-set-approval-invalid: ${authorization.reason}`), "final-write-set-approval-invalid");
346
+ return;
347
+ }
348
+ task = {
349
+ ...task,
350
+ writeSet: authorization.effectiveWriteSet,
351
+ resolvedFinalWriteSetApproval: {
352
+ approvalDigest: authorization.approvalDigest,
353
+ approvalSourceNodeId: task.finalWriteSetApproval.approvalSourceNodeId,
354
+ },
355
+ };
356
+ node.runtimeWriteAuthorization = {
357
+ schemaVersion: 1,
358
+ status: "validated",
359
+ approvalSourceNodeId: task.finalWriteSetApproval.approvalSourceNodeId,
360
+ approvalDigest: authorization.approvalDigest,
361
+ effectiveWriteSet: [...authorization.effectiveWriteSet],
362
+ };
363
+ }
233
364
  let projectGovernanceContext;
234
365
  if (task.governanceStandardReview) {
235
366
  try {
@@ -301,7 +432,7 @@ export async function executeDagNode(input) {
301
432
  await input.persistState();
302
433
  await notifyNodeObserver(input.observer, "onNodeStart", nodeId, state);
303
434
  if (isDynamicTask) {
304
- const started = Date.now();
435
+ const dynamicExecutionStartedAt = new Date().toISOString();
305
436
  try {
306
437
  const result = await input.executeDynamicNode({
307
438
  task,
@@ -314,20 +445,17 @@ export async function executeDagNode(input) {
314
445
  observer: input.observer,
315
446
  persistState: input.persistState,
316
447
  });
317
- node.durationMs = result.durationMs ?? Date.now() - started;
318
448
  node.stdout = result.stdout;
319
449
  node.stderr = result.stderr;
320
450
  node.failureCategory = result.failureCategory;
321
- node.finishedAt = new Date().toISOString();
322
- node.lastActivityAt = node.finishedAt;
323
451
  node.status = result.ok ? "FINISHED" : "ERROR";
324
452
  }
325
453
  catch (error) {
326
454
  node.status = "ERROR";
327
455
  node.stderr = error instanceof Error ? error.message : String(error);
328
- node.finishedAt = new Date().toISOString();
329
- node.durationMs = Date.now() - started;
330
456
  }
457
+ const dynamicExecutionFinishedAt = new Date().toISOString();
458
+ const dynamicExecutionWallDurationMs = durationBetween(dynamicExecutionStartedAt, dynamicExecutionFinishedAt);
331
459
  if (node.status === "FINISHED") {
332
460
  const artifactMeta = await persistLongNodeOutputArtifacts({
333
461
  runDir,
@@ -337,6 +465,14 @@ export async function executeDagNode(input) {
337
465
  });
338
466
  Object.assign(node, artifactMeta);
339
467
  }
468
+ const dynamicFinishedAt = new Date().toISOString();
469
+ node.finishedAt = dynamicFinishedAt;
470
+ node.lastActivityAt = dynamicFinishedAt;
471
+ node.durationMs = durationBetween(node.startedAt, dynamicFinishedAt);
472
+ node.timing = {
473
+ retryBackoffMs: 0,
474
+ settlementCleanupMs: Math.max(0, node.durationMs - dynamicExecutionWallDurationMs),
475
+ };
340
476
  state.nodes[nodeId].nodeRecordPath = path.join(runDir, `${nodeId}.json`);
341
477
  await writeNodeRecord(runDir, nodeId, state.nodes[nodeId]);
342
478
  await input.persistState();
@@ -382,6 +518,7 @@ export async function executeDagNode(input) {
382
518
  : undefined;
383
519
  const maxAttempts = retryPolicy?.maxAttempts ?? 1;
384
520
  const attempts = [];
521
+ let totalAttemptWallDurationMs = 0;
385
522
  let totalBackoffMs = 0;
386
523
  const livenessPolicy = resolveLivenessPolicy(spec.defaults?.livenessPolicy, task.livenessPolicy);
387
524
  /**
@@ -422,7 +559,6 @@ export async function executeDagNode(input) {
422
559
  let previousProtocolReason;
423
560
  for (let attemptNumber = 1; attemptNumber <= maxAttempts; attemptNumber += 1) {
424
561
  const attemptStartedAt = new Date().toISOString();
425
- const attemptStarted = Date.now();
426
562
  node.currentAttempt = attemptNumber;
427
563
  node.livenessStatus = "active";
428
564
  let result;
@@ -450,7 +586,7 @@ export async function executeDagNode(input) {
450
586
  ok: false,
451
587
  stdout: "",
452
588
  stderr: error instanceof Error ? error.message : String(error),
453
- durationMs: Date.now() - attemptStarted,
589
+ durationMs: 0,
454
590
  };
455
591
  }
456
592
  // R0: executor ok=true still fails closed when outputProtocol is violated.
@@ -474,11 +610,14 @@ export async function executeDagNode(input) {
474
610
  }
475
611
  }
476
612
  const attemptFinishedAt = new Date().toISOString();
613
+ const attemptWallDurationMs = durationBetween(attemptStartedAt, attemptFinishedAt);
614
+ totalAttemptWallDurationMs += attemptWallDurationMs;
477
615
  const attemptRecord = {
478
616
  attempt: attemptNumber,
479
617
  startedAt: attemptStartedAt,
480
618
  finishedAt: attemptFinishedAt,
481
- durationMs: result.durationMs ?? Date.now() - attemptStarted,
619
+ durationMs: attemptWallDurationMs,
620
+ timing: { executorDurationMs: result.durationMs },
482
621
  ok: result.ok,
483
622
  stdout: result.stdout,
484
623
  stderr: result.stderr,
@@ -500,10 +639,7 @@ export async function executeDagNode(input) {
500
639
  }
501
640
  // Reflect the latest attempt on the node so progress is observable,
502
641
  // but keep node.status RUNNING while retry is still possible.
503
- node.durationMs =
504
- retryPolicy === undefined
505
- ? attemptRecord.durationMs
506
- : attempts.reduce((sum, attempt) => sum + (attempt.durationMs ?? 0), 0) + totalBackoffMs;
642
+ node.durationMs = durationBetween(node.startedAt, attemptFinishedAt);
507
643
  node.stdout = result.stdout;
508
644
  node.stderr = result.stderr;
509
645
  node.failureCategory = result.failureCategory;
@@ -543,8 +679,10 @@ export async function executeDagNode(input) {
543
679
  break;
544
680
  const delayMs = computeBackoffDelayMs(attemptNumber + 1, retryPolicy);
545
681
  if (delayMs > 0) {
546
- totalBackoffMs += delayMs;
682
+ const backoffStartedAt = new Date().toISOString();
547
683
  await sleep(delayMs);
684
+ const backoffFinishedAt = new Date().toISOString();
685
+ totalBackoffMs += durationBetween(backoffStartedAt, backoffFinishedAt);
548
686
  // Keep lastActivityAt fresh so backoff wait is not misread as node-quiet.
549
687
  node.lastActivityAt = new Date().toISOString();
550
688
  state.nodes[nodeId].nodeRecordPath = path.join(runDir, `${nodeId}.json`);
@@ -591,7 +729,6 @@ export async function executeDagNode(input) {
591
729
  }
592
730
  }
593
731
  const result = terminalResult;
594
- const started = Date.now();
595
732
  try {
596
733
  recordRepairArtifactForSupervisorNode({
597
734
  task,
@@ -602,8 +739,6 @@ export async function executeDagNode(input) {
602
739
  if (outputChunk.trim()) {
603
740
  await notifyNodeObserver(input.observer, "onNodeOutput", nodeId, state, outputChunk);
604
741
  }
605
- node.finishedAt = new Date().toISOString();
606
- node.lastActivityAt = node.finishedAt;
607
742
  node.status = result.ok ? "FINISHED" : "ERROR";
608
743
  if (result.ok) {
609
744
  const decisionRecord = await recordDecisionEnvelopeForNode({
@@ -645,9 +780,19 @@ export async function executeDagNode(input) {
645
780
  catch (error) {
646
781
  node.status = "ERROR";
647
782
  node.stderr = error instanceof Error ? error.message : String(error);
648
- node.finishedAt = new Date().toISOString();
649
- node.durationMs = Date.now() - started;
650
783
  }
784
+ // Attempt artifacts must observe their own persisted boundaries before the
785
+ // potentially asynchronous artifact write; post-attempt work belongs to node timing.
786
+ const terminalFinishedAt = new Date().toISOString();
787
+ node.finishedAt = terminalFinishedAt;
788
+ node.lastActivityAt = terminalFinishedAt;
789
+ node.durationMs = durationBetween(node.startedAt, terminalFinishedAt);
790
+ node.timing = {
791
+ retryBackoffMs: totalBackoffMs,
792
+ settlementCleanupMs: Math.max(0, durationBetween(node.startedAt, terminalFinishedAt) -
793
+ totalAttemptWallDurationMs -
794
+ totalBackoffMs),
795
+ };
651
796
  if (node.status === "FINISHED") {
652
797
  const artifactMeta = await persistLongNodeOutputArtifacts({
653
798
  runDir,
@@ -681,6 +826,16 @@ export async function executeDagNode(input) {
681
826
  }
682
827
  }
683
828
  }
829
+ const finishedAt = new Date().toISOString();
830
+ node.finishedAt = finishedAt;
831
+ node.lastActivityAt = finishedAt;
832
+ node.durationMs = durationBetween(node.startedAt, finishedAt);
833
+ node.timing = {
834
+ ...node.timing,
835
+ settlementCleanupMs: Math.max(0, durationBetween(node.startedAt, finishedAt) -
836
+ totalAttemptWallDurationMs -
837
+ totalBackoffMs),
838
+ };
684
839
  state.nodes[nodeId].nodeRecordPath = path.join(runDir, `${nodeId}.json`);
685
840
  await writeNodeRecord(runDir, nodeId, state.nodes[nodeId]);
686
841
  await input.persistState();
@@ -48,6 +48,28 @@ export const dagShellVerifyEvidenceSchema = z.object({
48
48
  commandCount: z.number().int().nonnegative(),
49
49
  commandLabels: z.array(z.string()).default([]),
50
50
  commandTexts: z.array(z.string()).default([]),
51
+ /** Semantic command identities in the same deterministic order as commandTexts. */
52
+ canonicalKeys: z.array(z.string()).default([]),
53
+ /** Commands merged into a retained semantic command, keyed by canonical identity. */
54
+ mergedCommands: z
55
+ .array(z.object({
56
+ canonicalKey: z.string(),
57
+ keptLabel: z.string(),
58
+ mergedLabels: z.array(z.string()),
59
+ /** Retained timeout after semantic duplicates merge, or null when unspecified. */
60
+ effectiveTimeoutMs: z.number().int().positive().nullable(),
61
+ }))
62
+ .default([]),
63
+ /** Commands omitted only because a retained aggregate is known to cover them. */
64
+ coveredCommands: z
65
+ .array(z.object({ aggregateLabel: z.string(), coveredLabel: z.string() }))
66
+ .default([]),
67
+ /** Why a command was retained or deferred from the intermediate verify node. */
68
+ selectionReasons: z.array(z.string()).default([]),
69
+ /** Non-executing generation-time cwd/local-script validation evidence. */
70
+ preflight: z
71
+ .array(z.object({ label: z.string(), status: z.enum(["ok", "future-delivery-script"]) }))
72
+ .default([]),
51
73
  /** Effective timeout applied independently to each shell command. */
52
74
  commandTimeoutMs: z.number().int().positive().optional(),
53
75
  /** Worst-case serial budget: commandCount * commandTimeoutMs. */
@@ -344,6 +366,13 @@ export const dagFrontendBrowserToolPreflightSchema = z.object({}).strict();
344
366
  export const dagFrontendTestEvidenceValidationSchema = z.object({}).strict();
345
367
  export const dagFrontendTestCaseChecklistSchema = z.object({}).strict();
346
368
  export const dagFrontendTestHtmlReportSchema = z.object({}).strict();
369
+ export const dagFinalWriteSetApprovalGateSchema = z
370
+ .object({
371
+ writerNodeId: z
372
+ .string()
373
+ .regex(/^[a-z][a-z0-9-]*$/, "writerNodeId must be kebab-case"),
374
+ })
375
+ .strict();
347
376
  export const dagMavenWorkspaceManifestSchema = z.object({
348
377
  files: z.array(z.object({
349
378
  path: z.string(),
@@ -393,6 +422,7 @@ export const dagShellConfigSchema = z.object({
393
422
  frontendTestCaseChecklist: dagFrontendTestCaseChecklistSchema.optional(),
394
423
  frontendBrowserToolPreflight: dagFrontendBrowserToolPreflightSchema.optional(),
395
424
  frontendTestEvidenceValidation: dagFrontendTestEvidenceValidationSchema.optional(),
425
+ finalWriteSetApprovalGate: dagFinalWriteSetApprovalGateSchema.optional(),
396
426
  frontendTestL5Report: z.object({}).strict().optional(),
397
427
  frontendTestHtmlReport: dagFrontendTestHtmlReportSchema.optional(),
398
428
  backendTestPipeline: dagBackendTestPipelineSchema.optional(),
@@ -541,6 +571,24 @@ export const dagWriterOutcomePolicySchema = z
541
571
  requireChangedFiles: z.boolean().optional(),
542
572
  })
543
573
  .strict();
574
+ /**
575
+ * The final supervised audit is the sole runtime write authority for its
576
+ * writer. `allowedPaths` remains a ceiling; it is never a writer fallback.
577
+ */
578
+ export const dagFinalWriteSetApprovalBindingSchema = z
579
+ .object({
580
+ schemaVersion: z.literal(1),
581
+ writerNodeId: z
582
+ .string()
583
+ .regex(/^[a-z][a-z0-9-]*$/, "writerNodeId must be kebab-case"),
584
+ approvalSourceNodeId: z
585
+ .string()
586
+ .regex(/^[a-z][a-z0-9-]*$/, "approvalSourceNodeId must be kebab-case"),
587
+ auditedPlanNodeId: z
588
+ .string()
589
+ .regex(/^[a-z][a-z0-9-]*$/, "auditedPlanNodeId must be kebab-case"),
590
+ })
591
+ .strict();
544
592
  export const dagConvergenceSpecSchema = z
545
593
  .object({
546
594
  enabled: z.boolean().optional().default(false),
@@ -608,6 +656,16 @@ export const dagTaskSchema = z.object({
608
656
  * run-attributed diff.
609
657
  */
610
658
  writerOutcomePolicy: dagWriterOutcomePolicySchema.optional(),
659
+ /** Required for supervised writers that consume a final audited writeSet. */
660
+ finalWriteSetApproval: dagFinalWriteSetApprovalBindingSchema.optional(),
661
+ /** Runtime-only authorization facts injected after final approval validation. */
662
+ resolvedFinalWriteSetApproval: z
663
+ .object({
664
+ approvalDigest: z.string().regex(/^[a-f0-9]{64}$/),
665
+ approvalSourceNodeId: z.string().min(1),
666
+ })
667
+ .strict()
668
+ .optional(),
611
669
  /**
612
670
  * Explicit opt-in for the deterministic project governance context resolver
613
671
  * (AGENTS.md chain + referenced code standards). Only tasks that set this
@@ -5,7 +5,7 @@
5
5
  ## 入口
6
6
 
7
7
  - [`development-principles.md`](development-principles.md):工程原则与完成纪律。
8
- - [`feature-workflow.md`](feature-workflow.md):会话治理、taskKind、profileAgent DAG 路径。
8
+ - [`feature-workflow.md`](feature-workflow.md):会话治理、taskKind、profileAgent DAG 路径,以及 managed contract 的边界更新与恢复语法。
9
9
  - [`verification-matrix.md`](verification-matrix.md):按变更类型选择验证命令。
10
10
  - [`harness-methodology-tdd.md`](harness-methodology-tdd.md):测试先行的方法与边界。
11
11
  - [`harness-methodology-verification.md`](harness-methodology-verification.md):如何用新鲜命令结果支持完成声明。
@@ -282,8 +282,8 @@
282
282
  },
283
283
  "executors": {
284
284
  "type": "object",
285
- "description": " executor id 分组的执行器配置。受治理 runtime 仅支持 pi;Cursor 仅通过 cursor-prompt sidecar 使用,不得配置在 executors 中。",
286
- "additionalProperties": { "$ref": "#/$defs/executor" },
285
+ "description": "受治理 runtime 仅支持 pi;Cursor 仅通过 cursor-prompt sidecar 使用,不得配置在 executors 中。",
286
+ "additionalProperties": false,
287
287
  "properties": {
288
288
  "pi": { "$ref": "#/$defs/executor" }
289
289
  }
@@ -319,13 +319,13 @@
319
319
  "executor": {
320
320
  "type": "object",
321
321
  "additionalProperties": false,
322
- "description": "executor 设置。DAG 模型选择优先读取 LOW/MED/HIGH,其次读取 defaultModel,最后回退到 loop-agent runtime 默认矩阵。字面值 default 表示不覆盖。LOW/MED/HIGH 可为字符串或 { model, thinking? } 对象。",
322
+ "description": "Pi executor 设置。DAG 模型选择优先读取 LOW/MED/HIGH,其次读取 defaultModel,最后回退到 loop-agent runtime 默认矩阵。fresh init 只写 LOW/MED/HIGH;三档有效值齐全时不保留 defaultModel。defaultModel 仅兼容旧项目。字面值 default 表示不覆盖。LOW/MED/HIGH 可为字符串或 { model, thinking? } 对象。",
323
323
  "properties": {
324
324
  "description": { "type": "string" },
325
325
  "enabled": { "type": "boolean" },
326
326
  "defaultModel": {
327
327
  "type": "string",
328
- "description": "executor 默认模型。设置为 default 或省略时,会继续回退到 loop-agent runtime 默认值。",
328
+ "description": "旧项目兼容默认模型。fresh init 不输出该字段;设置为 default 或省略时,会继续回退到 loop-agent runtime 默认值。",
329
329
  "default": "default"
330
330
  },
331
331
  "LOW": {
@@ -339,10 +339,6 @@
339
339
  "HIGH": {
340
340
  "$ref": "#/$defs/executorTierModel",
341
341
  "description": "HIGH 复杂度 DAG task 的模型覆盖值,优先级高于 defaultModel。可为模型字符串,或 { model, thinking? } 对象。"
342
- },
343
- "requiresApiKey": {
344
- "type": "string",
345
- "description": "该 executor 需要的环境变量名,通常用于 Cursor 等可选外部 executor。init update 会剥离过时的 pi.requiresApiKey。"
346
342
  }
347
343
  }
348
344
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.28.7",
3
+ "version": "0.28.9",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",
@@ -128,7 +128,8 @@ SDK 回归或 SDK 可选依赖不可用时用 `cli-only` 诊断。CLI fallback
128
128
 
129
129
  ```bash
130
130
  loop-agent init instructions --repo-root <target-repo>
131
- loop-agent init --repo-root <target-repo> --profile full --merge
131
+ loop-agent init --repo-root <target-repo> --profile full --merge \
132
+ --provider deepseek --model deepseek/deepseek-v4-flash
132
133
  loop-agent init doctor --repo-root <target-repo>
133
134
  loop-agent init check-update --repo-root <target-repo> --json
134
135
  loop-agent init check-update --repo-root <target-repo> --markdown
@@ -141,9 +142,11 @@ loop-agent init upgrade --repo-root <target-repo> --run-id <run-id> --continue -
141
142
  loop-agent init upgrade --repo-root <target-repo> --run-id <run-id> --report --markdown
142
143
  ```
143
144
 
145
+ fresh init 将精确引用写到 `executors.pi.LOW/MED/HIGH`。fresh `loop-agent init` 会将合格且完整的 `provider/model` 引用投影到 `executors.pi.LOW/MED/HIGH`,即 `executors.pi.LOW`、`executors.pi.MED` 和 `executors.pi.HIGH`。三个 tier 齐全时不会写入 `defaultModel`,也不会恢复或建议旧顶层 routing 字段。
146
+
144
147
  `init check-update` 是只读升级报告,用于发现目标项目是否落后于当前包内初始化 surface。输出会区分 deterministic actions、model merge tasks、human decisions 和 recommended next。`--markdown` 会渲染可直接交给模型执行的合并指引,包含 `allowedPaths`、`forbiddenPaths`、`mergeRules` 和 `verification`。
145
148
 
146
- `init update --bootstrap-surface` 为旧项目写入 `.harness/init-surface.json` 的 `inferred-baseline`,不伪装成历史 recorded baseline。`init update --apply-safe` 只执行确定性安全动作:补缺失文件、创建目录、刷新 managed block;已有但无法确认与当前包一致的文件会进入 model merge tasks,不会被覆盖。
149
+ `init update --bootstrap-surface` 为旧项目写入 `.harness/init-surface.json` 的 `inferred-baseline`,不伪装成历史 recorded baseline。`init update --apply-safe` 只执行确定性安全动作:补缺失文件、创建目录、刷新 managed block;已有但无法确认与当前包一致的文件会进入 model merge tasks,不会被覆盖。模型 merge 只会自动展开不含 tier 或其他旧字段的唯一、完整且可证明等价的旧 `defaultModel: "provider/model"`;顶层 `model`、`models`、`modelProfiles`、`modelRouting`、`verify`、`sequentialWorkflowRole`、部分/对象 tier、bare model、冲突 provider 或未知语义均 fail closed,并保留原始 `harness.json`。
147
150
 
148
151
  `init upgrade` 是写入型升级入口:首次运行冻结 controller identity、检查 npm latest、扫描 surface、自动执行安全动作,并把 run facts 持久化到 `.harness/init-upgrades/<run-id>/`。`--status` 与 `--report` 严格只读;`--continue` 会重新核验 identity、当前 hash 与不变量。版本选择、无法判定冲突、凭据、破坏性删除或 active writer 才进入 human decision。普通语义冲突返回具体单文件 `allowedPaths` merge task,主会话必须合并后继续,不能把 `needs-model-merge` 作为完成。默认管理项目级 OpenCode/Pi recovery 与 `.pi/settings.json` nested merge,保留显式 disabled,Pi trust 后才加载;默认不读写 `~/.pi/agent/settings.json`。旧 `init reconcile` 保留为兼容低层诊断。
149
152
 
@@ -4,22 +4,29 @@
4
4
 
5
5
  ## 模型配置
6
6
 
7
- 模型设置来自 repo `harness.json`。历史 `models.<step>` 只作为旧 task metadata 兼容读取;新 DAG work 不从这里路由模型。
7
+ 模型设置来自 repo `harness.json.executors.pi.LOW|MED|HIGH`。新 DAG 不读取顶层 `model`、`models`、`modelProfiles`、`modelRouting`、`verify` `sequentialWorkflowRole`;JSON Schema runtime Zod 都拒绝这些字段。
8
8
 
9
- 示例模型记录:
9
+ fresh init 的完整三档示例:
10
10
 
11
11
  ```json
12
12
  {
13
- "provider": "wizard-local",
14
- "model": "gpt-5.3-codex-spark"
13
+ "executors": {
14
+ "pi": {
15
+ "LOW": "wizard-local/gpt-5.3-codex-spark",
16
+ "MED": "deepseek/deepseek-v4-flash",
17
+ "HIGH": "provider/high-reasoning-model"
18
+ }
19
+ }
15
20
  }
16
21
  ```
17
22
 
23
+ 三档齐全时不得保留 `defaultModel`。该字段仅为旧项目的 runtime fallback 兼容保留,不是 fresh-init 输出。旧项目只有不含 tier 或其他旧字段的单一完整 `defaultModel` 可自动展开;对象/部分 tier、旧顶层字段、bare model、冲突或未知 Pi 语义一律保留原始 `harness.json`,进入 model merge/human decision。
24
+
18
25
  Agent DAG node 的模型来自 DAG JSON 中的 `executorModels`,并由 `dag validate --strict-models` 检查 canonical matrix 漂移。若变更模型配置,须同步更新 `harness.json`、相关测试、repo docs 与本 skill。
19
26
 
20
- `executorModels.pi` 的每档值支持两种形式:裸模型名继续走内置映射或默认 `wizard-local`;`provider/model` 限定引用则显式选择 Pi provider,只在第一个 `/` 处分割并保留模型 ID 的剩余部分。provider 定义与凭证仍归 Pi 的 `~/.pi/agent/models.json` 和环境配置所有。例如不同档可分别写 `wizard-local/glm-5.2` 与 `deepseek/deepseek-v4-flash`。
27
+ `executors.pi` 的每档值支持字符串或 `{ model, thinking? }`。CLI 显式配置必须使用 `--model provider/model`,或成对使用 `--provider provider --model model`;空值、仅 provider、裸 `--model` 与冲突的重复 provider 限定会被拒绝。`provider/model` 只在第一个 `/` 处分割并保留模型 ID 的剩余部分。provider 定义与凭证仍归 Pi 的 `~/.pi/agent/models.json` 和环境配置所有。
21
28
 
22
- `pi-prompt` 是独立 one-shot helper,不使用 `harness.json.models` 或 DAG `executorModels`。当前默认是 `wizard-local/glm-5.2`;高复杂度 one-shot 显式传 `--model gpt-5.5`。Agent DAG `pi` executor 的 canonical matrix 保持;读写 profile 共用同一矩阵:
29
+ `pi-prompt` 是独立 one-shot helper,不使用 harness Pi tier matrix 或 DAG `executorModels`。当前默认是 `wizard-local/glm-5.2`;高复杂度 one-shot 显式传 `--model gpt-5.5`。Agent DAG `pi` executor 的 canonical matrix 保持;运行时优先级仍为 `LOW|MED|HIGH → defaultModel → DEFAULT_DAG_EXECUTOR_MODELS`:
23
30
 
24
31
  ```json
25
32
  {
@@ -33,6 +40,6 @@ Agent DAG node 的模型来自 DAG JSON 中的 `executorModels`,并由 `dag va
33
40
  ```bash
34
41
  loop-agent inspect
35
42
  ```
36
- 并在输出中核对 `models` 与 Agent DAG 文档中的 `executorModels` 约定。
43
+ 并在输出中核对 `executors.pi.LOW/MED/HIGH` 与 Agent DAG 文档中的 `executorModels` 约定。
37
44
 
38
45
  Pi SDK runtime reuse 仍为 **default-off**(`CODE_AGENT_PI_REUSE_RUNTIME` 未设/`off`/未知)。opt-in 须显式 `auto-run`;`CODE_AGENT_PI_BACKEND=cli-only` 绕过 reuse。无 live call 的确定性 M2/M3 decision 摘要用 `pi-reuse-benchmark`(见 `command-reference.md`)。