diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 0724443..c7312d1 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -5,14 +5,14 @@
},
"metadata": {
"description": "Thinloop skills and continuity hooks for Issue-backed discovery, read-only project-status navigation, multi-Issue project DAG decomposition and execution, evidence-backed reengineering and delivery, and approved skill evolution.",
- "version": "0.16.0"
+ "version": "0.16.1"
},
"plugins": [
{
"name": "thinloop",
"source": ".",
"description": "Discovery, Web UIUX, architecture, Next project-status navigation, Project DAG decomposition, Execute READY-wave orchestration, Reengineering gates, QuickDev delivery, maintenance, knowledge, and evolution skills with continuity checks; Project itself remains non-executing.",
- "version": "0.16.0"
+ "version": "0.16.1"
}
]
}
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index 85e6097..3439d43 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "thinloop",
- "version": "0.16.0",
+ "version": "0.16.1",
"description": "Simplify complex development with Issue-backed discovery, risk-adaptive Web experience and architecture design, non-executing multi-Issue project decomposition into dependency DAGs, bounded READY-wave execution, read-only project-status navigation, project-scale refactoring and cross-stack reimplementation, autonomous single-Issue PR delivery, evidence-backed maintenance, concise experiential knowledge, and human-approved skill evolution.",
"author": {
"name": "mindcarver"
diff --git a/.codebuddy-plugin/marketplace.json b/.codebuddy-plugin/marketplace.json
index 0724443..c7312d1 100644
--- a/.codebuddy-plugin/marketplace.json
+++ b/.codebuddy-plugin/marketplace.json
@@ -5,14 +5,14 @@
},
"metadata": {
"description": "Thinloop skills and continuity hooks for Issue-backed discovery, read-only project-status navigation, multi-Issue project DAG decomposition and execution, evidence-backed reengineering and delivery, and approved skill evolution.",
- "version": "0.16.0"
+ "version": "0.16.1"
},
"plugins": [
{
"name": "thinloop",
"source": ".",
"description": "Discovery, Web UIUX, architecture, Next project-status navigation, Project DAG decomposition, Execute READY-wave orchestration, Reengineering gates, QuickDev delivery, maintenance, knowledge, and evolution skills with continuity checks; Project itself remains non-executing.",
- "version": "0.16.0"
+ "version": "0.16.1"
}
]
}
diff --git a/.codebuddy-plugin/plugin.json b/.codebuddy-plugin/plugin.json
index acf362b..70ed624 100644
--- a/.codebuddy-plugin/plugin.json
+++ b/.codebuddy-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "thinloop",
- "version": "0.16.0",
+ "version": "0.16.1",
"description": "Simplify complex development with Issue-backed discovery, risk-adaptive Web experience and architecture design, non-executing multi-Issue project decomposition into dependency DAGs, bounded READY-wave execution, read-only project-status navigation, project-scale refactoring and cross-stack reimplementation, autonomous single-Issue PR delivery, evidence-backed maintenance, concise experiential knowledge, and human-approved skill evolution.",
"author": {
"name": "mindcarver"
diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json
index 0930b7d..8a4ff9f 100644
--- a/.codex-plugin/plugin.json
+++ b/.codex-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "thinloop",
- "version": "0.16.0",
+ "version": "0.16.1",
"description": "Simplify complex development with Issue-backed discovery, risk-adaptive Web experience and architecture design, non-executing multi-Issue project decomposition into dependency DAGs, bounded READY-wave execution, read-only project-status navigation, project-scale refactoring and cross-stack reimplementation, autonomous single-Issue PR delivery, evidence-backed maintenance, concise experiential knowledge, and human-approved skill evolution.",
"author": {
"name": "mindcarver"
diff --git a/.dsh-plugin/continuity.mjs b/.dsh-plugin/continuity.mjs
index f6c9859..98ffb4d 100644
--- a/.dsh-plugin/continuity.mjs
+++ b/.dsh-plugin/continuity.mjs
@@ -40,6 +40,8 @@ function correctiveMessage(text) {
}
export function apply(ctx) {
+ // Per live Agent, without persisting task data or retaining finished agents.
+ const corrections = new WeakMap();
ctx.on("agent/turn-stopping", async ({ agent, signal }) => {
if (signal?.aborted) return;
@@ -50,7 +52,10 @@ export function apply(ctx) {
try {
markdown = await readFile(statePath, "utf8");
} catch (error) {
- if (error?.code === "ENOENT") return; // no fallback state → nothing to protect
+ if (error?.code === "ENOENT") {
+ corrections.delete(agent);
+ return; // no fallback state → nothing to protect
+ }
ctx.logger.warn(
`thinloop continuity check failed open: ${
error instanceof Error ? error.message : String(error)
@@ -60,9 +65,24 @@ export function apply(ctx) {
}
const result = validateState(markdown);
- if (!result.managed || result.issues.length === 0) return;
+ if (!result.managed || result.issues.length === 0) {
+ corrections.delete(agent);
+ return;
+ }
+
+ const previous = corrections.get(agent);
+ if (previous?.statePath === statePath && previous.markdown === markdown) {
+ ctx.logger.warn(
+ `thinloop unresolved continuity handoff: .scd/tasks/current.md ` +
+ `is unchanged after correction; no further steering. ` +
+ `The task is not complete. Resume by fixing the note:\n` +
+ result.issues.map((issue) => `- ${issue}`).join("\n"),
+ );
+ return;
+ }
const issueList = result.issues.map((issue) => `- ${issue}`).join("\n");
+ corrections.set(agent, { statePath, markdown });
agent.steer(
correctiveMessage(
`${result.managedBy} paused before stopping because ` +
diff --git a/.zcode-plugin/plugin.json b/.zcode-plugin/plugin.json
index 552dc85..8ae6d95 100644
--- a/.zcode-plugin/plugin.json
+++ b/.zcode-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "thinloop",
- "version": "0.16.0",
+ "version": "0.16.1",
"description": "Simplify complex development with Issue-backed discovery, risk-adaptive Web experience and architecture design, non-executing multi-Issue project decomposition into dependency DAGs, bounded READY-wave execution, read-only project-status navigation, project-scale refactoring and cross-stack reimplementation, autonomous single-Issue PR delivery, evidence-backed maintenance, concise experiential knowledge, and human-approved skill evolution.",
"author": {
"name": "mindcarver"
diff --git a/README.md b/README.md
index 0f1874a..7b8c3c9 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
- v0.16.0
+ v0.16.1
ISSUE-DRIVEN
diff --git a/docs/installation.md b/docs/installation.md
index e40ce35..552f90e 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -254,6 +254,11 @@ codebuddy plugin update thinloop@thinloop --scope user
- 升级到 v0.16.0:确认 `Thinloop CI`、当前三臂评测、单一路由内核、README 产品表面和
Tag-only GitHub Release 链路均存在;发布范围、证据和已知限制见
[`docs/releases/v0.16.0.md`](./releases/v0.16.0.md)。
+- 升级到 v0.16.1:连续性检查兼容当前中文与旧英文状态,保留明确模板占位符检查,
+ 允许 HTML、泛型与 Markdown 自动链接。Claude Stop 首次要求纠正,重复纠正仍失败时
+ 输出未解决状态交接并结束自动纠正;DSH 对同一 Agent 的未改变错误状态不重复 steer,
+ 状态改变或恢复后可重新纠正。更新后新建会话使新 Hook 生效,版本与验证范围见
+ [`docs/releases/v0.16.1.md`](./releases/v0.16.1.md)。WorkBuddy / ZCode 维持 SKIP。
- 若从 v0.6.x 升级,另确认旧 `scd-dev-loop` 已消失。
更新后可以在 Thinloop 源码仓库运行只读检查器:
diff --git a/docs/releases/v0.16.1.md b/docs/releases/v0.16.1.md
new file mode 100644
index 0000000..7991f7d
--- /dev/null
+++ b/docs/releases/v0.16.1.md
@@ -0,0 +1,15 @@
+# Thinloop v0.16.1
+
+## 范围
+
+- 连续性校验兼容当前中文任务模板和旧英文章节、边界;明确的当前与旧模板占位符及 TODO/TBD 仍必须填写,HTML、泛型和 Markdown 自动链接不再误拦。
+- Claude Stop 第一次发现不可恢复状态时仍要求修复;`stop_hook_active: true` 时输出未解决状态交接并停止自动纠正,交接不会把任务标记为完成。PreCompact 和其他平台原有输出协议不变。
+- DSH 为每个存活 Agent 记录最近一次已纠正状态;同一错误内容不再次 steer,状态改变、恢复或文件移除后允许后续纠正。记录只存在内存,不写入用户状态或数据库。
+- 插件版本、市场清单、README 与升级入口统一为 0.16.1。
+
+## 验证与边界
+
+- 回归检查覆盖合法技术内容、正确填充与未填写的真实中文模板、旧英文状态、TODO/TBD、Claude Stop 首次与重复子进程输出、其他事件协议,以及 DSH 每 Agent 隔离和恢复。
+- DSH 的适配器检查不等于真实模型会话验收;Claude Hook 子进程检查不等于宿主完整任务交付。
+- 合并后刷新受影响的已安装 Agent,再核验版本、Skill 载荷和各平台支持的 Hook;WorkBuddy 与 ZCode 继续 `verification.mode: skip`。
+- 本补丁不表示模型效果、完整交付评测或成本有改善;其他审计项的实现和验收由对应 Issue 单独记录。
diff --git a/hooks/check-state.mjs b/hooks/check-state.mjs
index c43f1e2..8793cd6 100644
--- a/hooks/check-state.mjs
+++ b/hooks/check-state.mjs
@@ -83,6 +83,24 @@ async function main() {
}
const eventName = hookInput.hook_event_name || "lifecycle event";
+ if (
+ process.env.CLAUDE_PLUGIN_ROOT &&
+ !process.env.CODEBUDDY_PLUGIN_ROOT &&
+ !process.env.ZCODE_PLUGIN_ROOT &&
+ eventName === "Stop" &&
+ hookInput.stop_hook_active === true
+ ) {
+ emit({
+ continue: true,
+ systemMessage:
+ `${result.managedBy} unresolved continuity handoff: ` +
+ `.scd/tasks/current.md is still not resumable after correction. ` +
+ `Stopping automatic correction; this task is not complete. ` +
+ `Resume by fixing the note before further delivery:\n` +
+ result.issues.map((issue) => `- ${issue}`).join("\n"),
+ });
+ return;
+ }
block(eventName, result.managedBy, result.issues);
} catch (error) {
emit({
diff --git a/hooks/validate-state.mjs b/hooks/validate-state.mjs
index 5ba34e7..bd712a2 100644
--- a/hooks/validate-state.mjs
+++ b/hooks/validate-state.mjs
@@ -25,6 +25,30 @@ export const REQUIRED_SECTIONS = [
"Next action",
];
+const SECTION_ALIASES = {
+ Outcome: "结果",
+ Boundaries: "边界",
+ Acceptance: "验收条件",
+ Decisions: "决策",
+ Evidence: "证据",
+ "Next action": "下一步行动",
+};
+
+// Current Chinese and previous English current-task templates. Arbitrary angle
+// brackets also occur in HTML, type parameters and Markdown autolinks.
+const TEMPLATE_MARKERS = [
+ "",
+ "",
+ "",
+ "",
+ "",
+ "<命令或可观察行动>",
+ "",
+ "<唯一且具体的行动>",
+ "",
+ "",
+];
+
export function parseFrontmatter(markdown) {
const match = markdown.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
if (!match) {
@@ -61,9 +85,9 @@ export function sectionContent(markdown, heading) {
export function hasPlaceholder(content) {
return (
- /<[^>\r\n]+>/.test(content) ||
+ TEMPLATE_MARKERS.some((marker) => content.includes(marker)) ||
/\[(?:TODO|TBD)\b[^\]]*\]/i.test(content) ||
- /^\s*(?:TODO|TBD)\s*$/im.test(content)
+ /^\s*(?:[-*]\s+)?(?:TODO|TBD)(?:\s*[::].*)?\s*$/im.test(content)
);
}
@@ -102,7 +126,9 @@ export function validateState(markdown) {
const sections = {};
for (const heading of REQUIRED_SECTIONS) {
- const content = sectionContent(markdown, heading);
+ const content =
+ sectionContent(markdown, heading) ??
+ sectionContent(markdown, SECTION_ALIASES[heading]);
sections[heading] = content;
if (content === null) {
issues.push(`missing section: ${heading}`);
@@ -115,8 +141,8 @@ export function validateState(markdown) {
if (
sections.Boundaries &&
- (!/^\s*-\s*In\s*:/im.test(sections.Boundaries) ||
- !/^\s*-\s*Out\s*:/im.test(sections.Boundaries))
+ (!/^\s*-\s*(?:In\s*:|范围内\s*[::])/im.test(sections.Boundaries) ||
+ !/^\s*-\s*(?:Out\s*:|范围外\s*[::])/im.test(sections.Boundaries))
) {
issues.push("Boundaries must include both In and Out entries");
}
diff --git a/marketplace.json b/marketplace.json
index 895c9d0..1c1602a 100644
--- a/marketplace.json
+++ b/marketplace.json
@@ -6,7 +6,7 @@
"name": "thinloop",
"source": ".",
"description": "Discovery, Web UIUX, architecture, Next project-status navigation, Project DAG decomposition, Execute READY-wave orchestration, Reengineering gates, QuickDev delivery, maintenance, knowledge, and evolution skills with continuity checks; Project itself remains non-executing.",
- "version": "0.16.0"
+ "version": "0.16.1"
}
]
}
diff --git a/tests/check-state.test.mjs b/tests/check-state.test.mjs
index 8f5b2fd..fc32de6 100644
--- a/tests/check-state.test.mjs
+++ b/tests/check-state.test.mjs
@@ -353,3 +353,39 @@ test("fails open with a warning on invalid hook input", () => {
fs.rmSync(cwd, { recursive: true, force: true });
}
});
+
+
+test("Claude Stop offers one correction then hands off unresolved state without blocking", () => {
+ const cwd = makeWorkspace();
+ const env = { CLAUDE_PLUGIN_ROOT: path.resolve(testDir, "..") };
+ try {
+ writeState(cwd, validState({ evidence: "" }));
+ const first = parseOutput(runHook(cwd, { stop_hook_active: false }, env));
+ assert.equal(first.decision, "block");
+ assert.match(first.reason, /Update the note/);
+ const repeated = parseOutput(runHook(cwd, { stop_hook_active: true }, env));
+ assert.equal("decision" in repeated, false);
+ assert.equal("hookSpecificOutput" in repeated, false);
+ assert.equal(repeated.continue, true);
+ assert.match(repeated.systemMessage, /unresolved.*handoff/i);
+ assert.match(repeated.systemMessage, /empty section: Evidence/);
+ assert.match(repeated.systemMessage, /not complete/i);
+ writeState(cwd, validState({ evidence: '- clicked ``; checked `Map` and ' }));
+ assert.equal(parseOutput(runHook(cwd, { stop_hook_active: true }, env)), null);
+ } finally { fs.rmSync(cwd, { recursive: true, force: true }); }
+});
+
+test("repeat guard does not change PreCompact or other platform protocols", () => {
+ const cwd = makeWorkspace();
+ try {
+ writeState(cwd, validState({ evidence: "" }));
+ const precompact = parseOutput(runHook(cwd, { hook_event_name: "PreCompact", stop_hook_active: true }, { CLAUDE_PLUGIN_ROOT: "root" }));
+ assert.equal(precompact.decision, "block");
+ const zcode = parseOutput(runHook(cwd, { stop_hook_active: true }, { ZCODE_PLUGIN_ROOT: "root" }));
+ assert.equal(zcode.decision, "block");
+ const workbuddy = parseOutput(runHook(cwd, { stop_hook_active: true }, { CODEBUDDY_PLUGIN_ROOT: "root" }));
+ assert.equal(workbuddy.continue, false);
+ const generic = parseOutput(runHook(cwd, { stop_hook_active: true }));
+ assert.equal(generic.continue, false);
+ } finally { fs.rmSync(cwd, { recursive: true, force: true }); }
+});
diff --git a/tests/release-workflow.test.mjs b/tests/release-workflow.test.mjs
index 6800b59..a330194 100644
--- a/tests/release-workflow.test.mjs
+++ b/tests/release-workflow.test.mjs
@@ -8,6 +8,13 @@ import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const checker = path.join(root, "scripts", "check-version.mjs");
+const version = JSON.parse(fs.readFileSync(
+ path.join(root, ".codex-plugin/plugin.json"), "utf8",
+)).version;
+const tag = `v${version}`;
+const differentVersion = version.replace(/\d+$/, (patch) => Number(patch) + 1);
+const differentTag = `v${differentVersion}`;
+const releaseNotes = `docs/releases/${tag}.md`;
const workflow = fs.readFileSync(
path.join(root, ".github", "workflows", "release.yml"),
"utf8",
@@ -22,7 +29,7 @@ const versionFiles = [
"marketplace.json",
"README.md",
"docs/installation.md",
- "docs/releases/v0.16.0.md",
+ releaseNotes,
];
function makeFixture() {
@@ -53,13 +60,13 @@ test("version checker accepts all carriers, the matching tag, and exact notes",
const result = runChecker(
fixture,
"--tag",
- "v0.16.0",
+ tag,
"--require-release-notes",
);
assert.equal(result.status, 0, result.stderr);
- assert.match(result.stdout, /PASS version 0\.16\.0: 11 surfaces/);
- assert.match(result.stdout, /notes docs\/releases\/v0\.16\.0\.md/);
+ assert.ok(result.stdout.includes(`PASS version ${version}: 11 surfaces`));
+ assert.ok(result.stdout.includes(`notes ${releaseNotes}`));
} finally {
fs.rmSync(fixture, { recursive: true, force: true });
}
@@ -70,14 +77,14 @@ test("version checker rejects a mismatched carrier", () => {
try {
const manifestPath = path.join(fixture, ".claude-plugin", "plugin.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
- manifest.version = "0.15.0";
+ manifest.version = differentVersion;
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
const result = runChecker(fixture);
assert.equal(result.status, 1);
assert.match(result.stderr, /.claude-plugin\/plugin\.json version/);
- assert.match(result.stderr, /expected 0\.16\.0/);
+ assert.ok(result.stderr.includes(`expected ${version}`));
} finally {
fs.rmSync(fixture, { recursive: true, force: true });
}
@@ -86,13 +93,12 @@ test("version checker rejects a mismatched carrier", () => {
test("version checker rejects a tag that differs from the manifest", () => {
const fixture = makeFixture();
try {
- const result = runChecker(fixture, "--tag", "v0.16.1");
+ const result = runChecker(fixture, "--tag", differentTag);
assert.equal(result.status, 1);
- assert.match(
- result.stderr,
- /tag v0\.16\.1 does not match manifest version 0\.16\.0/,
- );
+ assert.ok(result.stderr.includes(
+ `tag ${differentTag} does not match manifest version ${version}`,
+ ));
} finally {
fs.rmSync(fixture, { recursive: true, force: true });
}
@@ -103,14 +109,13 @@ test("version checker consumes a tag-shaped GITHUB_REF_NAME", () => {
try {
const result = runCheckerWithEnv(
fixture,
- { GITHUB_REF_NAME: "v0.16.1" },
+ { GITHUB_REF_NAME: differentTag },
);
assert.equal(result.status, 1);
- assert.match(
- result.stderr,
- /tag v0\.16\.1 does not match manifest version 0\.16\.0/,
- );
+ assert.ok(result.stderr.includes(
+ `tag ${differentTag} does not match manifest version ${version}`,
+ ));
} finally {
fs.rmSync(fixture, { recursive: true, force: true });
}
@@ -119,16 +124,16 @@ test("version checker consumes a tag-shaped GITHUB_REF_NAME", () => {
test("version checker rejects missing exact-tag release notes", () => {
const fixture = makeFixture();
try {
- fs.rmSync(path.join(fixture, "docs", "releases", "v0.16.0.md"));
+ fs.rmSync(path.join(fixture, "docs", "releases", `${tag}.md`));
const result = runChecker(
fixture,
"--tag",
- "v0.16.0",
+ tag,
"--require-release-notes",
);
assert.equal(result.status, 1);
- assert.match(result.stderr, /missing release notes: docs\/releases\/v0\.16\.0\.md/);
+ assert.ok(result.stderr.includes(`missing release notes: ${releaseNotes}`));
} finally {
fs.rmSync(fixture, { recursive: true, force: true });
}
diff --git a/tests/validate-state.test.mjs b/tests/validate-state.test.mjs
index 5a7abae..c5f8287 100644
--- a/tests/validate-state.test.mjs
+++ b/tests/validate-state.test.mjs
@@ -1,5 +1,8 @@
import assert from "node:assert/strict";
import test from "node:test";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
import {
parseFrontmatter,
@@ -95,3 +98,76 @@ test("DSH continuity plugin exports a Cordis plugin shape", () => {
assert.ok(Array.isArray(plugin.inject));
assert.equal(typeof plugin.apply, "function");
});
+
+
+test("valid technical content is not a template placeholder", () => {
+ for (const evidence of [
+ '- observed `` responds to a click',
+ '- compiled `Promise` and `Map`',
+ '- verified and ',
+ ]) assert.deepEqual(validateState(state({ evidence })).issues, [], evidence);
+});
+
+test("current Chinese template must be filled and remains resumable", async () => {
+ const template = await fs.readFile(new URL("../skills/scd-quickdev/assets/current-task.md", import.meta.url), "utf8");
+ const filled = template
+ .replace("", "https://github.com/example/project/issues/123")
+ .replace("", "2026-09-05T12:00:00+08:00")
+ .replace("", "A1:检查通过")
+ .replace("<命令或可观察行动>", "node --test:通过")
+ .replace("<唯一且具体的行动>", "提交验收记录。");
+ assert.deepEqual(validateState(filled).issues, []);
+ for (const marker of ["", "<命令或可观察行动>", "<唯一且具体的行动>"]) {
+ const replacement = marker.includes("Issue") ? "A1:检查通过" : marker.includes("命令") ? "node --test:通过" : "提交验收记录。";
+ assert.ok(validateState(filled.replace(replacement, marker)).issues.some(issue => /unresolved placeholder/.test(issue)), marker);
+ }
+ assert.ok(validateState(template).issues.length > 0);
+ assert.ok(validateState(filled.replace("node --test:通过", "").replace("- 尚未执行;计划检查:", "")).issues.some(issue => /empty section: Evidence/.test(issue)));
+});
+
+test("known template markers and explicit TODO or TBD still fail", () => {
+ for (const evidence of [
+ "", "",
+ "", "",
+ "[TODO] capture result", "[TBD evidence]", "TODO", "TBD", "- TODO: capture result",
+ ]) assert.ok(validateState(state({ evidence })).issues.some(issue => /unresolved placeholder/.test(issue)), evidence);
+});
+
+test("DSH bounds unchanged invalid-state steering per agent and rearms after recovery", async () => {
+ const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "thinloop-dsh-hook-"));
+ const statePath = path.join(cwd, ".scd", "tasks", "current.md");
+ const messages = [], warnings = [];
+ const agent = { session: { header: { cwd } }, steer: message => messages.push(message) };
+ let listener;
+ plugin.apply({ on(event, callback) { assert.equal(event, "agent/turn-stopping"); listener = callback; }, logger: { warn: message => warnings.push(message) } });
+ try {
+ await fs.mkdir(path.dirname(statePath), { recursive: true });
+ const invalid = state({ evidence: "" });
+ await fs.writeFile(statePath, invalid);
+ await listener({ agent, signal: { aborted: true } });
+ assert.equal(messages.length, 0);
+ await listener({ agent });
+ assert.equal(messages.length, 1);
+ assert.match(messages[0].content[0].text, /empty section: Evidence/);
+ await listener({ agent });
+ await listener({ agent });
+ assert.equal(messages.length, 1);
+ assert.match(warnings[0], /unresolved.*handoff/i);
+ const another = { session: { header: { cwd } }, steer: message => messages.push(message) };
+ await listener({ agent: another });
+ assert.equal(messages.length, 2);
+ await fs.writeFile(statePath, state({ evidence: "[TODO] new failure" }));
+ await listener({ agent });
+ assert.equal(messages.length, 3);
+ for (const recovered of [state(), state({ managedBy: "another-tool" }), null]) {
+ if (recovered === null) await fs.unlink(statePath);
+ else await fs.writeFile(statePath, recovered);
+ await listener({ agent });
+ const before = messages.length;
+ await fs.writeFile(statePath, invalid);
+ await listener({ agent });
+ await listener({ agent });
+ assert.equal(messages.length, before + 1);
+ }
+ } finally { await fs.rm(cwd, { recursive: true, force: true }); }
+});