diff --git a/docs/harness/reviews/task_3_0_1_w1_sticky_table_source_audit_R1_20260918.md b/docs/harness/reviews/task_3_0_1_w1_sticky_table_source_audit_R1_20260918.md
index 5323aef..9020984 100644
--- a/docs/harness/reviews/task_3_0_1_w1_sticky_table_source_audit_R1_20260918.md
+++ b/docs/harness/reviews/task_3_0_1_w1_sticky_table_source_audit_R1_20260918.md
@@ -2,7 +2,7 @@
> **hat**:20-task-audit(书面审查 · R1 · 独立上下文 · 非起草者)
> **日期**:2026-09-18
-> **被审对象**:[`docs/tasks/active/task_3_0_1_w1_sticky_table_source.md`](../../tasks/active/task_3_0_1_w1_sticky_table_source.md)(slug `3-0-1-w1-sticky-table-source` · `draft` · 无独立 SPEC · HG-SPEC-SIGNOFF=N/A)
+> **被审对象**:[`docs/tasks/done/task_3_0_1_w1_sticky_table_source.md`](../../tasks/done/task_3_0_1_w1_sticky_table_source.md)(slug `3-0-1-w1-sticky-table-source` · `draft` · 无独立 SPEC · HG-SPEC-SIGNOFF=N/A)
> **对照基准**:[`docs/roadmap/PLAN_3_0_1_patch_v1_zh.md`](../../roadmap/PLAN_3_0_1_patch_v1_zh.md)(HG-NEXT-PLAN=approved · **W1 节** + 硬约束 **3/4/5**)· [`docs/harness/reviews/acceptance_3_0_0_specwave_20260918.md`](./acceptance_3_0_0_specwave_20260918.md) **§6.1 P1-1** · 10 invoke `docs/harness/invokes/by-task/3-0-1-w1-sticky-table-source/invoke_20260918_10_3-0-1-w1-sticky-table-source.md` · 先例形态 [`task_2_4_2_patch_audit_R1_20260915.md`](./task_2_4_2_patch_audit_R1_20260915.md)
> **审查方式**:只读通读 task + PLAN W1/硬约束 + §6.1 P1-1;**独立再钉** `sticky.ts` / `cmd.ts` / `table.ts` 行号现值;可选跑 `node bin/specgate.js task lint`(**LINT: PASS**);**未改被审 task 任何字节 · 未改 `src/`**。
diff --git a/docs/harness/reviews/task_3_0_1_w3_pins_io_failclosed_audit_R1_20260918.md b/docs/harness/reviews/task_3_0_1_w3_pins_io_failclosed_audit_R1_20260918.md
index 0bb524f..03a8457 100644
--- a/docs/harness/reviews/task_3_0_1_w3_pins_io_failclosed_audit_R1_20260918.md
+++ b/docs/harness/reviews/task_3_0_1_w3_pins_io_failclosed_audit_R1_20260918.md
@@ -2,7 +2,7 @@
> **hat**:20-task-audit(书面审查 · R1 · 独立上下文 · 非起草者)
> **日期**:2026-09-18
-> **被审对象**:[`docs/tasks/active/task_3_0_1_w3_pins_io_failclosed.md`](../../tasks/active/task_3_0_1_w3_pins_io_failclosed.md)(slug `3-0-1-w3-pins-io-failclosed` · `draft` · 无独立 SPEC · HG-SPEC-SIGNOFF=N/A)
+> **被审对象**:[`docs/tasks/done/task_3_0_1_w3_pins_io_failclosed.md`](../../tasks/done/task_3_0_1_w3_pins_io_failclosed.md)(slug `3-0-1-w3-pins-io-failclosed` · `draft` · 无独立 SPEC · HG-SPEC-SIGNOFF=N/A)
> **对照基准**:[`docs/roadmap/PLAN_3_0_1_patch_v1_zh.md`](../../roadmap/PLAN_3_0_1_patch_v1_zh.md)(HG-NEXT-PLAN=approved · **W3 节** + 硬约束 **3/5**)· [`acceptance_3_0_0_specwave_20260918.md`](./acceptance_3_0_0_specwave_20260918.md) **§6.3 P3-8** · 10 invoke `docs/harness/invokes/by-task/3-0-1-w3-pins-io-failclosed/invoke_20260918_10_3-0-1-w3-pins-io-failclosed.md` · 先例形态 [`task_3_0_1_w2_gate_table_contract_audit_R1_20260918.md`](./task_3_0_1_w2_gate_table_contract_audit_R1_20260918.md)
> **审查方式**:只读通读 task + PLAN W3/硬约束 3·5 + §6.3 P3-8;**独立再钉** `src/cli-pins.ts` `loadPins` / `readTruthVersion` 行号;`node bin/specgate.js task lint` → **LINT: PASS**;**未改被审 task 任何字节 · 未改 `src/`**。
diff --git a/docs/harness/reviews/task_3_0_1_w5_pin_hook_version_audit_R1_20260918.md b/docs/harness/reviews/task_3_0_1_w5_pin_hook_version_audit_R1_20260918.md
index d471980..eb7102d 100644
--- a/docs/harness/reviews/task_3_0_1_w5_pin_hook_version_audit_R1_20260918.md
+++ b/docs/harness/reviews/task_3_0_1_w5_pin_hook_version_audit_R1_20260918.md
@@ -2,7 +2,7 @@
> **hat**:20-task-audit(书面审查 · R1 · 独立上下文 · 非起草者)
> **日期**:2026-09-18
-> **被审对象**:[`docs/tasks/active/task_3_0_1_w5_pin_hook_version.md`](../../tasks/active/task_3_0_1_w5_pin_hook_version.md)(slug `3-0-1-w5-pin-hook-version` · `draft` · 无独立 SPEC · HG-SPEC-SIGNOFF=N/A)
+> **被审对象**:[`docs/tasks/done/task_3_0_1_w5_pin_hook_version.md`](../../tasks/done/task_3_0_1_w5_pin_hook_version.md)(slug `3-0-1-w5-pin-hook-version` · `draft` · 无独立 SPEC · HG-SPEC-SIGNOFF=N/A)
> **对照基准**:[`docs/roadmap/PLAN_3_0_1_patch_v1_zh.md`](../../roadmap/PLAN_3_0_1_patch_v1_zh.md)(HG-NEXT-PLAN=approved · **W5 节** + 硬约束 **3/5** · 及 7/8/10)· [`acceptance_3_0_0_specwave_20260918.md`](./acceptance_3_0_0_specwave_20260918.md) **§6.2 P2-2**(`:367-372`)· 10 invoke `docs/harness/invokes/by-task/3-0-1-w5-pin-hook-version/invoke_20260918_10_3-0-1-w5-pin-hook-version.md` · 先例形态 [`task_3_0_1_w4_docs_precision_audit_R1_20260918.md`](./task_3_0_1_w4_docs_precision_audit_R1_20260918.md)
> **审查方式**:只读通读 task + PLAN W5/硬约束 3·5(及 7/8/10)+ 验收 §6.2 P2-2;**独立再钉** `hooks.ts` / `materialize.ts` / `cmd.ts` / 手册 §7.3 / `package.json#version` / `RELEASING.md`;`node bin/specgate.js task lint` → **LINT: PASS**;**未改被审 task 任何字节 · 未改 `src/`**。
diff --git a/docs/tasks/active/.gitkeep b/docs/tasks/active/.gitkeep
new file mode 100644
index 0000000..69d1ad1
--- /dev/null
+++ b/docs/tasks/active/.gitkeep
@@ -0,0 +1 @@
+# 空目录占位。git 不跟踪空目录;task 全部进 done/ 后 active/ 仍须存在。
diff --git a/scripts/check-doc-links.mjs b/scripts/check-doc-links.mjs
index 98518f9..0544ba8 100644
--- a/scripts/check-doc-links.mjs
+++ b/scripts/check-doc-links.mjs
@@ -3,11 +3,12 @@
// 3.0 W7 CI hotfix(task_3_0_w7_ci_hotfix · 验收 #1/#2 · 硬约束 6/10):(i) 判据改「入库状态而非文件系统状态」——
// 本机 `.workbuddy/`(.gitignore 忽略)除 **9 件显式 tracked**(判据:`git ls-files .workbuddy/`)外均忽略;未入库实体曾使
// 11 处冻结坏链被 existsSync 掩盖(本地 23 假绿 / CI 干净 clone 34 真红)。
-// inRepo 目标 = tracked 文件 ∪ tracked 目录前缀(git 不跟踪目录 · 目录链须命中);仓外目标维持 existsSync。
+// inRepo 目标 = (tracked 文件 ∪ tracked 目录前缀)且工作区仍在。仅索引(rename 后未刷新)不算可解析;
+// 仅磁盘(.workbuddy 未入库)也不算。仓外目标维持 existsSync。
// tracked 集合以 `-z` 原样读取(避免非 ASCII 路径 quotePath 八进制转义)。冻结基线按修后本地/干净 clone 双跑实测重建 23 → 34。
//
// 两级判据(SPEC 08 §5.2):
-// (i) 可解析:docs/**/*.md 的 Markdown 相对链接(inline + reference-definition + )解析后目标须**已入库**(仓外目标须存在)。
+// (i) 可解析:docs/**/*.md 的 Markdown 相对链接(inline + reference-definition + )解析后,仓内目标须已入库且工作区仍在(仓外目标须存在)。
// (ii) 目标已入库:docs/ 内指向 `.workbuddy/…` 的链接目标须 `git ls-files` 命中(可解析但未入库 = 坏链)。
// S2 域(docs/tasks/ · docs/harness/reviews/ · docs/harness/invokes/by-task/)整体豁免 + 冻结基线
// (永不覆写 · 历史 stale 链接不可修 · 硬约束 1):S2 (i) 处数须 == 冻结基线(参数化排除 current task 路径)
@@ -112,8 +113,10 @@ export function checkDocLinks({ root, gitTracked, s2Baseline = S2_FROZEN_BASELIN
const targetRel = path.relative(root, targetAbs).split(path.sep).join('/')
const inRepo = !targetRel.startsWith('..') && !path.isAbsolute(targetRel)
const isWorkbuddy = /(^|\/)\.workbuddy\//.test(targetRel) || clean.includes('.workbuddy/')
- // (i) 可解析 = 入库状态:inRepo 须 tracked 文件或 tracked 目录前缀;仓外才看文件系统(F-HOT2-02)
- const exists = inRepo ? gitTracked.has(targetRel) || trackedDirs.has(targetRel) : existsSync(targetAbs)
+ // (i) 可解析 = 入库且工作区仍在。仅索引(task close 的 renameSync 不更新 index)不算;
+ // 仅磁盘不算(.workbuddy 假绿 · F-HOT2-02)。目录链仍用 tracked 祖先前缀。
+ const trackedHit = gitTracked.has(targetRel) || trackedDirs.has(targetRel)
+ const exists = inRepo ? trackedHit && existsSync(targetAbs) : existsSync(targetAbs)
const entry = { file: rel, line, target: clean, resolve: targetRel }
const bucket = isS2(rel) ? broken.s2 : broken.nonS2
if (!exists) bucket.i.push(entry)
diff --git a/src/cli/close-retarget.ts b/src/cli/close-retarget.ts
new file mode 100644
index 0000000..40d4520
--- /dev/null
+++ b/src/cli/close-retarget.ts
@@ -0,0 +1,139 @@
+import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
+import path from 'node:path'
+import { toRel } from '../cli-shared.ts'
+
+/**
+ * task close 真归档后,把 docs 里「解析后正好指向刚搬走的那个文件」的 Markdown 链接
+ * 改到新路径。只改链接目标,以及该链接标签里的仓内旧路径。
+ * 围栏、行内代码、指向其它文件的链接不动。
+ * 与 scripts/check-doc-links.mjs extractLinks 同口径:跳过 ``` 围栏与行内代码。
+ */
+const INLINE_RE = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g
+const REF_RE = /^\s*!?\[[^\]]+\]:\s*(\S+)/
+const HTML_RE = /]*href=["']?([^"'\s>]+)/gi
+
+function maskInlineCode(line: string): string {
+ return line.replace(/`[^`]*`/g, (m) => ' '.repeat(m.length))
+}
+
+function isExternal(target: string): boolean {
+ return /^(#|https?:\/\/|mailto:|tel:|data:|\/\/)/i.test(target)
+}
+
+function pointsAt(sourceAbs: string, url: string, fromAbs: string): boolean {
+ const clean = url.split('#')[0]
+ if (!clean || isExternal(url)) return false
+ return path.resolve(path.dirname(sourceAbs), clean) === path.resolve(fromAbs)
+}
+
+function linkHref(fromFileAbs: string, toAbs: string, hash: string): string {
+ let rel = path.relative(path.dirname(fromFileAbs), toAbs).split(path.sep).join('/')
+ if (!rel.startsWith('.')) rel = `./${rel}`
+ return hash ? `${rel}${hash}` : rel
+}
+
+type Hit = { start: number; end: number; text: string }
+
+function consider(
+ hits: Hit[],
+ line: string,
+ urlStart: number,
+ url: string,
+ sourceAbs: string,
+ fromAbs: string,
+ toAbs: string,
+ root: string,
+ labelStart: number,
+ labelEnd: number,
+): boolean {
+ if (!pointsAt(sourceAbs, url, fromAbs)) return false
+ const hash = url.includes('#') ? url.slice(url.indexOf('#')) : ''
+ hits.push({ start: urlStart, end: urlStart + url.length, text: linkHref(sourceAbs, toAbs, hash) })
+ if (labelStart >= 0 && labelEnd > labelStart) {
+ const fromRel = toRel(root, fromAbs)
+ const toRelPath = toRel(root, toAbs)
+ const label = line.slice(labelStart, labelEnd)
+ if (fromRel && fromRel !== '.' && label.includes(fromRel)) {
+ hits.push({ start: labelStart, end: labelEnd, text: label.split(fromRel).join(toRelPath) })
+ }
+ }
+ return true
+}
+
+function applyHits(line: string, hits: Hit[]): string {
+ const sorted = [...hits].sort((a, b) => b.start - a.start)
+ let out = line
+ for (const h of sorted) out = out.slice(0, h.start) + h.text + out.slice(h.end)
+ return out
+}
+
+/** 改一份 Markdown。count 只计被改写的链接目标,不计标签。 */
+export function retargetMarkdown(
+ content: string,
+ sourceAbs: string,
+ fromAbs: string,
+ toAbs: string,
+ root: string,
+): { text: string; count: number } {
+ const lines = content.split('\n')
+ let inFence = false
+ let count = 0
+ const out = lines.map((line) => {
+ if (/^\s*```/.test(line)) {
+ inFence = !inFence
+ return line
+ }
+ if (inFence) return line
+ const masked = maskInlineCode(line)
+ const hits: Hit[] = []
+ INLINE_RE.lastIndex = 0
+ let m: RegExpExecArray | null
+ while ((m = INLINE_RE.exec(masked)) !== null) {
+ const url = m[1]
+ if (!url) continue
+ const urlStart = m.index + m[0].indexOf('(') + 1
+ const labelStart = m.index + (m[0].startsWith('!') ? 2 : 1)
+ const labelEnd = m.index + m[0].indexOf('](')
+ if (consider(hits, line, urlStart, url, sourceAbs, fromAbs, toAbs, root, labelStart, labelEnd)) count += 1
+ }
+ HTML_RE.lastIndex = 0
+ while ((m = HTML_RE.exec(masked)) !== null) {
+ const url = m[1]
+ if (!url) continue
+ const urlStart = m.index + m[0].lastIndexOf(url)
+ if (consider(hits, line, urlStart, url, sourceAbs, fromAbs, toAbs, root, -1, -1)) count += 1
+ }
+ const ref = REF_RE.exec(masked)
+ if (ref?.[1]) {
+ const urlStart = ref.index + ref[0].lastIndexOf(ref[1])
+ if (consider(hits, line, urlStart, ref[1], sourceAbs, fromAbs, toAbs, root, -1, -1)) count += 1
+ }
+ return hits.length === 0 ? line : applyHits(line, hits)
+ })
+ return { text: out.join('\n'), count }
+}
+
+function walkMd(dir: string, out: string[]): void {
+ if (!existsSync(dir)) return
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
+ if (ent.name.startsWith('.')) continue
+ const p = path.join(dir, ent.name)
+ if (ent.isDirectory()) walkMd(p, out)
+ else if (/\.md$/i.test(ent.name)) out.push(p)
+ }
+}
+
+/** 扫描 root/docs 下全部 Markdown,改写指向 fromAbs 的链接。返回改写条数。 */
+export function retargetClosedTaskLinks(root: string, fromAbs: string, toAbs: string): number {
+ const files: string[] = []
+ walkMd(path.join(root, 'docs'), files)
+ let total = 0
+ for (const file of files) {
+ const before = readFileSync(file, 'utf8')
+ const { text, count } = retargetMarkdown(before, file, fromAbs, toAbs, root)
+ if (count === 0 || text === before) continue
+ writeFileSync(file, text)
+ total += count
+ }
+ return total
+}
diff --git a/src/cli/task-cmd.ts b/src/cli/task-cmd.ts
index 5b8eeab..f0c9b73 100644
--- a/src/cli/task-cmd.ts
+++ b/src/cli/task-cmd.ts
@@ -5,6 +5,7 @@ import { appendAuditEvent, resolveAuditFile, stampAuditEvent } from '../audit/lo
import { buildDoneSnapshot, extractTaskSlug, fail, findGitRoot, parseHarnessMeta, printJson, takeOption, toRel } from '../cli-shared.ts'
import { evalCloseExecEvidence, evalCloseGuard, lintTaskFile } from '../cli-checks.ts'
import { cmdTaskCheck, cmdTaskLintDone, cmdTaskLintWikiDelta } from '../cli-task-extra.ts'
+import { retargetClosedTaskLinks } from './close-retarget.ts'
import { CLOSE_GUARD_ORDER, TASK_USAGE } from './usage.ts'
async function cmdTaskLint(args: string[]): Promise {
@@ -169,6 +170,10 @@ async function cmdTaskClose(args: string[]): Promise {
if (!dest) fail('无法解析归档目标', 2)
mkdirSync(path.dirname(dest), { recursive: true })
renameSync(abs, dest)
+ // 归档使 active/ 路径失效。同一次 --yes 改写 docs 里正好指向该文件的 Markdown 链接,
+ // 不依赖 30 事后手改。dry-run 不执行。历史指向其它文件的坏链不动。
+ const retargeted = retargetClosedTaskLinks(closeJsonBase, abs, dest)
+ traces.push(`close: retarget · ${retargeted}`)
// K5:真归档(renameSync 执行)后构建 done 片段快照 —— 摘录归档文件内
// HARNESS_META_HEADING 节原文(extractSection · 归档真值防模板漂移);快照存在性
// 唯绑归档事件,与豁免旗标无关(20 审 R2 口径裁决:豁免 + --yes → 快照照打)
@@ -191,6 +196,7 @@ async function cmdTaskClose(args: string[]): Promise {
// 2.4-W3(D-24-OUTPUT-REL-EXIT):人类输出路径值同口径相对化(冻结文案 CLOSE: PASS 不动)
console.log(`moved: ${toRel(process.cwd(), abs)} → ${toRel(process.cwd(), dest)}`)
console.log(`CLOSE: PASS · ${slug}`)
+ console.log(`retarget: ${retargeted}`)
// K5(W1):PASS 分支 stdout 追加快照块 —— 归档后路径 + 元信息节摘录 + 禁手写提示;
// 取不到元信息节(异常态)打 canonical 模板占位 + WARN 行;不改 CLOSE: PASS 冻结文案,仅追加
console.log(`done_snapshot · path: ${toRel(process.cwd(), snapshot.path)}`)
diff --git a/test/check-doc-links.test.ts b/test/check-doc-links.test.ts
index 4947d91..a40a698 100644
--- a/test/check-doc-links.test.ts
+++ b/test/check-doc-links.test.ts
@@ -96,4 +96,17 @@ describe('3.0-W7 S7.7 · check-doc-links(两级机检 failClosed)', { concur
assert.equal(miss.status, 2, miss.combined)
})
})
+
+ it('索引仍有、工作区已搬走 → (i) 坏链(rename 不刷新 index 不再假绿)', async () => {
+ await withTemp(async (dir) => {
+ await writeRel(dir, 'docs/roadmap/a.md', '# a\n\n[t](./b.md)\n')
+ await writeRel(dir, 'docs/roadmap/b.md', '# b\n')
+ execGit(dir, ['init', '-q'])
+ execGit(dir, ['add', 'docs/roadmap/a.md', 'docs/roadmap/b.md'])
+ await rm(path.join(dir, 'docs', 'roadmap', 'b.md'))
+ const gone = run(dir, ['--s2-baseline', '0'])
+ assert.equal(gone.status, 2, gone.combined)
+ assert.match(gone.combined, /docs\/roadmap\/a\.md/)
+ })
+ })
})
diff --git a/test/close-retarget.test.ts b/test/close-retarget.test.ts
new file mode 100644
index 0000000..76ea3dc
--- /dev/null
+++ b/test/close-retarget.test.ts
@@ -0,0 +1,121 @@
+import assert from 'node:assert/strict'
+import { existsSync } from 'node:fs'
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import { describe, it } from 'node:test'
+import { retargetMarkdown } from '../src/cli/close-retarget.ts'
+import { runCore } from './_helpers/core-harness.ts'
+
+const ROOT = '/repo'
+const SOURCE = path.join(ROOT, 'docs/harness/reviews/r.md')
+const FROM = path.join(ROOT, 'docs/tasks/active/t.md')
+const TO = path.join(ROOT, 'docs/tasks/done/t.md')
+
+describe('task close · 归档后改写指向该文件的链接', () => {
+ it('只改指向本文件的链接;围栏、行内代码、其它文件、旁白不动', () => {
+ const input = [
+ '[`docs/tasks/active/t.md`](../../tasks/active/t.md) 审时为 active/',
+ '',
+ '```',
+ '[x](../../tasks/active/t.md)',
+ '```',
+ '',
+ '行内 `[nope](../../tasks/active/t.md)` 不动',
+ '',
+ '[other](../../tasks/active/other.md)',
+ '',
+ '[hash](../../tasks/active/t.md#s)',
+ '',
+ '[ref]: ../../tasks/active/t.md',
+ '',
+ 'x',
+ ].join('\n')
+ const { text, count } = retargetMarkdown(input, SOURCE, FROM, TO, ROOT)
+ assert.equal(count, 4)
+ assert.match(text, /\[`docs\/tasks\/done\/t\.md`\]\(\.\.\/\.\.\/tasks\/done\/t\.md\) 审时为 active\//)
+ assert.match(text, /```\n\[x\]\(\.\.\/\.\.\/tasks\/active\/t\.md\)\n```/)
+ assert.match(text, /行内 `\[nope\]\(\.\.\/\.\.\/tasks\/active\/t\.md\)` 不动/)
+ assert.match(text, /\[other\]\(\.\.\/\.\.\/tasks\/active\/other\.md\)/)
+ assert.match(text, /\[hash\]\(\.\.\/\.\.\/tasks\/done\/t\.md#s\)/)
+ assert.match(text, /\[ref\]: \.\.\/\.\.\/tasks\/done\/t\.md/)
+ assert.match(text, //)
+ })
+
+ it('task close --yes 改审查文链接;dry-run 不改', async () => {
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'dsh-ck-retarget-'))
+ await mkdir(path.join(dir, '.git'), { recursive: true })
+ try {
+ const taskRel = 'docs/tasks/active/task_retarget.md'
+ const taskAbs = path.join(dir, taskRel)
+ await mkdir(path.dirname(taskAbs), { recursive: true })
+ await writeFile(
+ taskAbs,
+ [
+ '# Task retarget',
+ '',
+ '> **状态**:`done`',
+ '',
+ '## Harness 元信息',
+ '',
+ '| 字段 | 值 |',
+ '|------|-----|',
+ '| **task_slug** | `retarget` |',
+ '| **test_strategy** | `recommended` |',
+ '| **wiki_delta** | `none` |',
+ '',
+ '### 人工闸',
+ '',
+ '| human_gate_id | status | blocks_hats | 说明 |',
+ '|---------------|--------|-------------|------|',
+ '| HG-TASK-DRAFT | approved | 20,30 | fixture |',
+ '| HG-AUDIT-R1 | approved | 30 | fixture |',
+ '',
+ '## 验收标准',
+ '',
+ '- [x] fixture item',
+ '',
+ '## 失败路径',
+ '',
+ '| F | Scenario |',
+ '|---|----------|',
+ '| F1 | fixture |',
+ '',
+ '### 自检结论(执行者)',
+ '',
+ '自检已回填:fixture retarget。',
+ '',
+ ].join('\n'),
+ 'utf8',
+ )
+ const reviewRel = 'docs/harness/reviews/r.md'
+ const reviewAbs = path.join(dir, reviewRel)
+ await mkdir(path.dirname(reviewAbs), { recursive: true })
+ const review = '[`docs/tasks/active/task_retarget.md`](../../tasks/active/task_retarget.md)\n'
+ await writeFile(reviewAbs, review, 'utf8')
+ const allow = [
+ '--allow-invoke-gap', '--allow-no-review', '--allow-kpi-gap', '--allow-wiki-gap',
+ '--allow-no-pr-merge', '--allow-no-hub', '--allow-experience-gap',
+ ]
+
+ const dry = await runCore(['task', 'close', '--file', taskAbs, ...allow])
+ assert.equal(dry.status, 0, dry.combined)
+ assert.match(dry.stdout, /CLOSE: READY/)
+ assert.equal(await readFile(reviewAbs, 'utf8'), review)
+ assert.equal(existsSync(taskAbs), true)
+
+ const pass = await runCore(['task', 'close', '--file', taskAbs, '--yes', ...allow])
+ assert.equal(pass.status, 0, pass.combined)
+ assert.match(pass.stdout, /CLOSE: PASS · retarget/)
+ assert.match(pass.stdout, /retarget: 1/)
+ assert.equal(existsSync(taskAbs), false)
+ assert.equal(existsSync(path.join(dir, 'docs/tasks/done/task_retarget.md')), true)
+ const after = await readFile(reviewAbs, 'utf8')
+ assert.match(after, /docs\/tasks\/done\/task_retarget\.md/)
+ assert.match(after, /\.\.\/\.\.\/tasks\/done\/task_retarget\.md/)
+ assert.equal(after.includes('tasks/active/'), false)
+ } finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+})