diff --git a/TaskFlowDocs/2026-09-22-todo-intake-insert/plan.md b/TaskFlowDocs/2026-09-22-todo-intake-insert/plan.md new file mode 100644 index 0000000..2eb2a47 --- /dev/null +++ b/TaskFlowDocs/2026-09-22-todo-intake-insert/plan.md @@ -0,0 +1,126 @@ +# Plan — `task intake` inserts a new entry into the previous section instead of `## Items` + +> Task version: v1 +> Status: ready + +No spec required — small, self-contained task. + +## Reference Pointers + +- `hooks/task` `intake` branch — the only production edit. +- `hooks/task` `remove` — how `## Removed` is placed; must stay compatible. +- `hooks/todo-check` `removed_at` (`sed -n '/^## Removed$/,/^## /p'`) — the + reader the insertion must not poison. +- `TaskFlowDocs/todo.md` — ``. +- `hooks/smoke-test` — new section after the parallel-intake block. + +## Related Tasks + +- `TF-20260921-337ab8` — this task's Todo item. +- `TF-20260918-985164` — duplicate ID + false Removed record; **not fixed here** + (data cleanup, separate). +- Promote title mismatch / Goal alignment — deferred per PRD Q1. + +## Skills / Tools Used + +- `Unaided — no capability applied to this phase; considered: shell/awk text + processing, POSIX-portability references, and the repository's own hook + conventions, all read directly.` + +## Preconditions + +- [x] Applicable repository documents and personal rules inspected; + precedence/conflicts recorded. — `CONTRIBUTING.md`, `CODE_STYLE.md`, + `TaskFlowDocs/todo.md`, `hooks/task`, `hooks/todo-check`, `hooks/smoke-test` + read in this worktree. +- [x] Worktree isolation: `.worktrees/fix-todo-intake-insert` on + `fix/todo-intake-insert` from `main` = `895e102`. + +## Approval + +- Status: approved +- Approved by: user +- Approved at: 2026-09-22 +- Approved version: v1 +- Approved scope: R1–R5; intake insertion only — no promote/title alignment + (PRD Open Question 1), no new refusal path when Items is missing (Q2). + +## Steps + +### Step 1 — `intake` insertion index + +- Goal: new block lands at top of `## Items` (R1), or just before + `## Removed` when Items is missing (R2), or EOF only when neither exists + (R3). +- Dependencies: none. +- Files: `hooks/task`. +- Implementation checklist: + - [x] In the `intake` branch, compute an insertion line index instead of + always emitting after `line[n]`. + - [x] Scan for `^## Items` then skip blanks / one leading HTML comment; + insert there. Else scan for `^## Removed`; insert there. Else keep + current EOF path (rstrip + append). + - [x] Keep Goal/ID duplicate checks, title derivation, and field template + unchanged (R4) — fields now go through the shared `emit()` path. + - [x] `bash -n hooks/task`. +- Acceptance: A, B, D. +- Verification: four manual fixtures (Items+trailing Removed; bare `# Todo`; + no Items + Removed; Removed-above-first-entry). Order + `Items < new < seed < Removed`; new ID absent from the `removed_at` range; + Goal-derived `## ` written. +- Rollback: revert this commit; no other file depends on the index. +- Status: done + +### Step 2 — smoke assertions + +- Goal: a regression of the insertion point fails the suite (R5). +- Dependencies: Step 1. +- Files: `hooks/smoke-test`. +- Implementation checklist: + - [x] Fixture with `## Items` + seed entry + trailing `## Removed`; intake; + assert new heading is after `## Items` and before both the seed + heading and `## Removed`. + - [x] Assert new ID absent from the `removed_at` sed range. + - [x] Bare `# Todo` still intakes (R3); no-Items+Removed lands before + Removed (R2). +- Acceptance: C. +- Verification: one full `bash hooks/smoke-test` → `ALL SMOKE PASSED`. +- Rollback: drop the block; Step 1 still stands alone. +- Status: done + +## Checkpoints + +- After Step 1: manual fixtures + `bash -n` before touching smoke — done. +- After Step 2: one full `bash hooks/smoke-test` — done; no chained mutation + runs; `pkill` confirmed 0 leftover processes. + +## Verification / Review + +| Check | Result | +|---|---| +| `bash -n hooks/task` | pass | +| `bash -n hooks/smoke-test` | pass | +| `bash hooks/smoke-test` | `ALL SMOKE PASSED` (rc=0) | +| `git diff --check` | clean | +| PRD reproduction fixture | order + removed-range + Goal title OK | +| No interpreter / bash 3.2 parse section | pass (suite) | + +## Change Log + +- v1 — approved and implemented; scope from `TF-20260921-337ab8` Notes + (insertion point + Removed pollution + smoke acceptance). Title/promote + alignment explicitly out of scope. + +## Follow-ups + +- `TF-20260918-985164` — remove the false tombstone / restore or cancel the + live duplicate. +- `TF-20260921-4d8ae2` — close the stale inbox entry (fix already on + `fix/repository-docs-reader`, merged via #46). +- Promote-title vs Goal — decide in the todo Notes' open question when that + item is picked up. + +## Version History + +- v1 — planning. +- v1 — approved (user, 2026-09-22); Steps 1–2 done; suite green. diff --git a/TaskFlowDocs/2026-09-22-todo-intake-insert/prd.md b/TaskFlowDocs/2026-09-22-todo-intake-insert/prd.md new file mode 100644 index 0000000..eb54e96 --- /dev/null +++ b/TaskFlowDocs/2026-09-22-todo-intake-insert/prd.md @@ -0,0 +1,138 @@ +# `task intake` inserts a new entry into the previous section instead of `## Items` + +> Task version: v1 +> Status: ready + +## Goal + +`hooks/task intake` writes the new entry at the top of `## Items` — after the +section heading and its comment, before any other `## ` heading — so an intake +can never land in `## Removed` or under another entry's title. + +## Background / Confirmed Facts + +All measured on `main` = `895e102` (PR #47 merged), 2026-09-22, in worktree +`.worktrees/fix-todo-intake-insert`. Nothing here is inferred. + +**Root cause (`hooks/task` `intake`, lines 193–216).** The branch: + +1. rejects duplicate Goal / ID against every section; +2. derives `title` from Goal (`rstrip "."` then truncate 80); +3. strips trailing blank lines from the whole file; +4. reprints the file; +5. appends `"\n\n## <title>\n\n- ID: …"` at EOF. + +There is no scan for `## Items`, no insertion index, and no awareness of +`## Removed`. The insertion point is always end-of-file. + +**Live reproduction** (fixture: preamble + `## Items` + one seed entry + +`## Removed` as last section), `bash hooks/task intake "New goal…" --root $tmp`: + +| Line | Content | +|---|---| +| 7 | `## Items` | +| 10 | `## Seed entry` | +| 23 | `## Removed` | +| 25 | tombstone `- ID: TF-20260101-bb (removed …)` | +| **27** | **`## New goal lands in the wrong section`** | +| **29** | **new live `- ID: TF-20260922-5fd629`** | + +The new live entry is the last section of the file, **after** `## Removed`. +`todo-check`'s `removed_at` is `sed -n '/^## Removed$/,/^## /p'`, so the range +ends at line 27; the live ID is not currently swallowed as a tombstone when the +new heading is present — but the entry is outside `## Items`'s intended top, +and any future writer that appends fields *without* a fresh `## ` heading (or a +reader that treats “after `## Removed`” as removed territory) sees a live entry +in the deleted region. The todo item also recorded a promote that derived its +task directory from the **previous** entry's heading when the new heading was +not a clean section break. + +**Documented convention the code contradicts** — `TaskFlowDocs/todo.md` line 11: + +```text +<!-- Add new items at the top using the template below. --> +``` + +**What is *not* broken:** + +- `task get` / `task next` / `task edit` locate by `- ID:` via `findsec`, not by + “am I under `## Items`”. +- `task remove` already places `## Removed` deliberately: above the first entry + that has an `- ID:` (lines 321–338), or appends the section when no entry is + left. +- Duplicate Goal/ID refusal works (smoke: `duplicate goal accepted`). +- Parallel-intake merge driver smoke passes; this fix does not touch merge. + +**Same-root secondary issue (out of scope, recorded in the todo Notes):** +`promote` derives the task directory name from the section heading returned by +`get`, and ~23 extra `## ` template headings plus ~30 hand-written titles do +not match Goal. Only `promote` errors when the heading is wrong; `get`/`findsec` +do not care. Deferred as an open question below. + +## Requirements + +- **R1.** `intake` inserts the new `## <title>` block immediately after the + `## Items` heading, its blank lines, and an optional leading `<!-- … -->` + comment — i.e. before the next `## ` line (or EOF if Items has no children). +- **R2.** The new entry never appears after a `## Removed` heading. If + `## Items` is absent but `## Removed` exists, insert immediately before + `## Removed` (still not at EOF). +- **R3.** If neither `## Items` nor `## Removed` exists (bare `# Todo inbox` + fixtures), keep the current append-at-EOF behaviour so existing smoke + fixtures do not need structural rewrites. +- **R4.** Duplicate Goal/ID refusal, title derivation, and the field template + stay byte-identical — only the insertion index changes. +- **R5.** `bash hooks/smoke-test` gains at least: (a) with Items+Removed + fixture, new entry's heading line number is `< Removed`'s and the entry sits + before the first existing entry heading; (b) `removed_at` range does not + contain the new ID; (c) bare fixture still accepts intake. + +## Acceptance Criteria + +- **A.** On the reproduction fixture above, after intake the line order is + `## Items` → **new `## <title>` + new `- ID:`** → `## Seed entry` → + `## Removed`; new ID not inside `sed -n '/^## Removed$/,/^## /p'`. +- **B.** `hooks/task get <new-id>` returns the Goal-derived title (section + heading intake wrote), not the previous entry's title. +- **C.** `bash hooks/smoke-test` → `ALL SMOKE PASSED`. +- **D.** `bash -n hooks/task` clean; no change to `hooks/task` outside the + `intake` branch's emission (plus any shared helper it needs). + +## In Scope + +- `hooks/task` — `intake` insertion index only. +- `hooks/smoke-test` — assertions in R5. +- `TaskFlowDocs/todo.md` — promote/status bookkeeping for this task. + +## Out of Scope + +- `promote` title / Goal alignment (open question in the todo Notes). +- `## Item template` and historical heading cleanup. +- `hooks/todo-check`, `hooks/merge-todo`, remove's tombstone placement. +- The other open todos (`985164` duplicate ID, `4d8ae2` stale close, `b7821b` + Approval shape). + +## Risks / Deferred Items + +- Fixtures that intentionally placed an entry at EOF after a non-Items + structure will keep working via R3; if a fixture has `## Items` *and* + expected EOF placement, the smoke run will surface it. +- R2's “before `## Removed`” when Items is missing is a narrower rule than + R1; a file with Removed but no Items is already malformed relative to the + documented layout. + +## Open Questions + +1. Should `promote` re-derive or rewrite the section title from Goal so + directory names stop depending on hand-written headings? — **Recommend: no + in this PR** (separate todo already covers the title mismatch; changing + promote here widens blast radius for no intake benefit). +2. Should `intake` refuse a todo.md that has `## Removed` but no `## Items`? + — **Recommend: no**; R2 inserts before Removed and moves on (ponytail: one + insertion rule, no new refusal path). + +## Version History + +- v1 — planning. +- v1 — ready; approved by user 2026-09-22 (R1–R5; Q1/Q2 recommendations + accepted: no promote-title change, no new refusal when Items is missing). diff --git a/TaskFlowDocs/todo.md b/TaskFlowDocs/todo.md index 47d1b6e..426d831 100644 --- a/TaskFlowDocs/todo.md +++ b/TaskFlowDocs/todo.md @@ -966,15 +966,15 @@ Every direct request or imported requireme ## `task intake` inserts a new entry into the previous section instead of `## Items` - ID: TF-20260921-337ab8 -- Status: inbox +- Status: promoted - Priority: normal - Owner: Codex - Source: user request - Added: 2026-09-21 -- Updated: 2026-09-21 +- Updated: 2026-09-22 - Goal: Fix task intake inserting a new entry into the previous section instead of the Items section, and its insertion point landing inside the Removed section. -- Task: Not promoted. -- Next action: Clarify and promote when ready. +- Task: `TaskFlowDocs/2026-09-22-todo-intake-insert/` +- Next action: Complete PRD / Spec / Plan and request approval. - Notes: **2026-09-21 用户指定:单独开一条。** **问题**:`hooks/task intake` 把新条目写进文件里的上一个 `## ` 节,而不是插到 `## Items` 下。新条目因此挂到别人的标题下——那次它挂在了「Stop `TaskFlowDocs/todo.md` from growing without bound…」节里,`promote` 又拿那行当标题生出 task 文档(本轮我用假标题「State in the Skill…」跑出了 plan/prd 的首行)。 **另一半**:`intake` 的插入点固定在文件末尾,所以它落在 `## Removed` 之后;如果 `## Removed` 不是最后一节,新条目会插进 `## Removed` 节内,`todo-check` 读 `## Removed` 时会把在途条目当成已删除记录(该节与 `TF-20260918-985164` 的删除语义直接冲突)。 diff --git a/hooks/smoke-test b/hooks/smoke-test index 5f63692..937fa4c 100755 --- a/hooks/smoke-test +++ b/hooks/smoke-test @@ -1109,6 +1109,81 @@ git -C "$par" merge -q --no-edit right grep -q '^<<<<<<<' "$par/TaskFlowDocs/todo.md" && { echo "FAIL parallel intake conflicted"; exit 1; } echo ok +echo "== intake inserts at the top of ## Items, never after ## Removed ==" +# The old intake always appended at EOF. With `## Removed` last, the new live +# entry landed in the deleted region — outside `## Items` and inside the range +# `todo-check` reads for authorized deletions. +ins_root="$tmp/intake-insert" +mkdir -p "$ins_root/TaskFlowDocs" +cat > "$ins_root/TaskFlowDocs/todo.md" <<'EOF' +# Todo inbox + +## Status flow + +`inbox → clarified → promoted → in_progress → done/cancelled` + +## Items + +<!-- Add new items at the top using the template below. --> +## Seed entry + +- ID: TF-20260101-aa +- Status: clarified +- Priority: normal +- Owner: smoke +- Source: fixture +- Added: 2026-01-01 +- Updated: 2026-01-01 +- Goal: seed +- Task: Not promoted. +- Next action: none + +## Removed + +- ID: TF-20260101-bb (removed 2026-01-01: fixture) +EOF +ins_out="$tmp/intake-insert-out" +ins_id="$(bash "$HERE/task" intake "New entry must sit under Items." "fixture" --root "$ins_root" > "$ins_out" && awk '{ print $NF }' "$ins_out")" +ins_todo="$ins_root/TaskFlowDocs/todo.md" +ins_items="$(grep -n '^## Items$' "$ins_todo" | cut -d: -f1)" +ins_new="$(grep -n '^## New entry must sit under Items$' "$ins_todo" | cut -d: -f1)" +ins_seed="$(grep -n '^## Seed entry$' "$ins_todo" | cut -d: -f1)" +ins_rem="$(grep -n '^## Removed$' "$ins_todo" | cut -d: -f1)" +[ -n "$ins_items" ] && [ -n "$ins_new" ] && [ -n "$ins_seed" ] && [ -n "$ins_rem" ] \ + || { echo "FAIL insert fixture missing a section heading"; cat "$ins_todo"; exit 1; } +[ "$ins_items" -lt "$ins_new" ] && [ "$ins_new" -lt "$ins_seed" ] && [ "$ins_seed" -lt "$ins_rem" ] \ + || { echo "FAIL new entry not between Items and Removed (items=$ins_items new=$ins_new seed=$ins_seed rem=$ins_rem)"; grep -n '^## ' "$ins_todo"; exit 1; } +sed -n '/^## Removed$/,/^## /p' "$ins_todo" | grep -q "^- ID: $ins_id$" \ + && { echo "FAIL new ID appears inside ## Removed: $ins_id"; exit 1; } +# Goal-derived heading is what `promote` will use as the task directory name. +grep -qx -- '## New entry must sit under Items' "$ins_todo" \ + || { echo "FAIL new heading is not the Goal-derived title"; exit 1; } + +# R2: no `## Items`, but `## Removed` exists — insert before Removed, not after. +ins2="$tmp/intake-no-items" +mkdir -p "$ins2/TaskFlowDocs" +cat > "$ins2/TaskFlowDocs/todo.md" <<'EOF' +# Todo inbox + +## Removed + +- ID: TF-20260101-bb (removed 2026-01-01: fixture) +EOF +bash "$HERE/task" intake "Before removed when Items is absent." "fixture" --root "$ins2" >/dev/null +ins2_new="$(grep -n '^## Before removed when Items is absent$' "$ins2/TaskFlowDocs/todo.md" | cut -d: -f1)" +ins2_rem="$(grep -n '^## Removed$' "$ins2/TaskFlowDocs/todo.md" | cut -d: -f1)" +[ -n "$ins2_new" ] && [ -n "$ins2_rem" ] && [ "$ins2_new" -lt "$ins2_rem" ] \ + || { echo "FAIL entry did not land before ## Removed when Items is absent"; cat "$ins2/TaskFlowDocs/todo.md"; exit 1; } + +# R3: bare `# Todo` keeps append-at-EOF so existing minimal fixtures stay valid. +ins3="$tmp/intake-bare" +mkdir -p "$ins3/TaskFlowDocs" +printf '# Todo\n' > "$ins3/TaskFlowDocs/todo.md" +bash "$HERE/task" intake "Bare file still accepts intake." "fixture" --root "$ins3" >/dev/null +grep -qx -- '## Bare file still accepts intake' "$ins3/TaskFlowDocs/todo.md" \ + || { echo "FAIL bare fixture did not get a heading"; cat "$ins3/TaskFlowDocs/todo.md"; exit 1; } +echo ok + echo "== task next writes the deterministic Todo fields and lands Notes before Updated ==" # The point of the command is tokens: the fields it writes are templated from # state the Agent already has, and hand-writing them costs a read of the whole diff --git a/hooks/task b/hooks/task index 4bb7975..db4baed 100755 --- a/hooks/task +++ b/hooks/task @@ -203,16 +203,66 @@ END { t = goal sub(/\.+$/, "", t) title = substr(t, 1, 80) - while (n > 0 && is_blank(line[n])) n-- - # text.rstrip() drops every trailing space and newline, so the last retained - # line is trimmed and emitted without its own newline before the blank pair. - if (n > 0) { - line[n] = rtrim(line[n]) - for (i = 1; i < n; i++) printf "%s\n", line[i] - printf "%s", line[n] + + # Insertion index: top of `## Items` (after its heading, blanks, and a + # leading HTML comment), else just before `## Removed`, else EOF. The old + # code always appended at EOF, so a file ending in `## Removed` put the new + # live entry in the deleted region and outside the documented "add at the + # top" convention in `todo.md`. + ins = 0 + for (i = 1; i <= n; i++) { + if (at(line[i], "## Items")) { + ins = i + 1 + while (ins <= n && (is_blank(line[ins]) || line[ins] ~ /^[ \t]*<!--/)) ins++ + break + } + } + if (ins == 0) { + for (i = 1; i <= n; i++) { + if (at(line[i], "## Removed")) { ins = i; break } + } } - printf "\n\n## %s\n\n- ID: %s\n- Status: inbox\n- Priority: normal\n- Owner: Codex\n- Source: %s\n- Added: %s\n- Updated: %s\n- Goal: %s\n- Task: Not promoted.\n- Next action: Clarify and promote when ready.\n", \ - title, id, source, today, today, goal + + lead = 0 + trail = 0 + if (ins <= 0 || ins > n) { + # EOF path: same rstrip/rtrim as before, one blank line between the last + # retained line and `##` (two leading blanks when the file was empty, so + # the bytes still match `\n\n##`). + while (n > 0 && is_blank(line[n])) n-- + if (n > 0) { + line[n] = rtrim(line[n]) + lead = 1 + } else { + lead = 2 + } + ins = n + 1 + ends_nl = 1 + } else if (!is_blank(line[ins])) { + trail = 1 + } + + nb = 0 + nb++; blk[nb] = "## " title + nb++; blk[nb] = "" + nb++; blk[nb] = "- ID: " id + nb++; blk[nb] = "- Status: inbox" + nb++; blk[nb] = "- Priority: normal" + nb++; blk[nb] = "- Owner: Codex" + nb++; blk[nb] = "- Source: " source + nb++; blk[nb] = "- Added: " today + nb++; blk[nb] = "- Updated: " today + nb++; blk[nb] = "- Goal: " goal + nb++; blk[nb] = "- Task: Not promoted." + nb++; blk[nb] = "- Next action: Clarify and promote when ready." + + total = lead + nb + trail + for (i = n; i >= ins; i--) line[i + total] = line[i] + for (i = 1; i <= lead; i++) line[ins + i - 1] = "" + for (i = 1; i <= nb; i++) line[ins + lead + i - 1] = blk[i] + if (trail) line[ins + lead + nb] = "" + n += total + emit() exit 0 }