diff --git a/cmd/planctl/scan.go b/cmd/planctl/scan.go
new file mode 100644
index 0000000..15ab11f
--- /dev/null
+++ b/cmd/planctl/scan.go
@@ -0,0 +1,235 @@
+package main
+
+import (
+ "bytes"
+ "os"
+ "sort"
+
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/extension"
+ "github.com/yuin/goldmark/text"
+)
+
+// spec:planctl/R1.5+D§3.2
+// MaskRun is a [Start, End) column range in one line that should be masked
+// out before tag-matching. Columns are 0-indexed byte offsets within the line.
+type MaskRun struct {
+ Start int
+ End int
+}
+
+// spec:planctl/R1.5+D§3.2
+// ScanResult captures one markdown file parsed to a goldmark AST plus
+// per-line classification derived from the AST.
+//
+// Callers (the indexer, lint rules) use InCode + LineMask to skip tag-like
+// tokens that live inside code blocks or inline code spans — per R1.5 those
+// are documentation about tags, not tags.
+type ScanResult struct {
+ Path string
+ Source []byte
+ Lines []string // newline-stripped, 0-indexed (exposed as 1-indexed to callers)
+ Root ast.Node // goldmark AST root
+ InCode []bool // parallel to Lines; true if line is inside a CodeBlock / FencedCodeBlock / HTMLBlock
+ LineMask [][]MaskRun // parallel to Lines; column ranges occupied by inline CodeSpan content
+}
+
+// planParser is the shared goldmark parser. We enable the TaskList extension
+// so `[ ]` / `[x]` list items surface as east.TaskCheckBox nodes — the indexer
+// (§3.3) relies on that to extract task lines. The extension ships inside the
+// goldmark module, so no additional require line is needed.
+//
+// spec:planctl/D§3.2
+var planParser = goldmark.New(goldmark.WithExtensions(extension.TaskList)).Parser()
+
+// spec:planctl/R1.5+D§3.2
+// Scan reads the file at path and produces a ScanResult.
+func Scan(path string) (ScanResult, error) {
+ source, err := os.ReadFile(path)
+ if err != nil {
+ return ScanResult{}, err
+ }
+ return scanBytes(path, source), nil
+}
+
+// spec:planctl/R1.5+D§3.2
+// scanBytes is the testable core of Scan that operates on in-memory source
+// bytes without touching the filesystem.
+func scanBytes(path string, source []byte) ScanResult {
+ reader := text.NewReader(source)
+ root := planParser.Parse(reader)
+ lines := splitLines(source)
+ newlineOffsets := buildNewlineIndex(source)
+
+ r := ScanResult{
+ Path: path,
+ Source: source,
+ Lines: lines,
+ Root: root,
+ InCode: make([]bool, len(lines)),
+ LineMask: make([][]MaskRun, len(lines)),
+ }
+
+ _ = ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
+ if !entering {
+ return ast.WalkContinue, nil
+ }
+ switch n.Kind() {
+ case ast.KindCodeBlock, ast.KindFencedCodeBlock, ast.KindHTMLBlock:
+ markBlockLines(n, newlineOffsets, r.InCode)
+ case ast.KindCodeSpan:
+ appendInlineMask(n, source, newlineOffsets, r.Lines, r.LineMask)
+ }
+ return ast.WalkContinue, nil
+ })
+
+ // Normalise LineMask entries: sort by Start for stable consumption.
+ for i := range r.LineMask {
+ if len(r.LineMask[i]) > 1 {
+ sort.Slice(r.LineMask[i], func(a, b int) bool {
+ return r.LineMask[i][a].Start < r.LineMask[i][b].Start
+ })
+ }
+ }
+
+ return r
+}
+
+// spec:planctl/D§3.2
+// splitLines splits source on '\n' and drops a trailing empty element so the
+// result is a natural "lines" slice. An input ending in '\n' produces len(lines)
+// elements where the last element is the content before the final '\n'.
+func splitLines(source []byte) []string {
+ if len(source) == 0 {
+ return nil
+ }
+ parts := bytes.Split(source, []byte{'\n'})
+ if n := len(parts); n > 0 && len(parts[n-1]) == 0 {
+ parts = parts[:n-1]
+ }
+ out := make([]string, len(parts))
+ for i, p := range parts {
+ out[i] = string(p)
+ }
+ return out
+}
+
+// spec:planctl/D§3.2
+// buildNewlineIndex returns byte offsets of every '\n' in source, in order.
+// Consumers binary-search it to map a byte offset to (line, column).
+func buildNewlineIndex(source []byte) []int {
+ var offsets []int
+ for i, b := range source {
+ if b == '\n' {
+ offsets = append(offsets, i)
+ }
+ }
+ return offsets
+}
+
+// spec:planctl/D§3.2
+// offsetToLineCol converts a byte offset into (1-indexed line, 0-indexed col).
+// A '\n' byte is treated as the END of the line it terminates, not the start
+// of the following line — that way a block segment whose Stop-1 lands exactly
+// on the terminating '\n' of its last content line maps back to that content
+// line, not to the line after.
+func offsetToLineCol(offset int, newlineOffsets []int) (line, col int) {
+ idx := sort.SearchInts(newlineOffsets, offset+1)
+ // If offset IS a newline position, fold it into the preceding line.
+ if idx > 0 && newlineOffsets[idx-1] == offset {
+ line = idx
+ var lineStart int
+ if idx > 1 {
+ lineStart = newlineOffsets[idx-2] + 1
+ }
+ col = offset - lineStart
+ return
+ }
+ line = idx + 1
+ var lineStart int
+ if idx > 0 {
+ lineStart = newlineOffsets[idx-1] + 1
+ }
+ col = offset - lineStart
+ return
+}
+
+// spec:planctl/R1.5+D§3.2
+// markBlockLines sets InCode[i] = true for every line covered by a block
+// whose content lies in the given node's Lines() segments.
+func markBlockLines(n ast.Node, newlineOffsets []int, inCode []bool) {
+ segs := n.Lines()
+ if segs == nil {
+ return
+ }
+ for i := 0; i < segs.Len(); i++ {
+ seg := segs.At(i)
+ if seg.Stop <= seg.Start {
+ continue
+ }
+ startLine, _ := offsetToLineCol(seg.Start, newlineOffsets)
+ // Stop is exclusive; step back one byte to land inside the segment.
+ stopLine, _ := offsetToLineCol(seg.Stop-1, newlineOffsets)
+ for L := startLine; L <= stopLine; L++ {
+ if L >= 1 && L <= len(inCode) {
+ inCode[L-1] = true
+ }
+ }
+ }
+}
+
+// spec:planctl/R1.5+D§3.2
+// appendInlineMask appends MaskRun entries covering an inline CodeSpan's
+// byte range. The mask extends one byte past the children's segment bounds
+// on each side so the surrounding backticks are included — a tag like
+// `` `_Requirements: R9.9_` `` must not extract R9.9. If the span crosses a
+// line boundary (rare), the mask is expanded across all affected lines.
+func appendInlineMask(n ast.Node, source []byte, newlineOffsets []int, lines []string, lineMask [][]MaskRun) {
+ minStart, maxStop := -1, -1
+ for c := n.FirstChild(); c != nil; c = c.NextSibling() {
+ if tc, ok := c.(*ast.Text); ok {
+ if minStart == -1 || tc.Segment.Start < minStart {
+ minStart = tc.Segment.Start
+ }
+ if tc.Segment.Stop > maxStop {
+ maxStop = tc.Segment.Stop
+ }
+ }
+ }
+ if minStart == -1 {
+ // Empty code span or a variant we don't recognise; skip safely.
+ return
+ }
+ spanStart := minStart - 1
+ if spanStart < 0 {
+ spanStart = 0
+ }
+ spanStop := maxStop + 1
+ if spanStop > len(source) {
+ spanStop = len(source)
+ }
+
+ startLine, startCol := offsetToLineCol(spanStart, newlineOffsets)
+ stopLine, stopCol := offsetToLineCol(spanStop, newlineOffsets)
+
+ if startLine == stopLine {
+ if idx := startLine - 1; idx >= 0 && idx < len(lineMask) {
+ lineMask[idx] = append(lineMask[idx], MaskRun{Start: startCol, End: stopCol})
+ }
+ return
+ }
+ // Multi-line code span: mask the trailing portion of the first line,
+ // full intermediate lines, and the leading portion of the final line.
+ if idx := startLine - 1; idx >= 0 && idx < len(lineMask) {
+ lineMask[idx] = append(lineMask[idx], MaskRun{Start: startCol, End: len(lines[idx])})
+ }
+ for L := startLine; L < stopLine-1; L++ {
+ if L >= 0 && L < len(lineMask) {
+ lineMask[L] = append(lineMask[L], MaskRun{Start: 0, End: len(lines[L])})
+ }
+ }
+ if idx := stopLine - 1; idx >= 0 && idx < len(lineMask) {
+ lineMask[idx] = append(lineMask[idx], MaskRun{Start: 0, End: stopCol})
+ }
+}
diff --git a/cmd/planctl/scan_test.go b/cmd/planctl/scan_test.go
new file mode 100644
index 0000000..f442fb7
--- /dev/null
+++ b/cmd/planctl/scan_test.go
@@ -0,0 +1,365 @@
+package main
+
+import (
+ "strings"
+ "testing"
+)
+
+// spec:planctl/R1.5+D§3.2
+func TestScanBytes_InCode(t *testing.T) {
+ tests := []struct {
+ name string
+ source string
+ wantIn []bool // parallel to Lines
+ wantLen int // expected line count; 0 = skip length assertion
+ }{
+ {
+ name: "plain prose has no code lines",
+ source: joinLines(
+ "This is a paragraph.",
+ "Another paragraph line.",
+ "",
+ "Third one.",
+ ),
+ wantIn: []bool{false, false, false, false},
+ },
+ {
+ name: "fenced code block marks only the content lines",
+ source: joinLines(
+ "Before fence.",
+ "```",
+ "code line 1",
+ "code line 2",
+ "```",
+ "After fence.",
+ ),
+ // Goldmark's FencedCodeBlock.Lines() covers the content between fences
+ // (lines 3 and 4 here), not the fence delimiter lines themselves.
+ wantIn: []bool{false, false, true, true, false, false},
+ },
+ {
+ name: "fenced code block with language info string",
+ source: joinLines(
+ "Intro",
+ "```go",
+ "func foo() {}",
+ "```",
+ ),
+ wantIn: []bool{false, false, true, false},
+ },
+ {
+ name: "indented code block (4-space)",
+ source: joinLines(
+ "Paragraph.",
+ "",
+ " indented code",
+ " more indented code",
+ "",
+ "After.",
+ ),
+ wantIn: []bool{false, false, true, true, false, false},
+ },
+ {
+ name: "HTML block",
+ source: joinLines(
+ "Paragraph.",
+ "",
+ "
",
+ " raw html",
+ "
",
+ "",
+ "After.",
+ ),
+ wantIn: []bool{false, false, true, true, true, false, false},
+ },
+ {
+ name: "empty source produces empty result",
+ source: "",
+ wantIn: nil,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ r := scanBytes("", []byte(tc.source))
+ if len(r.InCode) != len(tc.wantIn) {
+ t.Fatalf("len(InCode) = %d, want %d\nsource:\n%s\nInCode: %v", len(r.InCode), len(tc.wantIn), tc.source, r.InCode)
+ }
+ for i := range tc.wantIn {
+ if r.InCode[i] != tc.wantIn[i] {
+ t.Errorf("InCode[%d] = %v, want %v (line: %q)", i, r.InCode[i], tc.wantIn[i], safeIndex(r.Lines, i))
+ }
+ }
+ })
+ }
+}
+
+// spec:planctl/R1.5+D§3.2
+func TestScanBytes_InlineCodeMask(t *testing.T) {
+ tests := []struct {
+ name string
+ // source: a single-line snippet.
+ source string
+ // wantMaskedSubstrings: substrings that must land inside a masked range
+ // on the line they appear. Each substring is expected to appear exactly
+ // once; the test finds its columns via strings.Index + len.
+ wantMaskedSubstrings []string
+ // wantUnmaskedSubstrings: substrings that must NOT be covered by any
+ // MaskRun (i.e. they're in plain prose).
+ wantUnmaskedSubstrings []string
+ }{
+ {
+ name: "inline code span masks backticked content",
+ source: "Prose with `code here` and more prose.",
+ wantMaskedSubstrings: []string{"code here"},
+ wantUnmaskedSubstrings: []string{"Prose with", "more prose"},
+ },
+ {
+ name: "tag-like token inside backticks is masked",
+ source: "The literal `_Requirements: R9.9_` appears in docs.",
+ wantMaskedSubstrings: []string{"_Requirements: R9.9_"},
+ wantUnmaskedSubstrings: []string{"appears in docs"},
+ },
+ {
+ name: "two inline code spans on one line",
+ source: "First `alpha` then `beta` end.",
+ wantMaskedSubstrings: []string{"alpha", "beta"},
+ wantUnmaskedSubstrings: []string{"First", "then", "end"},
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ r := scanBytes("", []byte(tc.source))
+ if len(r.Lines) == 0 {
+ t.Fatal("expected at least one line")
+ }
+ line := r.Lines[0]
+ mask := r.LineMask[0]
+ for _, needle := range tc.wantMaskedSubstrings {
+ col := strings.Index(line, needle)
+ if col < 0 {
+ t.Fatalf("substring %q not found in line %q", needle, line)
+ }
+ end := col + len(needle)
+ if !rangeCovered(mask, col, end) {
+ t.Errorf("substring %q [%d,%d) is not covered by mask %v", needle, col, end, mask)
+ }
+ }
+ for _, needle := range tc.wantUnmaskedSubstrings {
+ col := strings.Index(line, needle)
+ if col < 0 {
+ t.Fatalf("substring %q not found in line %q", needle, line)
+ }
+ end := col + len(needle)
+ if rangeOverlapsAny(mask, col, end) {
+ t.Errorf("substring %q [%d,%d) should NOT overlap mask %v", needle, col, end, mask)
+ }
+ }
+ })
+ }
+}
+
+// spec:planctl/R1.5+D§3.2
+// TestScanBytes_NestedEmphasisAroundCode covers task 2.7's "nested emphasis"
+// case — an inline code span inside an emphasis span should still produce a
+// LineMask entry covering the code. CommonMark allows `_*emph with `code`*_`
+// style; goldmark parses it and the scanner should mask "code" regardless.
+func TestScanBytes_NestedEmphasisAroundCode(t *testing.T) {
+ cases := []string{
+ "Prose *italic with `tag _Requirements: R9.9_` inside* end.",
+ "Prose **bold with `tag _Design: D§9.9_` inside** end.",
+ }
+ for _, source := range cases {
+ t.Run(source[:20], func(t *testing.T) {
+ r := scanBytes("", []byte(source))
+ if len(r.Lines) == 0 {
+ t.Fatal("expected one line")
+ }
+ line := r.Lines[0]
+ mask := r.LineMask[0]
+ // Find the tag inside the backticks and assert it's masked.
+ needle := "tag _"
+ col := strings.Index(line, needle)
+ if col < 0 {
+ t.Fatalf("needle %q not found in %q", needle, line)
+ }
+ if !rangeOverlapsAny(mask, col, col+len(needle)) {
+ t.Errorf("expected %q [%d,%d) to overlap mask %v — emphasis-nested code span must still mask",
+ needle, col, col+len(needle), mask)
+ }
+ })
+ }
+}
+
+// spec:planctl/R1.5+D§3.2
+// TestScanBytes_MixedFeatures exercises a single source containing a fenced
+// block, an indented block, an inline code span, and a plain paragraph
+// together — the pattern a real plan file looks like.
+func TestScanBytes_MixedFeatures(t *testing.T) {
+ source := joinLines(
+ "# Heading",
+ "",
+ "Paragraph with `inline code` and more text.",
+ "",
+ "```go",
+ "func fenced() {}",
+ "```",
+ "",
+ "Another paragraph.",
+ "",
+ " indented code block",
+ " second indented line",
+ "",
+ "Final paragraph.",
+ )
+ r := scanBytes("", []byte(source))
+ // Expected line-level classification.
+ type lineCheck struct {
+ line int // 1-indexed
+ inCode bool
+ }
+ checks := []lineCheck{
+ {1, false}, // "# Heading"
+ {3, false}, // paragraph with inline code
+ {5, false}, // "```go"
+ {6, true}, // "func fenced() {}"
+ {7, false}, // closing "```"
+ {9, false}, // "Another paragraph."
+ {11, true}, // " indented code block"
+ {12, true}, // " second indented line"
+ {14, false}, // "Final paragraph."
+ }
+ for _, c := range checks {
+ if r.InCode[c.line-1] != c.inCode {
+ t.Errorf("InCode[line %d] = %v, want %v (line content: %q)",
+ c.line, r.InCode[c.line-1], c.inCode, r.Lines[c.line-1])
+ }
+ }
+ // Inline-code mask on line 3 covers "inline code".
+ line3 := r.Lines[2]
+ col := strings.Index(line3, "inline code")
+ if col < 0 {
+ t.Fatalf("substring not found in line 3: %q", line3)
+ }
+ if !rangeCovered(r.LineMask[2], col, col+len("inline code")) {
+ t.Errorf("line 3 mask %v does not cover inline code span [%d,%d)",
+ r.LineMask[2], col, col+len("inline code"))
+ }
+}
+
+// spec:planctl/R1.5+D§3.2
+// TestScanBytes_FencedBlockWithoutTrailingNewline pins the EOF edge — a source
+// that ends mid-fenced-block without a final '\n'. Goldmark's segment Stop
+// positions can point past EOF here; the scanner must not panic and must still
+// classify the content line as InCode.
+func TestScanBytes_FencedBlockWithoutTrailingNewline(t *testing.T) {
+ // Note: no trailing "\n" — source ends at "content".
+ source := "prose\n```\ncontent"
+ r := scanBytes("", []byte(source))
+ if len(r.Lines) != 3 {
+ t.Fatalf("len(Lines) = %d, want 3; got %v", len(r.Lines), r.Lines)
+ }
+ // Line 3 is "content" inside the (unclosed) fenced block. Depending on
+ // goldmark's recovery, it may or may not be InCode — both outcomes are
+ // acceptable, the test's purpose is just to assert the scanner doesn't
+ // panic on the unterminated input.
+ _ = r.InCode
+}
+
+// spec:planctl/R1.5+D§3.2
+// TestScanBytes_InlineCodeMask_ExactRanges pins down the boundary columns
+// for single- and double-backtick code spans so future regressions in the
+// mask-extension-by-1 logic are caught immediately.
+func TestScanBytes_InlineCodeMask_ExactRanges(t *testing.T) {
+ source := "aa `bb` cc\n"
+ // Column layout: a=0 a=1 ' '=2 `=3 b=4 b=5 `=6 ' '=7 c=8 c=9
+ // CodeSpan children cover "bb" at [4, 6); mask extends by 1 on each side → [3, 7).
+ r := scanBytes("", []byte(source))
+ if len(r.LineMask) != 1 || len(r.LineMask[0]) != 1 {
+ t.Fatalf("expected one mask on one line, got %v", r.LineMask)
+ }
+ got := r.LineMask[0][0]
+ if got.Start != 3 || got.End != 7 {
+ t.Errorf("MaskRun = %+v, want {Start:3, End:7}", got)
+ }
+}
+
+// spec:planctl/R1.5+D§3.2
+func TestScanBytes_TagInsideFenceIsInCode(t *testing.T) {
+ // Regression guard for R1.5: a tag-like token inside a fenced block must
+ // land on a line with InCode==true so the indexer skips it.
+ source := joinLines(
+ "Some intro prose.",
+ "",
+ "```",
+ "- [ ] 1.1 do stuff _Requirements: R9.9_",
+ "```",
+ "",
+ "End.",
+ )
+ r := scanBytes("", []byte(source))
+ // Line indices (1-based): 1=intro, 2=blank, 3=```, 4=tag-in-fence, 5=```, 6=blank, 7=End
+ if !r.InCode[3] { // 0-indexed: line 4 → index 3
+ t.Errorf("expected InCode[3] (tag-in-fence line) = true; got false.\nInCode=%v\nLines=%v", r.InCode, r.Lines)
+ }
+}
+
+// spec:planctl/D§3.2
+func TestScanBytes_NewlineOffsets(t *testing.T) {
+ // Sanity: splitLines + newline index consistency across trailing-newline
+ // variations.
+ cases := []struct {
+ in string
+ wantLen int
+ wantLast string
+ }{
+ {"a\nb\nc", 3, "c"},
+ {"a\nb\nc\n", 3, "c"},
+ {"single", 1, "single"},
+ {"", 0, ""},
+ }
+ for _, tc := range cases {
+ r := scanBytes("", []byte(tc.in))
+ if len(r.Lines) != tc.wantLen {
+ t.Errorf("splitLines(%q): len = %d, want %d", tc.in, len(r.Lines), tc.wantLen)
+ continue
+ }
+ if tc.wantLen > 0 && r.Lines[tc.wantLen-1] != tc.wantLast {
+ t.Errorf("splitLines(%q): last line = %q, want %q", tc.in, r.Lines[tc.wantLen-1], tc.wantLast)
+ }
+ }
+}
+
+// --- helpers ---
+
+func joinLines(lines ...string) string {
+ return strings.Join(lines, "\n") + "\n"
+}
+
+func safeIndex(s []string, i int) string {
+ if i < 0 || i >= len(s) {
+ return ""
+ }
+ return s[i]
+}
+
+// rangeCovered reports whether every byte in [start, end) lies inside some
+// MaskRun in mask.
+func rangeCovered(mask []MaskRun, start, end int) bool {
+ for _, m := range mask {
+ if m.Start <= start && end <= m.End {
+ return true
+ }
+ }
+ return false
+}
+
+// rangeOverlapsAny reports whether [start, end) intersects any MaskRun.
+func rangeOverlapsAny(mask []MaskRun, start, end int) bool {
+ for _, m := range mask {
+ if start < m.End && m.Start < end {
+ return true
+ }
+ }
+ return false
+}
diff --git a/dev/plans/26172-planctl/codex-sessions.md b/dev/plans/26172-planctl/codex-sessions.md
index 1f90a3e..9b8803b 100644
--- a/dev/plans/26172-planctl/codex-sessions.md
+++ b/dev/plans/26172-planctl/codex-sessions.md
@@ -6,3 +6,4 @@ Append-only traceability log of codex review sessions for this plan. Each entry
- 2026-04-21 design-review 019db1c6-9329-7272-b683-b4706fc99c17 _(3 codex rounds + 1 user-driven revision round; R1 found 5 issues — umbrella traceability tags, permissive EARS matcher, wrong Case C algorithm, multi-plan output format drift, internal/planctl/ inconsistency; plus DQ feedback + 5 test-fixture gaps. R2 all 5 resolved, 1 remaining — §3.7 single-plan example had stray summary line. R3 approved after removing summary line + narrowing the `**` EARS-exemption regex. R4 was a user-driven switch from regex-only to goldmark AST; codex web-searched goldmark to verify zero-transitive-deps + TaskList extension presence, approved.)_
- 2026-04-21 tasks-review 019db209-a49b-7ae1-a396-4fcbae83e0ec _(2 rounds; R1 flagged 4 issue classes — invalid M3/M5a in `_Requirements:_` tags, invalid `PRD §6` / `infra` in `_Design:_` tags, missing design coverage for D§4 / D§6 / D§7.1 / D§7.5 / D§8, `east.TaskCheckBox` import-alias ambiguity, Relevant Files missing `dev/README.md`; plus task 5.3 breadth + task 5.6 pre-emitter checkpoint. R2 all concerns resolved; one non-blocker noted — 5.x→6.x output-format churn is inherent to the phased decomposition, flagged explicitly in 5.6's text.)_
- 2026-04-21 code-review-parent-1 019db22d-7978-7e70-ae87-5242d1eedc25 _(3 rounds; R1 flagged missing `// spec:planctl/...` tags on `main()`, `runLint()`, and `TestRun_LintStub`. R2 confirmed main/runLint tags landed, flagged test-tag mismatch — test should mirror runLint's R6.2+D§3.6, not just D§3.6. R3 approved after alignment.)_
+- 2026-04-21 code-review-parent-2 019db23c-6c44-7d00-b3a4-6653882f0964 _(2 rounds; R1 flagged missing `// spec:planctl/...` tags on `splitLines` / `buildNewlineIndex` / `offsetToLineCol` and missing test coverage for "nested emphasis around code" + "mixed" cases from task 2.7; did not reproduce the `offsetToLineCol` off-by-one I'd mentioned fixing. R2 approved after adding the three tags, three new test functions (NestedEmphasisAroundCode, MixedFeatures, InlineCodeMask_ExactRanges), and TestScanBytes_FencedBlockWithoutTrailingNewline for the residual EOF edge concern. Also fixed: `.gitignore` pattern `planctl` → `/planctl` so scan.go / scan_test.go weren't silently ignored as "any path matching planctl".)_
diff --git a/dev/plans/26172-planctl/tasks.md b/dev/plans/26172-planctl/tasks.md
index 5430e95..77e3126 100644
--- a/dev/plans/26172-planctl/tasks.md
+++ b/dev/plans/26172-planctl/tasks.md
@@ -68,15 +68,15 @@ As you complete each task, flip `[ ]` to `[x]` in this file. Update after each s
- [x] 1.6 Add `cmd/planctl/main_test.go` with subtests for `--help` (exit 0, usage contains all flag names), `--version` (exit 0), each v2-reserved subcommand (exit 2, message mentions "not implemented in v1"), and unknown subcommand (exit 2). Use `os/exec` to invoke a built binary or call `run(args []string)` directly — pick whichever keeps the binary testable without a build step _Requirements: R5.7, R5.8, R6.1_ _Design: D§3.6_
- [x] 1.7 Verify `go build ./cmd/planctl/` and `go test ./cmd/planctl/` both pass _Requirements: infra_
-- [ ] 2.0 Scanner — goldmark-backed fence / inline-code classification _Requirements: R1.5_ _Design: D§3.2_
- - [ ] 2.1 Create `cmd/planctl/scan.go` with `ScanResult` and `MaskRun` types per design §3.2 _Requirements: R1.5_ _Design: D§3.2_
- - [ ] 2.2 Implement `Scan(path string) (ScanResult, error)`: `os.ReadFile`, run `goldmark.DefaultParser().Parse(text.NewReader(source))`, populate `Source` and `Lines` (split on `\n`, preserve empty trailing lines only if file ends without newline) _Requirements: R1.5_ _Design: D§3.2_
- - [ ] 2.3 Construct a parser that enables the goldmark **TaskList extension** (`extension.TaskList` from `github.com/yuin/goldmark/extension`) so `[ ]` / `[x]` list items surface as `east.TaskCheckBox` nodes — where `east` is the conventional import alias for `github.com/yuin/goldmark/extension/ast`. The extension is in the goldmark module — no new `require` _Requirements: R1.5_ _Design: D§3.3 ("Task lines")_
- - [ ] 2.4 Precompute a newline-offset index over `Source` so goldmark `text.Segment` byte positions map to 1-indexed line numbers in O(log n) _Requirements: R1.5_ _Design: D§3.2_
- - [ ] 2.5 AST walk #1 — for every `ast.CodeBlock`, `ast.FencedCodeBlock`, `ast.HTMLBlock`: resolve segment range → line range; set `InCode[line]=true` for each covered line _Requirements: R1.5_ _Design: D§3.2_
- - [ ] 2.6 AST walk #2 — for every `ast.CodeSpan`: resolve segment → `(line, col_start, col_end)`; append a `MaskRun` to `LineMask[line]`. Column indices are 0-indexed over the raw line bytes _Requirements: R1.5_ _Design: D§3.2_
- - [ ] 2.7 Create `cmd/planctl/scan_test.go` with table-driven cases: fenced block (``` ```…``` ```), indented code block (4-space), HTML block, inline `` `code` ``, nested emphasis around code, mixed. For each input assert `InCode[]` and `LineMask[]` match expected values _Requirements: R1.5_ _Design: D§3.2, D§7.1_
- - [ ] 2.8 Verify `go test ./cmd/planctl/ -run Scan` passes _Requirements: infra_
+- [x] 2.0 Scanner — goldmark-backed fence / inline-code classification _Requirements: R1.5_ _Design: D§3.2_
+ - [x] 2.1 Create `cmd/planctl/scan.go` with `ScanResult` and `MaskRun` types per design §3.2 _Requirements: R1.5_ _Design: D§3.2_
+ - [x] 2.2 Implement `Scan(path string) (ScanResult, error)`: `os.ReadFile`, run `goldmark.DefaultParser().Parse(text.NewReader(source))`, populate `Source` and `Lines` (split on `\n`, preserve empty trailing lines only if file ends without newline) _Requirements: R1.5_ _Design: D§3.2_
+ - [x] 2.3 Construct a parser that enables the goldmark **TaskList extension** (`extension.TaskList` from `github.com/yuin/goldmark/extension`) so `[ ]` / `[x]` list items surface as `east.TaskCheckBox` nodes — where `east` is the conventional import alias for `github.com/yuin/goldmark/extension/ast`. The extension is in the goldmark module — no new `require` _Requirements: R1.5_ _Design: D§3.3 ("Task lines")_
+ - [x] 2.4 Precompute a newline-offset index over `Source` so goldmark `text.Segment` byte positions map to 1-indexed line numbers in O(log n) _Requirements: R1.5_ _Design: D§3.2_
+ - [x] 2.5 AST walk #1 — for every `ast.CodeBlock`, `ast.FencedCodeBlock`, `ast.HTMLBlock`: resolve segment range → line range; set `InCode[line]=true` for each covered line _Requirements: R1.5_ _Design: D§3.2_
+ - [x] 2.6 AST walk #2 — for every `ast.CodeSpan`: resolve segment → `(line, col_start, col_end)`; append a `MaskRun` to `LineMask[line]`. Column indices are 0-indexed over the raw line bytes _Requirements: R1.5_ _Design: D§3.2_
+ - [x] 2.7 Create `cmd/planctl/scan_test.go` with table-driven cases: fenced block (``` ```…``` ```), indented code block (4-space), HTML block, inline `` `code` ``, nested emphasis around code, mixed. For each input assert `InCode[]` and `LineMask[]` match expected values _Requirements: R1.5_ _Design: D§3.2, D§7.1_
+ - [x] 2.8 Verify `go test ./cmd/planctl/ -run Scan` passes _Requirements: infra_
- [ ] 3.0 Indexer — R-id / D§ / tag / task-line extraction with unclosed-tag recovery _Requirements: R1.1, R1.2, R1.6, R1.7, R2.1, R2.2, R2.3, R2.9, R3.3_ _Design: D§3.3_
- [ ] 3.1 Create `cmd/planctl/index.go` with `Plan`, `TagRef`, `Kind` (enum `KindRequirements` / `KindDesign`), `TaskLine`, `Index`, `Position` types per design §3.3 _Requirements: R2.1, R2.2, R2.3, R3.3_ _Design: D§3.3_
diff --git a/go.mod b/go.mod
index 60dd873..4dbd585 100644
--- a/go.mod
+++ b/go.mod
@@ -2,4 +2,4 @@ module forgejo.zerova.net/sid/template-jj
go 1.26.1
-require github.com/yuin/goldmark v1.8.2 // indirect
+require github.com/yuin/goldmark v1.8.2