From 05cc2c4d87d363032c1c0608d7fbdc94d49befae Mon Sep 17 00:00:00 2001 From: hkwuks <25031515+hkwuks@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:29:27 +0800 Subject: [PATCH 1/2] fix: merge-todo treats ## Removed as a deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit key_of took the whole "- ID: TF-x (removed …)" suffix, and split_todo gave the entire ## Removed blob one key (the first tombstone's ID), so a tombstone never matched the live entry. The driver kept the other side's live copy as "only they have it" and resurrected it — seen live when a rebase of the housekeeping commit restored three already-removed todos. - key_of: first whitespace token; ## Removed always head:Removed - a live entry whose ID sits in the other side's Removed set is not emitted - two changed Removed bodies union by tombstone ID instead of conflicting Data: drop resurrected live sections for 4d8ae2/76e7fb/2877ca (tombstones remain); keep live 985164 (this task) and drop its false v1.0.7 tombstone. No duplicate - ID: lines remain. Smoke covers delete/keep in both directions and the tombstone union. --- .../2026-09-22-merge-todo-removed/plan.md | 116 +++++++++++++++ .../2026-09-22-merge-todo-removed/prd.md | 133 ++++++++++++++++++ TaskFlowDocs/todo.md | 99 ++----------- hooks/merge-todo | 124 ++++++++++++++-- hooks/smoke-test | 89 ++++++++++++ 5 files changed, 464 insertions(+), 97 deletions(-) create mode 100644 TaskFlowDocs/2026-09-22-merge-todo-removed/plan.md create mode 100644 TaskFlowDocs/2026-09-22-merge-todo-removed/prd.md diff --git a/TaskFlowDocs/2026-09-22-merge-todo-removed/plan.md b/TaskFlowDocs/2026-09-22-merge-todo-removed/plan.md new file mode 100644 index 0000000..082fb96 --- /dev/null +++ b/TaskFlowDocs/2026-09-22-merge-todo-removed/plan.md @@ -0,0 +1,116 @@ +# Plan — Fix `hooks/merge-todo` so a `## Removed` record matches its live entry by ID token and deletion wins + +> Task version: v1 +> Status: ready + +No spec required — small, self-contained task. + +## Reference Pointers + +- `hooks/merge-todo` — `key_of`, `Removed` key, deletion-wins, Removed union. +- `hooks/todo-check` `removed_at` — single `## Removed` range (C). +- `hooks/smoke-test` — new section `== todo merge driver treats ## Removed … ==`. +- `TaskFlowDocs/todo.md` — R5 data cleanup. + +## Related Tasks + +- `TF-20260918-985164` — this task's Todo item. +- `task remove` gate bypass — deferred (PRD Q2). + +## Skills / Tools Used + +- `Unaided — no capability applied to this phase; considered: shell/awk text + processing, POSIX-portability references, and the repository's own merge + driver conventions, all read directly.` + +## Preconditions + +- [x] Docs/rules inspected; worktree `.worktrees/fix-merge-todo-removed` on + `fix/merge-todo-removed` from `main` = `9887dc3`. +- [x] Bug reproduced (four duplicated IDs after rebase) and root-caused. + +## Approval + +- Status: approved +- Approved by: user +- Approved at: 2026-09-22 +- Approved version: v1 +- Approved scope: R1–R7; union on dual tombstone edits (Q1); gate fix out + (Q2). + +## Steps + +### Step 1 — merge-todo: token key, Removed key, deletion wins, union + +- Goal: R1–R4. +- Files: `hooks/merge-todo`. +- Implementation checklist: + - [x] `key_of`: first whitespace token; `Removed` heading → `head:Removed`. + - [x] Per-side removed-ID sets; skip lone live copy when the other side + names the ID (both only-we-have and theirs-only paths). + - [x] Dual-changed `## Removed` → union tombstones by ID, exit 0. + - [x] Header Rules comment updated (deletion is `## Removed` and wins). + - [x] `bash -n hooks/merge-todo`. +- Acceptance: A. +- Verification: smoke delete/keep fixtures (both directions) + union. +- Status: done + +### Step 2 — smoke assertions + +- Goal: R6/R7. +- Files: `hooks/smoke-test`. +- Implementation checklist: + - [x] Ours deletes / theirs keeps live → tombstone only, no conflict. + - [x] Symmetric reverse. + - [x] Both add tombstones → union, one `## Removed`. + - [x] Existing merge driver sections still pass. +- Acceptance: D. +- Verification: one full `bash hooks/smoke-test` → `ALL SMOKE PASSED`. +- Status: done + +### Step 3 — clear duplicate IDs in todo.md + +- Goal: R5/B/C. +- Files: `TaskFlowDocs/todo.md`. +- Implementation checklist: + - [x] `4d8ae2` / `76e7fb` / `2877ca`: live sections removed; tombstones kept. + - [x] `985164`: **live kept** (this task, promoted); false v1.0.7 tombstone + dropped — PRD R5 corrected: one record per ID, not “always drop live”. + - [x] `uniq -d` empty; single `## Removed` with three tombstones. +- Acceptance: B, C. +- Status: done + +## Checkpoints + +- After Step 1: `bash -n` + fixtures — done. +- After Step 2: one full suite (after killing a stuck run per memory); + `ALL SMOKE PASSED`; process count 0. +- After Step 3: `uniq -d` empty. + +## Verification / Review + +| Check | Result | +|---|---| +| `bash -n hooks/merge-todo` | pass | +| `bash -n hooks/smoke-test` | pass | +| `bash hooks/smoke-test` | `ALL SMOKE PASSED` | +| `git diff --check` | clean | +| `uniq -d` on todo IDs | empty | +| Delete/keep + union fixtures | suite section green | + +## Change Log + +- v1 — approved and implemented; R5 data rule clarified: keep live `985164` + (active task), drop its obsolete tombstone; three stale IDs keep tombstones + only. + +## Follow-ups + +- `task remove` gate vs pre-rewritten `Task:` — separate if desired. +- Archive family `172455` / `88e04c` / `e7c041`. +- `b7821b` Approval shape. + +## Version History + +- v1 — planning. +- v1 — approved (user, 2026-09-22); Steps 1–3 done; suite green. diff --git a/TaskFlowDocs/2026-09-22-merge-todo-removed/prd.md b/TaskFlowDocs/2026-09-22-merge-todo-removed/prd.md new file mode 100644 index 0000000..7d08d0a --- /dev/null +++ b/TaskFlowDocs/2026-09-22-merge-todo-removed/prd.md @@ -0,0 +1,133 @@ +# Fix `hooks/merge-todo` so a `## Removed` record matches its live entry by ID token and deletion wins + +> Task version: v1 +> Status: ready + +## Goal + +`hooks/merge-todo` treats a `## Removed` tombstone as an unrelated entry (its +key is the whole `… (removed …)` string), so a live entry the other side still +holds is classified “only they have it” and is **resurrected**. Fix the key, +make deletion win when one side has a tombstone and the other has the live +entry, and clear the duplicate IDs that resurrection left in `todo.md`. + +## Background / Confirmed Facts + +All measured on `main` = `9887dc3`, 2026-09-22, in worktree +`.worktrees/fix-merge-todo-removed` unless noted. Nothing here is inferred. + +**Original report (entry Notes, v1.0.7 era).** Live entry deleted → tombstone +written → merge `944d833` restored the live entry → same ID twice. Notes name +two roots: (1) `task remove` gate bypassed by rewriting `Task:` to +`Not promoted.` first; (2) `merge-todo` `key_of` using `substr($0, 7)` so the +tombstone key is `id:TF-… (removed …)` ≠ live `id:TF-…`. Directed fix: token +key + “Removed on one side, live on the other → delete”; **do not only tighten +the remove gate**. + +**Live reproduction — our own rebase of the housekeeping commit.** + +Commit `b9fd49e` correctly deleted three stale live entries and wrote three +tombstones (`todo.md` −74 lines). `git rebase origin/main` onto PR #49 +(`86a6a24`) invoked `merge=taskflow-todo`. Result `9887dc3` (pushed) has +`tombstones + live bodies` for the same IDs: + +| ID | Live line | Tombstone line | +|---|---|---| +| `TF-20260921-4d8ae2` | 15 | 990 | +| `TF-20260919-76e7fb` | 801 | 991 | +| `TF-20260919-2877ca` | 821 | 992 | +| `TF-20260918-985164` | 783 | 989 (pre-existing) | + +`grep -o '^- ID: TF-…' | sort | uniq -d` returns all four. + +**Why key_of alone is not enough.** `split_todo` cuts on `## `, so **all +tombstones live in one section** headed `## Removed`. `key_of` then reads only +the **first** `- ID:` inside that section. The Removed blob never carries keys +for IDs 2..n, and never equals a live entry’s `id:TF-…` unless the first +tombstone happens to be that ID and the suffix matches (it does not). + +**Why the live entry survives.** Driver rule (merge-todo:167–170): “Only we +have it: either we added it, or they removed it. **Keep it**.” During rebase, +theirs still had live `4d8ae2`; ours had no live section (only a line inside +`## Removed`). No match → emit theirs → resurrection. The comment assumes a +deleted entry simply disappears; `remove` moves it into `## Removed`, which +this driver does not join to the live key space. + +**`remove` itself works** on a clean fixture (live section gone, one tombstone +under `## Removed`). The gate bypass is historical; the merge bug is what +**undoes** a correct remove. + +**Structural facts the fix must respect:** + +- `## Removed` must remain **one** heading with tombstone lines under it + (todo-check: `sed -n '/^## Removed$/,/^## /p'`). +- Live sections stay separate `## title` blocks keyed by `- ID:` token. +- Existing smoke: disjoint adds, one-sided edit, double-edit conflict, + install idempotence — all must stay green. + +## Requirements + +- **R1.** `key_of`: ID key is the **first whitespace-delimited token** after + `- ID:` (tombstone `TF-x (removed …)` → `id:TF-x`). +- **R2.** A section whose heading is `Removed` is always keyed + `head:Removed` (never by the first tombstone’s ID), so both sides’ Removed + blobs match each other regardless of which tombstone is first. +- **R3.** Deletion wins on one-sided presence: if side A has a **live** entry + with ID `X` and side B’s Removed set contains `X`, do **not** emit A’s live + `X`. Symmetric for the other side. Removed-set membership uses R1’s token. +- **R4.** When both sides have `## Removed` and only one changed it relative to + base, the existing one-sided rule takes that side (already true if R2 holds). + When **both** added different tombstones, **union** tombstone lines by ID + token (order: ours first, then theirs-only), exit 0 — not a conflict. +- **R5.** Data cleanup in this PR’s `todo.md`: exactly one `- ID:` record per + duplicated ID. `4d8ae2` / `76e7fb` / `2877ca` keep **tombstone only**; + `985164` keeps the **live** promoted entry for this task and drops the false + v1.0.7 tombstone. `uniq -d` empty. +- **R6.** Smoke: (a) ours deletes live X→tombstone, theirs leaves X alone → + merge has tombstone, no live X; (b) symmetric; (c) both add tombstones → + union, no conflict; (d) existing merge sections green. +- **R7.** `bash hooks/smoke-test` → `ALL SMOKE PASSED`. + +## Acceptance Criteria + +- **A.** Reproduction of the rebase case (ours=deleted+tombstone, theirs=live + unchanged) yields tombstone only. +- **B.** `grep -o '^- ID: TF-…' TaskFlowDocs/todo.md | sort | uniq -d` empty + after R5. +- **C.** todo-check still reads all tombstones from one `## Removed` range. +- **D.** Suite green; `bash -n hooks/merge-todo`. + +## In Scope + +- `hooks/merge-todo` (key_of, Removed key, deletion-wins, Removed union). +- `hooks/smoke-test` — R6 assertions. +- `TaskFlowDocs/todo.md` — R5 cleanup + this task’s status. +- Task documents. + +## Out of Scope + +- Tightening `task remove`’s `Task:` gate (Notes: necessary but not + sufficient; gate-only leaves merge resurrection). +- `hooks/todo-check` (already extracts tombstone IDs correctly). +- Re-attempting the historical v1.0.7 archive (dir already gone). + +## Risks / Deferred Items + +- Delete/modify where theirs **also** edited the live body: R3 skips the live + entry (deletion wins) and may drop their edit. Preferable to resurrection; + a future rule could conflict instead — not observed, not required now. +- Gate bypass (rewrite `Task:` first) remains possible until a separate fix. + +## Open Questions + +1. Union tombstones when both sides add (R4) vs conflict — **Recommend: + union** (parallel removes are ordinary; conflict on two tombstone lines is + noise). +2. Fix `remove` gate in this PR? — **Recommend: no** (Notes explicitly says + gate-only is the wrong fix; keep this PR on the merge path + data). + +## Version History + +- v1 — planning. +- v1 — ready; approved by user 2026-09-22 (R1–R7; Q1 union, Q2 no gate fix; + R5 wording corrected: live `985164` kept). diff --git a/TaskFlowDocs/todo.md b/TaskFlowDocs/todo.md index cb6d7f3..8aa3e20 100644 --- a/TaskFlowDocs/todo.md +++ b/TaskFlowDocs/todo.md @@ -9,39 +9,24 @@ This is the repository's single lightweight intake list. It stores triage metada ## Items +## Fix hooks/merge-todo so a Removed record matches its live entry by ID token -## SessionStart fails the repository-docs index read across hosts and on index shape - -- ID: TF-20260921-4d8ae2 -- Status: inbox -- Priority: high +- ID: TF-20260918-985164 +- Status: promoted +- Priority: normal - Owner: Codex - Source: user request -- Added: 2026-09-21 -- Updated: 2026-09-21 -- Goal: Make the repository-docs index read tolerate what different hosts and older writers put in the file, instead of failing SessionStart with `repository-document index contains an invalid row`. -- Task: Not promoted. -- Next action: Fix landed on fix/repository-docs-reader; awaiting review. -- Notes: **2026-09-21 用户报错**:`SessionStart:resume hook error — Failed with non-blocking status code: repository-document index contains an invalid row`。用户判断这是**兼容性问题**。 -- **2026-09-21 已实现(未合并)**:实际根因与本条原判断不同——不是「某个 host 写出了怪行」,是**读取器把列数硬编码成 6–8、并要求路径必须带反引号**。实测证据:把 的 index(4 列、无 Status、无反引号)喂给当前 hook,直接复现 。该文件由**同一个 hook 的旧版本**写出,所以任何早期版本写下的 index 都会让后续 SessionStart 失败。修法:改成**按表头列名定位列**(Class / Source / Phases / Exists / Status),4/5/6/7 列全部可读,路径反引号可选,状态允许大小写与空格;仅当表头点名了 Status 列而该格是日期(说明该行掉格)才拒绝,无表头时退化为位置解析并仍拒绝日期/状态形状的路径。== every hook parses under the shell that is running == - **现场已核**:仓库当前 `TaskFlowDocs/repository-docs/index.md`(5 列,无日期)在本机 WSL 上跑不出这个错——我在同一检出上跑 `bash hooks/repository-docs-context` 是 rc=0、无 stderr。**所以触发条件不是当前这份文件**,而是某个 checkout / 某个 host 上形态不同的那份。 - **报错点**:`hooks/repository-docs-context:139-140`——awk 把无法解析的行计入 `bad`,写到 `$work/badcount`,非空即 `fail "repository-document index contains an invalid row"`。(注意与另一条**不同**的错误:`hooks/repository-docs-context:204` 的并发分支报的是 `repository-docs index is busy: `,不是这一条。) - **§ 已用变异实测的拒绝条件**(逐条跑过,见下表):解析现在要求 (a) 行以 `|` 开头且非分隔行/表头;(b) `split($0,f,"|")` 得到 **6–8** 个字段;(c) `f[3]` 必须**被反引号包裹** `/^`[^`]+`$/`;(d) 状态列(倒数第二格)必须匹配 `/^[a-z][a-z-]*$/`。 - - | 变异 | 结果 | - |---|---| - | 旧 6 列行(带 `Last checked` 日期格) | **ok**(`57d507a` 已刻意兼容) | - | 行尾多两个空格 | ok | - | **路径不带反引号** `\| repository-rule \| CONTRIBUTING.md \| … \|` | **FAIL** | - | **状态首字母大写** `Ready` | **FAIL** | - | **状态含空格** `needs review` | **FAIL** | - | CRLF(`\r\n`) | ok(`.gitattributes` 有 `* text=auto eol=lf`,实测通过) | - | gawk / mawk / nawk 三种 awk | 行解析结果一致,非 awk 方言问题 | - - **待查的关键半段(未完成)**:`1c5e59c`(09-16)的 index 行与今天形状相同,且我实测的第 7 列计数、列数分支都过了,**尚未定位是哪个写入方**在哪个 host 上写出了触发的那一行。下一步必须先拿到**出错现场的 `index.md` 与 host**(另开一个会话复现,或从报错机器的检出取那份文件),再决定修法——否则就是照猜测改检查。 - **修法方向(待现场确认后定,二选一)**:(a) 让解析器更宽(反引号可选、状态允许大小写与空格);(b) 只把这一列读进来做 carry-over 时用宽松口径,渲染仍严格。**先别放宽**:放宽会同时削弱 `hooks/smoke-test:338` 那条「畸形行必须被拒」的断言(`\| malformed row \|`),要一起改并补断言。 - **为什么它值得 high**:这条错误发生在 **SessionStart**,即每个会话开头——一旦命中,注入的仓库路由上下文就整段丢失,且是 **non-blocking**,用户只看到一行报错、不影响继续用,所以极易被忽略到下一次踩更重的坑。相关:`TF-20260921-337ab8`(本轮同批立的 `task intake` 条目)。 -- **2026-09-21 已实现(未合并)**:实际根因与本条原判断不同——不是「某个 host 写出了怪行」,是**读取器把列数硬编码成 6–8、并要求路径必须带反引号**。实测证据:把 `Node/Mimir` 的 index(4 列、无 Status、路径无反引号)喂给当前 hook,直接复现 `repository-document index contains an invalid row`;该文件由**同一个 hook 的旧版本**写出,所以任何早期版本写下的 index 都会让后续 SessionStart 失败。修法:改成**按表头列名定位列**(Class / Source / Phases / Exists / Status),4/5/6/7 列全部可读,路径反引号可选,状态允许大小写与空格;仅当表头点名了 Status 列而该格是日期(说明该行掉格)才拒绝,无表头时退化为位置解析并仍拒绝日期形状的路径。`hooks/smoke-test` 加了 7 条断言(4 种可读形态 + 反引号可选 + 状态宽容 + 移位行仍拒绝),并做过一次变异验证(把旧严格规则放回去,套件失败)。已核四个真实仓库(Mimir / cc-switch / Survival / TaskFlow)的 index 全部读通。 +- Added: 2026-09-18 +- Updated: 2026-09-22 +- Goal: Fix hooks/merge-todo so a ## Removed record matches its live entry by ID token and deletion wins, then clear the duplicate IDs that resurrection left in todo.md. +- Task: `TaskFlowDocs/2026-09-22-merge-todo-removed/` +- Next action: Complete PRD / Spec / Plan and request approval. +- Notes: **2026-09-19 复核(本条是同一 ID 的另一半,见文件末尾 `## Removed`)**:v1.0.7 已正常发布(tag `v1.0.7` = `c3c536d`),发布事务本身没有问题。真正发生的是**工作区/提交时序错乱**:本条目在 `3f683a5`(10:40)还是活条目,`86a0605`(10:41)被整个删掉,同一分钟 `9406720` 又在 `## Removed` 写下移除记录,声称「v1.0.7 已发布、目录已随 PR #42 删除」——而 PR #42 到 19:35 才合并,发布是 21:21。**移除记录先于它声称的事实写下。** 被删的活条目随后在 19 小时后由合并 `944d833`(`feature/dsh-host` 反向合并 main)复活,于是同一 ID 在文件里出现两次。 + + **两处根因(都不在发布流程)**:(1) **闸门被前置改写绕过**——`hooks/task remove` 拒绝删除 `Task:` 不是 `Not promoted.` 的条目,而 `3f683a5` 先把该条目的 `Task:` 改成了 `Not promoted.`,闸门于是放行;「已交付但记录未生」的状态由此可以把一条活条目删掉。(2) **`hooks/merge-todo` 认不出 `## Removed`**——移除记录行以 `- ID: TF-20260918-985164 (removed …)` 开头,而 `key_of` 用 `substr($0, 7)` 取 ID,得到的是 ` TF-20260918-985164 (removed 2026-09-19: …`(整段含括号),与活条目的 key `id:TF-20260918-985164` 不相等。driver 的规则是「key 只在一侧存在就保留该侧」且注释明写 entry 永不被删除,所以被删的活条目被当成「我们加过、他们没动」保留下来。**`## Removed` 记录在 driver 眼里只是一条 ID 不同的新条目**,既不表示删除也不参与匹配。 + + **修法方向(未开工)**:让 `key_of` 只取 ID 的首个空白分隔 token,并让 driver 在「某侧有 Removed 记录、另一侧有同名活条目」时按删除处理。**别只修 `task remove` 的闸门**——那只是让删除更难发生,没解决「删了也会被 merge 复活」。 + ## Windows worktree misjudgement: `hooks/task` reads a `D:/` git dir as relative. @@ -778,59 +763,6 @@ Every direct request or imported requireme - Next action: Complete PRD / Spec / Plan and request approval. - Notes: 本缺陷在归档 2026-09-18-todo-field-writes 时发现:`task get` 只打印 `^- ` 开头的行(`hooks/task` 的 `entry` 分支),而 Notes 的第二条及以后按仓库既有习惯写成**两空格缩进的 `- ` 行**,于是它们不出现在输出里,**且没有提示**。危害不是报错,是**静默**:调用方拿到一份看似完整、实则缺段的条目,据此决策。判据:本次取证用 `bash hooks/task get TF-20260918-454ac4` 只回出 Notes 的第一行,而文件里它下面还有三条缩进续行。修法二选一:(a) 把条目正文的缩进行也算正文一并打印;(b) 至少 stderr 提示该条目有 N 行未显示。v2 已合并,本缺陷未修。同族:`task next` 写 Notes 时用的是同一套字段边界(`isfield`),那边的续行判定虽已覆盖缩进,但输出侧没跟上。 -## Publish TaskFlow v1.0.7: the conflict-review rule, the executable launcher, task - -- ID: TF-20260918-985164 -- Status: in_progress -- Priority: normal -- Owner: Codex -- Source: user request -- Added: 2026-09-18 -- Updated: 2026-09-19 -- Goal: Publish TaskFlow v1.0.7: the conflict-review rule, the executable launcher, task next/get, and the README surfaces that shipped after v1.0.6. -- Task: `TaskFlowDocs/2026-09-18-release-v1-0-7/` -- Next action: Step 1 done; run the Validation checklist. -- Notes: **2026-09-19 复核(本条是同一 ID 的另一半,见文件末尾 `## Removed`)**:v1.0.7 已正常发布(tag `v1.0.7` = `c3c536d`),发布事务本身没有问题。真正发生的是**工作区/提交时序错乱**:本条目在 `3f683a5`(10:40)还是活条目,`86a0605`(10:41)被整个删掉,同一分钟 `9406720` 又在 `## Removed` 写下移除记录,声称「v1.0.7 已发布、目录已随 PR #42 删除」——而 PR #42 到 19:35 才合并,发布是 21:21。**移除记录先于它声称的事实写下。** 被删的活条目随后在 19 小时后由合并 `944d833`(`feature/dsh-host` 反向合并 main)复活,于是同一 ID 在文件里出现两次。 - - **两处根因(都不在发布流程)**:(1) **闸门被前置改写绕过**——`hooks/task remove` 拒绝删除 `Task:` 不是 `Not promoted.` 的条目,而 `3f683a5` 先把该条目的 `Task:` 改成了 `Not promoted.`,闸门于是放行;「已交付但记录未生」的状态由此可以把一条活条目删掉。(2) **`hooks/merge-todo` 认不出 `## Removed`**——移除记录行以 `- ID: TF-20260918-985164 (removed …)` 开头,而 `key_of` 用 `substr($0, 7)` 取 ID,得到的是 ` TF-20260918-985164 (removed 2026-09-19: …`(整段含括号),与活条目的 key `id:TF-20260918-985164` 不相等。driver 的规则是「key 只在一侧存在就保留该侧」且注释明写 entry 永不被删除,所以被删的活条目被当成「我们加过、他们没动」保留下来。**`## Removed` 记录在 driver 眼里只是一条 ID 不同的新条目**,既不表示删除也不参与匹配。 - - **修法方向(未开工)**:让 `key_of` 只取 ID 的首个空白分隔 token,并让 driver 在「某侧有 Removed 记录、另一侧有同名活条目」时按删除处理。**别只修 `task remove` 的闸门**——那只是让删除更难发生,没解决「删了也会被 merge 复活」。 - -## Make a release stop going through the TaskFlow PRD/branch flow: it re-plans an e - -- ID: TF-20260919-76e7fb -- Status: inbox -- Priority: normal -- Owner: Codex -- Source: user request -- Added: 2026-09-19 -- Updated: 2026-09-19 -- Goal: Make a release stop going through the TaskFlow PRD/branch flow: it re-plans an existing RELEASE.md procedure, and its documents cannot be inherited because the task type does not exist. -- Task: Not promoted. -- Next action: Delivered by 2026-09-19-release-flow-exception; awaiting that task's merge. -- Notes: **2026-09-19 用户实测后提出**:发布 v1.0.7 太慢,且**又走了 PRD 与分支的旧流程**(当天先改了一轮才纠正分支,Plan 从 157 行压到 116 行仍偏重)。用户已选定方向:**发布完全不走 TaskFlow 的 PRD/Plan**,只留精简记录。 -- **2026-09-19 交付**:本条的修法已落地,取的是「发布完全不走 TaskFlow」这一形态(用户当时选定的方向),不是给 `promote` 加 `release` 类型。落点:`SKILL.md` 的适用性门禁与 frontmatter description 去掉发布、写明例外;`RELEASE.md` 开头声明它在 base 检出直接执行、不建任务、不写 PRD/Spec/Plan,批准门禁随程序走;`CONTRIBUTING.md` 的 `## TaskFlow workflow` 与 `## Working branches` 各留例外(含「发布不切分支」的理由);`references/artifacts.md` 的发布节改为「发布不使用任务文档」;两份 README 同步。断言的落地见 `TF-20260919-2877ca`。 - **两处根因(我先查的,不是猜的)**: - (1) **任务类型不存在**。`hooks/task promote ` 只有两个尺寸选项,没有 `release` 类型。于是每个发布任务都被生成成通用的七节 PRD 骨架(Goal / Background / Requirements / Acceptance / In Scope / Out of Scope / Risks / Open Questions),而 `skills/taskflow/references/artifacts.md:115-121` 的「Release task documents」规则要求**记录决策与结果、不重述程序**——生成的骨架与规则直接冲突,每次都靠人手削。v1.0.4 77 行、v1.0.5 101、v1.0.6 141、v1.0.7 116,一轮比一轮重。 - (2) **规则是渐进披露的,但发布头几步没人会去读它**。`SKILL.md` 的 `## Supporting references` 明写 `artifacts.md` 是「Read these only when needed」,而发布任务的定义(`RELEASE.md:18`:创建一个发布任务)出现在 SKILL.md 的 Phase 1 之前,那时还没有任何东西提示去读 `artifacts.md`。规则存在 ≠ 规则生效——本条就是活例:`RELEASE.md` 与 `artifacts.md` 里都写着正确答案,我读了却没对上自己的动作。 - **分支那半的根因**:`CONTRIBUTING.md:16` 的硬要求是「每个任务一个短生命周期分支」,它没有给发布留例外;而 `RELEASE.md` 的默认路径是**直接从 main 打标签**。两条规则互相矛盾时,默认读到的是 `CONTRIBUTING.md`(它是 code 阶段的硬规则),于是自加了 `chore/release-v1-0-7`。 - **修法**(待开工时定):给发布一个不再走 PRD/Plan 的路径——可能是 `promote ` 增加 `release` 形态只生成一行记录,或 `RELEASE.md` 直接规定「发布不创建 TaskFlow 任务,只在 `achieved/` 留一条结果记录」;同时明确 `CONTRIBUTING.md` 的分支要求是否豁免发布。相关:`TF-20260919-2877ca`(让程序自述)、`TF-20260919-b7821b`(修 `version` 的 Approval 形状)。 - -## Make the release procedure self-describing so a release task does not need a PRD - -- ID: TF-20260919-2877ca -- Status: inbox -- Priority: normal -- Owner: Codex -- Source: audit follow-up -- Added: 2026-09-19 -- Updated: 2026-09-19 -- Goal: Make the release procedure self-describing so a release task does not need a PRD-and-Plan re-planning cycle. -- Task: Not promoted. -- Next action: Delivered by 2026-09-19-release-flow-exception; awaiting that task's merge. -- Notes: **2026-09-19 提出**:发布流程目前靠「先读 RELEASE.md 再看 SKILL.md 再想起来 artifacts.md 有发布规则」这条链条,任何一环没接上就退回通用流程。可操作的方向是让 **RELEASE.md 自己成为入口**——在它开头写一行「本程序由 TaskFlow 的发布流程执行;不创建 PRD/Spec/Plan,只留结果记录」,把规则推到 Agent 一定会读到的地方(执行发布时读的正是 RELEASE.md)。另一种是让 `hooks/task` 在识别到发布类目标时直接把 RELEASE.md 的路径写进任务记录。与 `TF-20260919-76e7fb` 同源:那条决定发布要不要走 TaskFlow,本条决定如果不走,规则放在哪才不会被跳过。 -- **2026-09-19 交付**:本条问的是「规则放哪才不会被跳过」,答案是 **`RELEASE.md` 自己**——发布执行时读的正是它,所以规则写在开头第 5 行,而不是留在只被渐进披露的 `references/artifacts.md` 里。相应地,`RELEASE.md` 的三处「record … in the release task」改为写进 CHANGELOG 段与 Release 正文。另用 `hooks/smoke-test` 的一条断言锁住这段文本,改坏即失败——这是「规则存在」与「规则生效」的分界。 - ## Make hooks/version write the same five-field Approval block hooks/task generates - ID: TF-20260919-b7821b @@ -986,7 +918,6 @@ Every direct request or imported requireme ## Removed -- ID: TF-20260918-985164 (removed 2026-09-19: release v1.0.7 shipped; its task document was deleted with the release-workflow change in PR #42, and the entry was promoted to that directory) - ID: TF-20260921-4d8ae2 (removed 2026-09-22: stale: fix merged on main in PR 46 (02c1441); entry still said awaiting review) - ID: TF-20260919-76e7fb (removed 2026-09-22: stale: delivered by release-flow-exception; merged in PR 42 (caad35b)) - ID: TF-20260919-2877ca (removed 2026-09-22: stale: delivered by release-flow-exception; merged in PR 42 (caad35b)) diff --git a/hooks/merge-todo b/hooks/merge-todo index 77a3981..09e2728 100755 --- a/hooks/merge-todo +++ b/hooks/merge-todo @@ -12,20 +12,26 @@ # # Rules: # * The preamble before the first `## ` heading comes from ours. -# * An entry is identified by its `- ID:` value, or by its heading when it has -# no ID yet. Entries are matched by that key, not by position. -# * An entry present on only one side is taken from that side. +# * An entry is identified by its `- ID:` value's first whitespace token, or +# by its heading when it has no ID yet. Entries are matched by that key, +# not by position. `## Removed` is always keyed `head:Removed` so the +# tombstone block matches across sides regardless of which record is first. +# * An entry present on only one side is taken from that side — unless the +# other side's `## Removed` names that ID, in which case the deletion wins +# and the lone live copy is not emitted (a tombstone is not a second entry). # * An entry both sides have, byte-identical, is taken once. # * An entry only one side changed relative to the ancestor is taken from that # side — this is the ordinary case, two branches each touching their own entry. +# * Two different `## Removed` bodies are unioned by tombstone ID, not +# conflicted: parallel removes are ordinary. # * An entry both sides changed differently is a real conflict: rewrite %A with # Git conflict markers and exit 1, so `git merge` reports it. # * Order is ours, then the entries only theirs, appended, so a merge cannot # reorder entries the user already sees. # -# The ancestor is consulted only to tell "we changed it" from "they did". A Todo -# entry is never deleted and its heading is never rewritten, so there is no -# delete/modify or rename/modify case for a full three-way merge to arbitrate. +# The ancestor is consulted only to tell "we changed it" from "they did". A live +# Todo entry disappears only by moving into `## Removed` (hooks/task remove); +# that move must survive a merge rather than resurrect on the other side. set -euo pipefail LC_ALL=C export LC_ALL @@ -36,7 +42,7 @@ base="$1"; ours="$2"; theirs="$3" work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT -# Split a todo file into its preamble and one record per `## ` entry. Entries are +# Split a todo file into a preamble and one record per `## ` entry. Entries are # written to `entry.N`; `key.N` holds the heading, which is the fallback identity. split_todo() { # split_todo mkdir -p "$2" @@ -75,7 +81,19 @@ split_todo() { # split_todo # the same heading text for different items, but the ID is what Todo commands # resolve against. It is emitted alongside the heading so an entry whose ID one # side has not written yet still matches its counterpart. +# +# The ID key is the first whitespace token only. A tombstone line is +# `- ID: TF-x (removed …)`; taking the whole suffix made that a different key +# from the live entry's `id:TF-x`, so a deletion never matched and the other +# side's live copy was kept as "only they have it". +# +# `## Removed` is one section holding every tombstone, so it never keys off the +# first record's ID — always `head:Removed`. key_of() { # key_of + if [ "$2" = "Removed" ]; then + printf '%s\n' "head:Removed" + return 0 + fi # One key per entry, no more: `exit` still runs END in awk, so the fallback has # to be suppressed by a flag rather than by exiting early. An extra line here # would shift every later key against its entry. @@ -83,7 +101,8 @@ key_of() { # key_of /^- ID:/ { v = substr($0, 7) sub(/^[ \t]+/, "", v); sub(/[ \t\r]+$/, "", v) - if (v != "" && !found) { print "id:" v; found = 1 } + split(v, a, /[ \t]+/) + if (a[1] != "" && !found) { print "id:" a[1]; found = 1 } } END { if (!found) print "head:" fallback } ' "$1" @@ -97,6 +116,62 @@ emit() { # emit printf '\n\n' >> "$work/result" } +# Tombstone IDs recorded in a side's `## Removed` section, one token per line. +collect_removed() { # collect_removed + local i=1 + : > "$3" + while [ "$i" -le "$2" ]; do + if [ "$(cat "$1/key.$i")" = "Removed" ]; then + awk '/^- ID:/ { + v = substr($0, 7) + sub(/^[ \t]+/, "", v); sub(/[ \t\r]+$/, "", v) + split(v, a, /[ \t]+/) + if (a[1] != "") print a[1] + }' "$1/entry.$i" >> "$3" + fi + i=$((i + 1)) + done +} + +# id:TF-x -> TF-x; heading keys yield nothing. +id_of_key() { # id_of_key + case "$1" in + id:*) printf '%s\n' "${1#id:}" ;; + esac +} + +# True when the other side's Removed set already names this ID. +other_removed() { # other_removed + [ -n "$1" ] && [ -s "$2" ] && grep -qxF -- "$1" "$2" +} + +# Union tombstone lines: ours first, then theirs-only IDs, still under one +# `## Removed` heading (the entry file already starts with that heading). +union_removed() { # union_removed + local ours_t="$work/union.ours_tombs" theirs_t="$work/union.theirs_tombs" + local line id + awk '/^- ID:/ { print }' "$1" > "$ours_t" + awk '/^- ID:/ { print }' "$2" > "$theirs_t" + { + cat "$1" + # Blank line after our body only if we already had tombstones; emit() adds + # the section separator, so keep the block tight: header + our lines + new. + # Re-read structure: entry file is `## Removed` + blank? + lines. We append + # missing tombstones after our last line. + while IFS= read -r line; do + id="$(printf '%s\n' "$line" | awk '{ + v = substr($0, 7) + sub(/^[ \t]+/, "", v); sub(/[ \t\r]+$/, "", v) + split(v, a, /[ \t]+/) + print a[1] + }')" + [ -n "$id" ] || continue + grep -qxF -- "$id" "$ours_t" && continue + printf '%s\n' "$line" + done < "$theirs_t" + } > "$3" +} + split_todo "$ours" "$work/ours" split_todo "$theirs" "$work/theirs" split_todo "$base" "$work/base" @@ -104,6 +179,9 @@ no="$(cat "$work/ours/count")" nt="$(cat "$work/theirs/count")" nb="$(cat "$work/base/count")" +collect_removed "$work/ours" "$no" "$work/removed.ours" +collect_removed "$work/theirs" "$nt" "$work/removed.theirs" + # Index a side's entry files by key and heading, so a lookup is a grep over a list # rather than a scan of the entries themselves (an entry's body can contain a line # that looks like another entry's key). @@ -165,16 +243,31 @@ while [ "$i" -le "$no" ]; do entry="$work/ours/entry.$i" j="$(match_of "$key" "$head")" if [ -z "$j" ]; then - # Only we have it: either we added it, or they removed it. Keep it — a - # resurrected entry is visible and fixable, a silently dropped one is neither. - emit "$entry" + # Only we have it: we added it, or they removed it. Deletion wins — a live + # copy whose ID sits in their ## Removed must not come back. + id="$(id_of_key "$key")" + if other_removed "$id" "$work/removed.theirs"; then + : + else + emit "$entry" + fi else other="$work/theirs/entry.$j" if cmp -s "$entry" "$other"; then emit "$entry" else b="$(base_of "$key" "$head")" - if [ -n "$b" ] && cmp -s "$entry" "$work/base/entry.$b"; then + if [ "$key" = "head:Removed" ] || [ "$head" = "head:Removed" ]; then + if [ -n "$b" ] && cmp -s "$entry" "$work/base/entry.$b"; then + emit "$other" # only they changed Removed + elif [ -n "$b" ] && cmp -s "$other" "$work/base/entry.$b"; then + emit "$entry" # only we changed Removed + else + # Both added or edited tombstones: union by ID, not a conflict. + union_removed "$entry" "$other" "$work/union.removed" + emit "$work/union.removed" + fi + elif [ -n "$b" ] && cmp -s "$entry" "$work/base/entry.$b"; then emit "$other" # only they changed it elif [ -n "$b" ] && cmp -s "$other" "$work/base/entry.$b"; then emit "$entry" # only we changed it @@ -203,7 +296,12 @@ while [ "$i" -le "$nt" ]; do key="$(sed -n "${i}p" "$work/keys.theirs")" head="$(sed -n "${i}p" "$work/heads.theirs")" if ! has "$key" "$work/keys.ours" && ! has "$head" "$work/heads.ours"; then - emit "$work/theirs/entry.$i" + id="$(id_of_key "$key")" + if other_removed "$id" "$work/removed.ours"; then + : # we already recorded this ID as deleted; do not resurrect their live copy + else + emit "$work/theirs/entry.$i" + fi fi i=$((i + 1)) done diff --git a/hooks/smoke-test b/hooks/smoke-test index 3e873d7..6dea12a 100755 --- a/hooks/smoke-test +++ b/hooks/smoke-test @@ -1017,6 +1017,95 @@ grep -q '^<<<<<<<' "$merged" && { echo "FAIL one-sided edit conflicted"; exit 1; [ "$(grep -c '^- ID: ' "$merged")" -eq 4 ] || { echo "FAIL one-sided merge lost an entry"; exit 1; } echo ok +echo "== todo merge driver treats ## Removed as a deletion, not a second entry ==" +# The failure this locks: one branch runs task remove (live section gone, +# tombstone under ## Removed); the other leaves the live entry alone. The old +# driver keyed the whole Removed blob by the first tombstone's full ID suffix, +# never matched the live copy, and kept it as "only they have it". +rm_drv="$tmp/merge-removed" +mkdir -p "$rm_drv" +git -C "$rm_drv" init -q +git -C "$rm_drv" symbolic-ref HEAD refs/heads/main +git -C "$rm_drv" config user.email smoke@example.invalid +git -C "$rm_drv" config user.name smoke +TASKFLOW_REPO_ROOT="$rm_drv" bash "$HERE/install-merge-driver" +mkdir -p "$rm_drv/TaskFlowDocs" +todo_write "$rm_drv/TaskFlowDocs/todo.md" "$(entry Base TF-20260101-01 done)" +git -C "$rm_drv" add -A +git -C "$rm_drv" commit -qm base + +# Ours: delete the live entry and record it (what task remove writes). +git -C "$rm_drv" checkout -qb del +{ + printf '# Todo inbox\n\n## Items\n\n' + printf '%s\n\n' "$(entry Base TF-20260101-01 done)" + printf '## Removed\n\n- ID: TF-20260101-02 (removed 2026-01-01: gone)\n' +} > "$rm_drv/TaskFlowDocs/todo.md" +git -C "$rm_drv" commit -qam 'ours: remove TF-02' + +# Theirs: add the live entry only (never sees the remove). +git -C "$rm_drv" checkout -q main +git -C "$rm_drv" checkout -qb keep +todo_write "$rm_drv/TaskFlowDocs/todo.md" \ + "$(entry 'Still live' TF-20260101-02 inbox)" \ + "$(entry Base TF-20260101-01 done)" +git -C "$rm_drv" commit -qam 'theirs: still has live TF-02' + +git -C "$rm_drv" checkout -q del +git -C "$rm_drv" merge -q --no-edit keep +merged="$rm_drv/TaskFlowDocs/todo.md" +grep -q 'TF-20260101-02 (removed 2026-01-01: gone)' "$merged" \ + || { echo "FAIL delete/keep merge dropped the tombstone"; cat "$merged"; exit 1; } +grep -q '^## Still live$' "$merged" \ + && { echo "FAIL delete/keep merge resurrected the live entry"; cat "$merged"; exit 1; } +[ "$(grep -c '^- ID: TF-20260101-02' "$merged")" -eq 1 ] \ + || { echo "FAIL TF-02 is not exactly one record after merge"; grep -n 'TF-20260101-02' "$merged"; exit 1; } +grep -q '^<<<<<<<' "$merged" && { echo "FAIL deletion merge conflicted"; exit 1; } + +# Symmetric: theirs deleted TF-03, ours kept the live entry alone. +git -C "$rm_drv" checkout -q -B del3 main +{ + printf '# Todo inbox\n\n## Items\n\n' + printf '%s\n\n' "$(entry Base TF-20260101-01 done)" + printf '## Removed\n\n- ID: TF-20260101-03 (removed 2026-01-01: other)\n' +} > "$rm_drv/TaskFlowDocs/todo.md" +git -C "$rm_drv" commit -qam 'del3: remove TF-03' +git -C "$rm_drv" checkout -q -B keep3 main +todo_write "$rm_drv/TaskFlowDocs/todo.md" \ + "$(entry 'Other live' TF-20260101-03 inbox)" \ + "$(entry Base TF-20260101-01 done)" +git -C "$rm_drv" commit -qam 'keep3: live TF-03' +git -C "$rm_drv" checkout -q del3 +git -C "$rm_drv" merge -q --no-edit keep3 +merged="$rm_drv/TaskFlowDocs/todo.md" +grep -q 'TF-20260101-03 (removed 2026-01-01: other)' "$merged" \ + || { echo "FAIL reverse delete/keep dropped the tombstone"; cat "$merged"; exit 1; } +grep -q '^## Other live$' "$merged" \ + && { echo "FAIL reverse delete/keep resurrected the live entry"; cat "$merged"; exit 1; } +grep -q '^<<<<<<<' "$merged" && { echo "FAIL reverse deletion merge conflicted"; exit 1; } + +# Both sides add different tombstones: union, not conflict. +git -C "$rm_drv" checkout -q -B both-a main +{ + printf '# Todo inbox\n\n## Items\n\n%s\n\n' "$(entry Base TF-20260101-01 done)" + printf '## Removed\n\n- ID: TF-20260101-0a (removed 2026-01-01: a)\n' +} > "$rm_drv/TaskFlowDocs/todo.md" +git -C "$rm_drv" commit -qam 'both-a' +git -C "$rm_drv" checkout -q -B both-b main +{ + printf '# Todo inbox\n\n## Items\n\n%s\n\n' "$(entry Base TF-20260101-01 done)" + printf '## Removed\n\n- ID: TF-20260101-0b (removed 2026-01-01: b)\n' +} > "$rm_drv/TaskFlowDocs/todo.md" +git -C "$rm_drv" commit -qam 'both-b' +git -C "$rm_drv" checkout -q both-a +git -C "$rm_drv" merge -q --no-edit both-b +merged="$rm_drv/TaskFlowDocs/todo.md" +grep -q 'TF-20260101-0a (removed' "$merged" || { echo "FAIL union lost tombstone a"; cat "$merged"; exit 1; } +grep -q 'TF-20260101-0b (removed' "$merged" || { echo "FAIL union lost tombstone b"; cat "$merged"; exit 1; } +[ "$(grep -c '^## Removed$' "$merged")" -eq 1 ] || { echo "FAIL union opened a second ## Removed"; exit 1; } +grep -q '^<<<<<<<' "$merged" && { echo "FAIL tombstone union conflicted"; exit 1; } +echo ok + echo "== merge driver installation is local, idempotent, and silent ==" install_root="$tmp/install" mkdir -p "$install_root" From ef66b6f7760b2e86a756d0f3a139b4e8ca31a128 Mon Sep 17 00:00:00 2001 From: hkwuks <25031515+hkwuks@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:38:18 +0800 Subject: [PATCH 2/2] fix: todo-check keys - ID: by first token, not the tombstone suffix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI job todo-merge-audit failed on this PR: ids_at captured the whole "- ID: TF-x (removed …)" line as one identity, while removed_at already took the TF- token. Deleting the stale 985164 tombstone while keeping the live entry therefore looked like losing an entry named for the entire parenthetical string. Tokenize ids_at the same way. Smoke keeps the live ID and drops only the twin tombstone; the real merge commit now reports STATUS: pass. --- .../2026-09-22-merge-todo-removed/plan.md | 3 +++ hooks/smoke-test | 25 +++++++++++++++++++ hooks/todo-check | 7 +++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/TaskFlowDocs/2026-09-22-merge-todo-removed/plan.md b/TaskFlowDocs/2026-09-22-merge-todo-removed/plan.md index 082fb96..378447e 100644 --- a/TaskFlowDocs/2026-09-22-merge-todo-removed/plan.md +++ b/TaskFlowDocs/2026-09-22-merge-todo-removed/plan.md @@ -103,6 +103,9 @@ No spec required — small, self-contained task. - v1 — approved and implemented; R5 data rule clarified: keep live `985164` (active task), drop its obsolete tombstone; three stale IDs keep tombstones only. +- follow-up on PR #50 CI: `todo-check` `ids_at` now takes the first ID token + (same identity as `removed_at`), so dropping a stale tombstone while keeping + the live ID is not reported as a drop. ## Follow-ups diff --git a/hooks/smoke-test b/hooks/smoke-test index 6dea12a..ca5c036 100755 --- a/hooks/smoke-test +++ b/hooks/smoke-test @@ -2010,6 +2010,31 @@ bash "$HERE/todo-check" "$tc" "$tc_merge_rev" > "$tc/out" 2>&1 tc_status=$? set -e [ "$tc_status" -eq 2 ] || { echo "FAIL single-commit fallback exit code: $tc_status"; cat "$tc/out"; exit 1; } + +# A stale tombstone may be deleted while the live ID stays (data cleanup after a +# resurrection). ids_at must key both sides by the first token, or the parent's +# "- ID: TF-x (removed …)" line looks like an entry whose name is the whole +# parenthetical and the merge is reported as a drop — which is what failed CI +# on PR #50 when the false v1.0.7 tombstone for 985164 was removed. +tc_write "$tc_todo" \ + "$(tc_entry Keep TF-20260101-91 inbox)" \ + "$(tc_entry Base1 TF-20260101-01 done)" +{ + printf '## Removed\n\n- ID: TF-20260101-91 (removed 2026-01-01: stale twin)\n' +} >> "$tc_todo" +git -C "$tc" add -A +git -C "$tc" commit -qm 'parent: live TF-91 plus a stale tombstone' +tc_twin_parent="$(git -C "$tc" rev-parse HEAD)" +tc_write "$tc_todo" \ + "$(tc_entry Keep TF-20260101-91 inbox)" \ + "$(tc_entry Base1 TF-20260101-01 done)" +git -C "$tc" commit -qam 'child: drop only the stale tombstone, keep live TF-91' +set +e +bash "$HERE/todo-check" "$tc" "$tc_twin_parent" > "$tc/out" 2>&1 +tc_status=$? +set -e +[ "$tc_status" -eq 0 ] || { echo "FAIL dropping a stale tombstone while keeping the live ID was reported as a drop ($tc_status):"; cat "$tc/out"; exit 1; } +grep -q 'STATUS: pass' "$tc/out" || { echo "FAIL tombstone-drop cleanup did not pass"; cat "$tc/out"; exit 1; } echo ok echo "== the dsh wiring runs session-start the way dsh runs it ==" diff --git a/hooks/todo-check b/hooks/todo-check index 0aae0d2..f640a64 100755 --- a/hooks/todo-check +++ b/hooks/todo-check @@ -42,9 +42,14 @@ todo="TaskFlowDocs/todo.md" # The `- ID:` values at a commit, sorted for `comm`. A commit without the Todo # file yields nothing rather than an empty line, so an empty set compares as one. +# The value is the first whitespace token only: a tombstone line is +# `- ID: TF-x (removed …)`, and taking the whole suffix made that a different +# identity from the live entry. A merge that keeps the live ID while dropping a +# stale tombstone then looked like a lost entry whose "name" was the full +# parenthetical string — which `removed_at` never matches (it already tokens). ids_at() { # ids_at git -C "$root" show "$1:$todo" 2>/dev/null \ - | sed -n 's/^- ID:[[:space:]]*\(.*[^[:space:]]\)[[:space:]]*$/\1/p' | sort -u + | sed -n 's/^- ID:[[:space:]]*\([^[:space:]]\{1,\}\).*/\1/p' | sort -u } # The IDs a commit records as authorized deletions. `hooks/task remove` writes