@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
|
-
])).
|
|
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(
|
|
708
|
-
const strictRecord = asRecord(
|
|
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
|
-
|
|
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(
|
|
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 :
|
|
1305
|
-
behaviorCommands: behaviorCommands.length > 0 ? behaviorCommands :
|
|
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
|
-
: [
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 =
|
|
129
|
+
parsed = extractUniqueReviewJson(trimmed);
|
|
77
130
|
}
|
|
78
131
|
catch (error) {
|
|
79
132
|
return {
|
|
80
133
|
ok: false,
|
|
81
|
-
reason: `output
|
|
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)) {
|