@tea-agent/loop-agent 0.28.10 → 0.28.12-beta.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.28.11] - 2026-08-06
6
+
7
+ ### 重点更新
8
+
9
+ - 修复 Night Scheduler 收尾提交因前导空格处理不当导致变更路径截断、暂存失败的问题
10
+
11
+ ### 修复
12
+
13
+ - Night Scheduler 收尾提交改用 trimEnd 替代 trim,保留 git porcelain 格式的前导空格,修复 worktree-only 变更路径被截断(如 src/hello.js 变为 rc/hello.js)导致 git add 失败的问题
14
+
5
15
  ## [0.28.10] - 2026-08-05
6
16
 
7
17
  ### 重点更新
@@ -26,11 +26,14 @@ export async function createNightClosingCommit(input) {
26
26
  reason: card.ok ? "task card required for closing commit" : card.reason,
27
27
  };
28
28
  }
29
+ // Never full String#trim() porcelain: a leading space is the index column for
30
+ // worktree-only changes (` M path`). Trimming turns ` M src/a` into `M src/a`,
31
+ // which parseGitStatusPorcelain then mis-reads as `rc/a`.
29
32
  const status = (await runGit(input.workspaceRoot, [
30
33
  "status",
31
34
  "--porcelain=v1",
32
35
  "--untracked-files=all",
33
- ])).trim();
36
+ ])).trimEnd();
34
37
  if (!status) {
35
38
  return { ok: false, reason: "no changes to commit after execution" };
36
39
  }
@@ -351,6 +351,19 @@ export function extractFrontendImplementationJson(text) {
351
351
  return JSON.parse(source);
352
352
  }
353
353
  catch (error) {
354
+ // Apply only deterministic, structure-preserving repairs before the
355
+ // quote repair below. These are common JSONC/model formatting defects,
356
+ // not a general parser relaxation.
357
+ const withoutComments = source
358
+ .replace(/\/\*[\s\S]*?\*\//g, "")
359
+ .replace(/^\s*\/\/.*$/gm, "");
360
+ const withoutTrailingCommas = withoutComments.replace(/,\s*([}\]])/g, "$1");
361
+ try {
362
+ return JSON.parse(withoutTrailingCommas);
363
+ }
364
+ catch {
365
+ // Continue with the narrower embedded-quote repair.
366
+ }
354
367
  // Models sometimes put ordinary ASCII quotes inside a JSON string
355
368
  // (for example: `reason: "支持..."`). Repair only quotes that are
356
369
  // clearly not structural: a closing quote is followed by JSON
@@ -503,6 +516,51 @@ function asStringArray(value) {
503
516
  .map((item) => asString(item))
504
517
  .filter((item) => item.length > 0);
505
518
  }
519
+ function normalizeFrontendRoute(value) {
520
+ const route = asString(value).replaceAll("\\", "/").replace(/^\/+/, "");
521
+ if (!route || route === "." || route.split("/").includes(".."))
522
+ return null;
523
+ return `/${route}`;
524
+ }
525
+ function normalizeFrontendContractOptionalFields(value) {
526
+ const record = asRecord(value);
527
+ if (!record)
528
+ return value;
529
+ const targets = asRecord(record.targets);
530
+ const mockApi = asRecord(record.mockApi);
531
+ return {
532
+ ...record,
533
+ ...(targets
534
+ ? {
535
+ targets: {
536
+ ...targets,
537
+ routes: Array.isArray(targets.routes)
538
+ ? targets.routes.map(normalizeFrontendRoute).filter((route) => Boolean(route))
539
+ : targets.routes,
540
+ },
541
+ }
542
+ : {}),
543
+ ...(mockApi
544
+ ? {
545
+ mockApi: {
546
+ ...mockApi,
547
+ endpoints: Array.isArray(mockApi.endpoints)
548
+ ? mockApi.endpoints.map((item) => {
549
+ const endpoint = asRecord(item);
550
+ if (!endpoint)
551
+ return item;
552
+ return {
553
+ ...endpoint,
554
+ fixture: asString(endpoint.fixture) || undefined,
555
+ consumer: asString(endpoint.consumer) || undefined,
556
+ };
557
+ })
558
+ : mockApi.endpoints,
559
+ },
560
+ }
561
+ : {}),
562
+ };
563
+ }
506
564
  function isRequirementId(value) {
507
565
  return REQUIREMENT_ID_PATTERN.test(value);
508
566
  }
