Skip to content

Commit 9117520

Browse files
committed
Add Markdown task and list commands
1 parent 337b3e3 commit 9117520

13 files changed

Lines changed: 871 additions & 79 deletions

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ Porcelain commands infer the format from the file extension:
101101
| `section replace <path> <heading> <content>` | Replace the body under one Markdown heading. |
102102
| `section append <path> <heading> <content>` | Append a block fragment under one Markdown heading. |
103103
| `section prepend <path> <heading> <content>` | Prepend a block fragment under one Markdown heading. |
104+
| `task close <path> <text>` | Ensure a Markdown task is closed. |
105+
| `task open <path> <text>` | Ensure a Markdown task is open, creating it when a destination is addressed. |
106+
| `list add <path> <text>` | Add one Markdown list item. |
107+
| `task add <path> <text>` | Add one open Markdown task. |
104108
| `create <path> [<content>]` | Create a file with explicit or extension-aware default content. |
105109
| `move <src> <dst>` | Move a file path. |
106110
| `copy <src> <dst>` | Copy a file path. |
@@ -164,6 +168,15 @@ etch set journal/today.md heartbeat "ok" --section "## Status" --tail
164168
etch delete tasks/follow-up.md snooze --task "Send follow-up"
165169
```
166170

171+
Mutate Markdown tasks and lists:
172+
173+
```sh
174+
etch task close tasks/follow-up.md "Send follow-up" --section "## Actions"
175+
etch task open tasks/follow-up.md "Review draft" --section "## Actions"
176+
etch list add tasks/follow-up.md "Capture launch note" --section "## Notes"
177+
etch task add tasks/follow-up.md "Send update" --section "## Actions"
178+
```
179+
167180
Mutate a Markdown section:
168181

169182
```sh
@@ -263,6 +276,9 @@ missing JSONL targets are created as empty logs before appending.
263276
For Markdown paths, structured selectors target YAML frontmatter by default.
264277
Markdown address flags such as `--body`, `--section`, and `--task` switch
265278
`set` and `delete` to Dataview-style inline fields in the Markdown body.
279+
Task/list commands use `--section`, `--before`, and `--after` to address where
280+
task and list mutations happen; `--before` and `--after` match list items, not
281+
arbitrary prose.
266282

267283
## Transaction Model
268284

docs/proposals/task-list-ops.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
status: draft
2+
status: implemented
33
depends_on:
44
- markdown-addressing
55
---
@@ -64,8 +64,8 @@ etch task add memory/2026-04-29.md "Send follow-up" --section "## Action Items"
6464
- `list add` defaults to tail placement within the selected compatible list.
6565
- `--before` and `--after` use the placement rules from
6666
[Markdown Addressing](markdown-addressing.md) to choose an insertion point
67-
relative to an existing item in the selected section or body. For `list add`
68-
and `task add`, these anchors match list items, not arbitrary prose.
67+
relative to an existing item in the selected section or body. For task/list
68+
commands, these anchors match list items, not arbitrary prose.
6969
- `task add` is shorthand for `list add ... --task`.
7070
- `list add ... --task` constructs an unchecked task item.
7171
- If the selected section already contains a compatible list, Etch follows that
@@ -119,12 +119,12 @@ Docs:
119119

120120
Code:
121121

122-
- Add `task close`, `task open`, `list add`, and `task add` to the verb catalog
123-
if approved.
122+
- Add `task close`, `task open`, `list add`, and `task add` to the verb
123+
catalog.
124124
- Add fixtures for exact task matching, ambiguous task text, section scoping,
125125
nested tasks, list add marker inference, default tail placement, before/after
126-
placement, task add shorthand, multiline add refusal, full-source add
127-
refusal, optional Dataview completion metadata, and no-op behavior.
126+
placement, task add shorthand, multiline add refusal, full-source add refusal,
127+
custom task status refusal, and no-op behavior.
128128

129129
## Deferred Move Design
130130

@@ -141,7 +141,7 @@ general block move. The hard part is not the spelling; it is preserving the
141141
source item's nested children, continuation lines, surrounding blank lines, and
142142
destination list structure without guessing.
143143

144-
## Open Questions
144+
## Deferred
145145

146146
- Should `task close` later set `[completion:: <date>]` in the same
147147
operation, or should that stay a separate Markdown field operation?

