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