@tea-agent/loop-agent 0.39.0-next.2 → 0.39.0-next.4
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/dist/build-stamp.json +2 -2
- package/dist/commands/init-upgrade.js +97 -19
- package/dist/executors/dag-pi-executor.js +1 -0
- package/dist/workflows/dag/init-hybrid.js +76 -13
- package/dist/workflows/dag/node-execution.js +21 -1
- package/dist/workflows/dag/retry-policy.js +4 -0
- package/package.json +1 -1
package/dist/build-stamp.json
CHANGED
|
@@ -938,6 +938,80 @@ function changedWorkspacePaths(before, after) {
|
|
|
938
938
|
.filter((relativePath) => before[relativePath] !== after[relativePath])
|
|
939
939
|
.sort();
|
|
940
940
|
}
|
|
941
|
+
/**
|
|
942
|
+
* Generated-output prefixes treated as verification noise when git ignore
|
|
943
|
+
* rules are unavailable (non-git target). In a git repo the authoritative
|
|
944
|
+
* filter is `.gitignore` via `git check-ignore`; this list only covers the
|
|
945
|
+
* common case where a verification command such as `npm run build` writes
|
|
946
|
+
* untracked artifacts (build/dist/coverage/...) into the workspace.
|
|
947
|
+
*/
|
|
948
|
+
const WORKSPACE_NOISE_PREFIXES = [
|
|
949
|
+
"node_modules/",
|
|
950
|
+
"build/",
|
|
951
|
+
"dist/",
|
|
952
|
+
"coverage/",
|
|
953
|
+
".turbo/",
|
|
954
|
+
".next/",
|
|
955
|
+
".nuxt/",
|
|
956
|
+
".cache/",
|
|
957
|
+
".parcel-cache/",
|
|
958
|
+
".vite/",
|
|
959
|
+
"out/",
|
|
960
|
+
"target/",
|
|
961
|
+
"__pycache__/",
|
|
962
|
+
];
|
|
963
|
+
function isWorkspaceNoisePath(relative) {
|
|
964
|
+
if (relative === ".git" || relative.startsWith(".git/"))
|
|
965
|
+
return true;
|
|
966
|
+
if (relative === RUN_ROOT || relative.startsWith(`${RUN_ROOT}/`))
|
|
967
|
+
return true;
|
|
968
|
+
return WORKSPACE_NOISE_PREFIXES.some((prefix) => relative === prefix.slice(0, -1) || relative.startsWith(prefix));
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Filters changed workspace paths down to meaningful (non-generated) changes
|
|
972
|
+
* so a verification command that legitimately writes git-ignored artifacts is
|
|
973
|
+
* not misjudged as an unauthorized workspace mutation. Git ignore rules are
|
|
974
|
+
* authoritative when the target is a git repo; otherwise the built-in
|
|
975
|
+
* generated-output prefixes apply.
|
|
976
|
+
*/
|
|
977
|
+
async function filterWorkspaceNoise(repoRoot, relativePaths) {
|
|
978
|
+
const builtinFiltered = relativePaths.filter((relative) => !isWorkspaceNoisePath(relative));
|
|
979
|
+
if (builtinFiltered.length === 0)
|
|
980
|
+
return builtinFiltered;
|
|
981
|
+
const ignored = await gitIgnoredPaths(repoRoot, builtinFiltered);
|
|
982
|
+
if (ignored === null)
|
|
983
|
+
return builtinFiltered;
|
|
984
|
+
return builtinFiltered.filter((relative) => !ignored.has(relative));
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Returns the subset of paths matched by git ignore rules, or null when the
|
|
988
|
+
* target is not a git repository (or git is unavailable), in which case the
|
|
989
|
+
* caller falls back to the built-in noise prefixes.
|
|
990
|
+
*/
|
|
991
|
+
async function gitIgnoredPaths(repoRoot, relativePaths) {
|
|
992
|
+
return await new Promise((resolve) => {
|
|
993
|
+
execFile("git", ["-C", repoRoot, "check-ignore", ...relativePaths], {
|
|
994
|
+
timeout: 15_000,
|
|
995
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
996
|
+
windowsHide: true,
|
|
997
|
+
}, (error, stdout) => {
|
|
998
|
+
if (error) {
|
|
999
|
+
// `git check-ignore` exits 1 when no path is ignored: that is a
|
|
1000
|
+
// successful lookup with an empty result set. Any other failure
|
|
1001
|
+
// (e.g. 128 outside a repo) means the git filter is unavailable.
|
|
1002
|
+
if (error.code === 1 && stdout.length === 0)
|
|
1003
|
+
resolve(new Set());
|
|
1004
|
+
else
|
|
1005
|
+
resolve(null);
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
resolve(new Set(stdout
|
|
1009
|
+
.split("\n")
|
|
1010
|
+
.filter(Boolean)
|
|
1011
|
+
.map((entry) => entry.split(path.sep).join("/"))));
|
|
1012
|
+
});
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
941
1015
|
function isFrozenVerificationSurface(value) {
|
|
942
1016
|
return (isRecord(value) &&
|
|
943
1017
|
value.schemaVersion === 1 &&
|
|
@@ -1587,7 +1661,7 @@ async function validateContinueMerge(directory, state, anchor) {
|
|
|
1587
1661
|
await assertSafeMergePath(state.repoRoot, allowedPath);
|
|
1588
1662
|
}
|
|
1589
1663
|
const current = await snapshotWorkspace(state.repoRoot);
|
|
1590
|
-
const
|
|
1664
|
+
const rawChanged = changedWorkspacePaths(authoritativeGuard.baselineWorkspace, current);
|
|
1591
1665
|
const pending = await readPendingAcceptance({
|
|
1592
1666
|
directory,
|
|
1593
1667
|
state,
|
|
@@ -1602,6 +1676,10 @@ async function validateContinueMerge(directory, state, anchor) {
|
|
|
1602
1676
|
pending,
|
|
1603
1677
|
})
|
|
1604
1678
|
: undefined;
|
|
1679
|
+
// Only the out-of-bound drift check treats git-ignored/generated-output
|
|
1680
|
+
// changes as noise; allowed-path changes are merge content and must stay
|
|
1681
|
+
// intact even when the target is git-ignored (e.g. `.agents/skills/*`).
|
|
1682
|
+
const changed = await filterWorkspaceNoise(state.repoRoot, rawChanged);
|
|
1605
1683
|
const unexpected = changed.filter((relativePath) => {
|
|
1606
1684
|
if (allowed.has(relativePath))
|
|
1607
1685
|
return false;
|
|
@@ -1611,7 +1689,7 @@ async function validateContinueMerge(directory, state, anchor) {
|
|
|
1611
1689
|
if (unexpected.length > 0) {
|
|
1612
1690
|
throw new Error(`init upgrade merge write-guard blocked out-of-bound workspace changes: ${unexpected.join(", ")}`);
|
|
1613
1691
|
}
|
|
1614
|
-
const changedAllowed =
|
|
1692
|
+
const changedAllowed = rawChanged.filter((relativePath) => allowed.has(relativePath));
|
|
1615
1693
|
if (changedAllowed.length === 0) {
|
|
1616
1694
|
if (pending) {
|
|
1617
1695
|
throw new Error("pending merge acceptance cannot authorize a merge without an allowed workspace change");
|
|
@@ -1834,16 +1912,8 @@ async function createVerificationWriteAudit(input) {
|
|
|
1834
1912
|
const note = (scope, file) => {
|
|
1835
1913
|
events.push(`${scope}=${file.split(path.sep).join("/")}`);
|
|
1836
1914
|
};
|
|
1837
|
-
const ignoredWorkspacePath = (candidate) =>
|
|
1838
|
-
|
|
1839
|
-
return (relative === ".git" ||
|
|
1840
|
-
relative.startsWith(".git/") ||
|
|
1841
|
-
relative === "node_modules" ||
|
|
1842
|
-
relative.startsWith("node_modules/") ||
|
|
1843
|
-
relative === RUN_ROOT ||
|
|
1844
|
-
relative.startsWith(`${RUN_ROOT}/`));
|
|
1845
|
-
};
|
|
1846
|
-
const install = async (scope, directory, ignore) => {
|
|
1915
|
+
const ignoredWorkspacePath = (candidate) => isWorkspaceNoisePath(toRepoPath(canonicalRepoRoot, candidate));
|
|
1916
|
+
const install = async (scope, directory, ignore, scopeRoot) => {
|
|
1847
1917
|
const canonical = await realpath(directory);
|
|
1848
1918
|
if (watched.has(canonical))
|
|
1849
1919
|
return;
|
|
@@ -1867,11 +1937,11 @@ async function createVerificationWriteAudit(input) {
|
|
|
1867
1937
|
// traversal opens an existing child directory. Nested watchers own real
|
|
1868
1938
|
// mutations below that child; persistent changes are also snapshot-checked.
|
|
1869
1939
|
if (!existingDirectory) {
|
|
1870
|
-
note(scope, path.relative(directory, candidate) || ".");
|
|
1940
|
+
note(scope, path.relative(scopeRoot ?? directory, candidate) || ".");
|
|
1871
1941
|
}
|
|
1872
1942
|
// A parent rename can introduce a new directory. Installing its watcher
|
|
1873
1943
|
// closes the delayed-descendant gap even when the container event is noise.
|
|
1874
|
-
void install(scope, candidate, ignore).catch((error) => {
|
|
1944
|
+
void install(scope, candidate, ignore, scopeRoot).catch((error) => {
|
|
1875
1945
|
const code = error.code;
|
|
1876
1946
|
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
1877
1947
|
failures.push(`watcher refresh failed for ${scope}: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -1891,10 +1961,10 @@ async function createVerificationWriteAudit(input) {
|
|
|
1891
1961
|
const child = path.join(canonical, entry.name);
|
|
1892
1962
|
if (ignore?.(child) || !entry.isDirectory() || entry.isSymbolicLink())
|
|
1893
1963
|
continue;
|
|
1894
|
-
await install(scope, child, ignore);
|
|
1964
|
+
await install(scope, child, ignore, scopeRoot);
|
|
1895
1965
|
}
|
|
1896
1966
|
};
|
|
1897
|
-
await install("workspace", canonicalRepoRoot, ignoredWorkspacePath);
|
|
1967
|
+
await install("workspace", canonicalRepoRoot, ignoredWorkspacePath, canonicalRepoRoot);
|
|
1898
1968
|
await install("controller", runRoot(input.repoRoot));
|
|
1899
1969
|
await install("controller-anchor", anchor.anchorRoot);
|
|
1900
1970
|
const piHomeEntry = await lstat(piHome).catch((error) => {
|
|
@@ -2194,17 +2264,25 @@ async function runVerificationCommand(input) {
|
|
|
2194
2264
|
if (audit.failures.length)
|
|
2195
2265
|
throw new Error(audit.failures.join("; "));
|
|
2196
2266
|
await assertFrozenVerificationSurface(repoRoot, surface);
|
|
2197
|
-
const changedWorkspace = changedWorkspacePaths(workspaceBefore, await snapshotWorkspace(repoRoot));
|
|
2267
|
+
const changedWorkspace = await filterWorkspaceNoise(repoRoot, changedWorkspacePaths(workspaceBefore, await snapshotWorkspace(repoRoot)));
|
|
2198
2268
|
const changedController = changedWorkspacePaths(controllerBefore, await snapshotDirectory(runRoot(repoRoot)));
|
|
2199
2269
|
const changedAnchors = changedWorkspacePaths(anchorBefore, await controllerAnchorSnapshot(repoRoot, state));
|
|
2200
2270
|
const changedPiHome = changedWorkspacePaths(piHomeBefore, await snapshotOptionalDirectory(path.join(os.homedir(), ".pi")));
|
|
2201
|
-
|
|
2271
|
+
// Workspace watcher events are repo-root relative (see scopeRoot in
|
|
2272
|
+
// createVerificationWriteAudit); drop git-ignored/generated-output noise
|
|
2273
|
+
// before deciding whether the subprocess wrote the workspace.
|
|
2274
|
+
const workspaceEvents = audit.events.filter((event) => event.startsWith("workspace="));
|
|
2275
|
+
const remainingEvents = [
|
|
2276
|
+
...audit.events.filter((event) => !event.startsWith("workspace=")),
|
|
2277
|
+
...(await filterWorkspaceNoise(repoRoot, workspaceEvents.map((event) => event.slice("workspace=".length)))).map((relative) => `workspace=${relative}`),
|
|
2278
|
+
];
|
|
2279
|
+
if (descendantsObserved || remainingEvents.length || changedWorkspace.length || changedController.length || changedAnchors.length || changedPiHome.length) {
|
|
2202
2280
|
return {
|
|
2203
2281
|
...result,
|
|
2204
2282
|
ok: false,
|
|
2205
2283
|
stderr: boundedOutput(`${result.stderr}\nverification write guard blocked changes: ${[
|
|
2206
2284
|
...(descendantsObserved ? ["process-tree=descendant"] : []),
|
|
2207
|
-
...(
|
|
2285
|
+
...(remainingEvents.length ? [`events=${remainingEvents.join(",")}`] : []),
|
|
2208
2286
|
...(changedWorkspace.length ? [`workspace=${changedWorkspace.join(",")}`] : []),
|
|
2209
2287
|
...(changedController.length ? [`controller=${changedController.join(",")}`] : []),
|
|
2210
2288
|
...(changedAnchors.length ? [`controller-anchor=${changedAnchors.join(",")}`] : []),
|
|
@@ -1043,6 +1043,7 @@ export function mapPiResultToDagNodeResult(result, firstProtocolLine) {
|
|
|
1043
1043
|
sdkAttempted: result.sdkAttempted,
|
|
1044
1044
|
tokensUsed: result.tokensUsed,
|
|
1045
1045
|
parsedEvents: result.parsedEvents,
|
|
1046
|
+
stopReason: readWriterThinkingExhaustionEvidence(result).stopReason,
|
|
1046
1047
|
};
|
|
1047
1048
|
}
|
|
1048
1049
|
function canonicalizeProtocolFirstLine(assistantText, firstProtocolLine) {
|
|
@@ -20,6 +20,7 @@ import { applySddEmbeddedEnhancements, probeRepoLocalSddSkills, } from "./sdd-em
|
|
|
20
20
|
import { discoverProjectGovernancePresence } from "./project-governance-context.js";
|
|
21
21
|
import { getTaskPaths, loadTaskConfig } from "../../task/runtime.js";
|
|
22
22
|
import { materializeTaskReferenceDocs } from "../../task/source-references.js";
|
|
23
|
+
import { REQUIREMENT_FACT_ROLES } from "../../task/source-prepare/artifact-meta.js";
|
|
23
24
|
import { observeTaskContract } from "../../task/contract/observe.js";
|
|
24
25
|
import { dagHasWriterExecution } from "./task-contract-binding.js";
|
|
25
26
|
import { DEFAULT_VERIFY_TIMEOUT_MS, resolveVerifyPreset, } from "../../executors/shell-verification.js";
|
|
@@ -1499,11 +1500,27 @@ function buildSourceContextBlock(sources) {
|
|
|
1499
1500
|
.relative(path.join(sources.taskDir, "source"), reference.path)
|
|
1500
1501
|
.replaceAll(path.sep, "/");
|
|
1501
1502
|
const referenceRef = toDagSourcePath(sources, reference.path);
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1503
|
+
// Fact-source roles (requirement/acceptance) carry the fields contracts
|
|
1504
|
+
// depend on (AC/scope/columns). Inject them in full so a fixed excerpt
|
|
1505
|
+
// cannot silently drop definitions; archival roles stay excerpted.
|
|
1506
|
+
const isFactRole = reference.role !== undefined &&
|
|
1507
|
+
REQUIREMENT_FACT_ROLES.has(reference.role);
|
|
1508
|
+
const referenceExcerpt = isFactRole
|
|
1509
|
+
? {
|
|
1510
|
+
text: reference.markdown.trim(),
|
|
1511
|
+
truncated: false,
|
|
1512
|
+
originalChars: reference.markdown.trim().length,
|
|
1513
|
+
maxChars: Number.POSITIVE_INFINITY,
|
|
1514
|
+
}
|
|
1515
|
+
: excerptMarkdown(reference.markdown, {
|
|
1516
|
+
sourceRef: referenceRef,
|
|
1517
|
+
});
|
|
1518
|
+
boundReadPaths.push(`- reference ${relativePath}${isFactRole ? " (full source)" : ""}: ${referenceRef}`);
|
|
1519
|
+
parts.push(`## Task source reference: ${relativePath}`, `Bound readPath (use for Pi read-tool calls): ${referenceRef}`, ...(isFactRole
|
|
1520
|
+
? [
|
|
1521
|
+
`Full source injected (role: ${reference.role}) — complete and authoritative; no excerpt truncation applied.`,
|
|
1522
|
+
]
|
|
1523
|
+
: []), referenceExcerpt.text);
|
|
1507
1524
|
}
|
|
1508
1525
|
parts.push("## Bound source read paths", ...boundReadPaths, "Use these repository-readable paths for any Pi read-tool calls. Bound files under `.harness/tasks/<taskId>/source/**` are read-only inputs: reading them is allowed even though writing `.harness/**` is forbidden.", "Never resolve task-relative citations such as `source/需求.md` or `source/references/*` against the repository root, invent `source/<taskId>/...`, search for substitutes, or fall back to `docs/**` when a bound read fails.", "## Task config summary", `- taskId: ${sources.taskConfig.taskId}`, `- flow: ${sources.taskConfig.flow}`, `- complexity: ${sources.taskConfig.complexity}`, `- contextProfile: ${sources.taskConfig.contextProfile}`, `- allowedPaths: ${sources.taskConfig.allowedPaths.join(", ") || "(none — review before execute)"}`, `- forbiddenPaths: ${sources.taskConfig.forbiddenPaths.join(", ") || "(none)"}`, '- Pi DAG nodes are read-only unless toolProfile="write" is explicitly selected for a bounded writer node.', "- Agent DAG read-only nodes must not write root artifacts/**; root artifacts/ is not a per-node scratchpad.", `- Derived execution contract and immutable references live under the Bound source read paths above (not as repo-root \`source/...\`).`);
|
|
1509
1526
|
if (sources.taskConfig.hardConstraints.length > 0) {
|
|
@@ -1541,10 +1558,49 @@ async function loadMaterializedSourceReferences(sourceDir) {
|
|
|
1541
1558
|
}
|
|
1542
1559
|
await collect(referenceDir);
|
|
1543
1560
|
referencePaths.sort((left, right) => left.localeCompare(right));
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1561
|
+
const roleByPath = await loadReferenceRoles(sourceDir);
|
|
1562
|
+
return Promise.all(referencePaths.map(async (filePath) => {
|
|
1563
|
+
const relative = path
|
|
1564
|
+
.relative(sourceDir, filePath)
|
|
1565
|
+
.replaceAll(path.sep, "/");
|
|
1566
|
+
const role = roleByPath.get(relative);
|
|
1567
|
+
return {
|
|
1568
|
+
path: filePath,
|
|
1569
|
+
markdown: await readFile(filePath, "utf-8"),
|
|
1570
|
+
...(role !== undefined ? { role } : {}),
|
|
1571
|
+
};
|
|
1572
|
+
}));
|
|
1573
|
+
}
|
|
1574
|
+
/**
|
|
1575
|
+
* Reads `source-manifest.json` into a materializedPath → role map so the DAG
|
|
1576
|
+
* generator can tell fact-source references (requirement/acceptance) apart
|
|
1577
|
+
* from archival ones (analysis/clarification/design). A missing or malformed
|
|
1578
|
+
* manifest yields an empty map; such references keep the bounded-excerpt
|
|
1579
|
+
* treatment instead of being injected in full.
|
|
1580
|
+
*/
|
|
1581
|
+
async function loadReferenceRoles(sourceDir) {
|
|
1582
|
+
const roleByPath = new Map();
|
|
1583
|
+
const manifestPath = path.join(sourceDir, "source-manifest.json");
|
|
1584
|
+
let raw;
|
|
1585
|
+
try {
|
|
1586
|
+
raw = await readFile(manifestPath, "utf-8");
|
|
1587
|
+
}
|
|
1588
|
+
catch {
|
|
1589
|
+
return roleByPath;
|
|
1590
|
+
}
|
|
1591
|
+
try {
|
|
1592
|
+
const manifest = JSON.parse(raw);
|
|
1593
|
+
for (const document of manifest.documents ?? []) {
|
|
1594
|
+
if (typeof document.materializedPath === "string" &&
|
|
1595
|
+
typeof document.role === "string") {
|
|
1596
|
+
roleByPath.set(document.materializedPath.replaceAll(path.sep, "/"), document.role);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
catch {
|
|
1601
|
+
// A malformed manifest must never break reference injection.
|
|
1602
|
+
}
|
|
1603
|
+
return roleByPath;
|
|
1548
1604
|
}
|
|
1549
1605
|
export async function loadTaskHybridSources(repoRoot, taskId) {
|
|
1550
1606
|
const paths = getTaskPaths(repoRoot, taskId);
|
|
@@ -2463,6 +2519,11 @@ async function buildFrontendHybridDagFromTask(sources) {
|
|
|
2463
2519
|
`Non-static targets (type unit/component/integration/mock) MUST set file to a concrete code file inside the implementation writeSet (task allowedPaths); the prewrite gate rejects any non-static target whose file falls outside the writeSet.`,
|
|
2464
2520
|
`Command-level checks that run project-wide (all tests, typecheck, build, governance) MUST use type "static" and must NOT be bound as non-static targets with file=package.json/tsconfig.json/vite.config.ts/scripts/*. Static targets are exempt from the writeSet containment check.`,
|
|
2465
2521
|
].join("\n");
|
|
2522
|
+
const mandatorySourceReadInstruction = [
|
|
2523
|
+
"## Mandatory full source read before contracting",
|
|
2524
|
+
"Before producing this contract/plan, use the Pi read tool to read the FULL bound source files (需求.md, 执行约束.md, and every `references/*` Bound readPath listed above) — the inline copies above may be truncated excerpts, and requirement/acceptance references are authoritative only in their full form.",
|
|
2525
|
+
"Do not drop scope fields, acceptance criteria, non-goals, UI states, or column/field definitions that exist in the full sources but are absent from the inline excerpts; if a field appears in the full source, it belongs in the contract.",
|
|
2526
|
+
].join("\n");
|
|
2466
2527
|
const strategy = resolveDagVerifyStrategy(taskConfig);
|
|
2467
2528
|
const readOnlyPaths = taskConfig.allowedPaths.length > 0 ? taskConfig.allowedPaths : ["**"];
|
|
2468
2529
|
const behaviorPaths = deriveFrontendBehaviorPaths(taskConfig);
|
|
@@ -2648,6 +2709,7 @@ async function buildFrontendHybridDagFromTask(sources) {
|
|
|
2648
2709
|
"Read task source and produce a concise frontend implementation contract.",
|
|
2649
2710
|
"Cover scope, non-goals, acceptance criteria, UI states, target runtime environment, risks, and verification expectations.",
|
|
2650
2711
|
"Read-only: do not modify code, docs, artifacts, or repository files.",
|
|
2712
|
+
mandatorySourceReadInstruction,
|
|
2651
2713
|
sourceContext,
|
|
2652
2714
|
].join("\n\n"),
|
|
2653
2715
|
},
|
|
@@ -2687,19 +2749,20 @@ async function buildFrontendHybridDagFromTask(sources) {
|
|
|
2687
2749
|
allowedPaths: readOnlyPaths,
|
|
2688
2750
|
forbiddenPaths,
|
|
2689
2751
|
skills: FRONTEND_IMPLEMENTATION_SKILLS,
|
|
2690
|
-
outputContract: "
|
|
2752
|
+
outputContract: "One-line lead-in, then exactly ONE fenced json object (```json ... ```) conforming to frontend-implementation-contract-v1. This fenced block is the single authoritative implementation contract the prewrite gate materializes; it must appear exactly once. Immediately after it, append exactly one ```openspec-citations``` fenced citation block. After the citation block, an optional Markdown explanation may follow covering Requirement Coverage, Implementation Steps, Target Files, UI State Handling, Styling / Component Strategy, Interaction Notes, Mock / API Strategy, Dependency Policy, Verification Plan, Real Integration Gap, and Residual Risks; this explanation may be omitted and may be truncated, and must not be placed before the contract JSON. Apart from the contract JSON fenced block and the openspec-citations block, do not emit any other fenced block or raw JSON. No file writes.",
|
|
2691
2753
|
subtask_prompt: [
|
|
2692
2754
|
"Based on frontend-contract-pi, frontend-scout-pi, task sources, and the generation-time Mock capability evidence, return a minimal frontend implementation plan.",
|
|
2693
2755
|
"Select the Mock / API strategy inside the plan and structured contract. Carry endpoint/fixture mapping, explicit activation, production-default-off rule, verification commands, and Real Integration Gap into both outputs.",
|
|
2694
2756
|
"Include ordered steps, target files, UI state handling, styling/component strategy, interaction notes, Mock/API strategy, dependency policy, deterministic verification entrypoints, and residual risks. Use only the fixed entrypoints below; implementation may add tests behind them but cannot replace them.",
|
|
2695
2757
|
"Every target file and verification target must be selected from the current target workspace and task scope. Do not reuse paths or symbols from examples, prior tasks, or loop-agent itself; if the project uses app/, packages/, spec/, __tests__, or another layout, preserve that layout.",
|
|
2696
2758
|
"Consume the Scout TARGET_SURFACE evidence before selecting files. Preserve the discovered existing entrypoint and data source. If implementationPaths or testPaths are outside task allowedPaths, record a blocking scope conflict; do not substitute a new page or silently broaden the writeSet.",
|
|
2697
|
-
"
|
|
2759
|
+
"Output in this exact order: (1) exactly one fenced json object conforming to frontend-implementation-contract-v1 — this fenced block is the single authoritative contract the prewrite gate materializes; (2) exactly one openspec-citations citation fenced block appended immediately after it; (3) optional Markdown explanation. The Markdown explanation may be omitted and may be truncated; never place evidence excerpts, duplicated upstream context, or long prose before the contract JSON. Do not emit any raw JSON or JSON objects in prose. Apart from the single contract JSON fenced block and the openspec-citations block, do not emit any other fenced block.",
|
|
2698
2760
|
"Each requirement must state its user-observable or logic-observable expectedOutcome. Each interaction must state its trigger and expectedBehavior. IDs plus file paths are not sufficient behavior semantics.",
|
|
2699
2761
|
requirementCoverageInstruction,
|
|
2700
2762
|
"verificationTargets[].commandLabel MUST be one of the frozen command labels listed above. Any other value will be rejected at contract materialization.",
|
|
2701
2763
|
verificationTargetFileInstruction,
|
|
2702
2764
|
"Read-only: do not modify code, docs, artifacts, or repository files.",
|
|
2765
|
+
mandatorySourceReadInstruction,
|
|
2703
2766
|
fixedVerificationContext,
|
|
2704
2767
|
sourceContext,
|
|
2705
2768
|
mockContextBlock,
|
|
@@ -2747,7 +2810,7 @@ async function buildFrontendHybridDagFromTask(sources) {
|
|
|
2747
2810
|
allowedPaths: readOnlyPaths,
|
|
2748
2811
|
forbiddenPaths,
|
|
2749
2812
|
skills: FRONTEND_IMPLEMENTATION_SKILLS,
|
|
2750
|
-
outputContract: "When the initial design review requests revision, return a
|
|
2813
|
+
outputContract: "When the initial design review requests revision, return a one-line lead-in followed by exactly ONE fenced json object (```json ... ```) conforming to frontend-implementation-contract-v1. This fenced block is the single authoritative implementation contract the prewrite gate materializes; it must appear exactly once. Immediately after it, append exactly one ```openspec-citations``` fenced citation block. After the citation block, an optional Markdown revision plan explanation may follow covering Requirement Coverage, Implementation Steps, Target Files, UI State Handling, Styling / Component Strategy, Interaction Notes, Mock / API Strategy, Dependency Policy, Verification Plan, Real Integration Gap, and Residual Risks; this explanation may be omitted and may be truncated, and must not be placed before the contract JSON. Apart from the contract JSON fenced block and the openspec-citations block, do not emit any other fenced block or raw JSON. No file writes.",
|
|
2751
2814
|
subtask_prompt: [
|
|
2752
2815
|
"Consume frontend-plan-pi (original plan) and frontend-design-review-pi (first design review findings).",
|
|
2753
2816
|
"This node runs only when frontend-design-review-pi emitted VERDICT: request-revision. Produce a complete revised implementation plan that addresses every Required Plan Correction from the design findings.",
|
|
@@ -2755,7 +2818,7 @@ async function buildFrontendHybridDagFromTask(sources) {
|
|
|
2755
2818
|
requirementCoverageInstruction,
|
|
2756
2819
|
"Do not turn MOCK_STRATEGY: blocked into an implementable strategy without new repository or contract evidence that resolves every blocker.",
|
|
2757
2820
|
"Read-only: do not modify code, docs, artifacts, or repository files. This node revises the plan only.",
|
|
2758
|
-
"
|
|
2821
|
+
"Output in this exact order: (1) exactly one fenced json object conforming to frontend-implementation-contract-v1 — this fenced block is the single authoritative contract the prewrite gate materializes; (2) exactly one openspec-citations citation fenced block appended immediately after it; (3) optional Markdown explanation. The Markdown explanation may be omitted and may be truncated; never place evidence excerpts, duplicated upstream context, or long prose before the contract JSON. Bind the contract to the supplied task sources; map every requirement and applicable UI state to concrete implementation and verification targets or an explicit blocking evidence gap. Do not emit any raw JSON or JSON objects in prose. Apart from the single contract JSON fenced block and the openspec-citations block, do not emit any other fenced block. Do not include secrets or unsafe paths.",
|
|
2759
2822
|
"Preserve each requirement expectedOutcome and each interaction trigger/expectedBehavior in the revised contract; do not reduce behavior semantics to IDs and paths.",
|
|
2760
2823
|
"verificationTargets[].commandLabel MUST be one of the frozen command labels listed above. Any other value will be rejected at contract materialization.",
|
|
2761
2824
|
verificationTargetFileInstruction,
|
|
@@ -152,6 +152,20 @@ function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCate
|
|
|
152
152
|
previousProtocolReason,
|
|
153
153
|
"Return exactly one fenced json block conforming to the frontend-implementation-contract-v1 schema.",
|
|
154
154
|
"Fix every reported field violation: do not emit null for optional fields, do not misspell field names, and match the required types exactly.",
|
|
155
|
+
"The Markdown explanation may be omitted; prioritize a complete contract.",
|
|
156
|
+
"</retry_instruction>",
|
|
157
|
+
].join("\n");
|
|
158
|
+
}
|
|
159
|
+
if (previousFailureCategory === "structured-output-truncated" &&
|
|
160
|
+
task.structuredContractOutput) {
|
|
161
|
+
return [
|
|
162
|
+
basePrompt,
|
|
163
|
+
"",
|
|
164
|
+
"<retry_instruction>",
|
|
165
|
+
"Previous attempt was truncated by the provider (stopReason=length) before the JSON contract was completed.",
|
|
166
|
+
"This attempt: output ONLY the single fenced json contract block, immediately followed by exactly one openspec-citations block.",
|
|
167
|
+
"Do not emit any Markdown explanation, evidence excerpts, or duplicated upstream context.",
|
|
168
|
+
"The contract JSON must be complete; the trailing Markdown explanation may be omitted entirely.",
|
|
155
169
|
"</retry_instruction>",
|
|
156
170
|
].join("\n");
|
|
157
171
|
}
|
|
@@ -768,10 +782,16 @@ export async function executeDagNode(input) {
|
|
|
768
782
|
sourceBinding: spec.sourceBinding,
|
|
769
783
|
});
|
|
770
784
|
if (!contractCheck.ok) {
|
|
785
|
+
// A provider stopReason=length means the response was cut before the
|
|
786
|
+
// JSON contract could complete; separate it from an ordinary bad
|
|
787
|
+
// contract so the retry switches to a JSON-only output strategy.
|
|
788
|
+
const attemptStopReason = result.stopReason;
|
|
771
789
|
result = {
|
|
772
790
|
...result,
|
|
773
791
|
ok: false,
|
|
774
|
-
failureCategory: "
|
|
792
|
+
failureCategory: attemptStopReason === "length"
|
|
793
|
+
? "structured-output-truncated"
|
|
794
|
+
: "invalid-output",
|
|
775
795
|
stderr: [result.stderr, contractCheck.reason]
|
|
776
796
|
.filter(Boolean)
|
|
777
797
|
.join("\n"),
|
|
@@ -21,6 +21,8 @@ export const STRUCTURED_OUTPUT_RETRY_CATEGORY = "output-too-large";
|
|
|
21
21
|
export const PROTOCOL_INVALID_RETRY_CATEGORY = "protocol-invalid";
|
|
22
22
|
/** Recoverable model artifact/schema formatting failure on read-only structured nodes. */
|
|
23
23
|
export const STRUCTURED_ARTIFACT_INVALID_RETRY_CATEGORY = "invalid-output";
|
|
24
|
+
/** Provider stopReason=length truncated the response before the JSON contract completed. */
|
|
25
|
+
export const STRUCTURED_OUTPUT_TRUNCATED_RETRY_CATEGORY = "structured-output-truncated";
|
|
24
26
|
/** Retry only a proven no-op from an explicitly opt-in bounded Pi writer. */
|
|
25
27
|
export const WRITER_EMPTY_DIFF_RETRY_CATEGORY = "writer-empty-diff";
|
|
26
28
|
/** Retry when a backend-test writer finished but Completeness Gate found missing/broken targets. */
|
|
@@ -36,6 +38,7 @@ export const STRUCTURED_REQUIRED_DAG_RETRY_CATEGORIES = [
|
|
|
36
38
|
...DEFAULT_DAG_RETRY_CATEGORIES,
|
|
37
39
|
STRUCTURED_OUTPUT_RETRY_CATEGORY,
|
|
38
40
|
STRUCTURED_ARTIFACT_INVALID_RETRY_CATEGORY,
|
|
41
|
+
STRUCTURED_OUTPUT_TRUNCATED_RETRY_CATEGORY,
|
|
39
42
|
];
|
|
40
43
|
/** Categories allowed on nodes that declare a machine-readable outputProtocol. */
|
|
41
44
|
export const PROTOCOL_AWARE_DAG_RETRY_CATEGORIES = [
|
|
@@ -48,6 +51,7 @@ export const ALL_DAG_RETRY_CATEGORIES = [
|
|
|
48
51
|
STRUCTURED_OUTPUT_RETRY_CATEGORY,
|
|
49
52
|
PROTOCOL_INVALID_RETRY_CATEGORY,
|
|
50
53
|
STRUCTURED_ARTIFACT_INVALID_RETRY_CATEGORY,
|
|
54
|
+
STRUCTURED_OUTPUT_TRUNCATED_RETRY_CATEGORY,
|
|
51
55
|
WRITER_EMPTY_DIFF_RETRY_CATEGORY,
|
|
52
56
|
INCOMPLETE_WRITE_SET_RETRY_CATEGORY,
|
|
53
57
|
WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY,
|