@@ -570,6 +628,112 @@ function canonicalizeVerificationTargetAliases(value) {
570
628
  : record.requirements;
571
629
  return { ...record, requirements };
572
630
  }
631
+ /**
632
+ * Model-written evidence gaps are hypotheses, not authoritative coverage
633
+ * facts. If the plan contains a real verification target that explicitly
634
+ * names a requirement, the executor can prove that the requirement has a
635
+ * verification path even when the model forgot to wire the target into the
636
+ * requirement entry or conservatively marked its gap as blocking.
637
+ *
638
+ * Keep gaps blocking when that proof cannot be derived. This is deliberately
639
+ * narrow: it uses only sourceBinding requirement IDs and structurally present
640
+ * verification targets, and never invents a command, file, or target.
641
+ */
642
+ function deriveFrontendVerificationCoverage(value, canonicalBinding) {
643
+ const record = asRecord(value);
644
+ if (!record)
645
+ return value;
646
+ const rawTargets = Array.isArray(record.verificationTargets)
647
+ ? record.verificationTargets
648
+ : [];
649
+ const targetIds = new Set(rawTargets
650
+ .map((item) => asRecord(item))
651
+ .filter((item) => Boolean(item))
652
+ .map((item) => asString(item.id))
653
+ .filter(Boolean));
654
+ const targetIdsByRequirement = new Map();
655
+ for (const item of rawTargets) {
656
+ const target = asRecord(item);
657
+ if (!target)
658
+ continue;
659
+ const targetId = asString(target.id);
660
+ const commandLabel = asString(target.commandLabel) || asString(target.command);
661
+ const file = asString(target.file);
662
+ // A target is usable evidence only when it has an identity, command and
663
+ // file. The strict schema will validate the final shape afterwards.
664
+ if (!targetId || !commandLabel || !file)
665
+ continue;
666
+ const targetRequirementIds = asStringArray(target.requirementIds);
667
+ const inferredRequirementIds = targetRequirementIds.length > 0
668
+ ? targetRequirementIds
669
+ : rawTargets.length === 1
670
+ ? canonicalBinding.requirementIds
671
+ : [];
672
+ for (const requirementId of inferredRequirementIds) {
673
+ const canonicalId = canonicalizeRequirementId(requirementId);
674
+ if (!canonicalBinding.requirementIds.includes(canonicalId))
675
+ continue;
676
+ const ids = targetIdsByRequirement.get(canonicalId) ?? [];
677
+ if (!ids.includes(targetId))
678
+ ids.push(targetId);
679
+ targetIdsByRequirement.set(canonicalId, ids);
680
+ }
681
+ }
682
+ const provenRequirementIds = new Set(targetIdsByRequirement.keys());
683
+ const requirements = Array.isArray(record.requirements)
684
+ ? record.requirements.map((item) => {
685
+ const requirement = asRecord(item);
686
+ if (!requirement)
687
+ return item;
688
+ const requirementId = canonicalizeRequirementId(asString(requirement.id));
689
+ const inferredTargetIds = targetIdsByRequirement.get(requirementId) ?? [];
690
+ const existingTargetIds = asStringArray(requirement.verificationTargetIds)
691
+ .filter((id) => targetIds.has(id));
692
+ const verificationTargetIds = [...new Set([
693
+ ...existingTargetIds,
694
+ ...inferredTargetIds,
695
+ ])];
696
+ const evidenceGap = asRecord(requirement.evidenceGap);
697
+ const hasProof = provenRequirementIds.has(requirementId);
698
+ return {
699
+ ...requirement,
700
+ ...(verificationTargetIds.length > 0 ? { verificationTargetIds } : {}),
701
+ ...(hasProof
702
+ ? (evidenceGap ? { evidenceGap: { ...evidenceGap, blocking: false } } : {})
703
+ : {
704
+ evidenceGap: {
705
+ requirementId,
706
+ description: `No executable verification target can be derived for ${requirementId}`,
707
+ blocking: true,
708
+ },
709
+ }),
710
+ };
711
+ })
712
+ : record.requirements;
713
+ const modelEvidenceGaps = Array.isArray(record.evidenceGaps)
714
+ ? record.evidenceGaps.map((item) => {
715
+ const gap = asRecord(item);
716
+ if (!gap)
717
+ return item;
718
+ const requirementId = canonicalizeRequirementId(asString(gap.requirementId));
719
+ // Model gaps are advisory. Blocking status is reconstructed below
720
+ // from the source binding and executable verification targets.
721
+ return { ...gap, blocking: false };
722
+ })
723
+ : [];
724
+ const derivedBlockingGaps = canonicalBinding.requirementIds
725
+ .filter((requirementId) => !provenRequirementIds.has(requirementId))
726
+ .map((requirementId) => ({
727
+ requirementId,
728
+ description: `No executable verification target can be derived for ${requirementId}`,
729
+ blocking: true,
730
+ }));
731
+ return {
732
+ ...record,
733
+ requirements,
734
+ evidenceGaps: [...modelEvidenceGaps, ...derivedBlockingGaps],
735
+ };
736
+ }
573
737
  function assertFrontendContractPathsSafe(value) {
574
738
  const record = asRecord(value);
575
739
  if (!record)
@@ -700,12 +864,13 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
700
864
  : rawRecord.uiStates,
701
865
  }
