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 }); } +});