internal/etch/catalog.go

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ func commandPhrase(parts ...string) string {
138138
var markdownAddressFlags = []string{"--body", "--section", "--item", "--item-type", "--task", "--after", "--before", "--head", "--tail"}
139139
var markdownSetFlags = append(append([]string{"--json"}, markdownAddressFlags...), "--hidden")
140140
var markdownDeleteFlags = markdownAddressFlags
141+
var markdownTaskListFlags = []string{"--section", "--before", "--after"}
142+
var markdownListAddFlags = []string{"--section", "--before", "--after", "--task"}
141143

142144
var allCommandSpecs = buildCommandSpecs()
143145

@@ -155,6 +157,10 @@ func buildCommandSpecs() []commandSpec {
155157
command("section replace", "section replace <path> <heading> <content>", "Replace the body under one Markdown heading.", ClassIdempotent, true, parseSection("replace")),
156158
command("section append", "section append <path> <heading> <content>", "Append a block fragment under one Markdown heading.", ClassNonIdempotent, true, parseSection("append")),
157159
command("section prepend", "section prepend <path> <heading> <content>", "Prepend a block fragment under one Markdown heading.", ClassNonIdempotent, true, parseSection("prepend")),
160+
command("task close", "task close <path> <text> [--section <heading>] [--before <item>] [--after <item>]", "Ensure one Markdown task is closed.", ClassIdempotent, true, parseMarkdownTaskList("task close"), markdownTaskListFlags...),
161+
command("task open", "task open <path> <text> [--section <heading>] [--before <item>] [--after <item>]", "Ensure one Markdown task is open, creating it when a destination is addressed.", ClassIdempotent, true, parseMarkdownTaskList("task open"), markdownTaskListFlags...),
162+
command("list add", "list add <path> <text> [--section <heading>] [--task] [--before <item>] [--after <item>]", "Add one Markdown list item.", ClassNonIdempotent, true, parseMarkdownTaskList("list add"), markdownListAddFlags...),
163+
command("task add", "task add <path> <text> [--section <heading>] [--before <item>] [--after <item>]", "Add one open Markdown task.", ClassNonIdempotent, true, parseMarkdownTaskList("task add"), markdownTaskListFlags...),
158164
command("create", "create <path> [<content>]", "Create a new file; omitted content uses an extension-aware default.", ClassIdempotent, true, parseCreate),
159165
command("move", "move <src> <dst>", "Move a file path.", ClassIdempotent, true, parseFileVerb("move")),
160166
command("copy", "copy <src> <dst>", "Copy a file path.", ClassIdempotent, true, parseFileVerb("copy")),
@@ -445,6 +451,57 @@ func parseSection(action string) commandParser {
445451
}
446452
}
447453

454+
func parseMarkdownTaskList(verb string) commandParser {
455+
return func(inv commandInvocation) ([]Operation, error) {
456+
spec, op := inv.Spec, inv.Op
457+
path, text, address, asTask, err := parseMarkdownTaskListArgs(inv.Args, verb == "list add")
458+
if err != nil {
459+
return nil, err
460+
}
461+
actualVerb := verb
462+
if asTask {
463+
actualVerb = "task add"
464+
}
465+
op.Verb, op.Kind, op.Class, op.Path, op.Value, op.ValueMode = actualVerb, "md-task-list", spec.Class, path, text, ValueModeString
466+
op.Target = PlanTarget{Path: path, Part: "body", Section: address.Section}
467+
op.Markdown = address
468+
return parsedOperation(op)
469+
}
470+
}
471+
472+
func parseMarkdownTaskListArgs(args []string, allowTaskFlag bool) (path, text string, address markdownAddress, asTask bool, err error) {
473+
var positional []string
474+
for i := 0; i < len(args); i++ {
475+
arg := args[i]
476+
if arg == "--task" {
477+
if !allowTaskFlag {
478+
return "", "", markdownAddress{}, false, usagef("--task is only accepted by list add")
479+
}
480+
asTask = true
481+
continue
482+
}
483+
parsed, next, err := parseMarkdownAddressFlag(args, i, &address, markdownTaskListAddressFlagOptions)
484+
if err != nil {
485+
return "", "", markdownAddress{}, false, err
486+
}
487+
if parsed {
488+
i = next
489+
continue
490+
}
491+
if strings.HasPrefix(arg, "--") {
492+
return "", "", markdownAddress{}, false, usagef("unknown Markdown task/list flag %s", arg)
493+
}
494+
positional = append(positional, arg)
495+
}
496+
if len(positional) != 2 {
497+
return "", "", markdownAddress{}, false, usagef("usage: etch <task/list command> <path> <text> [flags]")
498+
}
499+
if _, err := markdownPlacementFromFlags(false, false, address.Before, address.After); err != nil {
500+
return "", "", markdownAddress{}, false, err
501+
}
502+
return positional[0], positional[1], address, asTask, nil
503+
}
504+
448505
func parseTable(format string, tablePath ...string) commandParser {
449506
return func(inv commandInvocation) ([]Operation, error) {
450507
op, args := inv.Op, inv.Args
@@ -830,10 +887,10 @@ func fillDescriptor(op *Operation) {
830887
} else if op.Target.Selector != "" {
831888
parts = append(parts, op.Target.Selector)
832889
}
833-
if op.Target.Section != "" && op.Kind != "md-field" {
890+
if op.Target.Section != "" && op.Kind != "md-field" && op.Kind != "md-task-list" {
834891
parts = append(parts, shellQuote(op.Target.Section))
835892
}
836-
if op.Kind == "md-field" {
893+
if op.Kind == "md-field" || op.Kind == "md-task-list" {
837894
parts = appendMarkdownAddressDescriptor(parts, op.Markdown)
838895
}
839896
if op.Target.Range != "" {
@@ -971,9 +1028,9 @@ func printHelp(w io.Writer, topic string, all bool) error {
9711028
}
9721029
fmt.Fprintln(w)
9731030
if all {
974-
fmt.Fprint(w, "Topics: model, scripts, selectors, values, fields, plans, security, conflicts, addressing, section, table, csv\n")
1031+
fmt.Fprint(w, "Topics: model, scripts, selectors, values, fields, plans, security, conflicts, addressing, section, tasks, table, csv\n")
9751032
} else {
976-
fmt.Fprint(w, "Topics: model, scripts, selectors, values, fields, plans, security, conflicts, addressing, section, table, csv. Use --all for plumbing commands.\n")
1033+
fmt.Fprint(w, "Topics: model, scripts, selectors, values, fields, plans, security, conflicts, addressing, section, tasks, table, csv. Use --all for plumbing commands.\n")
9771034
}
9781035
case "scripts":
9791036
fmt.Fprint(w, scriptsHelp)
@@ -995,6 +1052,8 @@ func printHelp(w io.Writer, topic string, all bool) error {
9951052
fmt.Fprint(w, modelHelp)
9961053
case "section":
9971054
fmt.Fprint(w, sectionHelp)
1055+
case "tasks":
1056+
fmt.Fprint(w, tasksHelp)
9981057
case "table", "csv":
9991058
fmt.Fprint(w, tableHelp)
10001059
default:
@@ -1096,6 +1155,22 @@ Repeated matching headings are ambiguous. Append/prepend trim payload boundary b
10961155
and use one blank line between non-empty block fragments.
10971156
`
10981157

1158+
const tasksHelp = `Markdown task/list commands operate on exact source-normalized item text.
1159+
1160+
Commands:
1161+
etch task close note.md "Send follow-up" --section Actions
1162+
etch task open note.md "Send follow-up" --section Actions
1163+
etch list add note.md "Launch notes" --section Actions
1164+
etch task add note.md "Send follow-up" --section Actions
1165+
1166+
task close changes [ ] to [x] and never creates missing tasks.
1167+
task open ensures an open task: it reopens [x]/[X], no-ops on [ ], and creates
1168+
missing tasks only when a destination address such as --section, --before, or
1169+
--after is supplied. Custom checkbox statuses fail.
1170+
--before and --after match list items, not arbitrary prose.
1171+
list add and task add create source from plain item text; do not include "- " or "- [ ]".
1172+
`
1173+
10991174
const tableHelp = `Tables are ordered rows and named columns of string cells.
11001175
11011176
CSV:

internal/etch/formats_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,35 @@ func TestMarkdownInlineFieldCommandsCommitAndMaterialize(t *testing.T) {
722722
}
723723
}
724724

725+
func TestMarkdownTaskListCommandsCommitAndMaterialize(t *testing.T) {
726+
dir := initRepo(t)
727+
writeFile(t, dir, "note.md", "# Note\n\n## Actions\n- [ ] Send follow-up\n")
728+
commitAll(t, dir, "initial")
729+
730+
runOK(t, dir, "task", "close", "note.md", "Send follow-up", "--section", "Actions")
731+
got := testGit(t, dir, "show", "HEAD:note.md")
732+
if got != "# Note\n\n## Actions\n- [x] Send follow-up\n" {
733+
t.Fatalf("task close output = %q", got)
734+
}
735+
subject := stringsTrim(testGit(t, dir, "log", "-1", "--format=%s"))
736+
if subject != `etch task close note.md --section Actions "Send follow-up"` {
737+
t.Fatalf("task close subject = %q", subject)
738+
}
739+
740+
runOK(t, dir, "task", "open", "note.md", "Review draft", "--section", "Actions")
741+
got = testGit(t, dir, "show", "HEAD:note.md")
742+
if got != "# Note\n\n## Actions\n- [x] Send follow-up\n- [ ] Review draft\n" {
743+
t.Fatalf("task open create output = %q", got)
744+
}
745+
wt, err := os.ReadFile(filepath.Join(dir, "note.md"))
746+
if err != nil {
747+
t.Fatal(err)
748+
}
749+
if string(wt) != got {
750+
t.Fatalf("worktree not materialized:\nwt=%s\nhead=%s", wt, got)
751+
}
752+
}
753+
725754
func TestEvalMarkdownSectionReplace(t *testing.T) {
726755
tests := []struct {
727756
name string

internal/etch/help_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import (
77
)
88

99
func TestHelpTopicsSnapshotSmoke(t *testing.T) {
10-
for _, topic := range []string{"", "model", "scripts", "selectors", "values", "fields", "plans", "security", "conflicts", "addressing", "section", "table", "csv"} {
10+
for _, topic := range []string{"", "model", "scripts", "selectors", "values", "fields", "plans", "security", "conflicts", "addressing", "section", "tasks", "table", "csv"} {
1111
var out bytes.Buffer
1212
if err := printHelp(&out, topic, false); err != nil {
1313
t.Fatalf("help %q: %v", topic, err)
@@ -29,7 +29,7 @@ func TestDefaultHelpTableExcludesPlumbing(t *testing.T) {
2929
t.Fatalf("default help contains plumbing command %q:\n%s", hidden, text)
3030
}
3131
}
32-
for _, shown := range []string{"set <path>", "table set", "section replace", "section append", "section prepend"} {
32+
for _, shown := range []string{"set <path>", "table set", "section replace", "section append", "section prepend", "task close", "list add"} {
3333
if !strings.Contains(text, shown) {
3434
t.Fatalf("default help missing porcelain command %q:\n%s", shown, text)
3535
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package etch
2+
3+
type markdownAddressFlagOptions struct {
4+
Body bool
5+
Section bool
6+
Item bool
7+
ItemType bool
8+
Task bool
9+
After bool
10+
Before bool
11+
Head bool
12+
Tail bool
13+
Hidden bool
14+
}
15+
16+
var markdownFieldAddressFlagOptions = markdownAddressFlagOptions{
17+
Body: true, Section: true, Item: true, ItemType: true, Task: true,
18+
After: true, Before: true, Head: true, Tail: true,
19+
}
20+
21+
var markdownTaskListAddressFlagOptions = markdownAddressFlagOptions{
22+
Section: true, After: true, Before: true,
23+
}
24+
25+
func isMarkdownAddressFlag(arg string) bool {
26+
switch arg {
27+
case "--body", "--section", "--item", "--item-type", "--task", "--after", "--before", "--head", "--tail", "--hidden":
28+
return true
29+
default:
30+
return false
31+
}
32+
}
33+
34+
func parseMarkdownAddressFlag(args []string, index int, address *markdownAddress, options markdownAddressFlagOptions) (bool, int, error) {
35+
arg := args[index]
36+
switch arg {
37+
case "--body":
38+
if !options.Body {
39+
return false, index, nil
40+
}
41+
address.Body = true
42+
return true, index, nil
43+
case "--section", "--item", "--item-type", "--task", "--after", "--before":
44+
if !markdownAddressFlagWithValueAllowed(arg, options) {
45+
return false, index, nil
46+
}
47+
if index+1 >= len(args) {
48+
return true, index, usagef("%s requires a value", arg)
49+
}
50+
value := args[index+1]
51+
switch arg {
52+
case "--section":
53+
address.Section = value
54+
case "--item":
55+
address.Item = value
56+
case "--item-type":
57+
address.ItemTypes = append(address.ItemTypes, value)
58+
case "--task":
59+
address.Task = value
60+
case "--after":
61+
address.After = value
62+
case "--before":
63+
address.Before = value
64+
}
65+
return true, index + 1, nil
66+
case "--head":
67+
if !options.Head {
68+
return false, index, nil
69+
}
70+
address.Head = true
71+
return true, index, nil
72+
case "--tail":
73+
if !options.Tail {
74+
return false, index, nil
75+
}
76+
address.Tail = true
77+
return true, index, nil
78+
case "--hidden":
79+
if !options.Hidden {
80+
return false, index, nil
81+
}
82+
address.Hidden = true
83+
return true, index, nil
84+
default:
85+
return false, index, nil
86+
}
87+
}
88+
89+
func markdownAddressFlagWithValueAllowed(arg string, options markdownAddressFlagOptions) bool {
90+
switch arg {
91+
case "--section":
92+
return options.Section
93+
case "--item":
94+
return options.Item
95+
case "--item-type":
96+
return options.ItemType
97+
case "--task":
98+
return options.Task
99+
case "--after":
100+
return options.After
101+
case "--before":
102+
return options.Before
103+
default:
104+
return false
105+
}
106+
}

0 commit comments

Comments
 (0)