702
866
  : value;
867
+ const normalizedOptionalFields = normalizeFrontendContractOptionalFields(normalizedValue);
703
868
  // A payload can have the strict top-level shape while still containing
704
869
  // empty requirement coverage arrays. Do not trust shape alone: route such
705
870
  // payloads through the compatibility normalizer so targets and verification
706
871
  // references are deterministically filled from the contract context.
707
- if (looksLikeStrictFrontendContract(normalizedValue)) {
708
- const strictRecord = asRecord(normalizedValue);
872
+ if (looksLikeStrictFrontendContract(normalizedOptionalFields)) {
873
+ const strictRecord = asRecord(normalizedOptionalFields);
709
874
  const strictMockApi = asRecord(strictRecord?.mockApi);
710
875
  if (strictMockApi &&
711
876
  typeof strictMockApi.strategy === "string" &&
@@ -730,10 +895,28 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
730
895
  : verificationIds,
731
896
  };
732
897
  });
733
- return { ...strictRecord, requirements };
898
+ const strictMockApi = asRecord(strictRecord.mockApi);
899
+ const strictTargetFiles = asStringArray(asRecord(strictRecord.targets)?.files);
900
+ const mockEndpoints = Array.isArray(strictMockApi?.endpoints)
901
+ ? strictMockApi.endpoints.map((item) => {
902
+ const endpoint = asRecord(item);
903
+ if (!endpoint)
904
+ return item;
905
+ return {
906
+ ...endpoint,
907
+ consumer: asString(endpoint.consumer) ||
908
+ strictTargetFiles[0],
909
+ };
910
+ })
911
+ : strictMockApi?.endpoints;
912
+ return {
913
+ ...strictRecord,
914
+ requirements,
915
+ ...(strictMockApi ? { mockApi: { ...strictMockApi, endpoints: mockEndpoints } } : {}),
916
+ };
734
917
  }
735
918
  }
736
- const record = asRecord(normalizedValue);
919
+ const record = asRecord(normalizedOptionalFields);
737
920
  if (!record)
738
921
  return value;
739
922
  const implementation = asRecord(record.implementation);
@@ -1069,10 +1252,10 @@ export async function materializeFrontendImplementationContract(input) {
1069
1252
  const normalizedContract = coerceFrontendImplementationContractInput(parsed, canonicalBinding);
1070
1253
  // There is exactly one post-security candidate. A fallback candidate would
1071
1254
  // allow malformed raw fields to bypass the boundary checks above.
1072
- const candidate = {
1255
+ const candidate = deriveFrontendVerificationCoverage({
1073
1256
  ...(asRecord(normalizedContract) ?? parsed),
1074
1257
  sourceBinding: canonicalBinding,
1075
- };
1258
+ }, canonicalBinding);
1076
1259
  const result = frontendImplementationContractSchema.safeParse(candidate);
1077
1260
  if (!result.success)
1078
1261
  throw new Error(`invalid-output: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`);
@@ -1300,9 +1300,19 @@ async function discoverFrontendFallbackVerifyCommands(repoRoot) {
1300
1300
  "test:e2e",
1301
1301
  "e2e",
1302
1302
  ]);
