@tea-agent/loop-agent 0.28.13 → 0.29.0
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/AGENTS.md +1 -1
- package/CHANGELOG.md +44 -15
- package/dist/commands/client-recovery.js +56 -1
- package/dist/commands/init-upgrade.js +186 -21
- package/dist/executors/dag-pi-executor.js +49 -2
- package/dist/executors/shell-executor.js +135 -0
- package/dist/worker/console/app-data.js +132 -11
- package/dist/worker/console/chat/pi-runtime.js +24 -42
- package/dist/worker/console/chat/resource-loader.js +11 -20
- package/dist/worker/console/chat/routes.js +7 -8
- package/dist/worker/console/chat/runtime-context.js +1 -1
- package/dist/worker/console/chat/tools.js +67 -54
- package/dist/worker/console/operation-runner.js +15 -1
- package/dist/worker/console/operation-store.js +70 -49
- package/dist/worker/console/operator-actions.js +57 -1
- package/dist/worker/console/static/assets/index-Cwx-ZVEQ.js +29 -0
- package/dist/worker/console/static/favicon.svg +37 -0
- package/dist/worker/console/static/index.html +2 -1
- package/dist/workflows/dag/backend-test-scenario-param.js +846 -0
- package/dist/workflows/dag/backend-test-writer-completeness.js +418 -0
- package/dist/workflows/dag/init-hybrid.js +20 -6
- package/dist/workflows/dag/node-execution.js +31 -2
- package/dist/workflows/dag/retry-policy.js +55 -18
- package/dist/workflows/dag/types.js +1 -0
- package/dist/workflows/dag/validate.js +38 -3
- package/docs/operations/README.md +1 -1
- package/docs/templates/README.md +1 -1
- package/docs/templates/agent-dag.schema.json +3 -3
- package/docs/templates/backend-test-dag.json +36 -11
- package/docs/templates/init-managed-agents.md +1 -1
- package/harness.json +2 -2
- package/package.json +2 -1
- package/dist/worker/console/static/assets/index-BfRgtLF4.js +0 -29
package/AGENTS.md
CHANGED
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
|
|
50
50
|
启动 loop-agent DAG 流程后,主 agent 应自主推进到整个流程结束,不要中途请求无谓的人工确认(如“要我现在执行 DAG 吗?”)。只要 dry-run / validate / writeSet 审查通过就应直接执行并跑完全部 ranks;遇到真问题(契约不一致、writeSet 越界、verify 失败且超出 maxFixLoops、用户明显未授权的高风险动作)才停。即便 profile 是 supervised/reviewed,也不默认在 review-gate 主动停下等人——控制器会在需要人工 approve 时自行提示,主 agent 的职责是推动流程跑完。
|
|
51
51
|
|
|
52
|
-
DAG 执行期间主 agent 必须持续轮询监视直到 run 结束(FINISHED / FAILED / 需要 approve),不能轮询一次就停下等用户;轮询间隔应合理(避免频繁唤醒浪费 token,也不得住一个节点上赌一次就走)。判活必须用可靠方式:看 `state.json` 的 `runner.heartbeatAt` 是否持续刷新 + `session-events.jsonl` 是否在增长 + `dag doctor` 的 `liveness` 字段,不要只用 `tasklist /FI "PID eq X"` 这类过滤语法在 Git Bash 下会误报 DEAD,从而错判一个正常工作的 run 为 orphaned。遇到疑似 stall 先按 `docs/runtime/agent-dag-runner.md` 查 `lastMeaningfulProgressAt` / provider 活动,有真实进展就继续等,绝不盲目 supersede
|
|
52
|
+
DAG 执行期间主 agent 必须持续轮询监视直到 run 结束(FINISHED / FAILED / 需要 approve),不能轮询一次就停下等用户;轮询间隔应合理(避免频繁唤醒浪费 token,也不得住一个节点上赌一次就走)。判活必须用可靠方式:看 `state.json` 的 `runner.heartbeatAt` 是否持续刷新 + `session-events.jsonl` 是否在增长 + `dag doctor` 的 `liveness` 字段,不要只用 `tasklist /FI "PID eq X"` 这类过滤语法在 Git Bash 下会误报 DEAD,从而错判一个正常工作的 run 为 orphaned。遇到疑似 stall 先按 `docs/runtime/agent-dag-runner.md` 查 `lastMeaningfulProgressAt` / provider 活动,有真实进展就继续等,绝不盲目 supersede。持续监视过程中,主 agent 可在合适节点(例如 rank/节点状态变化、进入 verify/closeout、出现 stall 嫌疑或需要 approve 时)向用户做简短进度汇报(当前节点、状态、是否有风险),避免长时间静默;汇报是告知,不是请求确认,不得因此停下流程。
|
|
53
53
|
|
|
54
54
|
DAG 执行期间,主 agent 不得修改 writeSet 外的任何工作区文件(包括 AGENTS.md、docs、根配置等)。bounded writer 节点的 write guard 用「节点运行期间的工作区 diff」作为越界证据,不区分改动来自 pi 还是主会话——主会话在 pi 节点跑的时候改了 writeSet 外文件,会让 write guard 把账算到 pi 头上、判节点 ERROR、下游 cascade skip。需要改文档/约束时,要么在 DAG 启动前改完,要么等 run 结束后改;务必与 DAG 写入节点在时间上互斥。排查任务路由问题时优先用 `resolveTaskDagTemplateSelection` / `classifyTaskDemand` 等纯函数探针或 `--output` 指定临时路径,不要反复 `dag execute --dry-run` / 无必要的 lifecycle 探测。dry-run 不执行节点;若确需预演,检查命令返回的 `.harness/dag-runs/dry-run/<runId>/` `runDir`,它不进入 active overview,也不能作为 `dag resume` 目标。优先用纯函数探针或 `task status`。
|
|
55
55
|
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.29.0] - 2026-08-07
|
|
6
|
+
|
|
7
|
+
### 重点更新
|
|
8
|
+
|
|
9
|
+
- 新增 Automatic SemVer 自动化发布链,基于 Conventional Commit 增量自动决定版本号,且绝不自动发布破坏性 major 版本
|
|
10
|
+
- Operator Chat 默认开放完整的 Pi 工具集(read/write/edit/bash 等),在保持安全硬边界的前提下提升操作便捷性
|
|
11
|
+
- 后端测试在 16K 最大输出限制下引入分文件写入纪律与完整性恢复机制,彻底避免残缺资产被误判为成功
|
|
12
|
+
- 控制台新增横向莫比乌斯环页签图标,以暖黑双环表达持续运行,并用克制橙色交接面标记受控切换
|
|
13
|
+
|
|
14
|
+
### 新增
|
|
15
|
+
|
|
16
|
+
- 新增 Automatic SemVer 每日发布入口:严格依据最新稳定 tag 到 main HEAD 的增量决定版本,feat 触发 minor,其余触发 patch,破坏性标记会安全停止
|
|
17
|
+
- Operator Chat 默认注册并开放完整的 Pi 工具(read/write/edit/bash/grep/find/ls),Capabilities 与 system prompt 对齐 ADR 0011
|
|
18
|
+
- Loop Operator Console 新增横向暖黑莫比乌斯环页签图标,并覆盖静态资源与 UI 边界检查
|
|
19
|
+
- 客户端恢复机制支持静默忽略瞬时中断错误,并写入详细的恢复日志供运维人员核查
|
|
20
|
+
|
|
21
|
+
### 改进
|
|
22
|
+
|
|
23
|
+
- 后端测试在 pytest 执行前增加场景参数一致性检查,支持确定性有界自修,并在执行后生成结构化的《测试失败用例分析报告》
|
|
24
|
+
- 测试框架不再硬编码模型 ID,改为动态解析真实的 Pi 模型矩阵,提升跨环境兼容性
|
|
25
|
+
- 初始化升级流程在控制器基线合并中增加验证权限守卫,提升操作安全性
|
|
26
|
+
- AGENTS 及初始化托管块现允许在监控 DAG 时进行简短的进度更新
|
|
27
|
+
|
|
28
|
+
### 修复
|
|
29
|
+
|
|
30
|
+
- 修复控制台在用户主动切换标签页时,任务失败错误信息残留并跨标签页持续显示的问题
|
|
31
|
+
- 修复并发操作更新导致 JSON 路径产生竞态的问题,现对操作写入进行串行化处理
|
|
32
|
+
- 修复 Windows 环境下文件原子替换遇到短暂占用时导致操作卡死的问题,现有界重试并清理临时文件
|
|
33
|
+
- 修复后端测试 Markdown 生成器在已确认变更但无 diff 时误判的问题,现会进行严格的有界重试
|
|
34
|
+
- 改进 Windows 环境下临时目录清理机制,在遇到目录非空或繁忙(ENOTEMPTY/EBUSY)错误时自动重试
|
|
35
|
+
|
|
5
36
|
## [0.28.13] - 2026-08-06
|
|
6
37
|
|
|
7
38
|
### 重点更新
|
|
@@ -174,26 +205,24 @@
|
|
|
174
205
|
|
|
175
206
|
## [0.28.1] - 2026-08-05
|
|
176
207
|
|
|
208
|
+
### 重点更新
|
|
209
|
+
|
|
210
|
+
- 提升前端/后端任务执行稳定性,减少“看起来成功、实际未写入”的误判
|
|
211
|
+
- 缩短默认测试耗时,改善本地与发布验证体验
|
|
212
|
+
|
|
177
213
|
### 改进
|
|
178
214
|
|
|
179
|
-
-
|
|
180
|
-
-
|
|
181
|
-
-
|
|
182
|
-
-
|
|
183
|
-
- tolerate prose before frontend contract json
|
|
184
|
-
- fix frontend topology and verdict tests
|
|
185
|
-
- reduce llm protocol validation failures
|
|
186
|
-
- fix frontend prewrite contract normalization
|
|
215
|
+
- 默认测试流程更快,并归档已完成计划
|
|
216
|
+
- 维护类任务不再误入前端实现流程
|
|
217
|
+
- 部分证据与 lint 检查改为非阻断,降低无关噪声对交付的干扰
|
|
218
|
+
- 模型输出格式更宽松:说明文字、行内通过结论等常见写法更不易被误杀
|
|
187
219
|
|
|
188
220
|
### 修复
|
|
189
221
|
|
|
190
|
-
-
|
|
191
|
-
-
|
|
192
|
-
-
|
|
193
|
-
-
|
|
194
|
-
- harden verification and JSON parsing
|
|
195
|
-
- accept inline pass verdict
|
|
196
|
-
- normalize empty model fields
|
|
222
|
+
- 后端生成任务在未实际写入文件时不再误判为成功
|
|
223
|
+
- 前端写入前补齐契约校验,避免无效契约进入实现阶段
|
|
224
|
+
- 修正失败分类误把路径限制提示当成鉴权失败的问题
|
|
225
|
+
- 加强验证与结果解析,减少畸形输出导致的卡死或误判
|
|
197
226
|
|
|
198
227
|
## [0.28.0] - 2026-08-05
|
|
199
228
|
|
|
@@ -8,6 +8,7 @@ export const CLIENT_RECOVERY_MODES = [
|
|
|
8
8
|
"off",
|
|
9
9
|
];
|
|
10
10
|
export const OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH = ".opencode/plugins/loop-agent-transient-retry.js";
|
|
11
|
+
export const OPENCODE_TRANSIENT_RETRY_LOG_PATH = ".opencode/plugins/loop-agent-transient-retry.log";
|
|
11
12
|
export const OPENCODE_CONTEXT_OVERFLOW_COMPACT_PLUGIN_PATH = ".opencode/plugins/loop-agent-context-overflow-compact.js";
|
|
12
13
|
export const PI_CONTEXT_OVERFLOW_EXTENSION_PATH = ".pi/extensions/loop-agent-context-overflow.js";
|
|
13
14
|
/** Pi project settings are part of the managed init surface; never default to home. */
|
|
@@ -511,12 +512,29 @@ export function buildOpenCodeTransientRetryPluginSource() {
|
|
|
511
512
|
* Compensates transient UnknownError cases that OpenCode built-in APIError
|
|
512
513
|
* retry does not cover. Never runs concurrently with session.status === "retry".
|
|
513
514
|
* Permanent failures use interaction "${PERMANENT_ERROR_INTERACTION}".
|
|
515
|
+
* User aborts (MessageAbortedError / cancel words) are silently cleared.
|
|
516
|
+
* Best-effort file log: ${OPENCODE_TRANSIENT_RETRY_LOG_PATH}.
|
|
514
517
|
*/
|
|
518
|
+
import path from "node:path";
|
|
519
|
+
import { fileURLToPath } from "node:url";
|
|
520
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
521
|
+
|
|
515
522
|
const PLUGIN_PATH = ${JSON.stringify(OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH)};
|
|
523
|
+
const LOG_FILE_NAME = ${JSON.stringify(path.basename(OPENCODE_TRANSIENT_RETRY_LOG_PATH))};
|
|
524
|
+
const LOG_PATH = (() => {
|
|
525
|
+
try {
|
|
526
|
+
const dir =
|
|
527
|
+
import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));
|
|
528
|
+
return path.join(dir, LOG_FILE_NAME);
|
|
529
|
+
} catch {
|
|
530
|
+
return ${JSON.stringify(OPENCODE_TRANSIENT_RETRY_LOG_PATH)};
|
|
531
|
+
}
|
|
532
|
+
})();
|
|
516
533
|
const PERMANENT_INTERACTION = ${JSON.stringify(PERMANENT_ERROR_INTERACTION)};
|
|
517
534
|
const BACKOFF_MS = ${JSON.stringify([...BACKOFF_MS])};
|
|
518
535
|
const MAX_RETRIES = ${MAX_SESSION_RETRIES};
|
|
519
536
|
${buildGeneratedContextOverflowPatternSource()}
|
|
537
|
+
void logToFile("load ok");
|
|
520
538
|
|
|
521
539
|
const sessionState = new Map();
|
|
522
540
|
const sessionStatus = new Map();
|
|
@@ -542,6 +560,26 @@ function textOf(error) {
|
|
|
542
560
|
}
|
|
543
561
|
}
|
|
544
562
|
|
|
563
|
+
async function logToFile(line) {
|
|
564
|
+
try {
|
|
565
|
+
await mkdir(path.dirname(LOG_PATH), { recursive: true });
|
|
566
|
+
await appendFile(
|
|
567
|
+
LOG_PATH,
|
|
568
|
+
new Date().toISOString() + " " + line + "\\n",
|
|
569
|
+
"utf-8",
|
|
570
|
+
);
|
|
571
|
+
} catch {
|
|
572
|
+
// Best-effort observability log; never throw (ENOENT/EISDIR tolerated).
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function isUserAbort(error) {
|
|
577
|
+
if (error && typeof error === "object" && typeof error.name === "string") {
|
|
578
|
+
if (/^MessageAbortedError$/i.test(error.name)) return true;
|
|
579
|
+
}
|
|
580
|
+
return /aborted by user|user cancel|cancelled|canceled/i.test(textOf(error));
|
|
581
|
+
}
|
|
582
|
+
|
|
545
583
|
function isPermanent(error) {
|
|
546
584
|
const text = textOf(error);
|
|
547
585
|
if (error && typeof error === "object" && typeof error.name === "string") {
|
|
@@ -624,6 +662,7 @@ function promptWasAccepted(response) {
|
|
|
624
662
|
export default async function loopAgentTransientRetryPlugin({ client, $ }) {
|
|
625
663
|
void $;
|
|
626
664
|
void PLUGIN_PATH;
|
|
665
|
+
void logToFile("setup ok");
|
|
627
666
|
|
|
628
667
|
async function drainPendingRecovery(sessionId) {
|
|
629
668
|
const state = getState(sessionId);
|
|
@@ -662,9 +701,16 @@ export default async function loopAgentTransientRetryPlugin({ client, $ }) {
|
|
|
662
701
|
throwOnError: true,
|
|
663
702
|
});
|
|
664
703
|
} catch {
|
|
704
|
+
void logToFile(
|
|
705
|
+
"prompt attempt=" + nextAttempt + " accepted=false session=" + sessionId,
|
|
706
|
+
);
|
|
665
707
|
return;
|
|
666
708
|
}
|
|
667
|
-
|
|
709
|
+
const accepted = promptWasAccepted(response);
|
|
710
|
+
void logToFile(
|
|
711
|
+
"prompt attempt=" + nextAttempt + " accepted=" + accepted + " session=" + sessionId,
|
|
712
|
+
);
|
|
713
|
+
if (!accepted) return;
|
|
668
714
|
|
|
669
715
|
state.attempt = nextAttempt;
|
|
670
716
|
if (pendingErrors.get(sessionId) === pending) {
|
|
@@ -680,9 +726,11 @@ export default async function loopAgentTransientRetryPlugin({ client, $ }) {
|
|
|
680
726
|
}
|
|
681
727
|
state.locked = true;
|
|
682
728
|
state.rerunRequested = false;
|
|
729
|
+
void logToFile("worker start session=" + sessionId);
|
|
683
730
|
const worker = drainPendingRecovery(sessionId)
|
|
684
731
|
.catch(() => {})
|
|
685
732
|
.finally(() => {
|
|
733
|
+
void logToFile("worker end session=" + sessionId);
|
|
686
734
|
recoveryWorkers.delete(sessionId);
|
|
687
735
|
const rerun = state.rerunRequested;
|
|
688
736
|
state.rerunRequested = false;
|
|
@@ -751,12 +799,19 @@ export default async function loopAgentTransientRetryPlugin({ client, $ }) {
|
|
|
751
799
|
if (event.type === "session.error") {
|
|
752
800
|
if (!sessionId) return;
|
|
753
801
|
const error = properties.error || properties;
|
|
802
|
+
if (isUserAbort(error)) {
|
|
803
|
+
clearSession(sessionId);
|
|
804
|
+
void logToFile("classify abort session=" + sessionId);
|
|
805
|
+
return undefined;
|
|
806
|
+
}
|
|
754
807
|
if (!isTransientUnknown(error)) {
|
|
755
808
|
pendingErrors.delete(sessionId);
|
|
756
809
|
getState(sessionId).rerunRequested = false;
|
|
810
|
+
void logToFile("classify permanent session=" + sessionId);
|
|
757
811
|
return { interaction: PERMANENT_INTERACTION, reason: "plugin-ignore-permanent-error" };
|
|
758
812
|
}
|
|
759
813
|
pendingErrors.set(sessionId, { error });
|
|
814
|
+
void logToFile("classify transient session=" + sessionId);
|
|
760
815
|
startRecoveryWorker(sessionId);
|
|
761
816
|
return { pending: true };
|
|
762
817
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile, spawn } from "node:child_process";
|
|
2
|
-
import { watch } from "node:fs";
|
|
2
|
+
import { lstatSync, watch } from "node:fs";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
import { lstat, mkdtemp, mkdir, readFile, readdir, readlink, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
|
|
5
5
|
import os from "node:os";
|
|
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
|
|
|
8
8
|
import { resolveAdapter } from "../adapters/index.js";
|
|
9
9
|
import { createNpmUpdateClient } from "../cli/update/npm-client.js";
|
|
10
10
|
import { auditDocs } from "../governance/checks.js";
|
|
11
|
+
import { harnessManifestSchema } from "../governance/manifest-types.js";
|
|
11
12
|
import { isInitRuntimeActive, } from "../shared/runtime-activity.js";
|
|
12
13
|
import { MAX_OVERFLOW_RECOVERIES, MAX_SESSION_RETRIES, OPENCODE_CONTEXT_OVERFLOW_COMPACT_PLUGIN_PATH, OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH, PI_CONTEXT_OVERFLOW_EXTENSION_PATH, PI_PROJECT_SETTINGS_PATH, inspectProjectPiSettings, } from "./client-recovery.js";
|
|
13
14
|
import { applyInitUpdate, checkInitUpdate, initializeLoopAgentProject, runInitDoctor, } from "./init.js";
|
|
@@ -338,6 +339,9 @@ function mergeAuthorityProjection(state, authority) {
|
|
|
338
339
|
if (!sameJsonValue(state.mergeGuard.baselineWorkspace, authority.baselineWorkspace)) {
|
|
339
340
|
throw new Error("init upgrade merge guard projection differs from controller baseline authority");
|
|
340
341
|
}
|
|
342
|
+
if (!sameJsonValue(state.mergeGuard.verificationAuthority, authority.verificationAuthority)) {
|
|
343
|
+
throw new Error("init upgrade merge guard verification authority differs from controller baseline authority");
|
|
344
|
+
}
|
|
341
345
|
if (state.mergeReceipts.length !== authority.priorMergeReceiptCount) {
|
|
342
346
|
throw new Error("persisted pre-acceptance merge receipt count differs from controller authority");
|
|
343
347
|
}
|
|
@@ -653,6 +657,12 @@ async function snapshotWorkspace(repoRoot) {
|
|
|
653
657
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
654
658
|
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
655
659
|
for (const entry of entries) {
|
|
660
|
+
// Git may expose the same temp repository through a short/long Windows
|
|
661
|
+
// path pair, making path.relative() lose the leading `.git` segment.
|
|
662
|
+
// Exclude the top-level Git administration directory by entry identity;
|
|
663
|
+
// init-upgrade protects its own controller anchors separately.
|
|
664
|
+
if (directory === repoRoot && entry.name === ".git")
|
|
665
|
+
continue;
|
|
656
666
|
const absolute = path.join(directory, entry.name);
|
|
657
667
|
const relative = toRepoPath(repoRoot, absolute);
|
|
658
668
|
if (relative === ".git" || relative.startsWith(".git/"))
|
|
@@ -845,11 +855,8 @@ async function freezeVerificationSurface(repoRoot) {
|
|
|
845
855
|
collected.add(await assertSafeRegularRepoFile(repoRoot, relativePath));
|
|
846
856
|
};
|
|
847
857
|
await add("harness.json");
|
|
848
|
-
const harness =
|
|
849
|
-
const
|
|
850
|
-
const matrixPath = typeof harness.entrypoints?.verificationMatrix === "string"
|
|
851
|
-
? harness.entrypoints.verificationMatrix
|
|
852
|
-
: `${governanceRoot}/verification-matrix.md`;
|
|
858
|
+
const harness = await readVerificationHarness(repoRoot);
|
|
859
|
+
const matrixPath = verificationMatrixPath(harness);
|
|
853
860
|
await add(matrixPath);
|
|
854
861
|
const commands = ["bash scripts/check-repo.sh", await discoverQuickVerification(repoRoot)];
|
|
855
862
|
for (const command of commands) {
|
|
@@ -878,9 +885,68 @@ async function assertFrozenVerificationSurface(repoRoot, surface) {
|
|
|
878
885
|
}
|
|
879
886
|
}
|
|
880
887
|
}
|
|
888
|
+
function frozenVerificationMatrixPath(surface) {
|
|
889
|
+
const paths = surface.files
|
|
890
|
+
.map((entry) => entry.path)
|
|
891
|
+
.filter((pathName) => pathName === "verification-matrix.md" ||
|
|
892
|
+
pathName.endsWith("/verification-matrix.md"));
|
|
893
|
+
if (paths.length !== 1) {
|
|
894
|
+
throw new Error("verification authority surface must contain exactly one verification-matrix.md");
|
|
895
|
+
}
|
|
896
|
+
return paths[0];
|
|
897
|
+
}
|
|
898
|
+
function isControllerAuthorizedVerificationMergeTask(task, surface) {
|
|
899
|
+
const taskPath = normalizeAllowedPath(task.path);
|
|
900
|
+
if (taskPath === "harness.json")
|
|
901
|
+
return true;
|
|
902
|
+
return ((taskPath === "verification-matrix.md" ||
|
|
903
|
+
taskPath.endsWith("/verification-matrix.md")) &&
|
|
904
|
+
Boolean(surface?.files.some((entry) => entry.path === taskPath)));
|
|
905
|
+
}
|
|
906
|
+
async function assertFrozenVerificationSurfaceExcept(repoRoot, surface, exemptPath) {
|
|
907
|
+
if (!isFrozenVerificationSurface(surface)) {
|
|
908
|
+
throw new Error("verification authority surface manifest is invalid");
|
|
909
|
+
}
|
|
910
|
+
const canonicalExemptPath = exemptPath
|
|
911
|
+
? normalizeAllowedPath(exemptPath)
|
|
912
|
+
: undefined;
|
|
913
|
+
for (const entry of surface.files) {
|
|
914
|
+
if (entry.path === canonicalExemptPath)
|
|
915
|
+
continue;
|
|
916
|
+
const normalized = await assertSafeRegularRepoFile(repoRoot, entry.path);
|
|
917
|
+
if (normalized !== entry.path || sha256(await readFile(path.join(repoRoot, normalized))) !== entry.sha256) {
|
|
918
|
+
throw new Error(`verification authority surface changed: ${entry.path}`);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
async function validateControllerAuthorizedVerificationMerge(input) {
|
|
923
|
+
const taskPath = normalizeAllowedPath(input.task.path);
|
|
924
|
+
if (!isControllerAuthorizedVerificationMergeTask(input.task, input.surface))
|
|
925
|
+
return;
|
|
926
|
+
const harness = await readVerificationHarness(input.repoRoot);
|
|
927
|
+
const matrixPath = normalizeAllowedPath(verificationMatrixPath(harness));
|
|
928
|
+
const frozenMatrixPath = frozenVerificationMatrixPath(input.surface);
|
|
929
|
+
if (matrixPath !== frozenMatrixPath ||
|
|
930
|
+
(taskPath !== "harness.json" && taskPath !== frozenMatrixPath)) {
|
|
931
|
+
throw new Error("verification authority merge must retain the controller-frozen verification matrix path");
|
|
932
|
+
}
|
|
933
|
+
if (taskPath === "harness.json") {
|
|
934
|
+
harnessManifestSchema.parse(harness);
|
|
935
|
+
const authority = input.guard?.verificationAuthority;
|
|
936
|
+
if (authority &&
|
|
937
|
+
(!sameJsonValue(harness.executors ?? null, authority.executors) ||
|
|
938
|
+
!sameJsonValue(harness.workflowPolicy ?? null, authority.workflowPolicy))) {
|
|
939
|
+
throw new Error("verification authority harness.json merge cannot change controller executor or workflow authority");
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
const command = await discoverQuickVerification(input.repoRoot);
|
|
943
|
+
tokenizeCommand(command);
|
|
944
|
+
await commandRepositoryInputs(input.repoRoot, command);
|
|
945
|
+
}
|
|
881
946
|
function assertTaskOutsideVerificationSurface(task, surface) {
|
|
882
947
|
const taskPath = normalizeAllowedPath(task.path);
|
|
883
|
-
if (surface.files.some((entry) => entry.path === taskPath)
|
|
948
|
+
if (surface.files.some((entry) => entry.path === taskPath) &&
|
|
949
|
+
!isControllerAuthorizedVerificationMergeTask(task, surface)) {
|
|
884
950
|
throw new Error(`init upgrade merge task targets authority-sensitive verification input: ${taskPath}`);
|
|
885
951
|
}
|
|
886
952
|
}
|
|
@@ -918,11 +984,24 @@ function validateTaskBoundary(task) {
|
|
|
918
984
|
}
|
|
919
985
|
return allowed;
|
|
920
986
|
}
|
|
987
|
+
async function harnessVerificationAuthority(repoRoot, task) {
|
|
988
|
+
if (normalizeAllowedPath(task.path) !== "harness.json")
|
|
989
|
+
return undefined;
|
|
990
|
+
const harness = await readVerificationHarness(repoRoot);
|
|
991
|
+
return {
|
|
992
|
+
executors: cloneJson(harness.executors ?? null),
|
|
993
|
+
workflowPolicy: cloneJson(harness.workflowPolicy ?? null),
|
|
994
|
+
};
|
|
995
|
+
}
|
|
921
996
|
async function createMergeGuard(repoRoot, task) {
|
|
922
997
|
for (const allowedPath of validateTaskBoundary(task)) {
|
|
923
998
|
await assertSafeMergePath(repoRoot, allowedPath);
|
|
924
999
|
}
|
|
925
|
-
return {
|
|
1000
|
+
return {
|
|
1001
|
+
task,
|
|
1002
|
+
baselineWorkspace: await snapshotWorkspace(repoRoot),
|
|
1003
|
+
verificationAuthority: await harnessVerificationAuthority(repoRoot, task),
|
|
1004
|
+
};
|
|
926
1005
|
}
|
|
927
1006
|
function isMergeReceipt(value) {
|
|
928
1007
|
if (!isRecord(value))
|
|
@@ -1203,6 +1282,7 @@ async function recordAcceptedMerge(input) {
|
|
|
1203
1282
|
mergeAuthority: {
|
|
1204
1283
|
task: input.task,
|
|
1205
1284
|
baselineWorkspace: input.guard.baselineWorkspace,
|
|
1285
|
+
verificationAuthority: input.guard.verificationAuthority,
|
|
1206
1286
|
priorMergeReceiptCount: input.state.mergeReceipts.length,
|
|
1207
1287
|
priorMergeReceiptsSha256: sha256(serializeJson(input.state.mergeReceipts)),
|
|
1208
1288
|
stateProjection: controllerStateProjection(input.state),
|
|
@@ -1237,11 +1317,14 @@ async function validateContinueMerge(directory, state, anchor) {
|
|
|
1237
1317
|
}
|
|
1238
1318
|
const authority = mergeAuthorityFromAnchor(anchor, state);
|
|
1239
1319
|
const projection = mergeAuthorityProjection(state, authority);
|
|
1240
|
-
await assertFrozenVerificationSurface(state.repoRoot, authority.verificationSurface);
|
|
1241
1320
|
const task = authority.task;
|
|
1321
|
+
await assertFrozenVerificationSurfaceExcept(state.repoRoot, authority.verificationSurface, isControllerAuthorizedVerificationMergeTask(task, authority.verificationSurface)
|
|
1322
|
+
? task.path
|
|
1323
|
+
: undefined);
|
|
1242
1324
|
const authoritativeGuard = {
|
|
1243
1325
|
task,
|
|
1244
1326
|
baselineWorkspace: authority.baselineWorkspace,
|
|
1327
|
+
verificationAuthority: authority.verificationAuthority,
|
|
1245
1328
|
};
|
|
1246
1329
|
const allowed = new Set(validateTaskBoundary(task));
|
|
1247
1330
|
for (const allowedPath of allowed) {
|
|
@@ -1283,6 +1366,12 @@ async function validateContinueMerge(directory, state, anchor) {
|
|
|
1283
1366
|
return "no-change";
|
|
1284
1367
|
}
|
|
1285
1368
|
const currentSha256 = await verifyMergedFileInvariants(state.repoRoot, task);
|
|
1369
|
+
await validateControllerAuthorizedVerificationMerge({
|
|
1370
|
+
repoRoot: state.repoRoot,
|
|
1371
|
+
task,
|
|
1372
|
+
surface: authority.verificationSurface,
|
|
1373
|
+
guard: authoritativeGuard,
|
|
1374
|
+
});
|
|
1286
1375
|
if (pending && pending.guard.currentSha256 !== currentSha256) {
|
|
1287
1376
|
throw new Error("pending merge acceptance current hash is inconsistent");
|
|
1288
1377
|
}
|
|
@@ -1483,13 +1572,14 @@ async function createVerificationWriteAudit(input) {
|
|
|
1483
1572
|
const failures = [];
|
|
1484
1573
|
const watchers = [];
|
|
1485
1574
|
const watched = new Set();
|
|
1575
|
+
const canonicalRepoRoot = await realpath(input.repoRoot);
|
|
1486
1576
|
const piHome = path.join(os.homedir(), ".pi");
|
|
1487
1577
|
const anchor = await controllerAnchorPath(input.state);
|
|
1488
1578
|
const note = (scope, file) => {
|
|
1489
1579
|
events.push(`${scope}=${file.split(path.sep).join("/")}`);
|
|
1490
1580
|
};
|
|
1491
1581
|
const ignoredWorkspacePath = (candidate) => {
|
|
1492
|
-
const relative = toRepoPath(
|
|
1582
|
+
const relative = toRepoPath(canonicalRepoRoot, candidate);
|
|
1493
1583
|
return (relative === ".git" ||
|
|
1494
1584
|
relative.startsWith(".git/") ||
|
|
1495
1585
|
relative === "node_modules" ||
|
|
@@ -1510,9 +1600,21 @@ async function createVerificationWriteAudit(input) {
|
|
|
1510
1600
|
: canonical;
|
|
1511
1601
|
if (ignore?.(candidate))
|
|
1512
1602
|
return;
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1603
|
+
let existingDirectory = false;
|
|
1604
|
+
try {
|
|
1605
|
+
existingDirectory = lstatSync(candidate).isDirectory();
|
|
1606
|
+
}
|
|
1607
|
+
catch {
|
|
1608
|
+
// A removed or transient path is still a write-boundary event.
|
|
1609
|
+
}
|
|
1610
|
+
// Windows can report a parent-directory notification when a read-only
|
|
1611
|
+
// traversal opens an existing child directory. Nested watchers own real
|
|
1612
|
+
// mutations below that child; persistent changes are also snapshot-checked.
|
|
1613
|
+
if (!existingDirectory) {
|
|
1614
|
+
note(scope, path.relative(directory, candidate) || ".");
|
|
1615
|
+
}
|
|
1616
|
+
// A parent rename can introduce a new directory. Installing its watcher
|
|
1617
|
+
// closes the delayed-descendant gap even when the container event is noise.
|
|
1516
1618
|
void install(scope, candidate, ignore).catch((error) => {
|
|
1517
1619
|
const code = error.code;
|
|
1518
1620
|
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
@@ -1536,7 +1638,7 @@ async function createVerificationWriteAudit(input) {
|
|
|
1536
1638
|
await install(scope, child, ignore);
|
|
1537
1639
|
}
|
|
1538
1640
|
};
|
|
1539
|
-
await install("workspace",
|
|
1641
|
+
await install("workspace", canonicalRepoRoot, ignoredWorkspacePath);
|
|
1540
1642
|
await install("controller", runRoot(input.repoRoot));
|
|
1541
1643
|
await install("controller-anchor", anchor.anchorRoot);
|
|
1542
1644
|
const piHomeEntry = await lstat(piHome).catch((error) => {
|
|
@@ -1901,14 +2003,24 @@ async function runVerificationCommand(input) {
|
|
|
1901
2003
|
}
|
|
1902
2004
|
return result;
|
|
1903
2005
|
}
|
|
1904
|
-
|
|
1905
|
-
const harness = JSON.parse(await readFile(path.join(repoRoot, "harness.json"), "utf-8"));
|
|
2006
|
+
function verificationMatrixPath(harness) {
|
|
1906
2007
|
const governanceRoot = typeof harness.governanceRoot === "string"
|
|
1907
2008
|
? harness.governanceRoot
|
|
1908
2009
|
: "ai_workspace/loop-agent";
|
|
1909
|
-
|
|
2010
|
+
return typeof harness.entrypoints?.verificationMatrix === "string"
|
|
1910
2011
|
? harness.entrypoints.verificationMatrix
|
|
1911
2012
|
: `${governanceRoot}/verification-matrix.md`;
|
|
2013
|
+
}
|
|
2014
|
+
async function readVerificationHarness(repoRoot) {
|
|
2015
|
+
const parsed = JSON.parse(await readFile(path.join(repoRoot, "harness.json"), "utf-8"));
|
|
2016
|
+
if (!isRecord(parsed)) {
|
|
2017
|
+
throw new Error("verification authority harness.json must contain a JSON object");
|
|
2018
|
+
}
|
|
2019
|
+
return parsed;
|
|
2020
|
+
}
|
|
2021
|
+
async function discoverQuickVerification(repoRoot) {
|
|
2022
|
+
const harness = await readVerificationHarness(repoRoot);
|
|
2023
|
+
const matrixPath = verificationMatrixPath(harness);
|
|
1912
2024
|
await assertSafeMergePath(repoRoot, matrixPath);
|
|
1913
2025
|
const matrix = await readFile(path.join(repoRoot, matrixPath), "utf-8");
|
|
1914
2026
|
const targetTests = matrix.match(/^\|\s*target project tests are valid\s*\|\s*`([^`]+)`/im)?.[1];
|
|
@@ -2046,6 +2158,44 @@ async function runFinalVerification(state, authoritativeHomeFingerprint, surface
|
|
|
2046
2158
|
humanDecisionClean: state.humanDecisions.length === 0,
|
|
2047
2159
|
};
|
|
2048
2160
|
}
|
|
2161
|
+
function authoritativeVerificationFailureReason(verification) {
|
|
2162
|
+
const sources = [
|
|
2163
|
+
verification.quickVerification,
|
|
2164
|
+
verification.checkRepo,
|
|
2165
|
+
verification.inspect,
|
|
2166
|
+
verification.docsAudit,
|
|
2167
|
+
];
|
|
2168
|
+
for (const source of sources) {
|
|
2169
|
+
if (!isRecord(source))
|
|
2170
|
+
continue;
|
|
2171
|
+
const candidates = [
|
|
2172
|
+
typeof source.error === "string" ? source.error : undefined,
|
|
2173
|
+
source.exitCode === null && typeof source.stderr === "string"
|
|
2174
|
+
? source.stderr
|
|
2175
|
+
: undefined,
|
|
2176
|
+
];
|
|
2177
|
+
for (const value of candidates) {
|
|
2178
|
+
if (!value)
|
|
2179
|
+
continue;
|
|
2180
|
+
for (const line of value.split(/\r?\n/)) {
|
|
2181
|
+
const reason = line.trim();
|
|
2182
|
+
if (reason.length <= 500 &&
|
|
2183
|
+
(reason ===
|
|
2184
|
+
"verification authority harness.json merge cannot change controller executor or workflow authority" ||
|
|
2185
|
+
reason ===
|
|
2186
|
+
"verification authority rejects direct node code evaluation" ||
|
|
2187
|
+
reason ===
|
|
2188
|
+
"verification authority rejects package command code evaluation" ||
|
|
2189
|
+
/^verification authority (?:surface changed|input is not a regular file): [A-Za-z0-9_./-]+$/.test(reason) ||
|
|
2190
|
+
reason ===
|
|
2191
|
+
"verification authority merge must retain the controller-frozen verification matrix path")) {
|
|
2192
|
+
return reason;
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
return undefined;
|
|
2198
|
+
}
|
|
2049
2199
|
function verificationPassed(verification) {
|
|
2050
2200
|
const doctor = verification.doctor;
|
|
2051
2201
|
const inspect = verification.inspect;
|
|
@@ -2160,9 +2310,15 @@ async function executeUpgrade(directory, state, input) {
|
|
|
2160
2310
|
nextAction: "Controller identity drift detected. Re-run with the frozen controller; no target-project writes were made.",
|
|
2161
2311
|
};
|
|
2162
2312
|
}
|
|
2163
|
-
|
|
2313
|
+
const recordedCurrentChoice = input.mode === "continue" &&
|
|
2314
|
+
input.versionChoice === undefined &&
|
|
2315
|
+
state.versionGate.choice === "current"
|
|
2316
|
+
? "current"
|
|
2317
|
+
: undefined;
|
|
2318
|
+
if (!recordedCurrentChoice)
|
|
2319
|
+
state.phase = "VERSION_GATE";
|
|
2164
2320
|
state.versionGate = await currentVersionGate(input, identity);
|
|
2165
|
-
const choice = input.versionChoice;
|
|
2321
|
+
const choice = input.versionChoice ?? recordedCurrentChoice;
|
|
2166
2322
|
if (state.versionGate.outcome === "newer-available" && !choice) {
|
|
2167
2323
|
state.status = "needs-human-decision";
|
|
2168
2324
|
state.nextAction =
|
|
@@ -2274,6 +2430,13 @@ async function executeUpgrade(directory, state, input) {
|
|
|
2274
2430
|
const activeTask = post.modelMergeTasks[0];
|
|
2275
2431
|
const verificationSurface = await freezeVerificationSurface(state.repoRoot);
|
|
2276
2432
|
assertTaskOutsideVerificationSurface(activeTask, verificationSurface);
|
|
2433
|
+
if (verificationSurface.files.some((entry) => entry.path === normalizeAllowedPath(activeTask.path))) {
|
|
2434
|
+
await validateControllerAuthorizedVerificationMerge({
|
|
2435
|
+
repoRoot: state.repoRoot,
|
|
2436
|
+
task: activeTask,
|
|
2437
|
+
surface: verificationSurface,
|
|
2438
|
+
});
|
|
2439
|
+
}
|
|
2277
2440
|
state.mergeTasks = [activeTask];
|
|
2278
2441
|
state.mergeGuard = await createMergeGuard(state.repoRoot, activeTask);
|
|
2279
2442
|
// Bind the Git-dir anchor to the state actually handed to the model.
|
|
@@ -2329,8 +2492,10 @@ async function executeUpgrade(directory, state, input) {
|
|
|
2329
2492
|
}
|
|
2330
2493
|
else {
|
|
2331
2494
|
state.status = "failed";
|
|
2332
|
-
|
|
2333
|
-
|
|
2495
|
+
const authorityReason = authoritativeVerificationFailureReason(verification);
|
|
2496
|
+
state.nextAction = authorityReason
|
|
2497
|
+
? `Verification failed: ${authorityReason}. Inspect verification.json, repair only the controller-returned concrete merge path, then use --continue.`
|
|
2498
|
+
: "Verification failed. Inspect verification.json; repair only the controller-returned concrete merge path or address the reported external blocker, then use --continue.";
|
|
2334
2499
|
}
|
|
2335
2500
|
await saveState(directory, state);
|
|
2336
2501
|
await writeReport(directory, state, verification);
|
|
@@ -8,6 +8,8 @@ import { cleanupPlaywrightCliDefaultSession, createPlaywrightCliTool, PI_COMMAND
|
|
|
8
8
|
import { dagCommandPolicyAllows, resolveDagCommandPolicy, } from "../workflows/dag/types.js";
|
|
9
9
|
import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
|
|
10
10
|
import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelain, recoverRootNulArtifact, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
|
|
11
|
+
import { isWriterEmptyDiffRetryCandidate, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, } from "../workflows/dag/retry-policy.js";
|
|
12
|
+
import { assessBackendTestMdWriterCompleteness, assessBackendTestPytestWriterCompleteness, classifyBackendTestWriterCompletenessFailure, isBackendTestCompletenessRetryCandidate, writeBackendTestWriterProgressArtifacts, } from "../workflows/dag/backend-test-writer-completeness.js";
|
|
11
13
|
import { redactSecrets, truncateUtf8Preview } from "../shared/preview.js";
|
|
12
14
|
export const DAG_PI_READONLY_TOOLS = ["read", "grep", "find", "ls"];
|
|
13
15
|
/** Bounded writer tools: no unrestricted bash; edit/write are policy-wrapped via SDK customTools. */
|
|
@@ -680,7 +682,39 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
680
682
|
afterStatus: changeManifestAfterStatus ?? "",
|
|
681
683
|
});
|
|
682
684
|
}
|
|
683
|
-
|
|
685
|
+
let completenessFailure;
|
|
686
|
+
if (mapped.ok &&
|
|
687
|
+
writeGuardOk &&
|
|
688
|
+
!writerOutcomeViolation &&
|
|
689
|
+
isBackendTestCompletenessRetryCandidate(input.task)) {
|
|
690
|
+
try {
|
|
691
|
+
const progress = input.task.id === "generate-backend-pytest-pi"
|
|
692
|
+
? await assessBackendTestPytestWriterCompleteness(input.cwd)
|
|
693
|
+
: await assessBackendTestMdWriterCompleteness(input.cwd);
|
|
694
|
+
await writeBackendTestWriterProgressArtifacts({
|
|
695
|
+
runDir: meta.runDir,
|
|
696
|
+
progress,
|
|
697
|
+
attempt: input.attempt ?? 1,
|
|
698
|
+
maxAttempts: input.task.retryPolicy?.maxAttempts ?? 3,
|
|
699
|
+
});
|
|
700
|
+
if (progress.status !== "PASS") {
|
|
701
|
+
const classified = classifyBackendTestWriterCompletenessFailure(progress);
|
|
702
|
+
completenessFailure = {
|
|
703
|
+
failureCategory: classified.recoverable
|
|
704
|
+
? INCOMPLETE_WRITE_SET_RETRY_CATEGORY
|
|
705
|
+
: "invalid-output",
|
|
706
|
+
detail: `backend-test completeness gate ${progress.status}: targets=${progress.targetPaths.join(",") || "(none)"}; issues=${progress.issues.map((issue) => issue.detail).join("; ") || "none"}`,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
catch (error) {
|
|
711
|
+
completenessFailure = {
|
|
712
|
+
failureCategory: "invalid-output",
|
|
713
|
+
detail: `backend-test completeness gate crashed: ${error instanceof Error ? error.message : String(error)}`,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
if (writeGuardOk && !writerOutcomeViolation && !completenessFailure) {
|
|
684
718
|
return mapped;
|
|
685
719
|
}
|
|
686
720
|
const stderrParts = [mapped.stderr];
|
|
@@ -690,6 +724,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
690
724
|
if (writerOutcomeViolation) {
|
|
691
725
|
stderrParts.push(writerOutcomeViolation);
|
|
692
726
|
}
|
|
727
|
+
if (completenessFailure) {
|
|
728
|
+
stderrParts.push(completenessFailure.detail);
|
|
729
|
+
}
|
|
693
730
|
if (meta.writeGuardAttribution === "best-effort") {
|
|
694
731
|
stderrParts.push("write guard note: concurrent rank writers use best-effort per-node attribution; keep same-rank writeSet entries disjoint");
|
|
695
732
|
}
|
|
@@ -699,7 +736,13 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
699
736
|
stderr: stderrParts.filter(Boolean).join("\n\n"),
|
|
700
737
|
failureCategory: mapped.ok
|
|
701
738
|
? writeGuardOk
|
|
702
|
-
?
|
|
739
|
+
? completenessFailure
|
|
740
|
+
? completenessFailure.failureCategory
|
|
741
|
+
: changeManifestChangedFiles?.length === 0 &&
|
|
742
|
+
isWriterEmptyDiffRetryCandidate(input.task) &&
|
|
743
|
+
isChangedWriterImplementationOutcome(mapped.assistantText || mapped.stdout)
|
|
744
|
+
? WRITER_EMPTY_DIFF_RETRY_CATEGORY
|
|
745
|
+
: "invalid-output"
|
|
703
746
|
: "write-guard"
|
|
704
747
|
: mapped.failureCategory,
|
|
705
748
|
durationMs: mapped.durationMs || Date.now() - started,
|
|
@@ -776,6 +819,10 @@ function parseWriterImplementationOutcome(text) {
|
|
|
776
819
|
function isWriterImplementationOutcome(value) {
|
|
777
820
|
return (value === "changed" || value === "already-satisfied" || value === "blocked");
|
|
778
821
|
}
|
|
822
|
+
function isChangedWriterImplementationOutcome(text) {
|
|
823
|
+
const parsed = parseWriterImplementationOutcome(text);
|
|
824
|
+
return parsed.kind === "valid" && parsed.outcome === "changed";
|
|
825
|
+
}
|
|
779
826
|
function writerOutcomeDiagnostics(text, parsed, changedFiles) {
|
|
780
827
|
const firstNonEmpty = text
|
|
781
828
|
.split(/\r?\n/)
|