1303
+ if (staticCommands.length === 0 && behaviorCommands.length === 0) {
1304
+ try {
1305
+ await access(path.join(repoRoot, "node_modules", ".bin", "tsc"));
1306
+ staticCommands.push("npx --no-install tsc --noEmit");
1307
+ }
1308
+ catch {
1309
+ // No local TypeScript binary: leave verification unavailable rather
1310
+ // than invoking a missing script or downloading a package.
1311
+ }
1312
+ }
1303
1313
  return {
1304
- staticCommands: staticCommands.length > 0 ? staticCommands : behaviorCommands,
1305
- behaviorCommands: behaviorCommands.length > 0 ? behaviorCommands : staticCommands,
1314
+ staticCommands: staticCommands.length > 0 ? staticCommands : [],
1315
+ behaviorCommands: behaviorCommands.length > 0 ? behaviorCommands : [],
1306
1316
  };
1307
1317
  }
1308
1318
  function deriveFrontendBehaviorPaths(taskConfig) {
@@ -5956,12 +5966,12 @@ async function buildSoftVerifyNode(sources) {
5956
5966
  const strategy = resolveDagVerifyStrategy(sources.taskConfig, "1");
5957
5967
  const fallbackCommands = sources.repoRoot
5958
5968
  ? (await discoverFrontendFallbackVerifyCommands(sources.repoRoot)).staticCommands
5959
- : ["npm run typecheck"];
5969
+ : [];
5960
5970
  const candidatePlan = canonicalizeVerificationCommands([
5961
5971
  ...taskVerifyCommands(sources.repoRoot, sources.taskConfig),
5962
5972
  ...(sources.verifyCommands?.intermediate ?? []),
5963
5973
  ]);
5964
- const focusedCandidates = candidatePlan.commands.filter((command) => !isFullSuiteVerifyCommand(command));
5974
+ const focusedCandidates = candidatePlan.commands.filter((command) => !isFullSuiteVerifyCommand(command) && !isFrontendLintVerifyCommand(command));
5965
5975
  const quota = verifyQuotaLimit(strategy.intermediateQuota ?? "full");
5966
5976
  const focusedIntermediate = quota
5967
5977
  ? focusedCandidates.slice(0, quota)
@@ -5971,11 +5981,14 @@ async function buildSoftVerifyNode(sources) {
5971
5981
  commands: focusedIntermediate,
5972
5982
  taskConfig: sources.taskConfig,
5973
5983
  });
5974
- const commands = buildVerifyShellCommands({
5984
+ const commandsWithoutFallback = buildVerifyShellCommands({
5975
5985
  repoRoot: sources.repoRoot,
5976
5986
  commands: plannedIntermediate.commands,
5977
5987
  fallbackCommands,
5978
5988
  });
5989
+ const commands = commandsWithoutFallback.length > 0
5990
+ ? commandsWithoutFallback
5991
+ : ["node -e \"console.log('No project verification command configured; verification skipped')\""];
5979
5992
  return {
5980
5993
  id: "soft-verify-shell",
5981
5994
  depends_on: [implementId],
@@ -5994,7 +6007,7 @@ async function buildSoftVerifyNode(sources) {
5994
6007
  quota: strategy.intermediateQuota ?? "full",
5995
6008
  commandSource: sources.verifyCommands ? "adapter" : "inline",
5996
6009
  commands: plannedIntermediate.commands,
5997
- fallbackCommands,
6010
+ fallbackCommands: commands,
5998
6011
  commandTexts: commands,
5999
6012
  commandTimeoutMs: DEFAULT_VERIFY_TIMEOUT_MS,
6000
6013
  plan: candidatePlan,
@@ -6094,9 +6107,13 @@ function buildRepairNode(sources) {
6094
6107
  .join("\n\n"),
6095
6108
  };
6096
6109
  }
6097
- function buildHardVerifyNode(sources) {
6110
+ async function buildHardVerifyNode(sources) {
6098
6111
  const repairId = repairNodeId();
6112
+ const discoveredFallback = sources.repoRoot
6113
+ ? await discoverFrontendFallbackVerifyCommands(sources.repoRoot)
6114
+ : { staticCommands: [], behaviorCommands: [] };
6099
6115
  const fallbackCommands = [
6116
+ ...discoveredFallback.staticCommands.filter((command) => /(?:^|\s)(?:npm|pnpm|yarn|bun)\s+run\s+build(?:\s|$)/.test(command)),
6100
6117
  "HARNESS_ALLOW_ACTIVE_DAG_RUNS=1 bash scripts/check-repo.sh",
6101
6118
  ];
6102
6119
  const finalPlan = canonicalizeVerificationCommands([
@@ -6105,7 +6122,7 @@ function buildHardVerifyNode(sources) {
6105
6122
  ]);
6106
6123
  const plannedFinal = applyMavenVerificationPlanning({
6107
6124
  repoRoot: sources.repoRoot,
6108
- commands: finalPlan.commands,
6125
+ commands: finalPlan.commands.filter((command) => !isFrontendLintVerifyCommand(command)),
6109
6126
  taskConfig: sources.taskConfig,
6110
6127
  });
6111
6128
  const commands = buildVerifyShellCommands({
@@ -6274,7 +6291,7 @@ async function buildSupervisedHybridDag(standard, sources) {
6274
6291
  buildProcessSupervisorNode(sources),
6275
6292
  buildProcessGateNode(sources),
6276
6293
  buildRepairNode(sources),
6277
- buildHardVerifyNode(sources),
6294
+ await buildHardVerifyNode(sources),
6278
6295
  ...(authorityDecision.enabled
6279
6296
  ? [
6280
6297
  buildAuthoritySurfaceAuditNode(sources),
@@ -62,23 +62,76 @@ export function firstNonEmptyLine(text) {
62
62
  }
63
63
  return undefined;
64
64
  }
65
- /**
66
- * Parse the JSON-only review verdict protocol. The complete trimmed output
67
- * must be exactly one JSON object: fences, prose, partial objects, and
68
- * concatenated objects are all rejected before schema validation.
69
- */
65
+ function extractUniqueReviewJson(text) {
66
+ const source = String(text).trim();
67
+ const blocks = [...source.matchAll(/```(?:json|jsonc)?\s*([\s\S]*?)\s*```/gi)].map((match) => match[1].trim());
68
+ const candidates = blocks.length > 0 ? blocks : [source];
69
+ const parsed = [];
70
+ for (const candidate of candidates) {
71
+ let cursor = 0;
72
+ while (cursor < candidate.length) {
73
+ const start = candidate.indexOf("{", cursor);
74
+ if (start < 0)
75
+ break;
76
+ let depth = 0;
77
+ let inString = false;
78
+ let escaped = false;
79
+ let end = -1;
80
+ for (let index = start; index < candidate.length; index += 1) {
81
+ const character = candidate[index];
82
+ if (inString) {
83
+ if (escaped)
84
+ escaped = false;
85
+ else if (character === "\\")
86
+ escaped = true;
87
+ else if (character === '"')
88
+ inString = false;
89
+ continue;
90
+ }
91
+ if (character === '"')
92
+ inString = true;
93
+ else if (character === "{")
94
+ depth += 1;
95
+ else if (character === "}" && --depth === 0) {
96
+ try {
97
+ const value = JSON.parse(candidate.slice(start, index + 1));
98
+ if (value && typeof value === "object" && !Array.isArray(value))
99
+ parsed.push(value);
100
+ }
101
+ catch {
102
+ // Keep fail-closed behavior for malformed JSON.
103
+ }
104
+ end = index;
105
+ break;
106
+ }
107
+ }
108
+ if (end < 0)
109
+ break;
110
+ cursor = end + 1;
111
+ }
112
+ }
113
+ if (parsed.length !== 1) {
114
+ throw new Error(parsed.length === 0
115
+ ? "output must contain one valid JSON review object"
116
+ : "output must contain exactly one JSON review object");
117
+ }
118
+ return parsed[0];
119
+ }
120
+ /** Parse a JSON review verdict after extracting one unambiguous JSON object.
121
+ * Prose and one Markdown fence are tolerated at this boundary; ambiguity and
122
+ * schema/semantic violations remain fail-closed. */
70
123
  export function parseJsonReviewVerdict(text) {
71
124
  const trimmed = String(text).trim();
72
125
  if (!trimmed)
73
126
  return { ok: false, reason: "missing JSON output" };
74
127
  let parsed;
75
128
  try {
76
- parsed = JSON.parse(trimmed);
129
+ parsed = extractUniqueReviewJson(trimmed);
77
130
  }
78
131
  catch (error) {
79
132
  return {
80
133
  ok: false,
81
- reason: `output must be exactly one JSON object: ${error instanceof Error ? error.message : String(error)}`,
134
+ reason: `output JSON extraction failed: ${error instanceof Error ? error.message : String(error)}`,
82
135
  };
83
136
  }
84
137
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.28.10",
3
+ "version": "0.28.12-beta.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",